【开源鸿蒙跨平台开发--KuiklyUI--05】手把手教你开发跨平台汇率计算器(含原生入口配置)
基于KuiklyUI的跨平台汇率计算器开发实践 项目采用Kotlin DSL驱动原生渲染的技术路线,通过业务与宿主分离的架构设计,实现Android和HarmonyOS双平台支持。文章重点阐述了原生入口配置、路由系统设计、响应式状态管理等关键技术点,并分享了实际开发中遇到的组件缺失问题及解决方案。该项目不仅展示了Kuikly框架的高性能优势,也为跨平台开发提供了可复用的工程实践参考。项目代码已开源
【开源鸿蒙跨平台开发–KuiklyUI–05】手把手教你开发跨平台汇率计算器(含原生入口配置)
前言
在跨平台开发领域,Kuikly 是一个值得关注的高性能 UI 框架。最近我基于 KuiklyUI-mini 模板开发了一个汇率计算器,虽然项目看似简单,但在实际落地过程中,由于对 Kuikly DSL 的不熟悉以及部分组件的缺失,踩了不少“坑”。
本文将详细复盘整个开发过程,从原生入口配置、路由管理到核心业务逻辑,再到如何用“土办法”解决组件缺失问题,希望能为大家提供一份详实的实战参考。
项目地址
KuiklyUI-mini
这是本项目的模板,可以克隆下来下面这个项目,来完成这个汇率计算器应用
汇率计算器项目地址Kuikly-exchange-rate
1. 项目背景与架构哲学
1.1 为什么选择 Kuikly?
在如今 Flutter 和 React Native 盛行的时代,Kuikly 选择了一条独特的道路:Kotlin DSL 驱动原生渲染。
- 性能:它不依赖 WebView,也不像 Flutter 那样自带渲染引擎,而是将 DSL 映射为平台原生组件(Android 的 View,iOS 的 UIView,HarmonyOS 的 ArkUI 组件)。这意味着它拥有原生的启动速度和内存占用。
- 语言统一:业务逻辑完全使用 Kotlin 编写(KMP),利用 Kotlin 强大的类型系统和语法糖,避免了 JavaScript/Dart 的上下文切换。
1.2 KuiklyUI-mini 架构解构
本项目所使用的框架 KuiklyUI-mini 是一个精简版的工程模板,其核心在于业务与宿主分离:
- Shared Module (
:shared):这是大脑。包含了所有的 UI 布局代码(Kotlin DSL)、业务逻辑、网络请求封装等。它被编译为 Android 的.aar和 HarmonyOS 的.har/.so。 - Host Apps (
androidApp,ohosApp):这是躯壳。它们只负责启动应用、提供原生能力(如定位、蓝牙)的桥接,以及加载 Shared 模块。
1.3 跨平台渲染原理
当我们在 Shared 模块写下 View { attr { backgroundColor(Color.RED) } } 时,发生了什么?
- Virtual DOM 构建:Kotlin 代码在运行时构建了一棵轻量级的虚拟 DOM 树。
- Diff 算法:当状态改变时,框架计算新旧树的差异。
- 指令分发:差异被转化为指令序列(Create, Update, Delete)。
- 原生映射:
- 在 Android 上,指令通过 JNI 传递给 Java 层,创建
FrameLayout或自定义ViewGroup。 - 在 HarmonyOS 上,指令传递给 ArkUI,创建对应的
Stack或Column。
- 在 Android 上,指令通过 JNI 传递给 Java 层,创建
2. 前期准备与环境搭建
在开始写业务代码前,必须打通“经脉”——即配置路由和原生入口。
2.1 路由系统的设计
路由是页面间通信的协议。在 shared/src/commonMain/kotlin/com/goway/kuiklymini/Routes.kt 中,我们定义了页面的唯一标识。
object Routes {
// 首页,承载功能列表
const val ROUTER_PAGE = "router"
// 汇率计算器核心页面
const val EXCHANGE_RATE_PAGE = "exchange_rate"
}
设计思考:为什么使用 const val?因为这不仅在 Kotlin 代码中使用,未来如果涉及原生跳转(DeepLink),这些字符串常量也是原生层识别页面的依据。
2.2 原生宿主入口深度配置
这是跨平台开发中最容易被忽视的一环:Shared 模块写好了,原生 App 怎么知道启动哪个页面?
Android 端配置 (KuiklyRenderActivity.kt)
Android 的入口是一个 Activity。在 androidApp 模块中,我们需要接管页面参数。
// 位置:androidApp/.../KuiklyRenderActivity.kt
// 定义当前页面名称
private val pageName: String
get() {
// 1. 尝试从 Intent 中获取外部传入的页面名
val pn = intent.getStringExtra(KEY_PAGE_NAME) ?: ""
return if (pn.isNotEmpty()) {
return pn
} else {
// 2. [关键修改] 如果没有指定,默认启动 "router" 首页
// 这里修改为 "exchange_rate" 可直接调试汇率页
"exchange_rate"
}
}
// 页面数据传递
private fun createPageData(): Map<String, Any> {
// 将 Intent 中的 JSON 数据转为 Map 传递给 Kuikly 页面
val param = argsToMap()
// 注入一些原生环境参数,例如 appId
param["appId"] = 1
return param
}
HarmonyOS 端配置 (Index.ets)
鸿蒙应用采用 ArkTS 开发。在 ohosApp 模块的 EntryAbility 加载 Index.ets 页面。
// 位置:ohosApp/entry/src/main/ets/pages/Index.ets
build() {
Stack() {
// Kuikly 是一个 ArkUI 组件,充当容器
Kuikly({
// [关键修改] 指定启动页面
pagerName: this.pageName ?? 'exchange_rate',
// 传递初始化数据
pagerData: this.pageData ?? {},
// 绑定视图代理,处理生命周期
delegate: this.kuiklyViewDelegate,
// 注入原生管理器 (Bridge)
nativeManager: globalNativeManager,
// 异常捕获回调
onRenderException: (exception: Error, reason: ErrorReason) => {
this.exception = `${exception.name}:\n${exception.message}`;
},
})
}
// 确保容器铺满全屏,并扩展到安全区域(如刘海屏)
.width('100%')
.height('100%')
.expandSafeArea([SafeAreaType.KEYBOARD])
}
3. 核心业务逻辑设计 (Model & ViewModel)
业务逻辑是应用的灵魂。在 Kuikly 中,我们不需要像 Android ViewModel 那样复杂的封装,而是直接在 BasePager 子类中利用 observable 属性。
3.1 数据模型设计
我们需要一个简洁的数据类来描述货币。
// 汇率模型:包含代码、名称、国旗Emoji、对人民币汇率
data class Currency(
val code: String,
val name: String,
val flag: String,
val rate: Double
)
3.2 响应式状态管理机制
在 ExchangeRatePage.kt 中,我们定义了驱动 UI 变化的所有状态。
@Page(Routes.EXCHANGE_RATE_PAGE)
internal class ExchangeRatePage : BasePager() {
// --- 核心状态 ---
// 1. 选中的货币索引:改变它,UI上的国旗和计算结果会自动更新
private var currentCurrencyIndex by observable(0)
// 2. 输入金额:绑定到输入框显示的 Text
private var inputAmount by observable("0")
// 3. 计算结果:绑定到结果显示的 Text
private var resultAmount by observable("0.00")
// 4. UI控制状态:是否显示货币选择弹窗
private var isPickerVisible by observable(false)
// 5. 分页状态:因为没有 ScrollView,我们需要手动控制列表渲染范围
private var pickerPageIndex by observable(0)
private val pickerPageSize = 5 // 每页显示5条
// --- 静态数据 ---
private val currencies = listOf(
Currency("USD", "美元", "🇺🇸", 0.138), // 1 CNY = 0.138 USD
Currency("EUR", "欧元", "🇪🇺", 0.127),
// ...
)
}
原理解析:observable 委托属性在 set 方法被调用时,会标记当前 Page 为 “Dirty”,并在下一帧触发 Virtual DOM 的 Diff 操作,从而更新 UI。
3.3 业务逻辑层:状态机与算术运算
键盘输入状态机:
数字键盘看似简单,实则涉及多种边界情况:
- 初始状态:显示 “0”。点击数字应替换 “0”,点击 “0” 不应变成 “00”。
- 小数点:只能输入一个小数点。
- 删除:删到最后一位时应恢复为 “0”。
- 长度限制:防止 UI 溢出,限制输入长度。
private fun onKeyClick(key: String) {
println("Key clicked: $key") // 日志是跨端调试的神器
when (key) {
"C" -> {
// 清空逻辑
inputAmount = "0"
}
"DEL" -> {
// 退格逻辑
if (inputAmount.length > 1) {
inputAmount = inputAmount.substring(0, inputAmount.length - 1)
} else {
inputAmount = "0"
}
}
"." -> {
// 小数点去重逻辑
if (!inputAmount.contains(".")) {
inputAmount += "."
}
}
else -> {
// 数字追加逻辑
if (inputAmount == "0") {
inputAmount = key // 替换初始0
} else {
if (inputAmount.length < 10) { // 长度熔断
inputAmount += key
}
}
}
}
// 状态驱动:输入改变 -> 立即触发计算
calculate()
}
汇率计算逻辑:
private fun calculate() {
try {
// 安全转换,处理 "12." 这种中间状态
val input = inputAmount.toDoubleOrNull() ?: 0.0
// 获取当前汇率
val currentRate = currencies[currentCurrencyIndex].rate
// 核心算术
val result = input * currentRate
// 精度控制:保留两位小数
// 注意:在金融计算中通常使用 BigDecimal,但 Demo 中 Double 足够
resultAmount = (round(result * 100.0) / 100.0).toString()
} catch (e: Exception) {
resultAmount = "Error"
println("Calculation error: ${e.message}")
}
}
4. UI 构建实战:从 DSL 到像素
Kuikly 的 DSL 极其类似 Flutter 的 Widget 树,但采用了 Flexbox 布局模型。
4.1 页面骨架与 Flexbox 布局策略
根视图通常是一个占满全屏的容器。
override fun body(): ViewBuilder {
val ctx = this // 捕获上下文,方便在闭包中访问状态
return {
View {
attr {
flex(1f) // flex: 1,占满父容器剩余空间
backgroundColor(Color(0xFFF5F7FA)) // 浅灰背景
// [重要] 设置为相对定位,作为内部绝对定位元素的锚点
positionType(FlexPositionType.RELATIVE)
}
// 安全区域容器:避开刘海屏和底部手势条
View {
attr {
flex(1f)
padding(
top = ctx.pageData.safeAreaInsets.top,
bottom = ctx.pageData.safeAreaInsets.bottom
)
}
// ... 顶部导航栏 ...
// ... 汇率显示卡片 ...
// ... 数字键盘区域 ...
}
// ... 弹窗层 (与安全区域容器同级,覆盖在上方) ...
}
}
}
4.2 样式系统详解与 API 陷阱
陷阱:FontWeight 的枚举 vs 函数
在开发过程中,我遇到了 Unresolved reference: fontWeight 错误。这是因为 Kuikly 的 DSL 为了性能优化,将部分常用属性直接映射为函数,而不是属性赋值。
- 错误直觉:
fontWeight = FontWeight.BOLD - 正确姿势:
fontWeightBold()
Text {
attr {
text(ctx.resultAmount)
fontSize(32f)
color(Color(0xFF007AFF))
// 动态样式逻辑
if (isHighlighted) {
fontWeightBold()
} else {
fontWeightNormal()
}
}
}
4.3 交互事件系统
事件通过 event 代码块绑定。值得注意的是,事件回调是在主线程执行的,所以不要进行耗时操作。
View {
// 这是一个按钮
event {
click {
// 点击回调
println("Toggle picker visibility")
// 修改 observable 变量,触发 UI 刷新
ctx.isPickerVisible = !ctx.isPickerVisible
}
}
}
5. 填坑指南:在受限环境下实现复杂组件
这是本项目的精华部分。在没有原生 Dropdown 和 ScrollView 支持的下,如何实现复杂交互?
5.1 消失的下拉框:层叠上下文与 Z-Index
挑战:我最初尝试在汇率结果行下方直接放置一个绝对定位的列表。结果发现列表被父容器(显示卡片)裁剪了,或者被下方的键盘遮挡。
原理:在 Flexbox 和原生渲染中,overflow 属性和层叠顺序(Z-Order)决定了元素的可见性。如果父容器设置了 overflow: hidden(某些圆角实现会自动裁剪),子元素就无法显示在父容器外部。
解决方案:全屏 Dialog 模式
我放弃了局部下拉框,改用全屏模态框。将弹窗代码移到根视图的末尾,使其在 DOM 树中处于最后位置(天然覆盖),并配合 zIndex。
// 只有当 isPickerVisible 为 true 时才渲染 DOM
if (ctx.isPickerVisible) {
// 1. 全屏容器
View {
attr {
// 绝对定位铺满全屏
positionType(FlexPositionType.ABSOLUTE)
top(0f); left(0f); right(0f); bottom(0f)
// 强制最高层级
zIndex(9999)
// Flex 居中,使内部的 Dialog 居中显示
justifyContentCenter()
alignItemsCenter()
}
// 2. 遮罩层 (半透明黑)
View {
attr {
positionType(FlexPositionType.ABSOLUTE)
top(0f); left(0f); right(0f); bottom(0f)
backgroundColor(Color(0x80000000))
}
// 点击遮罩关闭弹窗
event { click { ctx.isPickerVisible = false } }
}
// 3. 弹窗主体
View {
attr {
width(320f)
backgroundColor(Color.WHITE)
borderRadius(12f)
zIndex(10000) // 确保在遮罩之上
}
// ... 内容 ...
}
}
}
5.2 缺失的 ScrollView:手动分页算法实现
挑战:货币列表有几十种,弹窗高度有限。KuiklyUI-mini 暂时没有暴露 ScrollView 组件,导致长列表直接撑爆布局或被截断。
解决方案:分页 (Pagination)
这是软件工程中的通用解法。既然不能滚,那就翻页。
算法实现:
- 定义
pageIndex(当前页) 和pageSize(每页5条)。 - 利用 Kotlin 集合操作
subList切割数据。
// 计算数据切片范围
val startIndex = ctx.pickerPageIndex * ctx.pickerPageSize
// 防止越界:endIndex 不能超过 list.size
val endIndex = kotlin.math.min(startIndex + ctx.pickerPageSize, ctx.currencies.size)
// 获取当前页数据
val pageItems = if (startIndex < ctx.currencies.size) {
ctx.currencies.subList(startIndex, endIndex)
} else {
emptyList()
}
// 渲染列表
pageItems.forEachIndexed { i, currency ->
val realIndex = startIndex + i // 还原真实索引
// ... 渲染 Item ...
}
// 底部控制栏逻辑
View {
// 上一页按钮
View {
attr {
// 第一页时变灰
backgroundColor(if (ctx.pickerPageIndex > 0) Color.BLUE else Color.GRAY)
}
event {
click { if (ctx.pickerPageIndex > 0) ctx.pickerPageIndex-- }
}
}
// 页码显示
Text { attr { text("${ctx.pickerPageIndex + 1} / $totalPages") } }
// 下一页按钮
View {
event {
click {
// 检查是否还有下一页
val total = (ctx.currencies.size + ctx.pickerPageSize - 1) / ctx.pickerPageSize
if (ctx.pickerPageIndex < total - 1) ctx.pickerPageIndex++
}
}
}
}
5.3 安全区域 (Safe Area) 的完美适配
现代手机都有刘海、挖孔或动态岛。如果不处理安全区域,顶部标题栏会被状态栏遮挡。
Kuikly 在 BasePager 中提供了 pageData.safeAreaInsets。
View {
attr {
// 利用 padding 避让
padding(
top = ctx.pageData.safeAreaInsets.top, // 避让刘海
bottom = ctx.pageData.safeAreaInsets.bottom // 避让底部横条
)
}
}
6. 全链路调试与发布
6.1 跨端日志系统
在 Shared 模块中使用的 println 会被 Kuikly 的适配层拦截并重定向。
- Android: 输出到 Logcat,Tag 通常为
System.out或Kuikly. - HarmonyOS: 输出到 HiLog.
- iOS: 输出到 Xcode Console.
技巧:在关键业务节点(如点击、计算、生命周期)打点,是排查跨端 UI 渲染问题的最快手段。
override fun created() {
super.created()
println("ExchangeRatePage created: Load ${currencies.size} currencies")
}
6.2 编译与构建
- Android: 直接运行 Android Studio 的
Run 'androidApp'。Gradle 会自动编译 Shared 模块并打包。 - HarmonyOS: 需要运行构建脚本(如
runOhosApp.sh或 Gradle 任务compileDebugKotlinOhosArm64)生成.so和.abc文件,然后复制到ohosApp工程目录,最后在 DevEco Studio 中运行。
运行结果展示
7. 总结与展望
通过这几千字的复盘,我们从零构建了一个可以在 Android 和 HarmonyOS 上完美运行的汇率计算器。
核心收获:
- 架构思维:理解了 Shared Core 与 Native Shell 的分离,这是 KMP 开发的基石。
- DSL 熟练度:掌握了 Flexbox 布局在 Kotlin 中的表达方式,以及如何规避 API 设计的“坑”。
- 工程化解题能力:在组件缺失的极端条件下,利用基础原语(View/Text)和逻辑算法(分页/层级控制)实现了复杂的业务需求。
未来展望:
虽然目前使用了“分页”来规避 ScrollView 的缺失,但在实际生产中,我们可以通过 Kuikly 的 Bridge 机制,封装原生的 RecyclerView (Android) 和 List (ArkUI) 供 Shared 模块调用,从而实现真正的原生级滚动体验。这将是进阶篇的好题材。
希望这篇长文能成为你跨平台开发路上的坚实向导!🚀
拓展阅读
- 【开源鸿蒙跨平台开发–KuiklyUI–01】 Windows平台Kuikly OpenHarmony开发环境搭建及脚本编译模板工程流程
- 【开源鸿蒙跨平台开发–KuiklyUI–02】华为云真机部署实战指南
- Kuikly官方文档
- OpenHarmony官方文档
- Kotlin Multiplatform官方文档
欢迎加入开源鸿蒙跨平台社区:开源鸿蒙跨平台开发者社区
作者:Goway_Hui
时间:2026-01-31
版权:本文基于 KuiklyUI-mini 开源项目实践撰写。
如果觉得本项目对你有帮助,欢迎点赞收藏!
更多推荐





所有评论(0)