Liping Zou bio photo

Liping Zou

An Android Developer

Email Twitter Instagram Github

Overview

误区

  从开始写Android开始到现在,总觉得SQLite就那么回事嘛,不就是各种query、insert之类的操作,写好SQL语句就可以了。直到遇到现在做的这个项目,才发现关于SQLite,我没踩过的坑还那么多。本文正是来源与此,将一一阐述这些日子对于SQLite的重新认识。

数据库的开启与关闭

  历史原因导致的「attempt to re-open an already-closed object」异常在错误日志中顽固存在着。就是从问题开始,我陷入了长期的苦恼中。在某个不经意的时刻,APP就崩溃了。定位到了错误的位置,但是却又发现执行这些操作可能导致崩溃,也可能正常运行,一切似乎关乎运气,非人事可以左右。

在详细解释前,首先说一下APP相关的Activity,方便后面的描述。打开APP,进入LoadingActivity,在LoadingActivity中,执行了一些数据同步的工作。Loading结束后,进入MainActivity。LoadingActivity和MainActivity都是继承自BaseActivity的。

数据库的开启

  在Android开发中,总会用到AsyncTask来执行费时的数据库操作,多线程执行写操作,往往会遇到「database is locked」问题,所以大家对于数据库的开启都很关注。使用到多线程就必须保证只有一个数据库连接,单例模式的使用就成了必然。一般写的代码,都是类似这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private static MyDBService instance;
private MyDatabaseHelper myDatabaseHelper;

private MyDBService(Context context){
    myDatabaseHelper = new MyDatabaseHelper(mContext);
}

public static synchronized MyDBService getInstance(Context context){
    if(instance == null){
        instance = new MyDBService(context);
    }
    return instance;
}

public synchronized SQLiteDatabase getDataBase() {
    if(myDatabaseHelper != null){
        db = myDatabaseHelper.getWritableDatabase();
    }
    return db;
}

  在程序运行的时候,保证线程安全,保证当前时刻仅有一个数据库实例。

数据的关闭

  试想这样一个场景,线程A执行完某些数据库操作后,关闭了数据库;此时,线程B正在执行数据库操作。因为使用了上面的单例模式,线程A和线程B getInstance().getDataBase()获取到的是同一个对象,线程A已经关闭了数据库,所以在线程B执行的时候,就会发现「attempt to re-open an already-closed object」异常。

数据库的关闭原则是没有人在使用数据库后再将其关闭。这时候,开发者可能会做的一件事就是不关闭数据库了,满心以为这下不会出错了吧。可是,异常又出现了,「SQLiteDatabase created and never closed」。所以,不关闭是不行的,随用随关也不可靠,只有在合适的时机关闭数据库,才能顺利完成所有的数据库操作。

现在回到开始的时候我提到的APP的问题。在前人写的代码中,数据库的关闭位置放在了BaseActivity的OnDestory()方法中。看起来好像很正确,在Activity被销毁的时候顺手把数据库关闭了。

熟悉Activity生命周期的开发者都知道,在LoadingActivity完成了它的工作后,打开了MainActivity,此时LoadingActivity不可见了,但是并没有立即被销毁。存在这样一种情况,LoadingActivity被销毁的时候,恰好MainActivity正在执行相关的数据库操作,那么很不幸,「attempt to re-open an already-closed object」异常就出现了。而且,在继承BaseActivity的Activity被销毁的时候,都会执行一次数据库的关闭,没有数据库操作的Activity,就会在销毁的时候开启一次数据库(因为我们使用了单例模式,实例不存在的时候,会重新创建一个新的实例),然后将其关闭,这是没有必要的操作。

因此,综合考虑本APP的数据库操作都是发生在MainActivity中,所以仅在MainActivity被销毁的时候,关闭一次数据库。

其他解决方案

  • 引用计数

  还有一种解决方案,在关闭数据库的时候不直接调用DataBase.close()方法,使用AtomicInteger来记录数据库开启的次数。只有当计数为0时,才真正将数据库关闭。这时候的代码,大概可以写成这样:

1
2
3
4
5
6
7
8
9
10
11
12
public synchronized SQLiteDatabase openDatabase() {
    if(mOpenCounter.incrementAndGet() == 1) {
        db = myDatabaseHelper.getWritableDatabase();
    }
    return db;
}

public synchronized void closeDatabase() {
    if(mOpenCounter.decrementAndGet() == 0) {
       db.close();
    }
}
  • ContentProvider

  当然,还有一种方案,使用ContentProvider,在Activity或Fragment中实现LoaderManager.LoaderCallbacks<Cursor>接口。在onCreateLoader()的时候,创建一个CursorLoader。加载完成后回调onLoadFinished(),展示获得的数据。这样就不需要考虑单例的问题了,仅需调用getContentResolver()就可以了,系统会为你处理好一切。不过让我很不解的是,对于ContentProvider,官方文档中提到「A content provider is only required if you need to share data between multiple applications. If you don’t need to share data amongst multiple applications you can use a database directly via SQLiteDatabase.」。在需要多个应用共享数据的时候,才是使用ContentProvider的最佳场景。涉及到多应用共享数据,必然导致了数据的不安全性,但是Google的iosched和Github官方的客户端都是使用ContentProvider。个中原因,我暂时还无法参透。也许iosched是为了与Google自家的应用G+之类的共享数据吧。

版本问题

  新的需求不断出现,以前设计的表结构可能已经无法满足产品新的需求了,数据库表模式的改变在开发中时有发生。一旦涉及到schema的修改,就要在onUpgrade()方法中做一些操作来完成数据的迁移。本来这一切都不会有任何问题,但是如果数据库保存在SD卡上,应用被卸载的时候,数据库并不会被删除。从旧版本升级到新版本时,因为版本号的提升,可以正确完成数据的迁移和扩展。但是,从新版本回退到旧版本呢,问题就出现了。从新版本回退到旧版本的时候,新版本被卸载,新版本的数据库保留在用户的SD卡中,安装完成的旧版本发现数据库的版本不需要升级,所以旧版本应用+新版本数据库的组合产生了,这样某些功能可能根本就无法使用了。

当然,在正常的情况下,不会遇到降版本的问题,但是一旦用户觉得新版不如旧版,做了这样的事情呢。对于此问题,本人目前没有解决方案,只是发现了这样的现象而已。如果不保存在SD卡则不会有这样的情况,APP卸载的时候,那部分数据也被删除了。现实生活中,APP需要携带的数据量大,仅依靠用户从网络方式获取,那么用户一定会抱怨流量的问题。很多时候不得不本地存储数据,不得不放在SD卡中。理想与现实中,需要找到一个极佳的平衡点,但并不是每次都能找得到,有时候需要某一方的妥协与让步。

关于这个问题,我又有了一点新的思考。数据库可以保存在SD卡,也可以保存在应用的data/data目录下。这两种方式都有利有弊。保存在SD卡上可以让用户感觉不到数据库的大小。在应用升级的时候,如果对数据库没有改变操作,那么就不需要重新读取建立,直接使用即可。但也有缺点,保存在SD卡上的数据直接暴露在外,存在安全隐患,用户可以直接读取到数据库文件,所以一般需要加密。保存在应用的目录下就不会遇到版本问题,应用在卸载的时候就会被清除了,但是升级的时候就需要每次都重建数据库。这中方式用户就很直观的感觉到这个应用的大小,如果某应用需要本地保存很多的数据,用户可能难以接受,并且焦虑。当然,数据也不是绝对安全的,root过的手机还是可以直接查看的,非root的手机想看也不是没有办法。所以两种方式都不是那么完美,只能按照需求,选择一个合适的。

数据库损坏

  在上一个问题中提到的表结构的变化,如果只是数据的变化,表结构不变,那么开发者要做的只是将新的数据库拷贝到指定的目录,更新数据库version。在程序启动的时候,读取已经存在的数据库的version,比较后发现有新的数据库时,删除旧版数据库文件,拷贝解压并建立数据库,迁移数据等等操作。但遇到了「database disk image is malformed」异常,异常信息显示在执行某个查询语句的时候发生了corruption,系统将数据库文件删除了。

stackoverflow上对于「database disk image is malformed」异常,给出的解释有提到SQLite的版本问题,说是某个版本以前确实存在这个问题,在某个版本之后修复了。但是查询了SQLite的版本后,发现我使用的是修复后的版本。还有一种解释是说要主动建立一个”android_metadata”表,这个表中要包含一个”locale”字段。但是实际上,系统会自动创建此表,不需要手动创建。

我的问题出在了查询数据库version的时候,是对数据库的一个表进行了查询操作。在删除该数据库,重新建立新数据库的时候,没有将数据库关闭导致了刚才提到的异常。所以,在删除旧数据库,建立新的数据库前,请确保数据已经关闭。

得到的一点启发

  任何问题都是事出有因的。作为一名开发者,遇到了bug,总在想这为什么发生。有时候觉得可能是操作不正确或者其他不可抗力导致的,但实际上,只要耐心寻找,给予足够的时间,一定能找到导致bug发生的代码。