【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 对象:

kotlin 复制代码
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 后缀无功能区别:

xml 复制代码
<string name="str_template">Hello, %2$s! You have %1$d new messages.</string>
kotlin 复制代码
Text(stringResource(Res.string.str_template, 100, "User_name"))

复数资源 :支持 zero、one、two、few、many、other 六种量词。注意并非所有语言都使用所有量词 (英语只关心 one 和 other):

xml 复制代码
<plurals name="new_message">
    <item quantity="one">%1$d new message</item>
    <item quantity="other">%1$d new messages</item>
</plurals>
kotlin 复制代码
Text(pluralStringResource(Res.plurals.new_message, 1, 1))

二、网络图片加载:Coil 3

2.1 为什么用 Coil 而不是手写

painterResource 只能加载打包在应用内的静态资源。网络图片需要异步下载、缓存、解码、显示,并处理占位图和错误状态。Coil 3 原生支持 Compose Multiplatform,核心优势是内存和磁盘缓存、图片降采样、自动暂停/取消请求。

2.2 配置与引擎选择

在 commonMain 中添加依赖:

kotlin 复制代码
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 基本使用与全局配置

kotlin 复制代码
AsyncImage(
    model = "https://example.com/avatar.jpg",
    contentDescription = "头像",
    modifier = Modifier.size(48.dp).clip(CircleShape),
)

AsyncImage 自动处理请求、缓存、解码、取消。如需自定义配置,在根 Composable 中调用 setSingletonImageLoaderFactory------这是 CMP 推荐的方式:

kotlin 复制代码
@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 层:

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 缓存 。内存压力时优先驱逐已解码的位图,保留编码数据------重新解码只需毫秒,重新下载需要秒级和带宽:

kotlin 复制代码
fun onMemoryPressure() {
    bitmapLru.evictAll()  // 先丢弃昂贵的解码数据
    // 保留编码数据,重新解码比重新下载便宜得多
}

如果应用有大量图片列表,建议在项目早期就实现这套机制,而不是等到 OOM 崩溃后再排查。

三、主题系统

3.1 主题的三个组成部分

Material3 的主题由三部分组成:颜色方案 (ColorScheme)、排版 (Typography)、形状 (Shapes)。MaterialTheme 通过 CompositionLocal 提供这三者。

kotlin 复制代码
MaterialTheme(
    colorScheme = AppLightColorScheme,
    typography = AppTypography,
    shapes = AppShapes,
) { /* 应用内容 */ }

3.2 定义颜色方案

第一步:收集原始颜色令牌,用具名常量明确语义:

kotlin 复制代码
val Primary = Color(0xFF6750A4)
val OnPrimary = Color(0xFFFFFFFF)

第二步:组装 ColorScheme,用这些令牌构造浅色和深色方案:

kotlin 复制代码
val AppLightColorScheme = lightColorScheme(
    primary = Primary, onPrimary = OnPrimary, /* ... */
)
val AppDarkColorScheme = darkColorScheme(/* 深色版本 */)

命名建议 :用 AppLightColorScheme 而非 LightColorScheme,避免与 Material3 类型名混淆。

3.3 定义排版与字体

在 composeResources/font/ 下放入 .ttf 文件,定义 FontFamily,再构建 Typography:

kotlin 复制代码
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 提供:

kotlin 复制代码
data class ExtendedColors(val success: Color, val onSuccess: Color, val warning: Color)
val LocalExtendedColors = staticCompositionLocalOf {
    ExtendedColors(Color.Unspecified, Color.Unspecified, Color.Unspecified)
}

通过 ColorScheme 的扩展属性暴露:

kotlin 复制代码
val ColorScheme.success: Color @Composable get() = LocalExtendedColors.current.success

使用时 MaterialTheme.colorScheme.success,与内置颜色槽位写法一致。

3.5 完整 AppTheme 与深色模式

kotlin 复制代码
@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 中添加应用名称和欢迎消息。

参考答案:

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 中使用。

参考答案:

xml 复制代码
<!-- 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>
kotlin 复制代码
Text(stringResource(Res.string.messages_count, 5))
Text(pluralStringResource(Res.plurals.new_message, 1, 1))
习题 8:加载网络图片

题目 :用 Coil 的 AsyncImage 加载网络图片,带圆角裁剪。

参考答案:

kotlin 复制代码
AsyncImage(
    model = "https://example.com/avatar.jpg",
    contentDescription = "头像",
    modifier = Modifier.size(64.dp).clip(CircleShape),
    contentScale = ContentScale.Crop,
)
习题 9:定义自定义排版

题目 :用自定义字体定义 AppTypography,headlineLarge 用粗体,bodyLarge 用常规体。

参考答案:

kotlin 复制代码
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 提供。

参考答案:

kotlin 复制代码
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 侧监听内存警告通知。

参考答案:

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() }
    }
}

综合设计

习题 12:完整的 AppTheme

题目 :实现 AppTheme,根据系统深色模式切换,同时提供扩展颜色。

参考答案:

kotlin 复制代码
@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 加载图片时显示占位图和错误图。

参考答案:

kotlin 复制代码
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课 测试与调试

相关推荐
旖旎夜光1 小时前
LeetCode 1576: 替换所有的问号(模拟) —— 题解
c++·学习·算法·leetcode·力控
mlidongfeng1 小时前
【学习】ParallelKittens(PK)学习与分析
学习
坤坤子吖2 小时前
Python基础语法学习:列表和元组
开发语言·笔记·python·学习
JWASX2 小时前
Java 转 go 学习 - 接口
学习·golang
sukalot2 小时前
Windows 驱动实例分析系列:libwdi 驱动分析 - 应用篇(一)
windows·驱动开发
I Am a robert girl2 小时前
从零读懂世界模型的持续学习:一份组合式基准的源码级拆解
学习·具身智能·持续学习·灾难性遗忘·机器人学习·世界模型·组合式基准
扶风ff2 小时前
练题簿在线免费刷题:刷题、背题、错题复习,一个小程序安排日常备考
学习·小程序
東隅已逝,桑榆非晚2 小时前
数据结构队列
数据结构·笔记·学习
老王爱玩车2 小时前
字符串和字符串函数
c语言·开发语言·数据结构·学习