Rork で AI にプロンプトを投げて完成したアプリを実機で試したとき、起動直後の一瞬だけフォントが「システムのデフォルト書体」で表示され、その後にカスタムフォントに置き換わる挙動を見たことはないでしょうか。Web でよく言われる FOIT(Flash of Invisible Text)や FOUT(Flash of Unstyled Text)と同じ現象が、モバイルアプリの起動時にも起きます。
Rork のプロンプトで「Noto Sans JP を使ってください」とお願いしても、expo-font の読み込みタイミングを明示しなければ、この一瞬のチラつきが残ってしまうことが多いです。ここではexpo-font と expo-splash-screen を組み合わせて、フォント読み込みが完了するまでスプラッシュ画面を保持するという、地味ですがユーザー体験に直結する実装パターンをご紹介します。
この「一瞬のチラつき」は何が起きているのか
Rork が生成するアプリは Expo + React Native の構成ですから、JavaScript の評価とネイティブ描画の間には必ずラグがあります。カスタムフォントの場合、ざっくり以下の順序で処理が進みます。
- ネイティブ層がスプラッシュ画面を表示する
- JavaScript バンドルが読み込まれ、React ツリーが評価される
useFontsフックなどがフォントファイルを非同期で読み込む- React のルートコンポーネントが描画される
- フォント読み込みが完了し、テキストが再レンダリングされる
問題は 4 と 5 の間です。フォントが読み込まれる前にテキストが描画されると、一瞬だけ OS のシステムフォント(iOS なら San Francisco、Android なら Roboto)で表示され、その後カスタムフォントに差し替わります。ユーザーの目には「画面が一瞬瞬いた」ように見えるのです。
この問題は fontFamily をプロンプトで指示するだけでは解決しません。読み込み完了までスプラッシュを維持するという、描画タイミングそのものの制御が必要になります。
解決の全体像 — expo-splash-screen を「手動で閉じる」
Expo のデフォルトでは、スプラッシュ画面は React のルートコンポーネントがマウントされた瞬間に自動で閉じられます。これを「フォントが読み込まれるまで待ってから手動で閉じる」方式に変更すれば、チラつきは原理的に発生しません。
必要なパッケージは以下の2つです。Rork が生成したプロジェクトであれば expo-font はほぼ確実に入っているはずですが、expo-splash-screen は明示的にインストールが必要な場合があります。
# Rork プロジェクトのルートで実行
npx expo install expo-font expo-splash-screenRork のチャット上で「expo-splash-screen を使ってフォント読み込み完了まで待機する実装に書き換えてください」と依頼すると、以下に似たコードが生成されます。ただし、細部の堅牢性まで AI 任せにすると落とし穴があるため、生成後に必ず手で見直すのが私の流儀です。
最小の実装例 — _layout.tsx に集約する
Expo Router を使う Rork プロジェクトなら、フォント読み込みは app/_layout.tsx に集約するのが素直です。以下は動作確認済みのサンプルで、フォント読み込み中はスプラッシュを維持し、完了後に手動で閉じます。
// app/_layout.tsx
import { Stack } from "expo-router";
import { useFonts } from "expo-font";
import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";
import { View } from "react-native";
// 1. SplashScreen の自動非表示を止める
// これを呼ばないと、React マウント直後にスプラッシュが消えてしまう
SplashScreen.preventAutoHideAsync().catch(() => {
// 二重呼び出しや WebView 環境では無視してよい
});
export default function RootLayout() {
// 2. カスタムフォントを登録する
const [fontsLoaded, fontError] = useFonts({
"NotoSansJP-Regular": require("../assets/fonts/NotoSansJP-Regular.ttf"),
"NotoSansJP-Bold": require("../assets/fonts/NotoSansJP-Bold.ttf"),
});
// 3. フォント準備完了 or エラー時にスプラッシュを閉じる
useEffect(() => {
if (fontsLoaded || fontError) {
SplashScreen.hideAsync();
}
}, [fontsLoaded, fontError]);
// 4. 準備完了まで null を返す(描画しない)
if (!fontsLoaded && !fontError) {
return null;
}
return (
<View style={{ flex: 1 }}>
<Stack screenOptions={{ headerShown: false }} />
</View>
);
}このコードの肝は 4 の「フォントが準備できるまで null を返す」 部分です。描画そのものを遅延させることで、システムフォントで一瞬表示される隙を物理的になくしています。fontError を判定に入れているのは、万が一フォントが壊れていても、スプラッシュで固まらず画面に進めるようにするためです。
期待する動作
- 起動時: スプラッシュが通常より長めに(フォント読み込み分 + 100〜300ms 程度)表示される
- スプラッシュが消えた瞬間: 最初の画面がカスタムフォントで描画される
- フォント読み込みに失敗した場合: スプラッシュが閉じ、システムフォントで画面が表示される(クラッシュはしない)
つまずきやすいポイントと対処
1. require のパスを間違える
assets/fonts/ に置いたつもりが、実際は assets/font/ だった、というような typo は Metro のバンドル時にエラーを吐いて起動が止まります。iOS シミュレータでは Unable to resolve module と出ますので、パスを素直に見直してください。
私の経験では、フォントファイル名のハイフンとアンダースコアの差異(Noto-Sans-JP vs NotoSansJP)で詰まることが最も多いです。Google Fonts からダウンロードすると NotoSansJP-Regular.ttf の形式で来るはずですから、そのままの名前で useFonts のキーとファイル名を揃えるのが安全です。
2. fontFamily の指定を「ファイル名」にしてしまう
よくある間違いは、<Text style={{ fontFamily: "NotoSansJP-Regular.ttf" }}> のように拡張子まで書いてしまうことです。useFonts に渡したキーの文字列を使うのが正解ですから、以下のように書きます。
<Text style={{ fontFamily: "NotoSansJP-Regular", fontSize: 16 }}>
こんにちは、カスタムフォントの世界へ
</Text>Rork に「このテキストを Noto Sans JP Regular で表示してください」と日本語で指示すると、ときどき拡張子付きで生成されることがあります。生成後に grep -n "\.ttf\"" app/ のようにチェックする癖をつけると未然に防げます。
3. iOS のビルドキャッシュが古いフォントを掴む
開発中にフォントを差し替えたのに、シミュレータでは古いフォントのまま表示される、という症状に出会ったら、ほぼキャッシュが原因です。以下の順番で試すと、だいたい解消します。
- Metro キャッシュをクリア:
npx expo start --clear - シミュレータのホーム画面からアプリを一度削除して再インストール
ios/buildとandroid/buildを削除して再ビルド(Bare workflow の場合)
4. スプラッシュが永遠に消えない
useFonts が [false, undefined] のまま止まっている、というケースです。原因の9割はフォントファイルのパスが解決できず、expo-font が内部でエラーを投げて fontError に入っているのに、useEffect の条件で拾えていないパターンです。
開発中は一時的に以下を入れて、どちらの経路で抜けたかログを確認するのがおすすめです。
useEffect(() => {
if (fontsLoaded) console.log("[Font] loaded OK");
if (fontError) console.log("[Font] error:", fontError);
if (fontsLoaded || fontError) {
SplashScreen.hideAsync();
}
}, [fontsLoaded, fontError]);Rork のプロンプトで指示する場合のコツ
Rork のチャットに「カスタムフォントを使いたい」と伝えるときは、以下の4点を1つのプロンプトに含めると、ほぼ一発で上記のパターンを生成してくれます。
- 使いたいフォントファミリー名と weight(例: Noto Sans JP の Regular と Bold)
- フォントファイルの配置場所(例:
assets/fonts/) expo-splash-screenでフォント読み込み完了まで待機したい旨_layout.tsxに集約してほしい旨
私がよく使うプロンプトの雛形はこんな形です。
Noto Sans JP の Regular と Bold を
assets/fonts/に置いたので、app/_layout.tsxでそれらをuseFontsで読み込み、expo-splash-screenを使って読み込み完了までスプラッシュを維持してください。フォント読み込み前はnullを返して描画を遅延させ、FOUT を防ぎたいです。
生成結果が気に入らなければ、「fontError を条件に含めてください」「preventAutoHideAsync のエラーは無視してください」など、差分だけを短く追加すると精度が上がります。Rork との対話は、一度に全部伝えるよりも「足りない所を小さく足していく」のが私の好みです。
Android で追加で気をつけたいこと
iOS は Metro がフォントをバンドルしてくれますが、Android の一部の端末(特に中華系のベンダーで独自のカスタム ROM を載せているモデル)では、fontFamily の解決が不安定なケースに遭遇したことがあります。その場合は、Text コンポーネントをラップする共通コンポーネントを作り、allowFontScaling と fontFamily を明示的に指定すると安定しやすいです。
import { Text, TextProps } from "react-native";
export function AppText(props: TextProps) {
return (
<Text
allowFontScaling={true}
{...props}
style={[
{ fontFamily: "NotoSansJP-Regular", color: "#222" },
props.style,
]}
/>
);
}全画面で <Text> を直接使わず <AppText> 経由にしておくと、後からフォントを差し替えたくなったときも1箇所の修正で済みます。
5. 開発ビルドでは正しいが、EAS のプロダクションビルドでシステムフォントに戻る
開発時には狙ったフォントで表示されるのに、EAS でビルドしたバイナリだけシステムフォントに戻ってしまうことがあります。経験上、以下のどれかに当てはまるケースがほとんどでした。
- フォントファイルが
assets/fonts/ではなく、独自のパスに置かれている app.jsonまたはapp.config.jsのassetBundlePatternsでフォントフォルダを除外してしまっている.gitignoreでassets/fonts/*.ttfを除外してしまい、CI のビルド時にフォントが存在しない
私は一度、フォントライセンスチェックのスクリプトを走らせた際に .gitignore にフォントを追加してしまい、半日溶かしたことがあります。git ls-files assets/fonts/ で手元にリストが出るか、最初に確認するのが一番早い予防策です。
CJK フォントで気になるバンドルサイズを減らす
Noto Sans JP は美しいフォントですが、Regular 単体でも約 5 MB あります。Regular と Bold を両方バンドルすると 10 MB 近く増え、20〜30 MB 規模のアプリではインストールサイズに無視できない影響が出ます。
私が現場でよく使う対処は2つです。
グリフのサブセット化: 使うグリフが限られている場合(例: 特定のテンプレート文言しか出てこないアプリ)、fonttools の pyftsubset で使うグリフだけに絞ったフォントを作れます。大きさが10分の1程度になることも珍しくありません。
# 使う Unicode のリストを作成したうえでサブセット化
pip install fonttools
pyftsubset NotoSansJP-Regular.ttf --unicodes-file=used_unicodes.txt --output-file=NotoSansJP-Regular.subset.ttf差し替えるだけでバンドルが劇的に軽くなります。ただし、UI コピーを追加するたびにサブセットを作り直す必要がある点だけは覚えておいてください。
遅延読み込み(Lazy load): たまにしか開かれない画面だけに使うフォントなら、CDN から起動後にダウンロードする手もあります。expo-font は URL 指定の Font.loadAsync({ Name: { uri: "https://..." } }) をサポートしているので、初期バンドルを軽く保ちながら必要な時だけフォントを追加できます。
複数プロジェクトで私が落ち着いた小さなお作法
Rork のプロジェクトをいくつか公開するうちに、フォント周りで「これは守っておくと未来の自分が楽」というお作法が固まってきたので、参考までにご紹介します。
- カスタムフォントは
assets/fonts/に平置き(サブフォルダを作らない) - Weight は Regular と Bold の2種類に絞る(デザイン要件が明確でない限り、増やすとバンドルだけ膨らむ)
Textは最初からAppTextにラップしておく(たとえ最初は props をそのまま流すだけでも、後で一括変更するときに救われる)- フォント読み込みのロジックは
_layout.tsx一箇所に集約(各画面でuseFontsを呼ぶと、ある日必ずどこかでガードを忘れる)
次の一歩
まずは手元の Rork プロジェクトで、ここに載せた _layout.tsx を貼り替えて、起動時のチラつきが本当に消えるかをシミュレータで確認してみてください。効果の違いは一目でわかるはずです。そのうえで、アプリ全体のタイポグラフィをカスタムフォントに統一していくと、Rork が生成した「それっぽい」UI が、自分のブランドらしいアプリへと変わっていきます。
Rork の UI 品質を底上げする周辺テクニックは、Rork 生成アプリの『安っぽさ』を消すプロンプト や Rork アニメーション実装ガイド でも掘り下げていますので、あわせて読んでいただけると嬉しいです。