ahha の購読状態の処理を整理しながら、こういうお知らせを作りました。
プレミアムプランが終了しました
ご利用期間が満了し、無料プランに切り替わりました。
期限切れのとき、そして支払い手段の問題で更新に失敗したときに出すお知らせです。状態判定もきちんとやり、文言も練り、重複表示の防止まで入れました。チェックリストに「○」を付けました。
そして二セッション後に見返して気づきました。このお知らせを見られるユーザーがほとんどいませんでした。
構造を見ればすぐ分かる
お知らせの状態はグローバル変数ひとつで持っていました。
export type PlanEndedNotice = "expired" | "billingIssue" | null; export const planEndedNoticeVar = makeVar<PlanEndedNotice>(null);
状態更新の時点でこの値をセットします。
const endedFromKind = isPremiumPlan(prev) ? prev.kind : expiredCacheKind;
expiredCacheKind = null;
if (notifyEnded && endedFromKind && status.kind === "free") {
planEndedNoticeVar(
endedFromKind === "billingIssue" ? "billingIssue" : "expired",
);
}
そしてこの値を読んでお知らせを出すコードは、「その他」画面の一か所にしかありませんでした。アプリのタブは四つです。分析・予想・結果・その他。
「その他」タブに入らないユーザーは、このお知らせを永遠に見られません。
なぜ見ないまま終わるのか
「あとで その他タブに入れば見えるだろう」ではありません。永遠に見えません。理由が二つあります。
1. この変数はメモリ上にしかない
MMKV のような永続ストレージに書いていません。アプリを終了して起動し直すと null に戻ります。
2. 判定の根拠が一回性である
終了への遷移判定に使う値 — 直前の状態(prev)と期限切れ経過のキャッシュ(expiredCacheKind)— は判定直後に解除されます。
const endedFromKind = isPremiumPlan(prev) ? prev.kind : expiredCacheKind; expiredCacheKind = null; // ← 判定 1 回で解除
解除しないと、以降の再照会(アプリ復帰など)で遅れてお知らせがまた出ます。だから解除するのが正しい。
ところが一度セットされたお知らせを消費しないままアプリを終了すると、次の起動時にはすでに free なので「遷移」が検出されません。判定の機会がちょうど一度きりで、その一度を逃したわけです。
[購読が期限切れ]
↓
アプリ起動 → 状態照会 → free への遷移を検出 → お知らせ Var をセット
↓
ユーザー: 分析タブだけ見てアプリ終了
↓
お知らせ Var が消滅(メモリ専用)
↓
次回起動 → すでに free →「遷移」ではない → お知らせ Var はセットされない
↓
永遠に表示されない
これがなぜ悪いのか
期限切れのケースはまだましです。機能がロックされれば「ああ終わったのか」とユーザーが察せます。
問題は支払い手段の問題(billing issue)のケースです。
これはユーザーが解約したわけではありません。カードの有効期限が切れた、限度額に達した、といった理由で更新に失敗しただけです。支払い手段を直せばすぐ復旧します。
ところがアプリが何も言わないと、ユーザーはこう思います。
お金は払っているのになぜ機能が使えない? このアプリおかしくないか?
復旧方法があるのにそれを伝えられていない。リリース後は問い合わせに直結し、最悪の場合は低評価レビューになります。
処方 — お知らせをグローバルへ上げる
描画を持たずサイドエフェクトだけを担う Guard コンポーネントを作り、ルートレイアウトに置きました。
/**
* PlanEndedNoticeGuard
*
* プレミアムプランがユーザー操作なしに終了した事実を、タブに関係なく
* グローバルに通知する。
*
* 対象:
* - expired : 利用期間の満了(時間経過・ストアサーバー通知の対象外)
* - billingIssue : 支払い手段の問題による更新失敗
*
* 解約(cancelled)はユーザーの能動的操作なので対象外。復元フローは結果通知が
* 別途表示されるため抑制する。
*
* グローバル配線の根拠:
* 従来は「その他」画面が唯一の消費先だった。お知らせ Var は永続化のない
* インメモリ Var であり、終了判定の根拠は一回性なので判定機会を過ぎると
* 再セットされない。したがって他のタブしか使わないユーザーは、機能が
* ロックされた理由を最後まで知ることができなかった。支払い問題のケースは
* 支払い手段を直せば復旧するため、未到達の損害がより大きい。
*
* オンボーディング未完了時は保留:
* Alert から決済画面へ繋がるため、オンボーディングに重なると第一印象を損なう。
* 消費せず Var を保持し、オンボーディング完了直後に表示される。
*
* 表示と同時に解除(一回性)し、再入・再照会で繰り返し表示されないようにする。
*
* 描画なし(サイドエフェクト専用コンポーネント)。
*/
function PlanEndedNoticeGuard() {
const notice = useReactiveVar(planEndedNoticeVar);
const onboardingCompleted = useReactiveVar(onboardingCompletedVar);
const { t, i18n } = useTranslation();
const openPaywall = useUpgradePrompt();
useEffect(() => {
if (!notice) return;
if (!i18n.isInitialized) return;
// オンボーディング未完了 = 消費せず保留。完了時に本 effect が再実行される。
if (!onboardingCompleted) return;
const key =
notice === "billingIssue"
? "more.plan.premium.endedBillingIssue"
: "more.plan.premium.endedExpired";
// 解除を Alert の前に行う = コールバック遅延・重複レンダによる再表示の防止。
clearPlanEndedNotice();
Alert.alert(t(`${key}Title`), t(`${key}Message`), [
{ text: t("more.plan.premium.endedClose"), style: "cancel" },
{
text: t("more.plan.premium.endedOpenPlan"),
onPress: () => openPaywall(),
},
]);
}, [notice, onboardingCompleted, i18n.isInitialized, t, openPaywall]);
return null;
}
ルートに置きます。これでどのタブにいてもお知らせが届きます。
設計判断三つ
Alert のボタンを二つにした。「閉じる」と「プランを見る」。お知らせだけで終わると、支払い問題のケースで復旧の導線がありません。二つ目のボタンが決済画面へ繋がります。
解除を Alert の前に行う。順序を逆にすると、Alert のコールバックが返る前に再レンダが起きてお知らせが二度出ることがあります。
オンボーディング未完了時は消費せず保留する。return で抜けつつ planEndedNoticeVar はそのまま残します。オンボーディングが終われば onboardingCompleted が変わって effect が再実行され、そのときにお知らせが出ます。消費してはいけません。オンボーディング中に使い切ると、ユーザーは見ないまま終わります。これは前述の未到達問題と同じ罠です。
スポンサーリンク
スポンサーリンク
Web 対応 — 決済ラインのないプラットフォーム
このアプリは Web 版も同じコードベースで動きます。Web には決済ラインがありません。
確認したところ、Web では決済モジュールがスタブに置き換わって planEndedNoticeVar は常に null で、Guard は即座に return します。終了のお知らせはそもそも出ません。正常です。
ただし別の問題がありました。Web でプレミアム機能のボタンを押すと「この機能はプレミアムプラン専用です」と出て終わりでした。Web では決済する方法がないので、ユーザーから見れば行き止まりです。文言をアプリのダウンロード誘導に変えました。
「実装した」と「届いている」は違う
状態をセットするコードを書き終えると、その機能を作り切ったように感じます。私はチェックリストに「○」まで付けました。
グローバル状態でユーザーへのお知らせを作るときは、三つを一緒に確認する必要があります。
| 確認項目 | 見るところ |
|---|---|
| 消費先はどこか | その状態を読んで実際に画面に出すコードが何か所あるか |
| セットの機会は何回か | 毎回再セットされるか、特定の遷移時に一度だけか |
| 見なかったとき何を損するか | 単なる情報案内か、復旧可能な問題の通知か |
この三つを並べると、お知らせの配置場所は「関係がありそうな画面」ではなく「損害の大きさ」で決めるべきだという結論になります。支払い問題の通知は、どのタブにいても届かなければならない種類でした。
スポンサーリンク
スポンサーリンク
この記事について
自社アプリ ahha(React Native + Expo Bare Workflow・RevenueCat サブスク・AdMob)の開発中に踏んだ内容です。
Alert ではなくアプリ内バナーやスナックバーで出す選択肢との転換率比較はしていません。Alert を選んだのは「見逃されては困る通知」という判断によるものです。オンボーディング完了直後の表示が実際に抵抗感が少ないかもユーザーデータで検証しておらず、設計上の判断です。コードは Apollo Client の makeVar ベースですが、問題の構造自体はグローバル状態ライブラリの種類に依存しません。



コメント