使用 Unreal 在獨立程序中執行可感知服務

本指南說明如何設定及實作,以便從 Unreal 應用程式在私人程序中執行 Android 可感知服務 (FGS)。

1. 設定可感知服務支援

本節說明如何設定必要權限,以及在專案資訊清單中宣告服務。

1.1 權限和服務聲明 (UPL 新增內容)

Unreal 不會取代引擎資訊清單,UnrealBuildTool 會產生已宣告 GameActivity 的 AndroidManifest.xml,因此 UPL 只需新增權限和服務,不必重新聲明啟動器活動。在 Source/PSUnreal/PSUnreal_UPL.xml 的 <androidManifestUpdates> 下方新增以下內容:

<androidManifestUpdates>
    <addPermission android:name="android.permission.FOREGROUND_SERVICE" />
    <addPermission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
    <addPermission android:name="android.permission.POST_NOTIFICATIONS" />

    <addElements tag="application">
        <service
            android:name="com.sample.fgs.DownloadService"
            android:process=":downloader"
            android:exported="false"
            android:foregroundServiceType="dataSync"
            android:stopWithTask="false" />
    </addElements>
</androidManifestUpdates>

foregroundServiceType必須與實際工作相符:dataSync用於轉移、mediaPlayback用於播放、location用於位置追蹤。這項權限必須與對應的 FOREGROUND_SERVICE_<TYPE> 權限和 startForeground 呼叫一致,否則服務啟動會失敗。

服務會使用完整名稱 com.sample.fgs.DownloadService,開頭的點會相對於應用程式 ID com.sample.psunreal 解析,但 Java 模組位於 com.sample.fgs 套件中,因此系統找不到該模組。

1.2 將服務程序 Java 新增至 Unreal 專案

使用 UPL 的 <prebuildCopies>,將服務實作項目的 Java 程式碼編譯為 JAR,並複製到暫存 libs 目錄。UEDeployAndroid 會將暫存 libs/ 複製到 Gradle 專案的 app/libs/,而 Gradle 會自動納入其中的每個 JAR,並將其封裝到 APK 中;服務程序會在執行階段載入這些類別:

<prebuildCopies>
    <copyFile src="$S(PluginDir)/fgs-android.jar"
              dst="$S(BuildDir)/libs/fgs-android.jar" />
</prebuildCopies>

$S(PluginDir) 是 UPL 檔案所在的目錄,請將編譯的 JAR 放在該處;$S(BuildDir) 是暫存目錄。

這些類別只能透過 JNI 和資訊清單存取,沒有 Java 呼叫網站,因此也必須保留在 <proguardAdditions> 中,否則縮減器會將其視為未使用並移除:

<proguardAdditions>
    <insert>
        -keep class com.sample.fgs.FgsBridge { public *; }
        -keep class com.sample.fgs.DownloadService { public *; }
        -keep class com.sample.fgs.ProgressFile { public *; }
        -keep class com.sample.fgs.FgsLogger { public *; }
    </insert>
</proguardAdditions>

2. 設定個別程序

您可以使用一個資訊清單屬性啟用程序界線:

android:process=":downloader"

開頭的半形冒號會建立名為 com.sample.psunreal:downloader 的應用程式專屬程序。這個程序與主要程序有不同的 PID,且在主要程序終止後仍可存續。

2.1 服務程序沒有 Unreal

為避免將 Unreal 的原生程式庫 (例如 libUE4.solibUE5.so) 帶入服務程序,服務端的 Java 不應使用任何 Unreal 類別,且只應依附於 Android ContextIntent 額外功能和平台 API。Unreal 引擎執行階段只會透過主要程序的 GameActivity 載入;私有 :downloader 程序不會載入這些程式庫,因此在服務程序中參照 Unreal 類別會無效。

2.2 處理程序啟動進入點

主要程序呼叫 startForegroundService 後,系統會分叉 :downloader 服務程序:

  • Application 例項化並呼叫 Application.onCreate,這會在每個程序中執行,因此服務程序所需的初始化作業必須在此處重新完成 (請參閱 2.3)。
  • 建立 DownloadService 並呼叫 onCreate,這是服務程序進入點。
  • 在啟動時建構 Intent,並使用該物件回呼 onStartCommand。服務會在此處將自己升級為前景,並啟動工作站 (請參閱 3.1)。

2.3 每個程序都有一個狀態

Dex 程式碼是共用的唯讀程式碼,但執行階段狀態不是:

  • Application.attachBaseContextApplication.onCreate 會在代管應用程式元件的每個程序中執行。
  • 每個程序中都有獨立的靜態初始設定式和靜態欄位。 在主要程序中指派靜態欄位時,不會與服務通訊。
  • Unreal、C++ 和 Activities 仍保留在主要程序中。

3. Java 實作

本節涵蓋 FGS 模組的 Java 端:3.1 和 3.2 位於 DownloadService服務程序中;3.3 位於FgsBridge主要程序中 (使用 JNI 的進入點 C++ 呼叫)。

3.1 先升級為前景服務

public class DownloadService extends Service {
    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        try {
            startForegroundCompat();

            // Start the download thread; must come after promoting to foreground
            // ...
        } catch (Exception e) {
            Log.e(TAG, "onStartCommand() failed [errorType="
                    + e.getClass().getSimpleName() + "]: " + e.getMessage(), e);
            stopSelf();
        }

        return START_NOT_STICKY;
    }

    private void startForegroundCompat() {
        Notification notification = buildNotification(0L);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            startForeground(NOTIFICATION_ID, notification,
                    ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
        } else {
            startForeground(NOTIFICATION_ID, notification);
        }
    }

    ...

3.2 新增通知

可察覺的服務需要在通知管道中顯示持續性通知,才能回報進度。通知會攜帶「停止」動作,使用 PendingIntent.getServiceACTION_STOP 傳送至服務本身,以停止服務。

private Notification buildNotification(long progressBytes) {
    int progressMib = (int) (progressBytes / MIB);

    Intent launchIntent =
            getPackageManager().getLaunchIntentForPackage(getPackageName());
    PendingIntent contentIntent = launchIntent != null
            ? PendingIntent.getActivity(this, 0, launchIntent,
                    PendingIntent.FLAG_IMMUTABLE
                            | PendingIntent.FLAG_UPDATE_CURRENT)
            : null;

    Intent stopIntent =
            new Intent(this, DownloadService.class).setAction(ACTION_STOP);
    PendingIntent stopPendingIntent = PendingIntent.getService(this, 0,
            stopIntent,
            PendingIntent.FLAG_IMMUTABLE
                    | PendingIntent.FLAG_UPDATE_CURRENT);

    Notification.Builder builder =
            new Notification.Builder(this, CHANNEL_ID)
            .setContentTitle("Download service")
            .setContentText("Downloaded " + progressMib + " MB / " + TOTAL_MIB + " MB")
            .setSmallIcon(android.R.drawable.stat_sys_download)
            .setProgress(TOTAL_MIB, progressMib, false)
            .setOngoing(true)
            .setOnlyAlertOnce(true)
            .addAction(
                    new Notification.Action.Builder(null, "Stop", stopPendingIntent).build());

    if (contentIntent != null) {
        builder.setContentIntent(contentIntent);
    }

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        builder.setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE);
    }
    return builder.build();
}

3.3 Java 主機進入點

FgsBridge 是主要程序用來控制 FGS 的 Java 進入點 (在主要程序中執行,而非服務程序)。C++ 會使用 JNI 呼叫這些靜態方法,啟動及停止服務,並處理通知權限:

public class FgsBridge {
    // Start the :downloader perceptible service and begin downloading
    public static void startDownloadService(Context context) {
        try {
            context.startForegroundService(
                    new Intent(context, DownloadService.class));
        } catch (Exception e) {
            Log.e(TAG, "startDownloadService() failed [errorType="
                    + e.getClass().getSimpleName() + "]: " + e.getMessage(), e);
        }
    }

    // Stop the service: sends a stop intent; the service removes its
    // notification before exiting
    public static void stopDownloadService(Context context) {
        try {
            context.startService(new Intent(context, DownloadService.class)
                    .setAction(ACTION_STOP));
        } catch (Exception e) {
            Log.e(TAG, "stopDownloadService() failed [errorType="
                    + e.getClass().getSimpleName() + "]: " + e.getMessage(), e);
        }
    }

    // Request notification permission (only needed on API 33+; if already
    // granted, no dialog is shown and it returns immediately)
    public static void requestNotificationPermission(Activity activity) {
        if (Build.VERSION.SDK_INT < 33) {
            return;
        }
        try {
            activity.requestPermissions(
                    new String[] { POST_NOTIFICATIONS }, NOTIFICATION_PERMISSION_REQUEST);
        } catch (Exception e) {
            Log.e(TAG, "requestNotificationPermission() failed [errorType="
                    + e.getClass().getSimpleName() + "]: " + e.getMessage(), e);
        }
    }
}

4. 從 Unreal (C++) 啟動及控制 FGS

PSUnrealAndroidBridge 是使用 JNI 呼叫 FgsBridge 的 C++ 包裝函式。以下是三種方法的實作方式:

void FPSUnrealAndroidBridge::StartDownloadService()
{
    CallActivityVoid(
            GBridgeInfo.StartDownloadService, TEXT("StartDownloadService"));
}

void FPSUnrealAndroidBridge::StopDownloadService()
{
    CallActivityVoid(
            GBridgeInfo.StopDownloadService, TEXT("StopDownloadService"));
}

void FPSUnrealAndroidBridge::RequestNotificationPermission()
{
    CallActivityVoid(
            GBridgeInfo.RequestNotificationPermission, TEXT("RequestNotificationPermission"));
}

CallActivityVoid 是內部 JNI 輔助程式,會使用 FJavaWrapper::GameActivityThis 做為 Context,呼叫對應的 Java 靜態方法。為確保服務程序開始時立即顯示通知,請在 RequestNotificationPermission 中呼叫 BeginPlay