一、项目定位

       这是一个基于 HarmonyOS(ArkTS)开发的局域网多人实时聊天室应用,配合 Node.js 后端服务器,支持多台设备同时在线聊天。项目的核心目标是:让多台 HarmonyOS 设备通过同一局域网内的服务器,实现实时消息收发、在线状态同步、用户信息管理及数据持久化。
       客户端负责 UI 展示与用户交互,服务端负责消息路由、用户管理和在线统计,两者通过 HTTP + WebSocket 双协议通信。

二、核心功能清单


1.用户登录 :输入账号密码登录,服务器在线则服务端登录,离线则本地登录;支持上次登录账号密码自动填充 。
2.实时消息: 通过 WebSocket 实时收发消息,消息气泡区分自己/他人,系统消息居中展示 。
3. 历史消息 :通过 HTTP 拉取服务器历史消息,与本地消息合并去重显示 。
4.在线统计:30 秒心跳包检测,标题栏实时显示在线人数和连接状态 
5.修改昵称 : 在个人信息页修改昵称,实时同步到聊天消息和历史记录 
6.数据持久化: 用户信息用 Preferences 存储,聊天记录用 JSON 文件存储,应用重启数据不丢失 
 7.服务器配置 :设置页可修改 HTTP/WebSocket 地址,适配模拟器和真机不同网络环境
8.重复登录防护: 同一账号不允许在两台设备上同时登录
9.退出登录 : 断开 WebSocket 连接,广播离开消息,清除登录态
 

三、项目架构与文件结构

整体文件架构:

┌─────────────────────┐          ┌─────────────────────┐
│   HarmonyOS 客户端   │          │   Node.js 服务端     │
│                     │          │                     │
│  Pages (UI 层)      │  HTTP    │  HTTP Server :3000  │
│    ├─ LoginPage     │ ───────> │    ├─ /api/login    │
│    ├─ ChatPage      │  <────── │    ├─ /api/messages │
│    ├─ ProfilePage   │          │    └─ /api/online   │
│    └─ SettingsPage  │          │                     │
│                     │   WS     │  WebSocket :8080    │
│  Services (业务层)   │ ───────> │    ├─ join/leave    │
│    ├─ WebSocketSvc  │  <────── │    ├─ message       │
│    ├─ HttpService   │          │    ├─ rename        │
│    ├─ AxiosService  │          │    └─ heartbeat     │
│    ├─ UserStore     │          │                     │
│    ├─ MessageStore  │          │  数据存储            │
│    └─ ServerConfig  │          │    ├─ users Map     │
│                     │          │    ├─ messages []   │
│  Models (数据层)     │          │    └─ registeredUsers│
│    ├─ UserInfo      │          │                     │
│    └─ ChatMessage   │          │                     │
└─────────────────────┘          └─────────────────────┘

客户端文件架构:

entry/src/main/ets/
├── entryability/
│   └── EntryAbility.ets          # 应用入口 Ability,设置键盘避让模式
├── model/
│   └── UserModel.ets             # 数据模型:UserInfo、ChatMessage、colorForNickname
├── service/
│   ├── AxiosService.ets          # Axios 登录/注册请求
│   ├── HttpService.ets           # HTTP 拉取历史消息
│   ├── MessageStore.ets          # JSON 文件读写聊天记录
│   ├── ServerConfig.ets          # Preferences 存储服务器地址
│   ├── UserStore.ets             # Preferences 存储用户信息
│   └── WebSocketService.ets      # WebSocket 实时通信 + 心跳
├── pages/
│   ├── Index.ets                 # 应用根页面,初始化服务 + 路由分发
│   ├── LoginPage.ets             # 登录页
│   ├── ChatPage.ets              # 聊天主页面
│   ├── ProfilePage.ets           # 个人信息页
│   └── SettingsPage.ets          # 服务器设置页
└── entrybackupability/
    └── EntryBackupAbility.ets    # 备份扩展
server/
└── server.js                     # Node.js 后端:HTTP + WebSocket 合一服务器

分层设计原则:

项目遵循三层架构:

1.Model 层  (model/UserModel.ets):纯数据定义,不含业务逻辑。定义了 UserInfo(用户信息)和 ChatMessage(聊天消息)两个核心类,以及根据昵称生成头像颜色的 `colorForNickname` 工具函数。

2. Service 层(service/ 目录):所有业务逻辑和网络通信都封装在此,采用单例模式,全局共享同一实例。每个 Service 职责单一:
   - WebSocketService:只管 WebSocket 连接、消息收发、心跳
   - HttpService:只管 HTTP 请求(历史消息)
   - AxiosService:只管 Axios 请求(登录注册)
   - UserStore:只管用户信息的读写持久化
   - MessageStore:只管聊天消息的读写持久化
   - ServerConfig:只管服务器地址配置

3. Page 层(pages/ 目录):纯 UI 展示与交互,通过调用 Service 层完成业务操作,不直接处理网络请求和数据存储。

四、数据模型设计

1.UserInfo - 用户信息

export class UserInfo {
  userId: string = '';        // 用户唯一ID,由服务器生成或本地生成
  nickname: string = '';      // 昵称
  avatarColor: string = '';   // 头像颜色,根据昵称 hash 生成
  joinTime: number = 0;       // 首次加入时间
  isLogin: boolean = false;   // 是否已登录
}

关键设计:userId 是用户的唯一标识,不会因昵称修改而改变。这保证了消息归属判断的稳定性——用 userId 判断 isMine,而非用 nickname。

2.ChatMessage - 聊天消息

export class ChatMessage {
  id: string = '';            // 消息唯一ID(timestamp_random)
  userId: string = '';        // 发送者 userId
  nickname: string = '';      // 发送者昵称(可变)
  avatarColor: string = '';   // 头像颜色
  content: string = '';       // 消息内容
  timestamp: number = 0;      // 时间戳
  isMine: boolean = false;    // 是否是自己发的(UI 展示用)
  isSystem: boolean = false;  // 是否是系统消息
}

关键设计:id 使用 timestamp_random 格式生成,保证唯一性。当需要触发 ForEach 重渲染时(如昵称修改后更新历史消息),会给 id 追加 _r${Date.now()} 或 _s${Date.now()} 后缀,因为 ArkUI 的 ForEach 通过比对 id 来决定是否重新渲染,直接修改对象属性不会触发 UI 更新。

3.头像颜色生成

const AVATAR_COLORS = [
  '#E57373', '#4FC3F7', '#81C784', '#FFB74D',
  '#BA68C8', '#4DD0E1', '#FF8A65', '#AED581',
  '#F06292', '#7986CB', '#DCE775', '#FFD54F',
];

export function colorForNickname(nickname: string): string {
  let hash = 0;
  for (let i = 0; i < nickname.length; i++) {
    hash = nickname.charCodeAt(i) + ((hash << 5) - hash);
  }
  return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
}

对昵称做 hash 运算后映射到 12 种预设颜色,同一昵称始终对应同一颜色,不同昵称大概率不同颜色。

五、服务端实现

服务端基于 Node.js,一个文件 `server.js` 实现了 HTTP + WebSocket 双协议服务器。

HTTP 服务(端口 3000)

| 接口 | 方法 | 功能 |
|------|------|------|
| `/api/login` | POST | 用户登录,返回 userId + nickname;检测重复登录返回 code -2 |
| `/api/register` | POST | 用户注册 |
| `/api/messages` | GET | 拉取历史消息,支持 `since` 参数增量获取 |
| `/api/online` | POST | 查询在线人数 |
| `/api/user/:userId` | GET | 查询用户信息 |

WebSocket 服务(端口 8080)

| 消息类型 | 方向 | 功能 |
|---------|------|------|
| `join` | 客户端→服务端 | 用户上线,服务端广播给其他客户端 |
| `message` | 客户端→服务端 | 发送消息,服务端广播给其他客户端 |
| `rename` | 客户端→服务端 | 修改昵称,服务端广播通知 |
| `heartbeat` | 客户端→服务端 | 心跳包,服务端返回在线人数 |
| `leave` | 服务端→客户端 | 用户下线广播 |
| `online_count` | 服务端→客户端 | 在线人数更新 |

核心数据结构

const users = new Map();              // userId → { userId, nickname },在线用户
const registeredUsers = new Map();    // nickname → userId,已注册用户(跨登录保留)
const messages = [];                  // 历史消息数组,最多保留 500 条
const wsClients = new Map();          // ws → { userId, nickname },WebSocket 连接映射

广播机制

服务端广播时跳过发送者的 WebSocket 连接(client !== excludeWs),而非按 userId 过滤。这保证了:
- 发送者不会收到自己的消息回声
- 同一 userId 如果有多连接(被 code -2 阻止前的情况)也能正确过滤

重复登录检测

if (userId && users.has(userId)) {
  res.end(JSON.stringify({ code: -2, message: '该账号已在线,不能重复登录' }));
  return;
}

登录时检查该 userId 是否已在 users Map 中,如果已在线则拒绝登录。

昵称修改与 registeredUsers 同步

当用户修改昵称时,服务端不仅更新 users Map,还会同步更新 registeredUsers Map:删除旧昵称→userId 的映射,添加新昵称→userId 的映射。这保证了用户修改昵称后再退出登录,下次用新昵称登录时仍能获得相同的 userId。

六、客户端核心实现

1.应用入口与初始化

Index.ets 是应用根页面,在 `aboutToAppear` 中完成所有服务的初始化:

async aboutToAppear(): Promise<void> {
  const context = getContext(this) as common.UIAbilityContext;

  // 1. 初始化用户存储
  const userStore = UserStore.getInstance();
  await userStore.init(context);

  // 2. 初始化服务器配置
  const serverConfig = ServerConfig.getInstance();
  await serverConfig.init(context);

  // 3. 将服务器地址注入各 Service
  const wsService = WebSocketService.getInstance();
  wsService.setServerUrl(serverConfig.wsUrl);
  const httpService = HttpService.getInstance();
  httpService.setBaseUrl(serverConfig.httpBaseUrl);
  const axiosService = AxiosService.getInstance();
  axiosService.setBaseUrl(serverConfig.httpBaseUrl);

  // 4. 检查登录态,路由分发
  const user = userStore.getUserInfo();
  if (user.isLogin) {
    this.navPathStack.replacePathByName('Chat', undefined);
  } else {
    this.navPathStack.replacePathByName('Login', undefined);
  }
}

页面导航使用 Navigation + NavPathStack,路由配置在 route_map.json 中声明,支持 replacePathByName 和 pushPathByName 两种跳转方式。

2.登录页 - LoginPage

登录页的 handleLogin 方法是整个登录流程的核心:

登录流程:

1. 检测网络:调用 WebSocketService.checkNetworkAvailability() 检查网络是否可用
2. 服务端登录:网络可用时,通过 AxiosService.login() 向服务端发送 POST 请求
   - 成功(code 0):获取服务端分配的 userId
   - 重复登录(code -2):提示"该账号已在线",停止登录
   - 失败(code -1):降级为本地登录
3. 本地补充:检查本地 UserStore 是否已有该用户信息
   - 老用户:从本地获取已有的 userId 和 joinTime
   - 新用户:使用服务端返回的 userId 或本地生成
4. 持久化:调用 saveUserInfo() 保存用户信息,调用 saveLastPassword() 保存密码
5. 跳转:导航到聊天页

自动填充:aboutToAppear 时从 UserStore 读取上次登录的账号密码,自动填入输入框。

3.聊天页 - ChatPage

聊天页是项目最复杂的页面,负责消息展示、收发、历史加载、昵称同步等。

aboutToAppear 初始化

aboutToAppear(): void {
  const context = getContext(this) as common.UIAbilityContext;
  const userStore = UserStore.getInstance();
  userStore.init(context).then(async () => {
    const user = userStore.getUserInfo();
    if (!user.isLogin) { this.navPathStack.replacePathByName('Login', undefined); return; }
    this.currentUser = user;

    // 1. 加载本地消息,用当前用户信息刷新 isMine/nickname/avatarColor
    this.messageStore.setContext(context);
    this.messages = this.messageStore.loadMessages();
    // ... 遍历消息,根据 userId 重算 isMine,覆盖自己的 nickname/avatarColor
    this.messageStore.saveMessages(this.messages);

    // 2. 拉取服务器历史消息,与本地去重合并
    await this.loadHistoryMessages();

    // 3. 添加系统消息
    const sysMsg = ChatMessage.createSystem(`${this.currentUser.nickname} 加入了群聊`);
    this.messages = [...this.messages, sysMsg];

    // 4. 注册 WebSocket 回调
    this.wsService.setOnMessage(...);
    this.wsService.setOnConnect(...);
    this.wsService.setOnDisconnect(...);
    this.wsService.setOnOnlineCount(...);
    this.wsService.setOnRename(...);

    // 5. 建立 WebSocket 连接
    this.wsService.connect(user.userId);
  });
}

历史消息去重

服务端返回的历史消息可能与本地已有的重复(同一用户、同一时间、同一内容),使用 userId + timestamp + content 组合键去重:

const existingKeys: Record<string, boolean> = {};
for (let i = 0; i < this.messages.length; i++) {
  const key = `${this.messages[i].userId}_${this.messages[i].timestamp}_${this.messages[i].content}`;
  existingKeys[key] = true;
}
// 遍历历史消息,只添加 existingKeys 中不存在的

不用 ID 去重是因为服务端和客户端各自生成的 ID 不同(都含随机数),无法匹配。

onShown - 页面恢复时的数据同步

这是解决"修改昵称后历史消息不更新"的关键。当从 ProfilePage 返回或从后台恢复时,onShown 回调触发:

.onShown(() => {
  const user = userStore.getUserInfo();
  this.currentUser = user;
  // 遍历所有消息,自己的消息用最新 nickname/avatarColor 覆盖
  // 系统消息中包含旧昵称的,替换为新昵称
  // 任何字段变化则给 id 加 _r 后缀触发 ForEach 重渲染
  this.messageStore.saveMessages(this.messages);
  // 如果昵称变了,通过 WebSocket 广播 rename
  if (oldNickname !== user.nickname) {
    this.wsService.sendRename(user.userId, user.nickname, user.avatarColor);
  }
})

消息发送

private sendMessage(): void {
  const content = this.inputText.trim();
  this.inputText = '';
  const timestamp = Date.now();

  // 1. 本地立即展示
  const myMsg = ChatMessage.create(this.currentUser.userId, this.currentUser.nickname, content, true);
  myMsg.timestamp = timestamp;
  this.messages = [...this.messages, myMsg];
  this.messageStore.saveMessages(this.messages);

  // 2. 通过 WebSocket 发送给服务端广播
  this.wsService.sendMessage(
    this.currentUser.userId, this.currentUser.nickname,
    content, timestamp, myMsg.avatarColor,
  );
}

发送时使用单一 Date.now() 同时赋给 id 和 timestamp,保证一致性。传递真实的 avatarColor(而非硬编码),确保服务端广播时其他客户端能正确显示头像颜色。

消息气泡 UI

聊天消息通过 isMine 区分左右布局:

- 自己的消息:右侧显示,蓝色气泡,右下角小圆角(bottomRight: 4),左侧大圆角
- 他人的消息:左侧显示,白色气泡,左下角小圆角(bottomLeft: 4),右侧大圆角,上方显示昵称
- 系统消息:居中显示,灰色圆角背景

使用 constraintSize({ maxWidth: '70%' }) 限制气泡最大宽度,textOverflow({ overflow: TextOverflow.Ellipsis }) 处理昵称溢出。

键盘弹起自动滚动

通过 onAreaChange 监听页面高度变化,当高度减小时(键盘弹起),自动滚动到底部:

.onAreaChange((oldArea: Area, newArea: Area) => {
  const oldH = oldArea.height as number;
  const newH = newArea.height as number;
  if (newH < oldH) {
    setTimeout(() => { this.listScroller.scrollEdge(Edge.Bottom); }, 100);
  }
})

同时在 EntryAbility 中设置了 KeyboardAvoidMode.RESIZE,让页面内容随键盘弹起而调整。

4.WebSocket 服务 - WebSocketService

连接管理

connect(userId: string): void {
  // 1. 如果已有连接,先关闭旧连接的所有回调
  if (this.ws !== undefined) {
    this.ws.off('open');
    this.ws.off('message');
    this.ws.off('close');
    this.ws.off('error');
    this.ws.close();
  }
  this.currentUserId = userId;

  // 2. 创建新 WebSocket
  this.ws = webSocket.createWebSocket();

  // 3. 注册回调
  this.ws.on('open', ...);
  this.ws.on('message', ...);
  this.ws.on('close', ...);
  this.ws.on('error', ...);

  // 4. 发起连接
  this.ws.connect(this.serverUrl);
}

消息接收与分发
on('message') 回调中根据 `type` 字段分发处理:

- message:过滤掉自己发的消息(data.userId === this.currentUserId),构造 ChatMessage 通知 UI
- join:构造系统消息 "xxx 加入了群聊"
- leave:构造系统消息 "xxx 离开了群聊,当前还有 N 人在线"
- rename:触发 onRenameCallback,通知 ChatPage 更新历史消息中的昵称
- online_count:更新 this.onlineCount,触发 onOnlineCountCallback

心跳机制

连接成功后启动 30 秒间隔心跳:

private startHeartbeat(): void {
  this.stopHeartbeat();
  this.sendHeartbeat();
  this.heartbeatTimer = setInterval(() => { this.sendHeartbeat(); }, 30000);
}

private sendHeartbeat(): void {
  const data = { type: 'heartbeat', userId: this.currentUserId, ... };
  this.ws.send(JSON.stringify(data));
}

服务端收到心跳后返回 { type: 'online_count', count: users.size },客户端据此更新标题栏在线人数。

网络检测

checkNetworkAvailability(): boolean {
  const netHandle = connection.getDefaultNetSync();
  return netHandle.netId !== 0;
}

登录前检测网络可用性,不可用时降级为本地登录模式。


5.数据持久化

UserStore - Preferences 存储

使用 HarmonyOS 的 @kit.ArkData 提供的 Preferences API,键值对存储用户信息:

| 键 | 值 | 说明 |
|----|-----|------|
| `user_id` | string | 当前登录用户 ID |
| `nickname` | string | 当前昵称 |
| `avatar_color` | string | 当前头像颜色 |
| `is_login` | boolean | 登录状态 |
| `join_time_{nickname}` | number | 每个昵称的首次加入时间 |
| `user_id_{nickname}` | string | 每个昵称对应的 userId |
| `last_username` | string | 上次登录账号 |
| `last_password` | string | 上次登录密码 |

关键细节:

1. 必须 flush:putSync只写入内存缓存,必须调用 await store.flush() 才能持久化到磁盘。所有写操作后都跟了flush()。

2. 单例 + initialized 标志init() 只在首次调用时创建 Preferences 实例,后续调用直接返回。避免重复创建实例导致缓存数据丢失。

3. 昵称迁移:saveUserInfo 检测到昵称变化时,自动将旧昵称的 joinTime 和 userId 映射复制到新昵称下。

4. 批量写入 + 单次 flush:所有 putSync 操作完成后才调用一次 flush(),避免多次异步 flush 导致的竞争条件。

MessageStore - JSON 文件存储

使用 @kit.CoreFileKit 的文件读写 API,将聊天消息序列化为 JSON 存储到应用沙箱目录:

private getFilePath(): string {
  return `${this.context.filesDir}/chat_messages.json`;
}

- 读取:fs.readTextSync 读取文件内容,JSON.parse 反序列化为 ChatMessage 数组
- 写入:fs.openSync 以 TRUNC 模式打开(清空重写),fs.writeSync 写入 JSON 字符串

每次消息变化(发送、接收、昵称更新)都会立即调用 saveMessages 写入文件,保证数据不丢失。

ServerConfig - 服务器地址存储

同样使用 Preferences,存储 HTTP 基地址和 WebSocket 地址,默认值为模拟器地址 10.0.2.2。

七、权限声明与网络配置

权限

在module.json5 中声明:

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

- INTERNET:允许网络访问(HTTP/WebSocket 通信必须)
- GET_NETWORK_INFO:允许检测网络状态(登录前判断网络可用性)

网络地址配置

- 模拟器:使用 10.0.2.2 访问宿主机(模拟器的特殊地址),HTTP 地址 http://10.0.2.2:3000,WebSocket 地址 ws://10.0.2.2:8080
- 真机:需要使用电脑的局域网 IP(如 192.168.x.x),在设置页修改

服务器地址可在应用内动态修改,修改后保存到 Preferences,下次启动自动加载。

八、页面导航设计

使用 Navigation 组件 +NavPathStack 实现页面导航:

Index(根页面)
  ├─ Login(登录页)── replacePathByName ──→ Chat
  ├─ Chat(聊天页)── pushPathByName ──→ Profile / Settings
  │                 ── replacePathByName ──→ Login(退出登录)
  ├─ Profile(个人信息页)── pop ──→ Chat
  └─ Settings(服务器设置页)── pop ──→ Login

路由配置在 route_map.json 中声明,每个页面通过@Builder 导出函数注册:

{
  "routerMap": [
    { "name": "Login", "pageSourceFile": "src/main/ets/pages/LoginPage.ets", "buildFunction": "LoginBuilder" },
    { "name": "Chat",  "pageSourceFile": "src/main/ets/pages/ChatPage.ets",  "buildFunction": "ChatBuilder" },
    { "name": "Profile", "pageSourceFile": "src/main/ets/pages/ProfilePage.ets", "buildFunction": "ProfileBuilder" },
    { "name": "Settings", "pageSourceFile": "src/main/ets/pages/SettingsPage.ets", "buildFunction": "SettingsBuilder" }
  ]
}

@Consume('navPathStack') 在各页面中获取共享的 NavPathStack 实例,实现统一导航控制。

九、踩坑与解决方案

1.ForEach 不刷新问题

问题:修改 ChatMessage 对象的属性后,UI 不更新。

原因:ArkUI 的 ForEach 通过比对 key(第二个参数返回的 `msg.id`)来决定是否重新渲染。如果 id 不变,即使对象属性变了也不会更新。

解决:需要修改消息时,创建新的 ChatMessage 对象,并给 id 追加后缀(如 _r${Date.now()}),使 ForEach 检测到 key 变化从而重渲染。

2.服务端广播回声

问题:发送消息后,自己收到了自己的消息,导致重复显示。

解决:服务端广播时跳过发送者的 WebSocket 连接(client !== excludeWs);客户端也在 onMessage 中增加userId === currentUserId 的过滤,双保险。

3. TCP + WebSocket 双连接冗余

问题:最初用 TCP 长连接做心跳/在线统计,WebSocket 做消息收发,两个连接、两个端口,维护复杂。

解决:将心跳合并到 WebSocket 中,客户端每 30 秒发送 heartbeat 类型消息,服务端返回在线人数。删除了独立的 TCP 服务,架构更简洁。

 4. 修改昵称后历史消息不更新

问题:在个人信息页修改昵称后返回聊天页,历史消息仍显示旧昵称。

原因:ChatPage 的 currentUser 在 aboutToAppear时设置,从 ProfilePage 返回时不会重新触发 aboutToAppear。

解决:使用 NavDestination 的 onShown 回调,每次页面显示时重新从 UserStore 读取用户信息,遍历所有消息用最新数据覆盖,并给 id 加后缀触发重渲染。同时在 aboutToAppear 中也做同样的覆盖,确保冷启动时也能修正。

5.关闭后台后登录态丢失

问题:应用切到后台再回来,登录态消失了。

原因:UserStore.init() 每次被调用都会创建新的 Preferences 实例,新实例从磁盘读取数据时,可能读到 flush 尚未完成时的旧数据。

解决:给 UserStore 加 initialized 标志,init() 只在首次调用时创建 Preferences 实例,后续调用直接返回,保证整个生命周期使用同一个内存缓存实例。

6. 同一账号多设备登录

问题:同一账号可以在两台设备上同时登录。

解决:服务端登录接口检查 userId 是否已在users Map 中,如果已在线则返回 code: -2。客户端收到 -2 后显示错误提示,阻止登录。

7. 退出登录再进来消息丢失

问题:退出登录后重新登录,之前的聊天消息没了。

原因:aboutToDisappear 不一定每次都会执行(如应用被系统杀死),导致消息没有及时保存。

解决:在 onShown 中增加检测——如果本地文件中的消息数量大于内存中的消息数量,说明有消息丢失,从文件恢复。同时在每次消息变化时都立即调用 saveMessages,减少丢失风险。

8. 换账号登录消息归属错误

问题:A 账号登录发了消息,退出后用 B 账号登录,A 的消息仍然显示在右边。

原因:JSON 文件中保存的 isMine 是 A 账号时的值(true),B 账号加载后没有重新计算。

解决:aboutToAppear 加载消息后,遍历所有消息,根据 userId === currentUser.userId 重新计算 isMine,并用当前用户的 nickname/avatarColor 覆盖自己消息的显示信息。

9. Preferences 不落盘

问题:修改个人信息后关闭应用,再次打开发现修改没保存。

原因:putSync 只写入内存缓存,不保证立即写入磁盘。

解决:所有putSync 操作后必须调用 await store.flush() 强制落盘。并且在 saveUserInfo 中采用"批量 putSync + 单次 flush"策略,避免多个异步 flush 的竞争条件。

十、项目运行

启动服务端

cd server
npm install ws
node server.js

服务端启动后会监听:
 

- HTTP API:http://localhost:3000
- WebSocket:ws://localhost:8080

启动客户端

1. 在 DevEco Studio 中打开项目
2. 连接 HarmonyOS 设备或启动模拟器
3. 如果使用真机,在设置页将服务器地址改为电脑的局域网 IP
4. 运行应用

多设备测试

1. 确保所有设备和运行服务端的电脑在同一局域网
2. 在每台设备上安装应用
3. 在设置页配置正确的服务器地址
4. 各自登录不同账号即可开始聊天

十一、总结

这个聊天室项目虽然功能不算复杂,但覆盖了 HarmonyOS 应用开发的多个核心领域:

1.ArkUI 声明式 UI:@State/@Consume 状态管理、@Builder 组件封装、ForEach 列表渲染、Navigation 导航
2. 网络通信:HTTP 请求、第三方库 Axios、WebSocket 实时通信、心跳机制
3. 数据持久化:Preferences 键值存储、文件读写 JSON
4.权限管理:INTERNET、GET_NETWORK_INFO 声明与使用
5. 生命周期管理:aboutToAppear/aboutToDisappear、onShown/onHidden、组件销毁前清理资源
6. 前后端联调:HTTP 接口定义、WebSocket 消息协议、数据格式约定
7. 边界情况处理:重复登录、昵称同步、消息去重、数据持久化时机、ForEach 重渲染

项目中遇到的很多(如 ForEach 不刷新、Preferences 不落盘、aboutToDisappear 不保证执行等)都是 HarmonyOS/ArkTS 开发中特有的问题,解决这些问题的过程也是深入理解 ArkUI 响应式机制和 HarmonyOS 数据存储机制的过程。
 

Logo

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

更多推荐