Skip to content

第 6 章 指令与列表 — vfor / vif / List / PageList 长列表实战

学习目标:掌握 Kuikly 的「条件渲染」和「列表渲染」两大场景;学会用 List / PageList 写出 60fps 的长列表;理解列表组件的「复用机制」是怎么省内存、省 CPU 的。


6.1 两大渲染范式

   ┌──────────────────────────────────────────────────┐
   │                "动态渲染"两件事                    │
   ├──────────────────────────────────────────────────┤
   │                                                   │
   │  1. 条件渲染                                       │
   │     "满足条件就显示,不满足就不显示"                 │
   │     例:登录态显示「我的」,未登录显示「登录」        │
   │                                                   │
   │  2. 列表渲染                                       │
   │     "把一组数据映射成一组 UI 节点"                   │
   │     例:商品列表、消息流、瀑布流                     │
   └──────────────────────────────────────────────────┘

Kuikly 用 Kotlin 的语言能力(if / forEach)天然支持这两件事,不需要额外的指令。但对于长列表,提供了专门的 List / PageList / WaterfallList 组件,性能更好。


6.2 条件渲染(vif 风格)

6.2.1 用 if 表达式

最简单:

kotlin
override fun body(): ViewBuilder {
    val ctx = this
    return {
        if (ctx.loggedIn) {
            Text { attr { text("Hi, ${ctx.nickname}") } }
        } else {
            View {
                event { click { /* 跳转登录 */ } }
                Text { attr { text("点击登录") } }
            }
        }
    }
}

📌 attr 块里任何 Kotlin 流程控制都能用 —— if/elsewhenforwhile?.let { },跟普通 Kotlin 代码无缝。

6.2.2 用 when 表达式

多分支用 when 更清晰:

kotlin
when (ctx.status) {
    "loading" -> ProgressBar { attr { color(Color.GRAY) } }
    "error"   -> Text { attr { text("加载失败"); color(Color.RED) } }
    "empty"   -> Text { attr { text("暂无数据") } }
    "success" -> renderContent(ctx).invoke(this)
}

6.2.3 短路写法:?.let

数据存在才渲染:

kotlin
ctx.user?.let { user ->
    Text { attr { text(user.name) } }
}

6.2.4 vif 注意事项

行为Kuikly 处理
条件由 false → true创建组件、挂载到 BuildTree
条件由 true → false销毁组件、从 BuildTree 移除
组件状态保留?不保留。条件切换 = 完全销毁重建

⚠️ 如果你需要"隐藏但保留状态",用 attr { visibility(false) }attr { opacity(0f) }不要用 if 切换


6.3 列表渲染(vfor 风格)

6.3.1 用 forEach

kotlin
val cities = listOf("北京", "上海", "广州", "深圳", "杭州")

cities.forEach { name ->
    Text { attr { text(name); fontSize(14f) } }
}

6.3.2 带 index 的版本

kotlin
cities.forEachIndexed { idx, name ->
    Text { attr { text("${idx + 1}. $name") } }
}

6.3.3 嵌套列表

kotlin
val groups = listOf("前端" to listOf("Vue", "React"), "后端" to listOf("Java", "Go"))

groups.forEach { (groupName, items) ->
    Text { attr { text(groupName); fontSize(16f); fontWeightBold() } }
    items.forEach { item ->
        Text { attr { text("  • $item"); fontSize(14f) } }
    }
}

6.3.4 forEach 的局限:长列表不行

forEach 在 attr 块里执行 = 一次性创建所有子节点。10 个 ok,1000 个就完蛋:

   ┌─────────────────────────────────────────────────────┐
   │  forEach 渲染 1000 条数据:                          │
   ├─────────────────────────────────────────────────────┤
   │   - 1000 个组件全部走 BuildTree → RenderTree         │
   │   - 创建 1000 个原生 View 节点                       │
   │   - 内存占用 ~50MB+                                  │
   │   - 首屏渲染卡 1-2 秒                                │
   │   - 即使滚出屏幕,依然在内存里                       │
   └─────────────────────────────────────────────────────┘

结论forEach 适合 <20 条 短列表(且不会快速增减)。再多必须用专门的 List


6.4 List 组件 — 长列表性能秘诀

6.4.1 List 是什么

List 是 Kuikly 提供的「虚拟列表」组件 —— 同时具备滚动 + 复用 + 懒加载能力:

   ┌──────────────────────────────────────────────────────┐
   │  List 的工作原理                                       │
   ├──────────────────────────────────────────────────────┤
   │                                                        │
   │  屏幕高度 600px,每个 item 80px → 一屏 7-8 个         │
   │                                                        │
   │  实际创建的原生 View 只有 ~12 个(一屏 + 缓冲区)      │
   │     ↑                                                  │
   │  滚动时:上方滑出屏幕的 View 被回收,复用到下方         │
   │                                                        │
   │  数据有 10000 条?只有 12 个 View 实例!              │
   │  内存占用 ~5MB(vs Scroller 的 500MB+)               │
   └──────────────────────────────────────────────────────┘

6.4.2 基础用法

kotlin
data class Article(val id: Int, val title: String, val author: String)

private val articles = observableListOf<Article>()

override fun body(): ViewBuilder {
    val ctx = this
    return {
        List {
            attr { flex(1f) }   // 占满屏幕

            // ★ 用 vfor 表达式:framework 帮你管理复用
            vfor({ ctx.articles }) { article ->
                articleCard(article).invoke(this)
            }
        }
    }
}

private fun articleCard(article: Article): ViewBuilder = {
    View {
        attr {
            height(80f)
            backgroundColor(Color.WHITE)
            padding(16f)
            marginBottom(8f)
        }
        Text { attr { text(article.title); fontSize(15f); fontWeightBold() } }
        Text { attr { text("@${article.author}"); fontSize(12f); marginTop(4f); color(Color.GRAY) } }
    }
}

6.4.3 vfor 是什么

vfor 是 Kuikly 提供的「列表迭代指令」,专门给 List / WaterfallList 用,不能在普通 View 里用

kotlin
vfor({ ctx.articles }) { item ->
    // 这里描述每个 item 的 UI
}

它跟 forEach 的区别:

维度forEachvfor
调用时机attr 块执行时(一次性)List 滚动时按需调用
创建节点全部只创建可视区附近
复用机制有(回收 + 复用)
内存跟数据量线性相关跟可视区相关
用在哪普通容器List / PageList / WaterfallList

6.4.4 列表事件

kotlin
List {
    attr { flex(1f) }

    event {
        scrollEnd { e ->
            // 滚到底部?拉下一页
            if (e.isAtBottom) loadMore()
        }
        dragBegin { /* 用户开始拖 */ }
        dragEnd   { /* 用户停止拖 */ }
    }

    vfor({ ctx.articles }) { /* ... */ }
}

6.4.5 下拉刷新 + 上拉加载

kotlin
@Page("FeedPage")
internal class FeedPage : Pager() {

    private val items = observableListOf<Item>()
    private var refreshing by observable(false)
    private var loadingMore by observable(false)
    private var page = 1

    override fun body(): ViewBuilder {
        val ctx = this
        return {
            List {
                attr { flex(1f) }
                event {
                    scrollEnd { e ->
                        if (e.offsetY + e.viewSize.height >= e.contentSize.height - 100
                            && !ctx.loadingMore) {
                            ctx.loadMore()
                        }
                    }
                }

                // 下拉刷新条
                Refresh {
                    attr { refreshing(ctx.refreshing) }
                    event { refresh { ctx.refresh() } }
                }

                vfor({ ctx.items }) { item ->
                    itemRow(item).invoke(this)
                }

                // 加载更多 footer
                if (ctx.loadingMore) {
                    View {
                        attr { height(50f); allCenter() }
                        Text { attr { text("加载中…"); color(Color.GRAY); fontSize(12f) } }
                    }
                }
            }
        }
    }

    private fun refresh() {
        refreshing = true
        page = 1
        // 模拟请求
        items.clear()
        items.addAll(fetchData(page))
        refreshing = false
    }

    private fun loadMore() {
        loadingMore = true
        page++
        items.addAll(fetchData(page))
        loadingMore = false
    }
}

6.5 PageList 组件 — 整页翻动

PageListList 几乎一模一样,区别是整页对齐滚动(类似抖音上下滑):

kotlin
PageList {
    attr {
        flex(1f)
        flexDirectionColumn()       // 也可以 row(横向轮播)
        pageItemSize(screen.height)  // 每"页"高度 = 屏幕高
    }
    vfor({ ctx.videos }) { video ->
        videoPlayer(video).invoke(this)
    }
}

应用场景:抖音 / 小红书短视频流、引导页、轮播图。


6.6 WaterfallList — 瀑布流

kotlin
WaterfallList {
    attr {
        flex(1f)
        columnCount(2)     // 2 列
        columnSpacing(8f)  // 列间距
        rowSpacing(8f)
    }
    vfor({ ctx.notes }) { note ->
        noteCard(note).invoke(this)   // 高度可变
    }
}

适用:小红书首页、Pinterest 风格图库。


6.7 完整实战:消息列表

把这一章学的全用上:

kotlin
data class Message(val id: Int, val avatar: String, val name: String, val content: String, val time: String, val unread: Int)

@Page("MessagePage")
internal class MessagePage : Pager() {
    private val messages = observableListOf<Message>()
    private var loading by observable(true)

    override fun created() {
        super.created()
        // 模拟加载
        messages.addAll(mockMessages(50))
        loading = false
    }

    override fun body(): ViewBuilder {
        val ctx = this
        return {
            attr { backgroundColor(Color(0xFFF5F5F7L)) }

            // 顶栏
            View {
                attr {
                    height(44f); flexDirectionRow(); allCenter()
                    backgroundColor(Color.WHITE)
                }
                Text { attr { text("消息"); fontSize(16f); fontWeightBold() } }
            }

            // 内容
            when {
                ctx.loading -> {
                    View {
                        attr { flex(1f); allCenter() }
                        Text { attr { text("加载中…"); color(Color.GRAY) } }
                    }
                }
                ctx.messages.isEmpty() -> {
                    View {
                        attr { flex(1f); allCenter() }
                        Text { attr { text("暂无消息"); color(Color.GRAY) } }
                    }
                }
                else -> {
                    List {
                        attr { flex(1f); paddingTop(8f) }

                        vfor({ ctx.messages }) { msg ->
                            View {
                                attr {
                                    flexDirectionRow()
                                    padding(12f)
                                    backgroundColor(Color.WHITE)
                                    marginBottom(1f)
                                }
                                event {
                                    click { /* 进入详情 */ }
                                }

                                Image {
                                    attr {
                                        src(msg.avatar)
                                        size(48f, 48f)
                                        borderRadius(24f)
                                        backgroundColor(Color(0xFFE0E0E0L))
                                        marginRight(12f)
                                    }
                                }

                                View {
                                    attr { flex(1f); justifyContentCenter() }
                                    View {
                                        attr { flexDirectionRow(); justifyContentSpaceBetween() }
                                        Text { attr { text(msg.name); fontSize(15f); fontWeightBold() } }
                                        Text { attr { text(msg.time); fontSize(11f); color(Color.GRAY) } }
                                    }
                                    Text {
                                        attr {
                                            text(msg.content); fontSize(13f); color(Color.GRAY)
                                            marginTop(4f); numberOfLines(1); textOverFlowEllipsis()
                                        }
                                    }
                                }

                                if (msg.unread > 0) {
                                    View {
                                        attr {
                                            minWidth(18f); height(18f); allCenter()
                                            paddingHorizontal(6f); borderRadius(9f)
                                            backgroundColor(Color.RED); marginLeft(8f)
                                        }
                                        Text {
                                            attr {
                                                text(if (msg.unread > 99) "99+" else "${msg.unread}")
                                                fontSize(10f); color(Color.WHITE)
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

6.8 列表性能优化 5 条军规

   ┌──────────────────────────────────────────────────────┐
   │   长列表性能黄金法则                                   │
   ├──────────────────────────────────────────────────────┤
   │                                                        │
   │   1. >20 条数据 → 必用 List / PageList / WaterfallList │
   │   2. item UI 抽成独立 ViewBuilder 函数                  │
   │   3. item 高度尽量固定(变高度更慢)                   │
   │   4. 复杂 item 用「平铺布局」而非深层嵌套               │
   │   5. 图片用 Image 组件(自带懒加载 + 缓存)             │
   │                                                        │
   └──────────────────────────────────────────────────────┘

6.8.1 反例:嵌套太深

kotlin
// ❌ 不好:4 层嵌套,每个 item 创建 10+ 节点
View {
    View {
        View {
            View {
                Text { ... }
            }
        }
    }
}

// ✅ 好:扁平化,每个 item 创建 4-5 节点
View {
    attr { flexDirectionRow() }
    Image { ... }
    View { Text { ... }; Text { ... } }
}

6.8.2 不要在 item 里用 forEach 循环创建子组件

kotlin
// ❌ 不好:每个 item 又有 forEach,复用复杂
List {
    vfor({ groups }) { group ->
        View {
            group.items.forEach { item ->         // ← item 内部又一个循环
                Text { attr { text(item) } }
            }
        }
    }
}

// ✅ 好:拆成两层 List
List {
    vfor({ flatItems }) { item ->     // 提前把数据拍平
        Text { attr { text(item) } }
    }
}

6.8.3 关注 vfor 的 key(如果版本支持)

部分版本 vfor 支持 key 参数,告诉框架「哪两个 item 是"同一个"」,复用更精确:

kotlin
vfor({ ctx.items }, key = { it.id }) { item ->
    // ...
}

📌 用 stable id 做 key,避免 list 顺序调整时大量 item 重建。这跟 React 的 key 概念一样。


6.9 章末小结

                    ★ 第 6 章渲染知识图谱 ★

        ┌─────────────────────┼────────────────────┐
        │                     │                    │
   ┌────▼─────┐        ┌──────▼───────┐     ┌─────▼──────┐
   │ 条件渲染 │        │ 短列表       │     │ 长列表     │
   ├──────────┤        ├──────────────┤     ├────────────┤
   │ if / when│        │ forEach      │     │ List       │
   │ ?.let    │        │ <20 条       │     │ vfor       │
   │ visibility│       │ 全部创建     │     │ 复用机制   │
   └──────────┘        └──────────────┘     └────────────┘

                                  ┌────────────────┼─────────────┐
                                  │                │             │
                            ┌─────▼─────┐    ┌────▼─────┐  ┌────▼──────┐
                            │ List      │    │ PageList │  │WaterfallList│
                            │ 普通滚动  │    │ 整页翻动 │  │ 瀑布流     │
                            └───────────┘    └──────────┘  └────────────┘

🎤 6.10 章末面试题(10 道高频题)

Q1. Kuikly 怎么做条件渲染?跟 Vue 的 v-if 有什么区别?

:直接用 Kotlin 的 if/when/?.let

kotlin
if (ctx.loggedIn) Text { ... } else Button { ... }
when (ctx.status) { ... }
ctx.user?.let { Text { ... } }

跟 Vue v-if 区别:v-if 是 Vue 模板语法的「指令」,需要框架解析;Kuikly 直接用 Kotlin 语言能力,没有自定义指令,更原生 / 更灵活。

Q2. forEach 跟 vfor 的本质区别是什么?

:执行时机和复用机制:

维度forEachvfor
调用时机attr 块执行时(一次性)List 按需(滚动时)
创建节点全部仅可视区
复用

forEach 是 Kotlin 标准库方法,跟 Kuikly 无关;vfor 是 Kuikly 内置的迭代指令,专门给虚拟列表用

Q3. 长列表为什么不能用 Scroller + forEach?

:因为 没有复用。Scroller 一次性挂载所有子组件,1000 条 = 1000 个原生 View。这会导致:

  • 首屏渲染慢(创建大量 View)
  • 内存占用大(每个 View 占几 KB - 几十 KB)
  • 滚动卡顿(每帧都要重新计算上千个节点的位置)

正确做法:用 List + vfor,框架会根据可视区做「只创建当前可见 ± 缓冲区,离开就回收」。

Q4. List 的复用机制是怎么实现的?

:核心思路是「View 池(Recycler Pool)」:

  1. 用户向下滚 → 顶部 item 滑出可视区 → 该 item 的原生 View 加入回收池
  2. 底部要展示新 item → 从回收池里取一个 View → 调 vfor 闭包获取新 item 数据 → 把 View 的属性更新成新数据

这跟 Android 的 RecyclerView、iOS 的 UITableView 思路完全一致。核心收益:N 条数据只需要 ~10 个 View 实例。

Q5. 用 if 隐藏组件会保留它的状态吗?

不会if (false) 时组件被完全销毁,内部所有 observable 字段、动画状态、滚动位置全部丢失。

如果想保留状态,用 attr { visibility(false) }attr { opacity(0f) },组件依然挂载,只是不可见。

Q6. 下拉刷新和上拉加载怎么写?

  • 下拉刷新:用 Refresh 子组件,绑定 refreshing 状态 + refresh 事件
  • 上拉加载:监听 List 的 scrollEnd 事件,判断是否到底部,是就触发加载

详细代码见 6.4.5。

Q7. PageList 跟 List 有啥区别?

  • List:自由滚动,停在哪算哪
  • PageList:每"页"对齐(自动停到 item 边界),常用作抖音上下滑、引导页、轮播图

PageList 实际就是 List + pagingEnable(true) + pageItemSize(...) 的预设配置。

Q8. 瀑布流(小红书首页那种 2 列)怎么写?

:用 WaterfallList

kotlin
WaterfallList {
    attr { columnCount(2); columnSpacing(8f); rowSpacing(8f) }
    vfor({ ctx.notes }) { note -> noteCard(note).invoke(this) }
}

它的复用机制跟 List 一样,但布局算法是"贪心填短列"。

Q9. List 的 item 高度可以变吗?

可以。Kuikly 的 List 支持「异构 item」(不同 item 不同高度),框架会按需测量。

性能上:高度可变 < 高度固定。如果你能预先知道 item 高度(比如固定 80px 的列表),首屏会快不少。

Q10. vfor 的 key 是干什么的?

:告诉框架「哪两个 item 是同一个对象」,这样在数据增删时能精准复用 / 销毁,而不是粗暴地按位置匹配。

kotlin
vfor({ ctx.items }, key = { it.id }) { item -> ... }

跟 React 的 key 一个意思:

  • 没 key:列表插一个到中间 → 后面所有 item 重新创建
  • 有 stable key:插一个到中间 → 只创建那一个,其他 item 原地复用

下一站 → 第 7 章 · 跨端架构原理 KMP + 两棵树 + Bridge →

🎬 可视化演示

演示加载缓慢或样式异常?点此在新标签页打开 ↗

💻 示例代码

kotlin
/**
 * 第 6 章配套代码 · 消息列表(List + vfor + 下拉刷新 + 上拉加载)
 *
 * 文件位置:shared/src/commonMain/kotlin/com/example/kuikly/pages/MessagePage.kt
 *
 * 本示例展示长列表的"标配能力":
 *   - List + vfor   长列表复用机制
 *   - Refresh        下拉刷新
 *   - scrollEnd      上拉加载更多
 *   - 状态机         loading/empty/success 三态切换
 */

package com.example.kuikly.pages

import com.tencent.kuikly.core.annotations.Page
import com.tencent.kuikly.core.base.Color
import com.tencent.kuikly.core.base.ViewBuilder
import com.tencent.kuikly.core.directives.vfor
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.Image
import com.tencent.kuikly.core.views.List
import com.tencent.kuikly.core.views.Refresh
import com.tencent.kuikly.core.views.Text
import com.tencent.kuikly.core.views.View

data class Message(
    val id: Int,
    val avatar: String,
    val name: String,
    val content: String,
    val time: String,
    val unread: Int,
)

@Page("MessagePage")
internal class MessagePage : Pager() {

    private val messages = observableListOf<Message>()
    private var loading by observable(true)
    private var refreshing by observable(false)
    private var loadingMore by observable(false)
    private var page = 1
    private var nextId = 1

    override fun created() {
        super.created()
        // 模拟首屏加载
        messages.addAll(mockData(page = 1, count = 20))
        loading = false
    }

    override fun body(): ViewBuilder {
        val ctx = this
        return {
            attr { backgroundColor(Color(0xFFF5F5F7L)) }

            // ─── 顶栏 ─────────────────────────
            View {
                attr {
                    height(48f)
                    flexDirectionRow()
                    allCenter()
                    backgroundColor(Color.WHITE)
                }
                Text {
                    attr {
                        text("消息")
                        fontSize(17f)
                        fontWeightBold()
                        color(Color.BLACK)
                    }
                }
            }

            // ─── 内容区:状态机切换 ──────────
            when {
                ctx.loading -> renderLoading().invoke(this)
                ctx.messages.isEmpty() -> renderEmpty().invoke(this)
                else -> renderList(ctx).invoke(this)
            }
        }
    }

    /** ── 状态 1:加载中 ── */
    private fun renderLoading(): ViewBuilder = {
        View {
            attr { flex(1f); allCenter() }
            Text {
                attr {
                    text("⏳ 加载中…")
                    fontSize(14f)
                    color(Color(0xFF9E9E9EL))
                }
            }
        }
    }

    /** ── 状态 2:空数据 ── */
    private fun renderEmpty(): ViewBuilder = {
        View {
            attr { flex(1f); allCenter() }
            Text {
                attr {
                    text("📭")
                    fontSize(40f)
                    marginBottom(8f)
                }
            }
            Text {
                attr {
                    text("暂无消息")
                    fontSize(14f)
                    color(Color(0xFF9E9E9EL))
                }
            }
        }
    }

    /** ── 状态 3:列表 ── */
    private fun renderList(ctx: MessagePage): ViewBuilder = {
        List {
            attr {
                flex(1f)
                paddingTop(8f)
            }
            event {
                scrollEnd { e ->
                    // 触底判断
                    val nearBottom = e.offsetY + e.viewSize.height >=
                                     e.contentSize.height - 100f
                    if (nearBottom && !ctx.loadingMore) {
                        ctx.loadMore()
                    }
                }
            }

            // ★ 下拉刷新 header
            Refresh {
                attr { refreshing(ctx.refreshing) }
                event { refresh { ctx.refresh() } }

                Text {
                    attr {
                        text(if (ctx.refreshing) "刷新中…" else "↓ 下拉刷新")
                        fontSize(12f)
                        color(Color(0xFF9E9E9EL))
                        textAlignCenter()
                        paddingVertical(12f)
                    }
                }
            }

            // ★ vfor 渲染列表
            vfor({ ctx.messages }) { msg ->
                renderMessageRow(msg).invoke(this)
            }

            // ★ 加载更多 footer
            if (ctx.loadingMore) {
                View {
                    attr {
                        height(50f)
                        allCenter()
                    }
                    Text {
                        attr {
                            text("加载更多…")
                            color(Color(0xFF9E9E9EL))
                            fontSize(12f)
                        }
                    }
                }
            }
        }
    }

    /** ── 单条消息行(抽出来便于复用 + 阅读)── */
    private fun renderMessageRow(msg: Message): ViewBuilder = {
        View {
            attr {
                flexDirectionRow()
                padding(12f)
                backgroundColor(Color.WHITE)
                marginBottom(1f)        // 1px 分割线效果
            }
            event { click { /* TODO 进入聊天详情 */ } }

            // 头像
            Image {
                attr {
                    src(msg.avatar)
                    size(48f, 48f)
                    borderRadius(24f)
                    backgroundColor(Color(0xFFE0E0E0L))
                    marginRight(12f)
                }
            }

            // 中间:名字 + 内容
            View {
                attr { flex(1f); justifyContentCenter() }

                View {
                    attr {
                        flexDirectionRow()
                        justifyContentSpaceBetween()
                        alignItemsCenter()
                    }
                    Text {
                        attr {
                            text(msg.name)
                            fontSize(15f)
                            fontWeightBold()
                            color(Color.BLACK)
                        }
                    }
                    Text {
                        attr {
                            text(msg.time)
                            fontSize(11f)
                            color(Color(0xFF9E9E9EL))
                        }
                    }
                }

                Text {
                    attr {
                        text(msg.content)
                        fontSize(13f)
                        color(Color(0xFF757575L))
                        marginTop(4f)
                        numberOfLines(1)
                        textOverFlowEllipsis()
                    }
                }
            }

            // 未读红点
            if (msg.unread > 0) {
                View {
                    attr {
                        minWidth(18f)
                        height(18f)
                        allCenter()
                        paddingHorizontal(6f)
                        borderRadius(9f)
                        backgroundColor(Color(0xFFFF3B30L))
                        marginLeft(8f)
                        alignSelfCenter()
                    }
                    Text {
                        attr {
                            text(if (msg.unread > 99) "99+" else "${msg.unread}")
                            fontSize(10f)
                            color(Color.WHITE)
                            fontWeightBold()
                        }
                    }
                }
            }
        }
    }

    // ─── 业务方法 ───────────────────────
    private fun refresh() {
        refreshing = true
        page = 1
        nextId = 1
        messages.clear()
        messages.addAll(mockData(page = 1, count = 20))
        refreshing = false
    }

    private fun loadMore() {
        loadingMore = true
        page++
        messages.addAll(mockData(page = page, count = 20))
        loadingMore = false
    }

    private fun mockData(page: Int, count: Int): List<Message> {
        return (1..count).map { i ->
            Message(
                id = nextId++,
                avatar = "https://example.com/avatar/$i.png",
                name = "用户$nextId",
                content = "第 $page 页的第 $i 条消息内容…",
                time = "${(11 + i % 12)}:${"%02d".format(i % 60)}",
                unread = if (i % 5 == 0) i % 100 else 0,
            )
        }
    }
}
kotlin
/**
 * 第 6 章配套代码 · 瀑布流(小红书风格 2 列)
 *
 * 文件位置:shared/src/commonMain/kotlin/com/example/kuikly/pages/WaterfallNotesPage.kt
 *
 * 演示 WaterfallList 用法 + 高度可变的 item。
 */

package com.example.kuikly.pages

import com.tencent.kuikly.core.annotations.Page
import com.tencent.kuikly.core.base.Color
import com.tencent.kuikly.core.base.ViewBuilder
import com.tencent.kuikly.core.directives.vfor
import com.tencent.kuikly.core.pager.Pager
import com.tencent.kuikly.core.reactive.collection.observableListOf
import com.tencent.kuikly.core.views.Image
import com.tencent.kuikly.core.views.Text
import com.tencent.kuikly.core.views.View
import com.tencent.kuikly.core.views.WaterfallList

data class Note(
    val id: Int,
    val coverUrl: String,
    val coverHeight: Float,    // 不同图片高度不同 → 瀑布流效果
    val title: String,
    val author: String,
    val likes: Int,
)

@Page("WaterfallNotesPage")
internal class WaterfallNotesPage : Pager() {

    private val notes = observableListOf<Note>()

    override fun created() {
        super.created()
        // 模拟数据:每个 note 高度 100~300 之间随机
        repeat(30) { i ->
            notes.add(
                Note(
                    id = i + 1,
                    coverUrl = "https://example.com/note/${i + 1}.jpg",
                    coverHeight = (100..300).random().toFloat(),
                    title = "笔记 #${i + 1} · " + listOf("旅行", "美食", "穿搭", "健身", "读书").random(),
                    author = "用户${i + 1}",
                    likes = (10..9999).random(),
                )
            )
        }
    }

    override fun body(): ViewBuilder {
        val ctx = this
        return {
            attr { backgroundColor(Color(0xFFF5F5F7L)) }

            // 顶栏
            View {
                attr {
                    height(48f); flexDirectionRow(); allCenter()
                    backgroundColor(Color.WHITE)
                }
                Text {
                    attr {
                        text("发现")
                        fontSize(17f)
                        fontWeightBold()
                    }
                }
            }

            // 瀑布流
            WaterfallList {
                attr {
                    flex(1f)
                    columnCount(2)            // 2 列
                    columnSpacing(8f)         // 列间距
                    rowSpacing(8f)            // 行间距
                    paddingHorizontal(8f)
                    paddingVertical(8f)
                }

                vfor({ ctx.notes }) { note ->
                    renderNoteCard(note).invoke(this)
                }
            }
        }
    }

    /** 单个笔记卡片:高度可变(封面图高度不同) */
    private fun renderNoteCard(note: Note): ViewBuilder = {
        View {
            attr {
                backgroundColor(Color.WHITE)
                borderRadius(8f)
            }
            event { click { /* TODO 进入详情 */ } }

            // 封面图(高度可变 → 瀑布流的"灵魂")
            Image {
                attr {
                    src(note.coverUrl)
                    width(100f)
                    height(note.coverHeight)
                    backgroundColor(Color(0xFFE0E0E0L))
                    borderRadius(8f)
                }
            }

            // 标题
            Text {
                attr {
                    text(note.title)
                    fontSize(13f)
                    color(Color.BLACK)
                    padding(8f)
                    numberOfLines(2)
                    lineHeight(18f)
                }
            }

            // 作者 + 点赞
            View {
                attr {
                    flexDirectionRow()
                    alignItemsCenter()
                    paddingHorizontal(8f)
                    paddingBottom(8f)
                    justifyContentSpaceBetween()
                }

                Text {
                    attr {
                        text(note.author)
                        fontSize(11f)
                        color(Color(0xFF9E9E9EL))
                    }
                }

                View {
                    attr { flexDirectionRow(); alignItemsCenter() }
                    Text {
                        attr {
                            text("♡")
                            fontSize(11f)
                            color(Color(0xFF9E9E9EL))
                            marginRight(2f)
                        }
                    }
                    Text {
                        attr {
                            text("${note.likes}")
                            fontSize(11f)
                            color(Color(0xFF9E9E9EL))
                        }
                    }
                }
            }
        }
    }
}

MessagePage.kt ↗ · WaterfallNotesPage.kt ↗