首页 / 资讯中心 / 文章详情

纯血鸿蒙+WebSocket:从零实现跨端实时聊天Demo

纯血鸿蒙+WebSocket:从零实现跨端实时聊天Demo ★ FEATURED ARTICLE
如果你手里正好有一台纯血鸿蒙设备又需要一个能立刻跑起来、而且能和 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 是发送者 IDto 是接收方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: Recordstring, 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: Recordstring, 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 装饰器声明入口组件。我定义了三个 Statemessages 消息数组、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: Recordstring, 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 langzh-CN head meta charsetutf-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 h3Web 端收发测试/h3 div idlist/div div styledisplay: flex; gap: 8px; margin-top: 8px; input idmsg styleflex: 1; padding: 8px; placeholder输入消息 / button idsend stylepadding: 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 classmeta msg.from time /spandiv escapeHtml(msg.content) /div; list.appendChild(div); list.scrollTop list.scrollHeight; } }; function escapeHtml(v) { return v.replace(//g, amp;).replace(//g, lt;).replace(//g, gt;); } 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 服务换成带鉴权的网关就能接用户体系再加上图片上传就是完整的聊天应用。最关键的是跨端通信这块地基你已经有数了以后不管接什么设备思路都是同一套。
阅读完成 · 觉得有帮助?
咨询建站