Flutter + Kotlin Multiplatform, Write Once Run Anywhere

本文同步自我的博客Flutter + Kotlin Multiplatform, Write Once Run Anywhere,轉載請註明出處。html

Motivation

Flutter是Google 2017年推出的跨平臺框架,擁有Fast DevelopmentExpressive and Flexible UINative Performance等特色。Flutter使用Dart做爲開發語言,Android和iOS項目能夠共用一套Dart代碼,不少人火燒眉毛的嘗試,包括我,但在學習的過程當中,同時在思考如下的問題:java

  • Flutter很優秀,但相對來講還比較新,目前並非全部的第三方SDK支持Flutter(特別是在國內),因此在使用第三方SDK時不少時候須要咱們編寫原生代碼集成邏輯,須要Android和iOS分別編寫不一樣的集成代碼。android

  • 項目要集成Flutter,一次性替換全部頁面有點不太實際,可是部分頁面集成的時候,會面臨須要將數據庫操做等公用邏輯使用Dart重寫一遍的問題,由於原生的邏輯在其餘的頁面也須要用到,沒辦法作到只保留Dart的實現代碼,因此很容易出現一套邏輯須要提供不一樣平臺的實現如:Dao.ktDao.swiftDao.dart。固然可使用Flutter提供的MethodChannel/FlutterMethodChannel來直接調用原生代碼的邏輯,可是若是數據庫操做邏輯須要修改的時候,咱們依然要同時修改不一樣平臺的代碼邏輯。ios

  • 項目組裏有內部的SDK,同時提供給不一樣項目(Android和iOS)使用,可是一些App須要集成Flutter,就須要SDK分別提供Flutter/Android/iOS的代碼實現,這時須要同時維護三個SDK反而增長了SDK維護者的維護和實現成本。git

因此,最後能夠把問題歸結爲原生代碼沒法複用,致使咱們須要爲不一樣平臺提供同一代碼邏輯實現。那麼有沒有能讓原生代碼複用的框架,答案是確定的,Kotlin Multiplatform是Kotlin的一個功能(目前還在實驗性階段),其目標就是使用Kotlin:Sharing code between platformsgithub

因而我有一個大膽的想法,同時使用Flutter和Kotlin Multiplatform,雖然使用不一樣的語言(Dart/Kotlin),但不一樣平臺共用一套代碼邏輯實現。使用Kotlin Multiplatform編寫公用邏輯,而後在Android/iOS上使用MethodChannel/FlutterMethodChannel供Flutter調用公用邏輯。sql

kmpp+flutter

接下來以實現公用的數據庫操做邏輯爲例,來簡單描述如何使用Flutter和Kotlin Multiplatform達到Write Once Run Anywhere數據庫

接下來的內容須要讀者對Flutter和Kotlin Multiplatform有所瞭解。json

Kotlin Multiplatform

咱們使用Sqldelight實現公用的數據庫操做邏輯,而後經過kotlinx.serialization把查詢結果序列化爲json字符串,經過MethodChannel/FlutterMethodChannel傳遞到Flutter中使用。小程序

Flutter的目錄結構以下面所示:

|
|__android
|  |__app
|__ios
|__lib
|__test
複製代碼

其中android目錄下是一個完整的Gradle項目,參照官方文檔Multiplatform Project: iOS and Android,咱們在android目錄下建立一個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
複製代碼

實現AccountingRepository

common module下建立commonMain目錄,並在commonMain目錄下建立AccountingRepository類用於封裝數據庫操做邏輯(這裏不須要關心代碼實現細節,只是簡單的查詢數據庫結果,而後序列化爲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)
  }
}
複製代碼

到這裏咱們已經實現了公用的數據庫操做邏輯,可是爲了Android/iOS更加簡單的調用數據庫操做邏輯,咱們把MethodChannel#setMethodCallHandler/FlutterMethodChannel#setMethodCallHandler中的調用邏輯進行簡單的封裝:

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#setMethodHandlerResultFlutterMethodChannel#setMethodHandlerFlutterResult對象不同,因此咱們在SqlDelightManager#methodCall定義result function以回調的形式讓外部處理。

在Android使用SqlDelightManager

在Android項目使用SqlDelightManager,參考官方文檔Multiplatform Project: iOS and Android,咱們須要先在app目錄下添加對common module的依賴:

implementation project(":common")
複製代碼

參照官方文檔Writing custom platform-specific code,咱們在MainActivity實現MethodChannel並調用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

繼續參考Multiplatform Project: iOS and Android,讓Xcode項目識別common module的代碼,主要把common module生成的frameworks添加Xcode項目中,我簡單總結爲如下步驟:

  • 運行./gradlew :common:build,生成iOS frameworks
  • General -> 添加Embedded Binaries
  • Build Setting -> 添加Framework Search Paths
  • Build Phases -> 添加Run Script

有一點跟官方文檔不一樣的是,frameworks的存放目錄不同,由於Flutter項目結構把android項目的build文件路徑放到根目錄,因此frameworks的路徑應該是$(SRCROOT)/../build/xcode-frameworks。能夠查看android/build.gradle:

rootProject.buildDir = '../build'
subprojects {
    project.buildDir = "${rootProject.buildDir}/${project.name}"
}
複製代碼

這幾步完成以後就能夠在Swift裏面調用common module的Kotlin代碼了。參照官方文檔Writing custom platform-specific code,咱們在AppDelegate.swift實現FlutterMethodChannel並調用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語法不一樣,咱們調用的是同一方法SqlDelightManager#methodCall,並不須要分別在Android/iOS上實現同一套邏輯。

到這裏咱們已經使用了Kotlin Multiplatform實現原生代碼複用了,而後咱們只需在Flutter使用MethodChannel調用相應的方法就能夠了。

Flutter

一樣的咱們在Flutter中也實現AccountingRepository類封裝數據庫操做邏輯:

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來調用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) {
                ...
              },
            ),
          )
        ],
      ),
    );
  }
}

複製代碼

完結撒花,最後咱們來看看項目的運行效果:

Android iOS
android
ios

Unit Test

爲了保證代碼質量和邏輯正確性Unit Test是必不可少的,對於common module代碼,咱們只要在commonTest中寫一套Unit Test就能夠了,固然有時候咱們須要爲不一樣平臺編寫不一樣的測試用例。在Demo裏我主要使用MockK來mock數據,可是遇到一些問題,在Kotlin/Native沒法識別MockK的引用。對於這個問題,我提了一個issue,目前還在處理中。

TL;DR

跨平臺這個話題在如今已是老生常談了,不少公司不少團隊都但願使用跨平臺技術來提升開發效率,下降人力成本,但開發的過程當中會發現踩的坑愈來愈多,不少時候並無達到當初的預期,我的認爲跨平臺的最大目標是代碼複用,Write Once Run Anywhere,讓多端的開發者共同實現和維護同一代碼邏輯,減小溝通致使實現的差別和多端代碼實現致使的差別,使代碼更加健壯便於維護。

本文簡單演示瞭如何使用Flutter和Kotlin Multiplatform來達到Write Once Run Anywhere的效果。我的認爲Kotlin Multiplatform有很大的前景,Kotlin Multiplatform還支持JS平臺,因此公用的代碼理論上還能提供給小程序使用(但願有機會驗證這個猜測)。在今年的Google IO上Google發佈了下一代UI開發框架Jetpack Compose,蘋果開發者大會上蘋果爲咱們帶來了SwiftUI,這意味着若是把這2個框架的API統一塊兒來,咱們可使用Kotlin來編寫擁有Native性能的跨平臺的代碼。Demo已經上傳到github,感興趣的能夠clone下來研究(雖然寫的很爛)。有問題能夠在github上提issue。Have Fun!

相關文章
相關標籤/搜索