尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

鸿蒙跨平台实战:基于Kuikly打造原生渲染汇率计算器

鸿蒙跨平台实战:基于Kuikly打造原生渲染汇率计算器 做鸿蒙开发小半年我手里过过不少跨平台方案踩过的坑能写满一本笔记本。这次用 Kuikly 从零撸了一个汇率计算器算是我最近最满意的一次实战。项目不大但麻雀虽小五脏俱全要接网络接口、要管输入状态、要处理页面焦点和软键盘还得让鸿蒙和安卓两端在原生渲染效果上保持一致。标题里的几个关键词——鸿蒙、跨平台、原生渲染、Kuikly、汇率计算器——基本把这次实战的技术主线和场景都点透了。这篇分享适合两类人一类是正在纠结鸿蒙跨平台技术选型的同学想看看原生渲染路线到底行不行另一类是刚开始接触 Kuikly、想找个带完整流程的案例跟着练手的开发者。我会把工程结构、核心代码、联调细节、踩坑记录都摊开来讲尽量做到你照着操作就能把项目跑起来。1. 选型与整体设计为什么这个场景值得用 Kuikly先聊点背景。现在鸿蒙生态已经过了“能不能用”的阶段进入“好不好用”的阶段跨平台方案也跟着卷起来了。市面上的路线大致分三种第一种是 WebView 套壳H5 页面包一层原生壳开发快但性能和体验天花板明显第二种是动态化容器方案比如各类小程序容器适合业务快速迭代但引入的运行时和依赖都不轻第三种是声明式 UI 加原生渲染代表的思路就是像 Flutter、Compose Multiplatform 那样UI 层用一套代码描述渲染走各端原生管线或者自绘引擎交互手感和性能最接近原生应用。Kuikly 走的就是第三条路线。它最大的特点是把 Kotlin 作为统一开发语言业务逻辑和 UI 状态管理全部用 Kotlin 编写在鸿蒙端对接 ArkUI 相关能力在安卓和 iOS 端使用各自的原生渲染管线来呈现。和 WebView 方案相比它没有网页桥接那一层页面里没有白屏等待、没有 JS 和原生反复通信的损耗滚动、输入、点击这些高频交互的响应都能保持在原生级别。和 Flutter 这种自带渲染引擎的方案相比Kuikly 又更贴近鸿蒙原生生态对 ArkUI 的组件和系统能力的利用更直接。那为什么要拿汇率计算器来当实战案例说实话这是我很推荐新手走的路径功能链路完整但不复杂。汇率计算器需要的功能点非常清晰选择源币种和目标币种输入金额并实时换算获取最新的汇率数据展示换算结果和历史记录处理网络异常和加载状态这刚好覆盖了跨平台开发里最核心的几件事网络请求、状态管理、声明式 UI 编写、异步任务处理、平台差异适配。而且这个项目对原生渲染性能有真实诉求金额输入要求键盘响应快数字跳动不能卡币种切换的列表滚动要跟手。如果你用 WebView 去做这些地方多多少少会有一种“慢半拍”的感觉而原生渲染方案恰恰能把这些体验拉满。选型的时候我其实还对比过 KMP 加原生 UI 的方案。那种方案逻辑层共享没问题但 UI 层还是得各端写一套工作量会翻倍。Kuikly 最吸引我的一点是 UI 层也能尽可能共享同时保留原生渲染的性能底子。这也是我最终围绕它来做完整项目的核心原因。1.1 Kuikly 的完整技术栈构成先把这个框架的技术栈拆开看一眼你在搭环境的时候心里会更清楚自己到底在装什么、用到了谁。Kotlin Multiplatform 是底座。KMP 提供了 expect/actual 机制允许你在公共代码里声明一个接口然后在各端平台模块里分别实现。网络库、序列化库、协程这些在 KMP 生态里都支持得不错这是整个项目能多端复用的基础。声明式 UI 描述层。Kuikly 提供了一套类似 Compose 风格的 DSL让你用 Kotlin 代码描述界面结构、状态绑定和事件回调。写起来比 XML 布局直观很多尤其是列表、条件渲染、状态刷新这些场景。各端原生渲染管线。这是 Kuikly 区别于传统套壳方案的关键。鸿蒙端借助 ArkUI 的渲染能力把 DSL 描述的界面真正画出来安卓端走安卓的原生 UI 管线并不是在一个 WebView 里画页面。异步与数据层。网络请求我用 Ktor Client序列化用 kotlinx.serialization状态管理用 StateFlow 加协程。这套组合在 KMP 生态里是标配在鸿蒙和安卓两端都能跑。记住一句话Kuikly 解决的痛点就是“一套代码、多端运行、原生体验”。它在产物层面不是生成 HTML 页面而是生成各个平台真正认识的原生界面描述再由各端去完成渲染这也是“原生渲染”四个字的核心含义。1.2 结构设计模块拆分的三种思路项目一开始我就把任务拆成了三个层级后面所有代码都是围绕这三个层级在长。数据层只做一件事从远端接口拿汇率数据解析成干净的模型对象交给上层。业务层管状态和换算逻辑持有当前输入金额、源币种、目标币种、最新汇率这些状态换算结果也在这里算好。UI 层只负责界面呈现和事件转发用户点了哪个按钮、改了哪个输入框UI 层把事件抛给业务层然后等业务层把新状态吐回来再自动刷新界面。这种分层看起来简单但在跨平台项目里特别重要。因为多端存在的意义就是共享数据层和业务层UI 层如果写得薄一点将来某个端要做特殊交互改动范围就能控制住不会牵一发动全身。2. 开发环境准备与工程结构搭建先说结论环境准备大概是整个项目里最容易被低估的一步。我当时照着文档操作看起来没什么坑结果在版本匹配上卡了两个小时。这里直接把我试通的组合列出来。2.1 环境与工具版本清单我在本机跑通这套项目的环境是这样的建议你尽量对齐操作系统macOSWindows 也能跑但鸿蒙相关工具的体验建议你还是准备一台 Mac 或者 Linux 开发机JDK17 或以上KMP 和鸿蒙构建工具链都依赖这个Android Studio最新稳定版用于打开 KMP 工程并构建安卓端DevEco Studio鸿蒙官方 IDE用于构建和调试 HarmonyOS 端Kotlin 版本2.x 系列Ktor Client2.x 系列kotlinx.serialization1.6 以上版本Kuikly 框架以官方发布版为准建议用最新稳定版需要留意的是Kotlin 版本、KMP 插件版本和 IDE 自带的 Kotlin 版本可能存在兼容性差异。如果编译时出现奇怪的 “Unresolved reference” 或者插件报错优先排查这里。2.2 创建一个带 Kuikly 依赖的 KMP 工程实际操作上不需要手动建一堆 Gradle 文件。我建议你先用 Android Studio 创建一个标准的 KMP 工程模板再把 Kuikly 依赖和鸿蒙插件加进去。这里给出关键步骤用 Android Studio 新建一个 KMP 项目模板选择 “Kotlin Multiplatform App”。在根目录的 build.gradle.kts 里声明 KMP 插件版本和 Kuikly 插件的版本。在 shared 模块的 build.gradle.kts 里配置 androidTarget() 和其他的平台目标。在 commonMain 的 dependencies 里添加 Kuikly 运行时依赖、Ktor Client 核心库、kotlinx.serialization 插件。用 DevEco Studio 打开工程的 harmony 模块目录做一次同步。这里要特别声明一下Kuikly 不同时期的版本细节可能有差异具体依赖坐标请以官方文档为准。我在项目里关注的是整体架构思路比如“公共代码放哪、各端入口怎么接、状态怎么流转”这些才是跨版本都通用的东西。2.3 完整的代码目录布局工程同步完成后目录结构大概是这个形态project-root ├── shared # KMP 共享模块90% 的业务代码都在这里 │ ├── src/commonMain # 公共代码数据层、状态管理、UI DSL │ ├── src/androidMain # Android 端平台实现 │ └── src/harmonyMain # HarmonyOS 端平台实现 ├── androidApp # Android 宿主应用入口 ├── harmonyApp # HarmonyOS 宿主应用入口 └── iosApp # iOS 宿主应用入口本次只做验证不展开这种结构的核心思路是把尽量多的代码塞进 commonMain宿主端只保留应用启动和平台相关的少量桥接代码。这样的好处是汇率计算器的核心逻辑、网络请求和界面定义在鸿蒙和安卓两端是同一份代码肉眼可见地减少了重复劳动。3. 汇率计算器核心功能实现全解析环境搭好之后就是写代码了。这一部分我把汇率计算器从数据到界面的完整实现链路拆开讲重点会放在 KMP 公共代码里因为这个部分才是真正“一套代码多端跑”的精华。3.1 需求拆解功能清单与状态流转写代码之前先把功能清单列清楚这也是我做小项目时养成的习惯避免写着写着就发散。支持输入任意金额默认 1.00支持源币种和目标币种切换币种列表包含常用币种金额输入后自动换算结果保留 4 位小数启动时自动拉取最新汇率并显示数据更新时间网络失败时给出错误提示并允许重试支持下拉或者手动刷新汇率对应到状态管理整个页面可以抽象成这样一个 UI 状态data class ExchangeRateUiState( val amountInput: String 1, val sourceCurrency: String USD, val targetCurrency: String CNY, val rateMap: MapString, Double emptyMap(), val convertedAmount: Double 0.0, val lastUpdate: String , val isLoading: Boolean false, val errorMessage: String? null )所有界面展示的内容都能从这个状态里取。用户每次操作本质上是修改这个状态然后 UI 自动重新渲染。这也是声明式 UI 和传统命令式 UI 最大的区别你不用手动去 updateTextView 或者找到某个节点改文字数据变了界面自己就会跟着变。3.2 数据层实现Ktor Client 拉取实时汇率汇率数据源我选了一个无需 API key 的开放接口Frankfurter它的数据来自欧洲央行支持多种货币按天更新足够演示用。你也可以换成自己喜欢的接口只要 JSON 结构能对应上就行。先在 commonMain 里定义数据模型Serializable data class ExchangeRateResponse( val base: String, val date: String, val rates: MapString, Double )接着封装一个跨平台的网络客户端。Ktor Client 在 KMP 里特别好用各端只需要提供一个引擎实现公共代码里调用的 API 完全是同一套// commonMain class ExchangeRateApi(private val client: HttpClient) { suspend fun fetchLatestRates(base: String): ExchangeRateResponse { return client.get(https://api.frankfurter.app/latest) { url { parameters.append(base, base) } }.body() } }在 androidMain 和 harmonyMain 里我分别实例化带对应引擎的 HttpClient。这段就是典型的 expect/actual 模式// androidMain actual fun createHttpClient(): HttpClient HttpClient(OkHttp) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys true }) } } // harmonyMain actual fun createHttpClient(): HttpClient HttpClient(Darwin) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys true }) } }这里有个细节值得注意iOS/鸿蒙侧的 Darwin 引擎和安卓侧的 OkHttp 引擎返回的数据在解析上有些微差别但我们在公共代码里拿到的都是同一个ExchangeRateResponse对象下层差异被完全屏蔽了。这就是 KMP 的魔力。3.3 业务层实现状态容器与汇率换算精度控制汇率换算本身很简单目标金额 输入金额 × 目标币种汇率 / 源币种汇率。但这里藏着一个新手很容易踩的坑——浮点精度问题。如果你直接拿 Double 算一秒后你可能看到 1 USD 7.199999999 这种结果。我的处理方式是在 UI 展示层统一格式化计算过程保留原始精度显示时才做舍入。代码大概长这样fun convert( amount: String, source: String, target: String, rates: MapString, Double ): Double { val input amount.toDoubleOrNull() ?: return 0.0 val sourceRate rates[source] ?: return 0.0 val targetRate rates[target] ?: return 0.0 val result input * targetRate / sourceRate return BigDecimal(result) .setScale(4, RoundingMode.HALF_UP) .toDouble() }BigDecimal 在这里的作用是保证结果只保留 4 位小数并四舍五入。实际生产场景里如果涉及金额精度要求更高建议用字符串格式化或者金额专用类型不要长期依赖 Double 做累计计算。状态容器我用了一个简单的 ViewModel 类内部维护 MutableStateFlowclass ExchangeRateViewModel( private val api: ExchangeRateApi ) { private val _uiState MutableStateFlow(ExchangeRateUiState()) val uiState: StateFlowExchangeRateUiState _uiState.asStateFlow() fun onAmountChange(newAmount: String) { _uiState.update { it.copy(amountInput newAmount) } recalculate() } fun onSourceCurrencyChange(currency: String) { _uiState.update { it.copy(sourceCurrency currency) } refreshRates() } fun refreshRates() { viewModelScope.launch { _uiState.update { it.copy(isLoading true, errorMessage null) } runCatching { api.fetchLatestRates(_uiState.value.sourceCurrency) } .onSuccess { response - _uiState.update { it.copy( rates response.rates, lastUpdate response.date, isLoading false ) } recalculate() } .onFailure { error - _uiState.update { it.copy( isLoading false, errorMessage error.message ?: 获取汇率失败 ) } } } } private fun recalculate() { val state _uiState.value val converted convert( amount state.amountInput, source state.sourceCurrency, target state.targetCurrency, rates state.rateMap ) _uiState.update { it.copy(convertedAmount converted) } } }这段代码的核心价值在于所有状态变更都走同一个管道UI 永远在消费同一个状态源不会出现“界面显示的数据和业务层的数据不一致”这种经典 bug。3.4 UI 层实现用声明式 DSL 构建计算器界面UI 层我用 Kuikly 的声明式 DSL 来写。下面是一个示意性的代码片段重点看结构和绑定方式具体 API 名称以你使用的 Kuikly 版本为准Composable fun CalculatorScreen(viewModel: ExchangeRateViewModel) { val state by viewModel.uiState.collectAsState() Column( modifier Modifier.fillMaxSize().padding(16.dp) ) { Text( text 汇率计算器, style typography.titleLarge ) Spacer(height 16.dp) OutlinedTextField( value state.amountInput, onValueChange viewModel::onAmountChange, label { Text(金额) }, keyboardType KeyboardType.Decimal ) Spacer(height 12.dp) CurrencySelector( label 源币种, selected state.sourceCurrency, onSelected viewModel::onSourceCurrencyChange ) CurrencySelector( label 目标币种, selected state.targetCurrency, onSelected viewModel::onTargetCurrencyChange ) Spacer(height 24.dp) Text( text 换算结果${state.convertedAmount} ${state.targetCurrency}, style typography.headlineMedium ) if (state.isLoading) { CircularProgressIndicator() } state.errorMessage?.let { message - Text( text message, color MaterialTheme.colors.error ) Button(onClick viewModel::refreshRates) { Text(重试) } } } }看到collectAsState这种写法你就明白了这完全就是 Compose 风格的响应式编程。金额输入框发生变化时ViewModel 里的状态更新然后collectAsState会感知到新的状态触发重组界面自动刷新。这个机制在鸿蒙端和安卓端的表现是一致的因为状态管理和重组逻辑都在公共代码里完成各端只是把最终的 UI 描述渲染出来。3.5 两端宿主工程接入让公共代码跑起来写完公共代码还差最后一步在各端宿主工程里把共享模块跑起来。在 Android 端MainActivity 里只需要拿到 shared 模块的 ViewModel然后 setContent 把CalculatorScreen挂上去即可。在 HarmonyOS 端入口方式类似通过 DevEco Studio 把 KMP 编译产物链接进工程然后调用共享模块里暴露的入口方法把首页组件加载出来。宿主端通常只有几十行代码剩下的全是 shared 模块的功劳。这一步是最容易出差错的。不同 IDE 对 KMP 工程的支持程度不同建议先单独构建 shared 模块确保 Android 端能跑通再切到 DevEco Studio 处理鸿蒙端不要一开始就两个 IDE 同时开着改。4. 原生渲染带来的性能体验与平台差异细节写完了业务代码我把注意力放到这次实战最有意思的部分原生渲染到底给用户带来了什么以及多端渲染存在哪些真实差异。4.1 原生渲染和 WebView 的真实感知差异为了验证原生渲染的价值我专门做了一个对比实验同样的汇率计算器页面用 WebView 方案实现一版再用 Kuikly 方案实现一版在鸿蒙模拟器上做主观体验对比。第一个感知差异在页面加载。WebView 方案冷启动时要初始化浏览器内核加载 HTML/CSS/JS第一次打开页面明显有个白屏等待期。Kuikly 方案因为是原生渲染页面几乎是一瞬间就出来了没有白屏没有资源加载的等待感。第二个感知差异在输入框。金额输入框是汇率计算器里使用频率最高的组件。WebView 方案在唤起数字键盘时有肉眼可见的延迟而且输入框聚焦、失焦时的滚动位置偶尔会跳。Kuikly 方案用的是原生输入框能力键盘唤起快输入和焦点切换都很跟手输入体验和完全原生的应用没有区别。第三个感知差异在列表滚动。币种选择列表如果币种很多WebView 方案在快速滑动时会有掉帧Kuikly 方案在原生列表上滑动很流畅帧率稳定。这些差异在静态截图里是看不出来的但你只要真机用五分钟就再也回不去 WebView 了。这也是我把“原生渲染”当作这次实战核心卖点的原因。4.2 鸿蒙端与安卓端的渲染细节差异虽然是同一套 UI 代码但两端毕竟是两套渲染管线细节上的差异还是有的我在项目里就遇到了三个典型问题。第一个是字体渲染差异。同样的文字大小和字体权重鸿蒙端和安卓端的字形、字重表现不完全一致。鸿蒙默认使用 HarmonyOS Sans安卓默认使用 Roboto。这种差异不算 bug但如果你对 UI 细节很敏感建议在主题里显式指定 fontFamily或者接受“两端字体本来就该不同”这个事实。第二个是安全区适配差异。鸿蒙和安卓在状态栏、导航栏、底部手势条的安全区定义不完全一样。我的界面底部有操作按钮如果不做安全区适配鸿蒙端有可能出现按钮顶到屏幕最底部的情况。这个问题在 KMP 公共代码里不容易统一处理我是在两端宿主工程里分别处理窗口 insets把安全区 padding 传进共享 UI。第三个是键盘弹出时页面压缩行为的差异。安卓端默认 adjustResize键盘弹出时页面会被压缩输入框自动顶起来。鸿蒙端的默认行为略有不同需要确认窗口模式配置。如果你不做处理很可能出现键盘把输入框挡住的情况。这个问题我在后面的排查章节会详细讲。4.3 性能指标实测与优化空间我在鸿蒙模拟器上简单测了一下数据页面冷启动时间大约在 300 到 500 毫秒级别输入内容后换算结果的刷新基本在 16 毫秒内完成一帧币种列表快速滑动没有明显掉帧。对比我之前用 WebView 做的工具类页面体验提升是肉眼可见的。当然这个项目规模不大性能数据只能说明 Kuikly 的基础表现。如果页面更复杂、列表更长、动画更多还需要进一步做性能优化。我的经验是把状态更新频率降下来避免无意义的重组列表项使用键值和复用机制这些都是跨平台声明式 UI 通用的优化手段。5. 常见问题与排查技巧实录这一部分我一边回忆实际开发过程一边把踩过的坑整理成问题清单。里面有几个坑我在网上搜资料时没人详细讲过这里一次性说清楚。5.1 问题速查表问题现象可能原因解决思路鸿蒙端编译不过报 KMP 相关错误Kotlin 版本与 IDE 内置版本不匹配统一 Kotlin 版本清除构建缓存重新同步网络请求一直失败宿主端缺少网络权限或者接口域名不是 HTTPS在两端配置网络权限演示接口尽量用 HTTPS页面出现但数据不刷新状态容器没有在正确的作用域创建检查 ViewModel 作用域确保 UI 消费的是同一个实例数字键盘不弹出输入类型没有设为 Decimal检查 TextField 的 keyboardType 配置键盘弹出时输入框被遮挡窗口模式没设置 adjustResize 或等效策略分别在两端宿主工程里处理窗口 insets金额计算结果出现浮点尾差Double 直接参与展示用 BigDecimal 或格式化函数统一处理币种列表滑动掉帧列表项没有复用或状态更新过于频繁为列表项设置稳定 key减少不必要重组5.2 网络请求失败排查实录这个坑必须单独拿出来说。我第一版在鸿蒙模拟器上跑汇率数据一直拉不到页面一直转圈。我一开始以为是接口问题用浏览器直接访问接口很正常后来怀疑是模拟器网络问题结果发现模拟器能正常打开网页。最后排查到原因宿主应用的网络权限没有配置完整。鸿蒙应用和其他移动端应用一样需要在应用配置文件里声明网络访问权限。权限加上之后请求立刻就通了。这个问题很基础但第一次接触鸿蒙应用开发时特别容易忽略因为你在 Android 和 iOS 上已经习惯了默认配置换到新平台会下意识认为这些配置是默认就有的。另外如果你用的接口是 HTTP 明文而不是 HTTPS鸿蒙端默认可能会拦截我建议直接用 HTTPS 接口省掉一堆配置麻烦。5.3 键盘遮挡输入框排查实录汇率计算器页面比较短键盘弹出时遮挡问题不明显。但如果以后你把这个框架用到表单类页面键盘遮挡就非常难受了。我最初在鸿蒙端测试时点击金额输入框后键盘弹出来结果输入框被键盘盖住用户输入时完全看不到自己的数字。这个问题的根源是窗口软键盘模式。安卓端默认的行为是键盘弹出时调整窗口大小把内容顶上去。鸿蒙端则需要你在 Entry 页面或者窗口配置里确认相应的策略是否生效。由于 Kuikly 的公共代码只管理 UI 内部结构窗口级别的行为需要在宿主工程里去适配这是跨平台框架和纯原生开发最大的区别之一。我的解决思路是在鸿蒙端宿主入口处理窗口避让给内容区域加一个可以监听键盘高度的底部 padding。这样不管键盘在不在界面都能自动调整不会出现内容被盖住的情况。5.4 状态刷新但界面不变化排查实录这个坑也很有代表性。我有一段时间发现点击币种切换后数据明明重新拉取了但界面上的币种名称没有变化。我一度以为是 UI 渲染有问题后来发现是状态容器的作用域写错了。在 KMP 工程里如果你在 UI 层和业务层分别创建了 ViewModel 实例那么业务层更新的那个实例UI 层根本感知不到。这种“两个实例”的问题在传统 Android 开发里也经常出现但在跨平台项目里更容易踩中因为模块之间依赖关系不直观。我的排错方法很简单在 ViewModel 的 init 块里加日志观察 UI 层拿到的是不是同一个实例。日志确认之后再回头检查依赖注入的写法问题就清楚了。6. 功能扩展与后续优化方向汇率计算器这个基础版本做完之后我给它规划了几个扩展方向这里也顺便说说方便你把这个项目作为模板继续玩下去。历史汇率曲线是我下一个想加的功能。Frankfurter 接口本身就支持时间范围查询拿到一段日期范围的汇率数据用折线图展示币种走势对用户的实际价值很大。渲染图表时用 Canvas 自绘可以进一步验证 Kuikly 在复杂绘制场景下的表现。离线汇率缓存也值得做。现在每次启动都拉网络接口如果网络不好就很尴尬。我计划把最近一次成功的汇率数据和获取时间缓存到本地启动时先读缓存再后台刷新。这个功能会涉及 KMP 的本地存储能力也是一个很好的练习点。多语言支持同样可以扩展。把这个应用的文案抽成资源文件适配英文和中文两种语言顺便把 Kuikly 在文本方向、字体适配上的表现摸一遍。另外还能做桌面元服务或者系统小卡片把一个计算器直接放到桌面小组件上点一下就能换算这也是鸿蒙生态里比较有意思的玩法。我在实际做这个项目的过程中最大的体会是跨平台开发真正的难点从来不是“写代码”而是“理解不同平台在哪些地方不一样、哪些地方必须尊重原生差异”。Kuikly 帮你把 80% 的重复劳动省掉了但剩下的 20% 平台适配工作必须实打实地在真机或者模拟器上摸一遍才能找到感觉。汇率计算器虽然小但这条链路上该遇到的事基本都遇到了做一遍你对鸿蒙跨平台开发的整个技术地图就会清晰很多。
返回列表