Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

Nuke + UIImageViewでいい感じにURLを読み込ませたい!

お久しぶりです。 モバイルクライアントグループの若宮(id:D_R_100)です。

もともとはAndroidアプリ専任だったのですが、昨年11月ごろよりiOSアプリ開発にも参加するようになりました。 今回は、iOSアプリに参加して取り組んでいたNukeによる画像読み込み処理改善についてまとめてみます。

画像の読み込み事情

Studyplusのクライアントアプリは画像の表示箇所が非常に多いアプリです。 ユーザーアイコンや特集記事のアイキャッチ、連携しているアプリアイコンなど至る所に画像が存在しています。

これらの画像は、それぞれのタイミングでSteakと呼ばれる社内システムから取得しています。 サーバー側の実装については、過去のエントリをご参照ください。

tech.studyplus.co.jp

Studyplus iOSアプリの歴史は古いため、もともと画像の読み込み処理を自前で実装していました。 また、その名残で画面サイズに応じたリサイズ済みの画像をViewごとにリクエストしていました。

こういった事情の中、iOSアプリ開発に余剰な戦力として参加することになりました。 AndroidではGlideを利用していた経験などもあったため、"お試し"で入っていたNukeをアプリ内で全面採用できるよう対応を進めるタスクに取り組みました。

Nuke + スタディプラスiOSアプリ

NukeはiOSアプリ開発で非常に人気のあるライブラリです。

github.com

余談となりますが、今回Nukeについていろいろと調べている時に下記のようなOSSライブラリを比較するページを見つけました。 有名ライブラリの活発さを比較できるのは面白いですね。 下記はKingfisherとNukeの比較ですが、単にGithubのリポジトリを見比べても気付きにくいところが比較できるので、選択のしやすさが高いなと感じます。

ios.libhunt.com

開発方針

開発を行うにあたりiOSのライブラリや既存コード、Androidライブラリの知見などを組み合わせて下記の方針を立てました。

  1. デフォルト画像を用意する側が意識せずとも、メモリに優しい実装とする
  2. イニシャライザの引数で、Viewを構成する要素を与え余計な変更の余地を残さない
  3. JSONのパラメーターをパースして渡すことになるため、nil許容で期待する動作を実現する
  4. 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に名前空間を作成しています。 これは意図しない関数の上書きを防ぐことや、コードの可読性をあげることを目的にしています。

この手法は下記のような記事を参考に取り入れているものです。

techblog.zozo.com

qiita.com

この手法に則り、今回は 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アプリの体験を向上できればと考えています!