UE4 移动端后台下载

一、前言

1.1 开发环境

UE版本:4.27.2

VS版本:2019

1.2 后台下载的必要性

现在手游体量越来越大,热更下载时间越来越长,后台下载现在基本是手游必备的,但是UE对于移动端的支持非常可怜,对于后台下载这一块别说官方支持,连网上资料也基本没有,如果你也有UE4移动端后台下载的需求,我们就接着往下看吧

二、后台下载概述

2.1后台下载的概述和思路

手机上在游戏下载中直接返回桌面,在不主动杀死后台进程的情况下需要继续下载热更内容到完成,这就是后台下载的流程,听起来很简单,实际实现上不懂移动平台开发的情况下还是非常坎坷的.

首先我们要理清楚两个思路,因为手机切换到桌面以后进程直接会被挂起,下面图中演示了安卓进程的生命周期示例,切换后台后我们的进程会被挂起,不会继续执行,所以一般做法就是把整个下载流程加上验证等操作都用Java/ObjectC 单独实现一遍,然后不管是前台后台都跑这些代码,但是因为我们已经用c++实现了整个下载流程,而且可能还会有Windows下载的需求,所以我的思路是把下载流程都还统一放到c++,安卓或者IOS端只是起一个服务,然后在服务里面执行我c++的代码,这是我的做法,大家也可以选择其他方式,我这样做是为了更加统一,不用去管每个平台的实现方式,并且可以很独立的封装成一个插件。

按home 退出: onPause()->onStop(),再进入:onRestart()->onStart()->onResume()

2.2 各平台实现概述

涉及的主要模块有 UE UPL

本文不会演示热更新相关的具体逻辑,只说明平台端后台保活的实现,后面可能会写一下热更新自动化打包和热更新下载的文章

安卓

  1. 新增Android *.UPL.XML,并在里面配置权限和创建服务逻辑等
  2. 在插件目录下新增Jave文件夹,然后新增Service模块
  3. 在service中通过通知的方式保活服务,并增加Binder起一个线程执行c++下载逻辑
  4. 最后在activity中添加启动/停止service相关的代码即可

IOS

ios相对于安卓反而非常简单,这里所使用的是简单又暴力的后台播放无声音乐保活进程的方法

  1. info.plist 添加权限
  2. 在开始下载时可直接在c++中添加OC代码循环播放无声音乐
  3. 然后下载结束停止音乐播放就行了

三、安卓平台后台下载

首先先看一下插件目录结构

框框中是安卓平台主要涉及到的内容

  1. 新建 AndroidDownload_UPL.XML 文件,并且在 *.Build.cs中添加加载.XML的代码
        if (Target.Platform == UnrealTargetPlatform.Android)
        {
            AdditionalPropertiesForReceipt.Add(new ReceiptProperty("AndroidPlugin", ModuleDirectory + "/AndroidDownload_UPL.XML"));
        }
  1. 然后新增 文件夹 Jave\src\com\自己起个包名 ,然后在里面新增 DownloadService.java
package com.你起的包名;
import android.content.Intent;
import android.app.Service;
import android.os.Binder;
import android.os.IBinder;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context;
import android.os.Build;
import android.graphics.Color;
import androidx.annotation.RequiresApi;
import androidx.core.app.NotificationCompat;
import static androidx.core.app.NotificationCompat.PRIORITY_MIN;

public class DownloadService extends Service {
    public native void nativeDownloadQuit();
    
    //用来执行c++下载逻辑的线程对象,类在DownloadThread.java声明
    private DownloadThread HotUpdate_DownloadThread     = null;

    //在c++端控制创建
    public void StartDownloadThread()
    {
        if (HotUpdate_DownloadThread != null)           return;
        HotUpdate_DownloadThread                        = new DownloadThread();
        HotUpdate_DownloadThread.Init();
    }
    //在c++端控制销毁
    public void StopDownloadThread()
    {
        if (HotUpdate_DownloadThread == null)           return;
        nativeDownloadQuit();
        HotUpdate_DownloadThread.Shutdown();
        HotUpdate_DownloadThread                        = null;
    }

    public void Shutdown()
    {
        //关闭前台服务
        stopForeground(true);

        stopSelf();
    }

    @Override
    public IBinder onBind(Intent intent) { return new DownloadBinder(this); }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        //开始创建通知
        createNotification();

        return START_STICKY;
    }
    //创建通知
    private void createNotification() {
        String channelId = null;

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            channelId = createNotificationChannel("kim.hsl", "ForegroundService");
        } else {
            channelId = "";
        }
        NotificationCompat.Builder builder = new NotificationCompat.Builder(this, channelId);
        //这里可自己定制通知的内容,图标等,具体方法网上查
        Notification notification = builder.setOngoing(true)
                .setPriority(PRIORITY_MIN)
                .setCategory(Notification.CATEGORY_SERVICE)
                .build();
        //开启前台通知
        startForeground(1, notification);
    }
    
    @RequiresApi(Build.VERSION_CODES.O)
    private String createNotificationChannel(String channelId, String channelName){
        NotificationChannel chan = new NotificationChannel(channelId,
                channelName, NotificationManager.IMPORTANCE_NONE);
        chan.setLightColor(Color.BLUE);
        chan.setLockscreenVisibility(Notification.VISIBILITY_PRIVATE);
        NotificationManager service = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
        service.createNotificationChannel(chan);
        return channelId;
    }
    //通过DownloadBinder在外部获取service对象
    public class DownloadBinder extends Binder
    {
        private DownloadService HotUpdate_DownloadService = null;
        
        public DownloadBinder(DownloadService InDownloadService)
        {
            HotUpdate_DownloadService = InDownloadService;
        }

        public DownloadService GetDownloadService()
        {
            return HotUpdate_DownloadService;
        }
    }
}

  1. 新增 DownloadThread.java,这里面没有什么东西,就是在线程里一直执行我们c++的逻辑
package com.你起的包名;

class DownloadThread extends Thread
{
    public boolean              bRunning = false;
    public void Init()
    {
        bRunning                = true;
        this.start();
    }
    public void Shutdown()
    {
        bRunning                = false;
    }
    @Override
    public void run()
    {
        while(bRunning)
        {
            try
            {
                Thread.sleep(20);
                //这里通过JNI调用自己c++的下载方法
            }
            catch (Exception e)
            {
                e.printStackTrace();
            }
        }
    }
}

  1. 关键步骤,创建 AndroidDownload_UPL.XML 并添加内容
<?xml version="1.0" encoding="utf-8"?>
<root xmlns:android="http://schemas.android.com/apk/res/android">

  <!-- optional files or directories to copy to Intermediate/Android/APK -->
  <resourceCopies>
    <copyDir src="$S(PluginDir)/Java" dst="$S(BuildDir)" />
  </resourceCopies>
  <!-- 这些节点我就不一一介绍了,可到官方上看下每个的用处 -->
  <gameActivityImportAdditions>
    <insert>
      import com.你起的包名.DownloadService;
      import android.content.ServiceConnection;
      import android.content.ComponentName;
      import android.os.IBinder;
    </insert>
  </gameActivityImportAdditions>
  
  <gameActivityClassAdditions>
    <insert>
      <!-- 这些保存Service对象方便后续使用 -->
      private DownloadService HotUpdate_DownloadService = null;
      
      private ServiceConnection HotUpdate_Connection = new ServiceConnection() {
        @Override
        public void onServiceConnected(ComponentName name, IBinder binder) {
          HotUpdate_DownloadService = ((DownloadService.DownloadBinder)binder).GetDownloadService();
        }
        @Override
        public void onServiceDisconnected(ComponentName name) {}
      };
      <!-- 这里是总得启动Service入口,可在c++中开始下载任务时调用 -->
      public void AndroidThunkJava_HotUpdate_StartService()
      {
        Intent Hotupdate_intent = new Intent(getBaseContext(), DownloadService.class);
        
        <!-- 这里很重要,Android版本大于多少时启动前台服务必须调用 startForegroundService -->
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { startForegroundService(Hotupdate_intent); }
        else { startService(Hotupdate_intent); }
        
        bindService(Hotupdate_intent, HotUpdate_Connection, Context.BIND_AUTO_CREATE);
      }
      
      public void AndroidThunkJava_HotUpdate_StopService()
      {
        if(HotUpdate_DownloadService == null) return;
        
        unbindService(HotUpdate_Connection);
        HotUpdate_DownloadService.Shutdown();
        HotUpdate_DownloadService = null;
      }
      <!-- 这里是具体下载线程的执行入口,可在c++实际开始下载时调用 -->
      public void AndroidThunkJava_HotUpdate_StartDownloadThread() { if(HotUpdate_DownloadService != null) HotUpdate_DownloadService.StartDownloadThread(); }
      public void AndroidThunkJava_HotUpdate_StopDownloadThread() { if(HotUpdate_DownloadService != null) HotUpdate_DownloadService.StopDownloadThread(); }
    
    </insert>
  </gameActivityClassAdditions>

  <!-- optional additions to GameActivity onCreate in GameActivity.java -->
  <gameActivityOnCreateAdditions>
    <insert>
    </insert>
  </gameActivityOnCreateAdditions>
  
  <!-- optional additions to GameActivity onDestroy in GameActivity.java-->
  <gameActivityOnDestroyAdditions>
    <insert>
      AndroidThunkJava_HotUpdate_StopDownloadThread();
      AndroidThunkJava_HotUpdate_StopService();
    </insert>
  </gameActivityOnDestroyAdditions>
  
  <!-- 重点在这里,往安卓配置里添加一个服务和前台服务权限 -->
  <androidManifestUpdates>
    <addElements tag="application">
      <service
        android:name="com.你起的包名.DownloadService"
        android:exported="false" >
      </service>
    </addElements>
    <addPermission android:name="android.permission.FOREGROUND_SERVICE"/>
  </androidManifestUpdates>
</root>


📌上面演示了所有java端代码,并做了每个步骤的简单说明,核心思路很简单,就是开启一个前台服务,并且用通知保活服务,一定注意androidManifest 别忘了添加,我在手上的测试机上均已测试,后台,熄屏等情况下依然会下载至结束,包括华为等

四、IOS平台后台下载

IOS相关的文件

  1. 新建 IOSDownload_UPL.xml 文件,并且在 *.Build.cs中添加加载.XML的代码
if (Target.Platform == UnrealTargetPlatform.IOS)
{
    AdditionalPropertiesForReceipt.Add(new ReceiptProperty("IOSPlugin", ModuleDirectory + "/IOSDownload_UPL.XML"));
}
  1. 创建IOS文件夹,并且把一个小的无声音乐文件放进去即可
  2. 这里只演示主要OC代码,供大家参考
//.h
#if PLATFORM_IOS
#import <AVFoundation/AVFoundation.h>
#endif
class XXX {
  #if PLATFORM_IOS
    AVAudioPlayer*                      AudioPlayer;
    // IOS Handles
    FDelegateHandle                     EnterBackgroundHandle;
    FDelegateHandle                     EnterForegroundHandle;
  #endif
}
//.cpp
void IOSStartKeepAlive()
{
#if PLATFORM_IOS
    if (!EnterBackgroundHandle.IsValid())           FCoreDelegates::ApplicationWillEnterBackgroundDelegate.AddUObject(this, &HandleEnterBackground);
    if (!EnterForegroundHandle.IsValid())           FCoreDelegates::ApplicationHasEnteredForegroundDelegate.AddUObject(this, &HandleEnterForeground);
#endif
}

void IOSStopKeepAlive()
{
#if PLATFORM_IOS
    StopPlayAudio();

    if (EnterBackgroundHandle.IsValid())
    {
        FCoreDelegates::ApplicationWillEnterBackgroundDelegate.Remove(EnterBackgroundHandle);
        EnterBackgroundHandle.Reset();
    }
    if (EnterForegroundHandle.IsValid())
    {
        FCoreDelegates::ApplicationWillEnterBackgroundDelegate.Remove(EnterForegroundHandle);
        EnterForegroundHandle.Reset();
    }
#endif
}

void HandleEnterBackground()
{
#if PLATFORM_IOS
    StartPlayAudio();
#endif
}

void HandleEnterForeground()
{
#if PLATFORM_IOS
    StopPlayAudio();
#endif
}

void StartPlayAudio()
{
#if PLATFORM_IOS
    // 1. Get the audio file
    FString NativePath = FString([[NSBundle mainBundle]bundlePath] ) + TEXT("/Silence.wav");
    NSURL* nsURL = nil;
    nsURL = [NSURL fileURLWithPath : NativePath.GetNSString()];

    if (nsURL == nil)
    {
        return;
    }
    // 2. Setting the audio session
    AVAudioSession* session = [AVAudioSession sharedInstance];
   
    NSError* error = nil;
    if (@available(iOS 11.0, *))
    {
        [session setCategory:AVAudioSessionCategoryPlayback mode : AVAudioSessionModeDefault routeSharingPolicy : AVAudioSessionRouteSharingPolicyDefault options : AVAudioSessionCategoryOptionMixWithOthers error : &error];
    }
    else
    {
        [session setCategory:AVAudioSessionCategoryPlayback mode : AVAudioSessionModeDefault options : AVAudioSessionCategoryOptionMixWithOthers error : &error];
    }

    if (error)
    {
        return;
    }
    // 3. Start the audio
    AudioPlayer = nil;
    AudioPlayer = [[AVAudioPlayer alloc]initWithContentsOfURL:nsURL error : nil];
    if (AudioPlayer == nil)
    {
        return;
    }
    else
    {
        AudioPlayer.numberOfLoops = -1;
        AudioPlayer.volume = 0.0;
        [AudioPlayer prepareToPlay];
        [AudioPlayer play];
    }
#endif
}

void StopPlayAudio()
{
#if PLATFORM_IOS
    UE_LOG(HotUpdateLog, Display, TEXT("Audio Stop"));
    if (AudioPlayer != nil)
    {
        [AudioPlayer stop];
        AudioPlayer = nil;
    }
#endif
}

  1. 重要步骤,inso.plist添加权限和拷贝音乐文件到ipa包中
<?xml version="1.0" encoding="utf-8"?>
<root>
    <iosPListUpdates>
        <copyFile src="$S(PluginDir)/IOS/Silence.wav" dst="$S(BuildDir)/Silence.wav" force="true"/>
        <addElements tag="dict" once="true">
            <key>UIBackgroundModes</key>
            <array>
              <string>audio</string>
            </array>
        </addElements>
    </iosPListUpdates>
</root>

📌注意:声音文件一定要放到Source下面的文件夹中,如果你要是放到了Content下面Mac机远程打包时默认不会上传,而且必须要通过上面copy到ipa包根目录下,不然在OC代码中获取不到虚幻里面的资源

五、总结

  1. UE4移动端后台下载的适用场景

    本教程只适用于UE4.27.2的移动端后台下载,其他版本我没有测试过,这个教程在安卓和IOS端均可适用

  2. 技术难点和注意事项

    • 安卓

      这里可以看到用的是前台任务并且开启通知保活的方案;
      缺点:通知栏会有一个通知常驻,有些机型不会有,通知栏网上有教程说通过卡bug的方式关 闭通知并且保活,我没成功,好像不行了,有兴趣的可以试试
      优点:非常坚挺,基本什么除了进程被杀掉之外的所有情况均可高速下载,大家也可以多测试 测 试

      注意:因为我不是专业安卓平台开发,我知道的是肯定是有更加完美的方案,可以做到在后台静悄 悄的下载,看不见任何东西开启,包括后台下载等方式我都试过,不过不是过段时间被杀掉就是会 有其他问题,也欢迎有知道其他方案的小伙伴在下面留言讨论

    • 苹果
      用的是后台无声音乐播放保活的方式
      缺点:可能权限在苹果包提交审核时会有失败的情况,但是应该是能过审核的,因为有发现其他知 名游戏使用此方案

      优点:就是简单粗暴,而且非常稳定,我也测试过很多种情况,不会打断后台下载

大家有问题的可以在下面留言,时间比较少,想写的文章比较多,可能会出现纰漏或错误,有其他方案和想法的也可以留言,谢谢

📌附件下载:无声音乐下载

六、参考文献

IOS后台播放示例项目

Android如何降低service被杀死概率 - 简书 (jianshu.com)

Android 进程保活 - 简书 (jianshu.com)

Android开发之定时任务(AlarmManager、WorkManager)-CSDN博客