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.etsFlex 响应式卡片流布局演示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 生命周期方法

方法触发时机用途
onCreateAbility 首次创建初始化数据、配置
onDestroyAbility 销毁释放资源
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 组件注意事项

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

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(手机竖屏)100dp3 列三列紧凑排列
600dp(大屏手机)100dp4~5 列空间充裕
800dp(折叠屏展开)100dp5~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 变量驱动内容
CreativeAppcurrentIndex当前 Tab 索引
DrawPagecurrentColor, brushSize, isDrawing颜色盘选中态、笔刷滑条、绘图状态
WritePagecontent, fontSize, templateIndex编辑区文字、字号滑条、模板选中态
PhotoEditPagehasImage, 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 failedArkTS 语法错误查看具体错误行号

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.ImagePackerCanvas 编码 PNGDrawPage 第 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

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

更多推荐