月々のサーバ費が数百円、広告収益がそれを下回る月が続く。そういうアプリが手元に1本あるとき、次の一手は機能追加ではありません。
アプリの作り方は無数に語られていますが、畳み方はほとんど語られません。私自身、個人で複数のアプリを並行運用する中で、役目を終えたアプリの扱いを決めるのはいつも自分ひとりでした。誰も指示してくれない代わりに、誰にも急かされない。だからこそ判断が先送りになり、動いていないアプリが固定費だけを積み上げていきます。
Rork で作ったアプリを畳むと決めてから、完全に終了するまでの間には、いくつかの段階があります。その段階の整理と、終了告知・データ持ち出しの実装、自動更新購読の後始末までを、順を追ってまとめました。結論から言えば、畳み方は運用中に1箇所だけ仕込んでおくと、後がまるで違います。
「畳む」には3つの段階がある
畳むという言葉には幅があります。まず段階を分けて、それぞれの費用と可逆性を見比べられるようにしておきます。
段階
やること
継続する費用
可逆性
既存ユーザーへの影響
1. 更新停止
新機能を止め、OS 追従とクラッシュ対応のみ続ける
サーバ費+年会費+最小限の保守時間
高い(いつでも再開できる)
ほぼなし
2. 販売取り下げ
App Store Connect で配信対象から外し、新規ダウンロードを止める
サーバ費+年会費
中(再公開は可能だが検索順位は戻らない)
既存インストールはそのまま動き続ける
3. 完全終了
サーバを停止し、アプリ内で終了を告知する
ほぼゼロ(静的配信のみ残す選択肢あり)
低い
オンライン機能が使えなくなる
見落とされがちなのは段階2と3の間の距離です。販売を取り下げても、既存ユーザーの端末ではアプリが動き続けます。サーバに依存する機能があるなら、取り下げから完全終了までの間に、告知とデータの持ち出し期間を挟む必要があります。ここを飛ばすと、ある日突然アプリが動かなくなったという体験だけが残ります。
判断のしきい値は数字で先に決めておく
畳む判断がずるずる延びるのは、しきい値を決めていないからです。私は月次で次の2つを見るようにしております。
1つ目は固定費です。サーバ費、ドメイン費、Apple Developer Program の年会費 99 ドルをアプリ数で按分した額。ここに OS のメジャーアップデート追従にかかる作業時間を時給換算で足すと、動いていないアプリでも月数千円の維持費になることが分かります。
2つ目は収益の趨勢です。単月ではなく3ヶ月移動平均で見ます。広告収益は季節や eCPM の変動で単月なら簡単に上下するためです。3ヶ月平均が固定費を下回り、かつ下降傾向が続いているとき、段階1に入る。私はこの線引きにしてから、判断で迷う時間が目に見えて減りました。
損益の計算式そのものは Rork Max の月200ドルは自分のアプリで元が取れるか、計算式で判断する で使ったものと同じ構造です。入れる数字が「導入の是非」から「撤退の是非」に変わるだけで、式は使い回せます。
なお、アプリ全体ではなく一部の機能だけが重荷になっている場合は、畳むのではなく削る選択肢を先に検討する余地があります。機能単位の撤去は 使われない機能をいつ、どう削るか — Rork 製アプリの機能棚卸しと安全な撤去の設計 に書いた三段階フローが使えます。
終了告知はサーバ由来のフラグで出す
段階3で最も重要なのが、アプリ内での終了告知です。ストアの説明文を書き換えても既存ユーザーには届きません。アプリ自身が「このアプリはいつ終了するか」を知る口が要ります。
ここで Firebase Remote Config を使う手もありますが、私は畳む予定のアプリには CDN 上の静的 JSON を勧めます。理由は2つあります。終了に向かうアプリに新しい SDK 依存を足したくないこと。そして静的 JSON なら、サーバ本体を停止した後も配信だけを数年単位でほぼ無料で残せることです。Cloudflare の静的ホスティングに JSON を1枚置くだけなら、費用は事実上ゼロです。
// app-status.ts — 配信側は CDN 上の静的 JSON 1枚
import AsyncStorage from "@react-native-async-storage/async-storage" ;
export type SunsetMode = "active" | "notice" | "readonly" | "ended" ;
export interface AppStatus {
mode : SunsetMode ;
message ?: string ; // 告知文(端末ロケールで日英を出し分け)
exportUntil ?: string ; // データ持ち出しの期限(ISO 8601)
learnMoreUrl ?: string ; // 詳細案内ページ
}
const STATUS_URL = "https://static.example.com/app-status/my-app.json" ;
const CACHE_KEY = "app_status_cache_v1" ;
export async function fetchAppStatus () : Promise < AppStatus > {
try {
const res = await fetch ( STATUS_URL , {
headers: { "cache-control" : "no-cache" },
});
if ( ! res.ok) throw new Error ( `status ${ res . status }` );
const status = ( await res. json ()) as AppStatus ;
await AsyncStorage. setItem ( CACHE_KEY , JSON . stringify (status));
return status;
} catch {
// 取得失敗時は前回値へフォールバック。
// 前回値もなければ active 扱いにして通常起動を守る
const cached = await AsyncStorage. getItem ( CACHE_KEY );
return cached ? ( JSON . parse (cached) as AppStatus ) : { mode: "active" };
}
}
フェッチに失敗したら通常起動に倒す、という向きが大切です。逆に倒すと、CDN の一時障害でアプリ全体が終了画面になります。告知は確実性より安全側を優先します。
起動側は mode で分岐させるだけです。
// App.tsx — 起動時に一度だけ判定する
const [ status , setStatus ] = useState < AppStatus >({ mode: "active" });
useEffect (() => {
fetchAppStatus (). then (setStatus);
}, []);
if (status.mode === "ended" ) {
return < SunsetScreen status = { status } />; // 終了画面(持ち出し導線つき)
}
return (
<>
{ status.mode !== "active" && < SunsetBanner status = { status } /> }
< MainApp readonly = { status.mode === "readonly" } />
</>
);
notice は通常どおり使える状態でバナーだけ出す段階、readonly は書き込み系を止めて閲覧と持ち出しだけ残す段階です。JSON の中身を書き換えるだけで、アプリの審査を通さずに段階を進められます。畳むと決めたアプリに最後のアップデートを1回だけ出し、この口を仕込む。私の場合、この最後の1回を惜しんだアプリほど、後始末に時間を取られました。
データの持ち出し口をつける
お気に入りや設定など、ユーザーの端末に溜まったデータには持ち出し口を用意します。実装は小さく、JSON を書き出して共有シートに渡すだけで足ります。
// export-user-data.ts
import AsyncStorage from "@react-native-async-storage/async-storage" ;
import * as FileSystem from "expo-file-system" ;
import * as Sharing from "expo-sharing" ;
export async function exportUserData () : Promise < void > {
const favorites = await AsyncStorage. getItem ( "favorites_v2" );
const settings = await AsyncStorage. getItem ( "settings_v1" );
const payload = {
exportedAt: new Date (). toISOString (),
schemaVersion: 2 ,
favorites: favorites ? JSON . parse (favorites) : [],
settings: settings ? JSON . parse (settings) : {},
};
const uri = FileSystem.cacheDirectory + "my-data-export.json" ;
await FileSystem. writeAsStringAsync (uri, JSON . stringify (payload, null , 2 ));
await Sharing. shareAsync (uri, { mimeType: "application/json" });
}
書き出す JSON にスキーマバージョンを含めておくと、後継アプリを出したときの取り込みが素直になります。移行先がなくても、手元にデータが残るという事実そのものが、終了の受け止められ方を変えます。
自動更新購読の後始末
課金、とりわけ自動更新購読があるアプリは、畳む順序が一段慎重になります。販売を取り下げても、既存の購読は解約されるまで更新が続くためです。順序はこうなります。
まず、アプリ内の購入導線とペイウォールを sunset フラグで閉じ、新規の購読開始を止めます。次に App Store Connect 側でサブスクリプション商品の新規提供を終了します。既存購読者には、アプリ内告知とサポートページで終了予定日と解約手順を案内します。解約はユーザー自身の操作になるため、案内は早いほど誠実です。
最後まで残った購読者にどう向き合うかは、金額よりも姿勢の問題だと考えております。提供できるサービスが実質なくなった状態で更新だけ続くことがないよう、終了予定日は購読の更新周期を考慮して、少なくとも1周期以上先に置く。返金の個別判断は Apple の仕組みに委ねる範囲が大きいものの、こちらから案内を尽くしたかどうかは、最後のレビュー欄にそのまま表れます。
サーバは一気に止めず、縮退させる
サーバ側は電源を切るのではなく、段階的に縮退させます。
順序
状態
やること
1
書き込み停止
POST 系エンドポイントを 410 で返し、read-only にする
2
静的化
読み取り系のレスポンスを静的スナップショットにして CDN 配信へ差し替える
3
停止
オリジンを削除。app-status.json と静的スナップショットだけ残す
Cloudflare Workers を使っているなら、順序2は Worker のレスポンスを KV や静的アセットの固定値に差し替えるだけで済みます。動的なバックエンドを止めても、最後に配っていたコンテンツのスナップショットを静的に残せば、既存ユーザーの体験は緩やかにしか劣化しません。この「ほぼ無料で数年残す」選択肢があることが、静的 JSON 方式を選ぶもう1つの理由です。
取り下げたあとに残るもの
販売取り下げのボタンを押す瞬間は、何度やっても指が少し重くなります。ただ、押した後に残るものを知っておくと、その重さは幾らか軽くなります。
既存ユーザーの端末では、アプリは動き続けます。サポート URL とプライバシーポリシーのページは、既存ユーザーがいる限り残しておきます。ここを消すと、問い合わせの行き場がなくなります。開発者アカウントを維持していれば、取り下げたアプリを同じ App ID で再公開することもできます。取り下げは廃棄ではなく、休止に近い操作です。
そして運用中のアプリへの一番の還元は、この経験を先回りして仕込めることです。いま動いているアプリに app-status.json を読む口を1つ足しておく。使う日が来ないならそれが一番ですが、来たときには、最後のアップデートを審査に通す時間の余裕が、そのまま告知の丁寧さに変わります。
畳む日の設計まで含めて、アプリの設計だと考えております。長く運用するほど効いてくる備えとして、参考にしていただければ幸いです。