🦁

300冊でクラッシュしていたPDFリーダーアプリのメモリ問題を解決した話

に公開

こんにちは、K@zuki.です。
BookilというPDFリーダーアプリを公開していますが、今回はこのアプリのメモリ消費量を改善した話を紹介します。

https://apps.apple.com/jp/app/bookil/id6475175265?l=en-US

要約

  • 300冊ぐらいからクラッシュして起動できなくなった
  • XCodeでデバッグしているとOutOfMemoryでクラッシュしていることが判明
  • 起動時の処理で不要なデータをメモリにロードしていた
  • 必要なデータのみロードするようにし、適宜解放するような処理に書き換えたら、メモリの消費量が(おそらく)数GBから80~120MB程度まで下がった👏

ある日アプリが100%クラッシュし始めた

技術書や論文を読むためにBookilというアプリを作っていたのですが、250ファイルあたりを超えてから、たまにクラッシュし始め、300冊を超えたあたりから100%起動しなくなりました。

原因を調査するためにXCodeに接続し、起動してみるとOutOfMemoryというそこまで変なことをしていないアプリでは珍しい理由で落ちていました。

全てのPDFをメモリ上に読み込んでいた

実際に問題となっていた箇所は、XCodeに接続していたこともあり、すぐに判明しました。
具体的には起動時の処理で呼び出されるfetchDocumentsで全てのPDFをPDFDocumentとしてメモリに読み込んでいる箇所でクラッシュしていることが判明しています。

  func fetchDocuments() -> [PDFDocument] {
      guard let path = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
        return []
      }

      do {
        let urls = try fileManager.contentsOfDirectory(at: path, includingPropertiesForKeys: nil)
        return urls
          .filter { $0.pathExtension == "pdf" }
          .compactMap { PDFDocument(url: $0) }  // 全PDFをメモリに読み込んでいる
      } catch {
        print("Error while enumerating files \(path.path): \(error.localizedDescription)")
        return []
      }
  }

一瞬でOutOfMemoryになるため、実際の消費量は分かりませんでしたが、ファイルの合計サイズが3GBを超えていたので、それだけメモリに読み込まれていた可能性がありました。

起動時の処理

Bookilの起動時は、大まかに説明すると以下のような処理を行っています。

  • アプリフォルダにあるファイルリストを構築
  • その中でもデータベースに登録されているファイルのみを抽出し、一覧表示用のオブジェクトへ変換

といったことをしています。
今回問題となっていたのが、ここのアプリフォルダにあるファイルリストを構築の一部が先ほどの関数となっており、問題を引き起こしています。

さて、この起動時の処理の中で必要なデータはなんでしょうか?

起動時の処理を見直す

実際に必要なデータは、以下の3つで十分です。

  • ファイル名 ... DBのキー名として利用
  • ファイルパス ... ファイルへの特定の操作を行う場合に必要
  • ページ数 ... 一覧の進捗表示に利用

つまり、PDFデータ自体はメモリにある必要がありません。
とはいえ、ページ数の取得のために一時的にPDFDocumentとして読み込む必要はあるため、autoreleasepoolを組み合わせつつ、必要な情報を一時的に読み込むようにします。

  func fetchDocumentInfos() -> [PDFInfo] {
      // 中略...
      for url in pdfURLs {
          autoreleasepool {  // autoreleasepoolで都度メモリ解放
              if let document = PDFDocument(url: url) {
                  let pageCount = document.pageCount

                  //  PDFDocument自体は保持せず、必要なデータのみ追加
                  let info = PDFInfo(
                      filePath: url,
                      fileName: fileName,
                      pageCount: pageCount,
                      thumbnail: nil // 後続処理で使うためのパラメータ
                  )
                  infos.append(info)
              }
          }
      }
      return infos
  }

これに合わせて他の処理も書き換えて実行してみると、約380冊(3GB〜)のメモリ消費量が80~110MB程度に落ち着きました 👏

autoreleasepool

Swiftではオブジェクトを使用しなくなってもすぐにメモリが解放されるわけではなく、今回のようなループの中で大量のデータを扱うような処理があった場合に、メモリの消費量が跳ね上がる可能性があります。
そのような場合にautoreleasepoolを使うことで、そのブロックを抜けてからできる限り早く解放されるようにメモリ管理を手動で行うことができます。

https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/MemoryMgmt/Articles/mmAutoreleasePools.html

今回のようなケースでは特に有用で、一時的にファイルを読み込まなければならないが、すぐに利用しなくなる場合はautoreleasepoolを使うことで、できるだけ早くメモリを解放させることができます。

まとめ

リリース当初は50冊程度でバッテリー消費量も大したことないなと思っていましたが、ファイル数やファイルサイズに応じてメモリの消費量がかなり影響を受けていたことをやっと認識でき、対応することができました。
まだまだ改善点があり、

  • サムネイル画像の圧縮
  • ページ数などのデータをSwiftDataを使ってキャッシュ

あたりを進めていければ、初回起動時の速度もバッテリー消費も今よりも軽減されるのはすでに判明しているので対応してきたいですね。

https://apps.apple.com/jp/app/bookil/id6475175265?l=en-US

もし気になった方がいればですが、ストア審査中なので、新しいバージョン2025.08.2がリリースされてからお試しすることを推奨します。

Discussion