パルカワ2

最近はFlutterをやっています

fitbit charge 5を買った

体調が良くもないが悪くもないずっと曇りみたいな状態だけど人生とはそんなもんだろと思ってあんま気にしていなかった。ところが同僚から体調が悪そうなので心配と言われたりしていたのでちゃんとしようという気持ちになってきた。ただどうにかしたいけどどうしようという感じだったので、よし!とりあえずトラッキングだ!計測だ!と思ってちょうど予約開始されたfitbit charge 5を買った。届いたのは発売されてから1週間くらい経ってからだった。

実際睡眠を測ってみるとこんな感じだった。5時間寝てれば十分でしょとか思ってたんだけどそうじゃないようで、6-8時間寝るのがいいって書いてた。あとベッドに入ってから5時間寝たらOKみたいな気持ちでいたけど、寝てるつもりでも起きてるときはあるようで、そういうのを差し引くともっと寝たほうがよいというのはわかった。

f:id:hisaichi5518:20211009112319j:plain

今はこういう状態

  • 早めに寝るために寝る準備を早めに終わらせる
  • 2度寝、3度寝してもいいから長時間寝る
  • 無限にパソコンしがちなので寝る前はパソコンをしない
  • 寝る前にコーヒーを飲まない
  • 朝に光を浴びる

みんな当たり前にやってそう。運動とかしたほうがいいんだろうけど、頑張ると続かないので頑張らない方針でいく。

Sorted³ 使い始めた

もともとSunsamaを使っていたけど、全然使いこなしてないのに月2000円くらい払っていたので良い感じのがあれば乗り換えるかという気持ちでいたらSorted³ というのを見つけたので使い始めた。

www.sortedapp.com

Sorted³のいいところは、雑に楽にスケジュールが決めやすい。

朝に今日やりたいことをタスクに分けて順番を決めてオートスケジュールを実行するといい感じに今日のスケジュールを決めてくれる。

support.sortedapp.com

f:id:hisaichi5518:20211008204930p:plain

僕は雑なので実際この通りに実行しなくても良いと思っていて散歩とか日を浴びるために10分くらい外にいたりするだけだったりするので全然沿ってないこともある。

次のタスクのタイミングになると通知が来るので、その時点でまだ前のタスクをやっていたら想定より遅れているので、そのときは終わってないタスクの時間を伸ばしてまたオートスケジュールすればよい。これで朝に今日やると決めたことが何時くらいに終わるか大体わかって無限に働かずに済む。

f:id:hisaichi5518:20211008205448p:plain

他にも指定したタスクの開始時間を一気に早めたり遅めたりとか出来て便利なのと買い切りなので安い。

support.sortedapp.com

不満なところがあるとしたら、こういうので頑張ってくれという気持ち

  • タスクのタイトル一覧をコピペできないこと
    • 仕事する日は朝Slackにやることを共有しているのでほしい 問い合わせた
  • Androidアプリがない
    • iPhoneを買ったのでちゃんと乗り換える
  • 完了したタスクのふりかえりが出来ないこと
    • まあでも、僕は毎週ふりかえりをやるような人間ではないんだよな…

バーン感が足りない

上期の自己評価を書いてる時に半年を振り返って、この半年は大体仕事をこなしただけでつまらなかったということに自分で気づいてハッとなった。

気づくのが遅すぎるというのは置いといて、なんでつまらなかったか考えてみるとやりたいことが出来ていないみたいなのは多少なりともあるんだけど、目の前のタスクに追われてやることを自分で小さく収めていたからだなと思った。

自分で小さく収めるとはなにかというと期限があるタスクに関して期限を優先して、やることを削ぎ落としてとにかく小さく実装することにして「本来はこうあるべきだけどしょうがないよね…」と言ってたなと思った。そうなると本来あるべき挑戦もなくて、ただただ期限までにタスクをこなすマシーンになっていたんだと思う。もちろん、期限は大事で守られるべきなんだけど、今いる会社は不確実だけどバーンと大胆な意思決定を推奨しているはずでそこに沿えてなかったな〜とおもった。

なので今日から「しょうがないからこう実装するか…」って話してたのを捨てて、より良い形で実装することにした。大胆な意思決定から生まれる挑戦をやっていく。

Riverpod を使ったアーキテクチャについて考える

前回この記事でRiverpodを使ったMVUアーキテクチャについて書いた。

hisaichi5518.hatenablog.jp

が、いくつか問題があるなと気づいた。

  • UIに関する処理を書く場所がない
  • 複数のStateを同時に扱う場合が考慮されていない(ビジネスロジックを書く場所がない)

なので、ViewModelを挟むのがよいのではと考えた。

// lib/screens/home/_home_view_model.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../../providers/providers.dart';

final viewModelProvider = Provider((ref) => HomeViewModel(ref.read));

final navigationProvider = StateProvider((ref) => NavigationState.none);

/// 値を持たせたい場合は、freezedなどを使う
enum NavigationState {
  none,
  showSnackbar,
}

class HomeViewModel {
  HomeViewModel(this._read);

  final Reader _read;

  Future<void> increment() async {
    final countValue = _read(counterNotifierProvider);

    final count = countValue.data?.value;
    if (count == null) {
      return;
    }
    final newCount = count + 1;

    /// LocalStorageのデータも新しいデータで更新
    final sharedPreference = await _read(sharedPreferenceProvider.future);
    await sharedPreference.setInt('counter', newCount);

    /// counter以外、他のStateを更新しても問題ない

    /// 処理を行ったらStateの更新
    /// CounterNotifierのupdateを呼び出してStateを更新
    await _read(counterNotifierProvider.notifier).update(newCount);

    /// 更新後にSnackbarを表示する
    _read(navigationProvider).state = NavigationState.showSnackbar;
  }
}
// lib/screens/home/home_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../../providers/providers.dart';
import '_home_view_model.dart';

class Home extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    ref.listen<StateController<NavigationState>>(navigationProvider, (value) {
      switch (value.state) {
        case NavigationState.showSnackbar:
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(content: Text('更新されました')),
          );
          break;
        default:
          break;
      }
    });

    return Scaffold(
      appBar: AppBar(title: const Text('Counter example')),
      body: Center(
        child: Consumer(builder: (context, ref, _) {
          /// 初期値の読み込みやStateが変更されたらrebuildなどwatchすれば行われる
          final count = ref.watch(counterNotifierProvider);

          /// 非同期でカウント数を読み込むので、loading, errorなどをちゃんと表現する
          return count.map(
            data: (data) => Text('${data.value}'),
            loading: (_) => const CircularProgressIndicator(),
            error: (_) => const Text('On Error'),
          );
        }),
      ),
      floatingActionButton: FloatingActionButton(
        /// ここでStateを更新するのではなくViewModelを経由する
        onPressed: () => ref.read(viewModelProvider).increment(),
        child: const Icon(Icons.add),
      ),
    );
  }
}

本来ViewModelは、UIに関する処理のみを担ってビジネスロジックは別のクラスであるUseCaseなどが担うべきな気はするけど、毎回UseCaseを作るのはめんどくさいので気にしていない。ビジネスロジックが大きくなってきたり共通化したい場合にUseCaseに分けるなどすればよいと思う。

Flutter/Dartでcustom lint ruleを作りたい

推奨されないコードを機械的に発見出来る部分は人間がコードレビューなどで指摘するのではなくLinterで気づきたい。 なので、プロジェクトごとにLinterのCustom Ruleを作りたいのだが、DartのLinterはCustom Ruleに対応していない。

github.com

このイシューに書かれてある通り、linterコマンドを自分で作る方法とanalyzer_pluginを利用する方法がある。

linterコマンドを自分で作る

この方法は実装が簡単というメリットがあり、IDE上ではエラーや警告が表示されないというデメリットがある。

linter/linter.dart at master · dart-lang/linter · GitHub

linter.dart の中で自分で作ったRuleを登録してあげて実行する。まあこういう感じで出来る

Future main(List<String> args) async {
  final enableRules = [
    MyCustomRule(),
  ];
  for (final rule in enableRules) {
    Analyzer.facade.register(rule);
  }
  await cli.run([
    '--packages=.packages',
    '--rules=${enableRules.map((r) => r.name).join(",")}',
    'lib',
    'test',
  ]);
}

analyzer_pluginを利用する方法

この方法はIDE上でもエラーや警告が表示されるというメリットがあり、実装やデバッグがめんどくさいというデメリットがある。

analyzer_plugin | Dart Packageを利用する。

めんどくさい実装をメンテし続けるのはつらいので、dart_code_metricsというライブラリをforkして自前のルールを追加するのはどうかと思ってやってみた。

試しに追加したのは、 DateTime.utc() とかを使うとエラーにするRule

github.com

これでDateTime.utc(2020) とかするとIDE上でエラーになる。ただまあ、forkなのであんまりやりたくないなと思っている。

RiverpodでModel-View-Updateを試す

ここ1年くらいFlutterで開発していて、Model-View-ViewModel(MVVM)を採用したけどViewModelの役割が大きいなど色々課題を感じていて、より良い形はないかなとFlux, Reduxなどのドキュメントを見ている。 とは言ってもFluxやReduxなどドキュメント見る限り結構めんどくさい事が多い印象で実際に開発してると大変そうだな〜と感じている。

なので、Stateの管理をRiverpodに任せてそれを薄くラップするくらいでよりシンプルな形に出来ないか考えている。

前提

  • The Elm ArchitectureをTEAと略します。
  • Model-View-UpdateをMVUと略します。
  • MVUというかThe Elm Architectureはこのあたりがわかりやすいです
  • Riverpod自体がStateを更新する前に処理を行ったり、更新したあとに処理を行うことは出来ないので自前で行う必要がある
  • Reduxでめんどくさそうだなと思っているのは1つのStoreを使い回すこと・Action objectを作ってdispatchしてStateを更新するあたりです。
    • 1つのStoreを使い回さないのはRiverpodを使えば解決できます

自分なりの理解

アプリには以下のようなフェーズがあると思っている。

  • 最初に値を取得しStateに反映させる「初期化表示」フェーズ
  • ユーザーの入力によって処理を行ってStateを更新しViewに反映させる「更新表示」フェーズ

それをMVUに当てはめて図にするとこんな感じ f:id:hisaichi5518:20210729234740p:plain

Flutter WidgetがView、StateがModel、ActionsがUpdateを行うくんです。

初期化表示のときもUpdateを経由するのでは?と思っていたのだけど、TEAのドキュメントを見る感じそういうわけではなさそうだった。

またTEAにはCmd(Command)という概念があって、そこで副作用のある処理を行ってupdateにstateを更新させる形になっていそうだけど、Actionsという名前を使うことにした。

さっそく実装する

カウンターアプリを作るのだが、ただlocal stateの数字を増やすだけのアプリだと簡単すぎるので、以下の仕様があるアプリをつくる。

  • カウント数はSharedPreferenceに保存される
  • アプリを立ち上げた時は、保存されたカウント数を非同期で取得し表示する
  • カウント数を取得している間はLoadingを表示する
  • カウントはユーザーの入力により増加し、同時にSharedPreferenceに保存される

図にするとこんな感じ f:id:hisaichi5518:20210730000502p:plain

図だとわかりにくい気がしたのでコードにコメントしていく

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';

final counterNotifierProvider =
    StateNotifierProvider<CounterNotifier, AsyncValue<int>>(
  (ref) => CounterNotifier(ref.read)..initialize(),
);

final sharedPreferenceProvider = FutureProvider(
  (ref) async => SharedPreferences.getInstance(),
);

final actionsProvider = Provider((ref) => CounterActions(ref.read));

/// CounterのStateをクラスで定義している。
/// 読み書きを行うStateで初期値をasync/awaitで読み込む時はStateNotifierを使う。
class CounterNotifier extends StateNotifier<AsyncValue<int>> {
  CounterNotifier(this.read) : super(const AsyncValue<int>.loading());

  final Reader read;

  /// ここで初期値を取得している
  /// AsyncValueを利用することで、1つのStateで読み込み中, エラー, データを表現できる
  Future<void> initialize() async {
    await Future<void>.delayed(const Duration(seconds: 1));
    final sharedPreference = await read(sharedPreferenceProvider.future);
    state = AsyncValue.data(sharedPreference.getInt('counter') ?? 0);
  }

  /// incrementするメソッド
  /// このクラスはStateなので自分のStateしか更新しない
  /// 本来ここは副作用がない(入力が同じなら何度実行してもStateが同じ結果になる)のがテストをしやすいと思うのだが、メソッドである以上そんな制約持たせるの無理じゃない?と思うので気にしないことにした。
  /// Elmは言語仕様でそういう制約があるみたい。
  Future<void> increment() async {
    final data = state.data;
    if (data == null) {
      // loading or error
      return;
    }
    final newCount = data.value + 1;
    state = AsyncValue.data(newCount);
  }
}

/// ユーザーの入力を受け取り、Stateの更新やBackend APIとやり取りを行うクラス
class CounterActions {
  CounterActions(this.read);

  final Reader read;

  Future<void> increment() async {
    /// CounterNotifierのincrementを呼び出してStateを更新
    await read(counterNotifierProvider.notifier).increment();

    /// LocalStorageのデータも新しいデータで更新
    final newCount = read(counterNotifierProvider).data?.value ?? 0;
    final sharedPreference = await read(sharedPreferenceProvider.future);
    await sharedPreference.setInt('counter', newCount);
  }
}

Future<void> main() async {
  runApp(
    const ProviderScope(child: MyApp()),
  );
}

class MyApp extends ConsumerWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return MaterialApp(
      home: Home(),
    );
  }
}

class Home extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(title: const Text('Counter example')),
      body: Center(
        child: Consumer(builder: (context, ref, _) {
          /// 初期値の読み込みやStateが変更されたらrebuildなどwatchすれば行われる
          final count = ref.watch(counterNotifierProvider);
          /// 非同期でカウント数を読み込むので、loading, errorなどをちゃんと表現する
          return count.map(
            data: (data) => Text('${data.value}'),
            loading: (_) => const CircularProgressIndicator(),
            error: (_) => const Text('On Error'),
          );
        }),
      ),
      floatingActionButton: FloatingActionButton(
        /// ここでStateを更新するのではなくActionsを経由する
        onPressed: () => ref.read(actionsProvider).increment(),
        child: const Icon(Icons.add),
      ),
    );
  }
}

まとめ

このようにStateの初期化の処理を行い表示するフェーズ・ユーザーの入力に起因して処理を行いStateを更新して表示するフェーズがある。

ViewModelのようにすべてのことをViewModelが行うのではなくStateに初期化やActionsに更新の役割を与えることで責務の分離がより出来るのではないかと思っている。