Flutter + Kotlin Multiplatform, Write Once Run Anywhere

本文同步自个人博客Flutter + Kotlin Multiplatform, Write Once Run Anywhere,转载请注明出处。

Motivation

Flutter是Google 2017年推出的跨平台框架,拥有Fast DevelopmentExpressive and Flexible UINative Performance等特点。Flutter使用Dart作为开发语言,Android和iOS项目可以共用一套Dart代码,很多人迫不及待的尝试,包括我,但在学习的过程中,同时在思考以下的问题:

  • Flutter很优秀,但相对来说还比较新,目前并不是所有的第三方SDK支持Flutter(特别是在国内),所以在使用第三方SDK时很多时候需要我们编写原生代码集成逻辑,需要Android和iOS分别编写不同的集成代码。

  • 项目要集成Flutter,一次性替换所有页面有点不太实际,但是部分页面集成的时候,会面临需要将数据库操作等公用逻辑使用Dart重写一遍的问题,因为原生的逻辑在其他的页面也需要用到,没办法做到只保留Dart的实现代码,所以很容易出现一套逻辑需要提供不同平台的实现如:Dao.ktDao.swiftDao.dart。当然可以使用Flutter提供的MethodChannel/FlutterMethodChannel来直接调用原生代码的逻辑,但是如果数据库操作逻辑需要修改的时候,我们依然要同时修改不同平台的代码逻辑。

  • 项目组里有內部的SDK,同时提供给不同项目(Android和iOS)使用,但是一些App需要集成Flutter,就需要SDK分别提供Flutter/Android/iOS的代码实现,这时需要同时维护三个SDK反而增加了SDK维护者的维护和实现成本。

所以,最后可以把问题归结为原生代码无法复用,导致我们需要为不同平台提供同一代码逻辑实现。那么有没有能让原生代码复用的框架,答案是肯定的,Kotlin Multiplatform是Kotlin的一个功能(目前还在实验性阶段),其目标就是使用Kotlin:Sharing code between platforms

于是我有一个大胆的想法,同时使用Flutter和Kotlin Multiplatform,虽然使用不同的语言(Dart/Kotlin),但不同平台共用一套代码逻辑实现。使用Kotlin Multiplatform编写公用逻辑,然后在Android/iOS上使用MethodChannel/FlutterMethodChannel供Flutter调用公用逻辑。

kmpp+flutter

接下来以实现公用的数据库操作逻辑为例,来简单描述如何使用Flutter和Kotlin Multiplatform达到Write Once Run Anywhere

接下来的内容需要读者对Flutter和Kotlin Multiplatform有所了解。

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!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容