鸿蒙ArkTS实战:模拟聊天软件开发(核心功能+登录体系全解析)

前言

QQ作为国民级社交应用,其核心体验涵盖登录认证、消息列表、联系人管理、个人中心等模块。基于鸿蒙ArkTS开发模拟聊天软件,既能深入掌握鸿蒙应用开发核心技术,也能理解大型社交软件的底层设计逻辑。本文以此为基础,从工程架构、登录体系、核心功能实现三个维度,完整讲解模拟聊天软件的开发过程,重点拆解登录模块的坑点与解决方案,提供可直接复用的代码与架构设计思路。

一、项目工程架构

1. 工程目录结构

模块遵循鸿蒙官方推荐的分层架构,核心目录如下:


entry
├── src/main/ets
│   ├── abilities          // 应用入口
│   │   └── EntryAbility.ets  // 全局生命周期、状态初始化
│   ├── common             // 通用资源
│   │   ├── constants      // 常量定义(接口地址、路由路径)
│   │   │   └── Config.ets
│   │   ├── styles         // 全局样式(颜色、字体、间距)
│   │   │   └── CommonStyles.ets
│   │   └── utils          // 工具类(网络、存储、加密、路由)
│   │       ├── RequestUtil.ets    // 网络请求封装
│   │       ├── StorageUtil.ets    // 本地存储(内存+持久化)
│   │       ├── EncryptUtil.ets    // 密码加密
│   │       └── RouterUtil.ets     // 路由封装
│   ├── models             // 数据模型(接口返回、本地实体)
│   │   ├── UserModel.ets  // 用户信息模型
│   │   ├── MessageModel.ets // 消息模型
│   │   └── LoginModel.ets // 登录相关模型
│   ├── pages              // 页面(核心业务)
│   │   ├── login          // 登录模块
│   │   │   ├── LoginPage.ets       // QQ/手机号登录页
│   │   │   └── RegisterPage.ets    // 注册页
│   │   ├── home           // 首页模块
│   │   │   ├── MessagePage.ets     // 消息列表页
│   │   │   ├── ContactPage.ets     // 联系人页
│   │   │   └── MinePage.ets        // 个人中心页
│   │   └── common         // 通用页面
│   │       ├── GuidePage.ets       // 新用户引导页
│   │       └── SettingPage.ets     // 设置页
│   └── main_pages.json    // 路由注册
└── src/main/resources
    ├── rawfile            // 静态资源
    │   └── network_security_config.json // 网络安全配置
    └── media              // 图片资源(QQ图标、默认头像等)

2. 核心技术选型

模块

技术/工具

应用场景

开发语言

ArkTS + TypeScript

全量业务代码,类型安全保障

状态管理

AppStorage + Preferences

登录状态(内存+持久化)、全局配置

网络通信

@ohos.net.http + 拦截器

登录请求、消息拉取、联系人同步

路由管理

@ohos.router + 路由封装

页面跳转、参数传递、路由栈管理

数据解析

JSON + 类型接口

前后端数据交互,避免any类型

安全加密

@ohos.crypto(MD5/SHA256)

密码传输、Token加密存储

二、核心功能实现

1. 登录体系:QQ账号/手机号双登录(重点)

登录是模拟应用的入口,需实现「身份校验、状态持久化、安全防护」三大核心目标,解决鸿蒙开发中常见的网络、存储、标准库限制问题。

(1)网络安全配置(解决HTTP明文限制)

鸿蒙默认禁止HTTP明文传输,在rawfile/network_security_config.json配置允许后端接口域名:

{
  "domainSettings": {
    "cleartextPermitted": false,
    "domains": [
      {
        "domain": "10.133.48.157",  // 后端接口IP
        "subdomains": true,
        "cleartextPermitted": true
      },
      {
        "domain": "10.0.2.2",       // 模拟器测试IP
        "subdomains": true,
        "cleartextPermitted": true
      }
    ]
  }
}

module.json5中引用配置并声明网络权限:

{
  "module": {
    "deviceConfig": {
      "default": {
        "network": {
          "securityConfig": {
            "domainSettings": "$rawfile:network_security_config.json"
          }
        }
      }
    },
    "reqPermissions": [
      {
        "name": "ohos.permission.INTERNET",
        "reason": "QQ登录、消息拉取需要网络连接",
        "usedScene": { "abilities": ["*"], "when": "always" }
      }
    ]
  }
}
(2)登录请求封装(兼容ArkTS标准库)

核心工具类utils/RequestUtil.ets,解决「标准库限制、Token自动携带、错误统一处理」问题:

import http from '@ohos.net.http';
import prompt from '@ohos.promptAction';
import AppStorage from '@ohos.app.ability.AppStorage';
import router from '@ohos.router';
import { Config } from '../constants/Config';

export class RequestUtil {
  // 基础配置
  private static BASE_URL = Config.BASE_API;
  private static RETRY_COUNT = 1; // 重试次数
  private static TIMEOUT = 10000; // 超时时间

  // 参数序列化(过滤undefined,避免JSON解析错误)
  private static serializeParams(params: any): string {
    const validParams: Record<string, any> = {};
    for (const key in params) {
      if (params.hasOwnProperty(key) && params[key] !== undefined && params[key] !== null) {
        validParams[key] = params[key];
      }
    }
    return JSON.stringify(validParams);
  }

  // 统一请求方法
  private static async request<T>(url: string, options: http.HttpRequestOptions): Promise<T> {
    const fullUrl = `${this.BASE_URL}${url}`;
    let request: http.HttpRequest | null = null;
    let retryCount = 0;

    while (retryCount <= this.RETRY_COUNT) {
      try {
        request = http.createHttp();
        request.setTimeout({ timeout: this.TIMEOUT });

        // 自动携带Token(登录后全局存储)
        const token = AppStorage.get<string>('token') || '';
        const headers = {
          'Content-Type': 'application/json',
          'Accept': 'application/json',
          'Authorization': `Bearer ${token}`,
          ...options.header
        };

        const response = await request.request(fullUrl, {
          ...options,
          header: headers,
          usingCache: false
        });

        // 状态码处理
        if (response.responseCode >= 200 && response.responseCode < 300) {
          const resultStr = response.result as string;
          if (!resultStr) throw new Error('接口返回空数据');
          const data = JSON.parse(resultStr) as T;
          return data;
        } else if (response.responseCode === 401) {
          // Token过期:清除状态并跳回登录页
          prompt.showToast({ message: '登录已过期,请重新登录' });
          AppStorage.delete('token');
          AppStorage.delete('userInfo');
          await router.replaceUrl({ url: Config.ROUTE_LOGIN });
          throw new Error('Token失效');
        } else {
          throw new Error(`服务器错误:${response.responseCode}`);
        }
      } catch (error) {
        retryCount++;
        if (retryCount > this.RETRY_COUNT) {
          const errMsg = error instanceof Error ? error.message : '网络请求失败';
          if (errMsg.includes('ERR_CLEARTEXT_NOT_PERMITTED')) {
            prompt.showToast({ message: '网络安全配置错误,请检查' });
          } else {
            prompt.showToast({ message: errMsg });
          }
          throw error;
        }
        // 重试延迟
        await new Promise(resolve => setTimeout(resolve, 1000));
      } finally {
        request?.destroy();
      }
    }
    throw new Error('请求失败');
  }

  // QQ账号登录
  static async qqLogin(qqNumber: string, password: string) {
    // 密码MD5加密(避免明文传输)
    const encryptPwd = EncryptUtil.md5(password);
    return this.request<LoginResponse>('/auth/qqLogin', {
      method: http.RequestMethod.POST,
      extraData: this.serializeParams({ qqNumber, password: encryptPwd })
    });
  }

  // 手机号验证码登录
  static async phoneLogin(phoneNumber: string, code: string) {
    return this.request<LoginResponse>('/auth/phoneLogin', {
      method: http.RequestMethod.POST,
      extraData: this.serializeParams({ phoneNumber, code })
    });
  }

  // 发送验证码
  static async sendCode(phoneNumber: string) {
    return this.request<BaseResponse>('/auth/sendCode', {
      method: http.RequestMethod.POST,
      extraData: this.serializeParams({ phoneNumber })
    });
  }
}

// 基础类型定义
interface BaseResponse {
  success: boolean;
  message: string;
}

interface LoginResponse extends BaseResponse {
  data: {
    token: string;
    user: {
      id: number;
      qqNumber: string;
      phoneNumber: string;
      nickname: string;
      avatar: string;
      isNewUser: boolean;
    };
  };
}

  

(3)登录页面实现(LoginPage.ets)

兼顾「账号登录、手机号登录切换、加载状态、输入校验」,符合交互逻辑:

import router from '@ohos.router';
import prompt from '@ohos.promptAction';
import AppStorage from '@ohos.app.ability.AppStorage';
import { RequestUtil } from '../../common/utils/RequestUtil';
import { StorageUtil } from '../../common/utils/StorageUtil';
import { Config } from '../../common/constants/Config';
import { CommonStyles } from '../../common/styles/CommonStyles';

@Component
export struct LoginPage {
  // 登录类型:qq/phone
  @State loginType: string = 'qq';
  // QQ登录字段
  @State qqNumber: string = '';
  @State qqPwd: string = '';
  // 手机号登录字段
  @State phoneNumber: string = '';
  @State verifyCode: string = '';
  @State countDown: number = 0;
  @State isLoading: boolean = false;

  build() {
    Column() {
      // QQ Logo
      Image($r('app.media.qq_logo'))
        .width(100)
        .height(100)
        .margin({ top: 80, bottom: 40 });

      // 登录类型切换
      Row() {
        Text('QQ账号登录')
          .fontSize(18)
          .fontWeight(this.loginType === 'qq' ? FontWeight.Bold : FontWeight.Normal)
          .color(this.loginType === 'qq' ? '#007AFF' : '#333')
          .onClick(() => this.loginType = 'qq')
          .padding(10);
        Text('手机号登录')
          .fontSize(18)
          .fontWeight(this.loginType === 'phone' ? FontWeight.Bold : FontWeight.Normal)
          .color(this.loginType === 'phone' ? '#007AFF' : '#333')
          .onClick(() => this.loginType = 'phone')
          .padding(10);
      }
      .margin({ bottom: 30 });

      // 登录表单
      if (this.loginType === 'qq') {
        // QQ账号登录表单
        this.renderQQForm();
      } else {
        // 手机号登录表单
        this.renderPhoneForm();
      }

      // 登录按钮
      Button('登录')
        .style(CommonStyles.mainButton)
        .disabled(this.isLoading || !this.checkFormValid())
        .onClick(() => this.handleLogin())
        .margin({ top: 30 });

      // 加载状态
      if (this.isLoading) {
        Progress()
          .width(40)
          .height(40)
          .margin({ top: 20 });
      }

      // 注册入口
      Text('还没有账号?立即注册')
        .fontSize(14)
        .color('#007AFF')
        .margin({ top: 50 })
        .onClick(() => router.pushUrl({ url: Config.ROUTE_REGISTER }));
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#f5f5f5')
    .padding({ left: 30, right: 30 });
  }

  // 渲染QQ登录表单
  @Builder renderQQForm() {
    TextInput()
      .hint('请输入QQ号')
      .value(this.qqNumber)
      .onChange((val) => this.qqNumber = val)
      .style(CommonStyles.inputStyle);
    TextInput()
      .hint('请输入密码')
      .value(this.qqPwd)
      .type(InputType.Password)
      .onChange((val) => this.qqPwd = val)
      .style(CommonStyles.inputStyle)
      .margin({ top: 20 });
  }

  // 渲染手机号登录表单
  @Builder renderPhoneForm() {
    TextInput()
      .hint('请输入手机号')
      .value(this.phoneNumber)
      .onChange((val) => this.phoneNumber = val)
      .style(CommonStyles.inputStyle);
    Row() {
      TextInput()
        .hint('请输入验证码')
        .value(this.verifyCode)
        .onChange((val) => this.verifyCode = val)
        .style(CommonStyles.inputStyle)
        .flexGrow(1);
      Button(this.countDown > 0 ? `${this.countDown}s后重发` : '获取验证码')
        .width(120)
        .height(50)
        .fontSize(14)
        .disabled(this.countDown > 0 || !/^1[3-9]\d{9}$/.test(this.phoneNumber))
        .onClick(() => this.sendVerifyCode())
        .margin({ left: 10 });
    }
    .margin({ top: 20 });
  }

  // 表单校验
  private checkFormValid(): boolean {
    if (this.loginType === 'qq') {
      return /^\d{5,15}$/.test(this.qqNumber) && this.qqPwd.length >= 6;
    } else {
      return /^1[3-9]\d{9}$/.test(this.phoneNumber) && /^\d{6}$/.test(this.verifyCode);
    }
  }

  // 发送验证码
  private async sendVerifyCode() {
    this.countDown = 60;
    const timer = setInterval(() => {
      this.countDown--;
      if (this.countDown <= 0) clearInterval(timer);
    }, 1000);

    try {
      const res = await RequestUtil.sendCode(this.phoneNumber);
      if (!res.success) {
        prompt.showToast({ message: res.message });
        this.countDown = 0;
      }
    } catch (error) {
      prompt.showToast({ message: '验证码发送失败' });
      this.countDown = 0;
    }
  }

  // 核心登录逻辑
  private async handleLogin() {
    this.isLoading = true;
    try {
      let res: LoginResponse;
      // 区分登录类型
      if (this.loginType === 'qq') {
        res = await RequestUtil.qqLogin(this.qqNumber, this.qqPwd);
      } else {
        res = await RequestUtil.phoneLogin(this.phoneNumber, this.verifyCode);
      }

      // 登录成功处理
      if (res.success && res.data?.token) {
        // 1. 存储登录状态(内存+本地持久化)
        AppStorage.setOrCreate('token', res.data.token);
        AppStorage.setOrCreate('userInfo', res.data.user);
        await StorageUtil.set('token', res.data.token);
        await StorageUtil.set('userInfo', res.data.user);

        // 2. 跳转页面(新用户引导/首页)
        if (res.data.user.isNewUser) {
          await router.pushUrl({ url: Config.ROUTE_GUIDE });
        } else {
          await router.pushUrl({ url: Config.ROUTE_MESSAGE });
          router.clear(); // 清除登录页,避免返回
        }
        prompt.showToast({ message: '登录成功' });
      } else {
        prompt.showToast({ message: res.message || '登录失败' });
      }
    } catch (error) {
      const errMsg = error instanceof Error ? error.message : '登录异常';
      prompt.showToast({ message: errMsg });
    } finally {
      this.isLoading = false;
    }
  }
}

       (4)开始登录时的界面:

2. 首页核心功能(登录后辐射场景)

(1)消息列表页(MessagePage.ets)

登录成功后默认进入消息列表,拉取当前用户的聊天会话:

import { RequestUtil } from '../../common/utils/RequestUtil';
import { MessageModel } from '../../models/MessageModel';
import { CommonStyles } from '../../common/styles/CommonStyles';

@Component
export struct MessagePage {
  @State messageList: MessageModel[] = [];
  @State isLoading: boolean = true;

  async aboutToAppear() {
    // 拉取消息列表
    await this.fetchMessageList();
  }

  private async fetchMessageList() {
    try {
      const res = await RequestUtil.request<{ list: MessageModel[] }>('/message/list', {
        method: http.RequestMethod.GET
      });
      this.messageList = res.list;
    } catch (error) {
      prompt.showToast({ message: '消息加载失败' });
    } finally {
      this.isLoading = false;
    }
  }

  build() {
    Column() {
      // 顶部导航栏
      Row() {
        Text('消息')
          .fontSize(22)
          .fontWeight(FontWeight.Bold);
        Image($r('app.media.plus'))
          .width(24)
          .height(24)
          .margin({ left: 'auto' });
      }
      .padding(15)
      .width('100%');

      // 消息列表
      if (this.isLoading) {
        Progress().margin({ top: 50 });
      } else {
        List() {
          ForEach(this.messageList, (item) => {
            ListItem() {
              Row() {
                // 头像
                Image(item.avatar || $r('app.media.default_avatar'))
                  .width(50)
                  .height(50)
                  .borderRadius(25);
                // 消息内容
                Column() {
                  Text(item.nickname)
                    .fontSize(16)
                    .fontWeight(FontWeight.Medium);
                  Text(item.lastMessage)
                    .fontSize(14)
                    .color('#999')
                    .maxLines(1)
                    .width(200);
                }
                .margin({ left: 15 })
                .alignItems(HorizontalAlign.Start);
                // 时间
                Text(item.time)
                  .fontSize(12)
                  .color('#999')
                  .margin({ left: 'auto' });
              }
              .padding(15)
              .width('100%')
              .onClick(() => {
                // 跳转到聊天页
                router.pushUrl({ url: '/pages/home/ChatPage', params: { userId: item.userId } });
              });
            }
          });
        }
        .width('100%')
        .flexGrow(1);
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#f5f5f5');
  }
}

(2)个人中心页(MinePage.ets)

展示登录用户信息,支持退出登录(清除状态):

import AppStorage from '@ohos.app.ability.AppStorage';
import router from '@ohos.router';
import { StorageUtil } from '../../common/utils/StorageUtil';
import { Config } from '../../common/constants/Config';
import { CommonStyles } from '../../common/styles/CommonStyles';

@Component
export struct MinePage {
  @State userInfo = AppStorage.get<any>('userInfo') || {};

  build() {
    Column() {
      // 个人信息头部
      Stack() {
        Image($r('app.media.mine_bg'))
          .width('100%')
          .height(200);
        Column() {
          Image(this.userInfo.avatar || $r('app.media.default_avatar'))
            .width(80)
            .height(80)
            .borderRadius(40)
            .border({ width: 2, color: '#fff' });
          Text(this.userInfo.nickname || '未知用户')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .color('#fff')
            .margin({ top: 10 });
          Text(`QQ号:${this.userInfo.qqNumber || '未绑定'}`)
            .fontSize(14)
            .color('#fff')
            .margin({ top: 5 });
        }
        .alignItems(HorizontalAlign.Center)
        .margin({ top: 60 });
      }

      // 功能列表
      List() {
        ListItem() {
          this.renderSettingItem('设置', 'app.media.setting', () => {
            router.pushUrl({ url: Config.ROUTE_SETTING });
          });
        }
        ListItem() {
          this.renderSettingItem('退出登录', 'app.media.logout', () => {
            this.handleLogout();
          });
        }
      }
      .width('100%')
      .margin({ top: 20 });
    }
    .width('100%')
    .height('100%');
  }

  @Builder renderSettingItem(title: string, icon: string, onClick: () => void) {
    Row() {
      Image($r(`app.media.${icon}`))
        .width(24)
        .height(24);
      Text(title)
        .fontSize(16)
        .margin({ left: 15 });
      Image($r('app.media.arrow_right'))
        .width(16)
        .height(16)
        .margin({ left: 'auto' });
    }
    .padding(15)
    .width('100%')
    .onClick(onClick);
  }

  // 退出登录
  private async handleLogout() {
    // 清除登录状态
    AppStorage.delete('token');
    AppStorage.delete('userInfo');
    await StorageUtil.clear('token');
    await StorageUtil.clear('userInfo');
    // 跳回登录页
    router.replaceUrl({ url: Config.ROUTE_LOGIN });
    prompt.showToast({ message: '已退出登录' });
  }
}

三、鸿蒙开发核心坑点与解决方案

1. 登录相关高频问题

问题现象

根本原因

解决方案

net::ERR_CLEARTEXT_NOT_PERMITTED

鸿蒙禁止HTTP明文传输,配置文件错误

检查network_security_config.json语法(无注释),module.json5引用路径正确

Usage of standard library is restricted

使用ArkTS受限API(如await同步方法)

AppStorage.setOrCreate是同步方法,移除await;避免Promise.allSettled等API

登录成功后重启App需重新登录

仅用AppStorage(内存存储),未持久化

配合Preferences封装StorageUtil,登录状态同步存储到本地文件

Token失效后页面无响应

未处理401状态码

拦截器中捕获401,自动清除状态并跳回登录页

五、总结

本文基于鸿蒙ArkTS的entry工程,完整实现了模拟QQ App的核心功能,重点拆解了登录体系的开发与坑点解决方案,同时覆盖了消息列表、个人中心等核心场景。开发过程中需重点关注「鸿蒙网络安全规范、ArkTS标准库限制、登录状态持久化」三大核心点,既能保证功能正常运行,也能符合鸿蒙应用的开发规范。

该项目的架构设计与代码实现可直接复用至各类鸿蒙社交类App开发,开发者可基于此扩展更多QQ特色功能(如群聊、表情、文件传输等),深入掌握鸿蒙应用开发的核心逻辑。

完整项目代码已同步至 Gitee(地址:),欢迎 Star 交流!

Logo

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

更多推荐