胶片录的拍摄记录管理:状态流转与数据持久化
如果你是胶片摄影爱好者,推荐去鸿蒙应用市场搜一下**「胶片录」**,下载体验体验。管理胶卷生命周期、追踪拍摄帧号、记录冲洗工艺,一套走下来对胶片摄影的全流程会有更清晰的把控。体验完再回来看这篇文章,你会更清楚状态流转和数据持久化背后是怎么实现的。
写在前面
大家好,我是一名写了十多年Web前端的老兵。从jQuery时代一路走到React/Vue,CSS3动画、requestAnimationFrame、Web Animation API这些都算是看家本领。去年开始转战鸿蒙生态,用ArkTS开发App,这一路踩了不少坑,也积累了不少心得。
很多人觉得"前端转鸿蒙"应该很容易------都是写UI嘛,组件化、状态管理、生命周期,概念都差不多。但真正上手之后你会发现,相似的地方让你觉得亲切,不同的地方让你抓狂。
比如:
- 数据持久化 :Web的
localStorage在鸿蒙里变成了@ohos.data.preferences,从同步API变成了异步API,而且需要手动调用flush()才能真正写入磁盘。 - 状态管理 :React的
useState变成了@State,看起来像,但更新机制完全不同------React是函数式触发重渲染,ArkTS是装饰器驱动的精准更新。 - 列表渲染 :React的
map()在ArkTS里变成了ForEach,语法差异不大,但性能优化策略完全不同。 - 路由导航 :React Router的
useNavigate在鸿蒙里变成了router.pushUrl,参数传递方式也不一样。
但别担心,核心思想是一样的:都是组件化开发,都是数据驱动UI。你之前积累的前端经验,在鸿蒙里依然是你的核心竞争力。
接下来这篇文章,我会用"胶片录"的实际开发经历,带你看看胶卷的状态流转管理、拍摄记录的数据持久化、以及成就系统怎么实现。代码我会同时给出React版本和ArkTS版本。
这篇文章聊什么
胶片录这个App,核心要解决三个问题:
- 胶卷状态管理:从未开封到拍摄中、已拍完、已冲洗,状态要能流转
- 拍摄记录持久化:每一张照片的参数都要保存下来
- 成就系统:激励用户坚持拍摄
对应到技术实现,就是:
- 状态机模式的状态流转
@ohos.data.preferences的数据存储- 条件判断驱动的成就解锁
第一步:胶卷状态流转
胶片录里,一个胶卷有6种状态:未开封、装卷中、拍摄中、已拍完、冲洗中、已冲洗。这是一个典型的状态机。
typescript
// 胶卷状态定义
type FilmStatus = 'unopened' | 'loading' | 'shooting' | 'shot_done' | 'developing' | 'developed';
// 状态流转规则------哪些状态可以转到哪些状态
const STATUS_TRANSITIONS: Record<FilmStatus, FilmStatus[]> = {
'unopened': ['loading'],
'loading': ['shooting'],
'shooting': ['shot_done'],
'shot_done': ['developing'],
'developing': ['developed'],
'developed': [] // 终态,不能再转了
};
// 状态中文名称
const STATUS_LABELS: Record<FilmStatus, string> = {
'unopened': '未开封',
'loading': '装卷中',
'shooting': '拍摄中',
'shot_done': '已拍完',
'developing': '冲洗中',
'developed': '已冲洗'
};
React版本的状态更新:
javascript
// React版本 - 更新胶卷状态
function updateFilmStatus(filmId, newStatus) {
const films = JSON.parse(localStorage.getItem('films') || '[]');
const film = films.find(f => f.id === filmId);
if (!film) return false;
// 检查状态转换是否合法
const allowed = STATUS_TRANSITIONS[film.status];
if (!allowed.includes(newStatus)) return false;
film.status = newStatus;
film.updatedAt = Date.now();
localStorage.setItem('films', JSON.stringify(films));
return true;
}
ArkTS版本:
typescript
import { preferences } from '@kit.ArkData';
async function updateFilmStatus(
context: Context,
filmId: string,
newStatus: FilmStatus
): Promise<boolean> {
try {
const store = await preferences.getPreferences(context, 'jiaopianlu_data');
let films: FilmRoll[] = [];
const stored = await store.get('films', '[]');
films = JSON.parse(stored as string);
const film = films.find(f => f.id === filmId);
if (!film) return false;
// 检查状态转换是否合法
const allowed = STATUS_TRANSITIONS[film.status as FilmStatus];
if (!allowed.includes(newStatus)) return false;
film.status = newStatus;
film.updatedAt = Date.now();
await store.set('films', JSON.stringify(films));
await store.flush();
return true;
} catch (err) {
console.error(`更新状态失败: ${err}`);
return false;
}
}
为什么要用状态机?因为胶卷的状态流转是有严格顺序的------你不能从"未开封"直接跳到"已冲洗",中间必须经过"装卷-拍摄-拍完-冲洗"这些步骤。状态机保证了流转的合法性。
第二步:拍摄记录的完整管理
一个胶卷可以拍多张照片,每张照片都有独立的参数记录。
typescript
interface ShotRecord {
id: string;
filmId: string; // 关联的胶卷ID
frameNumber: number; // 帧号
aperture: string; // 光圈
shutterSpeed: string; // 快门
iso: number;
focalLength: number;
scene: string;
notes: string;
photoUri: string;
timestamp: number;
}
添加一条拍摄记录:
typescript
// React版本
function addShotRecord(record) {
const records = JSON.parse(localStorage.getItem('shot_records') || '[]');
records.push(record);
localStorage.setItem('shot_records', JSON.stringify(records));
// 自动递增帧号
const filmRecords = records.filter(r => r.filmId === record.filmId);
return filmRecords.length;
}
// ArkTS版本
async function addShotRecord(context: Context, record: ShotRecord): Promise<number> {
try {
const store = await preferences.getPreferences(context, 'jiaopianlu_data');
let records: ShotRecord[] = [];
const stored = await store.get('shot_records', '[]');
records = JSON.parse(stored as string);
records.push(record);
// 按时间排序
records.sort((a, b) => a.timestamp - b.timestamp);
await store.set('shot_records', JSON.stringify(records));
await store.flush();
// 返回当前胶卷的拍摄张数
return records.filter(r => r.filmId === record.filmId).length;
} catch (err) {
console.error(`保存拍摄记录失败: ${err}`);
return 0;
}
}
帧号管理是个细节问题。胶片摄影的帧号是固定的(35mm通常是36张),每次拍一张要自动递增帧号,并且检查是否超过了胶卷的总帧数。
第三步:成就系统
胶片录有8个成就,激励用户坚持拍摄。成就的逻辑是:检查某个条件是否满足,满足就解锁。
typescript
interface Achievement {
id: string;
name: string;
description: string;
icon: string;
condition: (films: FilmRoll[], records: ShotRecord[]) => boolean;
unlocked: boolean;
unlockedAt?: number;
}
// 成就定义
const ACHIEVEMENTS: Achievement[] = [
{
id: 'first_shot',
name: '第一张',
description: '记录第一张拍摄参数',
icon: '📸',
condition: (films, records) => records.length >= 1,
unlocked: false
},
{
id: 'first_roll',
name: '第一卷',
description: '完成第一卷胶卷的拍摄',
icon: '🎞️',
condition: (films, records) => films.some(f => f.status === 'shot_done' || f.status === 'developed'),
unlocked: false
},
{
id: 'ten_rolls',
name: '胶片老手',
description: '累计拍摄10卷胶卷',
icon: '🏆',
condition: (films, records) => films.filter(f =>
f.status === 'shot_done' || f.status === 'developed'
).length >= 10,
unlocked: false
},
{
id: 'hundred_shots',
name: '百帧达成',
description: '累计记录100张拍摄参数',
icon: '💯',
condition: (films, records) => records.length >= 100,
unlocked: false
},
{
id: 'all_brands',
name: '品牌收集家',
description: '使用过5个不同品牌的胶卷',
icon: '🎨',
condition: (films, records) => {
const brands = new Set(films.map(f => f.brand));
return brands.size >= 5;
},
unlocked: false
},
{
id: 'all_formats',
name: '格式大师',
description: '使用过3种不同格式的胶卷',
icon: '📐',
condition: (films, records) => {
const formats = new Set(films.map(f => f.format));
return formats.size >= 3;
},
unlocked: false
},
{
id: 'night_shots',
name: '夜行者',
description: '记录10张夜景拍摄',
icon: '🌙',
condition: (films, records) => records.filter(r => r.scene === '夜景').length >= 10,
unlocked: false
},
{
id: 'developed_all',
name: '冲洗达人',
description: '累计冲洗20卷胶卷',
icon: '🧪',
condition: (films, records) => films.filter(f => f.status === 'developed').length >= 20,
unlocked: false
}
];
检查并解锁成就:
typescript
// React版本
function checkAchievements(films, records) {
const saved = JSON.parse(localStorage.getItem('achievements') || '[]');
let updated = false;
ACHIEVEMENTS.forEach(achievement => {
const existing = saved.find(a => a.id === achievement.id);
if (existing && existing.unlocked) return; // 已解锁,跳过
if (achievement.condition(films, records)) {
if (existing) {
existing.unlocked = true;
existing.unlockedAt = Date.now();
} else {
saved.push({ ...achievement, unlocked: true, unlockedAt: Date.now() });
}
updated = true;
}
});
if (updated) {
localStorage.setItem('achievements', JSON.stringify(saved));
}
return saved;
}
// ArkTS版本
async function checkAchievements(
context: Context,
films: FilmRoll[],
records: ShotRecord[]
): Promise<Achievement[]> {
try {
const store = await preferences.getPreferences(context, 'jiaopianlu_data');
let saved: Achievement[] = [];
const stored = await store.get('achievements', '[]');
saved = JSON.parse(stored as string);
let updated = false;
ACHIEVEMENTS.forEach(achievement => {
const existing = saved.find(a => a.id === achievement.id);
if (existing && existing.unlocked) return;
if (achievement.condition(films, records)) {
if (existing) {
existing.unlocked = true;
existing.unlockedAt = Date.now();
} else {
saved.push({ ...achievement, unlocked: true, unlockedAt: Date.now() });
}
updated = true;
}
});
if (updated) {
await store.set('achievements', JSON.stringify(saved));
await store.flush();
}
return saved;
} catch (err) {
console.error(`检查成就失败: ${err}`);
return [];
}
}
成就系统的关键是:每次数据变化时都要检查 。添加拍摄记录、更新胶卷状态、这些操作完成后都要调用checkAchievements。
第四步:数据统计页面
胶片录有个数据统计页面,展示用户的拍摄数据分析。
typescript
@Entry
@Component
struct StatsPage {
@State totalFilms: number = 0
@State totalShots: number = 0
@State brandDistribution: Record<string, number> = {}
@State formatDistribution: Record<string, number> = {}
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
async aboutToAppear() {
await this.loadStats()
}
async loadStats() {
const store = await preferences.getPreferences(getContext(), 'jiaopianlu_data');
const filmsStr = await store.get('films', '[]') as string;
const films: FilmRoll[] = JSON.parse(filmsStr);
const recordsStr = await store.get('shot_records', '[]') as string;
const records: ShotRecord[] = JSON.parse(recordsStr);
this.totalFilms = films.length
this.totalShots = records.length
// 品牌分布
this.brandDistribution = {}
films.forEach(f => {
this.brandDistribution[f.brand] = (this.brandDistribution[f.brand] || 0) + 1
})
// 格式分布
this.formatDistribution = {}
films.forEach(f => {
this.formatDistribution[f.format] = (this.formatDistribution[f.format] || 0) + 1
})
}
build() {
Column() {
// 概览卡片
Row() {
Column() {
Text(`${this.totalFilms}`)
.fontSize(32)
.fontWeight(FontWeight.Bold)
.fontColor('#F59E0B')
Text('胶卷数')
.fontSize(12)
.fontColor('#9CA3AF')
}
.layoutWeight(1)
Column() {
Text(`${this.totalShots}`)
.fontSize(32)
.fontWeight(FontWeight.Bold)
.fontColor('#F59E0B')
Text('拍摄数')
.fontSize(12)
.fontColor('#9CA3AF')
}
.layoutWeight(1)
}
.width('100%')
.padding(16)
.backgroundColor('#1F2937')
.borderRadius(12)
// 品牌分布饼图
Text('品牌分布')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.margin({ top: 16, bottom: 8 })
Canvas(this.ctx)
.width('100%')
.height(200)
.onReady(() => {
this.drawBrandChart()
})
}
.width('100%')
.height('100%')
.padding(16)
.backgroundColor('#111827')
}
private drawBrandChart() {
const ctx = this.ctx
const width = 300
const height = 200
const centerX = width / 2
const centerY = height / 2
const radius = 70
const brands = Object.entries(this.brandDistribution)
const total = brands.reduce((sum, [, count]) => sum + count, 0)
if (total === 0) return
const colors = ['#F59E0B', '#10B981', '#3B82F6', '#EF4444', '#8B5CF6', '#EC4899']
let startAngle = 0
brands.forEach(([brand, count], index) => {
const sliceAngle = (count / total) * 2 * Math.PI
ctx.beginPath()
ctx.moveTo(centerX, centerY)
ctx.arc(centerX, centerY, radius, startAngle, startAngle + sliceAngle)
ctx.closePath()
ctx.fillStyle = colors[index % colors.length]
ctx.fill()
// 标签
const labelAngle = startAngle + sliceAngle / 2
const labelX = centerX + (radius + 20) * Math.cos(labelAngle)
const labelY = centerY + (radius + 20) * Math.sin(labelAngle)
ctx.fillStyle = '#D1D5DB'
ctx.font = '10px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(brand, labelX, labelY)
startAngle += sliceAngle
})
}
}
第五步:React转ArkTS的关键差异
| 方面 | React (JavaScript) | ArkTS (HarmonyOS) |
|---|---|---|
| 数据存储 | localStorage (同步) |
preferences (异步) |
| 状态管理 | useState |
@State |
| 列表渲染 | array.map() |
ForEach |
| 条件渲染 | {condition && <Comp/>} |
if (condition) { Comp() } |
| 路由导航 | useNavigate() |
router.pushUrl() |
| 生命周期 | useEffect |
aboutToAppear / aboutToDisappear |
总结
这篇文章围绕"胶片录"的数据管理,深入讲解了三个核心主题:
- 状态流转:用状态机模式管理胶卷的6种状态,确保流转合法
- 数据持久化 :用
@ohos.data.preferences存储拍摄记录,记住异步+flush() - 成就系统:条件判断驱动的成就解锁,每次数据变化时检查
胶片录的数据结构不复杂,但状态流转的逻辑需要严谨。状态机模式保证了数据的一致性,成就系统则增加了用户的参与感。
如果你也是胶片摄影爱好者,希望这篇文章能帮你理解胶片录背后的数据管理逻辑。去鸿蒙应用市场下载体验一下吧,有问题欢迎交流。