以前このブログでは、Flutter入門でiPhoneとAndroidの2つに、Swift・Kotlin・Flutterの横並び比較で3つの作り方を並べました。どちらも舞台はスマホ2種類です。今回はその先へ行きます。同じ1つのコードを、iPhone・Android・Web・Mac・Windows の「5つの器」に出してみました😊
・なぜ同じコードが5つの器で動くのか(Flutterは自前で画面を描く)を図解
・ビルド時間・成果物サイズを5つで実測して比較
・OSごとに違う「通信の許可」の作法(macOS・Android・Windowsで全部違いました)
・「iOSはMacでしか、WindowsアプリはWindowsでしかビルドできない」という現実
・同じコードでも戻るボタンの形が変わるなど、細部のOS差
環境:Flutter 3.44.2 / Dart 3.12.2。Mac(Xcode 26.5・Android SDK 36)で iOS シミュレータ・Android エミュレータ・Web・macOS の4つを、Windows 11+Visual Studio 2022 の実機で Windows 版を確認しました。スマホの実機は使っていません。
作るもの:ブログのRSSを読むだけの小さなリーダー
題材はこのブログのRSSを取ってきて、記事一覧を出し、タップすると詳細を表示するだけのリーダーです。カウンターアプリのような「動くだけ」の題材にしないのは、通信・一覧・画面遷移という“普通のアプリの形”を通ると、器ごとの差が正直に出てくるからです。
作りは意図的に軽くしています。外部パッケージは通信用の http ひとつだけ。これは純粋なDart製で、OSごとのネイティブ部品を持ち込まないため、ビルドの準備がどの器でも同じで済みます。

なぜ同じコードが5つで動くのか(図解)
ここが今回いちばん面白いところです。「1つのコードで両OS」と聞くと、iOSのボタンとAndroidのボタンを、書いたコードから使い分けているように思えます。でもFlutterはそうではありません。
FlutterはOSの部品(ボタンや一覧)を借りません。真っ白なキャンバスを1枚もらって、ボタンも文字も、全部自分で描いています。だから器が変わっても絵が同じになるのです。
器ごとの土台はこうなっています。スマホとデスクトップでは、Dartのコードをあらかじめ機械語に翻訳(AOTコンパイル)して、描画エンジンと一緒にアプリへ詰めます。Webだけは事情が違い、DartはJavaScriptに変換され、描画エンジンはブラウザが読み込むWebAssemblyとして届きます。だからWeb版だけ「読み込みが重い」という話が出てきます(後で実測します)。
実装:1画面ぶんのコード
RSSを取ってきて一覧にするところが中心です。まず「今どの器で動いているか」を自分で知る部分。Webではdart:ioが使えないので、kIsWebを先に見るのがコツです。
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
String currentPlatform() {
if (kIsWeb) return 'Web'; // ★ Webは先に判定する(dart:ioが無い)
if (Platform.isIOS) return 'iOS';
if (Platform.isAndroid) return 'Android';
if (Platform.isMacOS) return 'macOS';
if (Platform.isWindows) return 'Windows';
return 'unknown';
}
RSSの取得と、記事の切り出しです。ここも外部パッケージを増やさず、正規表現で <item> を拾っています。utf8.decode(res.bodyBytes) にしている理由は、後の「つまずき③」で説明します。
Future<List<Article>> fetchArticles() async {
final res = await http.get(Uri.parse('https://eight-engineering-blog.com/rss.xml'));
if (res.statusCode != 200) throw Exception('RSSの取得に失敗しました (${res.statusCode})');
// ★ res.body ではなく bodyBytes を自分でUTF-8として解く(文字化け対策)
final xml = utf8.decode(res.bodyBytes);
final items = RegExp(r'<item>(.*?)</item>', dotAll: true).allMatches(xml);
return [
for (final m in items)
Article(
_unescape(_tag(m.group(1)!, 'title')),
_tag(m.group(1)!, 'link'),
_tag(m.group(1)!, 'pubDate').split(' ').take(4).join(' '),
_unescape(_tag(m.group(1)!, 'description')),
),
];
}
画面は、取得が終わるまでぐるぐるを出し、終わったら一覧を出すだけ。FutureBuilder がその出し分けを引き受けてくれます。
FutureBuilder<List<Article>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator()); // 読み込み中
}
if (snap.hasError) return Center(child: Text('${snap.error}')); // 失敗
final list = snap.data!;
return ListView.separated( // 成功
itemCount: list.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, i) => ListTile(
leading: CircleAvatar(child: Text('${i + 1}')),
title: Text(list[i].title, maxLines: 2, overflow: TextOverflow.ellipsis),
subtitle: Text(list[i].date),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ArticleDetailPage(article: list[i])),
),
),
);
},
)
これで全部です。OSの名前で分岐する箇所は「今どの器か」を表示する1か所だけで、画面の作り自体には一切登場しません。
5つの器に出す
器を選ぶのはコマンド1つです。プロジェクトを作るときに、出したい器を並べておきます。
# 5つの器ぶんの受け皿を作る
flutter create --platforms=ios,android,web,macos,windows blog_reader
# あとは器ごとにビルドするだけ(中身のコードは1つ)
flutter build ios --simulator # iPhone(シミュレータ用)
flutter build apk --release # Android
flutter build web --release # Web
flutter build macos --release # Mac
flutter build windows --release # Windows(★Windows実機でのみ実行できる)
——と、ここまでは教科書どおり。実際に動かしてみると、3回つまずきました😳 しかもそのうち2つは、「1つのコードで動く」という建前のすぐ裏側にありました。
つまずき①:macOSがネットに出られない
最初にmacOSアプリとしてビルドし、起動した瞬間これです。

ネットワークが死んでいるわけではありません。macOSアプリは既定でサンドボックス(砂場)の中で動き、外に出る権限を明示しないと通信できないのです。名前解決すら通らないので、原因がネットワーク側に見えてしまうのが厄介でした。
直し方は、権限ファイルに「外に通信していいですよ」の一行を足すだけです。しかもFlutterの雛形では、開発用にも本番用にも最初から入っていません。
<!-- macos/Runner/Release.entitlements と DebugProfile.entitlements の両方に -->
<key>com.apple.security.network.client</key>
<true/>
これで一覧が出ました。……が、今度は日本語が全部文字化けしていました(つまずき③へ続きます😅)。
つまずき②:Androidのリリース版も通信できない
Androidでも、まったく同じ顔をしたエラーに出会いました。

種明かしはこうです。Flutterの雛形は、インターネット権限を「開発用のマニフェスト」にしか書いていません。ホットリロードのためにツールが通信する必要があるからです。つまり開発中は動くのに、リリース版だけ通信できない。
<!-- android/app/src/debug/AndroidManifest.xml(雛形。開発用にだけ入っている) -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- ★ android/app/src/main/AndroidManifest.xml に自分で足す(本番用) -->
<uses-permission android:name="android.permission.INTERNET"/>
ここまでで気づくのは、「1つのコードで動く」のはアプリの中身だけだということです。OSに対する“お願いごと”(通信していいか)は、器ごとに別々の作法で書く必要があります。しかもその作法は、OSごとに置き場所も書き方もまったく違います。
つまずき③:日本語が全部文字化け
通信が通ったあと、記事タイトルが「ãã¼ã«ã«LLM…」のような姿で並びました😳 アプリのフォントを疑いましたが、原因は受け取った文字の解き方でした。
このブログのRSSは Content-Type: application/xml を返しますが、文字コードの指定(charset)が付いていません。すると http パッケージは規約どおりLatin-1(西欧の文字コード)として解釈してしまいます。日本語は当然そこに無いので、化けます。
// ✗ charsetが無いと Latin-1 として解かれる
final xml = res.body;
// ◯ バイト列を受け取り、自分でUTF-8として解く
final xml = utf8.decode(res.bodyBytes);
これは器の問題ではなく、5つ全部で等しく起きる種類のつまずきです。逆に言えば、1か所直せば5つ全部が直るのがクロスプラットフォームの気持ちよさでもあります👍
Windows版はWindowsでしか作れない
ここで壁にぶつかります。MacではWindows版のアプリを作れません。逆にWindowsではiPhone版を作れません。Flutterは「そのOSのアプリは、そのOSの上でしかビルドできない」ためです。コードは1本でも、器を成型する工場はOSごとに別々なんですね。
そこで最後の1つは、いつものWindows機(RTX 3060 / Windows 11)にお願いしました。必要なのは Flutter と、Visual Studio 2022 の「C++によるデスクトップ開発」。Windowsアプリの土台がC++で出来ているためです。

そして面白いことに、Windowsでは通信の許可を何も書かずに繋がりました。ファイアウォールの確認すら出ません。まとめるとこうです。
| 器 | 外に通信するために必要だったこと |
|---|---|
| macOS | 権限ファイルに network.client を追加(サンドボックス) |
| Android | 本番用マニフェストに INTERNET 権限を追加 |
| Windows | 何も要らない(既定で通信できる) |
| iOS | 何も要らない(httpsのため。平文httpは別途設定が要る) |
| Web | 何も要らない(ただし相手サーバーがCORSを許可していること) |
5つとも作法が違う、というのが正直な結論です。ちなみにWeb版が動いたのは、このブログを置いているCloudflare Pagesが access-control-allow-origin: * を返していたおかげでした。返さないサーバーが相手なら、Web版だけが動かないことになります。
実測:ビルド時間と成果物サイズ
条件を揃えるため、flutter clean でキャッシュを消してから1回目・続けて2回目を測りました(Mac=M系ノート、Windows=Ryzen 7 7840HS)。
| 器 | 1回目 | 2回目 | 成果物 |
|---|---|---|---|
| Web | 15.5秒 | 13.7秒 | 40.4MB 実転送は約8.9MB |
| Android | 19.5秒 | 2.9秒 | 46.1MB リリースAPK |
| macOS | 25.9秒 | 10.2秒 | 39.7MB |
| Windows | 52.5秒 | 8.1秒 | 28.0MB |
| iOS | 18.6秒 | 7.1秒 | 135.3MB ※シミュレータ用の開発ビルド |
※iOSだけは条件が違います。シミュレータ用は開発ビルド(デバッグ情報を含む)しか作れないため、他の器のリリース版と大きさを直接くらべることはできません。参考値として載せています。
数字から見えたことを3つ。
- Windowsの1回目が最も遅い(52.5秒)のは、C++のコンパイラ(MSVC)が土台を組み立てるためです。ただし2回目は8.1秒まで縮み、開発中の待ち時間はむしろ快適でした。
- Web版の「40.4MB」は誤解を招く数字です。実際にブラウザが落とすのは約8.9MB。内訳は描画エンジン5.76MB+アプリ本体2.25MB+日本語フォント0.74MBでした。しかも描画エンジンはGoogleのサーバー(gstatic)から取りに来ます。
- 日本語フォントが28ファイルもダウンロードされていました。Flutter Webは自分で文字を描くので、端末に入っているフォントを使えず、Web用のフォントを取りに行くのです。「Webだけ重い」の正体はこれでした。

もうひとつ、Windows版で分かった配布の勘どころ。できあがった blog_reader.exe はたったの0.09MBで、これは単なる起点にすぎません。実体は flutter_windows.dll(20.3MB)と data/ フォルダ(7.65MB)にあります。exeだけ配ってもまったく動きません。「1ファイル配ればいい」と思っていると必ず引っかかる所です😅
同じコードでも変わるところ
「絵は同じ」と書きましたが、Flutterは細部だけはOSの作法に寄せてきます。いちばん分かりやすいのが戻るボタンです。

AppBar なのに、iOSは「<」でタイトルが中央、Android・Windowsは「←」でタイトルが左。Flutterが自動で出し分けているほかにも、こんな差がありました。
- スクロール:スマホは指を離しても滑る「慣性スクロール」、デスクトップはマウスホイールの素直な動き
- フォント:Windowsでは Yu Gothic UI 系、Macではヒラギノ系と、そのOSの日本語フォントに置き換わる(Webだけは前述のとおり自前で取りに行く)
- ウィンドウ:デスクトップ版にはOSネイティブのタイトルバーが付き、最大化・リサイズが普通に効く
1つ、作り手として学んだこと。最初は「この器:◯◯」の表示を画面上部のバー(AppBar)の右端に置いていたのですが、Webだけ文字が見切れました。器ごとにフォントの幅が違うためです。表示を本文の先頭に移したら、5つとも同じ見え方になりました。「同じ絵が出る」を過信せず、狭い器で確かめるのは、やはり必要ですね。
まとめ
1つのコードを、5つの器——iPhone・Android・Web・Mac・Windows——に出せました。入門記事で書いた「1つのコードで2つのOS」は、実はもっと遠くまで届くという話です😊
- FlutterはOSの部品を借りず、自分で画面を描く。だから器が変わっても絵が同じになる
- ただしWebだけは描画の仕組みが変わり、描画エンジンとフォントを落としてから描き始める(実転送 約8.9MB)
- OSへの“お願いごと”は1コードにできない。通信の許可はmacOS・Android・Windowsで作法が全部違った
- 器を作る工場はOSごとに別々。iOSはMacでしか、WindowsアプリはWindowsでしか作れない
- ビルドは2回目以降がとても速い(Androidは2.9秒)。配布物は Windows 28.0MB/macOS 39.7MB/Android 46.1MB
「1つのコードで全部」は本当でした。ただしその一文には、「アプリの中身は」という注釈が付きます。器の縁の部分——権限、ビルドの工場、フォント——には、ちゃんとOSごとの現実があります。そこを知っていれば、Flutterの“届く範囲”はかなり広いです👍
それでは、今回はここまで。最後までありがとうございました😊