鸿蒙项目实战:手把手带你从零架构 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,我们一起完善它!

相关推荐
大时光2 小时前
粒子形成文字
前端
Kayshen2 小时前
春节期间我们开源了一个 AI-Native 的矢量设计工具,对标 Pencil.dev,让 AI Agent 直接画 UI
前端·aigc·agent
没想好d2 小时前
通用管理后台组件库-6-头部导航组件
前端
linux_cfan2 小时前
打造智慧校园视听新基建:高校与在线教育平台 Web 视频播放器选型指南 (2026版)
前端·学习·音视频·教育电商
JYeontu2 小时前
实现一个超萌的柯基交互输入框
前端
天蓝色的鱼鱼2 小时前
Vite 8:从“混动”到“纯电”,构建性能提升10倍+
前端·vite
dreams_dream2 小时前
XSS类型
前端·xss
wuhen_n2 小时前
副作用的概念与effect基础:Vue3响应式系统的核心
前端·javascript·vue.js
yangyanping201082 小时前
消息队列之消费者如何获取消息
分布式·架构·kafka