1. 程式人生 > >你應該知道的 Android 資料庫更新策略

你應該知道的 Android 資料庫更新策略

在涉及資料庫的應用中,我們不可能在應用剛上線時,就提前預知未來需要的欄位,只能在後期根據新的需求去不斷完善。所以,資料庫的更新就顯得十分重要,因為從最初搭建資料庫,你就需要做好後期升級的機制。如果剛開始沒有做,等 App 上線了,再想更新資料庫以新增表或欄位,你會發現是個大問題。

本文以使用 GreenDao 3.2為例,側重分享更新方案,至於基本配置及使用,網上已經有跟多優秀的譯文或者部落格,就不再贅述。

更新這塊重視的人還不多,所以想記錄一下,和大家一起交流。

1 GreenDao 自帶更新的問題

GreenDao 3.2 中自帶的更新類 DevOpenHelper,是不可用的,如下:

/** WARNING: Drops all table on Upgrade! Use only during development. */
public static class DevOpenHelper extends OpenHelper {
    public DevOpenHelper(Context context, String name) {
        super(context, name);
    }
    public DevOpenHelper(Context context, String name, CursorFactory factory) {
        super(context, name, factory);
    }
    @Override
    public void
onUpgrade(Database db, int oldVersion, int newVersion) { dropAllTables(db, true); onCreate(db); } }

註釋明確說明了僅限於開發階段,從上述程式碼可以看出,GreenDao 在資料庫版本更新時,預設刪除所有表再新建,開發階段無所謂,但這對於線上 App 升級是致命的,這樣一來老使用者的資料就全丟了,所以不適合用於 App 上線後更新資料庫。

可能我們會想,那我們改掉它不就行了嗎?改是不行的,因為 DevOpenHelperDaoMaster的內部類,而 Daomaster

是 GreenDao 自動生成的,會在 build 專案時被覆蓋重寫。

2 自定義更新

看來只能自己寫了,擼起袖子就是幹。

2.1 自定義更新類

仿照 DevOpenHelper,我們自定義 MyOpenHelper 繼承自 OpenHelper,並重寫onUpgrade()方法以自己維護更新:

public class MyOpenHelper extends DaoMaster.OpenHelper {

    public MyOpenHelper(Context context, String name) {
        super(context, name);
    }

    public MyOpenHelper(Context context, String name, SQLiteDatabase.CursorFactory factory) {
        super(context, name, factory);
    }

    @Override
    public void onUpgrade(Database db, int oldVersion, int newVersion) {
        super.onUpgrade(db, oldVersion, newVersion);
        //寫自己的更新邏輯
    }
}

此時,就可以在上述註釋的位置寫自己的邏輯了。

2.2 關聯自定義的 MyOpenHelper

光寫不行,要讓 GreenDao 知道我們使用自定義的更新類,所以在初始化 GreenDao 的地方指明使用 MyOpenHelper,如下:

public class App extends Application {

    private static DaoSession daoSession;

    @Override
    public void onCreate() {
        super.onCreate();
        //使用自定義更新類
        MyOpenHelper helper = new MyOpenHelper(this, "db-name");
        Database db = helper.getWritableDb();
        daoSession = new DaoMaster(db).newSession();
    }

    //對外暴露會話物件 DaoSession
    public static DaoSession getDaoSession() {
        return daoSession;
    }
}

3 更新資料庫

關聯自定義的更新類之後,下面開始真正的更新邏輯,分別以 新增表更新已有表的欄位 為例。

3.1 新增表

隨著專案迭代,假設這一版我們需要新增一個數據表,用來儲存使用者快取的視訊路徑,大致步驟如下:

1.新建 VideoCache 物件,用 @Entry 標識一下,加幾個屬性,再 build 一下專案,GreenDao 會自動幫我們補全 gettersetter 方法,同時生成對應的 VideoCacheDao
2.修改 appbuild.gradle 中宣告的資料庫版本號,+1;
3.在 MyOpenHelperonUpgrade() 方法中建立新表:

@Override
public void onUpgrade(Database db, int oldVersion, int newVersion) {
    super.onUpgrade(db, oldVersion, newVersion);
    //這麼寫能更新,但實際還存在跨版本升級問題
    VideoCacheDao.createTable(db, false);
}

4.執行即可;

3.2 更新已有表的欄位

同樣,隨著版本迭代,以前的資料庫表需要新增欄位以滿足現有的需求,以在VideoCache表中新增 FileSize 欄位為例。新增欄位不同於新增表,更新過程概括來說分為三步:
1. 備份 VideoCache 表到臨時表 VideoCache_Temp
2. 刪除原來的 VideoCache 表;
3. 新建帶有 FileSize 欄位的 VideoCache 表;
4. 遷移 VideoCache_Temp 表的資料至新建的 VideoCache

3.2.1 開源方案

上述過程自己實現起來還是有難度的,好在網上有開源的輔助類,直接拿過來,加點註釋,如下:

public class MigrationHelper {

    private static final String CONVERSION_CLASS_NOT_FOUND_EXCEPTION =
        "MIGRATION HELPER - CLASS DOESN'T MATCH WITH THE CURRENT PARAMETERS";
    private static MigrationHelper instance;

    public static MigrationHelper getInstance() {
        if (instance == null) {
            instance = new MigrationHelper();
        }
        return instance;
    }

    public void migrate(Database db, Class<? extends AbstractDao<?, ?>>... daoClasses) {
        //1. 備份表
        generateTempTables(db, daoClasses);
        //2. 刪除所有表
        DaoMaster.dropAllTables(db, true);
        //3. 重新建立所有表
        DaoMaster.createAllTables(db, false);
        //4. 恢復資料
        restoreData(db, daoClasses);
    }

    /**
     * 備份要更新的表
     */
    private void generateTempTables(Database db, Class<? extends AbstractDao<?, ?>>... daoClasses) {
        for (int i = 0; i < daoClasses.length; i++) {
            DaoConfig daoConfig = new DaoConfig(db, daoClasses[i]);

            String divider = "";
            String tableName = daoConfig.tablename;
            String tempTableName = daoConfig.tablename.concat("_TEMP");
            ArrayList<String> properties = new ArrayList<>();

            StringBuilder createTableStringBuilder = new StringBuilder();

            createTableStringBuilder.append("CREATE TABLE ").append(tempTableName).append(" (");

            for (int j = 0; j < daoConfig.properties.length; j++) {
                String columnName = daoConfig.properties[j].columnName;

                if (getColumns(db, tableName).contains(columnName)) {
                    properties.add(columnName);

                    String type = null;

                    try {
                        type = getTypeByClass(daoConfig.properties[j].type);
                    } catch (Exception exception) {
                    }

                    createTableStringBuilder.append(divider).append(columnName).append(" ").append(type);

                    if (daoConfig.properties[j].primaryKey) {
                        createTableStringBuilder.append(" PRIMARY KEY");
                    }

                    divider = ",";
                }
            }
            createTableStringBuilder.append(");");

            db.execSQL(createTableStringBuilder.toString());

            StringBuilder insertTableStringBuilder = new StringBuilder();

            insertTableStringBuilder.append("INSERT INTO ").append(tempTableName).append(" (");
            insertTableStringBuilder.append(TextUtils.join(",", properties));
            insertTableStringBuilder.append(") SELECT ");
            insertTableStringBuilder.append(TextUtils.join(",", properties));
            insertTableStringBuilder.append(" FROM ").append(tableName).append(";");

            db.execSQL(insertTableStringBuilder.toString());
        }
    }

    /**
     * 恢復資料
     */
    private void restoreData(Database db, Class<? extends AbstractDao<?, ?>>... daoClasses) {
        for (int i = 0; i < daoClasses.length; i++) {
            DaoConfig daoConfig = new DaoConfig(db, daoClasses[i]);

            String tableName = daoConfig.tablename;
            String tempTableName = daoConfig.tablename.concat("_TEMP");
            ArrayList<String> properties = new ArrayList();

            for (int j = 0; j < daoConfig.properties.length; j++) {
                String columnName = daoConfig.properties[j].columnName;

                if (getColumns(db, tempTableName).contains(columnName)) {
                    properties.add(columnName);
                }
            }

            StringBuilder insertTableStringBuilder = new StringBuilder();

            insertTableStringBuilder.append("INSERT INTO ").append(tableName).append(" (");
            insertTableStringBuilder.append(TextUtils.join(",", properties));
            insertTableStringBuilder.append(") SELECT ");
            insertTableStringBuilder.append(TextUtils.join(",", properties));
            insertTableStringBuilder.append(" FROM ").append(tempTableName).append(";");

            StringBuilder dropTableStringBuilder = new StringBuilder();

            dropTableStringBuilder.append("DROP TABLE ").append(tempTableName);

            db.execSQL(insertTableStringBuilder.toString());
            db.execSQL(dropTableStringBuilder.toString());
        }
    }

    private String getTypeByClass(Class<?> type) throws Exception {
        if (type.equals(String.class)) {
            return "TEXT";
        }
        if (type.equals(Long.class) || type.equals(Integer.class) || type.equals(long.class)) {
            return "INTEGER";
        }
        if (type.equals(Boolean.class)) {
            return "BOOLEAN";
        }

        Exception exception =
            new Exception(CONVERSION_CLASS_NOT_FOUND_EXCEPTION.concat(" - Class: ").concat(type.toString()));
        throw exception;
    }

    private static List<String> getColumns(Database db, String tableName) {
        List<String> columns = new ArrayList<>();
        Cursor cursor = null;
        try {
            cursor = db.rawQuery("SELECT * FROM " + tableName + " limit 1", null);
            if (cursor != null) {
                columns = new ArrayList<>(Arrays.asList(cursor.getColumnNames()));
            }
        } catch (Exception e) {
            Log.v(tableName, e.getMessage(), e);
            e.printStackTrace();
        } finally {
            if (cursor != null) cursor.close();
        }
        return columns;
    }
}

這樣,只需要在更新欄位時,在 MyOpenHelper 類的 onUpgrade() 方法中:

@Override
public void onUpgrade(Database db, int oldVersion, int newVersion) {
    super.onUpgrade(db, oldVersion, newVersion);
    //更新表的欄位
    MigrationHelper.getInstance().migrate(db, VideoCacheDao.class);
}

3.2.2 發現問題

上面的開源方案,使用起來如此順手,但不知道細心的你發現沒,這個更新輔助類是存在問題的:

public void migrate(Database db, Class<? extends AbstractDao<?, ?>>... daoClasses) {
    //1. 備份表
    generateTempTables(db, daoClasses);
    //2. 刪除所有表
    DaoMaster.dropAllTables(db, true);
    //3. 重新建立所有表
    DaoMaster.createAllTables(db, false);
    //4. 恢復資料
    restoreData(db, daoClasses);
}

發現其每次都是刪除所有表、再新建所有表,這意味著:

當我想更新一張表中的某個欄位,我卻要傳入所有的表對應的 XxxDao.class 物件,即使其它表不需要更新,也會經歷 備份刪除新建恢復 的過程,效率低下不說,一不小心還容易出問題

在上面,我們這麼更新表:

MigrationHelper.getInstance().migrate(db, VideoCacheDao.class);

問題在於,如果你不只是有一張表,在更新某張表的 欄位時,如上你只傳當前需要更新的表,則其它表的資料都會丟失。明白了嗎?沒明白的話再好好看看上面的程式碼。

3.2.3 改造以解決問題

於是我改造了一下,只需要傳入你想更新的表即可:

public class MigrationHelper {

    private static final String CONVERSION_CLASS_NOT_FOUND_EXCEPTION =
        "MIGRATION HELPER - CLASS DOESN'T MATCH WITH THE CURRENT PARAMETERS";
    private static MigrationHelper instance;

    public static MigrationHelper getInstance() {
        if (instance == null) {
            instance = new MigrationHelper();
        }
        return instance;
    }

    public void migrate(Database db, Class<? extends AbstractDao<?, ?>>... daoClasses) {
        //1.備份(同上)
        generateTempTables(db, daoClasses);
        //2. 只刪除需要更新的表(改造)
        deleteOriginalTables(db, daoClasses);
        //3. 只建立需要更新的表(改造)
        //DaoMaster.createAllTables(db, false);
        createOrignalTables(db, daoClasses);
        //4. 恢復資料
        restoreData(db, daoClasses);
    }

    /**
     * 備份要更新的表
     */
    private void generateTempTables(Database db, Class<? extends AbstractDao<?, ?>>... daoClasses) {
        //...
    }

    /**
     * 通過反射,刪除要更新的表
     */
    private void deleteOriginalTables(Database db, Class<? extends AbstractDao<?, ?>>... daoClasses){
        for (Class<? extends AbstractDao<?, ?>> daoClass : daoClasses) {
            try {
                Method method = daoClass.getMethod("dropTable", Database.class, boolean.class);
                method.invoke(null, db, true);
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            } catch (InvocationTargetException e) {
                e.printStackTrace();
            } catch (NoSuchMethodException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 通過反射,重新建立要更新的表
     */
    private void createOrignalTables(Database db, Class<? extends AbstractDao<?, ?>>... daoClasses){
        for (Class<? extends AbstractDao<?, ?>> daoClass : daoClasses) {
            try {
                Method method = daoClass.getMethod("createTable", Database.class, boolean.class);
                method.invoke(null, db, false);
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            } catch (InvocationTargetException e) {
                e.printStackTrace();
            } catch (NoSuchMethodException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 恢復資料
     */
    private void restoreData(Database db, Class<? extends AbstractDao<?, ?>>... daoClasses) {
        //...
    }

    private String getTypeByClass(Class<?> type) throws Exception {
        //...
    }

    private static List<String> getColumns(Database db, String tableName) {
        //...
    }
}

上面,我們通過反射,成功的做到了只 刪除備份 你傳入的表,其它不需要更新的表不需要關心。

有關反射的知識,可以參考我的另外兩篇有關反射的博文:

至此,有關資料庫的更新方案全部介紹完了,最後我們再看看上面遺留的問題:跨版本升級

4 跨版本升級

升級資料庫時,我們無法保證使用者每一版本都會及時更新,可能會跨版本升級,所以一般在 MyOpenHelperonUpgrade() 的方法中,我們不能直接忽視資料庫版本,像上面那樣直接將更新語句懟上去。

這一塊就不細說了,下面給出我跨版本升級的方案。假設即將發出去的應用資料庫版本為 7,則之前每一版本資料庫的變動如下所示。當然,這不是在某一版寫的,而是在升級過程中慢慢加上去的:

@Override
public void onUpgrade(Database db, int oldVersion, int newVersion) {
    super.onUpgrade(db, oldVersion, newVersion);
    //判斷之前的版本
    switch (oldVersion){
        case 1:
            // 無變動
        case 2:
            //新增 VideoCache 表
            VideoCacheDao.createTable(db, false);
        case 3:
        case 4:
        case 5:
            //新增 User 表
            UserDao.createTable(db, false);
        case 6:
            //更新 VideoCache 表字段
            MigrationHelper.getInstance().migrate(db, VideoCacheDao.class);
            //更新 User 表字段
            MigrationHelper.getInstance().migrate(db, UserDao.class);
    }
}

如果你對跨版本升級還不是很瞭解,上面的方案理解起來可能會比較困難,建議你多看幾遍。

總之,在版本 迭代過程中:

  • 資料庫升級的每一個 version 號都要出現在 case 中;
  • 而且中途不要有 break

這樣才能確保使用者跨版本升級不會出現問題。

以上就是本次分享全部內容,若有任何不當之處,還請指教。

掃描下方二維碼,關注我的公眾號,及時獲取最新文章推送!