如果你想做一个航模制作App,需要用到Preferences步骤管理
欢迎大家去鸿蒙应用市场搜索下载「航模坞」,体验一下这款航模制作助手。你可以在里面记录组装步骤、调节舵面角度、写试航日志,把每次动手的过程都留档。
写在前面
玩航模的人都知道,一架模型飞机从零件到能飞,中间要经历很多步骤。机翼怎么粘、尾翼怎么装、舵机怎么调角度、重心怎么找……每一步都不能马虎。而且很多时候你不是一天就能做完的,今天装了机翼,明天才能调舵面,后天才能去试飞。如果中间断了,下次再接上的时候,你可能已经忘了上次做到哪一步了。
所以我就想,能不能做一个App,把航模组装的每一步都记录下来?每一步完成就打个勾,没完成的标个待办。而且舵面角度这种参数,用滑块调好了直接存下来,下次打开还在。试飞的结果也记一笔,方便以后对比。
这就是「航模坞」这个App的由来。这篇文章,我就带你一步步实现它。
这篇文章聊什么
这篇文章主要聊三件事:
- 组装步骤的状态流转 -- 怎么定义步骤的状态(未开始、进行中、已完成),怎么用Preferences把这些状态存下来,下次打开App的时候能恢复。
- 舵面角度的参数存储和滑块交互 -- 怎么用Slider让用户精确调节舵面角度,并把角度值存到Preferences里。
- 试航日志的记录和查看 -- 怎么让用户写试航日志,包括日期、天气、飞行时间、备注这些信息,然后存到Preferences里。
跟上一篇文章一样,我们会先看React的实现,再看ArkTS的实现。
第一步:组装步骤的状态管理
航模组装通常有固定的步骤顺序。比如一架简单的滑翔机,步骤可能是这样的:
- 组装机翼骨架
- 粘贴机翼蒙皮
- 安装尾翼
- 安装舵机
- 连接控制拉线
- 调整舵面角度
- 找重心位置
- 试飞调试
每个步骤有三种状态:未开始(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 | 按钮组模拟 |
| 多行输入 | textarea | TextArea 组件 |
| 滚动容器 | overflow-y: auto | Scroll 组件 |
| 数据存储 | localStorage | Preferences |
总结
这篇文章我们用「航模坞」这个航模制作App,学习了三个核心功能的实现:
-
步骤状态流转:用字符串常量定义状态枚举,用Preferences存储步骤列表,用ForEach渲染列表,点击切换状态并实时保存。
-
舵面角度调节:把每个舵面的配置(名称、范围、当前值)组织成结构化的对象数组,用Slider组件实现角度调节,每个舵面单独存储到Preferences。
-
试航日志:用表单收集飞行信息,用Preferences存储日志列表,新增日志时放到数组最前面实现"最新优先"的排序。
这些功能虽然看起来简单,但组合在一起就是一个实用的航模制作助手。如果你对航模感兴趣,欢迎去鸿蒙应用市场搜索「航模坞」下载体验。