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

基于 HarmonyOS 的 AI 辅助创意应用开发实战 —— 从零构建 AI 剧本生成 App

作者:duluo
平台: HarmonyOS (ArkTS) + API 24
源码: demo012 - AI 剧本 App
字数: ≈ 10,000 字
阅读时间: 25 分钟


目录

  1. 引言:AI 时代的创意工具
  2. 项目概览与技术选型
  3. HarmonyOS 开发环境搭建与 ArkTS 语言特性
  4. 整体架构设计
  5. 类型系统与数据模型设计
  6. UI 层:ArkTS 声明式 UI 与组件化实践
  7. AI 剧本生成引擎设计与实现
  8. 数据持久化:AppStorage 本地存储方案
  9. 用户交互与体验优化
  10. 多模块复用设计:盲盒贴纸、动画与 AR 识别
  11. 性能优化与 ArkTS 合规实践
  12. 测试策略与质量保障
  13. 构建与打包部署
  14. 开发心得与踩坑记录
  15. 总结与展望

1. 引言:AI 时代的创意工具

2024-2025 年是 AI 技术爆发式落地的两年。大语言模型(LLM)不仅在代码生成、文档撰写等"生产力"场景大放异彩,在创意写作、剧本创作等"想象力"领域同样展现出惊人的潜力。然而,大多数 AI 写作工具仍停留在 Web 端或桌面端,移动端 + AI 原生的创意工具体验尚有巨大空白。

本文分享的 AI 剧本 App(项目代号 aiplay,API 24)正是一次将 AI 内容生成能力与 HarmonyOS 原生移动体验深度融合的实践。它让用户可以在手机上:

  • 选择题材(喜剧/悲剧/悬疑/科幻/爱情/历史/奇幻/恐怖/动作/文艺)
  • 选择叙事风格(经典叙事/现代派/极简风/诗意风/戏剧性)
  • 设置角色数、场次数、幕数等高级参数
  • 一键生成完整的剧本(含角色表、场景、对话、舞台说明)
  • 管理剧本库、编辑角色、续写、导出分享

全文将围绕该项目的完整开发过程,系统阐述如何在 HarmonyOS 平台(API 24)上,使用 ArkTS 语言构建一个 AI 辅助的创意内容生成应用。无论你是 HarmonyOS 新手,还是有经验的移动端开发者,都能从中获得实用的架构思路和编码技巧。


2. 项目概览与技术选型

2.1 项目功能矩阵

模块 功能 技术要点
AI 剧本生成 题材/风格选择、参数配置、自动生成 模板引擎 + 随机算法
剧本管理 剧本库列表、搜索、收藏、删除 AppStorage 持久化
角色管理 角色 CRUD、角色卡展示 表单校验、响应式 UI
AI 续写 在已有剧本基础上追加场景 增量生成算法
导出分享 复制到剪贴板 / Markdown 导出 pasteboard API
盲盒贴纸 开盲盒、收藏、稀有度系统 Preferences 存储
AR 饮食识别 食物识别、营养记录 Camera + AI
定格动画 照片序列播放 帧动画引擎
游戏剪辑 高光时刻裁剪 时间轴交互

2.2 技术栈

层级 技术选型 版本/说明
语言 ArkTS HarmonyOS 原生声明式语言
UI 框架 ArkUI 声明式 UI、@Component/@Builder
状态管理 @State / @Link / @Prop / @Builder 组件级响应式状态
持久化 AppStorage + Preferences 轻量级本地 KV 存储
构建工具 Hvigor HarmonyOS 构建系统 6.1.0+
目标 API API 24 HarmonyOS SDK
包名 com.atomgit.aiplay 单 Entry 模块

2.3 为什么选 HarmonyOS?

HarmonyOS 的 ArkTS 语言结合 ArkUI 框架,提供了与 SwiftUI / Jetpack Compose 相似的声明式 UI 开发体验,但又有其独特的优势:

  1. @Builder 装饰器:支持将 UI 片段封装为可复用的构建函数,比 Compose 的 @Composable 更灵活
  2. @State + @Link + @Prop 三级响应式状态体系,粒度控制清晰
  3. AppStorage 全局存储无需手动序列化,开箱即用
  4. 一键多设备适配:一套代码可运行在手机、平板、折叠屏上
  5. 无 any/unknown 类型约束:ArkTS 强制类型安全,减少运行时错误

3. HarmonyOS 开发环境搭建与 ArkTS 语言特性

3.1 环境准备

API 24 对应 HarmonyOS SDK 的较新版本。推荐使用 DevEco Studio(基于 IntelliJ IDEA 定制)作为 IDE。

# 项目配置关键文件
hvigor/hvigor-config.json5   # 构建配置,modelVersion: 6.1.0
build-profile.json5           # 模块构建配置
local.properties              # 本地 SDK 路径

3.2 ArkTS 核心语法要点

ArkTS 是 TypeScript 的超集,在标准 TS 基础上做了若干增强和限制。本项目严格遵守了以下规则:

3.2.1 禁止解构语法
// ❌ 不合法
const { name, age } = person;

// ✅ 合法替代
const name = person.name;
const age = person.age;
3.2.2 禁止展开运算符
// ❌ 不合法
const newObj = { ...oldObj, key: 'value' };

// ✅ 合法替代——显式复制每个字段
const newObj: MyType = {
  field1: oldObj.field1,
  field2: oldObj.field2,
  key: 'value',
};
3.2.3 禁止 any / unknown
// ❌ 不合法
let x: any;

// ✅ 合法——所有类型必须显式标注
let x: string = '';
let y: number = 0;
3.2.4 @Builder 内禁止声明变量
@Builder
MyView() {
  // ❌ 不合法
  const temp = someValue;

  // ✅ 合法——将逻辑移到普通函数
}

// 在 struct 内定义辅助方法
getSomething(): string {
  return 'result';
}

这些限制初看繁琐,但实际开发中强制了代码的整洁性和可读性,也避免了运行时因类型不确定导致的崩溃。

3.3 项目文件结构

entry/src/main/ets/
├── pages/
│   └── 111.ets                  # 主页面(单页面路由,5 个子页面)
├── components/
│   ├── BlindBoxItem.ets         # 盲盒组件
│   ├── FrameThumb.ets           # 帧缩略图组件
│   ├── PlaybackControl.ets      # 播放控制组件
│   ├── ScanResultPanel.ets      # AR 识别结果面板
│   ├── StickerCard.ets          # 贴纸卡片(3D 效果)
│   ├── TimelineBar.ets          # 视频时间轴裁剪组件
│   └── VideoPreview.ets         # 视频预览组件
├── model/
│   ├── ScriptTypes.ets          # 剧本类型定义 + 常量
│   ├── ScriptGenerator.ets      # AI 剧本生成引擎
│   ├── ScriptStorage.ets        # 剧本存储层
│   ├── CollectionManager.ets    # 盲盒收藏管理器
│   └── StickerData.ets          # 贴纸数据模型
├── entryability/
│   └── EntryAbility.ets         # Ability 入口
└── entrybackupability/
    └── EntryBackupAbility.ets   # 备份扩展 Ability

值得注意的是,本项目采用单页面路由模式——整个 App 只有一个 @Entry 组件(AppMain),通过 @State page: string 状态变量控制页面切换。这种模式在小体量应用中比 Navigation 路由更简洁。


4. 整体架构设计

4.1 分层架构

┌─────────────────────────────────────────────┐
│              UI Layer (ArkUI)                │
│  pages/111.ets (主页面 + 5 个子视图)         │
│  components/ (7 个可复用组件)                │
├─────────────────────────────────────────────┤
│           State Management Layer             │
│  @State / @Link / @Prop / AppStorage        │
├─────────────────────────────────────────────┤
│            Business Logic Layer              │
│  ScriptGenerator (AI 生成引擎)               │
│  CollectionManager (盲盒管理器)              │
├─────────────────────────────────────────────┤
│           Data Access Layer                  │
│  ScriptStorage (AppStorage CRUD)             │
│  preferences (Preferences 持久化)             │
├─────────────────────────────────────────────┤
│          HarmonyOS System APIs               │
│  pasteboard / preferences / media / camera   │
└─────────────────────────────────────────────┘

4.2 单页面 vs 多页面路由

在 HarmonyOS 中,页面路由有 router.pushUrl() 的能力。但本项目选择了单组件多状态的方案:

@Entry
@Component
struct AppMain {
  @State private page: string = 'home';

  build() {
    Stack() {
      Column() {
        if (this.page === 'home') { this.HomePage(); }
        else if (this.page === 'generate') { this.GeneratePage(); }
        else if (this.page === 'list') { this.ListPage(); }
        else if (this.page === 'detail') { this.DetailPage(); }
        else if (this.page === 'characters') { this.CharactersPage(); }
        else if (this.page === 'char_edit') { this.CharacterEditPage(); }
      }
      // Toast 浮层
      if (this.showToast) {
        Text(this.toastMsg)
          .position({ x: '50%', y: '88%' })
          .translate({ x: '-50%' }).zIndex(999)
      }
    }
  }
}

优点

  • 状态共享无需跨页面传递(所有数据在 AppMain 中统一管理)
  • 免去路由参数序列化/反序列化开销
  • 页面切换动画可精确控制(通过状态变化触发)

缺点

  • 单个文件较大(本项目 111.ets 约 960 行)
  • 不适用于团队协作的大型项目

建议:小型应用(< 10 个页面)适合此模式;大型应用推荐 Navigation + 模块化。


5. 类型系统与数据模型设计

5.1 核心类型定义

良好的类型系统是 ArkTS 项目的基石。以下是剧本模块的核心类型体系:

// ========== 剧本类型 ==========
export interface Script {
  id: string;              // 唯一标识
  title: string;           // 标题(由 logline 截取)
  genre: string;           // 题材标识
  style: string;           // 风格标识
  logline: string;         // 故事梗概(一句话)
  prompt: string;          // 自定义提示词
  content: string;         // 剧本正文
  characters: ScriptCharacter[];  // 角色列表
  scenes: SceneInfo[];     // 场景列表
  createdAt: number;       // 创建时间戳
  updatedAt: number;       // 更新时间戳
  wordCount: number;       // 字数
  isFavorite: boolean;     // 是否收藏
  actCount: number;        // 幕数
}

// ========== 角色类型 ==========
export interface ScriptCharacter {
  id: string;
  name: string;
  age: string;
  gender: string;
  personality: string;     // 性格描述
  appearance: string;      // 外貌(留空)
  background: string;      // 背景故事
  goals: string;           // 角色目标
  role: string;            // 定位:主角/反派/配角/龙套
}

// ========== 场景类型 ==========
export interface SceneInfo {
  id: string;
  title: string;
  location: string;
  timeOfDay: string;
  isInterior: boolean;     // 内景/外景
  actNumber: number;
  sceneNumber: number;
  summary: string;
}

// ========== 生成参数 ==========
export interface GenerateParams {
  genre: string;
  style: string;
  logline: string;
  characterCount: number;
  sceneCount: number;
  actCount: number;
  includeStageDirections: boolean;
  includeSubplots: boolean;
  customPrompt: string;
}

5.2 ID 生成策略

由于未引入外部 UUID 库,使用时间戳 + 随机数生成唯一 ID:

export function genId(): string {
  return 's_' + new Date().getTime() + '_' + Math.floor(Math.random() * 99999);
}

export function charId(): string {
  return 'c_' + new Date().getTime() + '_' + Math.floor(Math.random() * 99999);
}

export function sceneId(): string {
  return 'sc_' + new Date().getTime() + '_' + Math.floor(Math.random() * 99999);
}

设计考量

  • 使用 getTime()(毫秒级)而非 Date.now()(两者等价,但 getTime() 更语义化)
  • 加上随机数后缀避免同一毫秒内的碰撞
  • 带前缀(s_ / c_ / sc_)便于调试时快速识别类型

5.3 常量数据管理

题材、风格、角色类型等常量集中定义在 ScriptTypes.ets 中:

export const GENRE_LIST: GenreItem[] = [
  { id: 'comedy',  name: '喜剧',   icon: '😄', desc: '轻松幽默、笑中带泪' },
  { id: 'tragedy', name: '悲剧',   icon: '😢', desc: '命运无常、深刻感人' },
  { id: 'mystery', name: '悬疑',   icon: '🔍', desc: '层层推理、反转不断' },
  { id: 'sci-fi',  name: '科幻',   icon: '🚀', desc: '未来世界、科技奇想' },
  { id: 'romance', name: '爱情',   icon: '💕', desc: '情感纠葛、甜蜜虐心' },
  { id: 'history', name: '历史',   icon: '🏛️', desc: '王朝更迭、英雄史诗' },
  { id: 'fantasy', name: '奇幻',   icon: '🪄', desc: '魔法世界、冒险传奇' },
  { id: 'horror',  name: '恐怖',   icon: '👻', desc: '心理恐惧、惊悚氛围' },
  { id: 'action',  name: '动作',   icon: '💥', desc: '紧张刺激、热血燃爆' },
  { id: 'literary',name: '文艺',   icon: '📖', desc: '细腻叙事、艺术表达' },
];

配套的查询函数使用线性查找而非 Map,因为列表很小(最大 10 项),可读性优先:

export function genreName(id: string): string {
  for (let i = 0; i < GENRE_LIST.length; i++) {
    if (GENRE_LIST[i].id === id) return GENRE_LIST[i].name;
  }
  return id;
}

6. UI 层:ArkTS 声明式 UI 与组件化实践

6.1 主题与色彩体系

App 采用统一的设计语言:

Token 用途
主色 #1A1A2E 导航栏、按钮、标题
辅色 #5B6ABF 标签、链接、图标
背景 #F5F6FA 页面背景
强调色 #2ED573 成功、完成
警告色 #FF9F43 提醒、角色标签
危险色 #FF6B81 删除、警告

6.2 首页设计

首页包含四个区域:

  1. Hero 区域:品牌标识 + 三快捷入口(新剧本、剧本库、收藏)
  2. 题材网格:10 种题材的 Grid 布局,点击跳转生成页
  3. 最近剧本:最近 5 个剧本的水平列表
  4. 空态提示:无剧本时的引导
@Builder
HomePage() {
  Column() {
    // Hero 区域
    Column() {
      Row() {
        Text('AI 剧本').fontSize(28).fontWeight(FontWeight.Bold).fontColor(Color.White)
        Text('创作你的故事').fontSize(13).fontColor('rgba(255,255,255,0.7)')
      }
      Row() {
        this.QBtn('✨', '新剧本')
        this.QBtn('📋', '剧本库')
        this.QBtn('♥', '收藏')
      }
    }
    .backgroundColor('#1A1A2E')
    .borderRadius({ bottomLeft: 24, bottomRight: 24 })

    Scroll() {
      // 题材网格
      Grid() {
        ForEach(GENRE_LIST, (g: GenreItem) => {
          GridItem() { /* ... */ }
        })
      }
      .columnsTemplate('1fr 1fr 1fr 1fr 1fr')

      // 最近剧本
      if (this.scripts.length === 0) {
        // 空态
      } else {
        List() {
          ForEach(this.scripts.slice(0, 5), (item: Script) => {
            ListItem() { this.ScriptCardSmall(item) }
          })
        }
      }
    }
  }
}

6.3 @Builder 的最佳实践

@Builder 是 ArkUI 最强大的特性之一。它允许将 UI 片段封装为可复用函数:

@Builder
QBtn(icon: string, label: string) {
  Column() {
    Text(icon).fontSize(24)
    Text(label).fontSize(12).fontColor('rgba(255,255,255,0.85)')
  }
  .width(80).padding({ top: 10, bottom: 10 })
  .backgroundColor('rgba(255,255,255,0.10)').borderRadius(14)
  .onClick(() => {
    if (label === '收藏') { /* ... */ }
    else if (label === '剧本库') { /* ... */ }
    else { this.page = 'generate'; }
  })
}

关键规则

  1. @Builder 内不能声明变量或执行逻辑计算——所有计算提前在 struct 方法中完成
  2. @Builder 可以接收参数(如本例的 iconlabel
  3. 通过 this.xxx() 方式调用,而非 <QBtn />

6.4 生成页的复杂交互

生成页是 UI 最复杂的页面,包含:

  • 题材选择:水平滚动的 Grid(点击选中 + 高亮)
  • 风格选择:水平滚动的标签列表(胶囊样式)
  • 故事梗概:TextArea 输入
  • 高级设置:可折叠面板,内含角色数/场次数/幕数的 +/- 调节器
  • 生成按钮:Loading 状态控制
  • 结果卡片:生成成功后展示概要 + 保存/重新生成按钮
@Builder
GeneratePage() {
  Column() {
    this.NavBar('✨ 生成新剧本', true)
    Scroll() {
      // 题材选择
      Scroll() {
        Row() {
          ForEach(GENRE_LIST, (g: GenreItem, i: number) => {
            Column() {
              Text(g.icon).fontSize(22)
              Text(g.name).fontSize(11)
            }
            .backgroundColor(i === this.genGenreIdx ? '#EEF0FF' : '#F5F5F5')
            .borderRadius(10)
            .onClick(() => { this.genGenreIdx = i; })
          })
        }
      }

      // 高级设置面板(折叠)
      if (this.genShowAdvanced) {
        this.GenAdvancedSettings()
      }

      // 生成按钮
      Button() {
        if (this.genLoading) {
          LoadingProgress().width(22).height(22).color(Color.White)
        } else {
          Text('🚀 AI 生成剧本')
        }
      }
      .enabled(!this.genLoading)
      .onClick(() => { this.doGenerate(); })

      // 结果卡片
      if (this.genShowResult && this.genResult !== null) {
        this.GenResultCard(this.genResult!)
      }
    }
  }
}

6.5 可复用组件设计

components/ 目录下的 7 个组件均为 export struct,可在其他页面中 import 使用:

组件 用途 核心 Props
BlindBoxItem 盲盒开箱交互 boxType, onOpen
FrameThumb 动画帧缩略图 frame (AnimFrame)
PlaybackControl 播放/暂停/步进 isPlaying, onPlay, onPause, onStep
ScanResultPanel AR 识别结果面板 result (Link), showDetail
StickerCard 3D 贴纸卡片 sticker (Prop), collected, count, compact
TimelineBar 视频时间轴裁剪 project (Link)
VideoPreview 视频预览 project (Link)

StickerCard 为例,它展示了 compactfull 两种渲染模式:

@Component
export struct StickerCard {
  @Prop sticker: StickerDef = { /* default */ };
  @Prop collected: boolean = false;
  @Prop count: number = 0;
  @Prop compact: boolean = false;

  build() {
    Column() {
      if (this.compact) {
        this.CompactView()
      } else {
        this.FullView()
      }
    }
    .width(this.compact ? 72 : '100%')
    .animation({ duration: 300, curve: Curve.EaseOut })
  }

  @Builder
  CompactView() {
    Column() {
      Column() {
        Text(this.sticker.emoji).fontSize(28)
      }
      .width(56).height(56)
      .backgroundColor(this.collected ? this.sticker.bgColor : '#f0f0f0')
      .borderRadius(12)
    }
  }
}

7. AI 剧本生成引擎设计与实现

7.1 引擎架构

ScriptGenerator.ets 是整个项目最核心的模块,约 600 行。其核心设计理念是 模板 + 随机组合 而非调用大模型 API,这使得:

  • 生成速度极快(< 50ms,加上 1200ms 的模拟延迟)
  • 零 API 成本
  • 完全离线可用
  • 内容可控(无敏感内容风险)
generateScript(params)
    │
    ├── 生成标题(从 logline 截取前 20 字)
    ├── 生成角色(generateCharacter × characterCount)
    ├── 生成场景列表(按幕分组,生成 location/timeOfDay)
    └── 生成正文(generateScriptContent)
         ├── 角色表
         ├── 逐场景生成
         │    ├── 场景标题(内景/外景 + 地点 + 时段)
         │    ├── 舞台说明(随机从 ACTION_TEMPLATES 选取)
         │    ├── 对话(从 DIALOGUE_TEMPLATES 选取+角色分配)
         │    └── 灯暗标记
         └── 全剧终 + 版权信息

7.2 角色生成算法

function generateCharacter(index: number, total: number, genre: string): ScriptCharacter {
  const isMale = Math.random() > 0.5;
  const names = isMale ? CHINESE_NAMES_MALE : CHINESE_NAMES_FEMALE;
  const name = names[Math.floor(Math.random() * names.length)];
  const trait = PERSONALITY_TRAITS[Math.floor(Math.random() * PERSONALITY_TRAITS.length)];

  // 角色定位:第一个是主角,第二个是反派(如果总角色 >2),其余为配角
  let role: string;
  if (index === 0) { role = 'protagonist'; }
  else if (index === 1) { role = total > 2 ? 'antagonist' : 'supporting'; }
  else { role = 'supporting'; }

  // 角色目标随题材变化
  if (genre === 'comedy') { goals = '找到真正的快乐'; }
  else if (genre === 'sci-fi') { goals = '拯救人类的未来'; }
  // ...
}

命名库设计

const CHINESE_NAMES_MALE: string[] = [
  '张明远', '李浩然', '王思远', '陈志杰', '赵文博',
  '林海涛', '周景行', '吴子轩', '刘天宇', '杨云帆',
  '孙睿泽', '唐宋雨', '顾北辰', '沈墨言', '陆沉舟',
];

const CHINESE_NAMES_FEMALE: string[] = [
  '林语嫣', '苏婉晴', '白若溪', '叶知秋', '沈清月',
  '安若素', '花千雪', '柳如烟', '顾清漪', '唐小棠',
  '梅若兰', '夏晚晴', '江采薇', '钟离玥', '慕容晴',
];

两个特点:

  • 每个名字都带有明确的性别色彩(方便根据性别分配)
  • 名字风格多样,兼顾现代感与古风感
  • 各 15 个,组合出 [15 × 15 × …] 种角色组合,足够丰富

7.3 场景地点与时段

场景地点按题材分类,确保风格一致性:

const LOCATIONS: Record<string, string[]> = {
  'comedy':  ['咖啡馆', '学校教室', '写字楼', '菜市场', '地铁站', '婚宴现场'],
  'tragedy': ['废弃医院', '老宅', '悬崖边', '墓地', '雨夜街头', '法院'],
  'mystery': ['古堡', '地下室', '密室', '警局', '废弃工厂', '图书馆'],
  'sci-fi':  ['太空站', '实验室', '虚拟世界', '外星殖民地', '时间管理局', 'AI核心舱'],
  'romance': ['海边', '樱花树下', '书店', '音乐厅', '天台', '古镇小巷'],
  // ...
};

时段固定使用 7 个选项:['清晨', '正午', '午后', '黄昏', '夜晚', '深夜', '黎明']

7.4 对话模板系统

每个题材有 5 组对话模板,每组 2-3 句:

const DIALOGUE_TEMPLATES: Record<string, string[][]> = {
  'comedy': [
    ['你认真的?这主意比早餐还离谱。', '有时候最离谱的就是最棒的!'],
    ['我不是在笑你,我是在笑这个世界。', '那我们也够惨的,活在一个笑话里。'],
    // ...
  ],
  'romance': [
    ['你就是我的例外。', '但我不喜欢做例外,我想做日常。'],
    ['世界那么大,我只想待在你身边。', '你什么时候变得这么肉麻了?'],
    // ...
  ],
};

对话中还支持 $SKILL$ 变量占位,运行时替换为随机技能名:

const line1 = diagSet[0].replace(/\$SKILL\$/g, getRandomSkill());
// 例如:"你看我像是有画画的人吗?"

7.5 动作/舞台说明模板

每个题材 6 个动作模板,支持 #NAME##NAME2# 变量替换:

const ACTION_TEMPLATES: Record<string, string[]> = {
  'comedy': [
    '#NAME#一不小心滑倒在地,手里的咖啡洒了#NAME2#一身。',
    '#NAME#翻了个白眼,深吸一口气,挤出一个虚假的微笑。',
    '#NAME#摔门而出,然后两秒后又灰溜溜地回来拿遗忘的手机。',
    // ...
  ],
  'horror': [
    '#NAME#缓缓转过头——什么都没有。但当#NAME#转回来时,那张脸就在面前。',
    '#NAME#看着镜子,镜中的#NAME#露出一个#NAME#没有做的表情。',
    // ...
  ],
};

替换逻辑:

function replaceNames(template: string, chars: ScriptCharacter[]): string {
  let result = template;
  if (chars.length > 0) result = result.replace(/#NAME#/g, chars[0].name);
  if (chars.length > 1) result = result.replace(/#NAME2#/g, chars[1].name);
  else if (chars.length > 0) result = result.replace(/#NAME2#/g, chars[0].name);
  return result;
}

7.6 正文生成流水线

function generateScriptContent(
  genre: string, style: string,
  chars: ScriptCharacter[], scenes: SceneInfo[],
  includeStageDirections: boolean,
): string {
  const lines: string[] = [];

  // 1. 剧本标题
  lines.push('《' + getDefaultTitle(genre) + '》');

  // 2. 角色表
  lines.push('【角色表】');
  for (let ci = 0; ci < chars.length; ci++) {
    lines.push(c.role + ' ' + c.name + '(' + c.age + ')—— ' + c.personality);
  }

  // 3. 按场景生成内容
  for (let si = 0; si < scenes.length; si++) {
    // 幕分隔
    if (scene.actNumber !== currentAct) {
      lines.push('第 ' + toChineseNumber(currentAct) + ' 幕');
    }

    // 场景标题
    lines.push(scene.sceneNumber + '  ' + scene.title);

    // 交替生成 动作 + 对话
    for (let step = 0; step < 3 + random(4); step++) {
      if (step % 2 === 0 && includeStageDirections) {
        // 插入动作描述
      }
      // 插入对话(从模板随机选取)
    }

    if (includeStageDirections) lines.push('(灯暗)');
  }

  // 4. 结尾
  lines.push('【全剧终】');
  return lines.join('\n');
}

7.7 续写功能

export function continueScript(existingScript: Script, additionalScenes: number): Script {
  // 1. 复制已有场景
  const newScenes: SceneInfo[] = [...];  // 显式复制

  // 2. 生成新场景
  for (let i = 0; i < additionalScenes; i++) {
    newScenes.push({ /* 场景信息 */ });
  }

  // 3. 重新生成完整正文(包含旧 + 新场景)
  const fullContent = generateScriptContent(
    existingScript.genre, existingScript.style,
    existingScript.characters, newScenes, true,
  );

  // 4. 返回更新后的 Script
  return { /* ... */ };
}

这里有一个关键设计点:每次续写不是追加文本,而是用全部场景重新生成正文。这样做的原因是避免新旧内容风格割裂,但也意味着续写会改变之前生成的具体台词(保留角色、场景结构,但对话内容会变化)。

7.8 导出功能

支持两种格式:

export function exportScript(script: Script, format: string): string {
  if (format === 'txt') {
    return script.content;
  }
  // Markdown 格式
  let md = '# 《' + script.title + '》\n\n';
  md += '> **题材**:' + getGenreName(script.genre) + '\n';
  md += '> **角色数**:' + script.characters.length + '\n';
  // ... 结构化 Markdown
  return md;
}

配合剪贴板 API 实现分享:

exportText(text: string): void {
  try {
    const data = pasteboard.createData({ 'text/plain': text });
    pasteboard.getSystemPasteboard().setData(data);
    this.showToastMsg('已复制到剪贴板');
  } catch (_) {
    this.showToastMsg('导出失败');
  }
}

8. 数据持久化:AppStorage 本地存储方案

8.1 为什么选择 AppStorage

HarmonyOS 提供了多种存储方案:

方案 场景 本项目选择理由
AppStorage 全局可序列化数据 剧本数据是纯 JSON,无复杂查询需求
Preferences KV 持久化 盲盒收藏管理器使用
数据库 (RDB) 大数据量/复杂查询 本项目不需要
文件系统 大文件/BLOB 不需要

8.2 存储层设计

const STORAGE_KEY_SCRIPTS = 'ai_script_list_v3';

export function loadScripts(): Script[] {
  try {
    const data = AppStorage.get<string>(STORAGE_KEY_SCRIPTS);
    if (data) {
      const parsed: object = JSON.parse(data);
      if (Array.isArray(parsed)) {
        const result: Script[] = [];
        for (let i = 0; i < parsed.length; i++) {
          result.push(parsed[i] as Script);
        }
        return result;
      }
    }
  } catch (_) {}
  return [];
}

版本号策略:存储 key 使用 _v3 后缀。当数据模型变更时,修改版本号即可平滑迁移——旧数据会被 JSON.parse 忽略。

8.3 CRUD 操作

所有写操作都是全量更新模式(read → modify → write):

export function toggleFavorite(scriptId: string): Script[] {
  const list = loadScripts();
  const result: Script[] = [];
  for (let i = 0; i < list.length; i++) {
    const s = list[i];
    if (s.id === scriptId) {
      const updated: Script = {
        id: s.id, title: s.title, // ... 显式复制每个字段
        isFavorite: !s.isFavorite,
      };
      result.push(updated);
    } else {
      result.push(s);
    }
  }
  saveScripts(result);
  return result;
}

这种模式在剧本数量不大(通常 < 100 个)时完全够用。如果数据量增大,可以考虑使用索引优化。

8.4 Preferences 在多模块中的应用

CollectionManager 使用了 @ohos.data.preferences API:

import { preferences } from '@kit.ArkData';

async init(context: Context): Promise<void> {
  this.pref = await preferences.getPreferences(context, 'sticker_app');
  await this.load();
}

private async load(): Promise<void> {
  const oVal = await this.pref.get(KEY_COLLECTION, '{}');
  const parsed = JSON.parse(oVal as string);
  // ...
}

注意 Preferences API 是异步的,而 AppStorage 是同步的。选择依据:

  • AppStorage:简单、同步、适合全局状态
  • Preferences:异步、支持自定义路径、适合与 Context 绑定的数据

9. 用户交互与体验优化

9.1 Toast 反馈系统

轻量级的 Toast 实现,无需引入第三方库:

@State toastMsg: string = '';
@State showToast: boolean = false;

showToastMsg(msg: string): void {
  this.toastMsg = msg;
  this.showToast = true;
  setTimeout(() => { this.showToast = false; }, 2000);
}

// 在 build 中渲染
if (this.showToast) {
  Text(this.toastMsg)
    .fontSize(14).fontColor(Color.White)
    .padding({ left: 24, right: 24, top: 10, bottom: 10 })
    .backgroundColor('rgba(0,0,0,0.8)').borderRadius(20)
    .position({ x: '50%', y: '88%' }).translate({ x: '-50%' }).zIndex(999)
}

9.2 加载状态处理

生成按钮的加载状态通过 @State genLoading 控制:

doGenerate(): void {
  this.genLoading = true;
  this.genShowResult = false;
  setTimeout(() => {
    const script = generateScript({ /* params */ });
    this.genResult = script;
    this.genShowResult = true;
    this.genLoading = false;
  }, 1200);  // 模拟 AI 生成延迟
}

这里的 1200ms 是特意添加的模拟延迟——即使用户端生成只需几毫秒,保留一点等待时间能营造「AI 正在思考」的感觉,提升用户体验。

9.3 确认对话框

删除操作使用 AlertDialog 提供二次确认:

AlertDialog.show({
  title: '删除剧本',
  message: '确定删除《' + sc.title + '》吗?',
  primaryButton: { value: '取消', action: () => {} },
  secondaryButton: {
    value: '删除', fontColor: '#FF6B81',
    action: () => {
      this.scripts = deleteScript(sc.id);
      this.page = 'list';
      this.showToastMsg('已删除');
    },
  },
});

9.4 搜索与过滤

剧本库支持按关键词搜索和按题材过滤:

getFilteredList(): Script[] {
  const all = loadScripts();
  let tempList: Script[] = [];

  // 收藏筛选 / 题材筛选 / 全部
  if (this.listShowFav) { /* 只看收藏 */ }
  else if (this.listTab === 'all') { /* 全部 */ }
  else { /* 按题材筛选 */ }

  // 关键词搜索(大小写不敏感)
  if (this.listSearch.trim()) {
    const kw = this.listSearch.toLowerCase();
    return tempList.filter(s =>
      s.title.toLowerCase().includes(kw) ||
      s.logline.toLowerCase().includes(kw)
    );
  }
  return tempList;
}

10. 多模块复用设计:盲盒贴纸、动画与 AR 识别

10.1 桌面 3D 盲盒贴纸

盲盒系统是本项目中另一个完整的子模块,展现了 ArkTS 中枚举 + 接口的典型用法:

export enum Rarity {
  COMMON = '普通',
  RARE = '稀有',
  EPIC = '史诗',
  LEGENDARY = '传说',
  HIDDEN = '隐藏款',
}

export enum Series {
  ANIMALS = '动物乐园',
  FOOD = '美食世界',
  SPACE = '星际探索',
  FANTASY = '奇幻森林',
  CYBER = '赛博都市',
}

抽卡概率设计

盒子类型 普通 稀有 史诗 传说 隐藏款
免费盒 70% 25% 4% 1% 0%
白银盒 40% 35% 18% 5% 2%
黄金盒 20% 30% 28% 17% 5%
private rollRarity(boxType: BoxType): Rarity {
  const r = Math.random() * 100;
  switch (boxType) {
    case BoxType.FREE:
      if (r < 70) return Rarity.COMMON;
      if (r < 95) return Rarity.RARE;
      if (r < 99) return Rarity.EPIC;
      return Rarity.LEGENDARY;
    // ...
  }
}

收藏管理器设计为单例模式

export class CollectionManager {
  private static instance: CollectionManager | null = null;

  static getInstance(): CollectionManager {
    if (mgrInstance === null) {
      mgrInstance = new CollectionManager();
    }
    return mgrInstance as CollectionManager;
  }
}

10.2 照片变定格动画

播放控制组件的设计体现了回调注入模式:

@Component
export struct PlaybackControl {
  private isPlaying: boolean = false;
  private onPlay: () => void = () => {};
  private onPause: () => void = () => {};
  private onStep: (dir: number) => void = () => {};

  build() {
    Row() {
      Button() { Text('⏮') }.onClick(() => { this.onStep(-1); })
      Button() { Text(this.isPlaying ? '⏸' : '▶') }
        .onClick(() => {
          this.isPlaying = !this.isPlaying;
          if (this.isPlaying) this.onPlay();
          else this.onPause();
        })
      Button() { Text('⏭') }.onClick(() => { this.onStep(1); })
    }
  }
}

使用方通过 setter 方法注入回调:

playbackCtrl.setOnPlay(() => { /* 播放逻辑 */ });
playbackCtrl.setOnPause(() => { /* 暂停逻辑 */ });

10.3 AR 饮食识别面板

ScanResultPanel 使用了 @Link 双向绑定:

@Component
export struct ScanResultPanel {
  @Link result: RecognitionResult | null;
  @Link showDetail: boolean;
}

@Link 使子组件可以修改父组件的状态,适合「面板关闭→更新父页面」的场景。

10.4 游戏高光时刻剪辑

TimelineBarVideoPreview 共享 ClipProject 数据:

@Component
export struct TimelineBar {
  @Link project: ClipProject | null;
}

@Component
export struct VideoPreview {
  @Link project: ClipProject | null;
}

时间轴裁剪的核心逻辑:

moveStart(delta: number): void {
  const newStart = p.startTime + delta;
  if (newStart >= 0 && newStart < p.endTime - 500) {
    this.project = { /* 复制并更新 startTime */ };
  }
}

11. 性能优化与 ArkTS 合规实践

11.1 ArkTS 合规检查清单

规则 违反后果 本项目做法
禁止 any/unknown 编译报错 所有类型显式标注
禁止解构赋值 编译报错 逐个字段赋值
禁止展开运算符 编译报错 显式对象复制
@Builder 内禁止变量声明 行为未定义 逻辑移到 struct 方法
禁止 typeof 编译报错 使用类型守卫

11.2 响应式性能优化

1. 状态粒度控制

将页面切分为多个 @Builder,使状态变化时只重绘受影响的部分:

@Builder
GenAdvancedSettings() { /* 只有展开/折叠时重新渲染 */ }

@Builder
GenResultCard(script: Script) { /* 只有生成完成时重新渲染 */ }

2. 避免不必要的重建

ForEach 的 key 使用唯一 ID:

ForEach(GENRE_LIST, (g: GenreItem) => {
  GridItem() { /* ... */ }
})
// ForEach 默认使用对象引用作为 key,对于常量列表没问题

3. 动画性能

BlindBoxItem 使用 animation 实现摇晃效果,仅改变一个 rotate 属性:

.rotate({ angle: this.shaking ? 5 : 0 })
.animation({ duration: 200, curve: Curve.EaseInOut, iterations: this.shaking ? -1 : 0 })

11.3 内存优化

  • 所有 ID 使用 string 而非 number(避免精度问题)
  • 历史记录限制 100 条:if (this.history.length > 100) { this.history.shift(); }
  • 搜索使用低开销的 includes 而非正则
  • 角色表使用 Array 而非 Map(数据量小,遍历成本可忽略)

11.4 安全与健壮性

防御性编程

export function getScript(scriptId: string): Script | null {
  const list = loadScripts();
  for (let i = 0; i < list.length; i++) {
    if (list[i].id === scriptId) return list[i];
  }
  return null;  // 不存在时返回 null,而非抛异常
}

异常捕获

try {
  const data = pasteboard.createData({ 'text/plain': text });
  pasteboard.getSystemPasteboard().setData(data);
} catch (_) {
  this.showToastMsg('导出失败');
}

12. 测试策略与质量保障

12.1 测试文件结构

entry/src/
├── main/                          # 生产代码
├── mock/
│   └── mock-config.json5          # Mock 配置
├── ohosTest/
│   ├── module.json5
│   └── ets/test/                  # 鸿蒙原生测试
├── test/
│   ├── List.test.ets              # 列表测试
│   └── LocalUnit.test.ets         # 本地单元测试

12.2 测试重点

AI 引擎测试(核心)

- generateScript: 验证输出结构完整性
  - 角色数量 = characterCount
  - 场景数量 = sceneCount
  - 幕数 = actCount
  - content 包含角色表和「全剧终」
  - wordCount = content.length

- continueScript: 验证续写不丢失原数据
  - scenes 数量 = 原场景数 + additionalScenes
  - createdAt 不变
  - updatedAt 更新

- exportScript: 验证格式正确性
  - txt 格式直接返回 content
  - markdown 格式包含 # 标题和 --- 分割线

存储层测试

- createScript → loadScripts 一致性
- toggleFavorite 切换正确
- deleteScript 不删除其他剧本
- getStats 统计数据准确

12.3 Mock 策略

使用 @ohos/hamock 进行接口模拟:

// mock-config.json5
{
  "mocks": {
    "pasteboard": {
      "createData": { "return": {} },
      "getSystemPasteboard": { "return": { setData: () => true } }
    }
  }
}

13. 构建与打包部署

13.1 Hvigor 构建配置

// hvigor/hvigor-config.json5
{
  modelVersion: "6.1.0",
  execution: {
    // "analyze": "normal",
    // "daemon": true,
    // "incremental": true,
    // "parallel": true,
  },
  logging: {
    // "level": "info"
  },
}

13.2 模块配置

// entry/build-profile.json5
{
  "apiType": "stageMode",       // Stage 模型
  "buildOption": {
    "arkOptions": {
      "strictMode": "strict"    // 严格模式
    }
  }
}

13.3 应用签名

// AppScope/app.json5
{
  "app": {
    "bundleName": "com.atomgit.aiplay",
    "vendor": "AtomGit",
    "versionCode": 1000000,
    "versionName": "1.0.0",
    "buildVersion": "1",
    "icon": "$media:layered_image",
    "label": "$string:app_name"
  }
}

13.4 混淆规则

# obfuscation-rules.txt
-keep class com.atomgit.aiplay.** { *; }

14. 开发心得与踩坑记录

14.1 常见问题与解决方案

Q1: ArkTS 编译报错"Unsupported destructuring"

原因:ArkTS 不支持解构赋值语法。

解决:逐个字段赋值。

// ❌ 错误写法
const { id, title } = script;

// ✅ 正确写法
const id = script.id;
const title = script.title;
Q2: @Builder 内使用变量不生效

原因@Builder 不允许声明变量,但可以读取 struct 的属性和方法。

解决:在 @Builder 外部计算好值,通过参数传入。

// ✅ 正确写法
@Builder
ScriptCard(script: Script) {
  Text(genreIcon(script.genre))  // 调用 struct 方法
}
Q3: AppStorage 数据丢失

原因:存储 key 在不同版本之间不兼容。

解决:使用版本化 key(如 ai_script_list_v3),模型变更时递增版本号。

Q4: setState 不触发 UI 更新

原因:直接修改 @State 数组的元素不会触发响应式更新。

解决:创建新数组替换。

// ❌ 不触发更新
this.scripts[0].title = '新标题';

// ✅ 触发更新
const newList = [...];  // 显式创建副本
newList[0] = { ...this.scripts[0], title: '新标题' };
this.scripts = newList;

14.2 设计决策日志

决策 选项 选择 理由
页面路由 Navigation vs 单组件 单组件 状态共享简单,文件数少
AI 引擎 LLM API vs 模板引擎 模板引擎 零成本、离线可用、可控
存储方案 RDB vs AppStorage AppStorage 数据结构简单,查询需求低
ID 生成 UUID vs 时间戳+随机 时间戳+随机 无外部依赖,足够唯一
状态管理 全局 Store vs @State @State 应用规模小,无需引入 Redux 模式
导出格式 PDF vs TXT vs MD TXT + MD 实现简单,通用性高

14.3 经验总结

  1. 类型先行:在 ArkTS 中,好的类型设计能避免 80% 的编译错误。每个接口、函数都应该有明确的类型签名。

  2. @Builder 是质量倍增器:将 UI 切分为 @Builder 不仅提高复用性,还能让每个 @Builder 的职责清晰,降低调试难度。

  3. 模拟延迟提升体验:即使是本地生成,添加 1-2 秒的模拟延迟比瞬间输出更符合用户对「AI 生成」的心理预期。

  4. 显示复制 vs 解构:刚开始写显示复制很痛苦,但它迫使开发者明确每个字段的值,避免了因解构导致的隐式引用问题。

  5. 防御性返回 null:所有 get/query 函数都返回 null 而非抛出异常,上层调用者自行决定如何处理空值。


15. 总结与展望

15.1 项目成果

通过本文的完整介绍,我们完成了一个功能丰富的 AI 辅助剧本创作 App,涵盖:

  • AI 生成引擎:基于模板系统的剧本生成器,支持 10 种题材 × 5 种风格
  • 完善的管理系统:剧本 CRUD、收藏、搜索、角色管理
  • 多种导出方式:剪贴板复制、Markdown 导出
  • 多模块整合:盲盒贴纸、定格动画、AR 识别、视频剪辑
  • 全流程开发:从类型设计到 UI 实现,从本地存储到测试部署

15.2 可扩展方向

  1. 接入真实 LLM API:将模板引擎替换为调用大模型 API,实现真正的 AI 生成
  2. 云端同步:通过 @kit.NetworkKit 实现多设备剧本同步
  3. 多人协作:基于 @kit.DistributedKit 的分布式协作编辑
  4. 语音输入:集成语音转文字,口述故事梗概
  5. 剧本排版:引入富文本渲染,支持字体、颜色、格式调整
  6. 社区分享:搭建剧本广场,用户可分享和下载公开剧本

15.3 结语

构建 AI 辅助的创意工具,技术实现只是第一步。真正重要的是理解创作者的 workflow,在合适的位置融入 AI 能力,而不是为了 AI 而 AI。

本项目中的「AI」并非真正的深度学习模型,而是一个精心设计的模板+随机引擎——但这恰恰说明了:AI 辅助体验的核心不在于模型有多强,而在于产品设计能否降低用户的创作门槛,激发他们的表达欲望。

当用户选择题材、调整参数、点击生成,看到属于自己的剧本缓缓展开时,那种「创作完成」的成就感,无论是来自大模型还是模板引擎,都不会有区别。


附录

A. 关键文件索引

文件路径 行数 职责
ets/pages/111.ets ~960 主页面(6 个子页面)
ets/model/ScriptGenerator.ets ~600 AI 剧本生成引擎
ets/model/ScriptStorage.ets ~190 存储 CRUD
ets/model/ScriptTypes.ets ~210 类型定义 + 常量 + 工具函数
ets/model/CollectionManager.ets ~225 盲盒收藏管理器
ets/model/StickerData.ets ~96 贴纸数据模型

B. 依赖清单

@ohos/hamock@1.0.0    # 测试 Mock
@ohos/hypium@1.0.25   # 测试框架
@kit.BasicServicesKit  # 基础服务(pasteboard 等)
@kit.ArkData           # 数据存储(preferences)

C. 参考资源


Logo

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

更多推荐