dely Tech Blog

クラシル・TRILLを運営するdely株式会社の開発ブログです

クラシルリワードでインタラクティブアニメーションツールの Rive を導入しました

こんにちは、クラシルリワードのiOSエンジニア uetyo です!

クラシルリワードでは、くらしうさぎをはじめとする魅力的なキャラクターたちと共に、「使っていて楽しいアプリ」を目指して日々開発に取り組んでいます。私たちのアプリがユーザーに愛される理由の一つは、これらのキャラクターを生き生きと動かすアニメーションにあると考えています。

アプリのリリース初期から、私たちはAirbnbが開発したアニメーションツールであるLottieを活用してきました。Lottieは使いやすさと高い互換性で知られていますが、複雑なモーションを実行しようとすると読み込みに時間がかかってしまうなど、アプリ体験が低下する問題に直面しました。

この問題を解決するために、私たちは「インタラクティブ」アニメーションを実現可能にする新しいツール『Rive』を導入しました。

今回はRive導入の良かった点・微妙な点、実際の利用方法などをまとめていきます。

そもそも Rive とは

Riveはアメリカシリコンバレーのベンチャー企業である Rive, inc. が開発しているアニメーションツールです。

アニメーションに関することであれば基本的に何でもできます。他のアニメーションツールとは異なりファイル内で条件分岐や外部からの引数引き渡し、サウンド再生などができることが特徴です。

シンプルなロゴアニメーション(開くとアニメーションします)
https://rive.app/community/files/3157-6670-notion-animation-concept/

状態によって変化するアニメーション(開くとアニメーションします)
https://rive.app/community/files/1030-2020-liquid-download/

アニメーションに合わせてサウンドを再生するアニメーション
www.youtube.com

クラシルリワードでの利用例(移動タブのお知らせアイコンやオンボーディングなどで利用)

規模の大きいところだと Duolingo がマルチ言語に対応したアニメーションを再生するため積極的に利用しています。Duoをよりスムーズに高い品質で再生するには不可欠とのことです。 www.youtube.com

Rive を導入して良かった点

Lottie に比べて動作が軽く、パフォーマンスが高く、できることが多いです。これまでコードで分岐させて実装していたところをデザイナーに依頼することで解決できるためモバイルエンジニアの負担を大きく減らすことができました。

Riveによる比較記事: rive.app

再生時のCPU使用率やメモリ使用量が段違いに違うためアプリパフォーマンスの向上に繋がりました。Riveファイルはバイナリ形式なので読み込み的にも◎

アニメーション作成に必要なツールも大幅に削減できました。

マルチプラットフォームに対応しているのでiOS, Android, Webすべての環境で同一のリソースを何も変更することなく利用できています。

Rive の微妙な点

Riveを利用している中で以下の点が難しいなと感じました。

  • デザイナーの学習コスト
  • 導入事例の少なさ
  • 公式が提供しているドキュメントの不足
  • Riveの更新頻度が高くついていくのが難しい

Figmaやイラストレーターのようなデザインツールとしての側面とAfterEffectsのようなアニメーションを設定する側面の両方存在するためデザイナーの学習コストが高いです(リワード開発チームでも導入から2ヶ月ほど経過するまではRiveのLottieファイル読み込み機能を用いて変換していました)

また、日本で導入している企業が少なく、公式が提供しているドキュメントも完全には網羅されていないため、不具合などはRiveコミュニティやSDKのGitHubリポジトリで相談する必要があります。

ここ数ヶ月で追加された新しい機能(アニメーションにサウンドを追加する機能、独自フォントを埋め込んで利用)のドキュメントや知見が不足しているためファーストペンギンの気持ちで望む必要がありました。

最近になり料金形態が変更になり、より柔軟な契約ができるようになったため、日本でももっと流行らせて行きたいですね。

クラシルリワードでの RiveFile 管理方法

アプリでアニメーションを実行するにはRiveから出力したバイナリファイル(以下、RiveFile)のファイル名を指定します。

public struct RiveResource {
    /// リソースファイル名
    let name: String
    /// リソースのバンドル
    let bundle: Bundle?

    public init(
        name: String,
        bundle: Bundle? = nil
    ) {
        self.name = name
        self.bundle = bundle
    }
}

extension RiveResource {
    static let loading = RiveResource(name: Files.loadingRiv.name, bundle: .module)
}

ファイル名を直で設定しまうと、RiveFileを名前ごと差し替えた際に参照できず、ランタイムクラッシュしてしまうため SwiftGen を用いて定数化しています。

// swiftgen.yml
files:
  - inputs:
      - AppPackage/Sources/HogeFeature/Resources/Rive
    filter:
      - .+\.riv$
    outputs:
      - templateName: structured-swift5
        output: AppPackage/Sources/HogeFeature/Generated/Files.swift

クラシルリワードはマルチモジュール構成なので、以下のようにアニメーションを一箇所で利用する場合は特定のFeatureに配置、複数のFeatureで使い回す場合は共有のモジュールに配置するようにしています。

.
└── Sources/
    ├── HogeFeature/
    │   ├── Resources/
    │   │   ├── super_great_animation.riv
    │   │   └── sugoku_ii_animation.rive
    │   └── Generated/
    │       └── Files.swift
    └── CommonView/
        ├── Resources/
        │   ├── common_loading_animation.riv
        │   └── minna_tsukau_animation.riv
        └── Generated/
            └── Files.swift

クラシルリワードでの RiveView 利用方法

Rive公式が提供しているSDKの実装方法ではViewに @StateObject RiveViewModel というものを保持する必要があります。しかし、クラシルリワードのコード規約では極力ViewでStateを持たないようにする必要があるため、簡単なWapperを作成しています。

@MainActor
public struct RiveView: View {
    private let viewModel: RiveViewModel
    private var animationDidFinish: (() -> Void)?

    public init(
        riveResource: RiveResource,
        fit: RiveFit = .contain,
        alignment: RiveAlignment = .center,
        autoPlay: Bool = true
    ) {
        self.viewModel = RiveViewModel(
            fileName: riveResource.name,
            in: riveResource.bundle ?? .module,
            fit: fit,
            alignment: alignment,
            autoPlay: autoPlay,
            loadCdn: false, // CDN の読み込みが遅い & 今はローカルリソースで完結しているため
            customLoader: { _, _, _ in }
        )
    }

    public var body: some View {
        RiveViewRepresentable(
            viewModel: viewModel,
            animationDidFinish: animationDidFinish
        )
    }
}

extension RiveView {
    public func animationDidFinish(_ animationDidFinish: @escaping () -> Void) -> Self {
        var copy = self
        copy.animationDidFinish = animationDidFinish
        return copy
    }
}

private struct RiveViewRepresentable: UIViewRepresentable {
    private let viewModel: RiveViewModel
    private let animationDidFinish: (() -> Void)?

    init(
        viewModel: RiveViewModel,
        animationDidFinish: (() -> Void)?
    ) {
        self.viewModel = viewModel
        self.animationDidFinish = animationDidFinish
    }

    func makeUIView(context: Context) -> RiveRuntime.RiveView {
        let view = viewModel.createRiveView()
        view.playerDelegate = context.coordinator
        view.stateMachineDelegate = context.coordinator
        return view
    }

    func updateUIView(_ view: RiveRuntime.RiveView, context: Context) {
        viewModel.update(view: view)
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    class Coordinator: NSObject, RivePlayerDelegate, RiveStateMachineDelegate {
        var parent: RiveViewRepresentable

        init(_ parent: RiveViewRepresentable) {
            self.parent = parent
        }

        func player(playedWithModel riveModel: RiveRuntime.RiveModel?) {}

        func player(pausedWithModel riveModel: RiveRuntime.RiveModel?) {
            // アニメーションの再生完了時はこのdelegateメソッドが呼ばれるので、再生完了イベントとして扱う
            parent.animationDidFinish?()
        }

        func player(loopedWithModel riveModel: RiveRuntime.RiveModel?, type: Int) {}

        func player(stoppedWithModel riveModel: RiveRuntime.RiveModel?) {}

        func player(didAdvanceby seconds: Double, riveModel: RiveRuntime.RiveModel?) {}
    }
}

必要最低限のみ記載するとこのようなViewを用意することで別途StateObjectを定義することなく利用できます。(Wrapper を生やしている関係で新しい機能が追加されたら対応する必要があるのが難点です)

VStack(spacing: 0) {
    RiveView(riveResource: .loading)
        .frame(width: 210, height: 160, alignment: .center)
}

以上のような形でクラシルリワードではアニメーションを再生する実装をおこなっています。

まとめ

  • クラシルリワードではインタラクティブアニメーションツールの Rive を導入しました
  • Rive は学習コストが高いですが軽量で様々なことができます
  • 今後もクラシルリワードではRiveを用いた使っていて楽しいアプリを開発していきます 🔥