はじめに
マルチプラットフォーム開発を行うフレームワークであるFlutterといえども、
OSによって見た目や処理を切り替えたいことがあると思います。
その際によく出てくる代表的な判定方法がPlatform.isXxx
だと思います。
しかし、タイトルにもある通りテストを前提に開発を進める場合は圧倒的に
defaultTargetPlatform
を使うべきという結論に至りました。
この記事ではその理由を具体例を交えて解説していきます。
記事の対象者
- OS毎の分岐処理をどうやって実装していくべきか悩んでいる方
- OS毎の分岐処理をテストしたいのに上手くいかず悩んでいる方
- テストに関してある程度の知識を持っている方
記事を執筆時点での筆者の環境
[✓] Flutter (Channel stable, 3.22.1, on macOS 14.3.1 23D60 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 15.2)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2023.3)
[✓] VS Code (version 1.90.1)
サンプルプロジェクト
ボタンを押すと画面一番上のテキストの数字が変わります。
2つのボタン、どちらを押しても数字は変わります。
実行環境がandroidであれば1、iOSであれば2、それ以外であれば3を返します。
Gif
android
iOS
ソースコード
1. 数字を変えるメソッドの実装内容
実装は至ってシンプルです。
OSを判定し、返却する数字を変えています。
二つのメソッドの違いは今回のタイトルにあるとおり以下の違いです。
-
Platform.isXxx
で判定する -
defaultTargetPlatform == TargetPlatform.xxx
で判定する
class HomeScreenViewModel {
/// Platform.isXxxで判定する
int generateNumberForPlatform() {
if (Platform.isAndroid) {
return 1;
} else if (Platform.isIOS) {
return 2;
}
return 3;
}
/// defaultTargetPlatform == TargetPlatform.xxxで判定する
int generateNumberForDefaultPlatform() {
if (defaultTargetPlatform == TargetPlatform.android) {
return 1;
} else if (defaultTargetPlatform == TargetPlatform.iOS) {
return 2;
}
return 3;
}
}
ボタンのonPressed
でメソッドを呼び出した結果を変数に代入しています。
class HomeScreen extends StatefulWidget {
const HomeScreen({
required this.viewModel,
super.key,
});
final HomeScreenViewModel viewModel;
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int number = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'現在の値は$number',
style: const TextStyle(fontSize: 40),
),
const Gap(50),
ElevatedButton(
child: const Text('PlatformIsで判別'),
onPressed: () {
setState(() {
// 💡💡💡💡💡💡💡
final result = widget.viewModel.generateNumberForPlatform();
number = result;
});
},
),
const Gap(50),
ElevatedButton(
child: const Text('DefaultPlatformで判別'),
onPressed: () {
setState(() {
// 💡💡💡💡💡💡💡
final result =
widget.viewModel.generateNumberForDefaultPlatform();
number = result;
});
},
),
const Gap(50),
ElevatedButton(
child: const Text('リセット'),
onPressed: () {
setState(() {
number = 0;
});
},
),
],
),
),
);
}
}
Gifでも見てわかる通り、処理は両方とも同じ結果を返しています。
2. テストしてみると結果は違う
ではテストをしてみましょう。すると、明暗が分かれます。
結論、Platform.isXxx
で判定したgenerateNumberForPlatform
メソッドのテストは失敗します。
ちなみに詳しい方法は後述しますが、ここではテスト環境をandroidに偽造していました。
にもかかわらず、帰ってきた値が 3 なので、テスト環境がうまく偽装できていないことが考えられます。
2-1. テストの実行環境のOSを偽装
テスト実行環境をiOSやandroidなどそれぞれのケースに偽装したい場合はdebugDefaultTargetPlatformOverride
というグローバルなsetterを使います。
今回はテストごとにOSを変えたかったので、毎回debugDefaultTargetPlatformOverride
にOSを指定しています。
※ 通常はgroup
内のsetup
内で指定するのがいいと思います。
指定するときはTargetPlatform.xxx
という形で指定します。
そして、テストが終了するごとにtearDown
でnull
を入れてリセットします。
void main() {
final viewModel = HomeScreenViewModel();
tearDown(() {
debugDefaultTargetPlatformOverride = null;
});
group('成功するテスト:generateNumberForDefaultPlatform', () {
test('androidの場合', () {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
final result = viewModel.generateNumberForDefaultPlatform();
expect(result, 1);
});
test('iOSの場合', () {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
final result = viewModel.generateNumberForDefaultPlatform();
expect(result, 2);
});
});
group('失敗するテスト:generateNumberForPlatform', () {
test('androidの場合', () {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
final result = viewModel.generateNumberForPlatform();
expect(result, 1);
});
test('iOSの場合', () {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
final result = viewModel.generateNumberForPlatform();
expect(result, 2);
});
});
}
3. Platform.isXxx
がなぜ失敗するのか?
Platform
はdartの言語機能です。
Platform
の内部実装を見ると以下のような感じです。
final class Platform {
// 省略
@pragma("vm:platform-const")
static final operatingSystem = _Platform.operatingSystem;
// 省略
/// Whether the operating system is a version of
/// [Android]
@pragma("vm:platform-const")
static final bool isAndroid = (operatingSystem == "android");
/// Whether the operating system is a version of
/// [iOS](https://en.wikipedia.org/wiki/IOS).
@pragma("vm:platform-const")
static final bool isIOS = (operatingSystem == "ios");
// 省略
}
つまり、これは実行環境を直接読み取っています。
え、それでいいのでは?と思いますよね。
ダメなんです。
確かにエミュレーターなどにプログラムを流した場合に実行環境を読みに行ったら
iOSだったり、androidだったりします。
しかし、テストの時はどうでしょう?
私の場合はmacです。
そう、だから実行環境はmacOS
になってしまうのです。
そして最大の問題点が、
Platform
クラスにはOSをテスト時に偽装する仕組みがないのです。
4. defaultTargetPlatform
にはOSを偽装する仕組みがある
defaultTargetPlatform
はFlutterの機能です。
まずは内部実装を見ていきましょう。
@pragma('vm:platform-const-if', !kDebugMode)
TargetPlatform get defaultTargetPlatform => platform.defaultTargetPlatform;
更にこのplatform.defaultTargetPlatform
の内部実装を見ます。
/// The dart:io implementation of [platform.defaultTargetPlatform].
@pragma('vm:platform-const-if', !kDebugMode)
platform.TargetPlatform get defaultTargetPlatform {
platform.TargetPlatform? result;
if (Platform.isAndroid) {
result = platform.TargetPlatform.android;
} else if (Platform.isIOS) {
result = platform.TargetPlatform.iOS;
} else if (Platform.isFuchsia) {
result = platform.TargetPlatform.fuchsia;
} else if (Platform.isLinux) {
result = platform.TargetPlatform.linux;
} else if (Platform.isMacOS) {
result = platform.TargetPlatform.macOS;
} else if (Platform.isWindows) {
result = platform.TargetPlatform.windows;
}
assert(() {
if (Platform.environment.containsKey('FLUTTER_TEST')) {
result = platform.TargetPlatform.android;
}
return true;
}());
if (kDebugMode && platform.debugDefaultTargetPlatformOverride != null) {
result = platform.debugDefaultTargetPlatformOverride;
}
if (result == null) {
throw FlutterError(
'Unknown platform.\n'
'${Platform.operatingSystem} was not recognized as a target platform. '
'Consider updating the list of TargetPlatforms to include this platform.',
);
}
return result!;
}
最初はあれっと思いました。
Platform.isIOS
とかPlatform.isAndroid
で判定してるじゃん!と。
しかしよく見るとわかってきます。
テストを実行する場合だったとして、順番に見ていきましょう。
1. 現在の実行環境のプラットフォームを受け取るための変数を定義
@pragma('vm:platform-const-if', !kDebugMode)
platform.TargetPlatform get defaultTargetPlatform {
platform.TargetPlatform? result; // <= 💡 受け取る変数
2. if(Platform.isXxx)
で該当する実行環境のプラットフォームを探り、変数result
にTargetPlatform.xxx
の形で代入
ここではテストを実行している環境なので、私の場合で置き換えるとPlatform.isMacOS
に該当し、
result = platform.TargetPlatform.macOS;
が実行されます。
if (Platform.isAndroid) {
result = platform.TargetPlatform.android;
} else if (Platform.isIOS) {
result = platform.TargetPlatform.iOS;
} else if (Platform.isFuchsia) {
result = platform.TargetPlatform.fuchsia;
} else if (Platform.isLinux) {
result = platform.TargetPlatform.linux;
} else if (Platform.isMacOS) {
result = platform.TargetPlatform.macOS;
} else if (Platform.isWindows) {
result = platform.TargetPlatform.windows;
}
ここで注意なのがreturn result
ではないということです。
つまり処理はまだ続きます。
3. テスト中かどうかを確認
assert は、Dart言語のデバッグ用ステートメントで、条件が true であることを確認します。
条件が false の場合、実行を停止し、エラーをスローします。
主にデバッグ目的で使用され、リリースビルドでは無視されます。
そしてその中を見るとif節でテストの実行中かを確認しています。
もし、テストの実行中であれば
result = platform.TargetPlatform.android;
つまり、TargetPlatform
をandroid
に書き換えます。
assert(() {
if (Platform.environment.containsKey('FLUTTER_TEST')) {
result = platform.TargetPlatform.android;
}
return true;
}());
4. debugDefaultTargetPlatformOverrideで偽装する
ここは読んでそのままです。
もし、デバックモードかつdebugDefaultTargetPlatformOverride
に何かしらの値、
つまりTargetPlatform.xxx
の値が入っているのならそれをresult
に代入します。
例えばテストコード内で
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
と書いてある部分があればそれを代入します。
if (kDebugMode && platform.debugDefaultTargetPlatformOverride != null) {
result = platform.debugDefaultTargetPlatformOverride;
}
5. エラーがなければ最終的なresult
を返す
あとはここまできて何も問題なければ最終的な値、つまり今回でいけばTargetPlatform.ios
を返却します。
if (result == null) {
throw FlutterError(
'Unknown platform.\n'
'${Platform.operatingSystem} was not recognized as a target platform. '
'Consider updating the list of TargetPlatforms to include this platform.',
);
}
return result!;
defaultTargetPlatform
のまとめ
macOSでテスト中で、テスト環境をiOSで偽装する場合の流れ
- プラットフォームを受け取る変数を定義
-
if(Platform.isXxx)
で該当する実行環境のプラットフォームを取得(macOS) - テスト中かどうかを確認し、テスト中なので一旦
TargetPlatform.android
に上書き(android) - debugDefaultTargetPlatformOverrideによって
TargetPlatform.ios
に上書き(iOS) - エラーがないので、TargetPlatform.iosを返却(iOS)
-
defaultTargetPlatform
がTargetPlatform.ios
になる
低レイヤーであるdartの機能、Platform
で実行環境を読み取ります。
そのPlatform
をラップしたものがdefaultTargetPlatform
であり、
内部でテスト中は偽装できる機能が備わっているのです。
2024/6/22追記: test()
の引数、testOn
について
この記事を投稿した後に、test()
の引数、testOn
というものがあると知りました。
引数に文字列ではありますが、OSを指定するのでもしやと思ったのですが残念ながら
実行環境の偽装ではありませんでした。
これは実行環境がxxxOSだったらこのテストを実行するというものでした。
私としてはどんな用途で使うのかわかりません😇
group('testOnでテスト', () {
// testOn引数は結局のところテストの実行環境が引数だったら実行されるということ
// つまりPCがmacだったらmacOSでないと動かない
setUp(() {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
});
test(
'mac-osでテストするけど、'
'TargetPlatformは偽装できないので失敗',
testOn: 'mac-os', () {
final result = viewModel.generateNumberForPlatform();
expect(result, 2);
});
test(
'ioSでテストするけど、TargetPlatformが偽装されてるわけではないので、'
'そもそも動かないテスト',
testOn: 'ios', () {
final result = viewModel.generateNumberForPlatform();
expect(result, 2);
});
});
終わりに
いかがでしたでしょうか?
Platform.isXxx
とdefaultTargetPlatform
の違いついて理解いただけたでしょうか。
テストを前提とした開発を進める上でdefaultTargetPlatform
を利用することで、より信頼性の高いテストが実現できることをお伝えできたと思います。
動かすだけであればどちらを使っても問題ないものの、普段からdefaultTargetPlatform
を使用を癖づけておけば、いざテストを書くというときにつまづかなくて良くなります。
この記事に関するご意見、ご指摘がございましたらぜひコメントにてお知らせください。
この記事が皆様のお役に立てれば幸いです。
参考記事