第二周我簡單定義了一下 APP 的主題(其實主要就是顏色),搭建 APP 的資料庫並利用資料構建展示介面。
主題系統#
初始化專案的時候我選的 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 了。
What's Next?#
這週我還使用 webview 展示了 feed entry 的詳情,但總覺得首次加載的時候有點慢?不知道是需要特殊的優化策略還是 webview 局限就是如此。下週看看把 html 渲染到原生組件的方案。
最後一起看看它現在的樣子!