在单独的进程中运行 Unreal 可感知服务

本指南介绍了从 Unreal 应用在专用进程中运行 Android 可感知服务 (FGS) 所需的配置和实现。

1. 配置可感知服务支持

本部分介绍了如何设置所需权限并在项目的清单中声明服务。

1.1 权限和服务声明(UPL 添加项)

Unreal 不会替换引擎清单 - UnrealBuildTool 生成的 AndroidManifest.xml 已声明 GameActivity,因此 UPL 只需要添加 权限和服务;启动器 Activity 不必重新声明。在 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 项目

将服务实现 Java 代码编译为 jar,并使用 UPL 的 <prebuildCopies> 将其复制到暂存 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 extra 和平台 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++ 和 Activity 仍保留在主进程中。

3. Java 实现

本部分介绍了 FGS 模块的 Java 端:3.1 和 3.2 是服务进程中的 DownloadService;3.3 是主进程中的 FgsBridge(入口点 C++ 使用 JNI 调用)。

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 静态方法。 为确保在服务进程启动时立即显示通知,请在 BeginPlay 中调用 RequestNotificationPermission