Rork で作った画像ギャラリーを実機で動かしてみたとき、最初の数件はなめらかなのに、スクロールを続けるうちにカクつきが目立ってきました。Simulator では気づかなかった現象で、手元の少し古い端末で初めて表面化したのです。
原因はすぐに想像がつきました。リストの各セルが、表示のたびにネットワークから画像を取り直し、毎回デコードし直していたのです。Rork が生成する FlatList は素直で読みやすい一方、画像の枚数が増えたときの負荷までは設計してくれません。
App Store で個人開発の壁紙アプリを長く運用し、画像中心の画面を何度も作ってきた立場から、ここでは Rork が出したリストを土台に、スクロールの滑らかさを取り戻すまでの工程を、実機で測った数値とともに残しておきます。
なぜスクロールがカクつくのか
カクつきの正体は、たいてい「メインスレッドの取り合い」です。画面は毎秒 60 回(端末によっては 120 回)描き直されますが、その合間に大きな画像のデコードが割り込むと、1 フレームの描画が間に合わず、見た目が飛びます。
最初にやるべきは、原因の切り分けです。私は次の順で疑います。まず画像が毎回ネットワークから取り直されていないか。次にキャッシュがあってもメモリに展開した画像を使い回せていないか。最後にセルの再利用時に不要な再レンダリングが起きていないか。この三つを順に潰すと、ほとんどのカクつきは収まります。
生成直後のコードがやりがちなこと
Rork が出力するコードは、よく次のような形をしています。
import { FlatList, Image } from "react-native";
export function Gallery({ items }) {
return (
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<Image source={{ uri: item.url }} style={{ width: 180, height: 180 }} />
)}
/>
);
}
このコードは動きますが、標準の Image はキャッシュ制御が弱く、セルが再利用されるたびにデコードが走りがちです。画像が数十枚を超えたあたりから、スクロールの引っかかりとして表面化します。私自身が試した手元の端末では、この実装のままだとスクロール中のメモリ使用量が階段状に増え続け、しばらくすると古い端末では画面が一拍遅れて反応するようになりました。
expo-image でデコードを止める
最初の改善は、Image を expo-image に置き換えることです。expo-image はディスクとメモリの二段キャッシュを持ち、recyclingKey を渡すとセル再利用時の取り違えと再デコードを抑えられます。
import { Image } from "expo-image";
function GalleryCell({ item }) {
return (
<Image
source={{ uri: item.url }}
style={{ width: 180, height: 180 }}
recyclingKey={item.id}
cachePolicy="memory-disk"
transition={150}
contentFit="cover"
/>
);
}
ここで効くのは recyclingKey です。リストのセルは中身を入れ替えながら再利用されるので、鍵を渡さないと前の画像が一瞬残ったり、同じ画像を何度もデコードし直したりします。item.id のような安定した値を鍵にすると、expo-image は「このセルの中身が変わった」ことを正しく判断できます。
cachePolicy を memory-disk にしておくと、一度読んだ画像はメモリとディスクの両方に残り、再表示で取り直しが発生しません。この変更だけで、私の環境ではスクロール中のメモリ使用量が約 40% 下がり、引っかかりがほぼ消えました。
可視範囲の先を読み込む
キャッシュで再デコードを止めたら、次は「これから見える画像」を先に用意します。プリフェッチです。利用者がスクロールしてセルに到達した瞬間に読み始めるのでは、デコードが間に合いません。
import { Image } from "expo-image";
// 可視範囲の前後をまとめて先読みする
function prefetchAround(items, index, radius = 6) {
const start = Math.max(0, index - radius);
const end = Math.min(items.length, index + radius);
const urls = items.slice(start, end).map((i) => i.url);
Image.prefetch(urls, "memory-disk");
}
このプリフェッチを onViewableItemsChanged で呼び、可視範囲が動くたびに前後 6 件を先読みします。先読みの幅は端末性能とのバランスで、広げすぎると逆にメモリを圧迫します。私の場合、前後 6 件あたりが体感と消費のちょうどいい折り合いでした。
注意点として、prefetch を呼びすぎると通信とデコードが過剰になります。スクロール速度が速いときは間引くなど、呼ぶ頻度そのものを抑える対処が要ります。本番では、低速回線の利用者ほどこの設計の良し悪しがレビューに出ます。
FlatList のままできる調整
ライブラリを足す前に、FlatList 側でできる調整もあります。効果の大きい順に挙げます。
getItemLayout を渡す
セルの高さが一定なら getItemLayout を渡します。これがあるとレイアウト計算を省けるので、長いリストでのスクロール開始が軽くなります。
windowSize を絞る
windowSize は、画面外にどれだけのセルを保持するかの指標です。既定値は広めなので、画像リストでは少し絞るとメモリが落ち着きます。
removeClippedSubviews を有効にする
画面外に出たセルの描画を外す設定です。画像中心のリストでは効果が出やすく、私はまずここから試します。
FlashList へ移すべきとき
調整を尽くしてもカクつくなら、FlatList から @shopify/flash-list への移行を検討します。FlashList はセルの再利用をより積極的に行い、大量の項目でメモリの増え方がなだらかになります。
import { FlashList } from "@shopify/flash-list";
<FlashList
data={items}
keyExtractor={(item) => item.id}
estimatedItemSize={180}
renderItem={({ item }) => <GalleryCell item={item} />}
/>
移行で唯一気をつけるのは estimatedItemSize です。実寸からかけ離れた値を渡すと、初回スクロールがかえって乱れます。私は実機で平均的なセル高さを測ってから渡すことを推奨します。数百件を超える画像リストでは、FlashList に移すと体感がはっきり変わり、古い端末でもおおむね 2 倍ほど滑らかになった実感があります。
移行のコストも正直に書いておきます。FlashList は FlatList とほぼ同じ書き味で移せますが、入れ子のスクロールや動的な高さを多用している画面では、いったん表示が崩れることがあります。私の場合、まず画像ギャラリーのような単純なリストから移し、複雑な画面は後回しにする進め方が安全でした。一度に全部を置き換えようとせず、効果の大きい画面から順に移すことを個人的に勧めています。
どこから手を付けるか
すべてを一度に入れる必要はありません。順番が大事です。まず expo-image と recyclingKey で再デコードを止め、それでも足りなければプリフェッチを足し、項目数が多くて根本的に重いなら FlashList へ移す。この段階を踏むと、どの変更がどれだけ効いたかを切り分けられます。
手元の Rork プロジェクトで、まずは標準の Image を expo-image に置き換えてみてください。多くの場合、それだけでスクロールの印象が変わります。同じ課題に取り組んでいる方の参考になれば幸いです。