鸿蒙APP开发-带你走进胶片录的拍摄记录管理

胶片录的拍摄记录管理:状态流转与数据持久化

如果你是胶片摄影爱好者,推荐去鸿蒙应用市场搜一下**「胶片录」**,下载体验体验。管理胶卷生命周期、追踪拍摄帧号、记录冲洗工艺,一套走下来对胶片摄影的全流程会有更清晰的把控。体验完再回来看这篇文章,你会更清楚状态流转和数据持久化背后是怎么实现的。


写在前面

大家好,我是一名写了十多年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,核心要解决三个问题:

  1. 胶卷状态管理:从未开封到拍摄中、已拍完、已冲洗,状态要能流转
  2. 拍摄记录持久化:每一张照片的参数都要保存下来
  3. 成就系统:激励用户坚持拍摄

对应到技术实现,就是:

  • 状态机模式的状态流转
  • @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

总结

这篇文章围绕"胶片录"的数据管理,深入讲解了三个核心主题:

  1. 状态流转:用状态机模式管理胶卷的6种状态,确保流转合法
  2. 数据持久化 :用@ohos.data.preferences存储拍摄记录,记住异步+flush()
  3. 成就系统:条件判断驱动的成就解锁,每次数据变化时检查

胶片录的数据结构不复杂,但状态流转的逻辑需要严谨。状态机模式保证了数据的一致性,成就系统则增加了用户的参与感。


如果你也是胶片摄影爱好者,希望这篇文章能帮你理解胶片录背后的数据管理逻辑。去鸿蒙应用市场下载体验一下吧,有问题欢迎交流。

相关推荐
陈_杨1 小时前
鸿蒙APP开发-带你走进胶片录的相机控制
前端·javascript
陈_杨1 小时前
鸿蒙APP开发-带你走进节流战的Canvas图表
前端·javascript
陈_杨1 小时前
鸿蒙APP开发-带你走进光绘记的拍摄规划
前端·javascript
陈_杨1 小时前
鸿蒙APP开发-带你走进光绘记的长曝光模拟
前端·javascript
陈_杨1 小时前
鸿蒙APP开发-带你走进节拍器的声音怎么这么准
前端·javascript
搬砖的阿wei1 小时前
Pinia 与 Vuex 区别
前端·vue.js
KaMeidebaby1 小时前
卡梅德生物技术快报|原核表达系统工艺优化:包涵体重折叠 + 分子筛纯化实现功能 RBD 高效制备,附全参数配置
前端·人工智能·算法·数据挖掘·数据分析
最爱睡觉睡觉睡觉1 小时前
代碼案例:CSS 屬性對照
前端·app
VitoChang2 小时前
开发体验超赞的SolidJS2.0来了
前端