如果你手里正好有一台纯血鸿蒙设备,又需要一个"能立刻跑起来、而且能和 PC、其它手机互相收发消息"的聊天 Demo,这篇内容就是你照着做就能跑通的完整记录。所谓纯血鸿蒙,指的是不再兼容安卓 APK 的那一代系统,应用只能用 ArkTS/ArkUI 这一套技术栈从零写起,没有老的混合开发退路。我之所以选聊天作为练手项目,是因为它麻雀虽小五脏俱全:要解决设备连接、消息协议、服务端转发、UI 状态刷新、断线重连这一整条链路。这套链路一旦跑通,后面做遥控器、白板协作、设备面板都只是换皮。文章按我实际推进的顺序来讲:先说架构为什么选 WebSocket,再写服务端、鸿蒙端、浏览器端各自怎么实现,最后是跨端联调时最容易踩的几个坑。代码不是玩具代码,是能直接跑的最小可靠版本。
1. 先把架构想清楚:一条消息从 A 设备到 B 设备的完整路径
目标很简单:一台鸿蒙设备上运行的 App,和 PC 浏览器、另一台手机之间能互相发文本消息。这里有个前提必须说清楚——两个设备之间没有天然直连通道,它们各自在内网里,即使连在同一个路由器下,也不应该靠点对点打洞或者裸 Socket 互连。所以无论怎么设计,中间都需要一个"中转站"。中转站的角色很纯粹:维护谁在线、把消息转发给该拿到的设备、处理连接断开。
协议选型上我对比过四类方案,这里直接说结论:聊天互通场景用 WebSocket 是最平衡的。
- PC 端零安装。浏览器原生就有 WebSocket 客户端,打开一个 HTML 页面就能收发,不需要装任何插件。
- 鸿蒙端有原生 API。ArkTS 里可以直接创建 WebSocket 连接,TCP 握手、帧解析这些底层细节不用自己碰。
- 基于文本帧传输,天然适合 JSON 消息。一条聊天消息本质就是一个对象,序列化成 JSON 字符串,一个消息一个帧。
- 连接是双向的。服务端收到消息能立刻推给其它客户端,不需要像 HTTP 那样靠轮询"拉",实时性有保证。
为什么不选其它方案?UDP 虽然快,但浏览器没有原生 UDP 能力,PC 端要装插件,Demo 直接变重;MQTT 确实是为物联网设计的,有主题订阅、QoS 等级这些好东西,但聊天 Demo 要额外搭 broker,配置成本不低;原生 TCP Socket 在鸿蒙端没问题,可 PC 浏览器同样不支持。WebSocket 是唯一一个三端通吃的公共协议,选型上没什么好犹豫的。
1.1 服务端职责边界:转发、心跳、在线名单
服务端不要把业务逻辑做进去,它会慢慢变成一团浆糊。在这个 Demo 里,我只让它干四件事:
- 接收客户端的注册信息,拿到用户 ID,维护在线名单;
- 收到 chat 消息时,原样转发给其它客户端;
- 响应心跳 ping,让客户端知道连接还活着;
- 连接断开时,把该用户从在线名单里移除。
这样一个中间层的好处是,客户端怎么换都无所谓。你把鸿蒙 App 换成安卓 App,或者把浏览器页面换成命令行工具,服务端一行代码都不用动。这也意味着聊天业务的核心逻辑可以全部放在客户端,方便后面加功能。
1.2 消息格式一上来就定死:JSON 字段说明
协议字段我建议一上来就定死,不要边写边改,否则服务端、鸿蒙端、Web 端三处都要跟着动。我用的是下面这套 JSON:
{"type": "chat", "from": "dev-a", "to": "all", "content": "你好", "time": 1712300000000, "id": "msg-0001"} {"type": "register", "userId": "dev-a"} {"type": "online", "online": ["dev-a", "dev-b", "web-fang"]} {"type": "ping"} {"type": "pong"}type 字段区分消息种类,from 是发送者 ID,to 是接收方,"all" 表示广播,content 是文本内容,time 是服务端收到消息时的毫秒时间戳,id 是消息唯一标识。这里有个很重要的经验:时间戳一定要用服务端生成,不要用各设备的本地时钟。不同设备的时钟可能差几分钟,如果 UI 按客户端时间排序,会出现手机发出的消息排到别人前面这种诡异现象。排序时以服务端时间为准,一切就都平滑了。
2. 服务端:一个一百多行的 Node.js 中转站
服务端我用 Node.js 加 ws 库实现,没有任何框架依赖。为什么选 Node.js?它的异步事件模型和 WebSocket 长连接场景天生匹配,一个进程撑住几百个测试连接毫无压力,而且 ws 库几乎零配置,装了就能用。先初始化一个 npm 项目:
npm init -y npm install ws这里我建议把 HTTP 服务和 WebSocket 服务合在同一个端口上,额外加一个/health健康检查接口。这个接口在联调排错时极其有用——设备连不上时,先让它在浏览器里访问这个地址,能通就说明网络层没问题,问题在 WebSocket 握手;不通就直接定位到网络配置,省去很多猜疑。
// server.js const http = require('http'); const { WebSocketServer } = require('ws'); const server = http.createServer((req, res) => { if (req.url === '/health') { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('ok'); return; } res.writeHead(404); res.end(); }); const wss = new WebSocketServer({ server, path: '/ws' }); // 连接表:userId -> WebSocket 实例 const clients = new Map(); function broadcast(obj, except) { const text = JSON.stringify(obj); clients.forEach((ws, userId) => { if (userId !== except) { ws.send(text); } }); } function broadcastOnline() { const online = [...clients.keys()]; broadcast({ type: 'online', online }); } wss.on('connection', (ws) => { // 先给一个临时 ID,等待客户端通过 register 上报真实 ID let userId = 'tmp-' + Date.now().toString(36); clients.set(userId, ws); ws.on('message', (raw) => { let msg; try { msg = JSON.parse(raw.toString()); } catch (e) { return; } switch (msg.type) { case 'register': if (msg.userId) { clients.delete(userId); userId = msg.userId; clients.set(userId, ws); ws.send(JSON.stringify({ type: 'registered', self: userId })); broadcastOnline(); } break; case 'chat': if (msg.content && msg.content.trim()) { broadcast({ type: 'chat', id: 's-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6), from: userId, to: msg.to || 'all', content: msg.content.slice(0, 2000), time: Date.now() }); } break; case 'ping': ws.send(JSON.stringify({ type: 'pong' })); break; default: break; } }); ws.on('close', () => { clients.delete(userId); broadcastOnline(); }); ws.on('error', () => { clients.delete(userId); }); }); server.listen(8080, '0.0.0.0', () => { console.log('服务已启动: http://0.0.0.0:8080/health ws://0.0.0.0:8080/ws'); });2.1 连接管理:用 Map 记录在线客户端
clients 用 Map 而不是数组,因为 Map 天然支持"按键取值、按键删值"。每个连接创建时先存一个临时 ID,客户端随后发 register 消息上报真实 ID,这时把旧的临时键删除、用真实 ID 重新登记。这个设计避免了一个经典问题:客户端注册消息还没到、连接就断开的间隙,服务器无从知道该清理谁。
2.2 路由与广播:谁的消息发给谁
这个版本只做广播:任何 chat 消息都发给所有人,包括发送者自己。让发送者也能收到自己的回显消息,客户端就不用做"本地回显"了,消息的展示顺序完全由服务端控制,天然统一。如果你后面要做单聊,只需要在广播前判断msg.to,从 clients 里找到目标用户单独发一条就行,路由逻辑是现成的。
2.3 心跳与断开清理:别让僵尸连接占着坑
HTTP 之外的长连接最怕网络中间设备静默掐断。比如你连着公司 WiFi 或者手机热点,网关可能在 60 秒内没有流量就把连接关了,但客户端和服务端都不知道。所以客户端每 30 秒发一个 ping,服务端回 pong,双方能确认连接还活着。如果服务端在 close 事件里没收到通知(比如客户端所在网络戏剧性中断),下次心跳就能暴露问题。
3. 鸿蒙端工程准备:从新建工程到拿到联网权限
服务端跑通之后,开始写鸿蒙端。这里我默认你用的是 HarmonyOS NEXT 的开发套件,也就是新一代 IDE 配合新的 Kit 导入方式。新建工程时选 Empty Ability 模板就行,语言默认 ArkTS,编译 SDK 选当前最新稳定版本。工程建好后,代码目录我习惯这样组织:
entry/src/main/ets/ entryability/EntryAbility.ets pages/Index.ets // 聊天主页面 service/ChatSocket.ets // WebSocket 封装 model/Message.ets // 消息模型这个分法不复杂,但是边界清楚:页面只管 UI 和用户交互,socket 封装只管连接和收发,模型只定义数据结构。后面加文件传输、加图片消息,都往 model 和 service 里加,页面层不会爆炸。
3.1 module.json5 里的网络权限
纯血鸿蒙的网络访问不是开了就行,必须在模块配置文件里显式声明权限。打开 entry 模块下的 module.json5,在 module 节点里加:
{ "module": { "name": "entry", "srcEntry": "./ets/entryability/EntryAbility.ets", "requestPermissions": [ { "name": "ohos.permission.INTERNET" } ] } }这个 INTERNET 权限是普通权限,系统会直接授予,不需要弹窗申请。但别忘了写——不写的话,WebSocket connect 阶段就会报网络错误,而且报错信息不一定直白,排查起来很抓狂。
3.2 页面骨架:单页聊天室已经够用
Demo 阶段不要急着做"会话列表 + 聊天窗口"两套页面,一个单页聊天室就能验证所有核心链路。顶部一条标题栏显示当前用户 ID 和连接状态,中间消息列表,底部输入框加发送按钮。这个布局在 ArkUI 里用 Column 套三个子组件就能实现,代码量不大,又可以承载后续所有功能验证。
4. 聊天页实现:WebSocket 封装、消息气泡与发送栏
鸿蒙端最核心的是两部分:WebSocket 客户端封装,以及 ArkUI 页面的状态刷新。先把消息模型定义好:
// model/Message.ets export class MessageModel { id: string = ''; from: string = ''; to: string = ''; content: string = ''; time: number = 0; isSelf: boolean = false; }然后封装 ChatSocket。这一段我建议认真写,因为连接、心跳、消息回调的细节全集中在这里,写好了页面层会非常清爽。注意 ArkTS 是严格模式,所有类属性必须显式初始化,空实现回调也要给默认值。
// service/ChatSocket.ets import { webSocket } from '@kit.NetworkKit'; export class ChatSocket { private ws: webSocket.WebSocket | null = null; private url: string; private heartbeatTimer: number = -1; onOpen: () => void = () => {}; onMessage: (msg: Record<string, Object>) => void = () => {}; onClose: () => void = () => {}; constructor(url: string) { this.url = url; } connect() { this.ws = webSocket.createWebSocket(); this.ws.on('open', () => { this.startHeartbeat(); this.onOpen(); }); this.ws.on('message', (err, value) => { if (err) { return; } try { const text = typeof value === 'string' ? value : ''; this.onMessage(JSON.parse(text)); } catch (e) { console.error('消息解析失败: ' + e); } }); this.ws.on('close', () => { this.stopHeartbeat(); this.onClose(); }); this.ws.on('error', () => { console.error('WebSocket 错误'); }); this.ws.connect(this.url); } send(type: string, payload: Record<string, Object>) { const data = JSON.stringify({ type, ...payload }); this.ws?.send(data); } close() { this.stopHeartbeat(); this.ws?.close(); this.ws = null; } private startHeartbeat() { this.heartbeatTimer = setInterval(() => { this.send('ping', {}); }, 30000); } private stopHeartbeat() { if (this.heartbeatTimer > 0) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = -1; } } }如果你是兼容旧版本 SDK 的环境,把 import 换成import webSocket from '@ohos.net.webSocket'即可,API 用法基本一致。这段封装里最值得注意的就是 onMessage 里的 try-catch——WebSocket 把消息送达时只保证字节流是对的,不保证内容是合法 JSON。不兜这一层,服务端某次不小心塞了个非 JSON 帧,客户端整个页面会直接崩掉。
4.1 页面状态:@State 驱动的消息列表
聊天页用 @Entry 装饰器声明入口组件。我定义了三个 @State:messages 消息数组、inputText 输入框内容、connStatus 连接状态。@State 的作用是:只要值变了,ArkUI 自动刷新对应 UI。这有一个隐性要求——要更新数组视图,必须重新赋值,不能只 push。我一开始习惯性this.messages.push(...),页面纹丝不动,排查半天才发现是这么回事。
// pages/Index.ets import { ChatSocket } from '../service/ChatSocket'; import { MessageModel } from '../model/Message'; @Entry @Component struct ChatPage { @State messages: MessageModel[] = []; @State inputText: string = ''; @State connStatus: string = '连接中'; @State myId: string = ''; private scroller: Scroller = new Scroller(); private socket: ChatSocket | null = null; private reconnecting: boolean = false; private readonly serverUrl: string = 'ws://192.168.1.100:8080/ws'; aboutToAppear(): void { this.myId = 'dev-' + Math.random().toString(36).slice(2, 6); this.connectSocket(); } aboutToDisappear(): void { this.socket?.close(); } private connectSocket() { this.connStatus = '连接中'; this.socket = new ChatSocket(this.serverUrl); this.socket.onOpen = () => { this.connStatus = '已连接'; this.socket?.send('register', { userId: this.myId }); }; this.socket.onMessage = (msg) => { const type = msg['type'] as string; if (type === 'chat') { this.messages = [...this.messages, this.toMessage(msg)]; this.scrollToBottom(); } else if (type === 'registered') { this.myId = msg['self'] as string; } else if (type === 'online') { console.log('在线列表: ' + JSON.stringify(msg['online'])); } }; this.socket.onClose = () => { this.connStatus = '已断开'; this.scheduleReconnect(); }; this.socket.connect(); } private scheduleReconnect() { if (this.reconnecting) { return; } this.reconnecting = true; setTimeout(() => { this.reconnecting = false; this.connectSocket(); }, 2000); } private toMessage(msg: Record<string, Object>): MessageModel { const m = new MessageModel(); m.id = (msg['id'] as string) || ''; m.from = (msg['from'] as string) || ''; m.to = (msg['to'] as string) || 'all'; m.content = (msg['content'] as string) || ''; m.time = Number(msg['time'] || 0); m.isSelf = m.from === this.myId; return m; } private sendMessage() { const text = this.inputText.trim(); if (!text) { return; } this.socket?.send('chat', { to: 'all', content: text }); this.inputText = ''; } private scrollToBottom() { setTimeout(() => { this.scroller.scrollEdge(Edge.Bottom); }, 50); } build() { Column() { Row() { Text(`${this.connStatus} · ${this.myId}`) .fontSize(14) .fontColor('#666666') .padding(12) } .width('100%') .backgroundColor('#f2f3f5') List({ scroller: this.scroller }) { ForEach(this.messages, (item: MessageModel) => { ListItem() { MessageBubble({ msg: item }) } }, (item: MessageModel) => item.id) } .layoutWeight(1) .width('100%') Row({ space: 8 }) { TextInput({ text: this.inputText, placeholder: '输入消息' }) .layoutWeight(1) .onChange((v: string) => { this.inputText = v; }) .height(44) Button('发送') .height(44) .onClick(() => this.sendMessage()) } .padding({ left: 12, right: 12, top: 8, bottom: 8 }) .width('100%') .backgroundColor('#ffffff') } .width('100%') .height('100%') } }4.2 消息气泡组件:用 @Prop 传递数据
消息气泡是聊天 UI 的核心单元,抽成独立组件后,页面代码清爽很多。判断逻辑很简单:是本人的消息靠右显示、绿色背景,别人的靠左、白色背景;系统提示置中、灰色小字。
@Component struct MessageBubble { @Prop msg: MessageModel = new MessageModel(); build() { Row() { if (this.msg.from === 'system') { Text(this.msg.content) .fontSize(12) .fontColor('#999999') } else { Column() { Text(this.msg.from) .fontSize(10) .fontColor('#999999') Text(this.msg.content) .fontSize(16) .padding(10) .backgroundColor(this.msg.isSelf ? '#95ec69' : '#ffffff') .borderRadius(8) } .alignItems(this.msg.isSelf ? HorizontalAlign.End : HorizontalAlign.Start) } } .padding({ left: 12, right: 12, top: 6, bottom: 6 }) .width('100%') .justifyContent(this.msg.isSelf ? FlexAlign.End : FlexAlign.Start) } }4.3 发送策略:只发本地输入,不本地造消息
我在发送时只把{ to, content }发给服务端,不在本地手动追加消息。消息会由服务端广播回来,我再根据 from 判断 isSelf。这个设计的收益前面说过:所有设备看到的时间线完全一致,且不会出现"本地先冒出来一条、服务端回传又追加一条"的重复。代价是发送后消息有几十毫秒的网络往返延迟,肉眼几乎无感。
4.4 滚动到底部和断线重连:体验的最后一公里
消息多了以后,不在新消息到达时自动滚到底部,用户要自己往下滑,体验非常割裂。我在收到消息后调用scrollToBottom(),用 50 毫秒延时等列表布局刷新完成再滚。断线重连逻辑放在 onClose 回调里,用 reconnecting 标志位防止重复触发,间隔固定 2 秒。如果你要更精细,可以用指数退避:第一次 1 秒,第二次 2 秒,最多 30 秒,避免服务端还没起来时客户端疯狂重连。
5. PC 和其它手机端:一个 HTML 页面全部搞定
跨端互通的重点是"其它端"也要参与进来。PC 端最省事的方案不是写桌面应用,而是直接放一个 HTML 页面,浏览器打开就能收发。Android、苹果手机也一样,只要浏览器支持 WebSocket,打开同一个页面就能入群。整个页面代码不到一百行:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>跨端聊天测试</title> <style> body { font-family: sans-serif; max-width: 640px; margin: 0 auto; padding: 16px; } #list { border: 1px solid #ddd; height: 60vh; overflow-y: auto; padding: 8px; } .msg { margin: 8px 0; } .meta { color: #888; font-size: 12px; } </style> </head> <body> <h3>Web 端收发测试</h3> <div id="list"></div> <div style="display: flex; gap: 8px; margin-top: 8px;"> <input id="msg" style="flex: 1; padding: 8px;" placeholder="输入消息" /> <button id="send" style="padding: 8px;">发送</button> </div> <script> const ws = new WebSocket('ws://192.168.1.100:8080/ws'); const list = document.getElementById('list'); const input = document.getElementById('msg'); const sendBtn = document.getElementById('send'); ws.onopen = () => { ws.send(JSON.stringify({ type: 'register', userId: 'web-' + Math.random().toString(36).slice(2, 6) })); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'chat') { const div = document.createElement('div'); div.className = 'msg'; const time = new Date(msg.time).toLocaleTimeString(); div.innerHTML = '<span class="meta">' + msg.from + ' ' + time + '</span><div>' + escapeHtml(msg.content) + '</div>'; list.appendChild(div); list.scrollTop = list.scrollHeight; } }; function escapeHtml(v) { return v.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); } sendBtn.onclick = () => { const text = input.value.trim(); if (text) { ws.send(JSON.stringify({ type: 'chat', content: text })); input.value = ''; } }; input.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendBtn.click(); }); </script> </body> </html>这段代码里有个小细节:escapeHtml。聊天内容不可信,直接拼 innerHTML 有被注入的风险,虽然 Demo 无所谓,但好习惯从第一天养成。
5.1 四端同时在线的联调步骤
建议按这个顺序联调,每一步都有明确预期,出问题好定位:
- 在 PC 上启动服务端,浏览器访问
http://192.168.1.100:8080/health,页面显示 ok,确认服务端正常。 - PC 浏览器打开 HTML 页面,控制台确认 WebSocket 已连接,页面显示自己的消息。
- 另一台 PC 或手机浏览器打开同一个 HTML 页面,能看到在线人数变化。
- 鸿蒙真机运行 App,看到状态栏变为"已连接",在输入框发一条消息。
- 此时 PC、手机、鸿蒙三个方向互相发消息,确认都能实时收到、顺序正确。
5.2 连不上时的通用排查链路
跨端联调九成问题都出在网络,不是代码。我给自己定了一套排查顺序,效率很高:
- 第一步:在连不上的那台设备上,用浏览器访问
http://<PC的IP>:8080/health。能访问,说明网络链路通,问题在 WebSocket 路径或代码;不能访问,先查网络。 - 第二步:检查 PC 防火墙是否放行了 Node.js 进程或 8080 端口。Windows 在防火墙弹窗里要允许 Node.js 的专用网络访问。
- 第三步:确认所有设备在同一网段。鸿蒙设备 IP 和 PC IP 的前三段应该一致,不一致说明不在同一个局域网。
- 第四步:如果前面都通过但 WebSocket 还是握手失败,关掉周围代理类软件。局部代理会把 WebSocket 流量劫走,握手就会异常。
6. 跨端联调踩坑实录:四类最典型的问题
整个项目做完,最有价值的其实是踩过的这些坑。每个问题都花了我不少时间,整理出来希望你能直接跳过。
6.1 权限开了、模拟器上还报连接失败
第一个坑是鸿蒙模拟器。模拟器默认网络模型和宿主机不完全互通,有时连局域网地址是通的,有时需要手动配置网络映射,不同版本的模拟器行为还不一样。我的建议很直接:做网络相关的开发,尽快换成真机调试,模拟器只用来验证 UI 布局。真机调试记得开启开发者模式,用 USB 连接后在设备管理里能看到设备,App 真机运行后确保手机和 PC 在同一 WiFi 下。
6.2 手机连得上健康检查、WebSocket 却一直失败
这个坑最容易让人怀疑人生:health 接口浏览器能访问,但 App 里 WebSocket 就是连不上。后来发现是代理工具把 8080 端口的 WebSocket 流量劫走了。解决方式是在代理工具里把局域网 IP 加入直连名单,或者直接关掉代理再试。另外强调一件事:本地联调绝对不要用 wss 协议,你的服务端没有证书,wss 握手必然失败,老老实实用 ws。
6.3 消息能收到但顺序乱了:客户端时钟在捣乱
联调后期我发现一个现象:PC 发的消息经常排在鸿蒙设备发的消息前面,即使鸿蒙设备先发。原因就是不同设备本地时钟不一致。我在协议里坚持让服务端统一生成 time,客户端渲染时按 time 排序,问题立刻消失。如果你的服务端没有生成时间戳,消息顺序只能看 TCP 到达顺序,逻辑上没问题,但 UI 上要保证所有客户端看到一致的时间线,服务端时间戳是唯一可靠手段。
6.4 退后台回来连接断了,页面还死着
鸿蒙应用退到后台,系统可能挂起应用的网络资源;路由器和 WiFi 网关也会在空闲时回收连接。实测下来这套组合拳打下来,连接断掉是家常便饭。我的处理是:onClose 里自动重连,同时把连接状态实时显示在标题栏。加上 30 秒心跳,能让断线发现时间从"用户手动刷新"缩短到"几乎立刻恢复"。还有个小细节:重连成功后记得重新发 register,否则服务端那边还保留着旧连接记录。
收尾:这套 Demo 往后还能怎么长
按这个架构做完,你已经有了一个完整的跨端实时消息骨架。我在实际使用中的体会是,调试期一定要在 UI 上保留连接状态和错误日志,这是省时间成本最高的一笔投入。后续扩展空间也很明确:消息层加 AES-GCM 加密就能防止明文裸奔,把 Node.js 服务换成带鉴权的网关就能接用户体系,再加上图片上传就是完整的聊天应用。最关键的是,跨端通信这块地基你已经有数了,以后不管接什么设备,思路都是同一套。