先週の朝、運用中の壁紙アプリのクラッシュレポートを整理していて、手が止まりました。上位に並んでいたのは、スライドショー機能の画面遷移まわりのクラッシュです。
急いで分析イベントを確認すると、この機能の30日利用率は 0.7%。ほとんど誰も使っていない機能のために、私はその朝の貴重な時間を使って原因を追っていました。
Rork でアプリを作るようになって、機能を「足す」コストは劇的に下がりました。プロンプトをひとつ書けば、画面がひとつ増えます。けれど「消す」コストは、以前と変わらず重いままです。
壁紙アプリ6本の並行運用を続けるなかで、私は「機能を消す手順」を仕組みとして持つようになりました。使用率の測り方、削る・残す・隠すの判断基準、そして Remote Config を使った三段階の取り下げフロー。順に置いていきます。
機能は無料の在庫ではない — 消すほうが難しい理由
機能がひとつ増えると、増えるのは画面ひとつ分のコードだけではありません。
既存機能との組み合わせ、OS アップデートのたびに確認する箇所、ストア審査で見られる面、サポートで説明する範囲。これらが掛け算で増えていきます。
スライドショー機能の場合、依存していたのは画面遷移・タイマー処理・画像のプリフェッチ・スリープ抑制の4箇所でした。iOS のメジャーアップデートのたびに、この4箇所のどこかが軽く壊れます。利用率 0.7% の機能が、リリース前検証の時間の1割を持っていく構図です。
私自身は、機能数を資産ではなく負債側に置いて数えるようになりました。「この機能は、維持費を払ってでも置いておく価値があるか」。問いの形を変えるだけで、棚卸しの精度はかなり変わります。
Rork のような AI ビルダーの時代は、この問いがいっそう重要になっています。追加が簡単になった分、無自覚に機能が積もっていくからです。
まず10分の機能インベントリ — 計測の前にやること
いきなり計測コードを書く前に、エディタだけでできる棚卸しをおすすめします。手順は3つです。
- アプリのすべての画面とメニュー項目を書き出す(多くのアプリで10〜20個に収まります)
- それぞれに「最後に自分がその機能を使った日」をメモする
- 直近3ヶ月のクラッシュ・問い合わせ・レビューでの言及があれば添える
6アプリでこれをやったところ、機能は合計47項目になりました。そのうち約3分の1には、使用率を判断できる計測がそもそも入っていませんでした。
私の場合、この棚卸しはリリース作業の合間に1アプリずつ進めて、6本で合計2時間ほどでした。個人開発の規模なら、思い立った週のうちに終わる作業です。
自分が3ヶ月触っていない機能は、ユーザーもほとんど使っていない。これは経験則ですが、外れたことがあまりありません。ただし感覚だけで消すのは危険なので、計測のない機能には次のコードを仕込んでから判断します。
使用率を測る最小実装 — イベントを増やさず、パラメータで分ける
機能の使用率を測るとき、機能ごとに専用イベントを作るのは避けています。分析画面がイベントだらけになり、半年後に見返せなくなるからです。
イベントは feature_use の1本に固定し、どの機能かはパラメータで分けます。次のコードは「計測対象の機能を型で固定し、利用回数を月次で集計できるようにする」ための最小実装です。
// featureUsage.ts — 機能利用の計測(イベント1本 + パラメータ方式)
import AsyncStorage from '@react-native-async-storage/async-storage';
import analytics from '@react-native-firebase/analytics';
// 計測対象の機能を型で固定する(タイポと野良イベントを防ぐ)
export type FeatureId =
| 'slideshow'
| 'favorite_export'
| 'double_wallpaper'
| 'search_filter'
| 'widget_config';
const KEY_PREFIX = 'feature_usage_v1';
function monthKey(date = new Date()): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
return `${KEY_PREFIX}:${y}-${m}`;
}
export async function trackFeatureUse(id: FeatureId): Promise<void> {
try {
// 1) 集計基盤へ送る(イベント名は固定・機能名はパラメータ)
await analytics().logEvent('feature_use', { feature_id: id });
// 2) 端末ローカルにも月次カウントを残す(デバッグメニューでの確認用)
const key = monthKey();
const raw = await AsyncStorage.getItem(key);
const counts: Record<string, number> = raw ? JSON.parse(raw) : {};
counts[id] = (counts[id] ?? 0) + 1;
await AsyncStorage.setItem(key, JSON.stringify(counts));
} catch {
// 計測の失敗でアプリ本体を止めない
}
}
呼び出しは、機能の「入口」に1行だけ置きます。
const openSlideshow = () => {
trackFeatureUse('slideshow');
router.push('/slideshow');
};
なぜ入口1点だけなのか。撤去判断に必要なのは「その機能を開いた人の割合」だけだからです。機能内部の細かな操作まで測り始めると、計測自体が維持コストになります。
集計側では feature_use イベントを feature_id で分解し、月間アクティブユーザーに対する利用者の割合を出します。私は30日利用率1%を、最初に引く線にしています。
イベント設計を型で守る方法は Rork製6アプリで分析イベントを型で守る — 共有イベント層の設計メモ に詳しくまとめています。
削る・残す・隠すの判断基準
計測が1〜2ヶ月たまったら、判断に移ります。私が使っている基準は、利用率だけの一軸ではありません。
- 30日利用率が1%未満で、利用者の使用頻度も低い — 撤去候補です。誰のためにも機能していません
- 利用率は1%未満だが、使う人はほぼ毎日使う — 残します。ただしメニューの一等地からは外し、設定画面の奥へ移します。少数のヘビーユーザーに支えられた機能は、レビューへの影響が利用率の見た目より大きいためです
- 利用率は低いが、ストアの訴求素材に使っている — スクリーンショットや説明文との整合を先に解消してから判断します
- 利用率にかかわらず、OS 更新のたびに壊れる — 撤去か作り直しの二択にします。中途半端な延命がいちばん高くつきます
スライドショー機能は1つ目に該当しました。30日利用率 0.7%、利用者の起動回数も月2回未満。一方で、同じく利用率1%前後だった2画面分割の壁紙設定は、使う人が週5回以上使っていたため、導線を奥へ移して残しています。
数字は判断を支えますが、最後の一押しは「自分がこの機能の保守を続けたいか」です。個人開発では、開発者の気力も有限のリソースのうちだと考えています。
三段階で取り下げる — Remote Config による撤去フロー
消すと決めた機能も、次のバージョンでいきなり消すことはしません。三段階に分けます。
- deprecated(告知期) — 導線に「まもなく終了」のバッジを出し、機能を開いたときに終了予定を知らせます。期間は2〜4週間
- removed(導線撤去) — メニューから導線を消します。コードはまだ残します
- コード削除 — 次のストア配信で、コードと依存パッケージを物理的に削除します
段階を Remote Config で制御しておくと、ストア配信を待たずに進められ、問題があれば即座に巻き戻せます。
// useFeatureStage.ts — Remote Config で機能の段階を制御する
import { useEffect, useState } from 'react';
import remoteConfig from '@react-native-firebase/remote-config';
export type FeatureStage = 'active' | 'deprecated' | 'removed';
export function useFeatureStage(featureKey: string): FeatureStage {
const [stage, setStage] = useState<FeatureStage>('active');
useEffect(() => {
remoteConfig()
.fetchAndActivate()
.then(() => {
const value = remoteConfig()
.getValue(`stage_${featureKey}`)
.asString();
if (value === 'deprecated' || value === 'removed') {
setStage(value);
}
})
.catch(() => {
// 取得失敗時は active のまま — ネットワーク起因で機能を消さない
});
}, [featureKey]);
return stage;
}
導線側は、この段階を見て描画を切り替えます。
const stage = useFeatureStage('slideshow');
if (stage === 'removed') {
return null; // メニューに出さない(コードはまだ残っている)
}
if (stage === 'deprecated') {
return (
<MenuItem
label="スライドショー"
badge="まもなく終了"
onPress={openSlideshowWithSunsetNotice}
/>
);
}
return <MenuItem label="スライドショー" onPress={openSlideshow} />;
なぜ三段階にするのか。一気に消すと、低利用率の機能でも「ある日突然消えた」という不信感がレビューに表れるからです。deprecated 期間を置いてから消す運用にしてからは、6アプリ通算で、撤去起因の星1レビューを受けたことがありません。
取得失敗時に active へ倒す設計も大切です。逆向きに倒すと、機内モードのユーザーから機能が消えます。デフォルト値は必ず「現状維持」側に置きます。
撤去時の落とし穴 — 導線の外にある残骸
メニューから導線を消しても、機能への入口はまだ残っています。私が実際に踏んだものを順に挙げます。
ディープリンクとウィジェット。過去のお知らせ通知やウィジェットから、撤去済み画面への直接遷移が残っていることがあります。ルーティングの入口で受け止め、黙ってクラッシュさせずにホームへ逃がします。
// 撤去済み画面への遷移要求はホームへ逃がす
if (route === 'slideshow' && stage !== 'active') {
router.replace('/');
showToast('スライドショー機能は提供を終了しました');
return;
}
保存データ。再生間隔やプレイリストのような機能専用のデータは、コード削除と同時に「読む者のいない孤児データ」になります。容量は小さくても、マイグレーション処理が古いキーを参照し続けていると、後々の調査で混乱します。私は、コード削除の2バージョン後にストレージからも消す運用にしています。
ストア掲載情報。スクリーンショットや説明文に撤去済みの機能が写っていると、ユーザーの誤解を招くだけでなく、審査での指摘対象にもなります。撤去リリースの申請前に、App Store と Google Play 両方の掲載素材の棚卸しをセットで行います。
リリースノート。機能削除には触れずに済ませたくなりますが、私は1行だけ正直に書く派です。「スライドショー機能の提供を終了しました。ご利用いただいていた方には申し訳ありません」。隠して気づかれるより、先に伝えるほうが、結果としてレビューは荒れませんでした。
消した後に測る — 撤去の成否は「変化のなさ」で確認する
撤去は、消して終わりではありません。30日ほど、4つの数字を見ます。
- 関連クラッシュの件数。ゼロになっているはずです
- サポート問い合わせとレビューでの言及。スライドショーの場合は問い合わせ2件、うち復活要望が1件でした
- リテンションと DAU。変化がなければ成功です。下がった場合は、計測に表れないヘビーユーザーを見落としています
- 依存パッケージとビルドサイズ。スリープ抑制用の依存を1つ外せました
撤去の成功は、派手な改善ではなく「何も起きないこと」で確認します。リテンションが変わらず、クラッシュが減り、検証時間が短くなる。地味ですが、これが機能棚卸しの利益です。
浮いた時間は、使われている機能に回します。スライドショーの保守に使っていた時間で、利用率の高い検索まわりの改善を2件出せました。利用率45%の機能への投資は、0.7%への投資よりはるかに確実に返ってきます。
最初の一歩は、計測でも設計でもなく、書き出すことです。今週、ご自身のアプリの機能を10個書き出して、それぞれを最後に触った日を思い出してみてください。3ヶ月以上前の機能がひとつでもあれば、この手順の出番です。
機能を「消す」話を最後まで読んでくださった方は、きっとアプリを長く育てようとしている方だと思います。あなたのアプリの棚卸しが、良い整理になりますように。