【Dart】シングルトンパターンを用いたキャッシュ実装

  • 2024年4月12日
  • 2024年4月12日
  • Dart

Dartにおけるシングルトンパターンを用いたキャッシュ実装

対象者

この記事は、Dart言語でのプログラミングに慣れており、シングルトンの実践的な方法やデータキャッシュの実装に興味のある開発者を対象としています。

はじめに

データキャッシュは、アプリケーションのパフォーマンスを向上させるために重要な役割を果たします。キャッシュを使用することで、重いデータの読み込みを回避し、ユーザー体験を向上させることができます。この記事では、Dart言語でシングルトンパターンを用いたキャッシュクラスの実装方法を説明します。

私たちが開発しているアプリでは、ユーザー情報をバックエンドのデータベースに保存しています。ホーム画面にアクセスするたびに、バックエンドからユーザーデータを取得していましたが、これは無駄な通信を引き起こし、バックエンドに負担をかけていました。そこで、パフォーマンスの向上とバックエンドの負担軽減のため、キャッシュ機能の導入を決定しました。この記事では、その際に作成したサンプルコードをもとに、Dart言語でシングルトンパターンを用いたキャッシュクラスの実装方法を説明します。

キャッシュのユースケース

キャッシュは、以下のようなシナリオで有効に活用できます。

  • ネットワークからのデータ取得が頻繁に行われる場合
  • 重い計算結果を保持しておきたい場合
  • ユーザーがよくアクセスするデータを素早く提供したい場合

実装

キャッシュクラスの実装

キャッシュクラスは、シングルトンパターンを用いて実装します。シングルトンパターンを使用することで、アプリケーション内で一つのインスタンスのみを保証し、グローバルにアクセスできるようにします。
また、Completerを使うことで、キャッシュの取得が未完了の場合、すぐにFutureを返すことができます。このアプローチにより、データ取得自体は非同期で行われ、メインスレッドの遅延を防ぎます。データが利用可能になったらそのFutureからデータを取得できるようになるので、メインスレッドのパフォーマンスが維持されます。

class CacheData {
  static final CacheData _instance = CacheData._();

  CacheData._();

  factory CacheData() {
    return _instance;
  }

  Completer<String>? _cachedString;

  final _repository = DummyRepository();

  Future<String> getString() async {
    if (_instance._cachedString == null) {
      _instance._cachedString = Completer<String>();
      _repository.getData().then(
            (newValue) => _instance._cachedString!.complete(newValue),
          );
    }

    return _instance._cachedString!.future;
  }

  void invalidateCache() {
    _cachedString = null;
  }
}

getStringメソッドを使用してキャッシュされたデータを取得し、invalidateCacheメソッドを使用してキャッシュを無効にすることができます。
キャッシュの無効化は、例えばユーザの名前を更新したため、新しいデータを取得し直したいときなどに実施します。また、Future.delayを使って、取得後一定時間を経つと無効化する仕組みも考えられます。

ダミーデータ用のレポジトリクラスの実装

テスト目的で、ダミーデータを返すレポジトリクラスを実装します。このレポジトリクラスでは、データ取得を模擬するために100ミリ秒の遅延を行っています。また、アクセスが行われるたびにカウンターをインクリメントすることで、アクセス回数を追跡しています。

class DummyRepository {
  int _counter = 0;

  Future<String> getData() async {
    await Future.delayed(const Duration(milliseconds: 100));
    return 'value${_counter++}';
  }

  void reset() {
    _counter = 0;
  }
}

テストクラスで動作を確認する

同一のインスタンスの確認

このテストでは、キャッシュデータのクラスがシングルトンパターンに従って同じインスタンスであることを確認しています。さらに、取得されたデータが同一のインスタンスであることも確認しています。ただし、getStringメソッドで取得されるFuture型のインスタンスは、異なるインスタンスであることが確認されています(なんでだろ)。

test('同一インスタンス[データは同じインスタンスだが、Futureは同じインスタンスではない]', () async {
  final cache1 = CacheData();
  final cache2 = CacheData();
  expect(cache1, same(cache2));

  final future1 = CacheData().getString();
  final future2 = CacheData().getString();
  expect(future1, isNot(future2));
  expect(await future1, same(await future2));
});

キャッシュの無効化

このテストでは、キャッシュを無効化した後に新しいデータを取得する場合に、正しく新しいデータが取得されることを確認しています。ダミーレポジトリでは、アクセスごとにカウンターがインクリメントされるため、invalidateCacheを実行するごとにキャッシュの値が新しくなっていること、また未実施の間は同じ値を取得できることが確認できます。また、

test('キャッシュの無効化', () async {
  final future1 = CacheData().getString();
  final future2 = CacheData().getString();
  expect(await future1, 'value0');
  expect(await future2, 'value0');

  CacheData().invalidateCache();
  final value1_1 = await CacheData().getString();
  final value1_2 = await CacheData().getString();
  expect(value1_1, 'value1');
  expect(value1_2, 'value1');

  CacheData().invalidateCache();
  expect(await CacheData().getString(), 'value2');
  expect(await CacheData().getString(), 'value2');
});

時間の測定

このテストでは、キャッシュの取得にかかる時間を測定しています。最初のアクセスやinvalidateCacheを実行した後のアクセスでは100ミリ秒の遅延がありますが、一度データを取得しキャッシュが有効な場合はほとんど時間がかかりません。

test('時間測定', () async {
  CacheData().invalidateCache();
  final stopwatch = Stopwatch();
  stopwatch.start();

  expect(stopwatch.elapsed.inMilliseconds, closeTo(0, 5));
  await CacheData().getString();
  expect(stopwatch.elapsed.inMilliseconds, closeTo(100, 5));
  await CacheData().getString();
  expect(stopwatch.elapsed.inMilliseconds, closeTo(100, 5));
});

事前読み込みのテスト

このテストでは、キャッシュの事前読み込みが正しく行われることを確認しています。getStringメソッドを呼び出してから、起動時の処理のつもりで100ミリ秒遅延させた場合、レポジトリの遅延とメインスレッドの遅延が並行して発生するため、最終的な遅延時間は約100ミリ秒となります。
データ取得が確定している場合、アプリ起動後すぐにデータにアクセスすることで、事前にキャッシュができ、必要なときにすぐアクセスが可能になります。

test('事前読み込みとメインスレッドへの影響', () async {
    CacheData().invalidateCache();
    final stopwatch = Stopwatch();
    stopwatch.start();

    expect(stopwatch.elapsed.inMilliseconds, closeTo(0, 5));
    CacheData().getString();
    expect(stopwatch.elapsed.inMilliseconds, closeTo(0, 5));

    await Future.delayed(const Duration(milliseconds: 100));
    await CacheData().getString();
    expect(stopwatch.elapsed.inMilliseconds, closeTo(100, 5));
});

以上の4つのテストを通して、キャッシュクラスの動作が正しく行われていることを確認しています

Isolate内では使用できない

他の言語で並行処理をするとキャッシュ管理は厳密にする必要が出てきます。そこでDartでは、どうなるか実験してみました。
メインスレッドとIsolateの処理ではメモリ空間が分かれているため、シングルトンでも同じ値を使用できない事が確認できました。そのためIsolate内でキャッシュのデータを使いたい場合は、Isolateの前でデータを取得し、その値を渡す必要があります。

test('メインスレッドとIsolateでデータは共有されない', () async {
    final valueInIsolate = await compute((_) => CacheData().getString(), null);
    expect(valueInIsolate, 'value0');

    final valueInMain = await CacheData().getString();
    expect(valueInMain, isNot(valueInIsolate));
    
    
    final valueInIsolate2 = await compute((_) => valueInMain, null);
    expect(valueInMain, valueInIsolate2);
});

まとめ

この記事では、Dart言語でシングルトンパターンを用いたキャッシュクラスの実装方法について説明しました。キャッシュはアプリケーションのパフォーマンス向上に役立つ重要な機能です。適切に実装することで、ユーザー体験を向上させることができます。

ソース(main.dartにコピペして動作確認用)

import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';

class CacheData {
  static final CacheData _instance = CacheData._();

  CacheData._();

  factory CacheData() {
    return _instance;
  }

  Completer<String>? _cachedString;

  final _repository = DummyRepository();

  Future<String> getString() async {
    if (_instance._cachedString == null) {
      _instance._cachedString = Completer<String>();
      _repository.getData().then(
            (newValue) => _instance._cachedString!.complete(newValue),
          );
    }

    return _instance._cachedString!.future;
  }

  void invalidateCache() {
    _cachedString = null;
  }
}

class DummyRepository {
  int _counter = 0;

  Future<String> getData() async {
    await Future.delayed(const Duration(milliseconds: 100));
    return 'value${_counter++}';
  }

  void reset() {
    _counter = 0;
  }
}

main() {
  test('同一インスタンス[データは同じインスタンスだが、Futureは同じインスタンスではない]', () async {
    final cache1 = CacheData();
    final cache2 = CacheData();
    expect(cache1, same(cache2));

    final future1 = CacheData().getString();
    final future2 = CacheData().getString();
    expect(future1, isNot(future2));
    expect(await future1, same(await future2));
  });

  test('キャッシュの無効化', () async {
    final future1 = CacheData().getString();
    final future2 = CacheData().getString();
    expect(await future1, 'value0');
    expect(await future2, 'value0');

    CacheData().invalidateCache();
    final value1_1 = await CacheData().getString();
    final value1_2 = await CacheData().getString();
    expect(value1_1, 'value1');
    expect(value1_2, 'value1');

    CacheData().invalidateCache();
    expect(await CacheData().getString(), 'value2');
    expect(await CacheData().getString(), 'value2');

    final cacheData = CacheData();
    cacheData.invalidateCache();
    final value3 = await cacheData.getString();
    expect(value3, 'value3');
  });

  test('時間測定', () async {
    CacheData().invalidateCache();
    final stopwatch = Stopwatch();
    stopwatch.start();

    expect(stopwatch.elapsed.inMilliseconds, closeTo(0, 5));
    await CacheData().getString();
    expect(stopwatch.elapsed.inMilliseconds, closeTo(100, 5));
    await CacheData().getString();
    expect(stopwatch.elapsed.inMilliseconds, closeTo(100, 5));

    CacheData().invalidateCache();
    await CacheData().getString();
    expect(stopwatch.elapsed.inMilliseconds, closeTo(200, 5));
    await CacheData().getString();
    expect(stopwatch.elapsed.inMilliseconds, closeTo(200, 5));
    await CacheData().getString();
    expect(stopwatch.elapsed.inMilliseconds, closeTo(200, 5));
  });

  test('事前読み込みとメインスレッドへの影響', () async {
    CacheData().invalidateCache();
    final stopwatch = Stopwatch();
    stopwatch.start();

    expect(stopwatch.elapsed.inMilliseconds, closeTo(0, 5));
    CacheData().getString();
    expect(stopwatch.elapsed.inMilliseconds, closeTo(0, 5));

    await Future.delayed(const Duration(milliseconds: 100));
    await CacheData().getString();
    expect(stopwatch.elapsed.inMilliseconds, closeTo(100, 5));
  });

  test('メインスレッドとIsolateでデータは共有されない', () async {
    final valueInIsolate = await compute((_) => CacheData().getString(), null);
    expect(valueInIsolate, 'value0');

    final valueInMain = await CacheData().getString();
    expect(valueInMain, isNot(valueInIsolate));

    final valueInIsolate2 = await compute((_) => valueInMain, null);
    expect(valueInMain, valueInIsolate2);
  });
}