本课目标:掌握 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课 测试与调试

Logo

一站式 AI 云服务平台

更多推荐