第二周我简单定义了一下 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 渲染到原生组件的方案。
最后一起看看它现在的样子!