iOSアプリ開発におけるモック画像生成：外部サービスに頼らない安全なアプローチ

こんにちは。iOSアプリの開発を行っている @zrn-ns です。  
withアドベントカレンダー3日目です。

iOSアプリ開発をしていると、URLから画像を読み込んで画面に表示する機能を開発することが多々あります。
今回はそんなときに使える「モック画像生成」に関するtipsをお伝えします。


# モック画像を生成してくれるwebサービスについて

モック画像生成をしたいとき、まず真っ先に思いつくのは [placehold.jp](https://placehold.jp/) のようなwebサービスです。

https://placehold.jp/

非常に便利なサービスで `https://placehold.jp/3d4070/ffffff/150x150.png?text=hoge` のようにURLのパスやクエリにパラメータを指定することで、画像の背景色、文字色、画像サイズ、画像上に表示する文字列を任意に指定した画像を生成できるため、状況に沿った検証が可能です。

かなり昔からあるサービスですし、日本のSI企業が提供しているサービスであり、個人アプリの開発を行う際にはよく利用しています。


# モック画像生成サービスを利用することのリスク

しかしこのような外部サービスの利用には、少なからずリスクが存在します。

- **サービスが停止、終了、または内容変更される可能性**  
  サービスが利用できなくなると、アプリのコードを変更しなければなりません。
  
- **セキュリティリスク**  
  対象のサービスやドメインが悪意ある人間に買収された場合、悪意あるレスポンスがライブラリやOSの脆弱性を突く可能性があります。また、リクエスト情報が収集される危険性もあります。

あくまで可能性の話ではありますが、仕事でコードを書く際には、これらのリスクを完全に無視することはできません。

# アプリ内でダミー画像生成して返す仕組みを作る

そこで、アプリ内に上記の外部サービスのような仕組みを内包できないかと考えました。  
具体的には以下のような仕組みです。

- 特定のドメイン（例：`placeholder`）へのHTTP通信をインターセプトする。
- `/image.png` へのアクセスがあった場合、レスポンスとして画像データを返す。
  - 画像データはリクエストパラメータに応じてカスタマイズ可能。以下を例とします：
    - **height**: Int型。画像の高さ(px数)。デフォルトは250。
    - **width**: Int型。画像の幅(px数)。デフォルトは250。
    - **text**: String型。埋め込むテキスト。デフォルトは `"dummy"`。
    - **fgcolor**: String型。文字色（16進数表現）。デフォルトは `"202f55"`。
    - **bgcolor**: String型。背景色（16進数表現）。デフォルトは `"dddddd"`。


# 実装

以下に実装方法を解説します。

### 特定ドメインへの通信処理をインターセプトする

Foundationの `URLProtocol` を利用します。この仕組みにより、通信をインターセプトし、レスポンスを改変可能です。

```swift
import UIKit

/// プレースホルダー画像を返すためのカスタムURLProtocol
class PlaceholderImageProtocol: URLProtocol {
    override class func canInit(with request: URLRequest) -> Bool {
        guard let url = request.url, url.host == "placeholder" else { return false }
        return true
    }

    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }

    override func startLoading() {
        guard let url = request.url else {
            client?.urlProtocol(self, didFailWithError: URLError(.badURL))
            return
        }

        if url.path != "/image.png" {
            let response = HTTPURLResponse(
                url: url,
                statusCode: 404,
                httpVersion: nil,
                headerFields: ["Content-Type": "text/plain"]
            )
            client?.urlProtocol(self, didReceive: response!, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: "404 Not Found".data(using: .utf8) ?? Data())
            client?.urlProtocolDidFinishLoading(self)
            return
        }

        let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems
        let width = queryItems?.first(where: { $0.name == "width" })?.value.flatMap { Int($0) } ?? 250
        let height = queryItems?.first(where: { $0.name == "height" })?.value.flatMap { Int($0) } ?? 250
        let text = queryItems?.first(where: { $0.name == "text" })?.value ?? "dummy"
        let fgcolor = queryItems?.first(where: { $0.name == "fgcolor" })?.value ?? "202f55"
        let bgcolor = queryItems?.first(where: { $0.name == "bgcolor" })?.value ?? "dddddd"

        let image = generatePlaceholderImage(
            width: width,
            height: height,
            text: text,
            fgColor: UIColor(hex: fgcolor),
            bgColor: UIColor(hex: bgcolor)
        )

        if let imageData = image.pngData() {
            let response = HTTPURLResponse(
                url: url,
                statusCode: 200,
                httpVersion: nil,
                headerFields: ["Content-Type": "image/png"]
            )
            client?.urlProtocol(self, didReceive: response!, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: imageData)
        }

        client?.urlProtocolDidFinishLoading(self)
    }

    override func stopLoading() {}

    private func generatePlaceholderImage(width: Int, height: Int, text: String, fgColor: UIColor, bgColor: UIColor) -> UIImage {
        let size = CGSize(width: width, height: height)
        let renderer = UIGraphicsImageRenderer(size: size)

        return renderer.image { context in
            let rect = CGRect(origin: .zero, size: size)

            bgColor.setFill()
            context.fill(rect)

            let paragraphStyle = NSMutableParagraphStyle()
            paragraphStyle.alignment = .center

            let maxFontSize = min(CGFloat(width) * 0.8 / CGFloat(max(text.count, 1)), CGFloat(height) * 0.2)
            let font = UIFont.systemFont(ofSize: maxFontSize)

            let attributes: [NSAttributedString.Key: Any] = [
                .font: font,
                .foregroundColor: fgColor,
                .paragraphStyle: paragraphStyle
            ]

            let textSize = text.size(withAttributes: attributes)
            let textRect = CGRect(
                x: (size.width - textSize.width) / 2,
                y: (size.height - font.lineHeight) / 2,
                width: textSize.width,
                height: font.lineHeight
            )

            text.draw(in: textRect, withAttributes: attributes)
        }
    }
}

private extension UIColor {
    convenience init(hex: String) {
        let hexNumber = Int(hex, radix: 16) ?? 0
        
        let r = CGFloat((hexNumber & 0xff0000) >> 16) / 255
        let g = CGFloat((hexNumber & 0x00ff00) >> 8) / 255
        let b = CGFloat(hexNumber & 0x0000ff) / 255

        self.init(red: r, green: g, blue: b, alpha: 1.0)
    }
}
```

### URLProtocol の登録

上記で作成した `PlaceholderImageProtocol` を利用するには、 `URLProtocol` に登録する必要があります。

```swift
URLProtocol.registerClass(PlaceholderImageProtocol.self)
```

アプリ起動時（AppDelegate）など、適切なタイミングでこの関数を呼び出してプロトコルを登録してください。

# 使用例

以下のコードは、先ほどの `PlaceholderImageProtocol` を用いて、モック画像を取得して表示する例です。

```swift
import UIKit

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .white
        view.addSubview(imageView)
        view.addConstraints([
            imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            imageView.widthAnchor.constraint(equalToConstant: 300),
            imageView.heightAnchor.constraint(equalToConstant: 300)
        ])

        let url = URL(string: "http://placeholder/image.png?width=300&height=200&text=Hello&fgcolor=ffffff&bgcolor=ff0000")!
        let task = URLSession.shared.dataTask(with: url) { data, response, error in
            guard let data = data, let image = UIImage(data: data) else { return }
            DispatchQueue.main.async {
                self.imageView.image = image
            }
        }
        task.resume()
    }
    let imageView: UIImageView = {
        let imageView: UIImageView = .init()
        imageView.translatesAutoresizingMaskIntoConstraints = false
        imageView.backgroundColor = .lightGray
        imageView.contentMode = .scaleAspectFit
        return imageView
    }()
}

#Preview {
    ViewController()
}
```

Xcode Previews でも Simulator でも正常に動作しました（Xcode 16.1, iOS18.1 Simulator）

<img width="400px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95499/4406cbc8-2d72-a54c-5130-eb57ebdb6d0a.png" />
<img width="300px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95499/73c0776d-7fad-7a6d-fb33-56862b41acee.png" />

# メリットと注意点

このアプローチのメリットとして以下が挙げられます。

- 外部サービスに依存せず、アプリ内で完結するため、セキュリティリスクが軽減される
- ネットワーク環境に依存せず動作する
- アプリ内のコードとして実装しているので、必要に応じて柔軟にカスタマイズ可能

一方で注意点としては以下があります。

- あくまでテスト用の仕組みであり、プロダクション環境での使用を避けるべき

# さいごに

外部サービスに依存しない、安全かつ安定した仕組みを構築する事ができました。
http通信をインターセプトすると聞くと大変そうなイメージがありますが、実際には上記のようにかなり少ないコードで実現する事ができます。

現状だと最低限の実装となっていますが、URLを毎回設定するのが面倒であれば、ヘルパクラス等を用意するのが良いかなと思います。（ `PlaceholderImage.url(width: Int, height: Int, ...)` のような感じ ）

今回のソースコードはこちらのリポジトリに上げてありますので、動作確認したい場合などにご利用ください。

https://github.com/zrn-ns/PlaceholderImageExample

以上です。同じような悩みを抱えている方の助けになれば幸いです。
最後までお読みいただきありがとうございました！