Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails以外の開発一般

Flutter: window.devicePixelRatioを上書きしてUIを巨大化する

突然ですが、通常アプリを作成するとUIはこのぐらいの大きさかと思いますが

このように全体を大きくしたくなったことはありませんでしょうか。

これは1つ1つの文字やボタンの大きさを丹念に設定していったわけではなく、昨今のWebブラウザに搭載されているズーム機能

と似たような形で、Flutterの描画全体を大きくする事で実現しています。

今回はこれのやり方をご紹介致します。

注意

本記事で取り上げた内容はおそらくFlutter SDKの本来想定された使い方の範囲を超えており、実装に際して使用した手段もプロダクションコードに採用すべきものではありません。

あくまでFlutterの特性上こういうことも出来る、という一例としてお楽しみ下さい。

環境

執筆時点のstableを使います。

$ flutter --version
Flutter 1.20.3 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 216dee60c0 (5 days ago) • 2020-09-01 12:24:47 -0700
Engine • revision d1bc06f032
Tools • Dart 2.9.2

Flutterの描画と高解像度ディスプレイの取り扱い

最初にFlutterの描画と高解像度ディスプレイの取り扱いについて確認しておきます。

Flutterアプリケーション全体はengineの一部として実装された Window に描画されます。

その際、高解像度ディスプレイへの対応はWebのCSS pixel/Androidのdp/iOSのpt...のような論理的なピクセルの値と、物理解像度との比(devicePixelRatio)を扱うことでなされています。

Flutterの対応する各プラットフォームでは、プラットフォームごとのAPIを用いてdevicePixelRatioを取得します。

例えばiOSでは [UIScreen mainScreen].scale を用いて取得した値を使い

Androidでは context.getResources().getDisplayMetrics().density から取得します。

こうして得た window.devicePixelRatio はFlutter内の様々なところで使われ、高解像度ディスプレイを考慮しつつウィジェットや画面が描画されます。

※ なお、論理的なピクセルの値と物理解像度との比というのは、iOSのUIKitやSwiftUI、android.widget等のプラットフォームごとのSDKの持つUIツールキットで作成するいわゆる「ネイティブUI」でも当然考慮された上でレイアウトや描画が行われ、「高解像度ディスプレイ上だとボタンが小さくなってしまう」ような事にならないようになっています。

この Window のインスタンスは、Flutter開発者には main() の中身でおなじみの runApp() の中で行われたり、必要に応じて明示的に呼ぶ WidgetsFlutterBinding.ensureInitialized() 等で登場する WidgetsFlutterBinding にmixinされている WidgetsBinding から取得するようになっています。

There is a single Window instance in the system, which you can obtain from WidgetsBinding.instance.window.
Window class - dart:ui library - Dart APIより


システム内には単一のWindowインスタンスが存在し、WidgetsBinding.instance.windowから取得することができます。
同引用部のDeepL翻訳

devicePixelRatioを上書き

さて、window.devicePixelRatioについて確認しましたがこれは逆に言えばwindowから先、例えばMaterialのウィジェット等はプラットフォームごとのネイティブAPIから得る「物理解像度との比」の値を直接は見ず、window.devicePixelRatioを拠り所としていることになります。

プラットフォームごとのdevicePixelRatio取得に使われるAPIやその値、例えば [UIScreen mainScreen].scalegetDisplayMetrics().density は一介のサードパーティ製アプリから干渉できるようなものではなく、プラットフォームの「ネイティブUI」のレイアウトや描画について影響を及ぼせたりはしない(はず...私はそういう事をする方法を知らないです)ですが、間に1枚Flutter側の実装である window を通るFlutterでは

ここさえちょろまかせばdevicePixelRatioをほぼ上書き出来るのでは?

と思えます。「ネイティブUI」ではなく独自にUIウィジェットを再実装しているFlutterであれば「物理解像度との比」に実際と違う値を使う事でUIの描画に影響を及ぼし、アプリ全体を拡大/縮小出来そうです。

※ 記事冒頭で触れた「おそらくFlutter SDKの本来想定された使い方の範囲を超えたもの」になります。


ではdevicePixelRatioをちょろまかせる「独自window」をどう用意するか。コードを改変して独自にビルドしたengineを使うというのもの1つの手かとは思いますがちょっと大変です。

そこで、windowWidgetsBinding.instance.window から取得する...のであれば、アプリに「独自window」が取れる WidgetsBinding を使わせられないか、という発想でやってみます。

まず、Flutterアプリはよくこのように始まりますが

void main() => runApp(MyApp());

runApp() の中身を参考にしながら runCustomApp() を作り、それを使うようにします。

void runCustomApp(Widget app) {
  WidgetsFlutterBinding.ensureInitialized()
    // Timer.run()を用いて非同期にattachRootWidget()を実行する
    // scheduleAttachRootWidget()はprotecedで、
    // 本来はscheduleAttachRootWidget()と同等の処理を自作すべきだが
    // 同期的にattachRootWidget()でも一応アプリは起動するのでそちらでお茶を濁す
    //
    // ただし場合によってscheduleAttachRootWidget()導入
    // https://github.com/flutter/flutter/pull/39079
    // のきっかけとなったこの問題を踏むだろう
    // https://github.com/flutter/flutter/issues/31195
    ..attachRootWidget(app)
    ..scheduleWarmUpFrame();
}

void main() => runCustomApp(MyApp());

これでアプリを起動(rootWidgetを WidgetsFlutterBinding にattach)する場所に手を入れられるようになったので、「独自window」を持った WidgetsFlutterBinding であるCustomWidgetsBinding 作りにとりかかります。

以下のようにして、このアプリの WidgetsFlutterBinding.instanceCustomWidgetsBinding のインスタンスになるようにします。

class CustomWidgetsBinding extends WidgetsFlutterBinding {
  static CustomWidgetsBinding ensureInitialized() {
    if (WidgetsBinding.instance == null) {
      CustomWidgetsBinding();
    }
    return WidgetsBinding.instance as CustomWidgetsBinding;
  }
}

void runCustomApp(Widget app) {
  CustomWidgetsBinding.ensureInitialized()
  〜略〜
}

そして CustomWidgetsBinding から window を取得しようとすると「devicePixelRatioをちょろまかされた独自window」が得られるようにすれば良さそうです、ということで独自window作りにとりかかり、

import 'dart:ui' as ui;

class CustomWindow extends ui.Window

...と始めてしばらくすると Window がうまくサブクラスを作れない事に気づきます。

独自window作りに暗雲が立ち込めてきました、どうしましょう 🤔

DartのImplicit interfaces

ところでDartには Implicit interfaces という仕様があります。

詳しくはggって頂く事としまして、要するに事前にインターフェースが定義されていないクラスを使っている場所でも

辻褄さえあっていれば別のクラスをねじ込める

ということになります。 Window はインターフェースが定義されているわけではありませんが

  • Window のインターフェースを全て実装していて
  • devicePixelRatioについて任意の値を返すことができ
  • 他の値についてはホンモノの Window から取った値を返す

ようなものを作れば良さそうです...が手間がかかりそうですね...

flutter_test/lib/src/window.dart

ここで、FlutterのSDKに flutter_test/lib/src/window.dart というものが存在する事に気づきます。

これはFlutter SDKのユニットテストのために用意されたコードですが、ここに実装されている TestWindow

がまさに丁度

  • Window のインターフェースを全て実装していて
  • devicePixelRatio...に限らず様々な値を任意に設定出来て、
  • 他の値については内部でホンモノの Window を保持しておけるようになっていてそこから値やインスタンスを返せる

という、やりたかったことを実装したものになっています。そこで...

ユニットテスト用のクラスをアプリの実装に使うという暴挙 に出ます。

※ 記事冒頭で触れた「プロダクションコードに採用すべきものではない手段」です。

// 普通はこんなことをしてはいけません
import 'package:flutter_test/flutter_test.dart' show TestWindow;

class CustomWidgetsBinding extends WidgetsFlutterBinding {
  static CustomWidgetsBinding ensureInitialized() {
    if (WidgetsBinding.instance == null) {
      CustomWidgetsBinding();
    }
    return WidgetsBinding.instance as CustomWidgetsBinding;
  }

  // TestWindow を保持しておくところ
  TestWindow _customWindow;

  @override
  TestWindow get window {
    // windowを要求されたらTestWindowを(無ければその場で作ってキープしつつ)返す
    return _customWindow ??= TestWindow(window: ui.window);
  }
}

最後に、 TestWindow の「 〜TestValue に値を書き込むと上書きできる」機能を使ってdevicePixelRatioの値を上書きします。

今回はアプリ本体の起動前に値を設定することとして、main内でまず明示的に CustomWidgetsBinding.ensureInitialized() を呼び出し、 WidgetsBinding.instance を準備して上書きを行ってからアプリ本体を起動する事にします。

void main() {
  CustomWidgetsBinding.ensureInitialized();

  final binding = WidgetsBinding.instance as CustomWidgetsBinding;
  final originalDpr = binding.window.devicePixelRatio;
  binding.window.devicePixelRatioTestValue = originalDpr * 2;

  runCustomApp(MyApp());
}

全てが2倍になりました 😏

なお、この後さらに実装していけばアプリの動作中にdevicePixelRatioを変更できるスライダーを作ったりも出来ます。

おわりに

以上、Flutterはその気になればこんなことも出来るという例をご覧頂きました。

ちなみに何故こんな記事を書くネタがあったのか、こんなことをやった事があるかのと申しますと、私はFlutterに関する情報収集や練習を兼ねて自分用のRSSリーダを作って使っているのですが寝起きや調子が悪いと小さい文字が見えづらくなるという...これはもしかしてもうトシなのか...という個人的事情に際し、ただ文字を大きくするのではなくWebブラウザのズームのような事は出来ないか?と思った事がきっかけです。

今後も自身の調子に合わせてUIと文字を拡大しながらFlutterの動向を追いかけていきたいと思います。



CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。