HarmonyOS APP开发---航模坞航模制作App步骤管理

2 阅读10分钟

如果你想做一个航模制作App,需要用到Preferences步骤管理

欢迎大家去鸿蒙应用市场搜索下载「航模坞」,体验一下这款航模制作助手。你可以在里面记录组装步骤、调节舵面角度、写试航日志,把每次动手的过程都留档。


写在前面

玩航模的人都知道,一架模型飞机从零件到能飞,中间要经历很多步骤。机翼怎么粘、尾翼怎么装、舵机怎么调角度、重心怎么找……每一步都不能马虎。而且很多时候你不是一天就能做完的,今天装了机翼,明天才能调舵面,后天才能去试飞。如果中间断了,下次再接上的时候,你可能已经忘了上次做到哪一步了。

所以我就想,能不能做一个App,把航模组装的每一步都记录下来?每一步完成就打个勾,没完成的标个待办。而且舵面角度这种参数,用滑块调好了直接存下来,下次打开还在。试飞的结果也记一笔,方便以后对比。

这就是「航模坞」这个App的由来。这篇文章,我就带你一步步实现它。

这篇文章聊什么

这篇文章主要聊三件事:

  1. 组装步骤的状态流转 -- 怎么定义步骤的状态(未开始、进行中、已完成),怎么用Preferences把这些状态存下来,下次打开App的时候能恢复。
  2. 舵面角度的参数存储和滑块交互 -- 怎么用Slider让用户精确调节舵面角度,并把角度值存到Preferences里。
  3. 试航日志的记录和查看 -- 怎么让用户写试航日志,包括日期、天气、飞行时间、备注这些信息,然后存到Preferences里。

跟上一篇文章一样,我们会先看React的实现,再看ArkTS的实现。


第一步:组装步骤的状态管理

航模组装通常有固定的步骤顺序。比如一架简单的滑翔机,步骤可能是这样的:

  1. 组装机翼骨架
  2. 粘贴机翼蒙皮
  3. 安装尾翼
  4. 安装舵机
  5. 连接控制拉线
  6. 调整舵面角度
  7. 找重心位置
  8. 试飞调试

每个步骤有三种状态:未开始(pending)、进行中(in_progress)、已完成(completed)。用户可以点击步骤来切换状态。

React版本:步骤管理

// 步骤状态枚举
const STEP_STATUS = {
  PENDING: 'pending',
  IN_PROGRESS: 'in_progress',
  COMPLETED: 'completed',
};

// 默认组装步骤
const defaultSteps = [
  { id: '1', title: '组装机翼骨架', status: STEP_STATUS.PENDING, note: '' },
  { id: '2', title: '粘贴机翼蒙皮', status: STEP_STATUS.PENDING, note: '' },
  { id: '3', title: '安装尾翼', status: STEP_STATUS.PENDING, note: '' },
  { id: '4', title: '安装舵机', status: STEP_STATUS.PENDING, note: '' },
  { id: '5', title: '连接控制拉线', status: STEP_STATUS.PENDING, note: '' },
  { id: '6', title: '调整舵面角度', status: STEP_STATUS.PENDING, note: '' },
  { id: '7', title: '找重心位置', status: STEP_STATUS.PENDING, note: '' },
  { id: '8', title: '试飞调试', status: STEP_STATUS.PENDING, note: '' },
];

function AssemblySteps() {
  const [steps, setSteps] = useState(() => {
    const saved = localStorage.getItem('assembly_steps');
    return saved ? JSON.parse(saved) : defaultSteps;
  });

  // 切换步骤状态
  const toggleStepStatus = (stepId) => {
    setSteps(prev => {
      const updated = prev.map(step => {
        if (step.id !== stepId) return step;
        const nextStatus =
          step.status === STEP_STATUS.PENDING ? STEP_STATUS.IN_PROGRESS :
          step.status === STEP_STATUS.IN_PROGRESS ? STEP_STATUS.COMPLETED :
          STEP_STATUS.PENDING;
        return { ...step, status: nextStatus };
      });
      localStorage.setItem('assembly_steps', JSON.stringify(updated));
      return updated;
    });
  };

  // 获取状态对应的样式
  const getStatusStyle = (status) => {
    switch (status) {
      case STEP_STATUS.COMPLETED: return { bg: '#E8F5E9', text: '#2E7D32', icon: '[x]' };
      case STEP_STATUS.IN_PROGRESS: return { bg: '#FFF3E0', text: '#E65100', icon: '[~]' };
      default: return { bg: '#F5F5F5', text: '#757575', icon: '[ ]' };
    }
  };

  // 计算完成进度
  const progress = steps.filter(s => s.status === STEP_STATUS.COMPLETED).length / steps.length;

  return (
    <div className="assembly-steps">
      <h3>组装进度:{Math.round(progress * 100)}%</h3>
      <div className="progress-bar">
        <div className="progress-fill" style={{ width: `${progress * 100}%` }} />
      </div>
      {steps.map(step => {
        const style = getStatusStyle(step.status);
        return (
          <div
            key={step.id}
            className="step-item"
            style={{ backgroundColor: style.bg }}
            onClick={() => toggleStepStatus(step.id)}
          >
            <span>{style.icon} {step.title}</span>
          </div>
        );
      })}
    </div>
  );
}

这段代码的核心逻辑是toggleStepStatus函数。点击一个步骤的时候,它的状态会按照"未开始 -> 进行中 -> 已完成 -> 未开始"的顺序循环切换。为什么要循环切换而不是只切换一次呢?因为有时候你可能发现某一步做错了,需要回退到"进行中"重新做。循环切换给了用户更多的灵活性。

进度条的计算也很简单:用已完成步骤的数量除以总步骤数量,得到一个0到1之间的百分比。

ArkTS版本:步骤管理

在鸿蒙端,我们需要用Preferences来存储步骤数据。先定义数据结构和初始化逻辑:

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

// 步骤状态枚举,用字符串常量来定义
const STEP_STATUS_PENDING = 'pending';
const STEP_STATUS_IN_PROGRESS = 'in_progress';
const STEP_STATUS_COMPLETED = 'completed';

// 单个步骤的接口定义
interface AssemblyStep {
  id: string;
  title: string;
  status: string;
  note: string;
}

// 默认步骤数据
const DEFAULT_STEPS: AssemblyStep[] = [
  { id: '1', title: '组装机翼骨架', status: STEP_STATUS_PENDING, note: '' },
  { id: '2', title: '粘贴机翼蒙皮', status: STEP_STATUS_PENDING, note: '' },
  { id: '3', title: '安装尾翼', status: STEP_STATUS_PENDING, note: '' },
  { id: '4', title: '安装舵机', status: STEP_STATUS_PENDING, note: '' },
  { id: '5', title: '连接控制拉线', status: STEP_STATUS_PENDING, note: '' },
  { id: '6', title: '调整舵面角度', status: STEP_STATUS_PENDING, note: '' },
  { id: '7', title: '找重心位置', status: STEP_STATUS_PENDING, note: '' },
  { id: '8', title: '试飞调试', status: STEP_STATUS_PENDING, note: '' },
];

let dataStore: preferences.Preferences | null = null;
const STORE_NAME = 'hangmouwu_store';
const KEY_STEPS = 'assembly_steps';

为什么要用字符串常量来定义枚举值,而不是用TypeScript的enum?因为在ArkTS里,enum的使用有一些限制,而且字符串常量在存储和读取的时候更方便 -- 直接存字符串,读出来也是字符串,不需要额外的转换。

接下来是Preferences的初始化和步骤的读写:

// 初始化Preferences
async function initStore(context: Context) {
  try {
    dataStore = await preferences.getPreferences(context, STORE_NAME);
    console.info('航模坞:Preferences初始化成功');
  } catch (err) {
    console.error('航模坞:Preferences初始化失败', JSON.stringify(err));
  }
}

// 保存步骤数据
async function saveSteps(steps: AssemblyStep[]) {
  if (!dataStore) return;
  try {
    await dataStore.put(KEY_STEPS, JSON.stringify(steps));
    await dataStore.flush();
  } catch (err) {
    console.error('保存步骤失败', JSON.stringify(err));
  }
}

// 读取步骤数据
async function loadSteps(): Promise<AssemblyStep[]> {
  if (!dataStore) return DEFAULT_STEPS;
  try {
    const json = await dataStore.get(KEY_STEPS, '') as string;
    if (json === '') return DEFAULT_STEPS;
    return JSON.parse(json) as AssemblyStep[];
  } catch (err) {
    console.error('读取步骤失败', JSON.stringify(err));
    return DEFAULT_STEPS;
  }
}

注意loadSteps里,如果读取到的JSON是空字符串(说明从未保存过),就返回默认步骤。这里用空字符串而不是'[]'作为默认值,是因为我们要区分"从未保存过"和"保存了空列表"这两种情况。当然,在这个场景下,步骤列表不可能是空的,所以用空字符串作为默认值更合理。

然后是UI组件:

@Entry
@Component
struct AssemblyPage {
  @State steps: AssemblyStep[] = DEFAULT_STEPS;
  @State progress: number = 0;

  async aboutToAppear() {
    this.steps = await loadSteps();
    this.updateProgress();
  }

  // 更新进度值
  updateProgress() {
    const completed = this.steps.filter(
      (s: AssemblyStep) => s.status === STEP_STATUS_COMPLETED
    ).length;
    this.progress = completed / this.steps.length;
  }

  // 切换步骤状态
  toggleStepStatus(stepId: string) {
    this.steps = this.steps.map((step: AssemblyStep) => {
      if (step.id !== stepId) return step;
      let nextStatus: string = STEP_STATUS_PENDING;
      if (step.status === STEP_STATUS_PENDING) {
        nextStatus = STEP_STATUS_IN_PROGRESS;
      } else if (step.status === STEP_STATUS_IN_PROGRESS) {
        nextStatus = STEP_STATUS_COMPLETED;
      }
      return { ...step, status: nextStatus };
    });
    this.updateProgress();
    saveSteps(this.steps);
  }

  // 获取步骤状态对应的颜色
  getStatusColor(status: string): string {
    if (status === STEP_STATUS_COMPLETED) return '#E8F5E9';
    if (status === STEP_STATUS_IN_PROGRESS) return '#FFF3E0';
    return '#F5F5F5';
  }

  // 获取步骤状态对应的图标文字
  getStatusIcon(status: string): string {
    if (status === STEP_STATUS_COMPLETED) return '[x]';
    if (status === STEP_STATUS_IN_PROGRESS) return '[~]';
    return '[ ]';
  }

  build() {
    Column() {
      // 标题
      Text('组装步骤')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .margin({ bottom: 16 })

      // 进度条
      Row() {
        Text(`组装进度:${Math.round(this.progress * 100)}%`)
          .fontSize(16)
          .margin({ bottom: 8 })
      }
      .width('90%')

      Progress({ value: this.progress * 100, total: 100 })
        .width('90%')
        .color('#4CAF50')
        .margin({ bottom: 20 })

      // 步骤列表
      List({ space: 8 }) {
        ForEach(this.steps, (step: AssemblyStep) => {
          ListItem() {
            Row() {
              Text(this.getStatusIcon(step.status))
                .fontSize(14)
                .fontColor('#666666')
                .margin({ right: 12 })

              Text(step.title)
                .fontSize(16)
                .layoutWeight(1)

              Text(step.status === STEP_STATUS_COMPLETED ? '已完成' :
                   step.status === STEP_STATUS_IN_PROGRESS ? '进行中' : '未开始')
                .fontSize(12)
                .fontColor('#999999')
            }
            .width('100%')
            .padding(14)
            .backgroundColor(this.getStatusColor(step.status))
            .borderRadius(8)
            .onClick(() => {
              this.toggleStepStatus(step.id);
            })
          }
        }, (step: AssemblyStep) => step.id)
      }
      .width('90%')
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .padding(20)
    .alignItems(HorizontalAlign.Center)
  }
}

这里有几个关键点要讲。

第一,ForEach的使用。在ArkTS里,渲染列表数据要用ForEach,它接收三个参数:数据源、子项生成函数、键值生成函数。键值生成函数(step: AssemblyStep) => step.id很重要,它告诉框架每个列表项的唯一标识是什么。没有这个,列表更新的时候可能会出现渲染异常。

第二,Progress组件。这是鸿蒙内置的进度条组件,value是当前值,total是最大值。我们把进度百分比乘以100作为value,total设为100,这样进度条就能正确显示了。

第三,toggleStepStatus里调用了saveSteps。这里没有用await,是因为保存操作是"fire and forget"的 -- 我们不需要等保存完成才继续下一步操作。如果保存失败了,控制台会打印错误日志,但不会影响用户的操作体验。当然,如果你对数据安全性要求很高,可以用await等待保存完成。


第二步:舵面角度调节

航模的舵面角度是影响飞行姿态的关键参数。升降舵控制俯仰,方向舵控制偏航,副翼控制滚转。每个舵面都有一个中立位置(通常是0度),然后可以向左右各偏转一定的角度。

React版本:舵面角度调节

const defaultAngles = {
  elevator: 0,    // 升降舵角度,-30到30度
  rudder: 0,      // 方向舵角度,-30到30度
  aileronLeft: 0,  // 左副翼角度,-20到20度
  aileronRight: 0, // 右副翼角度,-20到20度
};

function ServoAdjuster() {
  const [angles, setAngles] = useState(() => {
    const saved = localStorage.getItem('servo_angles');
    return saved ? JSON.parse(saved) : defaultAngles;
  });

  const updateAngle = (name, value) => {
    setAngles(prev => {
      const updated = { ...prev, [name]: value };
      localStorage.setItem('servo_angles', JSON.stringify(updated));
      return updated;
    });
  };

  const resetAll = () => {
    setAngles(defaultAngles);
    localStorage.setItem('servo_angles', JSON.stringify(defaultAngles));
  };

  return (
    <div className="servo-adjuster">
      <h3>舵面角度调节</h3>
      <button onClick={resetAll}>全部归零</button>

      {Object.entries(angles).map(([name, value]) => (
        <div key={name} className="angle-group">
          <label>{name}:{value}度</label>
          <input
            type="range"
            min={name.includes('aileron') ? -20 : -30}
            max={name.includes('aileron') ? 20 : 30}
            step="1"
            value={value}
            onChange={(e) => updateAngle(name, parseInt(e.target.value))}
          />
        </div>
      ))}
    </div>
  );
}

这里用了一个对象来存储四个舵面的角度。Object.entries把对象转成数组,方便遍历渲染。每个舵面的滑块范围不一样 -- 副翼的范围小一些(-20到20度),升降舵和方向舵的范围大一些(-30到30度)。这个范围是根据实际航模的舵机行程来设定的。

"全部归零"按钮的作用是把所有舵面角度重置为0度。这在调试过程中很常用 -- 先归零,再一个一个微调。

ArkTS版本:舵面角度调节

// 舵面角度配置
interface ServoConfig {
  name: string;       // 舵面名称
  key: string;        // 存储用的key
  minAngle: number;   // 最小角度
  maxAngle: number;   // 最大角度
  angle: number;      // 当前角度
}

@Entry
@Component
struct ServoAdjusterPage {
  @State servoConfigs: ServoConfig[] = [
    { name: '升降舵', key: 'elevator', minAngle: -30, maxAngle: 30, angle: 0 },
    { name: '方向舵', key: 'rudder', minAngle: -30, maxAngle: 30, angle: 0 },
    { name: '左副翼', key: 'aileronLeft', minAngle: -20, maxAngle: 20, angle: 0 },
    { name: '右副翼', key: 'aileronRight', minAngle: -20, maxAngle: 20, angle: 0 },
  ];

  async aboutToAppear() {
    if (!dataStore) return;
    try {
      // 从Preferences读取每个舵面的角度
      for (let i = 0; i < this.servoConfigs.length; i++) {
        const config = this.servoConfigs[i];
        const saved = await dataStore.get(`servo_${config.key}`, config.angle) as number;
        this.servoConfigs[i].angle = saved;
      }
    } catch (err) {
      console.error('读取舵面角度失败', JSON.stringify(err));
    }
  }

  // 更新单个舵面角度
  async updateAngle(index: number, value: number) {
    this.servoConfigs[index].angle = value;
    if (!dataStore) return;
    try {
      await dataStore.put(`servo_${this.servoConfigs[index].key}`, value);
      await dataStore.flush();
    } catch (err) {
      console.error('保存舵面角度失败', JSON.stringify(err));
    }
  }

  // 全部归零
  async resetAllAngles() {
    for (let i = 0; i < this.servoConfigs.length; i++) {
      this.servoConfigs[i].angle = 0;
      if (dataStore) {
        await dataStore.put(`servo_${this.servoConfigs[i].key}`, 0);
      }
    }
    if (dataStore) {
      await dataStore.flush();
    }
  }

  build() {
    Scroll() {
      Column() {
        // 标题
        Text('舵面角度调节')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .margin({ bottom: 16 })

        // 归零按钮
        Button('全部归零')
          .width('90%')
          .margin({ bottom: 20 })
          .backgroundColor('#FF5722')
          .fontColor('#FFFFFF')
          .onClick(() => {
            this.resetAllAngles();
          })

        // 每个舵面的调节区域
        ForEach(this.servoConfigs, (config: ServoConfig, index: number) => {
          Column() {
            // 舵面名称和当前角度
            Row() {
              Text(config.name)
                .fontSize(16)
                .layoutWeight(1)
              Text(`${config.angle}度`)
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor(
                  config.angle > 0 ? '#4CAF50' :
                  config.angle < 0 ? '#F44336' : '#333333'
                )
            }
            .width('100%')
            .margin({ bottom: 8 })

            // 角度滑块
            Slider({
              min: config.minAngle,
              max: config.maxAngle,
              step: 1,
              value: config.angle,
              style: SliderStyle.OutSet
            })
              .width('100%')
              .blockColor('#2196F3')
              .onChange((value: number) => {
                this.updateAngle(index, value);
              })

            // 角度范围提示
            Text(`范围:${config.minAngle}度 ~ ${config.maxAngle}度`)
              .fontSize(12)
              .fontColor('#999999')
              .margin({ top: 4 })
          }
          .width('90%')
          .padding(16)
          .backgroundColor('#F5F5F5')
          .borderRadius(12)
          .margin({ bottom: 12 })
        }, (config: ServoConfig) => config.key)
      }
      .width('100%')
      .padding(20)
      .alignItems(HorizontalAlign.Center)
    }
    .width('100%')
    .height('100%')
  }
}

这里有一个跟React版本不同的设计决策。在React版本里,我把四个舵面的角度存在一个对象里,用Object.entries来遍历。但在ArkTS版本里,我把每个舵面的配置(包括名称、key、范围、当前角度)组织成一个ServoConfig对象,放在数组里。这样做的好处是,每个舵面的配置信息都自包含,不需要从外部推导(比如副翼的范围不需要通过名称来判断)。

角度值的颜色也做了区分:正值显示绿色,负值显示红色,零值显示黑色。这样用户一眼就能看出哪个舵面偏了、偏了多少。


第三步:试航日志

每次试飞之后,记录一下飞行情况是非常有价值的。天气怎么样、飞了多长时间、感觉怎么样、下次要改什么 -- 这些信息积累下来,就是你航模技术的成长记录。

React版本:试航日志

function FlightLog() {
  const [logs, setLogs] = useState(() => {
    const saved = localStorage.getItem('flight_logs');
    return saved ? JSON.parse(saved) : [];
  });
  const [newLog, setNewLog] = useState({
    date: new Date().toISOString().split('T')[0],
    weather: '晴天',
    duration: 5,
    notes: '',
  });

  const addLog = () => {
    const log = {
      ...newLog,
      id: Date.now().toString(),
      createdAt: new Date().toISOString(),
    };
    const updated = [log, ...logs];
    setLogs(updated);
    localStorage.setItem('flight_logs', JSON.stringify(updated));
    setNewLog({
      date: new Date().toISOString().split('T')[0],
      weather: '晴天',
      duration: 5,
      notes: '',
    });
  };

  return (
    <div className="flight-log">
      <h3>试航日志</h3>
      <div className="log-form">
        <input type="date" value={newLog.date}
          onChange={(e) => setNewLog({ ...newLog, date: e.target.value })} />
        <select value={newLog.weather}
          onChange={(e) => setNewLog({ ...newLog, weather: e.target.value })}>
          <option>晴天</option>
          <option>多云</option>
          <option>阴天</option>
          <option>微风</option>
          <option>有风</option>
        </select>
        <label>飞行时间(分钟):
          <input type="number" min="1" max="60" value={newLog.duration}
            onChange={(e) => setNewLog({ ...newLog, duration: parseInt(e.target.value) || 5 })} />
        </label>
        <textarea value={newLog.notes} placeholder="飞行感受和备注..."
          onChange={(e) => setNewLog({ ...newLog, notes: e.target.value })} />
        <button onClick={addLog}>记录本次试航</button>
      </div>
      <div className="log-list">
        {logs.map(log => (
          <div key={log.id} className="log-item">
            <p>{log.date} | {log.weather} | {log.duration}分钟</p>
            <p>{log.notes}</p>
          </div>
        ))}
      </div>
    </div>
  );
}

试航日志的数据结构很简单:日期、天气、飞行时间、备注。新增日志的时候,用[log, ...logs]把新日志放到数组最前面,这样最新的日志显示在最上面。

ArkTS版本:试航日志

// 试航日志的数据结构
interface FlightLog {
  id: string;
  date: string;
  weather: string;
  duration: number;
  notes: string;
  createdAt: string;
}

@Entry
@Component
struct FlightLogPage {
  @State logs: FlightLog[] = [];
  @State logDate: string = '';
  @State logWeather: number = 0;
  @State logDuration: number = 5;
  @State logNotes: string = '';

  private weatherOptions: string[] = ['晴天', '多云', '阴天', '微风', '有风'];

  async aboutToAppear() {
    // 获取当前日期
    const now = new Date();
    this.logDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;

    // 加载已有日志
    if (!dataStore) return;
    try {
      const json = await dataStore.get('flight_logs', '[]') as string;
      this.logs = JSON.parse(json) as FlightLog[];
    } catch (err) {
      console.error('读取试航日志失败', JSON.stringify(err));
    }
  }

  // 添加日志
  async addLog() {
    const newLog: FlightLog = {
      id: Date.now().toString(),
      date: this.logDate,
      weather: this.weatherOptions[this.logWeather],
      duration: this.logDuration,
      notes: this.logNotes,
      createdAt: new Date().toISOString(),
    };

    this.logs = [newLog, ...this.logs];

    if (!dataStore) return;
    try {
      await dataStore.put('flight_logs', JSON.stringify(this.logs));
      await dataStore.flush();
    } catch (err) {
      console.error('保存试航日志失败', JSON.stringify(err));
    }

    // 重置表单
    this.logNotes = '';
    this.logDuration = 5;
    this.logWeather = 0;
  }

  build() {
    Scroll() {
      Column() {
        Text('试航日志')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .margin({ bottom: 20 })

        // 日期选择
        Row() {
          Text('日期')
            .fontSize(16)
            .width(60)
          TextInput({ placeholder: 'YYYY-MM-DD', text: this.logDate })
            .layoutWeight(1)
            .onChange((value: string) => { this.logDate = value; })
        }
        .width('90%')
        .margin({ bottom: 12 })

        // 天气选择(用TextButton模拟下拉选择)
        Row() {
          Text('天气')
            .fontSize(16)
            .width(60)
          Row({ space: 8 }) {
            ForEach(this.weatherOptions, (option: string, index: number) => {
              Text(option)
                .fontSize(14)
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .backgroundColor(this.logWeather === index ? '#2196F3' : '#E0E0E0')
                .fontColor(this.logWeather === index ? '#FFFFFF' : '#333333')
                .borderRadius(16)
                .onClick(() => { this.logWeather = index; })
            }, (option: string, index: number) => `${index}`)
          }
        }
        .width('90%')
        .margin({ bottom: 12 })

        // 飞行时间
        Row() {
          Text('飞行时间')
            .fontSize(16)
            .width(80)
          Text(`${this.logDuration} 分钟`)
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .margin({ left: 8 })
        }
        .width('90%')
        .margin({ bottom: 4 })

        Slider({ min: 1, max: 60, step: 1, value: this.logDuration, style: SliderStyle.OutSet })
          .width('90%')
          .onChange((value: number) => { this.logDuration = value; })
          .margin({ bottom: 12 })

        // 备注
        TextArea({ placeholder: '飞行感受和备注...', text: this.logNotes })
          .width('90%')
          .height(80)
          .onChange((value: string) => { this.logNotes = value; })
          .margin({ bottom: 16 })

        // 提交按钮
        Button('记录本次试航')
          .width('90%')
          .backgroundColor('#4CAF50')
          .fontColor('#FFFFFF')
          .onClick(() => { this.addLog(); })

        // 日志列表
        if (this.logs.length > 0) {
          Text('历史记录')
            .fontSize(18)
            .fontWeight(FontWeight.Medium)
            .margin({ top: 24, bottom: 12 })

          ForEach(this.logs, (log: FlightLog) => {
            Column() {
              Row() {
                Text(log.date)
                  .fontSize(14)
                  .fontColor('#666666')
                Text(' | ')
                  .fontSize(14)
                  .fontColor('#CCCCCC')
                Text(log.weather)
                  .fontSize(14)
                  .fontColor('#666666')
                Text(' | ')
                  .fontSize(14)
                  .fontColor('#CCCCCC')
                Text(`${log.duration}分钟`)
                  .fontSize(14)
                  .fontColor('#666666')
              }
              if (log.notes) {
                Text(log.notes)
                  .fontSize(14)
                  .fontColor('#333333')
                  .margin({ top: 4 })
              }
            }
            .width('90%')
            .padding(12)
            .backgroundColor('#FAFAFA')
            .borderRadius(8)
            .margin({ bottom: 8 })
            .alignItems(HorizontalAlign.Start)
          }, (log: FlightLog) => log.id)
        }
      }
      .width('100%')
      .padding(20)
      .alignItems(HorizontalAlign.Center)
    }
    .width('100%')
    .height('100%')
  }
}

天气选择这里,我没有用下拉框(因为鸿蒙的下拉选择器API比较复杂),而是用了一排按钮来模拟。用户点击哪个天气选项,那个选项就高亮显示。这种做法在移动端其实比下拉框更好用,因为选项少(只有5个),一排按钮一目了然。

整个页面用Scroll包裹,因为内容可能超出屏幕高度。Scroll是鸿蒙的滚动容器组件,类似于Web的overflow-y: auto


流程图

flowchart TD
    A[用户打开App] --> B[初始化Preferences]
    B --> C[加载已保存的步骤状态]
    C --> D[显示组装步骤列表]
    D --> E{用户点击某个步骤}
    E --> F[切换步骤状态: pending -> in_progress -> completed]
    F --> G[保存状态到Preferences]
    G --> D

    D --> H[用户进入舵面调节]
    H --> I[加载已保存的舵面角度]
    I --> J[显示滑块调节界面]
    J --> K[用户拖动滑块]
    K --> L[实时更新角度值]
    L --> M[保存角度到Preferences]
    M --> J

    D --> N[用户进入试航日志]
    N --> O[填写日期、天气、时间、备注]
    O --> P[点击记录按钮]
    P --> Q[保存日志到Preferences]
    Q --> R[显示在历史列表中]

React vs ArkTS 对比表

功能点React (Web)ArkTS (鸿蒙)
步骤状态枚举对象常量字符串常量
列表渲染map()ForEach()
进度条div + CSS宽度Progress 组件
滑块input type="range"Slider 组件
下拉选择select + option按钮组模拟
多行输入textareaTextArea 组件
滚动容器overflow-y: autoScroll 组件
数据存储localStoragePreferences

总结

这篇文章我们用「航模坞」这个航模制作App,学习了三个核心功能的实现:

  1. 步骤状态流转:用字符串常量定义状态枚举,用Preferences存储步骤列表,用ForEach渲染列表,点击切换状态并实时保存。

  2. 舵面角度调节:把每个舵面的配置(名称、范围、当前值)组织成结构化的对象数组,用Slider组件实现角度调节,每个舵面单独存储到Preferences。

  3. 试航日志:用表单收集飞行信息,用Preferences存储日志列表,新增日志时放到数组最前面实现"最新优先"的排序。

这些功能虽然看起来简单,但组合在一起就是一个实用的航模制作助手。如果你对航模感兴趣,欢迎去鸿蒙应用市场搜索「航模坞」下载体验。