【开源鸿蒙跨平台开发–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) } } 时,发生了什么?

  1. Virtual DOM 构建:Kotlin 代码在运行时构建了一棵轻量级的虚拟 DOM 树。
  2. Diff 算法:当状态改变时,框架计算新旧树的差异。
  3. 指令分发:差异被转化为指令序列(Create, Update, Delete)。
  4. 原生映射
    • 在 Android 上,指令通过 JNI 传递给 Java 层,创建 FrameLayout 或自定义 ViewGroup
    • 在 HarmonyOS 上,指令传递给 ArkUI,创建对应的 StackColumn

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 业务逻辑层:状态机与算术运算

键盘输入状态机
数字键盘看似简单,实则涉及多种边界情况:

  1. 初始状态:显示 “0”。点击数字应替换 “0”,点击 “0” 不应变成 “00”。
  2. 小数点:只能输入一个小数点。
  3. 删除:删到最后一位时应恢复为 “0”。
  4. 长度限制:防止 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. 填坑指南:在受限环境下实现复杂组件

这是本项目的精华部分。在没有原生 DropdownScrollView 支持的下,如何实现复杂交互?

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)
这是软件工程中的通用解法。既然不能滚,那就翻页。

算法实现

  1. 定义 pageIndex (当前页) 和 pageSize (每页5条)。
  2. 利用 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.outKuikly.
  • 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 上完美运行的汇率计算器。

核心收获

  1. 架构思维:理解了 Shared Core 与 Native Shell 的分离,这是 KMP 开发的基石。
  2. DSL 熟练度:掌握了 Flexbox 布局在 Kotlin 中的表达方式,以及如何规避 API 设计的“坑”。
  3. 工程化解题能力:在组件缺失的极端条件下,利用基础原语(View/Text)和逻辑算法(分页/层级控制)实现了复杂的业务需求。

未来展望
虽然目前使用了“分页”来规避 ScrollView 的缺失,但在实际生产中,我们可以通过 Kuikly 的 Bridge 机制,封装原生的 RecyclerView (Android) 和 List (ArkUI) 供 Shared 模块调用,从而实现真正的原生级滚动体验。这将是进阶篇的好题材。

希望这篇长文能成为你跨平台开发路上的坚实向导!🚀


拓展阅读


作者:Goway_Hui
时间:2026-01-31
版权:本文基于 KuiklyUI-mini 开源项目实践撰写。

如果觉得本项目对你有帮助,欢迎点赞收藏!

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐