Skip to content

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。