HarmonyOS开发:资源打包——资源处理策略
HarmonyOS开发:资源打包——资源处理策略
📌 核心要点:资源文件占HAP体积的40-60%,理解资源打包规则、多分辨率处理、国际化策略和按需加载,是控制包体积和保证用户体验的关键。
背景与动机
你往resources目录里塞了一张2MB的PNG,打包完HAP体积直接涨了2MB。你觉得不对劲——这张图在手机上显示区域就100x100像素,为什么原图是2000x2000?
又或者,你做了10种语言的国际化,但90%的用户只用中文。剩下9种语言的字符串资源,全被打进了HAP,白白占了空间。
再或者,你的应用同时适配手机和平板,手机需要mdpi的图,平板需要xdpi的图,但打包时两种密度的图全塞进去了——手机用户下载了一堆用不上的高清图。
资源打包不是"把文件扔进resources目录就完事"。它有规则、有策略、有坑。搞不懂这些,你的HAP就是资源的垃圾桶——什么都往里装,有用的没用的全打包。
核心原理
HarmonyOS资源目录结构
HarmonyOS的资源目录有严格的结构规范:
resources/
├── base/ # 基础资源(必须存在)
│ ├── element/ # 字符串、颜色、尺寸等
│ │ ├── string.json
│ │ ├── color.json
│ │ └── float.json
│ ├── media/ # 图片资源
│ │ ├── icon.png
│ │ └── logo.webp
│ └── profile/ # 配置文件
│ └── main_pages.json
├── en_US/ # 英文资源(限定词目录)
│ └── element/
│ └── string.json
├── zh_CN/ # 中文资源(限定词目录)
│ └── element/
│ └── string.json
├── phone/ # 手机设备限定词
│ └── media/
│ └── bg_phone.png
├── tablet/ # 平板设备限定词
│ └── media/
│ └── bg_tablet.png
└── rawfile/ # 原始文件(不编译,原样打包)
├── config.json
└── data.db
资源限定词匹配规则
HarmonyOS的资源匹配遵循"最精确匹配"原则:
flowchart TD
A[请求资源] --> B{有限定词资源?}
B -->|有| C[匹配设备限定词]
B -->|无| D[使用base资源]
C --> E{匹配成功?}
E -->|是| F[使用限定词资源]
E -->|否| D
C --> G[匹配优先级]
G --> G1["1. 语言+地区<br>zh_CN > zh > base"]
G --> G2["2. 设备类型<br>phone/tablet > base"]
G --> G3["3. 屏幕密度<br>xdpi > mdpi > sdpi > base"]
classDef start fill:#2ECC71,stroke:#25A55A,color:#fff
classDef decision fill:#F39C12,stroke:#D68910,color:#fff
classDef match fill:#4A90D9,stroke:#2C5F8A,color:#fff
classDef fallback fill:#9B59B6,stroke:#8E44AD,color:#fff
classDef priority fill:#FF6B6B,stroke:#CC5555,color:#fff
class A start
class B,E decision
class C,F match
class D fallback
class G,G1,G2,G3 priority
关键规则:
- base目录是兜底:所有资源必须在base中有默认版本,限定词目录是覆盖
- 限定词可以组合:
zh_CN-phone表示中文+手机 - 匹配失败回退base:找不到限定词资源时,自动使用base中的资源
rawfile与编译资源的区别
| 对比项 | 编译资源(base/限定词目录) | rawfile |
|---|---|---|
| 编译处理 | ✅ 编译为二进制索引 | ❌ 原样打包 |
| 限定词匹配 | ✅ 支持多分辨率/多语言 | ❌ 不支持 |
| 访问方式 | $r('app.media.icon') |
$rawfile('config.json') |
| 体积优化 | ✅ 编译时压缩 | ❌ 不压缩 |
| 适用场景 | 图片、字符串、颜色 | JSON配置、数据库、字体文件 |
代码实战
基础用法:资源文件定义与引用
字符串资源:
// resources/base/element/string.json
{
"string": [
{ "name": "module_desc", "value": "模块描述" },
{ "name": "EntryAbility_desc", "value": "主界面入口" },
{ "name": "EntryAbility_label", "value": "我的应用" },
{ "name": "welcome_text", "value": "欢迎使用" },
{ "name": "login_button", "value": "登录" }
]
}
// resources/en_US/element/string.json(英文覆盖)
{
"string": [
{ "name": "welcome_text", "value": "Welcome" },
{ "name": "login_button", "value": "Login" }
]
}
颜色资源:
// resources/base/element/color.json
{
"color": [
{ "name": "start_window_background", "value": "#FFFFFF" },
{ "name": "primary_color", "value": "#007DFF" },
{ "name": "text_primary", "value": "#182431" },
{ "name": "text_secondary", "value": "#99182431" },
{ "name": "divider_color", "value": "#33182431" }
]
}
在代码中引用资源:
// entry/src/main/ets/pages/Index.ets
@Entry
@Component
struct IndexPage {
build() {
Column() {
// 引用字符串资源
Text($r('app.string.welcome_text'))
.fontSize(24)
.fontColor($r('app.color.text_primary')) // 引用颜色资源
// 引用图片资源
Image($r('app.media.logo'))
.width(100)
.height(100)
// 引用rawfile
Image($rawfile('images/banner.png'))
.width('100%')
.height(200)
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.start_window_background'))
}
}
资源引用格式:
- 编译资源:
$r('app.<type>.<name>'),其中type是element/media/profile - rawfile:
$rawfile('<relative_path>'),路径相对于rawfile目录
进阶用法:多分辨率资源与国际化
多分辨率图片资源:
HarmonyOS支持按屏幕密度提供不同分辨率的图片:
resources/
├── base/media/icon.png # 默认密度(mdpi)
├── sdpi/media/icon.png # 小密度 ~120dpi
├── mdpi/media/icon.png # 中密度 ~160dpi
├── ldpi/media/icon.png # 大密度 ~240dpi
├── xldpi/media/icon.png # 超大密度 ~320dpi
├── xxldpi/media/icon.png # 超超大密度 ~480dpi
└── xxxldpi/media/icon.png # 超超超大密度 ~640dpi
代码中引用时不需要指定密度,系统自动匹配:
// 系统根据设备密度自动选择最合适的icon.png
Image($r('app.media.icon'))
.width(48)
.height(48)
国际化资源:
// resources/zh_CN/element/string.json(简体中文)
{
"string": [
{ "name": "greeting", "value": "你好" },
{ "name": "settings", "value": "设置" },
{ "name": "about", "value": "关于" },
{ "name": "language_name", "value": "中文" }
]
}
// resources/en_US/element/string.json(英文)
{
"string": [
{ "name": "greeting", "value": "Hello" },
{ "name": "settings", "value": "Settings" },
{ "name": "about", "value": "About" },
{ "name": "language_name", "value": "English" }
]
}
// resources/ja_JP/element/string.json(日文)
{
"string": [
{ "name": "greeting", "value": "こんにちは" },
{ "name": "settings", "value": "設定" },
{ "name": "about", "value": "について" }
]
}
运行时获取当前语言:
// entry/src/main/ets/utils/I18nUtil.ets
import { i18n } from '@kit.LocalizationKit';
export class I18nUtil {
// 获取当前系统语言
static getCurrentLanguage(): string {
return i18n.System.getSystemLanguage();
}
// 获取当前地区
static getCurrentRegion(): string {
return i18n.System.getSystemRegion();
}
// 判断是否是中文环境
static isChinese(): boolean {
const lang = I18nUtil.getCurrentLanguage();
return lang.startsWith('zh');
}
}
设备类型限定词:
// resources/phone/element/string.json(手机专用字符串)
{
"string": [
{ "name": "layout_type", "value": "手机布局" },
{ "name": "nav_mode", "value": "底部导航" }
]
}
// resources/tablet/element/string.json(平板专用字符串)
{
"string": [
{ "name": "layout_type", "value": "平板布局" },
{ "name": "nav_mode", "value": "侧边导航" }
]
}
// resources/2in1/element/string.json(二合一设备专用字符串)
{
"string": [
{ "name": "layout_type", "value": "桌面布局" },
{ "name": "nav_mode", "value": "侧边导航" }
]
}
完整示例:资源按需加载策略
对于大型资源(如字体文件、数据库、视频教程),不应该全部打入HAP,而是按需下载:
// shared_common/src/main/ets/utils/ResourceLoader.ets
// 资源按需加载工具
import { http } from '@kit.NetworkKit';
import { fileIo } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
interface ResourceManifest {
name: string;
version: string;
url: string;
size: number;
md5: string;
required: boolean; // 是否必须预加载
}
export class ResourceLoader {
private context: common.UIAbilityContext;
private cacheDir: string;
private manifestUrl: string = 'https://cdn.example.com/resources/manifest.json';
constructor(context: common.UIAbilityContext) {
this.context = context;
this.cacheDir = context.cacheDir;
}
// 获取资源清单
async fetchManifest(): Promise<ResourceManifest[]> {
try {
const response = await http.createHttp().request(this.manifestUrl);
const manifest: ResourceManifest[] = JSON.parse(response.result as string);
return manifest;
} catch (error) {
console.error(`获取资源清单失败: ${error}`);
return [];
}
}
// 下载资源到本地
async downloadResource(resource: ResourceManifest): Promise<string | null> {
const localPath = `${this.cacheDir}/resources/${resource.name}`;
// 检查本地是否已存在
if (this.isResourceValid(localPath, resource.md5)) {
console.info(`资源已存在: ${resource.name}`);
return localPath;
}
try {
// 创建目录
const dir = path.dirname(localPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// 下载文件
const response = await http.createHttp().request(resource.url, {
method: http.RequestMethod.GET
});
// 写入文件
fs.writeFileSync(localPath, response.result as ArrayBuffer);
// 验证MD5
if (!this.isResourceValid(localPath, resource.md5)) {
fs.unlinkSync(localPath);
console.error(`资源校验失败: ${resource.name}`);
return null;
}
console.info(`资源下载完成: ${resource.name}`);
return localPath;
} catch (error) {
console.error(`资源下载失败: ${resource.name}, ${error}`);
return null;
}
}
// 验证资源完整性
private isResourceValid(filePath: string, expectedMd5: string): boolean {
if (!fs.existsSync(filePath)) return false;
const fileData = fs.readFileSync(filePath);
const actualMd5 = crypto.createHash('md5').update(fileData).digest('hex');
return actualMd5 === expectedMd5;
}
// 预加载必须资源
async preloadRequiredResources(): Promise<void> {
const manifest = await this.fetchManifest();
const requiredResources = manifest.filter(r => r.required);
console.info(`需要预加载 ${requiredResources.length} 个资源`);
for (const resource of requiredResources) {
await this.downloadResource(resource);
}
}
// 按需加载指定资源
async loadResourceOnDemand(resourceName: string): Promise<string | null> {
const manifest = await this.fetchManifest();
const resource = manifest.find(r => r.name === resourceName);
if (!resource) {
console.error(`资源不存在: ${resourceName}`);
return null;
}
return await this.downloadResource(resource);
}
// 清理过期资源
async cleanupOldResources(): Promise<number> {
const manifest = await this.fetchManifest();
const validNames = new Set(manifest.map(r => r.name));
const resourceDir = `${this.cacheDir}/resources/`;
if (!fs.existsSync(resourceDir)) return 0;
let cleanedCount = 0;
const files = fs.readdirSync(resourceDir);
for (const file of files) {
if (!validNames.has(file)) {
fs.unlinkSync(path.join(resourceDir, file));
cleanedCount++;
console.info(`清理过期资源: ${file}`);
}
}
return cleanedCount;
}
}
使用示例:
// entry/src/main/ets/entryability/EntryAbility.ets
import { ResourceLoader } from '@ohos/shared_common';
import { UIAbility, AbilityConstant, Want } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
export default class EntryAbility extends UIAbility {
private resourceLoader: ResourceLoader | null = null;
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
this.resourceLoader = new ResourceLoader(this.context);
// 应用启动时预加载必须资源
this.resourceLoader.preloadRequiredResources();
}
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
console.error(`加载内容失败: ${err.message}`);
return;
}
console.info('内容加载完成');
});
}
}
// 在页面中按需加载资源
// entry/src/main/ets/pages/Index.ets
import { ResourceLoader } from '@ohos/shared_common';
@Entry
@Component
struct IndexPage {
private resourceLoader: ResourceLoader | null = null;
@State fontLoaded: boolean = false;
aboutToAppear(): void {
this.resourceLoader = new ResourceLoader(getContext(this) as common.UIAbilityContext);
}
// 用户点击"书法模式"时才下载书法字体
async loadCalligraphyFont(): Promise<void> {
const fontPath = await this.resourceLoader?.loadResourceOnDemand('calligraphy_font.ttf');
if (fontPath) {
// 注册字体
font.registerFont({
familyName: 'Calligraphy',
familySrc: fontPath
});
this.fontLoaded = true;
}
}
build() {
Column() {
Text('示例文字')
.fontFamily(this.fontLoaded ? 'Calligraphy' : 'HarmonyOS Sans')
Button('加载书法字体')
.onClick(() => this.loadCalligraphyFont())
}
}
}
踩坑与注意事项
坑1:rawfile中的大文件不压缩
rawfile目录中的文件是原样打包的,不做任何压缩。如果你往rawfile里塞了一个10MB的JSON文件,HAP就多了10MB。
解法:
- JSON文件可以用gzip压缩,运行时解压
- 大文件尽量用按需下载,不要打进rawfile
- 必须打包的rawfile,用压缩格式(如.zip),运行时解压
// 运行时解压rawfile中的压缩文件
import { zlib } from '@kit.BasicServicesKit';
import { resourceManager } from '@kit.LocalizationKit';
async function extractCompressedRawFile(
context: Context,
zipFileName: string,
outputDir: string
): Promise<void> {
// 读取rawfile
const resMgr = context.resourceManager;
const fileData = await resMgr.getRawFileContent(zipFileName);
// 解压到目标目录
const options: zlib.Options = {
level: zlib.CompressLevel.COMPRESS_LEVEL_DEFAULT_COMPRESSION
};
await zlib.decompressFile(
`${context.cacheDir}/${zipFileName}`,
outputDir,
options
);
}
坑2:多分辨率图片全部打入HAP
你为5种密度各准备了一套图片,打包时5套全打进去了。但目标设备只需要其中一套。
解法:HarmonyOS目前不支持按设备密度选择性打包,所有密度的资源都会打入HAP。如果你对体积敏感,只保留base和最高密度两套,中间密度让系统自动缩放。
坑3:国际化资源遗漏
你在限定词目录(如en_US)中定义了新字符串,但忘了在base中定义默认值。当设备语言不在你的限定词列表中时,找不到资源就崩溃了。
解法:所有资源必须先在base中定义,限定词目录只做覆盖。建议写个脚本检查限定词目录中的资源是否在base中都有对应定义:
// scripts/check-i18n.ets
// 国际化资源完整性检查
function checkI18nCompleteness(baseDir: string, localeDirs: string[]): void {
// 读取base中的所有字符串名称
const baseStrings = readStringNames(baseDir);
const issues: string[] = [];
for (const locale of localeDirs) {
const localeStrings = readStringNames(locale);
// 检查locale中是否有base中不存在的字符串
for (const name of localeStrings) {
if (!baseStrings.includes(name)) {
issues.push(`[${locale}] "${name}" 不在base中定义`);
}
}
// 检查base中的字符串是否都有locale翻译
for (const name of baseStrings) {
if (!localeStrings.includes(name)) {
console.warn(`[${locale}] 缺少 "${name}" 的翻译`);
}
}
}
if (issues.length > 0) {
console.error('❌ 发现以下问题:');
issues.forEach(i => console.error(` ${i}`));
} else {
console.info('✅ 国际化资源检查通过');
}
}
坑4:资源名称冲突
不同限定词目录中的资源名称相同但类型不同,比如base中icon是图片,phone中icon是字符串。打包时会报资源冲突。
解法:同一资源名称在所有限定词目录中必须是同一类型。命名时加前缀区分类型:img_icon、str_title、clr_primary。
坑5:rawfile路径大小写敏感
$rawfile('Images/Banner.png')和$rawfile('images/banner.png')在Windows上可能等价(Windows文件系统不区分大小写),但在HarmonyOS设备上是不同的路径。开发时能跑,真机上找不到文件。
解法:rawfile路径严格区分大小写,和实际文件名完全一致。建议全用小写命名。
HarmonyOS 6适配说明
HarmonyOS 6在资源打包方面有以下改进:
- 资源按密度选择性打包:HarmonyOS 6支持在构建配置中指定目标设备密度,打包时只包含对应密度的资源。这是体积优化的重大改进。
// build-profile.json5
{
"app": {
"products": [
{
"name": "phone_release",
"output": {
"resourceFilter": {
"densities": ["mdpi", "xldpi"], // 只打包这两种密度
"locales": ["zh_CN", "en_US"], // 只打包这两种语言
"devices": ["phone"] // 只打包手机资源
}
}
}
]
}
}
-
资源热更新:HarmonyOS 6支持资源热更新,不需要重新发布整个HAP就能更新字符串和图片资源。通过AGC的资源分发能力,应用可以远程更新资源。
-
rawfile压缩打包:rawfile目录中的文件现在支持压缩打包,运行时自动解压。大幅减少rawfile对包体积的影响。
-
SVG矢量图原生支持:HarmonyOS 6增强了SVG支持,推荐使用SVG替代多分辨率位图。一个SVG文件覆盖所有密度,体积更小。
-
资源索引增量更新:资源索引文件(resources.index)支持增量更新,多模块工程中各模块的资源索引可以独立更新,不再需要全量重建。
总结
资源打包的核心原则就四个字:精准、精简。
精准——资源要匹配设备特征,语言、密度、设备类型,该有的有,不该有的不浪费。精简——能压缩的压缩,能按需加载的按需加载,能不打包的不打包。
记住这几个关键决策:
- 编译资源 vs rawfile:能用编译资源就用编译资源,rawfile是最后的选择
- 多分辨率:只保留base+最高密度,中间密度让系统缩放
- 国际化:base必须有默认值,限定词目录只做覆盖
- 大文件:字体、数据库、视频教程,统统按需下载
| 维度 | 评价 |
|---|---|
| 学习难度 | ⭐⭐⭐ 规则多但不复杂,踩几次坑就记住了 |
| 使用频率 | ⭐⭐⭐⭐⭐ 每次添加资源都涉及 |
| 重要程度 | ⭐⭐⭐⭐ 资源处理不当直接影响体积和用户体验 |
- 点赞
- 收藏
- 关注作者
评论(0)