Mobile Development
Intermediate

ជំពូកទី ៣៖ ការសរសេរកូដជាក់ស្ដែង (Build) និងការដាក់លើ Store (Ship)

ដល់ពេលសរសេរកូដហើយ។ បង្កើត App កត់ត្រាទម្លាប់ប្រចាំថ្ងៃ (Habit Tracker) ជាមួយ React Native និង Expo មើលវាដំណើរការលើទូរស័ព្ទផ្ទាល់ រក្សាទុកទិន្នន័យកុំឱ្យបាត់ពេលបិទ App ហើយស្វែងយល់ពីផ្លូវទៅកាន់ App Store និង Google Play។

ជំពូកទី ៣៖ ការសរសេរកូដជាក់ស្ដែង (Build) និងការដាក់លើ Store (Ship)

មានពេលមួយដែលកូដឈប់ជាទ្រឹស្ដី។ អ្នកវាយពាក្យបញ្ជាមួយបន្ទាត់ ស្កេន QR Code ហើយ App ដែលអ្នកទើបសរសេរ លេចឡើងនៅលើទូរស័ព្ទក្នុងហោប៉ៅរបស់អ្នក។ អ្នកប្ដូរពាក្យមួយក្នុងកូដ ចុច Save ហើយទូរស័ព្ទប្ដូរតាមភ្លាមៗ។

មេរៀននេះនាំអ្នកទៅដល់ពេលនោះ។ យើងនឹងបង្កើត Daily Habit Tracker ដែលជា App កត់ត្រាទម្លាប់ប្រចាំថ្ងៃ ដោយប្រើអ្វីៗទាំងអស់ដែលបានរៀនក្នុងជំពូកទី ១ និង ២៖ Lifecycle, Thumb Zone, Tap Target និង Safe Area។ បន្ទាប់មក យើងនឹងមើលផ្លូវពីកុំព្យូទ័ររបស់អ្នក ទៅដល់ទូរស័ព្ទរបស់មនុស្សផ្សេង។

បង្កើតអេក្រង់ដំបូង

យើងនឹងប្រើ Expo ដែលជា Toolchain ផ្លូវការ និងពេញនិយមបំផុតសម្រាប់ React Native។

ជំហានទី ១៖ បង្កើតគម្រោងថ្មី។ បើក Terminal ហើយវាយ៖

npx create-expo-app@latest HabitTracker --template blank
cd HabitTracker

ជំហានទី ២៖ ដំឡើងបណ្ណាល័យដែលត្រូវការ សម្រាប់ Safe Area និងការរក្សាទុកទិន្នន័យ៖

npx expo install @react-native-async-storage/async-storage react-native-safe-area-context

ជំហានទី ៣៖ ដំណើរការ App។

npx expo start
ចលនា៖ Terminal ដំណើរការ npx expo start ហើយបង្ហាញ QR Code ទូរស័ព្ទស្កេនវា App លេចឡើង ហើយពេល Save កូដ ចំណងជើងប្ដូរភ្លាមៗ
Save នៅលើកុំព្យូទ័រ ឃើញនៅលើទូរស័ព្ទ ក្នុងរយៈពេលមួយប៉ព្រិចភ្នែក។

Terminal នឹងបង្ហាញ QR Code ធំមួយ៖

  • iPhone: បើក Camera ធម្មតា ហើយស្កេន QR Code។ វានឹងបើកក្នុង Expo Go ដោយស្វ័យប្រវត្តិ។
  • Android: បើក Expo Go ហើយចុច "Scan QR code"។

ពីពេលនេះទៅ រាល់ពេលអ្នក Save App.tsx ទូរស័ព្ទនឹង Update ភ្លាមៗ។ គេហៅវាថា Fast Refresh។


ពី HTML ទៅ React Native

React Native មិនប្រើ <div> ឬ <p> ទេ។ វាប្រើ Component ដែលក្លាយជាធាតុពិតៗរបស់ iOS និង Android។ ប៉ុន្តែគំនិតនៅដដែល គ្រាន់តែឈ្មោះខុសគ្នា៖

តារាងផ្គូផ្គង៖ div និង section ទៅជា View, p span និង h1 ទៅជា Text, input ទៅជា TextInput, button ទៅជា Pressable, img ទៅជា Image, ul ដែលរមូរបាន ទៅជា FlatList
បើអ្នកចេះ HTML អ្នកស្គាល់គំនិតទាំងនេះរួចហើយ។

ចំណុចមួយដែលអ្នកចាប់ផ្ដើមតែងតែជួប៖ នៅក្នុង React Native អក្សរទាំងអស់ត្រូវនៅក្នុង <Text>។ បើអ្នកដាក់អក្សរដោយផ្ទាល់ក្នុង <View> App នឹង Error។


កុំឱ្យទិន្នន័យបាត់ពេលបិទ App

ចាំទេ ពីជំពូកទី ១? OS អាចបិទ App របស់អ្នកគ្រប់ពេល។ អ្វីៗដែលនៅក្នុង State (useState) រស់នៅក្នុង RAM ហើយបាត់ទៅជាមួយ App។ ដើម្បីរក្សាទុកវា យើងត្រូវសរសេរវាទៅ Disk៖

ចលនា៖ ទូរស័ព្ទពីរគ្រឿងបន្ថែមទម្លាប់ដូចគ្នា។ App ត្រូវបិទ។ ពេលបើកវិញ ទូរស័ព្ទដែលប្រើតែ State បង្ហាញ "nothing here" ចំណែកទូរស័ព្ទដែលប្រើ AsyncStorage ទាញទម្លាប់ទាំងអស់មកវិញ
RAM ត្រូវលុបពេលបិទ App។ មានតែអ្វីដែលបាន Save ទៅ Disk ទេដែលត្រឡប់មកវិញ។

AsyncStorage ស្រដៀងនឹង localStorage នៅលើ Web គឺរក្សាទុកជាគូ Key និង Value។ ភាពខុសគ្នាគឺវាដំណើរការជា Asynchronous (ប្រើ Promise) ដើម្បីកុំឱ្យ UI គាំងពេលកំពុងសរសេរ ឬអាន៖

import AsyncStorage from '@react-native-async-storage/async-storage';

// ១. រក្សាទុក (Save)
async function saveHabits(habits) {
  try {
    await AsyncStorage.setItem('@my_habits', JSON.stringify(habits));
  } catch (e) {
    console.error('បរាជ័យក្នុងការរក្សាទុកទិន្នន័យ', e);
  }
}

// ២. ទាញមកវិញ (Load)
async function loadHabits() {
  try {
    const jsonValue = await AsyncStorage.getItem('@my_habits');
    return jsonValue != null ? JSON.parse(jsonValue) : [];
  } catch (e) {
    console.error('បរាជ័យក្នុងការទាញយកទិន្នន័យ', e);
    return [];
  }
}

AsyncStorage រក្សាទុកតែ String ប៉ុណ្ណោះ ដូច្នេះយើងប្រើ JSON.stringify ពេល Save និង JSON.parse ពេល Load។


គម្រោង៖ Daily Habit Tracker

ឥឡូវយើងរួមបញ្ចូលអ្វីៗទាំងអស់។ សូមអានកូដខាងក្រោម ហើយសម្គាល់មើលថា មេរៀនពីជំពូកមុនៗបានលេចឡើងនៅកន្លែងណាខ្លះ៖

  • SafeAreaView ការពារកុំឱ្យ Notch បាំង Header។
  • ប៊ូតុង "បន្ថែម" និង Checkbox មាន minHeight: 48 តាមច្បាប់ Tap Target។
  • ប៊ូតុងលុប (✕) មាន minWidth: 44 និង minHeight: 44 ទោះ Icon តូចក៏ដោយ។
  • រាល់ការប្ដូរ ត្រូវ Save ទៅ AsyncStorage ភ្លាម ដូច្នេះ App អាចត្រូវបិទនៅពេលណាក៏បាន។
  • ការលុបសួរបញ្ជាក់ជាមុនសិន ព្រោះវាជាសកម្មភាពដែលមិនអាចត្រឡប់វិញបាន។

🎯 Hands-on Task (អនុវត្តផ្ទាល់)

  1. បើក App.js ឬ App.tsx ក្នុងគម្រោង HabitTracker របស់អ្នក។
  2. ជំនួសកូដទាំងអស់ដោយកូដខាងក្រោម ហើយ Save។
  3. នៅលើទូរស័ព្ទ បន្ថែមទម្លាប់ថ្មីមួយ ហើយធីកវា។
  4. បិទ App ទាំងស្រុង (អូសវាចេញពី App Switcher) រួចបើកវាឡើងវិញ។
  5. ទម្លាប់ដែលអ្នកបានបន្ថែម និង Streak 🔥 ត្រូវតែនៅដដែល។
import React, { useState, useEffect } from 'react';
import {
  StyleSheet,
  Text,
  View,
  TextInput,
  Pressable,
  FlatList,
  KeyboardAvoidingView,
  Platform,
  Alert,
} from 'react-native';
import { SafeAreaView, SafeAreaProvider } from 'react-native-safe-area-context';
import AsyncStorage from '@react-native-async-storage/async-storage';

interface Habit {
  id: string;
  name: string;
  completed: boolean;
  streak: number;
}

const STORAGE_KEY = '@panha_daily_habits';

function HabitApp() {
  const [habits, setHabits] = useState<Habit[]>([]);
  const [newHabitText, setNewHabitText] = useState('');

  // ១. ទាញយកទិន្នន័យចាស់ពេលបើក App ដំបូង
  useEffect(() => {
    async function loadData() {
      try {
        const stored = await AsyncStorage.getItem(STORAGE_KEY);
        if (stored) {
          setHabits(JSON.parse(stored));
        } else {
          // ទិន្នន័យគំរូដំបូង
          setHabits([
            { id: '1', name: 'ផឹកទឹក ២ លីត្រ', completed: false, streak: 3 },
            { id: '2', name: 'ហាត់ប្រាណ ២០ នាទី', completed: true, streak: 5 },
            { id: '3', name: 'អានសៀវភៅ ១០ ទំព័រ', completed: false, streak: 1 },
          ]);
        }
      } catch (err) {
        console.error('Cannot load habits', err);
      }
    }
    loadData();
  }, []);

  // ២. រក្សាទុកទិន្នន័យរាល់ពេល habits ប្រែប្រួល
  const saveHabitsToDisk = async (updatedHabits: Habit[]) => {
    setHabits(updatedHabits);
    try {
      await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updatedHabits));
    } catch (err) {
      console.error('Cannot save habits', err);
    }
  };

  // បន្ថែម Habit ថ្មី
  const handleAddHabit = () => {
    if (!newHabitText.trim()) return;
    const newHabit: Habit = {
      id: Date.now().toString(),
      name: newHabitText.trim(),
      completed: false,
      streak: 0,
    };
    saveHabitsToDisk([...habits, newHabit]);
    setNewHabitText('');
  };

  // ធីកបំពេញទម្លាប់ (Toggle Complete)
  const toggleHabit = (id: string) => {
    const updated = habits.map((h) => {
      if (h.id === id) {
        const nextStatus = !h.completed;
        return {
          ...h,
          completed: nextStatus,
          streak: nextStatus ? h.streak + 1 : Math.max(0, h.streak - 1),
        };
      }
      return h;
    });
    saveHabitsToDisk(updated);
  };

  // លុប Habit
  const deleteHabit = (id: string) => {
    Alert.alert('លុបទម្លាប់', 'តើអ្នកប្រាកដជាចង់លុបទម្លាប់នេះមែនទេ?', [
      { text: 'បោះបង់', style: 'cancel' },
      {
        text: 'លុបចេញ',
        style: 'destructive',
        onPress: () => {
          const filtered = habits.filter((h) => h.id !== id);
          saveHabitsToDisk(filtered);
        },
      },
    ]);
  };

  const completedCount = habits.filter((h) => h.completed).length;

  return (
    <SafeAreaView style={styles.container}>
      <KeyboardAvoidingView
        style={styles.keyboardView}
        behavior={Platform.OS === 'ios' ? 'padding' : undefined}
      >
        {/* Header Section */}
        <View style={styles.header}>
          <Text style={styles.headerTitle}>ទម្លាប់ប្រចាំថ្ងៃ 🎯</Text>
          <Text style={styles.headerSubtitle}>
            បានសម្រេច {completedCount} ក្នុងចំណោម {habits.length} ទម្លាប់
          </Text>
        </View>

        {/* Input Bar (Thumb Zone Friendly) */}
        <View style={styles.inputContainer}>
          <TextInput
            style={styles.input}
            placeholder="បញ្ចូលទម្លាប់ថ្មី (ឧ. គេងពីព្រលប់)..."
            placeholderTextColor="#888"
            value={newHabitText}
            onChangeText={setNewHabitText}
          />
          <Pressable style={styles.addButton} onPress={handleAddHabit}>
            <Text style={styles.addButtonText}>បន្ថែម</Text>
          </Pressable>
        </View>

        {/* Habit List */}
        <FlatList
          data={habits}
          keyExtractor={(item) => item.id}
          contentContainerStyle={styles.listContent}
          renderItem={({ item }) => (
            <View style={[styles.card, item.completed && styles.cardCompleted]}>
              <Pressable
                style={styles.checkboxTouchTarget}
                onPress={() => toggleHabit(item.id)}
              >
                <View style={[styles.checkbox, item.completed && styles.checkboxActive]}>
                  {item.completed && <Text style={styles.checkmark}>✓</Text>}
                </View>
                <View style={styles.cardTexts}>
                  <Text
                    style={[
                      styles.habitName,
                      item.completed && styles.habitNameCompleted,
                    ]}
                  >
                    {item.name}
                  </Text>
                  <Text style={styles.streakText}>🔥 {item.streak} ថ្ងៃជាប់គ្នា</Text>
                </View>
              </Pressable>

              <Pressable
                style={styles.deleteButton}
                onPress={() => deleteHabit(item.id)}
              >
                <Text style={styles.deleteText}>✕</Text>
              </Pressable>
            </View>
          )}
        />
      </KeyboardAvoidingView>
    </SafeAreaView>
  );
}

export default function App() {
  return (
    <SafeAreaProvider>
      <HabitApp />
    </SafeAreaProvider>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#0f172a', // Dark Navy Theme
  },
  keyboardView: {
    flex: 1,
  },
  header: {
    paddingHorizontal: 20,
    paddingTop: 16,
    paddingBottom: 20,
  },
  headerTitle: {
    fontSize: 28,
    fontWeight: '800',
    color: '#f8fafc',
  },
  headerSubtitle: {
    fontSize: 14,
    color: '#94a3b8',
    marginTop: 4,
  },
  inputContainer: {
    flexDirection: 'row',
    paddingHorizontal: 20,
    marginBottom: 16,
    gap: 12,
  },
  input: {
    flex: 1,
    height: 50,
    backgroundColor: '#1e293b',
    borderRadius: 12,
    paddingHorizontal: 16,
    color: '#f8fafc',
    fontSize: 15,
  },
  addButton: {
    backgroundColor: '#10b981', // Emerald Color
    justifyContent: 'center',
    alignItems: 'center',
    paddingHorizontal: 20,
    borderRadius: 12,
    minHeight: 48, // 48dp Tap Target Rule!
  },
  addButtonText: {
    color: '#ffffff',
    fontWeight: 'bold',
    fontSize: 15,
  },
  listContent: {
    paddingHorizontal: 20,
    paddingBottom: 40,
    gap: 12,
  },
  card: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
    backgroundColor: '#1e293b',
    padding: 16,
    borderRadius: 16,
    borderWidth: 1,
    borderColor: '#334155',
  },
  cardCompleted: {
    backgroundColor: '#064e3b',
    borderColor: '#059669',
    opacity: 0.85,
  },
  checkboxTouchTarget: {
    flexDirection: 'row',
    alignItems: 'center',
    flex: 1,
    minHeight: 48, // Minimum Tap Target
  },
  checkbox: {
    width: 28,
    height: 28,
    borderRadius: 8,
    borderWidth: 2,
    borderColor: '#64748b',
    justifyContent: 'center',
    alignItems: 'center',
    marginRight: 14,
  },
  checkboxActive: {
    backgroundColor: '#10b981',
    borderColor: '#10b981',
  },
  checkmark: {
    color: '#ffffff',
    fontSize: 16,
    fontWeight: 'bold',
  },
  cardTexts: {
    flex: 1,
  },
  habitName: {
    fontSize: 16,
    fontWeight: '600',
    color: '#f8fafc',
  },
  habitNameCompleted: {
    textDecorationLine: 'line-through',
    color: '#cbd5e1',
  },
  streakText: {
    fontSize: 12,
    color: '#fbbf24',
    marginTop: 4,
  },
  deleteButton: {
    padding: 10,
    minWidth: 44,
    minHeight: 44,
    justifyContent: 'center',
    alignItems: 'center',
  },
  deleteText: {
    color: '#ef4444',
    fontSize: 18,
    fontWeight: 'bold',
  },
});

អ្នកបានបញ្ចប់នៅពេលដែលទិន្នន័យរបស់អ្នករស់រានមានជីវិត បន្ទាប់ពីបិទ App ហើយបើកវិញ។

បើមានបញ្ហា

  • ទូរស័ព្ទមិនភ្ជាប់ទៅ Dev Server។ កុំព្យូទ័រ និងទូរស័ព្ទត្រូវនៅលើ Wi-Fi តែមួយ។ បើនៅតែមិនបាន សាកល្បង npx expo start --tunnel។
  • Error "Text strings must be rendered within a Text component"។ មានអក្សរដែលនៅក្រៅ <Text>។ រកមើលដកឃ្លា ឬអក្សរដែលនៅដោយផ្ទាល់ក្នុង <View>។
  • ទិន្នន័យបាត់ពេលបើកវិញ។ ពិនិត្យថា STORAGE_KEY ដូចគ្នាទាំងពេល Save និងពេល Load ហើយ saveHabitsToDisk ត្រូវបានហៅរាល់ពេលដែល Habits ប្ដូរ។

ពីកុំព្យូទ័ររបស់អ្នក ទៅដល់ទូរស័ព្ទរបស់អ្នកដទៃ

Expo Go គឺល្អសម្រាប់ការអភិវឌ្ឍ ប៉ុន្តែមនុស្សផ្សេងមិនអាចដំឡើង App របស់អ្នកពី Terminal របស់អ្នកបានទេ។ ដើម្បីដាក់វាលើ Store វាត្រូវដើរតាមផ្លូវនេះ៖

ផ្លូវប្រាំជំហាន៖ កូដរបស់អ្នក ទៅ EAS Build លើ Cloud ទៅជាឯកសារ .ipa និង .aab ទៅ Store Review ហើយទៅដល់ User។ ខាងក្រោម៖ Google Play $25 បង់ម្ដង និង Apple App Store $99 ក្នុងមួយឆ្នាំ
ការ Build ចំណាយពេលប៉ុន្មាននាទី។ ការ Review អាចចំណាយពេលប៉ុន្មានថ្ងៃ។ សូមគិតគូរពេលវេលាទុកជាមុន។

១. បង្កើតគណនី Developer

  • Google Play Console: $25 បង់តែម្ដង។ គណនីផ្ទាល់ខ្លួនថ្មីត្រូវមាន Tester យ៉ាងតិច ១២ នាក់ សាកល្បងរយៈពេល ១៤ ថ្ងៃ មុនពេលដាក់ជាសាធារណៈ។
  • Apple Developer Program: $99 ក្នុងមួយឆ្នាំ។ រួមបញ្ចូលទាំង TestFlight សម្រាប់ Beta Tester និង App Store Connect។

២. រៀបចំ Assets

  1. App Icon: ទំហំ 1024 × 1024 px ដោយគ្មានផ្ទៃថ្លា (Transparent)
  2. Screenshots: សម្រាប់ទំហំអេក្រង់ផ្សេងៗ (iPhone, iPad, Android)
  3. Privacy Policy URL: ទំព័រដែលពន្យល់ពីរបៀបដែល App ប្រើទិន្នន័យ
  4. ឈ្មោះ និងការពិពណ៌នា: ជាភាសាខ្មែរ ឬអង់គ្លេស

៣. Build ដោយប្រើ EAS

EAS (Expo Application Services) Build App របស់អ្នកនៅលើ Cloud ដូច្នេះអ្នកមិនចាំបាច់មាន Mac ថ្លៃៗដើម្បី Build សម្រាប់ iOS ទេ៖

# ១. ដំឡើង EAS CLI
npm install -g eas-cli

# ២. Login ចូលគណនី Expo
eas login

# ៣. កំណត់ Configuration
eas build:configure

# ៤. Build សម្រាប់ Android និង iOS
eas build --platform all

EAS នឹងផ្ដល់ឯកសារ .aab (Android) និង .ipa (iOS) មកវិញ។ អ្នកក៏អាចប្រើ eas submit ដើម្បី Upload ទៅ Store ដោយផ្ទាល់។


អ្វីដែលត្រូវចងចាំ

  • Expo អនុញ្ញាតឱ្យអ្នកមើល App លើទូរស័ព្ទពិតភ្លាមៗ តាមរយៈ QR Code និង Fast Refresh។
  • React Native ប្រើ View, Text, TextInput, Pressable, Image, FlatList ជំនួស HTML។
  • State រស់ក្នុង RAM ហើយបាត់។ ប្រើ AsyncStorage ដើម្បីរក្សាទុកទៅ Disk។
  • ការដាក់លើ Store ត្រូវការ គណនី Developer, EAS Build និងពេលវេលាសម្រាប់ Review។

🎉 អបអរសាទរ! អ្នកបានបញ្ចប់វគ្គ Mobile Development ទាំងបីជំពូក។ អ្នកដឹងពីរបៀបដែល App រស់នៅលើទូរស័ព្ទ របៀបរចនាសម្រាប់មេដៃ និងរបៀបសរសេរ ហើយដាក់ App ដំបូងរបស់អ្នកឱ្យដំណើរការ។ ជំហានបន្ទាប់គឺ៖ ប្ដូរ Habit Tracker នេះឱ្យក្លាយជារបស់អ្នក។