Skip to content

代码结构与反模式 ​

组件选型看组件库。本页只回答另一件事:同一个界面,代码该长成什么样。界面写完能不能改得动,几乎全在动手前那几个决定里 —— 这个 composable 该不该拆、参数怎么传、状态放在哪一层。

一句话判据

一个 composable 只做一件事:要么决定界面长什么样(渲染),要么决定数据从哪来(连接)。两者混在一起,就是本页后半部分所有反模式的源头。

一、先立骨架:一个插件页面的四层 ​

层放什么例子可见性
入口宿主唯一认识的东西,接 onFinishApp(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 不是一回事,后者让组合推迟到插槽被调用时。

组件库里现成的插槽,别自己再造一遍:

组件插槽类型
TopTitleScaffoldactions / bottomBar / topBar@Composable RowScope.() -> Unit / @Composable () -> Unit
ListItemtrailing@Composable (() -> Unit)?
SectionItemtrailing@Composable (() -> Unit)?
TextFieldtrailing / actions单图标槽 / 非图标动作槽
SectionRowcontent@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、不连网络,就能看见这个组件长什么样。两条铁律:

  1. 必须自己传假数据。预览函数是普通 composable,没有真数据源;指望「预览里也拉一遍接口」,等于预览只在有网、有登录态、接口正好返回数据的日子能看。
  2. 预览不该依赖网络、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()组件库 HorizontalDividerM3 的 Divider 已更名为 HorizontalDivider,但同名不同实现,必须用组件库那个(见 §7.9 的 warning)
ListItemDivider / SectionDivider / InsetListItemDividerHorizontalDivider三个旧名字已收敛成一个,源码里已不存在
CustomToast / ToastManager / toastSuccessPopupPrompt旧名字已不存在,见到就是旧代码
Button(type = …) 这类形态开关参数FilledButton / TonalButton / OutlinedButton / TertiaryButton参数里不再有 type,形态由你用哪个组件表达
SectionRows(分组容器)SectionRow互换过名字:旧 SectionRows → 现 SectionRow;旧 SectionRow(单行)→ 现 SectionItem
TextField(trailingIcon1 = …) / trailingIcon2trailing + actions旧的多图标参数已删除,现在只有「一个图标槽 + 一个动作槽」
Icons.Default.*(material-icons-extended)com.hualuo.compose.ui.icons.phosphor.*(图标)只保留 Phosphor 一套,别再引第二个图标库
accompanist-pager 的 HorizontalPagerfoundation 自带的 HorizontalPager / VerticalPager已内建;rememberPagerState 改成收 pageCount = { … } lambda 的形态
accompanist-swiperefresh组件库 PullToRefresh下拉刷新走组件库,别引 Accompanist
accompanist-flowlayoutfoundation 自带的 FlowRow / FlowColumn已内建,用普通 Modifier 控宽,不再需要 SizeMode
accompanist-systemuicontrollerenableEdgeToEdge()(入口层的事)插件里不要自己调 —— 标题栏与三边 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