見出し画像

【開発者向け】スマートグラス Even G2 アプリでタイマーを止めない!バックグラウンドでも動く常時更新テクニック

2026.04.16 追記

本件大物のバグだった(そりゃそうですよね)ようで、SDK 0.0.10? アプリ 2.1.2 あたりで、解消されたようです。
even-toolkitがどう対応するのかの様子も見ながら、僕が作ったプラグインも余計な実装もキレイにしたいと思います。そのうち^^

(以下、元の記事です)

Even Realities G2 スマートグラスでポモドーロタイマーアプリを作っています。

開発中に直面した最大の課題、それは「ブラウザがバックグラウンドになるとタイマー表示が止まる」問題でした。

G2グラスアプリはWebアプリとしてスマホ上で動作し、BLE経由でグラスに画面データを送信します。つまり、スマホでアプリを開いたままポケットに入れると……ブラウザはバックグラウンドになり、`setInterval` がスロットリングされ、グラス側の表示更新が止まってしまうのです。

この記事では、この問題をどう解決したかを5つのテクニックに分けて解説します。
(version 1.0.1で適用)



前提:G2グラスアプリのアーキテクチャ

このG2アプリは even-toolkit を使って構築しています。基本構造は:

  1. Web側(React) — 状態管理とUI表示

  2. Glass側 — `useGlasses` フックでスナップショットをグラスに送信

Glass側は「スナップショット」というオブジェクトを受け取り、それを元に `display()` 関数でグラスの表示データを生成します。スナップショットが変わるたびにグラスの表示が更新される仕組みです。

useGlasses({
  getSnapshot: () => snapshot,
  toDisplayData: (snap, nav) => toDisplayData(snap, nav),
  onGlassAction: (action, nav, snap) => onGAAction(action, nav, snap, ctxRef.current),
  deriveScreen: (path: string) => deriveScreen(path),
  appName: 'POMODO',
});

問題:ブラウザのバックグラウンドスロットリング

モダンブラウザは、バッテリー節約のためにバックグラウンドタブの `setInterval` を大幅にスロットリングします。

  • Chrome: 最低1秒間隔に制限

  • 5分以上バックグラウンドだと、さらに制限がかかることも

ポモドーロタイマーは「25分間ポケットに入れたまま」が普通の使い方。これでは表示がカクカクどころか、フェーズ遷移すらできません。

Even G2 のアプリもWebベースですから同じような問題が起こります。


テクニック1:タイムスタンプベースのカウントダウン

最も重要な基盤テクニックです。

NGパターン:差分カウント

// 毎秒1を引く → バックグラウンドでズレる
setInterval(() => {
  timeRemaining -= 1;
}, 1000);

`setInterval` が遅延すると、1秒経っても減算が実行されず、タイマーが実際の時間からズレていきます。

OKパターン:デッドラインからの逆算

// セッション開始時にデッドラインを設定
const phaseDeadline = Date.now() + workSeconds * 1000;

// 毎tickでDate.now()から逆算
const tick = () => {
  const remaining = Math.max(0, (phaseDeadline - Date.now()) / 1000);
  setTimeRemaining(remaining);
};

`Date.now()` は常に正確な現在時刻を返します。`setInterval` が1秒遅れようが10秒遅れようが、次のtickで正しい残り時間が計算されます。

実際のコード(PomodoroContext.tsx)

useEffect(() => {
  if (!activeSession || !activeSession.isRunning) return;

  const tick = () => {
    setActiveSession((prev) => {
      if (!prev || !prev.isRunning || !prev.phaseDeadline) return prev;
      const now = Date.now();
      const remaining = Math.max(0, (prev.phaseDeadline - now) / 1000);

      if (remaining > 0) {
        return { ...prev, timeRemaining: Math.round(remaining * 10) / 10 };
      }
      // Time's up → 次のフェーズへ遷移
      // ...
    });
  };

  const interval = setInterval(tick, 100);
  tick(); // 即座に1回実行

  return () => clearInterval(interval);
}, [activeSession?.isRunning, activeSession?.phase, activeSession?.cycleNumber, config]);

ポイント: `remaining` は `phaseDeadline - Date.now()` で計算。`setInterval` の精度に依存しません。


テクニック2:`_tick` によるスナップショットの強制更新

even-toolkitの `useGlasses` は、スナップショットオブジェクトの参照が変わったときにグラスの表示を更新します。

問題は、タイマーが動いているときの「残り時間」はContextのstateに入っていますが、stateの更新がスロットリングされるとスナップショットも更新されないこと。

解決策:100msごとにインクリメントするカウンターをスナップショットに含める。

// 100msごとにカウンターをインクリメント
const [tick, setTick] = useState(0);

useEffect(() => {
  const interval = setInterval(() => {
    setTick(prev => prev + 1);
  }, 100);
  return () => clearInterval(interval);
}, []);

// スナップショットにtickを含める
const snapshot: PomodoroSnapshot = {
  activeSession,
  config,
  focusedField,
  language,
  _tick: tick,  // ← これがキモ
};

`_tick` が変わるたびに新しいスナップショットオブジェクトが生成され、`useGlasses` がグラスへの送信をトリガーします。状態が変わっていなくても、表示データの再計算が走ります。


テクニック3:display関数内でのリアルタイム計算

テクニック2でスナップショットが定期的に更新されることは保証しました。でも、スナップショットに含まれる `timeRemaining` がstateの更新タイミングに依存していたら、表示される残り時間はまだ正確ではありません。

解決策:display関数の中で `Date.now()` を直接使って残り時間を計算する。

/** phaseDeadline から現在の残り秒数を計算 */
function getRemaining(snapshot: PomodoroSnapshot): number {
  const s = snapshot.activeSession;
  if (!s) return 0;
  // 動作中はデッドラインからリアルタイム計算
  if (s.phaseDeadline) {
    return Math.max(0, (s.phaseDeadline - Date.now()) / 1000);
  }
  // 一時停止中はstateの値をそのまま使う
  return s.timeRemaining;
}

export const sessionScreen = {
  display(snapshot, nav) {
    const remaining = getRemaining(snapshot);  // ← 描画のたびに最新値
    const pct = calcPercent(snapshot);
    // ... 表示データを生成
  },
};

なぜ2箇所で計算するか?

  • Context(テクニック1): Reactのstateとして管理。Web側UIの描画に使う

  • display関数(テクニック3): グラス描画の瞬間に `Date.now()` で計算。最も正確

Context側のstateが100ms遅れていても、display関数が呼ばれた瞬間に正確な残り時間がグラスに表示されます。


テクニック4:バックグラウンド復帰時の即時同期

ブラウザがバックグラウンドからフォアグラウンドに戻ったとき、即座にtickを実行して表示を同期します。

const handleVisibilityChange = () => {
  if (!document.hidden) {
    tick(); // フォアグラウンド復帰時に即座に同期
  }
};
document.addEventListener('visibilitychange', handleVisibilityChange);

`visibilitychange` イベントはスロットリングの対象外なので、フォアグラウンド復帰を確実にキャッチできます。これにより:

  1. バックグラウンド中に25分経過

  2. フォアグラウンドに戻る

  3. 即座に `tick()` が実行される

  4. `phaseDeadline` から正確な状態を計算(残り0秒 → フェーズ遷移)

  5. Web側もGlass側も一瞬で最新状態に同期


テクニック5:冪等なフェーズ遷移ポーリング

タイマーが「作業」→「休憩」→「作業」と遷移するとき、その遷移判定を高頻度でポーリングします。

useEffect(() => {
  if (!activeSession?.isRunning) return;

  const interval = setInterval(() => {
    transitionIfExpired();  // 何度呼んでも安全
  }, 100);
  transitionIfExpired(); // 即座に1回実行

  return () => clearInterval(interval);
}, [activeSession?.isRunning, transitionIfExpired]);

`transitionIfExpired` は冪等(何度呼んでも同じ結果になる)に設計されています:

const transitionIfExpired = useCallback(() => {
  setActiveSession((prev) => {
    if (!prev || !prev.isRunning || !prev.phaseDeadline) return prev;
    const now = Date.now();
    const remaining = (prev.phaseDeadline - now) / 1000;
    if (remaining > 0) return prev;  // まだ期限内 → 何もしない

    // 期限切れ → フェーズ遷移
    // ...
  });
}, [config]);

`remaining > 0` なら `prev` をそのまま返す(=何もしない)ので、100msごとに呼んでも無駄な再レンダリングは発生しません。


全体のデータフロー

┌─────────────────────────────────────────────────────┐
│  PomodoroContext (状態管理)                          │
│  ┌───────────────────────────────────────────────┐  │
│  │ setInterval(tick, 100ms)                      │  │
│  │  └─ remaining = phaseDeadline - Date.now()    │  │
│  │  └─ remaining <= 0 → フェーズ遷移               │  │
│  └───────────────────────────────────────────────┘  │
│  ┌───────────────────────────────────────────────┐  │
│  │ visibilitychange                              │  │
│  │  └─ フォアグラウンド復帰 → tick() 即時実行         │  │
│  └───────────────────────────────────────────────┘  │
└──────────────────────┬──────────────────────────────┘
                       │ state変更
                       ▼
┌─────────────────────────────────────────────────────┐
│  PomodoroGlasses (Glass接続)                        │
│  ┌───────────────────────────────────────────────┐  │
│  │ setInterval(setTick, 100ms)                   │  │
│  │  └─ tick++ → 新しいsnapshotオブジェクト生成       │  │
│  └───────────────────────────────────────────────┘  │
│  ┌───────────────────────────────────────────────┐  │
│  │ setInterval(transitionIfExpired, 100ms)       │  │
│  │  └─ デッドライン超過チェック(冪等)                │  │
│  └───────────────────────────────────────────────┘  │
│  ┌───────────────────────────────────────────────┐  │
│  │ useGlasses({ getSnapshot: () => snapshot })   │  │
│  │  └─ snapshot変更検知 → display() 呼び出し        │  │
│  └───────────────────────────────────────────────┘  │
└──────────────────────┬──────────────────────────────┘
                       │ display()
                       ▼
┌─────────────────────────────────────────────────────┐
│  session.ts (画面モジュール)                          │
│  ┌───────────────────────────────────────────────┐  │
│  │ getRemaining(snapshot)                        │  │
│  │  └─ phaseDeadline - Date.now()                │  │
│  │  └─ 描画時点で最も正確な残り時間                   │  │
│  └───────────────────────────────────────────────┘  │
│                      │                              │
│                      ▼                              │
│            グラスに表示データ送信                       │
└─────────────────────────────────────────────────────┘

まとめ:3つの設計原則

1. 絶対時刻ベース

相対的なカウンターではなく、`phaseDeadline`(絶対的なデッドラインのタイムスタンプ)を基準にする。`setInterval` がどれだけ遅延しても、`Date.now()` で正確な残り時間を復元できる。

2. 描画時点で最新値を計算

stateに保存された `timeRemaining` に頼らず、display関数の中で `Date.now()` を直接呼んで残り時間を計算する。stateの更新を待たずに、描画の瞬間の正確な値をグラスに表示。

3. 冗長な更新ループ

Context側とGlass側の両方で独立した `setInterval` を走らせる。片方がスロットリングされても、もう片方が動いていれば表示は更新され続ける。そして冪等な設計により、両方が動いていても副作用は起きない。


これらのテクニックは、スマートグラスアプリに限らず「バックグラウンドでも正確に動き続ける必要があるWebアプリ」全般に適用できます

PWAのタイマーアプリや、ブラウザベースのポモドーロツールでも同じ課題に直面するはずです。

参考になれば幸いです!

追記:でも・・・なぜうまくいく?と不思議に思っていたら・・・

なぜこれがうまくいくのだろう・・・と不思議に思っていたところ・・・謎の解明編を記事追加しました!

根本的に厳しいのかも…

しかし、そもそものシステム構成に起因する問題なので、このような「アプリがバックグラウンドになると…問題」は、他のアプリアイデアでもかなりの障害になりそう。
実際に Even Realities の Discord #dev-chat でも議論が巻き起こっています。
根本解決を頑張ってほしい!

さいごに

もしよかったら「スキ!」いただけますと、嬉しいです!

いいなと思ったら応援しよう!