0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

iOSローカル通知の64件制限に、起動時全再構築で対処した話

0
Posted at

対象読者・この記事で分かること

  • Expo/React Nativeでローカル通知を扱っていて、通知が増えたときの上限が気になっている人
  • 海外に持ち出しても日本時間で発火する通知を作りたい人
  • 日本株の営業日計算(T+2、祝日、年末年始)を実装しようとしている人

はじめに

株主優待のクロス取引(つなぎ売り)を管理するiOSアプリ「クロス手帳」を作りました。Expo/React Native + TypeScript製で、サーバーを持たない完全ローカル動作のアプリです。

このアプリの存在意義は、ほぼ通知の一点にあります。優待クロスは「権利付き最終日までに現物買いと売建を両方持ち、権利落ち日に現渡しする」という手順を踏むのですが、最後の現渡しを忘れると、余計な貸株料を払い続けたり、意図しない値動きのリスクを抱えることになります。日付は銘柄ごとにバラバラで、スプレッドシートで管理していても「今日が何の日か」は自分で見に行かないと分かりません。

だから「その日の朝にiPhoneが教えてくれる」ことに全振りしました。逆に言うと、通知が来なければこのアプリを使う理由がゼロになります。

ところが、iOSのローカル通知にはアプリあたり最大64件という上限があります。しかも65件目以降は例外も警告もなく、静かに無視されます。1銘柄あたり最大3件の通知を出す設計なので、22銘柄で破綻する計算でした。

この記事では、その制限にどう対処したか、そして実際に踏んだ罠を書きます。

なお筆者はSIer歴約20年のシステムエンジニアで、iOSアプリの個人開発は2本目です。1本目は診断アプリを7月にリリースしましたが、8日間のダウンロード数は1件(自分の分)でした。

前提: 何件の通知が必要になるか

「クロス手帳」の通知設計は3種類です。

通知の種類 発火タイミング 目的
権利付き最終日 前夜 前日 20:00(変更可) 翌日の予約注文を準備する
権利付き最終日 当日朝 当日 8:00(変更可) 現物買い・売建の最終チェック
権利落ち日 当日朝 当日 8:00(変更可) 現渡しを実行する

1銘柄あたり最大3件。優待クロスを本格的にやっている人は、権利確定月が集中する3月・9月に30〜50銘柄を扱うこともあります。50銘柄なら150件で、上限の倍以上です。

素朴に実装するとこうなります。

// ❌ この方式は静かに壊れる
async function onRecordSaved(record: CrossRecord) {
  const notifications = buildNotifications(record);
  for (const n of notifications) {
    await scheduleNotification(n);
  }
}

21件目までは正常に動きます。22件目から、どの通知が登録されなかったかを誰も知らない状態になります。iOSはエラーを返しません。ユーザーが気づくのは、通知が来なかった日の翌朝です。

設計方針

通知の信頼性を機能の豊富さより優先する

このアプリでは「通知が確実に来ること」を最優先の設計原則として、プロジェクトの仕様書(CLAUDE.md)の冒頭に書いています。機能追加を検討するときは、それが通知の信頼性を下げないかを先に確認する、というルールです。

64件制限への対処は、この原則の最初の適用先でした。

部分的に壊れるより、全体が一貫していることを選ぶ

「上限に近づいたら古いものから消す」といった漸進的な管理も考えましたが、状態が複雑になるほど「今何件登録されていて、どれが有効か」の把握が難しくなります。

そこで毎回すべて作り直す方式にしました。状態を持たず、その時点の全レコードから毎回同じ結果を導出する。冪等な処理にすることで、「何が登録されているか」を考える必要がなくなります。

実装: 起動時全再構築方式

やっていることは単純です。

アプリ起動時(およびレコードの追加・変更・削除時):
  1. 全レコードから通知候補を算出する
  2. 発火時刻の昇順にソートする
  3. 先頭から最大60件を選ぶ
  4. 保留中の通知を全キャンセルする
  5. 選んだ60件を登録する

上限を64ではなく60にしているのは安全マージンです。後述するテスト通知など、想定外の通知が枠を使う可能性を考慮しました。この値は MAX_PLANNED_NOTIFICATIONS として src/services/notificationSelection.ts:15 で定義し、通知種別を増やすときに再検討する対象だとコメントで明記しています。

算出を全部終えてからキャンセルする

上の手順で地味に重要なのが、4番(キャンセル)が3番(算出)の後にあることです。

// src/services/notificationScheduler.ts:99-142
async function runRebuild(records: CrossRecord[]): Promise<RebuildResult> {
  // 1. まず全候補を算出しきる。この時点では既存の通知に触っていない
  const { planned, skippedRecordIds } = selectNotifications(records, new Date());
 
  // 2. 権限を確認してから、キャンセル → 登録
  const all = await Notifications.getAllScheduledNotificationsAsync();
  await Promise.all(
    all
      .filter((n) => !n.identifier.startsWith(TEST_NOTIFICATION_ID_PREFIX))
      .map((n) => Notifications.cancelScheduledNotificationAsync(n.identifier)),
  );
 
  for (const notification of planned) {
    await Notifications.scheduleNotificationAsync(toRequest(notification));
  }
 
  return { planned, skippedRecordIds };
}

直感的には「先に全部消してから登録し直す」と書きたくなります。ですが、その順序だと算出処理が途中で例外を投げた場合に、通知が1件もない状態が残ります。

算出を先に済ませておけば、途中で失敗しても「古い通知がそのまま残っている」という状態で止まります。日付が多少古くても、通知がゼロよりはるかにマシです。失敗したときにどちらへ倒れるかを決めておく、という話です。

レコード単位でtry/catchする

権利日計算は、後述する祝日テーブルの範囲外で例外を投げる設計にしています。1銘柄の計算が失敗したときに、他の49銘柄の通知まで巻き添えにするわけにはいきません。

// src/services/notificationSelection.ts:196-200
export function selectNotifications(records: CrossRecord[], now: Date) {
  const candidates: Candidate[] = [];
  const skippedRecordIds: string[] = [];
 
  for (const record of records) {
    try {
      candidates.push(...buildRawCandidates(record, now));
    } catch {
      // 1件の計算失敗で他レコードの通知を巻き添えにしない
      skippedRecordIds.push(record.id);
    }
  }
 
  // ...グルーピング・ソート・上限での絞り込み
}

スキップしたレコードのIDは skippedRecordIds として戻り値(RebuildResult)に含めて、呼び出し側で把握できるようにしています。「静かに落ちる」のを避けるためです。

同一(種別・発火時刻)でグルーピングする

同じ権利確定月の銘柄を複数持っていると、「権利落ち日 当日朝」の通知が銘柄の数だけ発生します。8:00に5件の通知が並ぶのは、情報量としてはノイズです。

そこで、同一の(通知種別, 発火時刻)を1件にまとめました。グルーピングのキーは `${candidate.kind}:${candidate.fireAt.getTime()}` です。

// グルーピング前: 5件が同時刻に発火
8:00 本日は権利落ち日です / イオン (8267) 100株 現渡しを実行しましょう
8:00 本日は権利落ち日です / すかいらーくホールディングス (3197) 100株 ...
8:00 本日は権利落ち日です / ANAホールディングス (9202) 200株 ...
8:00 本日は権利落ち日です / ビックカメラ (3048) 100株 ...
8:00 本日は権利落ち日です / 吉野家ホールディングス (9861) 100株 ...
 
// グルーピング後: 1件
8:00 本日は権利落ち日です / イオン ほか4銘柄 現渡しを実行しましょう

結果として、60件の枠は実運用ではほぼ余ることになりました。実際にサンプルデータ8件(ステータスを分散させたもの)で試したところ、スケジュールされたのは十数件で、枠の4分の1未満でした。

グルーピングを入れる前提なら上限をもっと下げてもよかったかもしれませんが、通知種別を増やす余地を残して60のままにしています。

海外でも日本時間で発火させる

ここは制限とは別の話ですが、通知の信頼性という意味では同じくらい重要でした。

優待クロスの現渡しは、日本市場の寄り付き前に注文を入れる必要があります。出張や旅行で海外にいるときに現地時間の8:00に通知が来ても、日本ではとっくに市場が動いています。

UNCalendarNotificationTrigger を使うとズレる

expo-notifications で日付を指定して予約すると、内部的には UNCalendarNotificationTrigger が使われます。

// ❌ 端末のタイムゾーンの8:00に発火してしまう
await Notifications.scheduleNotificationAsync({
  content: { title: '本日は権利落ち日です', body: '...' },
  trigger: {
    type: Notifications.SchedulableTriggerInputTypes.CALENDAR,
    year: 2026, month: 7, day: 27,
    hour: 8, minute: 0,
  },
});

この書き方だと「端末のローカルタイムの8:00」に発火します。ニューヨークにいれば EST 8:00 = JST 22:00 で、13時間遅れです。

時間間隔トリガーに変える

代わりに UNTimeIntervalNotificationTrigger、つまり「今から何秒後」を指定する方式にしました。

// src/services/notificationScheduler.ts:150-161
const seconds = Math.max(
  1,
  Math.round((notification.fireAt.getTime() - now.getTime()) / 1000),
);
 
await Notifications.scheduleNotificationAsync({
  content: { title: '本日は権利落ち日です', body: '...' },
  trigger: {
    type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
    seconds,
    repeats: false,
  },
});

秒数の計算で Math.max(1, ...) としているのは、発火時刻が現在時刻とほぼ同じになったときに 0 や負数を渡さないためです。

+09:00 を文字列に埋め込むことで、端末がどのタイムゾーンにあっても「JSTの2026年7月27日8:00」という絶対的な瞬間までの秒数が正しく求まります。時間間隔トリガーは相対時間なので、タイムゾーンの影響を受けません。

この方針は仕様書にも「日付指定トリガーへ変更してはならない」と理由付きで明記しました。後から見たときに「なぜ回りくどい書き方をしているのか」が分からないと、善意でリファクタされてしまうためです。

ドメイン層では Date を使わない

これを支えるために、ドメイン層の日付I/Oは 'YYYY-MM-DD' 形式の文字列(DateStr 型)で統一しました。

// src/types/models.ts:6
export type DateStr = string; // 'YYYY-MM-DD'

Date オブジェクトを生成するのは、通知をスケジュールするときなど「日時が必要な境界」だけです。ドメイン層で Date を使うと、getMonth() や setDate() のような端末タイムゾーン依存の演算をうっかり書いてしまいます。文字列で持っている限り「7月27日は7月27日」であって、タイムゾーンの出る幕がありません。

権利日計算: T+2と営業日判定

通知の発火日を決めるには、権利付き最終日と権利落ち日を計算する必要があります。ルール自体は単純です。

権利付き最終日 = 実効基準日の2営業日前
権利落ち日     = 権利付き最終日の翌営業日

計算手順はこうなります。

1. 権利確定日を決める(デフォルトは月末。20日確定などにも対応)
2. 確定日が非営業日なら、その日以前の直近営業日へ繰り上げる(= 実効基準日)
3. 実効基準日から営業日を2つ遡る → 権利付き最終日
4. その翌営業日 → 権利落ち日

面倒なのは営業日の判定のほうでした。

非営業日の定義

非営業日 = 土日 + 国民の祝日(振替休日を含む) + 東証休場(12/31〜1/3)

12月30日(大納会)は営業日です。 東証の年末年始休場は12/31〜1/3であって、12/30は含まれません。ここを間違えると12月確定銘柄の計算が全部ズレます。実装初期に一度バグを出した箇所でもあります。

祝日は静的テーブルで持つ

祝日を計算式で求めることはしませんでした。内閣府が公開しているCSVを元にした静的テーブルをアプリに同梱しています。

// src/constants/holidays.ts:69
const HOLIDAY_DATES = [
  '2026-01-01', // 元日
  '2026-01-12', // 成人の日
  '2026-02-11', // 建国記念の日
  '2026-02-23', // 天皇誕生日
  '2026-03-20', // 春分の日
  // ...
];
 
export const HOLIDAYS: ReadonlySet<string> = new Set(HOLIDAY_DATES);

計算式にしなかった理由は2つあります。

  1. 法改正や特例で動く。春分の日・秋分の日は天文計算で近似できますが、確定するのは前年2月の官報公告です。2020年の東京オリンピック特措法のように、既存の祝日が丸ごと移動する例もあります
  2. 誤りがサイレントに実損につながる。祝日を1日間違えると権利付き最終日がズレ、ユーザーは間違った日に注文を出します
    テーブルの収録範囲は HOLIDAY_TABLE_START / HOLIDAY_TABLE_END としてexportし、範囲外の日付を渡された場合は例外を投げます。
// src/domain/businessDay.ts:17-19
if (!isWithinHolidayTableRange(date)) {
  throw new Error(
    `祝日テーブルの収録範囲外の日付です: ${date}(収録範囲: ${HOLIDAY_TABLE_START}〜${HOLIDAY_TABLE_END})`,
  );
}

エラーメッセージに収録範囲そのものを含めているのは、開発時に「いつまでのデータが入っているのか」をログだけで判断できるようにするためです。

UI側にも isWithinHolidayTableRange() による事前チェックを入れて、範囲外の月を登録しようとしたら分かりやすい警告を出すようにしています。ただしこれは親切のためのチェックであって、ドメイン層の例外の代替ではありません。繰り上げや遡りの計算途中で範囲外に出てしまうケースもあるので、最終防衛線は残しておく必要があります。

サイレントに誤答を返すくらいなら、例外で落ちたほうがましだという判断です。

テストケース

営業日計算のテストは、手計算で確認した値を期待値にしました。

// 通常月末: 2026/3/31(火)確定
//   → 実効基準日 3/31 → 最終日 3/27(金) → 権利落ち 3/30(月)
 
// 月末が休日: 2026/5/31(日)確定
//   → 実効基準日 5/29(金) → 最終日 5/27(水) → 権利落ち 5/28(木)
 
// 年末: 12月末確定
//   → 実効基準日 12/30(大納会) → 12/31〜1/3をスキップ
 
// GW跨ぎ: 4月末確定
//   → 4/29・5/3〜5/6をスキップ
 
// 特定日確定が土日に当たるケース

「大納会」と「GW跨ぎ」は、書いてみると当たり前なのに実装で落とすタイプのケースでした。

踏んだ罠3選(本編)

ここからが本題です。設計通りに作ったつもりでも、実際に動かすと事件は起きます。

罠1: テスト通知が再構築に巻き込まれて消える

App Storeの審査対策として、設定画面に「テスト通知を送る」機能を入れました。通知が主機能のアプリなのに、審査員は権利確定日を再現できません。「機能が確認できない」でリジェクトされるリスクを潰すためです。

実装して動かしてみると、テスト通知が届いたり届かなかったりするという現象が起きました。

原因は自分で書いた再構築処理でした。テスト通知を予約した直後にレコードを更新すると、再構築が走って「保留中の通知を全キャンセル」が実行され、テスト通知ごと消えていたのです。

対策として、テスト通知には専用のidentifierプレフィックスを付け、キャンセル対象から除外しました。

// src/services/notificationScheduler.ts:25, 119-125
export const TEST_NOTIFICATION_ID_PREFIX = 'test-';
 
// runRebuild 内のキャンセル処理
const all = await Notifications.getAllScheduledNotificationsAsync();
await Promise.all(
  all
    .filter((n) => !n.identifier.startsWith(TEST_NOTIFICATION_ID_PREFIX))
    .map((n) => Notifications.cancelScheduledNotificationAsync(n.identifier)),
);

この除外ロジックは呼び出し元を区別しない汎用処理なので、レコード保存時でも設定変更時でも同じように効きます。「テスト通知を送るときだけ気をつける」ではなく、再構築処理そのものがテスト通知を触らないという形にしました。

あわせて、テスト通知は MAX_PLANNED_NOTIFICATIONS の枠にカウントしないことにしました。テスト用の1件が実際のリマインダーを1件押し出すのは本末転倒だからです。この判断はコードにコメントとして残しています。

自分で書いた「全キャンセル」が、自分で書いた別の機能を壊すというのは、モジュールが増えたときに起きがちな事故だと思います。

罠2: 表示上の空白が、データの偶然に依存していた

ホーム画面で銘柄名と証券コードを並べて表示しているのですが、リストを眺めていて表示が揃っていないことに気づきました。

オリックス (8591)   ← 空白があるように見える
KDDI(9433)          ← 詰まって見える

最初はシードデータの銘柄名に末尾スペースが混ざっているのだろうと考えました。実際に全銘柄の文字列を検査したところ、空白は1件も含まれていませんでした。

原因はテンプレート側でした。

// src/components/ScheduleListItem.tsx
{record.stockName}({record.stockCode})

区切り文字が1つもありません。つまり見た目の空白は、全角文字(オリックス)と半角文字(KDDI)の字幅の違いによるものでした。全角文字はグリフの右側に余白を含んだ幅を持つため、半角の ( が続くと空いて見えます。

この実装の問題は、見た目がデータの中身に依存していることです。今はたまたま揃っていなくても目立たない程度ですが、将来インポート機能などで末尾スペース付きのデータが入れば、表示が明確に崩れます。

関数に切り出して解決しました。

// src/utils/stockLabel.ts:6
export function formatStockLabel(stockName: string, stockCode: string): string {
  return `${stockName.trim()} (${stockCode})`;
}

表示時に trim() を通すことで、万一空白付きのデータがあっても表示は必ず統一されます。同じ連結パターンが「今日やること」カードと通知本文にもあったので、そちらにも展開しました。

診断を間違えたこと自体も学びでした。「データが汚れているのだろう」と決め打ちせず、実際に測ってから直すべきでした。

罠3: 全ピクセル不透明のPNGでも、アイコンで弾かれる可能性がある

App Storeのアイコンでアルファチャンネルが原因のリジェクト(ITMS-90717)はよく知られています。申請前チェックとして assets/icon.png を検査したところ、こうなっていました。

サイズ:         1024x1024
カラータイプ:   6 (RGBA)
アルファ値:     全1,048,576ピクセルが255(完全不透明)

見た目には透過部分が一切ありません。それでも、PNGのフォーマットとしてアルファチャンネルが存在すること自体を理由に弾かれる可能性があります。

判明するのはビルドをアップロードした後です。EAS Buildの無料枠は月15回で、しかも2つのアプリで共有していたため、ここでリジェクトされると枠を2本失う計算でした(弾かれた分と、修正後の分)。

「たぶん大丈夫」に枠2本を賭ける理由がなかったので、RGBに変換しました。全ピクセルのアルファが255であることを事前に確認していたため、背景色との合成が不要で、各ピクセルからAバイトを落とすだけの純粋なフォーマット変換になります。見た目が1ピクセルも変わらないことが保証される、いちばん安全な部類の操作です。

Node標準の zlib だけでスクリプトを書き、変換前後のRGB値が全ピクセル一致することを検証しました。

変換前: カラータイプ6 (RGBA) / 645,724 bytes
変換後: カラータイプ2 (RGB)  / 544,161 bytes
RGB一致検証: 1,048,576 / 1,048,576 ピクセル(不一致 0件)

ついでにカラープロファイルのチャンク(iCCP / sRGB / gAMA)も変換前後で比較しました。元画像がどちらも未タグだったため、失われた情報はありませんでした。もし元が Display P3 のような広色域プロファイル付きだった場合は、単純な変換ではなく元のデザインデータからsRGBで書き出し直す必要があります。

EAS無料枠(月15回)とのつきあい方

上の罠3に関連して、ビルド枠の話も書いておきます。

EAS Buildの無料プランはiOSビルドが月15回までです。個人開発には十分に見えますが、アプリ2本で共有していたため実質的な余裕は多くありませんでした。

やったことは3つです。

ネイティブ依存を追加しない。 Expoのマネージドワークフローでは、JSの変更はdevelopment buildに即座に反映されます。再ビルドが必要になるのはネイティブ依存を追加・変更したときだけなので、機能を検討する段階で「これはネイティブモジュールが要るか」を毎回確認しました。要るなら、そのタスクにビルド1本分のコストが乗ることになります。

development buildは1本だけ作って使い回す。 通知の検証はExpo Goでは挙動が異なるため、development buildが必要です。最初に1本焼いてからは、JSの変更だけで検証を回しました。

ここで1つ罠があります。app.json の plugins に expo-notifications を入れ忘れたままビルドすると、ビルドは成功するのに通知が動きません。仕様書に「development buildを作る前に必ず確認する」と書いて予防しています。

本番ビルドは全部揃えてから1本だけ投げる。 申請準備(app.jsonの監査、プライバシーマニフェストの追加、アイコンのRGB変換、表示文言の統一、スクリーンショット制作)を全部終わらせてから本番ビルドを実行しました。途中で「ここだけ先に確認したい」という誘惑は何度かありましたが、結果的に本番ビルドは1本で通りました。

ちなみに1本目のdevelopment buildは、キュー待ちを含めて3時間17分かかりました。「終わるまで待って確認する」前提でスケジュールを組むと詰まります。

動作確認

# 確認項目 結果
1 レコード追加時に通知が再構築される ✅
2 アプリ再起動後も通知が保持される ✅
3 テスト通知が再構築で消えない ✅
4 期限切れ(過去日時)の候補が除外される ✅
5 同一種別・同一時刻の通知がグルーピングされる ✅
6 1件の計算失敗が他レコードに波及しない ✅
7 権利日計算(大納会・GW跨ぎ・月末休日) ✅

ドメインロジックと通知選定は副作用のない純関数として実装しているため、Jestで境界値テストを書いています。最終的にテストは28スイート・361件になりました。

まだできていないこと

  • 現引き期限の通知が未実装です。 証券会社によって現引きの期限(約定当日中など)が異なるため、証券会社マスタの設計とセットで対応する必要があり、v1.1に回しました
  • 通知時刻は選択式です。 朝6〜10時、夜18〜22時のチップから選ぶ形で、任意の時刻を分単位で指定することはできません
  • 登録画面に権利日のプレビューがありません。 保存時に計算はしていますが、入力中に「この権利月ならこの日付になる」を見せる機能はまだです。計算ロジック自体は既にあるので、表示するだけではあります
  • 複数名義に対応していません。 v1は1名義固定です

まとめ

  • iOSのローカル通知は64件が上限で、超過分は例外なく静かに無視される。積み上げ方式は必ずどこかで破綻する
  • 「起動時に全キャンセル→直近N件のみ再登録」の冪等な方式にすると、状態管理が不要になる。ただし算出を全部終えてからキャンセルする順序が重要
  • 全キャンセルは、後から足した別機能(テスト通知など)を巻き込む。identifierのプレフィックスで除外できるようにしておく
  • 海外でもJSTで発火させるには UNTimeIntervalNotificationTrigger を使う。UNCalendarNotificationTrigger は端末のタイムゾーンで解釈される
  • ドメイン層の日付は 'YYYY-MM-DD' 文字列で持ち、Date の生成は境界に限定するとタイムゾーン事故が減る
  • 日本株の営業日計算では12/30(大納会)が営業日。祝日は計算式ではなく静的テーブルで持ち、範囲外は例外にする
  • App Storeのアイコンは、全ピクセル不透明でもRGBAだと弾かれる可能性がある。ビルド枠が有限なら先に潰しておく

おわりに

通知は「来て当たり前」の機能で、正しく動いている限り誰にも意識されません。気づかれるのは来なかったときだけです。

ただ、その「気づかれなさ」を成立させるために、64件制限・タイムゾーン・営業日計算・自分で書いた別機能との衝突と、それなりに考えることがありました。地味な仕組みほど、壊れ方が静かで発見が遅れるのだと思います。

アプリは「クロス手帳」という名前でApp Storeに公開しています。会員登録もログインも不要で、広告も入れていません。優待クロスをスプレッドシートで管理している方に届けばと思っています。

使い方やよくある質問はサポートページにまとめてあります。


技術スタック: Expo (React Native) / TypeScript / expo-sqlite / expo-notifications / expo-router / Jest

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?