图形引擎实战:后台下载移动平台调试及iOS开发介绍
后台下载移动平台调试及iOS开发介绍
关于后台下载Unity插件的整体设计架构以及主要功能分析详情请见另一篇开发文档[1],这里对安卓与iOS后台下载功能修复调试过程中遇到的问题进行总结并对iOS平台的后台下载实现进行基本说明。
1.安卓平台调试
此节将分析批量测试中的一个异常并提供解决方法,同时周知正在设计安卓后台下载功能架构的开发者如何选择合适的任务类型。
在进行大规模的安卓多机型测试后,关于后台下载功能反馈回来的崩溃及异常中,
Exception Name:
android.app.RemoteServiceException$ForegroundServiceDidNotStartInTimeException
Error Message: Context.startForegroundService() did not then call Service.startForeground()
在Android13以上的机型中占比较高,在展开说明原因之前,先看以下出自安卓开发文档[2]的逻辑流程图确定如何选择用户启动任务的类型,
当前开发的下载组件中创建并使用了两个服务,一个是DownloadService,用于管理下载状态栏和多个下载任务,另一个是LiveService,简单的用于展示下载完成或下载失败的状态提示栏,启用这两个服务时会调用Service.startForeground来声明并指定该服务为前台服务,但在此之前需要先调用Context.startForegroundService来启动服务本身,以上提到的崩溃问题就出自这里。
通常此类异常原因是两接口调用的时间间隔过大导致,结合相关文档和宕机日志,并通过多次不同机型的测试,本次测试案例不仅与Service.startForeground是否成功调用有关,还与Context.stopService或者Service.stopSelf的调用位置是否合理有关。通常情况下,Context.startForegroundService后没有调用Service.startForeground就直接调用Context.StopService终止服务便会复现此问题,或者在没有调用Service.startForeground之后的数秒(不同机型可能时间不同)就会抛出相同的异常。
此类问题的解决办法有很多,首先关于停止服务调用位置的两种处理方式如下:
- 在Service.onCreate中调用Service.startForeground,在Service.onStartCommand函数中使用Service.stopSelf来取代外部Context.stopService接口,何时调用可以通过intent的扩展数据来传入,如:Intent.putExtra("STOP_SERVICE", true);等,因为onStartCommand一定在onCreate之后调用,保证了不会出现终止前还没有指定前台服务的情况。
- 因为DownloadService需要绑定到组件进行请求的发送和接收,因此可以通过Service.onBind监听到是否绑定完成来判断是否已经onCreate结束,然后自行记录一个命令队列,如果绑定成功,自定义指令如终止服务等,便可插入到命令队列中,如果还未绑定或者意外解除绑定,那么命令将会被排除在队列之外,是否缓存指令将根据需求来定。
关于没有成功调用Service.startForeground的可能性在此项目案例中是存在的,在某些平台包上是能够复现的,问题出在了Notification是否成功创建的判断上(因为startForeground需要传入Notification并且不能为空),而在一些平台上,由于API使用的问题,比如PendingIntent的FLAG_UPDATE_CURRENT标识在目标SDK版本31以上就会导致异常,进而影响后续通知栏的创建,从而导致未能成功调用Service.startForeground,以下是一些解决方法:
- 因为前序测试大部分机型对使用FLAG_UPDATE_CURRENT的使用不会抛出异常,所以为了不影响这些机型,需要对SDK版本进行适配判断,在某些环境下使用FLAG_IMMUTABLE或者FLAG_MUTABLE来代替FLAG_UPDATE_CURRENT,某些环境下保留,并对此段代码进行TryCatch捕获(使用Throwable代替Exception),保证不影响后续的通知栏创建。
- 或者可以对Notification创建函数外层进行TryCatch捕获,如果创建失败抛出异常,那么就使用一套默认的最简化的Notification(目前使用的通知栏功能较多,调用API不能保证在所有环境下适配),这样可以保证前台能够成功启用,不会导致宕机问题。
或者不使用以上方案,采用以下方法来规避杜绝此类问题:
- 使用Context.startService代替Context.startForegroundService在前台启动前台服务,但有一些限制[3],如:
目标SDK版本O(26)以上,不允许后台启动后台服务
目标SDK版本S(31)以上,不允许后台启动前台服务
这样会规避掉Context.startForegroundService引起的问题,但是修改后不会再有批量测试验证,因此暂未使用此方法。
结合以上流程图,关于后台下载任务的实现,使用前台服务处理的方式其实并不是最佳的,因为前台服务将会占用更多系统资源,并且系统限制也相当多,适合且能性能最大化的做法是采用backgroundWork或者JobService的方式,因为处于项目后期,不能再有较大变动,所以实践并测试高效后台下载方法将会留在之后的迭代中。
2. iOS平台实现相关
此节将提供iOS平台上实现后台下载相关的基础内容,主要从功能点出发介绍一下底层API的选择和创建流程
2.1 文件后台下载
- 配置Background Session
- 首先通过backgroundSessionConfigurationWithIdentifier方法初始化一个NSURLSessionConfiguration[4]实例,此方法将接收identifier字符串,并且需要保证identifier是应用内唯一的。
- 将NSURLSessionConfiguration实例上的sessionSendsLaunchEvents属性设置为True以保证当应用处于后台且任务完成时系统能够唤起应用。
- 通过NSURLSessionConfiguration实例创建NSURLSession[5]实例,这里注意后台Session应该尽可能少,理想状态是只有一个,然后通过Session来同时创建多个下载任务,因为为了防止滥用后台功能,应用在后台状态下开始新下载任务时,该任务会延迟一定时间后才执行,每次系统恢复或者重启应用时,这个延迟时间都会增加,当应用回到前台,延迟时间将会被重置为0。
以上内容在DownloadManager.m中的样例代码如下:
@interface DownloadManager ()
<NSURLSessionDelegate,NSURLSessionDownloadDelegate>
// ...
+ (instancetype)GetInstance
{
static DownloadManager* inst = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
inst = [[DownloadManager alloc] init];
[inst initSome];
});
return inst;
}
// 初始化函数
- (void)initSome
{
// 单线程代理队列
NSOperationQueue *queue = [[NSOperationQueue alloc] init];
queue.maxConcurrentOperationCount = 1;
NSDictionary<NSString *, id> *infoDictionary = [NSBundle mainBundle].infoDictionary;
NSString *bundleId = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleIdentifier"];
NSString *identifier = [NSString stringWithFormat:@"%@.BackgroundSession", bundleId];
// 创建后台NSURLSessionConfiguration实例
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:identifier];
configuration.allowsCellularAccess = YES;// 是否允许蜂窝网络下载
configuration.sessionSendsLaunchEvents = YES;// 是否后台唤醒
configuration.discretionary = NO;// 是否等待最佳传输条件
configuration.HTTPMaximumConnectionsPerHost = [[TakeDownloadEngine GetInstance]
GetMaxDownloadQueue];// 最大并发连接数
configuration.timeoutIntervalForRequest = 60;// 请求花费最大时间(秒)
configuration.timeoutIntervalForResource = 60*60;// 资源请求花费最大时间(秒)
configuration.networkServiceType = NSURLNetworkServiceTypeDefault;// 网络会话中所有
任务启用蜂窝网络切片所需的网络服务类型
// 创建NSURLSession,配置信息、代理、代理线程
_session = [NSURLSession sessionWithConfiguration:configuration delegate:self
delegateQueue:queue];
_tasks = [NSMutableArray array];
}
// ...
4. 提供NSURLSessionDelegate以及NSURLSessionDownloadDelegate回调来接收后台事件。
以下提到的DownloadTask、DownloadFileInfo均为自行封装结构,使用给定下载URL、下载文件信息以及NSURLSessionDownloadTask[6]来初始化前者,后者包含下载文件相关的信息,如文件路径、下载ID、下载文件大小、是否完成下载、错误次数等内容
下载任务结束且有错误(error变量只会包含客户端的错误信息,如果需要接收服务器报错,需要查看task变量的response属性)
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)sesionTask
didCompleteWithError:(NSError *)error
{
// 1.查找本地数据中的DownloadFileInfo对象
// 如果DownloadFileInfo存在,则尝试从error.userInfo中获取并解析resumeData,获取已下载的字节数,更新本地数据
// 2.遍历_tasks数组,找到对应的DownloadTask对象,并将其从数组中移除。接着更新DownloadTask对象的完成时间和错误次数,并调用代理方法传递下载失败的消息,下载失败计数器加1
}
下载任务结束且无报错(需要读取文件或者将文件从临时路径移出)
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location
{
// 1.根据下载任务的taskDescription找到对应的DownloadTask对象并更新其状态,将下载文件从临时路径移动到目标路径。如果移动成功,则通知代理下载成功;如果移动失败,则通知代理下载失败,并记录错误信息
}
下载过程中,周期性通知下载进度(如果不关心进度,只关心是否完成,可以跳过这里)
监控下载文件进度等具体详情请参考此文章[7]
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite
{
// 1.根据任务描述在_tasks数组中找到对应的DownloadTask对象
// 2.同步更新已写字节数和总字节数
// 3.计算当前时间和上一次更新时间的差值,如果大于n(这里取1000)毫秒,则更新本地数据并触发下载消息通知
}
其它回调参考这里[8],根据需求添加
- 下载任务的创建与断点续传
- 查看本地是否有断点续传的数据(即resumeData,缓存resumeData对象的操作需要在下载失败或者手动取消下载的回调中完成[9]),如果有则通过downloadTaskWithResumeData[10]来创建:
NSURLSessionDownloadTask *sessionTask;
sessionTask = [_session downloadTaskWithResumeData:infoInLocalData.resumeData];
如果没有可以通过downloadTaskWithRequest[11]或者downloadTaskWithURL进行创建。(*若支持同时下载多个文件,便需要创建多个NSURLSessionDownloadTask)
NSURL* nsurl = [NSURL URLWithString:infoInLocalData.url];
NSURLRequest* req = [NSURLRequest requestWithURL:nsurl cachePolicy:NSURLRequestUseProtocolCachePolicy timeoutInterval:timeout];
if(req != nil)
{
sessionTask = [_session downloadTaskWithRequest:req];
}
2. 通过调用resume接口来开始/继续任务。
[sessionTask resume];
以下给出的是启动下载任务的解释代码:
- (bool)startTask:(DownloadFileInfo*)info
{
// 1.查看info是否有效,并且在_tasks缓存数组中是否有相同URL的任务正在运行,在有效且无相同URL任务的前提下进行以下步骤
// A.获取本地下载断点数据,根据不同情况来分别调用不同的接口来进行任务创建
// B.更新下载任务描述,即URL
// C.启动下载任务
// D.创建DownloadTask对象并加入_tasks数组
// E.更新计数器和委托方法
}
2.2 本地推送
这里仅涉及到本地消息通知等内容,若想了解相关的完整内容,可以参考User Notifications框架[12]
- 权限管理
- 在推送本地通知之前,需要确保通知权限已经开启或者提示开启。
以下代码用于获取当前用户的通知设置,并在回调中检查通知权限是否被授权。如果未授权,则在主线程中弹出一个警告框,提示用户开启通知权限,并提供前往设置页面的选项。
- (void)checkSettings
{
[UNUserNotificationCenter.currentNotificationCenter getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings * _Nonnull settings) {
if(settings.authorizationStatus != UNAuthorizationStatusAuthorized) {
// 未打开通知权限的情况
dispatch_async(dispatch_get_main_queue(), ^{
NSString* message = @"没有打开通知权限,请在设置中开启";
UIAlertController * alert = [UIAlertController alertControllerWithTitle:nil message:message preferredStyle:UIAlertControllerStyleAlert];
UIAlertAction * cancelAction = [UIAlertAction actionWithTitle:@"取消" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { }];
UIAlertAction * setAction = [UIAlertAction actionWithTitle:@"去设置" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
dispatch_async(dispatch_get_main_queue(), ^{
NSURL * url = [NSURL URLWithString:UIApplicationOpenSettingsURLString];
[UIApplication.sharedApplication openURL:url options:nil completionHandler:^(BOOL success) {
}];
});
}];
[alert addAction:cancelAction];
[alert addAction:setAction];
});
}
}];
}
- 本地消息推送
这里将展示本地消息的基本推送流程,可以将其放置在下载开始时、下载过程中、下载结束时等位置
- 首先会为Notification初始化一个可编辑内容对象UNMutableNotificationContent[13],并修改内容的标题、内容、音效等信息
UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init];
content.title = _title;
content.body = _body;
content.sound = UNNotificationSound.defaultSound;
content.categoryIdentifier = @"customUICategory";
2. 然后创建一个Notification Trigger[14],设置多长时间之后进行推送
UNTimeIntervalNotificationTrigger *trigger = [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:1 repeats:NO];// 1秒之后推送
3. 创建Notification Request[15],它将包含通知的推送内容和触发条件
NSString *identifier = @"DeliverAfter1Second";
UNNotificationRequest *request = [UNNotificationRequest requestWithIdentifier:identifier content:content trigger:trigger];// 对于Notification Request来说identidier需要是唯一的
4. 将request添加到Notification Center[16]
[UNUserNotificationCenter.currentNotificationCenter addNotificationRequest:request withCompletionHandler:^(NSError * _Nullable error) {
if(error) {
NSLog(@"Failed to schedule. error:%@",error);
} else{
NSLog(@"Notification scheduled:%@",identifier);
}
}];
2.3 其它内容
- 音效播放
作为一些事件触发的提示,比如下载完成、应用进入后台、下载剩余时长达到某个阶段等,通常音效的播放也是必不可少的
- 首先要从Bundle中加载音效音频文件
- 在获取文件URL后,创建AVAudioPlayer[17]
AVAudioPlayer *audioPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:fileURL error:nil];
3. 在设置好音量、循环次数等参数后,可以通过调用prepareToPlay[18]来准备一个播放器,通常包含预加载音频Buffer以及申请硬件许可等内容
4. 使用AVAudioSession[19]来设置音频类目和模式并将其设置为活动状态,进而调用play播放音频
if(_player != nil)
{
if([[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback withOptions:AVAudioSessionCategoryOptionMixWithOthers error:NULL])
{
[[AVAudioSession sharedInstance] setActive:YES error:NULL];
}
[_player play];
}
iOS后台下载这里未涉及到的内容以及注意事项请前往查阅此文章[20]
3. iOS平台调试
iOS调试项目反馈信息较少,批量测试后导致异常宕机问题的主要集中在线程安全上
- synchronized对象在DownloadManager中因为使用了tasks这样的多线程共享资源,因此要使用synchronized进行线程同步,但是需要注意同步相同资源synchronized传入的对象需要保持一致(因为底层要使用对象作为Hashmap的Key),比如以下写法,分别在不同的线程上进行数组的读写操作,但是synchronized对象不同就会出现问题:
@interface SyncData : NSObject
@property NSString* url;
@end
@implementation SyncData
@end
@interface SyncDataMgr : NSObject
@property (nonatomic, strong) NSMutableArray<SyncData*>* dataArray;
@end
@implementation SyncDataMgr
- (void)syncTestFunc{
_dataArray = [NSMutableArray array];
dispatch_queue_t readQueue = dispatch_queue_create("readQueue", 0);
dispatch_queue_t writeQueue = dispatch_queue_create("writeQueue", 0);
dispatch_async(readQueue, ^{
while (true) {
@synchronized (self) {
for(int i = 0; i < self.dataArray.count; i++)
{
SyncData* temp = self.dataArray[i];
if(temp.url)
{
break;
}
}
}
}});
dispatch_async(writeQueue, ^{
while (true) {
@synchronized (self.dataArray) {
[self.dataArray addObject:[[SyncData alloc] init]];
}
}
});
}
@end
合理的写法是将synchronized对象统一,如下所示:
dispatch_async(readQueue, ^{
while (true) {
@synchronized (self.dataArray) {//这里改为self.dataArray
for(int i = 0; i < self.dataArray.count; i++)
{
SyncData* temp = self.dataArray[i];
if(temp.url)
{
break;
}
}
}
}
});
- 循环引用
在实现过程中,同时还需要注意强引用相互引用的问题,比如NSURLSession在初始化时会记录delegate的强引用,而通常初始化的类中,比如此案例中的DownloadManager也会记录NSURLSession的强引用:
DownloadManager.m
// 属性声明.
@property (nonatomic, strong) NSURLSession* session;
//...
// 初始化Session.
_session = [NSURLSession sessionWithConfiguration:configuration delegate:self
delegateQueue:queue];//delegate:self -> keep strong reference to DownloadManager
//...
因此需要在下载完成时以及DownloadManager销毁时释放会话(通过调用InvalidateAndCancel或者finishTasksAndInvalidate)并置空。
[1]图形引擎实战:手游Android端后台下载技术分享
[2]安卓开发者文档:后台任务概览
[3]安卓开发者文档:Context.startService
[4]iOS开发文档: NSURLSessionConfiguration
[5]iOS开发文档: NSURLSession
[6]iOS开发文档: NSURLSessionDownloadTask
[7]iOS开发文档: Downloading files from websites
[8]iOS开发文档: NSURLSessionDelegate NSURLSessionDownloadDelegate
[9]iOS开发文档: Pausing and resuming downloads
[10]iOS开发文档: downloadTaskWithResumeData
[11]iOS开发文档: downloadTaskWithRequest
[12]iOS开发文档: User Notifications
[13]iOS开发文档: UNMutableNotificationContent
[14]iOS开发文档: UNTimeIntervalNotificationTrigger
[15]iOS开发文档: UNNotificationRequest
[16]iOS开发文档: UNUserNotificationCenter
[17]iOS开发文档: AVAudioPlayer
[18]iOS开发文档: prepareToPlay
[19]iOS开发文档: AVAudioSession
[20]iOS开发文档: Downloading files in the background
欢迎加入我们!
感兴趣的同学可以投递简历至:CYouEngine@cyou-inc.com
#我的求职思考##游戏开发##引擎开发工程师##校招##求职#