3つ目に作ったアプリで、はじめてオンボーディング画面を5枚に増やしてみたことがあります。離脱データを見て愕然としました。3枚目で約半数が消え、最終画面まで到達したのは2割を切っていたのです。最初の体験で全部を伝えようとした結果、何ひとつ伝わっていませんでした。
その失敗から切り替えたのが、「使いながら気づかせる」設計でした。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 } from "expo-modules-core" ;
type TipActionPayload = {
tipId : string ;
actionId : string ;
};
// SDK 52 以降、ネイティブモジュールの JS オブジェクト自体が
// EventEmitter を継承した NativeModule です。
// new EventEmitter(module) は旧 API なので使いません。
const RorkTipKit = requireOptionalNativeModule ( "RorkTipKit" );
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 ( ! RorkTipKit) return () => {};
const subscription = RorkTipKit. addListener ( "onTipAction" , listener);
return () => subscription. remove ();
}
};
ここは Expo SDK 52 で作法が変わった箇所です。それ以前は new EventEmitter(nativeModule) でラップしていましたが、いまはモジュールのオブジェクトがそのまま addListener を持っています。古い記事のコードをそのまま持ってくると、購読しているつもりで一度もコールバックが来ない、という気づきにくい失敗になります。移行時はここを最初に確かめてください。
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 行程度で収まるので、結果的に保守はラクです。
React Native 側で描くという三つ目の道 — shouldDisplayUpdates を橋にする
前節の最後で、React Native のビューにポップオーバーを付けたい場合は SwiftUI 側に小さな View を用意する、と書きました。ただ、画面のほとんどが React Native でできているアプリでは、ヒント1つのために SwiftUI のかけらを持ち込むのが割に合わない場面もあります。
そこで使えるのが、TipKit が Tip インスタンスに用意している shouldDisplayUpdates です。これは「いま、このヒントは出してよいか」の変化を流し続ける AsyncStream で、TipView も popoverTip も使わずに購読できます。つまり TipKit を「判定だけしてくれるルールエンジン」として使い、描画は React Native に任せられる ということです。
// ios/RorkTipKitModule/RorkTipKitModule+Observe.swift
import ExpoModulesCore
import TipKit
@available ( iOS 17.0 , * )
extension RorkTipKitModule {
/// Tip の表示可否が変わるたびに JS へ流します。
/// 戻り値の Task を保持しておき、モジュール破棄時に cancel します。
func observe < T : Tip >( _ tip: T, tipId : String ) -> Task< Void , Never > {
Task { @MainActor [ weak self ] in
for await shouldDisplay in tip.shouldDisplayUpdates {
self ? . sendEvent ( "onTipDisplayChange" , [
"tipId" : tipId,
"shouldDisplay" : shouldDisplay
])
}
}
}
}
JS 側は、届いた tipId に対応する自前のバナーやツールチップを描くだけです。
// hooks/useTipVisibility.ts
import { useEffect, useState } from "react" ;
export function useTipVisibility ( tipId : string ) : boolean {
const [ visible , setVisible ] = useState ( false );
useEffect (() => {
if ( ! RorkTipKit) return ;
const sub = RorkTipKit. addListener (
"onTipDisplayChange" ,
( e : { tipId : string ; shouldDisplay : boolean }) => {
if (e.tipId === tipId) setVisible (e.shouldDisplay);
}
);
return () => sub. remove ();
}, [tipId]);
return visible;
}
ここには引き換えに手放すものがあります。自分で描いた場合、TipKit 側には「表示した」という記録が残りません。 表示回数と最終表示日時を記録しているのは TipView と popoverTip のほうなので、自前描画に切り替えると displayFrequency や表示上限といった頻度制御が効かなくなります。Xcode の Tips インスペクタで表示回数が 0 のまま増えないことで確認できます。
私は、この方式を採るときは頻度の管理を Rules 側へ寄せています。「7日以内に出したら出さない」のような条件は #Rule に書けるので、頻度制御をランタイム任せにせず、自分のルールとして明示してしまうほうが結局わかりやすいのです。閉じられたときの invalidate(reason:) も、当然こちらから呼ぶ必要があります。
判定は TipKit に、描画は自分たちに。 この線引きを先に決めておくと、あとから「やっぱり全部 SwiftUI で書き直す」という一番つらい分岐を踏まずに済みます。
ローカライゼーション — 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 自身が面倒を見てくれます。
まず前提を一つ。TipKit の CloudKit 同期は iOS 18 以降の機能です。 TipKit そのものは iOS 17 で入りましたが、同期はあとから足されました。@available(iOS 17.0, *) を付けたコードの中に同期の設定を書いてしまうと、iOS 17 の実機で configure ごと失敗します。私はここを取り違えたまま TestFlight に配り、「iPad ではヒントが出ません」という報告で初めて気づきました。
設定の書き方は次のとおりです。
// ios/RorkTipKitModule/TipsBootstrap.swift
import TipKit
@available ( iOS 17.0 , * )
enum TipsBootstrap {
static func configure () throws {
var options: [Tips.ConfigurationOption] = [
. displayFrequency (.immediate),
. datastoreLocation (.applicationDefault)
]
// 同期は iOS 18 以降でのみ追加する
if #available ( iOS 18.0 , * ) {
options. append (. cloudKitContainer (.automatic))
}
try Tips. configure (options)
}
}
.automatic は、アプリの entitlements に並んだ iCloud コンテナのうち 末尾が .tips のものを自動で拾います 。命名がそのまま設定の一部になっている、少し変わった API です。コンテナ名を自分で決めたいときは .named("iCloud.net.rorklab.example.tips") を渡します。
有効化には Xcode 側の設定も要ります。ターゲットの Signing & Capabilities で iCloud を追加して CloudKit にチェックを入れ、.tips で終わるコンテナ識別子を1つ作ります。さらに Background Modes を追加し、Remote notifications を有効にします。この3つが揃って初めて、バックグラウンドでも同期が進むようになります。
最後の行が、いちばん見つけにくい落とし穴です。NSPersistentCloudKitContainer は既定で entitlements に並んだ最初の iCloud コンテナ識別子 を使います。Tips 用のコンテナを先に足してしまうと、アプリ本体のデータが Tips のコンテナへ入りに行きます。entitlements ではアプリ本体の識別子を先に置くか、ストアを読み込む前に明示するのが確実です。
storeDescription.cloudKitContainerOptions =
NSPersistentCloudKitContainerOptions (
containerIdentifier : "iCloud.net.rorklab.example"
)
もう一つ、運用してみて分かった現実があります。CloudKit 同期は便利ですが、当てにはできません。オフライン、iCloud からのサインアウト、ストレージ不足——どれでも同期は止まります。私は「同期は best effort、なくても成立する設計」を必ず持つようにしています。同期が遅れて同じヒントが二度出ても、三回まで出す前提のヒントなら致命傷にはなりません。
同期は体験を良くする装置であって、正しさを預ける先ではありません。 ここを取り違えた設計は、電波の悪い場所にいるユーザーから順に壊れていきます。
よくある実装の落とし穴と対処
実装を始めると必ず踏むであろう罠を、見つかった順に挙げておきます。
「Tips.configure が App Delegate より先に呼ばれてしまう」: Expo Modules の OnCreate は App 起動の比較的早い段階で呼ばれます。アプリの DI コンテナや解析 SDK の初期化と順序が衝突して、内部のデータストアが作れずクラッシュすることがあります。対処は OnCreate の中で Task { ... } に包んで非同期にし、メインアクター上で実行することです。
「ヒントが永遠に出ない」: ほとんどのケースで、Rules の評価が一度も真にならないことが原因です。Xcode の Tips インスペクタ(Window メニューから開けます)でルールの評価結果が見られるので、開発中は必ず確認してください。私はここを知らずに半日溶かしました。
あわせて覚えておきたいのが、開発中の状態リセットです。TipKit には Tips.resetDatastore()・Tips.showAllTipsForTesting()・Tips.hideAllTipsForTesting() が用意されています。いずれも Tips.configure() より前に呼ぶ必要があります。 configure のあとに呼んでも何も起きません。Expo Modules の OnCreate は configure を書く場所でもあるので、同じ関数の中で順番を守るのがいちばん確実です。
OnCreate {
do {
# if DEBUG
// 条件を満たしたあとの挙動を毎回まっさらから確かめるため、
// configure より前にリセットします(順番が逆だと効きません)
if ProcessInfo.processInfo.arguments. contains ( "-ResetTips" ) {
try Tips. resetDatastore ()
}
# endif
try TipsBootstrap. configure ()
} catch {
NSLog ( "[RorkTipKit] configure failed: \( error. localizedDescription ) " )
}
}
リリースビルドに resetDatastore() が残ると、起動のたびに全ユーザーのヒント履歴が消えます。#if DEBUG で囲うところまでを一組として書いてください。
「別のヒントのイベントに引きずられる」: TipKit の永続データを覗くと分かるのですが、イベントは Tip と直接ひもづいていません。Event の id だけで識別され、同じ id への donation はすべての Tip で共有されます。 Event(id: "opened") のような一般的な名前を二つの Tip で別々に定義すると、片方の画面を開いた回数がもう片方のヒントの条件を満たしてしまいます。イベント定義は列挙型に集めて、id を一箇所で管理するのが安全です。
enum TipEvents {
static let searchOpened = Tips. Event ( id : "search.opened" )
static let filterUsed = Tips. Event ( id : "filter.used" )
static let collectionOpened = Tips. Event ( id : "collection.opened" )
}
ついでに言えば、Tip 側の id を書かなかった場合の既定値は「型の名前」です。リファクタリングで struct 名を変えると、TipKit から見れば別のヒントになり、既に閉じたはずのヒントが復活します。var id: String { "filter.tip" } を明示しておくと、この事故は起きません。
「@Parameter に構造体を入れたらクラッシュする」: @Parameter が受けられる型は Encodable であれば広いのですが、Rules の Predicate から構造体のプロパティを辿るとランタイムで落ちます 。#Rule(Self.$data) { $0.count > 3 } のような書き方です。パラメータは Bool・Int・String のような素朴な型に留め、複合的な判定はイベントの donation 回数で表現するほうが安定します。
「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 をディメンションに分解しています。
// expo-firebase-analytics は SDK 48 で削除されました。
// いまは React Native Firebase を直接使います。
import analytics from "@react-native-firebase/analytics" ;
tipKit. onAction ( async ({ tipId , actionId }) => {
await analytics (). logEvent ( "tip_action" , {
tip_id: tipId,
action_id: actionId
// 「ヒント表示後 N 秒以内に該当機能を使ったか」は別イベントで取ります
});
});
古い実装を引き継ぐときは、まず package.json に expo-firebase-analytics が残っていないかを見てください。SDK 48 でパッケージごと消えているので、名前が残っていれば、その計測は動いていません。ヒントの良し悪しを数字で判断してきたつもりが、実は何も届いていなかった——これが私の場合、いちばん高くついた勘違いでした。
評価軸は次の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 のフラグから取得し、アプリアップデートなしで配信比率を変えられるようにしています。
一方で、イベントの donation は id で共有されたままです 。これは分離できないのではなく、分離しないほうが正しい設計です。バリアント間で条件の成立タイミングを揃えたいのですから、「検索を3回開いた」という事実は共通の土台であるべきです。分けるのは Tip の記録、揃えるのはイベント——この二層を混ぜないことが、あとから結果を読むときに効いてきます。
ただし、同時に走らせるバリアントは2つまでに留めるのが現実解です。Eligibility ルールがそもそもサンプル数を絞るため、3つ以上にすると有意差が出るまで何ヶ月もかかります。
ポップオーバーかインラインか — 使い分けの判断軸
TipKit の例ではポップオーバーが目立ちますが、インライン表示(画面内に埋め込む帯)の方が適している場面も多くあります。「特定のボタンへ誘導するならポップオーバー」「今表示中のコンテンツに対する補足ならインライン」という基準で使い分けるのが、私の中で安定した判断軸になりました。
たとえば検索結果が少なすぎる画面で「検索条件を広げてみませんか?」という助言は、結果リストの上にバナーとしてインライン表示する方が、検索バーの上にポップオーバーを浮かべるより明確に自然です。インラインの実装は驚くほど単純です。
// SwiftUI の List 内
if let tip = ResultsHelpTip.current {
TipView (tip)
. padding (.horizontal)
}
トレードオフは「レイアウト上の空間を常に予約する」ことです。インラインは表示されてもされなくても枠を持ちます。なので、もともとバナー領域がある画面か、バナーがあっても自然な構成の画面に向いています。私は1画面に「ヒントスロット」を1か所だけ用意し、優先順位順に各 Tip へ表示意思を問い合わせて最初に表示する Tip を決める、という構造を取っています。
出荷前チェックリスト
新しいヒントを含むビルドを切る前に、私が必ず通すチェックリストです。
id を明示してあり、アプリ全体で一意です。命名規約(feature.purpose)に従っています
Event の id を列挙型に集約してあり、他のヒントと重複していません
サポートする全ロケールに文言が存在します(英語へのフォールバックなし)
時間窓ルール(donatedWithin(.days(N)))を最低1つ含めています
Tips インスペクタで、新規インストールと条件達成済みの両方からルールの評価を確認しました
全アクションボタンと閉じるボタンで invalidate(reason:) を呼んでいます
JS 側のアクションハンドラに、未知の actionId へのフォールバックがあります
ローカル限定か CloudKit 同期かを決め、その理由を書き残しています(同期なら iOS 18 以降の分岐も)
表示時とアクション時の両方で、計測イベントが安定したパラメータ名で発火します
resetDatastore() が #if DEBUG の外へ漏れていません
そのヒントを「いつ・なぜ廃止するか」を書き残しています
このうち1つでも省くと、1か月以内にサポート問い合わせという形で必ず請求書が回ってきます。
関連トピックへのつながり
TipKit と同じ「Expo モジュールで OS の機能を橋渡しする」型は、ほかの機能にもそのまま応用できます。Siri と Shortcuts を同じ構えで統合した手順は Rork アプリに Siri と Shortcuts を統合する にまとめました。ホーム画面の側から機能を思い出させたい場合は、ヒントと補い合う関係にある Rork Max × WidgetKit・ライブアクティビティの実装 が参考になります。ヒントが継続率に効いたかどうかを測る枠組みは 二つの「継続」を分けて測り直す設計 に書きました。
次の一歩
まずは1つだけ、今アプリで一番触ってほしい機能に対してヒントを実装してみてください。表示条件は「3回起動したユーザー、その機能を一度も使っていない、過去24時間以内のセッションあり」あたりから始めるのが安全です。1週間運用してみて、該当機能の使用率が動いたかどうかだけ見れば、TipKit があなたのアプリに合うかどうかは判断できます。
ヒントは少ないほど効きます。「足りない」と感じる程度が、ちょうど良い設計です。