性能优化(上):布局优化、内存优化、图片优化、APK瘦身与ANR分析
性能优化这个话题,说实话,是区分“能跑”和“好用”的分水岭。我见过太多APP功能齐全,但一用就卡、一开就崩,用户直接卸载。今天咱们就把性能优化这块硬骨头啃下来,分成五个核心方向来讲。
核心观点:性能优化不是事后补救,而是贯穿开发始终的设计思维。你想想看,等用户反馈卡顿再改,成本至少翻三倍。
一、布局优化:从嵌套地狱到扁平化
布局优化,说白了就是减少View树的层级。我早期做项目时,一个界面嵌套了七八层LinearLayout,结果在低端机上滑动卡成PPT。后来才明白,每多一层嵌套,measure和layout的时间就翻倍。
1.1 ConstraintLayout:扁平化的利器
ConstraintLayout是Google官方推荐的布局容器。它用相对定位取代了多层嵌套,一个ConstraintLayout就能搞定以前需要四五个布局才能实现的效果。
<!-- 传统嵌套方式(不推荐) -->
<LinearLayout android:orientation="vertical">
<LinearLayout android:orientation="horizontal">
<ImageView />
<TextView />
</LinearLayout>
<LinearLayout android:orientation="horizontal">
<Button />
<Button />
</LinearLayout>
</LinearLayout>
<!-- ConstraintLayout方式(推荐) -->
<androidx.constraintlayout.widget.ConstraintLayout>
<ImageView app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<TextView app:layout_constraintStart_toEndOf="@id/imageView"
app:layout_constraintTop_toTopOf="@id/imageView"/>
<Button app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/imageView"/>
<Button app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/imageView"/>
</androidx.constraintlayout.widget.ConstraintLayout>
我的习惯:能用ConstraintLayout的地方绝不用其他布局。配合layout_constraintChain和Guideline,几乎能实现任何复杂界面,而且层级永远只有两层。
1.2 ViewStub:按需加载的懒汉模式
ViewStub是个隐形英雄。它不占用布局资源,只有在调用inflate()时才真正加载。我曾在项目中用ViewStub优化了一个包含12个Tab的详情页,启动速度提升了40%。
<ViewStub
android:id="@+id/stub_network_error"
android:layout="@layout/network_error"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
// 在需要时加载
ViewStub stub = findViewById(R.id.stub_network_error);
if (stub != null) {
View inflatedView = stub.inflate();
// 现在可以操作inflatedView中的子控件了
}
注意:ViewStub一旦inflate后,就不再是ViewStub了,而是被替换成了实际的布局。所以inflate只能调用一次,不能重复使用。
二、内存优化:别让OOM找上门
内存泄漏是Android开发的顽疾。说白了就是对象该回收时没被回收,一直占着内存。时间长了,GC频繁触发,APP就卡了,严重时直接OOM崩溃。
2.1 LeakCanary:内存泄漏的照妖镜
LeakCanary是Square公司开源的自动内存泄漏检测工具。我习惯在debug版本中集成它,每次测试完看一眼通知栏,有没有泄漏一目了然。
// 在Application中初始化
public class MyApp extends Application {
@Override
public void onCreate() {
super.onCreate();
if (LeakCanary.isInAnalyzerProcess(this)) {
return; // 分析进程不需要初始化
}
LeakCanary.install(this);
}
}
集成后,一旦发生内存泄漏,LeakCanary会自动生成一条通知。点开就能看到泄漏的引用链,精确到哪个Activity、哪个字段没释放。
避坑指南:我曾经遇到一个诡异的泄漏——一个静态的Handler持有Activity引用,导致Activity无法回收。LeakCanary直接定位到了Handler的postDelayed那一行。从那以后,我所有内部类都改成静态+弱引用的写法。
2.2 常见内存泄漏场景
- 静态变量持有Activity引用:比如单例模式中传入了Context
- 内部类/匿名类持有外部类引用:Handler、Runnable、AsyncTask
- 未取消的注册:BroadcastReceiver、EventBus、观察者模式
- 资源未关闭:Cursor、FileInputStream、Bitmap
三、图片优化:一张图毁掉一个APP
图片是内存消耗的大户。一张1920x1080的ARGB_8888图片,加载到内存里要占8MB。如果列表里同时加载20张,160MB就没了。你想想看,低端机总共才512MB内存,不崩才怪。
3.1 Glide:轻量级图片加载库
Glide是Google推荐的图片加载库。它自动处理了图片的缓存、缩放、解码,而且支持GIF和缩略图。
// 基本用法
Glide.with(context)
.load(url)
.placeholder(R.drawable.placeholder) // 占位图
.error(R.drawable.error) // 错误图
.override(300, 300) // 指定尺寸
.centerCrop() // 裁剪方式
.into(imageView);
// 缓存策略
Glide.with(context)
.load(url)
.diskCacheStrategy(DiskCacheStrategy.ALL) // 缓存原图和转换后的图
.skipMemoryCache(true) // 跳过内存缓存(大图慎用)
.into(imageView);
3.2 Fresco:Facebook的巨无霸方案
Fresco的优势在于三级缓存(内存、磁盘、网络)和渐进式加载。如果你的APP图片特别多、特别大,Fresco是更好的选择。不过它体积较大,APK会增加2-3MB。
我的建议:普通APP用Glide就够了,轻量且够用。如果你的APP是图片社交类(比如小红书、Instagram),可以考虑Fresco。我个人习惯用Glide,因为集成简单,API更友好。
四、APK瘦身:从50MB到15MB的蜕变
APK太大,用户下载意愿会降低。我记得有一次优化一个老项目,APK从48MB瘦到了12MB,下载转化率提升了30%。
4.1 资源优化
| 优化项 | 具体做法 | 效果 |
|---|---|---|
| 图片压缩 | 使用WebP格式,或用TinyPNG压缩PNG | 减少50%-80% |
| 移除无用资源 | 使用Lint检测未使用的资源 | 视项目而定 |
| 只保留必要dpi | 只保留xxhdpi,其他用运行时缩放 | 减少30%-50% |
| 语言资源 | 只保留中文和英文 | 减少10%-20% |
4.2 代码优化
- 开启混淆:minifyEnabled true,去除无用代码
- 开启资源压缩:shrinkResources true,去除无用资源
- 使用Android App Bundle:按需分发,用户只下载需要的部分
// build.gradle配置
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
'proguard-rules.pro'
}
}
bundle {
language {
enableSplit = true // 按语言拆分
}
density {
enableSplit = true // 按屏幕密度拆分
}
}
}
五、ANR分析与解决:别让用户等太久
ANR(Application Not Responding)是Android系统对用户的保护机制。说白了,如果主线程5秒内没处理完输入事件,系统就会弹窗问用户“要不要关掉这个APP”。
5.1 ANR的常见原因
- 主线程做了耗时操作:网络请求、大文件读写、复杂计算
- 锁竞争:主线程等待子线程释放锁
- Binder调用超时:跨进程通信卡住
- 布局过于复杂:measure/layout耗时过长
5.2 如何定位ANR
当ANR发生时,系统会在/data/anr/目录下生成traces.txt文件。这个文件记录了所有线程的堆栈信息。我习惯用以下步骤分析:
- 找到主线程("main")的堆栈
- 看它卡在哪个方法上
- 如果是自己的代码,直接改;如果是系统方法,看是不是锁等待
// 模拟一个ANR场景
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// 错误:在主线程做网络请求
try {
URL url = new URL("https://example.com/api");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
// ... 这会导致ANR
} catch (Exception e) {
e.printStackTrace();
}
}
}
// 正确做法:使用协程或线程池
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
new Thread(() -> {
// 网络请求放在子线程
try {
URL url = new URL("https://example.com/api");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
// ...
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
避坑指南:我曾经遇到一个隐蔽的ANR——SharedPreferences的apply()方法在极端情况下会阻塞主线程。因为apply()虽然是异步写入,但如果写入队列满了,它会在主线程等待。后来我改用MMKV或者Room,再也没遇到过这个问题。
总结
性能优化没有银弹,但遵循几个原则就能避免大部分问题:布局扁平化、图片按需加载、内存及时释放、APK按需分发、主线程不做耗时操作。嗯,这些说起来简单,但真正落实到每个页面、每个功能,需要养成习惯。
我个人习惯在每次提交代码前,用Profiler跑一遍性能数据。如果发现某个页面帧率低于30fps,或者内存持续增长,就停下来排查。别等到用户反馈再改,那时候已经晚了。