Puppeteer を Docker で動かして開発環境も Dev Container にする

Puppeteer の利用には Chrome を入れる必要があり、そのへんを手動で環境構築するのは大変そうなので Docker でポンして開発環境も vscode Dev Container でできるようにする。

Docker は以下の本で学んだ。

本記事の GitHub レポジトリ。

https://github.com/starhoshi/puppeteer-dockergithub.com

必要なソフトウェア

Docker Desktop

docs.docker.com

Docker が利用できる環境が必要。Mac の場合は Docker Desktop をインストールする。
インストール後 Use Rosetta for x86/amd64 emulation on Apple Silicon を有効にする。

(Rosetta 2 を有効にしないとうまく動作せず、そこにたどり着くまでが大変だった https://github.com/puppeteer/puppeteer/issues/10855 )

Rosetta 2

Rosetta 2 を有効にする。

$ softwareupdate --install-rosetta --agree-to-license

vscode

Dev Container の利用に必要。

code.visualstudio.com

プロジェクトの作成

TypeScript で Puppeteer を実行するプロジェクトを作成する。

この GitHub にサンプルをあげておきました https://github.com/starhoshi/puppeteer-docker

Puppeteer を実行するコードを作成

index.ts に puppeteer を使うシンプルなコードを用意する。

import puppeteer from "puppeteer";

const main = async () => {
  const browser = await puppeteer.launch({ headless: "new" });
  const page = await browser.newPage();
  await page.goto("https://example.com", { waitUntil: "networkidle0" });
  console.log(`title: ${await page.title()}`);
  await browser.close();
};

main();

とりあえずこの index.ts を実行すると title: Example Domain が表示される。

$ npm install
...

$ npm run start

> puppeteer-docker@1.0.0 start
> npx ts-node index.ts

title: Example Domain

Docker を準備する

Dockerfile

Puppeteer 公式で Docker image を用意してくれているのでこれを利用すると便利 Docker | Puppeteer. 開発環境では vim を使いたいので本番を継承した dev も用意する。

FROM ghcr.io/puppeteer/puppeteer:21.7.0 as build
WORKDIR /app
USER root
COPY . .
RUN chown -Rh pptruser:pptruser /app
USER pptruser
RUN npm install

FROM build as dev
USER root
RUN apt-get update \
  && apt-get install -y vim
USER pptruser

docker-compose.yml

普通に Docker から実行しても良いが、引数をまとめるために docker compose を用意する。

version: '3.8'
services:
  app:
    build:
      context: .
      target: build
    platform: linux/amd64
    cap_add:
      - SYS_ADMIN
    command: >
      npm run start

(Docker 初心者なので npm install するタイミングは Dockerfile の時点でやった方がいいのか、 docker-compose.yml の command でやった方がいいのかわからない)

実行する

$ docker-compose run --rm app
[+] Creating 1/0
 ✔ Network puppeteer-docker_default  Created                                                                                                          0.0s 
[+] Building 5.2s (10/10) FINISHED                                                                                                    docker:desktop-linux
 => [app internal] load .dockerignore                                                                                                                 0.0s
 => => transferring context: 2B                                                                                                                       0.0s
 => [app internal] load build definition from Dockerfile                                                                                              0.0s
 => => transferring dockerfile: 257B                                                                                                                  0.0s
 => [app internal] load metadata for ghcr.io/puppeteer/puppeteer:21.7.0                                                                               0.5s
 => [app build 1/5] FROM ghcr.io/puppeteer/puppeteer:21.7.0@sha256:035977d02b83ea0b287fcca7a2f7577a5f5f178f8b4f6b79d15ce053c7999c32                   0.0s
 => [app internal] load build context                                                                                                                 0.1s
 => => transferring context: 278.16kB                                                                                                                 0.1s
 => CACHED [app build 2/5] WORKDIR /app                                                                                                               0.0s
 => [app build 3/5] COPY . .                                                                                                                          0.5s
 => [app build 4/5] RUN chown -Rh pptruser:pptruser /app                                                                                              2.6s
 => [app build 5/5] RUN npm install                                                                                                                   1.3s
 => [app] exporting to image                                                                                                                          0.2s
 => => exporting layers                                                                                                                               0.2s
 => => writing image sha256:3282bc45bfed43cfe4a82acb7853a10812552bf88397ad4311cb331f98ee8934                                                          0.0s
 => => naming to docker.io/library/puppeteer-docker-app                                                                                               0.0s

> puppeteer-docker@1.0.0 start
> npx ts-node index.ts

title: Example Domain

これで環境構築から実行まで一発でいける、バッチとして実行しているので --rm をつけている。

Dev Container で開発環境構築

.devcontainer/devcontainer.json を作成する。target を dev にしているので開発環境のコマンドラインvim が利用できる。

{
  "name": "Puppeteer Sample",
  "build": {
    "dockerfile": "../Dockerfile",
    "target": "dev"
  }
}

これを vscode の devcontainer で開くと環境構築をセットアップしてその中で開発ができる。
devcontainer 内のコマンドラインnpm run start で動作確認もできる。

終わり!

https://github.com/starhoshi/puppeteer-docker

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 を切り替えることができる。