我今年接手了一个把存量Kotlin业务搬到鸿蒙上的活,第一反应是“鸿蒙原生ArkTS再写一遍”工作量太大,团队最后定了Kuikly做跨端框架。忙完几个大版本迭代后,我想把这几个月在分场景适配上的心得系统整理一下,尤其是屏幕安全区、软键盘遮挡、生命周期回调、内存回收这几类最容易和Android行为不一致的地方,给后面要用Kuikly做鸿蒙适配的同学一个参考。
Kuikly这个框架,核心是用Kotlin写声明式UI,类似Jetpack Compose的编码范式,但底层可以同时渲染到Android、iOS和鸿蒙端。也就是说,业务代码写一次,UI层可以跑在多个平台上。这个思路在鸿蒙化改造时非常讨巧,因为它不是H5套壳,也不是简单的WebView容器,UI描述、状态管理、事件分发都是原生的,性能上更可控。但正因为跨端,鸿蒙和Android窗口体系、生命周期模型、系统服务能力有大量细节差异,这就逼着你必须把适配拆成“分场景”去处理,而不能拿一套通用逻辑硬套。
要说这套实践的收益,最直接的是人力节省。三端各写一套UI,每个新需求的排期会随着端数量成倍放大。而Kuikly让鸿蒙端的首页、列表页、表单页等核心场景保持一致交互逻辑,原生差异只在能力和系统交互层去做隔离。另外,鸿蒙生态现在支持hap、hsp、har三种包形态,Kuikly侧产出的har包可以嵌入现有鸿蒙工程,这对接入成本来说也是很大的优势。当然,跨端框架不是银弹,分场景适配的前提是你自己心里要有清单:哪些场景走统一逻辑,哪些场景必须单独处理。
1. Kuikly适配鸿蒙的整体思路与选型考量
1.1 为什么在鸿蒙项目里选Kuikly
团队选型时摆在桌面上的方案通常有三个:ArkTS重写、跨端框架、混合方案。ArkTS重写最彻底,但成本高,而且存量Kotlin业务如果量很大,短时间根本转不完。跨端框架里比较常见的有Flutter、RN这类熟悉面孔,但它们在鸿蒙上的成熟度参差不齐,要么是社区适配,要么是厂商维护,真遇到问题的时候你都不知道该给谁提issue。Kuikly当时打动我的点是它的跨端路径和鸿蒙官方Cocos、Flutter适配思路不一样,它把Kotlin作为统一语言,把ArkUI作为渲染层,这样对熟悉Kotlin的Android团队特别友好。
另外一个现实问题是,我们团队里Android成员占比高,让他们直接转ArkTS虽然也行,但语法、状态管理、组件模型的切换成本还是很高。Kuikly的声明式UI风格和Compose接近,学习曲线平滑很多。再加上它支持直接输出har包,意味着鸿蒙工程和Kuikly工程可以分离构建,业务开发不用过度关心鸿蒙侧的工程结构和签名配置。跨端不意味着不去了解鸿蒙,但至少能把核心复杂度和“分场景适配”这件事收敛到一个可控范围内。
这里我想强调一个选型前提:如果你的业务极其依赖HarmonyOS的分布式能力、系统级卡片、元服务等特性,那Kuikly当前并不适合。它更适合以页面为主、交互规范、且需要多端快速复用的泛互联网应用。没必要为了用而用,先把需求边界画清楚再选框架。
1.2 先分清适配场景再谈改代码
很多踩坑都是从“先把代码跑起来再说”开始的。鸿蒙的窗口模型、页面生命周期和应用生命周期定义和Android不完全一样,你不能指望同一个按钮点击事件在两端行为完全一致。我的做法是先拉一个场景矩阵,把所有能想到的异常情况都列出来,再进行分类。大致分三类:
- 纯UI差异:安全区、状态栏高度、横竖屏切换、深色模式、字体缩放。
- 生命周期差异:前后台切换、页面隐藏/显示、进程被杀后的状态恢复。
- 系统能力差异:权限申请、图片选择、文件存储、网络缓存、键盘规避。
拿生命周期举例,Android有onPause、onStop、onDestroy,鸿蒙这边页面级有aboutToAppear、aboutToDisappear、onPageShow、onPageHide,还有AbilityStage和UIAbility的应用级生命周期。Kuikly会把通用生命周期映射成自己的状态,但有些信息映射得不完整。比如应用进入后台再切回来,Android会回调onResume,鸿蒙也有对应事件,但不同版本和不同设备上,触发时机可能出现几十到几百毫秒的偏差。如果业务里正好有需要精准刷新数据的逻辑,就必须用分场景判断来做补偿处理,而不是盲目信任框架的统一封装。
所以我的建议是:第一步先不写任何业务代码,先用一个空壳工程把鸿蒙真机跑起来,把上面这些场景逐项打点打到日志里。这样后面写代码的时候,每碰到一个异常,你能迅速判断是框架封装问题还是鸿蒙系统特性。
2. 分场景适配核心:布局、屏幕与样式差异
2.1 屏幕尺寸与安全区适配
屏适配是最容易踩坑也最容易被忽视的。Android里我们有状态栏、导航栏、刘海屏的概念,鸿蒙也有类似的安全区,但叫法和获取方式不一样。在Kuikly里统一用dp作为逻辑尺寸单位,但鸿蒙端的窗口参数如果没设置正确,你会发现顶部或者底部总有一截内容被遮挡。这里要特别注意:鸿蒙的窗口默认是避让安全区的,但Kuikly的某些自绘组件在计算自身尺寸时可能并没有主动读取安全区数据,导致内容顶到系统状态栏下面。
我的做法是在思路里默认不再用传统的“状态栏高度+固定padding”方案,而是通过鸿蒙侧的WindowStage获取安全区变化事件,把顶部和底部安全区的值传给Kuikly的顶层Layout,形成一个全局的Insets对象。然后所有页面统一从这个对象读取top和bottom,而不是各自在样式里写死数值。因为不同机型的刘海尺寸差异很大,写死数值在真机调试时几乎必然出问题。
通信上,我封装了一个平台通道接口,鸿蒙侧在onWindowStageLoad之后获取窗口实例,注册avoidAreaChange回调,把结果转成可序列化的数据对象回传给Kuikly侧。Kuikly侧拿到之后更新状态,触发顶层布局重绘。刚开始我们忽略了这个细节,结果在带挖孔的机型上,页面底部按钮被导航条遮挡,点按钮经常误触到系统手势区域。后来加入安全区动态更新后,页面内容在不同设备上基本稳定。
2.2 字体缩放、横竖屏和折叠屏场景
字体缩放是分场景适配里比较特殊的一种。Android的sp单位会跟随系统字体大小变化,鸿蒙的fp单位也类似,但Kuikly在设计时有自己的尺寸单位体系。如果你全部使用dp,那系统字体缩放根本就影响不到你,看似省事,其实对系统设置里开了大号字体的用户很不友好。如果你全部用fp,又可能出现布局被文字撑爆的问题。
我的折中方案是:正文和按钮文字使用跟随系统缩放的字体单位,但设置最大放大倍数上限;标题和固定尺寸UI则使用dp。钢笔写代码的时候可能感觉不到,但拿到真机开启“超大字体”后,很多界面的文本会重叠、按钮变形,这些问题和Android没有本质区别,但跨端框架会引入额外不确定性。因此我建议在Kuikly中建立一个TextStyle工厂,统一管理字号、行高、上下间距,并留出字体缩放比例的计算入口。这样不同场景下,你可以通过同一个配置对象调整排版参数,避免在几十个页面里到处改magic number。
横竖屏和折叠屏又是另一类问题。折叠屏在展开和折叠过程中,窗口宽度会发生突变,Kuikly如果只是简单刷新状态,可能来不及重新计算列数或布局比例。我在实践里采用了两个策略:一是监听鸿蒙窗口尺寸变化事件,在宽度跨越阈值(比如展开态和折叠态的分界)时强制重建顶层组件树;二是所有网格布局、双栏布局都用百分比或弹性权重描述,不用固定px。这样至少在大多数折叠屏和Pad类设备上,界面能够自适应变化。
2.3 深色模式和主题变更处理
深色模式在Kuikly中可以通过定义Light和Dark两套color token来做。这里最容易忽略的是系统在应用运行中主动切换深浅色时,框架能否及时响应。鸿蒙在切换主题时会发出配置变更事件,但Kuikly内部的Compose风格状态管理可能无法自动感知系统级变化,必须由宿主工程监听系统配置变化,再手动通知Kuikly侧刷新主题状态。
我自己的做法是封装了一个ThemeManager单例,初始时从鸿蒙侧读取systemTheme值,同时向系统注册监听。监听到切换后,把新主题名称传入Kuikly侧,更新顶层Theme对象。因为所有颜色在页面里都不是直接写色值,而是从Theme对象里取,所以刷新一次即可全局生效。这个过程听起来不复杂,但坑在于不同鸿蒙版本对“深色模式”的开关状态读取方式不一致,有的版本返回“深色”和“浅色”,有的版本还支持“跟随系统”的三种状态。所以解析逻辑里必须兼容枚举名不一致的情况,不能假设字段值永远相同。
另外,深色模式下图片和阴影也要注意。有些图片素材只有浅色版本,深色背景下一看就是白边,比Android端更显眼。我们最终加了一个图片加载层的统一处理逻辑:根据当前主题给图片外层加一个混合模式,必要时用占位色兜底。这类细节很琐碎,但对用户观感影响很大。
3. 工程与能力适配:路由、生命周期与原生能力
3.1 页面路由与返回栈的差异处理
路由这块最容易出问题的是返回行为。Android的返回栈由系统管理,Activity或Fragment出栈时会触发一系列生命周期回调。鸿蒙页面有自己的Navigation栈,同时也有系统返回键事件。Kuikly可能内置了路由能力,但和鸿蒙原生Navigation栈混合使用时,栈的层级关系容易错乱。比如在一个Kuikly渲染的页面里跳转一个原生鸿蒙页面,再按返回键,可能直接把整个Kuikly页面关掉了,而不是回到列表的上一个状态。
我在工程里做了一个统一的路由管理中间层,把所有跳转都收敛成一种目标格式:Kuikly页面还是原生页面,使用栈ID区分。每次跳转时不直接调框架的默认API,而是通过这个中间层记录路由来源和页面类型。在系统返回事件里,先判断当前栈顶是否是需要特殊处理的混合页面,再决定是交给Kuikly处理还是交给原生处理。实际下来,返回栈问题减少了九成。
还有一点要提醒:ArkUI的命名路由如果配置错了,跳转时会直接闪退,而且报错信息不一定能定位到Kuikly代码。所以建议所有路由地址在编译期或初始化阶段做一个全量校验,不存在的页面直接暴露在日志里,不要等到运行时才崩溃。
3.2 生命周期事件对接鸿蒙AbilityStage/Page生命周期
Kuikly对生命周期的封装,和Android平台的Activity生命周期比较像,但它不一定能捕捉到鸿蒙UIAbility的冷启动和热启动差异。鸿蒙补一刀:UIAbility有onCreate、onNewWant、onForeground、onBackground、onDestroy,而页面组件有aboutToAppear、onPageShow、onPageHide、aboutToDisappear。这些事件在跨端框架中可能被合并或延迟处理,导致数据统计、埋点上报出现重复或缺失。
我在项目里做了一个比较笨但有效的方法:在Kuikly框架层的顶层组件里,自己实现一套生命周期事件分发,把“页面可见”和“应用可见”区分开。页面可见对应列表刷新、视频暂停这类需要精确感知页面状态的操作,应用可见对应全局数据同步、长连接心跳这类操作。分场景去订阅你所需要的生命周期事件,而不是统一用“条条大路通Rome”的方式处理。尤其是视频类应用,前后台切换时播放器的暂停恢复逻辑如果依赖于框架的onResume,很可能会因为时机偏差产生画面和声音不同步的现象。
另外,鸿蒙的Ability在后台被系统回收后,进程可能被杀,业务数据如果不做状态持久化,切回来就是白屏或者回到初始页。我建议所有关键页面数据结构都要实现可序列化或者用本地存储缓存,在onPageHide和aboutToDisappear时机进行保存,在恢复时优先读取缓存。这和Android的onSaveInstanceState思路一致,但鸿蒙的触发时机和传递粒度都更粗糙,不能完全照搬。
3.3 权限申请与系统能力调用实战
鸿蒙权限模型和Android有相似之处,但并不相同。Android在Manifest里声明权限,运行时用requestPermissions;鸿蒙需要先在module.json5中声明权限,然后在代码里通过abilityAccessCtrl申请。Kuikly没有把权限申请封装成统一API,所以这块我建议以原生鸿蒙代码为主,Kuikly只负责回调结果透传。
具体做法是:在Kuikly侧发起逻辑判断,如果需要权限,调用平台通道去鸿蒙侧执行requestPermissionsFromUser,回调结果里包含授权状态、是否弹窗、是否是用户主动拒绝。拿到结果后再回传给Kuikly更新UI。这里有一个关键细节:如果用户第一次拒绝且勾选了“不再询问”,后续主动requestPermissionsFromUser可能不会弹窗,必须引导用户去系统设置页。不同设备上“设置页”的打开方式不同,我在鸿蒙侧封装了一个跳转设置页的接口,保证逻辑统一。
还有一个容易踩的坑是:相机、相册、位置这类权限申请,必须在Ability的上下文里进行,如果此时页面是后台态,或者当前上下文被框架包裹成特殊对象,申请可能静默失败。所以我在申请权限之前,会先检查当前UIAbility是否在前台,如果不在就直接返回失败状态,避免用户不知道发生了什么、权限悄悄没弹出来。
4. 实测踩坑:多任务、键盘、图片与网络场景
4.1 软键盘弹出遮挡输入框的经典问题
软键盘遮挡输入框在Android里可以通过adjustResize或者adjustPan配置,但Kuikly在鸿蒙端并没有完全复刻这套配置。鸿蒙对于键盘弹出时的处理有自身的机制,需要监听avoidArea变化然后调整内容区域。因为我之前使用安全区动态更新时已经对avoidArea做过封装,所以软键盘问题改起来相对顺:在页面获得焦点输入时,监听avoidArea的bottom增量,如果增量大于某个阈值说明键盘弹出来了,就把整个页面的内容区paddingBottom改为该增量。
但是这样会带来一个新问题:有些页面有固定的底部操作栏,键盘弹出时底部操作栏应该被顶上去,而有些页面底部栏应该保持隐藏。不能一个全局策略解决。我最后是给每个页面打一个属性标签,比如“输入页”和“非输入页”。输入页在键盘弹出时整体高度压缩,非输入页则不变。经验是不要想着在框架层做黑科技自动规避,鸿蒙键盘高度在不同输入法上经常变化,尤其是第三方输入法,出现和消失的动画时长也不一致。比较保险的做法是严格监听系统回调并做状态驱动,而不是猜测键盘动画结束后再手动计算。
如果还有bug,可以在键盘弹出和收起时延迟几百毫秒再执行布局刷新。这不是什么优雅方案,但实测能减少很多输入法动画过程中的闪烁问题。
4.2 图片加载库与缓存目录的鸿蒙适配
图片加载在跨端上经常是重灾区。Android我们习惯用Glide或Coil,但鸿蒙没有对应的Glide API。Kuikly自己可能有图片加载方案,但能力不一定完整,尤其是网络图片、磁盘缓存和查看大图手势。我的做法是分两层:第一层在Kuikly侧保持统一的Image组件协议,通过URL加载图片;第二层在鸿蒙侧使用系统或第三方图片加载库,把结果转成Bitmap或PixelMap返回给Kuikly渲染。
这个方案有个坑:鸿蒙侧的图片加载结果如果是PixelMap,直接转给跨端渲染层可能涉及内存拷贝,大图高频加载时开销很高。所以我在列表页建议开启缓存复用,并且控制图片的解码尺寸。尤其在做商品列表或内容流时,很容易出现图片内存暴涨。解决办法是在加载前根据ImageView的尺寸计算目标采样大小,不要直接加载原图。
缓存目录也要注意。Android常见的是用cacheDir和filesDir,鸿蒙的目录层次和沙盒路径不同,如果代码里写死了Android路径,图片缓存会失败。我在平台通道里定义了一个统一的缓存目录管理接口,鸿蒙侧返回真实的沙盒缓存路径,Kuikly侧所有图片缓存都通过这个接口获取。这样至少不会出现磁盘缓存写了但读不到的情况。
4.3 多任务切换和内存回收后的状态恢复
多任务切换在鸿蒙和Android上都有一个常见问题:应用切换到后台一段时间后,系统可能在后台将页面销毁或回收内存,切回来时状态丢得一塌糊涂。Kuikly里的状态管理如果是依赖内存单例,那进程被杀死后所有状态都没了。这里不能只依赖框架的ViewModel机制,毕竟跨端层的状态可能没有落到持久化存储。
我的做法是引入一个轻量级的状态恢复协议,要求所有可恢复页面实现saveState和restoreState接口。saveState在页面即将隐藏或进入后台时被调用,把当前页面的滚动位置、输入框内容、筛选条件等关键状态写入本地存储。restoreState在页面重新创建时调用,从本地存储中恢复。这个机制很笨,但它不依赖任何第三方库,只要鸿蒙系统还能读到本地文件,状态就能恢复。
恢复时还有一个细节:列表页恢复了滚动位置,但数据还没刷新回来,界面会出现白屏或者占位图。所以我通常会把关键列表数据缓存一份到本地,恢复时先展示缓存数据,再请求网络刷新。这样至少用户看到的不至于空荡荡,体验提升明显。千万不要等到用户反馈“切后台再回来就白屏”才处理,这类问题出现一次,评分就很受伤。
5. 常见问题排查与避坑速查表
5.1 典型异常速查表
我整理了这几个月在鸿蒙适配过程中遇到的一些高频异常,按现象、原因和解决办法列成一张表,方便大家快速对照排查。
| 异常现象 | 可能原因 | 处理建议 |
|---|---|---|
| 页面顶部内容被状态栏遮挡 | 未处理安全区避让 | 注册avoidAreaChange并更新全局Insets |
| 底部按钮被系统导航条遮挡 | 使用了固定高度或固定margin | 改为读取底部安全区值,动态计算padding |
| 深色模式下页面文字看不清 | 主题变更未通知到Kuikly侧 | 通过平台通道同步系统主题,统一使用color token |
| 图片加载一闪而过或缓存消失 | 路径指向了Android目录 | 统一通过缓存目录接口获取路径 |
| 返回键直接退出整个应用 | Kuikly路由栈与原生栈混用 | 使用统一路由中间层管理栈类型 |
| 调用相机闪退或没反应 | 权限申请时机不对 | 检查Ability是否在前台,且上下文是否正确 |
| 键盘弹出时布局错乱 | 没有监听avoidArea变化 | 按页面类型处理键盘弹出和收起时的内容区高度 |
排查这类问题,我习惯先看鸿蒙原生日志,再去看Kuikly侧日志。因为很多异常是跨端传递过程中被吞掉或者被二次包装的,原生报错信息能直接告诉你底层发生了什么,比如window、focus、area、ability这些关键词的出现频率会比较高。
5.2 分场景适配效果验证清单
我自己在版本发布前会跑一张适配验证清单,每项都标注测试设备和通过标准。这里分享一份简化版,也就是实际可以照着做的验收项。
- 首次启动冷启动后进入首页,状态栏、导航栏、内容区布局是否正常。
- 从桌面图标切换进应用,页面是否恢复离开时的状态,不会白屏。
- 打开软键盘输入文字,再收起键盘,页面位置是否正确回归。
- 开启系统深色模式,切回应用,所有页面颜色刷新是否正常。
- 进入折叠屏展开态,布局是否重新排列;返回折叠态,是否恢复上一状态。
- 申请相册权限时,弹窗是否显示;拒绝权限后,应用是否能引导用户去设置页。
- 切到后台等1分钟再回前台,页面滚动位置是否保留,网络请求是否被重置。
- 使用无障碍大字体,核心页面文本是否完成换行,没有出现截断和重叠。
这套清单不是测试同学专用的,开发阶段就应该在自己手上跑一遍。尤其是你改动和适配相关的代码后,最少要跑前四项,否则很可能“莫名其妙又把安全区顶掉了”。
回到这次Kuikly和鸿蒙的分场景适配实践,我最大的体会是:跨端框架解决的是效率和同构问题,但它不能替你处理平台差异,反而会把平台差异从显式的代码细节变成隐式的行为差异。所以做鸿蒙适配,关键不是背熟某几个API,而是建立起“场景驱动”的思维:先盘清有哪些场景会走平台通道,哪些场景要单独实现,哪些场景复用统一能力。把这些边界画清楚了,后面每加一个页面、每接一个系统能力,心里都会有一个明确的地图,不至于每次都在同样的坑里翻车。
这个技术方向后续我觉得还会持续演进,尤其是方舟内存和ArkUI渲染层的接口弹性变大之后,Kuikly这类框架能做更精准的像素级控制。但在那一天到来之前,像我这样老老实实把安全区、生命周期、权限、键盘、图片缓存这些分场景适配做好,其实就是当前性价比最高的实践方式。