個人開発で運営している壁紙アプリに、利用者が自分の写真をアップロードして加工できる機能を足そうとしていたときのことです。社内で完結する素材だけを並べているうちは気にしなくてよかったのですが、ユーザー投稿や AI 生成画像が混ざり始めると、不適切な画像がギャラリーに並んでしまうリスクが一気に現実味を帯びてきました。
最初はサーバ側でまとめて判定すればよいと考えていました。けれど、アップロードから配信までのわずかな間に他の利用者の画面へ出てしまう経路や、オフラインで端末内に保存した画像を再表示する経路まで考えると、サーバ判定だけでは隙間が残ります。そこで「表示する直前に、その端末の中で一度確かめる」という層を足すことにしました。その役目をちょうど担えるのが、Apple の SensitiveContentAnalysis フレームワークです。実際に組み込む過程で分かったことを、実装手順としてまとめます。
なぜ「表示前」に「端末内で」判定する必要があるのか
不適切画像対策というと、まずアップロード時のサーバ判定を思い浮かべます。それは正しいのですが、モバイルアプリには次のような取りこぼしの経路があります。
アップロード直後、サーバ判定が完了する前に、他の利用者のフィードへ流れてしまう
いったん端末にキャッシュ・保存した画像を、オフラインで再表示する
外部 URL を直接読み込むウィジェットや共有シートのプレビュー
App Store の審査ガイドライン 1.2 は、ユーザー生成コンテンツを扱うアプリに対して、不適切なコンテンツをフィルタリングする仕組みと通報導線の用意を求めています。AdMob などの広告を載せている場合、不適切な画像の隣に広告が出ること自体がポリシー上の問題にもなり得ます。つまりこれは「審査を通すため」だけでなく、収益面でも守りの一手なのです。
サーバ判定が一次防衛線だとすれば、表示直前の端末内判定は最後の関所です。私自身、この二段構えにしてから、レビューでの指摘も利用者からの通報も目に見えて落ち着きました。
SensitiveContentAnalysis フレームワークの位置づけ
SensitiveContentAnalysis は iOS 17(macOS 14)で追加された、画像と動画に露骨な性的描写が含まれるかを端末内で 判定するための公式フレームワークです。特徴を整理します。
解析はすべてオンデバイスで完結し、画像が外部へ送られることはありません。プライバシーを損なわずに済みます
判定エンジンは Apple が保守するため、自前のモデルを学習・更新する必要がありません
利用には専用のエンタイトルメント com.apple.developer.sensitivecontentanalysis.client が必要です
重要な前提として、利用者が設定で「センシティブな内容の警告(Sensitive Content Warning)」を有効にしている端末でのみ判定が動きます。無効な端末では解析ポリシーが .disabled となり、判定は行われません
最後の一点が設計上いちばん効いてきます。「この API を入れれば全端末で必ず弾ける」わけではない、という前提を最初に握っておくことが、後述するフォールバック設計につながります。
Rork Max が生成する土台と、自分で足す部分
通常の Rork は React Native(Expo)でアプリを生成しますが、Rork Max はネイティブ Swift を生成します。SensitiveContentAnalysis のような Apple 固有フレームワークは、まさに Rork Max の土俵です。
ただ、自然言語の指示から出てくるのは「画像を表示するビュー」や「アップロードのフロー」といった足場までです。私の場合、Rork Max が作ってくれたギャラリー画面に対して、
エンタイトルメントの追加
解析を呼ぶ薄いラッパーの実装
判定結果に応じた表示の出し分け
の三つを自分で足す、という責任分界に落ち着きました。生成 AI に全部任せるのではなく、「セキュリティと審査に直結する判定ロジックは自分の手で書いて読める状態にしておく」という線引きです。ここは妥協しないほうが、後からの監査も楽になります。
Swift 実装:画像と動画を判定する
中心になるのは SCSensitivityAnalyzer です。まず解析ポリシーを確認し、有効なときだけ画像を解析します。
import SensitiveContentAnalysis
enum ScreeningResult {
case sensitive // 不適切と判定
case safe // 問題なし
case unavailable // 設定無効・非対応などで判定不能
}
struct ContentScreener {
private let analyzer = SCSensitivityAnalyzer ()
/// 端末内で画像を判定する。判定不能な場合は .unavailable を返す。
func screen ( imageAt url: URL) async -> ScreeningResult {
// 利用者が設定で機能を有効にしていなければ解析は動かない
guard analyzer.analysisPolicy != .disabled else {
return .unavailable
}
do {
let response = try await analyzer. analyzeImage ( at : url)
return response.isSensitive ? .sensitive : .safe
} catch {
// 解析自体が失敗したケースも「弾けなかった」として扱う
return .unavailable
}
}
}
動画の場合は analyzeVideo(at:) を使います。フレームを抜き出して走らせるため画像より時間がかかるので、UI をブロックしないよう必ず非同期の文脈で呼びます。
extension ContentScreener {
func screen ( videoAt url: URL) async -> ScreeningResult {
guard analyzer.analysisPolicy != .disabled else { return .unavailable }
do {
let response = try await analyzer. analyzeVideo ( at : url)
return response.isSensitive ? .sensitive : .safe
} catch {
return .unavailable
}
}
}
ポイントは、例外と .disabled を「安全」に倒さず、.unavailable(=判定できなかった)として明確に区別することです。判定できなかったものを安全扱いにすると、関所をすり抜ける画像が出てしまいます。
Expo / React Native から呼ぶ:ネイティブモジュール
通常の Rork(Expo)で作ったアプリにこの判定を足したい場合は、Expo Modules API でネイティブモジュールを書き、TypeScript から呼びます。Swift 側は次のように AsyncFunction で公開します。
import ExpoModulesCore
import SensitiveContentAnalysis
public class ContentScreenerModule : Module {
private let analyzer = SCSensitivityAnalyzer ()
public func definition () -> ModuleDefinition {
Name ( "ContentScreener" )
// "sensitive" | "safe" | "unavailable" を文字列で返す
AsyncFunction ( "screenImage" ) { ( uri : String ) -> String in
guard let url = URL ( string : uri) else { return "unavailable" }
guard self .analyzer.analysisPolicy != .disabled else { return "unavailable" }
do {
let response = try await self .analyzer. analyzeImage ( at : url)
return response.isSensitive ? "sensitive" : "safe"
} catch {
return "unavailable"
}
}
}
}
TypeScript 側では、判定不能だった画像をどう扱うかをアプリ側のポリシーとして決めます。
import ContentScreener from "./modules/content-screener" ;
type Verdict = "sensitive" | "safe" | "unavailable" ;
export async function shouldShowImage ( uri : string ) : Promise < boolean > {
const verdict = ( await ContentScreener. screenImage (uri)) as Verdict ;
switch (verdict) {
case "safe" :
return true ;
case "sensitive" :
return false ; // 端末内で不適切と判定 → 表示しない
case "unavailable" :
// 判定できなかった画像はサーバ側の判定結果に委ねる
return await fallbackToServerVerdict (uri);
}
}
Android にはこの API がないため、ネイティブモジュールの Android 実装は常に "unavailable" を返し、サーバ側判定へ寄せる、という割り切りが現実的でした。
判定結果をどう扱うか — UX と運用
「不適切」と判定したあとの見せ方は、いきなり消すよりも、ぼかし+タップで開く確認、という段階を挟むほうが体験として穏やかです。
既定ではぼかしプレースホルダを表示し、「表示する」操作で初めて原画像に切り替える
各画像に通報導線を必ず添える(ガイドライン 1.2 の要件でもあります)
判定結果そのものを端末外へ保存・送信しない。あくまで表示制御のためだけに使う
運用面では、.unavailable の比率をログで眺めておくと、設定を無効にしている利用者がどのくらいいるかが見えてきます。私の場合はこの比率が思ったより高く、「端末内判定だけに頼ってはいけない」という当初の判断が正しかったと確認できました。
サーバ側モデレーションとの二段構え
最終的に落ち着いた構成は、サーバ側判定を一次防衛線、端末内判定を表示直前の関所とする二段構えです。役割の違いを整理します。
観点 サーバ側モデレーション 端末内 SensitiveContentAnalysis
判定タイミング アップロード時 表示する直前
カバー範囲 全利用者に共通で適用 設定が有効な端末のみ
プライバシー 画像がサーバへ渡る 端末内で完結し外部送信なし
オフライン表示 守れない 守れる
カスタムの判定基準 自前で調整可能 Apple のエンジンに準拠
どちらか一方ではなく、両方を持って初めて隙間が埋まります。サーバ側は通報・統計・カスタム基準に強く、端末内は表示直前とオフラインに強い。互いの弱点を補い合う関係です。新しくユーザー投稿を扱う機能を設計するときは、この二段構えを前提に組むことを推奨します。
次の一歩
まずは小さく、ギャラリーの一画面だけに端末内判定を挟み、.unavailable の比率をログに出すところから始めてみてください。実データを見れば、サーバ側にどこまで投資すべきかの判断材料が手に入ります。
なお、フレームワークの API やエンタイトルメントの扱いは Apple のアップデートで変わることがあります。組み込む前に、必ず最新の公式ドキュメントで SCSensitivityAnalyzer の仕様をご確認ください。同じようにユーザー投稿の安全性に頭を悩ませている方の、最初の一歩の参考になれば幸いです。