外观
代码结构与反模式
组件选型看组件库。本页只回答另一件事:同一个界面,代码该长成什么样。界面写完能不能改得动,几乎全在动手前那几个决定里 —— 这个 composable 该不该拆、参数怎么传、状态放在哪一层。
一句话判据
一个 composable 只做一件事:要么决定界面长什么样(渲染),要么决定数据从哪来(连接)。两者混在一起,就是本页后半部分所有反模式的源头。
一、先立骨架:一个插件页面的四层
| 层 | 放什么 | 例子 | 可见性 |
|---|---|---|---|
| 入口 | 宿主唯一认识的东西,接 onFinish | App(onFinish) | public |
| 连接层 | 拿数据、持有状态、发请求,把状态与回调交给下一层 | FavoriteScreen() | private |
| 内容层 | 纯渲染:when 三态、列表、布局 | FavoriteContent(state, onXxx) | private |
| 叶层 | 一行 / 一块的行级组件 | FavoriteRow(item, onClick) | private |
kotlin
@Composable
fun App(onFinish: () -> Unit) { // 入口:只有它需要 public
var tab by rememberSaveable { mutableIntStateOf(0) }
Column(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.weight(1f)) {
if (tab == 0) HomeScreen(onFinish) else ProfileScreen(onFinish)
}
BottomNavBar(items = mainTabs, selectedIndex = tab, onSelect = { tab = it })
}
}命名把「这是哪一种」写进名字里:名词 = 组件(FavoriteRow,描述它显示什么);Content 后缀 = 无状态渲染层(只认参数,谁都能拿来预览);动词 = 副作用(LaunchedEffect 这类不发 UI、只做事)。别写 handleLogin() 这种既像 UI 又像副作用的含糊名字。
二、什么时候该抽子 composable
命中一个就抽:
| 信号 | 判据 |
|---|---|
| 行数 | 一个 @Composable 超过 约 60 行,或函数体里出现 3 个以上并列的语义块(头部 / 列表 / 底部) |
| 独立语义 | 这一段能用一个名词叫出来(「统计行」「空态块」)。说不出它叫什么,说明还没想清楚,先别抽 |
| 会被复用 | 同一段 UI 出现第二次就抽 —— 不是「三遍才抽」,第三遍时你已经有三份要同步的代码 |
| 有独立状态 | 这一段自己持有并读写状态:抽出来状态落在它自己身上,重组范围直接缩小 |
反过来,这几种别抽:单次使用的一行 Text、拆开之后要传七八个参数、为了「看起来模块化」而包的壳。
kotlin
// ❌ 巨型 composable:头部、统计、列表、弹窗、请求全在一个函数里(80 行)
@Composable fun ProfileScreen(onFinish: () -> Unit) { /* … */ }
// ✅ 父函数退化成一张目录,每一块各自成一个私有 composable
@Composable
fun ProfileScreen(onFinish: () -> Unit) {
var state by remember { mutableStateOf<UiState>(UiState.Loading) }
LaunchedEffect(Unit) { state = loadProfile() }
ProfileContent(state = state, onRetry = { /* … */ })
}
@Composable
private fun ProfileContent(state: UiState, onRetry: () -> Unit) {
when (state) {
is UiState.Loading -> LoadingState()
is UiState.Err -> ErrorState(message = state.message, onRetry = onRetry)
is UiState.Ok -> Column {
ProfileHeader(state.user); ProfileStats(state.user); ProfileBio(state.user.bio)
}
}
}拆到什么粒度:拆到「一句人能读的话」。「个人页由头部、统计、简介组成」—— 好;「个人页由 Box1、Row2、Column3 组成」—— 说明拆的是行不是语义。抽出来的私有函数别顺手加 remember 缓存,那是性能的活。 从设计稿到 composable:① 定根结构(整屏滚动 / 列表 / 底部弹层)→ ② 按视觉切横段(头部 / 内容 / 底部)→ ③ 段内定排列(Column + Arrangement.spacedBy、Row + weight、Box 叠层、LazyColumn)→ ④ 属性映射到 token → ⑤ 找出交互元素先查组件库。
三、参数怎么设计
3.1 modifier 永远是可选的第一个参数
kotlin
@Composable
fun FavoriteRow(item: FavoriteItem, modifier: Modifier = Modifier, onClick: () -> Unit = {})顺序固定:必传参数 → modifier → 其余可选参数 → 插槽 → 内容。调用方才能从外面调间距与对齐。例外:组件库标定过的地方不要用 modifier 覆盖尺寸 —— ListItem 的 48dp 图标容器、BottomNavBar 的 56dp 都是刻意值。
3.2 数据向下、回调向上
父传 value,子回 onValueChange;子组件不改父的状态,只报告「发生了什么」。
kotlin
@Composable
private fun PriceField(value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier) {
TextField( // 组件库 TextField:placeholder 与 label 必须同时传
value = value,
onValueChange = onValueChange,
placeholder = "0.00",
label = "单价",
modifier = modifier,
)
}三件不能做:composable 不返回值(要往外送东西就用回调);不把 ViewModel 传进子组件(只收数据,否则无法预览也无法复用);不在子组件里改父级状态(数据流一反,下次重组谁也说不清是谁触发的)。
3.3 参数别爆炸:收进 state holder
一个组件里超过五六份互相关联的状态、还配一堆改它们的回调,就收进 @Stable 的类,用 rememberXxxState() 拿:
kotlin
@Stable
class SearchState {
var query by mutableStateOf("")
private set
var page by mutableIntStateOf(1)
fun onQueryChange(value: String) { query = value; page = 1 } // 换关键词必须重置页码
}
@Composable fun rememberSearchState(): SearchState = remember { SearchState() }参数默认值也别现造对象:items: List<Int> = listOf(1, 2, 3) 每次重组都是新实例,等于每次都告诉子节点「变了」,跳过优化直接失效。默认值用 emptyList() 这类常量,或把类型标成 @Immutable。
3.4 CompositionLocal 只用来传「环境值」
它是隐式参数,不是省参数的捷径:只隔一两层的参数直接往下传,别造 LocalTitle 让子组件去读。组合期间不会变的环境值(主题 token、依赖)用 staticCompositionLocalOf(变一次整棵子树重算);会变的(登录态、语言)才用 compositionLocalOf(只有读了 .current 的组件失效)。
四、Slot 模式:用内容插槽代替布尔开关
「组件里装什么内容」用 @Composable lambda 表达,而不是用开关参数控制形态。
kotlin
// ❌ 布尔开关:调用方要读实现才知道 true 是什么意思,组合数还指数增长
@Composable fun InfoCard(showIcon: Boolean, isCompact: Boolean, hasAction: Boolean) { ... }
// ✅ 插槽:调用方传内容,组件只负责摆放
@Composable
fun InfoCard(icon: @Composable () -> Unit, content: @Composable () -> Unit, trailing: (@Composable () -> Unit)? = null) {
Row(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
icon(); Box(modifier = Modifier.weight(1f)) { content() }; trailing?.invoke()
}
}两条约定:可空插槽写成 (@Composable () -> Unit)? = null,为 null 时完全不占位(不渲染、不留间距);插槽传的是 lambda 而不是组合好的值 —— content: String 和 content: @Composable () -> Unit 不是一回事,后者让组合推迟到插槽被调用时。
组件库里现成的插槽,别自己再造一遍:
| 组件 | 插槽 | 类型 |
|---|---|---|
TopTitleScaffold | actions / bottomBar / topBar | @Composable RowScope.() -> Unit / @Composable () -> Unit |
ListItem | trailing | @Composable (() -> Unit)? |
SectionItem | trailing | @Composable (() -> Unit)? |
TextField | trailing / actions | 单图标槽 / 非图标动作槽 |
SectionRow | content | @Composable ColumnScope.() -> Unit |
插槽是渲染入口,不是逻辑入口
别在插槽 lambda 里发请求、起协程、注册监听 —— 插槽被调用几次、什么时候被调用由组件内部决定。数据和状态往上提,插槽里只做摆放。
五、Stateful / Stateless 成对写
每个有状态的页面组件都值得再拆一个无状态版本:有状态的连数据(别人接不进来),无状态的只认参数(能预览、能复用、能测)。
| Stateful(连接层) | Stateless(内容层) | |
|---|---|---|
| 例子 | FavoriteScreen() | FavoriteContent(state, onXxx) |
| 持有状态 | 是 | 否,全部来自参数 |
能开 @Preview | 不能(没有真数据) | 能,假数据一塞就跑 |
| 能被别处复用 | 不能 | 能(换个数据源就能用) |
kotlin
@Composable
fun FavoriteScreen() { // 连接层:拿数据、发请求、提供回调
var state by remember { mutableStateOf<FavoriteUiState>(FavoriteUiState.Loading) }
LaunchedEffect(Unit) { state = loadFavorites() }
FavoriteContent(state = state, onRetry = { /* 重新加载 */ })
}
@Composable
private fun FavoriteContent(state: FavoriteUiState, onRetry: () -> Unit) { // 内容层:纯函数
when (state) {
is FavoriteUiState.Loading -> LoadingState(text = "加载中…")
is FavoriteUiState.Err -> ErrorState(message = state.message, onRetry = onRetry)
is FavoriteUiState.Empty -> EmptyHint(text = "还没有收藏", description = "去挑几个喜欢的吧")
is FavoriteUiState.Ok -> FavoriteList(state.items)
}
}顺带的好处:三态(加载 / 出错 / 空)必须写全,因为内容层唯一的输入就是那个状态对象。状态写在连接层、分支写在内容层,谁也别越界。
六、@Preview 的用法
预览的价值是不启动 App、不连网络,就能看见这个组件长什么样。两条铁律:
- 必须自己传假数据。预览函数是普通 composable,没有真数据源;指望「预览里也拉一遍接口」,等于预览只在有网、有登录态、接口正好返回数据的日子能看。
- 预览不该依赖网络、
PluginKv、UserManager。任何一项依赖都让预览变成「有时能渲染」。真实数据由连接层负责,内容层只吃参数。
kotlin
@Preview(name = "有数据", showBackground = true)
@Composable
private fun FavoriteContentPreview() {
PluginTheme { // 包上入口那层主题
FavoriteContent(
state = FavoriteUiState.Ok(listOf(FavoriteItem(1, "示例条目", "示例作者"))),
onRetry = {},
)
}
}边界数据比正常数据更值得预览:空列表、超长标题、计数 99+、缺图标。用 PreviewParameterProvider 一次覆盖:
kotlin
class ItemPreviewProvider : PreviewParameterProvider<FavoriteItem> {
override val values = sequenceOf(
FavoriteItem(1, "短标题", "作者"),
FavoriteItem(2, "一个非常非常长的标题,长到一定会触发省略号", "作者"),
FavoriteItem(3, "", ""), // 空串边界
)
}
@Preview(showBackground = true)
@Composable
private fun FavoriteRowPreview(@PreviewParameter(ItemPreviewProvider::class) item: FavoriteItem) {
PluginTheme { FavoriteRow(item = item, onClick = {}) }
}浅色 / 深色 / 大字号 / 窄屏可以打在一个复合注解上(@Preview(name = "Light") + @Preview(uiMode = UI_MODE_NIGHT_YES) + @Preview(fontScale = 1.5f)),比在每处复制四行好维护。
预览不验证深浅色:深浅色由宿主设置决定(PluginTheme 反射读,见宿主桥),预览里切 uiMode 看的是配色本身对不对。
七、常见反模式清单
| 反模式 | 为什么坏 | 改成什么 |
|---|---|---|
| 一个 composable 几百行,什么都塞进去 | 没有可跳过的边界,改一行要读整页 | 按语义拆 XxxContent / XxxRow(§7.1) |
布尔参数控制形态(isHeader: Boolean) | 参数组合指数增长,两条分支都不敢动 | 拆组件,或改成 slot(§7.2) |
| 状态散落各处,同一份数据多处各存一份 | 永远同步不了;改一处忘一处 | 提到「读它的最低共同父级」(§7.3) |
| 在 composable 体里直接发网络请求 | 每次重组发一次;没有三态,失败没出路 | LaunchedEffect + 状态机(§7.4) |
事件当状态传(showToast: Boolean) | 每次重组都可能重放事件,转屏也重放 | 事件发生处直接调用(§7.5) |
到处 !!、到处 runCatching 包一切 | !! 是一次崩溃;runCatching 连协程取消一起吞 | 只捕获预期异常,映射成状态或提示(§7.6) |
用 LocalContext 拿 Activity | 拿到的是 PluginContext,不是 Activity | 从入口传回调(§7.7) |
| 自己拼标题栏 / 自己拼底部导航 | 漏 inset、高度与图标规格全站不一致 | TopTitleScaffold / BottomNavBar(§7.8) |
| 硬编码颜色 / 字号 / 圆角 | 换肤失效,跨插件观感不一致 | 一律走 token(§7.9) |
| 复制粘贴三遍才想起抽组件 | 抽的时候已经有三份要同步 | 第二次出现就抽(§7.10) |
| 造已经有的东西:自写轻提示 / 分割线 / 空态 / 按钮 | 位置、动效、颜色都对不齐 | PopupPrompt / HorizontalDivider / EmptyHint / FilledButton(组件库) |
一屏两个 FilledButton | 两个实心主色块互抢注意力 | 按视觉权重降级:TonalButton → OutlinedButton → TertiaryButton(按钮) |
7.1 巨型 composable
坏在哪:跳过优化以函数调用为边界,一个 300 行的函数内部没有边界;它同时持有视图、状态、请求、弹窗开关,任何一处改动都要通读全篇。怎么改:父函数退化成一张目录,只负责「由哪几块组成」(见 §2)。
7.2 用布尔参数控制组件形态
kotlin
// ❌ 调用方必须点进实现才知道 true / false 是什么
@Composable fun UserRow(isHeader: Boolean, showAvatar: Boolean) {
if (isHeader) { /* 大字号 + 头像 */ } else { /* 小字号 + 无头像 */ }
}
// ✅ 形态不同就是两个组件;真正共用的部分抽成第三个
@Composable private fun ProfileHeader(user: User) { /* … */ }
@Composable private fun ProfileRow(user: User, onClick: () -> Unit) { /* … */ }一个 Boolean 只分两种形态看着还行,两三个叠起来就是 2ⁿ 种组合,每一种都得测。形态差异用组件名表达,内容差异用 slot 表达,两者都别用开关。
7.3 状态散落各处,同一份数据多处各存一份
kotlin
// ❌ 搜索框和列表各记一份关键词:输入框改了,列表还按旧词过滤
@Composable fun SearchBar() {
var keyword by remember { mutableStateOf("") }
TextField(value = keyword, onValueChange = { keyword = it }, placeholder = "漫画名", label = "搜索")
}
@Composable fun ResultList() {
var keyword by remember { mutableStateOf("") } // 第二份,永远和上面那份不同步
}怎么改:把这份状态提到两个兄弟的最低共同父级,兄弟收 value + onValueChange。判定只有一句话:放到「所有读它的地方的最低共同父级」,然后停手 —— 只有一个组件读就地 remember,两个兄弟读就提到父级,寿命超过这个页面的才提到 App() 顶层。完整判据(含 rememberSaveable 的门槛与 derivedStateOf)在状态与副作用。
7.4 在 composable 里直接发网络请求
kotlin
// ❌ 主线程阻塞 + 每次重组都发一次;没有加载中 / 出错 / 空三态
@Composable fun Detail(id: String) {
val data = runBlocking { api.load(id) } // 没有 LaunchedEffect,没有状态机
Text(data.title)
}
// ✅ 副作用挂在 key 上,结果落进状态机
@Composable fun Detail(id: String) {
var state by remember { mutableStateOf<UiState>(UiState.Loading) }
LaunchedEffect(id) { // id 变了就重跑,退出时自动取消
state = try { UiState.Ok(api.load(id)) } catch (e: IOException) { UiState.Err("网络异常") }
}
DetailContent(state = state, onRetry = { /* … */ })
}三条必守:请求挂 LaunchedEffect(key) 而不是裸写在函数体里;必须有状态机(至少 Loading / Ok / Err 三态,配 LoadingState / ErrorState / EmptyHint);错误要有出路(ErrorState.onRetry 是必传的)。完整对照表在状态与副作用。
7.5 事件当状态传
kotlin
// ❌ 「显示轻提示」是一次性事件,却写成了状态:
// 删除之后任何一次重组(转屏、父级刷新、滚动重算)都可能再弹一次
var showToast by remember { mutableStateOf(false) }
LaunchedEffect(showToast) { if (showToast) { PopupPrompt.success("已删除"); showToast = false } }
// ✅ 事件就在发生的那一行处理
onDelete = { repo.delete(id); PopupPrompt.success("已删除") } // 组件库轻提示,见 /api/ui/toast要从深层子组件把事件送回顶层,就传 onDelete: () -> Unit 回调上去,或用一次性事件通道(Channel / SharedFlow)—— 别用 Boolean 传递「刚刚发生了一件事」。把 showDialog: Boolean 当「刚刚出错了」用,是同一个错。
7.6 到处 !!、到处 runCatching 包一切
kotlin
// ❌ 未登录时 token 就是 null(宿主桥明确不抛异常),这个 !! 等于一次线上崩溃
val token = UserManager.getUserToken()!!
// ✅ 缺失是正常分支:映射成状态,让用户看见出路
val token = UserManager.getUserToken()
if (token == null) { ErrorState(message = "请先登录宿主账号", onRetry = { /* … */ }); return }
// ❌ runCatching 连 CancellationException 一起吞:页面退出、协程取消也会被当成「业务失败」
runCatching { api.load() }.onFailure { PopupPrompt.error("加载失败") }
// ✅ 只捕获你预期的那一类,取消照常往外抛
try { api.load() } catch (e: IOException) { state = UiState.Err("网络异常") }!! 的问题不是「可能崩」,是把**「不确定」写成了「确定」** —— 崩溃点上没有任何补救信息。runCatching 的问题相反:把「确定」写成「全都要接」,让协程取消和真正的业务失败混成一件事。
7.7 用 LocalContext 拿 Activity
kotlin
// ❌ LocalContext 给的是 PluginContext,不是 Activity;一路解包是绕路,
// 把 Activity 存进 remember / 单例就是泄漏
val activity = LocalContext.current as? Activity
activity?.finish()怎么改:退出插件、跳登录页、看激励广告时,从入口把回调 / onFinish 往下传 —— App(onFinish) 背后本来就是宿主传进来的 ProxyActivity,不用在深处自己挖(见宿主桥)。长命对象里永远不要存 Context / Activity:要用就在 composable 内取、当场用。
7.8 自己拼标题栏 / 自己拼底部导航
kotlin
// ❌ 手拼标题栏:inset 漏一半,行高和字号全站对不上
Row(modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp)) {
IconButton(onClick = onFinish) { Icon(Phosphor.CaretLeft, contentDescription = "返回") }
Text("我的收藏", fontSize = 20.sp, fontWeight = FontWeight.Bold)
}
// ✅ 标题栏 + 三边 inset 一起交给组件库,动作区用 ActionImageButton
TopTitleScaffold(
title = "我的收藏",
onBack = onFinish, // 传 null 才不显示返回键
actions = { ActionImageButton(icon = Phosphor.MagnifyingGlass, onAction = { openSearch() }, contentDescription = "搜索") },
) { FavoriteList() }kotlin
// ❌ 手拼底部导航:自己补 navigationBarsPadding、自己加分割线,高度还不一致
Column {
Box(modifier = Modifier.weight(1f)) { Content() }
HorizontalDivider(startIndent = 0.dp, endIndent = 0.dp) // 组件库那条线不该自己加
Row(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) { /* 四个 Tab */ }
}
// ✅ BottomNavBar 自带通栏分割线与底部 inset、56dp 高,不要再加内边距
Column(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.weight(1f)) { Content() }
BottomNavBar(items = mainTabs, selectedIndex = tab, onSelect = { tab = it })
}理由不是「组件库更省代码」,而是规格收敛:标题栏的 insets、底栏的 56dp / 22dp 图标、那条通栏分割线的颜色与粗细,全站只有一份实现(标题栏、底部导航)。自己拼出来的每一处都「差一点」,叠起来就是「插件像半成品」。唯一例外是整屏沉浸式内容(大图 / 视频页)用 TopTitleScaffold(overlay = true)、标题色按亮度算出来传进去 —— 组件库文档明确允许;组件库覆盖不到的业务布局才自己排,但颜色、字阶、圆角仍然走 token。
7.9 硬编码颜色 / 字号 / 圆角
| 别写 | 为什么坏 | 写成 |
|---|---|---|
Color(0xFF6750A4) | 换肤 / 深浅色不跟着走 | MaterialTheme.colorScheme.primary(主题) |
fontSize = 20.sp | 字阶各自为政,改一档要全局搜 | MaterialTheme.typography.titleLarge(字阶) |
RoundedCornerShape(23.dp) | 圆角不成体系,同屏几种半径 | AppShapes.card / MaterialTheme.shapes.medium(形状) |
padding(13.dp) | 8dp 网格外的野值 | 8dp 网格,常用 16dp(布局与 Modifier) |
Divider() / 手画 Box(height = 1.dp) | 线粗细、颜色、缩进都不统一 | 组件库 HorizontalDivider()(分隔线) |
Icon(Icons.Default.Add) | 又引一套图标库,观感不一致 | Phosphor.Plus(图标) |
| 自写 Toast / 自拼提示条 | 位置、动效、语义色全站不统一 | PopupPrompt(轻提示) |
认准组件库那个同名组件
组件库的 HorizontalDivider 和 M3 的同名函数不是一条线:前者默认 1 个物理像素、outlineVariant@70%、左右各缩进 16dp;后者是 1.dp 且没叠 alpha。IDE 自动 import 很容易引到 M3 那个 —— 结果就是 3x 屏上「发丝线」变成一条灰带。遇到长得很像的组件,先看 import 是不是 com.hualuo.compose.ui.components.*。
7.10 复制粘贴三遍才想起抽组件
同一段 UI 出现第二次就该抽。等到第三遍,你已经有三份要同步的代码:改样式改一份,另外两份没跟上,界面上就出现「同一个东西两个长相」。抽出来的组件放在该页面文件里(private),只有真被两个以上页面用了,才提到公共文件。
八、已废弃 / 被替换的 Compose API
下面这些是插件作者真正会踩到的。不要写死版本号去判断,认「已被 X 取代」这个事实即可;组件库那几项以组件库文档为准。
| 旧写法 | 现在用 | 说明 |
|---|---|---|
Divider() | 组件库 HorizontalDivider | M3 的 Divider 已更名为 HorizontalDivider,但同名不同实现,必须用组件库那个(见 §7.9 的 warning) |
ListItemDivider / SectionDivider / InsetListItemDivider | HorizontalDivider | 三个旧名字已收敛成一个,源码里已不存在 |
CustomToast / ToastManager / toastSuccess | PopupPrompt | 旧名字已不存在,见到就是旧代码 |
Button(type = …) 这类形态开关参数 | FilledButton / TonalButton / OutlinedButton / TertiaryButton | 参数里不再有 type,形态由你用哪个组件表达 |
SectionRows(分组容器) | SectionRow | 互换过名字:旧 SectionRows → 现 SectionRow;旧 SectionRow(单行)→ 现 SectionItem |
TextField(trailingIcon1 = …) / trailingIcon2 | trailing + actions | 旧的多图标参数已删除,现在只有「一个图标槽 + 一个动作槽」 |
Icons.Default.*(material-icons-extended) | com.hualuo.compose.ui.icons.phosphor.*(图标) | 只保留 Phosphor 一套,别再引第二个图标库 |
accompanist-pager 的 HorizontalPager | foundation 自带的 HorizontalPager / VerticalPager | 已内建;rememberPagerState 改成收 pageCount = { … } lambda 的形态 |
accompanist-swiperefresh | 组件库 PullToRefresh | 下拉刷新走组件库,别引 Accompanist |
accompanist-flowlayout | foundation 自带的 FlowRow / FlowColumn | 已内建,用普通 Modifier 控宽,不再需要 SizeMode |
accompanist-systemuicontroller | enableEdgeToEdge()(入口层的事) | 插件里不要自己调 —— 标题栏与三边 inset 由 TopTitleScaffold 统一处理 |
collectAsState() | collectAsStateWithLifecycle() | 后者随生命周期自动停止收集,避免后台白跑与泄漏 |
NavHost + 字符串路由 | 插件自己管页面状态 + overlay 叠层 | 本项目不推荐路由图导航(总览):入口由宿主给,标题栏与返回一律用 TopTitleScaffold |
九、实验性 API:插件里不要用
Compose 有一些还在 alpha 的实验性 API,看起来能少写很多代码。结论:插件里一个都不要用。
| 实验性 API | 它想解决什么 | 为什么插件不能用 | 用什么代替 |
|---|---|---|---|
Style { } | 声明式描述各交互态的样式,框架自动插值 | alpha 阶段的实现与属性表还在变 | animate*AsState + Modifier,见动效 |
MutableStyleState | 显式驱动 checked / selected / pressed | 与 Style 绑死,同版本内行为都可能变 | 组件库组件已自带交互态;需要时用稳定的 MutableInteractionSource |
Modifier.styleable() | 把 Style 应用到任意 composable | 必须显式传 styleState 才响应状态,漏传静默失效;个别属性因构造器不可见直接编译不过 | Modifier.background / clip / graphicsLayer |
@ExperimentalFoundationStyleApi | 上面整族的 opt-in 门槛 | 升级 Compose 就可能编译不过或运行时崩 | 等它进稳定版 |
为什么这条对插件格外硬
插件是独立 APK,由宿主以 ProxyActivity 加载,编译期必须和组件库当前使用的 Compose 版本对齐。实验性 API 一旦改名、改语义、改默认行为,插件要么直接编译不过,要么更糟 —— 编译过了、行为变了。所以:业务代码里不该出现 @OptIn(ExperimentalXxxApi::class)。真要用某个能力,先找稳定替代;找不到就把它关在一小段代码里并写清理由,别撒进页面。
十、交付前自查
- [ ]
App(onFinish)是唯一 public 入口,其余页面组件都是private,没有超过约 60 行的巨型 composable - [ ] 没有用
Boolean参数控制组件形态;内容差异走 slot;参数顺序是必传 →modifier: Modifier = Modifier→ 其余可选 → 插槽 → 内容 - [ ] 每个有状态的页面都有无状态内容层,且它开了
@Preview、传的是假数据 - [ ] 同一份数据只有一个持有者,兄弟组件收
value+onValueChange - [ ] 没有在 composable 体里发请求;请求都挂在
LaunchedEffect(key)上且配了三态 - [ ] 没有把「一次性事件」写成
Boolean状态 - [ ] 没有
!!;try / catch只捕获预期异常 - [ ] 没有从
LocalContext解包 Activity;退出、跳转、广告走入口回调 - [ ] 标题栏用
TopTitleScaffold,底部导航用BottomNavBar,没有手拼 - [ ] 颜色 / 字阶 / 圆角 / 间距全部走 token,没有
Color(0xFF…)/fontSize/RoundedCornerShape(...) - [ ] 轻提示、分割线、空态、按钮都用组件库的;一屏只有一个
FilledButton;没有Experimental*的@OptIn