Merpay & Mercoin Tech Fest 2023 は、事業との関わりから技術への興味を深め、プロダクトやサービスを支えるエンジニアリングを知ることができるお祭りで、2023年8月22日(火)からの3日間、開催しました。セッションでは、事業を支える組織・技術・課題などへの試行錯誤やアプローチを紹介していきました。
この記事は、「SwiftUIでビットコインの価格チャートを改善・再実装した話」の書き起こしです。
@andooown:「SwiftUIでビットコインの価格チャートを改善・再実装した話」というタイトルで発表します。
まずは簡単な自己紹介です。株式会社メルコインのクライアントチームで、iOSエンジニアをしている、Yoshikazu Andoと申します。GitHubやSNSでは、@andooownというIDで活動しています。2019年に新卒で株式会社MIXIに入り、ウォレットサービスのiOSアプリの開発を行っていました。
その後、2021年にメルカリグループにジョインして、ビットコイン取引サービスの立ち上げに参加し、引き続きiOSアプリ開発者として、設計やグループ内の連携も含めて担当しています。
では、改めてSwiftUIでビットコインの価格チャートを改善・再実装した話をします。
まずは、メルカリのビットコイン取引機能について説明します。ビットコイン取引は、ありがたいことに、サービス開始から3ヶ月強で口座開設数が50万人を突破しました。引き続き伸ばしていきたいと考えています。
右の画像がサービスのトップ画面です。ビットコイン取引は、メルカリアプリの機能のひとつであり、メルカリアプリにSDKとして実装されています。これによって疎結合にしつつ、グループ共通の基盤機能やコンポーネントを利用して開発されています。
アプリ内の主な動線は、マイページ。口座開設や普段の利用も、マイページからご利用いただけます。ビットコイン取引機能も含めて、メルカリアプリはSwiftUIを基本として開発されています。SwiftUIも含めたアプリ全体のリライトの話は、メルカンに掲載されておりますのでぜひご覧ください。
参考記事:メルカリの事業とエコシステムをいかにサステナブルなものにするか?かつてない大型プロジェクト「GroundUp App」の道程
また、パスワードレスの認証システムであるFIDOを使用しているので、セキュアにビットコイン取引が行えることも特徴です。こちらはメルカリサービスで最初に採用されています。
本題のビットコインの価格チャートについてです。
チャートはサービスのトップ画面にあり、一番お客さまの目に入る画面となります。チャートには、ビットコインの価格の推移を表示しており、バックエンドから配信された価格のデータをもとに点の間を補完して描画しています。また、画面を開いている間は、一定間隔でデータが更新されます。お客さまはチャートの下のボタンから閲覧する期間を変更できます。
チャートはタップでき、それによって実際の価格や時刻が表示されたり、見た目が変わったりします。
タップされている位置より右側のラインの色はグレーになり、グラデーションの塗りつぶしもなくなります。タップ後には、線の太さや補間の方法が変化し、その間はアニメーションによって連続的になっています。
初期の段階では、このチャートの実装にOSSのChartsライブラリを利用していました。おそらく一番有名なdanielgindi/Chartsライブラリで、Appleプラットフォームでチャートやグラフを実装したことがある方ならご存知なのではないでしょうか?
こちらの画像はGitHubのREADMEから持ってきたものですが、このようなシンプルなチャートであれば、簡単に実装できます。
グラデーションでFillする機能もあり、こちらのサービスのチャートの要件にマッチしています。また、去年のWWDCで発表されたApple公式のSwift ChartsですがこちらはiOS 16から利用可能となっており、サービスの要件にマッチしていないため、今回は採用を見送りました。
OSSのChartsは、UIKitで作られており、メルカリアプリはSwiftUIを基本として開発されています。そのため、今回はUIViewRepresentableでラップした状態でサービスに組み込みました。
また、タップしている間に表示される価格や日時のコンポーネントはSwiftUIで実装しています。ChartViewと併せてVStackに入れ、タップされてる位置などを同期する必要があるので、Stateを引きまわして実装しています。
この実装にはいくつかの課題がありました。
まず感じていたのは、Chartsライブラリの制約によって、無理やり感のある実装設計になっていたことです。例えば、サービスとして実現したインタラクションがありましたが、そのためには、ライブラリ側で用意されているデリケートメソッドだけでは足りず、自前でUIGestureRecognizerを追加していました。
また、タップした位置の左右で色を変えたり、塗りつぶしの有無を変えたりという要件がありましたが、標準の機能ではこれを実現できませんでした。悩んだ上で、二つのChartViewを生成し、それぞれタップした左側用の設定・右側用の設定で描画し、それらをクロップした上で、ZStackで重ねる方法をとっていました。
ChartsのChartViewはかなり高機能で、そのようなViewを二つ生成しているので、無駄が多かったと思います。
この部分は後になってライブラリのRendererなどをオーバーライドして自作することで、一つのChartViewで実装できるようになりましたが、それによってメンテナンスコストが上がり、どちらにしても課題が残る状態となっていました。
次の課題として、データフローが複雑になっていることがありました。
UIViewRepresentableを使った実装ではあるあるだと思いますが、StateはSwiftUI側にあり、initializerでUIViewに渡します。その上で、タップイベント等はUIKit側から発生します。チャートの上にある価格表示部分はSwiftUIであり、座標をUIKit側と同期する必要があるため、SwiftUIのStateにも反映します。
UIKit、ChartViewも高機能であるため少なからずStateは持っており、SwiftUI側にも、もちろんStateがあります。これによって、Stateやライフサイクルの同期がうまくいかないことによるバグも発生していました。
例えば、「指を離しているのに線が残る」というもので、これはChartViewのバグにより想定しているデリケートメソッドが呼ばれず、Stateがずれてしまうということが原因でした。
この話に関連して、Chartsライブラリにも課題を感じていました。実装当時、Chartsライブラリは頻繁にメンテナンスされているとは言えず、バグ修正のPRはあったものの、1年半放置されている状況でした。そのため初期のChartの実装では、ブランチのライブラリを利用していました。
発表に際して、改めて状況を確認すると、最近新しいメジャーバージョンがリリースされていて、今後は多少活発になることが予測されます。
最大の課題は、ここまでの制約によってやりたいことができないということです。初期段階では、デザイナーさんなどがいろいろな表現の案を提案してくれていましたが、制約によって「かなり無理やりなことをしないと無理そう」と断ることが度々ありました。
チャートはサービスのトップ画面にあり、一番最初に目に入る機能のため、ここにはこだわりたい気持ちがありました。しかし、他のタスクや今後のメンテナンスを考えると、断らざるを得ない状況でした。
あるときPMが、「チャートは取引所サービスの顔」と言っていることがありましたが、その通りだと思いますし、もどかしい思いをしていました。アニメーションもこうして断念した表現の一つであり、初期の実装では、アニメーションなしで見た目が切り替わっていました。
「チャートはサービスの顔だが、不完全燃焼」という状態ですが、このままリリースするのは勿体ないということで、チーム内で合意を取って、チャートをフルスクラッチで実装し直すことになりました。
我々のサービスは、暗号資産交換業ということもあって、スペックや仕様については細かく文書化されているものの、チャートの部分については最低限守るべきスペックのみを定め、iOS・Androidのプラットフォームごとにできる表現をとことん突き詰めることも、このときに同意しました。この認識をチームで共有できたことで、この後が進めやすくなったので非常によかったです。
リライトしていくにあたり、まずは設計方針を決めました。チャートのコンポーネントは二層で構成し、汎用的な部分とサービストップのビットコイン価格チャート固有のドメインを含む部分とで分けることにしました。
汎用的な部分については、X方向に位置、Y方向にビットコイン価格を取る、グラフにおいて汎用的に使うであろう数値と座標の変換ロジックや、チャートのサイズの管理などを含みます。
トップ画面のドメイン固有のレイヤーに関しては、この時点では、どの程度サービスで再利用できるかがわからなかったこともあり、非常に多くのものを含んでいます。
価格の点の間を補完するロジック、画面仕様に合わせて、各個のコンポーネントをレイアウトするロジック、実際にチャートのラインや、グラデーションを構成描画する部分も、ドメイン固有のレイヤーに含まれます。
レイアウトの描画に関しても、SwiftUIのView・Shapeで構成する方針を立てました。これとは反対に、公式のSwift ChartsのようにChartContentというprotocolと専用のResult buildersを作ってデータモデルを構成し、それをもとに内部で描画をする方法もあります。
しかし、この場合は、SwiftUIに用意された豊富なレイアウト方法や、既存の資産を使うことができないため、表現の幅を制限しないためにも、View・Shapeを使うことにしました。これらの決定は後ほど活きてくることになります。
この方針をもとに、2週間ほどでPoCを作成しました。
作ったものについて説明します。リライト後、左の赤枠の範囲のViewは、ViewBuilderを使って通常のViewのように右のコードのように記述できるようになりました。
コードは実際のものを簡略化していますが、構成は同じで、汎用的な座標計算などをしてくれるContainer ViewであるLineChartの中に、SwiftUIの LayoutコンポーネントであるVStackなどを使ってチャート構成要素をレイアウトしています。
では、具体的に見ていきます。先ほどのコードで一番外側を囲っていたのがこのLineChartです。
これ自体もSwiftUIのViewであり、initializerでChartの点の配列と実際の表示要素を返すViewBuilderのクロージャを受け取ります。
そして、受け取ったクロージャに対してChartContextというオブジェクトを渡してViewを生成します。
bodyを見るとわかるように、LineChartは渡されたクロージャーから生成できるView以外に表示要素は持ちません。
そしてChartContextは、チャートの作成に必要な情報を持っています。右側のコードのように、実際はLineChartからさまざまなものをinitializerで渡して作成されています。
entriesは引き続きチャートの点のデータの配列。sizeやtransformerについてはこの後説明いたします。LineChartはこのチャートの作成に必要な情報を管理することが責務です。
ChartContextが持つsizeプロパティは、チャート部分の大きさを表しています。
ここでいうチャート部分とは、右の画像の赤の実線の範囲です。先ほどの通り、LineChart自体は赤の点線の範囲になりますが、チャートとして数値に関連した座標系を持つ範囲は実践の範囲ですので、その部分の大きさがsizeとして保持されています。
これは座標や数値の計算、各コンポーネントをレイアウトに利用するため、Contextに保持されています。
そのsizeですが、LineChartの中でどのように取得されているかというと、このようなmarkAsChartContent()というカスタムModifierが利用されています。
ViewのbackgroundにGeometryReaderを挿入して、サイズを取得するSwiftUIではおなじみの実装です。取得したサイズはpreferenceに記録されます。
LineChart側では、左側のように、preferenceを読み取り、privateな@Stateとして保持し、それをContextに渡しています。
実際の場面では、右側のコードで赤く囲まれたところのように、LineChartのViewBuilderの中で、座標系の範囲に相当するViewにModifierがつけられています。赤枠の上のMarkerViewはタップ時に価格や時刻を表示するコンポーネントなので、座標系は持ちません。
Contextの最後はMatrixTransformerオブジェクトです。これは左下のように、チャートの数値データと画面上の座標を相互変換する機能を持っています。これも各コンポーネントをレイアウト・描画するときに必要になります。
MatrixTransformerも右の画像のようにサイズを用いて作成されます。これによって、数値と座標の相互変換ができます。
実際のChartViewでは、これらの情報が詰まったContextを使ってViewを作ります。
このコードはタップ時に表示される縦の点線の例ですが、Contextのtransformerを使ってタップされているデータから、実際の画面上のX座標を取得してレイアウトされています。価格のラインやグラデーションなども同様にContextを利用して作成されています。
もう一つこのコードからわかるのは、タップされているデータであるselectedEntryが、LineChartの外で管理されていることです。LineChartは、数値・座標の管理のみが責務です。現時点では、触って数値を見れるという機能が、今後実装されるチャートでも同じかわからないため、このような設計になっています。
ここまでは、初期の実装にあったものをただSwiftUIでリライトしただけです。発表タイトルの再実装のみが回収されました。
ここから時間を取って改善を始めました。ただ再実装しただけであれば、開発面で運用コストが減ったかもしれませんが、プロダクトとして良くなった点はありません。
今まで制約によってできなかったけど、やりたい表現を実現するために、右のスクリーンショットのようなデモアプリを作成し、手元の端末で触れる形でPMやデザイナーも含めて配布しました。デモアプリではチャートに関連するパラメータをUIから操作できるようになっていて、タップしているときといないときの線の太さ、チャートをなめらかにするための数値処理、点の間の補間方法など、さまざまなものがあります。
アニメーションもこの段階で実装し、アニメーションの長さなども調整できるようになっていました。このデモアプリがあることによって、今までは「もっと線を丸い感じで」「なめらかにしたい」など、言語化しにくかった表現が、具体的なパラメータとして共有できるようになり、改善のPDCAが加速しました。
後半では、「デザイナーさんが気に入ったパラメータ」「PMさんが気に入ったパラメータ」のように、各々が気に入ったものをプリセットとして登録して、呼び出す機能も実装しました。最終的にはチームで画面共有をしながら、お客さまに届ける際のパラメータを決定しました。
デモの実装においては、SwiftUIでリライトしたことで、宣言的UIになったことや、アーキテクチャがSingle Source of Truthになっていることが存分に活きました。大量のパラメータを1ヶ所で管理し、各コンポーネントはパラメータに応じた振る舞いを記述するのみでよくなります。
UIKitなどの命令的なUIでは、パラメータが多い場合、パラメータ自体とパラメータを利用するView、パラメータを設定するためのViewの同期を取って更新するのが難しいと思います。
また、ViewBuilderの恩恵によってViewの構造を変化させるのも容易なため、一つのViewに全てを実装するのではなく、「補間方法に応じたViewを用意する」などもやりやすかったです。
リライトしたことの別の大きな恩恵は、アニメーションが簡単に実装できたことです。シェイプ自身がAnimatable protocolに準拠しており、その仕組みのおかげで実装できました。アニメーションは、UIKitの仕組みの場合は簡単には実装できなかったと思います。
アニメーション自体は初期からアイディアはあったものの、ライブラリの制約によって断念したため、改善フェーズで実装できて本当に良かったです。
アニメーションの実装方法に少しだけ触れておきます。左は、チャート上のラインを構成するコンポーネントで、SwiftUIのシェイプになっています。Animatable protocolでは、animatableDataプロパティを通じて、アニメーションにおいて連続的に変化する値を設定します。
ここでは、通常時は0、チャートをタップしたときに1となるような数値をanimatableDataとしています。これによって、アニメーション発生時はSwiftUIによって、animatableDataが0から1の間で0.1、0.2といったように、連続的に設定された状態でViewが描画され、アニメーションが実現されます。
チャートでは、滑らかなラインから詳細な価格がわかるラインへとアニメーションさせたいので、animatableDataデータの値に合わせて、二つの価格推移データの間を取る値を計算し、補間処理についてもその強度をanimatableDataを基に計算することで、アニメーションを実現しました。
改善を終えてみた感想です。やはりSwiftUIの特徴を生かしてチームでPDCAを回し、実際にプロダクトをより良いものにできたことは非常によかったと感じています。
デモ準備したりと工数はかかってしまうものの、共通の動くものを見ながら議論・意思決定するのはやはり迅速で、かつ同じものを見ているため、認識のずれも少なかったと思います。
これによって、リモートワークの環境でも、言語化しにくいUIの部分を改善できました。そして、ありきたりですが、リライトを通じてSwiftUIの理解はかなり深まったと感じています。
普段の画面開発や趣味の小さな実装では、なかなか深い理解が難しいことも多いですが、製品レベルでチャートのようなチャレンジングな課題に取り組むと、理解が早く深いものになるなと改めて感じました。
一方で、汎用的な設計を目指しましたが、それがどこまで通用するのかは未知数だとも思っています。OSSのChartsにも言えますが、Alamofireのように、通信データに関するものや、UI系でもAuto LayoutのためのSnapKitのようなユーティリティ系とは異なり、それ自身がUIコンポーネントを提供するタイプのものは、汎用するのが難しいなと改めて感じました。表現はサービスによって十人十色であり、ライブラリを使った表現が最適なサービスばかりではないからです。これはライブラリを利用する側にも作る側にも当てはまる話だと思います。
社内レベルのコンポーネントだとしても、年月を経て、最初は小さかったものが次第に高機能になり、逆に要件を満たしづらくなることもよくあることかと思います。
そして最後に製品のクオリティのために早くサービスをリリースしたいであろう立ち上げ時期に時間を取らせてくれた、また一緒に改善に臨んでくれたチームに感謝したいと思います。今回の進め方で都度チームで合意をとっていたのが良かったなと個人的には思っていますが、裏を返せば、合意をしてくれたチームのおかげです。本当にありがとうございました。
今後は、どこかの時点でパフォーマンスチューニングをしたいと考えています。もちろん、リリース時点で実機でカクつかないことを確認していますが、チャートtの補間部分やViewの構造など、最適化の余地はたくさんあると思っています。どこかでProfileを取りながら進めていきたいと考えています。
また、デモや動画など、動くものを使った議論の効率の良さを改めて目の当たりにしたので、これは継続したい思っています。
忙しくなってくると、「進捗がわかるものを出してください」と言われているわけではないので、自発的なスクリーンキャプチャーの共有などはおろそかにしがちです。
それによって実装が全て終わった後で、認識齟齬があって手戻りしてしまうことは、あるあるではないでしょうか?今後は、「今こんな感じです」と共有する気持ちを忘れずにいきたいと思います。
発表は以上になります。メルコインのクライアントチームでは、日々和気あいあいとプロダクトの開発に取り組んでいます。ご興味がありましたらぜひご連絡ください。
Software Engineer, iOS – Mercoin
また、今回ご紹介したチャートはOSSで公開をしています。
https://github.com/mercari/swiftui-chart
ご清聴ありがとうございました。