前言

很多 App 登录功能刚跑通时,都会先把 accessToken 存下来,然后每次请求自动带上。
这一版当然能用,但很快会遇到一个非常真实的问题:

  • token 过期了,用户还停留在业务页面
  • 下一次点按钮才发现接口 401
  • 页面突然退回登录页,体验很生硬

如果整个系统已经有 refreshToken 机制,那更好的方式不是“等接口报错”,而是:

在 token 快过期之前,App 主动去刷新,并把新的时效重新写回本地。

这次我在 注塑工程师助手 里做的,就是把登录态从“只记 access token”升级成“可主动续期的会话模型”。

最终这条链路分成四层:

  1. AuthSession.ets:把 token 时效建模清楚
  2. AuthApi.ets:登录与刷新接口统一解析
  3. AuthRepository.ets:前台检查、恢复检查、定时刷新都收在这里
  4. DemoStatePersistence.ets:把新的 token 和过期时间真正存下来

一、先明确问题:为什么只靠 401 兜底不够

很多项目一开始会这样处理:

  1. 登录成功后保存 accessToken
  2. 每次请求自动带 Authorization
  3. 某次接口返回 401
  4. 统一清空登录态,跳回登录页

这套方案并不算错,但它只有“被动处理”,没有“主动维护”。

问题在于,401 发生的时候,用户已经走到了业务操作那一步。
对工程师现场使用的 App 来说,这种时机通常不友好。

更稳的做法是让 App 自己知道:

  • 现在这张 token 什么时候过期
  • 距离过期还剩多久
  • 是否已经进入“该刷新”的时间窗口

只有把这三件事拿到手,主动刷新才有实现基础。

二、第一步:把 AuthSession 从“字符串”升级成“完整会话”

这次先改的是 AuthSession.ets

原来它只关心:

accessToken
tokenType
expiresIn
userId
displayName

现在补成这样:

export class AuthSession {
  accessToken: string;
  refreshToken: string;
  tokenType: string;
  expiresIn: number;
  expiresAt: number;
  userId: string;
  displayName: string;
}

这里有两个新增点非常关键:

  1. refreshToken:后续主动续期时真正要用的凭证
  2. expiresAt:绝对过期时间戳,方便本地直接判断是否快过期

相比只保存 expiresInexpiresAt 更适合 App 端本地决策。
因为 expiresIn 只是“登录成功后有效多少秒”,而 expiresAt 是“这张 token 到底在哪一刻失效”。

三、为什么我在模型里顺手加了三个方法

为了不让 Repository 到处自己算时间,这次把会话本身做成了一个“可判断时效”的对象:

hasRefreshToken(): boolean {
  return this.refreshToken.length > 0;
}

willExpireWithin(bufferMs: number): boolean {
  if (this.expiresAt <= 0) {
    return false;
  }
  return Date.now() + Math.max(0, bufferMs) >= this.expiresAt;
}

refreshDelayMs(bufferMs: number): number {
  if (!this.hasRefreshToken() || this.expiresAt <= 0) {
    return -1;
  }
  return Math.max(0, this.expiresAt - Math.max(0, bufferMs) - Date.now());
}

这三个方法分别解决三个问题:

  1. 当前会话有没有 refresh token
  2. 当前会话是否已经进入“快过期”窗口
  3. 距离应该触发刷新还剩多少毫秒

这样做的好处是,时间计算逻辑集中在一个地方,不会散落到登录页、Ability 或请求层。

四、第二步:登录接口和刷新接口统一走一套解析

既然现在会话字段变复杂了,就不应该让 login()refresh() 各写一套解析。

我在 AuthApi.ets 里把它们统一收到了 mapSession()

private mapSession(data: JsonRecord, fallbackUserId: string = ''): AuthSession {
  const userNode: JsonRecord = this.readRecord(data['user']);
  const displayName: string = this.resolveDisplayName(userNode, fallbackUserId);
  const expiresIn: number = this.resolveExpiresIn(data);
  const expiresAt: number = this.resolveExpiresAt(data, expiresIn);
  return new AuthSession(
    this.readString(data['access_token'], this.readString(data['accessToken'])),
    this.readString(data['refresh_token'], this.readString(data['refreshToken'])),
    this.readString(data['tokenType'], 'Bearer'),
    expiresIn,
    expiresAt,
    this.readStringLike(userNode['userId'], this.readString(userNode['username'], fallbackUserId)),
    displayName
  );
}

这个写法做了两件很实用的事:

  1. 同时兼容下划线和驼峰字段
    例如 access_token / accessTokenrefresh_token / refreshToken
  2. 把登录和刷新统一收口
    后面如果后端字段名有微调,只需要改这一层

对于需要快速出文章、后面又可能继续联调调整的项目来说,这种“集中解析”会很省事。

五、expiresAt 怎么来:优先吃后端绝对时间,没有再自己推

很多后端只返回:

{
  "access_token": "...",
  "refresh_token": "...",
  "expiresIn": 7200
}

也有些后端会直接给:

{
  "expiresAt": 1796112000000
}

所以这次 resolveExpiresAt() 的策略是:

  1. 先找显式过期时间
  2. 再兼容几种常见命名
  3. 如果都没有,再用 Date.now() + expiresIn * 1000

实现如下:

private resolveExpiresAt(data: JsonRecord, expiresIn: number): number {
  const explicitExpiresAt: number = this.readTimestampMillis(data['expiresAt']);
  if (explicitExpiresAt > 0) {
    return explicitExpiresAt;
  }
  const fallbackExpireAt: number = this.readTimestampMillis(
    data['expireAt'],
    this.readTimestampMillis(
      data['expireTime'],
      this.readTimestampMillis(
        data['accessTokenExpiresAt'],
        this.readTimestampMillis(data['expires_at'])
      )
    )
  );
  if (fallbackExpireAt > 0) {
    return fallbackExpireAt;
  }
  if (expiresIn > 0) {
    return Date.now() + expiresIn * 1000;
  }
  return 0;
}

这样做的意义是,接口联调时不至于因为字段命名不完全一致,就把整条登录链路又拆开重写一遍。

六、第三步:刷新接口路径单独提出来,后面改最方便

我这次没有把刷新路径直接写死在 AuthApi 里,而是提到了配置文件:

export const APP_API_AUTH_REFRESH_PATH: string = '/api/auth/refresh';

然后刷新方法这么写:

async refreshSession(refreshToken: string): Promise<AuthSession> {
  const requestPayload: RefreshTokenRequestPayload = new RefreshTokenRequestPayload(refreshToken, APP_API_TENANT_ID);
  const data: JsonRecord = await this.postPublicRecord(APP_API_AUTH_REFRESH_PATH, requestPayload);
  return this.mapSession(data);
}

这里故意保留了两个“好改点”:

  1. 路径常量独立
  2. 请求体结构独立

如果后端实际接口不是 /api/auth/refresh,或者字段叫 refresh_token,都可以在这一层小范围调整,不用去碰页面和仓储层。

七、真正的核心:主动续期为什么应该放在 AuthRepository

主动刷新不是网络层的职责,也不应该散在页面里。

它最适合放在 AuthRepository.ets,因为这里只有它同时知道:

  • 当前 token 是什么
  • refresh token 是什么
  • 当前用户是否已登录
  • 本地状态要不要写回
  • 刷新失败后要不要退出登录

这次我在仓储层里定义了两个关键常量:

const TOKEN_REFRESH_ADVANCE_MS = 5 * 60 * 1000;
const TOKEN_REFRESH_MIN_DELAY_MS = 1500;

意思很直接:

  1. 提前 5 分钟进入刷新窗口
  2. 定时器最短延迟 1.5 秒,避免刚恢复就高频抖动

八、登录成功后,不只是保存 token,还要立刻安排下一次刷新

这次 setSession() 的职责也升级了。

以前它只做“写入登录态”,现在还要顺手做两件事:

  1. 更新请求层使用的 access token
  2. 根据 expiresAt 安排下一次主动刷新

核心逻辑如下:

private setSession(session: AuthSession, user: AppUser, persist: boolean, operationId?: number): void {
  this.currentSession = session;
  this.signedInUser = user;
  appHttpClient.setAccessToken(session.accessToken);
  AppStorage.setOrCreate('authAccessToken', session.accessToken);
  AppStorage.setOrCreate('authRefreshToken', session.refreshToken);
  AppStorage.setOrCreate('authAccessTokenExpiresAt', session.expiresAt);
  this.scheduleRefresh(session);
  ...
}

也就是说,从这一刻开始,登录态已经不再是一个静态字符串,而是一份带自我续期计划的会话。

九、定时器续期怎么做:只算一次,不在页面里循环

很多人第一反应会是“开个轮询,每分钟检查一次”。
这当然也能做,但没有必要。

我这里更偏向一次性定时:

private scheduleRefresh(session: AuthSession): void {
  this.clearRefreshTimer();
  const delayMs: number = session.refreshDelayMs(TOKEN_REFRESH_ADVANCE_MS);
  if (delayMs < 0) {
    return;
  }
  const safeDelayMs: number = Math.max(TOKEN_REFRESH_MIN_DELAY_MS, delayMs);
  this.refreshTimer = setTimeout(() => {
    this.refreshTimer = undefined;
    void this.refreshSessionIfNeeded('timer', true);
  }, safeDelayMs);
}

这个方案的优点很明显:

  1. 不需要页面层参与
  2. 不需要固定频率轮询
  3. 每次拿到新 token 后,再重新计算下一次刷新时间

这样逻辑更干净,也更省资源。

十、除了定时器,为什么前台恢复也要主动检查一次

仅靠定时器还不够,因为 App 可能会切到后台很久,再回来。

所以这次我又在 EntryAbility.ets 里补了一个前台入口:

onForeground(): void {
  void authRepository.refreshSessionIfNeeded('foreground');
}

这一步非常实用。

因为用户从后台切回来时,App 不应该等他点到具体业务按钮后才发现 token 已经过期。
更自然的行为是:刚回到前台时,先快速判断一下当前会话是否已经进入刷新窗口。

这也是“主动刷新”最容易被忽略,但体验差异很明显的一个点。

十一、恢复历史登录态时,为什么要先判断是否该刷新

App 冷启动恢复历史会话时,也不能直接无脑拿旧 token 去调 users/me

因为这张 token 可能已经快过期,甚至已经过期了。

所以我在 restoreSessionSnapshot() 里增加了预处理:

private async prepareRestoredSession(
  accessToken: string,
  refreshToken: string,
  expiresAt: number,
  operationId: number
): Promise<AuthSession> {
  const restoredSession: AuthSession = new AuthSession(accessToken, refreshToken, 'Bearer', 0, expiresAt, '', '');
  appHttpClient.setAccessToken(restoredSession.accessToken);
  if (!restoredSession.hasRefreshToken() || !restoredSession.willExpireWithin(TOKEN_REFRESH_ADVANCE_MS)) {
    return restoredSession;
  }
  return this.requestRefreshedSession(restoredSession, operationId);
}

这样启动恢复链路就变成了:

  1. 读本地 accessToken / refreshToken / expiresAt
  2. 如果没进入刷新窗口,继续恢复用户信息
  3. 如果快过期了,先刷新 token
  4. 刷新成功后再去调 users/me

这个顺序会比“先请求、失败再退登录”平滑得多。

十二、本地持久化必须一起升级,不然主动刷新没有意义

如果主动刷新后,新的 token 只存在内存里,那 App 一旦重启,前面的刷新成果就丢了。

所以 DemoStatePersistence.ets 也同步加了两个 key:

const AUTH_REFRESH_TOKEN_KEY = 'auth_refresh_token_v1';
const AUTH_ACCESS_TOKEN_EXPIRES_AT_KEY = 'auth_access_token_expires_at_v1';

保存时也一起写入:

private saveAuthSession(loggedIn: boolean, userId: string, accessToken: string, refreshToken: string, expiresAt: number): void {
  store.put(AUTH_LOGGED_IN_KEY, loggedIn)
    .then(() => store.put(AUTH_USER_ID_KEY, userId))
    .then(() => store.put(AUTH_ACCESS_TOKEN_KEY, accessToken))
    .then(() => store.put(AUTH_REFRESH_TOKEN_KEY, refreshToken))
    .then(() => store.put(AUTH_ACCESS_TOKEN_EXPIRES_AT_KEY, expiresAt))
    .then(() => store.flush())
}

恢复时也不是只恢复 access token,而是整份会话快照一起恢复。

这一步很关键,因为主动刷新本质上是在维护“登录态时效”,而不是只维护某一个字符串。

十三、401 还要不要保留兜底

要,而且应该保留。

虽然这次我们把逻辑前移了,但现实里还是可能出现:

  • token 被服务端提前吊销
  • 设备时间异常
  • refresh token 自身失效
  • 多端登录触发旧 token 作废

所以 401 的兜底不能删,只是它不再是主要策略。

这次 handleUnauthorized() 的处理就改成了:

private handleUnauthorized(): void {
  if (this.refreshingSession) {
    return;
  }
  const currentSession: AuthSession | undefined = this.currentSession;
  if (currentSession !== undefined && currentSession.hasRefreshToken()) {
    void this.refreshSessionIfNeeded('unauthorized', true);
    return;
  }
  const operationId: number = this.beginOperation();
  this.clearSession(true, operationId);
}

也就是说:

  1. 先主动刷新
  2. 刷新也不行,再退出登录

这样 401 从“第一反应”退回成“最后兜底”,整条体验会稳很多。

十四、前后端接口怎么约定

后端接口按下面这个返回:

登录接口

{
  "code": 0,
  "data": {
    "access_token": "xxx",
    "refresh_token": "yyy",
    "tokenType": "Bearer",
    "expiresIn": 7200,
    "user": {
      "userId": "13718509651",
      "realName": "张工"
    }
  }
}

刷新接口

{
  "code": 0,
  "data": {
    "access_token": "new_xxx",
    "refresh_token": "new_yyy",
    "tokenType": "Bearer",
    "expiresIn": 7200
  }
}

十五、总结

这次主动刷新 Token 的改造,本质上做了四件事:

  1. 把登录态从“只存 access token”升级为完整会话模型
  2. 把登录和刷新接口统一收进 AuthApi
  3. 把前台恢复、启动恢复、定时续期三条路径统一收进 AuthRepository
  4. 把新的 token 时效真正写回本地持久化

这样做完之后,App 的登录体验会从:

  • 等接口 401
  • 被动清空登录态

升级成:

  • 快过期先主动刷新
  • 成功后静默续期
  • 失败时再统一退出

对于需要长期停留在业务页、又经常来回切前后台的工业 App 来说,这一步非常值。

附录:工程配置与版本说明

为了便于读者复现本文中的代码片段和运行现象,这里把当前文章系列对应的工程基线单独列出。本文所说的“当前工程”,指 e_notebook 项目的 HarmonyOS ArkTS 客户端,应用名称为“注塑工程师助手”,主要用于脱敏演示机台档案、产品档案、调机记录、参数模板、异常闭环、生产批次和看板报表等业务路径。

1. 应用与模块配置

  • 应用包名:com.atan.enotebook
  • 应用版本:versionName1.0.0versionCode1000000
  • 工程模型:ArkTS / ArkUI Stage 模型。
  • 主模块:entry,模块类型为 entry
  • 入口 Ability:EntryAbility,入口文件为 entry/src/main/ets/entryability/EntryAbility.ets
  • 主页面配置:模块通过 pages: "$profile:main_pages" 读取页面列表。
  • 设备类型:当前模块声明支持 phonetablet2in1
  • 安装方式:deliveryWithInstalltrueinstallationFreefalse,属于随应用安装的普通 entry 模块。

2. SDK 与 API 版本

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

  • DevEco Studio 版本:DevEco Studio Beta 26.0.0.461
  • 编译 SDK:HarmonyOS SDK API 26 Beta1,SDK 包版本为 26.0.0.23
  • SDK 平台信息:apiVersion26platformVersion26.0.0releaseType / stageBeta1
  • targetSdkVersion26.0.0
  • compatibleSdkVersion6.1.1(24)
  • API 口径说明:文章系列以 API 24 作为兼容目标进行表述;当前工程实际由 API 26 Beta SDK 编译,并在 API 24 模拟器上做过安装、启动和交互观察。因此,文中的“API 24 运行观察”表示兼容目标环境下的模拟器验证结果,不等同于使用 API 24 SDK 重新完成编译验证。

3. 构建与运行工具

  • 开发工具 IDE:DevEco Studio Beta,安装目录指向 D:/Program Files/Huawei/DevEco Studio Beta
  • SDK 路径:D:/Program Files/Huawei/DevEco Studio Beta/sdk
  • 构建系统:Hvigor,工程入口 hvigorfile.ts 使用 @ohos/hvigor-ohos-pluginappTasks
  • Hvigor 执行配置:开启 daemon、incremental、parallel 和 typeCheck,日志级别为 info
  • 构建脚本:本地 build.ps1 优先使用 DevEco Studio 自带的 JBR、Node.js、SDK 与 Hvigor,避免系统环境变量中的 Java 或 Node.js 版本干扰构建结果。
  • 调试产物:未配置签名时,本地构建生成 entry/build/default/outputs/default/entry-default-unsigned.hap。这类 unsigned HAP 只用于本地调试和模拟器验证,正式发布前需要在 DevEco Studio 中补充签名配置。

4. 本系列文章的验证边界

  • 本系列代码以脱敏演示数据为主,Repository、Store、页面状态和组件边界都围绕本地演示闭环展开。
  • 已观察过的运行现象以文中对应截图、布局树和人工核对记录为准;没有重新核对的页面,不在单篇文章中扩大为完整结论。
  • 如果读者使用更新的 DevEco Studio、HarmonyOS SDK 或真机系统版本复现,API 差异、控件行为和签名流程可能会发生变化。遇到差异时,建议优先核对 build-profile.json5module.json5、SDK Manager 中安装的 API 版本,以及当前设备或模拟器的系统 API 等级。

附录 2:项目目录结构与设计意图

下面这份目录说明对应当前 DevEco Studio 中打开的 harmonyos-app 工程。截图里能看到的目录并不只是文件摆放习惯,它反映了一个 ArkTS Stage 工程的分层方式:应用级配置、业务模块、页面源码、资源文件、构建配置和过程归档分别放在不同位置,方便后续排查问题时先判断“问题属于配置、页面、数据、状态、资源,还是构建产物”。
在这里插入图片描述

harmonyos-app/
├── AppScope/                         # 应用级配置与全局资源入口
│   ├── app.json5                     # bundleName、版本号、图标、应用标签等应用级元信息
│   └── resources/                    # 应用级图标、字符串和基础资源
├── entry/                            # 主业务模块,当前 App 的主要页面和业务代码都在这里
│   ├── src/main/ets/                 # ArkTS 源码根目录
│   │   ├── components/               # 可复用 ArkUI 组件,如底部导航、数据状态面板
│   │   ├── entryability/             # Stage 模型入口 Ability,负责应用启动入口
│   │   ├── features/                 # 按业务域拆分的功能页面
│   │   │   ├── debug/                # 调机记录相关页面
│   │   │   ├── exceptions/           # 异常处置与闭环相关页面
│   │   │   ├── home/                 # 首页看板与概览入口
│   │   │   ├── machines/             # 机台档案列表、详情和机台相关交互
│   │   │   ├── production/           # 生产批次、报工和结案门禁相关页面
│   │   │   ├── products/             # 产品档案、产品详情和关联信息
│   │   │   ├── reports/              # 周报、月报、班次报表和下钻入口
│   │   │   └── templates/            # 参数模板列表与详情
│   │   ├── models/                   # 业务对象的数据结构,如 Machine、Product、DebugRecord
│   │   ├── pages/                    # 页面容器与导航装配,如 Index.ets
│   │   ├── repositories/             # 脱敏演示数据、查询方法、快照持久化和数据重置边界
│   │   ├── stores/                   # 页面路由、导航选择和共享状态规则
│   │   └── utils/                    # 主题令牌、校验函数等通用工具
│   ├── src/main/resources/base/      # 模块级资源目录
│   │   ├── element/                  # 字符串、颜色等基础资源声明
│   │   ├── media/                    # 图标、启动图等媒体资源
│   │   └── profile/                  # 页面 profile 配置,如 main_pages.json
│   ├── src/main/module.json5         # entry 模块配置,声明 EntryAbility、设备类型和页面入口
│   ├── build-profile.json5           # 模块级构建目标、混淆和 target 配置
│   └── oh-package.json5              # entry 模块包信息与依赖声明
├── hvigor/                           # Hvigor 构建系统配置
│   └── hvigor-config.json5           # 构建执行参数,如增量、并行和类型检查
├── build-profile.json5               # 工程级 SDK、targetSdkVersion、compatibleSdkVersion 配置
├── hvigorfile.ts                     # 工程级构建任务入口,接入 appTasks
├── local.properties                  # 本机 SDK 路径配置
├── oh-package.json5                  # 工程级包信息与依赖声明
├── build.ps1                         # 本地构建脚本,固定使用 DevEco Studio 自带工具链
├── document_claude/                  # 开发过程归档、测试记录和验证材料
├── .hvigor/                          # Hvigor 生成的缓存和构建记录,不作为手写源码维护
├── .idea/                            # DevEco Studio / IntelliJ 工程配置,不承载业务逻辑
└── entry/build/                      # 构建输出目录,HAP 和中间产物由构建流程生成

1. 为什么应用级配置放在 AppScope

AppScope 负责应用整体身份,而不是某个页面的业务逻辑。app.json5 中的 bundleNameversionNameversionCode、应用图标和应用标签,会影响安装包身份、桌面展示和版本识别。把这类配置放在应用级目录,可以避免业务页面为了改一个标题或图标而混入应用发布配置。

在当前工程中,AppScope 更像“应用身份证”。它回答的是“这个 App 是谁、版本是多少、展示什么图标”,而不是“机台列表怎么筛选、详情页怎么返回”。

2. 为什么业务代码集中在 entry/src/main/ets

entry 是当前工程的主业务模块,src/main/ets 是 ArkTS 源码根目录。截图里打开的 MachineDetail.ets 就位于 features/machines 下面,说明机台详情页被归入“机台业务域”,而不是随意放在全局页面目录中。

这种组织方式的好处是定位明确:机台问题优先看 features/machines,产品问题优先看 features/products,生产批次问题优先看 features/production。当文章里讨论某个业务链路时,读者也能从目录直接反推代码位置。

3. componentsfeaturespages 的边界

components 放的是可复用组件,例如底部导航、加载/空态/失败态面板。它们不应该直接知道“当前打开的是哪台机台”,而是通过参数和回调服务于不同页面。

features 放的是业务域页面。每个子目录都围绕一个业务主题组织,例如 machines 负责机台档案,templates 负责参数模板,exceptions 负责异常闭环。业务页面可以组合组件,也可以读取模型和仓储,但应尽量把本业务域的显示和交互留在本目录内。

pages 更偏页面容器和入口装配。当前 Index.ets 承担主页面状态切换、底部导航和详情路径分发等职责。它不应该塞满所有业务细节,而是负责把用户当前所在位置、打开对象和页面分支组织起来。

4. modelsrepositoriesstores 分别解决什么问题

models 定义数据形状,例如机台、产品、调机记录、生产批次等对象有哪些字段。它让页面和仓储使用同一套类型语言,避免每个页面临时拼对象。

repositories 定义数据来源和查询边界。当前工程使用脱敏演示数据和本地持久化快照,因此仓储层负责“从哪里取数据、按什么 ID 查询、怎样重置演示数据”。页面不直接关心数据是内置数组、Preferences 快照,还是后续真实接口。

stores 定义页面级或应用级状态规则,例如当前导航项、路由分支、打开详情的类型和 ID。把状态规则从具体组件中抽出来,可以减少“列表、详情、导航互相覆盖状态”的问题。

5. 为什么资源放在 resources/base

resources/base/element 管字符串、颜色等声明,resources/base/media 管图标和图片,resources/base/profile 管页面 profile。它们和 ArkTS 页面代码分开,是为了让“界面逻辑”和“静态资源”各自清晰。

如果页面显示异常,先判断是布局代码问题还是资源引用问题。比如图标不显示,应优先检查 media 和资源引用;页面无法进入,应检查 profile/main_pages.jsonmodule.json5 的页面声明;颜色或字符串不符合预期,则回到 element 下核对。

6. 构建目录和生成目录不要手工维护

.hvigorentry/build 和部分中间产物目录由构建系统生成,主要用于缓存、编译记录、HAP 输出和临时文件。它们可以帮助排查构建结果,但不应该作为手写业务代码维护。

当前调试 HAP 位于 entry/build/default/outputs/default/entry-default-unsigned.hap。这个路径说明构建已经产出安装包,但它仍是 unsigned 调试产物;正式发布前应回到 DevEco Studio 的签名配置和发布流程,而不是直接修改 build 目录里的文件。

在这里插入图片描述

Logo

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

更多推荐