做了一堆鸿蒙 app 之后发现,项目第一天的目录结构 决定后 90 天开发顺不顺。走通几种结构后,总结出适合独立开发者的分层:model / dao / viewmodel / page / common / widget。记录一下这套结构 + 哪些文件该放哪里,顺便分享给有需要的朋友。
一、独立 app 不要搞 Clean Architecture
反面教训:
- 用 DDD 分 domain / application / infrastructure / presentation 四层
- 单文件嵌套 5 层目录
- 加一个字段改 7 个文件
- 独立开发者一个人写不下去
独立开发者的本质:
- 人少(1 人)
- 节奏快(1-2 周出 app)
- 可读性 > 可扩展性(没有团队要适应)
适合的分层:6 层,平的。
二、推荐的目录结构
entry/src/main/ets/
├── entryability/ # 入口 ability
│ └── EntryAbility.ets
├── entrybackupability/ # 备份能力(空壳)
│ └── EntryBackupAbility.ets
├── model/ # 纯数据模型
│ ├── Asset.ets
│ ├── Category.ets
│ └── Settings.ets
├── database/ # DB 相关
│ ├── DbHelper.ets # rdb 初始化
│ ├── AssetDao.ets
│ └── CategoryDao.ets
├── viewmodel/ # 业务逻辑
│ ├── ListViewModel.ets
│ └── DetailViewModel.ets
├── pages/ # 页面组件
│ ├── Index.ets
│ ├── ListPage.ets
│ └── DetailPage.ets
├── components/ # 可复用 UI 组件
│ ├── AssetRow.ets
│ ├── CategoryPicker.ets
│ └── EmptyState.ets
├── common/ # 工具 / 常量 / 单例
│ ├── Constants.ets
│ ├── DateUtils.ets
│ ├── ToastManager.ets
│ └── EventBus.ets
└── widget/ # 卡片(如果有)
├── MyWidgetAbility.ets
└── pages/
7 个顶层目录。平,不嵌套。
三、每层的职责
model/
纯数据结构。接口 / class。
// model/Asset.ets
export class Asset {
id: number = 0
title: string = ''
value: number = 0
createdAt: number = 0
updatedAt: number = 0
}
不要:
- 业务逻辑(不写 calculateSomething)
- DB 代码(不 import rdb)
- UI 代码(不 import ArkUI)
好处:纯粹。可测试,可序列化。
database/
rdb 相关。DbHelper 做初始化,Dao 做增删改查。
// database/AssetDao.ets
export class AssetDao {
async insert(asset: Asset): Promise<number> { ... }
async queryAll(): Promise<Asset[]> { ... }
async delete(id: number): Promise<number> { ... }
}
不要:
- Dao 里做业务计算(如 "计算总金额")
- Dao 导入 UI 组件
viewmodel/
业务逻辑 + state。连接 model/dao 和 UI。
// viewmodel/ListViewModel.ets
export class ListViewModel {
items: Asset[] = []
private dao: AssetDao = new AssetDao()
async load(): Promise<void> {
this.items = await this.dao.queryAll()
}
async add(title: string, value: number): Promise<void> {
const asset = new Asset()
asset.title = title
asset.value = value
asset.createdAt = Date.now()
await this.dao.insert(asset)
await this.load()
}
totalValue(): number {
return this.items.reduce((s, a) => s + a.value, 0)
}
}
ViewModel 的作用:
- 包装 Dao 调用
- 做业务计算
- UI 直接订阅
不要:
- 直接碰 rdb(走 Dao)
- 包含 UI 代码
pages/
页面组件。@Entry @Component 的 struct。
// pages/ListPage.ets
@Entry @Component
struct ListPage {
@State vm: ListViewModel = new ListViewModel()
aboutToAppear(): void {
this.vm.load()
}
build() {
Column() {
Text(`总计:¥${this.vm.totalValue()}`)
List() {
ForEach(this.vm.items, (item: Asset) => {
ListItem() { AssetRow({ asset: item }) }
})
}
}
}
}
页面只做:
- UI 布局
- 事件 → 调用 viewmodel
- 没业务逻辑
components/
可复用的 UI 组件。@Component 的 struct。
// components/AssetRow.ets
@Component
export struct AssetRow {
@Prop asset: Asset = new Asset()
build() {
Row() {
Text(this.asset.title)
Text(`¥${this.asset.value}`)
}
}
}
不要:
- components 里引 viewmodel
- components 里做数据加载
原则:传 Prop 进来 UI 显示。
common/
工具类 / 常量 / 单例。
// common/Constants.ets
export const DB_NAME = 'app.db'
export const PAGE_SIZE = 50
// common/DateUtils.ets
export class DateUtils {
static todayString(): string { ... }
}
// common/ToastManager.ets
export class ToastManager { ... }
widget/
卡片相关。只在有卡片的 app 存在。
四、文件命名
规范:PascalCase + .ets。
Asset.ets(class name)AssetDao.ets(class name + 后缀)ListPage.etsDateUtils.ets
不要:
asset.ets(小写)asset_dao.ets(下划线)AssetDAO.ets(全大写)asset-dao.ets(kebab)
一致性 > 其他:项目内统一一种,别混。
五、import 的规范
本项目内 import:相对路径。
// pages/ListPage.ets
import { ListViewModel } from '../viewmodel/ListViewModel'
import { AssetRow } from '../components/AssetRow'
import { Asset } from '../model/Asset'
鸿蒙 Kit import:@kit.XxxKit。
import { relationalStore } from '@kit.ArkData'
import { promptAction } from '@kit.ArkUI'
import { hilog } from '@kit.PerformanceAnalysisKit'
不要:
// ❌ 老的全路径
import relationalStore from '@ohos.data.relationalStore'
// ✓ 推荐
import { relationalStore } from '@kit.ArkData'
独立开发者的建议:全部用 @kit.XxxKit。鸿蒙 5.x 推荐 Kit 聚合写法。
六、单例 vs new 实例
适合单例(common/ 里):
- ToastManager(全局 toast 不叠加)
- EventBus(全局事件)
- CryptoHelper(key 托管)
适合 new 实例(viewmodel/ 里):
- 每个页面的 viewmodel(页面销毁,vm 销毁)
Dao:可以用 new 也可以单例。本案多用 new(Dao 本身无状态,new 开销可忽略)。
// 单例:ToastManager.getInstance().show('hi')
// new:new AssetDao().insert(...)
七、不要的目录
不要:
services/:和 viewmodel 混淆utils/:和 common 混淆types/:和 model 混淆apis/:独立 app 一般不调外网repository/:和 database 混淆providers/:ArkUI 没这个概念
独立开发者的建议:7 个顶层目录够用。多了心智成本高。
八、新加功能的落点
假设要加"导出 CSV":
- model/:不需要(没新数据结构)
- database/:可能新加(如果要查询筛选)
- viewmodel/:新加 ExportViewModel
- pages/:新加 ExportPage 或在 SettingsPage 加按钮
- components/:可能新加 FileNamePicker 组件
- common/:新加 CsvEncoder 工具类
步骤:
- 先写 common/CsvEncoder.ets(纯工具)
- 再写 viewmodel/ExportViewModel.ets(业务)
- 最后写 pages/ 入口(UI)
自底向上:底层稳 上层快。
九、代码量的 benchmark
独立 app 的规模:
- model/:10-30 文件,每文件 20-80 行
- database/:3-6 Dao + 1 Helper,每文件 50-150 行
- viewmodel/:3-10 ViewModel,每文件 50-200 行
- pages/:5-15 Page,每文件 100-400 行
- components/:10-30 组件,每文件 30-200 行
- common/:5-20 工具类,每文件 30-100 行
总代码量:3000-15000 行(1 人 2-4 周的产出)。
超过 20000 行:开始考虑分 module 或 refactor。
踩坑清单
- 不要 Clean Architecture,用7 层平目录
- model 纯数据,不碰 DB / UI
- database 的 Dao 只做 CRUD,不做业务计算
- viewmodel 包装业务 + state
- pages 只做 UI + 事件分发
- components 纯 Prop 的可复用
- common 工具类 / 单例 / 常量
- 命名用 PascalCase + .ets
- import 用 @kit.XxxKit 聚合写法
- 不要 services / utils / types / apis / providers 等多余目录
尾声
项目结构是独立开发者的 90% 顺畅度。好的结构6 个月后仍然能快速改代码;坏的结构2 周后就不想碰了。
独立开发者的建议:
- 项目第一天定好 6-7 层结构
- 开发中不要创建新的顶层目录(抗诱惑)
- 每周review 一次目录 看是否偏离了
对比:
- React 项目:pages / components / hooks / utils 的平结构
- iOS 项目:Models / Views / ViewModels / Services
- 鸿蒙 ArkUI:本案的 7 层结构
鸿蒙的项目结构和 MVVM / React 接近。独立开发者学一次 pattern 可以复用到多个 app。
一个意外发现:独立 app 的"技术债" 其实来自结构不清,不是写错代码。工程上结构清晰比算法先进 对独立开发者的长期产出更重要。