【小木箱成长营】功耗优化系列文章:
功耗优化 · 工具篇 · 常用的7款功耗优化工具
功耗优化 · 实战篇 · 100万日活APP功耗优化实践
Tips: 关注小木箱成长营公众号, 回复"功耗优化"可免费获取功耗优化思维导图。
一、序言
Hello,我是小木箱,欢迎来到小木箱成长营系列教程,今天将分享功耗优化 · 方案篇 · Android功耗优化指导规范。小木箱从五个维度将Android功耗优化指导规范解释清楚。
Android功耗优化指导规范第一部分内容是5W2H分析功耗优化。第二部分内容是功耗优化技术方案。第三部分内容是功耗优化方案分析。第四部分内容是功耗APM监控建设。最后一部分内容是总结与展望。
其中,功耗优化技术方案分为三部分内容,第一部分内容是功耗优化工具,第二部分内容是功耗优化指南,最后一部分内容是功耗优化SOP。
其中,功耗优化方案分析主要分为六部分内容。第一部分内容是CPU异常SOP,第二部分内容是Camera功耗,第三部分内容是低功耗,第四部分内容是热缓解,第五部分内容是动态帧率,最后一部分内容是SurfaceView替换TextureView。
如果学完小木箱功耗优化的入门篇、工具篇、方案篇和实战篇,那么任何人做功耗优化都可以拿到结果。
二、5W2H分析功耗优化
首先我们说说我们的第一部分内容,5W2H 分析功耗优化,5W2H 分析功耗优化提出了 6 个高价值问题
- What : 功耗优化定义
- Why: 高功耗的原因
- How: 功耗优化流程
- Who: 功耗痛点治理
- How Much: 功耗优化价值
- Where: 方案使用场景
2.1 What: 功耗优化定义
Android功耗优化是指优化Android APP在运行过程中的电量消耗,以获得更长的电池续航时间和更优秀的用户体验。功耗优化包括:设备的动态调频、网络优化、电池感知、界面优化、后台活动优化和内存优化等
2.2 How Much: 功耗优化价值
下面的思维导图小木箱从性能价值、用户价值和社会价值三个维度分析了功耗优化价值。
2.3 Why: 高功耗的原因
高功耗是因为设备在运行过程中需要使用大量的电力。
因为CPU高负载、高亮度显示器、频繁GPS定位、后台运行程序、高频率网络通信和不必要动画等原因会导致设备高功耗
下面思维导图小木箱简单总结高功耗6个原因。
2.4 Who: 功耗痛点治理
该如何做功耗优化呢?
首先, 高功耗任务要分析设备功耗优化的占比情况,然后逐一进行排查
影响高功耗的设备主要有NetWorrk、GPS、Audio、Camera、Display、GPU、BlueTooth和Sensor等。详见下图:
小木箱将所有高功耗设备按照MECE原则进行分类如下表格:
设备 | 功耗影响因子 |
---|---|
Display | 亮度、显示内容、刷新率 |
CPU | 工作频率、工作负载、运算内容 |
GPU | 工作频率、工作负载、绘制内容、分辨率、绘制帧率 |
WIFI | 信号、协议(影响收发速率)、工作频率(2.4G , 5G) |
Audio | 音量、音效算法 |
GPS | 频率、定位精度 |
Modem | 天线信号强度、协议(如5g功耗远高于4g)、环境(如高铁上会频繁测量搜网) |
Camera | 摄像头(主摄、微距、广角、前置、tof等)工作模式(预览、拍照和录像)分辨率、录制帧率 |
小木箱建议大家使用Android的电池监控工具来监测APP的电池使用情况
根据业务表现不同特征, 尽量使用系统 API、减少后台进程、避免让手机长时间处于高亮度模式、合理使用 GPS 和网络、优化图像和视频、尽量避免定时高功耗任务和避免频繁的唤醒 CPU方式进行优化功耗。
Android常见的高功耗设备可以参考下图:
那么, 该如何找出正在运行的高耗设备?
查找高功耗电 Android 设备,需要使用 Android 电池管理 API,详细代码如下:
BatteryStats batteryStats = BatteryStatsHelper.getBatteryStats(context, batteryInfo);
Map<String, ? extends BatteryStats.Uid> batteryData = batteryStats.getUidStats();
for (Map.Entry<String, ? extends BatteryStats.Uid> entry : batteryData.entrySet()) {
BatteryStats.Uid uid = entry.getValue();
int uidBatteryUsage = uid.getBatteryPercentOfTotal();
if (uidBatteryUsage > 0) {
// Use this information to identify which APPs are consuming the most battery power.
}
}
首先,高功耗任务需要新增android.permission.BATTERY_STATS
权限,方便使用电池管理 API。
然后,使用 Android 电池管理 API 获取 APP 的电池使用情况,并循环遍历每个 APP,最后, 确定它们对总电池使用情况的贡献。
最后,如果 APP 对总电池使用情况的贡献大于零,那么可以将该 APP 标识为消耗大量电力的 APP。
减少设备使用方式详见有如下思维导图:
下面我们逐一讲解着五种减少设备使用的方式。
2.4.1 降低亮度
WindowManager.LayoutParams layout = getWindow().getAttributes();
layout.screenBrightness = 0.5F;
getWindow().setAttributes(layout);
在上面的代码中,通过获取当前窗口的布局参数,并将其中的屏幕亮度设置为 0.5F。请注意,此亮度值仅在 0 到 1 之间。
因此,如果希望更低的亮度,则可以将此值设置为更小的值。
请注意,需要在活动中调用此代码,以便更改当前活动的屏幕亮度。
此外,还需要检查是否具有 android.permission.WRITE_SETTINGS
权限,以便更改系统设置。
2.4.2 降低动画帧率
getWindow().setWindowAnimationsScale(0.5f);
首先,请确保有SYSTEM_ALERT_WINDOW
设置的权限。
然后,通过调用 setWindowAnimationsScale
方法并将其参数设置为 0.5f,可以降低当前活动的动画帧率,该值仅在 0 到 1 之间。
最后, 需要在 Activity 中调用上面的代码就可以更改当前 Activity 的动画帧率。
2.4.3 减少数据收发
减少数据收发的方式有四种,分别是:
1. 使用后台线程:在后台线程中执行网络请求,而不是在主线程中,以避免阻塞主线程。
2. 缓存数据:使用本地缓存存储经常使用的数据,以避免不必要的网络请求。
3. 压缩数据:在发送数据前对数据进行压缩,以减少数据量。
4. 避免不必要的数据收发:使用状态管理,避免对已经存在的数据进行不必要的收发。
以下是一个使用 Retrofit 和 OkHttp 实现网络请求的代码示例,并将数据缓存到本地:
public class NetworkService {
private static final String BASE_URL = "https://github.microkibaco.com/api/";
private static final int CACHE_SIZE = 10 * 1024 * 1024; // 10 MB
private static NetworkService instance;
private Retrofit retrofit;
private OkHttpClient client;
private NetworkService() {
// 创建缓存目录
File cacheDirectory = new File(
Environment.getDownloadCacheDirectory(), "retrofit-cache");
Cache cache = new Cache(cacheDirectory, CACHE_SIZE);
// 配置 OkHttpClient
client = new OkHttpClient.Builder()
.cache(cache)
.build();
// 配置 Retrofit
retrofit = new Retrofit.Builder()
.baseUrl(BASE_URL)
.client(client)
.build();
}
public static NetworkService getInstance() {
if (instance == null) {
instance = new NetworkService();
}
return instance;
}
public Retrofit getRetrofit() {
return retrofit;
}
}
2.4.4 降低音量
在 Android 代码层面,可以使用以下代码来降低音量,从而优化电池使用寿命:
AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, audioManager.getStreamMinVolume(AudioManager.STREAM_MUSIC), 0);
该代码将音频流的音量设置为最低值,从而降低手机的音量。
2.4.5 治理 CPU 高负载
治理 CPU 高负载异常并优化电池使用寿命常见方式有四个:
检测 CPU 负载异常可以通过使用 Android 提供的两个工具。
第一, adb shell top
第二, adb shell dumpsys CPUinfo
分析负载异常原因方式也有两种:
第一使用 Android Studio 的“Profile GPU Rendering”
第二使用“Debug GPU Overdraw”功能。
如果分析结果是 APP 代码引起的负载异常,那么可以减少不必要的动画、使用多线程等来优化 CPU 负载。
如果分析结果表明是后台服务引起的负载异常,那么可以通过关闭服务来减少 CPU 负载。
小木箱后文会对 CPU 负载进行详细分析。
2.5 How: 功耗优化流程
当执行高功耗任务时候,业务开发应该反思下面四个问题:
-
高功耗任务是否必须要做?
-
高功耗任务是否必须准时做?
-
高功耗任务是否必须保证效果?
-
高功耗任务必须的方案是否具有唯一性?
关于问题1: 高功耗任务是否必须要做? 如果高功耗任务不是必须要做的,业务开发可以考虑延迟或不执行,达到动画泄漏治理,让后台高功耗任务延迟做
关于问题2: 高功耗任务是否必须准时做?如果高功耗任务不是必须准时做的,高功耗任务业务开发可以考虑分批减少频率,达到MQTT心跳降级,GPS定位频率因此会降低
关于问题3: 高功耗任务是否必须保证效果?如果高功耗任务不是必须保证效果,高功耗任务业务开发可以考虑降低效果,常见的做法是算法降级如:定位降级为网络定位
关于问题4: 高功耗任务必须的方案是否具有唯一性?如果高功耗任务必须的方案不具有唯一性,高功耗任务业务开发可以考虑硬解密替代软解密,厂商Push代替自建长链接
功耗设备降级的方式主要有异常治理、正面优化和业务降级四种.
异常治理有三种情况: 死循环治理、资源不释放治理和高频耗时函数优化
正面优化有三种情况: 动态帧率和刷新率、绘制图层优化和长链接心跳优化
业务降级有三种情况: 预加载降级、常驻动画降级和超分算法降级
后文小木箱会逐一进行讲解
2.6 Where: 方案使用场景
方案使用场景方案使用场景小木箱这里只会带大家了解一下异常治理和无损降级,后文会讲解有感降级。
2.6.1 异常治理
异常处理不是直接解决Android功耗优化的方法,但是通过异常处理可以避免不必要的资源浪费,从而减少功耗消耗。
高功耗任务可以通过避免不必要的资源分配、避免不必要计算、不必要数据库操作等异常处理解决Android功耗问题。
2.6.2 业务降级(无感知)
使用JobScheduler代替AlarmManager:JobScheduler可以在系统空闲时执行高功耗任务,因此可以降低功耗消耗。
使用后台服务降级:对于需要长时间运行的后台服务,可以通过降级方式(例如,从运行在前台的服务切换到后台定时高功耗任务)来减少功耗消耗。
使用Doze模式优化:Doze模式可以在系统进入空闲状态时减少后台活动,从而减少功耗消耗。
使用Firebase Cloud Messaging(FCM)代替定时高功耗任务:FCM可以在系统空闲时向APP发送消息,从而减少功耗消耗。
使用Battery Optimizations API:Battery Optimizations API可以在系统空闲时减少APP的活动,从而减少功耗消耗。
三、功耗优化技术方案
功耗优化技术方案主要分为四个部分,第一个部分是功耗优化工具,第二个部分是功耗优化指南,第三个部分是功耗优化SOP。
3.1 功耗优化工具
功耗优化工具有八个可以聊聊,分别是电流仪、Battery Historian、Energy Profiler、Dumpsys Batterystats、Battery-Metrics、BatteryCanary、Diggo、和Systrace
3.1.1 电流仪
优点
最准确的测试方法是稳压电源+电流仪, 稳压电源+电流仪优点是可以测试整机电流,并且数据精准
缺点
因为稳压电源+电流仪需要准备硬件工具,稳压电源+电流仪测试操作复杂,并且稳压电源+电流仪不能测试APP消耗电量,一般没有人在移动开发中使用稳压电源+电流仪
使用场景
ROM厂商整机电流科学实验
3.1.2 Battery Historian
传送门: 使用 Battery Historian 分析耗电情况
使用指南(待更新): space.bilibili.com/455432199
参数说明
Historian
参数 | 说明 |
---|---|
Battery Level | 电量 |
Userpace WakeLock | 用户唤醒锁 |
Long WakeLocks | 长期持有唤醒锁事件 |
GPS | 是否使用GPS |
Wifi radio | 是否正在通过wifi传输数据 |
Network Connectivity | 网络连接状态(wifi、mobile是否连接) |
Mobile signal strength | 移动信号强度(great/good/moderate/poor) |
Mobile network type | 移动信号类型 |
Audio | 音频是否开启 |
Camera | 相机是否在工作 |
Video | 是否在播放视频 |
Temperature | 电池温度 |
App Selection
参数名 | 说明 |
---|---|
Device estimated power use | 预计耗电量 |
Vibrator use | 手机振动器 |
Wakelocks | 唤醒锁事件 |
Services | app开启的服务 |
优点
- Battery Historian以图表形式展示,对APP耗电情况有详细数据展示,同步还提供了手机当前的状况,方便分析APP的具体原因
- Battery Historian有耗电量的详细数据如: APP的使用时间、电池消耗、唤醒锁定和CPU使用情况
- Battery Historian可以追踪应用程序的后台运行时间、网络使用情况、设备唤醒频率等
- Battery Historian可以确定哪些APP或服务高功耗
缺点
- Battery Historian只适用于Android5.0及以上系统
- Battery Historian无法定位具体是什么方法或者功能引起的耗电异常,无法获取APP异常运动时的堆栈信息
- Battery Historian收集的数据是历史数据,非实时电耗情况。
- Battery Historian收集 CPU 使用情况、网络数据传输等需要 root 权限。
使用场景
-
诊断应用程序、系统服务和硬件组件的电池消耗问题
-
评估Android设备电池的使用寿命信息
-
比较不同设备、不同应用程序或不同操作系统版本的电池消耗情况
3.1.3 Energy Profiler
传送门: developer.android.com/studio/prof…
使用指南(待更新): space.bilibili.com/455432199
优点
- Energy Profiler 可以提供实时的电池消耗
- Energy Profiler 可以针对特定的应用程序进行电池消耗分析,而不是整台设备
- Android Studio自带Buff,无需安装插件
- Energy Profiler提供CPU 使用情况、传感器使用情况和网络数据传输能力
- Energy Profiler支持历史数据分析
缺点
- 无法多端复用
- 只支持Android 8.0以上版本
- 不提供高功耗解决方案
使用场景
- 测试电池寿命
- 比较不同版本的应用程序
- 确认电池相关问题
3.1.4 Dumpsys Batterystats
传送门: developer.android.com/studio/comm…
使用指南(待更新): space.bilibili.com/455432199
优点
- Dumpsys Batterystats可以监测系统服务和其他应用程序等系统级别的电池消耗
- Dumpsys Batterystats可以监测应用程序在使用电池时的功耗情况,并将其记录在日志文件中
- Dumpsys Batterystats会生成应用程序、系统服务、电池消耗原因和耗电量使用情况报告
缺点
- 无法检测应用程序和服务之间的相互影响
- Dumpsys Batterystats测试只能在设备上运行一段时间后收集数据
- Dumpsys Batterystats方式获取的是原始的耗电记录数据,需要人肉查找关键信息,可读性不高
使用场景
- 电池寿命测试
- 电源管理优化
- Dumpsys Batterystats帮助用户排除设备电池故障
3.1.5 Battery-Metrics
使用指南(待更新): space.bilibili.com/455432199
优点
- Battery-Metrics可以检测到应用程序和服务之间的相互影响
- Battery-Metrics测试功耗方法和技术比Batterystats精准
- Battery-Metrics测试功耗能够检测用户APP使用习惯、网络连接质量、屏幕亮度、环境温度
- Battery-Metrics支持多种设备和操作系统版本
- Battery-Metrics可以通过hook和插桩的方式上报关键节点的电池和手机相关数据,可高度定制化
缺点
Battery-Metrics插件方案Hook点位受到API版本限制
使用场景
不同领域和高功耗优化场景高可用
3.1.6 BatteryCanary
使用指南(待更新): space.bilibili.com/455432199
BatteryCanary也可以通过hook和插桩的方式上报关键节点的电池和手机相关数据,已实现关键点的插桩和hook
优点
- 实时监测
- 易于集成
- 精细化分析
- 易于定位问题
- 开源免费
缺点
- 对测试设备要求较高
- 有些数据可能不够准确
- 导致应用程序的安装包变得更大
使用场景
不同领域和高功耗优化场景高可用
3.1.7 Diggo
使用指南(待更新): space.bilibili.com/455432199
优点
- Diggo 可以对应用程序的每个组件进行功耗采样和统计,得到精确的功耗数据,并可以将功耗数据可视化,便于开发人员分析和优化
- Diggo 提供了一些自动化的功耗优化策略,例如优化启动过程、减少资源使用等
- Diggo可以与Traceview、Jupyter进行集成
缺点
- 侵入业务代码
- 无法处理硬件功耗问题
- 可能会影响应用程序的性能
- 无法跨端使用
使用场景
- 开发人员和测试团队对App进行基准测试
- 优化应用程序的电池寿命
3.1.8 Systrace
传送门: blog.csdn.net/u011578734/…
使用指南(待更新): space.bilibili.com/455432199
优点
精确定位功耗问题:Systrace 可以提供精确到微秒级别的时间戳,定位功耗问题问题时间和位置。
缺点
Systrace 只能监测 Android 系统本身以及应用程序,但不能监测硬件功耗,例如屏幕、摄像头、传感器等硬件设备的功耗
使用场景
识别瞬时功耗:Systrace 可以捕捉瞬时功耗事件,例如启动应用程序、打开相机等操作。
可以识别到不必要的系统服务或者过多的数据同步操作等后台功耗问题
3.2 功耗优化指南
3.2.1 APP功耗统计流程
厂商耗电排行
在Android手机上,可以按照 设置 → 电池 → 电池百分比 步骤查看耗电排行
“耗电排行”显示的是自上次充满电后的一个累计情况,很难满足测试需求
关于耗电有一个很重要的API BatteryStatsHelper
首先, BatteryStatsHelper 通过 createFromParcel 方法统一耗电口径,然后,再通过 addPhoneUsage,最后, 通过 refreshStats 显示最近数天甚至一周以上的 APP 的电量统计数据。详细源码调用链参考如下:
private int mStatsType = BatteryStats.STATS_SINCE_CHARGED;
private static BatteryStatsImpl getStats(IBatteryStats service) {
try {
ParcelFileDescriptor pfd = service.getStatisticsStream();
if (pfd != null) {
//---------------------------------
try {
//---------------------------------
BatteryStatsImpl stats = com.android.internal.os.BatteryStatsImpl.CREATOR
.createFromParcel(parcel);
// 统计口径
//---------------------------------
return stats;
} catch (IOException e) {
Log.w(TAG, "Unable to read statistics stream", e);
}
}
} catch (RemoteException e) {
Log.w(TAG, "RemoteException:", e);
}
return new BatteryStatsImpl();
}
}
// 耗电数据集
private void addPhoneUsage() {
long phoneOnTimeMs = mStats.getPhoneOnTime(mRawRealtime, mStatsType) / 1000;
double phoneOnPower = mPowerProfile.getAveragePower(PowerProfile.POWER_RADIO_ACTIVE)
* phoneOnTimeMs / (60*60*1000);
if (phoneOnPower != 0) {
BatterySipper bs = addEntry(BatterySipper.DrainType.PHONE, phoneOnTimeMs, phoneOnPower);
}
}
private void processMiscUsage() {
//---------------------------------
addPhoneUsage();
//---------------------------------
}
// 有的 Android 设备系统可以显示最近数天甚至一周以上的 APP 的电量统计数据
public void refreshStats(int statsType, SparseArray<UserHandle> asUsers, long rawRealtimeUs,
long rawUptimeUs) {
// Initialize mStats if necessary.
// 计算APP的耗电
processAPPUsage(asUsers);
// 计算杂项的耗电
}
电量计算公式
Android APP 中,可以使用以下代码获取设备当前的电量信息:
// 获取电池管理器对象
BatteryManager batteryManager = (BatteryManager) context.getSystemService(Context.BATTERY_SERVICE);
// 获取当前电量
int currentBatteryLevel = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY);
// 获取电池容量(需要 API Level 28 及以上支持)
long batteryCapacity = batteryManager.getLongProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY);
通过上述代码获取到的电量和电池容量可以使用上面提到的公式计算剩余电量。
注意,电池容量的单位为毫安时(mAh),需要将其转换为标准单位(例如,使用瓦时,1 毫安时 = 0.001 瓦时)。
电量计算公式是:
模块电量(mAh)= 模块电流(mA)*模块耗时(h)
耗电器件模块主要是 Android 设备的各种硬件模块,主要可以分为以下三类:
第一类,像 Camera/FlashLight/MediaPlayer/ 一般传感器等之类的模块,其工作功率基本和额定功率保持一致,所以模块电量的计算只需要统计模块的使用时长再乘以额定功率即可。
第二类,像 Wifi/Mobile/BlueTooth 这类数据模块,其工作功率可以分为几个档位。
比如,当手机的 Wifi 信号比较弱的时候,Wifi 模块就必须工作在比较高的功率档位以维持数据链路。
所以这类模块的电量计算有点类似于高功耗任务日常的电费计算,需要 “阶梯计费”。
第三类,也是最复杂的模块,CPU 模块除了每一个 CPU Core 需要像数据模块那样阶梯计算电量之外,CPU 的每一个集群(Cluster,一般一个集群包含一个或多个规格相同的 Core)也有额外的耗电,此外整个 CPU 处理器芯片也有功耗。
简单计算的话,CPU 电量 = SUM (各核心功耗) + 各集群(Cluster)功耗 + 芯片功耗 。
如果往复杂方向考虑的话,那么 CPU 功耗还要考虑超频以及逻辑运行的信息熵损耗等电量损耗(Android 系统 CPU 的电量统计只计算到芯片功耗层)。
屏幕模块的电量计算就更麻烦了,很难把屏幕功耗合理地分配给各个 APP, 因此 Android 系统只是简单地计算 APP 屏幕锁(WakeLock)的持有时长,按固定系数增加 APP CPU 的统计时长,粗略地把屏幕功耗算进 CPU 里面。
最后,需要特别注意的是,以上提到的各种功率和时间在 Android 系统上的统计都是估算的,可想而知最终计算出来的电量数值可能与实际值相差巨大,Facebook 的工程师对此也有所吐槽:Mistrusting OS Level Battery Levels。
硬件电量统计
功率:power_profile.xml,Android 系统使用此文件来描述设备各个硬件模块的额定功率,包括上面提到的多档位功率和 CPU 电量算需要到的各种参数值。
时长:StopWatch & SamplingCounter,其中 StopWatch 是用来计算 APP 各种硬件模块的使用时长,而 SamplingCounter 则是用来采样统计 APP 在不同 CPU Core 和不同 CPUFreq 下的工作时长。
计算:PowerCalculators,每个硬件模块都有一个相应命名的 PowerCalculator 实现,主要是用来完成具体的电量统计算法。
存储:batterystats.bin,电量统计服务相关数据的持久化文件。
3.2.2 APP 功耗配置文件
通常,power_profile.xml 位于/system/framework/framework-res.apk 中,这是一个 android 设备的系统 apk,高功耗任务可以通过。
adb pull /system/framework/framework-res.apk ./
获取当前系统的 framework-res apk,这步不需要 root 即可进行,接着高功耗任务可以通过反编译工具,apktool 或者 jadx 都可以,对该 apk 进行反编译处理,高功耗任务所需要的功耗文件就在 /res/xml/power_profile.xml 中。
3.2.3 APP 功耗优化模式
Android 的 Doze 模式是一种省电模式,旨在减少设备的能耗。当设备进入 Doze 模式时,Android 会暂停一些不必要的后台活动,包括网络访问、同步、定位和其他不必要的服务。这样可以降低设备的能耗并延长电池寿命。Doze 模式分为两种:一种是 Light Doze,一种是 Deep Doze。
Doze 模式
Light Doze 会在设备未使用时激活,比如用户长时间未操作设备或将设备放置不用,它会限制 APP 的后台活动和一些系统服务。
而 Deep Doze 则需要设备完全静止一段时间,只有当设备完全静止一段时间后,才会激活 Deep Doze。在 Deep Doze 下,所有 APP 和系统服务都将被暂停,直到设备被唤醒。
需要注意的是,Doze 模式并不会完全关闭设备,而只是将设备进入一种低功耗状态。当用户开始使用设备时,设备会自动退出 Doze 模式,并恢复正常的工作状态。
// PowerManager提供的两种判断当前是否处于Doze模式方法
public boolean isDeviceIdleMode(){
try {
return mService.isDeviceIdleMode();
} catch (RemoteException e){、3603
throw e.rethrowFromSystemServer();
}
@SdkConstant(SdkConstant.SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_DEVICE_IDLE_MODE_CHANGED = "android.os.action.DEVICE_IDLE_MODE_CHANGED";
Deep Doze
系统处于息屏状态,并且 30 分钟不移动的情况下,就会进入到 Deep Doze 模式,Deep Doze 机制中有七种状态,分别如下:
//mState值,表示设备处于活动状态
private static final int STATE_ACTIVE = 0;
//mState值,表示设备处于不交互状态,灭屏、静止
private static final int STATE_INACTIVE = 1;
//mState值,表示设备刚结束不交互状态,等待进入IDLE状态
private static final int STATE_IDLE_PENDING = 2;
//mState值,表示设备正在感应动作
private static final int STATE_SENSING = 3;
//mState值,表示设备正在定位
private static final int STATE_LOCATING = 4;
//mState值,表示设备处于空闲状态,也即Doze模式
private static final int STATE_IDLE = 5;
//mState值,表示设备正处于Doze模式,紧接着退出Doze进入维护状态
private static final int STATE_IDLE_MAINTENANCE = 6;
1. 当设备亮屏或者处于正常使用状态时其就为 ACTIVE 状态;
2. ACTIVE 状态下不充电且灭屏设备就会切换到 INACTIVE 状态;
3. INACTIVE 状态经过 3 分钟,期间检测没有打断状态的行为就切换到 PRE_IDLE 的状态;
4. PRE_IDLE 状态经过 5 分钟,期间无打断就进入到 IDLE 状态;
5. 进入 IDLE 状态会根据是否有网络连接选择进入 WAITING_FOR_NETWORK 还是进入 MAINTENANCE 窗口期,进入窗口期的时间为:5 分钟,10 分钟,最后稳定最长为 15 分钟;
6. 进入 WAITING_FOR_NETWORK 会持续 5 分钟后重新进入到 IDLE 状态;
7. 进入 MAINTENANCE 会解除耗电策略的限制,并在 1 分钟后重新进入到 IDLE 状态。
Light Doze
从上面可以看到想要进入 Doze 模式的条件是很苛刻,需要在手机息屏并且没有移动的状态下才能进入,所以 Android7.0 开始引入了 Light Doze,处于息屏状态,但仍处于移动状态可进入 Light Doze,LightDoze 有 7 个状态,分别如下:
//mLightState状态值,表示设备处于活动状态
private static final int LIGHT_STATE_ACTIVE = 0; //mLightState状态值,表示设备处于不活动状态
private static final int LIGHT_STATE_INACTIVE = 1; //mLightState状态值,表示设备进入空闲状态前,需要等待完成必要操作
private static final int LIGHT_STATE_PRE_IDLE = 3; //mLightState状态值,表示设备处于空闲状态,该状态内将进行优化
private static final int LIGHT_STATE_IDLE = 4; //mLightState状态值,表示设备处于空闲状态,要进入维护状态,先等待网络连接
private static final int LIGHT_STATE_WAITING_FOR_NETWORK = 5; //mLightState状态值,表示设备处于维护状态
private static final int LIGHT_STATE_IDLE_MAINTENANCE = 6;
根据上图,他们的转换关系总结如下:
1. 当设备亮屏或者处于正常使用状态时其就为 ACTIVE 状态;
2. ACTIVE 状态下不充电且灭屏设备就会切换到 INACTIVE 状态;
3. INACTIVE 状态经过 3 分钟,期间检测没有打断状态的行为就切换到 PRE_IDLE 的状态;
4. PRE_IDLE 状态经过 5 分钟,期间无打断就进入到 IDLE 状态;
5. 进入 IDLE 状态会根据是否有网络连接选择进入 WAITING_FOR_NETWORK 还是进入 MAINTENANCE 窗口期,进入窗口期的时间为:5 分钟,10 分钟,最后稳定最长为 15 分钟;
6. 进入 WAITING_FOR_NETWORK 会持续 5 分钟后重新进入到 IDLE 状态。
Standby 模式
Android Standby 模式是一种类似于 Doze 模式的省电模式,它会在设备没有活动时将设备置于低功耗状态。在 Standby 模式下,APP 的后台活动和系统服务将被暂停,但仍可以使用特定的 API 允许部分后台活动和网络访问。
PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE);
if (powerManager.isIgnoringBatteryOptimizations(getPackageName())) {
// 已经允许APP忽略电量优化
// 开始执行后台高功耗任务和网络访问
// ...
} else {
// 请求允许APP忽略电量优化
Intent intent = new Intent();
intent.setAction(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
intent.setData(Uri.parse("package:" + getPackageName()));
startActivity(intent);
}
以上代码获取了 PowerManager 对象,并使用 isIgnoringBatteryOptimizations()方法检查 APP 是否允许忽略电量优化。
如果已经允许,则可以开始执行后台高功耗任务和网络访问。如果没有允许,则需要使用 ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS intent 请求用户允许 APP 忽略电量优化。
需要注意的是,与 Doze 模式类似,启用 Standby 模式并不意味着所有后台活动和网络访问都将被暂停。
APP 需要使用特定的 API 允许必要的后台活动和网络访问,以确保在 Standby 模式下能够正常工作。
3.2.4 APP 功耗工作流程
BatteryStatsService 的工作流程大致可以分为两个部分:时长统计 & 功耗计算。
BatteryStatsService 时长统计流程
BatteryStats
BatteryStatsService 框架的核心是 BatteryStatsService 持有的一个叫 BatteryStats 的类,BatteryStats 又持有一个 Uid [] 数组,每一个 Uid 实例实际上对应一个 APP,当高功耗任务安装或者卸载 APP 的时候,BatteryStats 就会更新相应的 Uid 元素以保持最新的映射关系。同时 BatteryStats 持有一系列的 StopWatch 和 SamplingCounter,当 APP 开始使用某些硬件模块的功能时,BatteryStats 就会调用相应 Uid 的 StopWatch 或 SamplingCounter 来统计其硬件使用时长。
Wifi 模块
这里以 Wifi 模块来举例:当 APP 通过 WifiManager 系统服务调用 Wifi 模块开始扫描的时候,实际上会通过 WifiManager#startScan () --> WifiScanningServiceImp --> BatteryStatsService#noteWifiScanStartedFromSource () --> BatteryStats#noteWifiScanStartedLocked (uid) 等一连串的调用,通知 BatteryStats 开启 APP 相应 Uid 的 Wifi 模块的 StopWatch 开始计时。当 APP 通过 WifiManager 停止 Wifi 扫描的时候又会通过类似的流程调用 BatteryStats#noteWifiScanStoppedLocked (uid) 结束 StopWatch 的计时,这样一来就通过 StopWatch 完成 APP 对 Wifi 模块使用时长的统计。
BatteryStatsService 功耗计算流程
BatteryStatsHelper
具体电量计算方面,BatteryStats 是通过 BatteryStats 依赖的一个 BatteryStatsHelper 的辅助类来完成的。BatteryStatsHelper 通过组合使用 Uid 里的时长数据、PoweProfile 里的功率数据(power_profile.xml 的解析实例)以及具体各个模块的 PowerCalculator 算法,计算出每一个 APP 的综合电量消耗,并把计算结果保存在 BatterySipper [] 数组里(按计算值从大到小排序)。
WIFI 模块
还是以 Wifi 模块来举例:当需要计算 APP 电量消耗的时候,BatteryStats 会通过调用 BtteryStatsHelper#refreshStats () --> #processAPPUsage () 来刷新 BatterySipper [] 数组以计算最新的 APP 电量消耗数据。而其中 Wifi 模块单独的电量统计就是在 processAPPUsage 方法中通过 WifiPowerCalculator 来完成的:Wifi 模块电量 = PowerProfile 预置的 Idle 功率 × Uid 统计的 Wifi Idle 时间 + 上行功率 × 上行时间 + 下行功率 × 下行时间。
public class WifiPowerCalculator extends PowerCalculator {
@Override
public void calculateApp(BatterySipper app, BatteryStats.Uid u, long rawRealtimeUs, long rawUptimeUs, int statsType) { ...
app.wifiPowerMah = ((idleTime * mIdleCurrentMa) + (txTime * mTxCurrentMa) + (rxTime * mRxCurrentMa)) / (1000 * 60 * 60);
}
public void noteWifiOnLocked() {
if (!mWifiOn) {
final long elapsedRealtime = mClocks.elapsedRealtime();
final long uptime = mClocks.uptimeMillis();
mHistoryCur.states2 |= HistoryItem.STATE2_WIFI_ON_FLAG;
addHistoryRecordLocked(elapsedRealtime,uptime);
mWifiOn = true;
mWifiOnTimer.startRunningLocked(elapsedRealtime);
scheduleSyncExternalStatsLocked("wifi-off",ExternalStatsSync.UPDATE_WIFI);
}
}
}
Audio 模块
Activity 状态改变
3.3 功耗优化 SOP
3.3.1 统计指标
-
app 处于前台时,提供电流作为耗电指标。
-
通过采集 app 的 CPU、流量和 GPS 等的使用,来计算出一个加权和作为耗电指标。
-
电池温度,作为衡量耗电的辅助参考。
3.3.2 线下监测
3.3.2.1 监测专项
参数 | 参数说明 |
---|---|
test_time | 监测时间 |
power_time | 监测耗时 |
power_env | 功耗环境 |
power_consumption | 功耗应用 |
data_src | 数据来源 |
power_consumption_device | 功耗设备 |
device_version | 设备版本 |
total_battery_capacity | 电池容量 |
battery_health_status | 电池健康度 |
power_detector | 功耗监测人 |
3.3.2.2 监测 SOP
监测时间段内,在游戏回播业务播放同一个测试回播资源 20 分钟。
监测时间内,确保应用走完倍速、音量梯度调整等播放主流程。
监测时间内,测量时间段内确保游戏回播全程播放。
监测时间内,确保游戏回播不切换视频资源。
监测过程中,屏幕保持常亮状态,亮度中等。因为屏幕唤醒本身就会有耗电开销。
监测过程中,使用 WiFi 网络,因为蜂窝测试数据不具备公正性。
监测过程中,把没有用到的传感器关掉,且不要充电。
3.3.2.3 监测结论
器件名称 | 耗电占比 |
---|---|
WIFI | xxx% |
Camera | xxx% |
Audio | xxx% |
Screen | xxx% |
NetWork | xxx% |
Display | xxx% |
合计 | xxx% |
异常: 电池温度骤增,发热明显。
3.3.2.4 监测数据
3.3.2.4.1 手机硬件运行状况及电量消耗曲线图
-
测量过程中,耗电 1%需要的时间大概 xxx 分钟。
-
测量过程中,应用耗电曲线平滑,耗电波动稳定。
-
测量过程中,保持屏幕常亮,长时间开启 Audio 和 Screen 无法进入深度休眠,导致手机耗电严重。
3.3.2.4.2 手机各应用及硬件耗能
以上数据为部分应用及硬件的耗电估算数据,仅供参考。
3.3.2.5 问题分析
监测期间影响 APP 高功耗的三个原因是 CPU、Audio 和 Screen 三个组件,可从中寻求优化方案。
3.3.3 功耗归因
-
高 CPU 可以通过 CPU 菜单查看高耗 CPU 的堆栈。
-
GPS(location)、Alarm 和 WakeLock 使用在超过指定持有时间和频次后,会上报当时的采集堆栈。
3.3.4 治理目标
-
消除主流手机的高功耗提醒。
-
建立健全的功耗监控及防劣化体系。
3.3.5 耗电治理
3.3.5.1 分模块治理
模块的耗电治理主要体现在下面几个方面:
3.3.5.1.1 CPU
死循环函数、高频函数、高耗时函数和无效函数等不必要的 CPU 消耗或消耗较多的函数治理。
CPU 使用率较高的场景及业务治理。
3.3.5.1.2 GPU&Display
过度绘制、过多的动画和不可见区域的动画等浪费 GPU 的场景治理。
主动降低屏幕亮度,使用深色 UI 等方案降低屏幕电量消耗。
3.3.5.1.3 NetWork
不影响业务和性能前提下,降低网络访问频率。
Doze 状态时减少无效的网络请求。
3.3.5.1.4 GPS
对使用 GPS 的场景,如小程序等,合理的降低精度,减少请求频率。
3.3.5.1.5 Audio、Camera、Video 等
3.3.5.2 分状态治理
除了分模块治理,还针对状态进行治理,主要状态有这几种:
3.3.5.2.1 Foreground 状态
渲染场景优化
音视频等场景优化
……
3.3.5.2.2 Background 状态
task 任务降频或者丢弃。
网络访问降频,适配 Doze 模式。
减少 CPU 消耗较多的函数执行。
减少 GPS 等高功耗场景。
3.3.5.3 防腐化建设
为了能更好的进行治理,完善的功耗分析和监控体系是不可避免的,不然就会出现无的放矢的状态。在这一块主要建设的点有。
3.3.5.3.1 CPU 监控
前后台高 CPU 消耗场景监控,高 CPU 消耗线程监控。
高频 task、高耗时 task 和后台 task 监控。
消耗较高、耗时较高的函数监控。
3.3.5.3.2 GPU&Display 监控
动画场景、过度绘制检测、View 层级检测和屏幕电量消耗监控等。
3.3.5.3.3 NetWork 监控
Rust、OkHttp 及其他网络请求场景,频率,消耗监控。
后台网络访问监控。
3.3.5.3.4 GPS 监控
GPS 使用场景、时长、电量消耗监控。
3.3.5.3.5 Audio、Camera、Video 等监控
使用场景、时长、电量消耗监控。
3.3.5.3.6 Global 电量监控
整体的电量消耗和不同场景的电量消耗,用来度量版本功耗的质量。
四、功耗优化案例分析
4.1 CPU异常SOP
4.1.1 CPU异常归因
CPU高负载异常是最常见的一类功耗问题。引起CPU高负载的原因有很多,通常是业务开发的不规范导 致的。高功耗任务主要通过线下和线上的工具,识别出CPU异常,进行进行Case by case的优化。
除了使用 Android Profiler 工具之外,也可以使用代码来实现 CPU 使用率的监控。以下是一个简单的示例代码,可以在应用程序中实现 CPU 使用率的实时监控:
private void startCpuMonitor() {
final Handler handler = new Handler();
final Runnable runnable = new Runnable() {
@Override
public void run() {
// 获取CPU使用率
float cpuUsage = getCpuUsage();
// 处理CPU使用率
handleCpuUsage(cpuUsage);
// 间隔1秒钟再次执行
handler.postDelayed(this, 1000);
}
};
handler.postDelayed(runnable, 1000);
}
private float getCpuUsage() {
try {
RandomAccessFile reader = new RandomAccessFile("/proc/stat", "r");
String line = reader.readLine();
String[] fields = line.trim().split("\s+");
long totalCpuTime1 = Long.parseLong(fields[1])
+ Long.parseLong(fields[2])
+ Long.parseLong(fields[3])
+ Long.parseLong(fields[4])
+ Long.parseLong(fields[5])
+ Long.parseLong(fields[6])
+ Long.parseLong(fields[7]);
long idleCpuTime1 = Long.parseLong(fields[4]);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
reader.seek(0);
line = reader.readLine();
reader.close();
fields = line.trim().split("\s+");
long totalCpuTime2 = Long.parseLong(fields[1])
+ Long.parseLong(fields[2])
+ Long.parseLong(fields[3])
+ Long.parseLong(fields[4])
+ Long.parseLong(fields[5])
+ Long.parseLong(fields[6])
+ Long.parseLong(fields[7]);
long idleCpuTime2 = Long.parseLong(fields[4]);
return (totalCpuTime2 - totalCpuTime1) * 100.0f / (totalCpuTime2 - totalCpuTime1 + idleCpuTime2 - idleCpuTime1);
} catch (IOException e) {
e.printStackTrace();
}
return 0.0f;
}
private void handleCpuUsage(float cpuUsage) {
// 处理CPU使用率
// ...
}
以上代码在每隔 1 秒钟获取一次 CPU 使用率数据,并调用handleCpuUsage
方法来处理 CPU 使用率。可以根据具体需求,来修改代码中的处理逻辑。
在 Android 操作系统中,可以通过代码的方式获取 CPU 使用率数据,并根据数据来确定异常 CPU 使用率的阈值。下面是一个简单的示例代码:
// 获取CPU使用率
private float getCpuUsage() {
try {
RandomAccessFile reader = new RandomAccessFile("/proc/stat", "r");
String line = reader.readLine();
String[] fields = line.trim().split("\s+");
long totalCpuTime1 = Long.parseLong(fields[1])
+ Long.parseLong(fields[2])
+ Long.parseLong(fields[3])
+ Long.parseLong(fields[4])
+ Long.parseLong(fields[5])
+ Long.parseLong(fields[6])
+ Long.parseLong(fields[7]);
long idleCpuTime1 = Long.parseLong(fields[4]);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
reader.seek(0);
line = reader.readLine();
reader.close();
fields = line.trim().split("\s+");
long totalCpuTime2 = Long.parseLong(fields[1])
+ Long.parseLong(fields[2])
+ Long.parseLong(fields[3])
+ Long.parseLong(fields[4])
+ Long.parseLong(fields[5])
+ Long.parseLong(fields[6])
+ Long.parseLong(fields[7]);
long idleCpuTime2 = Long.parseLong(fields[4]);
return (totalCpuTime2 - totalCpuTime1) * 100.0f / (totalCpuTime2 - totalCpuTime1 + idleCpuTime2 - idleCpuTime1);
} catch (IOException e) {
e.printStackTrace();
}
return 0.0f;
}
// 判断CPU使用率是否异常
private boolean isCpuUsageAbnormal(float cpuUsage, float threshold) {
return cpuUsage > threshold;
}
以上代码通过读取/proc/stat
文件获取 CPU 使用率数据,然后计算 CPU 使用率,并根据预设的阈值来判断 CPU 使用率是否异常。这里假设异常阈值为 80%。
// 获取CPU使用率
float cpuUsage = getCpuUsage();
// 判断CPU使用率是否异常
if (isCpuUsageAbnormal(cpuUsage, 80.0f)) {
// CPU使用率异常处理
// ...
}
注意,实际使用时需要考虑到多种因素,如不同的设备、不同的 Android 版本等,可能需要做一定的兼容性处理。
4.1.2 CPU 异常治理
4.1.2.1 死循环
Bad Case
死循环类:循环退出条件达不到。
handler 消息循环
// 边界条件未满足,无法break
while (true) {
...
if (shouldExit()) {
break
}
}
// 异常处理不妥当,导致死循环
while (true) {
try {
do someting;
break;
} catch (e) {
}
}
// 消息处理不当,导致Handler线程死循环
void handleMessage(Message msg) {
//do something
handler.sendEmptyMessage(MSG)
}
异常分支处理不当
// 方法逻辑有裁剪,仅贴出主要逻辑
private JSONArray packMiscLog() {
do {
......
try {
cursor = mDb.query(......);
int n = cursor.getCount();
......
if (start_id >= max_id) {
break;
}
} catch (Exception e) {
} finally {
safeCloseCursor(cursor);
}
} while (true);
return ret;
}
4.1.2.2 资源不释放
动画泄漏
Bad Code
以下代码将创建一个持续运行的动画,但是没有停止或释放它,这可能会导致电池的损耗和性能问题:
public class AnimationLeakExample extends Activity {
private ImageView imageView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
imageView = findViewById(R.id.image_view);
startAnimation();
}
private void startAnimation() {
RotateAnimation animation = new RotateAnimation(0, 360,
Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f);
animation.setDuration(1000);
animation.setRepeatCount(Animation.INFINITE);
imageView.startAnimation(animation);
}
}
Good Case
请注意,在实际使用中,应该在动画不再需要时停止或释放它,以避免泄漏和性能问题。例如:
public class AnimationLeakExample {
private Animation animation;
public AnimationLeakExample() {
animation = new TranslateAnimation(0, 100, 0, 100);
animation.setDuration(1000);
animation.setRepeatCount(Animation.INFINITE);
animation.start();
}
public void stopAnimation() {
animation.cancel();
}
}
Solution
通过正确停止和释放动画,可以帮助确保设备的电池寿命和性能。
音频泄漏
String16 AudioFlinger::ThreadBase::getWakeLockTag()
switch(mType){
case MIXER:
return String16("AudioMix");
case DIRECT:
return String16("AudioDirectOut"):
case DUPLICATING:
return String16("AudioDup");
case RECORD:
return String16("AudioIn");
case 0FFLOAD:
return String16("Audio0ffload");
case MMAP_PLAYBACK:
return String16("MmAPPlayback");
case MMAP_CAPTURE:
return String16("MmapCapture"):
case SPATIALIZER:
return String16("AudioSpatial");
default:
ALOG_ASSERT(false);
return String16("AudioUnknown"):
}
Bad Code
以下代码将创建一个 MediaPlayer 并不会释放它,这可能会导致电池的损耗和性能问题:
public class AudioMix {
private MediaPlayer mediaPlayer;
public AudioLeakExample(Context context) {
mediaPlayer = MediaPlayer.create(context, R.raw.sample_audio);
mediaPlayer.start();
}
}
Good Case
请注意,在实际使用中,应该在不再需要音频时释放 MediaPlayer,以避免泄漏和性能问题。例如:
public class AudioMix {
private MediaPlayer mediaPlayer;
public AudioLeakExample(Context context) {
mediaPlayer = MediaPlayer.create(context, R.raw.sample_audio);
mediaPlayer.start();
}
public void releaseMediaPlayer() {
if (mediaPlayer != null) {
mediaPlayer.release();
mediaPlayer = null;
}
}
}
Solution
正确释放 MediaPlayer,可以帮助确保设备的电池寿命和性能。
WakeLock 不释放
Bad Code
以下是一段示例代码,该代码演示了如何使用 WakeLock
来保持设备唤醒,并且不释放 WakeLock
导致电池损耗和性能问题。
public class MainActivity extends APPCompatActivity {
private PowerManager.WakeLock mWakeLock;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE);
mWakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "MyWakeLockTag");
mWakeLock.acquire();
}
@Override
protected void onDestroy() {
super.onDestroy();
//注意这里没有释放WakeLock,导致电池损耗和性能问题
}
}
Good Case
在这段代码中,在 onCreate
方法中获取了一个 WakeLock
,但是在 onDestroy
方法中没有释放该 WakeLock
。这样做会导致电池损耗和性能问题,因为设备将一直处于唤醒状态。
@Override
protected void onDestroy() {
super.onDestroy();
if (mWakeLock.isHeld()) {
mWakeLock.release();
}
}
Solution
为了解决这个问题,必须在不再需要该 WakeLock
时立即释放它,可以在 onDestroy
方法中添加以下代码实现,当该 Activity 不再需要保持设备唤醒时,就可以立即释放 WakeLock
,以避免电池损耗和性能问题。
4.1.2.3 高频调用耗时函数
高频调用 md5 校验耗时函数
Bad Code
这段代码是模拟高频率调用 MD5 校验函数导致电池损耗和性能问题的情形:
public class BatteryPerformanceProblem {
public static void main(String[] args) {
// 伪代码
while (true) {
String data = "example data";
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(data.getBytes());
byte[] hash = md.digest();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
这段代码会一直循环,不断地调用 MD5 校验函数,这将导致大量的 CPU 计算和内存使用,最终将导致电池损耗和性能问题。因此,在实际开发中,应该避免这样的高频率调用,控制对系统资源的使用。
Good Case
public class BatteryPerformanceProblem {
public static void main(String[] args) {
// 伪代码
if(临界条件){
String data = "example data";
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(data.getBytes());
byte[] hash = md.digest();
} catch (Exception e) {
e.printStackTrace();
}
}else{
// Todo sth
}
}
}
Solution
对高频调用 md5 校验耗时函数进行临界条件判断。
高频调用网络建链耗时函数
Bad Code
这段代码是模拟高频率调用网络建连耗时函数导致电池损耗和性能问题的情形:
public class BatteryPerformanceProblem {
public static void main(String[] args) {
// 伪代码
while (true) {
try {
URL url = new URL("https://github.com/MicroKibaco");
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.connect();
connection.disconnect();
} catch (MalformedURLException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
“NetworkNotAvaliabeException: network not avaliable
”
Good Case
这段代码会一直循环,不断地调用网络建连函数,这将导致大量的网络数据传输和系统资源使用,最终将导致电池损耗和性能问题。因此,在实际开发中,应该避免这样的高频率调用,控制对系统资源的使用。
public class BatteryPerformanceProblem {
public static void main(String[] args) {
// 伪代码
if(临界条件){
try {
URL url = new URL("https://github.com/MicroKibaco");
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.connect();
connection.disconnect();
} catch (MalformedURLException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}else{
// Todo sth
}
}
}
Solution
对高频调用网络建链耗时函数进行临界条件判断。
高频调用正则表达式耗时函数
正则表达式是一种非常强大的工具,但是使用不当也可能导致性能问题。高频调用正则表达式的函数可能会占用大量的 CPU 资源,从而导致电池损耗和性能问题。
Bad Case
这段代码中,当用户点击按钮时,将会使用正则表达式频繁地匹配字符串。如果该操作被频繁调用,可能会导致电池损耗和性能问题。
public class MainActivity extends APPCompatActivity {
private Pattern mPattern = Pattern.compile("\d+");
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
final Button button = findViewById(R.id.button);
final TextView textView = findViewById(R.id.textView);
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
String input = "The number is 123456.";
Matcher matcher = mPattern.matcher(input);
if (matcher.find()) {
textView.setText(matcher.group());
}
}
});
}
}
Good Case
这段代码使用了 indexOf
方法代替正则表达式,以找到所需的数字。因为这种方法不需要使用正则表达式,因此它可以提高性能并减少电池损耗。
public class MainActivity extends APPCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
final Button button = findViewById(R.id.button);
final TextView textView = findViewById(R.id.textView);
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
String input = "The number is 123456.";
int start = input.indexOf(" ") + 1;
int end = input.indexOf(".", start);
textView.setText(input.substring(start, end));
}
});
}
}
Solution
-
优化正则表达式:尽量简化正则表达式,避免使用不必要的重复匹配。
-
缓存结果:如果正在匹配相同的字符串,可以考虑缓存结果,以避免多次调用正则表达式。
-
使用其他技术:使用字符串函数代替正则表达式,以提高性能。
4.1.3 CPU 异常结论
-
手机电池温度一般在 37 度以下,不会触发 CPU 温控限制。
-
手机未充电且 30%电量以上,CPU 使用率比较稳定。
-
手机省电模式对 CPU 影响较大。
-
手机网络类型对 CPU 影响很小。
4.1.4 CPU 异常阈值
-
手机电池电量>=30,电池温度<=37 度。
-
不同 CPU 型号 * 不同场景。
-
CPU 劣化组的 P90 使用率。
4.2 Camera 功耗
Camera 功耗原因
Camera 功耗主要是由相机传感器,图像处理芯片,液晶显示器和自动对焦等组件造成的。
对于直播 APP 而言,最快捷的方式是限制相机的帧率,以减少图像处理所需的时间和电。
因为高分辨率高帧率的录制会带来快速的功耗消耗和温升,像抖音的开播场景,Camera 功耗 200mA+,占整机的 25%以上。
Camera 功耗场景
抖音在开播请求的采集帧率是 30fps,但只使用了 15fps。
Camera 优化手段
那么可以主动下调采集帧率。
30FPS 下调到15FPS后CPU下降13% ,整机功耗下降120mA
Camera camera = Camera.open();
Camera.Parameters params = camera.getParameters();
List<int[]> supportedFpsRanges = params.getSupportedPreviewFpsRange();
int[] targetRange = null;
for (int[] fpsRange : supportedFpsRanges) {
if (fpsRange[0] == 15000 && fpsRange[1] == 15000) {
targetRange = fpsRange;
break;
}
}
if (targetRange != null) {
params.setPreviewFpsRange(targetRange[0], targetRange[1]);
} else {
// 15 FPS is not supported, use a different value
}
camera.setParameters(params);
camera.startPreview();
请注意,某些设备可能不支持 15 FPS,因此在代码中应该加入错误处理逻辑。此外,如果要使用相机,请务必注意遵循 Android 平台的隐私和权限规则。
4.3 低功耗
4.3.1 低功耗背景
低电量情况下,用户对功耗更加敏感,高功耗任务需要更激进的功耗优化措施:
-
未充电的情况下,剩余电量<=5%,单次停留时长开始显著下降。
-
剩余最低电量在 2%-5%的,会话占比 1.2%,时长在 15-18 分钟左右,对比 剩余 10%的平均时长 27 分钟,要损失约 11 分钟。
4.3.2 低功耗模式
Animation 暂停
Android 为了电池优化会在设备电量较低时自动减少某些可视化效果,例如动画、滚动和转场效果等。如果需要手动暂停动画,可以使用以下代码:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
if (getWindow() != null) {
getWindow().setFlags(WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED);
}
}
该代码会在 Android 4.4 及以上版本中启用硬件加速,并且会自动暂停所有正在运行的动画,以降低电量消耗。需要注意的是,这个方法可能会导致某些视图出现问题,因此需要根据具体情况决定是否使用。
超分算降级
超分辨率算法是将低分辨率图像恢复到高分辨率图像的过程。相反,降分辨率则是将高分辨率图像转换为低分辨率图像。下面是一个使用 OpenCV 库进行图像降采样的 Python 代码示例:
import cv2
# 读入高分辨率图像
image = cv2.imread("high_res_image.jpg")
# 降采样图像
downsampled = cv2.pyrDown(image)
# 保存降采样后的图像
cv2.imwrite("low_res_image.jpg", downsampled)
在上面的代码中,cv2.imread
函数读取高分辨率图像,cv2.pyrDown
函数对其进行降采样,最后, 使用cv2.imwrite
函数将降采样后的图像保存到磁盘上。需要注意的是,降采样会丢失图像的某些细节,因此应该谨慎使用。
定位服务降级
以下是一个简单的示例代码,用于在定位服务中使用省电模式:
// 获取定位管理器
LocationManager locationManager = (LocationManager) getSystemService(Context.LOCATION_SERVICE);
// 创建定位请求
LocationRequest locationRequest = LocationRequest.create()
.setPriority(LocationRequest.PRIORITY_LOW_POWER)
.setInterval(30000) // 30秒更新一次位置
.setFastestInterval(15000); // 最快15秒更新一次位置
// 检查定位服务是否可用
if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
// 请求位置更新
locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, locationRequest, locationListener);
} else {
// 如果GPS不可用,则请求网络位置更新
locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, locationRequest, locationListener);
}
在这个示例代码中,高功耗任务首先获取了定位管理器的实例,然后,创建了一个定位请求,该请求使用了较低的电量优先级。
然后,高功耗任务检查了 GPS 是否可用,如果可用,高功耗任务就使用 GPS 请求位置更新。否则,高功耗任务使用网络提供商请求位置更新。
在这种情况下,高功耗任务使用了一个较长的位置更新间隔(30 秒)和较短的最快更新间隔(15 秒),以便在省电模式下更好地优化电量消耗。
Senor 降级
以下是一个简单的示例代码,用于在 Sensor 服务中使用低功耗模式:
// 获取SensorManager实例
SensorManager sensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE);
// 获取加速度传感器实例
Sensor accelerometerSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
// 创建SensorEventListener
SensorEventListener sensorEventListener = new SensorEventListener() {
@Override
public void onSensorChanged(SensorEvent event) {
// 处理传感器数据
}
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
// 不需要处理
}
};
// 注册传感器监听器
if (accelerometerSensor != null) {
// 使用SENSOR_DELAY_NORMAL模式,该模式下传感器数据更新速度较慢,可以节省电量
sensorManager.registerListener(sensorEventListener, accelerometerSensor, SensorManager.SENSOR_DELAY_NORMAL);
}
在这个示例代码中,高功耗任务获取了 SensorManager 的实例,然后,获取了一个加速度传感器的实例。
接着,任务创建了一个 SensorEventListener 来监听传感器数据的变化。
最后,高功耗任务使用 SensorManager 的 registerListener 方法注册了这个传感器监听器,使用了较慢的 SENSOR_DELAY_NORMAL 模式,以便在低功耗模式下更好地优化电量消耗。
这样,高功耗任务就实现了 Sensor 降级的效果。需要注意的是,在不需要使用传感器的时候,应该及时取消注册传感器监听器,以免造成资源浪费。
视频码率降级
以下是一个简单的示例代码,用于在视频播放中降低码率以减少数据流量消耗:
// 获取MediaPlayer实例
MediaPlayer mediaPlayer = new MediaPlayer();
// 设置视频数据源
mediaPlayer.setDataSource(videoUrl);
// 设置视频播放画质
mediaPlayer.setVideoQuality(MediaPlayer.VIDEOQUALITY_LOW);
// 准备MediaPlayer
mediaPlayer.prepare();
// 播放视频
mediaPlayer.start();
在这个示例代码中,高功耗任务首先获取了 MediaPlayer 的实例,然后,设置了视频的数据源,接着设置了视频的播放画质为低画质。
最后,高功耗任务调用 prepare 方法准备 MediaPlayer,然后,调用 start 方法开始播放视频。
这样,就实现了在视频播放中降低码率的效果,以减少数据流量消耗。
需要注意的是,在选择降低码率的同时,也会降低视频的清晰度和流畅度,需要根据具体场景进行权衡。
Off-Screen Rendering
以下是一个简单的示例代码,用于关闭离屏渲染(Off-screen rendering):
// 获取当前View的硬件加速类型
int currentLayerType = view.getLayerType();
// 关闭硬件加速
view.setLayerType(View.LAYER_TYPE_NONE, null);
在这个示例代码中,高功耗任务获取了当前 View 的硬件加速类型,然后,使用 setLayerType 方法将硬件加速类型设置为 LAYER_TYPE_NONE,即关闭硬件加速,从而关闭离屏渲染。
需要注意的是,关闭硬件加速可能会降低视图渲染的性能和效率,因此在实际 APP 中,应该根据具体需求和场景来决定是否关闭硬件加速。
MediaPlayer 降级
以下是一个简单的示例代码,用于在视频播放器中实现降级:
// 获取MediaPlayer实例
MediaPlayer mediaPlayer = new MediaPlayer();
// 设置视频数据源
mediaPlayer.setDataSource(videoUrl);
try {
// 尝试使用硬件解码器
mediaPlayer.setHardwareDecoderEnabled(true);
// 准备MediaPlayer
mediaPlayer.prepare();
} catch (Exception e) {
// 硬件解码器不可用,使用软件解码器
mediaPlayer.setHardwareDecoderEnabled(false);
// 准备MediaPlayer
mediaPlayer.prepare();
}
// 播放视频
mediaPlayer.start();
在这个示例代码中,高功耗任务首先获取了 MediaPlayer 的实例。然后,设置了视频的数据源。
接着,高功耗任务尝试开启硬件解码器,如果硬件解码器不可用,就使用软件解码器。最后,高功耗任务调用 prepare 方法准备 MediaPlayer。然后,调用 start 方法开始播放视频。
这样,就实现了在视频播放器中进行降级的效果。需要注意的是,软件解码器通常会比硬件解码器消耗更多的 CPU 和内存资源,因此在实际 APP 中,应该根据具体需求和场景来决定是否使用软件解码器。
4.3.3 生效的场景
非充电情况下电量低于 30%
易发热机型增加开启时长
4.3.4 低功耗收益
降低 xxxmA
4.4 热缓解
4.4.1 热缓解背景
手机发热会影响用户体验,并且系统会通过限制设备的使用,减少发热量。前台 APP 不及时进行调整的话很可能会出现卡顿。
4.4.2 热缓解方案
监控手机发热,在发热前主动进行激进的降级手段,减少资源消耗,减少发热以及卡顿的发生。
以上图片来源于字节技术沙龙
4.4.3 热状态代码
Google 官方在 N 版本以上提供了热缓解框架,热缓解框架热状态码详见下表:
虽然Google热缓解框架需要各 OEM 厂商适配,但 OEM 并没有适配热缓解框架,而是通过开放 SDK 里提供了类似的能力,支持度和灵敏度,兼容性更优于 Google热缓解框架
4.4.4 收拢厂商 SDK 兼容 热缓解
因为没法磨平厂商的差异性,因此高功耗任务需要对各厂商的 SDK 进行适配,提供统一的温控级别,并对各个厂商的 SDK 进行收拢,下图可以看到字节对华为、小米、Vivo 和 OPPO 的厂商热缓解框架 SDK 收拢架构图。
以上图片来源于字节技术沙龙
4.4.4.1 厂商的热缓解SDK 优点
提供壳温,高热识别更灵敏。
4.4.4.2 厂商的热缓解 SDK缺点
老机型不支持,需要升级版本,覆盖率不高。
4.4.5 电池温度 VS 壳温相关性分析
系统在壳温平均 43 度左右会开启温控限制,39 度左右解除限制。
壳温与电池温度相关,壳温高于电池温度约 2 度。
4.5 动态帧率
4.5.1 背景
绘制帧率可以很明显的影响 GPU 功耗。抖音大部分的视频帧率都是 30fps,叠加上各种动画(音乐转盘,跑马灯,进度条,活动动画),绘制帧率会处于 60fps,降低绘制帧率可以有效优化 GPU 耗电。
以上图片来源于字节技术沙龙
4.5.2 优化方向
4.5.2.1 帧率优化
动画帧率控制
动画帧率对齐
动画与播放器帧率对齐
4.5.2.3 厂商合作
调节 vsync 回调频率,实现动态帧率。
4.5.2.4 Kita 框架
Kita 框架通过托管多种动画的绘制流程,将整体的动 画调度逻辑在 kita controller 中进行统一管理,通过降帧,对齐后,实现了整体绘制帧率的降低。
以上图片来源于字节技术沙龙
厂商通过调节 Vsync 的回调频率或者利用 LTPO 屏幕的动态刷新率能力,都可以从系统侧实现对 APP 绘制帧率的控制,高功耗任务和厂商合作。
以上图片来源于字节技术沙龙
在视频推荐页通知系统降低帧率到 30fps,在部分场景恢复正常帧率(如弹幕,高帧率视频场景),可以在保证用户体验的情况下获取更大的功耗收益。
4.6 SurfaceView 替换 TextureView
4.6.1 背景
TextureView 和 SurfaceView 是两个最常用的播放视频控件。
以上图片来源于字节技术沙龙
TextureView 控件位 于主图层上,解码器将视频帧传递到 TextureView 对象还需要 GPU 做一次绘制才能在屏幕上显示,所以其功耗更高,消耗内存更大,CPU 占用率也更高。
以上图片来源于字节技术沙龙
以下是 TextureView 和 SurfaceView 对比图:
以上图片来源于字节技术沙龙
4.6.2 收益
CPU -xxx%
整机功耗 -xxxmA
5
功耗 APM 监控建设
“技术需求: 将前台的 WakeLock、Location、Alarm、CPU、Net 和 Sensor 等器件的消费时间和启动次数,以及前台总耗电、前台单位时长总耗电、前台模块单位时间耗电、前台总时长进行计算并将结果存储到 SD 卡上的 JSON 配置文件。后台读取 JSON 配置文件的消费时间、启动次数、前台总耗电、前台单位时长总耗电、前台模块单位时间耗电、前台总时长等字段后并进行上报,监控系统可以进行多样化消费阈值的定义。如果超过消费阈值,那么飞书机器人告警。
”
简单列举一下 WakeLock 监控,其他器件参考 WakeLock 监控完成。实现思路如下:
1. 下载 ASM 框架,并将其添加到 Android 项目的依赖中。ASM 是一个 Java 字节码操作库,可以用来修改现有的 Java 字节码,从而实现对类和方法的插桩。
2. 编写一个 ASM 插件,该插件将在应用程序启动时加载,并通过字节码插桩来修改应用程序代码以实现监控 WakeLock。
3. 在插件中使用 ASM 插桩技术,查找应用程序中所有使用 WakeLock 的地方,并插入代码以记录 WakeLock 的启动时间和使用时间。这些信息将保存在内存中,并在 WakeLock 释放时写入 SD 卡上的 JSON 配置文件中。
4. 为了在应用程序中读取 JSON 配置文件中的信息,需要创建一个后台进程,该进程可以定期读取 JSON 配置文件,并将信息存储在数据库或发送给服务器。
public class WakeLockPlugin implements ClassVisitor {
public WakeLockPlugin(ClassVisitor cv) {
super(Opcodes.ASM6, cv);
}
@Override
public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
if (name.equals("acquire") && desc.equals("(J)V")) {
return new WakeLockAcquireMethodVisitor(mv);
} else if (name.equals("release") && desc.equals("()V")) {
return new WakeLockReleaseMethodVisitor(mv);
}
return mv;
}
private class WakeLockAcquireMethodVisitor extends MethodVisitor {
public WakeLockAcquireMethodVisitor(MethodVisitor mv) {
super(Opcodes.ASM6, mv);
}
@Override
public void visitCode() {
super.visitCode();
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "android/os/SystemClock", "elapsedRealtime", "()J", false);
mv.visitVarInsn(Opcodes.LSTORE, 1);
}
}
private class WakeLockReleaseMethodVisitor extends MethodVisitor {
public WakeLockReleaseMethodVisitor(MethodVisitor mv) {
super(Opcodes.ASM6, mv);
}
@Override
public void visitCode() {
super.visitCode();
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "android/os/SystemClock", "elapsedRealtime", "()J", false);
mv.visitVarInsn(Opcodes.LLOAD, 1);
mv.visitInsn(Opcodes.LSUB);
mv.visitVarInsn(Opcodes.LSTORE, 2);
mv.visitLdcInsn("wakelock.json");
mv.visitLdcInsn("time");
mv.visitVarInsn(Opcodes.LLOAD, 2);
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "org/json/JSONObject", "put", "(Ljava/lang/String;J)Lorg/json/JSONObject;", false);
mv.visitLdcInsn("wakelock.json");
mv.visitLdcInsn("count");
mv.visitInsn(Opcodes.ICONST_1);
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "org/json/JSONObject", "put", "(Ljava/lang/String;I)Lorg/json/JSONObject;", false);
mv.visitLdcInsn("wakelock.json");
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "android/os/Environment", "getExternalStorageDirectory", "()Ljava/io/File;", false);
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/File", "getAbsolutePath", "()Ljava/lang/String;", false);
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "com/example/myapplication/JsonUtils", "writeJsonFile", "(Ljava/lang/String;Lorg/json/JSONObject;Ljava/lang/String;)V", false);
}
}
}
在上面的代码中,我们创建了一个 ClassVisitor 的子类,该子类用于访问类中的方法,并在必要时修改字节码。在 visitMethod 方法中,我们检查每个方法的名称和描述符,如果它们匹配 acquire 和 release 方法的名称和描述符,就创建相应的 MethodVisitor。
在 WakeLockAcquireMethodVisitor 中,我们在方法的开头插入代码,以获取当前时间,并将其保存在局部变量中。这个时间将在释放 WakeLock 时使用。
在 WakeLockReleaseMethodVisitor 中,我们在方法的开头插入代码,以获取当前时间,并计算 WakeLock 的使用时间。然后,我们将时间和计数器保存到 JSON 对象中,然后将 JSON 对象写入 SD 卡上的文件中。
最后,在主程序中,我们需要在应用程序启动时加载 WakeLockPlugin,并且每隔一段时间读取 SD 卡上的 JSON 配置文件,并将其写入数据库或发送到服务器。
public class MainActivity extends AppCompatActivity {
@Overrideprotected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// Load WakeLockPlugintry {
ClassReader cr = new ClassReader(WakeLock.class.getName());
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
WakeLockPlugin wp = new WakeLockPlugin(cw);
cr.accept(wp, ClassReader.EXPAND_FRAMES);
byte[] code = cw.toByteArray();
DexFile df = new DexFile(getPackageCodePath());
df.writeDexFile("classes.dex", code);
} catch (IOException e) {
e.printStackTrace();
}
// Start background service to read JSON configuration fileIntent intent = new Intent(this, ConfigService.class);
startService(intent);
}
}
在上面的代码中,我们在应用程序启动时加载 WakeLockPlugin,并将其转换为 Dex 格式的字节码。然后,我们启动一个后台服务 ConfigService,该服务将定期读取 SD 卡上的 JSON 配置文件,并将其写入数据库或发送到服务器。
以上就是在 Android 应用程序中使用 ASM 完成字节码插桩,实现 WakeLock 的监控并将其保存到 SD 卡上的 JSON 配置文件中的基本步骤。请注意,这只是一个示例,实际应用程序可能需要根据具体需求进行更改和调整。
六、总结&展望
Android功耗优化指导规范就讲解完毕了,简单的总结一下: Android功耗优化指导规范主要分为五部分内容,第一部分内容是5W2H分析功耗优化。第二部分内容是功耗优化技术方案。第三部分内容是功耗优化方案分析。第四部分内容是功耗APM监控建设。最后一部分内容是总结与展望。
针对常见功耗问题,目前主要从两个视角发现问题,第一个视角是从设备角度出发寻找功耗优化手段,第二个视角是从体验角度出发,衡量如何使用降级手段,如低功耗模式和热缓解等。
针对常见功耗问题,未来我们不但要建立完善不同器件的功耗异常检测框架,同时也要完善功耗防劣化能力。
我是小木箱,如果大家对我的文章感兴趣,那么欢迎关注小木箱的公众号小木箱成长营。小木箱成长营,一个专注移动端分享的互联网成长社区。
参考资料
- 抖音功耗优化实践
- 一种AndroidAPP耗电定位方案
- 大众点评APP的短视频耗电量优化实战
- 电量优化 - 电量的统计原理与监控
- 优化电池位置
- 定位服务
- Android Vitals
- Android耗电原理及飞书耗电治理
- 第十二期字节跳动技术沙龙直播回放(02:09:00-02:45:00)
- 第十二期字节跳动技术沙龙直播回放PPT下载地址(密码: 0d9s)
- 奔跑吧!智能Monkey之Fastbot跨平台
作者:小木箱在Singapore
链接:https://juejin.cn/post/7201364247502078013
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。