見出し画像

Now in REALITY Tech #52 iOS 16の新機能「ImageRenderer」でSwiftUIのViewを簡単に画像に書き出す

こんにちは、2022年8月にREALITYに入社した、iOSエンジニアのチュイ(@chuymaster)です。日々かわいいアバターに癒やされながら楽しく開発しています。最高かよ・・・

はじめに

今週リリースされたREALITY iOSアプリv7.16.0で、「バックグラウンドコメント視聴機能」NEXT REALITYの機能として追加しました。(iOS 16以上のみ)

この機能をONにすると、バックグラウンドで配信を聞きながらコメントを見ることができます。ながら見したい私のような人間にはとても便利です!

SwiftUIのViewで作ったバックグラウンドコメント視聴機能

この機能は、先月のiOSDC Japan 2022にて、「PiPを応用した配信コメントバー機能の開発秘話と技術の詳解」と「動画だけじゃない!iOS 15のピクチャ・イン・ピクチャを使って好きなUIを表示させよう!」のトークを聞いてインスピレーションを受けて開発しました。しかし、実装方法が異なる部分があるので、ご紹介したいと思います。

SwiftUIのViewから画像を作る際の課題

バックグラウンドコメント視聴機能は、PiPの技術を活用しています。PiPの動画プレイヤーで好きな View を描画するには、View から CMSampleBuffer を作って、AVPictureInPictureController の ContentSource に渡す必要があります。

前章のトークセッションの資料で、UIView から CMSampleBuffer を作る方法が紹介されていて素晴らしいですが、SwiftUI で作った View の場合、一度 View を UIHostingController に入れて UIView に変換してからでないといけない(実装例)ので、無駄な処理コストが発生しました。この処理コストが課題となりました。

ImageRendererの紹介

SwiftUI の View から直接画像を作る方法はないか?と思い、調べたところ、iOS 16.0から追加された ImageRenderer のAPIを見つけました。例えば、UIImage を書き出して保存したい場合、このように書けばすぐできます。

// 独立した関数
@MainActor func makeImage(body: some View, size: CGSize) -> UIImage? {
    let renderer = ImageRenderer(content: body)
    renderer.scale = UIScreen.main.scale
    renderer.proposedSize = ProposedViewSize(size)
    return renderer.uiImage // .cgImage もある
}

// 呼び出し
Task {
    let image = await makeImage(body: ContentView(), size: .init(width: 300, height: 50))
}

SwiftUI の View と出力したいサイズを渡すだけで、UIImage を作ることができます。なお、メインスレッドで実行する必要があるので、 @MainActor をつける必要があります。CGImage 版の .cgImage も用意されているので、活用幅がとても広いですね!

PlaygroundでREALITY君が現れた!

UIImage/CGImage さえできれば、あとは CMSampleBuffer に変換するだけです。様々な方がサンプルコードを書いている()ので、参考にすれば苦労しないと思います。

バックグラウンド時の罠

しかし、一つ重要な問題がありました。ImageRenderer の .uiImage はバックグラウンドでは nil を返してしまうのです・・・下記のコードで検証できます。(Xcode 14.1 beta 3で実行)

struct ContentView: View {
    @State var currentDate = Date()
    @Environment(\.scenePhase) var scenePhase
    let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

    var body: some View {
        Text("Hello World")
        .onReceive(timer) { time in
            let image = makeImage(body: Rectangle(), size: .init(width: 100, height: 100))
            let description = image == nil ? "uiImage is nil" : "uiImage is not nil"
            // 1秒ごとに画像を出力
            print("\(time.ISO8601Format(.iso8601)) \(description)")
        }
        .onChange(of: scenePhase) { newPhase in
            if newPhase == .active {
                print("ScenePhase: Active")
            } else if newPhase == .inactive {
                print("ScenePhase: Inactive")
            } else if newPhase == .background {
                print("ScenePhase: Background")
            }
        }
    }
}

出力されたログはこちら

ScenePhase: Active
2022-10-18T00:58:42Z uiImage is not nil
2022-10-18T00:58:43Z uiImage is not nil
ScenePhase: Inactive
2022-10-18T00:58:44Z uiImage is not nil
ScenePhase: Background
2022-10-18T00:58:46Z uiImage is nil
2022-10-18T00:58:46Z uiImage is nil

ご覧のように、ScenePhase が Background になるとすぐに .uiImage が nil に変わりました。これではPiPに画像を流すことができません。バックグラウンドで新しいコメントをリアルタイムで見せたいのに、バックグラウンド時にコメントを更新できないなんて・・・機能の意味がなくなりますね😢。

バックグラウンドで画像出力するには

幸い、ImageRenderer には、renderer という関数があります。この関数を使うと、アプリがバックグラウンド状態になっても、好きな CGContext に対して View を画像出力できました。(ありがとうApple様🙏)

コードは下記の通りです。

@MainActor func makeImage(body: some View, size: CGSize) -> UIImage? {
    let imageRenderer = ImageRenderer(content: body)
    imageRenderer.scale = UIScreen.main.scale
    imageRenderer.proposedSize = ProposedViewSize(size)
    let uiGraphicsImageRenderer = UIGraphicsImageRenderer(size: size)
    let image = uiGraphicsImageRenderer.image { context in
        imageRenderer.render { _, uiGraphicsImageRenderer in
            // CGContextの座標は上下が逆なので、反転させる
            let flipVerticalMatrix = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: size.height)
            context.cgContext.concatenate(flipVerticalMatrix)
            uiGraphicsImageRenderer(context.cgContext)
        }
    }
    return image
}

UIGraphicsImageRenderer の CGContext に SwiftUI の View を描画し、画像を反転させて UIImage を作成することができました。これでバックグラウンド時でも画像出力できて、無事 CMSampleBuffer に変換することができました🎉。

パフォーマンスについて

PiP で View を描画するには、パフォーマンスに気をつける必要があります。なぜなら、メインスレッドで実行しているからです。リアルタイムコメントのように、頻繁に更新される View を描画する場合、描画時間が長いとアプリ全体のパフォーマンス低下に繋がりかねません。

ということで、iPhone 8 を使って、 View を1,000回 UIImage に変換した際の平均実行時間を図りました。

描画するView

結果から分かるように、ImageRenderer による描画はとても早いです。iOS 16未満だと、UIView の .drawHierarchy を使うことになると思いますが、そのパフォーマンスの差が20倍以上です。

実際に描画する View はアイコン画像があるなど、もう少し要素が多いですが、それでも実測 5~6 ms 程度で描画できるので、何もチューニングせずとも、アプリのパフォーマンスにほとんど影響がないレベルを実現できました。

おわりに

iOS 16から使える ImageRenderer で、SwiftUI の View を簡単に画像に書き出すことができるようになりました。少し手間がありますが、アプリがバックグラウンド状態でも可能です。個人的に、時代は UIKit から SwiftUI に進んでいると感じたので、こういったネイティブAPIがあると嬉しいですね!

REALITYアプリにはNEXT REALITY機能があって、エンジニアが主体的に機能開発を進めることができます。このように、iOS 16のみサポートされている最新技術も割とすぐに実装してリリースできます。最新技術に興味がある方など、一緒にREALITYを作ってくれる仲間を募集中です!