壁紙アプリを iPhone 15 Pro で触っていて、最初に気づいたのは「スクロールがどうも 60fps っぽい」という感覚的な違和感でした。同じ iPhone でも Safari は 120Hz でぬるりと動くのに、自分のアプリだけが半テンポ遅い。手元に 6 本の壁紙アプリがある状況で、せっかく購入された ProMotion ディスプレイを活かしきれていないことが気持ち悪く、2 週間ほど腰を据えて取り組みました。
両家の祖父が宮大工で、組み上げが丁寧かどうかは「触ったときの違和感」で先に分かるという感覚を幼少から見てきました。スクロールの引っかかりもそれと近くて、数値で説明できる前に体が気づく類の話です。Rork で生成した React Native アプリに後から 120Hz 駆動を行き渡らせるまでの実装メモを、6 壁紙アプリ並行運用の現場目線で残しておきます。
ProMotion が効かない壁紙アプリで感じた違和感の正体
廣川政樹です。2014 年から個人で iOS / Android アプリを開発しており、壁紙ジャンルだけで累計 5,000 万ダウンロードを超えるストックを抱えています。新規インストールの約 4 割は iPhone 15 Pro 以上の ProMotion 対応モデルからきており、AdMob ダッシュボードを覗くと、この端末層は eCPM が他より 1.3〜1.5 倍ほど高く、平均セッション時間も長めに出ます。
つまり 120Hz 対応の手抜きは、最も支払い能力の高い端末層に対して粗い体験を出していることに等しいわけです。指で 5,000 枚を超える壁紙サムネイルをすべる動作は、このアプリで最も頻度の高い操作で、ここが 60fps と 120fps では「気持ちよさ」に決定的な差が出ます。
最初に Xcode Instruments の Display 計測を回したとき、ピーク描画は 120Hz に届いているものの、UIScrollView 側が頑なに 60Hz でクランプされていることが分かりました。これは Apple が 2022 年以降の iOS で導入した「アプリ側が明示的にオプトインしない限り、CADisplayLink の最小フレーム持続時間を 60Hz に縛る」挙動です。本番運用しているアプリの 6 本すべてがこの罠にハマっていたのは、Rork で生成した Expo の prebuild が Info.plist のキーまでは面倒を見てくれないからです。
まず Info.plist の CADisableMinimumFrameDurationOnPhone を確認する
ProMotion を有効化する第一歩は、iOS アプリの Info.plist に以下のキーを追加することです。Apple のドキュメントでも「ProMotion を使うアプリは明示的に opt-in せよ」と明記されています。
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
Rork で生成したプロジェクトはネイティブコードをそのまま編集できますが、私の場合は EAS Build で CI に乗せている都合上、Expo の config plugin で注入する方を推奨します。手書きで Info.plist を編集すると、Rork が次回 sync したときに上書きされ、6 本のアプリで個別に直す手間が積み上がるからです。
// plugins/withProMotion.ts
import {
ConfigPlugin,
withInfoPlist,
} from "@expo/config-plugins";
const withProMotion: ConfigPlugin = (config) => {
return withInfoPlist(config, (cfg) => {
cfg.modResults.CADisableMinimumFrameDurationOnPhone = true;
return cfg;
});
};
export default withProMotion;
app.json で "plugins": ["./plugins/withProMotion"] を読み込ませれば、expo prebuild --clean のたびに Info.plist に再注入されます。私の場合、6 壁紙アプリの monorepo で共通プラグインに昇格させて、配布忘れを防いでいます。
注意点として、シミュレータでは 120Hz 駆動を確認できません。M シリーズの Mac であっても CADisplayLink の preferredFrameRateRange.preferred は 60 を返してきます。検証は必ず ProMotion 対応の実機で行ってください。私は iPhone 15 Pro と iPhone 16 Pro Max を 1 台ずつ手元に置いて切り分けています。
CADisplayLink で実測 120Hz の境界条件を確かめる
Info.plist に opt-in しても、無条件で全画面が 120Hz になるわけではありません。Apple のフレームスケジューラは「直近の入力がある」「描画が変化している」「電力的に許容される」という 3 条件で動的に上限を引き下げます。だからこそ「効いているか」を計測する CADisplayLink ベースの簡易プローブを最初に置いておくのが現実的です。
// RNProMotionProbe.swift
import UIKit
@objc(RNProMotionProbe)
final class RNProMotionProbe: NSObject {
private var link: CADisplayLink?
private var lastTimestamp: CFTimeInterval = 0
private var intervals: [CFTimeInterval] = []
@objc func start() {
let displayLink = CADisplayLink(target: self, selector: #selector(tick(_:)))
displayLink.preferredFrameRateRange = CAFrameRateRange(
minimum: 80, maximum: 120, preferred: 120
)
displayLink.add(to: .main, forMode: .common)
self.link = displayLink
}
@objc private func tick(_ link: CADisplayLink) {
if lastTimestamp > 0 {
intervals.append(link.timestamp - lastTimestamp)
}
lastTimestamp = link.timestamp
if intervals.count >= 120 {
let avg = intervals.reduce(0, +) / Double(intervals.count)
print("ProMotion avg interval: \(avg * 1000) ms => \(1.0 / avg) fps")
intervals.removeAll()
}
}
}
React Native 側からは TurboModule 化した呼び出しを 1 行で叩くだけで、開発ビルドのコンソールに 8.34 ms => 120 fps のような行が流れます。Info.plist が正しく入っていれば 120fps、入っていなければ頑なに 60fps です。本番運用しているアプリの 6 本のうち、最初は 0 本が正しく 120Hz に到達していませんでした。
Reanimated v3 worklets で 60fps を 120fps に引き上げたリファクタ
Info.plist だけで描画ハードウェアは 120Hz を受け付けてくれますが、JS 側の更新サイクルが 16ms に最適化されたままだと、結局アニメーション中は半分のフレームが「同じ値で再描画」されるだけです。これでは ProMotion を体感できません。
私は Reanimated v3 の useFrameCallback を使って、worklet をフレームレート可変で回せる構造に書き換えました。壁紙プレビューのピンチイン / アウトと、サムネイル一覧のスクロール慣性が主な対象です。
// hooks/useProMotionScale.ts
import {
useFrameCallback,
useSharedValue,
useAnimatedStyle,
withTiming,
Easing,
} from "react-native-reanimated";
export const useProMotionScale = (target: number) => {
const scale = useSharedValue(1);
const lastTs = useSharedValue(0);
useFrameCallback((frame) => {
"worklet";
const dt = lastTs.value ? frame.timeSinceFirstFrame - lastTs.value : 0;
lastTs.value = frame.timeSinceFirstFrame;
const speed = 120 / 1000; // 120Hz 想定
const step = dt * speed * 0.01;
if (Math.abs(scale.value - target) < step) {
scale.value = target;
return;
}
scale.value = scale.value < target
? scale.value + step
: scale.value - step;
});
const style = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return style;
};
ここでのポイントは、useFrameCallback の dt を必ず使って速度を時間ベースに換算することです。withTiming だけで書くと、ProMotion 端末では同じ duration を指定しても 60fps 端末より滑らかですが「速さ」が変わって見える瞬間があります。dt ベースに直すと、60Hz / 90Hz / 120Hz いずれの端末でも同じ時間で目的値に収束させられます。
切り替え後、Xcode Instruments の Animation Hitches は 1,000 フレームあたり 6.2 件から 0.4 件まで落ちました。約 15 倍の改善です。実機の触り心地も「Safari と並んで違和感がない」水準に到達しています。
FlashList のスクロールを 120Hz 化するときの三つの落とし穴
サムネイル一覧は Shopify の FlashList v2 を採用しています。FlashList 自体は ProMotion を阻害しませんが、運用していく過程で 3 つハマりました。
estimatedItemSize の精度が低いと、スクロール開始 200ms 程度で frame drop が出る — FlashList は最初の数枚を実測してから自身でセル高さを推定し直しますが、その瞬間に layout pass が走るためです。私は壁紙サムネイルの実寸を 1 度測ってから estimatedItemSize={196} のように固定値で渡しています。
onScroll を useAnimatedScrollHandler 経由で受けないと、JS スレッドに 8ms ごとのコールバックが押し寄せる — 120Hz 環境では onScroll の呼び出し頻度も倍になるため、無加工で useState を更新するとリストが容易に固まります。スクロール位置は SharedValue で持ち、UI スレッドだけで完結させるのが鉄則です。
- iOS の
automaticallyAdjustsScrollIndicatorInsets が 120Hz で挙動が変わる — これは Apple の不具合に近い挙動で、contentInsetAdjustmentBehavior="never" を明示しないと、スクロール開始時に縦位置が 1px だけ跳ねます。120Hz だとこの跳ねが目視できてしまうため、私は壁紙アプリ全 6 本で never に統一しました。
3 番目は本番運用に上げてからレビューで「画面が一瞬ずれる」と報告が来て気づいた落とし穴です。Crashlytics には乗らないので、TestFlight の段階で iPhone 15 Pro 以上の実機で必ず一周触ることをお勧めします。
ProMotion 化が AdMob の eCPM と離脱率に与えた影響
体感の話だけでなく数値の話もしておきます。6 壁紙アプリの中で先行して ProMotion 化を入れた 2 本のアプリで、Pre / Post の指標を 2 週間ずつ突き合わせました。
| 指標 | ProMotion 化前 | ProMotion 化後 | 変化 |
| iPhone 15 Pro 以上の eCPM | $7.20 | $8.40 | +16.7% |
| 平均セッション時間 (iOS) | 2 分 18 秒 | 2 分 44 秒 | +18.8% |
| Day 7 リテンション | 14.2% | 16.1% | +1.9pt |
| インタースティシャル離脱率 | 23.6% | 21.4% | -2.2pt |
eCPM が伸びた直接の理由は AdMob のオークションで「セッションが長いユーザー」に対する単価が引き上げられるためで、ProMotion 化そのものが効いたというより、滞在時間が伸びた結果として ARPDAU が上がったと解釈しています。アプリ単体の月間広告売上で換算すると、2 本合計で月 12 万円ほどの純増になりました。6 本すべてに展開すれば、ProMotion 対応だけで月 30 万円規模の増収余地があるという肌感です。
派手な実装ではないので「これだけのために 2 週間かけて良いのか」と最初は迷いました。ただ 12 年やってきた個人開発の経験で言うと、ストアレビューに「滑らかになった」と書かれる修正は、後からじわじわとリテンションを押し上げます。短期 KPI に出ない投資ほど、長期ストックには効きやすいです。
Android の Variable Refresh Rate と運用差分
Android 側にも触れておきます。Android 11 以降で Display#setFrameRate API が使え、Pixel 7 Pro 以上や Galaxy S22 以降では LTPO ディスプレイの 120Hz 駆動が可能です。ただし運用は iOS よりも遥かに泥臭くなります。
// MainActivity.kt
override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.attributes = window.attributes.apply {
preferredDisplayModeId = pick120HzModeId() ?: 0
}
}
}
private fun pick120HzModeId(): Int? {
val display = display ?: return null
return display.supportedModes
.filter { it.refreshRate >= 119.0f }
.maxByOrNull { it.physicalHeight }
?.modeId
}
問題は端末依存の挙動差です。Pixel は素直に従いますが、Samsung 一部端末では「アプリ実行時に 120Hz を要求しても、システムが省電力モードで 60Hz に降ろす」ことがあります。私は Firebase Remote Config で android_promotion_enabled フラグを持ち、機種別に動的にオン / オフを切り替えられるように設計しました。本番運用で Crashlytics に変な異常が出たときに、即座に該当機種だけ無効化できる安全弁です。
Variable Refresh Rate の挙動は Android 13 / 14 / 15 でも細かく変わっており、毎年「これで完成」と思っても次のメジャーで挙動が変わります。本番で 2 週間以上回したうえで、私は「iOS は静的に opt-in、Android はリモートコンフィグでフラグ化」という非対称な構成に落ち着きました。
6壁紙アプリ並行運用で固めたチェックリスト
最後に、6 本のアプリで揃えて回しているチェックリストを置いておきます。これは TestFlight ビルドに上げる前と、AdMob 影響を計測したいときの両方で使っています。
- Info.plist の
CADisableMinimumFrameDurationOnPhone=true が prebuild 後の生成物に入っているか
- Expo config plugin の登録が monorepo 共通プラグインから外れていないか
- CADisplayLink プローブが実機で 120fps 付近を返すか(90 を下回るならどこかで降格している)
- 主要画面の
useFrameCallback が dt ベースで書かれているか(duration ベースでないか)
- FlashList の
estimatedItemSize が実測値で固定されているか
useAnimatedScrollHandler で SharedValue にスクロール位置を持っているか
contentInsetAdjustmentBehavior="never" を全 ScrollView に統一できているか
- Android 側は Firebase Remote Config の
android_promotion_enabled フラグが効くか
- Crashlytics で
Display.Mode 関連の異常が出ていないか
- AdMob ダッシュボードで eCPM と平均セッション時間を Pre / Post で 2 週間突き合わせたか
私自身、最初は 5 と 6 を見落としていて、Info.plist だけ入れた 1 週間は AdMob 数値が動きませんでした。Reanimated 側のリファクタが入ってからようやく数値が動き始めたので、ハードウェアとソフトウェアの両方を opt-in しないと ProMotion の恩恵は引き出せない、というのが結論です。
離れて暮らす子どもたちに何を残せるかと考えるたび、技術を分かりやすく書き残すことの意味を再確認します。同じく壁紙ジャンルやコレクション系アプリで ProMotion 化に踏み切れずにいる個人開発者の方が、この実装メモを土台にして 6 本分の試行錯誤を 1 週間で終えられるようなら嬉しいです。
次に試したいのは Android 16 で予告されている FrameRate API の改修と、iPad の ProMotion + Stage Manager 上での挙動再評価です。手元のアプリで検証が進んだら、また続きを書き残します。