鸿蒙项目实战:手把手带你从零架构 WanAndroid 鸿蒙版

前言

在之前的文章中,我们已经讲解了关于鸿蒙的ArkTSArkUI的基础知识点。在本篇中,我将带你手把手编写企业级WanAndroid鸿蒙版本的APP,你可以根据本文的节奏一步一步创建属于你的APP!

本文所需要掌握的知识点:

  1. # 鸿蒙零基础语法入门:开启你的开发之旅
  2. # 鸿蒙 ArkUI 从零到精通:基础语法全解析
  3. # 鸿蒙ArkUI:状态管理、应用结构、路由全解析

话不多说!直接开始!

1、项目结构搭建

1.1 项目结构分析

在 HarmonyOS 开发中,项目结构不仅仅是文件的堆砌,它直接决定了应用的可扩展性(Scalability)和模块间的解耦程度。尤其是面对企业级项目时,合理的目录划分能让我们在面对复杂的业务逻辑时,依然保持清晰的思路。

为了让大家有一个直观的感受,我绘制了一张典型的 HarmonyOS 企业级项目架构草图,它采用了主流的模块化(Modularization设计思想:

如图所示

好的架构应当如乐高积木般清晰。本项目采用了三层解耦设计:

  1. 顶层:product_phone (Entry 模块)

    • 角色: 它是整个 APP 的"组装员"。
    • 依赖: 它指向(依赖)所有的 feature 模块。
  2. 中间层:feature_xxxx (Feature 模块)

    • 角色: 业务功能实现(首页、广场等)。
    • 依赖: 它们彼此独立(不互相指向),但都向下指向(依赖)base_common
  3. 底层:base_common (Common 模块)

    • 角色: 基础设施(网络请求、公共 UI、工具类)。
    • 依赖: 它是最孤独的,不依赖任何业务模块,被所有人依赖。

为什么这样更"企业级"?

  • 单向依赖: 确保依赖链是单向的(从上到下),防止循环依赖导致项目编译失败。
  • 解耦: feature_home 挂了,不会影响 feature_user,因为它们之间没有箭头往来。
  • 易扩展: 以后你想做一个"平板版"或"折叠屏版",只需要新建一个 product_tablet,然后重新组合现有的 feature 模块即可。

OK,概念讲完了,开始实操了!

1.2 项目结构实操

如图所示

  • 根据上面的架构图分别创建了对应的文件夹:basefeatureproduct
  • 将原有的Entry重命名phone并且转移到了product
  • 对应的basefeature文件夹内,分别创建了对应的module

OK,项目虽然结构清晰了,但是对应的依赖链还没组成,那我们该如何按照正确的关系依赖呢?

这个就需要用到oh-package.json5文件 ,而这个文件它又分项目工程级模块化级;(这里简单过一下,下面还会详解)

  • 项目工程级oh-package.json5文件 :位于工程根目录下,主要用来描述全局配置,如:依赖覆盖(overrides)、依赖关系重写(overrideDependencyMap)和参数化配置(parameterFile)

  • 模块化级oh-package.json5文件 :位于工程各个模块的根目录下,用来描述包名、版本、入口文件(类型声明文件)和依赖项等信息

而我们目前要实现的仅仅是模块化之间的依赖关系,因此目前我们仅需要修改对应module下根目录oh-package.json5文件即可

  • 因此在各个feature下module下根目录oh-package.json5文件里添加如下代码

    json 复制代码
    "dependencies": {
     "common": "file:../../base/common"
    }
  • 而在product下的phone下根目录oh-package.json5文件里添加如下代码

    json 复制代码
    "dependencies": {
      "common": "file:../../base/common",
      "home": "file:../../feature/home",
      "course": "file:../../feature/course",
      "user": "file:../../feature/user",
      "square": "file:../../feature/square"
    }

🧐 深度思考:为什么 Product 层要"重复"依赖 Common?

看到这里,细心的小伙伴可能会问:既然 Feature 已经依赖了 Common,而 Product 又依赖了 Feature,根据传递性,Product 不是自动拥有了 Common 的能力吗?为什么还要多此一举? 这涉及到鸿蒙模块化开发中的两个核心问题:导出权限与维护成本。

  1. 导出权限(封装隔离)

    在鸿蒙工程中,模块间的依赖并不是"透明"的。即便 home 依赖了 common,如果 home 模块的入口文件 index.ets 中没有显式地通过 export * from '...' 将 common 的能力二次暴露出来,那么 product 模块是无法感知 common 存在的。

  2. 避免"维护地狱"

    假设我们不直接依赖 common,而是通过 home 模块中转导出。这时会面临两个致命问题:

    • 引用混乱: 当你在 product_phone 中调用一个底层工具类时,IDE 可能会跳出四个选项:来自 home、来自 course、来自 user... 这会破坏代码的语义化,让人分不清这个工具到底属于谁。

    • 结构脆弱: 这种模式会将 product 与某个特定的 feature 深度绑定。一旦未来项目重构,删除了被作为"中转站"的 home 模块,整个 product 层就会因为丢失底层引用而引发大面积报错。

总结: 在企业级开发中,我们提倡谁使用,谁显式依赖。让 product 直接依赖 common,不仅能获得更清晰的 import 路径,更能保证架构的健壮性。

OK!到这里,项目的骨架已经搭建完成,各模块之间的通路也已经打通。

但一个成熟的企业级项目,不可能完全"白手起家",我们需要引入各种优秀的开源库(如网络请求 Axios、图片加载、刷新组件等)来充当我们的"血肉"。

在鸿蒙开发中,如果我们在各个模块里随性地引入三方库,会导致版本冲突重复打包等灾难。因此,我们将讲解如何优雅地进行三方库的统一配置与引用。

2、三方库的引用

在之前的架构设计中,我们将 base_common 放在了最底层。为了保证项目整洁,我们遵循一个原则:所有的第三方库,原则上都先在 base_common 中引入并完成封装,再暴露给上层业务使用。

2.1 区分两种 oh-package.json5

而在引入三方库之前,我们必须先搞清楚两个同名oh-package.json5但职责完全不同的文件。你可以把整个项目想象成一家公司 ,而每个模块就是一个部门

2.1.1. 工程级(根目录) oh-package.json5:公司的"行政部"

位于项目最外层的根目录下。

  • 它的作用: 负责整个工程的全局配置。

  • 核心职能: * 管理公共依赖(Overrides): 它可以强制统一全工程所有模块使用的三方库版本,防止不同部门"各自为政"导致版本冲突。

    • 管理参数化配置: 比如定义全局的编译版本、签名信息等。
  • 误区提醒:不应该 在这里直接 install 具体的业务三方库(如 Axios),它更多是做宏观调控的。

2.1.2. 模块级(Module 根目录) oh-package.json5:部门的"采购清单"

位于每个具体的 featurebase 模块目录下。

  • 它的作用: 负责当前模块所需的具体依赖。

  • 核心职能:

    • 具体引用: 比如 base_common 需要用到 axios,来进行二次封装,你就必须在这个模块下的 oh-package.json5 中声明。
    • 定义模块属性: 定义当前模块的名字(name)、版本(version)。
  • 实战经验: 即使你在根目录配置了某种规则,具体的"体力活"还是得靠模块级的配置文件来完成。

2.1.3 "角色"对比表

为了方便大家记忆,我整理了这张表:

特性 工程级 (Project Level) 模块级 (Module Level)
位置 项目最根部 entry / feature / base 目录下
主要职责 全局依赖版本协调、全局策略配置 声明当前模块私有依赖、模块信息定义
依赖声明 用于控制版本上限/统一版本 用于实际引入三方库
打个比方 集团总部的战略方案 业务部门的执行清单

2.1.4 最佳实践建议

在我们的 WanAndroid 项目中,引入三方库的标准姿势是:

  1. 明确需求: 比如我们要用 axios 做网络请求。
  2. 定位模块: 按照我们之前的架构,这个库应该属于底层基础设施,所以我们要去 base/common 目录下的 oh-package.json5 中操作。
  3. 执行安装: 在该目录下运行 ohpm install @ohos/axios

💡 注意了!敲黑板了!: > 永远优先考虑 将三方库安装在 base_common 这种底层模块中,然后通过 export 暴露给上层,至于怎么暴露,下面会依次讲解。这样可以避免每个 Feature 模块都去重复配置 oh-package.json5,让我们的项目结构更加健壮。不强求所有都这样,具体根据项目实际情况而定!

OK! 理论铺垫完了,看看如何在 base_common 模块中完成三方库的引入和封装。

2.2 工具库与刷新库引入

2.2.1 三方工具库引入

一个成熟的企业级项目,离不开高效、稳定的工具类库。在鸿蒙开发中,harmony-utils 是一套非常优秀的开源库,它几乎涵盖了开发中所有的常见场景:从基础的字符串、日期处理,到进阶的线程间通信、异常捕获、加密解密,甚至系统级的扫码、相册和生物认证等,应有尽有。

引入这一类"基石级"工具,能极大提升我们的开发效率。

如图所示

  1. 安装工具库

    首先,我们需要在 base_common 模块下集成它。请确保命令行路径正确:

    npm 复制代码
    # 路径:D:...\WanAndroid\base\common
    ohpm i @pura/harmony-utils
  2. 确认配置

    安装完成后,base_common 模块下的 oh-package.json5 会自动更新,将看到如下声明:

    json 复制代码
    "dependencies": {
      "@pura/harmony-utils": "^1.4.0" // 请记得点击 "Sync Now" 同步工程
    }
  3. 底层能力"透传" (关键步骤)

    由于鸿蒙的模块化机制是物理隔离 的,虽然 base_common 引入了该库,但 feature_homeproduct_phone 等上层模块默认是无法感知的。

    为了让上层业务模块能够直接使用这些工具,我们不需要在每个模块里重复安装,而是通过 base_common 的入口文件进行统一导出

    base/common/index.ets 中添加:

    ts 复制代码
    // 将 harmony-utils 的能力全量透传给上层模块
    export * from '@pura/harmony-utils';

2.2.2 三方刷新库引入

  1. 安装工具库

    首先,我们需要在 base_common 模块下集成它。请确保命令行路径正确:

    npm 复制代码
    # 路径:D:...\WanAndroid\base\common
    ohpm install @abner/refresh_v2
  2. 确认配置

    安装完成后,base_common 模块下的 oh-package.json5 会自动更新,将看到如下声明:

    json 复制代码
    "dependencies": {
      "@abner/refresh_v2": "^1.0.4" // 请记得点击 "Sync Now" 同步工程
    }
  3. 底层能力"透传" (关键步骤)

    base/common/index.ets 中添加:

    ts 复制代码
    // 将 refresh_v2 的能力全量透传给上层模块
    export * from '@abner/refresh_v2'+

2.3 Axios引入与封装

2.3.1 Axios引入

如图所示

  1. 在我们的base_common根目录下打开命令行Terminal,一定要注意路径哟,运行ohpm install @ohos/axios

  2. 当运行成功后,我们就会看到base_common根目录下的oh-package.json5文件里有如下信息:"@ohos/axios": "^2.2.7"

  3. 因为 axios涉及到网络请求,因此我们需要给项目配置网络请求权限

    • 而权限配置相关内容就在entry模块也就是本项目product_phone\ src\main\module.json5 这个文件里
    • 如图所示,在该配置文件里添加如下代码 (注意要Sync Now哟)
    json 复制代码
    "requestPermissions": [
     {
       "name": "ohos.permission.INTERNET"
     }
    ],

OK!到这axios 算是正确集成到base_common里面了,不过框架集成了,但要怎么使用呢?

如图所示

这是axios官方文档的部分使用方式:

当看到官方的使用说明时,我就会有如下思考:

  • 如果不二次封装的话,只要有网络请求都要写一遍,URL分散,网络请求也分散,能否把它们都集中起来,统一管理

  • 而官方的使用说明,get请求方式都是非常标准的QUERY参数,那万一接口是RESTful 风格的,参数是Path类型的,那每次请求岂不是还要手动修改请求URL?

  • 然后就是,我做Android开发的,习惯了Retrofit网络框架的使用方式,突然切成前端Axios使用方式确实有点不习惯。而且Retrofit网络框架也支持RESTful 风格的接口。

  • 如图所示:虽然OpenHarmony三方库中心仓里面有关于Retrofit网络框架,但是热度以及更新效率远远不及Axios,既然这样想要,那也想要,那就根据Axios封装一个自己的Retrofit网络框架。

封装Axios之前,首先要了解我们接口响应大致是一个什么样的结构。根据接口响应结构来封装属于我们自己的网络框架库。

因此我们来看看WanAndroid的接口响应大致是什么结构:

如图所示

  • 接口响应结构大致为:errorCodeerrorMsgdata
  • 修改未登录的错误码为-1001,其他错误码为-1,成功为0
  • 需要登录访问的接口访问是需要cookie来授权的

OK!接口大致结构我们清楚了,直接开干!

2.3.2 Axios 封装:打造"鸿蒙版 Retrofit"

既然要模仿 Retrofit,我们的核心思路就是通过装饰器 来定义请求配置,通过单例管理类来处理拦截逻辑。这里我们主要通过两个核心文件来实现:

  • NetworkManager.ets:核心管理类。负责 Axios 实例的初始化、拦截器逻辑(处理响应码、日志打印、错误弹窗)以及底层请求的执行。
  • Decorators.ets :装饰器定义。利用 TS 的元数据反射机制,模仿 Retrofit 的 @GET, @POST, @Path, @Query 等语法。
1. NetworkManager:网络请求的中枢大脑

base_commoncore 目录下,我们创建 NetworkManager.ets。它的思路定义如下:

  • 单例模式:确保全应用只有一个网络配置入口。

  • 灵活的钩子函数 :通过 NetworkConfig 接口,我们将 baseUrlToken 注入逻辑以及登录失效后的跳转逻辑交由主模块(Entry)去实现,保持 base_common 的纯净。

  • 响应拦截处理

    • 业务状态码统一判断 :根据 WanAndroid 接口约定,当 errorCode !== 0 时,统一视为异常。
    • 自动吐司(Toast) :配合我们之前引入的 harmony-utils,当发生网络错误或业务异常时,直接调用 ToastUtil 弹出提示,省去了在每个页面手动写弹窗的烦恼。
    • RESTful 路径解析 :内置 formatUrl 方法,自动将 /user/{id} 替换为实际的数值。

核心代码实现:

TS 复制代码
/**
 * 初始化方法 (必须在 App 启动时调用)
 * @param config 全局配置
 */
public init(config: NetworkConfig) {
  this.config = config;
  // 创建 Axios 实例
  this.axiosInstance = axios.create({
    baseURL: config.baseUrl,
    timeout: config.timeout || 10000,
    headers: {
      'Content-Type': 'application/json;charset=utf-8'
    }
  });
  // 初始化拦截器
  this.initInterceptors();
}

//*****************此处省略一大堆代码*****************

// --- 响应拦截器 ---
this.axiosInstance.interceptors.response.use((response: AxiosResponse) => {
  // 1. 提取后端返回的数据
  const resData = response.data as BaseResponse<Any>;
  
  // 检查 callback 是否存在
  if (this.config.onHeadersReceived && response.headers) {
    // 将响应的header 抛出去,让应用层能拿到header内容
    this.config.onHeadersReceived(response.headers as Record<string, Any>);
  }

  // 2. 业务状态码判断   0 代表成功
  if (resData.errorCode !== 0) {
    // 触发全局错误钩子
    if (this.config.errorHandler) {
      this.config.errorHandler(resData.errorCode, resData.errorMsg);
    }
    // 打印错误日志,方便调试
    console.error(`[HTTP Error] 业务异常: ${resData.errorMsg}`);
    ToastUtil.showShort(resData.errorMsg)
    // 抛出业务错误,中断流程
    return Promise.reject(new Error(resData.errorMsg || 'Unknown Error'));
  }
  // 3. 成功,直接返回完整对象
  return resData.data as Any as AxiosResponse;
}

//*****************此处省略一大堆代码*****************
/**
 * 工具方法:处理 URL 路径参数替换
 * 示例:formatUrl("/user/{id}", {id: 100}) -> "/user/100"
 */
private formatUrl(url: string, pathParams?: Record<string, Any>): string {
  if (!pathParams || Object.keys(pathParams).length === 0) {
    return url;
  }

  let formattedUrl = url;
  Object.keys(pathParams).forEach((key) => {
    const value = String(pathParams[key]);
    // 替换 {key} 格式
    formattedUrl = formattedUrl.replace(new RegExp(`\{${key}\}`, 'g'), value);
    // 替换 :key 格式 (兼容某些后端风格)
    formattedUrl = formattedUrl.replace(new RegExp(`:${key}`, 'g'), value);
  });
  return formattedUrl;
}
2. Decorators:优雅的请求声明

这是让代码变"整洁"的关键。在 base_common 下创建 Decorators.ets。我们的封装思路是:

  • 元数据仓库(metadataStorage) :我们使用 WeakMap 自己实现了一套轻量级的元数据存储。它负责记录:哪个方法用了什么请求方式?哪个参数对应 URL 里的哪个占位符?

  • 参数装饰器 (Path, Query, Body) :它们不执行逻辑,只负责"打标签"。记录参数的位置和类型。

核心代码实现:

TS 复制代码
/**
 * 4. 参数装饰器工厂 (替代 @Path, @Query)
 * 原理:将参数信息存入上面的 metadataStorage
 */
const createParamDecorator = (type: ParamType) => {
  return (key?: string) => {
    return (target: Object, propertyKey: string, parameterIndex: number) => {
      // A. 获取该方法的元数据列表
      const existingParameters = getParamsMetadata(target, propertyKey);

      // B. 存入新参数信息
      existingParameters.push({
        key: key || '',
        index: parameterIndex,
        type: type,
      });

      // C. 无需手动 save,因为 getParamsMetadata 返回的是引用,直接 push 就已经存进 Map 了
    };
  };
};

// 导出具体的参数装饰器 (用法不变)
export const Path = createParamDecorator(ParamType.PATH);

export const Query = createParamDecorator(ParamType.QUERY);

export const Body = createParamDecorator(ParamType.BODY)();
  • 方法装饰器 (GET, POST) :它们是真正的"打工人"。

    1. 它们会拦截原始方法的调用。
    2. 从元数据仓库中取出之前存好的标签。
    3. 将你传入的实参一一对应,组装成 Axios 需要的配置。
    4. 最后交给 NetworkManager 去发起真正的网络请求。

核心代码实现:

TS 复制代码
/**
 * 5. 方法装饰器工厂 (替代 @GET, @POST)
 * 原理:从 metadataStorage 读取信息并发起请求
 */
const createMethodDecorator = (method: string) => {
  return (url: string) => {
    return (target: Object, propertyKey: string, descriptor: PropertyDescriptor) => {
      // 保留原始方法(虽然不执行,但保持结构)
      const originalMethod: Any = descriptor.value;

      // --- 核心逻辑:重写方法体 ---
      descriptor.value = async (...args: Any[]) => {

        // A. 读取元数据 (替换 Reflect.getOwnMetadata)
        const paramsMetadata = getParamsMetadata(target, propertyKey);

        // B. 准备请求容器
        const pathParams: Record<string, Any> = {};
        const queryParams: Record<string, Any> = {};
        let bodyData: Any = null;

        // C. 遍历元数据,从 args 中取值
        paramsMetadata.forEach((meta) => {
          const value = args[meta.index]; // 获取运行时传入的实参
          // D. 将对应类型的参数,放入对应类型的容器里面
          if (meta.type === ParamType.PATH) {
            pathParams[meta.key] = value;
          } else if (meta.type === ParamType.QUERY) {
            queryParams[meta.key] = value;
          } else if (meta.type === ParamType.BODY) {
            bodyData = value;
          }
        });

        // E. 组装 Options
        const options: RequestOptions = {
          url,
          method,
          pathParams,
          queryParams,
          data: bodyData
        };

        // F. 发起请求
        return await net.request<Any>(options);
      };

      return descriptor;
    };
  };
};

// 导出具体的方法装饰器 (用法不变)
export const GET = createMethodDecorator('GET');

export const POST = createMethodDecorator('POST');

export const PUT = createMethodDecorator('PUT');

export const DELETE = createMethodDecorator('DELETE');

2.3.3 封装后的实战效果

封装完这两个文件后,我们在 WanAndroid 项目中发起一个请求将变得极其优雅。

以前的使用方式:

TS 复制代码
// 需要手动拼接 URL,手动处理异常
axios.get('/article/list/0/json').then(res => { ... })

现在的 Retrofit 风格:

TS 复制代码
class ApiService {
  @GET("/article/list/{page}/json")
  async getArticleList(@Path("page") page: number): Promise<ArticleModel> {
    return null as any; // 实际逻辑已由装饰器接管
  }
}

是不是瞬间找回了 Android 开发的感觉?

注意: 记得在index.ets 将该网络框架使用权给导出来哟

TS 复制代码
export { NetworkManager } from './src/main/ets/retrofit_net/core/NetworkManager';
// 导出类型
export { BaseResponse, NetworkConfig } from './src/main/ets/retrofit_net/core/NetworkManager';
// 导出装饰器
export { GET, POST, PUT, DELETE, Path, Query, Body } from './src/main/ets/retrofit_net/decorators/Decorators';
export  { InternalAxiosRequestConfig } from '@ohos/axios';

既然三方库该引用的引用了,该封装的也封装了,现在就该准备初始化它们并且实际使用了!

2.4 三方库初始化

在上面我们提到过product_phone是整个 APP 的"组装员",因此初始化的工作就要交给它了!

而在初始化之前,我们要先了解EntryAbility

2.4.1 理解 EntryAbility

在 Android 中,我们习惯在 ApplicationonCreate 中一把唆地完成所有初始化。但在 HarmonyOS 中,虽然也有 AbilityStage(对应 Application 级别),但从业务组装 的角度来看,EntryAbility 才是 UI 界面与系统交互的真正起点。

EntryAbility是什么呢?它具备哪些特性?

  • 它是 UI 窗口的载体 :如果说 Application 是幕后的大管家,那么 EntryAbility 就是前台的"大堂经理"。它是系统调度应用的最小单元,负责创建窗口、加载 Index 页面。

  • 多入口特性:鸿蒙允许一个 App 存在多个 Ability。我们可以把它理解为一个应用可以有多个"独立的功能入口"(比如主程序入口、扫码快捷入口、独立的支付窗口等)。

  • 初始化的最佳时机 :虽然它不完全等同于 Android 的 Application,但在我们的 WanAndroid 实战中,它是获取 UI 上下文(Context)配置全局网络基址(BaseURL)以及启动首屏渲染的核心阵地。

2.4.2 实战初始化配置

既然 EntryAbility 是组装员,我们就在它的 onCreate 生命周期中,将之前封装好的 NetworkManager 进行"激活"。

product/phone 模块的 EntryAbility.ets 中编写如下代码:

TS 复制代码
// 定义一个 Key 用来存 Cookie
const APP_COOKIE_KEY = 'app_cookie_key';

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
      this.initSdk()
      this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
  }

  private initSdk(): void {
    this.initUtil()
    this.initNetWork()
  }

  private initUtil(): void {
    AppUtil.init(this.context);
    //初始化日志  0x1010  设置日志对应的领域标识, 'WanAndroid' 设置日志标识 , true 是否打印日志 , true 日志打印方式 true-hilog、false-console
    LogUtil.init(0x1010, 'WanAndroid', true, true);
    //异常工具捕获初始化
    CrashUtil.onHandled((exceptionInfo) => {
    })
  }

  private initNetWork(): void {
    NetworkManager.getInstance().init({
      baseUrl: "https://www.wanandroid.com",
      //请求拦截器
      headerHandler: (config: InternalAxiosRequestConfig) => {
        // 从内存/缓存中取出 Cookie 字符串
        const cookieStr = AppStorage.get<string>(APP_COOKIE_KEY);
        if (cookieStr) {
          // 这里的 Key 必须叫 'Cookie'
          config.headers['Cookie'] = cookieStr;
        }
        return config
      },
      // 【响应拦截】提取并保存 Cookie
      onHeadersReceived: (headers) => {
        // Axios 返回的 set-cookie 通常是一个数组,或者字符串
        // 在玩Android接口中,它是 'set-cookie' 字段
        const setCookie = headers['set-cookie'] as string[] | string;
        if (setCookie) {
          // 处理 Cookie 逻辑 (调用下面的工具方法)
          const cookieStr = this.processSetCookie(setCookie);
          // 如果解析到了有效的 Cookie,就存起来
          if (cookieStr && cookieStr.length > 0) {
            // 这里为了演示简单直接覆盖,实际业务可能需要和旧 Cookie 合并
            AppStorage.setOrCreate(APP_COOKIE_KEY, cookieStr);
            console.info('更新 Cookie 成功:', cookieStr);
          }
        }
      },
      //网络请求错误、接口业务报错回调
      errorHandler: (code, msg) => {
        if (code == -1001) {
          //登录失效
          // todo 这里将会跳转至登录页面
          return
        }
        //其他code 根据业务场景来处理,吐司已经在内部拦截器实现,当然也可以在这里弹吐司
      }
    })
  }

注意: 虽然我们初始化时,在请求与响应拦截 正确处理了 cookie以及错误code,但是我们直接写死了 baseUrl: 'https://www.wanandroid.com'。在实际开发中,我们通常会有 Debug(测试环境)Release(生产环境) 之分

在鸿蒙工程中,管理这类差异化配置的核心文件就是根目录下的 build-profile.json5

1. 认识 build-profile.json5 的职责

如果说 oh-package.json5 管理的是"包依赖",那么 build-profile.json5 管理的就是构建行为。它定义了:

  • 项目包含哪些 Module。
  • 编译的 Target 目标(比如针对手机、平板)。
  • 自定义构建参数(这正是我们需要的)。
2. 配置自定义参数

我们可以在 buildOptionSet -> arkOptions -> buildProfileFields 来注入变量。为了简单直观,我们可以在 app 层级也就是product_phone的配置中定义:

product_phone根目录 -> build-profile.json5

json 复制代码
//---------------------此处省略一大堆代码--------------------
"buildOptionSet": [
  {
    "name": "release",
    "arkOptions": {
       //---------------------此处省略一大堆代码--------------------
      "buildProfileFields": {
        "BASE_URL": "https://www.wanandroid.com/"
      }
    }
  },
  {
    "name": "debug",
    "arkOptions": {
      "buildProfileFields": {
        "BASE_URL": "https://www.wanandroid.com/"
      }
    }
  }
]
//---------------------此处省略一大堆代码--------------------

我们先简单Rebuild一下项目,鸿蒙构建工具会自动生成一个名为 BuildProfile 的类(注意:这个类是编译时自动生成的)。

如图所示

  • 因为我们的自定义参数是写在product_phone里面,因此只有APP级别的可以访问,其他module都不会生成自定义参数
  • 当然你也可以配置在工程级build-profile.json5,这样所有module生成的BuildProfile 的类都会有自定义参数。(工程级配置在buildModeSet->buildOption->arkOptions->buildProfileFields里面配置)

💡 小技巧: > 建议BASE_URL配置在APP级,这样如果你有多个 products(比如专门的 test 产品线),你可以为每个 product 配置不同的 API_BASE_URL

3. 在代码中动态获取

现在,我们可以回到 EntryAbility.ets 优化我们的初始化代码:

TS 复制代码
//---------------------此处省略一大堆代码--------------------
private initUtil(): void {
  AppUtil.init(this.context);
  //初始化日志  0x1010  设置日志对应的领域标识, 'WanAndroid' 设置日志标识 , true 是否打印日志 , true 日志打印方式 true-hilog、false-console
  LogUtil.init(0x1010, 'WanAndroid', BuildProfile.DEBUG, true);
  //异常工具捕获初始化
  CrashUtil.onHandled((exceptionInfo) => {
  })
}

private initNetWork(): void {
  NetworkManager.getInstance().init({
    baseUrl: BuildProfile.BASE_URL,
    //请求拦截器
    headerHandler: (config: InternalAxiosRequestConfig) => {
      // 从内存/缓存中取出 Cookie 字符串
      const cookieStr = AppStorage.get<string>(APP_COOKIE_KEY);
      if (cookieStr) {
        // 这里的 Key 必须叫 'Cookie'
        config.headers['Cookie'] = cookieStr;
      }
      return config
    }
    //---------------------此处省略一大堆代码--------------------

2.4.3 为什么这样做更"企业级"?

这样处理的好处显而易见:

  • 安全性:敏感的地址不会散落在代码各处。
  • 自动化 :当我们切换 Build Variant(构建变体)进行打包时,系统会自动选择对应的 URL,无需人工修改代码,彻底杜绝了带着测试环境地址上线的事故。
  • 统一性 :除了 BaseUrl,你还可以把三方库的 AppKey、日志开关等都丢进这里管理。

2.5 路由导航:打通模块间的"任意门"

网络框架搭好了,但在 HarmonyOS 的模块化开发中,我们还面临一个棘手的问题:如何从 feature_home 顺利跳转到 feature_user

由于各业务模块之间没有直接依赖关系,使用系统原生的 router 往往需要硬编码 url 路径,这不仅容易写错,更违背了我们解耦的初衷。因此,我们需要引入OpenHarmonyHMRouter 路由框架。

2.5.1 HMRouter引入

HMRouter路由框架与其他三方框架引入不同,其他框架可以在common 引入后,通过export将功能导出来。但它不行,因为它触及了鸿蒙 编译期插件(Compiler Plugin)运行时加载(Runtime Loading) 的本质区别。

简单来说:普通库(Axios、Utils)是"代码逻辑",而 HMRouter 是"底层基座"。

1. 编译期间扫描的"物理隔离" (HMRouter 特有)

HMRouter 不仅仅是一个运行时跳转的工具,它包含一个 Hvigor 编译插件

  • 普通库 :当你写 import { axios } from 'common' 时,编译器只需要在运行到那行代码时去找 common 模块要人就行了。
  • HMRouter :它在编译阶段会扫描所有带 @Router 注解的页面,并在当前模块的 generated 文件夹下生成新的 .ets 代码文件
  • 致命点 :这些生成的代码是由插件硬编码写入的,它们内部固定写着 import { ... } from '@hadss/hmrouter'
  • 结果 :因为生成的代码直接找 @hadss/hmrouter,而不经过你的 common/Index.ets 转发,所以当前模块必须拥有这个库的直接依赖权,否则编译器在处理这些自动生成的代码时会直接报"找不到模块"。

如图所示

  • 在每个moudule都添加依赖"@hadss/hmrouter": "^1.2.2"

🧐 思考: 此时可能有小伙伴就会想,既然每个module都添加依赖hmrouter,那这不就违背了单向依赖架构思想?

我们需要从工程逻辑鸿蒙构建机制 这两个更高的维度来重新审视这个问题:这并不是违背架构,而是在适配鸿蒙的构建底层逻辑。

  1. 逻辑依赖 vs 物理声明

    • 逻辑依赖(你的目标) :你希望业务模块只跟 common 打交道,这没错。在你的 TS/ArkTS 代码里,你依然只 importcommon,保持了代码层面的解耦。
    • 物理声明(系统的要求)oh-package.json5 是给 OHPM(包管理器)Hvigor(编译器) 看的。因为 HMRouter 会在你的业务模块里"注入"代码,如果不声明,编译器就无法在当前模块的沙箱里找到对应的库。

  1. 为什么这不算"违背架构"?

    在成熟的工程架构中,依赖通常分为两类:

    • 业务依赖 (Axios, Utils) :这些通过 common 转发,严格遵守架构分层。
    • 环境/插件依赖 (HMRouter) :这些属于基础设施。就像每个房间(模块)都必须有自己的电插座(声明依赖)才能取电,而不能指望只在走廊(common)装一个插座,全楼的电器就能隔空取电。

既然将它引入进来了,接下来就是关于它的集成了

2.5.2 HMRoute集成与使用

关于HMRoute集成与使用参考官方文档,写的很详细了,如果在文章里体现也是直接照搬过来,没啥意义。

💡 注意: 当使用HMRoute后,@HMRouter({ pageUrl: 'xxxx' }) 这里面的PageUrl最好是带上module前缀 ,例如:@HMRouter({ pageUrl: 'FeatureUser\xxxxPage' }),可以将他们统一放在base_common常量里。或者使用HMRoute服务器路由 ,将放在base_common常量里的pageUrl下沉至各个Feature中,通过HMServiceProvider,让它们各自实现提供pageUrl的服务,最后统一跳转。

2.6 版本统一管理:parameterFile 的妙用

随着项目规模的扩大,base_commonfeature_home 等多个模块可能会重复引用同一批三方库。如果版本号散落在各个模块的 oh-package.json5 中,后期升级时极易出现"漏改"或"版本冲突"的情况。

为了实现"一处修改,全模块同步",鸿蒙提供了 parameterFile 属性来帮助我们进行版本归一化管理。

2.6.1 定义全局版本配置文件

首先,在项目根目录下(与 build-profile.json5 同级),新建一个名为 oh-package-parameter.json5 的文件。

在这个文件里,我们将所有三方库的版本号提取出来进行统一声明:

oh-package-parameter.json5

json 复制代码
{  //各个三方库版本信息
  "parameter": {
    "axios_ver": "^2.2.7",
    "harmony-utils_ver": "^1.4.0",
    "hmrouter_ver": "^1.2.2",
    "refresh_v2": "^1.0.4"
  }
}

2.6.2 建立工程关联

定义好版本文件后,我们需要告诉工程去哪里读取这些信息。打开项目级根目录 下的 oh-package.json5 ,添加 "parameterFile" 属性进行关联:

oh-package.json5

json 复制代码
{
  "modelVersion": "6.0.1",
  "description": "Please describe the basic information.",
  "parameterFile": "./oh-package-parameter.json5",// 关联创建的文件  记得Sync Now哟
  "dependencies": {
  },
  "devDependencies": {
    "@ohos/hypium": "1.0.24",
    "@ohos/hamock": "1.0.0"
  }
}

注意: 修改完成后,请务必点击编辑器右上角的 Sync Now 进行同步,也要Rebuild工程哟

2.6.3 在模块中优雅引用

当配置生效并完成 Rebuild 后,我们就可以在各个子模块中使用这些定义的版本信息了。

使用方法: 在对应模块的 oh-package.json5 中,将具体的版本号替换为 @param:参数名 的形式。

如图所示

通过@param:就能使用 "parameterFile"配置的版本信息,一定要Rebuild后才可以使用哟

💡 为什么这种方案更"企业级"?

  1. 极简维护 :当 HMRoute 发布新版本需要全量升级时,你只需修改根目录下的一个 json5 文件,整个工程都会自动同步。
  2. 避免冲突 :强制全模块使用同一版本,彻底解决了由于版本不一致导致的运行时 Class Not FoundMethod Incompatibility 等隐性 Bug。
  3. 代码整洁:子模块的配置文件变得非常干净,不再充斥着杂乱的版本数字。

3、实战闭环:启动页的身份核验流

在项目架构搭建的最后,我们需要将网络封装、路由跳转和业务逻辑串联起来。我们通过 SplashPage 的生命周期钩子来实现一个严谨的启动逻辑:

如图所示

1. 业务流程拆解

  • 第一步:状态预判。当启动页加载时,首先检查本地存储的登录状态。

    TS 复制代码
    private async checkAndJump() {
    await new Promise<void>((resolve) => {
      setTimeout(resolve, 2000);
    });
    const isLogin = UserStorageUtil.isLogin()
    LogUtil.warn(`WelcomeLifecycle isLogin=${isLogin}`)
    if (!isLogin) {
      //如果没有登录,直接跳转登录页面
      this.jumpToLogin()
      return
    }
    //如果登录了,先调用用户信息接口,确保cookie没有过期
     this.checkLoginStatus()
    }
  • 第二步:动态分发

    • 未登录 :直接通过 HMRouter 导航至 LoginPage
    TS 复制代码
    private jumpToLogin() {
      HMRouterMgr.replace({ pageUrl: FeatureHomePath.LOGIN, param: { "from": "welcome" } });
    }
    • 已登录:不急着进首页,而是发起一次用户信息请求。
    TS 复制代码
    private async checkLoginStatus() {
      LogUtil.warn(`WelcomeLifecycle checkLoginStatus`)
      try {
        // // 这里的请求会自动会走配置好的 axios 拦截器
        // // 接口校验通过:说明没失效,跳转到首页
        PhoneApi.getUserInfo().then((userInfoResult: UserInfoResult) => {
          LogUtil.debug("checkLoginStatus->getUserInfo-> " + JSON.stringify(userInfoResult))
          HMRouterMgr.replace({ pageUrl: FeatureHomePath.HOME, param: { "from": "Loading" } })
        })
    
      } catch (er) {
        console.error("验证登录失效或网络异常", er);
      }
    }
  • 第三步:响应拦截器的妙用

    • 在登录成功时,我们的响应拦截器已经自动将 Set-Cookie 持久化到了首选项中。
    TS 复制代码
    // 2. 【响应拦截】提取并保存 Cookie
    onHeadersReceived: (headers) => {
      // Axios 返回的 set-cookie 通常是一个数组,或者字符串
      // 在玩Android接口中,它是 'set-cookie' 字段
      const setCookie = headers['set-cookie'] as string[] | string;
      if (setCookie) {
        // 处理 Cookie 逻辑 (调用下面的工具方法)
        const cookieStr = this.processSetCookie(setCookie);
        // 如果解析到了有效的 Cookie,就存起来
        if (cookieStr && cookieStr.length > 0) {
          // 这里为了演示简单直接覆盖,实际业务可能需要和旧 Cookie 合并
          // AppStorage.setOrCreate(APP_COOKIE_KEY, cookieStr);
          UserStorageUtil.saveCookie(cookieStr)
          console.info('更新 Cookie 成功:', cookieStr);
        }
      }
    }
    • 此时发起的请求会自动带上这些 Cookie。如果后端返回数据正常,说明身份有效,丝滑进入 HomePage
    TS 复制代码
    //请求拦截器
    headerHandler: (config: InternalAxiosRequestConfig) => {
      // 从内存/缓存中取出 Cookie 字符串
      const cookieStr = UserStorageUtil.getCookStr();
      if (cookieStr) {
        // 这里的 Key 必须叫 'Cookie'
        config.headers['Cookie'] = cookieStr;
      }
      return config
    },
    • 如果拦截器捕获到 errorCode: -1001(登录失效),则会自动触发清除逻辑并弹窗告知用户,重新导向登录页。
    javascript 复制代码
    //网络请求错误、接口业务报错回调
    errorHandler: (code, msg) => {
      if (code == -1001) {
        //登录失效
        HMRouterMgr.removeAll();
        //  这里将会跳转至登录页面
        HMRouterMgr.replace({ pageUrl: FeatureHomePath.LOGIN, param: { "from": "NetWork" } });
        return
      }
      //其他code 根据业务场景来处理,吐司已经在内部拦截器实现,当然也可以在这里弹吐司
    }

2. 这种设计的优势

这种"页面绑定生命周期"的逻辑,让启动页成为了一个纯粹的"调度中心"。它不持有复杂的业务逻辑,而是作为网络库与路由库的协调者,确保了用户进入首页时,App 的状态是绝对可靠的。

4、结语:开工大吉,架构先行

恭喜你读到了这里!此时春节的烟火气或许已经散去,我们也重新回到了充满挑战的代码世界。

在这篇文章中,我们没有急于去堆砌那些绚丽的 UI 界面,而是耐心地给我们的 WanAndroid 项目打下了一份坚实的基础:

  • 模块化分层:实现了业务模块间的物理隔离与逻辑解耦。
  • Retrofit 风格封装:利用 ArkTS 装饰器让 Axios 网络请求变得优雅且易于维护。
  • 版本归一化 :通过 parameterFile 实现了全工程依赖的统一管理。
  • 启动导航流 :构建了从 SplashPageHomePage 的身份核验闭环。

"磨刀不误砍柴工",这套架构虽然在前期投入了一定的思考成本,但它将保证我们在接下来的业务开发中如鱼得水,不再为配置冲突或逻辑耦合而头疼。

下集预告: 架构已成,接下来便是"填肉"。在接下来的实战篇中,我们将正式杀入 UI 开发阵地,带大家手把手实现WanAndroid 核心业务逻辑。

新的一年,新的开始。愿你的代码 无 Bug,无告警,全是 Feature!我们下一篇实战见!


4.1 饮水思源:致谢与开源

一个项目的成功离不开巨人肩上的力量。在编写 WanAndroid 鸿蒙版 的过程中,特别感谢以下开源项目与平台提供的支持:

感谢数据支持

  • WanAndroid :感谢 鸿洋 (Hongyang) 大佬提供的稳定 API 支持。正是因为有了如此完善的业务接口,才让我们的鸿蒙实战能够真正落地。

感谢核心开源框架

正是这些优秀的开源工具,构建了我们项目稳固的底层:

欢迎关注我的开源项目

本项目已在 GitHub 开源,包含完整的架构封装与业务实现,欢迎大家 StarFork,我们一起完善它!

相关推荐
自然语5 小时前
人工智能之数字生命 认知架构白皮书 第7章
人工智能·架构
kyriewen115 小时前
你点的“刷新”是假刷新?前端路由的瞒天过海术
开发语言·前端·javascript·ecmascript·html5
eastyuxiao5 小时前
如何在不同的机器上运行多个OpenClaw实例?
人工智能·git·架构·github·php
代码飞天6 小时前
harmonyOS开发基础之标题栏(HdsNavigation)
华为·harmonyos
skywalk81637 小时前
Kotti Next的tinyfrontend前端模仿Kotti 首页布局还是不太好看,感觉比Kotti差一点
前端
陈天伟教授7 小时前
智能体架构:大语言模型驱动的自主系统深度解析与演进研究(一)
人工智能·语言模型·架构
RopenYuan8 小时前
FastAPI -API Router的应用
前端·网络·python
走粥9 小时前
clsx和twMerge解决CSS类名冲突问题
前端·css
Purgatory0019 小时前
layui select重新渲染
前端·layui