最近观看 WWDC 2020
关于电量和性能调试的 session: Diagnose performance issues with the Xcode Organizer. 苹果在 iOS 14 系统中进一步提供了监控线上 App 性能的工具, 可以监控线上 App 运行时发生的 ScrollView 卡顿
和 磁盘写过多问题
在Xcode -> Window -> Organizer
能够看到各上线版本的情况. 隐含的要求是在打包机器上查看, 因为打包机器上具有 dsym
文件, 可以顺利解析苹果记录 App 运行时的堆栈信息. 依据这个线索,查看了 WWDC 历年关于电池优化的部分。
2019 年
电量优化覆盖 App 开发到发布的 3 个阶段:
1 单元测试和UI测试阶段
2 Beta 信息收集
3 App 发布阶段
对发布的App, 通过 Organizer
可以看到不同版本 App 在不同设备上各子系统的表现, 进而分析发布版本的质量.
消耗电量的子系统包括:
编号 | 系统 | 衡量标准 |
---|---|---|
1 | 计算过程 | CPU 时间和 GPU 时间 |
2 | 定位 | 累计使用的时间;后台运行时间 |
3 | 显示 | AVL (Average Pixel Luminance) 平均显示亮度 |
4 | 网络 | 上传/下载的流量; 链接过程 |
5 | 蓝牙 音乐 相机 等 | - |
性能指标
编号 | 项目 | 优化方式 |
---|---|---|
1 | Hangs(不响应时间, 主线程卡顿) | 移除占用主线程的代码 |
2 | Disk(磁盘写情况) | 减少不必要的写入;合并写入 |
3 | Application Launch (启动时间) Resum Time(从后台恢复时间) | 使用 TimeProfile 分析耗时 |
4 | 内存 | 挂起时占用的平均内存; 使用时内存峰值 |
5 | 自定义打点时间 | - |
1 XCTest Metrics
的使用
例如, 开发过程中, 有如下两个获取日期的方式: fun1
和 fun2
, 可以使用单元测试来评估两种方式的优劣. 在真机上运行后, 可以清晰看到fun1
消耗的物理内存:
- (void)fun1 {
NSDateFormatter *newDateForMatter = [[NSDateFormatter alloc] init];
[newDateForMatter setDateFormat:@"yyyy-MM-dd"];
NSString *str = [newDateForMatter stringFromDate:[NSDate date]];
}
- (void)fun2 {
time_t timeInterval = [NSDate date].timeIntervalSince1970;
struct tm *cTime = localtime(&timeInterval);
NSString *str = [NSString stringWithFormat:@"%d-%02d-%02d", cTime->tm_year + 1900, cTime->tm_mon + 1, cTime->tm_mday];
}
- (void)testPerformanceExample {
[self measureWithMetrics:@[[XCTClockMetric new],
[XCTMemoryMetric new],
[XCTCPUMetric new]] block:^{
[self fun1];
// [self fun2];
}];
}
还可以通过 XCTest Metrics
查看其它指标: 执行时间, CPU 运行时间, 物理内存峰值等.
需要注意的是,在运行 XCTest 来测试 App 的性能时,不要启用 Xcode 的 debugger(去掉下图中勾选的选项),也不要开启 Xcode 的一些诊断选项,例如僵尸对象检测、内存分配记录等,以避免它们影响到应用的性能表现。可以在项目中创建一个新的 scheme 来关闭这些干扰项。
通过 Xcode UITest
可以获取启动时间:
- (void)testLaunchPerformance {
if (@available(macOS 10.15, iOS 13.0, tvOS 13.0, *)) {
// This measures how long it takes to launch your application.
[self measureWithMetrics:@[XCTOSSignpostMetric.applicationLaunchMetric] block:^{
[[[XCUIApplication alloc] init] launch];
}];
}
}
2 使用 MetricKit
收集线上 App 运行数据
App 在运行时, 苹果会对其过去 24 小时的运行数据进行汇总分析, 形成日志记录在设备上, 还会传输给订阅数据的 App, 订阅方法很简单:
最后在订阅方法中返回苹果汇总的数据:
通过调用
payload
的方法, 可以将数据转变为 dict
和 json
, 文章末尾放置了一个完整的 payload
数据情况.
通过 mxSignposts
还可以自定义打点, 供苹果汇总数据。 创建方式:
func rightBtnClick() -> Void {
let photosLogHandle : OSLog = MXMetricManager.makeLogHandle(category: "Photos")
// 2. Drop mxSignpost around critical code sections
mxSignpost(.begin, log: photosLogHandle, name: "SavePhoto")
SavePhoto() // Application code
mxSignpost(.end, log: photosLogHandle, name: "SavePhoto")
}
func savePhoto() {
let viewSize = self.view.bounds.size;
UIGraphicsBeginImageContext(viewSize)
view.layer.render(in: UIGraphicsGetCurrentContext()!)
let image = UIGraphicsGetImageFromCurrentImageContext()!
UIGraphicsEndImageContext()
UIImageWriteToSavedPhotosAlbum(image, self, #selector(image(image:didFinishSavingWithError:contextInfo:)), nil)
}
通过以上收集的数据, 可以识别出 App 耗电的地方, 比如定位精度过高/发生卡顿等.
WWDC 2018 what’s new in energy debugging?
App 运行时消耗的 energy
是 power
和 time
的乘积.
overhead
为完成任务时, 唤醒硬件消耗带来的电量, 如使用网络时激活无线电, 共享单车时激活的蓝牙等.
消耗能量最多的 4 个子系统为: 过程处理
、网络
、定位
和 画面绘制
。
编号 | 系统 | 衡量标准 |
---|---|---|
1 | 过程处理 | 任务越多,消耗能量越多。 |
2 | 网络(蜂窝网络、wifi、蓝牙) | 请求越多, 消耗能量越多 |
3 | 定位 (GPS 定位、蜂窝网络和 wifi 网络定位) | 精度越高、使用时间越长,消耗能量越多 |
4 | 画面绘制 | 动画和 UI 显示越复杂, 消耗能量越多 |
节省能量的优化
1 不要用定时请求的方式,因为即使是很小的网络请求,也会激活硬件设备,有冰山效应:
替换为用户交互和通知刷新的方式,能够明显减少电量:
2 不要采用复杂的 UI,播放视频时,操作视频的设置(进度条/暂停等)要隐藏,系统内部在播放时能够对消耗电量进行优化。
3 使用后台任务。系统会自动采用最优方式使用网络,适用于日志的集中上传,而不要在日志产生时立即上传到服务器。要及时结束后台任务,避免产生任务尾部的能量消耗。能够申请后台任的最长时间为 10 分钟,具体使用方式参考文末代码。
调试能量消耗的工具
在真机上运行 App, 在指标选项卡中,能够看到能量分析的界面:
从图中能够清晰看出各子系统消耗的电量情况。
查看详细的异常代码,可以采用底部的堆栈日志(Xcode bug, 需要将鼠标放在窗口最右边缘,然后下拉),点击后会打开 Time Profile
, 查看详细情况(要显示对应代码,需要在 Xcode 设置生成 DSYM 信息)。
更详细的调试可以采用 Instrument 中的工具:
查看电量问题的新方式
耗能日志信息记录消耗 CPU 严重的电量事件:占用 80% 的CPU,在前台持续时间 3 分钟;或者在后台持续时间 1 分钟。这会消耗设备的电量超过 1%,相当于 8 分钟通话、6 分钟网页浏览和 30 分钟音乐时间。
通过对App运行时调用的堆栈进行不断采样,形成日志信息。比如进行了 6 次采样,其中 5 次调用了 method1
, 1 次调用了 method3
. 折算成权重分别为 83% 和 17%。
Apple 会自动(用户需要打开共享日志)将日志记录上传到苹果服务器进行整理分析,开发者在 Organizer
看到最终的结果。可以查看到消耗电量的代码片段排名,对解决的代码段可以标记完成。也可以对影响的设备进行筛选查看。
2017 Writing Energy Efficient Apps
App 消耗电量分为两部分:激活硬件和代码调用。
优化方式
1 对网络请求可以使用 WaitsForConnectivity
和 Cache
. WaitsForConnectivity
能够等待网络链接后,立即发送网络请求,而不是直接提示网络失败,这样能够减少再次发送请求时激活无线电的能量。
// Setup NSURLSession Default Session
let config = URLSessionConfiguration.default()
// Use WaitsForConnectivity
config.waitsForConnectivity = true
// NSURLSession Cache
let cachesDirectoryURL = FileManager.default().urlsForDirectory(.cachesDirectory,
inDomains: .userDomainMask).first!
let cacheURL = try! cachesDirectoryURL.appendingPathComponent("MyCache")
var diskPath = cacheURL.path
let cache = URLCache(memoryCapacity:16384, diskCapacity: 268435456, diskPath: diskPath)
config.urlCache = cache
config.requestCachePolicy = .useProtocolCachePolicy
要减少网络 retry
的次数;设置超时时间(减少等待时能量消耗);打包一些操作(减少多次唤醒硬件设备的时间)。当 retry
次数触发时,使用 Background Session
,让系统自动选择最佳时间执行。
2 对定位的使用要及时结束,避免后台持续使用:
3 关于图像绘制,要尽量减少不必要的界面更新。避免在动画图像上层使用模糊效果。
4 后台任务和 push
消息均要及时调用 completion
结束。
附录:
1 iOS13 中 Metric
上报数据详情:
{
appVersion = "1.0";
applicationLaunchMetrics = { // 启动时间
histogrammedResumeTime = { //后台启动
histogramNumBuckets = 3;
histogramValue = {
0 = {
bucketCount = 60;
bucketEnd = "210 ms";
bucketStart = "200 ms";
};
1 = {
bucketCount = 70;
bucketEnd = "310 ms";
bucketStart = "300 ms";
};
2 = {
bucketCount = 80;
bucketEnd = "510 ms";
bucketStart = "500 ms";
};
};
};
histogrammedTimeToFirstDrawKey = { //点击按钮到第一个页面渲染
histogramNumBuckets = 3;
histogramValue = {
0 = {
bucketCount = 50;
bucketEnd = "1,010 ms";
bucketStart = "1,000 ms";
};
1 = {
bucketCount = 60;
bucketEnd = "2,010 ms";
bucketStart = "2,000 ms";
};
2 = {
bucketCount = 30;
bucketEnd = "3,010 ms";
bucketStart = "3,000 ms";
};
};
};
};
applicationResponsivenessMetrics = {//没有响应
histogrammedAppHangTime = {
histogramNumBuckets = 3;
histogramValue = {
0 = {
bucketCount = 50;
bucketEnd = "100 ms";
bucketStart = "0 ms";
};
1 = {
bucketCount = 60;
bucketEnd = "400 ms";
bucketStart = "100 ms";
};
2 = {
bucketCount = 30;
bucketEnd = "700 ms";
bucketStart = "400 ms";
};
};
};
};
applicationTimeMetrics = {//运行时间
cumulativeBackgroundAudioTime = "30 sec";
cumulativeBackgroundLocationTime = "30 sec";
cumulativeBackgroundTime = "40 sec";
cumulativeForegroundTime = "700 sec";
};
cellularConditionMetrics = {// 网络时的电量情况
cellConditionTime = {
histogramNumBuckets = 3;
histogramValue = {
0 = {
bucketCount = 20;
bucketEnd = "1 bars";
bucketStart = "1 bars";
};
1 = {
bucketCount = 30;
bucketEnd = "2 bars";
bucketStart = "2 bars";
};
2 = {
bucketCount = 50;
bucketEnd = "3 bars";
bucketStart = "3 bars";
};
};
};
};
cpuMetrics = {// CPU使用时间
cumulativeCPUTime = "100 sec";
};
diskIOMetrics = { //IO使用时间
cumulativeLogicalWrites = "1,300 kB";
};
displayMetrics = { //平均亮度
averagePixelLuminance = {
averageValue = "50 apl";
sampleCount = 500;
standardDeviation = 0;
};
};
gpuMetrics = { //GPU时间
cumulativeGPUTime = "20 sec";
};
locationActivityMetrics = {
cumulativeBestAccuracyForNavigationTime = "20 sec";
cumulativeBestAccuracyTime = "30 sec";
cumulativeHundredMetersAccuracyTime = "30 sec";
cumulativeKilometerAccuracyTime = "20 sec";
cumulativeNearestTenMetersAccuracyTime = "30 sec";
cumulativeThreeKilometersAccuracyTime = "20 sec";
};
memoryMetrics = { //内存情况
averageSuspendedMemory = {
averageValue = "100,000 kB";
sampleCount = 500;
standardDeviation = 0;
};
peakMemoryUsage = "200,000 kB";
};
metaData = { //元数据
appBuildVersion = 1;
deviceType = "iPhone10,3";
osVersion = "iPhone OS 13.2.3 (17B111)";
regionFormat = CN;
};
networkTransferMetrics = { // 网络流量
cumulativeCellularDownload = "80,000 kB";
cumulativeCellularUpload = "70,000 kB";
cumulativeWifiDownload = "60,000 kB";
cumulativeWifiUpload = "50,000 kB";
};
signpostMetrics = ( //自定义埋点
{
signpostCategory = TestSignpostCategory1;
signpostIntervalData = {
histogrammedSignpostDurations = {
histogramNumBuckets = 3;
histogramValue = {
0 = {
bucketCount = 50;
bucketEnd = "100 ms";
bucketStart = "0 ms";
};
1 = {
bucketCount = 60;
bucketEnd = "400 ms";
bucketStart = "100 ms";
};
2 = {
bucketCount = 30;
bucketEnd = "700 ms";
bucketStart = "400 ms";
};
};
};
signpostAverageMemory = "100,000 kB";
signpostCumulativeCPUTime = "30,000 ms";
signpostCumulativeLogicalWrites = "600 kB";
};
signpostName = TestSignpostName1;
totalSignpostCount = 30;
},
{
signpostCategory = TestSignpostCategory2;
signpostIntervalData = {
histogrammedSignpostDurations = {
histogramNumBuckets = 3;
histogramValue = {
0 = {
bucketCount = 60;
bucketEnd = "200 ms";
bucketStart = "0 ms";
};
1 = {
bucketCount = 70;
bucketEnd = "300 ms";
bucketStart = "201 ms";
};
2 = {
bucketCount = 80;
bucketEnd = "500 ms";
bucketStart = "301 ms";
};
};
};
signpostAverageMemory = "60,000 kB";
signpostCumulativeCPUTime = "50,000 ms";
signpostCumulativeLogicalWrites = "700 kB";
};
signpostName = TestSignpostName2;
totalSignpostCount = 40;
}
);
timeStampBegin = "2020-07-05 16:00:00 +0000"; //生成时间
timeStampEnd = "2020-07-06 15:59:00 +0000";
}
使用后台任务
+ (instancetype)sharedTask {
static WBBackgroundTask *task;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
task = [self new];
});
return task;
}
- (void)startTask {
if (![self _checkSupportBackgroundTask]) {
NSLog(@"BackgroundTask. Current device don't support backgroundTask.");
return;
}
UIApplication *application = [UIApplication sharedApplication];
__block UIBackgroundTaskIdentifier taskId;
/// 申请后台执行
/// 注意: 在iOS7和该版本前,后台可以用下面的的方式在后台存活5-10分钟,在iOS8及后,最多存活3分钟
{
taskId = [application beginBackgroundTaskWithName:NSStringFromClass([self class]) expirationHandler:^{
NSLog(@"BackgroundTask. BackgroundTask is Over. The remained time: %f", application.backgroundTimeRemaining);
[application endBackgroundTask:taskId];
taskId = UIBackgroundTaskInvalid;
}];
}
if (UIBackgroundTaskInvalid == taskId) {
NSLog(@"BackgroundTask. Apply backgroundTask failed.");
return;
}
/// 可以监控后台任务剩余的时间, 针对业务可以去处理
{
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
__block NSTimeInterval remainedTime;
while (true) {
// 剩余可以后台执行的时间
dispatch_async(dispatch_get_main_queue(), ^{
// application.backgroundTimeRemaining 必须在主线程获取
remainedTime = application.backgroundTimeRemaining;
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSLog(@"BackgroundTask. The remained time: %f", remainedTime);
if (remainedTime < 10) {
// 可以告诉其他业务, 后台申请的时间即将结束了
}
if (remainedTime < 2) {
/// 这里可以做一些清除工作
{
// clean up
}
[application endBackgroundTask:taskId];
taskId = UIBackgroundTaskInvalid;
return;
}
// 睡眠(延时)1s
[NSThread sleepForTimeInterval:1.f];
});
});
}
});
}
}
/**
* 当前设备是否支持后台任务.
*
* @return YES, 支持后台任务. 否则, 不支持后台任务.
*/
- (BOOL)_checkSupportBackgroundTask {
SEL sel = @selector(isMultitaskingSupported);
BOOL supportBTask = [[UIDevice currentDevice] respondsToSelector:sel];
return supportBTask;
}