在这里插入图片描述
在这里插入图片描述

一、引言:当推理小说遇见人工智能

想象这样一个场景:你打开手机上的一个应用,点击"开始新案件",屏幕上浮现出一段神秘的文字描述——一个扣人心弦的谜案。你仔细阅读案件细节,像侦探一样思考,点击展开线索卡片逐步获取提示,在文本框中写下自己的推理过程,然后提交。几秒钟后,AI 分析你的推理,指出正确与不足之处,最后呈现一段极具仪式感的"通关文牒",宣告你成功破解了这个案件。

这不是某个商业游戏的宣传文案,而是我们用 HarmonyOS NEXT 原生 ArkTS 开发的一款完整应用——「重生 AI 推理大师」。它融合了 AI 大语言模型的智能推理能力(通过 SSE 流式接口实时对话)与鸿蒙原生声明式 UI 的优雅布局,展示了 HarmonyOS NEXT 在 AI 应用开发领域的强大能力。

本文将从零开始,完整剖析这款应用的架构设计、核心技术实现和工程化实践,涵盖 ArkTS 状态管理、网络通信、UI 布局等关键主题,适合有一定 ArkTS 基础的开发者深入阅读。


二、应用架构与设计理念

2.1 应用总览

「重生 AI 推理大师」是一款基于大语言模型(LLM)的交互式推理游戏应用。它的核心玩法是:

  1. AI 生成案件:应用调用 AI 大模型,按指定 JSON 格式生成一个完整的推理谜案。
  2. 用户逐步推理:用户阅读案件描述,选择性展开查看线索,在文本框中写下推理。
  3. AI 评判反馈:用户提交推理后,应用展示 AI 预先给出的推理结果、正确性分析和通关文牒。

整个游戏流程由四个清晰的状态节点驱动,每个状态对应不同的 UI 界面和用户交互模式。

2.2 整体架构图

┌─────────────────────────────────────────────────────┐
│                    Index.ets                         │
│  ┌──────────┐    ┌──────────┐    ┌────────────────┐ │
│  │ Welcome  │ →  │ Loading  │ →  │   Case Ready   │ │
│  │  欢迎页   │    │  加载中    │    │   案件展示      │ │
│  └──────────┘    └──────────┘    └───────┬────────┘ │
│                                          │          │
│                                ┌────────▼───────┐   │
│                                │  Result Shown  │   │
│                                │  推理结果展示    │   │
│                                └────────────────┘   │
└─────────────────────────────────────────────────────┘
                         │ 调用
                         ▼
┌─────────────────────────────────────────────────────┐
│                   AIChatService.ets                  │
│  ┌────────────────────────────────────────────────┐  │
│  │  HTTP + SSE Stream (chat/completions API)     │  │
│  │  · 流式请求 on('dataReceive')                 │  │
│  │  · 非流式回退 parseNonStreamingBody()         │  │
│  │  · 错误处理与请求取消                          │  │
│  └────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────┘

2.3 数据流与状态变化时序

理解应用的数据流向对于正确实现功能至关重要。在「重生 AI 推理大师」中,数据流遵循单向数据流模式:

用户操作 → 事件处理 → 状态变更 → UI 重绘

具体到各个功能节点:

案件生成流程的数据流:

点击"开始新案件"
  → generateNewCase() 被调用
  → gameState = CASE_LOADING(UI 显示加载动画)
  → queryAI() 发起网络请求
  → SSE 流式数据逐个到达 onData 回调
  → onDone 触发时累积数据完整
  → JSON 解析与字段校验
  → caseData 赋值(UI 显示案件内容)
  → gameState = CASE_READY
  → scrollEdge(Edge.End) 自动滚动到底部

线索展开/收起的交互流:

用户点击线索按钮
  → toggleHint(index) 被调用
  → revealedHints[index] 取反
  → revealedHints = [...revealedHints](新建数组触发 @State 检测)
  → hintRevealCount 更新
  → UI 重绘,显示或隐藏对应的线索内容

推理提交的数据流:

用户点击"提交推理"或"跳过推理"
  → submitReasoning() 被调用
  → showResults = true(UI 显示推理结果区域)
  → gameState = RESULT_SHOWN(UI 隐藏输入区域)
  → Scroll 滚动到底部展示通关文牒

这种单向数据流模式的好处是每一处状态变更都有明确的触发源和可追踪的变更路径,在调试时可以清晰地定位问题。当 UI 出现异常时,我们只需要检查对应的 @State 值是否符合预期即可。

2.4 技术栈选型理由

技术 选择理由
ArkTS 声明式 UI 原生支持,状态驱动视图,代码简洁
@State 装饰器 响应式状态管理,自动触发 UI 重绘
@Builder 组件化代码组织,避免重复 build 代码
@kit.NetworkKit 鸿蒙原生 HTTP 网络库,支持流式请求
SSE 流式响应 实时逐字输出 AI 响应,提升用户体验
Scroll + Scroller 长内容页面滚动,支持编程式滚动定位
自定义颜色主题 深色侦探风格 UI,沉浸式体验

三、项目工程搭建

3.1 项目初始化

在 DevEco Studio 中创建新项目时,选择以下配置:

  • 模板: Empty Ability(Stage 模型)
  • SDK: HarmonyOS NEXT 6.1.1(API 24)
  • 语言: ArkTS
  • 包名: 自定义(如 com.example.reasoningmaster)

项目创建后,核心的构建配置需要确认。根目录的 build-profile.json5 中指定了 SDK 版本:

{
  "app": {
    "products": [
      {
        "name": "default",
        "targetSdkVersion": "6.1.1(24)",
        "compatibleSdkVersion": "6.1.1(24)",
        "runtimeOS": "HarmonyOS",
      }
    ]
  }
}

SDK 版本 6.1.1(API 24)对应 HarmonyOS NEXT 的正式发布版本,它提供了完整的 ArkUI 框架支持,包括本文要用到的 Scroll、Scroller、@Builder 参数化、网络请求 API 等核心特性。

3.2 目录结构

项目创建并完成功能开发后,目录结构如下:

entry/src/main/ets/
├── entryability/
│   └── EntryAbility.ets            # Ability 生命周期
├── pages/
│   ├── Index.ets                   # 主页面(游戏核心)
│   ├── AIChatService.ets           # AI 网络通信服务
│   ├── SwiperCardDemo.ets          # 布局示例:Swiper
│   ├── ScrollFrictionDemo.ets      # 布局示例:Scroll
│   ├── OffsetDemo.ets              # 布局示例:Offset
│   └── RunnerPage.ets              # 其他页面
└── model/                          # 数据模型

3.3 网络权限声明

应用需要访问互联网以调用 AI API,因此在 module.json5 中需要声明 ohos.permission.INTERNET 权限。对于 HarmonyOS NEXT,权限声明在 src/main/module.json5requestPermissions 数组中:

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

四、AI 通信服务层的设计与实现

4.1 设计目标

AI 通信服务层(AIChatService.ets)是整个应用的网络核心,它负责与 AI 大语言模型 API 进行通信。我们设定了以下设计目标:

  1. 流式优先:默认使用 SSE(Server-Sent Events)流式传输,实现 AI 逐字输出的实时效果。
  2. 非流式回退:当流式模式不可用时,自动回退到非流式 JSON 解析,保证兼容性。
  3. 请求可取消:支持随时取消正在进行的请求,避免资源浪费。
  4. 健壮的错误处理:覆盖网络错误、HTTP 状态码异常、响应解析失败等多种异常场景。

4.2 核心数据类型定义

首先,我们定义了与 AI API 通信所需的所有数据类型。这些类型覆盖了请求和响应的完整结构:

// 聊天消息结构体:用于构建 API 请求中的 messages 数组
export interface ChatMessage {
  role: string;         // 'system' | 'user' | 'assistant'
  content: string;      // 消息文本内容
}

// 请求体结构体:对应 AI API 的请求参数
export interface ChatCompletionRequest {
  model: string;               // 模型名称,如 'deepseek-ai/DeepSeek-V3'
  messages: ChatMessage[];     // 对话历史
  stream: boolean;             // 是否启用流式输出
  max_tokens: number;          // 最大生成长度
  temperature: number;         // 温度参数(0~1),控制随机性
  top_p: number;               // 核采样参数
  frequency_penalty: number;   // 频率惩罚
  thinking_budget: number;     // 思考预算(模拟思维链)
}

// SSE 回调接口:应用层通过它获取流式数据
export interface AICallbacks {
  onData: (text: string) => void;     // 收到新 token
  onDone: () => void;                 // 流式响应结束
  onError: (errMsg: string) => void;  // 发生错误
}

4.3 SSE 流式响应解析

SSE(Server-Sent Events)是一种基于 HTTP 的实时通信协议。AI API 将生成的文本分成多个 token,每个 token 作为 SSE 的一条 data 事件发送给客户端。客户端的 HTTP 流式接口逐块接收这些数据,解析后实时呈现给用户。

SSE 数据格式如下:

data: {"choices":[{"delta":{"content":"案件涉及"}}]}

data: {"choices":[{"delta":{"content":"一起发生在"}}]}

data: [DONE]

每条 data 行以 data: 开头,后面跟着 JSON 字符串。当收到 data:[DONE] 时,表示流式响应结束。

我们的解析函数 parseSSEDataLine 负责从一条 data 行中提取 content 增量:

function parseSSEDataLine(line: string): string | null {
  // 去掉 "data:" 前缀
  const jsonStr = line.slice(5).trim();
  if (!jsonStr) {
    return null;
  }
  try {
    const parsed = JSON.parse(jsonStr) as Record<string, Object>;
    const choices = parsed.choices as Object[];
    if (choices && choices.length > 0) {
      const choice = choices[0] as Record<string, Object>;
      // 兼容 delta 和 message 两种格式
      const delta = choice.delta as Record<string, Object>;
      if (delta) {
        return delta.content as string;
      }
      const message = choice.message as Record<string, Object>;
      if (message) {
        return message.content as string;
      }
    }
  } catch (_) {
    // JSON 解析失败,跳过该行
  }
  return null;
}

这个函数的关键设计点是同时兼容 deltamessage 两种格式。不同 AI 服务商的流式响应格式可能略有差异,同时支持两种格式可以避免在切换供应商时修改解析逻辑。

4.4 主请求函数 queryAI

queryAI 函数是整个 AI 通信服务的核心。它创建 HTTP 请求,设置流式监听器,并在整个请求生命周期中触发相应的回调。这个函数的设计需要兼顾多个目标:流式数据的实时解析、请求的可取消性、非流式场景的自动回退。

export function queryAI(
  callbacks: AICallbacks,
  messages: ChatMessage[],
): void {
  // 1. 取消上一次未完成的请求
  if (httpRequestTask) {
    try { httpRequestTask.destroy(); } catch (_) { /* ignore */ }
    httpRequestTask = null;
  }

  const httpRequest = http.createHttp();
  httpRequestTask = httpRequest;

  // 2. 构建完整消息数组(系统提示词 + 用户消息)
  const fullMessages: ChatMessage[] = [
    { role: 'system', content: SYSTEM_PROMPT },
    ...messages,
  ];

  // 3. 构建请求体
  const requestBody: ChatCompletionRequest = {
    model: 'deepseek-ai/DeepSeek-V3',
    messages: fullMessages,
    stream: true,               // 开启流式
    max_tokens: 4096,
    temperature: 0.7,
    top_p: 0.95,
    frequency_penalty: 0,
    thinking_budget: 2048,
  };

  // 4. 监听流式数据到达
  httpRequest.on('dataReceive', (data: ArrayBuffer) => {
    const text = arrayBufferToString(data);
    buffer += text;

    // 按行拆解,SSE 以 \n 分隔
    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);
      }
    }
  });

  // 5. 数据接收完毕回调
  httpRequest.on('dataEnd', () => {
    if (!isDone) { isDone = true; callbacks.onDone(); }
    httpRequestTask = null;
  });

  // 6. 监听响应头(仅用于日志调试)
  httpRequest.on('headerReceive', (header: object) => {
    console.info('[AIChat] Header received: ' + JSON.stringify(header));
  });

  // 7. 发起 POST 请求
  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, resp) => { /* ... 非流式回退逻辑 ... */ },
  );
}

这里有几个关键设计点值得注意:

变量值传递与可取消性httpRequest 被赋值给模块级变量 httpRequestTask,这使得 cancelAI() 函数可以随时取消正在进行的请求。每次新请求发起前都会检查并销毁上一次请求,避免多个请求同时进行造成混乱。

缓冲区处理buffer 变量用于累积不完整的 SSE 数据行。由于网络传输是分块的,一条数据可能被拆成两半到达。例如,"data: {\"choice""s\":[{}]}\n\n" 可能分两次到达。我们通过 buffer.split('\n') 拆解后,将最后一部分(可能不完整)留在 buffer 中等待下一次数据到达时拼接。

4.5 非流式回退机制

尽管我们默认使用流式模式,但在某些网络环境或服务器配置下,流式响应可能不可用。为了提高应用的健壮性,我们实现了非流式回退机制:

// 在请求的完整回调中
(err, resp) => {
  // ... 错误检查 ...

  // 如果没有任何流式数据到达,尝试非流式解析
  if (!receivedAnyData && resp.result) {
    const bodyStr = typeof resp.result === 'string'
      ? resp.result
      : arrayBufferToString(resp.result as ArrayBuffer);

    // 方案一:按 SSE 格式解析完整响应体
    const sseContent = parseFullSSEBody(bodyStr);
    if (sseContent) {
      callbacks.onData(sseContent);
    } else {
      // 方案二:尝试非流式 JSON 格式
      const jsonContent = parseNonStreamingBody(bodyStr);
      if (jsonContent) {
        callbacks.onData(jsonContent);
      } else {
        callbacks.onError('无法解析响应');
        return;
      }
    }

    if (!isDone) { isDone = true; callbacks.onDone(); }
  }
}

这个回退机制采用了"两阶段解析"策略:先尝试 SSE 格式解析(兼容那些虽然返回了非流式响应但内容格式与 SSE 一致的服务),再尝试纯 JSON 格式解析(兼容标准的非流式 API 响应)。这种"兜底"设计确保了应用在各种 API 响应格式下都能正常工作。


五、系统提示词设计(Prompt Engineering)

AI 输出的质量在很大程度上取决于系统提示词(System Prompt)的设计。我们的系统提示词需要满足以下要求:

  1. 让 AI 生成结构一致的 JSON 数据
  2. 确保案件类型多样,避免重复
  3. 输出的文字要有氛围感和可读性

最终设计的系统提示词如下:

const SYSTEM_PROMPT: string =
  '你是「重生AI推理大师」,一位穿越重生的顶级推理大师,擅长设计精巧的推理谜题。\n' +
  '你的任务是为用户生成一个推理案件,并严格按照以下 JSON 格式返回:\n\n' +
  '{\n' +
  '  "problem": "案件描述文字,描述一个神秘的事件或场景",\n' +
  '  "hints": ["线索1", "线索2", "线索3"],\n' +
  '  "reasoning_results": ["推理结果1", "推理结果2", "推理结果3"],\n' +
  '  "analysis": "正确性分析与推理过程详解",\n' +
  '  "clearance": "通关文字——结案陈词" \n' +
  '}\n\n' +
  '要求:\n' +
  '1. problem 要有足够的悬念和细节。\n' +
  '2. hints 数组至少 3 条线索,逐步揭示关键信息。\n' +
  '3. reasoning_results 与 hints 一一对应。\n' +
  '4. analysis 要逻辑严谨,分析多种推理路径。\n' +
  '5. clearance 要有仪式感和成就感。\n' +
  '6. 案件类型不限:悬疑、逻辑推理、密室逃脱等均可。\n' +
  '7. 所有文字用中文。\n' +
  '每次响应只返回上述 JSON,不要包含任何额外文字。';

设计这个提示词时有几个关键技巧:

指定严格格式:提示词中明确给出了 JSON 的模板结构,包括字段名和类型。这相当于给 AI 一个"填空模板",大幅降低了输出格式异常的概率。

提供质量要求:每一条"要求"都对应一个明确的用户体验目标。例如"hints 数组至少 3 条线索"确保案件有足够的推理深度,"clearance 要有仪式感和成就感"确保游戏体验的完整性。

约束输出纯度:最后一句"每次响应只返回上述 JSON,不要包含任何额外文字"是最关键的一行。AI 默认倾向于在输出前后添加解释性文字,这个约束强制 AI 只输出 JSON,简化了客户端的解析逻辑。


六、主页面状态管理与 UI 构建

6.1 状态驱动架构

「重生 AI 推理大师」的 UI 完全由状态驱动。我们使用 @State 装饰器定义了两个核心状态:

@Entry
@Component
struct Index {
  // —— 游戏状态机 ——
  @State gameState: GameState = GameState.WELCOME;

  // —— 案件数据 ——
  @State caseData: CaseData | null = null;

  // —— 交互状态 ——
  @State userReasoning: string = '';       // 用户输入的推理
  @State revealedHints: boolean[] = [];     // 线索展开状态
  @State isLoading: boolean = false;
  @State errorMsg: string = '';
  @State showResults: boolean = false;

  // —— 滚动控制器 ——
  private scroller: Scroller = new Scroller();
}

游戏状态机 GameState 是页面渲染的核心驱动:

enum GameState {
  WELCOME,       // 欢迎页:展示应用介绍和开始按钮
  CASE_LOADING,  // 加载中:展示 Loading 动画
  CASE_READY,    // 案件已就绪:展示案件内容、线索、推理输入
  RESULT_SHOWN,  // 结果已展示:额外显示推理结果、分析和通关文牒
}

build() 方法中,我们根据 gameState 的值选择渲染哪个子 UI:

build() {
  Column() {
    // 标题栏
    Row() { /* 标题文字 */ }
      .width('100%').height(56)
      .backgroundColor('#1A1A2E')

    // 内容区域(可滚动)
    Scroll(this.scroller) {
      Column() {
        if (this.gameState === GameState.WELCOME && !this.isLoading) {
          this.buildWelcomeSection()
        } else if (this.isLoading) {
          this.buildLoadingSection()
        } else if (this.gameState === GameState.CASE_READY ||
                   this.gameState === GameState.RESULT_SHOWN) {
          if (this.caseData) {
            this.buildCaseContent()
          }
        }

        if (this.errorMsg && this.gameState === GameState.WELCOME) {
          Text(this.errorMsg)
            .fontSize(14).fontColor('#CC4444')
            .textAlign(TextAlign.Center).width('100%').padding(16)
        }
      }
    }
  }
}

这种基于状态条件渲染的模式是 ArkTS 声明式 UI 的核心范式。当 gameState 变化时,ArkTS 框架自动比较 UI 树的差异,只重新渲染变化的部分,性能非常高效。

6.2 欢迎页(Welcome Section)

欢迎页是用户进入应用后看到的第一个界面。它的设计目标是营造沉浸式的侦探氛围,并引导用户点击"开始新案件"按钮。

@Builder
buildWelcomeSection(): void {
  Column() {
    Blank().height(60)

    // 大侦探 Emoji
    Text('🕵️')
      .fontSize(64)
      .margin({ bottom: 16 })

    // 应用名称
    Text('重生 AI 推理大师')
      .fontSize(26)
      .fontWeight(FontWeight.Bold)
      .fontColor('#E8D5B7')
      .margin({ bottom: 8 })

    // 氛围描述
    Text('穿越重生,化身顶级侦探\n每个案件都是一场智慧的较量')
      .fontSize(15)
      .fontColor('#8899AA')
      .textAlign(TextAlign.Center)
      .lineHeight(24)
      .margin({ bottom: 32 })

    // 核心操作按钮
    Button() {
      Text('📋 开始新案件')
        .fontSize(18)
        .fontColor(Color.White)
        .padding({ left: 32, right: 32, top: 12, bottom: 12 })
    }
    .type(ButtonType.Capsule)
    .backgroundColor('#C9A84C')
    .onClick((): void => this.generateNewCase())
  }
  .width('100%')
  .alignItems(HorizontalAlign.Center)
}

欢迎页的设计遵循"倒金字塔"结构:顶部是视觉焦点(侦探图标),中部是文字信息(名称和描述),底部是操作入口(按钮)。这种设计模式引导用户的视觉流从上到下自然过渡到操作。

6.3 加载页(Loading Section)

加载页在用户点击"开始新案件"后展示,这个阶段应用正在等待 AI API 的响应。

@Builder
buildLoadingSection(): void {
  Column() {
    Blank().height(80)

    // 原生 Loading 组件
    LoadingProgress()
      .width(48).height(48)
      .color('#C9A84C')
      .margin({ bottom: 20 })

    // 氛围文字
    Text('正在编织案件迷雾...')
      .fontSize(16)
      .fontColor('#8899AA')
      .textAlign(TextAlign.Center)
  }
  .width('100%')
  .alignItems(HorizontalAlign.Center)
}

这里使用了 HarmonyOS 原生的 LoadingProgress 组件,旋转的加载动画配合"正在编织案件迷雾…"的文字描述,将用户的等待时间转化为一种沉浸式的叙事体验。

6.4 案件内容页(Case Content)

案件内容页是游戏的核心交互界面,分为五个区域:

  1. 案件描述:展示 AI 生成的案件谜题
  2. 线索列表:可展开/收起的线索卡片,模拟"逐步揭开真相"的体验
  3. 推理输入:TextArea 文本输入框,供用户写下推理
  4. 推理结果(提交后显示):每条线索对应的推理分析
  5. 通关文牒(提交后显示):结案陈词与成就感
6.4.1 可交互的线索卡片

线索卡片采用展开/收起设计,用户点击线索标题按钮可以切换展开状态。这种交互模式的设计意图是:

  • 信息节奏控制:不让所有线索一次性呈现在用户面前,而是让用户主动选择查看哪条线索,模拟真实侦探"逐步收集证据"的过程。
  • 交互反馈:每条线索的状态(展开/收起)用 🔒/🔓 图标表示,已揭晓的线索数量实时显示在标题栏中。
@Builder
buildHintCard(index: number, hint: string): void {
  Column() {
    // 线索标题按钮(可点击)
    Button() {
      Row() {
        Text(this.revealedHints[index] ? '🔓' : '🔒')
          .fontSize(16).margin({ right: 8 })
        Text('线索 ' + (index + 1))
          .fontSize(15).fontColor('#C9A84C').fontWeight(FontWeight.Medium)
        Blank()
        Text(this.revealedHints[index] ? '收起 ▲' : '展开 ▼')
          .fontSize(12).fontColor('#667788')
      }.width('100%')
    }
    .width('100%').height(44)
    .backgroundColor('#1E2A4A').borderRadius(10)
    .padding({ left: 14, right: 14 })
    .onClick((): void => this.toggleHint(index))

    // 线索内容(条件渲染:仅展开时显示)
    if (this.revealedHints[index]) {
      Column() {
        Text(hint)
          .fontSize(15).fontColor('#E0E0E0').lineHeight(22)
      }
      .width('100%').padding(14)
      .backgroundColor('#0F1A36')
      .borderRadius({ bottomLeft: 10, bottomRight: 10 })
    }
  }
  .width('100%')
  .margin({ bottom: 8 })
}

注意这里使用了 borderRadius({ bottomLeft: 10, bottomRight: 10 }) 这种分段式圆角设置——只有底部两个角是圆角,顶部卡片的按钮部分已经设置了整体圆角。这种细节处理让卡片在展开时看起来像一个统一的面板。

6.4.2 展开/收起状态管理

线索的展开状态存储在 revealedHints 数组中,每个元素对应一条线索的展开状态:

@State revealedHints: boolean[] = [];

// 切换线索的展开/收起
toggleHint(index: number): void {
  if (index >= 0 && index < this.revealedHints.length) {
    this.revealedHints[index] = !this.revealedHints[index];
    this.revealedHints = [...this.revealedHints];  // 触发 @State 变更检测
    if (this.revealedHints[index]) {
      this.hintRevealCount++;
    } else {
      this.hintRevealCount--;
    }
  }
}

这里有一个容易被忽视但非常重要的细节:this.revealedHints = [...this.revealedHints]。在 ArkTS 中,@State 装饰器通过引用比较来检测数组变化。直接修改数组元素(如 arr[i] = value)不会改变数组对象本身的引用,因此框架无法检测到变化。通过扩展运算符创建新数组,我们获得了新的引用,从而触发 UI 重绘。

6.5 案件生成流程(从点击到展示)

当用户点击"开始新案件"时,整个案件生成流程如下:

generateNewCase(): void {
  // 1. 防止重复点击
  if (this.isLoading) return;

  // 2. 重置所有状态
  this.gameState = GameState.CASE_LOADING;
  this.isLoading = true;
  this.showResults = false;
  this.caseData = null;
  this.userReasoning = '';
  this.errorMsg = '';
  this.hintRevealCount = 0;

  // 3. 构建用户消息
  const chatHistory: ChatMessage[] = [
    { role: 'user', content: '请给我生成一个推理案件。' },
  ];

  // 4. 调用 AI API
  let rawContent = '';
  queryAI({
    onData: (text: string): void => {
      rawContent += text;
    },
    onDone: (): void => {
      this.isLoading = false;
      // 5. 解析 JSON 响应
      try {
        let cleanJson = rawContent.trim();
        // 清理可能存在的 markdown 代码块标记
        if (cleanJson.startsWith('```json')) cleanJson = cleanJson.slice(7);
        else if (cleanJson.startsWith('```')) cleanJson = cleanJson.slice(3);
        if (cleanJson.endsWith('```')) cleanJson = cleanJson.slice(0, -3);
        cleanJson = cleanJson.trim();

        const parsed: CaseData = JSON.parse(cleanJson) as CaseData;

        // 6. 校验字段完整性
        if (!parsed.problem || !parsed.hints ||
            !parsed.reasoning_results || !parsed.analysis ||
            !parsed.clearance) {
          throw new Error('缺少必要字段');
        }

        // 7. 更新状态,触发 UI 重绘
        this.caseData = parsed;
        this.revealedHints = new Array(parsed.hints.length).fill(false);
        this.gameState = GameState.CASE_READY;

        // 8. 自动滚动到底部展示内容
        setTimeout(() => {
          this.scroller.scrollEdge(Edge.End);
        }, 100);
      } catch (e) {
        this.errorMsg = '案件生成异常,请重试。';
        this.gameState = GameState.WELCOME;
      }
    },
    onError: (errMsg: string): void => {
      this.isLoading = false;
      this.errorMsg = '灵机中断:' + errMsg;
      this.gameState = GameState.WELCOME;
    },
  }, chatHistory);
}

整个流程的关键步骤说明:

第 4 步的流式累积onData 回调将 AI 逐 token 输出的内容累积到 rawContent 变量中。由于我们不需要在案件加载过程中实时显示 AI 的思考过程(那不是推理游戏想要的效果),所以只是在后台累积数据,等到 onDone 触发时一次性解析。

第 5 步的 JSON 清理:某些 AI 模型虽然被要求"只返回 JSON,不要任何额外文字",但仍然会在 JSON 外面包裹 markdown 的代码块标记(` ```json … ````)。我们的清理逻辑兼容了这种常见情况。

第 6 步的字段校验:在解析成功后将解析结果赋值给 caseData 之前,我们验证了所有必需字段的存在性。这防止了因 AI 输出格式异常(如缺少某个字段)而导致应用在后续渲染时崩溃。

第 8 步的延迟滚动setTimeout(() => { this.scroller.scrollEdge(Edge.End); }, 100) 中的 100ms 延迟是为了给 ArkTS 框架足够的渲染时间。如果立即调用滚动,此时新的 UI 树可能尚未完全渲染,滚动位置的计算会不准确。

6.6 结果展示与通关文牒

用户提交推理后,应用进入 RESULT_SHOWN 状态,此时展示以下内容:

// 推理结果:每条线索对应的推理分析
ForEach(this.getReasoningResultsList(), (result: string, index: number) => {
  Column() {
    Row() {
      Text('线索 ' + (index + 1) + ' →')
        .fontSize(13).fontColor('#C9A84C').margin({ right: 8 })
      Text(this.getHintByIndex(index))
        .fontSize(13).fontColor('#667788')
        .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        .layoutWeight(1)
    }.width('100%').margin({ bottom: 6 })

    Text(result)
      .fontSize(15).fontColor('#E0E0E0').lineHeight(22)
  }
  .width('100%').backgroundColor('#1E2A4A')
  .borderRadius(10).padding(14).margin({ bottom: 8 })
})

// 通文牒(带金色边框)
Column() {
  Text(this.getClearance())
    .fontSize(15).fontColor('#FFD700').lineHeight(24)
}
.width('100%').backgroundColor('#1E2A4A')
.borderRadius(12).padding(16).margin({ bottom: 20 })
.border({ width: 1, color: '#C9A84C', style: BorderStyle.Solid })

通关文牒使用了金色文字(#FFD700)加上金色边框(border),营造出类似奖状或卷轴的视觉仪式感。这个设计细节呼应了游戏"穿越重生"的叙事主题——通关文牒就像是古代告示上的嘉奖令。


七、UI 主题与视觉风格

7.1 侦探风格色彩系统

整个应用采用深色主题,色彩方案围绕"侦探"和"推理"的氛围设计:

色值 用途 设计理念
#1A1A2E 标题栏背景 深邃的午夜蓝,营造神秘感
#0F1A36 页面主背景 更深的藏青色,适合阅读
#16213E 卡片背景 介于标题栏和主背景之间
#C9A84C 强调色/金色 代表"真相"和"智慧"
#E8D5B7 标题文字 米金色,复古侦探风格
#FFD700 通关文牒文字 金色,代表荣誉和成就

7.2 Scroll 与编程式滚动

主页的内容区使用 Scroll 包裹,配合 Scroller 控制器实现编程式滚动。在结果展示页面,当用户提交推理后,我们自动滚动到底部,让用户看到完整的推理分析和通关文牒:

private scroller: Scroller = new Scroller();

// 在提交推理后
submitReasoning(): void {
  this.showResults = true;
  this.gameState = GameState.RESULT_SHOWN;
  setTimeout((): void => {
    this.scroller.scrollEdge(Edge.End);  // 自动滚到底部
  }, 100);
}

scrollEdge(Edge.End) 是 Scroller 提供的一个便捷方法,它直接将内容滚动到末端。结合 Edge.End 枚举值,我们可以精确控制滚动到顶部(Edge.Start)还是底部(Edge.End)。配合 setTimeout 延迟调用,确保内容渲染完成后再执行滚动。


八、工程化实践经验

8.1 数据安全的考虑

AI API 的密钥(API Key)直接硬编码在 AIChatService.ets 中:

const API_KEY = 'hWE5Mxn1cyRqX2YvzJfF3udV';

在生产环境中,这显然是不安全的。对于 HarmonyOS NEXT 应用,推荐的做法是将密钥存储在系统安全组件中,或者通过云端配置服务下发。但在原型验证和教学演示阶段,直接硬编码可以降低入门门槛。

8.2 错误处理与用户体验

应用覆盖了多个层面的错误处理:

// 1. 网络层错误
onError: (errMsg: string): void => {
  this.isLoading = false;
  this.errorMsg = '灵机中断:' + errMsg;
  this.gameState = GameState.WELCOME;
}

// 2. JSON 解析错误
catch (e) {
  this.errorMsg = '案件生成异常,请重试。';
  this.gameState = GameState.WELCOME;
}

// 3. 字段校验错误
if (!parsed.problem || !parsed.hints || ...) {
  throw new Error('缺少必要字段');
}

// 4. 重复点击防护
if (this.isLoading) return;

错误信息使用"灵机中断"这样的游戏化表述,而非生硬的技术错误码,保持了沉浸式的用户体验。

8.3 @State 数组变更检测技巧

在使用 @State 装饰的数组时,直接修改数组元素不会触发 UI 重绘。正确的做法是创建新数组:

// ❌ 不会触发 UI 重绘
this.revealedHints[index] = true;

// ✅ 会触发 UI 重绘
this.revealedHints[index] = true;
this.revealedHints = [...this.revealedHints];

这个技巧在 ArkTS 开发中非常重要。对于对象类型的状态,同样需要创建新对象来触发重绘。

8.4 ArkTS 与 TypeScript 的差异点

对于从 TypeScript/React 生态转到 HarmonyOS ArkTS 的开发者,以下差异点值得特别注意:

装饰器驱动的响应式系统:ArkTS 使用 @State、@Prop、@Link 等装饰器标记响应式变量,而不是像 React 那样通过 useState hook 或者 Vue 的 data() 函数。装饰器在编译阶段就完成了依赖收集,运行时不再需要虚拟 DOM diff 的开销。

@Builder 而不是 JSX:ArkTS 使用 @Builder 装饰的函数来组织复杂的 UI 片段,而不是 JSX 或模板语法。@Builder 函数可以直接访问外层 struct 的成员变量,也可以通过参数传递数据。它与 struct 的 build() 方法共同构成了 ArkTS 组件化体系的两层结构。

条件渲染 vs v-if/ngIf:ArkTS 的条件渲染直接使用 if/else 语句,写在 build() 或 @Builder 函数中。框架在编译时将 if 分支编译为条件渲染节点,运行时根据条件动态创建或销毁组件子树。

列表渲染 vs v-for/ngFor:ArkTS 使用 ForEach 组件进行列表渲染,需要传入数据源、内容生成函数和键值生成函数。键值生成函数对于列表的高效更新至关重要,它帮助框架在列表数据变化时精确地定位哪些子节点需要新增、删除或更新。

8.5 调试与日志实践

在开发过程中,有效的日志输出对于排查问题至关重要。我们的 AIChatService 使用 console.info 输出关键的调试信息:

console.info('[AIChat] Header received: ' + JSON.stringify(header));
console.info('[AIChat] Fallback: parsing body from callback, len=' + bodyStr.length);

日志格式采用"标签+消息"的结构(如 [AIChat] 标签),便于在大量日志中快速筛选目标信息。HarmonyOS 的 hilog 系统会将 console.info 输出到 DevEco Studio 的 Log 面板中,开发者可以使用标签过滤功能只查看特定模块的日志。

对于 UI 相关的调试,可以利用 DevEco Studio 的 Inspector 工具检查组件树结构和布局边界。在 Inspector 中,可以直观地看到每个组件的尺寸、边距、对齐方式等布局信息,对于排查布局异常非常有帮助。


九、总结与展望

9.1 核心收获

通过「重生 AI 推理大师」的开发,我们实践了 HarmonyOS NEXT ArkTS 开发的以下核心能力:

  1. 状态驱动的 UI 架构:通过 @State 装饰器管理应用状态,实现响应式 UI 更新。四个游戏状态(WELCOME、CASE_LOADING、CASE_READY、RESULT_SHOWN)驱动整个页面的视图切换。

  2. 原生网络通信:使用 @kit.NetworkKithttp 模块发起 POST 请求,通过 on('dataReceive') 事件监听器实现 SSE 流式数据接收,并实现了完善的非流式回退机制。

  3. 流式 SSE 解析:实现了从 buffer 到行拆分到 JSON 解析的完整 SSE 数据流水线,兼容 delta 和 message 两种响应格式。

  4. 交互式 UI 组件:可展开/收起的线索卡片、带延迟的编程式滚动、Loading 状态反馈等丰富的交互模式。

  5. 游戏化设计:从系统提示词的"仪式感和成就感"要求,到通关文牒的金色边框设计,处处体现游戏化思维。

9.2 改进方向

作为原型应用,以下方面值得进一步优化:

  • API 密钥安全:迁移到安全组件或云端配置下发
  • 离线缓存:缓存用户已获得的案件,支持离线浏览
  • 案件历史:保存用户的历史案件记录,支持回顾
  • 难度分级:通过调整系统提示词控制案件难度
  • 多模态输入:支持语音输入推理过程
  • 成就系统:累计用户破案数量,解锁成就徽章

9.3 最后的思考

从技术角度来看,HarmonyOS NEXT 的 ArkTS 为 AI 应用开发提供了一套完整的工具链:@kit.NetworkKit 负责网络通信,@State + @Builder 驱动 UI 状态管理,Scroll + Scroller 处理长内容交互,@Entry + @Component 组织页面结构。这套工具链足够支撑从简单工具到复杂 AI 应用的各种需求。

从产品角度来看,AI 推理游戏是一个将大语言模型的"推理能力"转化为"游戏可玩性"的有趣尝试。传统推理游戏需要开发者手动编写案件和谜题,内容生产速度受限。而 AI 驱动的推理游戏可以在每次用户点击时生成全新的、独特的谜题,内容无限、体验不重复——这正是大语言模型为应用开发带来的范式变革。

真正的关键不是技术本身有多强大,而是运用这门技术讲出一个好故事。在「重生 AI 推理大师」中,AI 不是冷冰冰的 API 接口,而是一个"穿越重生的顶级推理大师";线索不是单纯的数据数组,而是"逐步揭开的真相";通关文牒不是简单的成功提示,而是"充满仪式感的结案陈词"。是故事让技术有了温度,让用户忘记了代码的存在,完全沉浸在推理的乐趣中。

这或许就是 AI 原生应用开发的终极追求——用技术构建体验,让用户感受到的不是 AI 的强大,而是故事的魅力。

Logo

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

更多推荐