Mobile Development 21 min read

HarmonyOS Skateboard App Tutorial: Trick Data Management with ArkTS Preferences

This tutorial demonstrates building a skateboard trick tracking app on HarmonyOS, covering trick library design, practice session storage using the Preferences API, combo management, statistical analytics, and key implementation pitfalls like data migration and async handling.

51CTO HarmonyOS Developer Community
51CTO HarmonyOS Developer Community
51CTO HarmonyOS Developer Community
HarmonyOS Skateboard App Tutorial: Trick Data Management with ArkTS Preferences

This article walks through the complete data management flow for a HarmonyOS skateboard trick tracking app called "Bandonglu" (板动录), written by a veteran web developer transitioning to ArkTS. The tutorial mirrors common web patterns — using localStorage or IndexedDB — but maps them to HarmonyOS's @ohos.data.preferences API.

Step 1: Design the Trick Library

The trick library covers six categories: basic, flip, grab, grind, slide, and balance. Each trick has an ID, name, category, difficulty (beginner/intermediate/advanced), description, and an array of tips. The ArkTS implementation uses a Trick interface and a constant TRICK_CATEGORIES array with color coding for UI. A sample ALL_TRICKS array includes entries like Ollie (basic, beginner) and Kickflip (flip, intermediate). A React equivalent is provided for comparison, showing identical data structures without TypeScript interfaces.

// ArkTS - Trick data structure
interface Trick {
  id: string;
  name: string;
  category: 'basic' | 'flip' | 'grab' | 'grind' | 'slide' | 'balance';
  categoryName: string;
  difficulty: 'beginner' | 'intermediate' | 'advanced';
  description: string;
  tips: string[];
}

const TRICK_CATEGORIES = [
  { id: 'basic', name: '基础', color: '#3b82f6' },
  { id: 'flip', name: '翻板', color: '#22c55e' },
  { id: 'grab', name: '抓板', color: '#f59e0b' },
  { id: 'grind', name: '磨板', color: '#ef4444' },
  { id: 'slide', name: '滑行', color: '#8b5cf6' },
  { id: 'balance', name: '平衡', color: '#ec4899' }
];

const ALL_TRICKS: Trick[] = [
  {
    id: 'ollie',
    name: 'Ollie',
    category: 'basic',
    categoryName: '基础',
    difficulty: 'beginner',
    description: '滑板最基础的跳跃动作',
    tips: ['后脚点板', '前脚刷板', '同时起跳']
  },
  {
    id: 'kickflip',
    name: 'Kickflip',
    category: 'flip',
    categoryName: '翻板',
    difficulty: 'intermediate',
    description: '让滑板沿纵轴旋转一周',
    tips: ['后脚点板', '前脚刷板角', '接板落地']
  }
  // ... more tricks
];

Step 2: Design Practice Session Records

Each practice session captures date, spot (location), duration in minutes, weather, training type, free-form notes, and an array of SessionTrick entries. Each SessionTrick records the trick ID, name, category, difficulty, whether it was landed (boolean), and attempt count. The ArkTS version defines SessionRecord and SessionTrick interfaces. The React version uses a factory function createSessionRecord that generates an ID via Date.now() and defaults the date to today.

// ArkTS - Session record structure
interface SessionRecord {
  id: number;
  date: string;
  spot: string;        // practice location
  duration: number;    // minutes
  weather: string;     // weather
  trainingType: string;
  notes: string;
  tricks: SessionTrick[];
}

interface SessionTrick {
  id: string;
  name: string;
  category: string;
  difficulty: string;
  landed: boolean;     // success flag
  attempts: number;    // attempt count
}

Step 3: Encapsulate Storage Service

A singleton StorageService wraps @ohos.data.preferences to provide asynchronous getItem<T>(key, defaultValue) and setItem<T>(key, value) methods. The service initializes a preferences instance named "bandonglu" using the UIAbilityContext. All values are JSON-stringified on write and parsed on read, with try/catch fallback to default values. The React counterpart uses localStorage with a prefixed key namespace ( app_bandonglu_${key}) and synchronous access.

// StorageService.ets
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';

export class StorageService {
  private static instance: StorageService;
  private prefInstance: preferences.Preferences | null = null;
  private context: common.UIAbilityContext;

  private constructor(context: common.UIAbilityContext) {
    this.context = context;
  }

  static getInstance(context: common.UIAbilityContext): StorageService {
    if (!StorageService.instance) {
      StorageService.instance = new StorageService(context);
    }
    return StorageService.instance;
  }

  async getPreferences(): Promise<preferences.Preferences> {
    if (!this.prefInstance) {
      this.prefInstance = await preferences.getPreferences(this.context, 'bandonglu');
    }
    return this.prefInstance;
  }

  async getItem<T>(key: string, defaultValue: T): Promise<T> {
    try {
      const pref = await this.getPreferences();
      const value = await pref.get(key, JSON.stringify(defaultValue));
      return JSON.parse(value as string);
    } catch (err) {
      return defaultValue;
    }
  }

  async setItem<T>(key: string, value: T): Promise<boolean> {
    try {
      const pref = await this.getPreferences();
      await pref.put(key, JSON.stringify(value));
      await pref.flush();
      return true;
    } catch (err) {
      return false;
    }
  }
}

Step 4: Implement Combo Management

Combos (连招) are ordered sequences of tricks. The Combo interface includes an ID, name, array of ComboTrick (each with trickId, trickName, order), and timestamps. ComboService depends on StorageService and provides getAll, add, update, delete, and reorderTricks methods. reorderTricks rebuilds the combo's trick array from an ordered list of trick IDs, looking up names from ALL_TRICKS. The React version mirrors the same logic using plain objects and array methods.

// ComboService.ets
interface Combo {
  id: number;
  name: string;
  tricks: ComboTrick[];
  createdAt: number;
  updatedAt: number;
}

interface ComboTrick {
  trickId: string;
  trickName: string;
  order: number;
}

export class ComboService {
  private storage: StorageService;

  constructor(context: common.UIAbilityContext) {
    this.storage = StorageService.getInstance(context);
  }

  async getAll(): Promise<Combo[]> {
    return await this.storage.getItem<Combo[]>('combos', []);
  }

  async add(combo: Combo): Promise<boolean> {
    const combos = await this.getAll();
    combos.push({ ...combo, createdAt: Date.now(), updatedAt: Date.now() });
    return await this.storage.setItem('combos', combos);
  }

  async update(combo: Combo): Promise<boolean> {
    const combos = await this.getAll();
    const index = combos.findIndex(c => c.id === combo.id);
    if (index === -1) return false;
    combos[index] = { ...combo, updatedAt: Date.now() };
    return await this.storage.setItem('combos', combos);
  }

  async delete(id: number): Promise<boolean> {
    const combos = await this.getAll();
    const filtered = combos.filter(c => c.id !== id);
    return await this.storage.setItem('combos', filtered);
  }

  async reorderTricks(comboId: number, trickIds: string[]): Promise<boolean> {
    const combos = await this.getAll();
    const combo = combos.find(c => c.id === comboId);
    if (!combo) return false;

    combo.tricks = trickIds.map((id, index) => ({
      trickId: id,
      trickName: ALL_TRICKS.find(t => t.id === id)?.name || '',
      order: index
    }));
    combo.updatedAt = Date.now();
    return await this.storage.setItem('combos', combos);
  }
}

Step 5: Implement Data Statistics

StatsService

computes four analytical views from session records:

Overview : total sessions, total minutes, total trick attempts, success rate (landed/attempts * 100), and current streak (consecutive days with sessions, allowing today or yesterday as the latest).

Category Distribution : counts per trick category, enriched with category name and color from TRICK_CATEGORIES.

Weekly Trend : sessions grouped by week (Monday start), last 12 weeks sorted chronologically.

Most Practiced Tricks : top 10 tricks by occurrence count, with names resolved from ALL_TRICKS.

The private calculateStreak method sorts unique session dates, checks if the latest date is today or yesterday, then counts backward while each consecutive day differs by exactly one day. The React version replicates the algorithms using plain objects and Object.entries.

// StatsService.ets (key methods)
export class StatsService {
  private storage: StorageService;

  constructor(context: common.UIAbilityContext) {
    this.storage = StorageService.getInstance(context);
  }

  async getOverview(): Promise<{ totalSessions: number; totalMinutes: number; totalTricks: number; successRate: number; currentStreak: number }> {
    const sessions = await this.storage.getItem<SessionRecord[]>('sessions', []);
    const totalSessions = sessions.length;
    const totalMinutes = sessions.reduce((sum, s) => sum + s.duration, 0);

    let totalAttempts = 0;
    let totalLanded = 0;
    sessions.forEach(s => {
      s.tricks.forEach(t => {
        totalAttempts += t.attempts;
        if (t.landed) totalLanded++;
      });
    });

    const successRate = totalAttempts > 0 ? Math.round((totalLanded / totalAttempts) * 100) : 0;

    const dates = [...new Set(sessions.map(s => s.date))].sort();
    const currentStreak = this.calculateStreak(dates);

    return { totalSessions, totalMinutes, totalTricks: totalAttempts, successRate, currentStreak };
  }

  private calculateStreak(dates: string[]): number {
    if (dates.length === 0) return 0;
    const today = new Date().toISOString().slice(0, 10);
    const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
    if (dates[dates.length - 1] !== today && dates[dates.length - 1] !== yesterday) return 0;

    let streak = 1;
    for (let i = dates.length - 2; i >= 0; i--) {
      const curr = new Date(dates[i + 1]);
      const prev = new Date(dates[i]);
      const diffDays = (curr.getTime() - prev.getTime()) / 86400000;
      if (diffDays === 1) streak++;
      else break;
    }
    return streak;
  }
  // ... getCategoryDistribution, getWeeklyTrend, getMostPracticedTricks omitted for brevity
}

Step 6: Integrate in UI Pages

The ArkTS Stats component uses @State for reactive data and loads statistics in aboutToAppear. The UI renders a scrollable column with three cards: an overview grid (three columns showing total sessions, success rate, streak), a category distribution list (colored circle + name + count), and a ranked list of most practiced tricks. The React version uses useState / useEffect and Tailwind-like class names for a functionally identical layout.

// ArkTS - Stats page (excerpt)
@Component
struct Stats {
  @State overview: any = {};
  @State categoryDistribution: any[] = [];
  @State mostPracticed: any[] = [];

  async aboutToAppear() {
    const statsService = new StatsService(getContext());
    this.overview = await statsService.getOverview();
    this.categoryDistribution = await statsService.getCategoryDistribution();
    this.mostPracticed = await statsService.getMostPracticedTricks();
  }

  build() {
    Scroll() {
      Column() {
        // Overview cards
        Card() {
          Grid() {
            GridItem() {
              Text(this.overview.totalSessions?.toString() || '0').fontSize(24).fontWeight(FontWeight.Bold)
              Text('练习次数').fontSize(12).fontColor('#666')
            }
            GridItem() {
              Text(`${this.overview.successRate || 0}%`).fontSize(24).fontWeight(FontWeight.Bold)
              Text('成功率').fontSize(12).fontColor('#666')
            }
            GridItem() {
              Text(this.overview.currentStreak?.toString() || '0').fontSize(24).fontWeight(FontWeight.Bold)
              Text('连续练习天数').fontSize(12).fontColor('#666')
            }
          }.columnsTemplate('1fr 1fr 1fr')
        }
        // Category distribution
        Card() {
          Text('技巧分类分布').fontSize(16).fontWeight(FontWeight.Bold).margin({ bottom: 8 })
          ForEach(this.categoryDistribution, (item: any) => {
            Row() {
              Circle({ width: 12, height: 12 }).fill(item.color)
              Text(item.categoryName).fontSize(14).margin({ left: 8 }).layoutWeight(1)
              Text(`${item.count}次`).fontSize(14).fontColor('#666')
            }.padding(4)
          })
        }
        // Most practiced tricks
        Card() {
          Text('最常练习的技巧').fontSize(16).fontWeight(FontWeight.Bold).margin({ bottom: 8 })
          ForEach(this.mostPracticed, (item: any, index: number) => {
            Row() {
              Text(`${index + 1}`).fontSize(14).fontColor('#666').width(24)
              Text(item.trickName).fontSize(14).layoutWeight(1)
              Text(`${item.count}次`).fontSize(14).fontColor('#666')
            }.padding(4)
          })
        }
      }.padding(16)
    }
  }
}

Pitfalls & Best Practices

Data Migration : When app upgrades change data structures, include a version number in storage to handle migrations.

Storage Limits : preferences suits small data (few KB). For larger datasets (MB), switch to a relational database.

Async Operations : All HarmonyOS storage APIs are asynchronous; use async/await and never call them directly inside build().

Data Backup : Provide an export feature so users don't lose data on uninstall.

Performance Optimization : With thousands of records, queries and stats become slow; add indexes or caching.

Summary

The article concludes with the core principle: design solid data structures, encapsulate a clean storage service . Data structure is the foundation, storage service is the bridge, business logic is the superstructure. The two-part series now covers the app's core features — slow-motion video recording and trick recording — and invites readers to download "Bandonglu" from the HarmonyOS App Gallery to see the result.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Mobile DevelopmentTypeScriptstatisticsHarmonyOSdata modelingArkTSlocal storagePreferences API
51CTO HarmonyOS Developer Community
Written by

51CTO HarmonyOS Developer Community

The HarmonyOS Developer Community is a learning-oriented community for developers to learn, communicate, ask questions, and share.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.