Flutter 運用 WebView 為自己的網站開發 Android APP 吧!

2025/06/19 41 0 行動平台 , 程式設計 , Android , Dart , APP開發

這篇教學會帶你用 Flutter 為 example.com 建立一個專屬的 APP。你只要會一點基本程式操作,就能跟著做出自己的「網站 APP」。我們會一步一步介紹 Flutter(Dart 語言)WebView 的概念,並帶你了解建立專案、設定 APP 圖示、管理套件、修改 Android 權限的一些重點,最後會完整講解 main.dart 的寫法,觀念點通了,速度就快了!

什麼是 Flutter?什麼是 Dart?

Flutter 是 Google 推出的跨平台 UI 框架,只要寫一次程式,就能同時產生 Android、iOS、Web 的 APP。它的語言叫做 Dart,語法跟 JavaScript、Java 有點像,很適合用來快速開發漂亮的手機 APP。拿來開發簡易的 Android APP 又快又有效率,稍作修改馬上也能開發 iOS 專用的 APP,不過本文不對這部分解說。

什麼是 WebView?

WebView 就是「把網站畫面包進 APP 裡」的一種方式。APP 會像瀏覽器一樣顯示你指定的網址,你的網站怎麼更新、APP 裡就跟著變。不需重寫 APP 內容,維護也超方便。

1. 開始建立 Flutter 專案

首先你要先安裝好 Flutter 開發環境。安裝好後,在終端機輸入下列指令建立新專案(請把 my_webapp 換成你要的專案名稱):

flutter create my_webapp

進入專案資料夾:

cd my_webapp

2. 製作與放置 APP 圖示

APP 一定要有自己的圖示。可以用 AppIcon 工具 或 Figma、Photoshop 製作 APP 圖示。

接著,把生成好的 Android 圖示,依照目錄分類放進 android/app/src/main/res/ 目錄底下,會有 mipmap-hdpimipmap-mdpi 等子目錄(每個子目錄都是不同解析度用的)。

3. 管理套件(pubspec.yaml)

接著我們要用到兩個重要套件:

  • flutter_inappwebview:WebView 元件,比官方的 webview 更強大。
  • url_launcher:用來處理跳轉外部瀏覽器的情境。

打開 pubspec.yaml,加入以下內容(版本請查詢最新):

dependencies:
  flutter:
    sdk: flutter
  flutter_inappwebview: ^6.0.0
  url_launcher: ^6.1.11

加完後執行:

flutter pub get

4. 修改 Android 權限(AndroidManifest.xml)

要讓 APP 能存取網路,一定要在 android/app/src/main/AndroidManifest.xml<application> 標籤前面加入:

<uses-permission android:name="android.permission.INTERNET" />

這樣 APP 才能正確載入網頁。

5. 編寫主程式 lib/main.dart

以下是完整的主程式範例,裡面會帶你理解每個部份的用法:

import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:url_launcher/url_launcher.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  if (Platform.isAndroid) {
    await AndroidInAppWebViewController.setWebContentsDebuggingEnabled(true);
  }
  runApp(
    MaterialApp(
      theme: ThemeData(
        primarySwatch: Colors.deepBlue,
        primaryColor: Colors.cyan[800],
        scaffoldBackgroundColor: Colors.black,
        canvasColor: Colors.black,
        cardColor: Colors.grey[850],
      ),
      home: const MyApp(),
      debugShowCheckedModeBanner: false,
    ),
  );
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});
  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final GlobalKey webViewKey = GlobalKey();
  InAppWebViewController? webViewController;
  String url = "";
  double progress = 0;

  InAppWebViewGroupOptions options = InAppWebViewGroupOptions(
    crossPlatform: InAppWebViewOptions(
      useShouldOverrideUrlLoading: true,
      mediaPlaybackRequiresUserGesture: false,
      incognito: false,
      cacheEnabled: true,
    ),
    android: AndroidInAppWebViewOptions(
      useHybridComposition: true,
      thirdPartyCookiesEnabled: true,
    ),
    ios: IOSInAppWebViewOptions(
      allowsInlineMediaPlayback: true,
      sharedCookiesEnabled: true,
    ),
  );

  late PullToRefreshController pullToRefreshController;

  @override
  void initState() {
    super.initState();
    pullToRefreshController = PullToRefreshController(
      options: PullToRefreshOptions(color: Colors.cyan[800]),
      onRefresh: () async {
        if (Platform.isAndroid) {
          webViewController?.reload();
        } else if (Platform.isIOS) {
          webViewController?.loadUrl(
            urlRequest: URLRequest(url: await webViewController?.getUrl()),
          );
        }
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Stack(
          children: [
            InAppWebView(
              key: webViewKey,
              initialUrlRequest: URLRequest(
                url: WebUri("https://example.com/"),
              ),
              initialOptions: options,
              pullToRefreshController: pullToRefreshController,
              onWebViewCreated: (controller) {
                webViewController = controller;
              },
              onLoadStart: (controller, url) {
                setState(() {
                  this.url = url.toString();
                });
              },
              androidOnPermissionRequest:
                  (controller, origin, resources) async {
                    return PermissionRequestResponse(
                      resources: resources,
                      action: PermissionRequestResponseAction.GRANT,
                    );
                  },
              shouldOverrideUrlLoading: (controller, navigationAction) async {
                var uri = navigationAction.request.url!;
                final whitelist = ['example.com'];

                if (["http", "https"].contains(uri.scheme)) {
                  bool isWhitelisted = whitelist.any((host) {
                    return uri.host == host || uri.host.endsWith('.$host');
                  });

                  if (isWhitelisted) {
                    return NavigationActionPolicy.ALLOW;
                  } else {
                    if (await canLaunchUrl(uri)) {
                      await launchUrl(
                        uri,
                        mode: LaunchMode.externalApplication,
                      );
                    }
                    return NavigationActionPolicy.CANCEL;
                  }
                }
                // 其它協定一律允許
                return NavigationActionPolicy.ALLOW;
              },
              onLoadStop: (controller, url) async {
                pullToRefreshController.endRefreshing();
                setState(() {
                  this.url = url.toString();
                });
              },
              onLoadError: (controller, url, code, message) {
                pullToRefreshController.endRefreshing();
              },
              onProgressChanged: (controller, progress) {
                if (progress == 100) {
                  pullToRefreshController.endRefreshing();
                }
                setState(() {
                  this.progress = progress / 100;
                });
              },
              onUpdateVisitedHistory: (controller, url, androidIsReload) {
                setState(() {
                  this.url = url.toString();
                });
              },
              onConsoleMessage: (controller, consoleMessage) {
                debugPrint(consoleMessage.toString());
              },
            ),

            // 載入進度條
            if (progress < 1.0)
              Positioned(
                top: 0,
                left: 0,
                right: 0,
                child: LinearProgressIndicator(value: progress),
              ),
          ],
        ),
      ),
    );
  }
}

程式解說

  • main():Flutter 入口點。設定 APP 主題顏色、啟用 WebView Debug(方便 Android 開發)。
  • MyApp:主畫面 Widget,採用 StatefulWidget 可隨時更新網頁狀態。
  • InAppWebViewGroupOptions:這裡設定 WebView 行為,像是否啟用快取、允許多媒體自動播放、Cookie 管理等等。
  • PullToRefreshController:提供下拉重新整理功能,符合 APP 使用習慣。
  • build():APP 畫面結構,主體就是一個 InAppWebView,顯示 https://example.com/
  • onWebViewCreated:WebView 建立時把控制器記錄下來。
  • shouldOverrideUrlLoading:網址白名單判斷,只有 example.com 的網頁才會在 APP 內開,其它網站會跳外部瀏覽器。
  • onLoadStart/onLoadStop/onLoadError/onProgressChanged:這幾個是監控網頁載入狀態、控制進度條的。
  • androidOnPermissionRequest:Android 網頁如有麥克風、相機等權限會自動允許(你可自行加強權限控制)。
  • 進度條:當網頁載入未完成,顯示在畫面頂端。

6. 設定金鑰(Key)相關事項

當你打算將 Flutter APP 發佈到 Google Play,必須要有一組簽章金鑰來進行 AAB 檔案的簽名。這部分主要與 Android 相關。
你可以使用下列指令產生自己的金鑰(假設你已經安裝好 keytool,通常隨 Java JDK 一起安裝):


keytool -genkey -v -keystore my-release-key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias my-key-alias

這條指令會要求你輸入密碼並建立一個 my-release-key.jks 檔案(可自行改名),記得密碼和別名(alias)要牢記且妥善儲存。
然後將金鑰檔放在專案根目錄下,接著在 android/app 目錄內建立或編輯 key.properties 檔案,內容如下:

storePassword=你的密碼
keyPassword=你的密碼
keyAlias=my-key-alias
storeFile=../my-release-key.jks

最後,編輯 android/app/build.gradle,在 android { ... } 區塊加上:

signingConfigs {
    release {
        if (project.hasProperty('KEYSTORE_PASSWORD')) {
            storeFile file(KEYSTORE_FILE)
            storePassword KEYSTORE_PASSWORD
            keyAlias KEY_ALIAS
            keyPassword KEY_PASSWORD
        } else if (project.hasProperty('storePassword')) {
            storeFile file(storeFile)
            storePassword storePassword
            keyAlias keyAlias
            keyPassword keyPassword
        }
    }
}
buildTypes {
    release {
        signingConfig signingConfigs.release
        minifyEnabled false
        shrinkResources false
        proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
    }
}

這樣每次你打包就會自動使用你設定好的金鑰檔案進行簽章,讓你的 APP 可以安全上架到 Google Play。

如果你還沒要上架,可以先跳過這一步,日後再補也沒關係,但千萬記得「金鑰檔要備份好」,一旦遺失,未來更新 APP 會遇到大麻煩!

7. 執行與除錯

現在可以連接 Android 手機或用模擬器,直接執行:

flutter run

如果有遇到問題,可參考終端機訊息或 Flutter 官網尋求協助。

最後可以把 APP 打包並上架 Google Play!

AAB(上架 Google Play 專用)與 APK 的打包指令如下:

flutter build appbundle --release
flutter build apk --release

總結

透過 Flutter + WebView,你可以超快速地將自己的網站包成原生 APP,而且後續維護只要管理網站本身就好,非常適合 MVP、內部系統、活動型網站使用。如果你想進階,還可以整合更多原生功能或客製 UI。歡迎試試看,開啟你的 Flutter APP 開發之路吧!

贊助廣告 ‧ Sponsor advertisements

留言區 / Comments

萌芽論壇