以前このブログでは、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ごとのネイティブ部品を持ち込まないため、ビルドの準備がどの器でも同じで済みます。

同じFlutterアプリがiOS・Android・macOS・Windows・Webの5つで同じ画面を表示している比較図
左から iOS・Android・macOS・Windows・Web。同じ1つのコードから出た5つの画面。帯の「この器:◯◯」だけが自分の居場所を教えてくれる

なぜ同じコードが5つで動くのか(図解)

ここが今回いちばん面白いところです。「1つのコードで両OS」と聞くと、iOSのボタンとAndroidのボタンを、書いたコードから使い分けているように思えます。でもFlutterはそうではありません。

FlutterはOSの部品(ボタンや一覧)を借りません。真っ白なキャンバスを1枚もらって、ボタンも文字も、全部自分で描いています。だから器が変わっても絵が同じになるのです。

ネイティブ開発(Swift / Kotlin) あなたのコード ↓ OSに「ボタンを出して」と頼む OSが用意した部品(UIKit / Jetpack) 画面 → OSごとに部品が違うので、   コードもOSごとに書き直す Flutter あなたのコード(Dart・1本) ↓ 部品は借りない Flutter自身が全部描く(描画エンジン) ボタンも文字も、線と色として自分で描く ↓ OSからは「キャンバス」だけ借りる 画面 → 器が変わっても絵は同じ。   コードは1本のまま ※ Webだけは「ブラウザの上に描く」に切り替わる
ネイティブはOSの部品を借りる。Flutterはキャンバスだけ借りて自分で描く。この違いが「1コードで5つの器」の正体

器ごとの土台はこうなっています。スマホとデスクトップでは、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アプリでRSS取得に失敗し、ホスト名の解決に失敗したというエラーが表示されている
「Failed host lookup」=サーバー名を引くことすらできていない。ネットは繋がっているのに、です

ネットワークが死んでいるわけではありません。macOSアプリは既定でサンドボックス(砂場)の中で動き、外に出る権限を明示しないと通信できないのです。名前解決すら通らないので、原因がネットワーク側に見えてしまうのが厄介でした。

直し方は、権限ファイルに「外に通信していいですよ」の一行を足すだけです。しかもFlutterの雛形では、開発用にも本番用にも最初から入っていません

<!-- macos/Runner/Release.entitlements と DebugProfile.entitlements の両方に -->
<key>com.apple.security.network.client</key>
<true/>

これで一覧が出ました。……が、今度は日本語が全部文字化けしていました(つまずき③へ続きます😅)。

つまずき②:Androidのリリース版も通信できない

Androidでも、まったく同じ顔をしたエラーに出会いました。

Androidのリリース版APKでRSS取得に失敗し、ホスト名の解決に失敗したというエラーが表示されている
リリース版APKだけが通信できない。開発中は動いていたので、よけいに悩みます

種明かしはこうです。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デスクトップアプリとして起動したブログリーダー。帯にこの器: Windowsと表示されている
Windows版。コードは1行も変えずにビルドが通り、日本語も正常。OSネイティブのタイトルバーが付くのがデスクトップらしいところ

そして面白いことに、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回目成果物
Web15.5秒13.7秒40.4MB
実転送は約8.9MB
Android19.5秒2.9秒46.1MB
リリースAPK
macOS25.9秒10.2秒39.7MB
Windows52.5秒8.1秒28.0MB
iOS18.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だけ重い」の正体はこれでした。
Chromeで動くFlutter Web版のブログリーダー。帯にこの器: Webと表示されている
Web版。見た目は他の器と変わらないが、裏では描画エンジンとフォントを落としてから描き始めている

もうひとつ、Windows版で分かった配布の勘どころ。できあがった blog_reader.exeたったの0.09MBで、これは単なる起点にすぎません。実体は flutter_windows.dll(20.3MB)と data/ フォルダ(7.65MB)にあります。exeだけ配ってもまったく動きません。「1ファイル配ればいい」と思っていると必ず引っかかる所です😅

同じコードでも変わるところ

「絵は同じ」と書きましたが、Flutterは細部だけはOSの作法に寄せてきます。いちばん分かりやすいのが戻るボタンです。

同じコードなのに戻るボタンの形が違う。iOSは山括弧、AndroidとWindowsは左矢印
同じ 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の“届く範囲”はかなり広いです👍

それでは、今回はここまで。最後までありがとうございました😊