用途
本技能讲解如何把设备能力以 @Composable expect 函数的形式暴露给共享 Compose 代码:在具备该能力的平台上给出完整实现,在不具备的平台上给出返回中性值的桩实现。目标是让共享代码的调用方不接触平台类型,平台细节只存在于每个目标平台的一个文件中。
主要能力
资料指出三种形态几乎覆盖所有情况,且各自失败方式不同:
@Composable expect fun getScreenSizeInfo(): ScreenSizeInfo—— 一种测量@Composable expect fun KeepScreenOn()—— 一种带撤销的效果@Composable expect fun rememberIsInPipMode(): Boolean—— 一种以状态读取的订阅
每个声明的类型都是公共类型:测量返回的是共享模块中声明的小型数据类,而不是平台窗口对象。
某个平台不具备的能力仍然要有完整的 actual,返回中性值,例如:
@Composable actual fun rememberIsInPipMode(): Boolean = false // this platform has no such mode
@Composable actual fun KeepScreenOn() { /* no display timeout to hold off here */ }
使用方式与陷阱
资料列出以下要点:
- 桩必须正确,而不是缺失。
TODO()写在 actual 里能编译、能发布,并在该平台首次绘制屏幕时让应用停止;由于它位于次要平台上无人测试的能力之后,会到达用户。中性值通常显而易见:模式查询返回 false、效果为空体、查询返回空列表。要写出来,并把原因写在同一行。 - 空 actual 是一种决定,与“平台不能”不是同一种决定。两者在源码中看起来一样。资料中检查的文件里,桌面目标用普通共享 Compose API 完整实现了测量,却把保持唤醒效果留空并附注以后实现。完整测量不能证明空效果,它们是不相关的能力。在断定某平台缺少某能力前,要确认确实有人查过。
- 中性返回值是真实答案,调用方会据此行动。窗口模式查询返回 false 不表示“未知”——共享代码中每个 if 都把它读作“不在该模式”并据此布局。对没有该模式的平台正确;对有该模式但尚未接线的平台则错误。如果这个差异重要,就让能力能表达“此处不支持”,并让调用方处理。
@Composable要同时出现在 expect 和每个 actual 上。它是声明的一部分,不是实现细节,不匹配会被拒绝;但令人困惑的是反向情况:不需要组合的 actual 仍带着注解,所以不要把它从桩上“清理掉”。- 效果型能力必须自我撤销。保持唤醒的 actual 在
DisposableEffect中设置标志并在onDispose中清除;订阅型 actual 同样注册监听器并移除。设置设备级标志却从不清除的能力会比请求它的屏幕活得更久,症状会出现在完全别处。如果 actual 没有onDispose,它要么确实无状态,要么就是泄漏。 - 测量需要缓存键,且正确的键因平台而异。Android actual 把计算包在
remember(configuration)中,从而在配置变化时重算而非每次重组都算。桌面 actual 读取 Compose 提供的窗口值,该值本身可观察,无需键。把 Android 的写法照搬为remember(Unit)会把值冻结在首次组合,窗口缩放时永不更新——这是只有可缩放窗口的平台才会显现的 bug。 - 测量报告的是容器,你自己的装饰条可能在其中。应用在内容上方绘制的标题栏仍属于测量读取的容器,因此返回的高度包含一条任何布局都无法使用的条带,每个计算“我有多少空间”的消费者都会恰好溢出这条带。它看起来像样式差异而非错误数字,任何以该测量为门槛的逻辑都会继承它。让修正得以存活需要两件事:一个常量,被绘制条带的栏和减去它的 actual 同时读取;以及条件性减法:
// adapted — the flag is published once, at window creation, before the first frame
object WindowChrome { const val TITLE_BAR_HEIGHT_DP = 40; @Volatile var inWindowBar = false }
val chromeTopPx = if (WindowChrome.inWindowBar)
with(density) { WindowChrome.TITLE_BAR_HEIGHT_DP.dp.roundToPx() } else 0
val contentHeightPx = (window.containerSize.height - chromeTopPx).coerceAtLeast(0)
条件不是“哪个平台”:同一平台在某些配置下绘制自己的栏,在另一些配置下让系统装饰窗口,而系统装饰位于容器尺寸之外——在那里相减会以同样数量少报。还要检查哪些窗口调用该测量:装饰不同的次要窗口需要自己的答案。
- 平台 actual 是查找 Activity 的地方,而查找方式决定没有 Activity 时会发生什么。资料中检查的 Android 文件在两个函数里两次遍历 context 链:一个在耗尽时返回 null,另一个抛出异常。基于抛出式遍历构建的 composable 在 Activity 之外托管时(预览、嵌入式宿主、测试)会让屏幕崩溃。按能力选择:测量可以回退为零,模式订阅可能无法订阅。
- 版本分支及其抑制注解位于 actual 中,这是正确的。Android 测量为其旧 API 分支带有
@Suppress("DEPRECATION")。把它留在 actual 内正是阻止抑制作用于共享代码的原因,所以不要提升它。
验证方式
资料给出以下命令。
按名称确认每个 expect 在你构建的每个源集中都有 actual:
grep -rhoE "expect fun [a-zA-Z][A-Za-z0-9_]*" --include="*.kt" . | sed 's/expect fun //' | sort -u | while read -r f; do
printf '%-30s actual in: %s\n' "$f" "$(grep -rlE "actual fun $f\b" --include="*.kt" . | sed -E 's|.*/src/([^/]+)/.*|\1|' | sort -u | tr '\n' ' ')"
done
读那一列,而不是数量:列出的源集比同类少的声明,要么是不会链接的目标,要么是某个父级覆盖多个目标的层级——两者都值得在发布构建告诉你之前知道。
然后找出桩,让每个都是你见过的决定:
find . -path "*/src/*Main/*" -name "*.kt" | while read -r f; do
sed -E 's://.*$::' "$f" | grep -vE "^[[:space:]]*$" \
| grep -A1 "actual fun " | grep -B1 "^[[:space:]]*\}$" \
| grep -oE "actual fun [A-Za-z_][A-Za-z0-9_]*" | sed "s|^|$f |"
done
先剥离注释才能找到那些写成裸注释、位于原本空的主体内的桩——这正是桩通常的写法。每个命中旁边都需要原因;没有原因的候选属于 noop-actual-not-platform-limit。
并确认没有发布未写的分支:
grep -rn -A4 "actual fun " --include="*.kt" . | grep -E "TODO\(\)|NotImplementedError"
最后,对于测量 actual,确认装饰条高度只有一个定义、两个读取者——绘制它的 composable 和减去它的 actual:
grep -rn 'TITLE_BAR_HEIGHT\|_BAR_HEIGHT_DP' --include='*.kt' . | grep -v '/build/'
栏中一个字面高度加上 actual 中另一个字面高度,正是这要防止的漂移;只有一个读取者而没有其他读取者,就是无人维护的减法。
适用场景
适用于在 Kotlin Multiplatform 共享 Compose 代码中暴露平台设备能力(测量、效果、订阅)的场景,尤其是需要区分“平台不支持”与“尚未实现”的场合。
限制
资料未提供该技能的具体安装方式、收费、价格、支持平台或效果数据,这些事实暂未提供。