2026年5月28日 星期四

在 .NET MAUI 中實作本地通知 – 使用 Plugin.LocalNotification

 


.NET MAUI(Multi-platform App UI)從 .NET 6 開始成為微軟主推的跨平台 UI 框架。只要寫一套 C# 與 XAML,就能編譯出 Android、iOS、macOS 與 Windows 的原生 App,取代了過去的 Xamarin.Forms。

雖然號稱「一次編寫,可多平台執行」,但現實是各平台的底層差異很大。為了維持框架核心的輕量,官方的Microsoft.Maui.Essentials只提供了最基礎的硬體呼叫功能(例如網路狀態、GPS、相機)。很多開發上必備的進階功能,像是本地通知、藍牙、或是背景常駐服務,官方目前都沒有直接內建,而是交由社群的 Plugin 來解決。

其中,「本地通知」幾乎是大部分 App 遲早會用到的功能。本地通知跟需要架設後端伺服器、申請憑證的遠端推播(FCM / APNs)不同,本地通知完全由裝置本機觸發,不需要網路連線。常見的應用場景包括:

● 行事曆提醒、鬧鐘

● 背景下載或資料同步完成的提示

● 離線狀態下的使用者互動

既然官方(包含 .NET MAUI Community Toolkit)都還沒提供這個功能,在目前的 MAUI 生態系中,由社群維護的 Plugin.LocalNotification 幾乎是唯一的首選。這套件成熟度高,且支援四大平台。

這篇文章將以 Plugin.LocalNotification 14.1 為例,紀錄如何從零實作跨平台的本地通知服務,順便整理各平台最常遇到問題的底層設定。


環境準備

筆者使用的環境是 Visual Studio 2026 18.5 ,必須安裝 .NET Multi-Platform App UI 開發,如圖:



建立專案

首先建立一個新的專案,選擇 .NET MAUI 應用程式,並為專案命名,筆者為其取名為 MAUINotify,架構選擇 .NET 10.0



安裝 NuGet 套件

選單選取 「工具」>NuGet 套件管理員」>「管理方案的NuGet管理員」,點按「瀏覽」頁,輸入「Plugin.LocalNotification」選取14.1版,以符合目前專案的架構版本。


 

在 MauiProgram.cs 註冊套件

安裝完套件後,最重要的第一步是在 App 啟動時呼叫 .UseLocalNotification(),同時將稍後建立的 NotificationService 以單例(Singleton)方式注入 DI 容器:

using Microsoft.Extensions.Logging;

using Plugin.LocalNotification;

 

namespace MauiNotify;

public static class MauiProgram

{

    public static MauiApp CreateMauiApp()

    {

        var builder = MauiApp.CreateBuilder();

        builder

            .UseMauiApp<App>()

            .UseLocalNotification()

            .ConfigureFonts(fonts =>

            {

                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");

                fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");

            });

 

        // Register notification service

        builder.Services.AddSingleton<Services.NotificationService>();

 

#if DEBUG

        builder.Logging.AddDebug();

#endif

 

        var app = builder.Build();

 

#if WINDOWS

       // Windows 平台需要在啟動時請求通知權限

        LocalNotificationCenter.Current.RequestNotificationPermission();

#endif

        return app;

    }

}

● UseLocalNotification():在 MAUI 應用程式啟動時初始化並註冊 Plugin.LocalNotification 的必要服務。

● AddSingleton<Services.NotificationService>():將我們自訂的通知服務註冊為Singleton,確保整個應用程式生命週期中共用同一個實體。


各平台設定要點

這是實作上最容易被忽略的細節。四大平台的通知機制底層完全不同,必須分別設定。

Android

Android 8(API 26)後強制要求必須先建立 Notification Channel,通知才能顯示。Channel 決定了通知的重要性等級、震動、燈號等行為,而且一旦建立後使用者可以在設定中個別關閉,程式碼無法強制覆蓋。

在 Platforms/Android/MainActivity.cs 中建立 Channel:

using Android.App;

using Android.Content.PM;

using Android.OS;

using System.Runtime.Versioning;

 

namespace MauiNotify

{

    [Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]

    public class MainActivity : MauiAppCompatActivity

    {

        protected override void OnCreate(Bundle? savedInstanceState)

        {

            base.OnCreate(savedInstanceState);

 

            if (OperatingSystem.IsAndroidVersionAtLeast(26))

            {

                CreateNotificationChannel();

            }

        }

 

        [SupportedOSPlatform("android26.0")]

        private void CreateNotificationChannel()

        {

            var notificationManager = (NotificationManager?)GetSystemService(NotificationService);

            if (notificationManager == null)

                return;

            // IMPORTANCE_HIGH = 彈出 Heads-up 通知

            var channel = new NotificationChannel(

                id: "general",

                name: "一般通知",

                importance: NotificationImportance.High)

            {

                Description = "App 的一般通知"

            };

            // 啟用震動與聲音以觸發 Heads-up 彈出效果

            channel.EnableVibration(true);

            channel.EnableLights(true);

            notificationManager.CreateNotificationChannel(channel);

        }

    }

}

● NotificationChannel:Android 8.0 (API 26) 以上必需,用來定義通知的行為(如震動、聲音、重要性)。

●  id: "general":此頻道 ID 必須與後續發送通知時指定的 ChannelId 完全相符。

● NotificationImportance.High:設定為高重要性,這樣通知才會以 Heads-up(螢幕上方彈窗)的形式直接顯示給使用者。

● EnableVibration 與 EnableLights:用來定義該頻道的預設行為。一旦頻道被建立,這些設定(包含重要性、聲音、震動)的控制權就會交給「使用者」。如果使用者自行到系統設定裡把你的 App 頻道調成靜音,程式碼將無法強制覆蓋他們的設定。

容易被忽略的細節:ChannelId 的字串(這篇範例用 "general")必須跟後面發通知時寫的 ChannelId 一模一樣。只要拼錯一個字,系統就找不到頻道,通知會直接靜默失敗(不會顯示也不會生錯誤訊息)。

在 .NET MAUI 10 中,專案預設目標的 Android 版本通常是 API 36 (Android 16)。從 Android 13(API 33)開始,系統對通知的安全性要求提高了,因此除了建立 Channel 之外,還必須在 `AndroidManifest.xml` 中明確宣告使用通知的權限,這個檔案的位置在專案的 Platforms/Android/AndroidManifest.xml,開啟之後你找到下方的「必要權限」區塊向下捲找到「POST_NOTIFICATIONS」,勾選即可。


你也可以按下該檔滑鼠右鍵選取開啟方式,使用「XML(文字)編輯器」開啟會看到完整的文字內容。



<?xml version="1.0" encoding="utf-8"?>

<manifest xmlns:android="http://schemas.android.com/apk/res/android">

        <application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true"></application>

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

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

        <!-- Required for local notifications on Android 13+ (API 33+) -->

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

        <!-- Required for scheduled/exact alarm notifications -->

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

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

</manifest>

● POST_NOTIFICATIONS:Android 13 (API 33) 引入的新權限,宣告應用程式需要發送通知的權限。


iOS 與 macOS Catalyst

受限於測試環境,本文的實作驗證主要聚焦於 Android Windows 平台。

雖然 Plugin.LocalNotification 同樣支援 Apple 體系,但 iOS 的通知機制有其嚴格的限制與 UX 規範(例如:授權對話框只會出現一次、前景執行時通知預設不彈出等)。若要在生產環境中部署 iOS 版本,建議必須在實體 iPhone Mac 環境下進行完整的生命週期測試。


Windows

這是最容易被忽略的平台。Plugin.LocalNotification 在 Windows 使用 WinRT Toast Notification,必須以 MSIX 封裝模式執行。若在 .csproj 中設定 <WindowsPackageType>None</WindowsPackageType>,通知功能會靜默失敗(不報錯、不顯示)。

必須在 Platforms/Windows/Package.appxmanifest 中加入 COM 啟動器,讓使用者點擊通知時能喚醒 App:

      <Extensions>

        <desktop:Extension Category="windows.toastNotificationActivation">

          <desktop:ToastNotificationActivation ToastActivatorCLSID="6e919706-2634-4d97-a93c-2213b2acc334" />

        </desktop:Extension>

        <com:Extension Category="windows.comServer">

          <com:ComServer>

            <com:ExeServer Executable="$targetnametoken$.exe" DisplayName="Toast activator">

              <com:Class Id="6e919706-2634-4d97-a93c-2213b2acc334" DisplayName="Toast activator" />

            </com:ExeServer>

          </com:ComServer>

        </com:Extension>

      </Extensions>

● desktop:ToastNotificationActivation:註冊 Toast 通知啟動器,讓系統知道如何處理點擊通知喚醒 App 的行為。

● com:ComServer 與 Toast activator CLSID:透過 COM 伺服器機制,讓 Windows 在背景能正確將通知點擊事件傳遞回你的 MAUI 應用程式。

CLSID 是唯一識別碼,可用 Visual Studio 選單「工具」à「建立 GUID」,在視窗中選擇「4. 登錄格式」,按複製取得CLSID 唯一識別碼。


建立 NotificationService 服務層

直接在 Page 的 Code-behind 呼叫 LocalNotificationCenter.Current 雖然會動,但這會把 UI 邏輯跟系統通知綁死,之後很難寫測試程式且不好維護。實務上建議包成一個獨立的 Service,並透過依賴注入(DI)來管理。
建立 Services/NotificationService.cs:

using Plugin.LocalNotification;

using Plugin.LocalNotification.Core.Models;

using Plugin.LocalNotification.Core.Models.AndroidOption;

using Plugin.LocalNotification.EventArgs;

 

namespace MauiNotify.Services;

    public class NotificationService : IDisposable

    {

        private bool _disposed;

 

        public NotificationService()

        {

// 訂閱通知點擊事件

            LocalNotificationCenter.Current.NotificationActionTapped += OnNotificationActionTapped;

        }

 

        public async Task<bool> RequestPermissionAsync()

        {

            return await LocalNotificationCenter.Current.RequestNotificationPermission();

        }

 

        public void ShowNotification(string title, string description, string returningData = "action=view")

        {

            if (string.IsNullOrWhiteSpace(title))

                title = "Notification";

 

            var request = new NotificationRequest

            {

                NotificationId = Random.Shared.Next(1, int.MaxValue),

                Title = title,

                Description = description,

                ReturningData = returningData,

                Android = new AndroidOptions

                {

                    ChannelId = "general"   // 對應 MainActivity 建立的 HIGH importance channel

                }

            };

            LocalNotificationCenter.Current.Show(request);

        }

 

        public void ScheduleNotification(string title, string description, DateTime notifyAt)

        {

            var request = new NotificationRequest

            {

                NotificationId = Random.Shared.Next(1, int.MaxValue),

                Title = title,

                Description = description,

                Schedule = new NotificationRequestSchedule

                {

                    NotifyTime = notifyAt

                }

            };

            LocalNotificationCenter.Current.Show(request);

        }

 

        private void OnNotificationActionTapped(NotificationActionEventArgs e)

        {

            try

            {

                var data = e.Request?.ReturningData;

                if (string.IsNullOrEmpty(data)) return;

               // 白名單驗證:只處理已知的 action 格式

                if (data.StartsWith("action=", StringComparison.OrdinalIgnoreCase))

                {

                    var action = data["action=".Length..];

                    switch (action)

                    {

                        case "view":

                           // 安全的導航範例

                        // MainThread.BeginInvokeOnMainThread(async () =>

                        //     await Shell.Current.GoToAsync("/details"));

                            break;

                        default:

                            // 未知 action,忽略

                            break;

                    }

                }

            }

            catch

            {

                // 防止惡意通知資料造成 App 閃退

            }

        }

 

        public void Dispose()

        {

            if (_disposed) return;

            // 記得解除訂閱,避免記憶體洩漏

            LocalNotificationCenter.Current.NotificationActionTapped -= OnNotificationActionTapped;

            _disposed = true;

        }

    }

● NotificationActionTapped事件:當使用者點擊通知時觸發,在這裡處理點擊後的導航或動作。

● RequestNotificationPermission():動態向系統請求發送通知的權限,回傳布林值表示是否授權。

● NotificationRequest:建立通知內容的核心物件,包含標題、描述、隨機產生的 NotificationId,以及特定平台(如 Android)的設定參數。

● ReturningData:附加在通知上的隱藏字串資料,當通知被點擊時,可以透過這個字串判斷要執行的動作。

● Dispose()釋放資源:因為 LocalNotificationCenter.Current是全域靜態物件,掛載在其上的事件(NotificationActionTapped)會強引用(Strong Reference)我們的 Service 實體。雖然註冊為 Singleton 時生命週期通常等同於應用程式,但養成實作 `IDisposable` 並解除事件訂閱(`-= OnNotificationActionTapped`)的習慣,是避免記憶體洩漏(Memory Leak)的關鍵防禦性設計。


App.xaml.cs 確保服務在Windows 關閉時被移除

為了確保 Dispose() App 關閉時被正確呼叫(特別是在 Windows 平台上避免關閉時觸發 Win32 例外),我們需要在 App.xaml.cs中覆寫 CreateWindow`方法,攔截 Window Destroying事件:

    protected override Window CreateWindow(IActivationState? activationState)

    {

        var window = new Window(new AppShell());

 

        window.Destroying += (s, e) =>

        {

            // 取消訂閱通知事件,防止 Windows 關閉時觸發 Win32 例外

            var svc = IPlatformApplication.Current?.Services

                          .GetService<Services.NotificationService>();

            svc?.Dispose();

        };

        return window;

    }

● window.Destroying事件:當應用程式的視窗準備關閉時觸發。我們在這裡透過 DI 容器取得 NotificationService實體,並手動呼叫其 Dispose()`方法,確保所有與底層作業系統的事件綁定都能乾淨地被解除。


安全性注意事項

本地通知看似單純,但有幾個安全細節容易被忽略:

1.          不在通知容中放置敏感資料

Title、Description、ReturningData的容會被儲存在裝置的通知佇列中,不應包含:

● 使用者 Token 或 Session ID

● 個人識別資訊(姓名、電話、信用卡號)

● 任何機密業務資料

2.          ReturningData白名單驗證

ReturningData是通知被點擊時傳回App的字串。若直接用它來決定導航路由或執行操作,可能被惡意偽造的通知資料利用。正確做法是只允許已知的

// 危險做法

await Shell.Current.GoToAsync(e.Request.ReturningData);

// 安全做法:白名單驗證

if (data == "action=view")

    await Shell.Current.GoToAsync("/details");

危險做法:直接將外部傳入的未信任字串作為路由路徑,可能導致意外的畫面跳轉或應用程式閃退。

安全做法:使用明確的 if 或 switch 判斷(白名單),只允許預先定義好的行為執行,大幅降低安全風險。



3.          防禦性 try-catch

在 OnNotificationActionTapped 裡面最好包一層 try-catch。這樣就算 ReturningData 格式怪怪的,App 也不會因為 Unhandled Exception 直接閃退。可以的話,在 catch 裡面補個 Log(例如寫入 Crashlytics),對之後查 bug 會很有幫助。


UI 整合

MainPage.xaml — 加入按鈕

<?xml version="1.0" encoding="utf-8" ?>

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"

             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"

             x:Class="MauiNotify.MainPage">

 

    <ScrollView>

        <VerticalStackLayout

            Padding="30,0"

            Spacing="25">

            <Image

                Source="dotnet_bot.png"

                HeightRequest="185"

                Aspect="AspectFit"

                SemanticProperties.Description="dot net bot in a hovercraft number nine" />

            <Button

                x:Name="NotifyBtn"

                Text="Send Notification"

                Clicked="OnNotifyClicked"

                HorizontalOptions="Fill" />

        </VerticalStackLayout>

    </ScrollView>

</ContentPage>

●  x:Name="NotifyBtn":為按鈕命名以便在 Code-behind 中參考。

● Clicked="OnNotifyClicked":綁定點擊事件,用來觸發發送通知的方法。


MainPage.xaml.cs — 注入服務並呼叫

namespace MauiNotify;

public partial class MainPage : ContentPage

{

    readonly Services.NotificationService _notificationService;

    public MainPage(Services.NotificationService notificationService)

    {

        InitializeComponent();

        _notificationService = notificationService;

    }

    protected override async void OnAppearing()

    {

        base.OnAppearing();

        var granted = await _notificationService.RequestPermissionAsync();

        if (!granted)

            await DisplayAlertAsync("通知權限", "請開啟通知權限以接收本地通知。", "確定");

    }

    private void OnNotifyClicked(object? sender, EventArgs e)

    {

        _notificationService.ShowNotification(

            title: "Hello from MAUI",

            description: "這是一則本地通知範例,不包含任何敏感資料。");

    }

}

●  IPlatformApplication.Current!.Services.GetService():從 MAUI 的依賴注入容器中解析出 NotificationService 實體。

● OnAppearing():覆寫頁面顯示時的生命週期方法,在這裡請求權限可以確保 UI 已經準備好顯示系統的授權對話框。

● _notificationService.ShowNotification():呼叫我們封裝的服務,實際發出一則包含標題與描述的本地通知。

關於執行期權限請求的時機:千萬不要在頁面的建構子(Constructor)裡面要權限。那時候 UI 還沒畫好,彈出視窗(DisplayAlert)或是系統的授權框可能會失敗。

更好的做法是:等使用者真的點了某個需要通知的按鈕時,再去要權限(情境式請求,如同我們的範例權限請求寫在OnAppearing方法),這樣使用者比較知道此情境真的必須給予權限否則就無法使用。


執行

啟動 Android 模擬器

應用程式啟動之後會向使用者請求通知權限:


















按下Send Notification按鈕之後會觸動通知如下:











啟動 Windows

Windows 上測試本地通知功能,必須以 MSIX 封裝模式啟動應用程式。這是因為 Windows 的通知系統(WinRT Toast Notification)要求應用程式必須註冊為「正式的」Windows App,才能正確處理通知的發送與點擊事件。

在專案按滑鼠右鍵選擇屬性,勾選「建立 Windows MSIX封裝」



啟動Windows 按下Send Notification按鈕之後會觸動通知。

範例程式請參考:https://github.com/UUUdemo/MAUINOTIFY


結語

Plugin.LocalNotification幫我們把四大平台底層那些複雜又各自為政的通知機制,包裝成一套相對好用的API。這篇示範的Service 封裝寫法,也是希望盡量把依賴切開,未來如果遇到預期外的問題,或官方終於出了內建 API,要換掉底層實作也會比較輕鬆。

這份專案的原始碼與設定邏輯可以當作一個基礎版型,記得根據你的需求,把白名單防禦跟權限修得更嚴謹一點!



推薦課程:

【UN498】.NET MAUI跨界先鋒從桌到移動的全平台開發旅程


0 意見:

張貼留言