外观
7 UI 设计和业务开发
界面和业务主体都在这一步。要做的事只有两件,各自只认一份文档:
| 要做的事 | 去哪 |
|---|---|
| 画界面(组件、主题、字阶、间距、设计基调) | → UI组件库 hualuo-compose-ui |
| 界面怎么写才对(布局 / 状态 / 列表 / 动效 / 无障碍 / 性能 / 反模式) | → UI 设计原理 |
| 拿数据、调能力(登录态、存储、网络、媒体落盘、广告、权益) | → 工具库 obsession-shared |
一句话:界面全部按组件库文档写,能力全部从工具库来。这两层之外的东西,才是你自己的业务逻辑。
这两层里的东西别自己造
- 按钮、输入框、列表项、空态、轻提示、弹窗、加载指示器 → 组件库都有。别用裸 Material3 控件自己拼一套。
- 网络请求、键值存储、记录列表、媒体落盘 → 工具库都有。别自己拼
SharedPreferences/openFileOutput/MediaStore那一套(插件目录是隔离的,自己写会落到错位置)。
一、UI 设计
现代、美观是硬要求,不是加分项
插件和宿主一起摆在用户桌面上,长得像半成品就是不合格。做法:大面积留白、克制的层级(用 M3 语义色和圆角表达深浅,不靠边框和阴影堆叠)、一屏一个主要动作。标题栏、字阶、间距一律由组件库给 —— TopTitleScaffold 负责标题栏和三边 inset,字号走 MaterialTheme.typography.*,间距按 8dp 网格(常用 16dp)。自己拼标题行、自己写 fontSize、自己补 statusBarsPadding(),出来一定「差一点」。
主题不用你管
别自己调主题。插件入口(步骤 ⑥)已经用 PluginTheme { … } 把 App() 整个包住了。PluginTheme 会反射读宿主的深浅色设置(0 跟随系统 / 1 浅色 / 2 深色),经 resolveDarkTheme 折算成布尔值再传给 HuaLuoTheme —— 也就是说用户切浅色 / 深色,插件自动跟着变,你一行代码都不用写。
你要做的只有一件事:颜色全部走 token。
kotlin
MaterialTheme.colorScheme.surfaceContainerLow // 浅色深色各自都对不要做的事:
| 别写 | 为什么 |
|---|---|
HuaLuoTheme { … } | 不传 darkTheme 时它默认跟系统,直接忽略宿主的设置 —— 这正是「浅色 / 深色失效」的根因 |
SettingsManager.getDarkModeOption() 自己算 | 那套折算逻辑就是 PluginTheme 在做的 |
isSystemInDarkTheme() 判断深浅色 | 同上,等于绕过宿主设置 |
Color(0xFF...) 硬编码 | 换肤时它不跟着走 |
插件不要去组件库找主题入口。你的主题入口只有一个:PluginTheme(在 obsession-shared,见宿主桥)。
骨架怎么立
业务逻辑主体都落在这个 App() 里。骨架套路(onFinish 由 MainActivity 传入,见步骤 ⑥):
kotlin
package com.zhinian.plugin.what2eat.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.hualuo.compose.ui.components.TopTitleScaffold
@Composable
fun App(onFinish: () -> Unit) {
TopTitleScaffold(
title = "今天吃什么",
onBack = onFinish, // 退出插件 = 结束宿主 ProxyActivity
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
// …业务 UI
}
}
}不写 findActivity()
LocalContext 是 PluginContext 不是 Activity,逐层解包是绕路 —— 需要退出 / 跳转就从入口传回调(activity 本就是宿主 ProxyActivity)。
硬约束
组件库文档才是权威
→ 打开 UI 组件库文档(hualuo-compose-ui)
索引页已按「骨架与分组 / 按钮与动作 / 标签与输入 / 列表 / 反馈与状态 / 主题与工具」分好组。挑组件从索引进,再点开它自己那页看签名和参数 —— 每个组件页都是同一个顺序:基本用法 → 参数 → 要点 → 说明。「要点」和「说明」里的判断都是刻意的(不少地方和 M3 基线不一样),别按 M3 文档「纠正」回来。
- 标题栏用
TopTitleScaffold,别自己拼标题行、别自己补statusBarsPadding()。 - 字阶走
MaterialTheme.typography.*、颜色走MaterialTheme.colorScheme.*、圆角走AppShapes—— 不硬编码Color(0xFF...),不自己写RoundedCornerShape/fontSize。间距按 8dp 网格。 size用默认值。FilledButton、PillTab这类组件的size默认值已按设计规范标定,默认就是标准用法;随手传size = ButtonSize.SMALL属于例外,要有明确理由(比如一行并排三个以上动作)。- 别造已经有了的东西:轻提示用
PopupPrompt、按钮用FilledButton、图标按钮用ActionImageButton、列表项用ListItem、空态用EmptyHint、加载用LoadingIndicator—— 裸 Material3 控件不用。 - 分隔线用
HorizontalDivider,不要Divider()、也不要自己画线。 - 图标用
com.hualuo.compose.ui.icons.phosphor.*(Bold / Fill / Regular),不引入其它图标库。
三条硬规范(踩过坑,别再犯)
一、列表翻页:自动加载,不要按钮
滚动到底部自动请求下一页。不要写「点击『加载更多』按钮」—— 用户要的是无限流,多一次点击就是多一次摩擦。
优先用官方 Paging 3
→ 官方 Paging 3 分页:PagingSource + collectAsLazyPagingItems 天生就是滚到底自动 append, 「加载中 / 失败重试 / 没有更多了」三态、去重、配置变更存活全都不用自己写。 下面这段手写方案是退路 —— 只在接口不适配 PagingSource 时用(比如一次请求要合并多个来源,或者接口根本不是分页码形态)。
kotlin
val listState = rememberLazyListState()
LaunchedEffect(listState, query) {
snapshotFlow {
val info = listState.layoutInfo
val last = info.visibleItemsInfo.lastOrNull()?.index ?: -1
// ★ totalItemsCount > 0 这个判断不能省:列表还空着时 last=-1,
// 不加判断会立刻判定「到底了」,一进页面就白发一次请求
info.totalItemsCount > 0 && last >= info.totalItemsCount - 3
}
.distinctUntilChanged()
.filter { it }
.collect { loadNextPage() } // 内部自己守卫 loading / hasMore
}自动加载必须自带的四件事,少一件就是 bug:
| 必须 | 为什么 |
|---|---|
加载中守卫(函数入口 if (loading || !hasMore) return) | 滚动过程中条件会反复为真,不守卫会连发十几个请求 |
| 失败后给「加载失败,点击重试」页脚 | 自动加载失败后不会自己再来一次,没有重试入口 = 用户永远看不到下一页 |
| 到底了显示「没有更多了」 | 否则用户一直往下拉,以为还在加载 |
| 接口有限流时加并发互斥 + 失败退避 | 快速下滑会把接口打爆(有的站点直接 404 冷却 60~90 秒) |
自动加载不等于裸奔
第三方接口有频率限制时,自动加载必须配两样东西:并发互斥(同一时刻只允许一个分页请求在飞)和 失败退避(delay 递增再重试)。否则用户快速下滑就足以把自己打进冷却。 手写方案的完整写法见 列表、滚动与自动加载更多;能用 Paging 3 就直接用 官方 Paging 3 分页。
二、搜索入口:二选一
需要搜索时,只允许两种画法:
| 画法 | 什么时候用 | 怎么写 |
|---|---|---|
| 页面里画搜索框 | 搜索就是这个页面的主要动作 | 用组件库 TextField,用户点进搜索页就在输入态 |
| 标题栏画图标按钮 | 搜索是次要入口 | TopTitleScaffold(actions = { ActionImageButton(icon = Phosphor.MagnifyingGlass, onAction = { … }) }) |
不要用列表项假装搜索框、不要把搜索放页面底部、不要自己拼一个「看起来像搜索框的 Row」。
三、TextField 必须同时传 placeholder 和 label
kotlin
TextField(
value = input,
onValueChange = { input = it },
placeholder = "漫画名 / 作者", // 空态引导:告诉用户这里能输什么
label = "搜索", // 浮动标签:告诉用户这个框是干什么的
leadingIcon = Phosphor.MagnifyingGlass,
)两个都要传。只传 placeholder —— 用户一输入就再也不知道这个框是什么;只传 label —— 空态没有任何引导。 这一条对所有 TextField / TextArea 都成立。
二、业务开发
把数据接进来
工具库按用途分了四块,先看 API 总览 里那张「什么场景用哪个」的表。最常见的组合:
| 场景 | 用 | 文档 |
|---|---|---|
| 存开关 / 小配置 | PluginKv | 键值存储 |
| 存一串同类记录(历史、草稿、收藏) | PluginRecords | 记录列表 |
| 结构固定的表装不下 | PluginDb | 自定义表 |
| 调后端接口 | OkHttp + ZhinianHost.BASE_URL | 网络请求 |
| 结果落盘到系统媒体库 | MediaExporter | 保存媒体 |
| 判断这次要不要花执念币 | RelayConfigClient | 中转配置与权益 |
| 登录态 / 跳登录页 / 激励广告 | UserManager / RewardAdManager | 宿主桥 |
三条不能绕的:
- 域名一律用
ZhinianHost.BASE_URL。 写死https://obsession.yihualuo.cn的插件,换域名后整体失联。 - 存储一律走工具库。
PluginKv是 SQLite(不受 SharedPreferences 的 1MB 限制、事务写入、多进程稳),键值库名约定就是插件包名。 - 币数 / 次数先拉配置再渲染。 从
RelayConfigClient读,不要写死「消耗 8 执念币」—— 后台改策略时插件要自动跟着变。
一个完整的最小例子
「拉接口 → 三态渲染 → 下拉刷新 → 出错能重试」,用的都是最常见的几块:网络 + 键值缓存 + 状态页 + 下拉刷新。
kotlin
package com.zhinian.plugin.what2eat.ui
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.hualuo.compose.ui.components.EmptyHint
import com.hualuo.compose.ui.components.ErrorState
import com.hualuo.compose.ui.components.LoadingState
import com.hualuo.compose.ui.components.PullToRefresh
import com.hualuo.compose.ui.components.TopTitleScaffold
import com.hualuo.zhinian.box.common.data.PluginKv
import com.hualuo.zhinian.box.common.host.ZhinianHost
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
/** 键值库名约定 = 插件包名 */
private const val DB = "com.zhinian.plugin.what2eat"
private const val CACHE_KEY = "last_json"
private sealed interface UiState {
data object Loading : UiState
data class Ok(val items: List<String>) : UiState
data class Err(val message: String) : UiState
}
@Composable
fun App(onFinish: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val client = remember { OkHttpClient() }
var state by remember { mutableStateOf<UiState>(UiState.Loading) }
var isRefreshing by remember { mutableStateOf(false) }
// 拉一次网络;失败就退回上次成功时的缓存
suspend fun load() {
state = UiState.Loading
state = try {
val body = withContext(Dispatchers.IO) {
client.newCall(
Request.Builder()
.url("${ZhinianHost.BASE_URL}/api/v1/list")
.build()
).execute().use { it.body?.string().orEmpty() }
}
PluginKv.put(context, DB, CACHE_KEY, body)
UiState.Ok(parse(body))
} catch (e: Exception) {
val cached = PluginKv.getString(context, DB, CACHE_KEY)
if (cached.isNullOrEmpty()) UiState.Err(e.message ?: "网络异常")
else UiState.Ok(parse(cached))
}
}
LaunchedEffect(Unit) { load() }
TopTitleScaffold(title = "今天吃什么", onBack = onFinish) {
when (val s = state) {
UiState.Loading -> LoadingState()
is UiState.Err -> ErrorState(
message = s.message,
onRetry = { scope.launch { load() } },
)
is UiState.Ok -> PullToRefresh(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
scope.launch {
load()
isRefreshing = false
}
},
) {
if (s.items.isEmpty()) {
EmptyHint(text = "还没有内容", description = "下拉刷新试试")
} else {
LazyColumn {
items(s.items) { Text(it, Modifier.padding(16.dp)) }
}
}
}
}
}
}parse(body) 是你自己的 JSON 解析,随便怎么写。
这段里每个组件都能点开看参数:TopTitleScaffold、LoadingState / ErrorState / EmptyHint、PullToRefresh。
三个容易忘的点:
isRefreshing要自己置回false,下拉刷新没有超时兜底;而且onRefresh里必须自己把它置true,否则指示器落位后立刻收到「已刷完」。ErrorState的onRetry是必传的,没有「不给回调就藏掉重试按钮」这种模式 —— 错误态必须能给用户一条出路。EmptyHint和ErrorState是两回事:请求成功但没数据用EmptyHint,请求失败用ErrorState。
交付前过一遍
- [ ] 界面外层是
PluginTheme,没有自己调HuaLuoTheme - [ ] 域名走
ZhinianHost.BASE_URL,没有硬编码 - [ ] 没有硬编码颜色 / 字号 / 圆角
- [ ] 三种状态都有:加载中、出错(能点重试)、空
- [ ] 存储走
PluginKv/PluginRecords/PluginDb,没有自己开SharedPreferences - [ ] 要花钱的操作,币数 / 次数从
RelayConfigClient读,没写死 - [ ] 轻提示用
PopupPrompt,没有自己造 toast
出包见下一步,完整清单见发布前 Checklist。