Flutter で画像読み込み時にガクッとならないように、画像サイズがわかる時は事前に高さを確保しておく

画像を表示する際、先に画像サイズがわかっている場合は以下のように高さを計算しあらかじめ高さを確保しておくと読み込み後に高さがガタッと変わることなく表示できて体験が良い。

なお以下は CachedNetworkImage を使っているが、LayoutBuilder で constraints.maxWidth をとりそれと画像の縦横比を比較し高さを算出している。これで画像サイズが動的に変化するとしても画面描画時に高さを確保できるのでガタッとならない。

LayoutBuilder(
  builder: (BuildContext context, BoxConstraints constraints) {
    double maxWidth = constraints.maxWidth;
    final ratio = (image.width ?? 0) / (image.height ?? 0);
    final height = maxWidth / ratio;
    return SizedBox(
      width: double.infinity,
      child: CachedNetworkImage(
        imageUrl: image.url,
        placeholder: (_, __) => SizedBox(
          height: height,
        ),
        fit: BoxFit.contain,
      ),
    );
  },
),

テスト環境だとしても本番でも差し支えない文言や画像を使いたい

テスト環境だと「テストテストテスト」とか「あああああああああああああああああああああ」みたいな文字をテストデータに使いがちである。画像も適当にネットで拾ったやつとか、カメラロールにあるやつとか、デスクの上でその場で撮ったりした画像を使いがちである。

でも「テスト環境だから」という理由で雑なデータは登録しない方が良い。理由は以下が挙げられる。

  • いかにも開発感が強すぎるデータを登録してしまうと、ベータ版アプリを使っている人が本番と同じ体験をすることができず、使い勝手や改善点をベータ版では気が付けない可能性がある
  • テスト環境であっても著作権上問題ない画像を使う必要がある
  • 万が一テスト環境本番環境を間違えてデータを登録してしまった時も、本番と差し支えないデータを登録することで一応のリスクヘッジとなる
  • 本番環境の話になるが、自分にしか見れないデータと思って適当な情報を登録していたら、不具合で他の人にその情報が公開されてしまうかもしれない
    • 前職で実際にあった

有名な資料として以下があるけど、これは「一見してテストとわかる文字の方が望ましい」とあるが、私はそれよりも本番に近い文字が望ましいと思う。(もちろんテストとわかるメリットもあるしサービスの特性にもよりそう)

www.infiniteloop.co.jp

少なくとも自分たちでドッグフーディングが可能なサービスに関しては本番と同等のデータを使いテスト環境を構築したいですね。

techlife.cookpad.com

iOS17 で tableView.tableHeaderView = searchController.searchBar してたらバグったので UISearchBar を使うことにした

iOS17 にしたら tableHeaderView に入れた searchController がバグって表示されるようになってしまった。

最初は hidesNavigationBarDuringPresentation を true にしたらいい感じにはなったんだけど、NavigationBar が隠れたまま次の画面に遷移すると NavigationBar が消えたままになってしまいダメだった。

解決するために試行錯誤したが、結局は UISearchController を使うのをやめて UISearchBar を使うことで解決した。

Before

final class ViewController: UIViewController {
    let searchController = UISearchController(searchResultsController: nil)

    override func viewDidLoad() {
        searchController.searchResultsUpdater = self
        searchController.obscuresBackgroundDuringPresentation = false
        searchController.hidesNavigationBarDuringPresentation = false
        searchController.searchBar.placeholder = "placeholder"
        searchController.delegate = self
        searchController.searchBar.delegate = self
        tableView.tableHeaderView = searchController.searchBar
    }

extension ViewController: UISearchResultsUpdating, UISearchControllerDelegate {
    func updateSearchResults(for searchController: UISearchController) {
        print(searchController.searchBar.text ?? "")
    }
}

extension ViewController: UISearchBarDelegate {
    func searchBar(_ searchBar: UISearchBar, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in
            print(keyword)
        }

        return true
    }
}

After

final class ViewController: UIViewController {
    let searchBar = UISearchBar()

    override func viewDidLoad() {
        searchBar.delegate = self
        searchBar.placeholder = "placeholder"
        searchBar.frame = CGRect(x:0, y:0, width:self.view.frame.width, height:56)
        tableView.sectionHeaderTopPadding = 0
        tableView.tableHeaderView?.frame = searchBar.frame
        tableView.tableHeaderView = searchBar
    }

extension ViewController: UISearchBarDelegate {
    func searchBar(_ searchBar: UISearchBar, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in
            print(searchBar.text!)
        }

        return true
    }
    
    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in
            print(searchBar.text!)
        }
    }
    
    func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
        searchBar.resignFirstResponder()
    }

    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        searchBar.resignFirstResponder()
    }

    func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
        searchBar.setShowsCancelButton(true, animated: true)
    }

    func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
        searchBar.setShowsCancelButton(false, animated: true)
    }
}

補足

DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) はこれ

mob-wasao.hatenablog.jp

おわり

iOSはNavigationBarと検索バー周りはぶっ壊れがちなので大変。
iOS13 の時も苦しんでいた。

Flutter hooks で useWebViewController() のカスタムフックを作る

Flutter hooks で useTextEditingController(); とか useScrollController(); が使えてめっちゃ便利と思っていて、これを webview_flutter などでも使いたい。

やり方は前に Flutter hooks で画面遷移のイベント変化を custom hooks でいい感じに検知する - star__hoshi's diary で作ったように Custom Hook を作る。

WebViewController useWebViewController() {
  return use(const _WebViewController());
}

class _WebViewController extends Hook<WebViewController> {
  const _WebViewController();

  @override
  _WebViewControllerState createState() => _WebViewControllerState();
}

class _WebViewControllerState
    extends HookState<WebViewController, _WebViewController> {
  final WebViewController _controller = WebViewController();

  @override
  WebViewController build(BuildContext context) {
    return _controller;
  }

  @override
  void dispose() {
    super.dispose();
  }
}

これで final controller = useWebViewController(); ができる。

Flutter 3.10.1 にしたら flutter format の代わりに dart format を使う必要がある

Flutter 3.10.1 にしたら flutter format が使えなくなってしまった

The "format" command is deprecated. Please use the "dart format" sub-command instead, which has the same command-line usage as "flutter format".

github actions で flutter format --dry-run lib/ をしていたが、これが使えなくなったので dart format に移行した。
その際、 --dry-run などのオプションもなくなってしまったので対応した。

-      - name: 'Run flutter format'
-        run: flutter format --dry-run lib/
+      - name: 'Run dart format'
+        run: dart format -o none --set-exit-if-changed $(find ./lib ./test -name "*.dart" -not \( -name "*.*freezed.dart" -o -name "*.*g.dart" \) )
  • -o none
    • output を無しにする
  • --set-exit-if-changed
    • もし変更があったら exit 1 して異常終了する
  • $(find ./lib ./test -name "*.dart" -not \( -name "*.*freezed.dart" -o -name "*.*g.dart" \) )
    • freezed, g が format で引っかかってしまったのでそれらを除外している

これを参考にした。

github.com

Flutter hooks で画面遷移のイベント変化を custom hooks でいい感じに検知する

Flutter で画面遷移の検知をするのには routeObserver.subscribe(...) したり with RouteAware する必要があって、結構めんどい。(参考: 【Flutter】RouteAwareで遷移を検知する方法 | 417.Run())

理想としては、 final appLifecycleState = useAppLifecycleState(); みたいに、 final routeAware = useRouteAwareEvent(); という形で custom hooks 形式で利用したい。

試行錯誤した結果、final routeAware = useRouteAwareEvent(ref.watch(routeObserverProvider)); で利用できるようになった。 useAppLifecycleState を参考に実装した。

実装

依存

以下に依存している。

  • go_router
  • go_router_builder
  • flutter_riverpod
  • riverpod_annotation
  • flutter_hooks
  • riverpod_generator

実装内容

まず RouteObserver をつくる。

@Riverpod(keepAlive: true)
RouteObserver routeObserver(RouteObserverRef ref) => RouteObserver();

GoRouter でそれを利用する。

@Riverpod(keepAlive: true)
GoRouter makeRouter(MakeRouterRef ref) => GoRouter(
      initialLocation: '/',
      routes: $appRoutes,
      observers: [
        FirebaseAnalyticsObserver(analytics: FirebaseAnalytics.instance),
        ref.watch(routeObserverProvider),
      ],
    );

custom hooks を実装する。

// RouteAware は abstract class なのでコールバックするだけの実装をする
class RouteAwareEvent extends RouteAware {
  VoidCallback? onDidPopNext;
  VoidCallback? onDidPop;
  VoidCallback? onDidPushNext;
  VoidCallback? onDidPush;

  @override
  void didPopNext() => onDidPopNext?.call();
  @override
  void didPop() => onDidPop?.call();
  @override
  void didPushNext() => onDidPushNext?.call();
  @override
  void didPush() => onDidPush?.call();
}

// hooks の返り値
enum RouteAwareType {
  didPush,
  didPop,
  didPopNext,
  didPushNext,
}

RouteAwareType? useRouteAwareEvent(RouteObserver routeObserver) {
  return use(_RouteAwareHook(routeObserver));
}

class _RouteAwareHook extends Hook<RouteAwareType?> {
  const _RouteAwareHook(this.routeObserver) : super();
  final RouteObserver routeObserver;

  @override
  __RouteAwareState createState() => __RouteAwareState();
}

class __RouteAwareState extends HookState<RouteAwareType?, _RouteAwareHook> {
  RouteAwareType? _state;
  final aware = RouteAwareEvent();

  @override
  void initHook() {
    super.initHook();
    aware.onDidPop = () => _state = RouteAwareType.didPop;
    aware.onDidPopNext = () => _state = RouteAwareType.didPopNext;
    aware.onDidPush = () => _state = RouteAwareType.didPush;
    aware.onDidPushNext = () => _state = RouteAwareType.didPushNext;
  }

  var firstBuild = true;
  @override
  RouteAwareType? build(BuildContext context) {
    // initHook で context を触るとエラーになるので、ここで初期化する
    // また、ここでは useEffect が使えないので、firstBuild を利用し初回のみ subscribe する
    if (firstBuild) {
      firstBuild = false;
      hook.routeObserver.subscribe(aware, ModalRoute.of(context)!);
    }
    return _state;
  }

  @override
  void dispose() {
    super.dispose();
    hook.routeObserver.unsubscribe(aware);
  }
}

Widget ではこのように利用できる。

class HogePage extends HookConsumerWidget {
  HogePage({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {

    final routeAware = useRouteAwareEvent(ref.watch(routeObserverProvider));
    useEffect(() {
      if (routeAware == RouteAwareType.didPop) {
        print('did pop');
      }
      return null;
    }, [routeAware]);

    return Scaffold();
  }
}

おわり。

【改訂新版】良いウェブサービスを支える 「利用規約」の作り方 読書感想文

利用規約、雰囲気で読んだり作ったりしているのでちゃんと学ぶか〜と思って読んでみた。めっちゃ良かった。利用規約、プライバシーポリシー、特定商取引法に基づく表示について理解が深まった。

ユーザが問題ある行動をした時「利用規約にこう定められています」と返答すると納得してくれることが多いとあり、ちゃんと利用規約を定めておくことはサービス運営にも役立つしもちろん裁判などの時にも有利になる。

利用規約なんて誰も読まないんだから適当にリンク置いときゃいいでしょ...」 って思ってたけど、防御のためにちゃんと同意を取る方式にしていく方が良さそう。ユーザが本当に同意したのかが裁判で焦点になることもあるようで、全文スクロールさせたりチェックボックスONにさせたりと、体験を損ねずに同意を取るのが良い。

利用規約、プライバシーポリシー、特定商取引法に基づく表示の雛形と解説が載せてあり、それをいい感じにつまみ食いするといい感じの利用規約などが生成できそう。

以下メモ

  • 利用規約を読んでいるのはユーザの15%という調査データがある
    • そんなに読んでる人いるの!!!
  • 他社の利用規約を真似るのは全然良い
    • 他社の利用規約を読むと将来的に問題になることを発見できたりしそう
  • プライバシーポリシーは個人情報とアクティビティログなどのパーソナルデータの取り扱いを定めた文書
    • 個人情報の扱いを明文化しておくことでユーザに安心感を与えることもできる
  • 画像やコンテンツの取り扱いなどは運営側を有利にしすぎると炎上する
  • 利用規約を定める際は将来を見据えたり包括的な内容にしておくのが良いが、範囲が広すぎると効力を失うのでやりすぎないこと
  • 利用規約はわかりやすく書くよりも正しく書くことを意識し、FAQなどでわかりやすく解説するのが良い
  • 電子メール広告は同意しないと送ってはいけない
    • 全然同意した覚えないのに広告メールくることがよくあるが、利用規約のどっかに書かれてるんだろうけど同意を取れているかは怪しいので問題ありそう
  • 海外のユーザと訴訟になると法律や裁判所の場所が大変なので、日本法で東京裁判所と記載しておく

Lean UX 第3版 ―アジャイルなチームによるプロダクト開発 読書感想文

社で推薦図書みたいなのになってたので読んでみた。Kindle はないけど、公式で epub が買える。 O'Reilly Japan - Lean UX 第3版

感想としては、リーンスタートアップ の内容や エンジニアリング組織論への招待 と似ているなと思った。不確実なところを確実にしていくことが大事そう。

アプトプットではなくアウトカムが大事というのが良い話と思って、エンジニアとかは実装してたら評価されたりしがちだけど、そうじゃなくてどれだけユーザに価値が届いたかが重要で、そこは間違えない様にしたい。でも自分としては実装よりのエンジニアなので自分で案出しとかは得意じゃなくて、出てきた案に対しコメントするのが得意なので、アウトプット寄りになってしまいがち。
無駄を取り除く話も好きで、「成果を向上させることに貢献しないものは全て無駄なので取り除くべき」とあり、この考え方はすごい好き。それ意味ある?みたいなのやりがちなのでちゃんと書かれていて嬉しい。

Lean UX 的には、組織の役割にとらわれるのではなく職能で担当が決めるのではなくメンバー誰もが複数の領域で貢献できると良いとある。それは納得なのだがジョブ型雇用とは乖離がありそうで、海外だとジョブ型が主流っぽいと聞いていたけどそれはどうなんだろう。

また、対面で顔を合わせてコミュニケーション取るのが大事と書かれていて、私は出社拒否勢なので心が痛い。

本を読んで新しい発見はあまりなかったけど、わりかし忘れがちな大事なことが書かれていてよかった。

flutterfire configure するとできる firebase_app_id_file.json

flutterfire configure すると ios/firebase_app_id_file.json が生成される。

Crashlytics で利用される

firebase_app_id_file で grep すると Xcode の Run Script のみ検出される。ここだけで使われているようだ。

"$PODS_ROOT/FirebaseCrashlytics/upload-symbols" --flutter-project "$PROJECT_DIR/firebase_app_id_file.json" 

以下にも解説が書かれている。

zenn.dev

stackoverflow でメンテナという人が回答しているが、この json はなくても良いようにしたいっぽい。過渡期でこの json はそのうち消えるかもしれない。

stackoverflow.com

Dev, Stg, Prod の3環境で firebase_app_id_file.json を利用したい

zenn.dev

この記事を参考に環境を作成しているが、この記事では書かれていない firebase_app_id_file.json も環境別に分ける。

flavor を dev, stg, prod で切っている場合、jsonファイルを flavor のパスに置く。

Run Script はこのようにする

"$PODS_ROOT/FirebaseCrashlytics/upload-symbols" --flutter-project "$PROJECT_DIR/$flavor/firebase_app_id_file.json" 

これで環境別に firebase_app_id_file を切り替えることができる。

Flutter 開発で Android Studio から VSCode に移行した

今まで Flutter 開発は Android Studio でやっていたけど、VSCode に移行した。

理由は AI の波が来ていて VSCode がその波に一番のりやすそうだったから。GitHub Copilot とかはライセンスの問題はあれど今後解決されていくだろうし、基本的に VSCode に寄せていくことにした。

VSCode の Flutter 連携もかなり良くできていて、特に困ったことはない。むしろ末尾のセミコロンの自動挿入とかができて大変便利。

やったこと

Plugins

この2つのプラグインを入れる。

.vscode/extensions.json はこの状態。

{
  "recommendations": [
    "dart-code.flutter",
    "dart-code.dart-code"
  ]
}

settings.json

ユーザ設定の settings.json に以下を追記した。末尾セミコロン、import と warning の自動修正を保存時にしてくれる。

  "[dart]": {
    "editor.formatOnSave": true,
    "editor.codeActionsOnSave": {
      "quickfix.insertSemicolon": true,
      "source.organizeImports": true,
      "source.fixAll": true,
    }
  }

.vscode/settings.json

fvm を使っているので以下を追記。

{
  "dart.flutterSdkPath": ".fvm/flutter_sdk",
  "search.exclude": {
      "**/.fvm": true
  },
  "files.watcherExclude": {
      "**/.fvm": true
  },
}

アプリの実行

⌘ + Shift + Dデバッグタブへ移動するので、そこで実行ができる。実行ボタンをポチポチすると 構成の追加 が出るので、そうすると .vscode/launch.json が作成されるので、そこで実行コマンドをいじる。

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "nimotsuireta",
      "request": "launch",
      "type": "dart"
    },
    {
      "name": "nimotsuireta (profile mode)",
      "request": "launch",
      "type": "dart",
      "flutterMode": "profile"
    },
    {
      "name": "nimotsuireta (release mode)",
      "request": "launch",
      "type": "dart",
      "flutterMode": "release"
    }
  ]
}

これで configuration を切り替えて実行ができるようになる。

実行端末の指定

画面下のバーに端末が表示されるので、これをポチポチして実行端末を変更することができる。

おわり

VSCode 移行して半日くらいは戸惑いがあったけど、半日経ったらこっちの方がええやんってなった。

VSCode 最高!