DartのUnmodifiableListViewを理解する

Simple app state management | Flutterのコード例の箇所でUnmodifiableListViewというのが出てきたんですが、役割や使い所をちゃんと理解しておきたく、調べてまとめます。

ドキュメントでは以下のような例で出てきました。CartModelクラスのgetterで使われています。

class CartModel extends ChangeNotifier {
  /// Internal, private state of the cart.
  final List<Item> _items = [];

  /// An unmodifiable view of the items in the cart.
  UnmodifiableListView<Item> get items => UnmodifiableListView(_items);

  /// The current total price of all items (assuming all items cost $42).
  int get totalPrice => _items.length * 42;

  /// Adds [item] to cart. This and [removeAll] are the only ways to modify the
  /// cart from the outside.
  void add(Item item) {
    _items.add(item);
    // This call tells the widgets that are listening to this model to rebuild.
    notifyListeners();
  }

  /// Removes all items from the cart.
  void removeAll() {
    _items.clear();
    // This call tells the widgets that are listening to this model to rebuild.
    notifyListeners();
  }
}

該当箇所だけをよく見ると、

/// An unmodifiable view of the items in the cart.
UnmodifiableListView<Item> get items => UnmodifiableListView(_items);

un-(否定) + modify(変更する) + -able(できる)なので、変更できないリストにしてそうな雰囲気があります。

動作を観察する

動作を試してみます。まずは先ほどのコード例をdartpad.devでも動くように単純にした上で、UnmodifiableListViewを使わないようにしました。

class Cart {
  final _items = [];
  
  get items => _items; // _items配列をそのまま返すgetter
  
  void addItem(int item) {
    _items.add(item);
  }
}

main() {
  final cart = Cart();
  print(cart.items); // => []

  cart.addItem(3);  // CartクラスのaddItemメソッドを使えば...
  print(cart.items); // => [3] // ...もちろん_items配列に追加されて、items getterで取り出せる

  final list = cart.items; // items getterの戻り値はList<int>なので...
  list.add(100);  // ...List型に備わっているaddメソッドが使えてしまい...
  print(list); // => [3, 100] // ... listに追加できるし...
  print(cart.items); // => [3, 100] // ...Cartクラスの_items配列自体も変わっている
}

一方、UnmodifiableListViewを使うとこのようになります。

import 'dart:collection';  // UnmodifiableListViewを使うために必要

class Cart {
  final _items = [];
  
  get items => UnmodifiableListView(_items);  // getterでUnmodifiableListViewを返すようにする
  
  void add(int item) {
    _items.add(item);
  }
}

main() {
  final cart = Cart();
  print(cart.items); // => []

  cart.addItem(3);
  print(cart.items); // => [3]. // addItemメソッドの挙動は変わらないが...
  
  final unmodifiableList = cart.items;  // items getterの戻り値がUnmodifiableListView、つまり変更不可のListになり...
  unmodifiableList.add(100); // => ...追加しようとした時点でエラーが出る! Uncaught Error: Unsupported operation: Cannot add to an unmodifiable list (変更不可のListには追加できません)
}

だいぶ分かりやすい違いがありました。items getterの返り値は文字通り変更不可のリストであるため、addメソッドで要素を追加したり、removeAtメソッドなどで要素を削除しようとするとエラーになりました。

DartAPI Documentを読む

動作を見た段階で何となく理解した気はしますが、一応DartのドキュメントのUnmodifiableListView class - dart:collection library - Dart APIを読んでおきます。 以下のように書いてあります。

An unmodifiable List view of another List. (他のリストの、変更不可のListのラッパー)

The source of the elements may be a List or any Iterable with efficient Iterable.length and Iterable.elementAt. (要素のsourceは、Listまたは効率的な Iterable.length と Iterable.elementAt を持つ任意の Iterableかもしれない)

Constructors UnmodifiableListView(Iterable source) Creates an unmodifiable list backed by source. (sourceというIterableに裏付けられた変更不可のリストを作成する)

UnmodifiableListViewのコンストラクタの引数は、 Iterable.lengthとIterable.elementAtを持つIterableですが、基本的にはListだと思っておいて良さそうですね。

ところで、ListにはList.unmodifiable()というfactory constructorがあるらしいです。 List.unmodifiable constructor - List - dart:core library - Dart API

List.unmodifiable(Iterable elements) Creates an unmodifiable list containing all elements. (すべての要素を含む変更不可のリストを作成する)

The Iterator of elements provides the order of the elements. (要素のIteratorは、要素の順序を提供する)

An unmodifiable list cannot have its length or elements changed. If the elements are themselves immutable, then the resulting list is also immutable. (変更不可のリストは、その長さや要素を変更することができない。 要素自体が不変であれば、結果として得られるリストも不変である)

final numbers = [1, 2, 3]; final unmodifiableList = List.unmodifiable(numbers); // [1, 2, 3] unmodifiableList[1] = 87; // 例外を投げる

UnmodifiableListView(elements)とList.unmodifiable(elements)、どちらも引数のelementsというListを変更不可にしたものを返すという意味では大体同じに見えます。

これらの違いについて、In Dart, what different about List.unmodifiable() and UnmodifiableListView? - Stack Overflowでちょうど解説されていたのでご紹介します。 (今更ですがList viewの「view」って、MVCなどの画面表示とかのviewじゃなくて、wrapperという意味合いのようですね)

この解答を読むと、変更不可Listを作ったあとに元のListを変更したときの挙動に違いがありそうです。

UnmodifiableListView(elements)のほうは、元のListの変更が変更不可Listにも波及します。つまり、変更不可Listは直接変更できませんが、元のListを変更することで間接的に変更することができます。使い所としては、最初に引用した例の_itemsのように、クラスのプライベートフィールドを元にUnmodifiableListViewを作成し、クラス内部のメソッドからは変更を許すが、クラス外部からの変更はできないようにするという場面かと思います。これによってカプセル化できるということになります。

一方でList.unmodifiable(elements)のほうは、元のListとは全く別の新しい変更不可Listを作ります。変更不可Listを作ったあとに元のListを変更しても、変更不可Listのほうには何の影響もありません。使い所としては、List.unmodifiableで変更不可Listを動的に作ったら、それ以後は一切変更しない定数のような扱いをするような場面かなと思います。

以上でUnmodifiableListViewについて一旦理解したことにします!参考になれば幸いです。

Flutter DocumentationのDevelopment > Data & backend > State managementのところを全部読む

公式ドキュメントをしっかり読み込むことの重要性を身にしみて感じているので、これからFlutter公式ドキュメントを少しずつでも読んでまとめていきたいと思い立ち、今日から始めます。 状態管理をちゃんと分かりたいので、まずはState Managementの項から、要点を箇条書きしていきます。箇条書きとコード引用のところ以外の部分は僕の感想になっています。 (2022年12月30日時点のドキュメントですので、お読みの時には古くなっている可能性があります)

Start thinking declaratively

docs.flutter.dev

  • Flutterは命令型ではなく宣言型のフレームワークである。
  • 状態の変更がトリガーとなってUIがゼロから再描画される。

命令型と宣言型の違いは、ReactとかVue.js入門みたいな文脈でよく見かける気がします。命令型ではたとえば「divタグを作れ。そのclass名はhelloClass変数の値にしろ。その中のテキストをこんにちはにしろ。」のように順番に命令して組み立てていきますが、これはコードから完成形がイメージしづらいという問題があります。 一方の宣言型では(Vue.jsですが)

<div :class="helloClass">
  こんにちは
</div>

のように出来上がりの形を書いて「これになれ」と言うイメージですね。

アプリの状態が変化したときにいちいちUIをゼロから再構築する、というのはWebの感覚からすると「遅くならない?」と思ってしまいますが、遅くならないそうです。すごいですね。(Flutter Webだとどうなんだろう)

Differentiate between ephemeral state and app state

docs.flutter.dev

  • ここでは状態(ステート)を「任意の時点でUIを再構築するために必要なあらゆるデータ」と定義する。
  • 自分で管理する状態(ステート)には、Appステートとエフェメラルステートの2つの種類がある。

エフェメラル(ephemeral)は聞き慣れない単語だと思います。もともと古典ギリシア語のephēmeros(エペーメロス)という形容詞から来ていて、これはepi-(upon) + hēmera(day) = 「一日(だけ)の」、転じて「短命の、儚い」という意味です。状態が短命、つまり一時的な状態ということですね。 Flutterアプリで管理する状態には、エフェメラルステートとAppステートの2種類があるとのことです。エフェメラルが「短命」なので、Appステートの方は「長生き」する状態という立ち位置になりそうな予感がしますね。

エフェメラルステート

  • エフェメラルステートは、UIステートまたはローカルステートと呼ばれることもある。
  • エフェメラルステートは、1 つのウィジェットに収められる状態であり、他のウィジェットからアクセスする必要がないもの。
  • エフェメラルステートの例
    • 現在のページPageView
    • 複雑なアニメーションの現在の進行状況
    • BottomNavigationBarで現在選択されているタブ
  • エフェメラルステートに必要なのはStatefulWidgetだけ。
  • ユーザーがアプリを閉じて再起動して状態がリセットされても気にする必要がないもの。
  • 以下の例では、_indexがエフェメラルステートである。
class MyHomepage extends StatefulWidget {
  const MyHomepage({super.key});

  @override
  State<MyHomepage> createState() => _MyHomepageState();
}

class _MyHomepageState extends State<MyHomepage> {
  int _index = 0;

  @override
  Widget build(BuildContext context) {
    return BottomNavigationBar(
      currentIndex: _index,
      onTap: (newIndex) {
        setState(() {
          _index = newIndex;
        });
      },
      // ... items ...
    );
  }
}

エフェメラルステートは、要するにStatefulWidgetのフィールドで保持するような状態のことのようです。 他のウィジェットからアクセスする必要がなさそうな状態については、Stateクラスのフィールドで十分ということですね。

Appステート

  • アプリケーションの多くの部分で共有し、ユーザーセッション間で保持したいような一時的でない状態をアプリケーションステート(共有ステートとも呼ばれる)と呼ぶ。
  • アプリの状態を管理するには、アプリの複雑さや性質、チームのこれまでの経験など、さまざまな観点から選択肢を調査する必要がある。
  • アプリケーションステートの例
    • ユーザー設定
    • ログイン情報
    • ソーシャルネットワーキングアプリの通知機能
    • Eコマースアプリのショッピングカート
    • ニュースアプリの記事の既読/未読状態

ユーザー設定やログイン情報のようにアプリ使用中ほとんど常に必要な状態などはアプリケーションステート(さっきまでAppステートと書いていたもの)ということのようです。そして状態管理のやり方はアプリやチームによって考えるべきということで、選択肢が色々ありそうな雰囲気ですね。

明確なルールは無い

  • ある変数がエフェメラルステートかアプリケーションステートかを区別する明確で普遍的なルールは無い。
  • StateとsetState()を使ってアプリのすべての状態を管理することができる。
  • エフェメラルステートから初めて、アプリケーションステートに移行する必要があるかもしれない。
  • この項を要約すると、
    • Flutterアプリには2種類のステートがある。
    • エフェメラルステートはStateとsetState()を使って実装でき、多くの場合1つのウィジェットにローカルに存在する。
    • どちらのタイプもFlutterアプリに存在し、その使い分けはあなたの好みとアプリの複雑さによって決まる。

エフェメラルステートかアプリケーションステートという区別は明確なルールはなく、例に挙げたものだってすべてエフェメラルステートでやろうと思えばできるが、その使い分けは好みとアプリの複雑さによるとのこと。急に丸投げされた感じですが、基本的にはエフェメラルステートを使い、アプリ全体で使いうるものはアプリケーションステート、と考えればよいでしょうか。

docs.flutter.dev

シンプルなアプリケーションステート管理

  • アプリケーションステート管理をやってみよう。
  • Providerパッケージから始めるべき。理解しやすく、コードもあまり使わないし、他のどのアプローチでも適用可能な概念を使用している。

Providerを改良して同じ作者がRiverpodというのを作ったという話は知っているので、Flutter公式がProviderパッケージの方を紹介しているのは意外。Riverpodよりも簡単ということなんでしょうか。(全然関係ないけどRiverpodがProviderのアナグラムであることに今気づいた)

そしてここからは感想を挟まずにガッと最後まで読んでいきます。

Our example

  • MyCatalogとMyCartからなるアプリで考えてみよう
  • MyCatalogはMyAppBarとMyListItemのScrollViewからなる
  • MyApp
    • MyCart
    • MyCatalog
      • MyAppBar
      • ScrollView
        • MyListItem
        • MyListItem
        • MyListItem
  • カートの状態をどこに置くかが問題になる

状態を引き上げる

  • Flutterでは状態はそれを使用するウィジェットの上の階層に置いておく。
  • Flutterではコンテンツが変わるたびに新しいウィジェットをbuildする。
  • MyCart.updateWith(somethingNew)のようなメソッドではなく、MyCart(contents)のようなコンストラクタで新しいウィジェットをbuildする。
  • 新しいウィジェットは親のbuildメソッドでしか構築ができない。
  • MyCartの親のMyAppにコンテンツを格納するので、MyCartはライフサイクルを気にせずに何を受け取り何を表示するかを宣言するだけでいい。
  • ウィジェットは、変化せずに古いものが新しいものと交換されるので、イミュータブル(不変)と言える。

状態にアクセスする

  • カートはMyListItemより上の階層にいる。どうやってカートに追加しようか?
  • 単純な方法は、MyListItemがクリックされた時に呼び出せるコールバック関数を渡すことだが、アプリケーションステートをさまざまな場所から変更する必要がある場合は多くのコールバックを渡さなければならなくなる。
  • ウィジェットがその子孫にデータやサービスを提供できるInheritedWidgetなどがあるが、ここでは低レベルなので扱わない。
  • providerパッケージを使えば、コールバックやInheritedWidgetを使わなくていい。その代わり次の3つを理解する必要がある。
    • ChangeNotifier
    • ChangeNotifierProvider
    • Consumer

ChangeNotifier

  • ChangeNotifierはFlutter SDKに含まれるシンプルなクラス。
  • リスナーはChangeNotifierの変更を購読できる。
  • providerでは、ChangeNotifierはアプリケーションの状態をカプセル化するための方法の1つ。
  • 非常にシンプルなアプリケーションでは、1つのChangeNotifierで十分。 複雑なアプリケーションでは、いくつかのモデルを持つことになり、したがっていくつかのChangeNotifierを持つことになる。
  • providerを使ってChangeNotifierを使う必要は全くないが、簡単に扱える。
  • Cartの状態をChangeNotifierで管理すると
class CartModel extends ChangeNotifier {
  /// カートの内部のプライベートな状態
  final List<Item> _items = [];

  /// カートの中のアイテムの変更不可のビュー
  UnmodifiableListView<Item> get items => UnmodifiableListView(_items);

  /// 現在の全アイテムの合計価格(全アイテムが42ドルとして)
  int get totalPrice => _items.length * 42;

  /// アイテムをカートに追加する。
  /// これとremoveAllが外からカートを編集する唯一の手段である。
  void add(Item item) {
    _items.add(item);
    // この呼び出しは、このモデルをリッスンしているウィジェットにrebuildを指示する
    notifyListeners();
  }

  /// カートからすべてのアイテムを削除
  void removeAll() {
    _items.clear();
    notifyListeners();
  }
}
  • ChangeNotifierに固有のコードは、notifyListeners() の呼び出しのみ。
  • アプリのUIを変更する可能性のある方法でモデルが変更された時は常にnotifyListeners()を呼び出す。
  • CartModelの他の全てはモデル自身とそのビジネスロジック
  • ChangeNotifierはflutter:foundationの一部で、Flutter の上位のクラスには依存しない。
  • 簡単にテストできる (ウィジェットテストを使う必要さえない)。
test('adding item increases total cost', () {
  final cart = CartModel();
  final startingPrice = cart.totalPrice;
  cart.addListener(() {
    expect(cart.totalPrice, greaterThan(startingPrice));
  });
  cart.add(Item('Dash'));
});

ChangeNotifierProvider

  • ChangeNotifierProviderは、ChangeNotifierのインスタンスをその子孫に提供するウィジェット。providerパッケージに含まれる。
  • ChangeNotifierProviderはアクセスする必要のあるウィジェットの上に置く。
  • ChangeNotifierProviderを必要以上に高い位置に配置することはない。(スコープを汚したくないため)
  • 今回のケースでは、MyCartとMyCatalogの両方の上にあるウィジェットは、MyApp だけ。
void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CartModel(),
      child: const MyApp(),
    ),
  );
}
  • CartModel の新しいインスタンスを作成するビルダーを定義していることに注意。
  • ChangeNotifierProviderは賢いので、絶対に必要でない限りCartModelをrebuildすることはない。
  • CartModelインスタンスが不要になった場合、自動的にCartModelのdispose()を呼び出す。
  • 複数のクラスを提供したい場合は、MultiProviderを使用する。
void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (context) => CartModel()),
        Provider(create: (context) => SomeOtherClass()),
      ],
      child: const MyApp(),
    ),
  );
}

Consumer

  • ここまでの準備で、CartModelは上部のChangeNotifierProvider宣言により、アプリ内のウィジェットに提供されるようになった。
  • CartModelはConsumerウィジェットを通して使う。
return Consumer<CartModel>(
  builder: (context, cart, child) {
    return Text('Total price: ${cart.totalPrice}');
  },
);
  • この場合、CartModel が必要なので、Consumer と記述する。ジェネリック() を指定する必要がある。
  • Consumerウィジェットの必須引数はbuilderのみ。モデル内でnotifyListeners()を呼び出すと、対応するすべてのConsumerwidgetのbuilderメソッドが呼び出される。
  • ビルダーは3つの引数で呼び出される。
    • 第1引数はcontextで、これはすべてのbuildメソッドと同様。
    • 第2引数は、ChangeNotifier のインスタンス
    • 3番目の引数はchild で、これは最適化のためにある。 モデルが変わっても変化しない大きなウィジェットのサブツリーがConsumerの下にある場合、それを一度buildしてbuilderを通して取得することができる。
return Consumer<CartModel>(
  builder: (context, cart, child) {
    return Stack(
      children: [
        // ここで SomeExpensiveWidget を使うと、毎回rebuildせずに済む
        if (child != null) child,
        Text('Total price: ${cart.totalPrice}'),
      ],
    );
  },
  // ここでコストの高いウィジェットをbuildする
  child: const SomeExpensiveWidget(),
);
// DON'T DO THIS
return Consumer<CartModel>(
  builder: (context, cart, child) {
    return HumongousWidget(
      // ...
      child: AnotherMonstrousWidget(
        // ...
        child: Text('Total price: ${cart.totalPrice}'),
      ),
    );
  },
);
// DO THIS
return HumongousWidget(
  // ...
  child: AnotherMonstrousWidget(
    // ...
    child: Consumer<CartModel>(
      builder: (context, cart, child) {
        return Text('Total price: ${cart.totalPrice}');
      },
    ),
  ),
);

Provider.of

  • 時には、UIを変更するためにモデル内のデータを本当に必要としないが、それでもそれにアクセスする必要がある。
  • そのためにConsumerを使うこともできるが、rebuildする必要のないウィジェットをrebuildするようにフレームワークに要求することになる。
  • 例えばremoveAll()メソッドを呼びたいだけなら、listenパラメータをfalseに設定したProvider.ofを使用することができる。
Provider.of<CartModel>(context, listen: false).removeAll();
  • これをbuildメソッド内で使用しても、notifyListeners()が呼ばれたときにそのウィジェットをrebuildすることはない。

Putting it all together(まとめ)

  • もっとシンプルなものをお望みなら、シンプルなCounterアプリを providerで構築するとどんな感じになるかをご覧ください。
  • これらの記事に従うことで、ステートベースのアプリケーションを作成する能力を大幅に向上させることができました。
  • これらのスキルを習得するために、providerを使ったアプリケーションを自分で作ってみてください。

ドキュメントは読み終わったので実際に作ってみます

......ざっと最後まで読んでしまいましたが、後半は実際に作ってみた方が良さそうですよね。以下で作っていきます。

Android Studioのnew flutter projectで「flutter_state_management_practice」という名前でプロジェクトを作ります(もちろんコマンドでflutter create flutter_state_management_practiceでもいいです)。 これでいつものカウンターアプリが出来ました。

次にproviderパッケージを追加します。コンソールでflutter pub add providerです。 これで準備完了です。

あとはアプリの中身を作っていきます!完成形がこちらです。

main.dart

import 'package:flutter/material.dart';
import 'package:flutter_state_management_practice/cart_model.dart';
import 'package:flutter_state_management_practice/my_catalog.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CartModel(),
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyCatalog(),
    );
  }
}

my_catalog.dart

import 'package:flutter/material.dart';
import 'package:flutter_state_management_practice/cart_model.dart';
import 'package:flutter_state_management_practice/cart_page.dart';
import 'package:provider/provider.dart';

class MyCatalog extends StatelessWidget {
  const MyCatalog({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('My Catalog'),
        actions: [
          IconButton(
            icon: const Icon(Icons.shopping_cart),
            onPressed: () {
              Navigator.push(
                context,
                MaterialPageRoute(
                  builder: (context) => const CartPage(),
                ),
              );
            },
          ),
        ],
      ),
      body: ListView.builder(
        itemCount: 10,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text('Item $index'),
            trailing: IconButton(
              icon: const Icon(Icons.add_shopping_cart),
              onPressed: () {
                Provider.of<CartModel>(context, listen: false).add('Item $index');
              },
            ),
          );
        },
      ),
    );
  }
}

cart_model.dart

import 'dart:collection';

import 'package:flutter/foundation.dart';

class CartModel extends ChangeNotifier {
  final List<String> _itemNames = [];

  UnmodifiableListView<String> get itemNames => UnmodifiableListView(_itemNames);

  // 全部2000円とする
  int get totalPrice => _itemNames.length * 2000;

  void add(String item) {
    _itemNames.add(item);
    notifyListeners();
  }

  void removeAll() {
    _itemNames.clear();
    notifyListeners();
  }

  void removeAt(int index) {
    _itemNames.removeAt(index);
    notifyListeners();
  }
}

(UnmodifiableListViewについてよく知らないので後で調べます)

cart_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_state_management_practice/cart_model.dart';
import 'package:provider/provider.dart';

class CartPage extends StatelessWidget {
  const CartPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Cart'),
        actions: [
          IconButton(
            icon: const Icon(Icons.remove_shopping_cart),
            onPressed: () {
              Provider.of<CartModel>(context, listen: false).removeAll();
            },
          ),
        ],
      ),
      body: Consumer<CartModel>(
        builder: (context, cart, child) {
          return ListView(
            children: [
              for (final item in cart.itemNames)
                ListTile(
                  title: Text(item),
                  trailing: IconButton(
                    icon: const Icon(Icons.remove_shopping_cart),
                    onPressed: () {
                      cart.removeAt(cart.itemNames.indexOf(item));
                    },
                  ),
                ),
              const Divider(),
              ListTile(title: Text('合計${cart.totalPrice}円')),
            ],
          );
        },
      ),
    );
  }
}

できました!これでカタログページからカートに追加をしたものがカートページにちゃんと入っており、カートページから個別削除・一括削除もできるようになりました。 モデルのメソッドを使いたいだけならProvider.of、モデルの中身を使って表示したい時にはConsumerを使うということで一旦理解しました。 単純なアプリならこれだけでかなり対応できそうな感じがしますね。

MPについて

MPの減りが早く、やろうと決めたことを続けることが困難になっている。MP、つまりHP(体力)ではなく精神力の方である。話題のRailsの本を読み始めた次の日にはエンジニアの生き方みたいな本を読んでいたり、Flutterを勉強しなきゃと思いつつデザインパターンの本を買ってみたり。ラテン語の本の輪読会には今年から参加しなくなった。筋トレも気まぐれに始めて、一発で筋肉痛になってやらなくなってしまった。Fusion 3603Dプリンタもここ何ヶ月か触っていない。アニメも漫画も全然追えていない。幸いブログは3日坊主にならず4日目に突入しているが、明日も書けるかどうか怪しい。

子供が産まれて育児をするようになってから、MP消費量が異常に増えており、MP最大値もかなり下がっていると感じる。自分の時間はほとんど無くなるし、どうやっても寝不足になる。世話をし続けないとすぐ死ぬ人間と暮らすのは気を抜ける時間が少ない。日々を過ごすだけでMPを使い果たす。新しいことを始める気力がない。ブログを始められて、しかも4日連続で書いているのは奇跡に近い。

自分の時間を取るために早起きするという人もいるらしい。しかしそれは自分の睡眠負債を返済するのに必死な状態では不可能だ。たとえ起きられても寝不足で何もできない。6時間しか寝ずに朝5時に起きてシャキシャキ活動できるのは超人だと思う。8時間睡眠が絶対必要な自分にとってはかなり厳しい。

MP低下で苦しむ生活の中では、MPを回復する活動がとても重要である。僕の場合は、睡眠はもちろんのこと、美味しいものを食べる、笑う、風呂に入る、換気をする、音楽を聴く、ゲームする、ドラムを叩くなど、しんどい時にやるべきことをストックしておき、可能な限りいつでもすぐできるように用意しておく。

うちの場合、限界が来てすぐに美味しいものを食べたいとなったら、ドミノピザの炭火焼きビーフ&高麗カルビのハーフ&ハーフの2倍盛りを注文し、インドの青鬼を飲みながら食べる。大抵これでかなり回復する。12月中なら全てのピザが半額になるクーポンがググったら出てきたので、しんどい人はぜひやってみてほしい。

タスク管理について

年齢を重ねるごとに、記憶の賞味期限が短くなってきたことを感じる。人から聞いただけのことは気を抜くと次の瞬間には忘れているし、覚えておこうと努力したこともやはり次の日には忘れている。頼みごとをされて「やっておきます」と言ったことも忘れていたことすらあり、いよいよ脳の老化がここまできたかと焦った。とにかく、まずはタスクを確実に管理しないことには人からの信頼を失いかねない。

タスク管理の取り組み方について、いくつかの段階があると思う。最も原始的なのは、自分の記憶だけに頼る方法である。これはタスク管理と呼べる代物かどうかも怪しいが、実際に大学時代の友人に自分の全てのスケジュールを記憶していた人がいたので、一応タスク管理法の1つということにしておく。確かに高校生くらいまでは僕もTODOリストなどは使わなかったので、記憶だけに頼っていたと言える。しかし今となっては、メモも取らず自分の記憶だけを信頼できる年齢はとうに過ぎてしまった。

次にTODOリストを使ったタスク管理である。僕は大学生の頃まではTODOリストなんて作っている時間が無駄だと思っていたが、社会人になってから実際にやってみるとやるべきことが頭の外で一覧できて、これが案外気分が良い。タスクを完了して消していくのも気持ち良い。やるべきことが多くなって頭がパンクしそうになると逆に何も手がつけられなくなるタイプなので、パンクしそうな時はまずその日のTODOリストを作る、ということを実践していた時期もある。しかしTODOリストだと、重要なタスクが他のタスクに紛れてしまって見落としたり、期日までの道筋が考えられていなかったり、そもそもTODOリストに入れ忘れていたりしていた。

そして現時点の最終段階として、タスク管理ツールOmniFocusを使って、タスク管理手法であるGTDを実践するということを続けている。上述したTODOリストにおける課題感を持っていたところ、会社でOmniFocusについて教えてもらい、GTDに関する書籍を読んで、それ以来仕事でもプライベートでもOmniFocus中心に行動している。

GTD(Getting Things Done)について説明しようとすると長くなるのでざっくり書くと、頭の中のタスクを思いつく限りすべて書き出した上で、それらを整理し、実行しつつタスクリストを振り返るという、タスク管理のフレームワークだ(と僕は理解している)。『はじめてのGTD ストレスフリーの整理術』という書籍がGTD提唱者が書いた本なので、興味のある方は読んでみてほしい。GTDが特徴的だと思うのは、とにかく頭の中のタスクを分類することなく頭の外(inboxという入れ物)に全て吐き出して可視化するというステップを最初に組み込んでいることだ。これによってストレスフリーにタスクに取り組める、とGTD提唱者は言っている。

OmniFocusはiOSiPadMac専用の有料アプリで、GTDを厳格に実践するために作られたアプリだ。ちゃんとInboxという場所が用意されているし、OmniFocusを開いていなくてもショートカットキーでInboxにタスクを追加するためのウィンドウが開くので、タスクの入れ忘れが起こりにくい。タグをつけて整理することもできるし、繰り返しタスクも登録できる。便利なのは期日や見積もり時間を入れておくことで、例えば期日が今日の11:00で見積もり時間が30分のタスクがあれば、今日の10:30になるとリマインドを飛ばしてくれる。また、やるべき日までタスクを延期するという設定もできて、その日になるまでタスクを非表示にできるので、今すぐ手をつけられるタスクを一覧しやすい。他にもまだまだ使いこなせていない機能が山ほどあると思う。

OmniFocusにはサブスクと買い切りの2つのプランがある。料金については変わりうるので詳しくはググってほしい。現在バージョン3のOmniFocus3が出ており、買い切りの場合はメジャーバージョンが上がるごとに買い直す必要があるらしい。つまりOmniFocus3を買っても、OmniFocus4が出たらそのバージョンはまた購入しないと使えない。僕は2週間の無料体験で毎日使っていたところ完全に手放せなくなってしまい、とはいえちょっと高いので無料で使い続ける方法は無いか調べてみたところ、現在ちょうどOmniFocus4のベータテスト中で、メールを登録して招待を受ければ無料で90日間使えるという情報を見つけた。早速メールを登録したら、翌日には招待が来てOmniFocus4を使えるようになった。OmniFocus3で登録していたタスクも、ログインすれば同期されたのでスムーズに使い始められた。

しかし90日間のベータテスト期間が終わるまでにOmniFocus4の正式版をリリースしてくれないと、OmniFocus3の方のサブスクに入る必要が生じてしまう。どうか早くリリースしてほしい。

家族KPTについて

KPTという振り返りの手法がある。

  • Keep:  良かったこと・継続すべきこと
  • Problem:  改善したいこと
  • Try:  改善するためにやること

といった内容でそれぞれを書き出して直近1週間を振り返り、改善サイクルを回すための手法である。

会社で毎週KPTを行なっていたのだが、家庭においてもこのフレームワークは有用そうに思ったので、「家族KPT」をここ数年実践している。毎週土曜の午前に一緒にコーヒーを飲みながら、その一週間の良かったこと・良くなかったことを振り返り、改善案を出すのである。幸運なことに妻も乗り気で付き合ってくれている。

Keepの欄は、今週作ったあの料理が美味しかったとか、なかなか掃除してなかったあれを掃除したとか、あそこに出かけて楽しかったとか、そういう話が多い。Problemの欄は、やるべきあのタスクができなかったとか、赤子の夜泣き対応がしんどいとか、良くない生活習慣とか、相手の直して欲しいところとか。そしてTryの欄はProblemそれぞれについて、どのようなアクションを取っていくかを記入する。話をして思い出しながら、パソコンに打ち込んでいく。大体20分くらいで完了する。

家庭で振り返りをするようになって、共同生活を続けていく中でどうしても生まれてしまう摩擦がうまく解消できるようになったと感じる。これまで別々のやり方で20数年生きてきた人間同士が、数年付き合ったくらいで同居しても、必ずどこかで「普通こうでしょ」の相違が顔を出す。それを誤魔化しながら同居を続けることもできるかもしれないが、たぶん長続きしないだろう。そこでKPTがとても役立つ。こうすると嬉しいんだということをKeepで、これは嫌なんだということをProblemでお互い共有し、今後はこうしようということを話し合ってTryに入れる。これを毎週繰り返すことで、相互理解が深まっていく。「普通こうでしょ」の相違がゼロになることはおそらく無いが、それでもゼロに近づける努力をすることに意味がある。

使うツールについても書いておく。会社ではTrello形式のボードでKPTを行なっているので、以前までは家族KPTでもTrelloを使っていたが、最近Google SpreadSheetに移行した。Trelloだとカードや列の数が増えてくると動作が重くなっていたことと、カードを横の列に動かすことはまず無いのでTrelloのようなカンバンである必要が全くないこと、そしてSpreadSheetの方がサービス終了によるデータ消失のリスクが低い(たとえサービス終了になってもExcelCSVにエクスポートできる)ことが移行の理由である。SpreadSheetでKPTを用意すると以下のような感じになる。

KPT用SpreadSheetの様式

1つの日付ごとにK・P・T・備考列の4列用意する(備考列は必須ではない)。最新のKPTは左端に列を4つ追加して行い、右に行けば行くほど古い日付のものに遡れることになる。これだと無限に右に伸びてしまうので、数ヶ月に一度は新しいシートに切り替えるのが良いと思う。列の色は好みに応じて変えれば見やすくなる。

KPTは毎週開催するので、毎回左に4列追加して色をつけて日付を入力して...という作業をするのが億劫になってしまい、先日GASでマクロを書いた。ショートカットキー1発で日付まで自動入力されて列が色付きで左端に追加され、すぐにKPTを始められる状態になるようになり、準備がかなり楽になった。こういうマクロのようなことはTrelloでは多分できないのではないだろうか(できるなら教えてほしい)。

SpreadSheetを使う利点はもう一つある。それは、TryがどのProblemに対してのTryなのかを、同じ行に書くことではっきり示せることである。Trelloの場合はカードの縦幅が文量で変わるため、あるTryがどのProblemに対するTryなのかがパッと見でわからないという問題があった。SpreadSheetの表形式であれば、Problemのすぐ横にそれに対するTryを書けばよいということになる。全てのProblemに対するTryが書けているかどうかもすぐにわかる。

あとはTryをOmniFocusのinboxに詰め込めばいい。OmniFocusについては気が向いたら書こうと思う。

寝かしつけについて

来月1歳になる息子がいる。最近ハイハイがみるみるうちに高速になっていき、僕がトイレに行こうものなら笑顔で叫びながらハイハイでドタドタ追ってくる。つかまり立ちからのつたい歩きをするようにもなったし、何もつかまなくてもその場で立つようにもなった。お腹が空いたら泣かずに僕の膝につかまって訴えかけてくる。ついこの前まではベッドで仰向けになってミルクを飲んでおしっこして泣くだけの生き物だったことを思い返すと、成長著しいなぁとしみじみする。

この子を寝かしつけるスタイルはいつも決まっていて、抱っこ紐で抱っこして、浅いスクワット運動をする。昼寝も夜寝もそうしている。寝るときは5分もしないうちに寝るし、寝ない時は1時間スクワットしても寝ない。抱っこしている子の頭の重さが胸に沈み込んできたら、顔は見えないけど寝たかな?と思って抱っこ紐を外してみる。そのとき笑顔で目が合ってニコッとされると、可愛くて笑ってしまうが、同時にゲッソリもする。

この寝かしつけ方法以外にも色々試したが、うちの子はこの「抱っこ紐スクワット法」が一番よく寝てくれるという実験結果を得たため、スクワットが多少キツくても続けているのである。

この方法の良いところは、子の寝つきが良くない場合にもスタンディングデスクでパソコンを触ることができることだ。現に今も抱っこ紐スクワット法で寝かしつけながらブログを書いている。調べ物をしたりKindle本を読んだりすることもできるし、コードを書くこともできる。こうすることで、寝そうで寝ない息子の寝かしつけがそれほど苦痛ではなくなる。

寝てくれるまで平均すると大体15分前後なので、短いタスクにちょうどいい。タスク管理ツールのOmniFocusで、すぐできそうなタスクを書き出しておいて、寝かしつけの間にどれをやろうか見て決める。今後は寝かしつけ中にブログを書くことを習慣づけても良いかもしれない。

息子がもう少し大きくなったりすると、パソコンをいじりながらスクワットで寝かしつけることもできなくなるだろう。後から振り返って、この頃はこういうことをしていたなぁと懐かしむだろうし、それを息子に話す時が来るかもしれない...などと思っていたら寝ついてくれたので今日はここまで。