お疲れ様です!IT業界で働くアライグマです!
個人開発している開発関連ニュースのキュレーションサービス「DevPick」のモバイルアプリに、端末内ブックマークのバックアップ・復元機能を実装しました。保存した記事を持ち出す手段がなかったことへの対応ですが、今回は「戻すときに、今あるデータをどう扱うか」が中心です。2026年10月11日に追加した実装をもとに、書き出しと取り込みをどう設計したかを振り返ります。
端末内のブックマークは、アカウントを戻しても復元されない
アカウント情報と端末内データの保存場所を分けて考える
DevPickには、サーバー側で管理する「いいね」「あとで読む」とは別に、モバイルアプリの端末内へ保存するブックマークがあります。記事の情報や要約、保存した日時を端末に保持する仕組みで、保存できる上限は200件です。
ここで区別したいのが、アカウントの復旧と、端末内データの復元は別の処理だということです。メールアドレスを使ってアカウントを復旧しても、サーバーに送っていないブックマークは戻りません。ログアウト時には端末内のブックマークも削除されるため、その前に持ち出せる手段が必要でした。
機種変更やログアウトの前に手動で持ち出す
今回追加したのは、端末内の一覧を書き出し、後から手動で取り込む機能です。複数の端末を常に同じ状態にする自動同期ではありません。機種変更やアプリの再インストール、ログアウトの前に、利用者がバックアップを残す使い方を想定しています。
書き出すデータには、記事の情報だけでなく、要約や保存日時も含めています。ただ記事へのリンクを集め直すのではなく、保存していた情報を取り込めるようにするためです。
この記事では追加した実装を説明します。配布済みアプリでの利用可否や、実機での機種変更を完了した結果として紹介するものではありません。
IT女子 アラ美取り込み前に、形式と追加される件数を確認する
ネイティブ版はJSONを共有し、Web版はファイルへ保存する
バックアップにはJSONを使いました。JSONは、名前と値の組でデータを表すテキスト形式です。今回は形式のバージョン、書き出し日時、件数、ブックマークの一覧をまとめています。形式のバージョンを持たせることで、対応していない形式をそのまま取り込まずに済みます。
ネイティブ版の書き出しでは、OSの共有画面へJSONのテキストを渡します。ファイルを添付する実装ではないため、共有先でそのテキストを保存しておく使い方です。Web版ではJSONファイルとしてダウンロードします。取り込みはJSONの貼り付けに対応し、Web版ではファイル選択も使えるようにしました。
共有先を選ぶ前には、個人向けのRSSから保存した記事などが含まれ得ることを画面で案内します。内容を外に持ち出す機能なので、何が入っているかを意識して保存先を選べることも大切です。
形式の確認後に、実際に増える件数を表示する
読み込んだJSONは、対応するバージョンか、一覧が配列になっているか、各項目に記事IDや保存日時など必要な形があるかを調べます。これは取り込みに必要な最低限の構造を確認する処理で、記事内のすべての値を検証するものではありません。JSONとして読めない場合や形式が合わない場合は、取り込みへ進めません。
形式を確認できた後は、新しく追加される件数、すでに保存されている件数、取り込み後の合計を画面に表示します。たとえば、取り込み対象が5件でも、そのうち3件が保存済みなら、新しく増えるのは2件です。同じ記事IDがバックアップ内で繰り返されている場合も、1件として数えます。
空の一覧も、JSONの形式としては有効です。ただし追加できる記事がないため、画面では空であることを伝え、取り込みボタンを無効にします。「形式が壊れている」と「正常に読めたが0件」を分けて扱いました。書き出し、取り込み前の表示、保存処理は、今回の同じ変更で追加しています。



保存済みの記事を残し、200件を超える取り込みは中断する
同じ記事IDはまとめ、現在の情報を上書きしない
復元先が空とは限りません。バックアップを作った後に同じ端末で記事を追加することも、新しい端末で先に別の記事を保存することも考えられます。古い一覧で丸ごと置き換えると、その間に増えたブックマークが失われます。
そこで、今ある記事を残し、バックアップにしかない記事を追加する形にしました。同じ記事かどうかは記事IDで判断します。すでに保存されているIDなら、バックアップ側のタイトルや要約、保存日時で上書きしません。バックアップの中に同じIDが複数ある場合は、先に出てきた項目を残します。
次は、この判断を単純化したTypeScriptの例です。バックアップ内の重複を除いた後の一覧を受け取り、現在の一覧との重複と上限を確認してから、保存する一覧を作ります。
type Bookmark = { id: number; title: string };
function mergeBookmarks(current: Bookmark[], incoming: Bookmark[]) {
const savedIds = new Set(current.map(article => article.id));
const additions = incoming.filter(article => !savedIds.has(article.id));
if (current.length + additions.length > 200) {
throw new Error("保存上限を超えるため、取り込みを中断します");
}
return [...additions, ...current];
}
重複を除いた合計が上限を超えたら、全体を中断する
たとえば、現在190件ある端末へ、未保存の15件を取り込もうとすると合計は205件です。この場合は保存処理に進まず、現在の190件をそのまま残します。空いている10件分だけを取り込んだり、古い記事を自動で消したりはしません。利用者が整理してから、もう一度取り込む流れになります。
一方、バックアップが15件でも、そのうち5件が保存済みなら、追加は10件なので合計200件で取り込めます。上限と比べるのは、バックアップの見かけの件数ではなく、重複を除いた後の合計です。
テストには、既存の記事との重複、バックアップ内の重複、空の一覧を渡しても既存の記事が残ることを入れています。190件に未保存の15件を追加しようとした場合も、エラーになり、元の190件が変わらないことを確認する項目があります。



通常の追加・削除と重なるため、保存直前に読み直す
確認画面を開いた後にも、保存件数は変わる
取り込み前の表示は、その画面が持っているブックマークの一覧をもとに計算します。ただし、画面に件数が出た時点と、実際に保存する時点の状態が同じとは限りません。表示後に通常の保存操作が行われれば、空き件数も変わります。
たとえば、現在190件で未保存の10件を追加できると表示した後に、通常の操作で1件増えたとします。表示時点の判定をそのまま信じて保存すると、合計201件になってしまいます。取り込み前の表示は説明用、保存直前の確認は実行可否の判断用として扱う必要があります。
既存の待ち行列で操作を順番に処理する
DevPickの保存処理には、もともと操作を順番に処理する待ち行列があります。今回は取り込みもそこへ載せ、順番が来た時点で端末内の最新の一覧を読み直すようにしました。その一覧をもとに、重複と200件上限を再計算します。画面で取り込めそうに見えていても、その後の変更で上限を超えていれば、保存前に中断します。
待ち行列を使う理由は、追加と削除がそれぞれ古い一覧を読んで、別々の結果を書き戻す事態を避けるためです。取り込み、通常の追加、通常の削除を同じ順番で処理すれば、先に終わった操作の結果を次の操作が読み取れます。この仕組みは今回新しく作ったものではなく、既存の保存処理を取り込みにも使っています。
テストにも、取り込みと通常の追加・削除を同時に要求する項目があります。取り込んだ記事と通常操作で追加した記事が残り、削除対象の記事が消えることを確かめる内容です。ただし、この待ち行列が扱うのは同じアプリ実行環境内の操作です。Web版を別のタブで開いた場合まで、同じ待ち行列で調整できるわけではありません。
以前のバックアップから実際に復元できるかを検証した話では、「保存できたこと」と「戻せること」を分けて考えました。今回はそこに、復元先にある最新の一覧も守る、という観点が加わります。



まとめ
DevPickの端末内ブックマークには、アカウント復旧とは別に、手動でバックアップを残して取り込む経路を追加しました。復元では今ある一覧を保ち、未保存の記事だけを追加します。件数は取り込み前に表示し、保存直前にも最新の一覧で重複と200件上限を確認します。
今回の設計で大切にしたのは、バックアップを戻すときにも、復元先で増えたデータを守ることです。上限を超えたら保存前に全体を中断する方針も、その一部です。復元機能を作るときは、空の保存先へ戻す場合だけでなく、すでにデータがある場合の扱いまで決めておきたいと考えています。












