最近发现项目中一个Activity启动缓慢,点击要要等2秒多的时间才能打开,经日志定位,发现是 setContentView 这个方法耗时1秒多的时间。这个Activity中包含了3个Toolbar,很可能是导致布局加载缓慢的原因。但我没有直接优化布局,而是决定采用预加载布局的策略解决这个问题。
核心思路:空间换时间
View 缓存的核心思想很简单:在用户可能打开下一个页面(目标页面 B)之前,在当前页面(页面 A)空闲时,提前将页面 B 的布局文件解析实例化成 View 对象,并存入缓存。当用户真正跳转到页面 B 时,直接从缓存中取出已经创建好的 View,跳过耗时的 inflate 过程。
这是一种典型的“空间换时间”策略,用内存缓存来换取更快的启动速度。
第一步:设计缓存管理器 ViewCacheManager
为了统一管理 View 缓存,我首先创建了一个 ViewCacheManager 类。它的主要职责是:
- 存储缓存: 内部使用
LruCache<Int, View>。Key 是布局资源的 ID (R.layout.xxx),Value 是预加载的 View 对象。LruCache能自动管理缓存大小,移除最近最少使用的项。 - 提供预加载接口:
preloadView(context, layoutResId)方法,用于异步或空闲时加载布局。 - 提供获取缓存接口:
getCachedView(layoutResId)方法,用于目标 Activity 尝试获取缓存。获取成功后,View 会从缓存中移除,防止复用。 - 提供清理接口:
clearCache()方法,用于在低内存等时机释放资源。 - 单例模式: 确保全局只有一个缓存管理器实例。
/**
* View缓存管理器
* 预加载View,减少View创建消耗
*/
public class ViewCacheManager {
private static final String TAG = "ViewCacheManager";
// 设置缓存大小,例如最多缓存10个View
private static final int MAX_CACHE_SIZE = 10;
private static volatile ViewCacheManager instance;
private final LruCache<Integer, View> cache;
private ViewCacheManager() {
cache = new LruCache<>(MAX_CACHE_SIZE);
}
public static ViewCacheManager getInstance() {
if (instance == null) {
synchronized (ViewCacheManager.class) {
if (instance == null) {
instance = new ViewCacheManager();
}
}
}
return instance;
}
/**
* 预加载指定布局ID的View
* @param context ContextThemeWrapper,要求包含主题信息
* @param layoutResId 布局资源ID
*/
public void preloadView(@NonNull ContextThemeWrapper context, @LayoutRes int layoutResId) {
// 检查缓存是否存在
synchronized (cache) {
if (cache.get(layoutResId) != null) {
Log.d(TAG, "Layout ID: " + layoutResId + " already in cache.");
return;
}
}
try {
View view = LayoutInflater.from(context).inflate(layoutResId, null, false);
// 再次检查缓存,防止并发预加载同一个布局
synchronized (cache) {
if (cache.get(layoutResId) == null) {
cache.put(layoutResId, view);
Log.d(TAG, "Successfully preloaded layout ID: " + layoutResId + ". Cache size: " + cache.size());
} else {
// 如果在 inflate 过程中其他线程已经缓存了,则丢弃当前 inflate 的结果
Log.d(TAG, "Layout ID: " + layoutResId + " was cached concurrently. Discarding this instance.");
}
}
} catch (Exception e) {
Log.e(TAG, "Error preloading layout ID: " + layoutResId, e);
// 发生异常时确保移除可能存在的半成品(虽然在此场景下不太可能放入缓存)
synchronized (cache) {
cache.remove(layoutResId);
}
}
}
/**
* 获取缓存的View,获取后会从缓存中移除
* @param layoutResId 布局资源ID
* @return 缓存的View,如果不存在则返回null
*/
@Nullable
public View getCachedView(@LayoutRes int layoutResId) {
View view;
synchronized (cache) {
view = cache.remove(layoutResId);
}
if (view != null) {
Log.d(TAG, "Retrieved cached view for layout ID: " + layoutResId + ". Cache size: " + cache.size());
// 确保View没有parent,防止"The specified child already has a parent"错误
if (view.getParent() instanceof ViewGroup) {
((ViewGroup) view.getParent()).removeView(view);
}
} else {
Log.d(TAG, "Cache miss for layout ID: " + layoutResId);
}
return view;
}
/**
* 清空所有缓存
*/
public void clearCache() {
Log.d(TAG, "Clearing all cached views. Current size: " + cache.size());
synchronized (cache) {
cache.evictAll();
}
}
}
(注意:preloadView 方法的 context 参数类型是 ContextThemeWrapper,这非常关键,我们稍后会详细讨论。)
第二步:选择预加载时机 (MainActivity)
预加载不能影响当前页面的流畅度。最佳时机是当前页面 (MainActivity) 渲染完成且主线程空闲时。我使用了 Looper.myQueue().addIdleHandler 来实现:
// MainActivity.java
public class MainActivity extends BaseActivity {
private final MessageQueue.IdleHandler preloadHandler = () -> {
Log.d("MainActivity", "IdleHandler: Preloading quick edit layout.");
// 可以直接使用Activity,也可创建带有主题的 Context
// ContextThemeWrapper themedContext = new ContextThemeWrapper(getApplicationContext(), getTheme());
ViewCacheManager.getInstance().preloadView(activity, R.layout.activity_quick_edit);
return false; // 执行一次后移除
};
@Override
protected void onResume() {
super.onResume();
Looper.myQueue().addIdleHandler(preloadHandler); // 添加 IdleHandler
}
@Override
protected void onPause() {
super.onPause();
Looper.myQueue().removeIdleHandler(preloadHandler); // 移除 IdleHandler,防止泄漏和无效操作
}
@Override
protected void onDestroy() {
super.onDestroy();
Looper.myQueue().removeIdleHandler(preloadHandler); // 确保移除
}
@Override
public void onLowMemory() {
super.onLowMemory();
ViewCacheManager.getInstance().clearCache(); // 低内存时清理缓存
}
// ...
}
这样,在 MainActivity 可见且空闲时,就会尝试预加载 QuickEditActivity 的布局。
第三步:使用缓存 (QuickEditActivity)
在目标页面 QuickEditActivity 的 onCreate 方法中,优先尝试从缓存获取 View:
// QuickEditActivity.java
public class QuickEditActivity extends BaseActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
final int layoutResId = R.layout.activity_quick_edit;
View contentView = ViewCacheManager.getInstance().getCachedView(layoutResId);
if (contentView == null) {
// 缓存未命中,正常加载
Log.d("QuickEditActivity", "Cache miss. Inflating layout ID: " + layoutResId);
contentView = getLayoutInflater().inflate(layoutResId, null, false);
} else {
// 缓存命中
Log.d("QuickEditActivity", "Cache hit! Using cached view for layout ID: " + layoutResId);
}
// 使用获取到的 View(缓存的或新创建的)
setContentView(contentView);
// !!! 注意:如果使用 ViewBinding/DataBinding/ButterKnife,需要手动绑定 !!!
// ButterKnife.bind(this, contentView);
// YourBinding binding = YourBinding.bind(contentView);
// ... 后续初始化逻辑 ...
}
@Override
public void onLowMemory() {
super.onLowMemory();
ViewCacheManager.getInstance().clearCache(); // 响应低内存
}
// ...
}
理论上,这样就完成了 View 缓存的集成。QuickEditActivity 在缓存命中时应该能更快地显示出来。
踩坑之路:恼人的 InflateException
然而,现实是残酷的。运行应用后,我在 preloadView 时遇到了崩溃:
第一个坑:Failed to resolve attribute
android.view.InflateException: Binary XML file line #16 ... Error inflating class androidx.appcompat.widget.Toolbar
Caused by: java.lang.UnsupportedOperationException: Failed to resolve attribute at index 37: TypedValue{t=0x2/d=0x7f030005 a=5}, theme={...}
错误指向布局中的 Toolbar,原因是无法解析某个主题属性(Attribute)。我意识到,inflate 操作需要正确的 Context 来解析 XML 中定义的 ?attr/xxx 这样的主题属性。
最初,我在 MainActivity 中调用 preloadView 时直接传入了 MainActivity.this。ViewCacheManager 内部为了安全,可能会尝试使用 ApplicationContext 或基于 Activity Context 创建 ContextThemeWrapper。但当 preloadView 在 IdleHandler 中(甚至后台线程)执行时,获取到的 Context 可能无法完美解析所有 Activity 主题相关的属性。
尝试修复 1:直接使用 ApplicationContext
我修改了 ViewCacheManager.preloadView,强制使用 context.getApplicationContext() 来 inflate。心想,虽然样式可能丢失,但至少能成功加载 View 结构吧?
第二个坑:自定义 View 内部的 InflateException
结果,崩溃依然存在,但堆栈指向了更深的地方:
android.view.InflateException: Binary XML file line #2 in com.haoxueren.note:layout/activity_quick_edit ... Error inflating class com.haoxueren.view.StatefulLayout
Caused by: android.view.InflateException: Binary XML file line #8 in com.haoxueren.note:layout/layout_empty ... Error inflating class <unknown>
这次问题出在 activity_quick_edit.xml 使用的一个自定义 View StatefulLayout 上。原来,StatefulLayout 在其构造函数中,就使用传入的 Context(现在是 ApplicationContext)去加载它内部的状态布局(如 layout_empty.xml)。如果这些内部布局也依赖 Activity 主题属性,那么问题就又回来了!
最终解决方案:修改自定义 View + 正确传递 Context
问题的根源在于 StatefulLayout 在构造函数中过早地加载了内部布局,并且依赖了传入的 Context。
为了尽可能保证预加载 View 的样式与最终显示一致,最佳实践是传入 Activity 作为 ContextThemeWrapper。
总结与思考
通过这次实践,我成功利用 View 缓存优化了 QuickEditActivity 的启动速度。但也深刻体会到:
- Context 很重要: 在
inflate时选择正确的Context至关重要,尤其涉及到主题属性时。ApplicationContext通常更安全,不易泄漏,但无法解析 Activity 特有的主题属性。Activity Context可以解析主题,但传递和持有不当容易导致内存泄漏。ContextThemeWrapper是一个在ApplicationContext基础上附加主题的好方法,但要确保源Context包含有效的主题。 - 自定义 View 的影响: 自定义 View 内部的实现(尤其是在构造函数中的操作)可能会对这种预加载策略产生意想不到的影响。
- 权衡利弊: View 缓存增加了代码复杂度和内存消耗。它最适用于:
- 布局复杂、
inflate耗时较长的页面。 - 用户导航路径相对固定,能够预测下一个页面的场景。
- 启动性能瓶颈确实在于布局加载的场景。
- 布局复杂、
- 现代框架: 像 Jetpack Compose 这样的声明式 UI 框架,其渲染机制与传统 View 系统不同,可能不需要或不适合这种手动缓存策略。
总的来说,View 预加载与缓存是一种有效的性能优化手段,但在实施过程中需要仔细考虑 Context、主题、自定义 View 和生命周期管理等问题。希望这次的踩坑记录能对遇到类似问题的同学有所帮助!