Android上令人愉快的持久化

原文出处:Delightful persistence on Android 

在文章开始之前,引用一位我最喜欢的武术大师之一李小龙的一段话:

“在我开始学习武术之时,对我来说一拳就是一拳,一脚就是一脚。在我学习武术之后,一拳不再是一拳,一脚也不再是一脚。现在,当我真正了解了这门艺术之后,便又感觉到一拳仍仅仅是一拳,一脚也仅仅是一脚罢了。”

在我使用sqlite数据库的时候,这种情况也差不多发生在我身上,不管是使用比如ORMLiteDBFlow等那样的ORM库,还是使用其他的数据库如Realm,一个数据库,不管它怎么变,也仅仅是一个数据库。

现在GitHub 满是管理sqlite数据库的安卓库 。几乎所有的库都用了ORM技术。但是,跟李小龙一样,我回到了本质,而且没有一个库给了我想要的完全掌控查询和操作的自由。对于很多项目而言数据库只是一个简单的数据库,因此别为了缓存数据或者存储数据模型而用libraries加重你的项目。

但是用普通的SQLiteOpenHelper又有点无聊,所以我继续搜索完美的数据库library,直到我发现了Square的SqlDelight。虽然不能说它完全满足了我的需要,但是至少对我有很大帮助。

声明:写这篇文章的时候,SQLDelight的最新版本是0.3.0 (2016-04-26)。

就如他们在repositories README文件中所说的:

SQLDelight根据SQL CREATE TABLE 语句生成Java model。这些model提供了一个类型安全的API去读写数据库。它帮助你把SQL statement有条理的放在一起,并方便从java获得。

为什么是SQLDelight:

  • 所有的SQL statement都存在.sq文件中。你可以轻易的把数据库的改变更新到CVS中。

  • 可以生成帮助你创建与查询表的 schema models。

  • 可以自由的使用普通SQLite的同时帮助你处理了程式化的代码。

  • 帮助你从Cursor映射到自定义model

  • 支持Cursor 和 ContentValues一样的类型,但是添加了自定义类型甚至ENUM的支持

开始SQLDelight

首先向build.gradle的buildscript方法中添加依赖。最新的版本可以在Square的sonatype找到:

buildscript {
  repositories {
    mavenCentral()
  }
  dependencies {
    classpath 'com.squareup.sqldelight:gradle-plugin:0.2.2'
  }
}

上面的步骤已经足够让你开始使用SQLDelight了,但是Square那帮人还制作了一个支持语法与高亮的IntelliJ插件,推荐安装,它可以帮助你避免一些语法错误。

打开 Android Studio -> Preferences -> Plugins -> 搜索与安装 SQLDelight。

对于这篇文章,我做了一个基本的实现,你可以从 GitHub克隆。

建立 database model

Our basic database model

Tables that we will create ourselves with the help of SQLDelight.

blob.png

sample project中把com.alexsimo.delightfulpersistence作为root package,在src/main的根目录下面你需要创建一个sqlidelight文件夹,并拷贝你的root package 结构。

最终你得到一个如下的目录结构:

├── java
│   └── com
│       └── alexsimo
│           └── delightfulpersistence
│               ├── DelightfulApplication.java
│               └── database
│                   ├── DatabaseManager.java
│                   ├── DelightfulOpenHelper.java
│                   ├── adapter
│                   │   └── DateAdapter.java
│                   └── model
│                       ├── Author.java
│                       └── Book.java
└── sqldelight
    └── com
        └── alexsimo
            └── delightfulpersistence
                └── database
                    └── model
                        ├── Author.sq
                        ├── Book.sq
                        └── BookAuthor.sq

在sqldelight下面的model目录是你存放.sq文件的地方,它包含了app需要的sql语句。

你还能看到其它的文件,我稍后解释。现在我们先关注为model创建.sq文件。为了保持文章的简短,我只贴出创建和查询Author 和 Book 表的SQL语句,完整的语句可以在GitHub repository上找到。

Author:

CREATE TABLE author (
  _id LONG PRIMARY KEY AUTOINCREMENT,
  name STRING NOT NULL,
  birth_year CLASS('java.util.Calendar')
);
select_all:
select *
from author;
select_by_name:
select *
from author
where author.name = ?;

Book:

CREATE TABLE book (
  _id LONG NOT NULL PRIMARY KEY AUTOINCREMENT,
  isbn STRING NOT NULL,
  title STRING NOT NULL,
  release_year CLASS('java.util.Calendar')
);
select_all:
select *
from book;
select_by_title:
select *
from book
where book.title = ?;
select_by_isbn:
select *
from book
where book.isbn = ?;

The good thing about SQLDelight is that you also store your queries on .sq files and will help you replace the bindable parameters as where book.isbn = ?.

创建database model

现在我们可以在我们的数据库中创建我们的表了,只需编译你的工程然后SQLDelight就会生成BookModel和AuthorModel。

如果查看BookModel的内部你可以看到它建立了CREATE TABLE SQL statement,而成员属性则代表表的每一列:

public interface BookModel {
  String TABLE_NAME = "book";
  String _ID = "_id";
  String ISBN = "isbn";
  String TITLE = "title";
  String RELEASE_YEAR = "release_year";
  String CREATE_TABLE = ""
      + "CREATE TABLE book (\\n"
      + "  _id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\\n"
      + "  isbn TEXT NOT NULL,\\n"
      + "  title TEXT NOT NULL,\\n"
      + "  release_year BLOB\\n"
      + ")";
 // 下面继续

除了创建表的语句,还生成了我们在.sq文件中存储的查询语句。

  String SELECT_ALL = ""
      + "select *\\n"
      + "from book";
  String SELECT_BY_TITLE = ""
      + "select *\\n"
      + "from book\\n"
      + "where book.title = ?";
  String SELECT_BY_ISBN = ""
      + "select *\\n"
      + "from book\\n"
      + "where book.isbn = ?";

注:其实它还生成了更多的东西,下面列出了一些。

有了这些SQL语句,我们可以轻易的创建表了,继承SQLiteOpenHelper:

public class DelightfulOpenHelper extends SQLiteOpenHelper {
  public static final String DB_NAME = "delightful.db";
  public static final int DB_VERSION = 1;
  private static DelightfulOpenHelper instance;
  public static DelightfulOpenHelper getInstance(Context context) {
    if (null == instance) {
      instance = new DelightfulOpenHelper(context);
    }
    return instance;
  }
  private DelightfulOpenHelper(Context context) {
    super(context, DB_NAME, null, DB_VERSION);
  }
  @Override
  public void onCreate(SQLiteDatabase db) {
    db.execSQL(BookModel.CREATE_TABLE);
    db.execSQL(AuthorModel.CREATE_TABLE);
    db.execSQL(BookAuthorModel.CREATE_TABLE);
    populate(db);
  }
  private void populate(SQLiteDatabase db) {
    AuthorPopulator.populate(db);
    BookPopulator.populate(db);
  }
}

如果你使用Android官网上所说的SQLiteOpenHelper的基本实现,你可能会遇到一些并发的问题,就如Dmytro的博文中说明的,如果你是一个西班牙读者,看这篇文章.。

为了处理上面提到的问题,我创建了一个SQLiteHelper的wrapper:

DatabaseManager.java:

public class DatabaseManager {
  private static AtomicInteger openCount = new AtomicInteger();
  private static DatabaseManager instance;
  private static DelightfulOpenHelper openHelper;
  private static SQLiteDatabase database;
  public static synchronized DatabaseManager getInstance() {
    if (null == instance) {
      throw new IllegalStateException(DatabaseManager.class.getSimpleName()
          + " is not initialized, call initialize(..) method first.");
    }
    return instance;
  }
  public static synchronized void initialize(DelightfulOpenHelper helper) {
    if (null == instance) {
      instance = new DatabaseManager();
    }
    openHelper = helper;
  }
  public synchronized SQLiteDatabase openDatabase() {
    if (openCount.incrementAndGet() == 1) {
      database = openHelper.getWritableDatabase();
    }
    return database;
  }
  public synchronized void closeDatabase() {
    if (openCount.decrementAndGet() == 0) {
      database.close();
    }
  }
}

上面的这个类,我在Application类中实例化它,或者使用Dagger。

使用一个非常简单的android connected test(需要设备或者模拟器),我们就能检测表是否成功创建:

public class DatabaseShould extends CustomRunner {
  @Override
  @Before
  public void setUp() throws Exception {
    super.setUp();
    DbCommon.deleteDatabase(context);
  }
  @Test
  public void be_able_to_open_writable_database() throws Exception {
    SQLiteDatabase db = givenWritableDatabase();
    assertTrue(db.isOpen());
    assertTrue(!db.isReadOnly());
  }
  @Test
  public void have_created_tables() throws Exception {
    SQLiteDatabase db = givenWritableDatabase();
    HashSet<String> tables = givenAllTables();
    Cursor cursor = db.rawQuery("SELECT name FROM sqlite_master WHERE type='table'", null);
    cursor.moveToFirst();
    do {
      String table = cursor.getString(0);
      tables.remove(table);
    } while (cursor.moveToNext());
    cursor.close();
    assertTrue(tables.isEmpty());
  }
  private SQLiteDatabase givenWritableDatabase() {
    return DbCommon.givenWritableDatabase(context);
  }
  private HashSet<String> givenAllTables() {
    HashSet<String> tables = new HashSet<>();
    tables.add(BookModel.TABLE_NAME);
    tables.add(AuthorModel.TABLE_NAME);
    tables.add(BookAuthorModel.TABLE_NAME);
    return tables;
  }
}

同样你还可以像上面那样测试表的列的创建过程,这不是很好的范例,我很高兴你能提出一些改进:

public class AuthorTableShould extends CustomRunner {
  @Before
  public void setUp() throws Exception {
    super.setUp();
    DbCommon.deleteDatabase(context);
  }
  @Test
  public void have_created_all_columns() throws Exception {
    HashSet<String> columns = givenAuthorColumns();
    SQLiteDatabase db = givenWritableDatabase();
    Cursor cursor = db.rawQuery("PRAGMA table_info(" + AuthorModel.TABLE_NAME + ")", null);
    cursor.moveToFirst();
    int columnNameIndex = cursor.getColumnIndex("name");
    do {
      String columnName = cursor.getString(columnNameIndex);
      columns.remove(columnName);
    } while (cursor.moveToNext());
    assertTrue(columns.isEmpty());
    cursor.close();
    db.close();
  }
  private SQLiteDatabase givenWritableDatabase() {
    return DbCommon.givenWritableDatabase(context);
  }
  private HashSet<String> givenAuthorColumns() {
    HashSet<String> columns = new HashSet<>();
    columns.add(AuthorModel._ID);
    columns.add(AuthorModel.NAME);
    columns.add(AuthorModel.BIRTH_YEAR);
    return columns;
  }
}

使用SQLDelight插入数据

记住在.sq文件的CREATE TABLE定义中,我们使用了一个自定类型,java.util.Calendar。因为它不是一个原生的SQLite类型,我们必须为它创建一个adapter。

那么我们就来创建它,在我的sample project里,我把它叫做DateAdapter:

public class DateAdapter implements ColumnAdapter<Calendar> {
  @Override
  public Calendar map(Cursor cursor, int columnIndex) {
    Calendar calendar = Calendar.getInstance();
    calendar.setTimeInMillis(cursor.getLong(columnIndex));
    return calendar;
  }
  @Override
  public void marshal(ContentValues values, String key, Calendar value) {
    values.put(key, value.getTimeInMillis());
  }
}

这本该就完成了,但是我们还必须告诉SQLDelight如何使用它,所以找到并实现SQLDelight生成的BookModel和AuthorModel,重写里面的Author 和 Book Marshall类并传递这个calendar adapter。

@AutoValue public abstract class Author implements AuthorModel {
  private final static DateAdapter DATE_ADAPTER = new DateAdapter();
  public final static Mapper<Author> MAPPER =
      new Mapper<>((Mapper.Creator<Author>) AutoValue_Author::new, DATE_ADAPTER);
  public static final class Marshal extends AuthorMarshal<Marshal> {
    public Marshal() {
      super(DATE_ADAPTER);
    }
  }
}

我们暂时跳过MAPPER 变量,在讲到从表请求数据的时候将解释。注意这里的final类Marshal,就是在这里告诉SQLDelight,对于java.util.Calendar类型它应该使用你的adapter。在sample中我使用了谷歌的 @AutoValue 和 RetroLambda来避免一些重复的代码。

对于插入数据,我创建了一个叫做BookPopullator或者AuthorPopullator的类:

public class AuthorPopullator {
  public static void populate(SQLiteDatabase db) {
    db.insert(AuthorModel.TABLE_NAME, null, new Author.Marshal().name("J. K. Rowling")
        .birth_year(new GregorianCalendar(1965, 7, 31))
        .asContentValues());
    db.insert(AuthorModel.TABLE_NAME, null, new Author.Marshal().name("Bella Forests")
        .birth_year(new GregorianCalendar(197, 17, 31))
        .asContentValues());
    db.insert(AuthorModel.TABLE_NAME, null, new Author.Marshal().name("Norah Roberts")
        .birth_year(new GregorianCalendar(1950, 10, 10))
        .asContentValues());
    db.insert(AuthorModel.TABLE_NAME, null, new Author.Marshal().name("David Baldacci")
        .birth_year(new GregorianCalendar(1960, 8, 5))
        .asContentValues());
    db.insert(AuthorModel.TABLE_NAME, null, new Author.Marshal().name("Jeff Wheeler")
        .birth_year(new GregorianCalendar(1955, 13, 31))
        .asContentValues());
  }
}

可以看到,SQLDelight为我们提供了一个非常流畅的builder,代码更干净了。

删除数据遵循相同的程序,你在.sq文件中定义sql删除语句然后使用SQLiteDatabase对象执行它。

使用SQLDelight查询数据

为了演示如何使用SQLDelight查询数据,我像上面那样使用tests:

@Test
public void be_able_to_return_cursor_with_all_default_authors() throws Exception {
    SQLiteDatabase db = givenWritableDatabase();
    Cursor cursor = db.rawQuery(AuthorModel.SELECT_ALL, new String\[0\]);
    int AUTHOR_COUNT = 5;
    assertTrue(cursor.getCount() == AUTHOR_COUNT);
  }
  @Test
  public void map_cursor_with_domain_model() throws Exception {
    SQLiteDatabase db = givenWritableDatabase();
    Cursor cursor = db.rawQuery(AuthorModel.SELECT_ALL, new String\[0\]);
    cursor.moveToFirst();
    Author author = Author.MAPPER.map(cursor);
    assertNotNull(author);
  }

仔细看看Author.MAPPER.map(cursor)。对的!它能把cursor映射到model类中。对我来说这是非常棒的功能,因为它让我少些了很多程式化的代码,也更不易出错。

使用SQLDelight迁移

实际上,处理迁移并没有一个官方的方法,但是你可以查看一个 open issue来了解怎么回事

在我的sample project中,我使用了一个比较不规范的方式把迁移语句存放到.sq文件中。

我把迁移文件存放在跟model创建与查询文件相同的目录中:

└── sqldelight
    └── com
        └── alexsimo
            └── delightfulpersistence
                └── database
                    └── model
                        ├── Author.sq
                        ├── Book.sq
                        ├── BookAuthor.sq
                        ├── Migration_v1.sq
                        └── Migration_v2.sq

如果你看一看 Migration_v1.sq 的内部,可以看到SQLDelight IntelliJ 插件与compiler要求你使用一个CREATE TABLE statement来开始一个.sq文件,因此我只是用并不会创建的dummy添加了一个CREATE TABLE语句:

CREATE TABLE dummy(_id LONG NOT NULL PRIMARY KEY AUTOINCREMENT);
migrate_author:
ALTER TABLE author
ADD COLUMN country STRING NOT NULL;

然后在我们的自定义SQLiteOpenHelper类的onUpgrade()方法中我们就可以使用迁移语句了:

@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
    if (oldVersion < 2) {
      db.execSQL(Migration_v1Model.MIGRATE_AUTHOR);
    }
    if (oldVersion < 3) {
      db.execSQL(Migration_v2Model.MIGRATE_BOOK);
    }
  }

这并不是最干净的迁移方法,但是我相信Square的同学会在未来的SQLDelight版本中找到一个更好的方法。