鸿蒙页面路由与 Navigation 组件

本文梳理 HarmonyOS NEXT 上 @ohos.router 与 Navigation 两套跳转方案的差异:pushUrl 的 Standard 与 Single 模式、replaceUrl、back、getParams 与错误码,NavPathStack 的注入与 pushPathByName、popToName 用法,NavDestination 配置与自定义转场,以及系统路由表方案。

引言

在 HarmonyOS NEXT 里做页面跳转,有两条并行的路:一条是 @ohos.router 提供的全局路由 API,写法轻快、接近小程序的 navigateTo;另一条是 ArkUI 的 Navigation 容器组件配合 NavPathStack,官方从 API 10 起持续加码,API 11 补上系统路由表与自定义转场,API 12 又加了 setInterception 拦截器与 pushDestination 的异步失败语义。

两条路都能跑通业务,但它们的页面栈是两套互不相通的数据结构。很多团队在项目中期才踩到:一半页面走 router.pushUrl,一半页面走 pageStack.pushPathByName,结果返回键行为错乱、getParams() 拿不到值、NavPathStack 在子组件里恒为 undefined。本文先把 router 的 API 面完整讲一遍,再讲 Navigation 的栈操作、NavDestination 配置、系统路由表与自定义转场,最后用对照表和坑清单帮你在项目早期就把方案定下来。

前置阅读:鸿蒙应用与页面生命周期 、鸿蒙元服务与卡片开发 。

router 全局路由 API 详解

router 模块从 API 7 起就存在,API 12 之后官方推荐改成从 @kit.ArkUI 统一导入,而不是老的 @ohos.router。功能没变,只是模块归口调整,新项目直接用 kit 导入即可。

// 推荐(API 12+)
import { router } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';

router 的核心能力是六件事:入栈、替换、出栈、清栈、取参、查栈。全部是全局单例上的方法,不依赖组件实例,因此在工具类、网络回调、甚至非 UI 的 .ets 文件里都能直接调用。这是它最大的优点,也是问题来源。

pushUrl 与 RouterMode

pushUrl 是入栈入口,签名是 pushUrl(options: RouterOptions, mode?: RouterMode): Promise<void>。RouterOptions 只有三个字段:

字段类型必填说明
urlstring是目标页面路径,必须在 main_pages.json 中注册过
paramsObject否传给目标页面的参数对象,会被原样保存进页面栈
recoverableboolean否是否支持应用恢复,默认 true

RouterMode 决定重复入栈策略:

  • RouterMode.Standard:默认值,每次调用都新建一个页面实例压栈。同一个页面可以压多份,各自持有独立的 params。
  • RouterMode.Single:如果栈里已经存在同名页面,不新建实例,而是把它移到栈顶并触发一次 onPageShow,同时用新的 params 覆盖旧参数。
// pages/Index.ets
import { router } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';

interface DetailParams { id: string; from: string; }

@Entry
@Component
struct Index {
  async gotoDetail(id: string): Promise<void> {
    const params: DetailParams = { id: id, from: 'Index' };
    try {
      await router.pushUrl({ url: 'pages/Detail', params: params, recoverable: true },
        router.RouterMode.Standard);
    } catch (err) {
      const e = err as BusinessError;
      console.error(`pushUrl failed, code=${e.code}, msg=${e.message}`);
    }
  }

  build() {
    Column({ space: 12 }) {
      Button('跳转详情,Standard').onClick(() => { this.gotoDetail('1001'); })
      Button('跳转详情,Single').onClick(async () => {
        await router.pushUrl({ url: 'pages/Detail' }, router.RouterMode.Single);
      })
    }
    .width('100%')
    .padding(16)
  }
}

注意 Single 模式的一个反直觉行为:它不是「阻止跳转」,而是「复用栈内已有实例并把它提到栈顶」。如果那个实例在栈中间,它上方的所有页面会被弹出。很多开发者以为 Single 只是防重复,结果发现中间页面莫名其妙消失了。

replaceUrl 与 back

replaceUrl(options, mode?) 用新页面替换当前栈顶页面,栈深度不变。典型场景是启动页跳首页、登录页跳主界面,用户按返回时不应该回到登录页。

back(options?) 出栈,options 不只是「去哪」,还能顺手把结果回传给上一页:

router.back({ url: 'pages/Index', params: { updated: true } });

容易混淆的点:back 的 params 不会替换上一页原本的 params,而是作为「返回携带的数据」被合并进去。上一页 onPageShow 里 router.getParams() 拿到的仍是原来那批参数,需要用额外字段判断是否有回传。

clear 与 getLength

router.clear() 清空整个页面栈,只保留当前页面。它在登出、切换账号时非常有用,但也非常危险:清栈后 back 会直接退出应用,用户会以为应用崩了。更稳妥的做法是 clear() 之后立刻 pushUrl 到首页,或者干脆用 replaceUrl。router.getLength() 返回当前页面栈深度,HarmonyOS 对页面栈有上限,标准形态下是 32 层,达到上限后继续 pushUrl 会抛 100003。

参数传递与 getParams

router.getParams() 返回栈顶页面的 params 对象,类型是 Object,需要自己断言。它只在页面被创建或被复用(Single 模式)时才有意义,普通 @Component 里调用拿到的是当前栈顶页面的参数,语义很容易搞错。

// pages/Detail.ets
import { router } from '@kit.ArkUI';

interface DetailParams { id: string; from: string; }

@Entry
@Component
struct Detail {
  @State id: string = '';
  @State from: string = 'unknown';

  aboutToAppear(): void {
    const raw = router.getParams() as DetailParams | undefined;
    if (raw !== undefined && raw.id !== undefined) {
      this.id = raw.id;
      this.from = raw.from ?? 'unknown';
    }
  }

  build() {
    Column({ space: 8 }) {
      Text(`id = ${this.id}`).fontSize(18)
      Text(`from = ${this.from}`).fontSize(14).fontColor('#666')
      Button('返回并带结果').onClick(() => {
        router.back({ url: 'pages/Index', params: { updated: true } });
      })
    }
    .width('100%')
    .padding(16)
  }
}

params 走的是内存引用传递,不是序列化拷贝。传一个含方法的对象也能带过去,但别这么干:页面栈里的对象生命周期不受控,持有闭包会拖住整个页面实例,是内存泄漏的常见来源。传纯数据(Record<string, Object> 或简单 interface)最安全。

getState 与页面栈快照

router.getState() 返回当前页面栈顶的状态快照 RouterState,含 index(从 1 开始的位置)、name(页面文件名)、path(完整路径),一行代码即可读出:const state = router.getState();。它只能读栈顶,既不能遍历整个栈,也不能按名字查找,这是 router 在设计上的硬边界。

错误码

router 的异常都通过 BusinessError.code 抛出,处理时至少要覆盖这几个:

错误码含义常见触发场景
100001内部错误页面栈状态异常,通常是并发跳转导致
100002url 无效或页面不存在路径没在 main_pages.json 注册,或拼写错误
100003页面栈数量达到上限连续 push 超过 32 层,多为循环跳转 bug
100004命名路由页面不存在用了 pushNamedRoute 但目标未注册

100002 是最常见的那个,九成情况是新增页面后忘了在 src/main/resources/base/profile/main_pages.json 里加路径。这个文件不会被 IDE 自动同步,改名或移动文件后必须手动更新。

router 的局限

把上面的 API 面看完,router 的边界就很清楚了:

  1. 不支持自定义转场动画。系统只提供默认的左右滑入滑出,无法改成淡入、缩放或共享元素。想要「图片从列表飞到大图」这类效果,router 做不到。
  2. 栈管理能力弱。只有 getLength 和 getState 两个只读接口,没有 popTo、没有按名字移除、没有 getIndexByName。想实现「回到首页并清掉中间所有页面」,只能 clear 后重新 push,会丢失首页的状态。
  3. 页面耦合强。跳转目标以字符串路径硬编码在代码里,重命名页面时没有编译期保护,只有运行时 100002。
  4. 返回拦截弱。只能在页面级 onBackPress 里处理,且页面一旦不是 @Entry 就完全没有这个钩子。
  5. 一多适配缺失。平板和折叠屏上的分栏布局需要自己写断点判断,Navigation 的 NavigationMode.Split 是开箱即用的。

这五点加起来,就是官方在 API 10 之后力推 Navigation 的原因。但要说清楚:router 并没有被废弃,它在轻量场景下依然是成本最低的选择。

Navigation 是一个容器组件,自身只负责「壳」——标题栏、工具栏、内容区;页面栈由它持有的 NavPathStack 对象管理。这个设计把「导航能力」从全局单例变成了组件状态,带来三个直接好处:栈可以被多个 Navigation 实例隔离、栈操作有完整 API、栈变化可以驱动 UI 刷新。

NavPathStack 必须由外层页面创建,再通过 @Provide 向下注入,子组件用 @Consume 取。直接 new NavPathStack() 放在子组件里是错的——那会得到一个和 Navigation 无关的空栈。

// pages/MainPage.ets
import { DetailParam } from '../model/DetailParam';

@Entry
@Component
struct MainPage {
  // 关键:@Provide 把栈注入组件树,key 显式写成 'pageStack'
  @Provide('pageStack') pageStack: NavPathStack = new NavPathStack();

  @Builder
  pageMap(name: string, param: object) {
    if (name === 'DetailPage') {
      DetailPage({ param: param as DetailParam })
    }
  }

  build() {
    Navigation(this.pageStack) {
      Column({ space: 12 }) {
        Button('pushPath 进入详情').onClick(() => {
          this.pageStack.pushPath({
            name: 'DetailPage',
            param: { id: '1001', title: '订单详情' } as DetailParam
          });
        })
        Button('pushPathByName 进入设置').onClick(() => {
          this.pageStack.pushPathByName('SettingPage', { theme: 'dark' });
        })
      }
      .width('100%')
      .padding(16)
    }
    .title('首页')
    .navDestination(this.pageMap)
  }
}

@Provide 的 key 用字符串显式指定,避免和同名变量冲突。如果写成 @Provide pageStack: NavPathStack,key 就是变量名 pageStack,子组件必须写 @Consume pageStack,变量名必须完全一致,重构时极易漏改。

pushPath 与 pushPathByName

两个入栈方法,区别只在参数形态:

  • pushPath(info: NavPathInfo, animated?: boolean): void:NavPathInfo 是对象,字段为 name、param、onPop、isEntry。
  • pushPathByName(name: string, param: Object, animated?: boolean): void:名字和参数分开传,另有带 onPop 回调的重载。

它们都是同步返回 void,失败不会抛异常——目标 name 找不到时只是什么都不发生。需要「失败可感知」时用 pushDestination / pushDestinationByName,它们返回 Promise<void>,失败会 reject。isEntry: true 表示这是一个可直接被系统路由表识别的入口页面,路由表方案下会被自动填充。

pop popToName 与 removeByName

出栈相关方法构成了 Navigation 栈管理的完整能力,这也是它相对 router 最实质的优势:

方法签名要点用途
poppop(animated?) / pop(result, animated?)弹出栈顶,可携带返回结果
popToNamepopToName(name, result?, animated?)弹到指定名字的页面,返回它所在的索引
popToIndexpopToIndex(index, result?, animated?)弹到指定层级
removeByNameremoveByName(name)只删除指定名字的页面,不影响其他层
removeByIndexesremoveByIndexes(indexes)批量按索引删除
replacePathreplacePath(info, animated?)用新页面替换栈顶
clearclear(animated?)清空栈,只保留根页面
moveToTopmoveToTop(name, animated?)把指定页面提到栈顶,不删其他

popToName 返回目标页面在栈中的索引,没找到时返回 -1。这个返回值很有用:可以据此判断「回退是否真的发生了」,没找到时再补一次 replacePath。

// components/DetailPage.ets
@Component
export struct DetailPage {
  @Consume('pageStack') pageStack: NavPathStack;
  @Prop param: DetailParam;

  build() {
    NavDestination() {
      Column({ space: 12 }) {
        Text(`id = ${this.param.id}`).fontSize(18)
        Button('popToName 回首页').onClick(() => {
          const index = this.pageStack.popToName('MainPage', { refreshed: true });
          if (index < 0) {
            this.pageStack.replacePath({ name: 'MainPage', param: {} });
          }
        })
        Button('只移除自己').onClick(() => { this.pageStack.removeByName('DetailPage'); })
      }
      .width('100%')
      .padding(16)
    }
    .title('详情')
    .mode(NavDestinationMode.STANDARD)
    .onBackPressed(() => {
      return false; // 返回 true 表示自行处理,阻止默认出栈
    })
    .onShown(() => { console.info('NavDestination onShown'); })
  }
}

NavDestination 是 Navigation 栈内页面的统一外壳,必须作为 @Component 的根节点,否则不会参与转场动画,标题栏也不会显示。它的 mode 有两种:NavDestinationMode.STANDARD(默认,入栈后铺满,可被上层页面覆盖)和 NavDestinationMode.DIALOG(以弹窗形式呈现,背景半透明,可配合 onBackPressed 实现点击外部关闭)。onShown / onHidden 是页面级可见性回调,对应 router 的 onPageShow / onPageHide;onWillShow / onWillHide(API 12)在动画开始前触发,适合做数据预取。

title 与 toolbar 与 menus

Navigation 和 NavDestination 各自有一套标题栏配置,层级关系是「子页面的配置覆盖父容器」:

Navigation(this.pageStack) {
  // 内容
}
.title('首页')                             // 字符串或 CustomBuilder
.titleMode(NavigationTitleMode.Mini)       // Mini 小标题 / Full 大标题
.menus([{ value: '搜索', icon: 'search.png', action: () => {} }])
.toolbarConfiguration([{ value: '首页', icon: 'home.png', action: () => {} }])
.hideTitleBar(false)

title 传 CustomBuilder 时,可以做出带副标题、带搜索框的自定义标题栏,这是 router 完全无法覆盖的场景。toolbarConfiguration 只对 NavigationMode.Split 下的侧边栏生效,Stack 模式下会被忽略。

customNavContentTransition 自定义转场

这是 Navigation 相对 router 最具决定性的一项能力。通过 customNavContentTransition 注册一个工厂函数,返回自定义的 NavigationTransition 对象,就能完全接管入栈、出栈、替换三种操作的动画:

import { FrameNode } from '@kit.ArkUI';

class SlideTransition implements NavigationTransition {
  private fromNode: FrameNode | undefined;
  private toNode: FrameNode | undefined;

  constructor(from: NavContentInfo, to: NavContentInfo) {
    this.fromNode = from.node;
    this.toNode = to.node;
  }

  transition(progress: number): void {
    // progress 由框架按帧驱动,取值 0 到 1
    this.toNode?.commonAttribute.translate({ x: 360 * (1 - progress) });
    this.fromNode?.commonAttribute.opacity(1 - progress * 0.3);
  }

  transitionEnd(): void {
    this.fromNode = undefined;
  }
}

// 注册处
Navigation(this.pageStack)
  .navDestination(this.pageMap)
  .customNavContentTransition((from: NavContentInfo, to: NavContentInfo,
    operation: NavigationOperation) => {
    if (operation === NavigationOperation.PUSH) {
      return new SlideTransition(from, to);
    }
    // 返回 undefined 时回落到系统默认转场
    return undefined;
  })

NavigationTransition 接口要求实现 transition(progress: number) 和 transitionEnd(),框架按帧回调 progress,你在里面改节点属性即可。NavContentInfo 提供 name、index、param、navDestinationId 和 node(FrameNode)。如果只是想微调系统转场的时长与缓动,可以在 transition 里配合 getUIContext().animateTo,而不是手写插值。

代价是性能与复杂度:自定义转场跑在主线程的每一帧上,节点属性改得太多会掉帧;transition 里的异常不会被框架吞掉,一旦抛错整个转场会卡在中途。

系统路由表方案

到 API 11 之前,Navigation 有个尴尬点:@Builder 的 pageMap 必须显式 import 每一个页面组件,导致首页文件成了所有页面的依赖汇聚点,编译期耦合严重,也拖慢冷启动。系统路由表解决了这个问题,做法分三步。

第一步,在 module.json5 里声明路由表文件:

{
  "module": {
    "name": "entry",
    "type": "entry",
    "routerMap": "$profile:route_map"
  }
}

第二步,在 src/main/resources/base/profile/route_map.json 里登记页面:

{
  "routerMap": [
    {
      "name": "DetailPage",
      "pageSourceFile": "src/main/ets/pages/DetailPage.ets",
      "buildFunction": "DetailPageBuilder",
      "data": { "description": "订单详情页", "needLogin": true }
    }
  ]
}

第三步,在页面文件里导出同名 @Builder 函数:

// pages/DetailPage.ets
@Builder
export function DetailPageBuilder() {
  DetailPage()
}

@Component
struct DetailPage {
  @Consume('pageStack') pageStack: NavPathStack;

  build() {
    NavDestination() {
      Text('详情页').fontSize(20)
    }
    .title('详情')
  }
}

配好之后,Navigation 上的 .navDestination(this.pageMap) 就可以删掉,框架会根据 name 自动从路由表加载对应页面。业务代码里依然写 pageStack.pushPathByName('DetailPage', param),但不再需要 import DetailPage。几个约束要记住:

  • buildFunction 的名字必须和文件里 export 的 @Builder 函数名完全一致。
  • 被路由表引用的页面文件,不能再被其他文件 import 后当作普通组件使用,否则会破坏懒加载收益,甚至导致重复注册。
  • data 字段(API 12 新增)可以挂业务元数据,运行时通过 NavPathInfo 或拦截器读取,适合做「该页面是否需要登录」这类前置校验。

最要紧的一条:router 的页面栈和 Navigation 的 NavPathStack 是两套完全独立的数据结构,互不可见。 用 router.pushUrl 进入的页面不会出现在 pageStack 里,反之亦然。由此衍生出几个具体后果:

  1. router.getLength() 不会把 Navigation 内的页面算进去,用它做「栈深保护」会失准。
  2. 在 Navigation 页面里调用 router.back(),可能直接退出应用而不是回到上一层 Navigation 页面。
  3. NavDestination 内的页面没有 onPageShow / onPageHide / onBackPress 这三个页面级钩子,必须改用 onShown / onHidden / onBackPressed。写惯了 router 的人最容易在这里踩空。

如果项目确实要迁移,推荐按模块切分而不是按页面切分:一个模块内部的页面要么全走 router,要么全走 Navigation,模块入口处用一次跳转做桥接。跨模块混用时,明确约定「Navigation 只作为顶层容器,router 只用于跨 Ability 跳转」,能规避绝大多数返回错乱。

页面跳转返回值与 pop 回调

router 的返回值模型是「上一页在 onPageShow 里读 getParams()」,属于拉模式,时机不精确。Navigation 提供了推模式:

// 调用方:注册 onPop 回调,页面被 pop 时触发
this.pageStack.pushPathByName('DetailPage', { id: '1002' },
  (popInfo: PopInfo) => {
    console.info(`result = ${JSON.stringify(popInfo.result)}`);
    console.info(`from = ${popInfo.info.name}`);
  });

PopInfo 有两个字段:info 是出栈页面的 NavPathInfo(含 name 和 param),result 是出栈时携带的结果对象,由出栈方通过 pop(result)、popToName(name, result) 或 popToIndex(index, result) 传入。用 pushPath 时,回调写在 NavPathInfo.onPop 上,效果等价。

两个关键约束:onPop 只在注册它的那次 push 所对应的页面实例出栈时触发,页面被 removeByName 移除、或整个栈被 clear 时回调不会触发;回调可能在转场动画中途执行,不要在里面对 UI 状态做「改了立刻重绘」的假设,稳妥做法是把结果写进 @State 或 AppStorage,让状态驱动刷新。

两种方案对照表

维度router 全局路由Navigation 加 NavPathStack
引入版本API 7 起,API 12 推荐改用 kit 导入Navigation API 8 起,NavPathStack API 10 起
栈管理仅 getState、getLength 两个只读接口pushPath、pop、popToName、removeByName、popToIndex 全可写
转场动画只有系统默认,不可定制customNavContentTransition 完全自定义,可回落默认
参数传递params 对象加 URL,getParams 一次性读取NavPathInfo.param,getParamByName 可按名反复读取
返回值back 携带 params,上一页在 onPageShow 拉取onPop 回调 PopInfo 推模式,pop 时携带 result
系统路由表不支持,路径写死在 main_pages.jsonmodule.json5 的 routerMap 加 route_map.json,无需 import
返回拦截仅 @Entry 页面的 onBackPressNavDestination.onBackPressed 加 setInterception
一多分栏无内建支持,需自行判断断点NavigationMode.Stack、Split、Auto 开箱即用
页面耦合路径字符串硬编码,无编译期保护组件引用加路由表,重命名有编译期报错
适用规模页面少于 10 个的轻量应用、快速原型中大型应用、需要自定义转场与分栏适配

权衡取舍

选型不是「新的就是好的」,要看三个约束。第一是迁移成本。 从 router 迁到 Navigation 不是替换几个 API 调用:每个目标页面要从 @Entry @Component 改成普通 @Component 加 NavDestination 根节点,onPageShow 要改成 onShown,getParams() 要改成 @Consume 加 getParamByName(),页面栈的创建与注入要重新梳理。一个 30 页的应用,工作量大致在 3 到 5 人日。

第二是性能特征。 Navigation 的 NavDestination 默认按需创建、出栈即销毁,内存占用比 router 的页面栈更可控;但自定义转场跑在主线程,动画期间如果有大量 @State 更新会掉帧。router 的页面栈由框架统一管理,转场期间开销相对固定。第三是团队认知。 router 的心智模型是「全局函数加页面路径」,新同学十分钟能上手;Navigation 需要理解 @Provide / @Consume 的注入链路、NavDestination 的渲染时机、路由表的加载顺序。如果团队规模小、迭代快、页面不复杂,坚持 router 是理性的。

比较务实的建议是:新项目直接用 Navigation,并且从第一天就用系统路由表,避免后期重构;存量项目按模块逐步迁移,先用 Navigation 包一层顶层容器,把 router 限制在跨 Ability 的场景里。

常见坑清单

  1. router 栈与 Navigation 栈不互通。 在 Navigation 页面里调 router.back() 可能直接退出应用。混用时必须明确「谁是当前栈的所有者」,并统一返回入口。
  2. NavPathStack 未用 @Provide 导致子组件拿不到。 子组件写 @Consume('pageStack') pageStack: NavPathStack,父级没有对应的 @Provide('pageStack') 时会报错或拿到 undefined。父子 key 必须逐字一致。
  3. pop 的参数类型。 pop(result, animated?) 的第一个参数是结果对象,第二个才是是否播动画。写成 pop(true) 会把 true 当结果传出去,调用方拿到的 popInfo.result 是布尔值而不是对象。
  4. Single 模式重复入栈导致中间页面消失。 RouterMode.Single 会把已存在的同名页面提到栈顶并弹出它上面的所有页面。做「防重复点击」不要用它,应该在按钮上加节流,或用 getState().name 先判断。
  5. 页面被回收后回调丢失。 onPop 绑定的是页面实例,实例被 removeByName 或 clear 移除后回调不会再触发。需要「无论怎么退出都能收到结果」的场景,改用 AppStorage 或 Emitter 传递。
  6. 系统路由表的 buildFunction 未导出。 route_map.json 里写的 buildFunction 必须在对应 .ets 文件里 export function,且被 @Builder 装饰。名字对不上时页面白屏,日志里往往只有一条不显眼的警告。
  7. 新增页面忘了注册 main_pages.json。 router 跳转抛 100002,且这个文件不会随文件重命名自动更新。
  8. 在 aboutToAppear 里读 router.getParams() 拿到旧值。 页面被 Single 模式复用时 aboutToAppear 不会重跑,参数更新要在 onPageShow 里读。Navigation 侧对应的问题是在 onShown 里重新 getParamByName。

小结

router 和 Navigation 不是替代关系,而是两个抽象层级:router 是「全局函数式导航」,Navigation 是「组件化导航容器」。判断标准很简单——如果应用需要自定义转场、需要按名字管理页面栈、需要在平板上有分栏布局,就选 Navigation,并且从项目第一天就用系统路由表;如果只是几个页面的轻量工具,router 的零心智负担依然是优势。

真正会出问题的从来不是选错方案,而是两套栈混着用。把「谁是当前页面栈的所有者」在架构文档里写清楚,比记住任何一个 API 都重要。跨端场景下,小程序的组件化导航设计与 Navigation 有不少可对照之处,可以参考 小程序组件化架构 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「鸿蒙开发」更多文章

  1. 鸿蒙 ohpm 包管理与 Hypium 测试框架
  2. ArkUI 动画体系与手势交互
  3. 鸿蒙应用安全:权限模型与 HUKS 密钥管理