本文所有代码都在真实的鸿蒙云手机和真实的华为云 ECS 上跑过,截图和数据来自实测,不是模拟器、不是伪造。

鸿蒙云手机运行任务接续应用

目录

一、为什么要做这件事

先说一个我踩过的坑。

去年做过一个「手机编辑到一半,换平板接着写」的需求。当时我们的做法很朴素:前端定时把草稿 POST 到服务器,换设备时再 GET 回来。

上线后问题全来了:

  • 用户在地铁里断网 30 秒,恢复后草稿回退到了 2 分钟前——因为轮询间隔是 30 秒
  • 两台设备同时在线时,后写的覆盖先写的,用户改的内容凭空消失
  • 服务器重启后,在线设备全部静默失联,用户以为还在同步,实际上消息全丢了

鸿蒙主打的就是「多设备协同」,分布式任务调度、流转、跨端迁移 这些能力看着很美。但真正落到自己的业务上,设备之间怎么传状态、断了怎么补、重了怎么去重,框架不会替你解决。

所以这次我不讲概念,直接用华为开发者空间的鸿蒙云手机搭一套真实可跑的任务接续服务,把数据摊开看。

二、环境清单

2.1 服务端:4 台华为云 ECS

角色规格内网 IP说明
ecs-00018vCPU/16GiB192.168.0.194Nginx + Node 集群 A + Redis
ecs-00028vCPU/16GiB192.168.0.162Node 集群 B
ecs-00038vCPU/16GiB192.168.0.148Node 集群 C
ecs-00048vCPU/16GiB192.168.0.35压测客户端

系统 Ubuntu 24.04,Node 20,Nginx 1.24 做 WebSocket 反向代理。

2.2 客户端:鸿蒙云手机

这是本次最关键的一环——不是模拟器,是真机云化:

设备型号:ALN-AL00(Mate 60 Pro)
系统版本:HarmonyOS 5.0.1.130(SP2C00E1R4P1log)
API 版本:17
分辨率:1260 × 2720
规格:8 vCPU / 12 GB

用 hdc shell param get 读到的原始输出:

ALN-AL00
5.0.1.130(SP2C00E1R4P1log)
17
phone

2.3 工具链

工具版本用途
hdspace CLI2.3.3配置 AK/SK、建云手机隧道
Command Line Tools6.0.1.251自带 hvigor 6.21.1 / ohpm 6.0.1 / hdc
HarmonyOS SDK6.0.1 (API 21)编译 HAP
JDK17.0.21hvigor 依赖

三、打通云手机:四步

第一步:配置 AK/SK

hdspace config
# 交互式输入 Access Key Id / Secret Access Key
# 注意:SK 需要输入两次确认

验证:

hdspace cloudphone list
+----+----------------------------------+-----------------+--------------------------+---------+
| NO |                ID                |      NAME       |           DESC           |  STATE  |
+----+----------------------------------+-----------------+--------------------------+---------+
| 1  | d06c4ad807b443f7806d668515680a9e | HMCPhone_l36urR | 8 vCPUs,12GB,2720x1260 | Running |
+----+----------------------------------+-----------------+--------------------------+---------+

第二步:建隧道

hdspace cloudphone start-tunnel --name=HMCPhone_l36urR --port=8712
client is listening on localhost:8712
connect to tunnel server success

第三步:hdc 连上去

hdc tconn 127.0.0.1:8712
hdc list targets
Connect OK
127.0.0.1:8712

第四步:编译并安装 HAP

export JAVA_HOME=/path/to/jdk-17
export DEVECO_SDK_HOME=/path/to/command-line-tools/sdk

hvigorw --mode module -p product=default -p module=entry@default assembleHap --no-daemon
> hvigor Finished :entry:default@CompileArkTS... after 8 s 79 ms
> hvigor Finished :entry:default@PackageHap... after 895 ms
> hvigor WARN: No signingConfig found for product default
> hvigor BUILD SUCCESSFUL in 18 s 320 ms

装到云手机:

hdc -t 127.0.0.1:8712 install entry-default-unsigned.hap
[Info]App install path:...entry-default-unsigned.hap msg:install bundle successfully.

注意:No signingConfig found 只是警告,云手机开发环境允许安装未签名 HAP,不用折腾证书。这一点省了我至少半小时。

四、客户端:ArkTS 任务接续实现

4.1 核心设计

三个要素:

  1. WebSocket 长连接:比轮询快,比 SSE 双向
  2. 消息序号 seq:每条任务态带自增号,用于检测缺口
  3. 影子(shadow):服务端保存每个设备最新状态,断线重连后拉一次就补齐

4.2 完整代码

import webSocket from '@ohos.net.webSocket';
import http from '@ohos.net.http';

interface TaskMsg {
  type: string;
  seq?: number;
  rid?: number;
  t0?: number;
  taskId?: string;
  target?: string;
  from?: string;
  node?: string;
  payload?: string;
  gap?: number;
}

@Entry
@Component
struct Index {
  @State server: string = 'ws://1.92.107.89/ws';
  @State deviceId: string = 'hm-phone-01';
  @State peerId: string = 'hm-tablet-01';
  @State connected: boolean = false;
  @State draft: string = '在手机上编辑到一半的文档';
  @State logs: string[] = [];
  @State sent: number = 0;
  @State recv: number = 0;
  @State reconnects: number = 0;
  @State rtt: number = 0;

  private ws: webSocket.WebSocket | null = null;
  private seq: number = 0;
  private pending: Map<number, number> = new Map();

  private log(s: string) {
    const line = `${new Date().toLocaleTimeString()} ${s}`;
    this.logs = [line, ...this.logs].slice(0, 60);
  }

  private connect() {
    if (this.connected) return;

    const url = `${this.server}?device=${this.deviceId}&type=phone`;
    this.ws = webSocket.createWebSocket();

    this.ws.on('open', () => {
      this.connected = true;
      this.log(`已连接 ${this.deviceId}`);
    });

    this.ws.on('message', (_err, data) => {
      try {
        const m = JSON.parse(data as string) as TaskMsg;

        // ACK 只用于算 RTT,不计入业务消息
        if (m.type === 'ack') {
          const t0 = this.pending.get(m.rid ?? -1);
          if (t0) {
            this.rtt = Date.now() - t0;
            this.pending.delete(m.rid ?? -1);
          }
          return;
        }

        this.recv++;
        const gapText = m.gap ? ` [缺口${m.gap}条->拉影子]` : '';
        this.log(`收到 seq=${m.seq} 来自=${m.from}${gapText}: ${m.payload}`);

        // 检测到缺口,主动补齐
        if (m.gap) {
          this.fetchShadow();
        }
      } catch (e) {
        this.log('消息解析失败');
      }
    });

    this.ws.on('close', () => {
      this.connected = false;
      this.reconnects++;
      this.log('连接关闭');
    });

    this.ws.on('error', () => {
      this.log('连接异常');
    });

    this.ws.connect(url, { header: {} }, (err) => {
      if (err) this.log(`连接失败 ${JSON.stringify(err)}`);
    });
  }

  private sendTask() {
    if (!this.ws || !this.connected) {
      this.log('未连接');
      return;
    }

    this.seq++;
    const rid = Date.now() % 1000000;
    this.pending.set(rid, Date.now());

    const msg: TaskMsg = {
      type: 'task_update',
      seq: this.seq,
      rid: rid,
      t0: Date.now(),
      taskId: 'task-' + this.seq,
      target: this.peerId,
      payload: this.draft
    };

    this.ws!.send(JSON.stringify(msg), (err) => {
      if (!err) {
        this.sent++;
        this.log(`发送 seq=${this.seq} -> ${this.peerId}`);
      } else {
        this.log('发送失败');
      }
    });
  }

  private fetchShadow() {
    const req = http.createHttp();
    const base = this.server.replace('ws://', 'http://').replace('/ws', '');
    req.request(`${base}/shadow/${this.deviceId}`, { method: http.RequestMethod.GET }, (err, data) => {
      if (!err) {
        this.log(`影子恢复: ${data.result}`);
      } else {
        this.log('影子拉取失败');
      }
      req.destroy();
    });
  }
}

module.json5 里必须申请网络权限,否则连接直接失败:

"requestPermissions": [
  { "name": "ohos.permission.INTERNET" }
]

4.3 服务端影子存储

// 收到任务态,写入 Redis 影子 + 广播给对端
async function onTaskUpdate(ws, msg) {
  const shadowKey = `shadow:${msg.target}`;
  await redis.set(shadowKey, JSON.stringify({
    task: msg.taskId,
    payload: msg.payload,
    ts: Date.now(),
    node: NODE_ID,
    state: 'online'
  }), 'EX', 300);

  broadcast(msg.target, {
    type: 'task_update',
    seq: msg.seq,
    from: msg.from,
    payload: msg.payload,
    gap: detectGap(msg)   // 检测序号缺口
  });
}

五、实测数据

5.1 首次连接:云手机 → 云服务器

点「连接」后,云手机控制台抓到:

12:16:36 已连接 hm-phone-01
12:16:41 收到 seq=272 来自=hm-tablet-01: 平板侧第272次编辑:第三章 分布式任务调度

5 秒内收到第一条消息,跨公网 WebSocket 建链 + 首包往返,这个延迟在可接受范围。

5.2 双向收发

连续点击 3 次「发送任务态到对端」:

发送 3   接收 9   断线 0   RTT 0ms

12:17:09 发送 seq=1 -> hm-tablet-01
12:17:12 发送 seq=2 -> hm-tablet-01
12:17:15 发送 seq=3 -> hm-tablet-01

发送正常。但 RTT 一直是 0ms——这是个真问题,后面第五节会讲。

5.3 故障演练:杀掉云手机正在连接的节点

这一步是重点。云手机通过 nginx 连到 8080 端口的 node 进程,我直接 kill -9:

kill -9 14623

云手机端立刻反应:

12:18:04 连接关闭
12:18:04 连接异常
发送 3   接收 17   断线 1   RTT 0ms

按钮从「已连接」回落到「连接」。

nginx 在 0.77 秒内把新连接切到了 8081 的备份节点,服务端是可用的。但客户端没有自动重连——用户必须手动再点一次「连接」。

手动重连后:

12:18:45 已连接 hm-phone-01
12:18:48 收到 seq=305 来自=hm-tablet-01 [缺口1条->拉影子]: 平板侧第305次编辑
12:18:49 影子恢复: {"task":"tablet-task-305","payload":"平板侧第305次编辑:第三章 分布式任务调度","ts":"1794673129006","node":"node2","state":"online"}

缺口检测 + 影子补齐生效了。断线期间漏掉的消息状态,通过一次影子拉取就恢复到最新,不用重放历史。

六、五个真实的坑

坑 1:hdspace 隧道会静默断线,且 hdc 会缓存死连接

表现:hdc list targets 返回空,hdc tconn 报 Connect failed,但 hdspace 日志还显示 connect to tunnel server success。

原因有两层:

  • 隧道进程本身可能已退出(Get-Process hdspace 查不到)
  • 即使隧道活着,hdc 客户端仍握着上一个死连接

解决办法:重连前必须先 hdc kill 清掉死连接,再 tconn:

def reconnect():
    subprocess.run([HDC, "kill"], capture_output=True)
    time.sleep(2)
    for _ in range(6):
        r = subprocess.run([HDC, "tconn", "127.0.0.1:8712"], capture_output=True, text=True)
        if "Connect OK" in (r.stdout + r.stderr):
            return True
        time.sleep(2)
    return False

顺序错了就永远连不上——这个坑我卡了将近 20 分钟。

坑 2:hvigor 报「工程结构需要升级」,实际是 modelVersion 缺失

错误信息很误导:

ERROR: 00303024 Configuration Error
The project structure and configuration need to be upgraded before use.

它建议你用 DevEco Studio 打开迁移。但命令行环境下没有 Studio。

真实原因是:hvigor/hvigor-config.json5 和根 oh-package.json5 两边都必须有 modelVersion,且值必须一致。

// hvigor/hvigor-config.json5
{
  "modelVersion": "5.0.0",
  "dependencies": {}
}
// oh-package.json5
{
  "name": "harmonyshadow",
  "version": "1.0.0",
  "modelVersion": "5.0.0",
  "dependencies": {}
}

只改一边,报错依旧是「需要升级」。我翻到 hvigor 的 validate-util.js 源码才确认这个校验逻辑。

坑 3:RTT 恒为 0 —— 服务端根本没回 ACK

代码里明明写了 ACK 处理逻辑,客户端 pending 也存了时间戳,但 RTT 就是 0。

查服务端发现:server.js 里根本没有 ack 的发送逻辑(grep -c ack server.js 返回 2,都是注释)。

这意味着:客户端发出去的消息,无法确认对端是否收到。在任务接续场景里这是致命的——用户以为「已同步」切了设备,结果对端根本没收到。

修法:服务端收到 task_update 后立即回一帧:

ws.send(JSON.stringify({ type: 'ack', rid: msg.rid }));

坑 4:缺口计数不准(实际丢 17 条,服务端只报 1)

看这段日志:

12:18:01 收到 seq=288
12:18:48 收到 seq=305 [缺口1条]

288 → 305,实际缺了 16 条(289~304),但 gap 字段报的是 1。

原因是服务端的缺口检测是基于单连接的 lastSeq 变量,而重连后这个变量被重置了,只和「重连后的第一条」比了一次。

正确做法是把 lastSeq 存到 Redis,按设备维度持久化:

async function detectGap(msg) {
  const key = `lastseq:${msg.from}`;
  const last = parseInt(await redis.get(key) || '0', 10);
  const gap = last > 0 ? Math.max(0, msg.seq - last - 1) : 0;
  await redis.set(key, msg.seq);
  return gap;
}

好在即使计数错了,行为是对的——只要 gap > 0 就会触发拉影子,最终状态仍然一致。这是「兜底机制救了统计错误」的典型案例。

坑 5:客户端不会自动重连

close 事件里只是把按钮状态改回「连接」,没有重试逻辑。生产环境必须加重连:

private retryDelay: number = 1000;

this.ws.on('close', () => {
  this.connected = false;
  this.reconnects++;
  this.log('连接关闭');
  // 指数退避重连,封顶 30 秒
  setTimeout(() => this.connect(), this.retryDelay);
  this.retryDelay = Math.min(this.retryDelay * 2, 30000);
});

对比服务端的平板模拟器,它就有 setTimeout(start, 2000) 的自动重连——同一个系统里,客户端反而比测试脚本脆弱,这在真实项目里太常见了。

七、实测截图证据

下面四张图全部来自云手机 hdc shell snapshot_display 实时抓屏,原始分辨率 1260×2720,未经裁剪。

1. 应用启动,界面渲染正常

云手机运行 com.example.harmonyshadow,1260×2720 竖屏,浅色主题:

应用启动界面

鸿蒙多设备任务接续
[ws://1.92.107.89/ws]
[hm-phone-01]  [hm-tablet-01]
[连接] [拉影子] [断开]
发送 0  接收 0  断线 0  RTT 0ms

页面结构:顶部标题、服务地址输入框、本端/对端设备 ID、三个操作按钮、收发统计栏、草稿编辑区、日志列表。全部由 ArkTS 声明式 UI 构建,没有引入任何三方 UI 库。

2. 连接成功,收到对端消息

连接成功并收到对端消息

已连接  发送 0  接收 1  断线 0  RTT 0ms
12:16:41 收到 seq=272 来自=hm-tablet-01: 平板侧第272次编辑:第三章 分布式任务调度

注意三点:

  • 按钮文案从「连接」变成「已连接」
  • 首条消息在点连接后 5 秒内到达
  • seq=272 是平板模拟器连续发送的第 272 条,说明服务端没有丢弃历史

3. 连续发送,双向通信正常

连续发送三条任务态

发送 3  接收 9  断线 0  RTT 0ms

12:17:09 发送 seq=1 -> hm-tablet-01
12:17:12 发送 seq=2 -> hm-tablet-01
12:17:15 发送 seq=3 -> hm-tablet-01

发送计数从 0 涨到 3,接收计数从 1 涨到 9,双向链路健康。但 RTT 始终是 0ms——这正是第六节坑 3 要说的:服务端没回 ACK。

4. 节点宕机,断线被瞬间检测到

kill -9 后断线检测

发送 3  接收 17  断线 1  RTT 0ms

12:18:04 连接关闭
12:18:04 连接异常

kill -9 打过去,云手机端在同一秒内就捕获到 close 和 error 两个事件,断线计数 +1。但按钮回落成了「连接」,说明没有自动重连逻辑。

5. 重连后检测缺口并拉取影子

重连后缺口检测与影子恢复

发送 3  接收 18  断线 1  RTT 0ms

12:18:45 已连接 hm-phone-01
12:18:48 收到 seq=305 来自=hm-tablet-01 [缺口1条->拉影子]
12:18:49 影子恢复: {"task":"tablet-task-305","payload":"平板侧第305次编辑:第三章 分布式任务调度","ts":"1794673129006","node":"node2","state":"online"}

这是整套机制真正生效的一屏:

  1. 序号从 288 直接跳到 305,客户端判定「有缺口」
  2. 自动发起 /shadow/hm-phone-01 请求
  3. 1 秒内拿回完整最新状态,node 字段显示已经落到 node2(备份节点)

即使缺口计数报了 1 条、实际丢了 16 条(见坑 4),最终状态依然一致——因为兜底逻辑只看 gap > 0,不依赖准确数量。这是「宽松触发 + 精确恢复」的设计,值得借鉴。

八、这套方案能扛多少

服务端压测数据(4 台 ECS,sysbench 风格自研压测客户端):

指标单进程4 进程 × 5000
连接数10,00020,000
建链耗时1.6 s3.4 s
消息总数55,000110,000
消息丢失00
P50 延迟202 ms143 ms
P95 延迟239 ms268 ms
P99 延迟—412 ms

单节点 3,300 连接时:CPU 1.6%–3.6%,内存约 100 MB。

结论:瓶颈不在服务端,在压测客户端。单进程 1 万连接时延迟被客户端自己拖住了,拆到 4 进程后 P50 反而降到 143 ms。这个结论和很多人的直觉相反。

九、总结

鸿蒙的「多设备任务接续」听起来是框架能力,实际落地要自己解决四件事:

问题方案本次实测结果
跨设备传状态WebSocket 长连接5 秒内建立并收到首包
能否送达ACK + rid未实现,RTT 恒 0ms
断线丢消息seq 序号 + 缺口检测检测成功,但计数偏小
补历史状态Redis 影子 + TTL一次拉取恢复最新态
节点故障nginx 多节点 + 客户端重连服务端 0.77s 切换,客户端需手动重连

三个最值得改的点,按优先级:

  1. 服务端补 ACK,否则「已同步」是假的
  2. 客户端加指数退避重连,否则节点一抖用户就要手动点
  3. 缺口计数落 Redis,否则监控数据不可信

云手机这条路是通的。hdspace + hdc + hvigorw 三件套跑起来之后,从改代码到装到云手机上看效果,整个链路不到 1 分钟(hvigor 增量编译约 18 秒,install 约 3 秒)。这比等真机快递、比模拟器卡顿要好太多。

下一篇我会把 Redis 哨兵和消息去重补上——这次实测里 node1 被杀后状态能恢复,靠的是单机 Redis;如果 Redis 挂了,影子就没了。那是下一个必须补的洞。

Logo

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

更多推荐