主题
第 10 章 实战项目 — 「迷你小红书」跨端 Demo
学习目标:把前 9 章学过的所有概念串起来,独立完成一个真·可跑的跨端 App。包含登录 / 瀑布流首页 / 笔记详情 / 发布 / 我的 5 个页面,覆盖 Pager 路由、observable、List/WaterfallList、HttpModule、StorageModule、自定义 Module 等核心能力。
10.1 项目总览
10.1.1 功能清单
┌──────────────────────────────────────────────────────────┐
│ 「迷你小红书」功能矩阵 │
├──────────────────────────────────────────────────────────┤
│ │
│ 首页 Tab │
│ - 瀑布流 2 列展示笔记 │
│ - 下拉刷新 / 上拉加载 │
│ - 点击笔记跳详情 │
│ │
│ 发现 Tab │
│ - 横向滑动话题分类 │
│ - 选中分类后展示对应笔记 │
│ │
│ 发布按钮(中间 Tab) │
│ - 输入标题 + 内容 │
│ - 调相机 / 相册(CameraModule) │
│ - 发布提交 │
│ │
│ 消息 Tab │
│ - 长列表展示消息 │
│ - 未读红点 │
│ │
│ 我的 Tab │
│ - 头像 + 昵称 + 简介 │
│ - 关注 / 粉丝 / 获赞 数据 │
│ - 切换主题(明暗模式) │
│ - 退出登录 │
│ │
│ 登录页(首次启动 / 退出后) │
│ - 手机号 + 验证码登录 │
│ - 复用第 4 章登录页 demo │
│ │
│ 笔记详情页 │
│ - 大图 + 标题 + 正文 │
│ - 点赞 / 收藏 / 评论按钮 │
│ - 评论列表 │
└──────────────────────────────────────────────────────────┘10.1.2 技术栈复盘
| 章节 | 用了什么 |
|---|---|
| 第 1-2 章 | 工程结构、shared / androidApp 模块 |
| 第 3 章 | Pager 三件套、生命周期 |
| 第 4 章 | View / Text / Image / Input / Scroller |
| 第 5 章 | observable / observableList / 全局 Store |
| 第 6 章 | List / WaterfallList / vfor |
| 第 7 章 | KMP commonMain / 跨端架构 |
| 第 8 章 | HttpModule / StorageModule / 自定义 CameraModule |
| 第 9 章 | 骨架屏、List 复用 |
10.2 工程目录
mini-xhs/
├── settings.gradle.kts
├── build.gradle.kts
├── buildSrc/
│
├── shared/
│ └── src/
│ ├── commonMain/kotlin/com/example/xhs/
│ │ ├── pages/
│ │ │ ├── MainPage.kt ← 主框架(含底 5 Tab)
│ │ │ ├── HomePage.kt ← 首页瀑布流
│ │ │ ├── ExplorePage.kt ← 发现页
│ │ │ ├── PublishPage.kt ← 发布页
│ │ │ ├── MessagePage.kt ← 消息页(复用第 6 章)
│ │ │ ├── ProfilePage.kt ← 我的
│ │ │ ├── LoginPage.kt ← 登录页(复用第 4 章)
│ │ │ └── NoteDetailPage.kt ← 笔记详情
│ │ │
│ │ ├── store/
│ │ │ ├── UserStore.kt ← 全局 用户状态
│ │ │ ├── ThemeStore.kt ← 主题
│ │ │ └── NoteStore.kt ← 笔记数据
│ │ │
│ │ ├── api/
│ │ │ └── NoteApi.kt ← HttpModule 封装
│ │ │
│ │ ├── modules/
│ │ │ └── CameraModule.kt ← 自定义 Module
│ │ │
│ │ └── components/
│ │ ├── BottomTabBar.kt ← 底 Tab 栏
│ │ └── NoteCard.kt ← 笔记卡片
│ │
│ └── androidMain/... ← Android 端 actual 实现
│
└── androidApp/... ← Android 宿主壳10.3 核心代码:主框架 + 底 Tab
kotlin
@Page("MainPage")
internal class MainPage : Pager() {
override fun body(): ViewBuilder {
val ctx = this
return {
attr {
backgroundColor(
if (ThemeStore.darkMode) Color(0xFF121212L)
else Color(0xFFF5F5F7L)
)
}
// ─── 主内容区(根据 Tab 切换)─────────
View {
attr { flex(1f) }
when (TabStore.currentTab) {
"home" -> HomePage().body().invoke(this)
"explore" -> ExplorePage().body().invoke(this)
"publish" -> PublishPage().body().invoke(this)
"message" -> MessagePage().body().invoke(this)
"profile" -> ProfilePage().body().invoke(this)
}
}
// ─── 底部 Tab 栏 ──────────────────────
BottomTabBar(
current = TabStore.currentTab,
onSelect = { TabStore.currentTab = it },
).invoke(this)
}
}
}
object TabStore {
var currentTab by observable("home")
}BottomTabBar 抽成可复用组件:
kotlin
fun BottomTabBar(current: String, onSelect: (String) -> Unit): ViewBuilder = {
View {
attr {
height(56f); flexDirectionRow()
backgroundColor(
if (ThemeStore.darkMode) Color(0xFF1E1E1EL) else Color.WHITE
)
border(Border(0.5f, BorderStyle.SOLID, Color(0xFFE0E0E0L)))
}
listOf(
Triple("home", "🏠", "首页"),
Triple("explore", "🔍", "发现"),
Triple("publish", "➕", ""),
Triple("message", "💬", "消息"),
Triple("profile", "👤", "我的"),
).forEach { (id, icon, label) ->
View {
attr { flex(1f); allCenter() }
event { click { onSelect(id) } }
if (id == "publish") {
// 中间发布按钮:圆形 + 蓝色
View {
attr {
size(40f, 40f); allCenter()
backgroundColor(Color(0xFFFF2442L))
borderRadius(20f)
marginBottom(4f)
}
Text {
attr { text(icon); fontSize(20f); color(Color.WHITE) }
}
}
} else {
Text {
attr {
text(icon); fontSize(20f)
color(
if (current == id) Color(0xFFFF2442L)
else Color(0xFF9E9E9EL)
)
}
}
Text {
attr {
text(label); fontSize(10f); marginTop(2f)
color(
if (current == id) Color(0xFFFF2442L)
else Color(0xFF9E9E9EL)
)
}
}
}
}
}
}
}10.4 全局 Store 设计
kotlin
// UserStore.kt
object UserStore {
var loggedIn by observable(false)
var userId by observable("")
var nickname by observable("")
var avatar by observable("")
var bio by observable("")
var followingCount by observable(0)
var followersCount by observable(0)
var likesCount by observable(0)
fun login(uid: String, name: String, av: String) {
userId = uid; nickname = name; avatar = av; loggedIn = true
// 持久化
acquireModule<SharedPreferencesModule>(SP_NAME).setItem("uid", uid)
}
fun logout() {
loggedIn = false; userId = ""; nickname = ""; avatar = ""
acquireModule<SharedPreferencesModule>(SP_NAME).removeItem("uid")
}
fun loadFromCache() {
val sp = acquireModule<SharedPreferencesModule>(SP_NAME)
val uid = sp.getItem("uid") ?: return
// 拉用户信息
NoteApi.fetchUserInfo(uid) { user ->
login(user.id, user.name, user.avatar)
bio = user.bio
followingCount = user.following
followersCount = user.followers
likesCount = user.likes
}
}
}
// ThemeStore.kt
object ThemeStore {
var darkMode by observable(false)
fun toggle() { darkMode = !darkMode }
}
// NoteStore.kt
object NoteStore {
val notes = observableListOf<Note>()
var loading by observable(false)
var page = 1
var hasMore by observable(true)
fun refresh(onDone: () -> Unit = {}) {
loading = true
page = 1
NoteApi.fetchNotes(page = 1) { list ->
notes.clear()
notes.addAll(list)
loading = false
onDone()
}
}
fun loadMore(onDone: () -> Unit = {}) {
if (loading || !hasMore) return
loading = true
page++
NoteApi.fetchNotes(page) { list ->
if (list.isEmpty()) {
hasMore = false
} else {
notes.addAll(list)
}
loading = false
onDone()
}
}
}10.5 首页:瀑布流
kotlin
data class Note(
val id: String,
val coverUrl: String,
val coverHeight: Float,
val title: String,
val authorAvatar: String,
val authorName: String,
val likes: Int,
val liked: Boolean,
)
@Page("HomePage")
internal class HomePage : Pager() {
override fun pageDidAppear() {
super.pageDidAppear()
if (NoteStore.notes.isEmpty()) {
NoteStore.refresh()
}
}
override fun body(): ViewBuilder {
val ctx = this
return {
attr {
backgroundColor(
if (ThemeStore.darkMode) Color(0xFF121212L)
else Color(0xFFF5F5F7L)
)
}
// 顶栏
View {
attr {
height(48f); allCenter()
backgroundColor(
if (ThemeStore.darkMode) Color(0xFF1E1E1EL) else Color.WHITE
)
}
Text {
attr {
text("迷你小红书")
fontSize(18f); fontWeightBold()
color(if (ThemeStore.darkMode) Color.WHITE else Color.BLACK)
}
}
}
// 内容
when {
NoteStore.loading && NoteStore.notes.isEmpty() -> {
renderSkeleton().invoke(this)
}
NoteStore.notes.isEmpty() -> {
View {
attr { flex(1f); allCenter() }
Text { attr { text("📭 暂无笔记") } }
}
}
else -> {
WaterfallList {
attr {
flex(1f)
columnCount(2); columnSpacing(8f); rowSpacing(8f)
paddingHorizontal(8f); paddingVertical(8f)
}
event {
scrollEnd { e ->
if (e.isAtBottom) NoteStore.loadMore()
}
}
Refresh {
attr { refreshing(NoteStore.loading) }
event { refresh { NoteStore.refresh() } }
}
vfor({ NoteStore.notes }) { note ->
NoteCard(note) {
ctx.openDetail(note)
}.invoke(this)
}
}
}
}
}
}
private fun openDetail(note: Note) {
acquireModule<PageRouterModule>(PageRouterModule.MODULE_NAME)
.openPage("NoteDetailPage", JSONObject().apply {
put("noteId", note.id)
})
}
private fun renderSkeleton(): ViewBuilder = {
View {
attr {
flex(1f); padding(8f)
flexDirectionRow(); flexWrapWrap()
}
repeat(8) { i ->
View {
attr {
width(176f) // (屏幕宽 - padding - gap) / 2
height(if (i % 2 == 0) 200f else 240f)
backgroundColor(Color(0xFFE0E0E0L))
borderRadius(8f)
margin(4f)
}
}
}
}
}
}NoteCard 组件:
kotlin
fun NoteCard(note: Note, onClick: () -> Unit): ViewBuilder = {
View {
attr {
backgroundColor(
if (ThemeStore.darkMode) Color(0xFF1E1E1EL) else Color.WHITE
)
borderRadius(8f)
}
event { click { onClick() } }
Image {
attr {
src(note.coverUrl)
width(176f); height(note.coverHeight)
backgroundColor(Color(0xFFE0E0E0L))
borderRadius(8f)
}
}
Text {
attr {
text(note.title); fontSize(13f)
color(if (ThemeStore.darkMode) Color.WHITE else Color.BLACK)
padding(8f); numberOfLines(2); lineHeight(18f)
}
}
View {
attr {
flexDirectionRow(); alignItemsCenter()
paddingHorizontal(8f); paddingBottom(8f)
justifyContentSpaceBetween()
}
View {
attr { flexDirectionRow(); alignItemsCenter() }
Image {
attr {
src(note.authorAvatar)
size(16f, 16f); borderRadius(8f)
backgroundColor(Color(0xFFE0E0E0L)); marginRight(4f)
}
}
Text {
attr {
text(note.authorName); fontSize(11f)
color(Color(0xFF9E9E9EL))
}
}
}
View {
attr { flexDirectionRow(); alignItemsCenter() }
Text {
attr {
text(if (note.liked) "♥" else "♡"); fontSize(13f)
color(if (note.liked) Color.RED else Color(0xFF9E9E9EL))
marginRight(2f)
}
}
Text {
attr {
text("${note.likes}"); fontSize(11f)
color(Color(0xFF9E9E9EL))
}
}
}
}
}
}10.6 我的页面:复用 UserStore + 主题切换
kotlin
@Page("ProfilePage")
internal class ProfilePage : Pager() {
override fun body(): ViewBuilder {
val ctx = this
return {
attr {
backgroundColor(
if (ThemeStore.darkMode) Color(0xFF121212L)
else Color(0xFFF5F5F7L)
)
}
if (!UserStore.loggedIn) {
renderLoginPrompt().invoke(this)
} else {
renderProfile(ctx).invoke(this)
}
}
}
private fun renderLoginPrompt(): ViewBuilder = {
View {
attr { flex(1f); allCenter(); padding(40f) }
Text {
attr {
text("👤")
fontSize(60f); marginBottom(16f)
}
}
Text {
attr {
text("还没登录")
fontSize(18f); fontWeightBold()
color(Color(0xFF424242L)); marginBottom(20f)
}
}
View {
attr {
width(180f); height(44f); allCenter()
backgroundColor(Color(0xFFFF2442L))
borderRadius(22f)
}
event {
click {
acquireModule<PageRouterModule>(PageRouterModule.MODULE_NAME)
.openPage("LoginPage", JSONObject())
}
}
Text {
attr {
text("立即登录"); color(Color.WHITE)
fontSize(15f); fontWeightBold()
}
}
}
}
}
private fun renderProfile(ctx: ProfilePage): ViewBuilder = {
Scroller {
attr {
flex(1f)
backgroundColor(
if (ThemeStore.darkMode) Color(0xFF121212L)
else Color(0xFFF5F5F7L)
)
}
// 顶部用户区
View {
attr {
backgroundColor(
if (ThemeStore.darkMode) Color(0xFF1E1E1EL) else Color.WHITE
)
padding(20f)
}
View {
attr { flexDirectionRow(); alignItemsCenter() }
Image {
attr {
src(UserStore.avatar)
size(64f, 64f); borderRadius(32f)
backgroundColor(Color(0xFFE0E0E0L)); marginRight(16f)
}
}
View {
attr { flex(1f) }
Text {
attr {
text(UserStore.nickname); fontSize(18f)
fontWeightBold()
color(if (ThemeStore.darkMode) Color.WHITE else Color.BLACK)
}
}
Text {
attr {
text(UserStore.bio.ifEmpty { "这家伙很懒" })
fontSize(13f)
color(Color(0xFF9E9E9EL)); marginTop(4f)
}
}
}
}
// 数据三栏
View {
attr {
flexDirectionRow(); justifyContentSpaceAround()
marginTop(20f)
}
listOf(
UserStore.followingCount to "关注",
UserStore.followersCount to "粉丝",
UserStore.likesCount to "获赞",
).forEach { (count, label) ->
View {
attr { allCenter() }
Text {
attr {
text("$count"); fontSize(18f); fontWeightBold()
color(if (ThemeStore.darkMode) Color.WHITE else Color.BLACK)
}
}
Text {
attr {
text(label); fontSize(12f); color(Color(0xFF9E9E9EL))
marginTop(2f)
}
}
}
}
}
}
// 设置项
View {
attr {
marginTop(12f)
backgroundColor(
if (ThemeStore.darkMode) Color(0xFF1E1E1EL) else Color.WHITE
)
}
renderSettingItem("🌓", "深色模式", ThemeStore.darkMode) {
ThemeStore.toggle()
}.invoke(this)
renderSettingItem("⭐", "我的收藏", null) { /* TODO */ }.invoke(this)
renderSettingItem("⚙️", "设置", null) { /* TODO */ }.invoke(this)
renderSettingItem("🚪", "退出登录", null, isDanger = true) {
UserStore.logout()
acquireModule<ToastModule>(ToastModule.MODULE_NAME).showToast("已退出")
}.invoke(this)
}
}
}
private fun renderSettingItem(
icon: String,
label: String,
switchValue: Boolean?,
isDanger: Boolean = false,
onClick: () -> Unit,
): ViewBuilder = {
View {
attr {
flexDirectionRow(); alignItemsCenter()
padding(16f)
border(Border(0.5f, BorderStyle.SOLID, Color(0xFFE0E0E0L)))
}
event { click { onClick() } }
Text { attr { text(icon); fontSize(18f); marginRight(12f) } }
Text {
attr {
text(label); fontSize(15f); flex(1f)
color(when {
isDanger -> Color.RED
ThemeStore.darkMode -> Color.WHITE
else -> Color.BLACK
})
}
}
when {
switchValue != null -> {
Text {
attr {
text(if (switchValue) "✅" else "⬜")
fontSize(20f)
}
}
}
else -> {
Text { attr { text("›"); fontSize(20f); color(Color(0xFFBDBDBDL)) } }
}
}
}
}
}10.7 笔记详情页
kotlin
@Page("NoteDetailPage")
internal class NoteDetailPage : Pager() {
private var note by observable<Note?>(null)
private val comments = observableListOf<Comment>()
override fun created() {
super.created()
val noteId = pageData.params.getString("noteId") ?: return
loadNote(noteId)
loadComments(noteId)
}
private fun loadNote(id: String) {
NoteApi.fetchNote(id) { n -> note = n }
}
private fun loadComments(id: String) {
NoteApi.fetchComments(id) { list ->
comments.clear()
comments.addAll(list)
}
}
override fun body(): ViewBuilder {
val ctx = this
return {
attr { backgroundColor(Color.WHITE) }
ctx.note?.let { n ->
Scroller {
attr { flex(1f) }
// 大图
Image {
attr {
src(n.coverUrl)
width(375f); height(400f)
backgroundColor(Color(0xFFE0E0E0L))
}
}
// 作者
View {
attr {
flexDirectionRow(); padding(16f)
alignItemsCenter()
}
Image {
attr {
src(n.authorAvatar)
size(40f, 40f); borderRadius(20f)
backgroundColor(Color(0xFFE0E0E0L)); marginRight(12f)
}
}
Text { attr { text(n.authorName); fontSize(15f); fontWeightBold() } }
}
// 标题 + 正文
Text {
attr {
text(n.title); fontSize(20f); fontWeightBold()
paddingHorizontal(16f); marginBottom(8f)
}
}
Text {
attr {
text("这里是详细内容…")
fontSize(14f); color(Color(0xFF424242L))
paddingHorizontal(16f); lineHeight(22f)
marginBottom(20f)
}
}
// 评论区
Text {
attr {
text("评论 ${ctx.comments.size}")
fontSize(14f); fontWeightBold()
paddingHorizontal(16f); marginBottom(12f)
}
}
ctx.comments.forEach { comment ->
renderComment(comment).invoke(this)
}
} ?: View {
attr { flex(1f); allCenter() }
Text { attr { text("加载中…") } }
}
// 底部操作栏
View {
attr {
flexDirectionRow(); height(56f); alignItemsCenter()
paddingHorizontal(16f)
backgroundColor(Color.WHITE)
border(Border(0.5f, BorderStyle.SOLID, Color(0xFFE0E0E0L)))
}
Input {
attr {
flex(1f); height(36f)
placeholder("说点什么…")
paddingHorizontal(12f)
backgroundColor(Color(0xFFF5F5F7L))
borderRadius(18f); fontSize(13f)
marginRight(12f)
}
}
actionIcon("♡", "${n.likes}") { /* 点赞 */ }.invoke(this)
actionIcon("⭐", "12") { /* 收藏 */ }.invoke(this)
actionIcon("💬", "${ctx.comments.size}") { /* 评论 */ }.invoke(this)
}
}
}
}
private fun actionIcon(icon: String, count: String, onClick: () -> Unit): ViewBuilder = {
View {
attr { allCenter(); marginLeft(12f) }
event { click { onClick() } }
Text { attr { text(icon); fontSize(18f) } }
Text {
attr { text(count); fontSize(10f); color(Color(0xFF9E9E9EL)); marginTop(2f) }
}
}
}
private fun renderComment(c: Comment): ViewBuilder = {
View {
attr {
flexDirectionRow(); paddingHorizontal(16f); paddingVertical(8f)
}
Image {
attr {
src(c.avatar); size(32f, 32f); borderRadius(16f)
backgroundColor(Color(0xFFE0E0E0L)); marginRight(8f)
}
}
View {
attr { flex(1f) }
Text { attr { text(c.author); fontSize(13f); fontWeightBold() } }
Text {
attr {
text(c.content); fontSize(13f); marginTop(2f)
color(Color(0xFF424242L))
}
}
}
}
}
}
data class Comment(val id: String, val author: String, val avatar: String, val content: String)10.8 接下来你可以做什么
┌──────────────────────────────────────────────────────────┐
│ 在这个 demo 基础上,自己加 5 个功能: │
├──────────────────────────────────────────────────────────┤
│ 1. 真接入一个测试后端(替换 mock) │
│ 2. 把 LoginPage 用真验证码 / 真 token │
│ 3. CameraModule 跑通拍照 + 上传 │
│ 4. WaterfallList 加分类筛选 │
│ 5. iOS 端跑起来(Mac + Xcode) │
└──────────────────────────────────────────────────────────┘更进阶可以挑战:
- 接入第三方登录 SDK(QQ / 微信)
- 加 JWT 鉴权 + token 刷新机制
- 写一个 PublishModule 把发布数据上传到 OSS
- 跑通鸿蒙端
- 把 home 模块做成动态化下发
10.9 学习闭环:你已经走过的路
┌─────────────────────────────────────────────────────────┐
│ ★ 14 天 / 10 章学习路线复盘 ★ │
├─────────────────────────────────────────────────────────┤
│ │
│ Day 1-2 : 第 1-2 章 理解定位 + 跑通 Hello │
│ Day 3-5 : 第 3-4 章 写出第一个有交互的页面 │
│ Day 6-8 : 第 5-6 章 搞定响应式 + 长列表 │
│ Day 9-11 : 第 7-8 章 理解架构 + 调原生 │
│ Day 12-13: 第 9 章 动态化 + 性能调优 │
│ Day 14 : 第 10 章 完整跨端 App │
│ │
│ 现在你能: │
│ ✅ 给团队介绍 Kuikly 的"为什么"和"是什么" │
│ ✅ 独立写出复杂的跨端 UI │
│ ✅ 理解每行 DSL 背后的"两棵树 + 渲染指令"机制 │
│ ✅ 接通跨端能力(相机、推送、IM) │
│ ✅ 调优性能、做动态化、做包体瘦身 │
│ ✅ 应付 Kuikly / 跨端框架方向的高级岗面试 │
│ │
└─────────────────────────────────────────────────────────┘🎤 10.10 大综合面试题(5 道场景题)
Q1. 设计一个"小红书首页",要兼顾首屏速度 + 滑动流畅 + 内存可控,你怎么做?
答:
- 首屏速度:
- 用骨架屏占位(first paint < 300ms)
- 数据走缓存优先 + 后台拉新
- SDK 延迟初始化非启动必需 Module
- 滑动流畅:
- 用
WaterfallList + vfor,确保复用 - item 抽 ViewBuilder,扁平化 2-3 层
- 图片走 Image 组件(异步解码 + LRU 缓存)
- 用
- 内存可控:
- List 复用机制保证 view 数恒定
- 图片 LRU 限制大小
- Pager 销毁时取消所有 listener / timer
Q2. 用户反馈"App 启动慢",给你一个 Kuikly 项目,怎么定位 + 优化?
答:
- 定位:
- Android Studio Profiler → 看 onCreate 各阶段耗时
- 在每个 Module 注册前后打时间戳
- 用 Systrace 看主线程是否有阻塞
- 优化:
- SDK 延迟初始化(非启动必需 Module 走 postIdle)
- 首屏骨架屏
- body 拆分(折叠以下延后挂)
- 数据预加载(启动后台拉首屏数据)
- 检查启动期是否有同步网络 / 同步 SP 读取
Q3. 我们要做一个"发布动态"功能,需要拍照 + 上传 + 跳转,请讲一下整体方案
答:
- UI 层:PublishPage,包含输入框 + 图片选择按钮 + 发布按钮
- 拍照:自定义
CameraModule,commonMain 定义 expect class,Android 用ACTION_IMAGE_CAPTURE,iOS 用UIImagePickerController - 上传:用
HttpModulePOST 图片到 OSS,回调拿到 URL - 发布:调业务 API,把 URL + 内容提交,成功后用
PageRouterModule跳到详情页 - 状态管理:Pager 内
loading字段 + 进度条 UI - 错误处理:网络失败 → Toast + 重试按钮
Q4. 一个 Pager 在生产环境出现内存泄漏,你怎么排查?
答:
- 现象确认:用 LeakCanary 抓泄漏堆栈
- 常见原因检查:
- 是否有 NotificationListener 没在 pageWillDestroy 解绑
- 是否有 Timer 没 cancel
- 是否被全局 Store 引用(GlobalStore.currentPage)
- 是否有匿名内部类持有 Pager 引用没释放
- 定位:MAT / Profiler 看 GC Roots 路径
- 修复:在 pageWillDestroy 里彻底清理;如必须长持有,用 WeakReference
Q5. 你想把现有 Android 项目某个页面"切换到 Kuikly",怎么做迁移?最少代价是什么?
答:3 步:
- 接入 SDK:在 Android 项目里依赖 Kuikly aar,配置 KuiklyApplication 和 KSP 插件
- 建 KMP 模块:新建一个 shared 模块,里面写一个
@Page("XxxPage")的 Pager - 替换打开方式:原本跳 Activity 的地方改成
KuiklyActivity.createIntent("XxxPage", ...)
最少代价:
- 业务代码可以渐进迁移,老 Activity / Kuikly 页面可以共存
- 第一次只迁一个简单页面练手(运营活动页是好选择)
- 共享数据用 SharedPreferences 或 Room(KMP 兼容库)
- 复杂业务通过 Module 桥接调老的 Java/Kotlin 代码
🎉 写在最后
恭喜你看完了这套 Kuikly 学习笔记!如果你按节奏走完了 14 天,现在你应该:
┌─────────────────────────────────────────────────────────┐
│ ✅ 知道 Kuikly 是什么、能做什么、不能做什么 │
│ ✅ 独立完成了一份 6 端通用的跨端 Demo │
│ ✅ 理解了「两棵树 + 渲染指令」的架构精髓 │
│ ✅ 掌握了 Module / observable / vfor 等核心 API │
│ ✅ 能向同事 / 面试官系统讲解 Kuikly │
└─────────────────────────────────────────────────────────┘后续学习方向:
- 官方文档:https://kuikly.tds.qq.com/ 查看最新 API
- GitHub Issue:https://github.com/Tencent-TDS/KuiklyUI 看真实问题讨论
- 看源码:从
core模块Pager类入手,跟踪一次渲染的完整链路 - 造轮子:实现自己的 Module / Component
- 看 Compose 源码:Kuikly 的设计哲学跟 Compose 有很多共通之处
祝你跨端开发愉快!🚀
🎬 可视化演示
演示加载缓慢或样式异常?点此在新标签页打开 ↗
💻 示例代码
kotlin
/**
* 第 10 章配套代码 · 首页瀑布流
*
* 文件位置:shared/src/commonMain/kotlin/com/example/xhs/pages/HomePage.kt
*
* 综合演示:
* - WaterfallList + vfor 长列表复用
* - Refresh 下拉刷新
* - scrollEnd 上拉加载
* - 骨架屏(首屏 loading 状态)
* - 全局 NoteStore 跨 Pager 共享数据
* - PageRouterModule 跳转详情页
* - 跟随 ThemeStore 切换暗色模式
*/
package com.example.xhs.pages
import com.example.xhs.components.NoteCard
import com.example.xhs.store.NoteStore
import com.example.xhs.store.ThemeStore
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.module.PageRouterModule
import com.tencent.kuikly.core.nvi.serialization.json.JSONObject
import com.tencent.kuikly.core.pager.Pager
import com.tencent.kuikly.core.views.Refresh
import com.tencent.kuikly.core.views.Text
import com.tencent.kuikly.core.views.View
import com.tencent.kuikly.core.views.WaterfallList
@Page("HomePage")
internal class HomePage : Pager() {
override fun pageDidAppear() {
super.pageDidAppear()
if (NoteStore.notes.isEmpty()) {
NoteStore.refresh()
}
}
override fun body(): ViewBuilder {
val ctx = this
return {
attr {
backgroundColor(
if (ThemeStore.darkMode) Color(0xFF121212L)
else Color(0xFFF5F5F7L)
)
}
// ─── 顶栏 ─────
View {
attr {
height(48f); allCenter()
backgroundColor(
if (ThemeStore.darkMode) Color(0xFF1E1E1EL) else Color.WHITE
)
}
Text {
attr {
text("迷你小红书")
fontSize(18f); fontWeightBold()
color(if (ThemeStore.darkMode) Color.WHITE else Color.BLACK)
}
}
}
// ─── 内容区 ─────
when {
NoteStore.loading && NoteStore.notes.isEmpty() ->
renderSkeleton().invoke(this)
NoteStore.notes.isEmpty() ->
renderEmpty().invoke(this)
else ->
renderWaterfall(ctx).invoke(this)
}
}
}
/** 骨架屏 */
private fun renderSkeleton(): ViewBuilder = {
View {
attr {
flex(1f); padding(8f)
flexDirectionRow(); flexWrapWrap()
}
repeat(8) { i ->
View {
attr {
width(174f)
height(if (i % 2 == 0) 200f else 240f)
backgroundColor(Color(0xFFE0E0E0L))
borderRadius(8f); margin(4f)
}
}
}
}
}
/** 空数据 */
private fun renderEmpty(): ViewBuilder = {
View {
attr { flex(1f); allCenter() }
Text { attr { text("📭"); fontSize(40f); marginBottom(8f) } }
Text {
attr {
text("暂无笔记")
fontSize(14f); color(Color(0xFF9E9E9EL))
}
}
}
}
/** 瀑布流 */
private fun renderWaterfall(ctx: HomePage): ViewBuilder = {
WaterfallList {
attr {
flex(1f)
columnCount(2)
columnSpacing(8f)
rowSpacing(8f)
paddingHorizontal(8f)
paddingVertical(8f)
}
event {
scrollEnd { e ->
val nearBottom = e.offsetY + e.viewSize.height >=
e.contentSize.height - 200f
if (nearBottom) NoteStore.loadMore()
}
}
// 下拉刷新
Refresh {
attr { refreshing(NoteStore.loading) }
event { refresh { NoteStore.refresh() } }
Text {
attr {
text(if (NoteStore.loading) "刷新中…" else "↓ 下拉刷新")
fontSize(12f); color(Color(0xFF9E9E9EL))
textAlignCenter(); paddingVertical(12f)
}
}
}
// 笔记列表
vfor({ NoteStore.notes }) { note ->
NoteCard(note) {
ctx.openDetail(note.id)
}.invoke(this)
}
// 加载更多 footer
if (NoteStore.notes.isNotEmpty() && !NoteStore.hasMore) {
View {
attr { height(50f); allCenter() }
Text {
attr {
text("— 没有更多了 —")
fontSize(12f); color(Color(0xFFBDBDBDL))
}
}
}
}
}
}
private fun openDetail(noteId: String) {
acquireModule<PageRouterModule>(PageRouterModule.MODULE_NAME)
.openPage("NoteDetailPage", JSONObject().apply {
put("noteId", noteId)
})
}
}kotlin
/**
* 第 10 章配套代码 · 主框架页(5 Tab + 路由切换)
*
* 文件位置:shared/src/commonMain/kotlin/com/example/xhs/pages/MainPage.kt
*
* 这是「迷你小红书」的入口 Pager。
* 所有 Tab 共享 1 个 Pager,按 currentTab 切换内容区,避免每次跳转重建。
*
* 涉及知识点:
* - 跨页面共享状态(TabStore / ThemeStore / UserStore)
* - ViewBuilder 抽组件(BottomTabBar)
* - 条件渲染(when 切换 Tab 内容)
*/
package com.example.xhs.pages
import com.example.xhs.components.BottomTabBar
import com.example.xhs.store.TabStore
import com.example.xhs.store.ThemeStore
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.pager.Pager
import com.tencent.kuikly.core.views.View
@Page("MainPage")
internal class MainPage : Pager() {
// 复用各 Tab 页面的实例(避免每次切换都新建)
private val homePage by lazy { HomePage() }
private val explorePage by lazy { ExplorePage() }
private val publishPage by lazy { PublishPage() }
private val messagePage by lazy { MessagePage() }
private val profilePage by lazy { ProfilePage() }
override fun body(): ViewBuilder {
return {
attr {
backgroundColor(
if (ThemeStore.darkMode) Color(0xFF121212L)
else Color(0xFFF5F5F7L)
)
}
// ─── 主内容区 ─────
View {
attr { flex(1f) }
when (TabStore.currentTab) {
"home" -> homePage.body().invoke(this)
"explore" -> explorePage.body().invoke(this)
"publish" -> publishPage.body().invoke(this)
"message" -> messagePage.body().invoke(this)
"profile" -> profilePage.body().invoke(this)
}
}
// ─── 底部 Tab 栏 ─────
BottomTabBar(
current = TabStore.currentTab,
onSelect = { id -> TabStore.currentTab = id },
).invoke(this)
}
}
}
// ─── 跨页面共享 Tab 状态 ───────────────────────
package com.example.xhs.store
import com.tencent.kuikly.core.reactive.handler.observable
object TabStore {
var currentTab by observable("home") // home / explore / publish / message / profile
}
object ThemeStore {
var darkMode by observable(false)
fun toggle() { darkMode = !darkMode }
}kotlin
/**
* 第 10 章配套代码 · 笔记 API 封装(HttpModule 包装层)
*
* 文件位置:shared/src/commonMain/kotlin/com/example/xhs/api/NoteApi.kt
*
* 演示如何把 HttpModule 调用封装成业务友好的 API。
* 真实工程会走 Ktor + 协程,这里用 Kuikly 内置 HttpModule 演示。
*/
package com.example.xhs.api
import com.example.xhs.pages.Comment
import com.example.xhs.pages.Note
import com.tencent.kuikly.core.module.HttpModule
import com.tencent.kuikly.core.nvi.serialization.json.JSONObject
import com.tencent.kuikly.core.pager.PagerManager
import kotlin.random.Random
object NoteApi {
private const val BASE_URL = "https://api.example.com"
private fun http(): HttpModule {
return PagerManager.getCurrentPager()
.acquireModule(HttpModule.MODULE_NAME)
}
/**
* 拉取笔记列表(瀑布流)
*/
fun fetchNotes(page: Int, callback: (List<Note>) -> Unit) {
// 真实场景:
// http().request(
// url = "$BASE_URL/notes",
// method = "GET",
// params = JSONObject().apply { put("page", page); put("size", 20) }
// ) { result -> ... }
// 演示:mock 数据(避免依赖真实后端)
val mocked = mockNotes(page = page, count = 20)
callback(mocked)
}
/**
* 拉取单条笔记详情
*/
fun fetchNote(id: String, callback: (Note?) -> Unit) {
// mock
val n = mockNotes(page = 1, count = 1).first().copy(id = id)
callback(n)
}
/**
* 拉取笔记的评论
*/
fun fetchComments(noteId: String, callback: (List<Comment>) -> Unit) {
val list = (1..5).map { i ->
Comment(
id = "c-$i",
author = "用户$i",
avatar = "https://example.com/avatar/$i.png",
content = listOf(
"好棒!",
"学到了 👍",
"求出处",
"我也想去",
"已收藏",
)[i - 1],
)
}
callback(list)
}
/**
* 点赞 / 取消点赞
*/
fun toggleLike(noteId: String, liked: Boolean, callback: (Boolean) -> Unit) {
// 真实场景调 POST /notes/{id}/like
callback(true)
}
/**
* 发布新笔记
*/
fun publishNote(
title: String,
content: String,
coverUrl: String,
callback: (success: Boolean, noteId: String?) -> Unit,
) {
// 真实场景调 POST /notes
callback(true, "note-${System.currentTimeMillis()}")
}
// ─── Mock 数据生成 ───────────────────────
private fun mockNotes(page: Int, count: Int): List<Note> {
val titles = listOf(
"周末爬山日记 ⛰️",
"今日穿搭分享",
"巨好吃的拉面店推荐",
"iPad 学习方法 📱",
"家居改造 / 不到 2k 的收纳",
"健身记录 day 30",
"云南旅行攻略",
"宝藏咖啡馆探店",
"新手化妆教程",
"读书笔记《人月神话》",
)
val authors = listOf("Tom", "李四", "Pinky", "小明", "Anna", "Jack", "Lisa")
return (1..count).map { i ->
val realId = (page - 1) * count + i
Note(
id = "note-$realId",
coverUrl = "https://example.com/cover/$realId.jpg",
coverHeight = (160..280).random().toFloat(),
title = titles.random() + " #$realId",
authorAvatar = "https://example.com/avatar/${authors.random()}.png",
authorName = authors.random(),
likes = Random.nextInt(20, 9999),
liked = false,
)
}
}
}kotlin
/**
* 第 10 章配套代码 · 笔记卡片可复用组件
*
* 文件位置:shared/src/commonMain/kotlin/com/example/xhs/components/NoteCard.kt
*
* 演示如何把通用 UI 块抽成 ViewBuilder 函数复用。
*/
package com.example.xhs.components
import com.example.xhs.pages.Note
import com.example.xhs.store.NoteStore
import com.example.xhs.store.ThemeStore
import com.tencent.kuikly.core.base.Color
import com.tencent.kuikly.core.base.ViewBuilder
import com.tencent.kuikly.core.views.Image
import com.tencent.kuikly.core.views.Text
import com.tencent.kuikly.core.views.View
/**
* 笔记卡片(瀑布流的 item)
* @param note 笔记数据
* @param onClick 点击卡片回调(一般跳转详情)
*/
fun NoteCard(note: Note, onClick: () -> Unit): ViewBuilder = {
View {
attr {
backgroundColor(
if (ThemeStore.darkMode) Color(0xFF1E1E1EL) else Color.WHITE
)
borderRadius(8f)
}
event { click { onClick() } }
// ─── 封面图 ─────
Image {
attr {
src(note.coverUrl)
width(174f)
height(note.coverHeight)
backgroundColor(Color(0xFFE0E0E0L))
borderRadius(8f)
}
}
// ─── 标题 ─────
Text {
attr {
text(note.title)
fontSize(13f)
color(if (ThemeStore.darkMode) Color.WHITE else Color.BLACK)
padding(8f)
numberOfLines(2)
lineHeight(18f)
}
}
// ─── 底部:作者 + 点赞数 ─────
View {
attr {
flexDirectionRow()
alignItemsCenter()
paddingHorizontal(8f)
paddingBottom(8f)
justifyContentSpaceBetween()
}
// 左:头像 + 昵称
View {
attr { flexDirectionRow(); alignItemsCenter(); flex(1f) }
Image {
attr {
src(note.authorAvatar)
size(16f, 16f)
borderRadius(8f)
backgroundColor(Color(0xFFE0E0E0L))
marginRight(4f)
}
}
Text {
attr {
text(note.authorName)
fontSize(11f)
color(Color(0xFF9E9E9EL))
flex(1f)
numberOfLines(1)
textOverFlowEllipsis()
}
}
}
// 右:点赞按钮(点击切换)
View {
attr { flexDirectionRow(); alignItemsCenter() }
event {
click {
// ★ 阻止点击穿透到外层卡片
NoteStore.toggleLike(note.id)
}
}
Text {
attr {
text(if (note.liked) "♥" else "♡")
fontSize(13f)
color(if (note.liked) Color(0xFFFF2442L) else Color(0xFF9E9E9EL))
marginRight(2f)
}
}
Text {
attr {
text(formatCount(note.likes))
fontSize(11f)
color(Color(0xFF9E9E9EL))
}
}
}
}
}
}
/** 格式化数字:1234 → 1.2k */
private fun formatCount(n: Int): String = when {
n >= 10000 -> "${n / 1000 / 10.0}w"
n >= 1000 -> "${n / 1000.0}k"
else -> n.toString()
}kotlin
/**
* 第 10 章配套代码 · 我的页面
*
* 文件位置:shared/src/commonMain/kotlin/com/example/xhs/pages/ProfilePage.kt
*
* 综合演示:
* - 基于 UserStore.loggedIn 的条件渲染(登录提示 vs 个人主页)
* - 基于 ThemeStore 的主题切换
* - PageRouterModule 跳到 LoginPage
* - ToastModule 退出提示
*/
package com.example.xhs.pages
import com.example.xhs.store.ThemeStore
import com.example.xhs.store.UserStore
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.module.PageRouterModule
import com.tencent.kuikly.core.module.ToastModule
import com.tencent.kuikly.core.nvi.serialization.json.JSONObject
import com.tencent.kuikly.core.pager.Pager
import com.tencent.kuikly.core.views.Image
import com.tencent.kuikly.core.views.Scroller
import com.tencent.kuikly.core.views.Text
import com.tencent.kuikly.core.views.View
@Page("ProfilePage")
internal class ProfilePage : Pager() {
override fun body(): ViewBuilder {
val ctx = this
return {
attr {
backgroundColor(
if (ThemeStore.darkMode) Color(0xFF121212L)
else Color(0xFFF5F5F7L)
)
}
if (!UserStore.loggedIn) {
renderLoginPrompt(ctx).invoke(this)
} else {
renderProfile(ctx).invoke(this)
}
}
}
/** 未登录提示页 */
private fun renderLoginPrompt(ctx: ProfilePage): ViewBuilder = {
View {
attr { flex(1f); allCenter(); padding(40f) }
Text { attr { text("👤"); fontSize(60f); marginBottom(16f) } }
Text {
attr {
text("还没登录")
fontSize(18f); fontWeightBold()
color(if (ThemeStore.darkMode) Color.WHITE else Color(0xFF424242L))
marginBottom(8f)
}
}
Text {
attr {
text("登录后查看你的笔记和数据")
fontSize(13f)
color(Color(0xFF9E9E9EL))
marginBottom(24f)
}
}
View {
attr {
width(180f); height(44f); allCenter()
backgroundColor(Color(0xFFFF2442L))
borderRadius(22f)
}
event {
click {
ctx.acquireModule<PageRouterModule>(PageRouterModule.MODULE_NAME)
.openPage("LoginPage", JSONObject())
}
}
Text {
attr {
text("立即登录")
color(Color.WHITE)
fontSize(15f)
fontWeightBold()
}
}
}
}
}
/** 已登录的个人主页 */
private fun renderProfile(ctx: ProfilePage): ViewBuilder = {
Scroller {
attr { flex(1f) }
// ─── 顶部用户信息卡 ─────
View {
attr {
backgroundColor(
if (ThemeStore.darkMode) Color(0xFF1E1E1EL) else Color.WHITE
)
padding(20f)
}
// 头像 + 昵称
View {
attr { flexDirectionRow(); alignItemsCenter() }
Image {
attr {
src(UserStore.avatar)
size(64f, 64f); borderRadius(32f)
backgroundColor(Color(0xFFE0E0E0L))
marginRight(16f)
}
}
View {
attr { flex(1f) }
Text {
attr {
text(UserStore.nickname)
fontSize(18f); fontWeightBold()
color(if (ThemeStore.darkMode) Color.WHITE else Color.BLACK)
}
}
Text {
attr {
text(UserStore.bio.ifEmpty { "这家伙很懒,什么都没写~" })
fontSize(13f)
color(Color(0xFF9E9E9EL))
marginTop(4f)
}
}
}
}
// 数据三栏
View {
attr {
flexDirectionRow()
justifyContentSpaceAround()
marginTop(20f)
}
listOf(
Triple(UserStore.followingCount, "关注", "📋"),
Triple(UserStore.followersCount, "粉丝", "👥"),
Triple(UserStore.likesCount, "获赞", "♥"),
).forEach { (count, label, _) ->
View {
attr { allCenter(); flex(1f) }
Text {
attr {
text("$count")
fontSize(20f); fontWeightBold()
color(if (ThemeStore.darkMode) Color.WHITE else Color.BLACK)
}
}
Text {
attr {
text(label); fontSize(12f)
color(Color(0xFF9E9E9EL)); marginTop(4f)
}
}
}
}
}
}
// ─── 设置项 ─────
View {
attr {
marginTop(12f)
backgroundColor(
if (ThemeStore.darkMode) Color(0xFF1E1E1EL) else Color.WHITE
)
}
renderSettingItem(
icon = "🌓",
label = "深色模式",
switchValue = ThemeStore.darkMode,
onClick = { ThemeStore.toggle() },
).invoke(this)
renderSettingItem(
icon = "⭐", label = "我的收藏",
switchValue = null, onClick = { /* TODO */ },
).invoke(this)
renderSettingItem(
icon = "📥", label = "草稿箱",
switchValue = null, onClick = { /* TODO */ },
).invoke(this)
renderSettingItem(
icon = "⚙️", label = "设置",
switchValue = null, onClick = { /* TODO */ },
).invoke(this)
renderSettingItem(
icon = "🚪", label = "退出登录",
switchValue = null,
isDanger = true,
onClick = {
UserStore.logout()
ctx.acquireModule<ToastModule>(ToastModule.MODULE_NAME)
.showToast("已退出登录")
},
).invoke(this)
}
// ─── 版本号 ─────
Text {
attr {
text("迷你小红书 v1.0.0")
fontSize(11f)
color(Color(0xFFBDBDBDL))
textAlignCenter()
marginTop(40f)
marginBottom(20f)
}
}
}
}
/** 设置项行 */
private fun renderSettingItem(
icon: String,
label: String,
switchValue: Boolean?,
isDanger: Boolean = false,
onClick: () -> Unit,
): ViewBuilder = {
View {
attr {
flexDirectionRow(); alignItemsCenter()
padding(16f)
border(
Border(0.5f, BorderStyle.SOLID, Color(0xFFE0E0E0L))
)
}
event { click { onClick() } }
Text {
attr {
text(icon); fontSize(18f); marginRight(12f)
}
}
Text {
attr {
text(label); fontSize(15f); flex(1f)
color(when {
isDanger -> Color(0xFFFF5252L)
ThemeStore.darkMode -> Color.WHITE
else -> Color.BLACK
})
}
}
when {
switchValue != null -> {
Text {
attr {
text(if (switchValue) "✅" else "⬜")
fontSize(20f)
}
}
}
else -> {
Text {
attr {
text("›")
fontSize(22f)
color(Color(0xFFBDBDBDL))
}
}
}
}
}
}
}kotlin
/**
* 第 10 章配套代码 · 全局 Store 集合
*
* 文件位置:shared/src/commonMain/kotlin/com/example/xhs/store/Stores.kt
*
* 演示如何用全局 object 装 observable 字段,实现「跨 Pager 状态共享」。
*
* 类比:Vue Pinia / React Zustand / Android ViewModel + StateFlow
*/
package com.example.xhs.store
import com.example.xhs.api.NoteApi
import com.example.xhs.pages.Note
import com.tencent.kuikly.core.module.SharedPreferencesModule
import com.tencent.kuikly.core.module.ToastModule
import com.tencent.kuikly.core.pager.Pager
import com.tencent.kuikly.core.reactive.collection.observableListOf
import com.tencent.kuikly.core.reactive.handler.observable
// ═══════════════════════════════════════════════════════════════════
// 用户 Store
// ═══════════════════════════════════════════════════════════════════
object UserStore {
var loggedIn by observable(false)
var userId by observable("")
var nickname by observable("")
var avatar by observable("")
var bio by observable("")
var followingCount by observable(0)
var followersCount by observable(0)
var likesCount by observable(0)
fun login(uid: String, name: String, av: String, b: String = "") {
userId = uid
nickname = name
avatar = av
bio = b
loggedIn = true
}
fun logout() {
loggedIn = false
userId = ""; nickname = ""; avatar = ""; bio = ""
followingCount = 0; followersCount = 0; likesCount = 0
}
}
// ═══════════════════════════════════════════════════════════════════
// 主题 Store
// ═══════════════════════════════════════════════════════════════════
object ThemeStore {
var darkMode by observable(false)
fun toggle() { darkMode = !darkMode }
}
// ═══════════════════════════════════════════════════════════════════
// 笔记 Store
// ═══════════════════════════════════════════════════════════════════
object NoteStore {
val notes = observableListOf<Note>()
var loading by observable(false)
var page = 1
var hasMore by observable(true)
fun refresh(onDone: () -> Unit = {}) {
if (loading) return
loading = true
page = 1
NoteApi.fetchNotes(page = 1) { list ->
notes.clear()
notes.addAll(list)
hasMore = list.isNotEmpty()
loading = false
onDone()
}
}
fun loadMore(onDone: () -> Unit = {}) {
if (loading || !hasMore) return
loading = true
page++
NoteApi.fetchNotes(page) { list ->
if (list.isEmpty()) {
hasMore = false
} else {
notes.addAll(list)
}
loading = false
onDone()
}
}
/**
* 切换某条笔记的点赞态:演示 data class.copy() 替换 item
*/
fun toggleLike(noteId: String) {
val idx = notes.indexOfFirst { it.id == noteId }
if (idx < 0) return
val n = notes[idx]
notes[idx] = n.copy(
liked = !n.liked,
likes = if (n.liked) n.likes - 1 else n.likes + 1,
)
}
}
// ═══════════════════════════════════════════════════════════════════
// Tab Store
// ═══════════════════════════════════════════════════════════════════
object TabStore {
var currentTab by observable("home")
}HomePage.kt ↗ · MainPage.kt ↗ · NoteApi.kt ↗ · NoteCard.kt ↗ · ProfilePage.kt ↗ · UserStore.kt ↗