無料プレビューとして1枚だけ見せたかった有料の壁紙が、スクリーンショットで原寸のまま持ち出される。画像を主役にしたアプリを個人開発で長く運営していると、この「気軽な持ち出し」は避けて通れない悩みになります。私自身、プレビュー画質を落として対策したつもりでも、Retina 解像度のスクショは実用に十分な品質で、ほとんど無意味だったという経験があります。
ここで考えたいのは、完璧な防御ではありません。撮影そのものを技術的に封じ込めるのは、少なくとも iOS では不可能です。それでも「ワンタップで全部持っていける」状態を、「少し手間がかかる」状態に変えるだけで、漏れの大半は減らせます。ここで整理するのは、Rork が生成する Expo アプリに後付けできる、スクリーンショットと画面収録の検知・目隠しの実装です。本番運用で詰まった箇所、つまり実際の落とし穴まで含めて見ていきます。
守れるのは「気軽な持ち出し」まで、と最初に割り切る
実装に入る前に、期待値を正しく置くことが何より大事だと感じています。これは DRM ではありません。本気で画像を抜こうとする人は、別の端末で画面を物理的に撮影すれば、こちらが何をしようと止められません。
ですから目的は「悪意のない、あるいはごく軽い気持ちの持ち出しを思いとどまらせること」に絞ります。私の場合、守りたいのは収益のうちの数パーセントの漏れであって、要塞を建てることではありません。この線引きを最初にしておかないと、検知をすり抜ける方法をいくつも想像しては実装が肥大化し、個人開発の時間が溶けていきます。抑止としての費用対効果が成り立つ範囲に留める、という判断をまず固めておきます。
iOS と Android で「できること」がまるで違う
最初に混乱したのは、iOS と Android で取れる手段が対称ではない点でした。整理すると次のようになります。
やりたいこと iOS Android
撮影・録画そのものを止める 不可(OS が許可していません) 可(FLAG_SECURE )
スクショされた「事実」を受け取る 可(システム通知) 原則不可
録画・ミラーリング中を検知 可(isCaptured) 限定的
つまり Android は「そもそも撮れなくする」方向が素直で、iOS は「撮られたら気づいて反応する」方向しか選べません。この非対称を踏まえると、両プラットフォームで同じコードに寄せようとするのは筋が悪く、それぞれの土俵で別の手を打つのが結局いちばん簡潔になります。
スクリーンショットの検知は expo-screen-capture で足りる
スクリーンショットへの反応と、Android 側の撮影禁止は、公式パッケージの expo-screen-capture だけで実装できます。まずはここから入るのが堅実です。
import * as ScreenCapture from 'expo-screen-capture' ;
import { useEffect } from 'react' ;
// プレミアムなプレビューを表示する画面でだけ呼ぶ前提のフック
export function useScreenshotGuard ( onShot : () => void ) {
useEffect (() => {
// Android: 撮影・録画自体を OS レベルで止める(画面が黒くなる)
ScreenCapture. preventScreenCaptureAsync ( 'premium-preview' );
// iOS: 撮影は止められないので「撮られた事実」だけを受け取る
const sub = ScreenCapture. addScreenshotListener (() => {
onShot (); // 例: 目隠しを出す / 流出計測を1件記録する
});
return () => {
sub. remove ();
// 画面を離れたら解除。常時 FLAG_SECURE を付けっぱなしにしない
ScreenCapture. allowScreenCaptureAsync ( 'premium-preview' );
};
}, [onShot]);
}
ポイントは、preventScreenCaptureAsync をプレビュー画面の表示中だけ に限定することです。アプリ全体に常時かけてしまうと、Android では正規ユーザーが自分の設定画面を共有したいといった正当な場面まで撮れなくなり、サポート対応の負担に跳ね返ります。撮影を止めるのは、本当に守りたい画面に入ったときだけ。離れたら必ず解除する、という出し入れを徹底します。
iOS 側の addScreenshotListener は「撮られた後」にしか発火しません。つまり1枚目は防げません。そこで一般的な落とし所は、検知したら一瞬で目隠しを被せ、「保存された画像にはプレビューしか写っていない」状態を狙うのではなく、「これ以上は撮らせない」という意思表示と、流出の計測に使うことです。
録画とミラーリングは公式パッケージでは検知できない
ここが本番でいちばん詰まった部分です。expo-screen-capture はスクリーンショットには反応しますが、画面収録が始まっている最中や、外部ディスプレイへのミラーリング中をリアルタイムには教えてくれません 。録画は1枚のスクショと違い、プレビューを表示している数秒間ずっと中身を記録し続けます。静止画対策だけでは、いちばん漏れる経路が空いたままになります。
iOS にはこれを拾う仕組みが用意されています。UIScreen.main.isCaptured が現在キャプチャ中かどうかを返し、UIScreen.capturedDidChangeNotification が状態の変化を通知します。公式パッケージにこの口が無い以上、薄いネイティブモジュールを自前で足すのが現実的でした。
ネイティブモジュールで isCaptured をリアルタイムに拾う
Expo Modules API を使えば、数十行のネイティブモジュールで isCaptured の監視を JS に橋渡しできます。eject せずとも、開発ビルドの中にローカルモジュールとして同居させられます。
import ExpoModulesCore
import UIKit
public class CaptureGuardModule : Module {
public func definition () -> ModuleDefinition {
Name ( "CaptureGuard" )
// JS から購読するイベント
Events ( "onCaptureChange" )
// 監視を開始し、開始時点の isCaptured を即時に返す
Function ( "start" ) { () -> Bool in
NotificationCenter.default. addObserver (
forName : UIScreen.capturedDidChangeNotification,
object : nil ,
queue : .main
) { [ weak self ] _ in
self ? . sendEvent ( "onCaptureChange" , [
"isCaptured" : UIScreen.main.isCaptured
])
}
return UIScreen.main.isCaptured
}
}
}
start() が開始時点の値を返す ようにしている点が地味に効きます。録画を始めてからアプリを起動した、あるいはバックグラウンドから復帰した直後は、通知が飛ばないことがあります。復帰のたびに現在値を取り直すことで、「録画中なのに目隠しが外れている」取りこぼしを塞げます。私はこのバックグラウンド復帰直後の再取得を入れていなかったために、テスト中に一度すり抜けを見ています。
JS 側のラッパーとフックはこうなります。
import { useEffect, useState } from 'react' ;
import { requireNativeModule } from 'expo-modules-core' ;
const CaptureGuard = requireNativeModule ( 'CaptureGuard' );
export function useScreenCaptured () : boolean {
const [ captured , setCaptured ] = useState ( false );
useEffect (() => {
// start() は監視開始時点の値を返す(復帰直後の取りこぼし対策)
setCaptured (CaptureGuard. start ());
const sub = CaptureGuard. addListener (
'onCaptureChange' ,
( e : { isCaptured : boolean }) => setCaptured (e.isCaptured),
);
return () => sub. remove ();
}, []);
return captured;
}
JS 側はオーバーレイを状態で出し分けるだけにする
ネイティブ側で「いま録画されているか」が真偽値で取れてしまえば、React 側の仕事は驚くほど単純になります。captured が真のあいだだけ、プレビューの上にぼかしを被せる。それだけです。
import { View, Text, StyleSheet } from 'react-native' ;
import { BlurView } from 'expo-blur' ;
import { useScreenCaptured } from './useScreenCaptured' ;
export function PremiumPreview ({ children } : { children : React . ReactNode }) {
const captured = useScreenCaptured ();
return (
< View style = { StyleSheet.absoluteFill } >
{ children }
{ captured && (
< BlurView intensity = { 80 } tint = "dark" style = { StyleSheet.absoluteFill } >
< View style = { styles.center } >
< Text style = { styles.note } >
録画・ミラーリング中は、プレビューを一時的に隠しています
</ Text >
</ View >
</ BlurView >
) }
</ View >
);
}
const styles = StyleSheet. create ({
center: { flex: 1 , alignItems: 'center' , justifyContent: 'center' , padding: 24 },
note: { color: '#fff' , fontSize: 15 , textAlign: 'center' },
});
ぼかしの上に一文を添えているのは、ユーザーへの説明のためです。何の前触れもなく画面が暗転すると「バグだ」と受け取られ、低評価レビューにつながります。「いま隠しているのは仕様です」と短く伝えるだけで、印象は大きく変わります。実際、画像を守る挙動はクレームと紙一重なので、なぜ隠れているのかが一目で分かる文言を必ず添えるようにしています。
なお <View> を children の上に重ねるとき、absoluteFill の z 順だけに頼ると、リスト系コンポーネントの内部で重なり順が崩れることがあります。プレビューは専用の画面(モーダル)に切り出して、その最前面にオーバーレイを置く構成にすると安定しました。なお、アプリをバックグラウンドへ回した瞬間のスナップショット対策はこれとは別の仕組みが必要で、アプリスイッチャーのスナップショットを隠す設計 で扱っています。
Android は検知より「撮れなくする」方が素直
Android には iOS のような「スクショされた事実を受け取る」公式の口が原則ありません。代わりに FLAG_SECURE を立てれば、撮影も録画もミラーリングも OS が黒画面にして拒否します。検知して反応するより、根本から撮れなくする方が確実です。
前述の preventScreenCaptureAsync がこの FLAG_SECURE をラップしています。Android では検知オーバーレイのロジックは不要で、プレビュー画面の出入りに合わせて prevent/allow を切り替えるだけで完結します。iOS のために書いたネイティブモジュールは Android では動かしません。プラットフォームごとに別の手を使うこの割り切りが、コードをいちばん短く保ちます。
ひとつ注意したいのは、FLAG_SECURE は一部の正規機能(アクセシビリティ系のツールや一部の画面共有)にも影響することです。アプリ全体ではなく、本当に守りたい画面に限定して付け外しする原則は、ここでも同じです。
本番で効いた小さな判断
最後に、運用してみて効いたと感じる判断をいくつか残します。検知を「壁」ではなく「シグナル」として扱うことが、結局いちばん健全でした。
スクショ検知を即ブロックに使わず、まず計測に使う 。どのプレビュー画像が、どのくらいの頻度で撮られているのかが分かると、そもそも無料で見せる範囲を再設計する判断材料になります。撮られて困る画像を無料枠から外す方が、検知を強化するより収益への効きが素直でした。私はこの計測優先のやり方を強く推奨します。
目隠しは可逆にする 。録画が終われば isCaptured は偽に戻り、オーバーレイも自動で消えます。一度検知したら永続的にロックする作りにすると、誤検知のときにユーザーが詰みます。誤検知への対処として、状態に追従して自然に出入りする設計に寄せておくのが安全です。
有料導線の一部として位置づける 。プレビューが守られている体験自体が、「これは持ち出すものではなく、買って手元に置くものだ」という静かなメッセージになります。App Store 上で画像を売る立場として、私はこの「守られている感触」が購入の後押しになる場面を何度か見てきました。守りの実装が、そのまま価値の提示になっているのが理想だと考えています。認証トークンのような機密データの保存先については、expo-secure-store と生体認証ゲートの設計 も合わせて参考にしていただければと思います。
実装を始めるなら、まず expo-screen-capture でスクショ検知と Android の FLAG_SECURE を入れ、計測だけ回してみてください。録画経路の漏れが無視できないと分かった段階で、isCaptured のネイティブモジュールを足す——この順番が、個人開発の手数として最も無駄がないはずです。