
📖 引言
上一篇我们完成了开发环境的搭建,并创建了第一个 Hello World 项目。但你可能会发现,新建项目里有一大堆文件和文件夹:AppScope、entry、oh_modules、build-profile.json5、hvigorfile.ts......每个都是做什么的?为什么要这么组织?
为什么有的资源放 media 目录,有的放 rawfile?r() 和 rawfile() 底层有什么区别?限定符(dark、zh_CN、en)的匹配算法是怎样的?Stage 模型和传统的 FA 模型有什么本质区别?
理解项目结构不只是"知道每个文件夹叫什么",更要理解背后的设计思想和运行机制。只有搞清楚资源是怎么被编译、打包、匹配的,你才能在遇到资源加载问题时快速定位;只有搞懂 Stage 模型的生命周期调度,你才能写出高效、健壮的应用。
本文将以「民族图鉴」项目为例,从根目录到每一个配置文件,从资源编译流程到限定符匹配算法,带你彻底搞懂 HarmonyOS 工程的组织方式与底层原理。
🎯 学习目标
完成本文后,你将能够:
- ✅ 深入理解 Stage 模型的设计理念与 Ability 生命周期调度机制
- ✅ 掌握 HarmonyOS 工程的目录结构与各模块职责划分
- ✅ 读懂 app.json5 / module.json5 每个配置项的底层含义
- ✅ 理解资源编译流程:从源文件到编目资源的完整链路
- ✅ 掌握限定符匹配算法的优先级规则与组合匹配逻辑
- ✅ 正确选择 r() 与 rawfile() 的使用场景
- ✅ 按照模块化原则组织代码,为后续扩展打下基础
💡 需求分析
为什么要关注项目结构
好的项目结构就像好的建筑设计------布局合理、动线清晰、扩展方便。但很多开发者只停留在"知道每个文件夹叫什么"的层面,遇到问题只能瞎试。
理解项目结构的三个层次:
| 层次 | 认知水平 | 表现 |
|---|---|---|
| L1 表层 | 知道每个文件夹叫什么 | 能找到文件,但改配置全靠猜 |
| L2 中层 | 理解每个配置项的作用 | 能按需调整配置,知道改了会影响什么 |
| L3 深层 | 理解背后的原理和机制 | 遇到问题能快速定位,能做架构级优化 |
本文目标是带你达到 L3 水平------不仅知其然,更知其所以然。
「民族图鉴」项目模块划分
| 层级 | 位置 | 职责 | 设计考量 |
|---|---|---|---|
| 应用层 | AppScope | 全局应用配置(包名、版本、图标) | 多模块共享,统一管理 |
| 模块层 | entry/ | 主模块,包含所有页面和逻辑 | 当前项目规模小,单模块即可 |
| 页面层 | entry/src/main/ets/pages/ | 各个页面的 UI 组件 | 按功能划分,每个页面独立文件 |
| 服务层 | entry/src/main/ets/services/ | 业务逻辑与数据管理 | 页面与业务分离,便于复用和测试 |
| 模型层 | entry/src/main/ets/models/ | 数据类型定义 | 类型系统的基础,全局共享 |
| 资源层 | entry/src/main/resources/ | 字符串、颜色、图片等资源 | 系统管理,支持限定符自动匹配 |
🛠️ 核心实现
步骤1:Stage 模型深度解析
1.1 Stage 模型 vs FA 模型:本质区别
HarmonyOS 发展至今,经历了两代应用模型:
| 维度 | FA 模型(传统) | Stage 模型(新一代) |
|---|---|---|
| 推出时间 | HarmonyOS 2.0 | HarmonyOS 3.1+ |
| 组件模型 | Ability + Particle | UIAbility + ExtensionAbility |
| 进程模型 | 每个 Ability 独立进程 | 同一应用同一进程(可配置) |
| 窗口管理 | Ability 管理窗口 | 专门的 WindowStage 管理 |
| 生命周期 | 简单的 onCreate/onActive | 更细粒度的 Foreground/Background |
| 配置文件 | config.json | app.json5 + module.json5 |
| 开发模式 | 类 Web 开发 | 原生应用开发体验 |
| 性能 | 进程开销大,启动慢 | 共享进程,启动快,资源占用少 |
Stage 模型的核心优势:
- 统一进程模型:同一应用的所有 UIAbility 默认运行在同一进程,减少内存占用,提升组件间通信效率
- 窗口与业务分离:WindowStage 专门管理窗口,UIAbility 专注业务逻辑,职责更清晰
- 更精细的生命周期:Foreground/Background 区分更明确,便于资源管理和状态恢复
- 更强大的组件化:ExtensionAbility 支持多种扩展类型(服务、卡片、输入法等)
- 更好的多设备支持:天然支持多窗口、多设备协同
💡 为什么叫 Stage? Stage 是"舞台"的意思,WindowStage 就是窗口舞台,UIAbility 在这个舞台上表演。这个比喻很形象------窗口是舞台,业务逻辑是演员,两者解耦。
1.2 UIAbility 生命周期详解
UIAbility 是 Stage 模型的核心组件,它的生命周期比你想象的要复杂。理解生命周期,是写出健壮应用的基础。
完整生命周期状态机:
┌─────────────┐
│ Initial │ 初始状态,Ability 已创建但未初始化
└──────┬──────┘
│ onCreate()
▼
┌─────────────┐
│ Created │ 已创建,完成初始化工作
└──────┬──────┘
│ onForeground()
▼
┌─────────────┐
│ Foreground │ 前台显示,用户可交互
│ (Active) │ onActive 进入激活状态
└──────┬──────┘
│ onBackground()
▼
┌─────────────┐
│ Background │ 后台运行,不可见
└──────┬──────┘
│ onDestroy()
▼
┌─────────────┐
│ Destroyed │ 已销毁,释放所有资源
└─────────────┘
各生命周期回调的职责:
| 回调 | 调用时机 | 应该做什么 | 不应该做什么 |
|---|---|---|---|
| onCreate | Ability 首次创建时 | 初始化全局变量、注册监听、加载配置 | 做耗时操作、UI 相关操作 |
| onForeground | 进入前台前 | 恢复临时暂停的任务、刷新数据 | 做过重的计算,影响启动速度 |
| onActive | 获得焦点时 | 开始动画、开始播放、注册传感器 | ------ |
| onInactive | 失去焦点时 | 暂停动画、暂停播放、注销传感器 | 保存重要数据(可能还会回来) |
| onBackground | 进入后台时 | 保存状态、释放非必要资源、停止后台任务 | 做耗时操作(有 ANR 风险) |
| onDestroy | 销毁前 | 释放所有资源、取消定时器、保存持久化数据 | 异步操作(可能来不及执行) |
「民族图鉴」中的实际应用:
typescript
// entry/src/main/ets/entryability/EntryAbility.ets
import { UIAbility } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';
const TAG: string = 'EntryAbility';
const DOMAIN_NUMBER: number = 0x0000;
export default class EntryAbility extends UIAbility {
/**
* Ability 创建时调用
* 职责:初始化全局服务、加载配置
*/
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
hilog.info(DOMAIN_NUMBER, TAG, 'EntryAbility onCreate');
// 初始化存储服务(全局只需一次)
// StorageService.getInstance().init(this.context);
// 初始化主题服务
// ThemeService.getInstance().init(this.context);
}
/**
* 窗口创建时调用
* 职责:设置窗口属性、加载首页内容
*/
onWindowStageCreate(windowStage: window.WindowStage): void {
hilog.info(DOMAIN_NUMBER, TAG, 'WindowStage create');
// 设置主页面
windowStage.loadContent('pages/SplashPage', (err, data) => {
if (err.code) {
hilog.error(DOMAIN_NUMBER, TAG, 'Failed to load content. Cause: %{public}s',
JSON.stringify(err) ?? '');
return;
}
hilog.info(DOMAIN_NUMBER, TAG, 'Succeeded in loading content');
});
}
/**
* 进入前台时调用
* 职责:恢复暂停的任务、刷新数据
*/
onForeground(): void {
hilog.info(DOMAIN_NUMBER, TAG, 'onForeground');
// 恢复音乐播放(如果在播放中)
// MusicService.getInstance().resume();
}
/**
* 进入后台时调用
* 职责:保存状态、释放资源
*/
onBackground(): void {
hilog.info(DOMAIN_NUMBER, TAG, 'onBackground');
// 暂停音乐播放
// MusicService.getInstance().pause();
// 保存用户状态
// StorageService.getInstance().flush();
}
/**
* Ability 销毁时调用
* 职责:释放所有资源
*/
onDestroy(): void {
hilog.info(DOMAIN_NUMBER, TAG, 'onDestroy');
// 释放音乐播放器资源
// MusicService.getInstance().release();
}
}
⚠️ 重要原则:onBackground 和 onDestroy 中不要做耗时操作!系统给后台应用的时间有限(通常只有几秒),超时会被强制杀死。重要数据要及时保存,不要等到 onDestroy 才做。
1.3 WindowStage:窗口舞台
在 Stage 模型中,窗口管理和业务逻辑是分离的。WindowStage 就是窗口的"舞台",负责管理窗口的创建、销毁、属性设置等。
WindowStage 的核心能力:
| 能力 | 说明 |
|---|---|
| loadContent | 加载页面内容(最重要) |
| setWindowLayoutFullScreen | 设置全屏显示 |
| setWindowBackgroundColor | 设置窗口背景色 |
| setPreferredOrientation | 设置屏幕方向 |
| getMainWindow | 获取主窗口实例 |
| on/ off('windowStageEvent') | 监听窗口事件(获得/失去焦点等) |
为什么要分离?
传统 FA 模型中,Ability 既要管业务逻辑,又要管窗口,职责混乱。Stage 模型将窗口管理抽离出来,UIAbility 只需要关注业务逻辑,窗口相关的事情交给 WindowStage。这符合单一职责原则,也让多窗口支持变得更自然。
步骤2:工程根目录全景解析
2.1 根目录结构全景
一个标准的 HarmonyOS Stage 模型工程,根目录长这样:
民族图鉴项目/
├── AppScope/ # 应用全局配置(所有模块共享)
│ ├── app.json5 # 应用级配置文件
│ └── resources/ # 全局资源(应用图标、启动页等)
│
├── entry/ # 主模块(entry 类型,编译为 HAP)
│ ├── src/
│ │ └── main/
│ │ ├── ets/ # ArkTS 源代码
│ │ ├── resources/ # 模块资源
│ │ └── module.json5 # 模块级配置文件
│ ├── build-profile.json5 # 模块构建配置
│ ├── hvigorfile.ts # 模块构建脚本
│ ├── obfuscation-rules.txt # 混淆规则
│ └── oh-package.json5 # 模块依赖配置
│
├── hvigor/ # hvigor 构建工具配置
│ ├── hvigor-wrapper.js # hvigor 包装脚本
│ └── wrapper/ # 包装器配置
│
├── oh_modules/ # 依赖包(自动生成,类似 node_modules)
│
├── .gitignore # Git 忽略文件
├── build-profile.json5 # 项目级构建配置
├── hvigorfile.ts # 项目级构建脚本入口
├── hvigorw # hvigor 命令行包装器(Unix)
├── hvigorw.bat # hvigor 命令行包装器(Windows)
├── oh-package.json5 # 项目级依赖配置
└── oh-package-lock.json5 # 依赖锁定文件(保证版本一致)
2.2 各目录/文件深度解析
AppScope/ --- 为什么需要全局配置?
你可能会问:为什么要有 AppScope?直接在 entry 里定义不行吗?
答案是:为了多模块支持。一个应用可以有多个模块(entry 模块 + 多个 feature 模块),但应用级配置(包名、版本号、应用图标)只能有一个。AppScope 就是用来放这些全局共享的配置的。
即使现在只有一个 entry 模块,按照规范也要用 AppScope,因为:
- 未来加模块时不用改结构
- 配置分层更清晰
- 符合鸿蒙应用的标准结构
entry/ --- 主模块
entry 是入口模块的意思。每个应用至少有一个 entry 类型的模块,它是应用的主入口。
大型项目可以有多个模块:
entry/:主模块,包含核心功能feature_chat/:聊天功能模块feature_music/:音乐功能模块library_base/:基础库模块(HAR 包)
「民族图鉴」目前功能不算复杂,用单模块(entry)就够了。
oh_modules/ --- 依赖管理
oh_modules 是 ohpm(OpenHarmony Package Manager)的依赖目录,类似前端的 node_modules。
oh_modules/
├── @ohos/ # 官方库
│ ├── hvigor-ohos-plugin/ # 构建插件
│ └── ...
├── @app/ # 应用内共享库
└── ... # 第三方库
关于 oh-package.json5:
json5
{
"name": "ethnic-chronicles",
"version": "1.0.0",
"description": "民族图鉴 - 56个民族文化百科",
"main": "",
"author": "",
"license": "Apache-2.0",
"dependencies": {
// 项目依赖在这里声明
},
"devDependencies": {
// 开发依赖在这里声明
}
}
步骤3:app.json5 与 module.json5 配置详解
3.1 app.json5 --- 应用级配置
app.json5 定义了应用的"身份证"信息:
json5
{
"app": {
"bundleName": "com.ethnic.encyclopedia",
"vendor": "ethnic",
"versionCode": 1000000,
"versionName": "1.0.0",
"icon": "$media:layered_image",
"label": "$string:app_name",
"versionCode": 1000000,
"versionName": "1.0.0",
"minCompatibleVersionCode": 1000000,
"apiVersion": {
"compatible": 12,
"target": 12,
"releaseType": "Release"
}
}
}
关键字段深度解析:
| 字段 | 说明 | 注意事项 |
|---|---|---|
| bundleName | 应用包名,全球唯一 | 域名倒写格式,发布后不建议修改 |
| versionCode | 版本号(整数) | 系统用来判断版本新旧,必须递增 |
| versionName | 版本名称(字符串) | 展示给用户看的,可以随便写 |
| minCompatibleVersionCode | 最低兼容版本 | 用于跨设备兼容,低版本设备能否安装 |
| icon | 应用图标 | 引用 media 资源,支持分层图标 |
| label | 应用名称 | 引用 string 资源,支持国际化 |
💡 versionCode 的命名规范:通常用 6 位数字,前 2 位主版本、中间 2 位次版本、后 2 位修订号。比如 1.2.3 对应 010203 = 10203。
3.2 module.json5 --- 模块级配置
module.json5 是模块的配置文件,内容比 app.json5 多得多。让我们逐段解析:
json5
{
"module": {
// 基本信息
"name": "entry",
"type": "entry",
"mainElement": "EntryAbility",
"description": "$string:module_desc",
// 设备类型
"deviceTypes": ["phone", "tablet"],
// 分发与安装
"deliveryWithInstall": true,
"installationFree": false,
// 页面路由
"pages": "$profile:main_pages",
// UIAbility 声明
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"description": "$string:EntryAbility_desc",
"icon": "$media:layered_image",
"label": "$string:EntryAbility_label",
"startWindowIcon": "$media:startIcon",
"startWindowBackground": "$color:start_window_background",
"exported": true,
"skills": [
{
"entities": ["entity.system.home"],
"actions": ["ohos.want.action.home"]
}
]
}
],
// 权限声明
"requestPermissions": [
{
"name": "ohos.permission.INTERNET",
"reason": "$string:permission_internet_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "always"
}
}
],
// 快捷方式
"shortcuts": [
// ...
]
}
}
核心配置项深度解读:
1. type --- 模块类型
| 类型 | 说明 | 编译产物 |
|---|---|---|
| entry | 入口模块,应用的主入口 | HAP |
| feature | 特性模块,可选功能 | HAP |
| har | 共享库模块,代码复用 | HAR(类似 AAR/JAR) |
| HSP | 共享包,运行时共享 | HSP(Harmony Shared Package) |
2. deliveryWithInstall & installationFree
这两个配置涉及原子化服务(Atomic Service)概念:
| deliveryWithInstall | installationFree | 说明 |
|---|---|---|
| true | false | 普通应用,用户主动安装后才能使用 |
| true | true | 原子化服务,免安装,可直接打开 |
| false | true | 动态特性模块,按需分发下载 |
「民族图鉴」是普通应用,所以 deliveryWithInstall: true, installationFree: false。
3. abilities --- UIAbility 声明
每个 UIAbility 都必须在这里声明,系统才能找到并启动它。
关键子项:
- name:Ability 的类名,必须和代码里的类名一致
- srcEntry:Ability 的入口文件路径
- exported:是否允许其他应用启动(true=允许)
- skills:匹配规则,定义 Ability 能响应哪些 Intent/Want
skills 的工作原理:
系统收到一个 Want(要启动某个页面的请求)
↓
遍历所有已安装应用的所有 Ability
↓
检查每个 Ability 的 skills 是否匹配 Want
↓
匹配成功 → 可以启动这个 Ability
↓
如果有多个匹配 → 弹出选择框让用户选
这就是为什么桌面能启动你的应用------因为你的 Ability 声明了 action.home + entity.home,桌面启动器的 Want 正好匹配这个 skill。
4. requestPermissions --- 权限声明
应用需要的权限必须在这里声明,否则无法使用。
权限类型:
- 普通权限:声明即可使用,如 INTERNET
- 敏感权限:声明后还需要用户动态授权,如定位、相机
- 系统权限:只有系统应用才能用
「民族图鉴」只需要网络权限(普通权限),所以声明后直接可用。
步骤4:资源管理体系 --- 编译流程与原理
4.1 资源为什么需要"编译"?
你可能觉得奇怪:图片、字符串这些资源,直接打包进去不就行了?为什么还要"编译"?
答案是:为了性能 和多端适配。
资源编译做了哪些事:
- 编目(Catalog):给每个资源分配一个唯一的 ID,运行时通过 ID 查找,比字符串匹配快得多
- 优化:图片压缩、格式转换、去重
- 限定符匹配预处理:预先生成匹配表,运行时快速查找
- 生成索引:生成 resources.index 文件,加速资源查找
- 对齐优化:内存对齐,提高加载速度
这就是为什么 $r('app.string.app_name') 比字符串查找快------底层是用 ID 查的。
4.2 资源编译完整流程
源资源文件
│
├─ base/element/string.json ──┐
├─ base/element/color.json ───┤
├─ base/media/logo.png ───────┤
├─ dark/element/color.json ───┤
├─ zh_CN/element/string.json ─┤
└─ ... │
▼
┌─────────────────────┐
│ 资源编译工具链 │
│ (Resource Compiler) │
└─────────┬───────────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
编目资源表 优化后的资源文件 resources.index
(resources.arsc) (压缩/对齐/去重) (资源索引)
│ │ │
└─────────────────┼─────────────────┘
▼
┌─────────────────────┐
│ 打包进 HAP │
└─────────────────────┘
编译后的资源结构(在 HAP 包里):
resources/
├── index/
│ └── resources.index # 资源索引文件(加速查找)
├── base/
│ ├── elements/
│ │ └── resources.arsc # 编目后的元素资源(二进制格式)
│ └── media/
│ └── logo.png # 优化后的图片
├── dark/
│ └── elements/
│ └── resources.arsc # 深色模式的元素资源
├── zh_CN/
│ └── elements/
│ └── resources.arsc # 中文的元素资源
└── rawfile/ # rawfile 原样打包,不编译
└── coverImage/
└── 01_han.jpg
💡 这就是为什么叫 $r():r 是 Resource 的缩写,但更深层的含义是------它引用的是编目(catalog)后的资源,有唯一 ID,查找高效。
4.3 element 资源:字符串、颜色、尺寸
element 目录下的是元素资源,这些资源会被编译成二进制格式(resources.arsc),运行时通过 ID 快速查找。
string.json --- 字符串资源:
json
{
"string": [
{
"name": "app_name",
"value": "民族图鉴"
},
{
"name": "home_tab",
"value": "首页"
},
{
"name": "search_hint",
"value": "搜索民族名称、拼音..."
}
]
}
color.json --- 颜色资源:
json
{
"color": [
{
"name": "primary_color",
"value": "#E74C3C"
},
{
"name": "text_primary",
"value": "#1A1A1A"
},
{
"name": "text_secondary",
"value": "#666666"
},
{
"name": "background_color",
"value": "#F5F5F5"
}
]
}
float.json --- 尺寸资源:
json
{
"float": [
{
"name": "font_size_xs",
"value": "12fp"
},
{
"name": "font_size_sm",
"value": "14fp"
},
{
"name": "font_size_md",
"value": "16fp"
},
{
"name": "spacing_xs",
"value": "4vp"
},
{
"name": "spacing_sm",
"value": "8vp"
},
{
"name": "spacing_md",
"value": "12vp"
},
{
"name": "spacing_lg",
"value": "16vp"
}
]
}
💡 单位说明:fp = font pixel(字体像素,随系统字号缩放),vp = viewport pixel(视口像素,类似 dp)。
4.4 media 资源:媒体文件
media 目录存放图片、图标等媒体资源。
特点:
- 不能有子目录,所有文件平铺
- 文件名即资源名(去掉扩展名)
- 会被系统优化(压缩、格式转换)
- 支持密度限定符(mdpi/hdpi/xhdpi/xxhdpi)
- 通过
$r('app.media.xxx')引用
「民族图鉴」的 media 资源:
resources/base/media/
├── startIcon.png # 启动图标
├── layered_image.json # 分层图标配置
├── icon_home.png # 首页图标
├── icon_search.png # 搜索图标
├── icon_map.png # 地图图标
├── icon_quiz.png # 测验图标
├── icon_profile.png # 个人中心图标
└── ...
4.5 rawfile:原始文件
rawfile 是一个特殊的目录------里面的文件不会被编译处理,会原样打包进 HAP。
rawfile 的特点:
| 特点 | 说明 |
|---|---|
| 不编译 | 文件原样打包,没有编目,没有优化 |
| 支持子目录 | 可以自由组织目录结构 |
| 动态加载 | 可以通过路径字符串动态加载 |
| 无限定符 | 不支持 dark/en/zh_CN 等限定符 |
| 性能略低 | 运行时路径解析,没有 ID 索引 |
什么时候用 rawfile?
- 大量同类型资源:比如 56 个民族的封面图,放 media 里平铺太乱,放 rawfile 可以建子目录分类
- 需要动态路径 :
Image($rawfile(coverImage/${id}.jpg)),动态拼接路径 - 原始数据文件:JSON 配置、二进制数据、音视频文件等
- 不想被系统优化:有些图片你不希望系统压缩转码,就要放 rawfile
「民族图鉴」的 rawfile 组织:
resources/rawfile/
├── coverImage/ # 民族封面图(56张)
│ ├── 01_han.jpg
│ ├── 02_zhuang.jpg
│ ├── 03_hui.jpg
│ └── ... (共56张)
└── ...
步骤5:限定符匹配算法深度解析
5.1 限定符是什么?
限定符(Qualifier)是 HarmonyOS 资源管理的核心机制。它允许你为不同的设备状态(深色模式、语言、屏幕密度、设备类型等)提供不同的资源,系统会自动选择最合适的。
常见限定符类型:
| 限定符类型 | 示例 | 说明 |
|---|---|---|
| 移动国家码 | mcc460 | 中国(460是中国MCC) |
| 移动网络码 | mnc00 | 运营商 |
| 语言 | zh, en, ja | 简体中文、英文、日文 |
| 文字 | Hans, Hant | 简体、繁体 |
| 国家/地区 | CN, US, JP | 中国、美国、日本 |
| 横竖屏 | horizontal, vertical | 横屏、竖屏 |
| 设备类型 | phone, tablet, wearable | 手机、平板、手表 |
| 设备密度 | mdpi, hdpi, xhdpi, xxhdpi | 屏幕密度 |
| 主题模式 | dark, light | 深色、浅色 |
限定符可以组合使用,比如 zh_Hans_CN 表示:中文(zh)+ 简体(Hans)+ 中国(CN)。
5.2 限定符匹配算法
限定符匹配是一个从精确到模糊的匹配过程,优先级是固定的。
匹配优先级(从高到低):
MCC/MNC(移动国家码/网络码)
↓
语言 + 文字 + 地区(zh_Hans_CN)
↓
横竖屏(horizontal/vertical)
↓
设备类型(phone/tablet/wearable)
↓
屏幕密度(mdpi/hdpi/xhdpi)
↓
主题模式(dark/light)
↓
base(默认)
匹配规则:
- 系统会列出所有限定符组合,按优先级排序
- 从最高优先级开始匹配,能匹配上就用这个
- 匹配不上就降级,尝试下一级
- 最后都匹配不上就用 base(默认)
举个例子:手机 + 中文 + 深色模式
资源目录列表:
├── dark/ ← 只有深色
├── zh_CN/ ← 只有中文
├── zh_CN-dark/ ← 中文+深色(组合限定符)
├── phone/ ← 只有手机
└── base/ ← 默认
匹配过程:
1. 先找 zh_CN-dark/phone → 没有
2. 降级找 zh_CN-dark → 有!用这个 ✅
3. 如果没有 zh_CN-dark,继续降级找 zh_CN → 有就用
4. 如果也没有,找 dark → 有就用
5. 最后用 base
💡 组合限定符的命名规则 :多个限定符用下划线连接,优先级高的在前。比如
zh_CN-dark(语言+地区在前,主题在后),而不是dark-zh_CN。
5.3 「民族图鉴」的限定符设计
「民族图鉴」支持中文+英文双语,支持深色模式,所以资源目录是这样的:
resources/
├── base/ # 默认(中文浅色)
│ ├── element/
│ │ ├── string.json # 中文字符串
│ │ ├── color.json # 浅色配色
│ │ └── float.json # 尺寸
│ ├── media/
│ └── profile/
├── en/ # 英文(浅色)
│ └── element/
│ └── string.json # 英文字符串
├── dark/ # 深色(中文)
│ └── element/
│ ├── color.json # 深色配色
│ └── float.json
└── rawfile/ # rawfile 没有限定符
└── coverImage/
深色模式颜色示例:
json
// dark/element/color.json
{
"color": [
{
"name": "primary_color",
"value": "#FF6B6B"
},
{
"name": "text_primary",
"value": "#E0E0E0"
},
{
"name": "text_secondary",
"value": "#A0A0A0"
},
{
"name": "background_color",
"value": "#1A1A1A"
},
{
"name": "card_background",
"value": "#2D2D2D"
}
]
}
⚠️ 注意 :深色模式下,颜色名字要和 base 里的完全一样,这样系统才能覆盖替换。比如 base 里叫
text_primary,dark 里也要叫text_primary。
步骤6:r() vs rawfile() --- 底层原理对比
6.1 $r() 的工作原理
$r('app.string.app_name') 看起来是个简单的函数调用,但背后做了很多事。
$r() 的查找流程:
$r('app.string.app_name')
│
├─ 解析参数:type=string, name=app_name
│
▼
查找 resources.index(资源索引)
│
├─ 找到资源 ID(比如 0x7F010001)
│
▼
根据当前设备状态(语言、深色、密度等)
找到对应的限定符目录
│
▼
在对应目录的 resources.arsc 中
通过 ID 查找资源值
│
▼
返回 Resource 对象(包装了资源值和类型)
为什么 $r() 不能动态拼接?
因为资源 ID 是编译时确定的,编译器需要在编译阶段就知道你引用的是哪个资源,才能把 ID 编进去。如果你运行时才拼字符串,编译器不知道你要找哪个资源,就没法生成对应的 ID。
typescript
// ✅ 可以:编译时就能确定引用的资源
const name = $r('app.string.app_name');
// ❌ 不行:编译时不知道 name 是什么
const resourceName = 'app_name';
const name = $r(`app.string.${resourceName}`);
6.2 $rawfile() 的工作原理
$rawfile() 就简单多了------它就是按路径找文件。
$rawfile() 的查找流程:
$rawfile('coverImage/01_han.jpg')
│
├─ 直接在 rawfile 目录下按路径查找
│
▼
找到对应文件
│
▼
返回 Resource 对象(指向原始文件)
为什么 $rawfile() 可以动态拼接?
因为它就是文件路径查找,运行时拼路径也能找到。没有编译时编目,也就没有编译时检查。
typescript
// ✅ 可以:运行时动态拼接路径
const imagePath = `coverImage/${this.ethnic.id}_${this.ethnic.pinyin}.jpg`;
Image($rawfile(imagePath))
6.3 详细对比与选型指南
| 对比维度 | $r() (编目资源) | $rawfile() (原始文件) |
|---|---|---|
| 编译处理 | 编目、优化、生成索引 | 原样打包,不处理 |
| 查找方式 | 通过资源 ID 查找(快) | 通过文件路径查找(略慢) |
| 限定符支持 | 完整支持(dark/语言/密度等) | 不支持 |
| 子目录 | 不支持(media 下平铺) | 支持 |
| 动态拼接 | 不支持(编译时确定) | 支持(运行时拼接) |
| 编译检查 | 有(写错了编译报错) | 无(写错了运行时才发现) |
| 内存占用 | 低(索引优化) | 略高 |
| 适用场景 | UI 图标、颜色、字符串、尺寸 | 大量内容图、原始数据、动态加载 |
「民族图鉴」的选型实践:
typescript
// ✅ 用 $r():字符串、颜色、尺寸、UI 图标
Text($r('app.string.home_tab'))
.fontSize($r('app.float.font_size_md'))
.fontColor($r('app.color.text_primary'))
Image($r('app.media.icon_home'))
// ✅ 用 $rawfile():56个民族封面图(数量多、动态加载)
Image($rawfile(`coverImage/${this.ethnic.coverImage}`))
步骤7:代码分层架构设计
7.1 为什么要分层?
你可能见过这样的代码:一个几千行的 .ets 文件,UI、业务逻辑、数据处理全混在一起。改一个需求要翻半天,加一个功能牵一发而动全身。这就是没有分层的后果。
分层的好处:
| 好处 | 说明 |
|---|---|
| 职责清晰 | 每层只做一件事,改 UI 不影响业务逻辑 |
| 便于维护 | 出问题知道去哪一层找 |
| 便于测试 | 业务逻辑可以单独写单元测试 |
| 便于复用 | Service 层可以被多个页面复用 |
| 便于协作 | 不同人负责不同层,减少冲突 |
7.2 「民族图鉴」的分层架构
┌─────────────────────────────────┐
│ Pages(页面层) │
│ 只做 UI 展示和用户交互 │
│ Index.ets, EthnicDetailPage... │
└──────────────┬──────────────────┘
│ 调用
▼
┌─────────────────────────────────┐
│ Services(服务层) │
│ 业务逻辑、状态管理、数据处理 │
│ StorageService, ThemeService │
│ MusicService, I18nService... │
└──────────────┬──────────────────┘
│ 调用
▼
┌─────────────────────────────────┐
│ Models(模型层) │
│ 数据结构定义、类型约束 │
│ EthnicGroup, QuizQuestion... │
└─────────────────────────────────┘
▲
│ 引用
┌─────────────────────────────────┐
│ Mock(模拟数据) │
│ 本地测试数据 │
│ EthnicMockData, MusicMockData │
└─────────────────────────────────┘
各层职责详解:
Page 层(页面层):
- 只负责 UI 展示和用户交互
- 不写业务逻辑,只调用 Service
- 不直接操作数据,通过 Service 获取
- 页面状态(如选中项、输入内容)可以存在页面里
Service 层(服务层):
- 封装业务逻辑
- 管理数据状态(如收藏列表、浏览历史)
- 处理数据持久化(读写 Preferences)
- 对外提供清晰的 API,供页面调用
- 单例模式,全局共享状态
Model 层(模型层):
- 定义数据结构(interface / class)
- 定义枚举、常量
- 不包含业务逻辑,只是数据的载体
Mock 层(模拟数据):
- 提供模拟数据,供开发和测试使用
- 真实项目中会被 API 服务层替代
7.3 分层调用原则
✅ 允许:Page → Service → Model
✅ 允许:Page → Model(只使用类型,不操作数据)
✅ 允许:Service → Model
✅ 允许:Service 之间互相调用
❌ 禁止:Model → Service(数据模型不能调用业务逻辑)
❌ 禁止:Model → Page(数据模型不能直接操作页面)
❌ 禁止:Service → Page(服务不能直接操作页面,用通知/回调)
❌ 禁止:Page 之间直接调用(用 Service 或路由传参)
反模式示例(不要这么写):
typescript
// ❌ 反模式:页面里写了一堆业务逻辑
@Entry
@Component
struct EthnicDetailPage {
@State ethnic: EthnicGroup | null = null;
@State isFavorited: boolean = false;
@State historyList: string[] = [];
aboutToAppear(): void {
// 页面里直接读写 Preferences(应该封装到 Service)
Preferences.getInstance().then(prefs => {
prefs.get('favorites', []).then(favs => {
this.isFavorited = favs.includes(this.ethnic!.id);
});
prefs.get('history', []).then(hist => {
this.historyList = hist;
// 去重、添加、排序... 业务逻辑全写在页面里
const newList = hist.filter(id => id !== this.ethnic!.id);
newList.unshift(this.ethnic!.id);
prefs.put('history', newList.slice(0, 100));
prefs.flush();
});
});
}
}
正确模式(Service 封装):
typescript
// ✅ 正确:Service 封装业务逻辑,页面只调用
// services/StorageService.ets
export class StorageService {
private static instance: StorageService;
static getInstance(): StorageService {
if (!StorageService.instance) {
StorageService.instance = new StorageService();
}
return StorageService.instance;
}
async addToHistory(ethnicId: string): Promise<void> {
// 所有业务逻辑在这里处理:去重、排序、容量限制
const history = await this.getHistory();
const filtered = history.filter(id => id !== ethnicId);
filtered.unshift(ethnicId);
const limited = filtered.slice(0, 100); // 最多保存100条
await this.saveHistory(limited);
}
async toggleFavorite(ethnicId: string): Promise<boolean> {
const favorites = await this.getFavorites();
const index = favorites.indexOf(ethnicId);
if (index > -1) {
favorites.splice(index, 1);
await this.saveFavorites(favorites);
return false;
} else {
favorites.push(ethnicId);
await this.saveFavorites(favorites);
return true;
}
}
// ... 其他方法
}
// 页面层(简洁清爽)
@Entry
@Component
struct EthnicDetailPage {
@State ethnic: EthnicGroup | null = null;
@State isFavorited: boolean = false;
private storageService: StorageService = StorageService.getInstance();
aboutToAppear(): void {
// 页面只调用 Service,不关心内部实现
this.loadData();
}
async loadData(): Promise<void> {
this.isFavorited = await this.storageService.isFavorite(this.ethnic!.id);
await this.storageService.addToHistory(this.ethnic!.id);
}
async handleFavoriteClick(): Promise<void> {
this.isFavorited = await this.storageService.toggleFavorite(this.ethnic!.id);
}
}
看,页面代码是不是清爽多了?而且 StorageService 里的逻辑,收藏页、图鉴页都能复用。
⚠️ 常见问题与解决方案
问题1:资源引用失败,图片不显示
现象 :
代码里写了 Image('coverImage/01_han.jpg'),运行后图片不显示,报资源找不到。或者写了 Image($r('app.media.xxx')) 但编译器报错找不到资源。
常见原因与排查:
| 错误写法 | 错误原因 | 正确写法 |
|---|---|---|
Image('coverImage/01_han.jpg') |
字符串路径不能直接加载 rawfile | Image($rawfile('coverImage/01_han.jpg')) |
Image($r('app.rawfile.xxx')) |
$r 不能引用 rawfile | Image($rawfile('xxx.jpg')) |
Image($r('app.media.cover/01')) |
media 下不能有子目录 | 放 rawfile,用 $rawfile |
Image($r('app.media.Logo')) |
大小写错误(logo vs Logo) | 统一小写命名 |
Image($r('app.media.logo.jpg')) |
不需要加扩展名 | $r('app.media.logo') |
资源引用速查表:
resources/base/media/logo.png
↓ 引用方式
$r('app.media.logo')
resources/base/element/string.json → { "name": "app_name", ... }
↓ 引用方式
$r('app.string.app_name')
resources/base/element/color.json → { "name": "primary", ... }
↓ 引用方式
$r('app.color.primary')
resources/rawfile/coverImage/01_han.jpg
↓ 引用方式
$rawfile('coverImage/01_han.jpg')
问题2:新建页面后路由跳转报错
现象 :
新建了一个页面,代码里写 router.pushUrl({ url: 'pages/NewPage' }),运行时跳不过去,控制台报错找不到页面。
原因 :
页面没有在 main_pages.json 中注册。HarmonyOS 的路由系统是静态注册的,必须在配置文件中声明所有页面。
为什么要静态注册?
- 安全:防止恶意代码随意跳转到应用内部页面
- 性能:编译时就能构建路由表,启动时加载更快
- 包管理:原子化服务按需分发时,知道哪些页面在哪个包里
完整正确流程:
第1步:创建页面文件
entry/src/main/ets/pages/NewPage.ets
第2步:在 main_pages.json 注册
resources/base/profile/main_pages.json
→ 在 src 数组中添加 "pages/NewPage"
第3步:代码中跳转
router.pushUrl({ url: 'pages/NewPage' })
注意事项:
- 路径完全一致(大小写敏感)
- 不要加 .ets 后缀
- 路径是 pages/xxx,对应 ets/pages/xxx.ets
- 第一个页面是启动页(如果 Ability 没指定的话)
问题3:深色模式下颜色不对
现象 :
切换到深色模式后,有些颜色还是浅色模式的颜色,导致文字看不清、背景太白。
常见原因:
| 原因 | 表现 | 解决方法 |
|---|---|---|
| 颜色只在 base 定义了,dark 里没有 | 所有地方都用浅色 | 在 dark/element/color.json 中补全 |
| 颜色名不匹配 | 部分颜色对,部分不对 | 检查 dark 里的颜色名和 base 是否一致 |
| 硬编码颜色值 | 某个控件颜色永远不变 | 改成用 $r('app.color.xxx') 引用 |
| 图片没做多版本 | 深色模式下图标看不清 | 给深色模式做一套图标,放 dark/media |
深色模式适配检查清单:
□ 所有文字颜色都用 $r('app.color.text_xxx') 引用
□ 所有背景颜色都用 $r('app.color.bg_xxx') 引用
□ dark/element/color.json 里定义了所有颜色
□ 颜色名称和 base 里完全一致
□ 图标在深色模式下也能看清(必要时放 dark/media)
□ 图片上的文字有半透明遮罩,确保在任何背景下都可读
问题4:为什么 media 下不能有子目录?
现象 :
想在 media 下建个子目录分类放图片,结果引用不到。
原因 :
这是 HarmonyOS 资源系统的设计限制。media 下的资源会被编目,文件名即资源名,系统是扁平化管理的。
为什么这么设计?
- 性能:扁平化查找更快,不需要遍历子目录
- 限定符匹配:子目录会让限定符匹配逻辑变得非常复杂
- 唯一性:保证资源名全局唯一,不会冲突
解决方案:
| 方案 | 适用场景 | 示例 |
|---|---|---|
| 命名前缀 | 图片数量中等(几十-上百张) | icon_home.png, cover_han.jpg |
| 放 rawfile | 图片数量多,需要分类组织 | rawfile/coverImage/01_han.jpg |
| 拆分模块 | 大型项目,功能独立 | feature_music/src/main/resources/media/ |
「民族图鉴」的选择:
- UI 图标(少量)→ 放 media,用命名前缀:
icon_home.png - 民族封面图(56张,多)→ 放 rawfile,用子目录:
rawfile/coverImage/01_han.jpg
问题5:修改了资源但预览/运行没生效
现象 :
改了 string.json 里的文字,或者换了一张图片,但 Previewer 里还是旧的,运行到手机上也没变。
常见原因与解决:
| 原因 | 概率 | 解决方法 |
|---|---|---|
| Previewer 缓存没刷新 | 30% | 点 Previewer 顶部的刷新按钮 |
| 构建缓存过期 | 25% | Build → Clean Project → Rebuild |
| 设备上的应用没更新 | 20% | 卸载旧应用,重新安装运行 |
| 改的不是当前生效的文件 | 15% | 确认改的是 base 还是 zh_CN 还是 dark |
| 文件名/资源名写错了 | 10% | 检查拼写,注意大小写 |
万能解决步骤(资源相关问题):
第1步:点击 Previewer 刷新按钮
↓ 不行
第2步:Build → Clean Project
↓
第3步:Build → Rebuild Project
↓ 不行
第4步:卸载设备上的旧应用
↓
第5步:重新运行
↓ 还不行
第6步:File → Invalidate Caches → 重启 IDE
💡 经验之谈:资源问题 90% 都是缓存导致的。改了资源没生效,先 Clean + Rebuild 再说。
📝 本章小结
核心知识点
本文从原理到实践,深度解析了 HarmonyOS 工程的目录结构与资源体系:
1. Stage 模型原理
- Stage 模型 vs FA 模型:统一进程、窗口分离、更细生命周期
- UIAbility 生命周期:Created → Foreground → Background → Destroyed
- WindowStage:窗口舞台,专门管理窗口,与业务逻辑解耦
- 各生命周期回调的职责与注意事项
2. 工程目录结构
- AppScope:全局配置,多模块共享
- entry/:主模块,代码资源都在这
- oh_modules/:依赖包(类似 node_modules)
- build-profile.json5 / hvigorfile.ts:构建配置
3. 配置文件详解
- app.json5:应用级配置(包名、版本、图标)
- module.json5:模块级配置(Ability、权限、页面路由)
- skills 匹配原理:系统如何找到并启动你的 Ability
- 权限声明:普通权限、敏感权限、系统权限
4. 资源编译原理
- 为什么资源需要编译:编目加速查找、优化、限定符预处理
- 资源编译流程:源文件 → 编译优化 → 编目 → 打包
- element 资源(string/color/float):编译为二进制 arsc
- media 资源:优化压缩,文件名即资源名
- rawfile:原样打包,不编译,支持子目录
5. 限定符匹配算法
- 限定符类型:语言、地区、设备类型、密度、主题模式
- 匹配优先级:从精确到模糊,MCC > 语言 > 设备类型 > 密度 > 主题
- 组合限定符:下划线连接,高优先级在前
- base 是最后的兜底
6. r() vs rawfile()
- $r():编目资源,ID 查找,性能好,有限定符,编译检查,不能动态拼接
- $rawfile():原始文件,路径查找,支持子目录,可动态拼接,无限定符
- 选型原则:图标/颜色/字符串用 r(),大量内容图/动态加载用 rawfile()
7. 代码分层架构
- 三层架构:Page → Service → Model
- 各层职责:页面做UI,服务做业务,模型做数据定义
- 调用原则:从上往下调用,禁止反向依赖
- 单例模式:Service 层全局共享状态
最佳实践总结
✅ 资源引用选对方式
typescript
// 图标、颜色、字符串、尺寸 → 用 $r()
Image($r('app.media.icon_search'))
Text($r('app.string.home_tab'))
.fontColor($r('app.color.text_primary'))
.fontSize($r('app.float.font_size_md'))
// 大量内容图片、动态加载 → 用 $rawfile()
Image($rawfile(`coverImage/${ethnic.coverImage}`))
✅ 新页面必走三步
1. 创建 .ets 页面文件
2. 在 main_pages.json 中注册路由
3. 写跳转代码 router.pushUrl()
✅ 深色模式同步维护
base/element/color.json 里加了一个颜色
↓
dark/element/color.json 里也要加同名的颜色
↓
值要适配深色背景(文字变浅,背景变深)
✅ 按分层组织代码,不要堆在页面里
typescript
// 页面只做 UI 和交互
// 业务逻辑放 Service
// 数据模型单独定义
// 各司其职,代码才好维护和复用
✅ 修改资源没生效?先清理缓存
Clean Project → Rebuild Project → 卸载重装
三步下来,90% 的资源问题都能解决
下一步预告
在下一篇文章中,我们将:
- 📚 深入学习 ArkTS 语言的核心语法与类型系统
- 🔤 掌握接口、类、枚举的设计原则与最佳实践
- 🔄 理解函数式编程特性与 ArkTS 的独特之处
- 🚀 学习 ArkTS 严格模式的约束与规避技巧
- 💡 为声明式 UI 开发打下坚实的语言基础
🔗 相关链接
- 项目源码 : GitCode 仓库
- 应用配置文件 : 官方文档 app.json5
- 模块配置文件 : 官方文档 module.json5
- 资源分类与访问 : 官方文档
- 限定符匹配 : 官方文档
- UIAbility 生命周期 : 官方文档
💡 提示:建议对照着自己的项目文件来看本文,打开每个目录和文件看看,再结合原理理解,比光看文字印象深刻得多。搞懂了项目结构和资源体系,后面开发就能事半功倍------遇到资源问题知道怎么查,想加功能知道往哪放。