Coding Story

在黑暗中寫故事 👻

0%

Android Room Database 立馬上手就看這篇

前言故事

自從團隊重新打造新 App 開始,小唯就四處查看是否有可用的新技術,Android Jetpack 中的 Room 就是其中之一(話說那時候好像還不是 Part of Jetpack)。Room 是 Google 官方前幾年推出來的技術,官方也建議開發者使用 Room 而不直接使用 SQLite APIs。小唯的團隊當下就開始使用 Room 來取代原本的 SQLite 程式碼,當然途中一定遇到了不少大小問題,也是小唯紀錄這幾篇的主因之一,希望避免再有人浪費時間在這些問題上。

(如何從現有的 db 檔來建立在 2.2 版 Room 已有支援,可查看這篇官方文章。當初小唯團隊使用時可還沒有,那時是使用 humazed/RoomAsset

建立 Room DB

要使用 Room,須先定義 database 中要包含哪些 table,裡面有哪些動作可以執行等,例:

AppDatabase.java
1
2
3
4
5
@Database(entities = {SearchHistoryEntity.class}, version = 1)
@TypeConverters({RoomConverter.class})
public abstract class AppDatabase extends RoomDatabase {
public abstract SearchHistoryDao searchHistoryDao();
}

@Database(entities = {SearchHistoryEntity.class}, version = 1) 定義這個 database 內含有什麼 table,和 table 中有什麼欄位。

@TypeConverters({RoomConverter.class}) 定義當 Room 遇到不能識別的類型,該要如何做轉換。

SearchHistoryDao 定義 database 支援什麼類型的 database 動作,像是新增、刪除、讀取,通常一個動作就是一個 sqlite statement。

定義 Entity 資料

Entity 將相關的資料欄位合併在一起,要比擬的話可以看做 sqlite 中的 table row 。先來看 SearchHistoryEntity.java,這個 entity 定義了搜尋紀錄的資料,內容很直覺的。這邊有特地舉出使用三個非 sqlite primitive 的欄位:

SearchHistoryEntity.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
@Entity(tableName = "search_history")
public class SearchHistoryEntity {

public enum TYPE {
TYPE_ONE,
TYPE_TWO,
TYPE_THREE,
}

// 像是 table 中的 _id 欄位
@PrimaryKey(autoGenerate = true)
private int uid;

// enum 類型,需透過 Room converter 來轉換,後面會接受
@ColumnInfo(name = "type")
private TYPE type;

// 一般 String,沒什麼特別的
@ColumnInfo(name = "display_text")
private String displayText;

// 透過 JSON dictionary 來把文字跟 Map 互相轉換
@ColumnInfo(name = "query_string")
private Map<String, String> queryString;

// ISO 8601 的日期格式
@ColumnInfo(name = "date")
private String date;

public SearchHistoryEntity(
TYPE type,
String displayText,
Map<String, String> queryString,
String date) {
this.type = type;
this.displayText = displayText;
this.queryString = queryString;
this.date = date;
}

// Getters and setters are required for Room to work.
public int getUid() {
return uid;
}

public void setUid(int uid) {
this.uid = uid;
}

public TYPE getType() {
return type;
}

public void setType(TYPE type) {
this.type = type;
}

public String getDisplayText() {
return displayText;
}

public void setDisplayText(String displayText) {
this.displayText = displayText;
}

public Map<String, String> getQueryString() {
return queryString;
}

public void setQueryString(Map<String, String> queryString) {
this.queryString = new HashMap<>(queryString);
}

public String getDate() {
return date;
}

public void setDate(String date) {
this.date = date;
}
}

DAO 定義資料操作方式

有了資料格式與轉換方式,接著需要的就是定義 DAO (Data Access Object),這步將原本 sqlite 中會使用的 sqlite statement 轉為 API。依照 Room 的方式,只需把 sqlite statement 放在 annotation 上:

SearchHistoryDao.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@Dao
public interface SearchHistoryDao {

/**
* 取得所有 Search History 紀錄,依時間先後順序排序
*/
@Query("SELECT * FROM search_history order by timestamp desc")
Flowable<List<SearchHistoryEntity>> getAll();

/**
* 取得某幾筆 Search History 紀錄,依時間先後順序排序
*/
@Query("SELECT * FROM search_history WHERE uid IN (:ids) order by timestamp desc")
Flowable<List<SearchHistoryEntity>> getByIds(int[] ids);

/**
* 取得某特定類型的 Search History 紀錄,依時間先後順序排序
*/
@Query("SELECT * FROM search_history WHERE type = :type order by timestamp desc")
Flowable<List<SearchHistoryEntity>> getByType(SearchHistoryEntity.TYPE type);

/**
* 取得某特定類型的 Search History 紀錄,指定最多取得的筆數,依時間先後順序排序
*/
@Query("SELECT * FROM search_history WHERE type = :type order by timestamp desc LIMIT :limit")
Flowable<List<SearchHistoryEntity>> getByTypeWithLimit(SearchHistoryEntity.TYPE type, int limit);

/**
* 新增 Search History 至 Room
*/
@Insert
Long[] insertAll(SearchHistoryEntity... searchHistories);

/**
* 刪除某筆 Search History
*/
@Delete
int delete(SearchHistoryEntity searchHistory);

/**
* 刪除所有 Search History
*/
@Query("DELETE FROM search_history")
int clearTable();
}

使用 Converter 來互換資料類型

如果資料中有使用到一些非基本的資料類型,像是 Text、Integer、Numeric、Blob 等,就需要用 Converter 協助轉換程式中的物件到 Room DB 的資料結構(上述的那幾種),例如說,程式中可能直接使用 Date 物件,但如果要存到 Room DB 就需要轉換為像 2021-02-22T18:01Z 的 String。

來看看 RoomConverter.java 怎麼樣把 Map 轉 Json 存入、Enum 物件轉為 int 存入、還有 Date 物件轉為 ISO 8601 String 存入:

RoomConverter.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
public class RoomConverter {

private static final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME;

/**
* 將 Map 轉為 Json,並存入 Room
*/
@TypeConverter
public static String mapToJson(Map<String, String> params) {
return new JSONObject(params).toString();
}

/**
* 把 Json 從 Room 讀取出,並轉換為 Map 給 App 使用
*/
@TypeConverter
public static Map<String, String> jsonToMap(String json) {
if (TextUtils.isEmpty(json)) {
return Collections.emptyMap();
}
Gson gson = new Gson();
Type type = new TypeToken<Map<String, String>>() {}.getType();
return gson.fromJson(json, type);
}

/**
* 把 App 中的 Enum 物件轉為 Room 可服用的格式
*/
@TypeConverter
public static int searchHistoryTypeToInt(SearchHistoryEntity.TYPE type) {
return type.ordinal();
}

/**
* 把 Room 的資料轉為 App 可識得的 Enum 物件
*/
@TypeConverter
public static SearchHistoryEntity.TYPE intToSearchHistoryType(int type) {
return SearchHistoryEntity.TYPE.values()[type];
}

/**
* 把 Room 的 ISO 8601 的日期字串轉為 OffsetDateTime 物件
*/
@TypeConverter
public static OffsetDateTime toOffsetDateTime(String offsetDateTime) {
if (!TextUtils.isEmpty(offsetDateTime)) {
return OffsetDateTime.from(dateTimeFormatter.parse(offsetDateTime));
} else {
return null;
}
}

/**
* 把 OffsetDateTime 物件轉為 Room 可服用的 ISO 8601 的日期字串
*/
@TypeConverter
public static String fromOffsetDateTime(OffsetDateTime dateTime) {
return dateTimeFormatter.format(dateTime);
}
}

設定 sqlite trigger

以往用 sqlite 時,常會定義一些自動化 trigger 來調整資料內容,但是換作 Room 後,使用的方法就有點不太一樣。新的方式要定義在 AppDatabase.java 中,插入 callback 在 DB 建立時獲取通知並建立 trigger:

AppDatabase.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Database(entities = {SearchHistoryEntity.class}, version = 1)
@TypeConverters({RoomConverter.class})
public abstract class AppDatabase extends RoomDatabase {

public abstract SearchHistoryDao searchHistoryDao();

public static AppDatabase getAppDatabase(Context context) {
if (sInstance == null) {
RoomDatabase.Builder<AppDatabase> builder =
Room.databaseBuilder(context.getApplicationContext(), AppDatabase.class, "userdata");
builder.addCallback(callback);
sInstance = builder.build();
}
return sInstance;
}

// 可利用 callback 取得 DB 建立的時機,在建立時一併建立 trigger
private static Callback callback =
new Callback() {
@Override
public void onCreate(@NonNull SupportSQLiteDatabase db) {
super.onCreate(db);

// 最多保留 200 筆搜尋紀錄,每當有新搜尋紀錄新增,就刪除 200 筆後的資料
db.execSQL(
"CREATE TRIGGER IF NOT EXISTS truncate_search_history AFTER INSERT ON search_history BEGIN "
+ "delete from search_history where timestamp < "
+ " (select timestamp from search_history order by timestamp desc limit 1 offset 200);"
+ "END;");
}

@Override
public void onOpen(@NonNull SupportSQLiteDatabase db) {
super.onOpen(db);
}
};

}

心得

小唯團隊開始使用 Room 時,那時技術還很新,很多功能支援不是很完善,像是不支援 prepolutate 資料庫。但這些日子下來,可以看到 Room 的支援度越來越好,不但加入了 Android Jetpack,更新也很頻繁。下次專案上如有需要,應該還是會推薦團隊使用。

------------- 本文结束 話說為什麼叫做 Room~ -------------