【观止·诗史汇 HarmonyOS 实战系列 02】三层架构落地:entry、features、commons 的边界设计

很多 HarmonyOS 项目一开始都能把页面跑起来,但真正写到第二周、第三周,问题往往不是某个按钮不会写,而是工程边界开始变糊:入口页顺手读取业务数据,业务页顺手写路由字符串,公共层又悄悄知道了具体页面。代码还能运行,却越来越难改。

《观止·诗史汇》是一个本地优先的诗文与历史学习 App,首页要展示每日诗句和每日史事,后续还要继续接入诗文时空、兴替解意、古今地图、练习、收藏笔记、统计和设置。这个体量不算巨大,但已经足够暴露一个真实问题:如果不早一点把 entryfeaturescommons 的职责讲清楚,后面每增加一个页面,都会给维护留下债。

这一篇不只讲“目录长什么样”,而是复盘项目里三层架构真正落地时的判断:哪些东西应该留在产品入口,哪些东西属于业务模块,哪些能力可以沉到公共层,以及这些边界怎么通过代码和验证命令被固定下来。

观止·诗史汇首页截图

上图是当前应用首页。看起来只是一个诗史学习 App 的入口,但背后已经涉及五个底部 Tab、响应式断点、公共主题 token、业务服务初始化、路由常量和本地状态仓。本文就沿着这些对象拆开看。

维度 内容
应用 观止·诗史汇
技术栈 HarmonyOS NEXT、ArkTS、ArkUI、Stage 模型
本篇主题 三层架构落地:entryfeaturescommons 的边界设计
核心文件 build-profile.json5entry/src/main/ets/pages/Index.etsfeatures/Index.etscommons/Index.ets
验收目标 入口只做产品装配,业务能力收束到 features,公共能力不反向依赖业务模块

本章导读

这一篇会按工程流来讲,而不是按概念堆定义。

先看为什么学习类 App 一开始就需要分层,而不是等代码变多再整理。然后拆根目录 build-profile.json5 和各模块 oh-package.json5,确认模块关系是不是清楚。接着分别看 entryfeaturescommons 三层的真实代码职责。最后给出验证路径、问题复盘和验收清单,方便后续文章继续沿用这套标准。

当前验证环境

项目 版本或说明
DevEco Studio 6.x 系列
HarmonyOS SDK targetSdkVersion: 6.1.0(23)
兼容版本 compatibleSdkVersion: 6.0.2(22)
应用模型 Stage 模型
工程模块 entrycommonsfeatures
当前验证页面 首页、时间轴、收藏、统计、设置五个一级入口

这篇文章的验收重点不是“首页好不好看”,而是三件事:模块依赖是不是单向、入口层是不是足够轻、公共层是不是足够稳定。只要这三件事成立,后面做诗文详情、朝代解读、练习闭环和收藏笔记时,代码就不容易互相拖住。

为什么三层架构不能只停留在目录

HarmonyOS 的工程经常会被拆成入口模块、公共模块和业务模块。目录拆开只是第一步,真正难的是长期遵守这些目录背后的规则。

《观止·诗史汇》当前已经包含这些能力:

能力 页面或服务
首页 HomePage、每日诗句、每日史事、功能入口
诗文 PoemListPagePoemPackListPagePoemDetailPage
历史 TimelinePageDynastyInsightPageDynastyOverviewPage
地理与文脉 GeoPageLiteraturePage
练习 PracticeHomePagePracticeRunPage
收藏笔记 FavoritePageFavoriteFolderPageNoteDetailPage
统计设置 StatsPageSettingPage 与多个设置子页

如果这些页面都各自保存路由字符串、各自处理主题颜色、各自打开 Preferences,短期看很快,长期看会出现三个问题:

问题 直接后果 当前设计
路由散落在页面里 改一个页面名要全局搜索 AppRoutes 集中管理
主题 token 写在业务页里 暗色、高对比、字号缩放难以统一 commons/theme 统一提供
状态仓到处初始化 首屏、收藏、统计容易不同步 AppBootstrap.hydrateAll(ctx) 统一水合
公共层引用业务层 模块边界倒置,后续拆包困难 commons 只向上提供能力

所以第二篇要解决的不是“把代码放进三个文件夹”,而是让每一层都知道自己的边界。

根 build-profile:先把工程模块定下来

根目录 build-profile.json5 是三层架构的第一道证据。它明确声明了当前工程只暴露三个主要模块:

"modules": [
  {
    "name": "entry",
    "srcPath": "./entry"
  },
  {
    "name": "commons",
    "srcPath": "./commons"
  },
  {
    "name": "features",
    "srcPath": "./features"
  }
]

这里先不急着拆更多 HAR/HSP。对一个学习 App 的第一阶段来说,过早拆太细会带来额外的构建和引用成本。当前更合适的做法是:根工程只声明三层,业务内部再按目录继续收束,等某个业务域真正稳定以后,再考虑独立成更细的模块。

这也是本篇文章的一个关键判断:架构不是越碎越好,而是要能承接当前复杂度,并给未来扩展留出清晰方向。

依赖关系:entry 可以装配,features 可以依赖 commons

只看根模块还不够,还要看模块之间的依赖方向。当前 entry/oh-package.json5 依赖两个模块:

"dependencies": {
  "commons": "file:../commons",
  "features": "file:../features"
}

这说明 entry 是产品入口层,它可以使用公共能力,也可以装配业务页面。它不应该变成业务仓库,也不应该直接保存诗文、历史事件、收藏笔记这些业务数据。

再看 features/oh-package.json5

"dependencies": {
  "commons": "file:../commons"
}

features 只依赖 commons,不依赖 entry。这条规则非常重要:业务模块可以使用公共主题、路由、工具和数据封装,但不能知道入口层怎么组织 Tab,也不能反过来控制应用壳。

最终形成的依赖关系应该是:

entry
  ├─ uses commons
  └─ uses features

features
  └─ uses commons

commons
  └─ no entry / features dependency

这条链路越简单,后续越容易定位问题。比如收藏页刷新不及时,就先看 features/state 和页面订阅;如果 Tab 切换异常,就先看 entry;如果路由参数丢失,就先看 commons/router

entry:产品入口只负责装配,不沉淀业务逻辑

entry/src/main/ets/pages/Index.ets 是应用第一屏的入口。它做的事情很克制:维护当前 Tab、装配五个一级页面、监听断点变化、启动时触发业务状态水合。

核心结构如下:

import { AppColors, AppDimens, AppText, AppRoutes, AppBp } from 'commons';
import {
  HomePage, TimelinePage, FavoritePage, StatsPage, SettingPage, AppBootstrap
} from 'features';

@Entry
@Component
struct Index {
  @State currentKey: string = AppRoutes.TAB_HOME;
  @State favoriteRefreshSignal: number = 0;
  @State statsRefreshSignal: number = 0;
  @StorageLink('curBp') curBp: string = AppBp.SM;

  aboutToAppear(): void {
    const ctx: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
    AppBootstrap.hydrateAll(ctx);
  }
}

这段代码有几个值得保留的边界:

入口层行为 为什么合理
currentKey 只保存 Tab key 入口只需要知道当前展示哪个一级页面
favoriteRefreshSignalstatsRefreshSignal 只做刷新信号 不直接读取收藏或统计数据
AppBootstrap.hydrateAll(ctx) 在入口触发 应用启动时统一恢复状态,但具体 store 仍在 features
主题、尺寸、断点来自 commons 产品壳只消费 token,不自己定义 token

这让 entry 更像一个“装配层”。它知道首页、时间轴、收藏、统计、设置这些一级入口,但不知道诗词列表如何加载,也不知道收藏项如何持久化,更不知道统计趋势怎么计算。

Tabs:一级页面不走路由,减少入口复杂度

当前五个一级入口通过 Tabs 组件直接装配:

Tabs({
  barPosition: AppBp.isLg(this.curBp) ? BarPosition.Start : BarPosition.End,
  index: this.indexOfKey(this.currentKey)
}) {
  TabContent() {
    HomePage()
  }.tabBar(this.TabBarItem(AppRoutes.TAB_HOME, '首页'))

  TabContent() {
    TimelinePage()
  }.tabBar(this.TabBarItem(AppRoutes.TAB_TIMELINE, '时间轴'))

  TabContent() {
    FavoritePage({ refreshSignal: this.favoriteRefreshSignal })
  }.tabBar(this.TabBarItem(AppRoutes.TAB_FAVORITE, '收藏'))
}

这里有一个容易忽略的设计:一级 Tab 没有通过 router.pushUrl 跳转,而是由 Tabs 容器直接切换。原因很简单,首页、时间轴、收藏、统计、设置属于应用壳的固定入口,不是临时详情页。它们和底部导航强绑定,由入口层直接装配更稳定。

对应地,诗文详情、朝代解读、练习运行、笔记详情这类子页面才适合通过路由进入。这样页面层级会更清楚:

页面类型 处理方式 示例
一级入口 Tabs 直接装配 首页、时间轴、收藏、统计、设置
子页面 Navigator.push 跳转 诗文详情、朝代解读、练习运行
设置子页 路由进入 外观、字号、无障碍、关于

这套规则后面会继续影响页面设计。比如第三篇写首页时,首页只负责展示和入口触达;进入诗文列表或练习页时,再交给 commons/router 和具体业务页。

响应式断点也应该属于入口装配的一部分

Index.ets 里还有一段断点逻辑:

GridRow({ columns: 1 }) {
  GridCol({ span: 1 }) {
    this.TabsContainer()
  }
}
.onBreakpointChange((bp: string) => {
  this.curBp = bp;
  AppStorage.setOrCreate(AppBp.STORAGE_KEY, bp);
})

断点状态保存在 AppStorage,并通过 @StorageLink('curBp') 连接到入口组件。这样做的好处是:入口层决定导航栏在大屏时放左侧、小屏时放底部,而业务页面只需要消费当前布局上下文。

这也是 entry 可以承担的职责。它不处理具体业务数据,但它可以处理产品级布局:导航栏位置、Tab 容器、首屏初始化、一级页面装配。这些都是应用壳该做的事情。

features:业务模块先用 Index.ets 收口

features/Index.ets 是业务模块的出口文件。它把当前 App 的业务页面和状态仓集中导出:

export { HomePage } from './src/main/ets/home/HomePage';
export { TimelinePage } from './src/main/ets/timeline/TimelinePage';
export { FavoritePage } from './src/main/ets/favorite/FavoritePage';
export { StatsPage } from './src/main/ets/stats/StatsPage';
export { SettingPage } from './src/main/ets/setting/SettingPage';

export {
  FavoriteStore, NoteStore, StatsStore, SettingsStore, WrongStore,
  PracticeAggResult, WrongQuestion, AppBootstrap
} from './src/main/ets/state/AppStores';

这里的价值不是少写几行 import,而是给 entry 一个稳定入口。入口层不需要知道 HomePagesrc/main/ets/home/HomePage,也不需要知道 AppBootstrapstate/AppStores。它只从 features 拿能力。

这相当于给业务层做了一层门面:

外部看见 内部真实目录
HomePage features/src/main/ets/home/HomePage.ets
TimelinePage features/src/main/ets/timeline/TimelinePage.ets
FavoritePage features/src/main/ets/favorite/FavoritePage.ets
AppBootstrap features/src/main/ets/state/AppStores.ets

后续如果首页内部拆组件、统计页换目录、收藏状态仓拆文件,只要 features/Index.ets 的导出保持稳定,入口层就不用改。

features 内部按业务域分目录,而不是按组件类型分堆

当前 features/src/main/ets 下的目录是按业务域组织的:

features/src/main/ets
  ├─ home
  ├─ poem
  ├─ timeline
  ├─ dynasty
  ├─ geo
  ├─ literature
  ├─ practice
  ├─ favorite
  ├─ stats
  ├─ setting
  ├─ services
  ├─ state
  └─ domain

这种组织方式比“components / pages / services”更适合这个项目。因为诗文、历史、练习、收藏这些能力会长期扩展,每个业务域都有自己的页面、局部组件、模型和服务。先按业务收束,能让维护者更快找到上下文。

比如首页相关组件放在 home/components

文件 职责
DailyPoemCard.ets 首页每日诗句卡片
DailyEventCard.ets 首页每日史事卡片
EntryCard.ets 首页功能入口卡片

而跨多个页面共享的诗文、历史、地理服务,则放到 services。这样业务目录不会无限膨胀,公共业务服务也不会被误放到 commons

这里要特别注意:services 仍然属于 features,不是 commons。因为诗文服务、历史服务、练习服务都带有明确业务语义,不能因为“多个页面会用”就下沉到公共层。

commons:公共能力只能向上提供,不能知道业务

commons/Index.ets 是公共层出口:

// Theme tokens
export { AppColors } from './src/main/ets/theme/AppColors';
export { AppDimens } from './src/main/ets/theme/AppDimens';
export { AppText } from './src/main/ets/theme/AppText';
export { AppBp } from './src/main/ets/theme/Breakpoints';

// Router
export { AppRoutes } from './src/main/ets/router/RouteNames';
export { Navigator, NavigateParams } from './src/main/ets/router/Navigator';

// Data
export { PrefsStore } from './src/main/ets/data/PrefsStore';
export { RawJsonLoader } from './src/main/ets/data/RawJsonLoader';

这个文件暴露的是稳定基础能力:主题、路由、工具、数据封装、通用 UI。它不能 import features,也不能 import entry

判断一个文件能不能放进 commons,可以用下面这张表:

能力 是否适合 commons 原因
颜色、字号、尺寸 token 适合 全应用通用,不带业务语义
路由常量和导航封装 适合 页面跳转基础设施
Preferences JSON 封装 适合 存储基础能力
Loading / Empty / Error 通用视图 适合 通用 UI 状态
诗文列表加载 不适合 属于诗文业务
历史事件分析 不适合 属于历史业务
收藏文件夹模型 不适合 带具体业务含义
练习题统计规则 不适合 属于学习闭环

这张表很朴素,但实战里特别有用。公共层一旦塞进业务对象,就会越来越像“全能工具箱”,最后反而谁都不敢改。

AppRoutes:一级 Tab key 和子页面路由分开

commons/src/main/ets/router/RouteNames.ets 里把一级 Tab key 和子页面路由放在一起集中管理,但语义上做了区分:

export class AppRoutes {
  // 一级 Tab key(用于 Tabs 组件)
  static readonly TAB_HOME: string = 'tab_home';
  static readonly TAB_TIMELINE: string = 'tab_timeline';
  static readonly TAB_FAVORITE: string = 'tab_favorite';
  static readonly TAB_STATS: string = 'tab_stats';
  static readonly TAB_SETTING: string = 'tab_setting';

  // 子页面(router.pushUrl 用)
  static readonly POEM_DETAIL: string = 'pages/PoemDetailPage';
  static readonly DYNASTY_INSIGHT: string = 'pages/DynastyInsightPage';
  static readonly PRACTICE_RUN: string = 'pages/PracticeRunPage';
}

这比在页面里写字符串更稳。因为路由名本质上是一种跨模块契约,只要散落到页面里,就会变成隐形耦合。集中到 AppRoutes 后,新增页面时就有一个固定步骤:先加路由常量,再在页面里引用。

在《观止·诗史汇》里,路由常量还承担了一个隐含作用:提醒开发者哪些页面属于一级入口,哪些页面属于二级详情。一级入口的 key 不直接传给 router.pushUrl,子页面才走路由。

Navigator:跨页参数要有名字,失败也要可观测

Navigator.ets 封装了 router.pushUrlreplaceUrlback 和参数读取。这里最值得看的不是封装本身,而是 NavigateParams

export interface NavigateParams {
  dynastyId?: string;
  poemId?: string;
  eventId?: string;
  geoId?: string;
  practiceType?: string;
  practiceMode?: string;
  folderId?: string;
  noteId?: string;
  poemCatSlug?: string;
  poemShard?: number;
}

参数有名字以后,页面之间的协作会稳定很多。比如诗文详情需要 poemId,时间轴详情需要 eventId,收藏文件夹需要 folderId。如果直接传一个松散对象,调用方和接收方很容易各写各的字段名。

导航失败也没有被吞掉:

static async push(url: string, params?: NavigateParams): Promise<void> {
  try {
    const opts: router.RouterOptions = { url: url, params: params ?? ({} as NavigateParams) };
    await router.pushUrl(opts);
  } catch (err) {
    hilog.error(DOMAIN, TAG, 'pushUrl failed url=%{public}s err=%{public}s',
      url, JSON.stringify(err));
  }
}

这个设计对后续排查很关键。页面跳转失败时,调用页面不一定有明显报错,但日志里能看到 url 和错误对象,至少不会陷入“点了没反应”的盲猜。

AppBootstrap:状态水合从入口触发,具体实现留在业务层

entryaboutToAppear 会调用 AppBootstrap.hydrateAll(ctx),但 AppBootstrap 本身定义在 features/state/AppStores.ets。这就是一个很好的边界示例:入口层负责触发应用启动流程,业务层负责具体状态恢复。

核心逻辑如下:

export class AppBootstrap {
  private static hydrated: boolean = false;
  private static hydrating: Promise<void> | null = null;

  static async hydrateAll(ctx: Ctx): Promise<void> {
    if (AppBootstrap.hydrated) return;
    if (!AppBootstrap.hydrating) {
      AppBootstrap.hydrating = AppBootstrap.doHydrateAll(ctx);
    }
    await AppBootstrap.hydrating;
  }

  private static async doHydrateAll(ctx: Ctx): Promise<void> {
    await Promise.all([
      SettingsStore.instance().hydrate(ctx),
      FavoriteStore.instance().hydrate(ctx),
      NoteStore.instance().hydrate(ctx),
      StatsStore.instance().hydrate(ctx),
      WrongStore.instance().hydrate(ctx)
    ]);
    AppBootstrap.hydrated = true;
  }
}

这里有三个工程价值:

设计 价值
hydrated 防重复 页面重建时不会反复恢复状态
hydrating 复用 Promise 并发触发时不会重复水合
Promise.all 并行恢复 设置、收藏、笔记、统计、错题互不阻塞

这也解释了为什么 AppBootstrap 不适合放到 commons。它虽然被入口调用,但它恢复的是收藏、笔记、统计、错题这些业务状态,属于学习 App 的业务闭环。

PrefsStore:公共存储封装,业务 store 自己定义 key

commons 中的 PrefsStore 适合做基础存储封装,但具体存什么应该由业务 store 决定。比如 FavoriteStore 打开的是 favoritesStatsStore 打开的是 statsSettingsStore 打开的是 settings

这种分工可以这样理解:

层级 负责什么
PrefsStore 打开 Preferences、读写 JSON、读写基础类型
FavoriteStore 收藏项、文件夹、收藏状态
StatsStore 学习时长、练习正确率、趋势、徽章
SettingsStore 主题、字号、高对比、减少动效

公共层提供“怎么存”,业务层决定“存什么”。这样公共层就不会知道诗文、收藏、统计这些业务概念,也不会被后续业务变化拖着改。

分层边界的一个实用判断法

写 HarmonyOS 页面时,经常会遇到一个问题:这个函数到底放哪里?下面这张表是当前项目采用的判断方式。

想放的代码 推荐位置 判断依据
五个一级 Tab 的装配 entry 属于应用壳
首页每日诗句卡片 features/home 属于首页业务
诗文详情数据读取 features/poemfeatures/services 带诗文业务语义
路由常量 commons/router 跨模块基础契约
跳转失败日志封装 commons/router 通用导航能力
颜色、字号、断点 commons/theme 全局视觉 token
收藏/笔记状态仓 features/state 属于学习闭环
Empty / Loading / Error commons/ui 通用 UI 状态

这个判断法并不复杂,但能避免很多“顺手”。工程变差往往不是因为一次大错误,而是很多个“这个先放这里吧”叠起来。

工程验收记录

本篇对应的本机验证可以分成四类。

第一类,看工作区和模块文件是否存在:

git status --short
Get-ChildItem -LiteralPath . -Directory
Get-Content -LiteralPath .\build-profile.json5 -Encoding UTF8

第二类,看模块依赖方向:

Get-Content -LiteralPath .\entry\oh-package.json5 -Encoding UTF8
Get-Content -LiteralPath .\features\oh-package.json5 -Encoding UTF8
Get-Content -LiteralPath .\commons\oh-package.json5 -Encoding UTF8

验收时重点确认:

检查项 期望
entry 依赖 commonsfeatures
features 只依赖 commons
commons 不依赖 entryfeatures

第三类,看模拟器里应用是否能打开并展示入口页:

& "D:\Program Files\HuaWei\DevEco Studio\sdk\default\openharmony\toolchains\hdc.exe" list targets
& "D:\Program Files\HuaWei\DevEco Studio\sdk\default\openharmony\toolchains\hdc.exe" shell aa start -a EntryAbility -b com.example.app_project02
& "D:\Program Files\HuaWei\DevEco Studio\sdk\default\openharmony\toolchains\hdc.exe" shell snapshot_display -i 0 -f /data/local/tmp/app.png -w 1080 -h 2400 -t png

第四类,看源码层是否遵守边界。比如公共层不应该 import 业务模块,可以用搜索确认:

Select-String -Path .\commons\**\*.ets -Pattern "from 'features'","from 'entry'" -CaseSensitive
Select-String -Path .\features\**\*.ets -Pattern "from 'entry'" -CaseSensitive

这类命令不会替代人工判断,但能快速发现最危险的反向依赖。

常见问题复盘

1. 为什么不把所有页面都放在 entry?

因为 entry 是应用入口,不是业务容器。如果所有页面都堆在 entry,短期会少一个模块引用,长期会让入口层越来越重。入口层一旦混入诗文、历史、练习、收藏、统计逻辑,后续做多端布局或入口改版时,风险会被放大。

2. 为什么 features 现在还是一个模块,没有拆成多个 HAR?

当前项目还处在系列实战的前半段,业务边界正在稳定。先用一个 features 模块承接所有业务,再用目录区分业务域,是比较稳的选择。等诗文、历史、练习、收藏等域的接口稳定后,再拆成独立 HAR 会更自然。

3. 为什么路由常量放 commons,不放 features?

路由名是跨页面协作契约。entry 和多个业务页面都会用到它,所以放在 commons/router 更合适。但要注意,AppRoutes 只保存字符串契约,不应该在里面塞页面实例或业务逻辑。

4. 为什么 AppBootstrap 放 features,不放 commons?

AppBootstrap 虽然被 entry 调用,但它水合的是 FavoriteStoreNoteStoreStatsStoreWrongStore 这些业务状态。它不是通用基础设施,所以不应该下沉到 commons

5. 为什么一级 Tab 不走 router?

一级 Tab 是应用壳的一部分,它和底部导航、侧栏导航强绑定。直接用 Tabs 装配更简单,也更符合入口层职责。子页面才通过 Navigator.push 进入。

文件职责整理

文件 职责
build-profile.json5 声明 entrycommonsfeatures 三个主要模块
entry/oh-package.json5 声明入口层依赖公共层和业务层
features/oh-package.json5 声明业务层只依赖公共层
entry/src/main/ets/pages/Index.ets 五 Tab 装配、断点监听、启动水合触发
features/Index.ets 业务页面和状态仓统一导出
features/src/main/ets/state/AppStores.ets 收藏、笔记、统计、设置、错题等状态仓
commons/Index.ets 公共主题、路由、工具、数据、UI 能力统一导出
commons/src/main/ets/router/RouteNames.ets 一级 Tab key 和子页面路由常量
commons/src/main/ets/router/Navigator.ets 路由跳转、参数读取、失败日志封装
commons/src/main/ets/theme/* 颜色、尺寸、文字、断点 token

验收清单

  • 根工程只声明 entrycommonsfeatures 三个核心模块。
  • entry 可以依赖 commonsfeatures,但不直接沉淀业务数据。
  • features 可以依赖 commons,但不能依赖 entry
  • commons 不 import featuresentry
  • 一级 Tab 由 entryTabs 容器装配。
  • 子页面路由常量集中在 AppRoutes
  • 跨页参数通过 NavigateParams 传递。
  • 业务页面通过 features/Index.ets 暴露给入口层。
  • 主题 token、通用 UI、基础存储封装放在 commons
  • 收藏、笔记、统计、错题、设置这些业务状态留在 features/state

本章小结

第二篇真正想解决的不是“项目里有几个目录”,而是让《观止·诗史汇》的工程边界从一开始就可解释、可验证、可扩展。

entry 是产品入口,负责五个一级 Tab、响应式导航和启动触发。features 是业务能力层,承接诗文、历史、地理、练习、收藏、统计和设置。commons 是公共基础层,提供主题、路由、工具、存储封装和通用 UI。

这套分层让后续文章可以继续往下拆:第三篇写首页时,只需要关注首页如何组织每日诗句、每日史事和入口卡片;第四篇写内容包时,只需要关注数据如何进入业务服务;后面写练习、收藏、统计时,也能沿着同一条边界继续推进。工程一开始稳住,后面的功能才不会越写越散。

#HarmonyOS #ArkTS #ArkUI #DevEco Studio #鸿蒙开发

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐