ArkTS 极简全能创作软件实战 —— 从零构建绘图、文案、修图三合一 App


在这里插入图片描述

目录

  1. HarmonyOS NEXT 与 ArkTS 概述
  2. 项目结构设计
  3. EntryAbility:应用入口与生命周期
  4. Tabs 底部导航架构
  5. Flex 响应式卡片流布局(FlexCardLayout)
  6. 模块一:绘图页面 —— Canvas 手指绘画
  7. 模块二:写文案页面 —— 文本编辑与剪贴板
  8. 模块三:修照片页面 —— Canvas 像素滤镜引擎
  9. 通用组件与类型定义
  10. @State 响应式数据流深度解析
  11. Hvigor 构建与调试
  12. ArkTS 最佳实践与常见陷阱
  13. 总结与扩展方向

1. HarmonyOS NEXT 与 ArkTS 概述

1.1 什么是 HarmonyOS NEXT

HarmonyOS NEXT 是华为从底层自研的操作系统,去掉了 AOSP 代码,完全基于鸿蒙内核。这意味着它不再兼容 Android 应用,所有应用必须使用鸿蒙原生开发方式。API 24 是 HarmonyOS NEXT 的一个里程碑版本,带来了更完善的 ArkUI 组件能力和更稳定的 ArkTS 编译器。

1.2 ArkTS 语言特性

ArkTS 是鸿蒙原生的声明式 UI 开发语言,基于 TypeScript 语法扩展。它与 SwiftUI、Jetpack Compose 属于同一代声明式 UI 范式。核心特性包括:

  • @Entry @Component 装饰器:标记页面入口和组件
  • @State @Link @Prop 装饰器:响应式状态管理
  • @Builder 装饰器:轻量级 UI 构建函数
  • 链式 API 调用.width() .height() .backgroundColor()
  • ForEach 循环渲染:列表数据驱动 UI
  • 内置 Canvas 2D API:与 Web Canvas 高度相似

1.3 工程结构

一个标准的 HarmonyOS NEXT 工程结构如下:

project-root/
├── entry/
│   └── src/
│       └── main/
│           ├── ets/
│           │   ├── entryability/        # Ability 生命周期
│           │   │   └── EntryAbility.ets
│           │   └── pages/               # 页面文件
│           │       ├── Index.ets
│           │       ├── FlexCardLayout.ets
│           │       └── CreativeApp.ets
│           ├── resources/               # 资源文件
│           └── module.json5             # 模块配置
├── build-profile.json5                  # 构建配置
├── hvigorfile.ts                        # 构建脚本
└── oh-package.json5                     # 包管理

2. 项目结构设计

2.1 本文涉及的页面

本文围绕两个页面展开:

文件 功能 行数
CreativeApp.ets 极简全能创作软件(绘图+文案+修图) 910 行
FlexCardLayout.ets Flex 响应式卡片流布局演示 583 行
EntryAbility.ets 应用入口,加载 CreativeApp 页面 48 行

2.2 设计思路

我们的目标是构建一个「新手友好、一应俱全」的创作工具。三个核心功能互不依赖,适合用 Tabs 底部导航组织。每个功能独立为一个 @Component,通过主入口的 @State 控制 Tab 切换状态。


3. EntryAbility:应用入口与生命周期

3.1 UIAbility 基础

HarmonyOS 的 Ability 相当于 Android 的 Activity 或 iOS 的 UIViewController。EntryAbility 是应用的启动入口,继承自 UIAbility

import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';

const DOMAIN = 0x0000;

export default class EntryAbility extends UIAbility {
  // Ability 创建时调用
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    try {
      // 设置颜色模式:跟随系统
      this.context.getApplicationContext()
        .setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
    } catch (err) {
      hilog.error(DOMAIN, 'testTag',
        'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
    }
    hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
  }
}

3.2 加载页面

最关键的方法是 onWindowStageCreate,在这里通过 loadContent 指定首页的页面路径:

onWindowStageCreate(windowStage: window.WindowStage): void {
  hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
  windowStage.loadContent('pages/CreativeApp', (err) => {
    if (err.code) {
      hilog.error(DOMAIN, 'testTag',
        'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
      return;
    }
    hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
  });
}

loadContent 的第一个参数是页面路径,对应 pages/ 目录下的 .ets 文件(不含扩展名)。路径格式为 'pages/页面名',必须与 @Entry 装饰的结构体名称一致。

3.3 生命周期方法

方法 触发时机 用途
onCreate Ability 首次创建 初始化数据、配置
onDestroy Ability 销毁 释放资源
onWindowStageCreate 窗口创建 加载 UI 页面
onWindowStageDestroy 窗口销毁 清理窗口资源
onForeground 进入前台 恢复 UI 状态
onBackground 进入后台 保存数据快照

4. Tabs 底部导航架构

4.1 主入口结构

CreativeApp 结构体使用 @Entry@Component 装饰,表示它是一个页面入口。内部通过 Tabs 组件实现底部导航栏,配合 @State currentIndex 追踪当前选中的 Tab。

@Entry
@Component
struct CreativeApp {
  @State private currentIndex: number = 0;

  build() {
    Column({ space: 0 }) {
      // ---- 顶栏 ----
      Row() {
        Text('✨ 极简创作')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1A1A2E')
        Blank()
        Text('新手友好 · 一应俱全')
          .fontSize(11)
          .fontColor('#AAA')
      }
      .width('100%')
      .padding({ left: 20, right: 20, top: 12, bottom: 8 })
      .backgroundColor('#FFFFFF')

      // ---- Tabs 内容区 ----
      Tabs({
        barPosition: BarPosition.End,   // 标签栏在底部
        index: this.currentIndex,
        onChange: (idx: number) => { this.currentIndex = idx; }
      }) {
        TabContent() { DrawPage() }
          .tabBar(this.TabBarItem('🎨', '绘图', 0))

        TabContent() { WritePage() }
          .tabBar(this.TabBarItem('✍️', '写文案', 1))

        TabContent() { PhotoEditPage() }
          .tabBar(this.TabBarItem('📷', '修照片', 2))
      }
      .width('100%')
      .height('100%')
      .backgroundColor('#F5F6FA')
    }
  }
}

4.2 TabBarItem 构建器

@Builder 是 ArkTS 中用于构建 UI 片段的轻量级函数。与 @Component 不同,@Builder 没有独立的状态管理,适合封装纯展示性 UI。

@Builder
TabBarItem(icon: string, label: string, index: number) {
  Column({ space: 2 }) {
    Text(icon).fontSize(22)
    Text(label)
      .fontSize(11)
      .fontColor(this.currentIndex === index ? '#6C63FF' : '#999')
  }
  .width('100%')
  .padding({ top: 6, bottom: 4 })
  .alignItems(HorizontalAlign.Center)
  .justifyContent(FlexAlign.Center)
}

关键设计细节:

  • 选中态高亮:通过 this.currentIndex === index 判断当前 Tab,高亮色为 #6C63FF(紫色),非选中为 #999(灰色)
  • barPosition: BarPosition.End:将 Tab 栏固定在底部,符合移动端常见交互模式
  • onChange 回调:Tab 切换时更新 currentIndex,从而触发 UI 刷新

4.3 Tabs 组件注意事项

属性 说明
barPosition BarPosition.Start(顶部)/ BarPosition.End(底部)
index 当前选中的 Tab 索引(受控模式)
onChange Tab 切换回调,参数为选中索引

ArkTS 中 Tabs 的受控模式需要同时设置 indexonChange 才能正常工作。只设置 index 而不处理 onChange 会导致点击 Tab 后切换失败。


5. Flex 响应式卡片流布局(FlexCardLayout)

5.1 布局原理

在移动端开发中,商品卡片、图片墙的自适应排列是最常见的 UI 需求之一。传统方式需要监听屏幕宽度、计算列数、编写断点。而在 ArkTS 中,Flex + wrap + layoutWeight 的组合可以零代码实现完全自适应的响应式卡片流。

核心思路:

Flex({ wrap: FlexWrap.Wrap })  // 超出换行
  → 每张卡片 .layoutWeight(1)  // 行内等分
    → .constraintSize({ minWidth, maxWidth })  // 边界保护

5.2 数据模型定义

// 商品数据
class Commodity {
  id: number = 0;
  name: string = '';
  price: number = 0;
  imageColor: string = '';
  icon: string = '';

  constructor(id: number, name: string, price: number, color: string, icon: string) {
    this.id = id;
    this.name = name;
    this.price = price;
    this.imageColor = color;
    this.icon = icon;
  }
}

// 图片墙数据
class WallImage {
  id: number = 0;
  label: string = '';
  gradientStart: string = '';
  gradientEnd: string = '';
  icon: string = '';
}

5.3 核心 Flex 容器

Flex({
  wrap: FlexWrap.Wrap,          // 【核心】自动换行
  direction: FlexDirection.Row, // 水平排列
  justifyContent: FlexAlign.SpaceBetween,
  alignItems: ItemAlign.Start,
}) {
  ForEach(this.commodities, (item: Commodity, index: number) => {
    this.CommodityCard(item, index)
  }, (item: Commodity) => item.id.toString())
}
.width('100%')

5.4 layoutWeight 响应式等分

每张卡片设置 .layoutWeight(1),表示同一行内的所有卡片权重相同,layoutWeight 会将行内剩余空间按权重比例分配给每个卡片。

@Builder
CommodityCard(item: Commodity, index: number) {
  Column({ space: 6 }) {
    // 模拟商品图
    Row() {
      Text(item.icon).fontSize(32)
    }
    .width('100%')
    .aspectRatio(1.0)
    .backgroundColor(item.imageColor)
    .borderRadius(12)
    .justifyContent(FlexAlign.Center)
    .alignItems(VerticalAlign.Center)

    // 商品名称
    Text(item.name)
      .fontSize(13)
      .maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
      .width('100%')

    // 价格
    Text(`¥${item.price}`)
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FF6B81')
      .width('100%')
  }
  .layoutWeight(1)                                  // ← 行内等分剩余空间
  .constraintSize({ minWidth: 100, maxWidth: 200 })  // ← 防变形
  .margin(6)
  .backgroundColor(Color.White)
  .borderRadius(14)
  .shadow({ radius: 6, color: 'rgba(0,0,0,0.06)', offsetX: 0, offsetY: 3 })
}

5.5 自适应效果对照

设备宽度 卡片最小宽度 每行列数 排列效果
360dp(手机竖屏) 100dp 3 列 三列紧凑排列
600dp(大屏手机) 100dp 4~5 列 空间充裕
800dp(折叠屏展开) 100dp 5~6 列 充分利用宽屏

完全不需编写 @MediaQuery 断点,Flex 自身完成全部适配。

5.6 交互:增删卡片

// 新增商品
private addCommodity(): void {
  const names = ['智能台灯', '电动牙刷', '加湿器', '充电宝', '数据线', '手机壳', '移动硬盘', '摄像头'];
  const colors = ['#FF6348', '#2ED573', '#5352ED', '#FFA502', '#70A1FF', '#FF4757'];
  const icons = ['💡', '🪥', '💨', '🔋', '🔌', '📱', '💾', '📹'];
  const idx = Math.floor(Math.random() * names.length);
  const price = Math.floor(Math.random() * 3000) + 49;

  this.commodities.push(
    new Commodity(this.nextCommodityId, names[idx], price, colors[idx % colors.length], icons[idx])
  );
  this.nextCommodityId++;
  promptAction.showToast({ message: `✅ 已添加「${names[idx]}`, duration: 1200 });
}

// 删除最后一件
private removeLastCommodity(): void {
  if (this.commodities.length === 0) {
    promptAction.showToast({ message: '⚠️ 已经没有商品了', duration: 1200 });
    return;
  }
  const removed = this.commodities.pop()!;
  promptAction.showToast({ message: `🗑️ 已删除「${removed.name}`, duration: 1200 });
}
@State 数组的响应式行为

在 ArkTS 中,@State 监听的是数组引用变化。直接调用 push() / pop() 会触发数组的写操作,ArkTS 编译器会自动将其转换为响应式更新。这一点与 React 的 useState 不同——React 需要不可变数据([...arr, newItem]),而 ArkTS 可以安全地调用数组的变异方法。


6. 模块一:绘图 Page —— Canvas 手指绘画

6.1 技术方案选择

绘图功能是 Canvas 2D API 最经典的应用场景。ArkTS 提供的 CanvasRenderingContext2D 与 Web Canvas API 高度兼容,支持 beginPathmoveTolineTostrokearcfillRect 等全部核心绘图方法。

6.2 状态定义

@Component
struct DrawPage {
  // Canvas 渲染上下文
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);

  // @State 驱动 UI 的状态
  @State private currentColor: string = '#6C63FF';
  @State private brushSize: number = 4;
  @State private isDrawing: boolean = false;

  // 笔画历史(非 UI 状态,不触发刷新)
  private strokes: DrawStroke[] = [];        // 已完成笔画
  private currentStroke: DrawPoint[] = [];   // 当前正在画的笔画

这里的关键设计思路是分层管理状态:

  • @State 变量currentColor, brushSize):UI 直接依赖这些值——颜色盘选中态、滑条数值、工具栏文字都基于它们渲染。修改它们会触发 build() 重新执行。
  • 私有实例变量strokes, currentStroke):这些是内部状态,UI 不直接展示笔画列表。修改它们不触发 UI 刷新,只在需要重绘时通过 context.stroke() 手动操作 Canvas。

6.3 Canvas 声明

Canvas(this.context)
  .width('100%')
  .height(420)
  .backgroundColor('#FFFFFF')
  .borderRadius(16)
  .shadow({ radius: 8, color: 'rgba(0,0,0,0.06)', offsetY: 4 })
  .onReady(() => {
    this.drawGrid();
  })
  .onTouch((event: TouchEvent) => {
    this.handleCanvasTouch(event);
  })

Canvas 组件通过 CanvasRenderingContext2D 实例连接,onReady 回调保证 Canvas 的 widthheight 属性已就绪,是执行初始化的最佳时机。onTouch 是触摸事件入口,处理 Down / Move / Up 三种触摸类型。

6.4 触摸事件处理

private handleCanvasTouch(event: TouchEvent): void {
  // 获取 Canvas 在屏幕上的位置,将触摸坐标转换为画布坐标
  const rect = this.context.canvas.getBoundingRect();
  const x = event.touches[0].x - (rect.left ?? 0);
  const y = event.touches[0].y - (rect.top ?? 0);

  switch (event.type) {
    case TouchType.Down:
      this.isDrawing = true;
      this.currentStroke = [{ x, y }];        // 记录新笔画起点
      this.context.beginPath();
      this.context.moveTo(x, y);
      break;

    case TouchType.Move:
      if (!this.isDrawing) return;
      this.context.lineTo(x, y);
      this.context.strokeStyle = this.currentColor;
      this.context.lineWidth = this.brushSize;
      this.context.lineCap = LineCap.Round;    // 圆头端点
      this.context.lineJoin = LineJoin.Round;  // 圆角连接
      this.context.stroke();
      this.currentStroke.push({ x, y });       // 记录路径点
      break;

    case TouchType.Up:
      this.isDrawing = false;
      if (this.currentStroke.length > 0) {
        this.strokes.push([...this.currentStroke]);  // 保存整笔笔画
        this.currentStroke = [];
      }
      break;
  }
}
坐标转换详解

Canvas 可能位于屏幕的任何位置(受父容器 paddingmargin 等影响)。getBoundingRect() 返回 Canvas 在屏幕坐标系中的位置和尺寸。触摸事件的 touches[0].x 是屏幕坐标,需要减去 rect.leftrect.top 才能得到 Canvas 内部的绘制坐标。

// 不正确的做法(直接使用 touch 坐标):
const x = event.touches[0].x;  // ❌ 忽略了 Canvas 的偏移

// 正确的做法:
const rect = this.context.canvas.getBoundingRect();
const x = event.touches[0].x - rect.left;  // ✅ 转换为画布坐标

6.5 撤销功能实现

撤销的核心思路是「笔画历史栈」。strokes 数组存储每一笔完整路径,撤销时 pop() 弹出最后一笔,然后清空画布从零重绘。

private undo(): void {
  if (this.strokes.length === 0) {
    promptAction.showToast({ message: '⚠️ 没有可撤销的笔画', duration: 1200 });
    return;
  }
  this.strokes.pop();         // 移除最后一笔
  this.redrawAll();           // 清空并重绘所有剩余笔画
  promptAction.showToast({ message: '↩ 已撤销上一步', duration: 1000 });
}

private redrawAll(): void {
  const ctx = this.context;
  const w = ctx.canvas.width;
  const h = ctx.canvas.height;
  ctx.clearRect(0, 0, w, h);   // 清空画布
  this.drawGrid();              // 绘制背景网格

  for (const stroke of this.strokes) {
    if (stroke.length < 2) continue;
    ctx.beginPath();
    ctx.moveTo(stroke[0].x, stroke[0].y);
    for (let i = 1; i < stroke.length; i++) {
      ctx.lineTo(stroke[i].x, stroke[i].y);
    }
    ctx.strokeStyle = this.currentColor;
    ctx.lineWidth = this.brushSize;
    ctx.lineCap = LineCap.Round;
    ctx.lineJoin = LineJoin.Round;
    ctx.stroke();
  }
}

6.6 保存画作为图片

CanvasRenderingContext2D 提供了 getPixelMap() 方法,可以将 Canvas 的当前帧转换为 PixelMap(像素图),再通过 image.ImagePacker 编码为 PNG 格式的 ArrayBuffer

import { image } from '@kit.ImageKit';

private saveDrawing(): void {
  const ctx = this.context;
  const pixelMap = ctx.getPixelMap(0, 0, ctx.canvas.width, ctx.canvas.height);
  if (!pixelMap) {
    promptAction.showToast({ message: '⚠️ 保存失败,请重试', duration: 1500 });
    return;
  }

  const packer: image.ImagePacker = image.createImagePacker();
  const packOpts: image.PackingOption = { format: 'image/png', quality: 100 };
  const arrayBuffer: ArrayBuffer = packer.packing(pixelMap, packOpts);
  packer.release();

  const sizeKB: number = Math.round(arrayBuffer.byteLength / 1024);
  promptAction.showToast({
    message: `✅ 画作已保存!(${sizeKB}KB)`,
    duration: 2000,
  });
}

注意:本示例以 Toast 提示模拟保存成功。在完整应用中,可以通过 @ohos.file.fs@ohos.multimedia.mediaLibraryArrayBuffer 写入设备媒体库。

6.7 颜色盘与笔刷

颜色盘使用 Flex + wrap 实现流式排列,12 种预设颜色可点击切换:

Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
  ForEach(this.presetColors, (color: string) => {
    Row() {
      if (color === this.currentColor) {
        Text('✓')
          .fontSize(14)
          .fontColor(color === '#F9F9F9' ? '#333' : '#FFF')
          .fontWeight(FontWeight.Bold)
      }
    }
    .width(32).height(32)
    .backgroundColor(color)
    .borderRadius(16)
    .border({
      width: color === this.currentColor ? 2.5 : 1,
      color: color === this.currentColor ? '#6C63FF' : '#E8E8E8',
    })
    .margin(4)
    .justifyContent(FlexAlign.Center)
    .alignItems(VerticalAlign.Center)
    .onClick(() => { this.currentColor = color; })
  })
}

笔刷大小通过 Slider 滑条调节,范围 1~20px:

Slider({
  value: this.brushSize,
  min: 1,
  max: 20,
  step: 1,
  style: SliderStyle.OutSet,
})
.width('70%')
.trackThickness(4)
.blockColor('#6C63FF')
.trackColor('#E0E0FF')
.selectedColor('#6C63FF')
.onChange((val: number) => { this.brushSize = val; })

Slider 关键属性说明:

  • value:当前值(双向绑定)
  • min / max:取值范围
  • step:步进单位
  • styleSliderStyle.OutSet 滑块凸起风格,SliderStyle.InSet 滑块内嵌风格

7. 模块二:写文案 Page —— 文本编辑与剪贴板

7.1 状态与 UI 布局

@State private content: string = '';       // 文案内容
@State private fontSize: number = 16;       // 字号
@State private templateIndex: number = -1;  // 当前选中的模板索引

文案编辑区使用 TextArea 组件,它是 ArkUI 中支持多行文本输入的组件,与 TextInput(单行)不同,适合段落式的文案创作场景。

TextArea({
  text: this.content,
  placeholder: '在这里输入文案……\n\n💡 试试点击下方的「快捷模板」一键填充',
})
.width('100%')
.height(240)
.fontSize(this.fontSize)
.fontColor('#1A1A2E')
.backgroundColor(Color.White)
.borderRadius(16)
.padding(14)
.placeholderFont({ size: 13, color: '#CCC' })
.onChange((val: string) => { this.content = val; })
.shadow({ radius: 6, color: 'rgba(0,0,0,0.04)', offsetY: 3 })

7.2 字号滑动调节

字号调节通过 Slider 组件实现,范围 12~28px。A⁻A⁺ 标签直观显示缩小/放大功能:

Row({ space: 10 }) {
  Text('A⁻').fontSize(13).fontColor('#999')
  Slider({
    value: this.fontSize, min: 12, max: 28, step: 1,
    style: SliderStyle.OutSet,
  })
  .width('55%')
  .trackThickness(3)
  .blockColor('#6C63FF')
  .trackColor('#E0E0FF')
  .selectedColor('#6C63FF')
  .onChange((v: number) => { this.fontSize = v; })
  Text('A⁺').fontSize(16).fontColor('#999')
  Blank()
  Button('📋 复制')
    .fontSize(13)
    .height(34)
    .backgroundColor('#6C63FF')
    .onClick(() => this.copyToClipboard())
}

7.3 系统剪贴板操作

ArkTS 通过 @kit.BasicServicesKit 模块提供 pasteboard 剪贴板服务。写入流程:

import { pasteboard } from '@kit.BasicServicesKit';

private copyToClipboard(): void {
  if (!this.content.trim()) {
    promptAction.showToast({ message: '⚠️ 还没有内容可以复制', duration: 1200 });
    return;
  }
  try {
    // 1. 创建纯文本剪贴板数据
    const data: pasteboard.PasteData =
      pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, this.content);
    // 2. 获取系统剪贴板实例并写入
    pasteboard.getSystemPasteboard().setPasteData(data);
    promptAction.showToast({ message: '✅ 文案已复制到剪贴板', duration: 1500 });
  } catch (err) {
    promptAction.showToast({ message: '⚠️ 复制失败,请手动复制', duration: 1500 });
  }
}
pasteboard API 详解
API 说明
pasteboard.createData(mimeType, text) 创建一条剪贴板数据
pasteboard.getSystemPasteboard() 获取系统剪贴板单例
pasteboard.setPasteData(data) 将数据写入剪贴板
pasteboard.MIMETYPE_TEXT_PLAIN 纯文本 MIME 类型常量

7.4 快捷模板

6 种预设文案模板覆盖多种场景,点击即填充到编辑区:

private readonly templates: string[] = [
  '✨ 生活的诗意,藏在每一个认真的日子里。\n—— 极简创作 · 每日一句',
  '🎯 新品上市!限时特惠,错过等一年~\n点击链接立即抢购 👉',
  '📸 镜头定格的瞬间,是时光写给未来的情书。',
  '💪 自律不是束缚,而是通往自由的钥匙。\n今天也要加油鸭! 🦆',
  '🌅 早安!新的一天,新的可能。\n愿你心中有光,脚下有路。',
  '🎉 感恩遇见,感谢相伴。\n这份心意,只给最特别的你。',
];

模板采用胶囊式按钮展示,选中态高亮为紫色:

Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
  ForEach(this.templates, (tpl: string, idx?: number) => {
    Row() {
      Text(tpl.substring(0, 18) + (tpl.length > 18 ? '…' : ''))
        .fontSize(12)
        .fontColor('#555')
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
    }
    .padding({ left: 12, right: 12, top: 8, bottom: 8 })
    .backgroundColor(idx === this.templateIndex ? '#6C63FF' : '#F0F0FF')
    .borderRadius(20)
    .margin(4)
    .onClick(() => {
      this.templateIndex = idx ?? 0;
      this.content = tpl;
    })
  })
}

8. 模块三:修照片 Page —— Canvas 像素滤镜引擎

8.1 技术原理

修照片功能的核心是 Canvas 像素级操作。整体流程如下:

1. Canvas 绘制示例图片
2. 调用 getImageData() 获取像素数组 [R,G,B,A, R,G,B,A, ...]
3. 遍历像素数组,对每个像素的 R/G/B 应用滤镜算法
4. 调用 putImageData() 将处理后的像素写回 Canvas

8.2 状态定义

@Component
struct PhotoEditPage {
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
  private isReady: boolean = false;

  @State private hasImage: boolean = false;
  @State private currentFilter: string = '原图';
  @State private brightness: number = 100;
  @State private contrast: number = 100;
  @State private sampleImageLoaded: boolean = false;

  private originalData: ImageData | null = null;  // 原始像素数据缓存
  private canvasWidth: number = 0;
  private canvasHeight: number = 0;

  private readonly filters: FilterDef[] = [
    { name: '原图', icon: '🟦' },
    { name: '黑白', icon: '⬛' },
    { name: '怀旧', icon: '🟫' },
    { name: '反转', icon: '🔄' },
    { name: '冷色', icon: '🔵' },
    { name: '暖色', icon: '🟠' },
  ];
}

8.3 加载示例图

本示例不依赖外部图片文件,而是用 Canvas 绘制作一幅包含渐变天空、太阳、山峦、树木的彩色风景图:

private loadSampleImage(): void {
  if (!this.isReady) return;
  const ctx = this.context;
  const w = ctx.canvas.width;
  const h = ctx.canvas.height;
  this.canvasWidth = w;
  this.canvasHeight = h;

  // 1. 创建渐变背景
  const grad = ctx.createLinearGradient(0, 0, w, h);
  grad.addColorStop(0, '#FF6B6B');
  grad.addColorStop(0.3, '#FFD93D');
  grad.addColorStop(0.6, '#6BCB77');
  grad.addColorStop(1, '#4D96FF');
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, w, h);

  // 2. 绘制太阳(带阴影辉光)
  ctx.beginPath();
  ctx.arc(w * 0.3, h * 0.35, w * 0.12, 0, Math.PI * 2);
  ctx.fillStyle = '#FFD93D';
  ctx.fill();
  ctx.shadowColor = 'rgba(255,217,61,0.4)';
  ctx.shadowBlur = 30;
  ctx.fill();
  ctx.shadowBlur = 0;

  // 3. 绘制山峦
  ctx.beginPath();
  ctx.moveTo(0, h * 0.75);
  ctx.lineTo(w * 0.25, h * 0.45);
  ctx.lineTo(w * 0.5, h * 0.7);
  ctx.lineTo(w * 0.75, h * 0.4);
  ctx.lineTo(w, h * 0.65);
  ctx.lineTo(w, h);
  ctx.lineTo(0, h);
  ctx.closePath();
  ctx.fillStyle = '#2D3436';
  ctx.globalAlpha = 0.3;
  ctx.fill();
  ctx.globalAlpha = 1;

  // 4. 绘制树木
  for (let i = 0; i < 5; i++) {
    const tx = w * (0.1 + i * 0.2);
    const ty = h * (0.5 + Math.sin(i * 1.5) * 0.08);
    ctx.beginPath();
    ctx.moveTo(tx, ty);
    ctx.lineTo(tx - w * 0.04, ty + h * 0.15);
    ctx.lineTo(tx + w * 0.04, ty + h * 0.15);
    ctx.closePath();
    ctx.fillStyle = '#6BCB77';
    ctx.globalAlpha = 0.5;
    ctx.fill();
    ctx.fillStyle = '#8B4513';
    ctx.globalAlpha = 0.4;
    ctx.fillRect(tx - 3, ty + h * 0.15, 6, h * 0.05);
  }
  ctx.globalAlpha = 1;

  // 5. 保存原始像素
  this.originalData = ctx.getImageData(0, 0, w, h);
  this.hasImage = true;
  this.sampleImageLoaded = true;
}

8.4 像素滤镜算法

这是整个应用技术含量最高的部分。每个滤镜通过数学公式修改像素的 R/G/B 值:

private applyCurrentFilter(): void {
  if (!this.originalData) return;

  const srcData = new Uint8ClampedArray(this.originalData.data);
  const imgData = new ImageData(srcData, this.canvasWidth, this.canvasHeight);
  const pixels = imgData.data;

  const bFactor = this.brightness / 100;
  const cFactor = this.contrast / 100;
  const cMid = 128;  // 对比度中点

  for (let i = 0; i < pixels.length; i += 4) {
    let r = pixels[i];
    let g = pixels[i + 1];
    let b = pixels[i + 2];

    // ---- 滤镜计算 ----
    switch (this.currentFilter) {
      case '黑白': {
        // 灰度公式:0.299R + 0.587G + 0.114B
        const gray = 0.299 * r + 0.587 * g + 0.114 * b;
        r = gray; g = gray; b = gray;
        break;
      }
      case '怀旧': {
        // 棕褐色调(Sepia)矩阵
        r = 0.393 * r + 0.769 * g + 0.189 * b;
        g = 0.349 * r + 0.686 * g + 0.168 * b;
        b = 0.272 * r + 0.534 * g + 0.131 * b;
        break;
      }
      case '反转': {
        r = 255 - r;
        g = 255 - g;
        b = 255 - b;
        break;
      }
      case '冷色': {
        r = r * 0.7;           // 降低红色
        b = Math.min(255, b * 1.3);  // 增强蓝色
        g = g * 0.9;           // 略降绿色
        break;
      }
      case '暖色': {
        r = Math.min(255, r * 1.3);  // 增强红色
        b = b * 0.7;           // 降低蓝色
        g = g * 0.95;          // 略降绿色
        break;
      }
    }

    // ---- 亮度调节 ----
    r = r * bFactor;
    g = g * bFactor;
    b = b * bFactor;

    // ---- 对比度调节 ----
    r = (r - cMid) * cFactor + cMid;
    g = (g - cMid) * cFactor + cMid;
    b = (b - cMid) * cFactor + cMid;

    // ---- 钳位到 [0, 255] ----
    pixels[i]     = Math.max(0, Math.min(255, r));
    pixels[i + 1] = Math.max(0, Math.min(255, g));
    pixels[i + 2] = Math.max(0, Math.min(255, b));
    // alpha (pixels[i+3]) 保持不变
  }

  ctx.putImageData(imgData, 0, 0);
}
滤镜数学原理解析

灰度公式Gray = 0.299R + 0.587G + 0.114B

这是 ITU-R BT.601 标准的亮度公式,人眼对绿色最敏感(权重最高 0.587),对蓝色最不敏感(权重最低 0.114)。

怀旧(Sepia)

R' = 0.393R + 0.769G + 0.189B
G' = 0.349R + 0.686G + 0.168B
B' = 0.272R + 0.534G + 0.131B

这是一种模拟老照片棕褐色调的变换矩阵。R 通道获得较大增益,B 通道被衰减,整体呈现暖黄褐色。

冷色与暖色:通过调整 RGB 通道的比例实现。冷色增强 B 衰减 R,暖色增强 R 衰减 B。

8.5 避免累积失真

每次应用滤镜时,必须从原始数据this.originalData)重新计算,而不是在上一次滤镜结果上叠加:

// ✅ 正确:每次从原始数据拷贝
const srcData = new Uint8ClampedArray(this.originalData.data);
const imgData = new ImageData(srcData, w, h);

// ❌ 错误:从当前 Canvas 读取(会累积失真)
// const currentData = ctx.getImageData(0, 0, w, h);

8.6 对比度算法详解

对比度调节公式:

r = (r - cMid) * cFactor + cMid;

其中 cMid = 128 是 RGB 值域中点,cFactor = contrast / 100

  • cFactor = 1.0(对比度 100%):(r-128)*1+128 = r,无变化
  • cFactor = 1.5(对比度 150%):(r-128)*1.5+128,小于 128 的值更低、大于 128 的值更高,对比度增强
  • cFactor = 0.5(对比度 50%):(r-128)*0.5+128,所有值向 128 靠拢,对比度降低

9. 通用组件与类型定义

9.1 类型定义

/** 绘图的单个点 */
interface DrawPoint {
  x: number;
  y: number;
}

/** 一笔完整的笔画 */
type DrawStroke = DrawPoint[];

/** 滤镜定义 */
interface FilterDef {
  name: string;
  icon: string;
}

ArkTS 中 interfacetype 的行为与 TypeScript 一致。DrawStroke 作为 DrawPoint[] 的类型别名,使代码语义更清晰。

9.2 ActionButton 通用组件

@Component
struct ActionButton {
  private label: string = '';
  private bgColor: string = '#6C63FF';
  private onClickAction: () => void = () => {};

  build() {
    Button(this.label)
      .fontSize(13)
      .fontWeight(FontWeight.Medium)
      .fontColor('#FFFFFF')
      .backgroundColor(this.bgColor)
      .borderRadius(20)
      .height(36)
      .padding({ left: 18, right: 18 })
      .onClick(() => { this.onClickAction(); })
  }
}

这是一个精心设计的可复用组件:

  • 圆角胶囊风格borderRadius(20)height(36) 配合,两端呈完美半圆
  • 回调委托:通过 onClickAction 属性将点击事件交给父组件处理
  • 颜色可定制bgColor 默认紫色,可由父组件自由替换

使用示例:

ActionButton('↩ 撤销', '#FF6B6B', () => this.undo())
ActionButton('🗑 清空', '#845EC2', () => this.clearCanvas())
ActionButton('💾 保存', '#6C63FF', () => this.saveDrawing())

9.3 @Builder 与 @Component 的区别

特性 @Builder @Component
独立生命周期
独立状态管理 无(共享父组件状态) 有(@State 独立)
复用性 同一结构体内复用 全局复用
参数传递 独立参数(推荐) 对象属性语法
适用场景 纯展示性 UI 片段 有交互逻辑的独立模块

10. @State 响应式数据流深度解析

10.1 @State 的工作原理

@State 是 ArkTS 声明式 UI 的核心。当一个变量被 @State 装饰后,编译器会跟踪该变量的读取和写入:

  • :组件 build() 方法中读取 @State 变量时,会自动注册对该变量的依赖
  • @State 变量被修改时,所有依赖它的组件会自动重新执行 build()

10.2 @State 数组的特殊行为

ArkTS 对 @State 数组进行了优化。直接调用数组的变异方法(pushpopsplicesort 等)会被编译器检测到,并触发响应式更新:

@State private items: string[] = ['A', 'B', 'C'];

// 这些操作都会触发 UI 刷新:
this.items.push('D');       // ✅ 新增
const last = this.items.pop();  // ✅ 删除
this.items[0] = 'X';        // ✅ 修改(支持索引位赋值)
this.items.sort();          // ✅ 排序

这与 React 的 useState 不同。React 要求:

// React 中必须创建新数组
setItems([...items, 'D']);      // ✅
setItems(items.filter(...));    // ✅
items.push('D');                // ❌ 不会触发重渲染

10.3 本项目中的 @State 使用情况

组件 @State 变量 驱动内容
CreativeApp currentIndex 当前 Tab 索引
DrawPage currentColor, brushSize, isDrawing 颜色盘选中态、笔刷滑条、绘图状态
WritePage content, fontSize, templateIndex 编辑区文字、字号滑条、模板选中态
PhotoEditPage hasImage, currentFilter, brightness, contrast, sampleImageLoaded 滤镜列表、亮度和对比度滑条

10.4 常见误区

误区 1:在非 build() 方法中读取 @State 不会建立依赖。

@State private count: number = 0;

private logCount(): void {
  console.log(`count = ${this.count}`);  // 可以读取值,但不会建立响应式依赖
}

build() {
  Text(`${this.count}`)  // 这里读取会建立依赖
}

误区 2:修改 @State 对象但未改变引用。

@State private user = { name: 'Alice', age: 25 };

// ✅ 会触发刷新:
this.user = { name: 'Bob', age: 30 };  // 新对象

// ❌ 不会触发刷新:
// this.user.age = 30;  // 修改属性不改变引用

注意:ArkTS 编译器对 @State 对象的属性修改有特殊处理,某些场景下直接修改属性也会触发刷新。但为了代码一致性,建议尽量使用新对象赋值。


11. Hvigor 构建与调试

11.1 构建命令

HarmonyOS NEXT 使用 hvigorw 作为构建工具(类似 Gradle 之于 Android、Webpack 之于前端)。

# 完整构建
hvigorw assembleHap --no-daemon --mode module -p module=entry

# 清理构建缓存
hvigorw clean --no-daemon

# 仅编译 ArkTS
hvigorw assembleHap --no-daemon --mode module -p module=entry --tasks CompileArkTS

11.2 构建输出解读

> CompileArkTS... after 3 s 159 ms  ✓
> PackageHap... after 457 ms          ✓
> BUILD SUCCESSFUL in 8 s 157 ms      ✓
WARN: Will skip sign 'hos_hap'.
      No signingConfigs profile is configured.

签名警告是正常现象。调试运行时,DevEco Studio 会自动使用调试证书签名。发布应用时需在 build-profile.json5 中配置正式签名信息。

11.3 常见构建错误

错误信息 原因 解决
ArkTS:ERROR File not found 导入路径错误 检查 import 路径
ArkTS:ERROR Cannot find name 'XXX' 类型未定义或未导入 添加类型定义或导入
AdaptorError: Task not found 构建命令参数错误 检查 task 名称
hvigor ERROR: CompileArkTS failed ArkTS 语法错误 查看具体错误行号

12. ArkTS 最佳实践与常见陷阱

12.1 最佳实践清单

  1. @Builder 参数使用独立形式参
    @Builder Foo(name: string, age: number)
    @Builder Foo({ name, age }: { name: string, age: number })

  2. ForEach 必须提供第三个参数(keyGenerator)

    ForEach(list, (item) => { Item(item) }, (item) => item.id.toString())
    

    keyGenerator 用于优化列表 diff 性能,避免不必要的全量重渲染。

  3. Canvas 坐标转换
    触摸事件坐标是屏幕坐标,必须通过 getBoundingRect() 转换为 Canvas 内部坐标。

  4. 像素滤镜从原始数据开始
    每次应用滤镜应从 originalData 新建拷贝,避免多次叠加导致的精度损失。

  5. 非 UI 数据不放在 @State 中
    不需要驱动 UI 刷新的数据(如笔画历史、Canvas 上下文)应声明为普通私有变量。

  6. 使用 try-catch 保护系统 API
    剪贴板、文件系统等系统 API 在不同设备上可能因权限或其他原因抛出异常。

12.2 常见陷阱

陷阱 1:Tabs 受控模式缺少 onChange

// ❌ index 受控但没有 onChange,点击 Tab 无响应
Tabs({ index: this.currentIndex }) {
  TabContent() { PageA() }.tabBar('A')
  TabContent() { PageB() }.tabBar('B')
}

// ✅ 必须设置 onChange 更新状态
Tabs({ index: this.currentIndex, onChange: (idx) => { this.currentIndex = idx } })

陷阱 2:在 @Builder 中使用解构参数

// ❌ 旧版写法,新版 ArkTS 编译器报错
@Builder Card({ item, index }: { item: Item, index: number }) { ... }

// ✅ 正确写法
@Builder Card(item: Item, index: number) { ... }
// 调用时:
this.Card(item, index)  // 而不是 this.Card({ item, index })

陷阱 3:@State 数组直接修改索引

// 在大多数情况下是支持的,但复杂对象数组建议整体替换
this.items[0] = newItem;  // 可能触发刷新

// 更保险的方式:
const newItems = [...this.items];
newItems[0] = newItem;
this.items = newItems;    // 引用变化,一定触发刷新

13. 总结与扩展方向

13.1 项目总结

本文通过构建一个包含绘图、文案、修图三大功能的极简创作软件,全面展示了 HarmonyOS NEXT(API 24)下 ArkTS 的开发技术栈:

技术点 应用场景 核心代码位置
@Entry @Component 页面入口和组件化 CreativeApp.ets 第 19-21 行
Tabs 底部导航 三功能切换 CreativeApp.ets 第 44-66 行
@Builder 构建器 Tab 图标、颜色盘、工具栏 全文件多处
@State 响应式 UI 状态驱动 4 个组件共 10 个 @State
Canvas 2D 绘画绘制与像素滤镜 DrawPage + PhotoEditPage
Flex + wrap + layoutWeight 卡片流自适应 FlexCardLayout.ets 第 109-123 行
pasteboard 系统剪贴板 WritePage 第 491-503 行
image.ImagePacker Canvas 编码 PNG DrawPage 第 347-368 行

13.2 扩展方向

本应用已具备基础功能,可以沿以下方向扩展:

  1. 绘图模块

    • 笔画颜色持久化存储(每笔独立颜色)
    • 图片插入功能(在 Canvas 上叠加图片)
    • 文字工具(在画布上输入文字)
    • 导出到相册(使用 mediaLibrary API)
  2. 文案模块

    • 多文档切换(类似标签页)
    • 云端同步(接入华为云服务)
    • AI 文案生成(接入大模型 API)
    • 历史版本追踪
  3. 修图模块

    • 支持从相册选取图片
    • 自定义滤镜参数调节
    • 裁剪/旋转/缩放
    • 图层叠加
  4. 全局功能

    • 主题切换(暗色模式)
    • 多语言国际化
    • 手势返回
    • 无障碍适配

13.3 写在最后

HarmonyOS NEXT 的 ArkTS 语言在声明式 UI 领域展现了优秀的设计理念。与 SwiftUI 的 @State、Jetpack Compose 的 mutableStateOf 相比,ArkTS 的装饰器语法更加直观,对 TypeScript 开发者几乎没有学习成本。Canvas 2D API 的高度兼容性使得 Web 端的绘图知识可以直接迁移。

作为开发者,在鸿蒙原生生态中构建应用时,最重要的是深刻理解「响应式数据流」这一核心概念。一旦掌握了 @Statebuild() → UI 刷新 这一闭环,ArkTS 开发就变得简单而优雅。


本文所有代码基于 HarmonyOS NEXT 6.1.1(API 24)编写,使用 DevEco Studio 构建调试。代码文件位于 entry/src/main/ets/pages/CreativeApp.etsentry/src/main/ets/pages/FlexCardLayout.ets

Logo

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

更多推荐