iOS 电量优化

最近观看 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 的使用

例如, 开发过程中, 有如下两个获取日期的方式: fun1fun2, 可以使用单元测试来评估两种方式的优劣. 在真机上运行后, 可以清晰看到fun1消耗的物理内存:

image.png
- (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 运行时间, 物理内存峰值等.

image.png

需要注意的是,在运行 XCTest 来测试 App 的性能时,不要启用 Xcode 的 debugger(去掉下图中勾选的选项),也不要开启 Xcode 的一些诊断选项,例如僵尸对象检测、内存分配记录等,以避免它们影响到应用的性能表现。可以在项目中创建一个新的 scheme 来关闭这些干扰项。

image.png

通过 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, 订阅方法很简单:

image.png

最后在订阅方法中返回苹果汇总的数据:

image.png

通过调用 payload 的方法, 可以将数据转变为 dictjson, 文章末尾放置了一个完整的 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 运行时消耗的 energypowertime 的乘积.

image.png

overhead 为完成任务时, 唤醒硬件消耗带来的电量, 如使用网络时激活无线电, 共享单车时激活的蓝牙等.

消耗能量最多的 4 个子系统为: 过程处理网络定位画面绘制

image.png
编号 系统 衡量标准
1 过程处理 任务越多,消耗能量越多。
2 网络(蜂窝网络、wifi、蓝牙) 请求越多, 消耗能量越多
3 定位 (GPS 定位、蜂窝网络和 wifi 网络定位) 精度越高、使用时间越长,消耗能量越多
4 画面绘制 动画和 UI 显示越复杂, 消耗能量越多
节省能量的优化

1 不要用定时请求的方式,因为即使是很小的网络请求,也会激活硬件设备,有冰山效应:

image.png

替换为用户交互和通知刷新的方式,能够明显减少电量:

image.png

2 不要采用复杂的 UI,播放视频时,操作视频的设置(进度条/暂停等)要隐藏,系统内部在播放时能够对消耗电量进行优化。

3 使用后台任务。系统会自动采用最优方式使用网络,适用于日志的集中上传,而不要在日志产生时立即上传到服务器。要及时结束后台任务,避免产生任务尾部的能量消耗。能够申请后台任的最长时间为 10 分钟,具体使用方式参考文末代码。

image.png
调试能量消耗的工具

在真机上运行 App, 在指标选项卡中,能够看到能量分析的界面:

image.png

从图中能够清晰看出各子系统消耗的电量情况。

查看详细的异常代码,可以采用底部的堆栈日志(Xcode bug, 需要将鼠标放在窗口最右边缘,然后下拉),点击后会打开 Time Profile, 查看详细情况(要显示对应代码,需要在 Xcode 设置生成 DSYM 信息)。

image.png

更详细的调试可以采用 Instrument 中的工具:

image.png
查看电量问题的新方式

耗能日志信息记录消耗 CPU 严重的电量事件:占用 80% 的CPU,在前台持续时间 3 分钟;或者在后台持续时间 1 分钟。这会消耗设备的电量超过 1%,相当于 8 分钟通话、6 分钟网页浏览和 30 分钟音乐时间。

image.png

通过对App运行时调用的堆栈进行不断采样,形成日志信息。比如进行了 6 次采样,其中 5 次调用了 method1, 1 次调用了 method3. 折算成权重分别为 83% 和 17%。

image.png

Apple 会自动(用户需要打开共享日志)将日志记录上传到苹果服务器进行整理分析,开发者在 Organizer 看到最终的结果。可以查看到消耗电量的代码片段排名,对解决的代码段可以标记完成。也可以对影响的设备进行筛选查看。

image.png

2017 Writing Energy Efficient Apps

App 消耗电量分为两部分:激活硬件和代码调用。

image.png
优化方式

1 对网络请求可以使用 WaitsForConnectivityCache. 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,让系统自动选择最佳时间执行。

image.png

2 对定位的使用要及时结束,避免后台持续使用:


image.png

image.png

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;
}

你可能感兴趣的:(iOS 电量优化)