主题
第 5 章 响应式状态 — observable / observableList / 数据驱动 UI
学习目标:彻底理解 Kuikly「改了状态 → UI 自动刷」的魔法是怎么实现的;掌握
observable/observableList/observableMap三大武器;学会用「派生属性」让代码更声明式;避开 5 个新手必踩的响应式坑。
5.1 为什么需要"响应式"
5.1.1 没有响应式:手动 setText 的痛
回到 Android 原生时代,写一个计数器需要:
kotlin
val text = findViewById<TextView>(R.id.count)
val btn = findViewById<Button>(R.id.btn)
var count = 0
btn.setOnClickListener {
count++
text.text = "Count: $count" // ★ 手动同步 UI
}3 行代码就有 1 个隐患:字段变了,但忘了 setText。当字段被多个按钮、多个网络回调修改时,遗漏更新会带来一堆 bug。
5.1.2 有了响应式:声明 = 实现
Kuikly 里同样的需求:
kotlin
private var count by observable(0)
override fun body(): ViewBuilder {
val ctx = this
return {
Text { attr { text("Count: ${ctx.count}") } }
View {
event { click { ctx.count++ } }
Text { attr { text("+1") } }
}
}
}📌 改了
count字段,UI 自动更新。你不需要写任何"刷新 UI 的代码" —— 这就是响应式的"魔法"。
5.2 三大响应式武器
┌─────────────────────────────────────────────────────┐
│ Kuikly 响应式三件套 │
├─────────────────────────────────────────────────────┤
│ │
│ 1. observable<T> 单值 │
│ var count by observable(0) │
│ var user by observable<User?>(null) │
│ │
│ 2. observableList<T> 响应式列表 │
│ val items = observableListOf<Item>() │
│ items.add(Item("foo")) // 自动触发更新 │
│ │
│ 3. observableMap<K,V> 响应式 Map │
│ val cache = observableMapOf<String, Data>() │
│ cache["key"] = Data(...) // 自动触发更新 │
│ │
└─────────────────────────────────────────────────────┘5.2.1 observable:单值响应式
kotlin
private var name by observable("Anonymous")
private var age by observable(0)
private var avatar by observable<String?>(null)
private var loading by observable(false)怎么用:跟普通字段一样读写,但读写都会被框架追踪:
kotlin
// 读:自动注册依赖
attr { text(ctx.name) }
// 写:自动通知所有依赖更新
fun login() {
ctx.name = "Tom" // 引用了 ctx.name 的 attr 块都会重跑
}5.2.2 observableList:列表响应式
kotlin
private val items = observableListOf<TodoItem>()
// 添加(自动通知 List 组件刷新)
items.add(TodoItem("买菜"))
// 删除(自动通知)
items.removeAt(0)
// 修改(自动通知)
items[0] = TodoItem("写笔记")📌 普通的
mutableListOf没有响应式,改它的内容 UI 不会刷新。一定要用observableListOf。
5.2.3 observableMap:键值对响应式
kotlin
private val cache = observableMapOf<String, User>()
// 写:触发更新
cache["123"] = User("张三")
// 读:注册依赖
attr { text(cache["123"]?.name ?: "未知") }适用场景:缓存型 UI(按 ID 查显示)、表单字段映射、动态字段集合。
5.3 响应式背后的魔法:3 步原理
很多新人觉得响应式像"黑魔法",其实就是 3 步:
┌──────────────────────────────────────────────────────────┐
│ STEP 1:依赖收集(attr 块执行时) │
│ ────────────────────────────────────── │
│ Kuikly 用一个全局 currentBlock = 当前 attr 块 │
│ 每次读 observable 字段,记录:"这个字段被 currentBlock 用了"│
│ │
│ 读 ctx.count → 记录 [count → 这个 attr 块] │
│ 读 ctx.name → 记录 [name → 这个 attr 块] │
├──────────────────────────────────────────────────────────┤
│ STEP 2:写时通知(observable 的 setter 触发) │
│ ────────────────────────────────────── │
│ ctx.count = 5 → 查依赖表 → 找到 [count → attr 块] │
│ │
│ 把 attr 块加入"待重跑队列" │
├──────────────────────────────────────────────────────────┤
│ STEP 3:批量重跑(下一帧) │
│ ────────────────────────────────────── │
│ 合并同一帧内的多次更新(避免抖动) │
│ 重跑队列里的 attr 块 → 拿到新属性 → 下发原生指令 │
└──────────────────────────────────────────────────────────┘5.3.1 Kotlin by 委托是怎么"挂钩子"的
observable 本质是一个 ReadWriteProperty 委托:
kotlin
// 简化版伪代码
class ObservableDelegate<T>(initial: T) : ReadWriteProperty<Any?, T> {
private var value = initial
private val subscribers = mutableListOf<() -> Unit>()
override fun getValue(thisRef: Any?, property: KProperty<*>): T {
// ★ 收集依赖:把当前正在执行的 attr 块加入订阅
currentBlock?.let { subscribers.add(it) }
return value
}
override fun setValue(thisRef: Any?, property: KProperty<*>, newValue: T) {
if (value != newValue) {
value = newValue
// ★ 通知所有订阅者重跑
subscribers.forEach { it() }
}
}
}
fun <T> observable(initial: T): ReadWriteProperty<Any?, T> = ObservableDelegate(initial)📌 认知升级:
var x by observable(0)不是真的字段,而是 Kotlin 编译器把它转成getX()/setX()调用 → 转发给委托对象。委托对象里装的就是依赖收集 + 通知逻辑。这跟 Vue 3 的ref心智模型完全一样。
5.4 派生属性:让代码更声明式
5.4.1 痛点:手动同步派生数据
「计算属性」是任何响应式框架的灵魂。来看一个反例:
kotlin
private var firstName by observable("Tom")
private var lastName by observable("Smith")
private var fullName by observable("Tom Smith") // ← 派生数据
// 改 firstName 时,必须手动同步 fullName
fun setFirstName(s: String) {
firstName = s
fullName = "$firstName $lastName" // ← 容易忘
}5.4.2 解法:用 Kotlin getter 派生
Kuikly 没有专门的"computed"概念,但 Kotlin 的 get() 自动支持:
kotlin
private var firstName by observable("Tom")
private var lastName by observable("Smith")
private val fullName: String
get() = "$firstName $lastName" // ★ 自动随依赖变化fullName 的 getter 里读了 firstName 和 lastName(都是 observable),所以任何 attr 块读 fullName,会被自动追踪到对这两个字段的依赖。
kotlin
Text { attr { text(ctx.fullName) } } // firstName/lastName 任一变化都会刷新5.4.3 实战:手机号校验
kotlin
private var phone by observable("")
private val phoneInvalid: Boolean
get() = phone.isNotEmpty() && !phone.matches(Regex("^1[3-9]\\d{9}\$"))
private val canSubmit: Boolean
get() = phone.length == 11 && !phoneInvalid
// body 里直接用
Input {
attr {
border(Border(1f, SOLID, if (ctx.phoneInvalid) Color.RED else Color.GRAY))
}
}
View {
attr { backgroundColor(if (ctx.canSubmit) Color.BLUE else Color.GRAY) }
event { click { if (ctx.canSubmit) submit() } }
}📌 重要:派生属性用
val ... get() = ...而不是var ... = ...。前者每次访问都重新计算(自动响应),后者只算一次。
5.5 observableList 详解
5.5.1 列表的"两层响应式"
列表有两层变化:
┌─────────────────────────────────────────────────────┐
│ 第 1 层:列表本身的变化(add / remove / replace) │
│ items.add(...) → 触发列表"长度" 依赖 │
│ items.removeAt(0) → 同上 │
│ ↓ │
│ 第 2 层:列表中元素的变化(item 内部字段) │
│ items[0].count++ → ★ 这种改动 Kuikly 监测不到 │
│ 除非元素自身字段是 observable│
└─────────────────────────────────────────────────────┘5.5.2 「整个 item 替换」的写法
kotlin
data class Todo(val title: String, val done: Boolean)
private val todos = observableListOf<Todo>()
// ✅ 替换整个 item(触发刷新)
fun toggle(idx: Int) {
val t = todos[idx]
todos[idx] = t.copy(done = !t.done)
}
// ❌ 想直接改 done 字段是不行的(data class val 字段)
// todos[idx].done = !todos[idx].done // 编译失败5.5.3 「item 自身响应式」的写法
如果 item 内部状态频繁变(比如详情页的播放进度),可以让 item 自己也是响应式的:
kotlin
class Player {
var progress by observable(0f)
var playing by observable(false)
}
private val players = observableListOf<Player>()
fun setProgress(idx: Int, v: Float) {
players[idx].progress = v // ✅ 触发某个 item 的 attr 重跑
}5.5.4 常用 API 对照
| 操作 | 写法 | 触发刷新? |
|---|---|---|
| 添加 | list.add(item) | ✅ |
| 在指定位置插入 | list.add(0, item) | ✅ |
| 删除 | list.removeAt(0) | ✅ |
| 清空 | list.clear() | ✅ |
| 替换 | list[0] = newItem | ✅ |
| 排序 | list.sortBy { ... } | ✅ |
| 改 item 内部字段(非 observable) | list[0].xxx = y | ❌ |
| 改 item 内部字段(observable) | list[0].xxx = y | ✅ |
| 整个赋值 | list = newList | ❌(这是改字段引用,得用 var observable) |
5.6 完整示例:Todo List
把这一章学到的都用上:
kotlin
data class Todo(val id: Int, val title: String, val done: Boolean)
@Page("TodoListPage")
internal class TodoListPage : Pager() {
private val todos = observableListOf<Todo>()
private var input by observable("")
private var filter by observable("all") // all / active / done
private var nextId = 1
// ─── 派生属性 ──────────────────────
private val filteredTodos: List<Todo>
get() = when (filter) {
"active" -> todos.filter { !it.done }
"done" -> todos.filter { it.done }
else -> todos
}
private val activeCount: Int
get() = todos.count { !it.done }
override fun body(): ViewBuilder {
val ctx = this
return {
attr {
backgroundColor(Color(0xFFF5F5F7L))
paddingTop(40f)
paddingHorizontal(16f)
}
// ─── 输入区 ──────────────
View {
attr { flexDirectionRow(); marginBottom(16f) }
Input {
attr {
flex(1f)
height(44f)
placeholder("新增 Todo")
paddingHorizontal(12f)
backgroundColor(Color.WHITE)
borderRadius(8f)
marginRight(8f)
value(ctx.input)
}
event { onTextChange { v -> ctx.input = v.text } }
}
View {
attr {
width(64f); height(44f); allCenter()
backgroundColor(Color(0xFF1976D2L))
borderRadius(8f)
}
event {
click {
if (ctx.input.isNotBlank()) {
ctx.todos.add(Todo(ctx.nextId++, ctx.input.trim(), false))
ctx.input = ""
}
}
}
Text { attr { text("+"); color(Color.WHITE); fontSize(20f) } }
}
}
// ─── 筛选 Tab ─────────────
View {
attr { flexDirectionRow(); marginBottom(12f) }
listOf("全部" to "all", "未完成" to "active", "已完成" to "done")
.forEach { (label, value) ->
View {
attr {
flex(1f); height(36f); allCenter()
backgroundColor(
if (ctx.filter == value) Color(0xFF1976D2L)
else Color.WHITE
)
borderRadius(8f)
marginHorizontal(2f)
}
event { click { ctx.filter = value } }
Text {
attr {
text(label)
fontSize(13f)
color(
if (ctx.filter == value) Color.WHITE
else Color.BLACK
)
}
}
}
}
}
// ─── 计数 ─────────────────
Text {
attr {
text("还剩 ${ctx.activeCount} 项未完成")
fontSize(12f)
color(Color(0xFF9E9E9EL))
marginBottom(8f)
}
}
// ─── 列表渲染 ─────────────
ctx.filteredTodos.forEach { todo ->
View {
attr {
flexDirectionRow()
alignItemsCenter()
padding(12f)
marginBottom(8f)
backgroundColor(Color.WHITE)
borderRadius(8f)
}
// 选中圆圈
View {
attr {
size(20f, 20f)
allCenter()
borderRadius(10f)
border(Border(1.5f, SOLID, Color(0xFF1976D2L)))
backgroundColor(if (todo.done) Color(0xFF1976D2L) else Color.WHITE)
marginRight(12f)
}
event {
click {
val idx = ctx.todos.indexOfFirst { it.id == todo.id }
if (idx >= 0) {
ctx.todos[idx] = todo.copy(done = !todo.done)
}
}
}
if (todo.done) {
Text { attr { text("✓"); color(Color.WHITE); fontSize(13f) } }
}
}
Text {
attr {
flex(1f)
text(todo.title)
fontSize(15f)
color(if (todo.done) Color(0xFFBDBDBDL) else Color.BLACK)
textDecorationLineThrough() // 注意:实际只在 done=true 时加
}
}
// 删除按钮
Text {
attr {
text("✕")
color(Color(0xFFFF5252L))
fontSize(18f)
paddingHorizontal(8f)
}
event {
click { ctx.todos.removeAll { it.id == todo.id } }
}
}
}
}
}
}
}5.7 5 个新手必踩的坑
坑 1:在普通字段上期待响应式
kotlin
// ❌ 错误
private var count = 0 // 普通字段,改了 UI 不更新
// ✅ 正确
private var count by observable(0)坑 2:用 mutableListOf 期待列表响应式
kotlin
// ❌ 错误
private val items = mutableListOf<Item>()
items.add(...) // UI 不刷新
// ✅ 正确
private val items = observableListOf<Item>()
items.add(...) // ✅ UI 自动刷新坑 3:在 attr 外读 observable,期待依赖被收集
kotlin
// ❌ 错误
val current = ctx.count // 读取发生在 body() 一开始
return {
Text { attr { text("$current") } } // current 不再变
}
// ✅ 正确
val ctx = this
return {
Text { attr { text("${ctx.count}") } } // 在 attr 闭包内读,依赖会被追踪
}坑 4:派生用 var 而不是 val + get()
kotlin
// ❌ 错误:只在初始化时算一次
private var fullName = "$firstName $lastName"
// ✅ 正确:每次访问重新计算
private val fullName: String get() = "$firstName $lastName"坑 5:把 observable 委托用在 const / 顶层全局
kotlin
// ❌ 不推荐
private object Global {
var theme by observable(0) // 全局响应式管理麻烦
}
// ✅ 推荐
class GlobalStore {
val theme = observable(0) // 在某个 ViewModel/Store 类里管理
}📌 理由:Pager 销毁后,对应的依赖收集表会清理;全局对象的依赖会持续累积,可能引发意外的响应式行为或泄漏。
5.8 高级:跨 Pager 共享状态
如果多个页面共享同一份状态(如登录态、购物车),用一个单例 Store:
kotlin
object UserStore {
var loggedIn by observable(false)
var nickname by observable("")
var avatar by observable("")
fun login(name: String, av: String) {
loggedIn = true
nickname = name
avatar = av
}
fun logout() {
loggedIn = false
nickname = ""
avatar = ""
}
}
// 任何 Pager 都能读
class HomePage : Pager() {
override fun body() = {
val ctx = this
Text { attr { text("Hi, ${UserStore.nickname}") } }
}
}📌 这相当于「全局响应式 Store」,跟 Vue 的 Pinia / React 的 Zustand 一个思路。
5.9 章末小结
★ 第 5 章响应式知识图谱 ★
│
┌─────────────────────┼────────────────────┐
│ │ │
┌────▼─────┐ ┌──────▼───────┐ ┌─────▼──────┐
│ 三大武器 │ │ 原理 3 步 │ │ 派生属性 │
├──────────┤ ├──────────────┤ ├────────────┤
│ observable│ │ 1. 依赖收集 │ │ val ... get│
│ List │ │ 2. setter 通知│ │ 自动追踪 │
│ Map │ │ 3. 批量重跑 │ │ 简化代码 │
└──────────┘ └──────────────┘ └────────────┘
│
★ 心智模型:声明 = 实现 ★
改了字段 → UI 自己刷新🎤 5.10 章末面试题(10 道高频题)
Q1. observable 的工作原理是什么?
答:基于 Kotlin 的「属性委托」(by 关键字)。
var x by observable(0) 等价于 val xDelegate = ObservableDelegate(0),编译器自动把 x 的读写转成 xDelegate.getValue() / setValue()。
委托内部:
- getValue:当前正在执行的 attr 闭包注册为该字段的"订阅者"
- setValue:把所有订阅者加入"下一帧待更新队列",等本帧结束统一刷新
Q2. 为什么 mutableListOf 不能驱动 UI 刷新?
答:因为 mutableListOf 返回普通 MutableList,没有依赖收集机制。它的 add / remove 直接修改内部数组,没人通知 Kuikly。
observableListOf 返回的是 Kuikly 内部封装的响应式列表(类似 Vue 的 reactive array),每个修改方法都会通知所有依赖了它的 attr 块。
Q3. 为什么 attr 块里读 ctx.count 能被追踪,但 body() 一开始读就不行?
答:因为依赖收集发生在「attr 闭包执行时」。
具体机制:
- attr 块准备执行时 → 框架设置
currentBlock = 这个 attr 块 - attr 块里读 observable → 委托的 getter 把 currentBlock 注册为订阅者
- attr 块结束 → 清空 currentBlock
如果你在 body() 顶部读 observable,那时 currentBlock = null,没有上下文可以追踪,依赖丢失。
Q4. 派生属性应该用 val + get() 还是 var = 直接赋值?为什么?
答:用 val + get()。
kotlin
// ✅ 正确
private val fullName: String get() = "$firstName $lastName"
// ❌ 错误
private var fullName = "$firstName $lastName"原因:get() 是函数,每次访问都执行,自然能追踪到 firstName/lastName 变化;而 var = ... 是字段初始化,只在创建时算一次,之后再变都不更新。
Q5. 如何让一个对象列表中"某个 item 的字段变化"也驱动 UI?
答:两种方案:
方案 1(轻量):用 data class 的 copy() 替换整个 item,借助 list[idx] = newItem 触发刷新:
kotlin
todos[i] = todos[i].copy(done = !todos[i].done)方案 2(item 频繁变化):让 item 本身也是响应式的:
kotlin
class TodoItem {
var done by observable(false)
}
todos[i].done = true // 自动触发刷新Q6. observable 字段被多个 attr 块读,会执行多少次依赖收集?
答:每个 attr 块独立收集一次。
举例:3 个 Text 都读了 count,那么 count 的订阅者列表里会有 3 个条目。count 变化时,3 个 attr 块都会被加入待更新队列。
Kuikly 会做去重 + 批量合并,避免同一帧里重复执行同一个块。
Q7. 跨 Pager 共享状态怎么写?
答:用单例 object 装 observable 字段:
kotlin
object UserStore {
var loggedIn by observable(false)
var nickname by observable("")
}任何 Pager 的 attr 里 UserStore.nickname 都会被自动追踪。一处改 → 全应用 UI 自动同步。
注意单例的生命周期跟整个 App 一样长,不要把短期数据塞进去(如某次请求的 loading 状态)。
Q8. observable 跟 Compose 的 mutableStateOf 有啥区别?
答:心智模型一致,实现细节不同:
| 维度 | Kuikly observable | Compose mutableStateOf |
|---|---|---|
| 语法 | var x by observable(0) | var x by remember { mutableStateOf(0) } |
| 跟谁绑定 | 当前 Pager 实例 | 当前 Composable 调用 |
| 重组单元 | attr 块 | @Composable 函数 |
| 依赖收集 | 每个 attr 块单独 | 通过 Snapshot 系统 |
底层都是观察者模式 + 依赖追踪,差别在于追踪粒度和上下文管理方式。
Q9. 改了 observable 字段,UI 是同步刷新还是异步?
答:异步、批量、下一帧。
Kuikly 不会同步刷新(不然在一个事件里改 10 个字段就重渲染 10 次)。它把所有改动收集到一个「待重跑队列」,下一帧统一处理 + 自动去重。
这跟 React 的"批量 setState"、Vue 的 nextTick 思路一致。如果你需要立刻拿到最新 UI 状态,得在 setTimeout(0) 或 pagerView.getViewSize() 等异步回调里查。
Q10. 为啥 Kuikly 没有专门的"computed"概念?
答:因为 Kotlin 自带的 val ... get() = ... 已经够用了。
任何"派生属性"用 getter 就能自动响应(getter 里读了 observable,访问 getter 时自动注册为订阅)。
对比 Vue 必须用 computed(() => ...) 显式声明 —— Kuikly 借助 Kotlin 语言特性,让派生属性"无成本"。用 Kotlin 写响应式比用 JS 自然得多。
下一站 → 第 6 章 · 指令与列表 vfor / vif / List / PageList →
🎬 可视化演示
演示加载缓慢或样式异常?点此在新标签页打开 ↗
💻 示例代码
kotlin
/**
* 第 5 章配套代码 · Todo List(observableList + 派生属性 综合演示)
*
* 文件位置:shared/src/commonMain/kotlin/com/example/kuikly/pages/TodoListPage.kt
*
* 本示例展示:
* - observable 单值(input/filter/nextId)
* - observableList 列表响应式
* - 派生属性(filteredTodos / activeCount)
* - data class.copy() 替换 item 触发刷新
* - 跨字段联动:改 input → 加按钮文案变化
*/
package com.example.kuikly.pages
import com.tencent.kuikly.core.annotations.Page
import com.tencent.kuikly.core.base.Border
import com.tencent.kuikly.core.base.BorderStyle
import com.tencent.kuikly.core.base.Color
import com.tencent.kuikly.core.base.ViewBuilder
import com.tencent.kuikly.core.pager.Pager
import com.tencent.kuikly.core.reactive.collection.observableListOf
import com.tencent.kuikly.core.reactive.handler.observable
import com.tencent.kuikly.core.views.Input
import com.tencent.kuikly.core.views.Text
import com.tencent.kuikly.core.views.View
data class TodoItem(val id: Int, val title: String, val done: Boolean)
@Page("TodoListPage")
internal class TodoListPage : Pager() {
// ───── 响应式状态 ─────
private val todos = observableListOf<TodoItem>()
private var input by observable("")
private var filter by observable("all") // all / active / done
private var nextId = 1 // 普通字段(不需要驱动 UI)
// ───── 派生属性(自动响应)─────
private val filteredTodos: List<TodoItem>
get() = when (filter) {
"active" -> todos.filter { !it.done }
"done" -> todos.filter { it.done }
else -> todos
}
private val activeCount: Int get() = todos.count { !it.done }
private val canAdd: Boolean get() = input.trim().isNotEmpty()
override fun created() {
super.created()
// 演示用:预置 3 条
todos.add(TodoItem(nextId++, "看 Kuikly 第 5 章", true))
todos.add(TodoItem(nextId++, "敲一遍 Todo 示例", false))
todos.add(TodoItem(nextId++, "回顾响应式原理", false))
}
override fun body(): ViewBuilder {
val ctx = this
return {
attr {
backgroundColor(Color(0xFFF5F5F7L))
paddingTop(40f)
paddingHorizontal(16f)
}
// ─── 标题 ───────────────
Text {
attr {
text("我的 Todo")
fontSize(22f)
fontWeightBold()
color(Color.BLACK)
marginBottom(16f)
}
}
// ─── 输入区 ─────────────
View {
attr { flexDirectionRow(); marginBottom(16f) }
Input {
attr {
flex(1f); height(44f)
placeholder("写下你今天要做的事…")
placeholderColor(Color(0xFFBDBDBDL))
paddingHorizontal(12f)
backgroundColor(Color.WHITE)
borderRadius(8f)
marginRight(8f)
fontSize(14f)
color(Color.BLACK)
value(ctx.input)
}
event {
onTextChange { p -> ctx.input = p.text }
}
}
View {
attr {
width(64f); height(44f); allCenter()
backgroundColor(
if (ctx.canAdd) Color(0xFF1976D2L)
else Color(0xFFBDBDBDL)
)
borderRadius(8f)
opacity(if (ctx.canAdd) 1f else 0.6f)
}
event {
click {
if (!ctx.canAdd) return@click
ctx.todos.add(TodoItem(ctx.nextId++, ctx.input.trim(), false))
ctx.input = ""
}
}
Text {
attr {
text("+")
color(Color.WHITE)
fontSize(22f)
fontWeightBold()
}
}
}
}
// ─── 筛选 Tab ───────────
View {
attr { flexDirectionRow(); marginBottom(12f) }
listOf(
"全部" to "all",
"未完成" to "active",
"已完成" to "done",
).forEach { (label, value) ->
View {
attr {
flex(1f); height(36f); allCenter()
backgroundColor(
if (ctx.filter == value) Color(0xFF1976D2L)
else Color.WHITE
)
borderRadius(8f)
marginHorizontal(2f)
}
event { click { ctx.filter = value } }
Text {
attr {
text(label)
fontSize(13f)
color(
if (ctx.filter == value) Color.WHITE
else Color(0xFF424242L)
)
}
}
}
}
}
// ─── 计数(派生属性自动更新)─
Text {
attr {
text("还剩 ${ctx.activeCount} 项未完成 · 共 ${ctx.todos.size} 项")
fontSize(12f)
color(Color(0xFF9E9E9EL))
marginBottom(8f)
}
}
// ─── 列表渲染 ────────────
// 注:第 6 章会教用 List 组件做长列表复用,这里直接 forEach 简单演示
ctx.filteredTodos.forEach { todo ->
renderTodoRow(ctx, todo).invoke(this)
}
}
}
/** 单行 Todo 抽成 ViewBuilder,便于复用 + 阅读 */
private fun renderTodoRow(ctx: TodoListPage, todo: TodoItem): ViewBuilder = {
View {
attr {
flexDirectionRow()
alignItemsCenter()
padding(12f)
marginBottom(8f)
backgroundColor(Color.WHITE)
borderRadius(8f)
}
// ─── 完成态圆圈 ───
View {
attr {
size(20f, 20f); allCenter()
borderRadius(10f)
border(
Border(1.5f, BorderStyle.SOLID, Color(0xFF1976D2L))
)
backgroundColor(
if (todo.done) Color(0xFF1976D2L) else Color.WHITE
)
marginRight(12f)
}
event {
click {
// ★ 关键:data class .copy() 替换整个 item
val idx = ctx.todos.indexOfFirst { it.id == todo.id }
if (idx >= 0) {
ctx.todos[idx] = todo.copy(done = !todo.done)
}
}
}
if (todo.done) {
Text {
attr {
text("✓")
color(Color.WHITE)
fontSize(13f)
fontWeightBold()
}
}
}
}
// ─── 标题 ───
Text {
attr {
flex(1f)
text(todo.title)
fontSize(15f)
color(
if (todo.done) Color(0xFFBDBDBDL)
else Color.BLACK
)
if (todo.done) textDecorationLineThrough()
}
}
// ─── 删除 ───
Text {
attr {
text("✕")
color(Color(0xFFFF5252L))
fontSize(18f)
paddingHorizontal(8f)
}
event {
click {
ctx.todos.removeAll { it.id == todo.id }
}
}
}
}
}
}kotlin
/**
* 第 5 章配套代码 · 跨 Pager 共享状态(全局 Store)
*
* 文件位置:shared/src/commonMain/kotlin/com/example/kuikly/store/UserStore.kt
*
* 演示如何用单例 object 装 observable 字段,做"全局响应式 Store"。
* 多个 Pager 同时读 UserStore.xxx,会被自动追踪 → 一处改、处处刷新。
*
* 类比:Vue Pinia / React Zustand / Android ViewModel + StateFlow
*/
package com.example.kuikly.store
import com.tencent.kuikly.core.reactive.collection.observableListOf
import com.tencent.kuikly.core.reactive.handler.observable
object UserStore {
// ─── 用户基础信息 ───
var loggedIn by observable(false)
var userId by observable("")
var nickname by observable("游客")
var avatar by observable("")
// ─── 偏好设置 ───
var darkMode by observable(false)
var fontScale by observable(1.0f)
// ─── 消息中心 ───
val unreadMessages = observableListOf<String>()
// ─── 派生属性:未读数量 ───
val unreadCount: Int get() = unreadMessages.size
// ─── 业务方法 ───
fun login(uid: String, name: String, av: String) {
userId = uid
nickname = name
avatar = av
loggedIn = true
}
fun logout() {
userId = ""
nickname = "游客"
avatar = ""
loggedIn = false
unreadMessages.clear()
}
fun pushMessage(msg: String) {
unreadMessages.add(0, msg)
}
fun clearMessages() {
unreadMessages.clear()
}
fun toggleDarkMode() {
darkMode = !darkMode
}
}
/* ────────────────────────────────────────────────────────
* 使用示例(在任何 Pager 里):
*
* class HomePage : Pager() {
* override fun body() = {
* Text { attr {
* text("Hi, ${UserStore.nickname}")
* color(if (UserStore.darkMode) Color.WHITE else Color.BLACK)
* } }
*
* Text { attr { text("未读 ${UserStore.unreadCount} 条") } }
*
* View {
* event { click { UserStore.toggleDarkMode() } }
* Text { attr { text("切换主题") } }
* }
* }
* }
*
* ★ 任何一个 Pager 调 UserStore.login(...),所有页面 UI 自动同步更新。
* ──────────────────────────────────────────────────────── */