HarmonyOS应用<民族图鉴>开发第3篇:项目结构——工程目录与资源体系深度解析

📖 引言

上一篇我们完成了开发环境的搭建,并创建了第一个 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 模型的核心优势

  1. 统一进程模型:同一应用的所有 UIAbility 默认运行在同一进程,减少内存占用,提升组件间通信效率
  2. 窗口与业务分离:WindowStage 专门管理窗口,UIAbility 专注业务逻辑,职责更清晰
  3. 更精细的生命周期:Foreground/Background 区分更明确,便于资源管理和状态恢复
  4. 更强大的组件化:ExtensionAbility 支持多种扩展类型(服务、卡片、输入法等)
  5. 更好的多设备支持:天然支持多窗口、多设备协同

💡 为什么叫 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,因为:

  1. 未来加模块时不用改结构
  2. 配置分层更清晰
  3. 符合鸿蒙应用的标准结构

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 资源为什么需要"编译"?

你可能觉得奇怪:图片、字符串这些资源,直接打包进去不就行了?为什么还要"编译"?

答案是:为了性能多端适配

资源编译做了哪些事:

  1. 编目(Catalog):给每个资源分配一个唯一的 ID,运行时通过 ID 查找,比字符串匹配快得多
  2. 优化:图片压缩、格式转换、去重
  3. 限定符匹配预处理:预先生成匹配表,运行时快速查找
  4. 生成索引:生成 resources.index 文件,加速资源查找
  5. 对齐优化:内存对齐,提高加载速度

这就是为什么 $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?

  1. 大量同类型资源:比如 56 个民族的封面图,放 media 里平铺太乱,放 rawfile 可以建子目录分类
  2. 需要动态路径Image($rawfile(coverImage/${id}.jpg)),动态拼接路径
  3. 原始数据文件:JSON 配置、二进制数据、音视频文件等
  4. 不想被系统优化:有些图片你不希望系统压缩转码,就要放 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(默认)

匹配规则

  1. 系统会列出所有限定符组合,按优先级排序
  2. 从最高优先级开始匹配,能匹配上就用这个
  3. 匹配不上就降级,尝试下一级
  4. 最后都匹配不上就用 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. 安全:防止恶意代码随意跳转到应用内部页面
  2. 性能:编译时就能构建路由表,启动时加载更快
  3. 包管理:原子化服务按需分发时,知道哪些页面在哪个包里

完整正确流程

复制代码
第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 下的资源会被编目,文件名即资源名,系统是扁平化管理的。

为什么这么设计?

  1. 性能:扁平化查找更快,不需要遍历子目录
  2. 限定符匹配:子目录会让限定符匹配逻辑变得非常复杂
  3. 唯一性:保证资源名全局唯一,不会冲突

解决方案

方案 适用场景 示例
命名前缀 图片数量中等(几十-上百张) 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 开发打下坚实的语言基础

🔗 相关链接


💡 提示:建议对照着自己的项目文件来看本文,打开每个目录和文件看看,再结合原理理解,比光看文字印象深刻得多。搞懂了项目结构和资源体系,后面开发就能事半功倍------遇到资源问题知道怎么查,想加功能知道往哪放。