Site icon image技術メモ

漫画ダウンロード機能設計・実装後の覚書

ダウンロード機能提供に必要な実装

機能概要

① マンガダウンローダーの実装

  • サーバーから画像URLを受け取り、画像データをローカルに保存する
  • ダウンロード進捗や書籍情報をローカルデータベースに保持し、更新する

② UI構築

  • 新規画面
    • ダウンロード画面の実装
    • オフライン時に表示する画面実装
  • 既存
    • 本棚、マイページなど
  • オンライン ←→オフライン切り替え処理の実装

マンガダウンローダーの実装

ダウンロード処理の流れ
  • 前処理 → 本処理 → 後処理 の順番で必要な処理を行う
  • それぞれやっていること
    • 前処理
      • 画像データ取得前までの検証や下準備
    • 本処理
      • 画像データを一ページずつダウンロードし保存
    • 後処理
      • ローカルデータベースの情報を更新
  • より細かく処理を羅列した結果が下記図になります
Image in a image block

実装詳細

  • フォルダ構成
    • DownloaderPluginに分かれている
    Image in a image block
    • Core(BookDownloader)
      • BookDownloadManager(class)
        • BookDownloaderの配列を保持し、ダウンロード処理全体を管理
      • BookDownloader(class)
        • PageDownloadOperationなどの、ダウンロード処理に関わるOperationのサブクラスを配列で保持し、マンガ1巻のダウンロード処理を担うクラス
      • PageDownloadOperation(class)
        • 1ページ分のダウンロード処理を担うクラス
      • BookDownloadInjectable(protocol)
        • 準拠することで、ダウンロード処理流れの各ポイントに対して、処理を注入することできる
      • BookDownloadObserver
        • 準拠することで、ダウンロード処理の進捗通知を受け取ることができる
      • Downloaderの主要クラスの関係ざっくり図解
      Image in a image block
    • Plugin
      • 画像ダウンロード処理に必須ではないが、ユースケースによっては必要な処理を簡単に行うための手段をまとめたもの
        • 主に、BookDownloadInjectableを継承したクラス内で利用される
      • Plugin例
        • DeviceStorageDataSource
          • 端末の空き容量取得メソッドを用意
        • LocalFileDataSource
          • 端末のローカルフォルダのファイル読み取り,書き込み,バックアップ対策処理などを用意
        • LocalNotificationWrapper
          • ダウンロード完了時にローカル通知を出すためのWrapperクラス
        • AES
          • 暗号化処理
  • 実装の要点
    • OperationQueueを活用したバックグラウンド処理
      • Operationとは
        An abstract class that represents the code and data associated with a single task.
        An operation queue executes its operations either directly, by running them on secondary threads, or indirectly using the libdispatch
         library (also known as Grand Central Dispatch).
        • サブクラスして使うabstruct class
        • タスクを必要に応じてGCDを介して実行することが可能
          • GCDにできることは基本的にOperationでも実現可能
            • GCDと比較した場合の強み
              • タスクの一時停止が可能
              • タスクのキャンセルが可能
              • タスクの依存関係を簡単に定義可能
                • GCDの場合はDispatchSemaphoreを活用して書くことになるが、Operationの場合は専用のメソッドが用意されている
              • 他にもOperationQueueのAPIにさまざま便利機能あります
              • この辺りのThreadingや非同期処理に関するおすすめ動画こちら
                • 90番台~100番台で非同期処理や並列処理について語ってるので、事前調査していた頃に大変助かりました
      • OperationQueueの使い方
    • マルチスレッドでRealmへ書き込む
      • ダウンロード処理がバックグラウンドスレッドで走るので、Injector経由で実行されるRealmへのアクセスもバックグラウンドで行われるが、書き込みをバックグラウンドで普通にやるとクラッシュする
        • 調査しても記事が全然なくて、結局古いバージョンのRealmのドキュメントから掘り出しました
      • やっていること
        • ManagedObjectか否かをチェックして、真の場合は ThreadSafeReference を介して書き込むようにする
      • 参考
    import Foundation
    import os.log
    import RealmSwift
    
    // MARK: - Background Write
    extension Realm {
    
        /// バックグラウンドで書き込む
        ///
        /// #Reference:
        ///  [Recipe: Pass an object to a background thread](https://docs.mongodb.com/realm-legacy/docs/cookbook/swift/object-to-background.html)
        func asyncWrite<T: ThreadConfined>(
            _ obj: T,
            config: Realm.Configuration,
            block: @escaping ((Realm, T?) -> Void),
            successHandler: @escaping (() -> Void),
            errorHandler: @escaping ((_ error: Swift.Error) -> Void)
        ) {
            os_log("Realm+ asyncWrite start obj: %s", log: OSLog.realm, type: .info, "\(obj)")
    
            let myConfig = self.configuration
    
            var wrappedObj: ThreadSafeReference<T>?
    
            if obj.realm != nil {
                os_log("Realm+ asyncWrite obj is managed.", log: OSLog.realm, type: .info)
    
                // managed objectの場合はobjectのreferenceを取得する
                wrappedObj = ThreadSafeReference(to: obj)
            }
    
            self.queue.addOperation {
                autoreleasepool {
                    do {
                        let realm = try Realm(configuration: myConfig)
                        if let wrappedObj {
                            let resolved = realm.resolve(wrappedObj)
                            try realm.write {
                                block(realm, resolved)
                            }
                        } else {
                            // unmanaged objectの場合はそのまま書き込みが可能
                            try realm.write {
                                block(realm, obj)
                            }
                        }
                        successHandler()
                    } catch {
                        errorHandler(error)
                    }
                }
            }
        }
    }
    
    // MARK: - Queue for Background Write
    extension Realm {
    
        public var queue: OperationQueue {
            if let queue = OperationQueueStore.queue {
                os_log("queue already created", log: OSLog.realm, type: .info)
                return queue
            } else {
                os_log("create new queue", log: OSLog.realm, type: .info)
    
                let queue = Self.createDefaultQueue()
                OperationQueueStore.queue = queue
                return queue
            }
        }
    
        private enum OperationQueueStore {
            static var queue: OperationQueue?
        }
    
        private static func createDefaultQueue() -> OperationQueue {
            let queue = OperationQueue()
            queue.maxConcurrentOperationCount = 1
            queue.qualityOfService = .default
            return queue
        }
    }
    • なぜやるのか
      • データ競合と競合状態の違い
        • データ競合を防ぐためにThreadSafeな形でオブジェクトに書き込む
          • Realmでは読み取りはもともとThreadSafe
        • 競合状態を防ぐために maxConcurrentOperationCount=1に設定した OperationQueue を介して書き込みを行う
    • デバッグ関連
      • ログ出力
        // MARK: - OSLogger
        final class OSLogger {
        
            static let shared = OSLogger()
        
            private let logger = Logger()
        
            private init() {}
        
            func log(
                _ message: String,
                category: Category = .general
            ) {
                guard !Environment.isReleaseBuild else {
                    return
                }
                self.logger.debug("[\(category.rawValue)] \(message, privacy: .public)")
            }
        }
        • OSLogを利用したロギング
        • バックグラウンドダウンロードのデバッグ時に便利そうだったので入れた
          • なくても問題ないっちゃない
        • Console.appにつなげばログが出るので大変楽
          • FADで配布したテストアプリのログもPCに繋げば見れる
      • ダウンロードした画像データや、RealmDBの中身の確認
      • 実機
        • Devices&SimulatorsからContainerをダウンロードする
        • ダウンロードしたデータの中身を開いて確認
        • RealmファイルはRealmStudioで確認
      • シミュレーター
        • SimulatorManagerを使って、利用中のSimulatorのContainerにアクセスして内部データを確認する
    • 特にハマったポイント
      • isDiscretionary=falseに設定するのを忘れていた
        • うぇぶりのダウンロード処理は単一現状単一Queueなので、一つのダウンロード処理が一時停止すると次に進めないデッドロックが発生する
        • 正しく実装しているはずなのにたまに処理が止まる原因これだった
      • 本棚スクロール時に稀にクラッシュする問題
        • BookDownloaderが破棄されてもURLSessionはどうやら残る
          • ダウンロードするコンテンツごとに同一のidentifierを使っていたため、本棚をスクロールするタイミングによっては、古いURLSessionが破棄される前に新しいBookDownloaderが生成されてしまっていたため、ダウンロードが始まらなかった
      • realmのバックグラウンド書き込み時に稀にクラッシュする問題の対策
        • realmのバックグラウンド書き込みをjobとしてqueueに突っ込む形にして負荷軽減
        • realmファイルを分割して負荷軽減