Building a Stock App with Kuikly: Multi-Module KMP Setup & Reactive System Deep Dive
The author shares hands-on experience building a stock viewing app with Tencent's Kuikly Kotlin Multiplatform framework, covering multi-module project setup, network layer migration, component encapsulation, and a deep technical analysis of Kuikly's reactive update mechanism with practical solutions for child component observation.
Kuikly is a Kotlin Multiplatform (KMP) framework from Tencent's TDS team. The author built a stock viewing app called SaiRen (repository: https://github.com/1250422131/SaiRen) to explore Kuikly. The Kuikly framework site is at https://kuikly.tds.qq.com/home.
Multi-Module Project Setup
Since Kuikly is based on KMP, existing platform-agnostic code can be migrated directly. The author extracted common designs from a previous KMP project (BILIBILIAS, article: https://juejin.cn/post/7641139636971323443) and created three modules: common, chart, and network.
Common Module Configuration
The common module mirrors Kuikly's own Shared module structure. The author copied the Gradle build scripts from Shared, including both the standard build.gradle.kts and the HarmonyOS-specific build.ohos.gradle.kts. Only commonMain dependencies (api shared dependencies) were copied so other modules depending on common become KMP-compatible. AI assistance (Tencent's WorkBuddy) was used to help with the migration and verification.
Network Layer Encapsulation
The network module depends on the common module. The author migrated a previously designed network request wrapper but noted Kuikly cannot use Ktor directly; instead, Kuikly's own network request components must be used.
kotlinx.serialization Integration
Serialization is available via Kuikly's component marketplace (search link: https://kuikly.tds.qq.com/home/components?q=kotlinx.serialization). The dependency must be added with Tencent's Maven repository since it's only published there. Usage follows standard kotlinx.serialization patterns.
Network Logic Migration
The author wrapped Kuikly's network request API into Kotlin Flow, where the Flow emits network request states. Coroutines library was added to support Flow. The migration demonstrates reusing existing business logic without starting from scratch.
Component Encapsulation Experience
Kuikly uses a Compose-like DSL but lacks Compose's composition model, which initially felt awkward. Components require paired attributes and events, so the author recommends using the IDE plugin to generate boilerplate.
Navigation Bar Component Example
The top navigation bar was split into two slots: left side (icon + title) and right side (action buttons). Attribute and event classes were defined:
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)
}The action slot uses a lambda of type (ViewContainer<*, *>.() -> Unit)?. In the component body, a Row layout invokes the lambda:
Row {
attr { alignItemsCenter() }
ctx.attr.action?.let { action -
action()
}
}Usage example shows passing multiple icons with click events via the action lambda:
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") }
}
}
}
}
}Reactive Programming Exploration
Kuikly's reactive system differed from the author's expectations (ViewModel-like pattern), leading to several pitfalls. The author created a state holder class but noted holding a pager reference is as dangerous as holding a Context in a ViewModel.
How Reactive Propagation Works
Directly reading an observable property inside an attr block works:
Text {
attr {
text(ctx.attr.details.data?.name ?: "")
}
}But passing the observable to a custom child component breaks reactivity:
StockDetailTopInfo {
attr {
details = ctx.store.state.detailResult
}
}
internal class StockDetailTopInfoViewAttr : ComposeAttr() {
var details: NetWorkResult<StockDetail> = emptyNetWorkResult()
}
// Child component body
Text {
attr {
text(ctx.attr.details.data?.name ?: "")
}
}The child's Text never receives updates. The official documentation (https://kuikly.tds.qq.com/DevGuide/reactive-update.html#%E9%94%99%E8%AF%AF1-%E5%9C%A8%E5%B1%9E%E6%80%A7%E8%AE%BE%E7%BD%AE%E4%B8%AD%E6%9C%AA%E5%BC%95%E7%94%A8%E5%93%8D%E5%BA%94%E5%BC%8F%E5%AD%97%E6%AE%B5) explains that extracting the observable into a local variable breaks the reactive dependency because attr re-executes its lambda on changes, and the delegate's getValue must be called during that execution to register the dependency.
Delegate Mechanism Analysis
The author traced the source: attr calls bindValueChange → valueChange → invokes the attr lambda → delegate's getValue is called → field name recorded in activeReadPropertyNames map → addObserver associates the observer with those fields and immediately clears the map. This clever mechanism captures exactly which observable properties are read during the attr lambda execution.
Child Component Reactive Observation Solutions
Solution 1: Child component holds its own observable property; parent's attr setter updates it:
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) {
[email protected] = value
field = value
}
}Solution 2 (preferred): Change the attribute type to a lambda () -> NetWorkResult<StockDetail>. The child calls the lambda inside its attr block, triggering getValue each render:
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() }
}
// Parent usage
StockDetailTopInfo {
attr {
details = { ctx.store.state.detailResult }
}
}This avoids extra observable objects and leverages the existing dependency tracking.
Conclusion
Despite the evolving ecosystem, the author found Kuikly's development experience solid after several weeks. The community still faces substantial platform adaptation work, but future development is promising. Kuikly's GitHub: https://github.com/Tencent-TDS/KuiklyUI, Documentation: https://kuikly.tds.qq.com/Introduction/arch.html.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
TDS Framework
Kuikly is a cross‑platform framework under TDS Client Services, built on Kotlin Multiplatform. A single codebase targets Android, iOS, HarmonyOS, H5, and mini programs, delivering high performance and dynamic updates for efficient full‑platform app development.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
