こんにちは、クラシルリワードの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を用いた使っていて楽しいアプリを開発していきます 🔥