鸿蒙原生应用开发实战:从零搭建家庭能源管理 App,深度集成蓝耘元生代 MaaS 实现 AI 流式对话
鸿蒙原生应用开发实战:从零搭建家庭能源管理 App,深度集成蓝耘元生代 MaaS 实现 AI 流式对话
本文记录一个完整的 HarmonyOS 原生应用从架构设计到上线编译的全过程——以「家庭能源统计」为业务载体,深度集成蓝耘元生代 MaaS 平台,实现 AI 能源助手对话、多模型切换、Markdown 富文本渲染与品牌视觉融合。所有代码均通过 DevEco Studio 编译验证。


一、为什么要在鸿蒙上接蓝耘 MaaS
我手头有一个家庭能源管理的需求:记录每月水电气读数,生成趋势图,帮用户做节能决策。痛点是——用户看到一堆数字,不知道该怎么优化。
传统做法是写死规则引擎:用电超 300 度提示「偏高」。但规则是死的,三口之家和五口之家的「正常用电」完全不同。于是想到接大模型,让 AI 结合上下文给个性化建议。问题来了——在鸿蒙端怎么接?
我选了蓝耘元生代 MaaS:https://maas.lanyun.net/#/model/modelSquare,理由:
| 考量维度 | 蓝耘 MaaS 的表现 | 为什么重要 |
|---|---|---|
| 协议兼容 | OpenAI 兼容 Chat Completions | 鸿蒙端用 @kit.NetworkKit 的 http 模块发 POST,零学习成本 |
| 模型覆盖 | DeepSeek / Kimi / Qwen / GLM / MiniMax 等 50+ 模型 | 一个 API Key 调所有模型,切换只改一个字符串 |
| 流式支持 | SSE 逐字返回 | 聊天框体感丝滑,用户不用干等整段弹出 |
| 成本透明 | usage 字段含 reasoning_tokens / cached_tokens |
移动端流量敏感,能精确追踪 Token 消耗 |
| 免费额度 | 注册即送体验 Token | 开发调试阶段不花钱 |



蓝耘 MaaS 把「选模型、调网关、管计费」收成了统一入口,鸿蒙端只需要一套 HTTP 调用代码,就能在后端自由切换所有主流大模型。
二、项目整体架构
2.1 技术栈
平台 : HarmonyOS NEXT (API 12+)
语言 : ArkTS (严格模式)
UI : ArkUI 声明式
架构 : Stage 模型(UIAbility + 多页面 Tabs)
网络 : @kit.NetworkKit → http.createHttp()
AI后端 : 蓝耘元生代 MaaS (https://maas-api.lanyun.net/v1)
主模型 : deepseek-v4-flash(深度思考模型)
调用方式: 非流式 chat()(模拟器)/ 流式 chatStream()(真机)
渲染 : RichText + Markdown→HTML(AI 回复富文本)
日志 : hilog 分层(网络层 0xA002 / UI 层 0xA001)
2.2 工程目录
entry/src/main/ets/
├── entryability/EntryAbility.ets ← Stage 模型入口,沉浸式状态栏
├── common/
│ ├── Theme.ets ← 蓝耘品牌色系
│ └── LanYunAI.ets ← 蓝耘 MaaS AI 服务层(核心)
└── pages/
├── Index.ets ← 主入口,底部五 Tab
├── HomeTab.ets ← 首页:能耗概览 + 蓝耘品牌
├── Func1Tab.ets ← 记录页:录入读数
├── Func2Tab.ets ← 分析页:月度趋势柱状图
├── AITab.ets ← AI 助手页:多模型对话(核心)
└── ProfileTab.ets ← 我的页:蓝耘平台介绍

架构采用 「数据展示 + AI 增值」 双层:数据层是传统业务逻辑(ArkUI 声明式 UI),AI 层是独立的 AI 助手 Tab。AI 服务层封装为独立模块,业务页面通过 import 调用,解耦干净。AI 功能是增量,不是侵入——蓝耘服务挂了,基础能耗统计照常运行。
三、Stage 模型入口:沉浸式状态栏
// EntryAbility.ets
async onWindowStageCreate(windowStage: window.WindowStage): Promise<void> {
windowStage.loadContent('pages/Index', (err) => {
if (err.code) { return; }
const win = windowStage.getMainWindowSync();
win.setWindowLayoutFullScreen(true); // 1. 全屏沉浸式
win.setWindowSystemBarProperties({
statusBarContentColor: '#1C2333' // 2. 状态栏文字深色
});
// 3. 安全区域存入 AppStorage
const top = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
const bottom = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR);
AppStorage.setOrCreate('safeTop', px2vp(top.topRect.height));
AppStorage.setOrCreate('safeBottom', px2vp(bottom.bottomRect.height));
});
}
三个关键点:setWindowLayoutFullScreen(true) 让蓝耘品牌渐变头部铺满顶部;状态栏文字设为深色适配蓝耘深蓝头部;安全区域存入 AppStorage,每个页面通过 @StorageProp('safeTop') 读取,自动适配不同设备。

四、蓝耘品牌色系:Theme.ets
export class C {
static readonly primary: string = '#1B4FCC'; // 蓝耘深蓝(主色)
static readonly primaryDeep: string = '#0D2E7A'; // 蓝耘墨蓝
static readonly accent: string = '#3B82F6'; // 蓝耘亮蓝
static readonly accentCyan: string = '#06B6D4'; // 蓝耘青蓝
static readonly primarySoft: string = '#DCE7FB'; // 蓝耘浅蓝
// 蓝耘渐变组合(135° 对角线)
static readonly gradLanYun: LinearGradient = {
angle: 135, colors: [['#1B4FCC', 0.0], ['#3B82F6', 0.5], ['#06B6D4', 1.0]]
};
static readonly gradLanYunDeep: LinearGradient = {
angle: 135, colors: [['#0D2E7A', 0.0], ['#1B4FCC', 1.0]]
};
}
主色 #1B4FCC 用于所有主按钮、选中态、品牌标识。gradLanYun 三段渐变(深蓝→亮蓝→青蓝)用于首页支出卡、AI 发送按钮、用户头像。gradLanYunDeep 墨蓝渐变用于品牌横幅,更沉稳。

五、蓝耘 MaaS AI 服务层:LanYunAI.ets(核心)
5.1 连接配置
const BASE_URL = 'https://maas-api.lanyun.net/v1';
const API_KEY = 'sk-hfp6wgtzvwfw5wpoj36xcvxrmtokmbhrn7brbgll6bina7i6';
const DEFAULT_MODEL = 'deepseek-v4-flash';
BASE_URL 必须精确到 /v1。DEFAULT_MODEL 用 deepseek-v4-flash,适度推理(思考税约 50-60%),速度和质量平衡最好。
安全提醒:演示直接硬编码了 Key。生产环境务必用后端中转,不要把 Key 写进客户端代码。泄露后立即在蓝耘控制台删除重建。
5.2 非流式调用 chat()
export async function chat(messages: ChatMessage[], model: string = DEFAULT_MODEL,
maxTokens: number = 2048, temperature: number = 0.4): Promise<string> {
const client = http.createHttp();
hilog.info(DOMAIN, TAG, 'chat start: model=%{public}s', model);
// 超时保护:90 秒未返回则主动 reject,防止永久挂起
const timeoutPromise = new Promise<string>((_, reject) => {
setTimeout(() => reject(new Error('请求超时(90s)')), 90000);
});
const requestPromise = new Promise<string>(async (resolve, reject) => {
try {
const resp = await client.request(BASE_URL + '/chat/completions', {
method: http.RequestMethod.POST,
header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + API_KEY },
extraData: JSON.stringify({ model, messages, max_tokens: maxTokens,
temperature, stream: false }),
connectTimeout: 30000, readTimeout: 60000,
});
hilog.info(DOMAIN, TAG, 'responseCode=%{public}d', resp.responseCode);
if (resp.responseCode !== 200) {
resolve('[请求失败] 状态码: ' + resp.responseCode);
return;
}
const json: ChatResponse = JSON.parse(`${resp.result}`);
const msg = json.choices?.[0]?.message;
if (!msg) { resolve('[蓝耘 AI 返回为空]'); return; }
// ⚠️ 关键:推理模型正文可能在 content,也可能只有 reasoning_content
const content = msg.content ?? '';
const reasoning = msg.reasoning_content ?? '';
hilog.info(DOMAIN, TAG, 'content len=%{public}d, reasoning len=%{public}d',
content.length, reasoning.length);
resolve(content.length > 0 ? content : (reasoning.length > 0 ? reasoning : '[蓝耘 AI 返回为空]'));
} catch (e) { reject(e); }
});
try {
return await Promise.race([requestPromise, timeoutPromise]);
} catch (e) {
return '[请求异常] ' + (e as Error).message;
} finally {
client.destroy();
}
}
四个关键设计:
max_tokens从 1024 提到 2048——推理模型会把 token 大半消耗在思维链上,1024 经常导致正文被截断甚至完全为空content为空时回退到reasoning_content——deepseek-v4-flash这类推理模型,短回答场景下正文可能为空、只有思维链Promise.race超时保护——模拟器网络栈偶发挂起,没有超时保护会永久卡在「思考中」- 分层 hilog——网络层用独立 domain(
0xA002)和 tag(LanYunAI),与 UI 层(tagAITab)隔离,出问题一眼定位是哪一层
resp.result 统一用 `${resp.result}` 转字符串——ArkTS 里它的类型可能是 string / ArrayBuffer / Object,直接 as string 在某些场景会拿到 [object Object]。
5.3 流式调用 chatStream()——聊天框的灵魂
⚠️ 血泪教训:SSE 是长连接,服务端推完数据不会主动断开。用
await http.request()等 SSE 响应会永久阻塞——request()默认等完整响应体才 resolve,而 SSE 没有「完整」这个概念。必须用requestInStream()。
export async function chatStream(messages: ChatMessage[], callbacks: StreamCallbacks,
model: string = DEFAULT_MODEL, maxTokens: number = 2048, temperature: number = 0.4): Promise<void> {
const httpReq = http.createHttp();
let fullText = '';
let buffer = '';
let done = false;
const processLines = (text: string) => {
buffer += text; // ⚠️ 跨块缓冲
const lines = buffer.split('\n');
buffer = lines.pop() ?? ''; // 最后一行可能不完整,留到下一块
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) { continue; }
const dataStr = trimmed.slice(5).trim();
if (dataStr === '[DONE]') { continue; }
try {
const obj: StreamResponse = JSON.parse(dataStr);
const delta = obj.choices?.[0]?.delta;
if (!delta) { continue; }
// 思维链与正文都累加进 fullText,保证极端情况下有内容可展示
if (delta.reasoning_content) {
fullText += delta.reasoning_content;
callbacks.onReasoning?.(delta.reasoning_content);
}
if (delta.content) {
fullText += delta.content;
callbacks.onContent?.(delta.content);
}
} catch (_) { /* 跳过无法解析的行 */ }
}
};
const finish = () => {
if (done) { return; }
done = true;
callbacks.onDone?.(fullText);
httpReq.off('dataReceive');
httpReq.off('dataEnd');
httpReq.destroy();
};
// 逐块接收流式数据(只有 requestInStream 才会触发)
httpReq.on('dataReceive', (data: ArrayBuffer) => {
processLines(util.TextDecoder.create('utf-8').decodeToString(new Uint8Array(data)));
});
httpReq.on('dataEnd', () => { finish(); });
try {
// ⚠️ 关键:必须用 requestInStream,不能用 request
const code = await httpReq.requestInStream(BASE_URL + '/chat/completions', {
method: http.RequestMethod.POST,
header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + API_KEY,
'Accept': 'text/event-stream' },
extraData: JSON.stringify({ model, messages, max_tokens: maxTokens,
temperature, stream: true }),
connectTimeout: 30000, readTimeout: 120000,
});
if (code !== 200) { callbacks.onError?.('状态码: ' + code); httpReq.destroy(); }
} catch (e) {
callbacks.onError?.((e as Error).message);
httpReq.destroy();
}
}
三个关键点:
| 要点 | 说明 |
|---|---|
requestInStream() 而非 request() |
SSE 长连接用 await request() 会永远等不到 resolve |
跨块缓冲 buffer |
一次 dataReceive 可能只拿到半行 JSON,直接 JSON.parse 会失败;用 buffer 拼接残留 |
util.TextDecoder |
ArrayBuffer → UTF-8 字符串,中文不会乱码 |
SSE 解析:蓝耘 MaaS 流式返回遵循 Server-Sent Events 协议,每行 data: {"choices":[{"delta":{"content":"xxx"}}]},最后 data: [DONE]。delta.reasoning_content 是思维链,delta.content 是正文。
为什么 reasoning 和 content 分开回调? 推理模型(如 deepseek-v4-flash)会先「想」再「答」。思维链用户不一定要看见,但平台已计 reasoning_tokens。产品层:onReasoning → 「思考中」折叠区;onContent → 主气泡逐字显示;onDone → 存入对话历史。
但最终我选择了非流式——DevEco 模拟器对 requestInStream 支持不稳定(日志出现 NETSTACK http handover manager init fail)。真机可正常流式,模拟器调试阶段建议降级为 chat() 非流式,减少变量。生产包再切回流式。


5.4 回调接口
export interface StreamCallbacks {
onContent?: (chunk: string) => void; // 正文片段
onReasoning?: (chunk: string) => void; // 思维链片段
onDone?: (fullText: string) => void; // 完成
onError?: (err: string) => void; // 错误
}
四个回调可选,调用方按需实现。聊天页用全部四个做 UI 更新;后台静默调用只用 onDone。
5.5 多模型列表
export const LAN_YUN_MODELS: string[] = [
'deepseek-v4-flash', // 通用首选,适度推理
'/maas/deepseek-ai/DeepSeek-V3.2', // 零思考税,高频简单任务
'kimi-k2.5', // 快速响应,零思考税
'qwen3.6-flash', // 强推理(思考税极高,慎用)
'/maas/zhipuai/GLM-5.2', // 智谱系
'minimax-m3', // 信息密度高
];
这正是蓝耘统一网关的核心价值——同一个 client,只改 model 字符串就能切换模型,base_url 和 api_key 全部不变。

六、首页 HomeTab:能耗概览 + 蓝耘品牌
首页从上到下:蓝耘 Logo 头部 → 蓝耘品牌横幅(墨蓝渐变)→ 本月支出卡(蓝耘三段渐变)→ 消耗列表 → 蓝耘平台信息卡。
蓝耘平台信息卡展示五大核心特性:多模型统一网关、OpenAI 兼容协议、流式输出、智能路由、成本可控。卡片底部标注 API 端点 https://maas-api.lanyun.net/v1 和协议名称,让用户直观感知 App 背后跑的是蓝耘技术栈。

七、记录页 Func1Tab:每日读数录入
三个表类型切换按钮(电表/水表/气表),选中态用蓝耘主色 C.primary 填充 + 白字,未选中态白底灰字。大号数字输入框 + 保存按钮(蓝耘深蓝底)。品牌色驱动选中态,让蓝耘视觉渗透到每个交互细节。

八、分析页 Func2Tab:手写柱状图
年度累计卡用 gradLanYun 蓝耘三段渐变。柱状图纯 ArkUI 手写:数据归一化(柱高 = 实际值 × 120 / 最大值),ForEach 渲染 6 个月,alignItems(VerticalAlign.Bottom) 底部对齐,柱体颜色用蓝耘主色 #1B4FCC。

九、AI 助手页 AITab:聊天核心
9.1 状态设计
@State bubbles: Bubble[] = []; // 聊天气泡列表
@State sending: boolean = false; // 防抖标志
@State currentModel: number = 0; // 模型索引
private systemPrompt: string =
'你是蓝耘 AI 能源助手,集成在家庭能源管理 App 中。' +
'你可以帮用户分析家庭用电、用水、燃气消耗数据,提供节能建议。' +
'回答要简洁实用,使用中文,适当使用 emoji。';
9.2 发送流程
⚠️ 本项目最坑的一个 Bug:
@Component里用async send()+await chat(),await之后的代码不执行。现象是网络层日志完整打印到chat done, result len=453,但 UI 层await之后的日志一行都没有,界面永远停在「思考中」。
ArkUI 组件方法中 await 的续延不保证在 UI 线程执行,@State 更新会静默失效。改用 .then()/.catch() 回调:
private send(text: string) { // ← 注意:不是 async
if (text.trim().length === 0 || this.sending) { return; }
// 1. 添加用户气泡
this.bubbleId++;
this.bubbles.push({ id: this.bubbleId, role: 'user', content: text.trim(), loading: false });
this.bubbles = this.bubbles.slice();
// 2. 添加 AI 气泡(loading)
this.bubbleId++;
const aiId = this.bubbleId;
this.bubbles.push({ id: aiId, role: 'assistant', content: '', loading: true });
this.bubbles = this.bubbles.slice();
this.sending = true;
this.scrollToBottom();
// 3. 构建上下文(system + 最近 7 条),请求前快照
const messages: ChatMessage[] = [{ role: 'system', content: this.systemPrompt }];
const recent = this.bubbles.filter(b => !b.loading && b.content.length > 0).slice(-7);
for (const b of recent) { messages.push({ role: b.role, content: b.content }); }
const model = LAN_YUN_MODELS[this.currentModel];
hilog.info(0xA001, 'AITab', 'send: model=%{public}s, msgs=%{public}d', model, messages.length);
// 4. 调用蓝耘 MaaS —— 用 .then()/.catch() 而非 await
chat(messages, model).then((reply: string) => {
const idx = this.bubbles.findIndex(b => b.id === aiId);
if (idx >= 0) {
// ⚠️ 用 splice 替换元素,索引赋值 arr[i] = x 不触发 @State 观察
this.bubbles.splice(idx, 1, {
id: aiId, role: 'assistant',
content: reply.length > 0 ? reply : '[蓝耘 AI 返回为空]',
loading: false,
});
this.bubbles = [...this.bubbles]; // 强制新引用触发渲染
}
this.sending = false;
this.bubbles = [...this.bubbles];
this.scrollToBottom();
}).catch((e: Error) => {
const idx = this.bubbles.findIndex(b => b.id === aiId);
if (idx >= 0) {
this.bubbles.splice(idx, 1, {
id: aiId, role: 'assistant',
content: '请求失败: ' + e.message + '\n\n请检查网络连接后重试。',
loading: false,
});
this.bubbles = [...this.bubbles];
}
this.sending = false;
});
}
流程:添加用户气泡 → 添加 AI 气泡(loading 显示「蓝耘 AI 思考中…」)→ 构建 system + 最近 7 条上下文 → .then() 里拿到回复后更新气泡并清除 loading。
9.3 气泡 UI 与 Markdown 渲染
用户气泡右对齐,蓝耘深蓝底白字。AI 气泡左对齐,带蓝耘渐变「耘」字头像。loading 且 content 为空时显示 Loading 动画 + 「蓝耘 AI 思考中…」。
AI 回复必须用 RichText 而非 Text——大模型输出大量 Markdown(**加粗**、1. 列表),用 Text 会把标记符号原样显示给用户,非常难看。写一个轻量 Markdown → HTML 转换:
/** Markdown → HTML(轻量转换,覆盖 AI 常用格式) */
private mdToHtml(md: string): string {
let html = md;
// 先转义 HTML 特殊字符
html = html.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); // **加粗**
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>'); // *斜体*
// 有序列表 1. xxx
html = html.replace(/^(\d+)\.\s+(.+)$/gm,
'<div style="margin:4px 0;padding-left:4px"><strong>$1.</strong> $2</div>');
// 无序列表 - xxx / • xxx
html = html.replace(/^[-•]\s+(.+)$/gm, '<div style="margin:4px 0;padding-left:4px">• $1</div>');
html = html.replace(/\n/g, '<br/>'); // 换行
return `<body style="font-size:14px;color:${C.text};line-height:22px">${html}</body>`;
}
// 气泡渲染
RichText(this.mdToHtml(b.content)).width('100%')
注意:
RichText没有fontSize/fontColor/lineHeight属性(编译报错Property 'fontSize' does not exist on type 'RichTextAttribute')。样式只能通过 HTML 内的style内联控制。
9.4 ForEach 的 key 必须随内容变化
即使 @State 更新了,ForEach 仍可能复用旧组件不重新渲染:
// ❌ key 只有 id,内容变了但 key 不变 → ForEach 复用旧组件,UI 不刷新
}, (b: Bubble) => b.id.toString())
// ✅ key 加入 loading 和内容长度 → 状态变化时强制重建
}, (b: Bubble) => `${b.id}_${b.loading ? 1 : 0}_${b.content.length}`)
配合前面的 splice + [...this.bubbles],三重保险确保 UI 一定刷新。
9.5 快捷提问
底部输入栏上方有四个快捷提问按钮:「💡 怎么省电?」「📊 分析我的用电」「🌱 节能建议」「💰 电费计算」,点击直接发送。输入框 placeholder 为「问蓝耘 AI 任何能源问题…」,发送按钮用蓝耘渐变。


十、我的页 ProfileTab:蓝耘平台介绍中心
用户卡头像用蓝耘「耘」字 Logo(半透明白底),标注「AI 助手由蓝耘 MaaS 驱动」。页面中央有完整的蓝耘 MaaS 平台介绍卡:平台名称、描述、五大特性(带 ✓ 标记)、协议、API 端点、可用模型数。底部版本号标注「Version 2.0.0 · Powered by 蓝耘元生代 MaaS」。

十一、踩坑实录:ArkTS 严格模式编译错误
11.1 错误一:arkts-no-any-unknown
ERROR: Use explicit types instead of "any", "unknown"
At File: LanYunAI.ets:55:13
原因:JSON.parse() 返回 any 类型,ArkTS 严格模式禁止隐式 any。
修复:为 JSON.parse 结果定义显式接口:
// 修复前
const json = JSON.parse(resp.result as string);
return json.choices?.[0]?.message?.content;
// 修复后
interface ChatResponse { choices: ChoiceItem[] }
interface ChoiceItem { message: MessageItem }
interface MessageItem { content: string }
const json: ChatResponse = JSON.parse(resp.result as string);
return json.choices?.[0]?.message?.content ?? '';
11.2 错误二:arkts-no-untyped-obj-literals
ERROR: Object literal must correspond to some explicitly declared class or interface
At File: LanYunAI.ets:155:29
原因:LAN_YUN_INFO 对象字面量没有显式类型标注。
修复:定义接口并显式标注:
export interface LanYunInfo {
name: string; platform: string; baseUrl: string;
protocol: string; desc: string; features: string[];
}
export const LAN_YUN_INFO: LanYunInfo = { /* ... */ };
11.3 经验总结
ArkTS 严格模式与普通 TypeScript 最大区别:禁止 any/unknown,禁止未类型化的对象字面量。所有 JSON.parse 结果必须标注接口类型,所有导出常量必须显式声明类型。建议在项目初期就定义好所有数据接口,避免编译时集中报错。

十二、Debug 实录:AI 回复「一直思考中」的四层排查
这是本项目耗时最久的 Bug:AI 请求明明成功了,界面却永远停在「蓝耘 AI 思考中…」。现象迷惑性极强——蓝耘后台显示调用成功,curl 也没问题,但 App 就是不显示。
最终定位到 四个叠加的坑,逐层剥开才解决。
12.1 第一层:SSE 流式接口用错了 API
现象:await http.request() 对 SSE 接口永久阻塞,代码卡在请求那一行,后面解析 data: 的代码根本没机会执行。
根因:request() 默认等完整响应体才 resolve,而 SSE 是长连接,服务端推完数据不主动断开,靠 [DONE] 标记结束。
修复:改用 requestInStream() + on('dataReceive') 事件逐块接收(见 5.3)。
12.2 第二层:推理模型的 token 被思维链吃光
现象:思维链正常返回,但正文 content 为空 → 气泡永远 loading。
根因:deepseek-v4-flash 是深度思考模型,先输出 reasoning_content 再输出 content。原代码有两个问题:
reasoning_content只回调onReasoning,而AITab根本没注册这个回调 → 思维链全部丢弃,fullText永远为空max_tokens默认 1024 → 思维链吃掉大半 token,正文被挤没,甚至finish_reason: "length"时正文完全为空
实测证据(max_tokens=50):50 个 token 全被思维链占用,content 字段全程是 null。
修复:思维链也累加进 fullText 作为兜底;max_tokens 提到 2048。
12.3 第三层:ArkUI 组件里 await 后续延不执行 ⭐ 最坑
现象:网络层日志完整打印到 chat done, result len=453,但 UI 层 await 之后的日志一行都没有,@State 更新静默失效。
LanYunAI chat start: model=deepseek-v4-flash
LanYunAI request sending...
LanYunAI responseCode=200
LanYunAI chat done, result len=453 ← 网络层成功
LanYunAI client destroyed
(AITab 的 reply len= 日志:完全没有)
根因:@Component 的 async 方法中,await 的续延不保证在 UI 线程执行,导致 @State 更新失效。
修复:去掉 async/await,改用 .then()/.catch()(见 9.2)。
这个坑的隐蔽之处在于:编译不报错、运行不崩溃、网络层一切正常,只是 UI 不更新。如果只在一处打日志,永远定位不到。
12.4 第四层:ForEach 复用旧组件,状态更新了也不重渲染
现象:.then() 里 this.bubbles 已经更新(sending 也重置了,否则第二次发送会被拦截),但界面仍显示「思考中」。
根因:两个叠加问题:
ForEach的 key 只用了b.id,内容变了但 key 不变 → 复用旧组件- 索引赋值
this.bubbles[idx] = {...}不触发@State观察
修复:三重保险(见 9.4)
this.bubbles.splice(idx, 1, { ... }); // ① splice 替换,触发 @State
this.bubbles = [...this.bubbles]; // ② 新数组引用,强制刷新
// ③ ForEach key 加入 loading 和内容长度
}, (b: Bubble) => `${b.id}_${b.loading ? 1 : 0}_${b.content.length}`)
12.5 方法论:分层打日志是定位这类问题的唯一手段
这个 Bug 之所以能定位,全靠网络层和 UI 层用不同的 hilog tag/domain:
// 网络层 LanYunAI.ets
const TAG = 'LanYunAI';
const DOMAIN = 0xA002;
// UI 层 AITab.ets
hilog.info(0xA001, 'AITab', ...);
在 DevEco Studio Log 面板分别过滤 LanYunAI 和 AITab:
| 过滤结果 | 结论 |
|---|---|
| 两层日志都完整 | UI 渲染问题(ForEach / key) |
只有 LanYunAI 有日志 |
await 续延没执行(12.3) |
LanYunAI 停在 request sending... |
网络层阻塞(12.1) |
content len=0 但有 reasoning len |
思维链吃光 token(12.2) |
教训:如果只在 UI 层打日志,看到「没有任何输出」会误判为网络请求没发出;如果只在网络层打日志,会误判为「数据回来了应该没问题」。两层都打,才能一眼看出断点在哪一层。
十三、网络权限配置
蓝耘 MaaS 调用需要网络权限,在 module.json5 中声明:
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.INTERNET",
"reason": "$string:reason_internet",
"usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
}
]
}
}
漏配这个权限,http.createHttp().request() 会直接失败,报 undefined 或权限错误。usedScene.when 设为 inuse 表示仅在使用时申请,符合最小权限原则。
十四、蓝耘 MaaS 实测验证
编译通过后,我用 curl 快速验证了蓝耘 API 连通性:
curl -s --max-time 30 https://maas-api.lanyun.net/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk-hfp6wgtzvwfw5wpoj36xcvxrmtokmbhrn7brbgll6bina7i6" \
-d '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"只回复四个字:蓝耘成功"}],"stream":false,"max_tokens":64}'
返回:
{
"choices": [{
"message": {
"role": "assistant",
"content": "蓝耘成功",
"reasoning_content": "我们只需要回复四个字:蓝耘成功。"
}
}],
"usage": {
"prompt_tokens": 91,
"completion_tokens": 15,
"total_tokens": 106
}
}
API 连通正常,返回「蓝耘成功」。注意 reasoning_content 字段——模型先想了「我们只需要回复四个字」,再输出正文。这就是前面说的思维链,usage 里也会计入 reasoning_tokens。

十五、蓝耘 MaaS 在本项目中的核心价值
回顾整个开发过程,蓝耘元生代 MaaS 在这个鸿蒙项目里承担了四个核心角色:
15.1 统一网关:一套代码调六模型
App 里的 AI 助手支持 6 个模型切换,但整个代码库里只有一套 HTTP 调用逻辑。切换模型只是改 LAN_YUN_MODELS[this.currentModel] 这一个字符串。如果没有统一网关,要对接 6 家不同厂商的 SDK,代码量和维护成本会翻好几倍。
15.2 流式输出:聊天体验的基石
蓝耘 MaaS 的 SSE 流式支持,让 AI 助手的聊天体验从「等 5 秒弹出整段」变成「1 秒后开始逐字显示」。这在移动端尤其重要——用户注意力短,干等超过 3 秒就会觉得卡。
工程建议:真机用 chatStream() 流式;DevEco 模拟器对 requestInStream 支持不稳定(日志会报 NETSTACK http handover manager init fail),调试阶段降级为 chat() 非流式,把网络变量降到最少,确定业务逻辑无误后再切回流式。
15.3 思维链分离:产品可观测性
reasoning_content 和 content 分开返回,让产品层可以做「思考中」折叠区。数据管道已预留 onReasoning 回调,后续产品迭代可直接接入。
但要注意深度思考模型的坑:思维链会消耗大量 token,如果 max_tokens 给小了,正文会被挤没甚至完全为空。本项目 chat() 里做了兜底处理——正文为空时回退展示思维链,保证用户至少能看到内容。
15.4 成本透明:usage 字段
蓝耘 MaaS 的 usage 字段包含 prompt_tokens、completion_tokens、reasoning_tokens、cached_tokens,让每次调用的成本可追溯。移动端应用对成本敏感,这些数据可以用来优化 prompt 长度、选择性价比更高的模型。
十六、与其他平台的对比
在选型阶段,我也对比了其他几个方案:
| 维度 | 蓝耘 MaaS | 直接对接 OpenAI | 自建推理服务 |
|---|---|---|---|
| 接入成本 | 改 base_url 即可 |
需要翻墙/代理 | 买卡+部署+运维 |
| 模型选择 | 50+ 模型一个 Key | 仅 OpenAI 系列 | 仅自己部署的 |
| 国内访问 | 直连,延迟低 | 需要代理,不稳定 | 取决于机房位置 |
| 成本 | 按 Token 计费,有免费额度 | 按 Token 计费,无免费 | 固定成本(显卡) |
| 运维 | 零运维 | 零运维 | 高运维 |
对于鸿蒙端开发者,蓝耘 MaaS 的优势在于国内直连 + OpenAI 兼容 + 多模型统一网关。鸿蒙的 @kit.NetworkKit 不需要额外配置代理,直接 http.createHttp().request() 就能调通,这在开发调试阶段省了大量时间。
十七、总结
这个家庭能源管理 App 从零到编译通过,完整经历了:
- Stage 模型搭建:EntryAbility 沉浸式状态栏 + 安全区域适配
- 品牌视觉统一:Theme.ets 蓝耘色系,所有页面统一引用
- 业务页面开发:首页概览、记录录入、分析图表,纯 ArkUI 声明式
- AI 服务层封装:LanYunAI.ets 封装蓝耘 MaaS 非流式/流式调用
- AI 助手页面:多模型切换、多轮上下文、Markdown 富文本渲染
- 编译排错:ArkTS 严格模式的 any/unknown 和未类型化对象字面量
- 运行时 Debug:SSE 长连接阻塞、思维链吃 token、
await续延失效、ForEach不重渲染 - 实测验证:curl 验证 API 连通,DevEco Studio 编译通过

更多推荐





所有评论(0)