【個人開発】Android用画像・漫画ビューアを作ったお話

リリースして1年ちょっとたちますが、30回ぐらいアップデートしてそろそろ使えるレベルになったんじゃないかなと
思ったので記事を書きます。

ダウンロードよろしくおねがいします。

**[ComiChu - ZIP・SMB2対応画像・漫画ビューア](https://play.google.com/store/apps/details?id=glanium.comicz&hl=ja&gl=US)**

# どんなアプリか
タイトルにも書いたようにandroid向けの画像・漫画ビューアです。
軽くインストールして触って見ると記事が理解しやすくなるかもしれませんw

ストレージとして端末とローカルネットワーク(SMB2)以外にDropboxやOneDriveなどのクラウドのストレージにも対応しています。
Zipファイルの中身をダウンロードして待つことなくストリーミングで表示できます。

シンプル、軽量、超高速がウリです。


<img src=https://play-lh.googleusercontent.com/qPIoWra0ZwORycfDCm5vA-VbIoHSq2jmx9BiQx21GH0UbAgtrD4lbF8JkVkvHttUjjY=w720-h310>

<img src=https://play-lh.googleusercontent.com/G03Cj_pn0gQP3y4xcz2ofYnysH8hJc359jiasa_ROnnRlw9kVlzimsnlS3DI5l1lIzU=w720-h310>

画面は古いですが今はMaterial 3もどきに対応しています。

# 技術的なあれこれ

開発言語は開発開始時期が古いのでJavaがメインだったのですが、途中で一部KotlinにしてAsyncTaskをコルーチンで置き換えたりしてます
全体は

* MVVM+データバインディング

で作ってます。

#### 画面構成

* MainActivity
* ViewerActivity
* StoragesActivity
* SettingsActivity
* OAuth2StorageActivity

アクティビティは5つです。最初の2つが重要ですね。

MainActivityは起動指定されてて、ここにストレージ内のファイル一覧が表示されます。フォルダの中身を表示するフラグメントであるFileBrowserFragmentをホストします。
フォルダ階層を辿るたびに新しいFileBrowserFragmentを作成して表示して、
バックスタックにどんどん積んでいき元にフォルダに戻れるようになっています。

上部にフォルダ階層のBreadcrumbが表示しています。

#### Parcelable vs Serializable

ところで、今更感がありますが、みなさんはどっち派でしょうか？

めんどくさがり屋の自分はSerializableです。Serilalizableの利点はマーカーインターフェースなので実装が簡単ってのもあるのですが、やっぱり複雑なオブジェクトグラフを効率よくシリアライズしてくれるところです。

例えば、このアプリではファイルブラウザからファイルを選択してビューアで表示するときに、ファイルブラウザでの表示順やフィルター結果などをそのまんまビューアで表示するので、ファイルブラウザで表示されてるファイルの情報をすべてをシリアライズしてビューアに渡しています。
アクティビティやフラグメント間でBundle経由でデータをやり取りするとき、データ量が大きすぎるとTransactionTooLargeException例外が出たりするのですが、この対策にもなるべく効率よくシリアライズしてくれるSerializableを重宝しています。

とは言ってもこのアプリではそれでもまずくて、圧縮という必殺技を使ってます。

シリアライズしてバイトの配列にしたあと、GZipStreamなどでデータを圧縮して、アクティビティやフラグメント間でデータをやり取りしてます。

例えば、10000ファイルぐらいのオブジェクトグラフをシリアライズして、GZIPOutputStreamで圧縮して送信すると
データ量はこんな感じになります

* 非圧縮 800KB
* gzip  300KB

圧縮、伸長もほぼ一瞬で画面遷移時にもたつきは全く感じられません。

まぁ、圧縮しようがいずれ限界(TransactionTooLargeException例外)にぶち当たりますが

#### キャッシュのお話
画像や漫画を見るのに表示が遅くてはたまりませんので、ここはひたすらキャッシュしまくりました。

まずは、サムネイルについて
メモリキャッシュ+ディスクキャッシュの2段階構成です
最初、サムネイル画像をメモリキャッシュするときに、JPGなどでエンコードされた状態でメモリキャッシュしとけば、いっぱいキャッシュできると実装したのですが、いざ、サムネイル画像が表示されるファイルブラウザ部分で高速にスクロールさせるとデコードが追いつきませんでした:disappointed_relieved:
おとなしくデコードした[Bitmap](https://developer.android.com/reference/android/graphics/Bitmap)の状態でキャッシュし、
同じみの[LruCache](https://developer.android.com/reference/android/util/LruCache)を使って管理しています。

ファイルについて
SMB2やDropboxなどリモートストレージにも対応しているのでファイルキャッシュは重要です。
こちらもメモリキャッシュ+ディスクキャッシュの2段階構成です。
こちらはファイルはデコードしていないそのまんまの形でメモリにキャッシュしています。
SMB2などローカルネットワークは普通のWifi環境があれば、ディスクキャッシュしなくてもメモリキャッシュだけで十分高速なので
ディスクキャッシュを無効にするオプションを用意しています。
クラウドストレージは頻繁にアクセスすると怒られそうなので常にディスクキャッシュはオンです。

そして、画像の先読み機能
現在表示してる画像の次や前の画像を前もって読んでおいて、表示を高速にする機能ですね
これは[ViewPager2](https://developer.android.com/reference/androidx/viewpager2/widget/ViewPager2)を使ってページ遷移を
実装しているので、そのoffscrenPageLimitを使ってます(ありがたや)。



ここからはアプリのバージョン別に追加した機能毎に要点となる技術を上げます。
詳細は各自Google検索して下さい。

アプリの更新履歴とともに振り返ってみます。

#### V1.4(2021/12/17)-1.6(2021/12/20)
- ファイルブラウザに名前を付けて保存機能の追加
- 名前を付けて保存機能の信頼性向上
- 名前を付けて保存時に進捗状況を通知領域に表示またキャンセルできるように変更
 
v1.4でファイルブラウザにファイルを保存する機能を追加しました。
最初に普通に実装しましたが、巨大なファイルをコピー中にアプリを閉じたりすると...
はい、信頼性がやばいですね。

ということで[WokerManager:grinning:](https://developer.android.com/topic/libraries/architecture/workmanager)を使いましょう。Workerをフォアグラウンドにし、通知領域に進捗状況を表示し途中でキャンセルできるようにしました。

#### V1.9(2022/12/30)
- ファイルブラウザに削除機能の追加(端末、ローカルネットワークのみ)
- ファイルブラウザに名前の変更機能の追加(端末のみ)

最初のリリースではファイルの管理機能は全くなかったのですが、v1.9でファイルを
削除できるようにしました。管理する機能の事を全く考慮せずRecyclerViewで最小限に
実装していたので、試しに削除機能を実装すると削除するたびにRecylerViewのAdapterを再作成する作りになっていたのでスクロール位置がリセットされる:sob:


ということで、まずは、お決まりのあれ。[DiffUtil:grinning:](https://developer.android.com/reference/androidx/recyclerview/widget/DiffUtil)で

DiffUtilsは変更前と変更後のリストを与えると差分をとってくれてそれをもとにアニメーション表示してくれるやつです。

実装していい感じなのですが、試しにファイル数が10000ぐらいのフォルダで削除してみると
そこそこいいスペックのSoCのデバイス上で実行しても2,3秒かかって重い
変更が多ければ多くなるほどどんどんやばくなります。
はい、差分取るのに時間がかかりすぎで、10000ファイルは想定内のファイル数なのでダメですね。

ということで仕方なく元のリストを[ObservableList:grinning:](https://developer.android.com/reference/android/databinding/ObservableList)にして
RecylerViewのAdapterの方で監視して適宜、AdapterのnotifyXXXで変更通知するようにしました

#### v1.11(2022/01/28)
- ストレージの管理画面の追加
- ドラッグ&ドロップでストレージの表示位置を変更する機能の追加

v1.11でストレージの管理をするStoragesActivityを追加しました。
はい、ストレージの表示位置をドラッグ&ドラップで変更できるようにするのが目的です。

こんな時はお決まりの[ItemTouchHelper:grinning:](https://developer.android.com/reference/androidx/recyclerview/widget/ItemTouchHelper)を使いましょう

簡単に実装できます。

#### v1.13(2022/03/03)
- Material 3に対応

はい。[Material Components for Android](https://github.com/material-components/material-components-android)がV1.5で[Material Design 3](https://m3.material.io/)に対応し始めたので、Material 3に対応させました。

#### v1.20(2022/08/18)
- 画像エフェクト機能の追加
- 画像エフェクトの128ビットSIMDへの対応(NEON,SSE)
- グレースケールエフェクトの追加
- 反転エフェクトの追加

画像のエフェクト機能を追加しました。最初はJavaでテスト的に実装しますが案の定、速度的に遅すぎるのでC/C++を使った[NDK](https://developer.android.com/ndk?hl=ja)を使うことになりました。
更にももちろんSIMDで高速化しました。

[ひとりNEON Advent Calendar 2020](https://qiita.com/advent-calendar/2020/neon-only)にはお世話になりました。

#### v1.27(2023/02/07)
- 履歴機能の追加(書庫フォルダのみ、デフォルトは無効)

ここで、履歴機能の追加でRecyclerViewの見直しを行っていたのですが、DiffUtilを使うように変更しました。v1.9でDiffUtilsの使用を諦めてObservableListを使うようにしたと書きましたが、例えば、変更の差分が大きくなるファイルの並べ替え処理とかでも差分を取ろうとしていた自分がおバカさんでした。
ということで何でもかんでも差分を取るのではなく、変更の差分が大きくなる処理ではRecyclerViewのAdapter毎交換して、ファイルの１件削除などの変更の差分が小さい処理でだけ差分を取るようにしました。



以上です。