Android上令人愉快的持久化
原文出处:Delightful persistence on Android
在文章开始之前,引用一位我最喜欢的武术大师之一李小龙的一段话:
“在我开始学习武术之时,对我来说一拳就是一拳,一脚就是一脚。在我学习武术之后,一拳不再是一拳,一脚也不再是一脚。现在,当我真正了解了这门艺术之后,便又感觉到一拳仍仅仅是一拳,一脚也仅仅是一脚罢了。”
在我使用sqlite数据库的时候,这种情况也差不多发生在我身上,不管是使用比如ORMLite, DBFlow等那样的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. |
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版本中找到一个更好的方法。