一、背景:今天我踩的“Debug 包卡顿坑”
最近在开发的项目中包含了很多复杂的Ui以及各种自定义view和手动添加的view,包括各种复杂的动画效果,在今天调试一个动画的时候,发现页面滑动严重的卡顿,用开发者模式的Gpu条形图发现滑动的时候gpu占用直接满了,导致疯狂掉帧,排查后发现一个非常诡异的现象:Debug 包滑动时掉帧严重,动画出现明显卡顿;但用相同代码打出的Release包,运行流畅无比,丝般顺滑
我一度以为是动画太复杂、布局过深等老问题,这也符合常理,本身复杂的预算就会导致性能较差的设备会卡顿。今天刚好要加上一些更复杂的动画运算,所以就想看能不能顺便解决卡顿的问题
二、性能分析:Android Profiler性能消耗查看
通过捕获相关详细发现以下内容
通过图上可以看到,主要耗时线程:GLThread、Socket、AppExecutor、OkHttp、主线程(打马赛克的)
其中:
GLThread - 渲染线程(onDrawFrame & eglSwapBuffers)
- Total: 33,882,012 µs(约 33.9 秒,占总时间 600%)
- Self: 33,665,199 µs,说明基本没有耗时留给子调用 → 大部分时间在 GLThread 内部
- onDrawFrame:调用时间 140,792 µs,属于你的自定义渲染逻辑(比如使用 OpenGL 的话)
- eglSwapBuffers:交换帧缓冲的地方,有 75,985 µs 和一个子线程 Lock contention(锁竞争)
问题点:
- eglSwapBuffers 耗时 + 有锁竞争(Lock contention)是掉帧高风险点
- 应用中包含OpenGL渲染,说明每帧的绘制和提交时间偏高
- 应用中存在 UI过度绘制、大量纹理上传、复杂合成逻辑
这也符合我们上面说的项目的情况,对于整体项目的重构肯定是不显示的,不符合经济和人力上的预期,在尽可能的优化下,发现整体的提升不大
突然想起来,跟上个版本做一下对比,想看看这个版本带来的性能消耗有多少,安装线上版本后发现,性能消耗非常低,同样的设备基本完全不卡顿,gpu条形图显示消耗只有原本的五分之一甚至十分之一,难道一个版本带来的差异就这么大吗?
尝试拉取线上代码,想看看两个版本的差异,发现差异不可能导致这么大的性能差距,抓破脑袋没想出来为什么
这个时候,对于线上版本的代码一运行,发现不对劲了,一样的卡顿,一样的掉帧,一样的性能消耗。同样的代码,为什么调试运行在设备上会变得这么卡?
三、原因分析:Debug vs Release 性能差距核心因素
我们来看为什么 相同的代码,在 Debug 和 Release 下性能差距能如此巨大。
- 方法内联与编译优化(Inlining)
Debug 构建为了方便调试,禁用了大部分内联优化。而 Release 构建通过 R8 或 ART 的优化器,将大量小方法(如 getX()、wrapNull())直接内联展开,减少方法调用和栈帧创建。
fun isVisible(): Boolean = visibility == View.VISIBLE
// 这些小方法在 Release 构建中会直接内联到调用者内,提升效率
例如R8中:
public boolean canInlineMethod(InvokeMethod invoke, DexEncodedMethod target) {
if (target == null || target.getCode() == null) {
return false;
}
// 方法太大就不内联
if (target.getCode().estimatedSize() > MAX_INLINE_SIZE) {
return false;
}
// 如果是外部库或配置禁止,也不内联
if (!target.isInlineCandidate()) {
return false;
}
return true;
}
(com/android/tools/r8/ir/optimize/inliner/InliningOracle.java)
内联逻辑在构建 IR 的时候生效
// 构建中间表示(IR)时,插入内联代码块
if (shouldInlineInvoke(invoke)) {
inlineMethodBody(invoke);
}
(com/android/tools/r8/ir/conversion/IRBuilder.java)``
ART 执行阶段:JIT 和 AOT 编译内联逻辑,简单来说,ART 的 HInliner 会在运行时分析调用,并根据方法体大小、调用频次等条件决定是否 JIT 内联该方法。
// 判断函数是否适合内联
bool HInliner::IsCandidate(HInvoke* invoke) {
if (invoke->GetInlined()) return false;
HGraph* callee_graph = AnalyzeInvoke(invoke);
if (callee_graph == nullptr) return false;
// 判断方法体大小
if (callee_graph->GetInstructionCount() > kMaxInstructions) {
return false;
}
return true;
}
(art/compiler/optimizing/inliner.cc)
- JVM/ART 执行优化(JIT vs AOT)
-
JIT(Just In Time):运行时编译热点方法
-
AOT(Ahead of Time):安装前将字节码直接编译为 native 代码
-
ART 支持 JIT + AOT 混合,Release 包更倾向 AOT 编译
ART编辑器入口:在 Debug 下,为了保持可调试性,会保留完整的堆栈追踪和 JNI 跟踪逻辑;Release 则会跳过这些耗时操作。
extern "C" uint64_t artQuickGenericJniTrampoline(...) {
// 从 Java 方法调用 native
ArtMethod* method = ...;
// 记录调用栈信息(调试时很重要)
ScopedJniEnvLocalRefState env_state(env);
// 调用真正的 JNI 方法
method->Invoke(...);
}
(art/runtime/entrypoints/jni/jni_entrypoints.cc)
JIT编辑器入口:运行时 Just-In-Time 编译器入口,会监控热点方法并编译为 native 代码以加速执行(Debug 模式较依赖 JIT)。
void Jit::CreateJit(...) {
jit->jit_compiler_ = new JitCompiler(...);
jit->jit_options_->SetUseInlining(true); // 开启内联(Release 常设为 true)
}
void Jit::CompileMethod(...) {
if (jit_options_->UseInlining()) {
// 热方法被内联优化
InlineHotMethods();
}
compiler_driver_->Compile(...);
}
3. 未使用代码和逻辑大量存在于 Debug 包
- Debug 包未做 shrink、minify,所有类、资源、方法都被保留
- 无用代码(如测试类、mock数据、调试器辅助)仍在应用中运行
-
Debug 包中未启用 ProGuard 和 R8 ➜ 未使用的类、资源全都保留;
-
layout 中的 tools: 属性、view tag、临时 ID、调试逻辑都在;
-
layoutInflater 加载慢、class 构造路径长 ➜ view 构建/重绘代价高。
TreePruner 是 R8 中用于**删除未被引用的类、方法、字段的核心模块。它会根据使用情况(call graph)进行遍历,保留被引用的代码,其他一律裁剪。
public class TreePruner {
public ProgramDefinitionSet run(ProgramDefinitionSet liveSet) {
// 构建使用图,仅保留 live code
for (DexProgramClass clazz : appView.appInfo().classes()) {
if (!liveSet.contains(clazz)) {
continue; // 类未使用,跳过(将被移除)
}
for (DexEncodedMethod method : clazz.methods()) {
if (!liveSet.contains(method)) {
// 方法未使用,不加入输出
continue;
}
// 方法使用了,保留
}
}
return newAppWithPrunedCode;
}
}
(com.android.tools.r8.shaking.TreePruner)
- Debug 包保留了断点、调试符号、调试辅助代码
包括但不限于:
| 类型 | 调试内容 |
|---|---|
| 源码路径 | 类名、方法名未混淆 |
| 行号表 | 提供断点和栈信息 |
| ViewDebug | 辅助绘制边界、ID |
| layoutInspector | 自动 hook View 构建、tag 注入 |
这些信息在引入复杂自定义 View 后,导致每个 onDraw() 都会带上额外绘制逻辑
// ViewRootImpl.java
if (ViewDebug.considerRequestLayout(view)) {
canvas.drawRect(...); // 画出边界
}
5. layout 渲染、动画调度、View 添加频繁触发 ViewDebug / LayoutInspector
-
使用 addView() 动态添加时,layoutInflater 会走完整 XML inflate 和 ID 映射逻辑;
-
动画频繁触发 invalidate() ➜ layout & draw 过程被 debug hook;
-
所有绘制路径包含调试辅助 ➜ 每帧工作负载显著增加。
以一个自定义view举例子
class FancyButton @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawRoundRect(..., paint)
canvas.drawText("按钮", ..., textPaint)
}
}
在 Debug 下:
- 每次 draw 都会附带 ViewDebug 的边界检测、调试栈信息;
- canvas.drawXXX 频繁触发 layoutInspector hook;
- 每个动画 frame 都是未优化解释执行的调用链;
而 Release 构建中:
-
以上方法全部内联或提前编译;
-
调试辅助完全禁用;
-
View tag/inspector 注入逻辑全被剥离;
最终会看到:同样的 draw,Release 的时间可能是 Debug 的 1/4。
其实在之前一直没有注意到debug和release的差异的很大一部分原因是因为在一台调试设备上只运行了debug包没有去尝试线上包,而在另外性能更好的设备上,运行两个包都没有出现卡顿现象,所以一直没有发现这个有趣的现象。