前言
Kuikly是腾讯TDS团队推出的Kotlin多平台框架,这个框架从发布前我在Kotlin中文开发者大会发布前就在关注了,只是很可惜一直没有机会尝试。
这次碰上了腾讯犀牛鸟开源人才培养计划,其中就有Kuikly这个框架,当然我没参加issue组件贡献,而是选择参加之后的开源实战,因为在组件这块经验也不是很多,不是很想消磨精力。
总之,借此机会尝试一下Kuikly这个跨平台框架的开发体验,这篇文章将挑一些有意思的内容称述我在这个框架的开发尝试。
我非常推荐大家看一下我文章里面对Kuikly响应式实现的观察,真的非常颠覆我这个安卓小子的认知啊。
成品展示

多平台演示

怎么样?效果还不错吧?这次很遗憾,没有来得及研究和尝试自适应尺寸,一方面时间有限,另一方面确实设计不是我的强项。
下面文章感兴趣也可以配合仓库代码,当然这篇文章主要还是讲讲开发感受,和一些小巧思。
仓库地址:github.com/1250422131/...
Kuikly基建体验
Kuikly也是基于KMP 的框架,这意味着我们不必重新开始,得益于这点,现在我们可以快速在Kuikly开始了。
我们可以直接迁移平台无关的代码,我们对网络请求的流程处理封装、数据类、结构转换都可以迁移,这好很多,这些代码是我们程序的灵魂 ,是我们思想的体现
不知道大家有没有看过我上一篇文章:KMP项目迁移,这个项目和大家介绍了一个我的项目BILIBILIAS,我们本次研发就从这个项目提取了一些通用设计。
模块化设计

这里我们做的简单一些,和之前的项目类似,我们拆出common、chart、network模块,我们项目这里拆分的意义不大,但是我想试一下多模块开发是否还能正常。
以Common模块为例
既然这里是common,那我们就要想着把通用依赖加到这里来,我们先看看Kuikly 自己写的Shared 模块,这其实和KMP是一样的,我们在KMP项目做多模块时也是直接看Shared模块怎么写,然后我们照着写,就可以把其他模块也变成KMP可以直接依赖的了。

这里我们发现有两个Gradle脚本,有一个看来是专门给鸿蒙用的,我们先看看这个build脚本,因为这个和我们最常见。

里面配置还挺多,但对于我们来说,我们只关注什么?我们只关注依赖对吧

接下来我们直接找猫画虎,创建common模块,然后修改gradle脚本的内容,直接把shared的复制一份,到common模块就可以了对吧?

但这里我们只复制commonMain的依赖,api共享依赖,这样其他模块引入common这个模块,这样我们就做好了一个kmp的kuikly的模块。
这里还有一个build.ohos的脚本对吧?我们就是一模一样的,也复制过去。
这里我们可以让AI帮忙辅助完成这个步骤和验证,其实感觉给AI做这个还挺好的,大家也可以试试看,这里我们用腾讯的WorkBuddy执行,因为这次活动给我们发放了一定的积分,咱们还是要好好利用起来。
NetWork封装
这里和正常gradle项目一样,我们前面就写了common模块,现在就引入给network吧!

前面我说过,我们不必从头开始,现在我们就把之前项目设计的网络请求封装拿过来。

不过我们要注意的是,Kuikly并不能直接采用Ktor,而是得用Kuikly框架封装好的网络请求组件,这里等一下我们实现就需要调整。
kotlinx-serialization
这个库不陌生了,谈及网络请求,那序列化就不可以少,幸运的是Kuikly已经迁移好了。
大家在组件市场就可以搜索到:kotlinx.serialization

这里我们按照文档要求,在对应的模块下引入就可以了,注意要有腾讯的maven仓库,不然找不到的,这个只在腾讯仓库发布了。

接下来我们就可以直接使用了:

网络请求逻辑迁移

因为篇幅缘故,这里我就不细讲了,大家可以对照代码看看,这里我把Kuikly框架的网络侵权封装成了Flow,Flow里面是网络请求的状态,大家感兴趣可以看看我的代码,将这个是希望让大家看到我们可以不必从头开始。
这里我们还得引入协程库,帮助我们采用Flow哦。

到这里,已经带着大家了解了Kuikly怎么和我们之前一样做多模块,怎么引入第三方库,怎么复用我们其他项目的代码,大家感兴趣可以自己再尝试一下。
组件封装体验
刚开始用Kuikly其实我非常不习惯,因为它既有Compose的DSL写法,又没有Compose的组合,其实一开始写着还挺别扭。
以首页顶部导航栏为例

这里我们创建组件看看,因为Kuikly组件需要配套属性和事件,所以手写不太方便,这里建议直接用插件创建。

创建好之后这里我们可以先粗略的分一下槽位:

分两块,左边的图标和标题,右边的Action栏,就是中规中矩吧。
Kotlin
internal class SRNavigationBarViewAttr : ComposeAttr() {
var title = ""
var action: (ViewContainer<*, *>.() -> Unit)? = null
var icon: ImageUri? = null
var enabledBack: Boolean = false
}
internal class SRNavigationBarViewEvent : ComposeEvent() {
var callBack: (() -> Unit)? = null
}
internal fun ViewContainer<*, *>.SRNavigationBar(init: SRNavigationBarView.() -> Unit) {
addChild(SRNavigationBarView(), init)
}
这里我们先看看我们的组件是怎么写的属性。
首先我们标题和右侧的图标样式肯定是固定尺寸的,那我们就可以考虑直接定义成变量,比如我们传就传标题内容,图片地址这样子。
这里我们还有一个enabledBack,顾名思义,就是需不需要显示这个返回的icon,这里图简单就这样写了。
但是还有一个问题,Aciton按钮那一排怎么办?我们写Compose肯定是知道加一个lambda对吧?现在我们也在Kuikly写一下。
Kotlin
Row {
attr { alignItemsCenter() }
ctx.attr.action?.let { action ->
action()
}
}
这里我们定义一个横向布局,里面我们调用我们的lambda。
kotlin
var action: (ViewContainer<*, *>.() -> Unit)? = null
完整代码大家可以看看仓库的代码,下面我们看看怎么调用。
Kotlin
SRNavigationBar {
attr {
title = "塞壬"
icon = ImageUri.pageAssets("logo.png")
action = {
Image {
attr {
size(30f,30f)
src("ai_action.gif".toCommonAssets())
marginRight(5f)
}
event {
click {
ctx.acquireRouterModule().openPage("agent_chat")
}
}
}
SRIcon {
attr {
src("search_24dp.svg".toCommonAssets())
marginRight(5f)
tintColor(Color(ctx.SRThemeColor.primary))
}
event {
click {
ctx.acquireRouterModule().openPage("search")
}
}
}
.............
}
}
}
我们可以看到,我们通过lambda传入了这个action里面的多个图标。
响应式开发探索
Kuikly的响应式和我一开始想的有一些不一样,这使得我最开始也踩了不少坑,大家可以跟着下面内容看。
在一个逻辑和数据复杂的页面,我们自然会想到能不能像是ViewModel一样抽离业务代码,这里我参考ViewModel的写法,做一个类,但是这里实际上做了许多妥协,比如我们持有了pager,这和我们在viewmodel持有context是一样危险的,但是我暂时没找到好办法,有其他建议也可以在评论区和我讨论。

这里还有一个stock,这个是后来AI辅助迁移时产生的,其实我们完全不需要持有stock,因为调用函数完全可以传递。

这是正常在Kuikly响应式的办法,这就是为什么我们要持有pager对象,因为这个observable正是来自它。
现在首页的控件就可以使用state对象来做响应式了。
如何保持响应式传递
就像是这个例子,我们读取了observable中的detailResult,如果这在这里写的是下面的代码,也就是直接用Text控件,那完全没问题的。
kotlin
// 实际使用
Text {
attr {
text(ctx.attr.details.data?.name ?: "")
}
}
但当我们换成自定义控件的时候,你会发现控件内部的组件完全无法收到响应式更新:
kotlin
StockDetailTopInfo {
attr {
details = ctx.store.state.detailResult
}
}
internal class StockDetailTopInfoViewAttr : ComposeAttr() {
var details: NetWorkResult<StockDetail> = emptyNetWorkResult()
}
// 子组件内部实际使用
override fun body(): ViewBuilder {
val ctx = this
return {
Text {
attr {
text(ctx.attr.details.data?.name ?: "")
}
}
}
}
这个问题当时我都看懵了,这理论上没有问题,特别是当我们写compose时,这种情况下组件应该会完全重载,很显然这是我太死板了。
Kuikly响应式实现
我当时看了一下官方的文档:常见错误,看到了一段这样的描述:

这看上去有一些奇怪诶,为什么我们用一个变量承接引用的时候,响应式便会丢失?天塌了啊
我马上看了下为什么非得写在attr里面:

我们可以看到,attr 内部会注册监听,发现变化后就会重新执行apply(init)
kotlin
private var textContent by observable("Hello")
val content = ctx.textContent // 错误:提前取值,断开了响应式依赖
Text {
attr {
text(content) // 错误:使用普通变量,不会响应ctx.textContent的变化
}
}
那现在我们看这段有问题的代码我们就知道什么情况了,因为本质上他是把attr的lambda重新执行了一遍,这就是为什么我们无法正常更新了,因为ctx.textContent本质上是调用了getValue,content就不会再改变了,当我们更新textContent ,也是textContent.value在更新,所以这种情况才会无法触发更新。
但还有问题,组件是怎么知道自己要观察的对象是谁呢?这里我们就看看委托究竟做了什么。

这里我们找到了这个委托的接口,我们去找一下实现:


我们发现getValue时执行了一个通知,传入了调用对象和属性名称,相当于记录了这个字段,存入了map,那还是有问题,这只是记录字段,怎么关联的?那我们好像还是要回去看看。

于是我深层去看attr,我们发现bindValueChange会先调用valueChange,而这就会导致attr传入的函数被调用,委托的getValue被执行,字段被存放到activeReadPropertyNames 这个map里面,接下来调用addObserver。 
接着就看到了上面这些代码,addObserver这个函数非常的震撼嗷,这,这对吗,这样居然是没问题的吗?
你看看这个代码是立刻去读取存储到activeReadPropertyNames 的字段,然后和当前监听关联的,然后再立刻清空activeReadPropertyNames,见识少的我被震撼一百年,直接吓哭了,没想到居然这样灵巧的就解决了,我必须再次震撼!我一直以为这是什么高深黑魔法。
子组件观察响应数据
其实我写这篇文章时才真正仔细看了这里,发现感觉很难的事情其实并没有这么难,又是一个小收获。
真的是好极了,我们通过上面的分析,知道了attr会观察数据更新,那现在我们是不是就可以知道,我们像是下面这样当然完全没有用。
因为这就相当于重新执行了一遍赋值,没有调用委托的getValue,完全没有注册这个字段作为响应式。
那太好改了,我们现在要想到一个问题,那就是
kotlin
StockDetailTopInfo {
attr {
details = ctx.store.state.detailResult
}
}
internal class StockDetailTopInfoViewAttr : ComposeAttr() {
var details: NetWorkResult<StockDetail> = emptyNetWorkResult()
}
是不是我们就可以考虑改一下?那怎么改?我们知道attr是观察了属性,是不是我们这里就可以考虑直接通知这个字段更新啊
方案一
那么,我们首先来看这个方案
Kotlin
private var result by observable<NetWorkResult<StockDetail>>(emptyNetWorkResult())
override fun body(): ViewBuilder {
val ctx = this
return {
Text {
attr {
text(ctx.result.data?.name ?: "")
}
}
}
}
inner class StockDetailTopInfoViewAttr : ComposeAttr() {
var details: NetWorkResult<StockDetail> = emptyNetWorkResult()
set(value) {
this@StockDetailTopInfoView.result = value
field = value
}
}
既然一定得是observable,那我们让set给我们的子组件的observable更新就可以,但是这看上去还是有一些麻烦?
方案二
我们来看看最后的解决方案:
kotlin
override fun body(): ViewBuilder {
val ctx = this
return {
Text {
attr {
text(ctx.attr.details().data?.name ?: "")
}
}
}
}
internal class StockDetailTopInfoViewAttr : ComposeAttr() {
var details: () -> NetWorkResult<StockDetail> = { emptyNetWorkResult() }
}
这里我们把details 直接换成lambda,必须传入函数,然后我们在使用的地方调用这个传入的函数,那每次调用函数,就相当于调用委托的getValue,按照我们上面的了解,这样就会注册记录这个字段,然后和组件绑定,这样我们就不需要产生额外的观察对象,一举两得。
ini
StockDetailTopInfo {
attr {
details = { ctx.store.state.detailResult }
}
}
调用时写起来写还不错,大家可以试试看这种方案。
结尾
本来还想再补充一些的,但是篇幅实在是太长了,我挑了几个我比较感兴趣的分享给大家,之后有机会再写文章分享,希望对大家有所帮助。
尽管目前生态还在发展,但是从这几周的时间使用Kuikly 体验下来之后,其实感觉还是很不错的,只是目前社区仍然面临庞大的平台代码工作要做,也期待Kuikly未来的发展。
感谢大家观看!

仓库地址:github.com/1250422131/...
腾讯犀牛鸟开源人才培养计划:opensource.tencent.com/summer-of-c...