使用 Winapp CLI 搭配 Flutter

想要完整的工作範例,請參考本資料庫中的 Flutter 範例

本指南示範如何在 Flutter 應用程式中使用 winapp CLI,加入套件識別碼,並將應用程式打包成 MSIX。

套件身份是 Windows app 模型中的核心概念。 它讓你的應用程式能存取特定的 Windows API(例如通知、安全、AI API 等)、乾淨安裝/卸載體驗等等。

標準的 Flutter Windows 版本沒有套件識別碼。 本指南說明如何新增它用於除錯,然後再打包以供發佈。

先決條件

  1. Flutter SDK:請依 照官方指南安裝 Flutter。

  2. winapp CLI:透過 Winget 安裝 winapp CLI(若已安裝則更新):

    winget install Microsoft.winappcli --source winget
    

1. 建立新的 Flutter 應用程式

請依照官方 Flutter 文件的指南建立新應用程式並執行。

你應該會看到預設的 Flutter 計數器應用程式。

2. 更新代碼以驗證身份

我們會更新應用程式,以檢查它是否運行於具有套件識別的狀態。 我們會使用 Dart FFI 來呼叫 Windows GetCurrentPackageFamilyName API。

首先,加入 ffi 這個包裹:

flutter pub add ffi

接著,將 的內容 lib/main.dart 替換成以下程式碼。 此程式碼嘗試使用 Windows API 取得目前的套件識別碼。 若成功,則會在使用者介面中顯示套件族名稱;否則,會顯示「未包裝」。

import 'dart:ffi';
import 'dart:io' show Platform;

import 'package:ffi/ffi.dart';
import 'package:flutter/material.dart';

/// Returns the Package Family Name if running with package identity, or null.
String? getPackageFamilyName() {
  if (!Platform.isWindows) return null;

  final kernel32 = DynamicLibrary.open('kernel32.dll');
  final getCurrentPackageFamilyName = kernel32.lookupFunction<
      Int32 Function(Pointer<Uint32>, Pointer<Uint16>),
      int Function(
          Pointer<Uint32>, Pointer<Uint16>)>('GetCurrentPackageFamilyName');

  final length = calloc<Uint32>();
  try {
    // First call to get required buffer length
    final result =
        getCurrentPackageFamilyName(length, Pointer<Uint16>.fromAddress(0));
    if (result != 122) return null; // ERROR_INSUFFICIENT_BUFFER = 122

    // Second call with buffer to get the name
    final namePtr = calloc<Uint16>(length.value);
    try {
      final result2 = getCurrentPackageFamilyName(length, namePtr);
      if (result2 == 0) {
        return namePtr.cast<Utf16>().toDartString(); // ERROR_SUCCESS = 0
      }
      return null;
    } finally {
      calloc.free(namePtr);
    }
  } finally {
    calloc.free(length);
  }
}

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;
  late final String? _packageFamilyName;

  @override
  void initState() {
    super.initState();
    _packageFamilyName = getPackageFamilyName();
  }

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Container(
              padding: const EdgeInsets.all(16),
              margin: const EdgeInsets.only(bottom: 24),
              decoration: BoxDecoration(
                color: _packageFamilyName != null
                    ? Colors.green.shade50
                    : Colors.orange.shade50,
                borderRadius: BorderRadius.circular(8),
                border: Border.all(
                  color: _packageFamilyName != null
                      ? Colors.green
                      : Colors.orange,
                ),
              ),
              child: Text(
                _packageFamilyName != null
                    ? 'Package Family Name:\n$_packageFamilyName'
                    : 'Not packaged',
                textAlign: TextAlign.center,
                style: Theme.of(context).textTheme.bodyLarge,
              ),
            ),
            const Text('You have pushed the button this many times:'),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

3. 無身份運行

現在,照常建立並執行應用程式:

flutter build windows

直接執行執行檔(如果不同,請替換 flutter_app 成你的專案名稱):

.\build\windows\x64\runner\Release\flutter_app.exe

小提示

建置輸出會放在 x64 資料夾裡,不管你的機器架構如何——這是 Flutter Windows 建置時預期的。

你應該會看到應用程式顯示橘色的「未包裝」指示。 這確認了標準執行檔在沒有任何套件身份的情況下執行。

4. 使用 Winapp CLI 初始化 Project

winapp init 指令一次設定你需要的所有東西:應用程式清單、資產,以及可選的 Windows 應用程式 SDK C++ 開發標頭。 清單定義了你應用程式的身份(名稱、發佈者、版本),Windows 用來授權 API 存取。

執行以下指令並依照提示操作:

winapp init

出現提示時:

  • 套件名稱:按 Enter 接受預設名稱(源自你的專案名稱)
  • Publisher name:按 Enter 接受預設或輸入你的名字
  • 版本:按下 "Enter" 以接受 1.0.0.0
  • Description:按 Enter 接受預設(Windows 應用程式)
  • Setup SDKs:選擇「Stable SDK」下載 Windows 應用程式 SDK 並產生 C++ 標頭(步驟 6 需要)

這個命令將會執行以下作業:

  • Create Package.appxmanifest — 定義你應用程式身份的清單
  • 建立 Assets 資料夾 — MSIX 包裝與商店提交所需的圖示
  • 建立一個.winapp資料夾,裡面有Windows 應用程式 SDK標頭和函式庫
  • 建立一個 winapp.yaml 設定檔來鎖定 SDK 版本

你可以開啟 Package.appxmanifest 來進一步自訂屬性,例如顯示名稱、發佈者和功能。

5. 利用身份驗證除錯

若要測試需要身份識別的功能(例如通知),但不完全打包應用程式,你可以使用 winapp run。 這會註冊一個鬆散的版面套件(就像真正的 MSIX 安裝一樣),然後一步啟動應用程式。 除錯不需要憑證或簽署。

  1. 建立應用程式

    flutter build windows
    
  2. 以身份行事

    winapp run .\build\windows\x64\runner\Release
    

小提示

winapp run 同時也會在你的系統上註冊包裹。 這也是為什麼當你在第 7 步嘗試安裝時,MSIX 可能會顯示為「已安裝」。 完成後,請使用 winapp unregister 清理開發套件。

你現在應該會看到應用程式顯示綠色指示燈:

Package Family Name: flutterapp.debug_xxxxxxxx

這能確認你的應用程式是以有效的套件身份執行!

小提示

關於進階除錯工作流程(附加除錯器、IDE 設定、啟動除錯),請參閱 除錯指南

6. 使用 Windows 應用程式 SDK(可選)

如果你選擇在 winapp init 時設定 SDK,現在你就能在 .winapp/include 資料夾中存取Windows 應用程式 SDK C++ 標頭。 由於 Flutter 的 Windows 執行程式是 C++,你可以從原生程式碼呼叫 Windows 應用程式 SDK API,並透過方法通道將它們暴露給 Dart。 如果您只需要發佈時的套件識別,可跳至步驟 7。

我們來加一個簡單的範例,顯示 Windows 應用程式 執行時版本。

建立原生外掛

創建 windows/runner/winapp_sdk_plugin.h

#ifndef RUNNER_WINAPP_SDK_PLUGIN_H_
#define RUNNER_WINAPP_SDK_PLUGIN_H_

#include <flutter/flutter_engine.h>

// Registers a method channel for querying Windows App SDK info.
void RegisterWinAppSdkPlugin(flutter::FlutterEngine* engine);

#endif  // RUNNER_WINAPP_SDK_PLUGIN_H_

創建 windows/runner/winapp_sdk_plugin.cpp

#include "winapp_sdk_plugin.h"

#include <flutter/method_channel.h>
#include <flutter/standard_method_codec.h>
#include <winrt/Microsoft.Windows.ApplicationModel.WindowsAppRuntime.h>

#include <string>

void RegisterWinAppSdkPlugin(flutter::FlutterEngine* engine) {
  auto channel = std::make_unique<flutter::MethodChannel<flutter::EncodableValue>>(
      engine->messenger(), "com.example/winapp_sdk",
      &flutter::StandardMethodCodec::GetInstance());

  channel->SetMethodCallHandler(
      [](const flutter::MethodCall<flutter::EncodableValue>& call,
         std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
        if (call.method_name() == "getRuntimeVersion") {
          try {
            // Flutter already initializes COM in main.cpp, so we skip
            // winrt::init_apartment() here — the apartment is already set up.
            auto version = winrt::Microsoft::Windows::ApplicationModel::
                WindowsAppRuntime::RuntimeInfo::AsString();
            std::string versionStr = winrt::to_string(version);
            result->Success(flutter::EncodableValue(versionStr));
          } catch (const winrt::hresult_error& e) {
            result->Error("WINRT_ERROR", winrt::to_string(e.message()));
          } catch (...) {
            result->Error("UNKNOWN_ERROR",
                          "Failed to get Windows App Runtime version");
          }
        } else {
          result->NotImplemented();
        }
      });

  // prevent channel destruction by releasing ownership
  channel.release();
}

更新 CMakeLists.txt

編輯 windows/runner/CMakeLists.txt 以進行三項更改。 找到該 add_executable 區塊並加入 "winapp_sdk_plugin.cpp" 原始檔案清單:

add_executable(${BINARY_NAME} WIN32
  "flutter_window.cpp"
  "main.cpp"
  "utils.cpp"
  "win32_window.cpp"
  "winapp_sdk_plugin.cpp"       # <-- add this line
  "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
  "Runner.rc"
  "runner.exe.manifest"
)

接著在檔案末尾加上這兩行,連結 WinRT 函式庫並包含 Windows 應用程式 SDK 標頭:

# Link Windows Runtime libraries for WinRT
target_link_libraries(${BINARY_NAME} PRIVATE "WindowsApp.lib")

# Windows App SDK headers from winapp CLI
target_include_directories(${BINARY_NAME} PRIVATE
  "${CMAKE_SOURCE_DIR}/../.winapp/include")

註冊外掛

windows/runner/flutter_window.cpp中,將檔案頂端的 include 與其他 include 一起加入:

#include "winapp_sdk_plugin.h"

然後找到RegisterPlugins中的呼叫FlutterWindow::OnCreate(),並在它後面的那行上加上RegisterWinAppSdkPlugin

  RegisterPlugins(flutter_controller_->engine());
  RegisterWinAppSdkPlugin(flutter_controller_->engine());  // <-- add this line

更新 main.dart

請在 lib/main.dart 的最上方新增以下匯入,並與現有的匯入並列:

import 'package:flutter/services.dart';

在現有getPackageFamilyName()函式下方(且確保不在任何類別內)加入此函式:

/// Queries the Windows App Runtime version via a native method channel.
Future<String?> getWindowsAppRuntimeVersion() async {
  if (!Platform.isWindows) return null;
  try {
    const channel = MethodChannel('com.example/winapp_sdk');
    final version = await channel.invokeMethod<String>('getRuntimeVersion');
    return version;
  } catch (_) {
    return null;
  }
}

在該 _MyHomePageState 類別中,在現有 _packageFamilyName欄位旁新增一個欄位:

  late final String? _packageFamilyName;
  String? _runtimeVersion;         // <-- add this line

更新 initState() 以呼叫這個新函式:

  @override
  void initState() {
    super.initState();
    _packageFamilyName = getPackageFamilyName();
    // Fetch the runtime version asynchronously
    getWindowsAppRuntimeVersion().then((version) {
      setState(() {
        _runtimeVersion = version;
      });
    });
  }

最後,在方法中顯示執行時版本 build 。 在Column子清單中加入這個小工具,緊接著Container顯示套件識別碼:

            if (_runtimeVersion != null)
              Padding(
                padding: const EdgeInsets.only(bottom: 16),
                child: Text(
                  'Windows App Runtime: $_runtimeVersion',
                  style: Theme.of(context).textTheme.bodyLarge,
                ),
              ),

建置並執行

重建應用程式:

flutter build windows
winapp run .\build\windows\x64\runner\Release

你現在應該會看到輸出如下:

Package Family Name: flutterapp.debug_xxxxxxxx
Windows App Runtime: 8000.731.1532.0

.winapp/include 目錄包含所有為 Windows 應用程式 SDK 所需的必要標頭。

  • winrt/ - 用於存取Windows 執行階段 API 的 WinRT C++ 投影標頭
  • Microsoft.UI.*.h - 用於現代 UI 元件的 WinUI 3 標頭
  • MddBootstrap.h - Windows 應用程式 SDK 自助模式
  • WindowsAppSDK-VersionInfo.h - 版本資訊
  • 以及更多 Windows 應用程式 SDK 元件

想了解更進階的Windows 應用程式 SDK使用,請參考 Windows 應用程式 SDK 文件

7. MSIX 封裝

當你準備好發佈應用程式時,可以用相同的清單打包成 MSIX。

準備套件目錄

首先,先在發佈模式下建置你的應用程式:

flutter build windows

接著,建立一個包含你發行檔案的目錄:

mkdir dist
copy .\build\windows\x64\runner\Release\* .\dist\ -Recurse

Flutter Windows 建置輸出包含執行檔、flutter_windows.dll 以及 data 資料夾——這些都是必備的。

產生開發證書

在包裝前,你需要一份開發證書才能簽名。 如果你還沒生成,請生成一個:

winapp cert generate --if-exists skip

簽名與包裝

現在你可以打包並簽名:

winapp pack .\dist --cert .\devcert.pfx

注意:pack 指令會自動使用你目前目錄中的 Package.appxmanifest,並在打包前將其複製到目標資料夾。

安裝憑證

在安裝 MSIX 套件之前,你需要先信任你機器上的開發憑證。 以管理員身份執行這個指令(你只需要在每個憑證上執行一次):

winapp cert install .\devcert.pfx

安裝並執行

小提示

如果你在步驟5使用 winapp run 過,包裹可能已經在你的系統上註冊了。 使用 winapp unregister 先移除開發註冊,然後再安裝正式發行套件。

請雙擊產生 .msix 的檔案,或使用 PowerShell 安裝套件:

Add-AppxPackage .\flutterapp.msix

小提示

MSIX 檔名包含版本與架構(例如 flutterapplication1_1.0.0.0_x64.msix)。 請檢查你的目錄,確認確切的檔名。 如果程式碼變更後需要重新打包,請在 Version 中增加 Package.appxmanifest — Windows 需要更高的版本號才能更新已安裝的套件。

Tips

  1. 一旦準備好分發,你可以用憑證授權中心的程式碼簽署憑證來簽署 MSIX,讓使用者不必安裝自簽憑證。
  2. Azure 信任簽署 服務是安全管理憑證並將簽約整合進 CI/CD 管線的絕佳方式。
  3. Microsoft Store 會幫你簽署 MSIX,提交前不需要簽名。

後續步驟