Hyoban

Hyoban

Don’t do what you should do, do you want.
x
github
email
telegram
follow

Re: 從零開始的 React Native 之旅

為什麼要寫 rn#

一個是我自己還沒正兒八經地寫過 rn,想試試它的體驗怎麼樣。加上最近對 Follow 這個 RSS 閱讀器很感興趣,但是它暫時還沒移動端,可以作為我邊學習邊實踐的對象。再有就是最近開始上班了,自己老是沒什麼動力在下班後寫點想寫的代碼,有個目標更容易讓自己專注。

準備工作#

hello world#

好了,廢話不多說,讓我們跑起來第一個 app 吧。不過正所謂 “工欲善其事,必先利其器”,我們先準備好環境。

一般來說你只需要安裝好 Xcode 就行了,不過如果你像我一樣,最近升級了 macOS beta 的話,就會麻煩一些:

  1. App Store 裡的 Xcode 是不能打開的,和系統版本不匹配。
  2. 下載完的 Xcode beta 沒辦法直接打開,提示 the plug-in or one of its prerequisite plug-ins may be missing or damaged and may need to be reinstalled.,需要手動安裝 Xcode.app/Contents/Resources/Packages/ 下的安裝包。參見 https://forums.developer.apple.com/forums/thread/660860
  3. 命令行中需要 select 到你在用的 beta 版 Xcode,xcode-select -s /Applications/Xcode-beta.app

然後就是需要一個 nice 的腳手架,我不太熟悉 rn 這邊的技術棧,看完 State of React Native 就選擇了之前在 Twitter 上看到的 Create Expo Stack

它除了作為一個 expo 項目的腳手架之外,還給你提供了很多主流技術棧的組合選項,這對於我想儘快開始寫 app 非常友好。最終我選擇的組合是:

npx create-expo-stack@latest follow-app --expo-router --tabs --tamagui --pnpm --eas

處理深色模式#

腳手架默認為只支持淺色模式,強迫症表示不能接受,所以首先處理一下先。參考這個 issue,我需要修改 expo 的設置為:

{
  "expo": {
    "userInterfaceStyle": "automatic",
    "ios": {
      "userInterfaceStyle": "automatic"
    },
    "android": {
      "userInterfaceStyle": "automatic"
    }
  }
}

然後你的 useColorScheme 就能正常獲得用戶當前選擇的主題模式。不過需要注意的是,修改完這個配置,你需要再執行一次 expo prebuild,確保 Info.plist 文件裡 key 為 UIUserInterfaceStyle 的值為 Automatic

正戲開始#

好了,現在我們來寫 Follow app 吧!

登錄賬號#

雖然 expo 文檔有很詳細的 Authentication 接入文檔,但我們不需要使用它。 Follow 的網頁端已經處理好了,我們只需要調用網頁端的登錄,為 app 註冊處理網頁登錄後會跳轉的 scheme 鏈接就好。

首先設置好 app 的 scheme,在 app config 裡面設置 scheme: 'follow',然後運行一下 expo prebuild

expo-web-browser 打開 Follow 登錄頁面:

await WebBrowser.openBrowserAsync('https://dev.follow.is/login')

然後用 expo-linking 註冊 url 的監聽事件,在接收到登錄網頁調起的 url 信息後,解析裡面的 token。

Linking.addEventListener('url', ({ url }) => {
  const { hostname, queryParams } = Linking.parse(url)
  if (hostname === 'auth' && queryParams !== null && typeof queryParams.token === 'string') {
    WebBrowser.dismissBrowser()
    if (Platform.OS !== 'web') {
      SecureStore.setItemAsync(SECURE_AUTH_TOKEN_KEY, queryParams.token)
    }
  }
})

這裡還遇到的一個問題是 iPhone 上 Safari 的異步函數裡的 window.open 會無效,需要加上 target="_top" 的參數。參考 https://stackoverflow.com/q/20696041/15548365

因為 url 會跳到 auth 這個頁面,我們可以加個讓它跳到主頁的路由 app/auth.tsx

import { router } from 'expo-router'

export default function Auth() {
  router.navigate('/')
  return null
}

OK,這樣我們就已經能夠獲取到用戶的認證憑據了。來試試調個接口看看。

獲取用戶信息#

在 rn 中發起網絡請求看起來和 web 沒有區別,我們仍然可以使用自己喜歡的庫。

function useSession() {
  return useSWR(URL_TO_FOLLOW_SERVER, async (url) => {
    const authToken = await SecureStore.getItemAsync(SECURE_AUTH_TOKEN_KEY)
    const response = await fetch(url, {
      headers: {
        cookie: `authjs.session-token=${authToken}`,
      },
      credentials: 'omit',
    })
    const data = (await response.json()) as Session
    return data
  })
}

這裡我暫時做了一點反常的設置,是因為 rn 中基於 cookie 的身份驗證存在一些 已知的問題,如果不設置 credentials: 'omit' 的話,就會在第二次請求時設置不正確的 cookie,導致請求失敗。這裡是參考 https://github.com/facebook/react-native/issues/23185#issuecomment-1148130842 的做法。

有了數據我們就可以渲染頁面,這裡先簡單寫寫:

export default function UserInfo() {
  const { data: session, mutate } = useSession()

  return (
    <YStack flex={1} padding={20}>
      {session ? (
        <YStack>
          <XStack gap={24} alignItems="center">
            <Image
              source={{
                uri: session.user.image,
                height: 100,
                width: 100,
              }}
              borderRadius={50}
            />
            <YStack gap={8}>
              <Text color="$color12" fontSize="$8" fontWeight="600">
                {session.user.name}
              </Text>
              <Text color="$color12" fontSize="$5">
                {session.user.email}
              </Text>
            </YStack>
          </XStack>
        </YStack>
      ) : (
        <Button onPress={handlePressButtonAsync}>Login</Button>
      )}
    </YStack>
  )
}

好了,來看看現在的效果。

啊哦,看起來 Follow 的網頁端還需要做點移動端適配,我又可以水 PR 了。

主題系統#

初始化項目的時候我選的 Tamagui,但是當我要開始自定義主題系統的時候,看文檔看得我頭暈😵‍💫。加上它大包大攬的風格,讓我切換到了 Unistyles

它的主題系統就是普通的對象,我只需要將我十分喜歡的 Radix Color 傳遞給它就好。和 Tailwind 的配色不同的是,它為每個顏色都設計了對應的深色,支持深色主題變得十分簡單。

export const lightTheme = {
  colors: {
    ...accent,
    ...accentA,
  },
} as const

export const darkTheme = {
  colors: {
    ...accentDark,
    ...accentDarkA,
  },
} as const

因為要傳遞的顏色還比較多,容易忘記在對應的深色主題也添加上對應的主題,可以通過類型檢查來進行約束。參考 How to test your types 一文。

type Expect<T extends true> = T
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false
type _ExpectLightAndDarkThemesHaveSameKeys = Expect<Equal<
  keyof typeof lightTheme.colors,
  keyof typeof darkTheme.colors
>>

此外,你可以利用它的運行時來輕鬆修改主題,那麼寫像下面這樣的動態主題切換就十分簡單了。

UnistylesRuntime.updateTheme(
  UnistylesRuntime.themeName,
  oldTheme => ({
    ...oldTheme,
    colors: {
      ...oldTheme.colors,
      ...accent,
      ...accentA,
      ...accentDark,
      ...accentDarkA,
    },
  }),
)

Local First#

如果說是寫網頁的話,不做 Local First 還情有所原。APP 作為可以跑 SQLite 的環境,沒什麼理由不能在無網的環境中打開。目前我的想法是 APP 主要和本地的數據庫進行交互,利用網絡請求來進行數據的同步。

關於技術棧的選型,毫不猶豫的就選擇了 drizzle,原因有如下幾點:

  1. 目前 Follow 的 server 端也在用,我甚至能 copy 很多表的定義。
  2. 比起 Prisma 這種利用代碼生成來做類型的庫,我還是更喜歡用 ts 來寫表定義,讓類型即時刷新。
  3. Expo 官方文檔 推薦的和 Expo SQLite 的整合就是 drizzle,Prisma 的集成 還處在 Early Access 階段。

Expo SQLite 提供了 addDatabaseChangeListener 的接口,使得我們可以實時獲得數據庫中最新的數據,drizzle 就提供了 useLiveQuery 的封裝。不過目前它的 hook 存在沒有正確處理 useEffect 依賴數組的問題:

此外,我們還需要對結果進行緩存,否則來會切換頁面時會有很多不必要的數據庫查詢。所以,我們自己利用 swr 來包裝一個 hook。

import { is, SQL, Subquery } from 'drizzle-orm'
import type { AnySQLiteSelect } from 'drizzle-orm/sqlite-core'
import { getTableConfig, getViewConfig, SQLiteTable, SQLiteView } from 'drizzle-orm/sqlite-core'
import { SQLiteRelationalQuery } from 'drizzle-orm/sqlite-core/query-builders/query'
import { addDatabaseChangeListener } from 'expo-sqlite/next'
import type { Key } from 'swr'
import type { SWRSubscriptionOptions } from 'swr/subscription'
import useSWRSubscription from 'swr/subscription'

export function useQuerySubscription<
  T extends
  | Pick<AnySQLiteSelect, '_' | 'then'>
  | SQLiteRelationalQuery<'sync', unknown>,
  SWRSubKey extends Key,
>(
  query: T,
  key: SWRSubKey,
) {
  function subscribe(_key: SWRSubKey, { next }: SWRSubscriptionOptions<Awaited<T>, any>) {
    const entity = is(query, SQLiteRelationalQuery)
    // @ts-expect-error
      ? query.table
      // @ts-expect-error
      : (query as AnySQLiteSelect).config.table

    if (is(entity, Subquery) || is(entity, SQL)) {
      next(new Error('Selecting from subqueries and SQL are not supported in useQuerySubscription'))
      return
    }

    query.then((data) => { next(undefined, data) })
      .catch((error) => { next(error) })

    let listener: ReturnType<typeof addDatabaseChangeListener> | undefined

    if (is(entity, SQLiteTable) || is(entity, SQLiteView)) {
      const config = is(entity, SQLiteTable) ? getTableConfig(entity) : getViewConfig(entity)

      let queryTimeout: NodeJS.Timeout | undefined
      listener = addDatabaseChangeListener(({ tableName }) => {
        if (config.name === tableName) {
          if (queryTimeout) {
            clearTimeout(queryTimeout)
          }
          queryTimeout = setTimeout(() => {
            query.then((data) => { next(undefined, data) })
              .catch((error) => { next(error) })
          }, 0)
        }
      })
    }

    return () => {
      listener?.remove()
    }
  }

  return useSWRSubscription<Awaited<T>, any, SWRSubKey>(
    key,
    subscribe as any,
  )
}

注意這裡的 queryTimeout !!!。由於表變化可能十分頻繁,我們需要取消掉之前的查詢,否則會影響查詢的效率。Drizzle 還不支持用 AbortSignal 來取消查詢,所以用 setTimeout 來處理。

https://github.com/drizzle-team/drizzle-orm/issues/1602

OK,這樣我們只要在請求數據的時候正確地設置 key,就能高效地獲取最新的數據了。配合下拉刷新和定時同步數據,我們的 APP 就能夠實現基本的 Local First 了。

最後一起看看它現在的樣子!

分享你寫的 iOS App#

這篇筆記是記錄我在分發 App 給到別人測試的過程中踩的坑,希望能讓你少踩一次,當然前提你需要有 Apple Developer 的賬號。

參考 Expo 的 Share pre-release versions of your app 一文,你有以下三種方式分享你 App 的預覽版。

  1. Internal distribution
  2. TestFlight 內部測試
  3. TestFlight 外部測試

內部分發#

  • 通過內部分發的方式,每台測試設備需要使用臨時的配置文件,並且每年只能使用此方法分發至最多 100 部 iPhone。
  • 臨時配置文件的需要需要獲取設備的 UDID。要麼你需要讓用戶自己通過 Mac Xcode 連接來獲取,要麼需要通過安裝配置文件來獲取(你需要建立和測試者之間的信任)。
  • 每次註冊測試設備到 Apple,你都需要等待 Apple 來處理,這可能會花上一天的時間。
  • 每次註冊完新的設備,你都需要重新進行 build。
  • 這種方式分發的應用需要用戶在手機上開啟開發者模式。

綜上,這種方式只適用於很小範圍內的內部測試。

TestFlight 內部測試#

TestFlight 內部測試需要你為測試者分配你的 Apple Developer 賬號權限,它不需要將你的 App 提交審核。所以它同樣只適用於小範圍的內部測試。

TestFlight 外部測試#

TestFlight 外部測試可以以多種方式來分發你的 App 到用戶,比如通過郵箱添加或是鏈接添加,這也是最常見的外部測試方式。

它的要求是你需要提交 App 到審核,提交時還顯示需要你提供用於測試人員測試的賬號,但實際上你可以忽略提交這個信息。據我提交的體驗來說,首次提交會需要一天的時間,但也不會不讓通過。後面的審核都是即時通過的機審,很方便。

順便一提,填聯系信息時,手機號的報錯並不正確,你只是需要添加上 +86。

總結#

在你想要分享你寫的 App 給別人使用時,我推薦你首先嘗試 TestFlight 外部測試來分發,即使你還沒準備好審核。如果首次審核直接過的話,那就皆大歡喜了。

使用 expo 和 eas 來構建並提交 App 十分方便,你只需要:

npx eas build --profile production --local
npx eas submit -p ios

當然,別忘了更新你的 eas 配置:

{
  "cli": {
    "appVersionSource": "remote"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal"
    },
    "preview": {
      "distribution": "internal"
    },
    "production": {
      "autoIncrement": true
    }
  },
  "submit": {
    "production": {
      "ios": {
        "ascAppId": "123456"
      }
    }
  }
}
載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。