When I started working on MusicBee Remote‘s Library Browsing support It became clear that some kind of data store functionality was required that didn’t include ArrayLists of objects in some class running in the memory. This was how the implementation of the Now Playing list worked until now and you could see the usage of the memory skyrocket especially with lists around 10000 tracks.
The first though was to use SQLite and thus some experimentation started. However I soon realized that I would prefer to avoid writing all the CRUD and POJO creation code by hand and maintaining it. However I had already started working with Content Providers, Cursors and CursorLoaders and I wanted a way to combine all these things in an application.
At some point I started playing with GreenDao then I moved to ORMlite, I don’t really remember the exact reason however after some testing my workload I found out that GreenDao requires half the time of ORMLite for the exact same thing. That was reason enough to revert to GreenDao.
Now the problem with the GreenDao generator (at least the currently available version, though it will probably change in future) is that generates one ContentProvider per Entity. This does not suit my need so after some searching on Google and StackOverflow I realized that none of the available solutions was what I was really looking for.
Thus the decision came to me. Why not check how the GreenDao Generator works and try to replicate the functionality but closer to my needs. The initial idea was to create some kind of fork but then I decided against and settled for including my changes in my applications generator Gradle module.
Based on the GreenDao’s ContentProvider template a new implementation was created to suit my needs. A template that creates a single ContentProvider for all the Entities in the Schema. I had to move some parts like CONTENT_URI on a Helper – Contract class. After some work the template took the following form:
package ${contentProvider.javaPackage}; import android.content.ContentValues; import android.content.UriMatcher; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteQueryBuilder; import android.net.Uri; import android.text.TextUtils; import com.google.inject.Inject; import de.greenrobot.dao.DaoLog; import roboguice.content.RoboContentProvider; /* Copy this code snippet into your AndroidManifest.xml inside the <application> element: <provider android:name="${contentProvider.javaPackage}.${contentProvider.className}" android:authorities="${contentProvider.authority}"/> */ public class ${contentProvider.className} extends RoboContentProvider { public static final String AUTHORITY = "${contentProvider.authority}"; private static final UriMatcher URI_MATCHER; static { URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH); <#list schema.entities as entity> ${entity.className}Helper.addURI(URI_MATCHER); </#list> } @Inject private DaoSession daoSession; @Override public boolean onCreate() { DaoLog.d("Content Provider started: " + AUTHORITY); return super.onCreate(); } protected SQLiteDatabase getDatabase() { if (daoSession == null) { throw new IllegalStateException("DaoSession must be set during content provider is active"); } return daoSession.getDatabase(); } <#-- ########################################## ########## Insert ############## ########################################## --> @Override public Uri insert(Uri uri, ContentValues values) { <#if contentProvider.isReadOnly()> throw new UnsupportedOperationException("This content provider is readonly"); <#else> int uriType = URI_MATCHER.match(uri); long id; String path; switch (uriType) { <#list schema.entities as entity> case ${entity.className}Helper.${entity.className?upper_case}_DIR: id = getDatabase().insert(${entity.className}Helper.TABLENAME, null, values); path = ${entity.className}Helper.BASE_PATH + "/" + id; break; </#list> default: throw new IllegalArgumentException("Unknown URI: " + uri); } getContext().getContentResolver().notifyChange(uri, null); return Uri.parse(path); </#if> } <#-- ########################################## ########## Delete ############## ########################################## --> @Override public int delete(Uri uri, String selection, String[] selectionArgs) { <#if contentProvider.isReadOnly()> throw new UnsupportedOperationException("This content provider is readonly"); <#else> int uriType = URI_MATCHER.match(uri); SQLiteDatabase db = getDatabase(); int rowsDeleted; String id; switch (uriType) { <#list schema.entities as entity> case ${entity.className}Helper.${entity.className?upper_case}_DIR: rowsDeleted = db.delete(${entity.className}Helper.TABLENAME, selection, selectionArgs); break; case ${entity.className}Helper.${entity.className?upper_case}_ID: id = uri.getLastPathSegment(); if (TextUtils.isEmpty(selection)) { rowsDeleted = db.delete(${entity.className}Helper.TABLENAME, ${entity.className}Helper.PK + "=" + id, null); } else { rowsDeleted = db.delete(${entity.className}Helper.TABLENAME, ${entity.className}Helper.PK + "=" + id + " and " + selection, selectionArgs); } break; </#list> default: throw new IllegalArgumentException("Unknown URI: " + uri); } getContext().getContentResolver().notifyChange(uri, null); return rowsDeleted; </#if> } <#-- ########################################## ########## Update ############## ########################################## --> @Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { <#if contentProvider.isReadOnly()> throw new UnsupportedOperationException("This content provider is readonly"); <#else> int uriType = URI_MATCHER.match(uri); SQLiteDatabase db = getDatabase(); int rowsUpdated; String id; switch (uriType) { <#list schema.entities as entity> case ${entity.className}Helper.${entity.className?upper_case}_DIR: rowsUpdated = db.update(${entity.className}Helper.TABLENAME, values, selection, selectionArgs); break; case ${entity.className}Helper.${entity.className?upper_case}_ID: id = uri.getLastPathSegment(); if (TextUtils.isEmpty(selection)) { rowsUpdated = db.update(${entity.className}Helper.TABLENAME, values, ${entity.className}Helper.PK + "=" + id, null); } else { rowsUpdated = db.update(${entity.className}Helper.TABLENAME, values, ${entity.className}Helper.PK + "=" + id + " and " + selection, selectionArgs); } break; </#list> default: throw new IllegalArgumentException("Unknown URI: " + uri); } getContext().getContentResolver().notifyChange(uri, null); return rowsUpdated; </#if> } <#-- ########################################## ########## Query ############## ########################################## --> @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder(); int uriType = URI_MATCHER.match(uri); switch (uriType) { <#list schema.entities as entity> case ${entity.className}Helper.${entity.className?upper_case}_DIR: queryBuilder.setTables(${entity.className}Helper.TABLENAME); break; case ${entity.className}Helper.${entity.className?upper_case}_ID: queryBuilder.setTables(${entity.className}Helper.TABLENAME); queryBuilder.appendWhere(${entity.className}Helper.PK + "=" + uri.getLastPathSegment()); break; </#list> default: throw new IllegalArgumentException("Unknown URI: " + uri); } SQLiteDatabase db = getDatabase(); Cursor cursor = queryBuilder.query(db, projection, selection, selectionArgs, null, null, sortOrder); cursor.setNotificationUri(getContext().getContentResolver(), uri); return cursor; } <#-- ########################################## ########## GetType ############## ########################################## --> @Override public final String getType(Uri uri) { switch (URI_MATCHER.match(uri)) { <#list schema.entities as entity> case ${entity.className}Helper.${entity.className?upper_case}_DIR: return ${entity.className}Helper.CONTENT_TYPE; case ${entity.className}Helper.${entity.className?upper_case}_ID: return ${entity.className}Helper.CONTENT_ITEM_TYPE; </#list> default : throw new IllegalArgumentException("Unsupported URI: " + uri); } } }
Since I use RoboGuice with MusicBee Remote, I made my class inherit the RoboContentProvider and made the DaoSession injectable. If you want to use it without RoboGuice, just modify the template to inherit the ContentProvider.class instead of RoboContentProvider and change the way the DaoSession is passed in the generated ContentProvider. The original template used static field though according to the comment included it would probably change in the future.
The template is one of the three parts of my implemenation. The second part is the Helper classes that we will check later and finally the HelperGenerator.class.
On a side note, with RoboGuice the DaoSession is provided by a DaoSessionProvider. In the applications module a binding is registered in the configure method.
bind(DaoSession.class) .toProvider(DaoSessionProvider.class) .asEagerSingleton();
And this is the DaoSessionProvider.class:
package com.kelsos.mbrc.providers; import android.content.Context; import android.database.sqlite.SQLiteDatabase; import com.google.inject.Inject; import com.google.inject.Provider; import com.kelsos.mbrc.dao.DaoMaster; import com.kelsos.mbrc.dao.DaoSession; public class DaoSessionProvider implements Provider<DaoSession> { @Inject private Context mContext; @Override public DaoSession get() { final DaoMaster daoMaster; SQLiteDatabase db; DaoMaster.DevOpenHelper helper = new DaoMaster.DevOpenHelper(mContext, "lib-db", null); db = helper.getWritableDatabase(); daoMaster = new DaoMaster(db); return daoMaster.newSession(); } }
The template of the helper class is the following:
package ${entity.javaPackageDao}; import android.database.Cursor; import android.net.Uri; import android.content.UriMatcher; import android.content.ContentResolver; public final class ${entity.className}Helper { private ${entity.className}Helper() { } <#list entity.properties as property> public static final String ${property.propertyName?upper_case} = ${entity.className}Dao.Properties.${property.propertyName?cap_first}.columnName; </#list> public static final String TABLENAME = ${entity.classNameDao}.TABLENAME; public static final String PK = ${entity.classNameDao}.Properties.${entity.pkProperty.propertyName?cap_first}.columnName; <#assign counter = id> public static final int ${entity.className?upper_case}_DIR = ${counter}; public static final int ${entity.className?upper_case}_ID = ${counter+1}; public static final String BASE_PATH = "${entity.className?lower_case}"; public static final Uri CONTENT_URI = Uri.parse("content://" + ${contentProvider.className}.AUTHORITY + "/" + BASE_PATH); public static final String CONTENT_TYPE = ContentResolver.CURSOR_DIR_BASE_TYPE + "/" + BASE_PATH; public static final String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/" + BASE_PATH; public static void addURI(UriMatcher sURIMatcher) { sURIMatcher.addURI(${contentProvider.className}.AUTHORITY, BASE_PATH, ${entity.className?upper_case}_DIR); sURIMatcher.addURI(${contentProvider.className}.AUTHORITY, BASE_PATH + "/#", ${entity.className?upper_case}_ID); } public static final String[] PROJECTION = { <#list entity.properties as property> ${property.propertyName?upper_case}<#if property_has_next>,</#if> </#list> }; public static ${entity.className} fromCursor(Cursor data) { final ${entity.className} entity = new ${entity.className}(); <#list entity.properties as property> <#if property.propertyType?lower_case == "boolean"> entity.set${property.propertyName?cap_first}(data.getInt(data.getColumnIndex(${property.propertyName?upper_case})) > 0); <#else> entity.set${property.propertyName?cap_first}(data.get${property.propertyType?cap_first}(data.getColumnIndex(${property.propertyName?upper_case}))); </#if> </#list> return entity; } }
The helpers include information like static string references to the table name, primary key and properties along with the CONTENT_URI and the types of data returned. A String array called PROJECTION is also included. This array is used in the CursorLoader creation like in the following example. Some of the properties already exists in the EntityDao and are repeated here only for ease of access.
To explain what I mean with ease of access let's take the template above. Now imaging that we have a table named "Genre" with a column named "Name". After running the DaoGenerator the column name will be availble under <strong>GenreDao.Properties.Name.columnName</strong> this will be mapped to a String property named <strong>NAME</strong> in the helper class. @Override public Loader<Cursor> onCreateLoader(int id, Bundle args) { return new CursorLoader(getActivity(), GenreHelper.CONTENT_URI, GenreHelper.PROJECTION, null, null, null); }
A Cursor loader requests of the Genre CONTENT_URI (contained in the GenreHelper.class, all the fields (PROJECTION).
Each helper also includes a helper method fromCursor(Cursor cursor), the method takes a cursor and creates a new object of the entity the helper is for. The method has absolutely no checks, safeguards for misuse and might raise Exceptions if not used properly. The method requires a Cursor that was created with the Helper.PROJECTION, which means all the columns of the table should exist in the Cursor.
The final part required to generate the ContentProvider and helper methods from the templates is the HelperGenerator.class:
package com.kelsos.mbrc; import de.greenrobot.daogenerator.ContentProvider; import de.greenrobot.daogenerator.Entity; import de.greenrobot.daogenerator.Schema; import freemarker.template.Configuration; import freemarker.template.DefaultObjectWrapper; import freemarker.template.Template; import freemarker.template.TemplateException; import java.io.File; import java.io.FileWriter; import java.io.IOException; import java.io.Writer; import java.util.HashMap; import java.util.List; import java.util.Map; public class HelperGenerator { public static final int INCREASE = 3; private Template templateHelper; private Template templateContentProvider; private ContentProvider mProvider; private int id; public HelperGenerator() throws IOException { Configuration config = new Configuration(); config.setClassForTemplateLoading(this.getClass(), "/"); config.setObjectWrapper(new DefaultObjectWrapper()); templateHelper = config.getTemplate("contract.java.ftl"); templateContentProvider = config.getTemplate("content-provider.java.ftl"); id = 0; } public void generateAll(Schema schema, String outDir) { long start = System.currentTimeMillis(); List<Entity> entities = schema.getEntities(); File outDirFile = null; try { outDirFile = toFileForceExists(outDir); } catch (IOException e) { e.printStackTrace(); } mProvider = new ContentProvider(schema, schema.getEntities()); mProvider.init2ndPass(); mProvider.setClassName("LibraryProvider"); for (Entity entity : entities) { generateHelpers(schema, entity, outDirFile); } generateContentProvider(schema, outDirFile); long time = System.currentTimeMillis() - start; System.out.println("Processed " + entities.size() + " entities in " + time + "ms"); } private void generateHelpers(Schema schema, Entity entity, File outDirFile) { Map<String, Object> root = new HashMap<>(); root.put("schema", schema); root.put("entity", entity); root.put("contentProvider", mProvider); root.put("id", id); id += INCREASE; generate(entity.getClassName() + "Helper", outDirFile, root, templateHelper, entity.getJavaPackage()); } @SuppressWarnings("ResultOfMethodCallIgnored") private void generate(String className, File outDirFile, Map<String, Object> root, Template template, String javaPackage) { try { File file = toJavaFilename(outDirFile, javaPackage, className); file.getParentFile().mkdirs(); try (Writer writer = new FileWriter(file)) { template.process(root, writer); writer.flush(); System.out.println("Written " + file.getCanonicalPath()); } catch (TemplateException e) { e.printStackTrace(); } } catch (IOException e) { e.printStackTrace(); } } private void generateContentProvider(Schema schema, File outDirFile) { Map<String, Object> root = new HashMap<>(); root.put("schema", schema); root.put("contentProvider", mProvider); generate(mProvider.getClassName(), outDirFile, root, templateContentProvider, mProvider.getJavaPackage()); } protected File toJavaFilename(File outDirFile, String javaPackage, String javaClassName) { String packageSubPath = javaPackage.replace('.', '/'); File packagePath = new File(outDirFile, packageSubPath); return new File(packagePath, String.format("%s.java", javaClassName)); } protected File toFileForceExists(String filename) throws IOException { File file = new File(filename); if (!file.exists()) { throw new IOException(filename + " does not exist. This check is to prevent accidental file generation into a wrong path."); } return file; } }
This HelperGenerator works in the same way the DaoGenerator works. It will take a Schema instance and an output directory and it will generate the classes. It should be the same schema and output directory as the one passed in the DaoGenerator since must be in the same directory for everything to work.
new HelperGenerator().generateAll(schema, outDir);
You can find a working example at GitHub
Please keep in mind that with the current implementation if you try to get Foreign Key objects with GreenDao it will crash with the following exception:
de.greenrobot.dao.DaoException: Entity is detached from DAO context
A DaoSession should be attached to an Entity in order to retrieve foreign key objects.