【Compose Multiplatform 跨端开发学与练】第8课 资源管理与主题
本课目标:掌握
composeResources的多平台资源目录结构与类型安全访问器,理解限定符的优先级与本地化目录命名规则(特别是中文双脚本回退机制),掌握 Coil 3 在 CMP 中的网络图片加载与 iOS 内存压力适配,建立自定义主题、扩展颜色与深色模式的系统适配方法。
系列整体规划
| 课次 | 主题 | 核心内容 | 难度 |
|---|---|---|---|
| 第1课 | 从零开始 | 技术概览、环境搭建、第一个应用、代码解读 | ⭐ |
| 第2课 | Compose 基础语法 | @Composable、状态管理、重组机制、Modifier 体系 | ⭐⭐ |
| 第3课 | 布局与组件 | Column/Row/Box、LazyColumn、Material3 组件库 | ⭐⭐ |
| 第4课 | 导航与路由 | Navigation Compose、类型安全路由、深层链接 | ⭐⭐⭐ |
| 第5课 | 网络与数据层 | Ktor 客户端、序列化、Repository 模式 | ⭐⭐⭐ |
| 第6课 | 状态管理与架构 | ViewModel、单向数据流、依赖注入 | ⭐⭐⭐⭐ |
| 第7课 | 平台适配与互操作 | expect/actual、SwiftUI 互操作、平台特定 API | ⭐⭐⭐⭐ |
| 第8课 | 资源管理与主题 | 多平台资源、图片加载、深浅色主题 | ⭐⭐⭐ |
| 第9课 | 测试与调试 | Compose UI 测试、单元测试、性能分析 | ⭐⭐⭐⭐ |
| 第10课 | 发布与部署 | Android/iOS/桌面/Web 打包发布、CI/CD | ⭐⭐⭐⭐⭐ |
第8课 资源管理与主题
一、Compose Multiplatform 资源系统
1.1 从“平台各管各的”到“统一资源目录”
Android 原生开发用 res/ 目录和 R.drawable.xxx,iOS 用 Asset Catalog 和 Localizable.strings,桌面端又有自己的资源加载方式。每个平台的资源系统各不相同,跨平台共享资源一直是痛点。
Compose Multiplatform 1.6.0 引入的统一资源 API 从根本上解决了这个问题。你只需要在 commonMain 下创建一个 composeResources 目录,按约定结构组织文件,构建时 CMP 会为所有平台生成统一的类型安全访问器。资源文件本身是平台无关的(一张 PNG 在所有平台上都是同样的字节),引用方式也是平台无关的(Res.drawable.logo 在所有平台上写法一致)。平台差异被资源系统完全封装了。
1.2 目录结构与四类资源
在 composeApp/src/commonMain/ 下创建 composeResources 目录,按资源类型组织:
commonMain/composeResources/
├── drawable/ # 图像资源
├── font/ # 字体文件(.ttf)
├── values/ # 字符串资源(strings.xml)
└── files/ # 其他任意文件
四类资源的定位:
- drawable:存放所有图像。支持光栅格式(PNG、JPEG、WebP)和矢量 Android XML(不引用 Android 资源)。
- font:存放
.ttf字体文件。文件名建议使用小写字母、数字和下划线。 - values:存放
strings.xml字符串资源。这是本地化的核心目录。 - files:存放其他任意文件(JSON、二进制数据等)。注意:
files目录不生成类型安全访问器,需要通过Res.readBytes("files/xxx")手动读取,且不支持限定符。
1.3 生成类型安全访问器
添加资源后构建项目,CMP 会生成 Res 对象:
import project.shared.generated.resources.Res
import project.shared.generated.resources.logo
@Composable
fun App() {
Image(painter = painterResource(Res.drawable.logo), contentDescription = null)
Text(stringResource(Res.string.app_name))
Text("自定义字体", fontFamily = FontFamily(Font(Res.font.roboto)))
}
painterResource 用于图像,stringResource 用于字符串,Font 构造函数接受字体资源。如果 Res 无法解析,检查三件事:资源确实在 composeResources 下、build.gradle.kts 中有 implementation(compose.components.resources)、Gradle 已同步。
1.4 限定符与本地化
同一个资源在不同环境下可能需要不同版本。限定符通过目录名后缀表达,用连字符连接。
支持三类限定符,按优先级排序:语言 > 主题 > 密度。 可组合使用,如 drawable-en-rUS-mdpi-dark 表示美国英语、160 DPI、深色主题下的图像。
语言限定符:语言代码用双字母(ISO 639-1)或三字母(ISO 639-2)。区域代码带小写 r 前缀,如 drawable-spa-rMX。语言和区域代码区分大小写。
主题限定符:light 或 dark,CMP 根据系统主题自动选择。
密度限定符:ldpi (120 DPI) 到 xxxhdpi (640 DPI)。
中文双脚本的回退陷阱:在 Android 和桌面端,系统可能请求不带书写方式的语言区域(如 zh 而非 zh-Hans)。如果你只提供 values-b+zh+Hans 和 values-b+zh+Hant,所有请求都会匹配到它们,导致资源无法解析而崩溃。必须同时提供 values-zh 作为无脚本回退:
commonMain/composeResources/
├── values/ # 默认资源
├── values-zh/ # 不带脚本的 zh 资源(关键!)
├── values-b+zh+Hans/ # 简体中文
└── values-b+zh+Hant/ # 繁体中文
这个规则同样适用于塞尔维亚语(sr-Cyrl、sr-Latn)等具有多种脚本的语言。
1.5 字符串模板与复数
字符串模板:支持 %1$s 和 %2$d 占位符,$s 和 $d 后缀无功能区别:
<string name="str_template">Hello, %2$s! You have %1$d new messages.</string>
Text(stringResource(Res.string.str_template, 100, "User_name"))
复数资源:支持 zero、one、two、few、many、other 六种量词。注意并非所有语言都使用所有量词(英语只关心 one 和 other):
<plurals name="new_message">
<item quantity="one">%1$d new message</item>
<item quantity="other">%1$d new messages</item>
</plurals>
Text(pluralStringResource(Res.plurals.new_message, 1, 1))
二、网络图片加载:Coil 3
2.1 为什么用 Coil 而不是手写
painterResource 只能加载打包在应用内的静态资源。网络图片需要异步下载、缓存、解码、显示,并处理占位图和错误状态。Coil 3 原生支持 Compose Multiplatform,核心优势是内存和磁盘缓存、图片降采样、自动暂停/取消请求。
2.2 配置与引擎选择
在 commonMain 中添加依赖:
implementation("io.coil-kt.coil3:coil-compose:3.5.0")
implementation("io.coil-kt.coil3:coil-network-ktor3:3.5.0")
关键配置:CMP 项目必须用 Ktor 引擎而不是 OkHttp。 Coil 官方文档明确指出:使用 Compose Multiplatform 时,需要 Ktor 而非 OkHttp,因为 OkHttp 只在 Android 和 JVM 上可用。
2.3 基本使用与全局配置
AsyncImage(
model = "https://example.com/avatar.jpg",
contentDescription = "头像",
modifier = Modifier.size(48.dp).clip(CircleShape),
)
AsyncImage 自动处理请求、缓存、解码、取消。如需自定义配置,在根 Composable 中调用 setSingletonImageLoaderFactory——这是 CMP 推荐的方式:
@Composable
fun App() {
setSingletonImageLoaderFactory { context ->
ImageLoader.Builder(context).crossfade(true).build()
}
// ...
}
2.4 iOS 内存压力:Skia 解码的隐藏陷阱
这是一个真实生产环境中的关键问题。CMP 在 iOS 上使用 Skia 解码图片,完全绕过了 iOS 的 NSCache、ImageIO 渐进式解码和系统内存压力回收机制。 Skia 把图片解码到 SkBitmap 中,由 Kotlin/Native 内存运行时管理。iOS 对这些分配零可见性,发送内存压力通知时无法回收任何东西。在图片密集的列表中滚动 200+ 张图片,常驻内存只增不减,最终导致 OOM 崩溃。
修复方案的核心是两步:
第一步:用 expect/actual 桥接 iOS 内存压力通知到 Kotlin 层:
// commonMain
expect class MemoryPressureMonitor() {
fun onLowMemory(callback: () -> Unit)
}
// iosMain
actual class MemoryPressureMonitor {
actual fun onLowMemory(callback: () -> Unit) {
NSNotificationCenter.defaultCenter.addObserverForName(
UIApplicationDidReceiveMemoryWarningNotification, null, null
) { callback() }
}
}
第二步:实现双层 LRU 缓存。内存压力时优先驱逐已解码的位图,保留编码数据——重新解码只需毫秒,重新下载需要秒级和带宽:
fun onMemoryPressure() {
bitmapLru.evictAll() // 先丢弃昂贵的解码数据
// 保留编码数据,重新解码比重新下载便宜得多
}
如果应用有大量图片列表,建议在项目早期就实现这套机制,而不是等到 OOM 崩溃后再排查。
三、主题系统
3.1 主题的三个组成部分
Material3 的主题由三部分组成:颜色方案(ColorScheme)、排版(Typography)、形状(Shapes)。MaterialTheme 通过 CompositionLocal 提供这三者。
MaterialTheme(
colorScheme = AppLightColorScheme,
typography = AppTypography,
shapes = AppShapes,
) { /* 应用内容 */ }
3.2 定义颜色方案
第一步:收集原始颜色令牌,用具名常量明确语义:
val Primary = Color(0xFF6750A4)
val OnPrimary = Color(0xFFFFFFFF)
第二步:组装 ColorScheme,用这些令牌构造浅色和深色方案:
val AppLightColorScheme = lightColorScheme(
primary = Primary, onPrimary = OnPrimary, /* ... */
)
val AppDarkColorScheme = darkColorScheme(/* 深色版本 */)
命名建议:用 AppLightColorScheme 而非 LightColorScheme,避免与 Material3 类型名混淆。
3.3 定义排版与字体
在 composeResources/font/ 下放入 .ttf 文件,定义 FontFamily,再构建 Typography:
val AppFontFamily = FontFamily(
Font(Res.font.roboto_regular, FontWeight.Normal),
Font(Res.font.roboto_bold, FontWeight.Bold),
)
val AppTypography = Typography(
headlineLarge = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp),
bodyLarge = TextStyle(fontFamily = AppFontFamily, fontSize = 16.sp),
)
命名用 AppTypography 而非 Typography,避免类型名冲突。
3.4 扩展颜色:CompositionLocal 方案
Material3 的 ColorScheme 有 error 但没有 success,品牌辅助色也无处安放。这些不能放进 ColorScheme,而是通过 CompositionLocal 提供:
data class ExtendedColors(val success: Color, val onSuccess: Color, val warning: Color)
val LocalExtendedColors = staticCompositionLocalOf {
ExtendedColors(Color.Unspecified, Color.Unspecified, Color.Unspecified)
}
通过 ColorScheme 的扩展属性暴露:
val ColorScheme.success: Color @Composable get() = LocalExtendedColors.current.success
使用时 MaterialTheme.colorScheme.success,与内置颜色槽位写法一致。
3.5 完整 AppTheme 与深色模式
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val colorScheme = if (darkTheme) AppDarkColorScheme else AppLightColorScheme
val extendedColors = if (darkTheme) AppDarkExtendedColors else AppLightExtendedColors
CompositionLocalProvider(LocalExtendedColors provides extendedColors) {
MaterialTheme(colorScheme = colorScheme, typography = AppTypography, content = content)
}
}
isSystemInDarkTheme() 在所有平台上都能正确读取系统设置。
3.6 无障碍对比度:容易被忽略的底线
在定义颜色方案时,必须验证对比度符合 WCAG 标准。确保 onPrimary 与 primary、onSurface 与 surface 等配对的对比度至少为 4.5:1。自定义前景/背景色应为 7:1,尤其是小文本。
iOS 还提供“增强对比度”无障碍设置,通过 UIAccessibilityDarkerSystemColorsEnabled 检测。如果应用面向有视觉障碍的用户,应该提供高对比度调色板,在系统启用该设置时切换。
四、习题与参考答案
本课习题分为三类:概念理解(1-5 题)、代码实践(6-11 题)、综合设计(12-15 题)。
概念理解
习题 1:资源目录与限定符支持
题目:composeResources 下有哪些子目录?哪个目录不支持限定符?
参考答案:drawable/(图像)、font/(字体)、values/(字符串)、files/(其他文件)。files/ 目录不支持限定符,且不生成类型安全访问器,需要通过 Res.readBytes() 手动读取。
习题 2:中文双脚本回退机制
题目:为什么中文资源必须同时提供 values-zh 和 values-b+zh+Hans?只提供后者会怎样?
参考答案:Android 和桌面端系统可能请求不带书写方式的语言区域(如 zh 而非 zh-Hans)。如果只提供 values-b+zh+Hans,所有不带脚本的请求都会匹配到它,导致资源无法解析而崩溃。必须同时提供 values-zh 作为无脚本回退。
习题 3:Coil 的网络引擎选择
题目:CMP 项目中使用 Coil 时,为什么必须用 Ktor 引擎而不是 OkHttp?
参考答案:OkHttp 引擎只在 Android 和 JVM 上可用,iOS 和 Web 无法使用。Ktor 本身跨平台,coil-network-ktor3 在所有目标平台上都能工作。
习题 4:iOS 图片内存压力的根源
题目:为什么 CMP 在 iOS 上大量加载图片会导致 OOM 崩溃?
参考答案:CMP 在 iOS 上用 Skia 解码图片,完全绕过了 iOS 的 NSCache、ImageIO 渐进式解码和系统内存压力回收机制。Skia 把图片解码到 Kotlin/Native 堆中,iOS 无法感知和回收。图片密集的列表中常驻内存只增不减,最终 OOM。
习题 5:扩展颜色的必要性
题目:Material3 的 ColorScheme 已经有很多槽位,为什么还需要扩展颜色?
参考答案:Material3 覆盖了通用语义角色,但应用可能有专属需求。比如“成功”状态——Material3 有 error 但没有 success。这些专属颜色不能放进 ColorScheme,需要通过 CompositionLocal 提供。
代码实践
习题 6:添加中文字符串资源
题目:在 values/strings.xml 和 values-zh/strings.xml 中添加应用名称和欢迎消息。
参考答案:
<!-- values/strings.xml -->
<resources>
<string name="app_name">My App</string>
<string name="welcome">Welcome, %s!</string>
</resources>
<!-- values-zh/strings.xml -->
<resources>
<string name="app_name">我的应用</string>
<string name="welcome">欢迎,%s!</string>
</resources>
习题 7:使用字符串模板与复数
题目:定义一个带占位符的字符串模板和一条复数资源,并在 Composable 中使用。
参考答案:
<!-- values/strings.xml -->
<resources>
<string name="messages_count">You have %1$d new messages</string>
<plurals name="new_message">
<item quantity="one">%1$d new message</item>
<item quantity="other">%1$d new messages</item>
</plurals>
</resources>
Text(stringResource(Res.string.messages_count, 5))
Text(pluralStringResource(Res.plurals.new_message, 1, 1))
习题 8:加载网络图片
题目:用 Coil 的 AsyncImage 加载网络图片,带圆角裁剪。
参考答案:
AsyncImage(
model = "https://example.com/avatar.jpg",
contentDescription = "头像",
modifier = Modifier.size(64.dp).clip(CircleShape),
contentScale = ContentScale.Crop,
)
习题 9:定义自定义排版
题目:用自定义字体定义 AppTypography,headlineLarge 用粗体,bodyLarge 用常规体。
参考答案:
val AppFontFamily = FontFamily(
Font(Res.font.roboto_regular, FontWeight.Normal),
Font(Res.font.roboto_bold, FontWeight.Bold),
)
val AppTypography = Typography(
headlineLarge = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp),
bodyLarge = TextStyle(fontFamily = AppFontFamily, fontSize = 16.sp),
)
习题 10:定义扩展颜色
题目:定义 ExtendedColors 数据类,包含 success 和 warning,通过 CompositionLocal 提供。
参考答案:
data class ExtendedColors(val success: Color, val warning: Color)
val LocalExtendedColors = staticCompositionLocalOf {
ExtendedColors(Color.Unspecified, Color.Unspecified)
}
val AppLightExtendedColors = ExtendedColors(
success = Color(0xFF4CAF50), warning = Color(0xFFFF9800),
)
val ColorScheme.success: Color @Composable get() = LocalExtendedColors.current.success
习题 11:iOS 内存压力桥接
题目:用 expect/actual 实现一个 MemoryPressureMonitor,iOS 侧监听内存警告通知。
参考答案:
// commonMain
expect class MemoryPressureMonitor() {
fun onLowMemory(callback: () -> Unit)
}
// iosMain
actual class MemoryPressureMonitor {
actual fun onLowMemory(callback: () -> Unit) {
NSNotificationCenter.defaultCenter.addObserverForName(
UIApplicationDidReceiveMemoryWarningNotification, null, null
) { callback() }
}
}
综合设计
习题 12:完整的 AppTheme
题目:实现 AppTheme,根据系统深色模式切换,同时提供扩展颜色。
参考答案:
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val colorScheme = if (darkTheme) AppDarkColorScheme else AppLightColorScheme
val extendedColors = if (darkTheme) AppDarkExtendedColors else AppLightExtendedColors
CompositionLocalProvider(LocalExtendedColors provides extendedColors) {
MaterialTheme(colorScheme = colorScheme, typography = AppTypography, content = content)
}
}
习题 13:带深色模式适配的图标
题目:为 Logo 提供浅色和深色两个版本。
参考答案:
composeResources/
├── drawable/logo.xml
└── drawable-dark/logo.xml
统一用 Res.drawable.logo,系统根据主题自动选择。
习题 14:带占位图的网络图片
题目:Coil 加载图片时显示占位图和错误图。
参考答案:
AsyncImage(
model = "https://example.com/image.jpg",
contentDescription = null,
placeholder = painterResource(Res.drawable.placeholder),
error = painterResource(Res.drawable.error),
modifier = Modifier.size(200.dp),
)
习题 15:对比度检查与高对比度主题
题目:如何验证颜色方案的对比度符合无障碍标准?iOS 上如何提供高对比度主题?
参考答案:使用 WCAG 对比度检测工具,确保 onPrimary/primary 对比度至少 4.5:1,自定义前景/背景色应为 7:1。iOS 上通过 UIAccessibilityDarkerSystemColorsEnabled 检测“增强对比度”设置,启用时切换到高对比度调色板。
五、本课小结
资源系统:composeResources 统一管理多平台资源。drawable 放图像,font 放字体,values 放字符串,files 放其他文件(不支持限定符)。构建时自动生成 Res.drawable.xxx、Res.string.xxx 类型安全访问器。
限定符与本地化:优先级为语言 > 主题 > 密度。中文必须同时提供 values-zh 和 values-b+zh+Hans,否则无脚本请求会崩溃。支持字符串模板和六种量词的复数资源。
Coil 图片加载:CMP 必须用 Ktor 网络引擎。setSingletonImageLoaderFactory 是 CMP 推荐的全局配置方式。iOS 上 Skia 解码绕过系统内存管理,图片密集列表需实现 expect/actual 内存压力桥接和双层 LRU 缓存,优先驱逐解码位图、保留编码数据。
主题系统:由 ColorScheme、Typography、Shapes 组成。扩展颜色通过 CompositionLocal 提供。isSystemInDarkTheme() 跨平台读取系统设置。颜色方案必须验证 WCAG 对比度(正文 4.5:1,自定义前景/背景 7:1)。
六、下一课预告
第9课 测试与调试
更多推荐




所有评论(0)