使用 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,提交前不需要簽名。

後續步驟