TORICO Tech ブログhttps://tech.torico-corp.com/blog/2024-03-28T04:21:46+00:00株式会社TORICO 技術開発チームのブログTORICO が漫画アプリ開発に Flutter を採用した事例の紹介2023-01-09T06:55:43+00:002024-03-28T02:47:32+00:00四柳剛https://tech.torico-corp.com/blog/author/yotsuyanagi/https://tech.torico-corp.com/blog/torico-flutter-adoption-example/<p>当社 TORICO は、モバイルアプリの開発に Flutter を採用しています。</p>
<p>この記事では、2023年1月現在の、TORICO での Flutter 開発事例を紹介します。</p>
<h2>TORICO が モバイルアプリ開発に Flutter を採用する理由</h2>
<p>共通のコードで iOS と Android に両対応できる点と、Dart 言語での開発の体験が良いことが Flutter を採用している理由です。</p>
<p>私の主観が入りますが、Dart 言語については、概ね TypeScript での開発体験と似ていますが、TypeScript が引きずっている JavaScript が持つ特徴的な挙動(this の挙動、null, undefinedの扱い、Date オブジェクト等)が無く、書きやすい言語だと感じます。</p>
<p>Dart はライブラリの開発もしやすく、パッケージマネージャ(pub)も標準で搭載されていますので、有志の型が開発されているライブラリの導入も容易です。</p>
<p>加えて、Flutter は Windows や Mac 等のデスクトップアプリにも対応できます。<br/>開発体験は Electron を使った場合と似ていますが、Electron に比べて生成プログラムのサイズを小さく保てますので、配布に適していると思います。</p>
<p>TORICO では、社内で使う小規模なツールの開発に Flutter を使う場合があります。</p>
<h2>TORICO で Flutter を採用しているアプリ</h2>
<h3>スキマ</h3>
<p><a href="https://www.sukima.me/" target="_blank">スキマ | 全巻無料漫画が32,000冊読み放題!</a></p>
<p><img alt="" height="377" src="https://d1qjlssvz4u32r.cloudfront.net/media/uploads/site-1/service-screenshots/sukima.png" width="670"/></p>
<p>スキマは漫画を無料で読めるサービスです。</p>
<p>モバイルアプリ版は Flutter で開発しており、iOS Android 両プラットフォームで配信しています。</p>
<p>スキマのサービスは、2015年に開発を開始し、4月28日よりサービスを開始しました。<br/>開発当初は、iOS は Objective-C、Android は Java で開発していましたが、2019年に Flutter に切り替えています。</p>
<p>当初の Flutter のバージョンは 1.3 ぐらいだったと思います。</p>
<p>当時は状態管理やルーティングの有用なライブラリは少なく、FutureBuilder などをそのまま使う場面が多くありました。</p>
<p>Flutter のバージョンアップには積極的に対応し、現在は Flutter 2の最新版で開発を行っています。</p>
<p>2023年1月現在の技術スタックは、 zenn の 0maru の記事を参考にしてください。</p>
<p><a href="https://zenn.dev/0maru/articles/262c0f8ad52a0d" target="_blank">約3年間Flutter で開発してきてのあれやこれや</a></p>
<p>スキマや、後述の漫画全巻ドットコム等で使われている、 Flutter での Twitter のログインを提供するパッケージ <a href="https://pub.dev/packages/twitter_login" target="_blank">twitter_login</a> は、 0maru の開発によるものです。</p>
<p>スキマは 広告SDKを多く扱うため、Swift や Kotlin の開発分量も少なくありませんが、広告 SDK のコントロールは Flutter 上から行えるようになっており、Firebase と連携してリモートで調整することができます。</p>
<p><img alt="" height="240" src="https://play-lh.googleusercontent.com/Q29yK9V5JkZESuXDqTST94p_ecC4BdSrKvifFxNASquQxZ_FehrJdZfzkYdnGsMHjg=w240-h480-rw" width="240"/></p>
<p>スキマのモバイルアプリをダウンロード: <a href="https://www.sukima.me/mobileapp/download/ios/" target="_blank">iOS</a> | <a href="https://www.sukima.me/mobileapp/download/android/" target="_blank">Android</a></p>
<h3>漫画全巻ドットコム</h3>
<p><a href="https://www.mangazenkan.com/" target="_blank">漫画全巻ドットコム | コミックセット通販</a></p>
<p><img alt="" height="377" src="https://d1qjlssvz4u32r.cloudfront.net/media/uploads/site-1/service-screenshots/mangazenkan.png" width="670"/></p>
<p>漫画全巻ドットコムは、漫画の全巻販売に特化したECサイトです。</p>
<p>モバイルアプリも提供しており、 Flutter で開発し、iOS Android 両プラットフォームで配信しています。</p>
<p>アプリのリリースは 2021年7月27日より行っており、リリース初期から Flutter を使っています。</p>
<p>リリース時期の技術スタックは、 zenn の 0maru の記事に書かれていますのでご覧ください。</p>
<p><a href="https://zenn.dev/0maru/articles/d8362fa9b95167" target="_blank">Flutter でECアプリを新規開発してみて</a></p>
<p><img alt="" height="240" src="https://play-lh.googleusercontent.com/cSKqr6qHD-5xwSQEDkTHmlrRzuK2IMZvygnedAYeFmMBtJXB_6q5GrCCY_Jt8kiDXg=w240-h480-rw" width="240"/></p>
<p>漫画全巻ドットコムのモバイルアプリをダウンロード: <a href="https://apps.apple.com/jp/app/id1539547270" target="_blank">iOS</a> | <a href="https://play.google.com/store/apps/details?id=com.mangazenkan.mangazenkanec&hl=ja&pli=1" target="_blank">Android</a></p>
<h3>漫画全巻ドットコム MZ Reader</h3>
<p>漫画全巻ドットコムは、紙のコミックの全巻セット販売だけでなく、電子書籍も販売しています。</p>
<p>MZ Reader は、漫画全巻ドットコムで購入した電子書籍を読むためのアプリです。</p>
<p>電子書籍サービスは 2012年11月に開始しており、当時は iOS は Objective-C、Android は Java で開発を行っており、Windows 版も提供していました。</p>
<p>電子書籍ビューアは 2020年10月にリニューアルを行い、フレームワークに Flutter を採用しています。リニューアル時点ではアプリの評価が低かったのですが、体験の向上を目的としたブラッシュアップを行い、アプリの評価を大きく改善することができました。</p>
<p><a href="https://tech.torico-corp.com/blog/improve-mz-reader-app-review-score/" target="_blank">新入社員が入社後数ヶ月でコミックリーダーアプリのレビュースコアを 1.4 → 4.7 に改善した話</a></p>
<p>電子書籍のダウンロードには、Dio と Flutter Downloader を場面により使い分けています。</p>
<p>2023年1月現在、Flutter2 でリリースをしています。</p>
<p><img alt="" height="240" src="https://play-lh.googleusercontent.com/Y9nwCYFblvjIDc4aY2WHrIBFDcrQVc6PWr13B5Av2MmI7M0j0nA4HhbJtHdghm8uwA4=w240-h480-rw" width="240"/></p>
<p>MZ Reader をダウンロード: <a href="https://www.mangazenkan.com/gw/viewer/ios/" target="_blank">iOS</a> | <a href="https://www.mangazenkan.com/gw/viewer/android/" target="_blank">Android</a></p>
<h3>マンガ展</h3>
<p><a href="https://www.manga10.com/" target="_blank">マンガ展 | まんが作品の原画展・イラスト展やサイン会などのイベント情報掲載</a></p>
<p><img alt="" height="377" src="https://d1qjlssvz4u32r.cloudfront.net/media/uploads/site-1/service-screenshots/manga10.png" width="670"/></p>
<p>マンガ展は、マンガの作家さんとのイベントを提供するプロジェクトです。</p>
<p>現在、東京の池袋と渋谷(Magnet By Shibuya 109内)、大阪の谷町六丁目、名古屋の栄のオアシス21内、台湾の台北101の近くに店舗を運営しており、イベントを開催しています。</p>
<p><a href="https://www.torico-corp.com/access/" target="_blank">国内の各店舗の地図</a> | <a href="https://world.manga10.com/" target="_blank">マンガ展台湾の地図</a> </p>
<p>物品の購入や会場での来場スタンプが獲得できるモバイルアプリを公開しています。</p>
<p>マンガ展のアプリは 2019年1月からリリースしており、Flutter を採用したアプリの中では一番早い時期となります。</p>
<p>Flutter のバージョンは v0.10 あたりから使い始めたように思います。マンガ展アプリのリリース直前で、Flutter v1.0 が公開されたタイミングだったように記憶しています。</p>
<p>現在は Flutter2 対応で Null Safety になり、状態管理は RiverPod を使っています。</p>
<p><img alt="" height="240" src="https://play-lh.googleusercontent.com/cNNg1W7j7oO-PFfkv0VbJAKgcp7oP2u1D5HymKab1i553RrTWmAGwijwGvnNJoZh_fY4=w240-h480-rw" width="240"/></p>
<p>マンガ展のモバイルアプリをダウンロード: <a href="https://itunes.apple.com/jp/app/id1451255153?l=ja&ls=1&mt=8" target="_blank">iOS</a> | <a href="https://play.google.com/store/apps/details?id=com.manga10.app" target="_blank">Android</a></p>
<h3>Andorid レジ</h3>
<p><a href="https://www.sunmi.com/" target="_blank">Sunmi</a> 社の <a href="https://www.sunmi.com/ja/t2s/" target="_blank">T2s</a> や <a href="https://www.sunmi.com/ja/t2-mini/" target="_blank">T2 mini</a> で動作するレジを Flutter で開発しています。</p>
<p>Sunmi 社のレシートプリンタのバインディングライブラリの <a href="https://pub.dev/packages/sunmi_printer_plus" target="_blank">sunmi_printer_plus</a> 内の、T2 mini の LCD のバインディングを ytyng が開発しています。</p>
<p>T2s は、2つのディスプレイを持つ Android 端末です。<a href="https://pub.dev/packages/presentation_displays" target="_blank">presentation_displays</a><span> パッケージを使うことで、サブディスプレイにも Flutter のウィジェットを表示することができます。</span></p>
<p>状態管理は Riverpod, hooks_riverpod を使っています。</p>
<p><img alt="" height="960" src="https://d1qjlssvz4u32r.cloudfront.net/media/uploads/site-6/yotsuyanagi/flutter-example/img_7670-2.jpg" width="1280"/></p>
<p><img alt="" height="960" src="https://d1qjlssvz4u32r.cloudfront.net/media/uploads/site-6/yotsuyanagi/flutter-example/img_7669.jpg" width="1280"/></p>
<p><img alt="" height="960" src="https://d1qjlssvz4u32r.cloudfront.net/media/uploads/site-6/yotsuyanagi/flutter-example/img_7671-2.jpg" width="1280"/></p>
<h3>デスクトップツール</h3>
<p>Windows上で動作する、漫画全巻ドットコムの CMS 連携ツールを Flutter で開発しています。</p>
<p>漫画全巻ドットコムでは、一部のキャンペーンページのコンテンツデータをデータベースに保存しています。</p>
<p>コンテンツデータは非エンジニアの運用スタッフが作成するのですが、Web フォーム上での作成は体験が悪いので、Windows 上の VSCode と連携して作成できるツールを Flutter で作っています。</p>
<p>Flutter を採用している理由は、社内の開発者は全員 Mac を使っているため、Windows での使用を要件としたクロスプラットフォーム開発となると使える技術が絞られることと、その中で 実行バイナリのサイズが小さく、別途ランタイムライブラリも必要無く、動作が安定しているためです。</p>
<p><img alt="" height="667" src="https://d1qjlssvz4u32r.cloudfront.net/media/uploads/site-6/yotsuyanagi/flutter-example/mangazenkan-contents-editor.png" width="307"/></p>
<h2>最後に</h2>
<p>株式会社TORICO では、Flutter で自社サービスを開発する技術者を募集しています。</p>
<p>業務内容として Flutter 100% ではなく、AWS、MySQL、ElasticSearch(OpenSearch)、Python Django 等サーバサイド開発も扱っていただきます。応募フォームからの連絡をお待ちしています。</p>
<p><a href="https://www.torico-corp.com/recruit/" target="_blank">https://www.torico-corp.com/recruit/</a></p>
<p></p>Flutter riverpod + hooks_riverpod の基本的な状態管理の使い方を Vuex (typed-vuex/vuex-module-decorators)と比較して紹介2022-01-22T11:55:37+00:002024-03-28T04:21:46+00:00四柳剛https://tech.torico-corp.com/blog/author/yotsuyanagi/https://tech.torico-corp.com/blog/flutter-riverpod-hooks-riverpod-vs-nuxt-typed-vuex-module-decorators/<p></p>
<p>当社 TORICO ではモバイルアプリ開発のフレームワークに Flutter を採用し、ウェブのフロントエンドのフレームワークとしては Nuxt を採用しています。</p>
<p>普段 Nuxt で開発している方が Flutter での開発を始める際、Flutter での状態管理フレームワークとして、riverpod + hooks_riverpod を使う場合、基本的な使い方を Vuex と比較すると理解が早まります。</p>
<h2>状態管理とは</h2>
<p>フロントエンド開発の際、「状態管理」という言葉がよく使われます。この「状態」という言葉、最初聞いた時は「この処理はステートフルだ」という言い回しでよく使う「ステート」と同じものだと思っていたのですが、実際はそれより狭い意味であり、「<strong>変数の変化をUIの再描画にリアクティブに伝える仕組み</strong>」ぐらいに思っておくと良いと思います。</p>
<p>今回の例では、数字の配列から平均値を求めてUIに表示していますが、「配列に値を追加する」という処理を書いただけ(実行するだけ)で、平均値の再計算と必要なUIの再描画が自動で行われます。この一連の自動処理を、「状態管理」と呼ぶと思ってください。</p>
<h2>Nuxt + Vuex のカウンターアプリ</h2>
<p>Githubでにソースコードを公開しています。</p>
<ul>
<li><a href="https://github.com/torico-tokyo/counter-demo-nuxt" target="_blank">Nuxt typed-vuex版</a></li>
<li><a href="https://github.com/torico-tokyo/counter-demo-nuxt/tree/vuex-module-decorators" target="_blank">Nuxt vuex-module-decorators版</a></li>
<li><a href="https://github.com/torico-tokyo/counter-demo-flutter" target="_blank">Flutter riverpod + hooks_riverpod版</a></li>
</ul>
<p>まずは、Nuxt + Vuex でカウンターアプリを作ってみます。上のボタンを押すと、カウンターが増え、下のボタンを押すと、そのカウントを Array に追加します。ついでに Array の平均値も表示するようになっています。</p>
<p><img alt="" height="454" src="https://d1qjlssvz4u32r.cloudfront.net/media/uploads/site-6/counter-app/counter-nuxt.gif" width="464"/></p>
<p>Vuex を TypeScript で書く場合、サポートライブラリとして nuxt-typed-vuex を使う方法と vuex-module-decorators を使う方法がありますが、今回はその両方の比較も兼ねて、両方のパターンで書いてみました。</p>
<h3>Nuxt typed-vuex で書いたストア</h3>
<p><a href="https://github.com/torico-tokyo/counter-demo-nuxt/blob/master/store/counter.ts" target="_blank">Github で見る</a></p>
<pre>import {getterTree, mutationTree, actionTree} from 'typed-vuex'<br/><br/>export const state = () => ({<br/> // カウンター<br/> <strong>count</strong>: 0 as number,<br/> // カウンターの値を追加するリスト<br/> <strong>countList</strong>: [] as number[]<br/>})<br/><br/>export type RootState = ReturnType<typeof state><br/><br/>export const getters = getterTree(state, {<br/> // countList の平均値を求める<br/> <strong>average</strong>: (state) => state.countList.length<br/> ? state.countList.reduce((p: number, c: number) => p + c, 0) / state.countList.length<br/> : 0<br/>})<br/><br/>export const mutations = mutationTree(state, {<br/> // カウンターを増加<br/> <strong>countUp</strong>(state) {<br/> state.count += 1<br/> },<br/> // カウンターリストに追加する<br/> <strong>appendCount</strong>(state, value: number) {<br/> // nuxt の場合再代入の必要なし。push で再描画される。<br/> state.countList.push(value)<br/> }<br/>})<br/><br/>export const actions = actionTree(<br/> {state, getters, mutations},<br/> {<br/> // 現在のカウンターの値を countList に追加するアクション<br/> async <strong>addToCountList</strong>({commit, dispatch, getters, state}) {<br/> commit('appendCount', state.count)<br/> }<br/> }<br/>)</pre>
<h3>Nuxt vuex-module-decorators で書いたストア</h3>
<p><a href="https://github.com/torico-tokyo/counter-demo-nuxt/blob/vuex-module-decorators/store/counter.ts" target="_blank">Github で見る</a></p>
<pre>import {Module, VuexModule, Mutation, Action} from 'vuex-module-decorators'<br/><br/>@Module({<br/> name: 'counter',<br/> stateFactory: true,<br/> namespaced: true<br/>})<br/>export default class extends VuexModule {<br/> // カウンター<br/> <strong>count</strong>: number = 0<br/><br/> // カウンターの値を追加するリスト<br/> <strong>countList</strong>: number[] = []<br/><br/> // countList の平均値を求める<br/> get <strong>average</strong>() {<br/> return this.countList.length<br/> ? this.countList.reduce((p: number, c: number) => p + c, 0) / this.countList.length<br/> : 0<br/> }<br/><br/> // カウンターを増加<br/> @Mutation<br/> <strong>countUp</strong>() {<br/> this.count += 1<br/> }<br/><br/> // カウンターリストに追加する<br/> @Mutation<br/> <strong>appendCount</strong>(value: number) {<br/> // vuex の場合再代入の必要なし。push で再描画される。<br/> this.countList.push(value)<br/> }<br/><br/> // 現在のカウンターの値を countList に追加するアクション<br/> @Action({rawError: true})<br/> <strong>addToCountList</strong>() {<br/> this.appendCount(this.count)<br/> }<br/>}</pre>
<p>両方のコードはまったく同じ動作をします。</p>
<p>ポイントとして、 appendCount のミューテーションですが、countList に .push で値を追加しているだけです。</p>
<p>リアクティブに状態の更新を UI に伝えるために、新しい Array を作って countList に代入しなくて大丈夫か、という感じはしますが、vuex の場合は Array や Object がステートに登録された場合、Observer というクラスを介して操作され、内容の変更もリアクティブに再描画されるようになりますので、これで大丈夫です。</p>
<p>Observerは、一部の Array のメソッドが使えなかったり、デバッグ時に値が直接見にくいなどの弊害もあるので、個人的には、この挙動はお節介すぎる気がしますが、理解して使えばコードを短く書けるため、上手に使いましょう。</p>
<h3>Flutter + riverpod + hooks_riverpod で書いた場合</h3>
<p>これと同様のアプリを Flutter + Riverpod + riverpod hooksで書いています。</p>
<p><a href="https://github.com/torico-tokyo/counter-demo-flutter" target="_blank">Github で見る</a></p>
<p><img alt="" height="342" src="https://d1qjlssvz4u32r.cloudfront.net/media/uploads/site-6/counter-app/counter-flutter.gif" width="270"/></p>
<h4>counter_controller.dart</h4>
<p><a href="https://github.com/torico-tokyo/counter-demo-flutter/blob/master/lib/controllers/counter_controller.dart" target="_blank">Githubで見る</a></p>
<pre>import 'package:hooks_riverpod/hooks_riverpod.dart';<br/><br/>// カウンター<br/>final <strong>countState</strong> = StateProvider<int>((ref) => 0);<br/><br/>// カウンターの値を追加するリスト<br/>final <strong>countListState</strong> = StateProvider<List<int>>((ref) => []);<br/><br/>// countListState の平均値を求める<br/>final <strong>averageProvider</strong> = Provider<double>((ref) {<br/> final productList = ref.watch(countListState);<br/> return productList.isNotEmpty<br/> ? productList.fold<int>(0, (v, e) => v + e) / productList.length<br/> : 0.0;<br/>});<br/><br/>final counterController =<br/> Provider.autoDispose((ref) => CounterController(ref.read));<br/><br/>class CounterController {<br/> final Reader _read;<br/><br/> CounterController(this._read);<br/><br/> // カウンターを増加<br/> void <strong>countUp</strong>() {<br/> // この update は、以下と同じ動作をする<br/> // _read(countState.notifier).state = _read(countState) + 1;<br/> _read(countState.notifier).update((s) => s + 1);<br/> }<br/><br/> // 現在のカウンターの値を countList に追加するアクション<br/> void <strong>addToCountList</strong>() {<br/> _read(countListState.notifier).update((s) => [...s, _read(countState)]);<br/> }<br/>}</pre>
<p>ほぼ同じ感覚で書けています。</p>
<p>vuex store でいう <code>state</code> は、<code>StateProvider</code> を使い、<code>getter</code> は <code>Provider</code> を使って書けます。</p>
<p><code>mutation</code> は、 <code>StateProvider</code> に対し、 <code>_read(myStateProvider.notifier).state = xxx</code> とします。<br/>これにより、状態の変更が StateProvider を Watch しているウィジェットに伝わり、再描画を行います。</p>
<p><code>action</code> で書くようなロジックは、それらのメソッドを持つクラス(上記例では CounterController )を作り、そのインスタンスを <code>Provider</code> として作っておくのが良いと思います。 <code>WidgetRef</code> にアクセスさえできればどこに書いても動くのですが、vuex を真似て、StateProvider 等が書いてあるファイル中に一緒にコントローラークラスとして作っておくと、Flutter のプロジェクトも Nuxt のプロジェクトも同じような感覚で修正できるので、楽になるのではないでしょうか。</p>
<h5>UI 側のコード(main.dart)の抜粋</h5>
<p><a href="https://github.com/torico-tokyo/counter-demo-flutter/blob/master/lib/main.dart" target="_blank">Github で見る</a></p>
<pre>class CounterWidget extends <strong>HookConsumerWidget</strong> {<br/> const CounterWidget({Key? key}) : super(key: key);<br/><br/> @override<br/> Widget build(BuildContext context, <strong>WidgetRef ref</strong>) {<br/> final count = <strong>ref.watch(countState)</strong>;<br/><br/> return Column(children: [<br/> Text(count.toString(), style: const TextStyle(fontSize: 48)),<br/> ElevatedButton(<br/> style: ElevatedButton.styleFrom(<br/> shape: const StadiumBorder(),<br/> ),<br/> onPressed: () {<br/> <strong>ref.read(counterController).countUp()</strong>;<br/> },<br/> child: const Text('CountUp'))<br/> ]);<br/> }<br/>}</pre>
<p><code>HookConsumerWidget</code> を継承したウィジェットを作り、その中で <code>ref.watch</code> すると、その変数が更新された時に UI が再描画されます。</p>