我们大家都知道Android平台提供给我们一个数据库辅助类来创建或打开数据库,这个辅助类继承自SQLiteOpenHelper类,在该类的构造器中,调用Context中的方法创建并打开一个指定名称的数据库对象。继承和扩展SQLiteOpenHelper类主要做的工作就是重写以下两个方法。
onCreate(SQLiteDatabase db) : 当数据库被首次创建时执行该方法,一般将创建表等初始化操作在该方法中执行。
onUpgrade(SQLiteDatabse dv, int oldVersion,int new Version):当打开数据库时传入的版本号与当前的版本号不同时会调用该方法。
除了上述两个必须要实现的方法外,还可以选择性地实现onOpen 方法,该方法会在每次打开数据库时被调用。
SQLiteOpenHelper 类的基本用法是:当需要创建或打开一个数据库并获得数据库对象时,首先根据指定的文件名创建一个辅助对象,然后调用该对象的getWritableDatabase 或 getReadableDatabase方法 获得SQLiteDatabase 对象。
调用getReadableDatabase 方法返回的并不总是只读数据库对象,一般来说该方法和getWriteableDatabase 方法的返回情况相同,只有在数据库仅开放只读权限或磁盘已满时才会返回一个只读的数据库对象
SQLite实质上是将数据写入一个文件,通常情况下,在应用的包名下面都能找到xxx.db的文件,拥有root权限的手机,可以通过adb shell,看到data/data/packagename/databases/xxx.db这样的文件。
我们可以得知SQLite是文件级别的锁:多个线程可以同时读,但是同时只能有一个线程写。Android提供了SqliteOpenHelper类,加入Java的锁机制以便调用。
如果多线程同时读写(这里的指不同的线程用使用的是不同的Helper实例),后面的就会遇到android.database.sqlite.SQLiteException: database is locked这样的异常。
对于这样的问题,解决的办法就是keep single sqlite connection,保持单个SqliteOpenHelper实例,同时对所有数据库操作的方法添加synchronized关键字。
public class DatabaseHelper extends SQLiteOpenHelper { public static final String TAG = "DatabaseHelper"; private static final String DB_NAME = "practice.db"; private static final int DB_VERSION = 1; private Context mContext; private static DatabaseHelper mInstance; private DatabaseHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } public synchronized static DatabaseHelper getInstance(Context context) { if (mInstance == null) { mInstance = new DatabaseHelper(context); } return mInstance; } @Override public void onCreate(SQLiteDatabase db) { // TODO Auto-generated method stub } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // TODO Auto-generated method stub } public synchronized void queryMethod() { SQLiteDatabase readableDatabase = getReadableDatabase(); //read operation } public void updateMethod() { SQLiteDatabase writableDatabase = getWritableDatabase(); //update operation } }
Android为我们提供了SqliteOpenHelper类,我们可以通过getWritableDatabase或者getReadableDatabase拿到SQLiteDatabase对象,然后执行相关方法。这2个方法名称容易给人误解,我也在很长的一段时间内想当然的认为getReadabeDatabase就是获取一个只读的数据库,可以获取很多次,多个线程同时读,用完就关闭,实际上getReadableDatabase先以读写方式打开数据库,如果数据库的磁盘空间满了,就会打开失败,当打开失败后会继续尝试以只读方式打开数据库。
public synchronized SQLiteDatabase getReadableDatabase() { if (mDatabase != null && mDatabase.isOpen()) { return mDatabase; // The database is already open for business } if (mIsInitializing) { throw new IllegalStateException("getReadableDatabase called recursively"); } try { return getWritableDatabase(); } catch (SQLiteException e) { if (mName == null) throw e; // Can't open a temp database read-only! Log.e(TAG, "Couldn't open " + mName + " for writing (will try read-only):", e); } SQLiteDatabase db = null; try { mIsInitializing = true; String path = mContext.getDatabasePath(mName).getPath(); db = SQLiteDatabase.openDatabase(path, mFactory, SQLiteDatabase.OPEN_READONLY); if (db.getVersion() != mNewVersion) { throw new SQLiteException("Can't upgrade read-only database from version " + db.getVersion() + " to " + mNewVersion + ": " + path); } onOpen(db); Log.w(TAG, "Opened " + mName + " in read-only mode"); mDatabase = db; return mDatabase; } finally { mIsInitializing = false; if (db != null && db != mDatabase) db.close(); } }
在多线程中,如果第一个线程先调用getWritableDatabase,后面线程再次调用,或者第一个线程先调用getReadableDatabase,后面的线程调用getWritableDatabase,那么后面的这个方法是会失败的,因为数据库文件打开后会加锁,必须等前面的关闭后后面的调用才能正常执行,正是因为这个原因,可以1 Write+Many Read(有可能产生冲突,因为第一个getReadableDatabase有可能先于getWritableDatabase执行,导致后面的失败),也可以Many Read,但是不可能Many Write。所以使用单例加上同步的数据库操作方法,就不会出现死锁的问题,这部分例子请参照附件,多线程可以运行的很好,另外关于Sqlite database locking collisions example,网上有很不错的一个例子,可以这里去下载。
其实我觉得理论上可以修改getReadableDatabase方法,打开的数据库都是Read Only的,这样就能同时1 Write+Many Read,只不过要保证打开之前,数据库要创建或者升级好,这样读操作就不会互斥写操作,效率相对更高。
关于数据库关闭的问题,在下面好的习惯中会专门说明。
接触过数据库的人,对事务这个概念一定不陌生,它是原子性的,要么执行成功,执行一半失败后会回滚,这样就能保证数据的完整性。SQLiteDatabase也提供了Transaction的相关方法,常见用法:
db.beginTransaction(); try { ... db.setTransactionSuccessful(); } finally { db.endTransaction(); }使用事务对于批量更新有极大的好处,因为单次更新会频繁的调用数据库,曾经我同步过联系人,没使用事务之前,300 个联系人写入自己的数据库大概需要3~5 秒钟的时间,引入事务后,读取联系人的时间没有减少,但是所有更新的时间降为200ms 级,提升极为明显。
在应用迭代多个版本后,随着功能的增加和改变,数据库改变是很常见的事情,由于数据库中的数据一般是需要永久保存的,应用更新后,用户不希望数据丢失,特别是如果应用有几十万,百万级的用户量,如果很粗鲁的丢弃旧版本数据库中数据,对用户体验是很不好的,如果你没有提供云端备份的方案,就需要为用户保留旧的数据,即便数据库结构要发生变化。
实际上多次数据库变动的升级是很痛苦的事情,要考虑每一个旧的版本,理论上用户可以从任何一个旧的版本直接升级到最新版本,我们需要考虑每一种情况。在onUpgrade方法中,针对每一种版本号,先把旧的临时数据保存下来,删去旧的表,创建新表,然后将数据根据情况插入到新表中,不需要的字段可以丢弃,新增字段填默认值,数据可以临时存放到一个数组中,或者可以临时cache到文件中,最后将临时文件清空。
更新操作可以使用事务提高效率,另外需要知道的是I/O操作时耗时的,如果数据量较大,还需要放到单独的线程中处理,防止阻塞UI。
我们也经常会遇到数据库中需要初始化数据,比如城市,机场,号码归属地等信息,如果数据量不是很大,我们可以处理后放到asset或者raw文件下,创建数据库后导入进去,并且在2.3以前,asset中文件有大小限制,文件大小不能超过1M,否则AssetManager或Resources classes方法来获取InputStream,将抛出DEBUG/asset(1123): Data exceeds UNCOMPRESS_DATA_MAX的java.io.IOException异常。
解决这个问题有4个方法:
1.改名称(最简单):
aapt工具在打包apk文件时,会将资源文件压缩以减小安装包大小(raw文件夹下的资源则不受影响)。但是可以通过修改文件成下面的扩展名,逃避检查。
/* these formats are already compressed, or don't compress well */ static const char* kNoCompressExt[] = { ".jpg", ".jpeg", ".png", ".gif", ".wav", ".mp2", ".mp3", ".ogg", ".aac", ".mpg", ".mpeg", ".mid", ".midi", ".smf", ".jet", ".rtttl", ".imy", ".xmf", ".mp4", ".m4a", ".m4v", ".3gp", ".3gpp", ".3g2", ".3gpp2", ".amr", ".awb", ".wma", ".wmv" };2.压缩: