3つ目に作ったアプリで、はじめてオンボーディング画面を5枚に増やしてみたことがあります。離脱データを見て愕然としました。3枚目で約半数が消え、最終画面まで到達したのは2割を切っていたのです。最初の体験で全部を伝えようとした結果、何ひとつ伝わっていませんでした。
アーティスト・クリエイターの廣川政樹です。Rork Lab を運営しながら、Rork Max でアプリを作り続けています。今回は、その失敗から学んで切り替えた「使いながら気づかせる」設計、つまり Apple の TipKit を Rork Max のアプリに組み込む方法を、本番運用に耐えるレベルまで掘り下げて共有します。最初に断っておくと、TipKit は単なる「ツールチップを出すフレームワーク」ではありません。表示条件・頻度制御・同期・計測まで含めた一つの設計言語として捉えると、リテンション数値の景色が変わります。
なぜ今 TipKit なのか — オンボーディング画面の限界
iOS 17 で導入された TipKit は、ユーザーが「その機能に近づいた瞬間」にだけヒントを出すための公式フレームワークです。従来のオンボーディング画面の最大の問題は、ユーザーがまだ何も操作していない段階で、説明だけを浴びせてしまう点にありました。文脈がないので、いくら丁寧な文章を書いても記憶に残らないのです。
TipKit の発想は逆向きです。「フィルター機能は、検索結果が出てから初めて意味を持つ」「ウィジェットの追加方法は、ホーム画面に戻った直後にだけ意味がある」というように、機能と接触する直前または直後にだけ短い説明を差し込みます。私の手元のデータでも、オンボーディング画面 5 枚のときの機能発見率と比べて、TipKit を中心にしたアプリ内ガイダンスに切り替えた後では特定機能のアクティブ利用率が 1.7 倍前後まで改善しました。
もう一つ無視できないのが、@AppStorage や独自のフラグ管理を一切書かなくて済むことです。「7日間に最大3回」「1度ボタンを押したら永久に出さない」「他のヒントが出ていない時だけ表示」といった制御はすべて TipKit のルールエンジンに委ねられます。Rork Max の React Native コードをスッキリ保ちながら、表示ロジックだけネイティブに閉じ込められるのは、個人開発者にとって大きな武器です。
TipKit の基本構造を3分で押さえる
TipKit の中心は Tip プロトコルです。各ヒントは独立した Swift の struct で、表示する文言・条件・アクションを宣言的に記述します。表示形式は2種類で、ポップオーバー型(吹き出し)とインライン型(画面内に差し込む帯)から選びます。
import TipKit
struct AddToCollectionTip : Tip {
var title: Text {
Text ( "お気に入りに追加" )
}
var message: Text ? {
Text ( "画像を長押しすると、コレクションへすばやく保存できます。" )
}
var image: Image ? {
Image ( systemName : "heart.fill" )
}
var actions: [Action] {
[
Action ( id : "learn-more" , title : "詳細を見る" ),
Action ( id : "got-it" , title : "わかりました" )
]
}
}
ここで重要なのは、Tip は「表示するかどうか」を自分で持たない点です。表示判定は TipKit ランタイムが内部のルール(Rules)と発生したイベント(Events)から自動で行います。アプリ起動時に一度だけ Tips.configure(...) を呼び出すと、データストアの初期化と表示頻度の設定が完了します。
Rork Max から TipKit を呼ぶ全体設計
Rork Max は React Native + Expo がベースなので、TipKit のような Swift 専用 API は直接は触れません。私がたどり着いた構成は、Expo Modules API でネイティブブリッジを作り、JS 側からは「ヒントの登録」「表示条件のイベント発火」「アクションのコールバック受信」だけを行う形です。理由はシンプルで、TipKit のルール評価は Swift 側で完結させた方がバグが少なく、JS 側に状態を漏らすほど将来の保守が辛くなるからです。
全体の流れは次のようになります。アプリ起動時に Swift 側で Tips.configure(...) を実行し、利用可能なヒント定義を SwiftUI の View Modifier として埋め込みます。Rork Max 側で「フィルターを開いた」「3回目の検索を実行した」などのイベントが発生したら、Expo Module を経由して event.donate() を呼び出します。これにより、登録済みのヒントが内部ルールに従って自動表示されます。
// ios/RorkTipKitModule/RorkTipKitModule.swift
import ExpoModulesCore
import TipKit
@available ( iOS 17.0 , * )
public class RorkTipKitModule : Module {
public func definition () -> ModuleDefinition {
Name ( "RorkTipKit" )
OnCreate {
do {
try Tips. configure ([
. displayFrequency (.immediate),
. datastoreLocation (.applicationDefault)
])
} catch {
// 失敗してもアプリ全体は止めない。Crashlytics に送る前提のログ
NSLog ( "[RorkTipKit] configure failed: \( error. localizedDescription ) " )
}
}
AsyncFunction ( "donateEvent" ) { ( eventId : String ) -> Void in
await TipEventCenter.shared. donate (eventId)
}
AsyncFunction ( "invalidateTip" ) { ( tipId : String ) -> Void in
await TipEventCenter.shared. invalidate (tipId)
}
Events ( "onTipAction" )
}
}
Events("onTipAction") を宣言しておくと、JS 側から購読できる EventEmitter として扱えます。ヒントのアクションボタンが押されたときに sendEvent("onTipAction", payload) を発火すれば、React Native のロジックでハンドリング可能です。実装してみると分かりますが、最初から「アクションは1種類だけ」と思い込まずに、複数アクションが将来増える前提で配列を返す設計にしておくと、後から泣かずに済みます。
実装ステップ1: 最小構成で動かす
ここからは段階的に組み上げます。まず Rork Max のプロジェクトに Expo Modules を追加し、上記の Swift モジュールを ios/RorkTipKitModule/ 配下に配置します。次に、JS から呼び出す薄いラッパーを書きます。
// modules/rork-tipkit/index.ts
import { requireOptionalNativeModule, EventEmitter } from "expo-modules-core" ;
type TipActionPayload = {
tipId : string ;
actionId : string ;
};
const RorkTipKit = requireOptionalNativeModule ( "RorkTipKit" );
const emitter : EventEmitter | null = RorkTipKit
? new EventEmitter (RorkTipKit)
: null ;
export const tipKit = {
/**
* ヒント表示の元になるイベントを記録します。
* 同じ eventId に対して複数回呼ばれても、TipKit が頻度を制御してくれます。
*/
async donate ( eventId : string ) : Promise < void > {
if ( ! RorkTipKit) return ; // iOS 17 未満や Android では no-op
try {
await RorkTipKit. donateEvent (eventId);
} catch (e) {
console. warn ( "[tipKit.donate] failed" , e);
}
},
/**
* 「もう表示しない」操作を記録します。
* ユーザーが意図的に閉じた場合に必ず呼んでください。
*/
async invalidate ( tipId : string ) : Promise < void > {
if ( ! RorkTipKit) return ;
try {
await RorkTipKit. invalidateTip (tipId);
} catch (e) {
console. warn ( "[tipKit.invalidate] failed" , e);
}
},
/**
* アクションボタンが押されたときのリスナを登録します。
* 戻り値の関数を呼ぶと購読解除されます。
*/
onAction ( listener : ( payload : TipActionPayload ) => void ) : () => void {
if ( ! emitter) return () => {};
const subscription = emitter. addListener ( "onTipAction" , listener);
return () => subscription. remove ();
}
};
requireOptionalNativeModule を使っているのは、Android ビルドや iOS 16 以下の環境で起動時にクラッシュさせないためです。私は最初これを requireNativeModule にしてしまい、TestFlight で Android 端末を持っているテスターから「アプリが起動しません」と報告を受けて慌てました。Rork Max でクロスプラットフォームを謳う以上、片側に存在しない API は必ず optional 経由で扱うのが安全です。
JS 側からの呼び出しは、たとえば検索画面のマウント時に次のように書きます。
// app/search.tsx の useEffect 内
useEffect (() => {
tipKit. donate ( "search.opened" );
const unsubscribe = tipKit. onAction (({ tipId , actionId }) => {
if (tipId === "filter.tip" && actionId === "learn-more" ) {
router. push ( "/help/filter" );
}
});
return unsubscribe;
}, []);
期待される動作は、検索画面が3回開かれたあたりで「フィルターを開いてみませんか?」というポップオーバーがフィルターアイコンの近くに自動表示される、というものです(後述の Eligibility ルールで頻度を制御します)。
実装ステップ2: Eligibility ルールで「適切なタイミング」を作る
Rules を書かずに donate だけしてしまうと、毎回ヒントが出てしまい、即座にユーザーをうんざりさせます。ここを丁寧に設計するのが TipKit 導入の本番です。
// ios/RorkTipKitModule/Tips/FilterTip.swift
import TipKit
@available ( iOS 17.0 , * )
struct FilterTip : Tip {
static let searchOpenedEvent = Event ( id : "search.opened" )
static let filterUsedEvent = Event ( id : "filter.used" )
var id: String { "filter.tip" }
var title: Text { Text ( "もっと絞り込めます" ) }
var message: Text ? {
Text ( "検索結果が多いときは、上部のフィルターで一気に絞り込めます。" )
}
var image: Image ? { Image ( systemName : "line.3.horizontal.decrease.circle" ) }
var rules: [Rule] {
// 検索画面を3回以上開いている
#Rule ( Self .searchOpenedEvent) { $0 .donations. count >= 3 }
// まだフィルターを使っていない
#Rule ( Self .filterUsedEvent) { $0 .donations. count == 0 }
// 過去24時間以内にも検索画面を開いている(古い使い方の人を除外)
#Rule ( Self .searchOpenedEvent) {
$0 .donations. donatedWithin (. days ( 1 )). count >= 1
}
}
}
ここで重要なのは「複数の Rule は AND で評価される」点と、「ドネーションは過去のすべてが累積する」点です。私が陥った最初の失敗は、donations.count >= 3 だけで条件を組んでしまい、半年前に3回起動したきり休眠していた人が、復帰直後の最初の検索でいきなりヒントを浴びる、という事故を起こしたことです。donatedWithin(.days(1)) のような時間窓を1つ噛ませるだけで、UX の質はぐっと上がります。
実装ステップ3: ポップオーバー表示と SwiftUI 連携
ヒントの定義とイベント発火が揃ったら、最後は表示位置です。SwiftUI なら .popoverTip(_:) と .tipBackground(_:) のどちらかを対象 View に付けるだけで完結します。
Rork Max 全体は React Native ですが、ヒントを出す対象(フィルターアイコンなど)が Swift 側にも存在する場合があります。たとえばカスタムタブバーを SwiftUI で実装している、あるいは Native Stack の Header を独自にカスタムしているケースです。その場合は次のようなコードを書きます。
// SwiftUI 側のカスタムタブバー内
import SwiftUI
import TipKit
@available ( iOS 17.0 , * )
struct FilterButton : View {
private let tip = FilterTip ()
var body: some View {
Button ( action : openFilter) {
Image ( systemName : "line.3.horizontal.decrease.circle" )
. font (.title2)
}
. popoverTip (tip, arrowEdge : .top) { action in
// アクションボタンがタップされたとき
// 親モジュールに転送して JS 側に通知
RorkTipKitModule. dispatch (
tipId : tip.id,
actionId : action.id
)
tip. invalidate ( reason : .actionPerformed)
}
}
private func openFilter () {
// donate しておくと「フィルターを既に使ったユーザー」として
// 別のヒントの判定に活用できる
Task { await TipEventCenter.shared. donate (FilterTip.filterUsedEvent.id) }
}
}
React Native のビュー(FlatList のヘッダーアイコンなど)に直接ポップオーバーを付けたい場合は、UIView のリファレンスを取得して .popoverPresentationController でホストする独自ヘルパが必要になります。私はこの場合、無理に React Native 側へ寄せず、ヒント対象の小さな View だけ SwiftUI で書き、SwiftUIRootView でラップして Rork Max 側にぶら下げる構成にしています。コードベースが分散するように見えますが、ヒント1個あたり 30 行程度で収まるので、結果的に保守はラクです。
ローカライゼーション — 4言語以上を運用する場合の現実解
Rork Lab で運営しているアプリは日本語・英語・中国語(簡)・韓国語の4言語が主戦場です。TipKit のヒント文言は、Swift 側の Text にローカライズキーを渡せば標準的な Localizable.strings から解決されます。ここで悩ましいのが「文言は Rork Max 側の翻訳ファイル(messages/ja.json など)で一元管理しているのに、ヒントだけ Swift 側に分かれる」状態です。
私の運用は、ビルド時スクリプトで messages/{locale}.json から tipkit.* 名前空間のキーだけ抽出し、ios/RorkTipKitModule/Resources/{locale}.lproj/Tips.strings に変換しています。CI に組み込んでおけば、翻訳者は JSON だけ触ればよく、Swift コードに触れる必要がなくなります。
// scripts/sync-tipkit-strings.mjs(Cloudflare CI でも動く ESM)
import { readFile, writeFile, mkdir } from "node:fs/promises" ;
import { join } from "node:path" ;
const LOCALES = [ "ja" , "en" , "zh-Hans" , "ko" ];
for ( const locale of LOCALES ) {
try {
const json = JSON . parse (
await readFile ( join ( "messages" , `${ locale }.json` ), "utf8" )
);
const tipKeys = Object. entries (json.tipkit ?? {});
if (tipKeys. length === 0 ) {
console. warn ( `[sync-tipkit] no keys for ${ locale }, skipping` );
continue ;
}
const lines = tipKeys
. map (([ k , v ]) => `"tipkit.${ k }" = ${ JSON . stringify ( v ) };` )
. join ( " \n " );
const dir = join ( "ios" , "RorkTipKitModule" , "Resources" , `${ locale }.lproj` );
await mkdir (dir, { recursive: true });
await writeFile ( join (dir, "Tips.strings" ), lines + " \n " , "utf8" );
console. log ( `[sync-tipkit] wrote ${ tipKeys . length } keys → ${ locale }` );
} catch (e) {
console. error ( `[sync-tipkit] failed for ${ locale }:` , e);
process.exitCode = 1 ;
}
}
process.exitCode = 1 を1回でもセットしておくと、複数言語の一部が失敗しても処理は続行しつつ、CI 全体は失敗扱いにできます。サイレント失敗が一番怖いので、ここは少し冗長でも書いておく価値があります。
CloudKit 同期 — 端末をまたぐ「もう見た」状態の扱い
ユーザーが iPhone と iPad の両方を使っている場合、片方で見たヒントがもう片方でまた出てきたら興ざめです。TipKit は Tips.configure の引数で CloudKit によるデータストア同期を有効化できます。
try Tips. configure ([
. displayFrequency (.immediate),
. datastoreLocation (
. groupContainer ( identifier : "group.net.rorklab.example.tips" )
),
. cloudKitContainer (. namedDefault ( "iCloud.net.rorklab.example" ))
])
ただし運用してみて気づいた現実があります。CloudKit 同期は「便利だけど不安定」です。ネットワークが切れているとき、サインアウト中、ストレージ満杯時など、想定外の状態で同期が止まることがあります。私は「同期は best effort、なくても致命的にならない設計」を必ず持つようにしています。具体的には、同期が遅れて同じヒントが2回出ても 1〜2 回までは許容する、という設計判断です。3回出る前提のヒントなら、同期が間に合わなくても致命傷にはなりません。
よくある実装の落とし穴と対処
実装を始めると必ず踏むであろう罠を、見つかった順に挙げておきます。
「Tips.configure が App Delegate より先に呼ばれてしまう」: Expo Modules の OnCreate は App 起動の比較的早い段階で呼ばれます。アプリの DI コンテナや解析 SDK の初期化と順序が衝突して、内部のデータストアが作れずクラッシュすることがあります。対処は OnCreate の中で Task { ... } に包んで非同期にし、メインアクター上で実行することです。
「ヒントが永遠に出ない」: ほとんどのケースで、Rules の評価が一度も真にならないことが原因です。Xcode の Tips コンソール(Window メニューから開けます)でルールの評価結果が見られるので、開発中は必ず確認しましょう。私はここを知らずに半日溶かしました。
「invalidate(reason:) を呼び忘れる」: アクションボタンを押したり、X ボタンで閉じたりした際に明示的に invalidate しないと、TipKit は「閉じられた」とは認識しません。ユーザーから見ると「閉じたのにまた出る」という最悪の体験になります。popoverTip のコールバック内で必ず invalidate するクセをつけてください。
「テストが書けない」: TipKit は内部状態がデータストア依存なので、ユニットテストで完全制御するのは難しいです。私は「Rules の述語だけ純粋関数として切り出してテスト可能にする」アプローチを取っています。#Rule マクロの中身を関数に抽出し、その関数だけ XCTest でカバーすれば、ロジックの正しさは保証できます。
「Android で no-op したつもりが落ちる」: requireOptionalNativeModule を使い忘れると、Android ビルド時に Cannot find native module で起動失敗します。クロスプラットフォーム前提のときは、ネイティブモジュールラッパーは必ず optional + early return パターンで書いてください。
計測 — どのヒントが効いたかを評価する
ヒントは「出した」だけでは意味がなく、「行動を変えたか」が全てです。私の運用では、onAction のリスナで GA4 のカスタムイベントを送り、ヒント ID とアクション ID をディメンションに分解しています。
import * as Analytics from "expo-firebase-analytics" ;
tipKit. onAction (({ tipId , actionId }) => {
Analytics. logEvent ( "tip_action" , {
tip_id: tipId,
action_id: actionId,
// 「ヒント表示後 N 秒以内に該当機能を使ったか」も別途取る
});
});
評価軸は次の3つで十分です。第一に「ヒント露出後 24 時間以内の該当機能の使用率」。第二に「ヒント露出から該当機能の初回使用までの中央値時間」。第三に「ヒント表示直後にアプリを閉じる率(離脱率)」です。3つ目が高いヒントは、ユーザーにとって邪魔をしている可能性が高く、即座に文言かタイミングを見直すサインです。
私自身、最初に作ったヒントの3割は離脱率を上げてしまっていました。「便利だと思って入れたヒントが、実は余計なお世話だった」を直視できるかどうかが、TipKit 運用の成否を分けます。
A/B テスト — Tip 構造を壊さずにコピーを比較する
ヒント文言を A/B テストしたくなったとき、FilterTipA と FilterTipB のように Tip を分岐させると、ルール定義が二重化して必ず破綻します。3アプリで実際に半年間運用してみて、私は「Tip 定義は1つに保ち、レンダリング時に文言だけ差し替える」方式に統一しました。
@available ( iOS 17.0 , * )
struct FilterTip : Tip {
static var copyVariant: CopyVariant = .control
var id: String { "filter.tip. \( Self . copyVariant . rawValue ) " }
var title: Text { Text ( Self .copyVariant.titleKey) }
var message: Text ? { Text ( Self .copyVariant.messageKey) }
var image: Image ? { Image ( systemName : "line.3.horizontal.decrease.circle" ) }
var rules: [Rule] {
// 全バリアントで共通のルール。差分は文言のみ
#Rule ( Self .searchOpenedEvent) { $0 .donations. count >= 3 }
#Rule ( Self .filterUsedEvent) { $0 .donations. count == 0 }
}
}
enum CopyVariant : String , CaseIterable {
case control , friendly , urgent
var titleKey: LocalizedStringResource { "tipkit.filter.title. \( rawValue ) " }
var messageKey: LocalizedStringResource { "tipkit.filter.message. \( rawValue ) " }
}
id にバリアント名を含めるのが肝です。これがないと TipKit のデータストア内で全バリアントが同じ予算を共有してしまい、どの文言が効いたかを切り分けられません。バリアント割当は JS 側で Cloudflare Workers KV のフラグから取得し、アプリアップデートなしで配信比率を変えられるようにしています。
ただし、同時に走らせるバリアントは2つまでに留めるのが現実解です。Eligibility ルールがそもそもサンプル数を絞るため、3つ以上にすると有意差が出るまで何ヶ月もかかります。
ポップオーバーかインラインか — 使い分けの判断軸
TipKit の例ではポップオーバーが目立ちますが、インライン表示(画面内に埋め込む帯)の方が適している場面も多くあります。「特定のボタンへ誘導するならポップオーバー」「今表示中のコンテンツに対する補足ならインライン」という基準で使い分けるのが、私の中で安定した判断軸になりました。
たとえば検索結果が少なすぎる画面で「検索条件を広げてみませんか?」という助言は、結果リストの上にバナーとしてインライン表示する方が、検索バーの上にポップオーバーを浮かべるより明確に自然です。インラインの実装は驚くほど単純です。
// SwiftUI の List 内
if let tip = ResultsHelpTip.current {
TipView (tip)
. padding (.horizontal)
}
トレードオフは「レイアウト上の空間を常に予約する」ことです。インラインは表示されてもされなくても枠を持ちます。なので、もともとバナー領域がある画面か、バナーがあっても自然な構成の画面に向いています。私は1画面に「ヒントスロット」を1か所だけ用意し、優先順位順に各 Tip へ表示意思を問い合わせて最初に表示する Tip を決める、という構造を取っています。
出荷前チェックリスト
新しいヒントを含むビルドを切る前に、私が必ず通すチェックリストです。
id がアプリ全体で一意で、命名規約(feature.purpose)に従っている
サポートする全ロケールに文言が存在する(英語へのフォールバックなし)
時間窓ルール(donatedWithin(.days(N)))が最低1つ含まれている
Tips Inspector で、新規インストールと条件達成済みユーザーの両方でルールが正しく評価されることを確認した
全アクションボタンと閉じるボタンで invalidate(reason:) を呼んでいる
JS 側のアクションハンドラに、未知の actionId に対するフォールバックがある(将来のバリアント追加に備える)
ローカル限定か CloudKit 同期か、判断と理由が記録されている
表示時とアクション時の両方で計測イベントが安定したパラメータ名で発火する
そのヒントを「いつ・なぜ廃止するか」が文書化されている
このうち1つでも省くと、1か月以内にサポート問い合わせという形で必ず請求書が回ってきます。
関連トピックへのつながり
TipKit と相性の良いネイティブ統合は他にもいくつかあります。Expo Modules の自作で同様の橋渡しをする方法は、より一般的な土台として Rork Max で Expo Modules を使ってカスタムネイティブを書くガイド にまとめています。ヒントから誘導した先で実際にユーザーへフィードバックを返す体験設計には、Rork Haptic Feedback 完全ガイド の振動パターン設計が役立ちます。ホーム画面側で機能を思い出させたい場合は、TipKit と相補的に Rork Max WidgetKit ホーム画面ウィジェット完全ガイド も検討してみてください。
技術書を併読するなら、SwiftUI の宣言的 API の理解が TipKit の Rules 設計にも直結します。
次の一歩
まずは1つだけ、今アプリで一番触ってほしい機能に対してヒントを実装してみてください。表示条件は「3回起動したユーザー、その機能を一度も使っていない、過去24時間以内のセッションあり」あたりから始めるのが安全です。1週間運用してみて、該当機能の使用率が動いたかどうかだけ見れば、TipKit があなたのアプリに合うかどうかは判断できます。
ヒントは少ないほど効きます。「足りない」と感じる程度が、ちょうど良い設計です。