轻规划鸿蒙开发实战14:一多架构下的工程治理,多 Feature 模块解耦设计与资源共享最佳实践

背景介绍

在 HarmonyOS 应用的大型工程化落地过程中,我们始终面临着一个核心命题:“一次开发,多端部署(简称一多)”

“轻规划”(AeroPlan)作为一款效率类规划工具,需要同时兼容手机、折叠屏和平板等多种物理尺寸与屏幕形态的终端。然而,随着项目功能迅速膨胀,如果把所有代码都塞进单 HAP(Harmony Ability Package)壳工程里,在进行工程迭代时,很容易遭遇以下几个痛点:

  • 团队协作冲突频繁:多个业务组的开发者同时修改同一个包,Git 冲突层出不穷,极大地拉低了合并分支的效率。
  • 编译速度劣化:哪怕只微调了一行 UI 样式,编译系统也会对整包进行全量重新编译,耗费大量等待时间。
  • 边界风险与隐患:业务模块之间强引用、隐式耦合,导致“牵一发而动全身”。底层的微小改动可能导致高层模块出现意外的稳定性隐患,严重影响应用的线上质量。

为此,“轻规划”团队全面重构了工程结构,采用了高内聚、低耦合的 Feature Modules(多业务模块) 治理方案。

轻规划鸿蒙开发实战14:一多架构下的工程治理,多 Feature 模块解耦设计与资源共享最佳实践-1.png

今天,我们将从模块化工程拆分、一多自适应响应,到跨 HAP/HAR/HSP 静态资源访问、解耦路由映射等全链路进行深度的实战解析。


1. 架构纵览:一多模块化工程依赖拓扑图

在鸿蒙工程中,HAP 是应用运行和打包的实体,而 HAR(Harmony Archive)或 HSP(Harmony Shared Package)是共享模块包。我们在设计“轻规划”的依赖关系时,秉持了“上层轻量、下层沉淀、同级隔离”的原则,整体依赖拓扑关系如下:

轻规划鸿蒙开发实战14:一多架构下的工程治理,多 Feature 模块解耦设计与资源共享最佳实践.png

静态共享包 (HAR) 与 动态共享包 (HSP) 的技术选型建议

在多模块治理中,如何选择 HAR 与 HSP 是许多开发者的痛点。它们的核心技术差异如下表所示:

维度 HAR (Harmony Archive) HSP (Harmony Shared Package)
打包方式 编译时拷贝,每个 HAP 会包含一份 HAR 代码的副本。 运行时共享,整个应用内只存在一份 HSP 实例。
包体积影响 若多个 HAP 均引用同一 HAR,会导致包体积重复增大。 不管被引用多少次,运行时动态链接,能够有效优化体积。
冷启动性能 代码直接打入 HAP,无需动态链接,启动略快。 启动时需要动态链接,如果 HSP 过多会微弱影响启动耗时。
适用场景 工具类、UI 基础组件库、轻量级共享逻辑。 大体量业务模块、高复用度的公共服务与复杂 Feature。

对于“轻规划”项目:

  • 核心公共 UI 组件库(uicomponents)由于包含大量底层渲染组件,且需要保持冷启动无损,我们选择将其作为 HAR 进行编译。
  • 一些大体积的独立 Feature(如 AI 导师模块)在后续演进中可以作为 HSP 独立部署,达到按需动态加载的目的,防止主包体积超出系统限制。

2. 工程治理:模块的物理划分与隔离

在“轻规划”中,我们将代码与业务逻辑彻底隔离,按职责划分在不同的目录下:

  • entry:只负责承载一多自适应分流入口,包含 Ability 路由中转逻辑以及 App 级的全局配置。
  • features/plan:负责打卡计划制定、打卡热力图计算以及习惯打卡核心事务治理。
  • features/vision:负责 AI 导师会话、九宫格高保真渲染等大视觉交互。
  • share/uicomponents:托管自研的 HapticCanvasComponent 烟花组件、DynamicRadarChart 雷达图、HabitHeatmapView 热力图等高度复用的原子 UI。
  • share/base:托管分布式数据库同步逻辑、一键登录工具类以及全局动态路由中转中心。
避坑指南:oh-package.json5 的强类型版本控制

为了防止由于不同 Feature 模块引用了不同版本的第三方库而导致运行时冲突,我们必须在根目录下的 oh-package.json5 中进行全局统筹配置。而在各个 Feature 的声明中,通过 file: 相对路径进行内部本地引用:

// 位于根目录的 oh-package.json5(做全局依赖与版本统筹)
{
  "dependencies": {
    "@ohos/lottie": "2.0.0"
  }
}
// 位于 features/plan/oh-package.json5 的本地依赖声明
{
  "name": "@aeroplan/plan",
  "version": "1.0.0",
  "dependencies": {
    // 使用相对路径引用底层的公共 UI 组件模块
    "@aeroplan/uicomponents": "file:../../share/uicomponents",
    // 使用相对路径引用底层的基建与公共库模块
    "@aeroplan/base": "file:../../share/base"
  }
}
工程核心 module.json5 解构

主 HAP 壳工程中,module.json5 需要针对一多设备进行显式声明,以下是“轻规划”主 entry 模块的工程治理配置:

{
  "module": {
    "name": "entry",
    "type": "entry", // 声明为应用主入口 HAP
    "srcEntry": "./ets/entryability/EntryAbility.ets",
    "description": "$string:module_desc",
    "mainElement": "EntryAbility",
    "deviceTypes": [
      "phone",
      "tablet",
      "2in1" // 显式指明支持直屏、平板和 2in1 终端,适配一多架构
    ],
    "deliveryWithInstall": true,
    "installationFree": false,
    "pages": "$profile:main_pages",
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "description": "$string:EntryAbility_desc",
        "icon": "$media:layered_image",
        "label": "$string:EntryAbility_label",
        "startWindowIcon": "$media:layered_image",
        "startWindowBackground": "$color:start_window_background",
        "exported": true,
        "skills": [
          {
            "entities": [
              "entity.system.home"
            ],
            "actions": [
              "action.system.home"
            ]
          }
        ]
      }
    ]
  }
}

3. App 级路由解耦跳转架构设计与实现

在大中型鸿蒙应用中,各个 Feature 模块(如 features/planfeatures/vision)互为兄弟模块,如果进行直接引用会产生循环依赖导致编译报错。要解决解耦跳转的问题,必须设计一套基于 Navigation 路由表的统一路由中心。

下面是“轻规划”中基于系统 route_map.json 结合统一路由中转中心的设计与核心 ArkTS 实现。

步骤 1:在 Feature 模块中配置 route_map.json

在 Feature 模块的 src/main/resources/base/profile/ 下新建或编辑 route_map.json,声明当前模块对外暴露的路由页:

// features/plan/src/main/resources/base/profile/route_map.json
{
  "routerMap": [
    {
      "name": "PlanDetailView", // 路由跳转所用的唯一标识符
      "pageSourceFile": "src/main/ets/components/PlanDetailView.ets", // 页面的源文件路径
      "buildFunction": "PlanDetailViewBuilder", // 页面组件生成的 Builder 入口函数
      "data": {
        "description": "习惯计划详情展示页面"
      }
    }
  ]
}
步骤 2:在目标 Feature 页面中定义入口 Builder
// features/plan/src/main/ets/components/PlanDetailView.ets

import { BaseRouteParams } from '@aeroplan/base';

// 路由组件参数定义
export interface PlanDetailParams extends BaseRouteParams {
  planId: string;
  planName: string;
}

@Component
export struct PlanDetailView {
  // 接收从路由中介器传递过来的 NavPathStack 实例
  @Consume('pageStack') pageStack: NavPathStack;
  @State planId: string = '';
  @State planName: string = '';

  aboutToAppear(): void {
    // 从 NavPathInfo 中解析出跳转携带的数据,实现类型安全的数据接续
    const pathInfo = this.pageStack.getParent();
    if (pathInfo) {
      const params = pathInfo.getParamByName('PlanDetailView') as PlanDetailParams;
      if (params) {
        this.planId = params.planId;
        this.planName = params.planName;
      }
    }
  }

  build() {
    NavDestination() {
      Column({ space: 16 }) {
        Text(`计划 ID: ${this.planId}`)
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
        Text(`计划名称: ${this.planName}`)
          .fontSize(16)
        
        Button('返回上一页')
          .onClick(() => {
            // 通过栈操作安全弹出页面,消除耦合关系
            this.pageStack.pop();
          })
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
    }
    .title('习惯详情')
  }
}

// 统一对外暴露的 Builder 函数,必须与 route_map.json 中的 buildFunction 一致
@Builder
export function PlanDetailViewBuilder() {
  PlanDetailView()
}
步骤 3:在共享底座中实现统一路由中介器 (RouterManager)

为了抹平物理模块之间的跳转感知,我们在底座 share/base 中封装一个专门处理导航行为的单例:

// share/base/src/main/ets/router/RouterManager.ets

export interface BaseRouteParams {
  // 定义通用参数基类,便于扩展路由传递控制标志
  fromPage?: string;
}

/**
 * 统一路由中介器,基于 Navigation 的栈设计。
 * 规避模块间强导入依赖,以纯字符串标识和统一数据类型实现解耦跳转。
 */
export class RouterManager {
  private static instance: RouterManager;
  private pathStack: NavPathStack | null = null;

  private constructor() {}

  // 单例模式,保证全局路由上下文唯一
  public static getInstance(): RouterManager {
    if (!RouterManager.instance) {
      RouterManager.instance = new RouterManager();
    }
    return RouterManager.instance;
  }

  /**
   * 初始化路由控制栈,在 entry 壳工程主页加载时调用
   * @param stack NavPathStack 实例
   */
  public registerStack(stack: NavPathStack): void {
    this.pathStack = stack;
  }

  /**
   * 安全的页面跳转方法,集成稳定性防范拦截逻辑
   * @param name 目标路由名(必须与 route_map.json 配置一致)
   * @param params 传递给目标页面的参数数据
   */
  public push(name: string, params?: BaseRouteParams): void {
    if (!this.pathStack) {
      console.error('RouterManager: 路由栈未注册,无法完成页面跳转');
      return;
    }
    
    // 对跳转请求实施安全边界防范拦截,例如检验动态路由表加载情况
    try {
      this.pathStack.pushPath({ name: name, param: params });
      console.info(`RouterManager: 成功向路由栈压入页面 -> ${name}`);
    } catch (error) {
      // 捕获异常,防止非法访问等非授权操作或未加载模块引发程序闪退
      console.error(`RouterManager: 页面压栈失败。原因: ${JSON.stringify(error)}`);
    }
  }

  /**
   * 弹出当前页面返回上一级
   */
  public pop(): void {
    if (this.pathStack) {
      this.pathStack.pop();
    }
  }
}

4. 详细解构跨 HSP/HAR 静态资源共享大坑

静态资源共享是多 Feature 治理中出错率最高的版块。核心痛点在于:默认情况下,系统解析器会到当前发起调用的 HAP 包中查找资源文件。如果在主 HAP 壳工程中试图加载公共模块内部的资源,却未声明命名空间,就会抛出资源缺失的崩溃

场景 A:主包访问静态共享 HAR (uicomponents) 包内的图片

如果资源文件位于 HAR 包(uicomponents)中,其内部路径为 resources/base/media/ic_star.png

// 错误示范❌:这会导致解析器去主 entry 包里寻找该资源而引发崩溃
Image($r('app.media.ic_star'))

// 正确示范✅:必须使用三段式参数,显式指明资源来源模块
Image($r('app.media.ic_star', '', 'uicomponents'))
  .width(24)
  .height(24)
场景 B:主包访问动态共享 HSP 内部的资源与媒体

若需要读取大 Feature 包(编译类型为 HSP)中的资源,仅仅通过字符串标识是不够的。由于 HSP 包在运行时为独立沙箱,我们需要动态切换模块 Context,才能安全获取其实际资源。

以下是封装的跨包资源管理器工具类实现:

// share/base/src/main/ets/utils/HspResourceManager.ets

import common from '@ohos.app.ability.common';

/**
 * 跨模块资源获取助手,解决不同动态链接包(HSP)运行时上下文环境隔离的瓶颈
 */
export class HspResourceManager {
  
  /**
   * 根据指定的 HSP 模块名安全地提取其内部图片像素映射
   * @param currentContext 当前调用的上下文(通常是 HAP 的 Context)
   * @param hspModuleName 目标动态共享包的名字,例如 "ai_vision"
   * @param resourceName 资源名称(注意是资源常量定义的字符串)
   * @returns 得到的 PixelMap 或者是 Resource 实体
   */
  public static async getHspMedia(
    currentContext: common.UIAbilityContext,
    hspModuleName: string,
    resourceName: string
  ): Promise<Resource> {
    try {
      // 1. 通过主上下文为指定的动态共享包创建专有模块上下文环境
      const moduleContext = currentContext.createModuleContext(hspModuleName);
      
      // 2. 利用目标模块的资源管理器解析出资源的 ID
      // 动态资源管理在大型组件复用以及规避安全性隐患的绕过机制中极其重要
      const resourceManager = moduleContext.resourceManager;
      
      // 注意:跨包反射时,需要通过完整限定路径来拼装或直接通过系统底层的资源映射表动态获得
      // 这里返回目标模块上下文绑定好的动态 Resource 对象,可以直接传给 Image 组件
      const res: Resource = {
        id: -1, // 标识采用动态名称定位资源
        type: 20000, // 20000 对应 media 类型
        params: [resourceName],
        bundleName: currentContext.applicationInfo.name,
        moduleName: hspModuleName // 指明目标模块名字
      };
      
      return res;
    } catch (e) {
      console.error(`HspResourceManager: 获取跨包资源时遭遇异常。详情: ${JSON.stringify(e)}`);
      // 优雅退化方案:当动态加载遭遇稳定性隐患而失败时,返回兜底的本包灰色占位图
      return $r('app.media.placeholder_error');
    }
  }
}

使用示例:

// 在 vision 模块的渲染组件中
@Component
export struct ImageLoaderWidget {
  @State imageSource: Resource | null = null;

  async aboutToAppear() {
    let context = getContext(this) as common.UIAbilityContext;
    // 动态拉取指定 HSP 包下的 icon_avatar 资源,实现资源运行态加载
    this.imageSource = await HspResourceManager.getHspMedia(context, 'vision', 'icon_avatar');
  }

  build() {
    Column() {
      if (this.imageSource) {
        Image(this.imageSource)
          .width(100)
          .height(100)
          .objectFit(ImageFit.Cover)
      }
    }
  }
}

5. 一多适配自适应:GridCol 与 GridRow 弹性栅格重构

在“一次开发,多端部署”方案中,我们绝不能根据特定的设备类型(如 phonetablet)写死硬编码逻辑。取而代之的是,系统倡导使用 弹性栅格布局(GridRow/GridCol) 以及断点状态机来驱动 UI 的自适应排布。

以下是“轻规划”中 AI 看板及九宫格矩阵在大屏与小屏态下的自适应重构:

// features/vision/src/main/ets/components/AdaptiveVisionLayout.ets

import { DynamicRadarChart } from '@aeroplan/uicomponents';

@Component
export struct AdaptiveVisionLayout {
  // 定义当前的宽度断点,支持 xs(超小设备)、sm(手机)、md(折叠屏展开)、lg(大平板/2in1)
  @State currentBreakpoint: string = 'sm';

  build() {
    Column() {
      // 弹性栅格容器
      GridRow({
        columns: 12, // 整个视口在横向等分为 12 栏
        breakpoints: {
          value: ["320vp", "600vp", "840vp"], // 分界线:[sm, md, lg]
          reference: BreakpointsReference.WindowSize // 依据窗口实际物理大小而非设备固化类型进行响应
        },
        direction: GridRowDirection.Row,
        gutter: { x: 16, y: 16 } // 设置组件卡片之间的间隙(横向/纵向 16vp)
      }) {
        
        // 1. 核心大图——九宫格规划板卡片
        // 手机端(xs, sm)占满 12 栏;平板及中屏(md)占 8 栏,给右侧留出 4 栏
        GridCol({
          span: { xs: 12, sm: 12, md: 8, lg: 8 },
          offset: 0
        }) {
          Column() {
            Text('九宫格核心掌控盘')
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor($r('sys.color.ohos_id_color_text_primary'))
              .margin({ bottom: 12 })
            
            // 九宫格核心业务卡片
            NineGridBalanceMatrix()
          }
          .padding(16)
          .backgroundColor($r('sys.color.ohos_id_color_card_bg'))
          .borderRadius(16)
        }

        // 2. 统计雷达图卡片
        // 手机端(xs, sm)下因纵向空间较充裕,占 12 栏进行底置平铺
        // 在折叠屏/平板(md, lg)中,占 4 栏,与九宫格呈左右双栏排版,彻底拉伸大屏的信息密度
        GridCol({
          span: { xs: 12, sm: 12, md: 4, lg: 4 },
          offset: 0
        }) {
          Column() {
            Text('规划均衡分析')
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor($r('sys.color.ohos_id_color_text_primary'))
              .margin({ bottom: 12 })

            // 渲染动态雷达图组件,组件代码托管在共享 HAR 库中
            DynamicRadarChart()
          }
          .padding(16)
          .backgroundColor($r('sys.color.ohos_id_color_card_bg'))
          .borderRadius(16)
        }
      }
      .onBreakpointChange((breakpoint: string) => {
        // 当屏幕宽度发生变化并跨越临界值时,自动触发断点更新事件
        this.currentBreakpoint = breakpoint;
        console.info(`AdaptiveLayout: 视口宽度断点变更 -> ${breakpoint}`);
      })
      .width('100%')
      .padding(16)
    }
    .width('100%')
    .height('100%')
    .backgroundColor($r('sys.color.ohos_id_color_sub_background'))
  }
}

// 模拟的九宫格业务矩阵渲染组件
@Component
struct NineGridBalanceMatrix {
  private matrixData: string[] = ['健康', '事业', '财务', '家庭', '社交', '心智', '娱乐', '成长', '贡献'];

  build() {
    Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
      ForEach(this.matrixData, (item: string) => {
        Column() {
          Text(item)
            .fontSize(16)
            .fontColor($r('sys.color.ohos_id_color_text_primary_contrast'))
            .fontWeight(FontWeight.Medium)
        }
        .width('30%')
        .height(80)
        .margin({ bottom: 12 })
        .backgroundColor($r('sys.color.ohos_id_color_emphasized'))
        .borderRadius(12)
        .justifyContent(FlexAlign.Center)
      }, (item: string) => item)
    }
    .width('100%')
  }
}

6. 总结与下期预告

通过在“轻规划”中实践基于多 Feature 模块划分、一多工程的解耦治理方案,我们顺利理清了复杂的底层依赖拓扑,将代码解耦拆包,在有效解决团队并行合并冲突的同时,消解了潜在的系统级稳定性隐患。此外,结合特定的包限定名资源访问规则和动态路由中介器(RouterManager),我们也完美绕过了不同组件包混编过程中的各种资源找不到大坑。

在一多架构与工程依赖梳理清晰后,我们将对打卡习惯中引入的 AR 3D 骨骼矩阵计算进行更深度的探秘——当用户在平板或手机前做伸展运动时,颈椎向上、下、左、右扭动的物理角度在运动学上到底是如何通过三维空间四元数算出来的?

在下一篇文章中,我们将正式踏入硬核图形学与运动学解算:AR 3D 骨骼矩阵换算数学原理与颈椎拉伸角度运动学姿态求解! 敬请期待。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐