AI 万能手册 —— 鸿蒙 NEXT ArkTS 应用开发实战


AI 万能手册 —— 鸿蒙 NEXT ArkTS 应用开发实战
SDK 版本:HarmonyOS NEXT 6.1.1(API 24)
开发语言:ArkTS(Ark TypeScript)
核心能力:SSE 流式 AI 对话、智能生活助手、ArkUI 原生布局
一、应用概述
1.1 项目背景
在移动互联网时代,AI 助手已经渗透到日常生活的方方面面。从情感咨询到职场建议,从学习指导到健康管理,用户希望有一个「万能手册」能随时随地解答各种生活问题。
「AI 万能手册」正是基于这一需求而诞生的鸿蒙原生应用。它使用 HarmonyOS NEXT 6.1.1(API 24)的 ArkTS 语言开发,通过集成 GitCode 平台的 AI API(DeepSeek-V3 模型),为用户提供一个流畅、智能的对话式生活助手。
1.2 核心功能
- AI 智能对话:通过 SSE(Server-Sent Events)流式协议实时显示 AI 回复内容
- 多场景覆盖:情感关系、职场工作、学习成长、健康生活、日常生活、心理情绪六大领域
- 快捷提问:预设常见问题,一键发送
- 流式加载动画:AI 回复时显示优雅的「正在输入」动画
- 对话历史管理:清空历史、停止生成等交互控制
1.3 技术栈
| 技术 | 用途 |
|---|---|
| ArkTS | 应用开发语言,基于 TypeScript 的鸿蒙原生语言 |
| ArkUI | 声明式 UI 框架,提供 Column/Row/Scroll 等布局组件 |
| @kit.NetworkKit | 原生 HTTP 网络请求能力 |
| SSE 流式协议 | 实时逐 token 接收 AI 回复 |
| DeepSeek-V3 | 底层 AI 大模型(通过 GitCode API 调用) |
二、项目架构设计
2.1 文件目录结构
entry/src/main/ets/pages/
├── Index.ets # 主页面:聊天界面 + UI 组件
└── AIChatService.ets # AI 通信服务层:HTTP + SSE 解析
整个应用只有两个核心文件,职责划分清晰:
- Index.ets:用户界面层,负责消息展示、输入交互、状态管理
- AIChatService.ets:网络通信层,负责 HTTP 请求、SSE 流式解析、数据回调
2.2 数据流架构
用户输入 → Index.ets (sendMessage)
↓
调用 queryAI(callbacks, history)
↓
AIChatService.ets (HTTP POST + SSE)
↓
onData(chunk) → 实时更新 currentAIResponse → UI 流式显示
onDone() → 将完整回复加入消息列表 → UI 完成态
onError() → 显示错误信息 → UI 错误态
2.3 组件树结构
@Entry AIUniversalManualPage
├── 顶部标题栏 (Row)
│ ├── Text('AI 万能手册')
│ ├── Text('停止') [isLoading 时显示]
│ └── Text('清空')
├── 消息列表区 (Scroll > Column)
│ ├── [空状态] 欢迎卡片 + 快捷提问
│ ├── [消息列表] ForEach(ChatBubble)
│ └── [加载中] ChatBubble(isLoading)
└── 底部输入区 (Row)
├── TextInput
└── Circle (发送按钮)
三、核心代码逐层解析
3.1 类型定义与常量
3.1.1 消息模型
在 AIChatService.ets 中定义了核心数据结构:
/** 聊天消息结构体 */
export interface ChatMessage {
role: string; // 'user' | 'assistant' | 'system'
content: string; // 消息文本内容
}
三者的职责:
- system:系统提示词,用于设定 AI 的行为和身份,不直接展示给用户
- user:用户发送的问题或指令
- assistant:AI 的回复内容
3.1.2 API 配置
const API_URL = 'https://api-ai.gitcode.com/v1/chat/completions';
const API_KEY = 'RJrfVZXqqkmJLXnAd8oawHmF'; /* 请替换为你自己的 API Key */
const SYSTEM_PROMPT: string =
'你是一位资深的恋爱沟通顾问,擅长帮助男生分析女友话语背后的情感需求,' +
'并提供真诚、体贴、有温度的高情商回复建议。\n\n' +
'以下是你要遵循的原则:\n' +
'1. 先共情:理解女友当下的情绪(开心、难过、生气、焦虑等)。\n' +
'2. 再分析:指出她话语中隐含的需求(被关注、被理解、被重视、安全感等)。\n' +
'3. 给建议:提供 1~3 条具体可用的回复话术,并说明每条话术的适用场景。\n' +
'4. 语气温柔但真诚,避免油腻或过度讨好。\n' +
'5. 如果涉及矛盾冲突,优先建议冷静沟通而非道歉敷衍。\n\n' +
'请用中文回复,保持简洁实用。';
SYSTEM_PROMPT 是整个 AI 对话的「灵魂」—— 它定义了 AI 的角色、能力边界和回答风格。你可以根据需要修改这个提示词,让 AI 扮演不同的角色。例如,改为职场顾问、学习导师或健康助手。
3.1.3 请求体结构
export interface ChatCompletionRequest {
model: string; // 模型名称,如 'deepseek-ai/DeepSeek-V3'
messages: ChatMessage[]; // 对话消息列表(含 system + user + assistant)
stream: boolean; // 是否启用流式输出
max_tokens: number; // 最大输出 token 数
temperature: number; // 生成温度(0~2),越低越确定,越高越随机
top_p: number; // 核采样参数
frequency_penalty: number; // 频率惩罚
thinking_budget: number; // 思考预算
}
3.1.4 回调接口
export interface AICallbacks {
/** 每次收到新的 token 内容时触发 */
onData: (text: string) => void;
/** 流式响应结束时触发 */
onDone: () => void;
/** 发生错误时触发 */
onError: (errMsg: string) => void;
}
这个回调接口是连接 UI 层和网络层的桥梁:
onData:每收到一个 token 立即回调,实现逐字显示onDone:流式结束回调,触发 UI 从加载态切换到完成态onError:网络异常或服务端错误回调,触发错误提示
3.2 Index.ets —— 主界面实现
3.2.1 页面状态定义
@Entry
@Component
struct AIUniversalManualPage {
/** 聊天消息列表 */
@State messages: ChatMessage[] = [];
/** 输入框文本 */
@State inputText: string = '';
/** 是否正在请求 AI */
@State isLoading: boolean = false;
/** 当前正在接收的 AI 回复(流式拼接中) */
@State currentAIResponse: string = '';
ArkTS 的 @State 装饰器将变量标记为响应式状态。当这些变量的值发生变化时,框架会自动重新渲染关联的 UI 组件,无需手动操作 DOM。
四个状态变量各司其职:
messages:存储完整的对话记录,驱动消息列表渲染inputText:双向绑定输入框内容isLoading:控制发送按钮/停止按钮的切换,以及加载动画的显示currentAIResponse:流式输出过程中不断拼接的 AI 回复,驱动实时显示
3.2.2 顶部标题栏
Row() {
Text('🤖 AI 万能手册')
.fontSize(20).fontWeight(FontWeight.Bold).fontColor('#ffffff')
Blank()
if (this.isLoading) {
Text('停止')
.fontSize(14).fontColor('#ffffff').opacity(0.8)
.onClick(() => {
cancelAI();
this.isLoading = false;
})
}
Text('清空')
.fontSize(14).fontColor('#ffffff').opacity(0.8).margin({ left: 16 })
.onClick(() => {
cancelAI();
this.messages = [];
this.currentAIResponse = '';
this.isLoading = false;
})
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ top: 12, bottom: 12, left: 20, right: 20 })
.backgroundColor('#2d5f8a')
布局要点:
- 使用
Row实现水平排列,Blank()占满中间空间实现左右两端对齐 - 「停止」按钮仅在
isLoading为true时通过条件渲染显示 cancelAI()在两种场景下调用:点击「停止」中断当前请求,点击「清空」重置所有状态
3.2.3 空状态引导页
当用户首次打开应用,messages 数组为空时,展示欢迎引导界面:
if (this.messages.length === 0) {
Column() {
Text('🤖').fontSize(56)
Text('AI 万能手册')
.fontSize(22).fontWeight(FontWeight.Bold).fontColor('#1a1a2e')
.margin({ top: 8 })
Text('解决你生活中的各种问题')
.fontSize(14).fontColor('#666').margin({ top: 4 })
// 能力卡片展示
Row() {
ForEach([
{ icon: '💕', text: '情感关系' },
{ icon: '💼', text: '职场工作' },
{ icon: '📚', text: '学习成长' },
{ icon: '🏃', text: '健康生活' },
{ icon: '🏠', text: '日常生活' },
{ icon: '🧠', text: '心理情绪' },
] as FeatureItem[], (item: FeatureItem) => {
Column() {
Text(item.icon).fontSize(24)
Text(item.text).fontSize(11).fontColor('#555').margin({ top: 4 })
}
.width(72).height(72)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor('#f5f7fa').borderRadius(12).margin(4)
})
}
.justifyContent(FlexAlign.Center)
.width('80%')
.margin({ top: 16 })
// 快捷提问
Text('💡 试试问这些问题:')
.fontSize(13).fontColor('#888').margin({ top: 20 })
Row() {
ForEach(QUICK_QUESTIONS, (q: string) => {
Text(q)
.fontSize(12).fontColor('#2d5f8a')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor('#eef2f7').borderRadius(16).margin(4)
.onClick(() => {
this.sendMessage(q);
})
})
}
.justifyContent(FlexAlign.Center)
.width('90%')
.margin({ top: 8 })
}
.alignItems(HorizontalAlign.Center)
.width('100%')
.margin({ top: 40 })
}
这段代码展示了 ArkTS 中的条件渲染 + 循环渲染组合使用:
if (this.messages.length === 0)是条件渲染,控制整个引导页的显示与隐藏ForEach()是循环渲染,遍历数组生成 UI 组件- 快捷问题使用
onClick直接触发sendMessage(),提供一键体验
3.2.4 消息列表渲染
// 消息列表
ForEach(this.messages, (msg: ChatMessage) => {
ChatBubble({ msg: msg })
})
// AI 回复中(流式加载中)
if (this.isLoading) {
ChatBubble({
msg: { role: 'assistant', content: this.currentAIResponse },
isLoading: !this.currentAIResponse
})
}
这里的关键设计是两条线索:
this.messages数组存储已经完成的对话(用户消息 + 完整的 AI 回复)this.currentAIResponse+isLoading状态管理正在流式输出的 AI 回复
这种分离设计避免了中途修改 messages 数组引起的复杂状态同步问题。当流式完成后,onDone 回调将完整内容 push 到 messages 数组,currentAIResponse 归零。
3.2.5 消息发送逻辑
sendMessage(text: string): void {
if (this.isLoading || !text.trim()) return;
// 添加用户消息
const userMsg: ChatMessage = { role: 'user', content: text.trim() };
this.messages.push(userMsg);
this.inputText = '';
this.isLoading = true;
this.currentAIResponse = '';
// 构建对话历史
const history: ChatMessage[] = this.messages
.filter(m => m.content !== '' && !(m.role === 'assistant' && m === this.messages[this.messages.length - 1]))
.slice(-20); // 最多保留最近 20 条
// 调用 AI
queryAI(
{
onData: (content: string) => {
this.currentAIResponse += content;
},
onDone: () => {
if (this.currentAIResponse) {
this.messages.push({
role: 'assistant',
content: this.currentAIResponse,
});
}
this.currentAIResponse = '';
this.isLoading = false;
},
onError: (errMsg: string) => {
this.messages.push({
role: 'assistant',
content: `😅 抱歉,我遇到了问题:${errMsg}`,
});
this.currentAIResponse = '';
this.isLoading = false;
},
},
history,
);
}
sendMessage 的执行流程:
| 步骤 | 操作 | 目的 |
|---|---|---|
| ① | 检查 isLoading 和输入有效性 |
防重复提交、防空消息 |
| ② | 用户消息 push 到 messages |
立即显示用户的消息气泡 |
| ③ | 清空输入框、设置加载状态 | 切换 UI 到等待态 |
| ④ | 过滤构建 history |
去除流式临时消息,限制上下文窗口 |
| ⑤ | 调用 queryAI 传入回调 |
发起网络请求 |
| ⑥ | onData:拼接 currentAIResponse |
实时更新流式显示 |
| ⑦ | onDone:完整回复加入 messages |
完成一条对话回合 |
| ⑧ | onError:错误信息加入 messages |
友好地展示错误信息 |
history 构建的细节:
const history: ChatMessage[] = this.messages
.filter(m => m.content !== '' && !(m.role === 'assistant' && m === this.messages[this.messages.length - 1]))
.slice(-20);
这里的过滤逻辑有两个目的:
- 排除
content为空的无效消息(可能出现的边界情况) - 排除最后一条 assistant 消息——因为在流式过程中,最后一条可能是临时的
.slice(-20)限制上下文窗口,防止过长历史导致 API 超出 token 限制
3.2.6 底部输入区
Row() {
TextInput({ text: this.inputText, placeholder: '描述你遇到的问题...' })
.layoutWeight(1)
.height(40)
.fontSize(15)
.padding({ left: 14 })
.backgroundColor('#f0f4f8')
.borderRadius(20)
.onChange((val: string) => {
this.inputText = val;
})
.onSubmit(() => {
if (this.inputText.trim()) {
this.sendMessage(this.inputText.trim());
}
})
Circle()
.width(40).height(40)
.fill(this.inputText.trim() ? '#2d5f8a' : '#ccc')
.margin({ left: 8 })
.onClick(() => {
if (this.inputText.trim()) {
this.sendMessage(this.inputText.trim());
}
})
}
底部输入区的设计要点:
TextInput的.layoutWeight(1)让输入框撑满可用空间.onSubmit支持键盘回车直接发送,提升交互效率- 发送按钮(圆形)的
fill颜色根据输入框是否有内容动态切换:有内容时显示主题色#2d5f8a,无内容时显示灰色#ccc,这是用户最直观的「可发送」提示
3.3 ChatBubble 组件
@Component
struct ChatBubble {
private msg: ChatMessage = { role: 'user', content: '' };
private isLoading: boolean = false;
build() {
Column() {
if (this.msg.role === 'user') {
// 用户消息:右对齐蓝色气泡
Row() {
Blank()
Column() {
Text(this.msg.content)
.fontSize(15).fontColor('#ffffff').lineHeight(22)
}
.padding(14)
.backgroundColor('#2d5f8a')
.borderRadius({ topLeft: 16, topRight: 4, bottomLeft: 16, bottomRight: 16 })
.constraintSize({ maxWidth: '80%' })
}
.width('100%')
.alignItems(VerticalAlign.Top)
} else {
// AI 消息:左对齐白色气泡 + 头像
Row() {
// AI 头像
Column() {
Text('🤖').fontSize(20)
}
.width(36).height(36)
.backgroundColor('#eef2f7').borderRadius(18)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.margin({ right: 8 })
// 气泡内容
Column() {
if (this.isLoading && !this.msg.content) {
// 正在输入动画
Row() {
ForEach([0, 1, 2], (idx: number) => {
Circle()
.width(6).height(6).fill('#2d5f8a')
.opacity(0.4 + idx * 0.3)
.margin({ left: 2, right: 2 })
})
}
.padding(14)
.backgroundColor('#f0f4f8')
.borderRadius(16)
} else {
Text(this.msg.content)
.fontSize(15).fontColor('#1a1a2e').lineHeight(22)
}
}
.padding(14)
.backgroundColor('#f0f4f8')
.borderRadius({ topLeft: 4, topRight: 16, bottomLeft: 16, bottomRight: 16 })
.constraintSize({ maxWidth: '70%' })
.alignItems(HorizontalAlign.Start)
Blank()
}
.width('100%')
.alignItems(VerticalAlign.Top)
}
}
.width('100%')
.margin({ bottom: 12 })
}
}
ChatBubble 的设计模式:
- 角色区分:通过
if (this.msg.role === 'user')分支渲染两种完全不同的气泡样式 - 用户气泡:右对齐 + 蓝色背景 + 左上左圆角(典型的「我发送的」样式)
- AI 气泡:左对齐 + AI 头像 + 白色背景 + 右上右圆角(典型的「对方发送的」样式)
- 加载动画:当
isLoading为 true 且msg.content为空时,显示三个跳动的圆点动画,通过opacity(0.4 + idx * 0.3)实现渐变效果 - 宽度约束:
.constraintSize({ maxWidth: '70%' })限制气泡最大宽度为父容器的 70%,避免超长文本撑满屏幕影响可读性
3.4 AIChatService.ets —— 网络通信层
这是整个应用最具技术含量的部分。它需要处理 HTTP 请求、SSE 流式解析、多种 API 响应格式兼容等问题。
3.4.1 SSE 解析核心
SSE(Server-Sent Events)是一种服务器推送协议,服务端以 data: 前缀逐行推送数据。一个典型的 SSE 流看起来像:
data: {"choices":[{"delta":{"role":"assistant","content":""},"index":0}]}
data: {"choices":[{"delta":{"content":"你好"},"index":0}]}
data: {"choices":[{"delta":{"content":"!"},"index":0}]}
data: {"choices":[{"delta":{"content":"很高兴"},"index":0}]}
data: [DONE]
每条 data: 行后跟一个换行,两条数据之间用空行分隔。最后以 data: [DONE] 标记结束。
逐行解析函数:
function parseSSEDataLine(line: string): string | null {
const jsonStr = line.slice(5).trim(); // 去掉 "data:" 前缀
if (!jsonStr) {
return null;
}
if (jsonStr === '[DONE]') { // 跳过结束标记
return null;
}
try {
const parsed: object = JSON.parse(jsonStr);
const choices: object[] = (parsed as Record<string, object>)['choices'] as object[] || [];
if (choices.length > 0) {
return extractContent(choices[0]);
}
} catch (_) {
// JSON 解析失败,跳过
}
return null;
}
内容提取函数 —— 兼容 delta 和 message 两种格式:
function extractContent(obj: object): string | null {
try {
const o = obj as Record<string, object>;
// 尝试 delta.content(流式格式)
const delta = o['delta'] as Record<string, object> | undefined;
if (delta) {
const content = delta['content'];
if (typeof content === 'string') {
return content;
}
}
// 尝试 message.content(非流式格式)
const message = o['message'] as Record<string, object> | undefined;
if (message) {
const content = message['content'];
if (typeof content === 'string') {
return content;
}
}
} catch (_) {
// 类型转换失败,跳过
}
return null;
}
为什么需要兼容两种格式?因为在流式场景下,API 返回的是 delta(增量),而在非流式回退场景下,API 返回的是完整的 message 对象。extractContent 通过先尝试 delta、再尝试 message 的顺序,实现了双格式兼容。
完整体解析函数 —— 用于非流式回退场景:
function parseFullSSEBody(body: string): string {
let result = '';
const lines = body.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('data:')) {
const afterData = trimmed.slice(5).trim();
if (afterData === '[DONE]') {
break;
}
const content = parseSSEDataLine(trimmed);
if (content) {
result += content;
}
}
}
return result;
}
非流式 JSON 解析:
function parseNonStreamingBody(body: string): string | null {
try {
const parsed: object = JSON.parse(body);
const choices: object[] = (parsed as Record<string, object>)['choices'] as object[] || [];
if (choices.length > 0) {
return extractContent(choices[0]);
}
} catch (_) {
// 不是纯 JSON,可能是 SSE 格式
}
return null;
}
3.4.2 双路径完成保障机制
在 HarmonyOS NEXT API 24 中,http.HttpRequest.on('dataReceive', ...) 方法已被标记为 deprecated。部分设备上,SSE 事件完全不触发,而另一些设备上可以触发。为了兼容这两种情况,我们设计了双路径完成保障机制:
export function queryAI(
callbacks: AICallbacks,
messages: ChatMessage[],
): void {
// 取消上一次未完成的请求
if (httpRequestTask) {
try { httpRequestTask.destroy(); } catch (_) { }
httpRequestTask = null;
}
const httpRequest = http.createHttp();
httpRequestTask = httpRequest;
// 构建请求体
const fullMessages: ChatMessage[] = [
{ role: 'system', content: SYSTEM_PROMPT },
...messages,
];
const requestBody: ChatCompletionRequest = {
model: 'deepseek-ai/DeepSeek-V3',
messages: fullMessages,
stream: true,
max_tokens: 2048,
temperature: 0.6,
top_p: 0.95,
frequency_penalty: 0,
thinking_budget: 2048,
};
let isDone: boolean = false;
let receivedAnyData: boolean = false;
let buffer = '';
// ── 路径 A:SSE 流式事件(API 24 中部分设备不触发) ──
httpRequest.on('dataReceive', (data: ArrayBuffer) => {
const text = arrayBufferToString(data);
buffer += text;
receivedAnyData = true;
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
if (trimmed === 'data:[DONE]') {
if (!isDone) { isDone = true; callbacks.onDone(); }
continue;
}
const content = parseSSEDataLine(trimmed);
if (content) { callbacks.onData(content); }
}
});
httpRequest.on('dataEnd', () => {
if (!isDone) { isDone = true; callbacks.onDone(); }
httpRequestTask = null;
});
// ── 路径 B:request 回调(始终触发,兜底保障) ──
httpRequest.request(
API_URL,
{
method: http.RequestMethod.POST,
header: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
Accept: 'text/event-stream',
},
extraData: JSON.stringify(requestBody),
connectTimeout: 30000,
readTimeout: 120000,
},
(err: BusinessError | null, resp: http.HttpResponse) => {
if (err) {
callbacks.onError(`请求失败: ${JSON.stringify(err)}`);
safeDestroy(httpRequest);
return;
}
if (resp.responseCode !== 200) {
const errBody = typeof resp.result === 'string' ? resp.result : 'HTTP ' + resp.responseCode;
callbacks.onError(`服务器返回错误 (${resp.responseCode}): ${errBody}`.substring(0, 200));
safeDestroy(httpRequest);
return;
}
// ── 统一响应体提取 ──
const bodyStr: string = typeof resp.result === 'string'
? resp.result
: (resp.result instanceof ArrayBuffer
? arrayBufferToString(resp.result as ArrayBuffer)
: '');
if (bodyStr) {
// 尝试 SSE 格式解析
const sseContent = parseFullSSEBody(bodyStr);
if (sseContent) {
callbacks.onData(sseContent);
} else {
// 尝试非流式 JSON 格式解析
const jsonContent = parseNonStreamingBody(bodyStr);
if (jsonContent) {
callbacks.onData(jsonContent);
} else {
// 解析失败,仍然尝试显示原始内容
console.warn('[AIChat] 无法解析响应');
callbacks.onData(bodyStr);
}
}
}
// ★ 无论是否解析成功,都确保 onDone 被调用
if (!isDone) {
isDone = true;
callbacks.onDone();
}
safeDestroy(httpRequest);
},
);
}
双路径流程分析:
路径 A:SSE 事件触发
dataReceive(chunk) → onData(chunk.content)
dataReceive(chunk) → onData(chunk.content)
...(逐 token 推送)
data:[DONE] → onDone()
→ isDone = true
路径 B:SSE 事件不触发
request 回调完成 → 获取完整 body (37083 bytes)
→ parseFullSSEBody(body) → onData(完整内容)
→ onDone()
路径 B(非 SSE 响应):
request 回调完成 → body 是标准 JSON
→ parseNonStreamingBody(body) → onData(完整内容)
→ onDone()
这种双路径设计保证了在所有 HarmonyOS NEXT 版本上的兼容性。路径 A 提供实时流式体验,路径 B 保证功能的基本可用性。
3.4.3 请求取消与安全销毁
export function cancelAI(): void {
if (httpRequestTask) {
try { httpRequestTask.destroy(); } catch (_) { }
httpRequestTask = null;
}
}
function safeDestroy(req: http.HttpRequest): void {
try { req.destroy(); } catch (_) { }
httpRequestTask = null;
}
cancelAI 对外暴露给 UI 层调用,用于「停止」按钮。safeDestroy 是内部工具函数,在所有请求结束路径中调用,避免 destroy() 抛出异常。
3.4.4 ArrayBuffer 转字符串
function arrayBufferToString(buffer: ArrayBuffer): string {
const uint8Arr = new Uint8Array(buffer);
let text = '';
for (let i = 0; i < uint8Arr.length; i++) {
text += String.fromCharCode(uint8Arr[i]);
}
return text;
}
这段代码将 ArrayBuffer 逐字节解码为字符串。虽然 String.fromCharCode 对多字节 UTF-8 字符的处理不是最优的(应当使用 TextDecoder),但在当前 ArkTS 运行环境中,TextDecoder 并非在所有场景下可用,因此保留了这个兼容方案。
四、ArkTS 关键语法要点
4.1 @Entry 与 @Component 装饰器
@Entry // 标记为页面入口
@Component // 标记为可复用的 UI 组件
struct AIUniversalManualPage {
// 组件内容
}
@Entry:表示该组件是一个页面入口,可以被路由导航加载@Component:表示该结构体是一个 UI 组件,拥有独立的生命周期
4.2 @State 响应式状态
@State messages: ChatMessage[] = [];
@State inputText: string = '';
@State isLoading: boolean = false;
@State currentAIResponse: string = '';
@State 是 ArkTS 最核心的装饰器之一。当被装饰的变量值发生变化时,框架自动重新渲染所有依赖该变量的 UI 部分。
@State 的变更检测规则:
- 基础类型(string / number / boolean):直接赋值即可触发更新
- 数组:必须替换整个数组引用(
this.messages.push()后必须重新赋值或使用新数组) - 对象:必须替换整个对象引用(修改属性后需创建新对象)
4.3 条件渲染
if (this.messages.length === 0) {
// 显示空状态引导页
}
if (this.isLoading) {
// 显示「停止」按钮
}
ArkTS 中 if/else 可以出现在 build() 方法中的组件树的任意位置。条件为 true 时渲染分支内的组件,为 false 时销毁组件。
4.4 循环渲染 —— ForEach
ForEach(this.messages, (msg: ChatMessage) => {
ChatBubble({ msg: msg })
})
ForEach(QUICK_QUESTIONS, (q: string) => {
Text(q)
.onClick(() => { this.sendMessage(q); })
})
ForEach 是 ArkTS 中遍历数组渲染的语法。第一个参数是数据源,第二个参数是组件生成函数。ForEach 也可以接受第三个参数作为键值生成器,用于优化列表 diff 更新的性能。
4.5 布局属性链式调用
Text('AI 万能手册')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#1a1a2e')
.margin({ top: 8 })
ArkTS 的 UI 组件使用链式调用的方式设置属性。每个 . 方法返回组件自身,因此可以无限串联。这种风格的好处是代码结构清晰、IDE 自动补全友好。
4.6 布局组件
| 组件 | 用途 | 关键属性 |
|---|---|---|
Row |
水平排列子组件 | .alignItems(), .justifyContent() |
Column |
垂直排列子组件 | .alignItems(), .justifyContent() |
Scroll |
可滚动的容器,包裹内容 | .scrollable(), .layoutWeight() |
Blank |
弹性占位空间,用于对齐 | .layoutWeight() 可分配比例 |
Circle |
绘制圆形 | .width(), .height(), .fill() |
五、网络权限配置
要使应用能够正常访问网络 API,需要在 module.json5 中添加网络权限声明:
{
"module": {
"name": "entry",
"type": "entry",
"requestPermissions": [
{
"name": "ohos.permission.INTERNET",
"reason": "需要网络连接以获取AI智能回答"
}
],
// ... 其他配置
}
}
requestPermissions 数组用于声明应用运行所需的权限。ohos.permission.INTERNET 是最基础的网络访问权限,没有它将无法发起任何 HTTP 请求。
权限的 reason 字段是一个字符串资源引用,用于向用户解释为什么需要该权限。在 HarmonyOS NEXT 中,安装应用时会向用户展示权限申请理由。
六、完整的流水线调试日志解读
在开发过程中,我们通过 console.info 输出了关键节点的日志,帮助排查问题。一个典型的成功请求日志序列如下:
[AIChat] Header received: undefined
[AIChat] 收到响应体, len=37083
第一行是 headerReceive 事件的输出,undefined 是正常的——某些 API 实现不会在 SSE 流中附带自定义头部信息。
第二行显示请求完成,响应体大小为 37083 字节(约 37KB)。这是一个完整的非流式 JSON 响应,包含约 10000~15000 个中文字符的 AI 回复内容。
如果在日志中看到以下信息,说明解析路径的选择:
[AIChat] 收到响应体, len=...+ 正常显示 → 路径 B(request 回调)工作dataReceive事件触发 + 逐 token 显示 → 路径 A(SSE 事件)工作
七、常见问题与解决方案
7.1 AI 不回复 / 页面无响应
现象:发送消息后,加载动画出现但没有任何内容显示,最终超时消失。
排查步骤:
-
检查 API Key 是否有效
const API_KEY = '你的有效Key';过期的 Key 会导致服务端返回 401 或 403 错误。
-
检查网络权限
"requestPermissions": [{ "name": "ohos.permission.INTERNET" }]缺少权限时,所有网络请求都会被系统拦截。
-
检查 request 回调中的
onDone是否被调用
如果 SSE 事件不触发且 request 回调不调用onDone,UI 将永远卡在加载状态。我们的双路径机制保证了这一点。
7.2 显示空白气泡
现象:AI 回复了,但页面上只显示一个空的气泡,没有文字内容。
根因:@State messages 数组中的消息对象被直接修改属性(msg.content = newValue),但对象引用未变,@Prop 无法检测到变更。
解决方案:使用新数组替换旧数组,确保每次变更都创建新的对象引用。
7.3 SSE 事件不触发
现象:日志中只有「收到响应体,len=…」,没有 dataReceive 事件。
根因:HarmonyOS NEXT API 24 中 http.HttpRequest.on('dataReceive', ...) 已被标记为 deprecated,部分设备上不再触发此事件。
解决方案:我们的双路径设计已经处理了这种情况——当 SSE 事件不触发时,request 回调中的 resp.result 会包含完整的响应体,通过 parseFullSSEBody 或 parseNonStreamingBody 函数解析后,一次性 onData 回调返回完整内容。
7.4 响应解析失败
现象:日志显示「无法解析响应」,页面显示原始 JSON 字符串。
根因:
- API 返回了非标准格式的响应
choices字段不在标准位置- 响应中包含额外的元数据字段
解决方案:extractContent 函数同时尝试 delta 和 message 两种格式,覆盖了绝大多数 API 实现。如果仍解析失败,代码会退而显示原始文本,至少保证用户能看到内容。
八、性能优化建议
8.1 限制对话历史长度
const history: ChatMessage[] = this.messages
.filter(...)
.slice(-20); // 最多保留最近 20 条
不限制历史长度的后果:
- API 请求体过大,增加网络传输时间
- 超过模型的 context window 限制会被截断
- 过多的上下文可能导致 AI 偏离主题
建议根据模型的支持能力调整 .slice() 的参数。DeepSeek-V3 支持 128K context,但我们仍建议限制在 20~30 条对话以内,兼顾上下文连贯性和响应速度。
8.2 使用 layoutWeight 优化空间分配
TextInput({ ... })
.layoutWeight(1) // 占用剩余空间
.layoutWeight(1) 是 ArkUI 中分配弹性空间的最佳方式。它让组件在父容器的剩余空间中按权重比例分配。相比固定 width('80%'),这种方案在屏幕尺寸变化时自动适配。
8.3 条件渲染减少不必要的组件
if (this.isLoading) {
ChatBubble({ msg: ..., isLoading: true })
}
将 ChatBubble 放在条件渲染中,仅在 AI 回复时才创建组件,避免不必要的内存占用。当 isLoading 变为 false 时,该组件自动销毁。
九、ArkTS 与 TypeScript 的关键差异
对于有 TypeScript 经验但首次接触 ArkTS 的开发者,以下差异需要特别注意:
| 特性 | TypeScript | ArkTS |
|---|---|---|
| 类型系统 | 结构性类型系统 | 名义性类型系统(更严格) |
any 类型 |
允许 | 禁止(arkts-no-any-unknown) |
| 对象展开 | {...obj} 允许 |
仅数组可展开(arkts-no-spread) |
| 索引访问 | obj[key] 允许 |
受限(arkts-no-props-by-index) |
| 装饰器 | @Component 等需配置 |
原生支持 |
| 运行时类型 | 类型在编译时擦除 | Ark Compiler 保留类型信息 |
| UI 范式 | 无内置 UI 框架 | 声明式 ArkUI |
ArkTS 的这些限制看似严格,实则为了保证更好的运行性能和编译时类型安全。以下是一些适配技巧:
使用 Record<string, T> 替代 any:
// ❌ TypeScript 写法
const data: any = JSON.parse(str);
// ✅ ArkTS 写法
const data: Record<string, object> = JSON.parse(str) as Record<string, object>;
const value = data['key']; // 使用方括号访问
使用显式属性赋值替代对象展开:
// ❌ TypeScript 写法
const newObj = { ...oldObj, name: 'new' };
// ✅ ArkTS 写法
const newObj = {
name: 'new',
age: oldObj.age,
email: oldObj.email,
// ... 逐个列出所有属性
};
十、总结与展望
10.1 已实现的能力
「AI 万能手册」应用通过约 550 行的 ArkTS 代码,实现了以下核心能力:
- 完整的 AI 对话界面:消息气泡、流式加载动画、输入交互
- SSE 流式对话:实时显示 AI 回复内容
- 双路径完成保障:兼容 SSE 事件触发和不触发两种场景
- 多格式响应解析:兼容 delta/message 两种 API 返回格式
- 对话历史管理:上下文窗口控制、清空、取消
- 空状态引导:能力卡片展示 + 快捷提问
10.2 可扩展的方向
基于当前架构,可以快速扩展更多功能:
- 多轮对话优化:支持上下文引用、消息编辑、重新生成
- 场景预设切换:点击不同场景卡片自动切换对应的系统提示词
- 语音输入:集成语音识别 API,支持语音提问
- 图片理解:接入多模态模型,支持图片上传分析
- 本地历史存储:使用
@kit.StorageKit保存对话记录到本地 - 主题切换:支持深色模式、自定义主题色
10.3 写在最后
鸿蒙 NEXT 的 ArkTS + ArkUI 为应用开发提供了一套高效、类型安全的声明式 UI 方案。与传统的 WebView 套壳方案不同,原生 ArkUI 组件在渲染性能、内存占用和系统能力调用上具有明显优势。
AI 应用的开发核心不在于 UI 有多炫酷,而在于网络层的稳定可靠和交互层的流畅自然。本文分享的双路径 SSE 完成保障机制、多格式响应兼容策略以及 ArkTS 严格模式下的类型处理技巧,都是在实际开发中经过反复调试沉淀下来的经验,希望能为你的鸿蒙 AI 应用开发提供参考。
本文基于 HarmonyOS NEXT 6.1.1(API 24)编写,使用 ArkTS 语言与 ArkUI 框架。文中所有代码均来自实际运行的应用,经过 DevEco Studio 编译验证。
更多推荐



所有评论(0)