お久しぶりです。 モバイルクライアントグループの若宮(id:D_R_100)です。
もともとはAndroidアプリ専任だったのですが、昨年11月ごろよりiOSアプリ開発にも参加するようになりました。 今回は、iOSアプリに参加して取り組んでいたNukeによる画像読み込み処理改善についてまとめてみます。
画像の読み込み事情
Studyplusのクライアントアプリは画像の表示箇所が非常に多いアプリです。 ユーザーアイコンや特集記事のアイキャッチ、連携しているアプリアイコンなど至る所に画像が存在しています。
これらの画像は、それぞれのタイミングでSteakと呼ばれる社内システムから取得しています。 サーバー側の実装については、過去のエントリをご参照ください。
Studyplus iOSアプリの歴史は古いため、もともと画像の読み込み処理を自前で実装していました。 また、その名残で画面サイズに応じたリサイズ済みの画像をViewごとにリクエストしていました。
こういった事情の中、iOSアプリ開発に余剰な戦力として参加することになりました。 AndroidではGlideを利用していた経験などもあったため、"お試し"で入っていたNukeをアプリ内で全面採用できるよう対応を進めるタスクに取り組みました。
Nuke + スタディプラスiOSアプリ
NukeはiOSアプリ開発で非常に人気のあるライブラリです。
余談となりますが、今回Nukeについていろいろと調べている時に下記のようなOSSライブラリを比較するページを見つけました。 有名ライブラリの活発さを比較できるのは面白いですね。 下記はKingfisherとNukeの比較ですが、単にGithubのリポジトリを見比べても気付きにくいところが比較できるので、選択のしやすさが高いなと感じます。
開発方針
開発を行うにあたりiOSのライブラリや既存コード、Androidライブラリの知見などを組み合わせて下記の方針を立てました。
- デフォルト画像を用意する側が意識せずとも、メモリに優しい実装とする
- イニシャライザの引数で、Viewを構成する要素を与え余計な変更の余地を残さない
- JSONのパラメーターをパースして渡すことになるため、nil許容で期待する動作を実現する
- enumを利用し、Xcode上で簡単に呼び出しができるようにする
デフォルト画像については、iOSの UIImage
のドキュメントを確認したところ named
引数をとるイニシャライザを利用することで自動的に管理されることがわかりました。
このため named
引数をとる UIImage
を使いやすくすることで、メモリに優しい処理が自然と利用できるようにしました。
https://developer.apple.com/documentation/uikit/uiimage
Use the init(named:in:compatibleWith:) method (or the init(named:) method) to create an image from an image asset or image file located in your app’s main bundle (or some other known bundle). Because these methods cache the image data automatically, they are especially recommended for images that you use frequently.
それ以外の方針については、次の節からコードを参考に説明していきたいと思います。
Nukeを呼び出すexクラスの作成
スタディプラスiOSアプリでは、アプリ内で利用する拡張関数を作成する際、Extensionに名前空間を作成しています。 これは意図しない関数の上書きを防ぐことや、コードの可読性をあげることを目的にしています。
この手法は下記のような記事を参考に取り入れているものです。
この手法に則り、今回は ex.loadUrl
と呼び出せるよう関数を追加しました。
合わせて、アプリ内で画像のリサイズモードをOSに(そこまで)依存せずに呼び出せるよう ProcessorsOption
というenumを作成しています。
public enum ProcessorsOption { case resize case resizeRound(radius: CGFloat) case resizeCircle } public typealias AspectMode = ImageProcessor.Resize.ContentMode public extension Extension where Base == UIImageView { func loadUrl(imageUrl: String?, processorOption: ProcessorsOption = ProcessorsOption.resize, aspectMode: AspectMode = .aspectFill, crop: Bool = false, defaultImage: UIImage? = nil, contentMode: UIView.ContentMode? = nil) { loadUrl(imageUrl: imageUrl, processorOption: processorOption, aspectMode: aspectMode, crop: crop, placeHolder: defaultImage, failureImage: defaultImage, contentMode: contentMode) } func loadUrl(imageUrl: String?, processorOption: ProcessorsOption = ProcessorsOption.resize, aspectMode: AspectMode = .aspectFill, crop: Bool = false, placeHolder: UIImage? = nil, failureImage: UIImage? = nil, contentMode: UIView.ContentMode? = nil) { guard let url: String = imageUrl else { base.image = failureImage return } guard let loadUrl: URL = URL(string: url) else { base.image = failureImage return } let resizeProcessor = ImageProcessor.Resize(size: base.bounds.size, contentMode: aspectMode, crop: crop) let processors: [ImageProcessing] switch processorOption { case .resize: processors = [resizeProcessor] case .resizeRound(let radius): processors = [resizeProcessor, ImageProcessor.RoundedCorners(radius: radius)] case .resizeCircle: processors = [resizeProcessor, ImageProcessor.Circle()] } let request = ImageRequest( url: loadUrl, processors: processors ) var contentModes: ImageLoadingOptions.ContentModes? if let mode = contentMode { contentModes = ImageLoadingOptions.ContentModes.init(success: mode, failure: mode, placeholder: mode) } let loadingOptions = ImageLoadingOptions(placeholder: placeHolder, failureImage: failureImage, contentModes: contentModes) Nuke.loadImage(with: request, options: loadingOptions, into: base) } }
ロード中と失敗時の画像として同一の UIImage
を用いるケースが多いため、画像指定の分岐を吸収する関数を用意しています。
また UIImageView
の拡張にすることで、 UIImageView
のサイズに応じたリサイズ処理を実施しています。
サーバー側で実装していた画像のリサイズ処理をアプリ側に移譲することで、アプリ側でディスクキャッシュを利用するなどの機能改善の余地を作ることができました。
URLを受け取るUIImageViewの作成
続いてアプリ内でよく使う切り抜き処理などを加えた、汎用的なImageViewを作成します。
DefaultIconName
を用意すると、利用時にenumを選択することで named
を引数としたUIImageが作成されるようになります。
enum DefaultIconName: String { case user = "default_icon_user" case university = "record_icon_university" } extension DefaultIconName { func createIcon() -> UIImage? { return UIImage(named: self.rawValue) } } final class UrlImageView: UIImageView { enum ShapeType { case square case round(radius: CGFloat) case circle } enum EdgeType { case none case edge } private var imageUrl: String? private var crop: Bool private var shapeType: ShapeType private var defaultImage: UIImage? private var edgeType: EdgeType private var aspectMode: AspectMode private var loadImageContentMode: ContentMode? convenience init(size: CGSize, imageUrl: String? = nil, crop: Bool = false, shapeType: ShapeType = .square, defaultIconName: DefaultIconName, edgeType: EdgeType = .none, aspectMode: AspectMode = .aspectFill, loadImageContentMode: UIView.ContentMode? = nil) { self.init(size: size, imageUrl: imageUrl, crop: crop, shapeType: shapeType, defaultImage: defaultIconName.createIcon(), edgeType: edgeType, aspectMode: aspectMode, loadImageContentMode: loadImageContentMode) } init(size: CGSize, imageUrl: String? = nil, crop: Bool = false, shapeType: ShapeType = .square, defaultImage: UIImage? = nil, edgeType: EdgeType = .none, aspectMode: AspectMode = .aspectFill, loadImageContentMode: UIView.ContentMode? = nil) { self.imageUrl = imageUrl self.crop = crop self.shapeType = shapeType self.defaultImage = defaultImage self.edgeType = edgeType self.aspectMode = aspectMode self.loadImageContentMode = loadImageContentMode super.init(frame: CGRect(origin: .zero, size: size)) self.image = defaultImage contentMode = .scaleAspectFit clipsToBounds = true isUserInteractionEnabled = true drawImage() } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// Reset image view for [UITableViewCell.prepareForReuse](https://developer.apple.com/documentation/uikit/uitableviewcell/1623223-prepareforreuse) func reset() { image = defaultImage imageUrl = nil } /// Set the image URL and load the image from the network /// /// - Parameters: /// - imageUrl: a url for loading an image func setImageUrl(imageUrl: String?) { self.imageUrl = imageUrl drawImage() } private func drawImage() { switch shapeType { case .square: switch edgeType { case .none: drawSquare() case .edge: drawEdgedSquare() } case .round(let radius): switch edgeType { case .none: drawRound(radius: radius) case .edge: drawEdgedRound(radius: radius) } case .circle: switch edgeType { case .none: drawCircle() case .edge: drawEdgeCircle() } } } private func drawSquare() { loadImage() } private func drawEdgedSquare() { drawEdge() loadImage() } private func drawRound(radius: CGFloat) { self.layer.cornerRadius = radius loadImage() } private func drawEdgedRound(radius: CGFloat) { self.layer.cornerRadius = radius drawEdge() loadImage() } private func drawCircle() { layer.cornerRadius = width * 0.5 loadImage() } private func drawEdgeCircle() { layer.cornerRadius = width * 0.5 drawEdge() loadImage() } private func loadImage() { (self as UIImageView).ex.loadUrl(imageUrl: imageUrl, processorOption: .resize, aspectMode: aspectMode, crop: crop, defaultImage: image, contentMode: loadImageContentMode) } private func drawEdge() { layer.borderColor = UIColor.defaultTint().cgColor layer.borderWidth = 0.5 } }
デフォルト画像の角丸や円形の切り抜きに対応するため UrlImageView
の角に処理を加えています。
こういったImageViewの操作がiOSは対応しやすいので、大変感動しました。
コード追加による効果
UrlImageView
を導入したことで、旧来の画像読み込み処理を素早く置き換えることができました。
SwiftのコードをObjective-Cから呼び出すことで、手を出しづらかった歴史的なコードの改修ができるようになったことは、個人的なモチベーションの向上にもつながっています。
その他には、一部の画像において旧来の処理よりも解像度がよくなったように見える箇所が存在しています。 旧来のコードのリファクタリングは少々困難な状態にあったため、良い形でリプレースすることができました。
終わりに
今回が初めて業務でのiOS開発でした。 SwiftやXcodeをはじめ戸惑うことは多かったのですが、Appleのドキュメントが非常に充実しており大変開発に取り組みやすかったです。
今後もアレコレと対応を進め、iOSアプリの体験を向上できればと考えています!