本文同步自個人博客Flutter + Kotlin Multiplatform, Write Once Run Anywhere,轉(zhuǎn)載請注明出處。
Motivation
Flutter是Google 2017年推出的跨平臺框架,擁有Fast Development,Expressive and Flexible UI,Native Performance等特點。Flutter使用Dart作為開發(fā)語言,Android和iOS項目可以共用一套Dart代碼,很多人迫不及待的嘗試,包括我,但在學(xué)習(xí)的過程中,同時在思考以下的問題:
Flutter很優(yōu)秀,但相對來說還比較新,目前并不是所有的第三方SDK支持Flutter(特別是在國內(nèi)),所以在使用第三方SDK時很多時候需要我們編寫原生代碼集成邏輯,需要Android和iOS分別編寫不同的集成代碼。
項目要集成Flutter,一次性替換所有頁面有點不太實際,但是部分頁面集成的時候,會面臨需要將數(shù)據(jù)庫操作等公用邏輯使用Dart重寫一遍的問題,因為原生的邏輯在其他的頁面也需要用到,沒辦法做到只保留Dart的實現(xiàn)代碼,所以很容易出現(xiàn)一套邏輯需要提供不同平臺的實現(xiàn)如:
Dao.kt,Dao.swift,Dao.dart。當(dāng)然可以使用Flutter提供的MethodChannel/FlutterMethodChannel來直接調(diào)用原生代碼的邏輯,但是如果數(shù)據(jù)庫操作邏輯需要修改的時候,我們依然要同時修改不同平臺的代碼邏輯。項目組里有內(nèi)部的SDK,同時提供給不同項目(Android和iOS)使用,但是一些App需要集成Flutter,就需要SDK分別提供Flutter/Android/iOS的代碼實現(xiàn),這時需要同時維護(hù)三個SDK反而增加了SDK維護(hù)者的維護(hù)和實現(xiàn)成本。
所以,最后可以把問題歸結(jié)為原生代碼無法復(fù)用,導(dǎo)致我們需要為不同平臺提供同一代碼邏輯實現(xiàn)。那么有沒有能讓原生代碼復(fù)用的框架,答案是肯定的,Kotlin Multiplatform是Kotlin的一個功能(目前還在實驗性階段),其目標(biāo)就是使用Kotlin:Sharing code between platforms。
于是我有一個大膽的想法,同時使用Flutter和Kotlin Multiplatform,雖然使用不同的語言(Dart/Kotlin),但不同平臺共用一套代碼邏輯實現(xiàn)。使用Kotlin Multiplatform編寫公用邏輯,然后在Android/iOS上使用MethodChannel/FlutterMethodChannel供Flutter調(diào)用公用邏輯。
接下來以實現(xiàn)公用的數(shù)據(jù)庫操作邏輯為例,來簡單描述如何使用Flutter和Kotlin Multiplatform達(dá)到Write Once Run Anywhere。
接下來的內(nèi)容需要讀者對Flutter和Kotlin Multiplatform有所了解。
Kotlin Multiplatform
我們使用Sqldelight實現(xiàn)公用的數(shù)據(jù)庫操作邏輯,然后通過kotlinx.serialization把查詢結(jié)果序列化為json字符串,通過MethodChannel/FlutterMethodChannel傳遞到Flutter中使用。
Flutter的目錄結(jié)構(gòu)如下面所示:
|
|__android
| |__app
|__ios
|__lib
|__test
其中android目錄下是一個完整的Gradle項目,參照官方文檔Multiplatform Project: iOS and Android,我們在android目錄下創(chuàng)建一個common module,來存放公用的代碼邏輯。
Gradle腳本
apply plugin: 'org.jetbrains.kotlin.multiplatform'
apply plugin: 'com.squareup.sqldelight'
apply plugin: 'kotlinx-serialization'
sqldelight {
AccountingDB {
packageName = "com.littlegnal.accountingmultiplatform"
}
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation deps.kotlin.stdlib.stdlib
implementation deps.kotlin.serialiaztion.runtime.common
implementation deps.kotlin.coroutines.common
}
androidMain.dependencies {
implementation deps.kotlin.stdlib.stdlib
implementation deps.sqldelight.runtimejvm
implementation deps.kotlin.serialiaztion.runtime.runtime
implementation deps.kotlin.coroutines.android
}
iosMain.dependencies {
implementation deps.kotlin.stdlib.stdlib
implementation deps.sqldelight.driver.ios
implementation deps.kotlin.serialiaztion.runtime.native
implementation deps.kotlin.coroutines.native
}
}
targets {
fromPreset(presets.jvm, 'android')
final def iOSTarget = System.getenv('SDK_NAME')?.startsWith("iphoneos") \
? presets.iosArm64 : presets.iosX64
fromPreset(iOSTarget, 'ios') {
binaries {
framework('common')
}
}
}
}
// workaround for https://youtrack.jetbrains.com/issue/KT-27170
configurations {
compileClasspath
}
task packForXCode(type: Sync) {
final File frameworkDir = new File(buildDir, "xcode-frameworks")
final String mode = project.findProperty("XCODE_CONFIGURATION")?.toUpperCase() ?: 'DEBUG'
final def framework = kotlin.targets.ios.binaries.getFramework("common", mode)
inputs.property "mode", mode
dependsOn framework.linkTask
from { framework.outputFile.parentFile }
into frameworkDir
doLast {
new File(frameworkDir, 'gradlew').with {
text = "#!/bin/bash\nexport 'JAVA_HOME=${System.getProperty("java.home")}'\ncd '${rootProject.rootDir}'\n./gradlew \$@\n"
setExecutable(true)
}
}
}
tasks.build.dependsOn packForXCode
實現(xiàn)AccountingRepository
在common module下創(chuàng)建commonMain目錄,并在commonMain目錄下創(chuàng)建AccountingRepository類用于封裝數(shù)據(jù)庫操作邏輯(這里不需要關(guān)心代碼實現(xiàn)細(xì)節(jié),只是簡單的查詢數(shù)據(jù)庫結(jié)果,然后序列化為json字符串)。
class AccountingRepository(private val accountingDB: AccountingDB) {
private val json: Json by lazy {
Json(JsonConfiguration.Stable)
}
...
fun getMonthTotalAmount(yearAndMonthList: List<String>): String {
val list = mutableListOf<GetMonthTotalAmount>()
.apply {
for (yearAndMonth in yearAndMonthList) {
val r = accountingDB.accountingDBQueries
.getMonthTotalAmount(yearAndMonth)
.executeAsOneOrNull()
if (r?.total != null && r.yearMonth != null) {
add(r)
}
}
}
.map {
it.toGetMonthTotalAmountSerialization()
}
return json.stringify(GetMonthTotalAmountSerialization.serializer().list, list)
}
fun getGroupingMonthTotalAmount(yearAndMonth: String): String {
val list = accountingDB.accountingDBQueries
.getGroupingMonthTotalAmount(yearAndMonth)
.executeAsList()
.map {
it.toGetGroupingMonthTotalAmountSerialization()
}
return json.stringify(GetGroupingMonthTotalAmountSerialization.serializer().list, list)
}
}
到這里我們已經(jīng)實現(xiàn)了公用的數(shù)據(jù)庫操作邏輯,但是為了Android/iOS更加簡單的調(diào)用數(shù)據(jù)庫操作邏輯,我們把MethodChannel#setMethodCallHandler/FlutterMethodChannel#setMethodCallHandler中的調(diào)用邏輯進(jìn)行簡單的封裝:
const val SQLDELIGHT_CHANNEL = "com.littlegnal.accountingmultiplatform/sqldelight"
class SqlDelightManager(
private val accountingRepository: AccountingRepository
) : CoroutineScope {
...
fun methodCall(method: String, arguments: Map<String, Any>, result: (Any) -> Unit) {
launch(coroutineContext) {
when (method) {
...
"getMonthTotalAmount" -> {
@Suppress("UNCHECKED_CAST") val yearAndMonthList: List<String> =
arguments["yearAndMonthList"] as? List<String> ?: emptyList()
val r = accountingRepository.getMonthTotalAmount(yearAndMonthList)
result(r)
}
"getGroupingMonthTotalAmount" -> {
val yearAndMonth: String = arguments["yearAndMonth"] as? String ?: ""
val r = accountingRepository.getGroupingMonthTotalAmount(yearAndMonth)
result(r)
}
}
}
}
}
因為MethodChannel#setMethodHandler中Result和FlutterMethodChannel#setMethodHandler中FlutterResult對象不一樣,所以我們在SqlDelightManager#methodCall定義result function以回調(diào)的形式讓外部處理。
在Android使用SqlDelightManager
在Android項目使用SqlDelightManager,參考官方文檔Multiplatform Project: iOS and Android,我們需要先在app目錄下添加對common module的依賴:
implementation project(":common")
參照官方文檔Writing custom platform-specific code,我們在MainActivity實現(xiàn)MethodChannel并調(diào)用SqlDelightManager#methodCall:
class MainActivity: FlutterActivity() {
private val sqlDelightManager by lazy {
val accountingRepository = AccountingRepository(Db.getInstance(applicationContext))
SqlDelightManager(accountingRepository)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
GeneratedPluginRegistrant.registerWith(this)
MethodChannel(flutterView, SQLDELIGHT_CHANNEL).setMethodCallHandler { methodCall, result ->
@Suppress("UNCHECKED_CAST")
val args = methodCall.arguments as? Map<String, Any> ?: emptyMap()
sqlDelightManager.methodCall(methodCall.method, args) {
result.success(it)
}
}
}
...
}
在iOS使用SqlDelightManager
繼續(xù)參考Multiplatform Project: iOS and Android,讓Xcode項目識別common module的代碼,主要把common module生成的frameworks添加Xcode項目中,我簡單總結(jié)為以下步驟:
- 運(yùn)行
./gradlew :common:build,生成iOS frameworks - General -> 添加Embedded Binaries
- Build Setting -> 添加Framework Search Paths
- Build Phases -> 添加Run Script
有一點跟官方文檔不同的是,frameworks的存放目錄不一樣,因為Flutter項目結(jié)構(gòu)把android項目的build文件路徑放到根目錄,所以frameworks的路徑應(yīng)該是$(SRCROOT)/../build/xcode-frameworks??梢圆榭?code>android/build.gradle:
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
這幾步完成之后就可以在Swift里面調(diào)用common module的Kotlin代碼了。參照官方文檔Writing custom platform-specific code,我們在AppDelegate.swift實現(xiàn)FlutterMethodChannel并調(diào)用SqlDelightManager#methodCall(Swift代碼全是靠Google搜出來的XD):
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
lazy var sqlDelightManager: SqlDelightManager = {
Db().defaultDriver()
let accountingRepository = AccountingRepository(accountingDB: Db().instance)
return SqlDelightManager(accountingRepository: accountingRepository)
}()
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?
) -> Bool {
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
let sqlDelightChannel = FlutterMethodChannel(
name: SqlDelightManagerKt.SQLDELIGHT_CHANNEL,
binaryMessenger: controller)
sqlDelightChannel.setMethodCallHandler({
[weak self] (methodCall: FlutterMethodCall, flutterResult: @escaping FlutterResult) -> Void in
let args = methodCall.arguments as? [String: Any] ?? [:]
self?.sqlDelightManager.methodCall(
method: methodCall.method,
arguments: args,
result: {(r: Any) -> KotlinUnit in
flutterResult(r)
return KotlinUnit()
})
})
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
...
}
可以看到,除了MethodChannel/FlutterMethodChannel對象不同以及Kotlin/Swift語法不同,我們調(diào)用的是同一方法SqlDelightManager#methodCall,并不需要分別在Android/iOS上實現(xiàn)同一套邏輯。
到這里我們已經(jīng)使用了Kotlin Multiplatform實現(xiàn)原生代碼復(fù)用了,然后我們只需在Flutter使用MethodChannel調(diào)用相應(yīng)的方法就可以了。
Flutter
同樣的我們在Flutter中也實現(xiàn)AccountingRepository類封裝數(shù)據(jù)庫操作邏輯:
class AccountingRepository {
static const _platform =
const MethodChannel("com.littlegnal.accountingmultiplatform/sqldelight");
...
Future<BuiltList<TotalExpensesOfMonth>> getMonthTotalAmount(
[DateTime latestMonth]) async {
var dateTime = latestMonth ?? DateTime.now();
var yearMonthList = List<String>();
for (var i = 0; i <= 6; i++) {
var d = DateTime(dateTime.year, dateTime.month - i, 1);
yearMonthList.add(_yearMonthFormat.format(d));
}
var arguments = {"yearAndMonthList": yearMonthList};
var result = await _platform.invokeMethod("getMonthTotalAmount", arguments);
return deserializeListOf<TotalExpensesOfMonth>(jsonDecode(result));
}
Future<BuiltList<TotalExpensesOfGroupingTag>> getGroupingTagOfLatestMonth(
DateTime latestMonth) async {
return getGroupingMonthTotalAmount(latestMonth);
}
Future<BuiltList<TotalExpensesOfGroupingTag>> getGroupingMonthTotalAmount(
DateTime dateTime) async {
var arguments = {"yearAndMonth": _yearMonthFormat.format(dateTime)};
var result =
await _platform.invokeMethod("getGroupingMonthTotalAmount", arguments);
return deserializeListOf<TotalExpensesOfGroupingTag>(jsonDecode(result));
}
}
簡單使用BLoC來調(diào)用AccountingRepository的方法:
class SummaryBloc {
SummaryBloc(this._db);
final AccountingRepository _db;
final _summaryChartDataSubject =
BehaviorSubject<SummaryChartData>.seeded(...);
final _summaryListSubject =
BehaviorSubject<BuiltList<SummaryListItem>>.seeded(BuiltList());
Stream<SummaryChartData> get summaryChartData =>
_summaryChartDataSubject.stream;
Stream<BuiltList<SummaryListItem>> get summaryList =>
_summaryListSubject.stream;
...
Future<Null> getGroupingTagOfLatestMonth({DateTime dateTime}) async {
var list =
await _db.getGroupingTagOfLatestMonth(dateTime ?? DateTime.now());
_summaryListSubject.sink.add(_createSummaryList(list));
}
Future<Null> getMonthTotalAmount({DateTime dateTime}) async {
...
var result = await _db.getMonthTotalAmount(dateTime);
...
_summaryChartDataSubject.sink.add(...);
}
...
在Widget中使用BLoC:
class SummaryPage extends StatefulWidget {
@override
State<StatefulWidget> createState() => _SummaryPageState();
}
class _SummaryPageState extends State<SummaryPage> {
final _summaryBloc = SummaryBloc(AccountingRepository.db);
...
@override
Widget build(BuildContext context) {
return Scaffold(
...
body: Column(
children: <Widget>[
Divider(
height: 1.0,
),
Container(
color: Colors.white,
padding: EdgeInsets.only(bottom: 10),
child: StreamBuilder(
stream: _summaryBloc.summaryChartData,
builder: (BuildContext context,
AsyncSnapshot<SummaryChartData> snapshot) {
...
},
),
),
Expanded(
child: StreamBuilder(
stream: _summaryBloc.summaryList,
builder: (BuildContext context,
AsyncSnapshot<BuiltList<SummaryListItem>> snapshot) {
...
},
),
)
],
),
);
}
}
完結(jié)撒花,最后我們來看看項目的運(yùn)行效果:
| Android | iOS |
|---|---|
android
|
ios
|
Unit Test
為了保證代碼質(zhì)量和邏輯正確性Unit Test是必不可少的,對于common module代碼,我們只要在commonTest中寫一套Unit Test就可以了,當(dāng)然有時候我們需要為不同平臺編寫不同的測試用例。在Demo里我主要使用MockK來mock數(shù)據(jù),但是遇到一些問題,在Kotlin/Native無法識別MockK的引用。對于這個問題,我提了一個issue,目前還在處理中。
TL;DR
跨平臺這個話題在現(xiàn)在已經(jīng)是老生常談了,很多公司很多團(tuán)隊都希望使用跨平臺技術(shù)來提高開發(fā)效率,降低人力成本,但開發(fā)的過程中會發(fā)現(xiàn)踩的坑越來越多,很多時候并沒有達(dá)到當(dāng)初的預(yù)期,個人認(rèn)為跨平臺的最大目標(biāo)是代碼復(fù)用,Write Once Run Anywhere,讓多端的開發(fā)者共同實現(xiàn)和維護(hù)同一代碼邏輯,減少溝通導(dǎo)致實現(xiàn)的差異和多端代碼實現(xiàn)導(dǎo)致的差異,使代碼更加健壯便于維護(hù)。
本文簡單演示了如何使用Flutter和Kotlin Multiplatform來達(dá)到Write Once Run Anywhere的效果。個人認(rèn)為Kotlin Multiplatform有很大的前景,Kotlin Multiplatform還支持JS平臺,所以公用的代碼理論上還能提供給小程序使用(希望有機(jī)會驗證這個猜想)。在今年的Google IO上Google發(fā)布了下一代UI開發(fā)框架Jetpack Compose,蘋果開發(fā)者大會上蘋果為我們帶來了SwiftUI,這意味著如果把這2個框架的API統(tǒng)一起來,我們可以使用Kotlin來編寫擁有Native性能的跨平臺的代碼。Demo已經(jīng)上傳到github,感興趣的可以clone下來研究(雖然寫的很爛)。有問題可以在github上提issue。Have Fun!