WCDB for Android
前言
最近自己項(xiàng)目記錄數(shù)據(jù)庫(kù)有用戶反饋數(shù)據(jù)會(huì)丟失秘通,我們一直都沒(méi)找到初步原因股淡,因此也是懷疑部分用戶數(shù)據(jù)庫(kù)損壞導(dǎo)致声邦,查看了下sqlite官網(wǎng)的說(shuō)法有導(dǎo)致?lián)p壞db文件的如下幾點(diǎn)原因:
- 文件錯(cuò)寫
- 文件鎖 bug
- 文件 sync 失敗
- 設(shè)備損壞
- 內(nèi)存覆蓋
- 操作系統(tǒng) bug
- SQLite bug
具體的大家可以看下這篇文章:微信客戶端SQLite數(shù)據(jù)庫(kù)損壞修復(fù)實(shí)踐
因此我們才會(huì)調(diào)研考慮要不要使用微信自己出的這個(gè)WCDB數(shù)據(jù)庫(kù)编丘,下面先具體的講解下WCDB
具體的功能
- 基于SQLCipher的數(shù)據(jù)庫(kù)加密
- 使用連接池實(shí)現(xiàn)并發(fā)讀寫
- Reparir Kit工具類用于修復(fù)損壞數(shù)據(jù)庫(kù)
- 針對(duì)占用空間大小優(yōu)化的數(shù)據(jù)庫(kù)備份和恢復(fù)功能
- 日志輸出重定向和性能跟蹤接口
- 內(nèi)建用于全文搜索的mmicu FTS3/4的分詞器
接入
在build.gradle下面配置
dependencies {
...
compile 'com.tencent.wcdb:wcdb-android:1.0.2'
}
選擇接入的CPU架構(gòu)挽牢,WCDB包含 armeabi, armeabi-v7a, arm64-v8a, x86四種架構(gòu)的動(dòng)態(tài)庫(kù)谱煤,具體的就想用哪個(gè)用哪個(gè)了具體配置在build.gradle:
android {
defaultConfig {
...
ndk {
// 接入 armeabi ,armeabi-v7a ,x86
abiFilters 'armeabi', 'armeabi-v7a','x86'
}
}
}
加密:WCDB在android上語(yǔ)法和官方再帶的sqlite是一樣的,記得導(dǎo)包的時(shí)候引用tencent的禽拔,下面開(kāi)始看一個(gè)具體的列子:
import android.content.Context;
import com.tencent.wcdb.DatabaseErrorHandler;
import com.tencent.wcdb.database.SQLiteCipherSpec;
import com.tencent.wcdb.database.SQLiteDatabase;
import com.tencent.wcdb.database.SQLiteOpenHelper;
public class DBHelper extends SQLiteOpenHelper {
static final String DATABASE_NAME = "test-repair.db";
static final int DATABASE_VERSION = 1;
static final byte[] PASSPHRASE = "testkey".getBytes();
// The test database is taken from SQLCipher test-suit.
//
// To be compatible with databases created by the official SQLCipher
// library, a SQLiteCipherSpec must be specified with page size of
// 1024 bytes.
static final SQLiteCipherSpec CIPHER_SPEC = new SQLiteCipherSpec()
.setPageSize(1024);
// We don't want corrupted databases get deleted or renamed on this sample,
// so use an empty DatabaseErrorHandler.
static final DatabaseErrorHandler ERROR_HANDLER = new DatabaseErrorHandler() {
@Override
public void onCorruption(SQLiteDatabase dbObj) {
// Do nothing
}
};
public DBHelper(Context context) {
super(context, DATABASE_NAME, null, CIPHER_SPEC, null,
DATABASE_VERSION, ERROR_HANDLER);
// super(context,DATABASE_NAME,null,DATABASE_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL("CREATE TABLE t1(a,b);");
// OPTIONAL: backup master info for corruption recovery.
// However, we want to test recovery feature, so omit backup here.
//RepairKit.MasterInfo.save(db, db.getPath() + "-mbak", PASSPHRASE);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
// Do nothing.
}
}
- 也是繼承SQLiteOpenHelper去做事情刘离。WCDB 使用了 SQLCipher 的 C 層庫(kù)室叉,但沒(méi)有直接使用 SQLCipher Android 的封裝層。SQLCipher Android 封裝層中很多設(shè)置需要手寫 PRAGMA 語(yǔ)句實(shí)現(xiàn)硫惕,比如設(shè)置 KDF 迭代次數(shù)(兼容老版本 SQLCipher DB)太惠、設(shè)置 Page Size 等操作。
- 構(gòu)造方法中直接傳入一個(gè)byte[]作為密碼加密操作疲憋,很簡(jiǎn)單凿渊,WCDB 將 String 類型的密碼改為 byte[] 類型,可以支持非打印字符作為密碼(比如 hash(user id) 方式)缚柳,原來(lái)字符類型密碼只要轉(zhuǎn)換為 UTF-8 的 byte 數(shù)組即可埃脏,和 SQLCipher Android 兼容。
數(shù)據(jù)遷移
SQLCipher 提供了 sqlcipher_export SQL 函數(shù)用于導(dǎo)出數(shù)據(jù)到掛載的另一個(gè) DB秋忙,可以用于數(shù)據(jù)遷移彩掐。 但這個(gè)函數(shù)用于 Android 的 SQLiteOpenHelper 并不方便。
SQLiteOpenHelper 主要幫助開(kāi)發(fā)者做 Schema 版本管理灰追,通過(guò)它打開(kāi) SQLite 數(shù)據(jù)庫(kù)堵幽,會(huì)讀取 user_version 字段來(lái)判斷是否需要升級(jí),并調(diào)用子類實(shí)現(xiàn)的 onCreate弹澎、onUpgrade 等接口來(lái)完成創(chuàng)建或升級(jí)操作朴下。 sqlcipher_export 由于是導(dǎo)出而非導(dǎo)入,就跟 onCreate 等接口不搭了苦蒿,因?yàn)橐P(guān)閉原來(lái)的 DB殴胧, 打開(kāi)老的 DB,執(zhí)行 export 到新 DB佩迟,再重打開(kāi)团滥。
為了方便使用,WCDB 就做了擴(kuò)展报强,將 sqlcipher_export 擴(kuò)展為可以接受第二個(gè)參數(shù)表示從哪里導(dǎo)出灸姊, 從而實(shí)現(xiàn)了導(dǎo)入,列子看下:
@Override
public void onCreate(SQLiteDatabase db) {
// Check whether old plain-text database exists, if so, export it
// to the new, encrypted one.
File oldDbFile = mContext.getDatabasePath(OLD_DATABASE_NAME);
if (oldDbFile.exists()) {
Log.i(TAG, "Migrating plain-text database to encrypted one.");
// SQLiteOpenHelper begins a transaction before calling onCreate().
// We have to end the transaction before we can attach a new database.
db.endTransaction();
// Attach old database to the newly created, encrypted database.
String sql = String.format("ATTACH DATABASE %s AS old KEY '';",
DatabaseUtils.sqlEscapeString(oldDbFile.getPath()));
db.execSQL(sql);
// Export old database.
db.beginTransaction();
//從old舊的數(shù)據(jù)庫(kù)倒出數(shù)據(jù)庫(kù)到main
DatabaseUtils.stringForQuery(db, "SELECT sqlcipher_export('main', 'old');", null);
db.setTransactionSuccessful();
db.endTransaction();
// Get old database version for later upgrading.
int oldVersion = (int) DatabaseUtils.longForQuery(db, "PRAGMA old.user_version;", null);
// Detach old database and enter a new transaction.
db.execSQL("DETACH DATABASE old;");
// Old database can be deleted now.
oldDbFile.delete();
// Before further actions, restore the transaction.
db.beginTransaction();
// Check if we need to upgrade the schema.
if (oldVersion > DATABASE_VERSION) {
onDowngrade(db, oldVersion, DATABASE_VERSION);
} else if (oldVersion < DATABASE_VERSION) {
onUpgrade(db, oldVersion, DATABASE_VERSION);
}
} else {
Log.i(TAG, "Creating new encrypted database.");
// Do the real initialization if the old database is absent.
db.execSQL("CREATE TABLE message (content TEXT, "
+ "sender TEXT);");
}
// OPTIONAL: backup master info for corruption recovery.
RepairKit.MasterInfo.save(db, db.getPath() + "-mbak", /*mPassphrase.getBytes()*/null);
}
如此就可以不關(guān)閉原來(lái)的數(shù)據(jù)庫(kù)實(shí)現(xiàn)數(shù)據(jù)導(dǎo)入,可以兼容 SQLiteOpenHelper 的接口了秉溉。
數(shù)據(jù)庫(kù)修復(fù)
Android 接口支持三種修復(fù)方法力惯,如下:
修復(fù)方法 | 簡(jiǎn)介 | 相關(guān)接口 |
---|---|---|
Repair Kit | 解析 B-tree 修復(fù) | RepairKit類 |
備份恢復(fù) | 壓縮備份完整數(shù)據(jù),使用備份數(shù)據(jù)恢復(fù) | BackupKit 和 RecoverKit |
Dump | .dump 命令坚嗜,已廢棄 | DBDumpUtil |
一夯膀,Repair Kit
使用 Repair Kit 可以直接從損壞的數(shù)據(jù)庫(kù)里盡量讀出未損壞的數(shù)據(jù)诗充,不需要事先準(zhǔn)備苍蔬, 但是先備份 Master 信息可以大大增加恢復(fù)成功率。 如果有意使用 Repair Kit 恢復(fù)數(shù)據(jù)庫(kù)蝴蜓, 建議備份 Master 信息碟绑。Master 信息保存了數(shù)據(jù)庫(kù)的 Schema俺猿,建議每次執(zhí)行完數(shù)據(jù)庫(kù)創(chuàng)建或升級(jí)時(shí)執(zhí)行備份,可以保證備份 是最新的格仲。不修改 Schema 的話 Master 信息不會(huì)改變押袍。如果你使用 SQLiteOpenHelper,最佳 實(shí)踐是在 SQLiteOpenHelper.onCreate(...) 和 SQLiteOpenHelper.onUpgrade(...) 的 最后進(jìn)行備份凯肋。備份 Master 信息只需要調(diào)用 RepairKit.MasterInfo.save(...) 即可谊惭。備份 Master 信息 典型消耗為幾kB ~ 幾十kB,幾毫秒 ~ 幾十毫秒侮东,但如果你有非常非常多的表和索引(萬(wàn)數(shù)量級(jí))圈盔, 這個(gè)過(guò)程可能會(huì)有點(diǎn)慢,建議放在子線程完成.如下:
public class DBHelper extends SQLiteOpenHelper {
public DBHelper(Context context) {
super(context, DATABASE_NAME, PASSPHRASE, CIPHER_SPEC, null,
DATABASE_VERSION, ERROR_HANDLER);
}
@Override
public void onCreate(SQLiteDatabase db) {
// 執(zhí)行 CREATE TABLE 創(chuàng)建 Schema
db.execSQL("CREATE TABLE t1(a,b);");
db.execSQL("CREATE TABLE t2(c,d);");
// ......
// 備份 Master 信息
RepairKit.MasterInfo.save(db, db.getPath() + "-mbak", BACKUP_PASSPHRASE);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
// 執(zhí)行升級(jí)
db.execSQL("ALTER TABLE t1 ADD COLUMN x TEXT;");
// 備份 Master 信息
RepairKit.MasterInfo.save(db, db.getPath() + "-mbak", BACKUP_PASSPHRASE);
}
}
二悄雅,恢復(fù)損壞數(shù)據(jù)庫(kù)
恢復(fù)損壞數(shù)據(jù)庫(kù)驱敲,首先加載之前備份的 Master 信息(如果有)。
RepairKit.MasterInfo master = RepairKit.MasterInfo.load('/path/to/database.db-mbak',
BACKUP_PASSPHRASE, null);
if (master == null) {
// 加載不成功宽闲,可能是不存在或者損壞
}
使用 RepairKit 打開(kāi)損壞的數(shù)據(jù)庫(kù)众眨,使用 SQLiteDatabase 打開(kāi)新的數(shù)據(jù)庫(kù),調(diào)用 output(...) 即可將損壞數(shù)據(jù)庫(kù)的內(nèi)容轉(zhuǎn)移到新數(shù)據(jù)庫(kù)容诬。
RepairKit repair = new RepairKit(
"/path/to/corrupted.db" // 損壞的數(shù)據(jù)庫(kù)文件
PASSPHRASE, // 數(shù)據(jù)庫(kù)密鑰(不是備份文件密鑰)
CIPHER_SPEC, // 加密描述娩梨,與打開(kāi)DB時(shí)一樣
master // 之前加載的 Master 信息
);
SQLiteDatabase newDb = SQLiteDatabase.openOrCreateDatabase(...);
// 打開(kāi)新DB用于承載恢復(fù)數(shù)據(jù),是否加密沒(méi)所謂
boolean result = repair.output(newDb, 0);
// 輸出恢復(fù)數(shù)據(jù)到新DB
if (!result) {
// 恢復(fù)失敗
}
repair.release();
// 最后要 release 釋放資源
恢復(fù)的過(guò)程需時(shí)較長(zhǎng)览徒,請(qǐng)務(wù)必在子線程完成姚建,如數(shù)據(jù)庫(kù)較大請(qǐng)考慮持有 Wake Lock。
三吱殉,選擇性恢復(fù)
Repair Kit 可以只恢復(fù)一部分表掸冤,只需要在 MasterInfo.load(...) 或者 MasterInfo.make(...) 里指定白名單即可。
// 白名單友雳,只有白名單里列到的表才會(huì)恢復(fù)稿湿,表對(duì)應(yīng)的索引也會(huì)相應(yīng)恢復(fù)
String[] tables = new String[] {
"t1", "t2" // 只恢復(fù) t1 和 t2 兩個(gè)表
};
RepairKit.MasterInfo master = RepairKit.MasterInfo.load('/path/to/database.db-mbak',
BACKUP_PASSPHRASE, tables);
日志重定向與性能監(jiān)控
SQLite 和 WCDB 框架在運(yùn)行中會(huì)產(chǎn)生日志,這些日志默認(rèn)會(huì)打印到系統(tǒng)日志(logcat)押赊,但這可能不是 所有開(kāi)發(fā)者都希望的行為饺藤。比如擔(dān)心日志里帶有敏感信息,直接輸出到系統(tǒng)不妥流礁,或者希望將日志寫到文件 用于上報(bào)和分析涕俗,WCDB 提供接口來(lái)完成日志重定向。使用情況:
//不打印任何日志
Log.setLogger(Log.LOGGER_NONE);
//或者自定義日志
Log.setLogger(new Log.LogCallback() {
@Override
public void println(int priority, String tag, String msg) {
//處理日志
}
});
WCDB 還提供了性能監(jiān)控接口 SQLiteTrace神帅,實(shí)現(xiàn)接口并綁定到 SQLiteDatabase 可以在每次 執(zhí)行 SQL 語(yǔ)句或連接池?fù)矶碌臅r(shí)候得到回調(diào)
SQLiteTrace trace=new SQLiteTrace() {
@Override
public void onSQLExecuted(SQLiteDatabase db, String sql, int type, long time) {
//每次之行完一條sql的語(yǔ)句執(zhí)行的回調(diào)
}
@Override
public void onConnectionObtained(SQLiteDatabase db, String sql, long waitTime, boolean isPrimary) {
//從連接池獲得了鏈接成功
}
@Override
public void onConnectionPoolBusy(SQLiteDatabase db, String sql, List<String> requests, String message) {
//等待連接池超過(guò)3秒的回調(diào)再姑,因?yàn)榇嬖趧e的操作占用著連接池
}
@Override
public void onDatabaseCorrupted(SQLiteDatabase db) {
//數(shù)據(jù)庫(kù)損壞時(shí)回調(diào)
}
};
mDB.setTraceCallback(trace);
SQLiteDatabase 也開(kāi)放了 dump 方法,可以打印出數(shù)據(jù)庫(kù)的當(dāng)前狀態(tài)找御,包括連接池內(nèi)所有連接 被持有的狀態(tài)以及最近執(zhí)行的 SQL 語(yǔ)句和耗時(shí)元镀,對(duì)排查性能和死鎖問(wèn)題也有很大幫助绍填。
優(yōu)化 Cursor 實(shí)現(xiàn)
Android 框架查詢數(shù)據(jù)庫(kù)使用的是 Cursor 接口,調(diào)用 SQLiteDatabase.query(...) 會(huì)返回一個(gè)Cursor 對(duì)象栖疑,之后就可以使用 Cursor 遍歷結(jié)果集了讨永。Android SDK SQLite Cursor 的實(shí)現(xiàn)是分配一個(gè)固定 2MB 大小的緩沖區(qū),稱作 Cursor Window遇革,用于存放查詢結(jié)果集卿闹。
查詢時(shí),先分配Cursor Window萝快,然后執(zhí)行 SQL 獲取結(jié)果集填充之比原,直到 Cursor Window 放滿或者遍歷完結(jié)果集,之后將 Cursor 返回給調(diào)用者杠巡。
假如 Cursor 遍歷到緩沖區(qū)以外的行量窘,Cursor 會(huì)丟棄之前緩沖區(qū)的所有內(nèi)容,重新查詢氢拥,跳過(guò)前面的行蚌铜,重新選定一個(gè)開(kāi)始位置填充 Cursor Window 直到緩沖區(qū)再次填滿或遍歷完結(jié)果集。
這樣的實(shí)現(xiàn)能保證大部分情況正常工作嫩海,在很多情況下卻不是最優(yōu)實(shí)現(xiàn)冬殃。微信對(duì) DB 操作最多的場(chǎng)景是獲取 Cursor 直接遍歷獲取數(shù)據(jù)后關(guān)閉,獲取到的數(shù)據(jù)叁怪,一般是生成對(duì)應(yīng)的實(shí)體對(duì)象(通過(guò) ORM 或者自行從 Cursor 轉(zhuǎn)換)后放到 List 或 Map 等容器里返回审葬,或用于顯示,或用于其他邏輯奕谭。
在這種場(chǎng)景下涣觉,先將數(shù)據(jù)保存到 Cursor Window 后再取出,中間要經(jīng)歷兩次內(nèi)存拷貝和轉(zhuǎn)換(SQLite → CursorWindow → Java)血柳,這是完全沒(méi)有必要的官册。另外,由于 Cursor Window 是定長(zhǎng)的难捌,對(duì)于較小的結(jié)果集膝宁,需要無(wú)故分配 2MB 內(nèi)存,對(duì)于大結(jié)果集根吁,如果 2MB 不足以放下员淫,遍歷到途中還會(huì)引發(fā) Cursor 重查詢,這個(gè)消耗就相當(dāng)大了击敌。
Cursor Window介返,其實(shí)也是在 JNI 層通過(guò) SQLite 庫(kù)的 Statement 填充的,Statement 這里可以理解為一個(gè)輕量但只能往前遍歷愚争,沒(méi)有緩存的 Cursor映皆。這個(gè)不就跟我們的場(chǎng)景一致嗎挤聘?何不直接使用底層的 Statement 呢轰枝?我們對(duì) Statement 做了簡(jiǎn)單的封裝捅彻,暴露了 Cursor 接口, SQLiteDirectCursor 就誕生了鞍陨,它直接操作底層 SQLite 獲取數(shù)據(jù)步淹,只能執(zhí)行往前迭代的操作,但這完全滿足需要诚撵。
com.tencent.wcdb.Cursor cursor=mDB.rawQueryWithFactory(SQLiteDirectCursor.FACTORY,sql,null);
try {
while (cursor.moveToNext()) {
//處理數(shù)據(jù)
}
}catch (Exception e){
e.printStackTrace();
}
在大部分不需要將 Cursor 傳遞出去的場(chǎng)景缭裆,能很好的解決 Cursor 的額外消耗,特別是結(jié)果集大于 2MB 的場(chǎng)合寿烟。