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#

さて、無駄話はこれくらいにして、最初のアプリを走らせてみましょう。しかし、「工欲善其事、必先利其器」というように、まずは環境を整えましょう。

一般的には 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. コマンドラインで使用している beta 版 Xcode を選択する必要があります。xcode-select -s /Applications/Xcode-beta.app

次に、素晴らしいスキャフォールドが必要です。rn の技術スタックにはあまり詳しくないので、State of React Nativeを見た後、Twitter で見かけたCreate Expo Stackを選びました。

これは Expo プロジェクトのスキャフォールドとしてだけでなく、多くの主流技術スタックの組み合わせオプションも提供してくれるので、アプリを書き始めるのに非常に便利です。最終的に選んだ組み合わせは:

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 ファイル内のUIUserInterfaceStyleキーの値がAutomaticになっていることを確認する必要があります。

本番開始#

さて、Follow アプリを書いてみましょう!

アカウントにログイン#

Expo のドキュメントには非常に詳細なAuthentication 接続ドキュメントがありますが、私たちはそれを使用する必要はありません。Follow のウェブ版ですでに処理されているので、ウェブ版のログインを呼び出し、アプリにウェブログイン後にリダイレクトされるスキームリンクを登録するだけで済みます。

まず、アプリのスキームを設定します。アプリの設定でscheme: 'follow'を設定し、次にexpo prebuildを実行します。

expo-web-browserを使用して Follow のログインページを開きます:

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

次に、expo-linkingを使用して URL のリスニングイベントを登録し、ログインウェブページから呼び出された URL 情報を受け取った後、トークンを解析します。

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
}

これで、ユーザーの認証情報を取得できるようになりました。API を呼び出してみましょう。

ユーザー情報の取得#

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'を設定しないと、2 回目のリクエスト時に不正な 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,
    },
  }),
)

ローカルファースト#

ウェブを書く場合、ローカルファーストを行わないのは理解できますが、アプリは SQLite を実行できる環境であるため、オフライン環境で開くことができない理由はありません。現在の私の考えは、アプリが主にローカルデータベースと対話し、ネットワークリクエストを利用してデータを同期することです。

技術スタックの選定については、迷わず drizzle を選びました。その理由は以下の通りです:

  1. 現在 Follow のサーバー側でも使用しており、多くのテーブル定義をコピーできます。
  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

これで、データをリクエストする際に正しく key を設定すれば、最新のデータを効率的に取得できるようになります。プルダウンリフレッシュと定期的なデータ同期を組み合わせることで、私たちのアプリは基本的なローカルファーストを実現できます。

最後に、現在の様子を見てみましょう!

あなたが書いた iOS アプリを共有する#

このノートは、アプリを他の人にテスト用に配布する過程で私が遭遇した問題を記録したもので、あなたが同じ問題を避けられることを願っています。もちろん、前提として Apple Developer アカウントが必要です。

Expo のアプリのプレビュー版を共有するを参考にすると、アプリのプレビュー版を共有する方法は以下の 3 つです。

  1. 内部配布
  2. TestFlight 内部テスト
  3. TestFlight 外部テスト

内部配布#

  • 内部配布の方法では、各テストデバイスは一時的なプロビジョニングプロファイルを使用する必要があり、毎年最大 100 台の iPhone にしか配布できません。
  • 一時的なプロビジョニングプロファイルを取得するには、デバイスの UDID が必要です。ユーザーに Mac の Xcode を使って取得してもらうか、プロビジョニングプロファイルをインストールして取得する必要があります(ユーザーとの信頼関係を築く必要があります)。
  • テストデバイスを Apple に登録するたびに、Apple が処理するのを待つ必要があり、これには 1 日かかることがあります。
  • 新しいデバイスを登録するたびに、再度ビルドを行う必要があります。
  • この方法で配布されたアプリは、ユーザーが携帯電話で開発者モードを有効にする必要があります。

以上のことから、この方法は非常に限られた範囲での内部テストにのみ適しています。

TestFlight 内部テスト#

TestFlight 内部テストでは、テスターに Apple Developer アカウントの権限を割り当てる必要がありますが、アプリを審査に提出する必要はありません。したがって、これも小規模な内部テストにのみ適しています。

TestFlight 外部テスト#

TestFlight 外部テストでは、メールアドレスの追加やリンクの追加など、さまざまな方法でアプリをユーザーに配布できます。これが最も一般的な外部テストの方法です。

この方法では、アプリを審査に提出する必要があり、提出時にテスト担当者用のアカウントを提供する必要があると表示されますが、実際にはこの情報を提出する必要はありません。私の提出体験によれば、最初の提出には 1 日かかることがありますが、通過しないことはありません。その後の審査は即時に通過する機械審査であり、非常に便利です。

ちなみに、連絡先情報を入力する際、電話番号のエラーは正しくありません。単に + 86 を追加する必要があります。

まとめ#

あなたが書いたアプリを他の人に使用してもらいたい場合、まず TestFlight 外部テストを試して配布することをお勧めします。たとえまだ審査の準備ができていなくても、最初の審査が直接通過すれば、皆が喜ぶことになります。

expo と eas を使用してアプリを構築し、提出するのは非常に便利です。次のコマンドを実行するだけで済みます:

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"
      }
    }
  }
}
読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。