為什麼要寫 rn#
一個是我自己還沒正兒八經地寫過 rn,想試試它的體驗怎麼樣。加上最近對 Follow 這個 RSS 閱讀器很感興趣,但是它暫時還沒移動端,可以作為我邊學習邊實踐的對象。再有就是最近開始上班了,自己老是沒什麼動力在下班後寫點想寫的代碼,有個目標更容易讓自己專注。
準備工作#
hello world#
好了,廢話不多說,讓我們跑起來第一個 app 吧。不過正所謂 “工欲善其事,必先利其器”,我們先準備好環境。
一般來說你只需要安裝好 Xcode 就行了,不過如果你像我一樣,最近升級了 macOS beta 的話,就會麻煩一些:
- App Store 裡的 Xcode 是不能打開的,和系統版本不匹配。
- 下載完的 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 - 命令行中需要 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,原因有如下幾點:
- 目前 Follow 的 server 端也在用,我甚至能 copy 很多表的定義。
- 比起 Prisma 這種利用代碼生成來做類型的庫,我還是更喜歡用 ts 來寫表定義,讓類型即時刷新。
- 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 的預覽版。
- Internal distribution
- TestFlight 內部測試
- 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"
}
}
}
}