九月時 EH Redux 0.6 終於發布了,這個版本最主要的改進就是下載功能,之所以 0.5 和 0.6 之間隔了這麼久,其實是因為我花了一些時間重寫了幾乎全部的程式碼,前景(foreground)和背景(background)之間的資料同步也讓我卡關了很久。
Moor
我目前使用 Moor 做為 ORM,這個 library 的預設使用方式是在前景連接資料庫,在大多數情況下不會有效能問題,是最簡單的使用方式。
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final dir = await getApplicationDocumentsDirectory();
final file = File(join(dir.path, 'db.sqlite'));
return VmDatabase(file);
});
}
一開始實作下載功能時,我是在前景和背景分別連接資料庫,雖然兩方都可以正常讀取和寫入資料,但是無法監聽資料變動,而且如果同時寫入同一筆資料的話,可能會引發 lock。
以我的例子來說,當背景正在下載時,雖然可以正常把進度寫入到資料庫,但是前景不會觸發更新;當前景暫停或取消下載時,如果背景作業也剛好正在寫入下載進度的話,則會造成死鎖。
關於這個問題,在 GitHub 上有相關的 issue 討論,結論是,如果前景和背景同時執行 migration 的話,可能會造成資料不一致。如果背景確定不會執行 migration,且背景不會干涉前景的話,那就無須特別處理,前景和背景各自連接資料庫即可,否則需要利用 SendPort
/ ReceivePort
讓前景和背景共用同一個 MoorIsolate
。
Isolate 之間的通訊
在 Dart 裡面,所有的程式都在 Isolate
裡執行,不同的 Isolate
之間如果要通訊的話,就要透過 ReceivePort
/SendPort
來傳送和接收訊息。除此之外,Dart 還有另一個 IsolateNameServer
class,用來註冊 global 的 SendPort
。
舉例來說,假設有兩個 isolate 要通訊,其中一個是接收端,另一個則是發送端。
首先接收端要先建立一個 ReceivePort
,然後在 IsolateNameServer
註冊 ReceivePort.sendPort
。這裡要注意的是,如果 port 已經被註冊的話,必須要先移除原本註冊的 port,否則新註冊的 port 不會覆蓋掉原本舊的 port。
final receivePort = ReceivePort();
receivePort.listen((msg) {
// Message received
});
// 如果要覆蓋的話,必須要先移除原本註冊的 port
// IsolateNameServer.removePortNameMapping('example');
IsolateNameServer.registerPortWithName(receivePort.sendPort, 'example');
註冊完成後,發送端就可以用指定的名稱來搜尋已註冊的 port。
final port = IsolateNameServer.lookupPortByName('example');
port?.send('ping');
改用 Isolate 連接資料庫
首先,必須讓 Moor 產生 Isolate 相關的程式碼,在專案根目錄的 build.yaml
新增以下內容後,重跑 flutter pub run build_runner build
即可。
targets:
$default:
builders:
moor_generator:
options:
generate_connect_constructor: true
接著要改寫 Database class。
// 這個 class 用來包裝要傳到 isolate 的資料
class _Request {
_Request(this.sendPort, this.targetPath);
final SendPort sendPort;
final String targetPath;
}
void _startBackground(_Request request) {
// 建立新的 VmDatabase
final executor = VmDatabase(File(request.targetPath));
// 因為目前的函數已經在背景 isolate 執行了,所以這邊直接讓 Moor 在目前的 isolate 啟動
final moorIsolate = MoorIsolate.inCurrent(
() => DatabaseConnection.fromExecutor(executor),
);
// 把 moorIsolate 回傳給 sendPort
request.sendPort.send(moorIsolate);
}
Future<MoorIsolate> _createMoorIsolate() async {
// 資料庫檔案的路徑
final dir = await getApplicationDocumentsDirectory();
final path = join(dir.path, 'db.sqlite');
// 建立新的 ReceivePort
final receivePort = ReceivePort();
// 在新的 isolate 裡執行 _startBackground
await Isolate.spawn(
_startBackground,
_Request(receivePort.sendPort, path),
);
// 等待 receivePort 回傳的 MoorIsolate
return await receivePort.first as MoorIsolate;
}
@UseMoor()
class Database extends _$Database {
// 這個新的 factory 函數用來從 DatabaseConnection 產生 Database instance
Database.connect(DatabaseConnection connection) : super.connect(connection);
}
Future<void> main() async {
final isolate = await _createMoorIsolate();
final db = Database.connect(await isolate.connect());
// 現在可以照常使用 db 了
}
共用資料庫連接
為了要讓背景能夠共用前景的資料庫連接,我在前景資料庫連接成功後,註冊一個 ReceivePort
用來傳送 MoorIsolate.connectPort
。
const _requestPortName = 'database.request';
const _instancePortName = 'database.instance';
void shareIsolate(MoorIsolate isolate) {
// 建立一個 ReceivePort
final requestPort = ReceivePort();
// 監聽 requestPort 的事件,當接收到事件時,把 connectPort 回傳給 instancePort
requestPort.listen((message) {
final instancePort =
IsolateNameServer.lookupPortByName(_instancePortName);
instancePort?.send(isolate.connectPort);
});
// 移除先前註冊的 requestPort
IsolateNameServer.removePortNameMapping(_requestPortName);
// 註冊 requestPort
IsolateNameServer.registerPortWithName(
requestPort.sendPort, _requestPortName);
}
背景方面則是先去尋找前景註冊的 port,如果有的話就對該 port 發送事件並等待回傳的 connectPort
,否則就建立一個新的 MoorIsolate
。
Future<MoorIsolate> reuseIsolate() async {
// 尋找已註冊的 requestPort
final requestPort =
IsolateNameServer.lookupPortByName(_requestPortName);
if (requestPort == null) return null;
// 建立一個 ReceivePort 用來接收 connectPort
final instancePort = ReceivePort();
try {
// 註冊 instancePort
IsolateNameServer.registerPortWithName(
instancePort.sendPort, _instancePortName);
// 對 requestPort 發送事件
requestPort.send(null);
// 等待回傳的 connectPort
final connectPort = await instancePort.first as SendPort;
// 利用剛剛回傳的 connectPort 建立 MoorIsolate
return MoorIsolate.fromConnectPort(connectPort);
} finally {
// 最後,移除並關閉 instancePort
IsolateNameServer.removePortNameMapping(_instancePortName);
instancePort.close();
}
}