iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0

前言:資料拿到了,但 APP 重開就失憶?

在昨天的實作中,我們成功地將「個人檔案頁」與「課表頁」的職責分離,並順利透過 Cloud Run 拿到了 AI 為東京馬拉松量身打造的結構化 JSON 課表。

但隨即發現了一個問題:只要使用者滑掉 APP、或是 Expo Go 重新載入,產生的 AI 課表與個人設定就會瞬間消失!

在真實的應用場景中,跑者不可能每次打開 APP 都要重新輸入資料、點擊「生成課表」並傻傻等待 AI 運算 5~10 秒。我們必須讓 APP 擁有**「全域狀態 (Global State)」讓不同頁面共享資料,並且具備「持久化記憶 (Persist Storage)」**,把資料實實在在地寫入手機的本地儲存空間中。

今天,我們將透過 Zustand 與它的 Persist 中介軟體,一次解決這兩個痛點!

觀念解說:為什麼捨棄 Context API,選擇 Zustand + Persist?

在 React Native 生態系中,要處理跨頁面的資料共享,常見的幾種選擇:

  1. React Context API:內建免安裝,但每次狀態更新都會引發底下所有子元件的重新渲染 (Re-render);且沒有內建持久化機制,需要手動在每個生命週期寫 AsyncStorage.setItem 與 getItem
  2. Redux Toolkit:效能優秀,但樣板程式碼 (Boilerplate) 過於龐大,光是設定 Store、Slice、Action、Reducer 就會耗掉大半精力。
  3. Zustand:德文「狀態」的意思。這是一套主打輕量、Hook 風格的現代狀態管理套件。它不僅程式碼極度簡潔、不需要在最外層包裝 <Provider>,最厲害的是它內建了強大的 Middleware (中介軟體) 系統。我們只需要掛上 persist,它就能全自動地在背景幫我們把狀態同步到手機硬碟中!

因此我最後選擇使用 Zustand!

動手實作 1:安裝套件與 AI 詠唱 Store 倉庫

打開終端機,透過 Expo CLI 安裝 Zustand 以及 React Native 官方推薦的非同步儲存套件:

npx expo install @react-native-async-storage/async-storage zustand

接下來,我們要建立這個具備持久化能力的大腦,需求Prompt如下:

「我正在使用 Expo 開發 React Native APP,並使用 Zustand 進行狀態管理。 請幫我建立一個名為 useStore.ts 的全域狀態,需求:

  1. 包含三個核心狀態:runnerProfile(存跑者暱稱、身高體重、目標等)、aiPlan(存 AI 課表 JSON)、selectedWeek(記錄目前課表頁選中第幾週,預設 1)。
  2. 提供對應的更新 Actions 以及一個重置狀態的 clearStore 函式。
  3. 重點:結合 Zustand 的 persist 中介軟體與 @react-native-async-storage/async-storage,設定儲存名稱為 kakeru-storage,並加入 onRehydrateStorage 回呼函式,讓快取從手機硬碟還原時能印出日誌確認。」

迅速為產出了具備 TypeScript 型別與持久化中介軟體的 src/store/useStore.ts

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';

export interface RunnerProfile {
  nickname: string;
  height: number | string;
  weight: number | string;
  targetRace?: string;
  weeklyDays?: number | string;
}

export interface StoreState {
  runnerProfile: RunnerProfile | null;
  aiPlan: any | null;
  selectedWeek: number;

  setRunnerProfile: (profile: Partial<RunnerProfile> | null) => void;
  setAiPlan: (plan: any | null) => void;
  setSelectedWeek: (week: number) => void;
  clearStore: () => void;
}

const defaultRunner: RunnerProfile = {
  nickname: 'Takuya',
  height: 171,
  weight: 68,
  targetRace: '東京馬拉松 (目標 Sub 4)',
  weeklyDays: 4,
};

const useStore = create<StoreState>()(
  persist(
    (set) => ({
      // --- 狀態定義 ---
      runnerProfile: defaultRunner,
      aiPlan: null,
      selectedWeek: 1,

      // --- Actions ---
      setRunnerProfile: (data) =>
        set((state) => ({
          runnerProfile: data ? { ...(state.runnerProfile || defaultRunner), ...data } : null,
        })),

      setAiPlan: (plan) => set({ aiPlan: plan }),

      setSelectedWeek: (week) => set({ selectedWeek: week }),

      clearStore: () =>
        set({
          runnerProfile: null,
          aiPlan: null,
          selectedWeek: 1,
        }),
    }),
    {
      name: 'kakeru-storage', // 儲存在手機 AsyncStorage 的 Key
      storage: createJSONStorage(() => AsyncStorage), // 指定 React Native 的儲存引擎
      onRehydrateStorage: () => (state) => {
        console.log('✅ [Zustand Persist] 快取資料已從手機硬碟成功恢復!', {
          hasRunner: Boolean(state?.runnerProfile),
          hasPlan: Boolean(state?.aiPlan),
        });
      },
    }
  )
);

export default useStore;

實戰踩坑:新裝 Native Module 記得重啟 Metro!

在實體手機 Expo Go 上測試時,曾短暫遇到「滑掉重開後仍然要求重新生成」的情況,而這一點只需在終端機重啟 Metro 並清除快取:

npx expo start -c

重啟後重新生成一次課表,資料便會確實寫入手機硬碟!

動手實作 2:跨頁面寫入與讀取持久化記憶

有了具備持久化能力的 useStore,整個 App 的跨頁面互動變得無比流暢:

1.「個人檔案頁」秒存個人體能數據

當跑者更新身高體重時,直接呼叫 setRunnerProfile,資料在記憶體更新的同時也秒存手機硬碟:

import useStore from '../store/useStore';

export default function ProfileScreen() {
  const setRunnerProfile = useStore((state) => state.setRunnerProfile);

  const handleSave = () => {
    // 立即寫入全域狀態與本地儲存,無需發送耗時的 AI 請求
    setRunnerProfile({ nickname, height: Number(height), weight: Number(weight) });
    Alert.alert('儲存成功', '跑者基本資料已更新!');
  };
}

2.「課表頁」寫入 AI 成果與綁定週次 Tab

在課表頁透過 Cloud Run 拿到 AI 回傳的資料後,呼叫 setAiPlan;而週次切換(第 1 ~ 4 週)也直接綁定全域 selectedWeek

import useStore from '../store/useStore';

export default function ScheduleScreen() {
  const { aiPlan, setAiPlan, runnerProfile, selectedWeek, setSelectedWeek } = useStore();

  const handleGeneratePlan = async () => {
    const apiResponse = await generateMarathonPlan(payload);
    // 一行代碼,同時更新全域狀態並非同步寫入手機硬碟!
    setAiPlan(apiResponse.data);
  };
}

3.「首頁」隨處提取,拒絕 Prop Drilling

首頁直接透過 Hook 提取 runnerProfile 與 aiPlan,精準響應最新狀態:

import useStore from '../store/useStore';

export default function HomeScreen() {
  const runnerProfile = useStore((state) => state.runnerProfile);
  const aiPlan = useStore((state) => state.aiPlan);

  return (
    <Text>歡迎回到 KAKERU, {runnerProfile?.nickname}</Text>
  );
}

實機測試:見證「持久化」的威力!

把程式碼存檔後,在實體手機的 Expo Go 進行驗證:

  1. 在「課表」頁面生成一份東京馬拉松課表。
  2. 把 Expo Go 從手機的背景完全滑掉關閉
  3. 重新點開 Expo Go 進入 App。

終端機印出:

[Zustand Persist] 快取資料已從手機硬碟成功恢復! { hasRunner: true, hasPlan: true }

今日總結與明日預告

今天我們透過 Zustand + Persist 完成了 App 的升級:

  • 解決了 React 元件跨頁面與跨 Tab 無法共用狀態的痛點。
  • 透過 @react-native-async-storage/async-storage 實現了離線快取與本地持久化儲存。
  • 排查並掌握了 Expo 新增原生儲存模組需重啟 Metro 的實戰經驗。
  • 減少重複呼叫 Cloud Run 與 Gemini API 的次數,省下了寶貴的 Token 成本。

有了這個能夠永久記憶的全域大腦,明天(Day 19)將把重點轉移到視覺與互動體驗上,做一個看起來比較有質感的 AI 教練評估卡片、可平滑切換的週次選擇器,以及精緻的每日訓練任務清單!我們明天見!


上一篇
Day 17 | 前後端串接:透過 Axios 呼叫 Cloud Run,並用 AI 跨端排查 Guardrail 障礙
下一篇
Day 19 | 介面實作 (2):告別生硬 JSON!拆解 UI 元件並完美綁定 AI 課表資料
系列文
單鐵的人生如履薄冰!AI 教練 APP 30天開發旅程,你說能走到最後嗎?22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言