【Flutter】キャッシュ戦略と実装を解説!基本の「キ」から学ぶパフォーマンス向上術

はじめに

こんにちは、株式会社メンバーズ Cross ApplicationOpen in new tab カンパニーの田原です。

みなさんは「キャッシュ」と聞いて、何を思い浮かべるでしょうか?

Webブラウザに残る閲覧履歴、スマートフォンのアプリに溜まった一時ファイル、あるいはデータベースの高速化技術でしょうか。

現代のソフトウェア開発において、キャッシュは避けて通れない重要な概念です。適切にキャッシュを活用することで、アプリケーションのパフォーマンスを飛躍的に向上させ、ユーザー体験を劇的に改善できます。

特に、モバイルアプリケーション開発において、キャッシュの重要性は計り知れません。限られたネットワーク帯域、不安定な接続環境、そしてバッテリー消費の抑制。これらの課題を克服し、快適なアプリケーション体験を提供するために、キャッシュは欠かせない存在です。

私自身、プライベートで Dart 製のキャッシュパッケージOpen in new tabを公開し、日々の開発の中でキャッシュの恩恵を強く感じています。

本記事では、キャッシュの基本の「キ」から始め、Flutterアプリケーションにおけるキャッシュの実装方法まで、実践的な知識とノウハウを共有します。

「Flutterアプリでキャッシュを実装するにはどうすればいいの?」という疑問をお持ちのFlutter開発者の方々にとって、本記事が少しでもお役に立てれば幸いです。

キャッシュとは

プログラミングやITの分野におけるキャッシュは、一般的に「Cache」を指します。

Cache とは、「よく使うデータを手元に残しておいて、
必要になったらすぐに取り出せるようにする仕組み」です。

キャッシュの主な目的

  • データアクセスの高速化
  • システムの負荷を低減

これらの目的を達成することで、システムのパフォーマンス向上や、コスト最適化に繋がります。

キャッシュが使われる場所

キャッシュは、コンピュータシステムにおいて様々な場所で活用されています。それぞれの場所で、キャッシュは異なる役割を担い、システムの効率化に貢献しています。

クライアントサイド

クライアントサイド、つまりWebブラウザやモバイルアプリケーションなどのユーザーインターフェースに近い部分では、キャッシュは以下のような目的で利用されます。

同じデータを何度もサーバーから取得せずに済むようにする

Webページで一度読み込んだ画像やJavaScriptファイルなどをキャッシュとして保存しておけば、次回以降のアクセス時に再読み込みする必要がなくなります。これにより、Webサイトの表示速度が向上し、ユーザー体験が向上します。

アプリケーションをサクサク動かす

アプリケーション内で頻繁に使用するデータをキャッシュしておくことで、データの取得にかかる時間を短縮し、アプリケーション全体の動作を高速化することができます。

サーバーサイド

サーバーサイドでは、キャッシュは主にサーバーの負荷を軽減し、ユーザーのリクエストに素早く応答するために使用されます。

サーバー負荷の低減

データベースへの問い合わせ結果や、複雑な処理の結果などをキャッシュしておくことで、同じリクエストが来た場合に、キャッシュから結果を返すことができます。これにより、サーバーはデータベース処理や複雑な計算処理を繰り返す必要がなくなり、負荷を大幅に軽減できます。

リクエストに素早く応答する

キャッシュは、メモリなどの高速な記憶媒体に置かれることが多いため、ディスクから直接データを読み出すよりもはるかに高速にアクセスできます。これにより、ユーザーのリクエストに対して迅速に応答し、レスポンスタイムを改善できます。

データベース

データベースにおいても、キャッシュは重要な役割を果たしています。

重いクエリを毎回実行しないで済むようにする

データベースは、データ検索のためにSQLクエリを実行する必要があります。クエリの内容によっては、非常に時間とリソースがかかる場合があります。頻繁に実行されるクエリの結果をキャッシュしておくことで、データベースは毎回クエリを実行する必要がなくなり、パフォーマンスを向上させることができます。

キャッシュの注意点

データの鮮度

キャッシュされたデータは、元のデータが更新された場合に、古い情報となってしまう可能性があります。このため、キャッシュの有効期限を適切に設定したり、データの更新に合わせてキャッシュを更新する仕組みを導入する必要があります。

メモリ管理

キャッシュは、通常メモリなどの限られたリソースを使用します。キャッシュを増やしすぎると、他の処理に必要なメモリが不足し、システムのパフォーマンスが低下してしまう可能性があります。キャッシュのサイズを適切に管理し、メモリの使用状況を監視することが重要です。

キャッシュ戦略の基本

キャッシュは、アプリケーションのパフォーマンスを向上させるための重要なツールですが、その効果を最大限に引き出すには適切な戦略が必要です。

キャッシュ戦略は、以下の2つの主要な側面から構成されます。

  1. キャッシュ更新戦略
    • いつキャッシュにデータを保存するか。
    • いつバックエンド(データベースやAPIなど)とデータを同期するか。
  2. キャッシュ置換戦略
    • キャッシュが満杯になったときに、どのデータを削除するか。

これらの戦略を適切に選択することで、データアクセスの効率化とシステムの負荷軽減を実現できます。

キャッシュ更新戦略

キャッシュ更新戦略は、データの読み書きとバックエンドとの同期方法によっていくつかの種類があります。

主なキャッシュ更新戦略

Read Through

  • データの読み取り時に、まずキャッシュを確認します。
  • キャッシュにデータがない場合(キャッシュミス)、キャッシュが自動的にバックエンドからデータを取得し、キャッシュに保存してからアプリケーションに返します。
  • アプリケーションは常にキャッシュを通じてデータにアクセスするため、シンプルな実装が可能です。

Write Through

  • データの書き込み時に、キャッシュとバックエンドの両方に同時にデータを反映します。
  • これにより、常にキャッシュとバックエンドのデータが同期されます。
  • データの整合性が重要な場合に適しています。

Write Around

  • データの書き込み時に、データをバックエンドに直接書き込み、キャッシュは更新しません。
  • キャッシュには頻繁に読み取られるデータのみを保持し、書き込み頻度の高いデータはキャッシュをバイパスします。
  • キャッシュの負荷を軽減できます。

Write Back

  • データの書き込み時に、まずキャッシュにのみデータを保存し、バックエンドの更新は非同期で行います。
  • 書き込み処理を高速化できますが、システムのクラッシュ時にデータ損失のリスクがあります。

Cache Aside

  • アプリケーションが必要に応じてデータをキャッシュに直接読み書きします。
  • キャッシュミスが発生した場合、アプリケーションがバックエンドからデータを取得し、キャッシュに格納します。
  • アプリケーション側でキャッシュの制御が必要になりますが、柔軟なキャッシュ戦略を実装できます。

キャッシュ置換戦略

キャッシュ置換戦略は、限られたメモリ空間を効率的に利用するために、不要なデータを削除する仕組みです。

基本的なキャッシュ置換戦略

RR (Random Replacement)

  • 最も単純な戦略で、ランダムにデータを選んで削除します。
  • 実装は容易ですが、キャッシュヒット率が低い場合があります。

FIFO (First-In-First-Out)

  • 最初にキャッシュされたデータから順番に削除します。
  • 古いデータが削除されるため、新しいデータが優先されますが、アクセス頻度を考慮しません。

MRU (Most Recently Used)

  • 最も最近アクセスされたデータから削除します。
  • 最近使われたデータは一時的な使用であると考え、再度使われる可能性が低いと想定します。
  • 一時的なバッチ処理で短時間に大量のデータを連続的に処理する場合などに有効です。
  • 一般的なデータアクセスパターンにおいてはLRUよりもキャッシュヒット率が低いことが多いです。

LRU (Least Recently Used)

  • 最も長い間使われていないデータから削除します。
  • 過去のアクセス履歴に基づき、最終アクセス時刻が最も古いデータを削除することで、効率的なキャッシュ利用が期待できます。
  • 最も一般的なキャッシュ置換戦略の一つです。

LFU (Least Frequently Used)

  • 使用頻度が最も低いデータから削除します。
  • 頻繁に使われるデータを優先しますが、過去の頻度に影響されるため、アクセス頻度が変化する場合には不向きです。
  • データアクセス頻度に偏りがあり、頻繁にアクセスされるデータが限られている場合には、高いキャッシュヒット率を実現できます。

高度なキャッシュ置換戦略

Tiny-LFU (Tiny Least Frequently Used)

  • LFU のメモリ効率を改善した戦略です。
  • カウンタ構造を用いてアクセス頻度を追跡し、メモリ使用量を削減します。

W-Tiny-LFU (Window-Tiny Least Frequently Used)

  • Tiny-LFU をさらに改良し、Window 機能を導入した戦略です。
  • Window は、短期間だけ新規データを保持する領域です。
  • 時間が経ってもアクセスされ続けるデータのみがメインキャッシュに昇格します。
  • 突発的な大量アクセスに対する耐性が向上します。

その他のキャッシュ戦略

二層キャッシュ (Two-Level Cache / Multi-Level Cache)

  • 異なる特性を持つキャッシュ層を組み合わせて、アクセス速度とメモリ効率を最適化します。
  • 例:
    • ディスクキャッシュとメモリキャッシュを組み合わせることで、速度と容量のバランスを取ります。
    • CPU のレジスタ、L1、L2、L3 キャッシュ、メインメモリ、スワップ領域など、階層的なキャッシュ構造を利用します。

キャッシュ戦略の選択

最適なキャッシュ置換戦略は、アプリケーションのデータの特性、アクセスパターン、メモリ容量などによって異なります。これらの要素を考慮して、適切な戦略を選択することが重要です。

Flutterにおけるキャッシュ

Flutterにおけるキャッシュの重要性

モバイルアプリにおいて、キャッシュはユーザー体験を向上させるための重要な要素です。ネットワーク接続が不安定な状況や、オフライン環境でもアプリを快適に利用できるようにするために、キャッシュの活用は欠かせません。

Flutterアプリにおいても、キャッシュを適切に利用することで、パフォーマンスの向上、データ使用量の削減、そしてよりスムーズなユーザー体験を実現できます。

Flutterにおけるキャッシュの種類

Flutterアプリでは、主に以下の種類のキャッシュが利用されます。ただし、これらは独立した概念ではなく、組み合わせて利用されることもあります。

メモリキャッシュ

アプリの実行中にメモリ上にデータを保持するキャッシュです。高速にアクセスできますが、アプリが終了するとデータは失われます。

ディスクキャッシュ

スマートフォンのストレージにデータを保持するキャッシュです。アプリを再起動してもデータが残りますが、メモリキャッシュよりもアクセス速度は遅くなります。

ネットワークキャッシュ

ネットワークから取得したデータを効率的に管理するためのキャッシュの仕組みです。画像のダウンロードやAPIのレスポンスなどをキャッシュすることで、ネットワークへのアクセス回数を減らし、パフォーマンスを向上させます。

ネットワークキャッシュは、メモリキャッシュとディスクキャッシュを組み合わせて利用することが一般的です。

Flutter キャッシュパッケージを用いた実装例

Flutter では、キャッシュを実装するための便利なパッケージがいくつか提供されています。

cached_network_image パッケージを使った画像キャッシュ

cached_network_imageOpen in new tabパッケージは、ネットワークから取得した画像をキャッシュするためのパッケージです。

CachedNetworkImage(
  imageUrl: "http://example.com/image.png",
  placeholder: (context, url) => CircularProgressIndicator(),
  errorWidget: (context, url, error) => Icon(Icons.error),
)

上記の例では、指定されたURLの画像をキャッシュし、表示します。画像のダウンロード中はCircularProgressIndicator が表示され、エラーが発生した場合は Icons.error が表示されます。

flutter_cache_manager パッケージを使ったファイルキャッシュ

flutter_cache_managerOpen in new tab パッケージは、ファイルをキャッシュするためのパッケージです。画像だけでなく、JSON ファイルや動画ファイルなどもキャッシュできます。

import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'dart:io';

Future<void> cacheFileFromUrl(String url) async {
  // URLからファイルをダウンロードしてキャッシュ
  final file = await DefaultCacheManager().getSingleFile(url);

  // キャッシュされたファイルのパスを表示
  print('Cached file path: ${file.path}');

  // キャッシュされたファイルを使用
  final fileContent = await File(file.path).readAsString();
  print('File content: $fileContent');
}

Future<void> clearCache() async {
  // キャッシュをクリア
  await DefaultCacheManager().emptyCache();

  print('Cache cleared.');
}

Future<void> getCacheInfo() async {
  // キャッシュ情報を取得
  final cacheInfo = await DefaultCacheManager().getFileFromCache(
      'https://example.com/data.json'); // キャッシュしたいファイルのURL

  if (cacheInfo != null) {
    print('Cache info: ${cacheInfo.file.path}');
  } else {
    print('Cache not found.');
  }
}

Future<void> main() async {
  // サンプルファイルのURL
  final fileUrl = 'https://example.com/data.json';

  // URLからファイルをキャッシュ
  await cacheFileFromUrl(fileUrl);

  // キャッシュ情報を取得
  await getCacheInfo();

  // キャッシュをクリア
  await clearCache();
}

上記の例では、以下の機能を紹介しています。

  1. URL からファイルをキャッシュ
    • cacheFileFromUrl 関数は、指定された URL からファイルをダウンロードし、キャッシュします。ダウンロードしたファイルのパスを表示し、ファイルの内容を読み込んで出力します。
  2. キャッシュ情報を取得
    • getCacheInfo 関数は、指定された URL のキャッシュ情報を取得します。キャッシュが存在する場合、そのファイルのパスを表示します。
  3. キャッシュをクリア
    • clearCache 関数は、キャッシュをクリアします。

shared_preferences パッケージを使った簡単なデータキャッシュ

shared_preferencesOpen in new tab パッケージは、簡単なデータを永続化するためのパッケージです。プリミティブなデータ型(文字列、数値、真偽値など)をキャッシュできます。

import 'package:shared_preferences/shared_preferences.dart';

class SharedPreferencesService {
  // データを保存する
  Future<void> saveData<T>(String key, T value) async {
    final prefs = await SharedPreferences.getInstance();
    if (value is String) {
      await prefs.setString(key, value);
    } else if (value is int) {
      await prefs.setInt(key, value);
    } else if (value is bool) {
      await prefs.setBool(key, value);
    } else if (value is double) {
      await prefs.setDouble(key, value);
    } else if (value is List<String>) {
      await prefs.setStringList(key, value);
    } else {
      throw SharedPreferencesException('Unsupported type');
    }
    print('Data saved: $key, $value');
  }

  // データを取得する
  Future<T?> getData<T>(String key) async {
    final prefs = await SharedPreferences.getInstance();
    if (T == String) {
      return prefs.getString(key) as T?;
    } else if (T == int) {
      return prefs.getInt(key) as T?;
    } else if (T == bool) {
      return prefs.getBool(key) as T?;
    } else if (T == double) {
      return prefs.getDouble(key) as T?;
    } else if (T == List<String>) {
      return prefs.getStringList(key) as T?;
    }
    throw SharedPreferencesException('Unsupported type');
  }

  // データを削除する
  Future<void> removeData(String key) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.remove(key);
    print('Data removed: $key');
  }

  // 全てのデータを削除する
  Future<void> clearAllData() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.clear();
    print('All data removed');
  }
}

// SharedPreferencesに関連する例外クラス
class SharedPreferencesException implements Exception {
  final String message;
  SharedPreferencesException(this.message);
  @override
  String toString() {
    return 'SharedPreferencesException: $message';
  }
}

Future<void> main() async {
  final service = SharedPreferencesService();

  // データの保存
  await service.saveData<String>('username', 'John Smith');
  await service.saveData<int>('age', 30);
  await service.saveData<bool>('isLoggedIn', true);
  await service.saveData<double>('height', 170.0);
  await service.saveData<List<String>>('language', ['English', 'Dart']);

  // データの取得
  final username = await service.getData<String>('username');
  final age = await service.getData<int>('age');
  final isLoggedIn = await service.getData<bool>('isLoggedIn');
  final height = await service.getData<double>('height');
  final language = await service.getData<List<String>>('language');

  print('Username: $username');
  print('Age: $age');
  print('IsLoggedIn: $isLoggedIn');
  print('Height: $height');
  print('Language: $language');

  // データの削除
  await service.removeData('username');

  // 全データ削除
  await service.clearAllData();
}

自作のキャッシュの実装

より複雑なキャッシュ戦略を実装する場合は、自作のキャッシュを作成することも可能です。メモリキャッシュとディスクキャッシュを組み合わせたり、独自のキャッシュ置換戦略を実装したりできます。

LRU Cache の実装例

以下はFlutter(Dart)を用いたシンプルな LRU キャッシュの実装例です。

import 'dart:collection';

class LRUCache<K, V>  {
  final int maxSize;
  final LinkedHashMap<K, V> _cache = LinkedHashMap();

  LRUCache(this.maxSize) {
    if (maxSize <= 0) {
      throw ArgumentError('maxSize must be greater than 0.');
    }
  }

  Iterable<K> getKeys() => _cache.keys; // キャッシュ内のキーを返す

  V? get(K key) {
    // キーに対応する値を取得し、キャッシュ内で最後にアクセスされた要素にする
    if (!_cache.containsKey(key)) return null;
    final value = _cache.remove(key);
    if (value == null) return null;
    _cache[key] = value;
    return value;
  }

  void set(K key, V value) {
    // キーと値をキャッシュに設定し、キャッシュがいっぱいの場合はLRU要素を削除
    if (_cache.containsKey(key)) {
      _cache.remove(
        key,
      );
    } else if (_cache.length >= maxSize) {
      _cache.remove(
        _cache.keys.first,
      );
    }
    _cache[key] = value;
  }

  void clear() {
    // キャッシュをクリア
    _cache.clear();
  }

  @override
  String toString() {
    return _cache.toString();
  }
}

ここの実装例では、LRUCache クラスを用いて LRU キャッシュを実装しています。

  • maxSize はキャッシュの最大サイズを指定します。
  • _cache は LinkedHashMap を用いてキャッシュ本体を保持します。LinkedHashMap は、要素の挿入順序を保持するため、LRUアルゴリズムの実装に適しています。
  • get(K key) メソッドは、指定されたキーに対応する値をキャッシュから取得します。キャッシュヒットした場合、その要素をキャッシュ内で最後にアクセスされた要素として更新します。
  • set(K key, V value) メソッドは、指定されたキーと値をキャッシュに設定します。キャッシュが最大サイズに達している場合、最も古い要素(最初に挿入された要素)を削除します。
  • clear() メソッドは、キャッシュをクリアします。

キャッシュサイズが maxSize で指定した容量を超えた際に、LRU ポリシーに則って、「最も古くアクセスされた要素」を削除することで、キャッシュの効率的な利用を実現しています。

実装例の問題点と解決策

上記の LRU キャッシュ実装例は、単一スレッド環境では問題なく動作しますが、マルチスレッド環境においては、複数のスレッドが同時にキャッシュにアクセスすることで、データ競合が発生する可能性があります。

例えば、複数のスレッドが同時に同じキーに対して get() メソッドを呼び出した場合、キャッシュの内部状態が予期せぬ状態になり、誤った結果を返す可能性があります。

マルチスレッド環境で安全にキャッシュを利用するためには、ロック機構などを用いて、キャッシュへのアクセスを同期する必要があります。しかし、これらの実装は複雑になりやすく、テストによる動作保証も必要となるため、自作するのは少々面倒です。

そこで紹介したいのが、cacherineOpen in new tab

cacherineOpen in new tab(キャシャリン)

cacherineOpen in new tab は、マルチスレッド対応であり、テストも完備しています。また、複数のキャッシュ置換アルゴリズムに対応しており、モニタリング機能も備えているため、より高度なキャッシュ管理が可能です。

使用例

import 'package:cacherine/cacherine.dart';

void main() async {
  final cache = FIFOCache<String, String>(maxSize: 5);
  await cache.set('key1', 'value1');
  print(await cache.get('key1')); // 'value1'
}

ぜひ使ってみましょう!

おわりに

ここまで、キャッシュの基本と、Flutter におけるキャッシュの実装方法や有用なパッケージなどを紹介しました。

There are only 2 hard problems in computer science: cache invalidation and naming things. – Phil Karlton

コンピュータサイエンスにおいて本当に難しいことは2つだけ。キャッシュの無効化と命名だ。

キャッシュにまつわるこんなジョークがあるように、キャッシュされた情報がデータソースと不整合を起こさないように管理することは、アプリケーション開発において非常に重要です。

キャッシュを適切に利用することで、パフォーマンスを向上させ、ユーザー体験を向上させることができますが、同時に、キャッシュの有効期限や更新戦略、メモリ管理など、様々な課題にも注意を払う必要があります。

本記事で紹介した内容が、Flutter アプリケーションにおけるキャッシュの活用の一助となれば幸いです!

最後までお読みいただき、ありがとうございました。

この記事が役に立ったと思ったら、
ぜひ「いいね」とシェアをお願いします!

リンクをコピーXでシェアするfacebookでシェアする

この記事を書いた人

田原 葉
田原 葉
2024年にメンバーズに中途で入社。前職はiOSエンジニア。現在はCross ApplicationカンパニーでFlutter技術をメインにモバイルアプリ開発支援を担当。
詳しく見る
ページトップへ戻る