Skip to content

第 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 里读了 firstNamelastName(都是 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 observableCompose 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 自动同步更新。
 * ──────────────────────────────────────────────────────── */

TodoListPage.kt ↗ · UserStore.kt ↗