個人開発の壁紙アプリを長く運用していると、ユーザーが一番自然に友人へ「いいよ」と勧めてくれる瞬間は、アプリストアのリンクを送るときではなく、気に入った一枚をそのまま会話に貼るときだと気づきます。だとすれば、その共有の場であるメッセージそのものに、アプリの世界観を持ち込めないか。そう考えて取り組んだのが iMessage 拡張でした。
iMessage 拡張は、Messages フレームワークに依存するネイティブの仕組みです。React Native からは事実上扱えませんが、Rork Max はネイティブ Swift のプロジェクトを生成するため、拡張ターゲットを足して MSMessagesAppViewController を継承する道が開けます。ここでは配布導線という観点から、表示の切り替え・メッセージ送信・つまずきどころを順に整理します。
なぜ「アプリ本体」ではなくメッセージ拡張なのか
アプリの紹介をストアのリンクで送ると、受け手はアプリを離れて App Store を開く必要があります。一方、iMessage 拡張から送った素材は会話の中に残り、受け手はその場で軽く触れられます。私の感覚では、後者の方が「使ってみようかな」までの心理的な距離がずっと短くなります。AdMob 中心の無料アプリにとって、入口を一つ増やす意味は小さくありません。
ただし拡張は本体アプリとは別のターゲットであり、メモリ制限も厳しめです。素材を丸ごと読み込む発想だと、すぐに限界に当たります。軽さを前提に設計するのが出発点になります。
Step 1: 拡張ターゲットを追加し、本体と素材を共有する
Rork Max が生成したプロジェクトに、iMessage 拡張のターゲットを追加します。本体アプリと素材(画像など)を共有するため、App Group を有効化して共有コンテナ越しに読み出す構成にします。拡張内に画像を二重に抱えると、配布サイズもメモリも無駄に膨らみます。
// 共有コンテナから素材の URL を解決する
func sharedAssetURL(_ name: String) -> URL? {
let groupID = "group.net.rorklab.sample"
let base = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: groupID)
return base?.appendingPathComponent("assets/\(name)")
}
App Group の識別子は本体と拡張で完全に一致させる必要があります。ここがずれていると、拡張側で素材が常に nil になり、原因の特定に時間を取られます。
デコード後のバイト数で、並べられる枚数が決まる
「拡張はメモリが厳しい」と一般論で書いても、設計は一つも決まりません。効いてくるのはファイルサイズではなく、デコード後のバイト数です。ディスク上で 1.2MB の JPEG も、画面に出た瞬間は幅 × 高さ × 4 バイトの生ビットマップになります。
手元で数えてみました。
// imessage-memory.mjs — デコード後のビットマップがどれだけ場所を取るか
const MB = 1024 * 1024;
const BUDGET_MB = 40; // 拡張プロセスに置く自前の予算
const source = { w: 1290, h: 2796 }; // 原寸の壁紙
const cellPt = 110, scale = 3; // グリッド1枠 110pt を @3x で描く
const thumb = { w: cellPt * scale, h: Math.round(cellPt * scale * (source.h / source.w)) };
const bytes = (s) => s.w * s.h * 4; // RGBA8
const fits = (s) => Math.floor((BUDGET_MB * MB) / bytes(s));
console.log(`per-image full=${(bytes(source) / MB).toFixed(2)}MB thumb=${(bytes(thumb) / MB).toFixed(2)}MB`);
console.log(`fits full=${fits(source)} thumb=${fits(thumb)}`);
for (const n of [6, 12, 24, 48]) {
console.log(`${n} | ${((n * bytes(source)) / MB).toFixed(1)} | ${((n * bytes(thumb)) / MB).toFixed(1)}`);
}
Node v22.22.3 で実行した出力を表にしたものが以下です。サムネイルは 330 × 715 ピクセルになりました。
| 並べる枚数 | 原寸のまま(MB) | サムネイル(MB) | 40MB 予算 |
| 1 | 13.76 | 0.90 | 原寸は3枚目で超過 |
| 6 | 82.6 | 5.4 | 原寸のみ超過 |
| 12 | 165.1 | 10.8 | 原寸のみ超過 |
| 24 | 330.2 | 21.6 | 原寸のみ超過 |
| 48 | 660.4 | 43.2 | 両方とも超過 |
原寸とサムネイルの差は 15.3 倍。予算 40MB に収まる枚数は、原寸だと 2 枚、サムネイルなら 44 枚です。コンパクト表示に 12 枚並べたいだけで、原寸のままなら 165MB を要求してしまう計算になります。
もう一つ、見落としやすい行が最後にあります。サムネイルでも 48 枚で 43.2MB。つまり縮めれば安全、ではなく、縮めたうえで画面外のセルを解放する前提がいります。私はここを読み違えて、スクロールを速くした人の端末だけで落ちる、という再現しづらい不具合を抱えていました。
縮めるタイミングも肝心です。UIImage(contentsOfFile:) で読み込んでから縮小するのでは遅く、その時点で原寸のビットマップが一度メモリに載っています。ImageIO でデコードそのものを小さく行います。
import ImageIO
import UIKit
// 「読み込んでから縮める」のではなく、小さくデコードする
func downsample(_ url: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage? {
let srcOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let src = CGImageSourceCreateWithURL(url as CFURL, srcOptions) else { return nil }
let maxPixel = max(pointSize.width, pointSize.height) * scale
let options = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixel,
] as CFDictionary
guard let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, options) else { return nil }
return UIImage(cgImage: cg, scale: scale, orientation: .up)
}
kCGImageSourceShouldCache: false を読み込み側に渡しておくのは、原寸のデコード結果を ImageIO 側に抱え込ませないためです。拡張のように余白のないプロセスでは、この一行の有無で挙動が変わります。本体アプリ側の一覧でも同じ考え方が効くので、詳しくはiOSの壁紙アプリでサムネイル一覧のカクつきを消す — ImageIOダウンサンプリングの実装メモにまとめてあります。キャッシュ側の上限設計は壁紙アプリの画像キャッシュが静かに膨らんでメモリで落ちる — Rork運用で効いた計測と上限設計の運用メモが近い話です。
Step 2: コンパクト表示と拡張表示を切り替える
iMessage 拡張には、キーボード位置に収まる「コンパクト」と、画面の大半を使う「拡張」の二つの表示があります。最初はコンパクトで素材の一覧をさっと見せ、選択後に拡張へ広げて仕上げを確認する、という流れが自然です。
import Messages
final class MessagesViewController: MSMessagesAppViewController {
override func willBecomeActive(with conversation: MSConversation) {
super.willBecomeActive(with: conversation)
presentList() // まず一覧を見せる
}
func didSelectAsset(_ name: String) {
// 選択されたら拡張表示へ広げて確認画面を出す
requestPresentationStyle(.expanded)
presentDetail(for: name)
}
}
requestPresentationStyle(_:) は要求であって即時の切り替えではありません。実際の遷移後に UI を組み直すには didTransition(to:) を併用します。ここを混同すると、表示が広がる前に古いレイアウトのまま描画され、一瞬ちらつきます。
Step 3: 素材をメッセージとして組み立てて送る
共有の核になるのが MSMessage の組み立てです。会話に残る吹き出しの見た目は MSMessageTemplateLayout で決めます。受け手の画面に何が残るかを、ここで設計します。
func send(asset name: String, in conversation: MSConversation) {
let layout = MSMessageTemplateLayout()
// 吹き出し用も原寸で読み込まない(後述の downsample を通す)
layout.image = sharedAssetURL(name).flatMap {
downsample($0, to: CGSize(width: 110, height: 238), scale: 3)
}
layout.caption = "お気に入りの一枚を送りました"
let message = MSMessage()
message.layout = layout
conversation.insert(message) { error in
if let error = error {
print("insert failed: \(error)") // 送信枠への挿入失敗を握りつぶさない
}
}
}
conversation.insert は、メッセージを「送信枠に挿入する」だけで、実際の送信はユーザーの送信ボタンに委ねられます。ここを「送信した」と誤解すると、勝手に送られないという仕様を不具合と取り違えてしまいます。私は最初それで悩み、挿入と送信が分かれている設計だと理解して腑に落ちました。
Step 4: ドロワーに出てこないときの切り分け
拡張を作って実機に入れたのに、メッセージのアプリドロワーに出てこない——これは iMessage 拡張で頻発する症状です。原因はコードよりも構成側にあることが大半です。
確認する順序は次の通りです。第一に、拡張ターゲットの Bundle Identifier が本体の識別子に正しくぶら下がっているか。第二に、拡張の Info.plist に Messages 拡張としての NSExtensionPointIdentifier が設定されているか。第三に、デバイスのメッセージ設定でアプリが有効化されているか。私の場合、二番目の拡張ポイントの取り違えが原因で、ビルドは通るのにドロワーに現れない状態に陥っていました。
Step 5: 配布効果を測れる形にしておく
拡張からの共有がどれくらい新規につながったかは、後から必ず知りたくなります。送信メッセージに識別用のクエリを付けた URL を MSMessage.url に持たせ、受け手がそこからアプリを開いた経路を本体側で計測できるようにしておきます。私は AdMob 収益と並べて、この共有経由の起動数を簡易に追う形にしました。数字が見えると、拡張に手をかける価値があるかを淡々と判断できます。
共有ループが増幅装置なのか、足し算なのかを先に数える
拡張に時間を投じるか迷ったとき、私は一度だけ数字にしてみることにしています。共有は指数的に効くのか、それとも素直な足し算なのか。ここを取り違えると、期待の置きどころがずれます。
共有ループの強さは k で表せます。k = 共有する人の割合 × 1 共有あたりの受け手 × 受け手が開く率 × 開いた人が入れる率。k が 1 未満なら累計インストールは種の 1 / (1 - k) 倍で頭打ちになります。
// share-loop.mjs — Messages 拡張の共有ループを k で見る
const scenarios = [
{ name: '控えめ', shareRate: 0.02, recipients: 1.0, openRate: 0.20, installRate: 0.15 },
{ name: '現実的', shareRate: 0.05, recipients: 1.3, openRate: 0.30, installRate: 0.20 },
{ name: '強気', shareRate: 0.12, recipients: 1.8, openRate: 0.40, installRate: 0.30 },
];
const seed = 1000;
for (const s of scenarios) {
const k = s.shareRate * s.recipients * s.openRate * s.installRate;
const mult = 1 / (1 - k);
console.log(`${s.name} | k=${k.toFixed(4)} | ${mult.toFixed(3)}x | ${Math.round(seed * mult)}`);
}
const r = scenarios[1]; // 現実的シナリオの後段を固定して
const tail = r.recipients * r.openRate * r.installRate; // k=0.5 に必要な共有率を逆算する
for (const target of [0.2, 0.5, 1.0]) {
console.log(`k=${target} needs share rate ${((target / tail) * 100).toFixed(1)}%`);
}
同じく Node v22.22.3 での出力です。
| シナリオ | 共有率 | 受け手 | 開く率 | 入れる率 | k | 増幅 | 種1,000人からの累計 |
| 控えめ | 2% | 1.0 | 20% | 15% | 0.0006 | 1.001倍 | 1,001(+1) |
| 現実的 | 5% | 1.3 | 30% | 20% | 0.0039 | 1.004倍 | 1,004(+4) |
| 強気 | 12% | 1.8 | 40% | 30% | 0.0259 | 1.027倍 | 1,027(+27) |
強気に振っても増幅は 2.7% どまり。後段の率を現実的シナリオのまま固定して k = 0.2 に必要な共有率を逆算すると 256.4% で、そもそも到達できない値が返ってきます。
正直に書くと、私は当初この機能に指数的な期待を持っていました。数字にして分かったのは、期待していたのは k の形ではなかったということです。効いているのは増幅ではなく、線形の積み上げでした。
| シナリオ | 1,000 共有あたりのインストール |
| 控えめ | 30 |
| 現実的 | 78 |
| 強気 | 216 |
こちらは素直です。共有が積み上がった分だけ、比例して入口が増えます。だとすれば追うべき数字は増幅率ではなく、共有した人の割合と、1 共有あたりのインストール数の二つ。この二つなら、拡張の UI を一手減らす、といった具体的な改善に直接つながります。
なお、ここで置いた率はすべて仮値です。実際の値は Step 5 の識別子付き URL から拾えるので、自分の数字に差し替えて読んでください。招待コードのように意図的に増幅を狙う仕組みとは目的が違う点も、あわせて押さえておくと迷いません(紹介コードで友達を招く仕組みを、重いバックエンドなしで設計する)。
個人開発で拡張に投資すべきかの判断
iMessage 拡張は、作り込もうとすれば無限に凝れる領域です。ですが配布導線として見るなら、まずは「気に入った素材を一手で会話に貼れる」ところまでで十分に効果が出ます。私が個人開発で採った方針は、共有の一往復を軽く速く仕上げることに集中し、装飾的な機能は後回しにすることでした。
メッセージは、ユーザーが自分の言葉で誰かを思い浮かべている場所です。そこにアプリの世界観をそっと差し出せること自体に、宣伝以上の意味があると感じています。Rork Max でネイティブの拡張に手が届くようになった今、配布をコードで設計する発想を持てるかどうかが、地味ながら効いてくるはずです。