Saving Objects Transparently in Android
Automagically save your objects
Introduction
I will show you a new and innovative way of saving your objects in Android.
We will use neither SQLite, nor Room, nor any DAO or SharedPreferences. Our data objects will be automagically and transparently persisted!
We will use Chainvayler for that. Chainvayler is a new and innovative way of persisting and replicating POJO (Plain Old Java Object) graphs transparently. We will use Chainvayler’s persistence capabilities.
See this blog post for details about Chainvayler.
Lets do it!
This demo application is published at Google Play. The complete source code can be found here.
Chainvayler is not in central Maven repository. So first clone the Chainvayler repo from this link.
And run this command in the Chainvayler/chainvayler
folder:
./gradlew clean build publishToMavenLocal
Now create a new project in Android Studio and add Chainvayler dependencies to build.gradle
implementation 'raft.chainvayler:chainvayler:0.1'
implementation 'org.prevayler:prevayler-core:2.6'
implementation 'org.javassist:javassist:3.26.0-GA'
Now we can start writing our data classes. First the Library
class:
@Chained
public class Library implements Serializable {
private static final long serialVersionUID = 1L;
private final Map<Integer, Book> books = new HashMap<>();
private final Map<Integer, Author> authors = new HashMap<>();
private int lastId = 1;
private boolean populated = false;
public Book getBook(int id) {
return books.get(id);
}
@Modification
public void addBook(Book book) {
book.setId(lastId++);
books.put(book.getId(), book);
}
@Modification
public void removeBook(Book book) {
books.remove(book.getId());
}
public Collection<Book> getBooks() {
return Collections.unmodifiableCollection(books.values());
}
@Modification
public void removeAllBooks() {
books.clear();
authors.values().forEach(author -> author.removeAllBooks());
}
public Author getAuthor(int id) {
return authors.get(id);
}
@Modification
public void addAuthor(Author author) {
author.setId(lastId++);
authors.put(author.getId(), author);
}
@Modification
public void removeAuthor(Author author) {
authors.remove(author.getId());
}
public Collection<Author> getAuthors() {
return Collections.unmodifiableCollection(authors.values());
}
public Author findAuthorByName(String name) {
return authors.values().stream()
.filter(author -> name.equalsIgnoreCase(author.getName()))
.findAny()
.orElse(null);
}
@Modification
public void removeAllAuthors() {
authors.clear();
books.values().forEach(book -> book.setAuthor(null));
}
public Set<String> getGenres() {
Set<String> genres = new TreeSet<>();
books.values().forEach(book -> genres.addAll(book.getGenres()));
return genres;
}
public Set<String> getCountries() {
Set<String> genres = new TreeSet<>();
authors.values().forEach(author -> {
String country = author.getCountry();
if ((country != null) && !country.trim().isEmpty())
genres.add(author.getCountry());
});
return genres;
}
public boolean isPopulated() {
return populated;
}
@Modification
public void setPopulated() {
populated = true;
}
}
As can be seen this is quite a POJO class, nothing special.
@Chained
annotation marks the classes which will be managed by Chainvayler.
@Modification
annotation marks the methods which modify the data in @Chained
classes. All methods which modify the data should be marked with @Modification
annotation and they should be deterministic.
Next lets write the Book
class:
@Chained
public class Book implements Serializable {
private static final long serialVersionUID = 1L;
private int id;
private String name;
private Author author;
private boolean read = false;
private boolean favorite = false;
private String notes = "";
private Set<String> genres = new HashSet<>();
public Book(String name) {
this.name = name;
}
public Book(String name, Author author) {
this(name);
setAuthor(author);
}
public int getId() {
return id;
}
@Modification
void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
@Modification
public void setName(String name) {
this.name = name;
}
public Set<String> getGenres() {
return Collections.unmodifiableSet(genres);
}
@Modification
public void addGenre(String genre) {
genres.add(genre);
}
@Modification
public void setGenres(Collection<String> genres) {
this.genres.clear();
this.genres.addAll(genres);
}
@Modification
public void removeGenre(String genre) {
genres.remove(genre);
}
public Author getAuthor() {
return author;
}
@Modification
public void setAuthor(Author author) {
if (this.author != null) {
this.author.removeBook(this);
}
this.author = author;
if (author != null) {
author.addBook(this);
}
}
public boolean isRead() {
return read;
}
@Modification
public void setRead(boolean read) {
this.read = read;
}
public boolean isFavorite() {
return favorite;
}
@Modification
public void setFavorite(boolean favorite) {
this.favorite = favorite;
}
public String getNotes() {
return notes;
}
@Modification
public void setNotes(String notes) {
this.notes = notes;
}
@Override
public String toString() {
return "Book:" + name;
}
}
And finally the Author
class:
@Chained
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
private int id;
private String name;
private String country;
private Date birth;
private Date death;
private Set<Book> books = new HashSet<>();
public Author(String name) {
this.name = name;
}
public int getId() {
return id;
}
@Modification
void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
@Modification
public void setName(String name) {
this.name = name;
}
public Set<Book> getBooks() {
return Collections.unmodifiableSet(books);
}
@Modification
void addBook(Book book) {
books.add(book);
}
@Modification
void removeBook(Book book) {
books.remove(book);
}
@Modification
void removeAllBooks() {
books.clear();
}
public String getCountry() {
return country;
}
@Modification
public void setCountry(String country) {
this.country = country;
}
public Date getBirth() {
return birth;
}
@Modification
public void setBirth(Date birth) {
this.birth = birth;
}
public Date getDeath() {
return death;
}
@Modification
public void setDeath(Date death) {
this.death = death;
}
@Override
public String toString() {
return "Author:" + name;
}
}
How shall we access our Library
?
Lets create the LibraryApplication
class:
public class LibraryApplication extends Application {
private Library library; @Override
public void onCreate() {
super.onCreate();
String persistDir = getApplicationContext().getFilesDir().getAbsolutePath() + "/persist";
try {
this.library = Chainvayler.create(Library.class, persistDir);
if (!library.isPopulated()) {
populate();
library.setPopulated();
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public Library getLibrary() {
return library;
}
private void populate() throws Exception {
// create some initial books and authors
library.addAuthor(new Author("Jorge Luis Borges"));
library.addAuthor(new Author("Jack London"));
library.addBook(new Book("Call of the Wild", library.findAuthorByName("Jack London")));
library.addBook(new Book("The Book of Sand", library.findAuthorByName("Jorge Luis Borges")));
}
}
And modify AdnroidManifest.xml
to use our LibraryApplication
class:
<application
android:name=".LibraryApplication"
In your Activity
classes, just cast the application to LibraryApplication
and get your Library
instance.
public class BookListActivity extends AppCompatActivity {
private Library library; @Override
protected void onCreate(Bundle savedInstanceState) {
// ..
LibraryApplication application = (LibraryApplication) getApplication();
this.library = application.getLibrary();
}
}
Almost there. Chainvayler does its magic by instrumentation (injecting bytecode) of Java classes. So we need to invoke Chainvayler compiler during our build process.
Add these lines to build.gradle
:
afterEvaluate {
android.applicationVariants.each { variant ->
variant.javaCompileProvider.configure {
def javaPath = it.classpath + files(it.destinationDir)
// below line is required only if Android specific classes (Log etc.) are used inside @Chained classes
// javaPath += files(android.sdkDirectory.absolutePath + '/platforms/' + android.compileSdkVersion + '/android.jar')
it.doLast {
javaexec {
classpath += javaPath
main = 'raft.chainvayler.compiler.Compiler' // Chainvayler compiler
args 'raft.chainvayler.samples.android.data.Library' // Root class of @Chained graph
jvmArgs = ['-ea']
// enable assertions, generally a good idea especially at build/debug time
}
}
}
}
}
Next step is optional but a good idea. Put these lines to your LibraryApplication
class:
/** Take a snapshot of @Chained graph after at least this much transactions.
* This is to accelerate application startup next time. */
private static final int SNAPSHOT_TRANSACTIONS = 100;/** Maybe take a snapshot of @Chained graph, to accelerate app launch time next time */
public void maybeTakeSnapshot() {
try {
if (Chainvayler.getTransactionCount() > Chainvayler.getLastSnapshotVersion() + SNAPSHOT_TRANSACTIONS) {
Chainvayler.takeSnapshot();
Chainvayler.deleteRedundantFiles();
}
} catch (Exception e) {
Log.e(LOG_TAG, "Failed to take snapshot", e);
}
}
And invoke inonStop
method of your main activity:
@Override
protected void onStop() {
super.onStop();
((LibraryApplication) getApplication()).maybeTakeSnapshot();
}
Behind the scenes, Chainvayler writes transaction classes to disk for each call to @Modification
methods, and invokes the methods with the exact same order and arguments at startup time. Taking a snapshot just accelerates this process.
Done! We are good to go! The rest is basically UI programming.
Limitations
All of your @Chained
objects are always in memory. This provides you lightning fast read performance but at the same time implies your objects will fit into memory.
For most of the applications this will not be an issue, possibly tens of thousands of objects will be just fine.
But of course, if your application just keeps creating @Chained
objects like there is no tomorrow, then Chainvayler is not suitable for you.
BTW, Garbage Collection works as expected with @Chained
classes.
Conclusion
As promised, we used neither SQLite, nor Room, nor any DAO or SharedPreferences. Our data objects are just automagically and transparently persisted!
So, cheers and happy persistence in Android :)