新着記事

Viewing posts for the category Flutter

TORICO が漫画アプリ開発に Flutter を採用した事例の紹介

当社 TORICO は、モバイルアプリの開発に Flutter を採用しています。

この記事では、2023年1月現在の、TORICO での Flutter 開発事例を紹介します。

TORICO が モバイルアプリ開発に Flutter を採用する理由

共通のコードで iOS と Android に両対応できる点と、Dart 言語での開発の体験が良いことが Flutter を採用している理由です。

私の主観が入りますが、Dart 言語については、概ね TypeScript での開発体験と似ていますが、TypeScript が引きずっている JavaScript が持つ特徴的な挙動(this の挙動、null, undefinedの扱い、Date オブジェクト等)が無く、書きやすい言語だと感じます。

Dart はライブラリの開発もしやすく、パッケージマネージャ(pub)も標準で搭載されていますので、有志の型が開発されているライブラリの導入も容易です。

加えて、Flutter は Windows や Mac 等のデスクトップアプリにも対応できます。
開発体験は Electron を使った場合と似ていますが、Electron に比べて生成プログラムのサイズを小さく保てますので、配布に適していると思います。

TORICO では、社内で使う小規模なツールの開発に Flutter を使う場合があります。

TORICO で Flutter を採用しているアプリ

スキマ

スキマ | 全巻無料漫画が32,000冊読み放題!

スキマは漫画を無料で読めるサービスです。

モバイルアプリ版は Flutter で開発しており、iOS Android 両プラットフォームで配信しています。

スキマのサービスは、2015年に開発を開始し、4月28日よりサービスを開始しました。
開発当初は、iOS は Objective-C、Android は Java で開発していましたが、2019年に Flutter に切り替えています。

当初の Flutter のバージョンは 1.3 ぐらいだったと思います。

当時は状態管理やルーティングの有用なライブラリは少なく、FutureBuilder などをそのまま使う場面が多くありました。

Flutter のバージョンアップには積極的に対応し、現在は Flutter 2の最新版で開発を行っています。

2023年1月現在の技術スタックは、 zenn の 0maru の記事を参考にしてください。

約3年間Flutter で開発してきてのあれやこれや

スキマや、後述の漫画全巻ドットコム等で使われている、 Flutter での Twitter のログインを提供するパッケージ twitter_login は、 0maru の開発によるものです。

スキマは 広告SDKを多く扱うため、Swift や Kotlin の開発分量も少なくありませんが、広告 SDK のコントロールは Flutter 上から行えるようになっており、Firebase と連携してリモートで調整することができます。

スキマのモバイルアプリをダウンロード: iOS | Android

漫画全巻ドットコム

漫画全巻ドットコム | コミックセット通販

漫画全巻ドットコムは、漫画の全巻販売に特化したECサイトです。

モバイルアプリも提供しており、 Flutter で開発し、iOS Android 両プラットフォームで配信しています。

アプリのリリースは 2021年7月27日より行っており、リリース初期から Flutter を使っています。

リリース時期の技術スタックは、 zenn の 0maru の記事に書かれていますのでご覧ください。

Flutter でECアプリを新規開発してみて

漫画全巻ドットコムのモバイルアプリをダウンロード: iOS | Android

漫画全巻ドットコム MZ Reader

漫画全巻ドットコムは、紙のコミックの全巻セット販売だけでなく、電子書籍も販売しています。

MZ Reader は、漫画全巻ドットコムで購入した電子書籍を読むためのアプリです。

電子書籍サービスは 2012年11月に開始しており、当時は iOS は Objective-C、Android は Java で開発を行っており、Windows 版も提供していました。

電子書籍ビューアは 2020年10月にリニューアルを行い、フレームワークに Flutter を採用しています。リニューアル時点ではアプリの評価が低かったのですが、体験の向上を目的としたブラッシュアップを行い、アプリの評価を大きく改善することができました。

新入社員が入社後数ヶ月でコミックリーダーアプリのレビュースコアを 1.4 → 4.7 に改善した話

電子書籍のダウンロードには、Dio と Flutter Downloader を場面により使い分けています。

2023年1月現在、Flutter2 でリリースをしています。

MZ Reader をダウンロード: iOS | Android

マンガ展

マンガ展 | まんが作品の原画展・イラスト展やサイン会などのイベント情報掲載

マンガ展は、マンガの作家さんとのイベントを提供するプロジェクトです。

現在、東京の池袋と渋谷(Magnet By Shibuya 109内)、大阪の谷町六丁目、名古屋の栄のオアシス21内、台湾の台北101の近くに店舗を運営しており、イベントを開催しています。

国内の各店舗の地図 | マンガ展台湾の地図 

物品の購入や会場での来場スタンプが獲得できるモバイルアプリを公開しています。

マンガ展のアプリは 2019年1月からリリースしており、Flutter を採用したアプリの中では一番早い時期となります。

Flutter のバージョンは v0.10 あたりから使い始めたように思います。マンガ展アプリのリリース直前で、Flutter v1.0 が公開されたタイミングだったように記憶しています。

現在は Flutter2 対応で Null Safety になり、状態管理は RiverPod を使っています。

マンガ展のモバイルアプリをダウンロード: iOS | Android

Andorid レジ

Sunmi 社の T2s や T2 mini で動作するレジを Flutter で開発しています。

Sunmi 社のレシートプリンタのバインディングライブラリの sunmi_printer_plus  内の、T2 mini の LCD のバインディングを ytyng が開発しています。

T2s は、2つのディスプレイを持つ Android 端末です。presentation_displays パッケージを使うことで、サブディスプレイにも Flutter のウィジェットを表示することができます。

状態管理は Riverpod, hooks_riverpod を使っています。

デスクトップツール

Windows上で動作する、漫画全巻ドットコムの CMS 連携ツールを Flutter で開発しています。

漫画全巻ドットコムでは、一部のキャンペーンページのコンテンツデータをデータベースに保存しています。

コンテンツデータは非エンジニアの運用スタッフが作成するのですが、Web フォーム上での作成は体験が悪いので、Windows 上の VSCode と連携して作成できるツールを Flutter で作っています。

Flutter を採用している理由は、社内の開発者は全員 Mac を使っているため、Windows での使用を要件としたクロスプラットフォーム開発となると使える技術が絞られることと、その中で 実行バイナリのサイズが小さく、別途ランタイムライブラリも必要無く、動作が安定しているためです。

最後に

株式会社TORICO では、Flutter で自社サービスを開発する技術者を募集しています。

業務内容として Flutter 100% ではなく、AWS、MySQL、ElasticSearch(OpenSearch)、Python Django 等サーバサイド開発も扱っていただきます。応募フォームからの連絡をお待ちしています。

https://www.torico-corp.com/recruit/

Flutter riverpod + hooks_riverpod の基本的な状態管理の使い方を Vuex (typed-vuex/vuex-module-decorators)と比較して紹介

当社 TORICO ではモバイルアプリ開発のフレームワークに Flutter を採用し、ウェブのフロントエンドのフレームワークとしては Nuxt を採用しています。

普段 Nuxt で開発している方が Flutter での開発を始める際、Flutter での状態管理フレームワークとして、riverpod + hooks_riverpod を使う場合、基本的な使い方を Vuex と比較すると理解が早まります。

状態管理とは

フロントエンド開発の際、「状態管理」という言葉がよく使われます。この「状態」という言葉、最初聞いた時は「この処理はステートフルだ」という言い回しでよく使う「ステート」と同じものだと思っていたのですが、実際はそれより狭い意味であり、「変数の変化をUIの再描画にリアクティブに伝える仕組み」ぐらいに思っておくと良いと思います。

今回の例では、数字の配列から平均値を求めてUIに表示していますが、「配列に値を追加する」という処理を書いただけ(実行するだけ)で、平均値の再計算と必要なUIの再描画が自動で行われます。この一連の自動処理を、「状態管理」と呼ぶと思ってください。

Nuxt + Vuex のカウンターアプリ

Githubでにソースコードを公開しています。

まずは、Nuxt + Vuex でカウンターアプリを作ってみます。上のボタンを押すと、カウンターが増え、下のボタンを押すと、そのカウントを Array に追加します。ついでに Array の平均値も表示するようになっています。

Vuex を TypeScript で書く場合、サポートライブラリとして nuxt-typed-vuex を使う方法と vuex-module-decorators を使う方法がありますが、今回はその両方の比較も兼ねて、両方のパターンで書いてみました。

Nuxt typed-vuex で書いたストア

Github で見る

import {getterTree, mutationTree, actionTree} from 'typed-vuex'

export const state = () => ({
// カウンター
count: 0 as number,
// カウンターの値を追加するリスト
countList: [] as number[]
})

export type RootState = ReturnType<typeof state>

export const getters = getterTree(state, {
// countList の平均値を求める
average: (state) => state.countList.length
? state.countList.reduce((p: number, c: number) => p + c, 0) / state.countList.length
: 0
})

export const mutations = mutationTree(state, {
// カウンターを増加
countUp(state) {
state.count += 1
},
// カウンターリストに追加する
appendCount(state, value: number) {
// nuxt の場合再代入の必要なし。push で再描画される。
state.countList.push(value)
}
})

export const actions = actionTree(
{state, getters, mutations},
{
// 現在のカウンターの値を countList に追加するアクション
async addToCountList({commit, dispatch, getters, state}) {
commit('appendCount', state.count)
}
}
)

Nuxt vuex-module-decorators で書いたストア

Github で見る

import {Module, VuexModule, Mutation, Action} from 'vuex-module-decorators'

@Module({
name: 'counter',
stateFactory: true,
namespaced: true
})
export default class extends VuexModule {
// カウンター
count: number = 0

// カウンターの値を追加するリスト
countList: number[] = []

// countList の平均値を求める
get average() {
return this.countList.length
? this.countList.reduce((p: number, c: number) => p + c, 0) / this.countList.length
: 0
}

// カウンターを増加
@Mutation
countUp() {
this.count += 1
}

// カウンターリストに追加する
@Mutation
appendCount(value: number) {
// vuex の場合再代入の必要なし。push で再描画される。
this.countList.push(value)
}

// 現在のカウンターの値を countList に追加するアクション
@Action({rawError: true})
addToCountList() {
this.appendCount(this.count)
}
}

両方のコードはまったく同じ動作をします。

ポイントとして、 appendCount のミューテーションですが、countList に .push で値を追加しているだけです。

リアクティブに状態の更新を UI に伝えるために、新しい Array を作って countList に代入しなくて大丈夫か、という感じはしますが、vuex の場合は Array や Object がステートに登録された場合、Observer というクラスを介して操作され、内容の変更もリアクティブに再描画されるようになりますので、これで大丈夫です。

Observerは、一部の Array のメソッドが使えなかったり、デバッグ時に値が直接見にくいなどの弊害もあるので、個人的には、この挙動はお節介すぎる気がしますが、理解して使えばコードを短く書けるため、上手に使いましょう。

Flutter + riverpod + hooks_riverpod で書いた場合

これと同様のアプリを Flutter + Riverpod + riverpod hooksで書いています。

Github で見る

counter_controller.dart

Githubで見る

import 'package:hooks_riverpod/hooks_riverpod.dart';

// カウンター
final countState = StateProvider<int>((ref) => 0);

// カウンターの値を追加するリスト
final countListState = StateProvider<List<int>>((ref) => []);

// countListState の平均値を求める
final averageProvider = Provider<double>((ref) {
final productList = ref.watch(countListState);
return productList.isNotEmpty
? productList.fold<int>(0, (v, e) => v + e) / productList.length
: 0.0;
});

final counterController =
Provider.autoDispose((ref) => CounterController(ref.read));

class CounterController {
final Reader _read;

CounterController(this._read);

// カウンターを増加
void countUp() {
// この update は、以下と同じ動作をする
// _read(countState.notifier).state = _read(countState) + 1;
_read(countState.notifier).update((s) => s + 1);
}

// 現在のカウンターの値を countList に追加するアクション
void addToCountList() {
_read(countListState.notifier).update((s) => [...s, _read(countState)]);
}
}

ほぼ同じ感覚で書けています。

vuex store でいう state は、StateProvider を使い、getter は Provider を使って書けます。

mutation は、 StateProvider に対し、 _read(myStateProvider.notifier).state = xxx とします。
これにより、状態の変更が StateProvider を Watch しているウィジェットに伝わり、再描画を行います。

action で書くようなロジックは、それらのメソッドを持つクラス(上記例では CounterController )を作り、そのインスタンスを Provider として作っておくのが良いと思います。 WidgetRef にアクセスさえできればどこに書いても動くのですが、vuex を真似て、StateProvider 等が書いてあるファイル中に一緒にコントローラークラスとして作っておくと、Flutter のプロジェクトも Nuxt のプロジェクトも同じような感覚で修正できるので、楽になるのではないでしょうか。

UI 側のコード(main.dart)の抜粋

Github で見る

class CounterWidget extends HookConsumerWidget {
const CounterWidget({Key? key}) : super(key: key);

@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(countState);

return Column(children: [
Text(count.toString(), style: const TextStyle(fontSize: 48)),
ElevatedButton(
style: ElevatedButton.styleFrom(
shape: const StadiumBorder(),
),
onPressed: () {
ref.read(counterController).countUp();
},
child: const Text('CountUp'))
]);
}
}

HookConsumerWidget を継承したウィジェットを作り、その中で ref.watch すると、その変数が更新された時に UI が再描画されます。

Search