Activity 启动速度优化:View 缓存实战与踩坑记录

466 阅读7分钟

最近发现项目中一个Activity启动缓慢,点击要要等2秒多的时间才能打开,经日志定位,发现是 setContentView 这个方法耗时1秒多的时间。这个Activity中包含了3个Toolbar,很可能是导致布局加载缓慢的原因。但我没有直接优化布局,而是决定采用预加载布局的策略解决这个问题。

核心思路:空间换时间

View 缓存的核心思想很简单:在用户可能打开下一个页面(目标页面 B)之前,在当前页面(页面 A)空闲时,提前将页面 B 的布局文件解析实例化成 View 对象,并存入缓存。当用户真正跳转到页面 B 时,直接从缓存中取出已经创建好的 View,跳过耗时的 inflate 过程。

这是一种典型的“空间换时间”策略,用内存缓存来换取更快的启动速度。

第一步:设计缓存管理器 ViewCacheManager

为了统一管理 View 缓存,我首先创建了一个 ViewCacheManager 类。它的主要职责是:

  1. 存储缓存: 内部使用 LruCache<Int, View>。Key 是布局资源的 ID (R.layout.xxx),Value 是预加载的 View 对象。LruCache 能自动管理缓存大小,移除最近最少使用的项。
  2. 提供预加载接口: preloadView(context, layoutResId) 方法,用于异步或空闲时加载布局。
  3. 提供获取缓存接口: getCachedView(layoutResId) 方法,用于目标 Activity 尝试获取缓存。获取成功后,View 会从缓存中移除,防止复用。
  4. 提供清理接口: clearCache() 方法,用于在低内存等时机释放资源。
  5. 单例模式: 确保全局只有一个缓存管理器实例。
/**
 * 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)

在目标页面 QuickEditActivityonCreate 方法中,优先尝试从缓存获取 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.thisViewCacheManager 内部为了安全,可能会尝试使用 ApplicationContext 或基于 Activity Context 创建 ContextThemeWrapper。但当 preloadViewIdleHandler 中(甚至后台线程)执行时,获取到的 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 的启动速度。但也深刻体会到:

  1. Context 很重要: 在 inflate 时选择正确的 Context 至关重要,尤其涉及到主题属性时。ApplicationContext 通常更安全,不易泄漏,但无法解析 Activity 特有的主题属性。Activity Context 可以解析主题,但传递和持有不当容易导致内存泄漏。ContextThemeWrapper 是一个在 ApplicationContext 基础上附加主题的好方法,但要确保源 Context 包含有效的主题。
  2. 自定义 View 的影响: 自定义 View 内部的实现(尤其是在构造函数中的操作)可能会对这种预加载策略产生意想不到的影响。
  3. 权衡利弊: View 缓存增加了代码复杂度和内存消耗。它最适用于:
    • 布局复杂、inflate 耗时较长的页面。
    • 用户导航路径相对固定,能够预测下一个页面的场景。
    • 启动性能瓶颈确实在于布局加载的场景。
  4. 现代框架: 像 Jetpack Compose 这样的声明式 UI 框架,其渲染机制与传统 View 系统不同,可能不需要或不适合这种手动缓存策略。

总的来说,View 预加载与缓存是一种有效的性能优化手段,但在实施过程中需要仔细考虑 Context、主题、自定义 View 和生命周期管理等问题。希望这次的踩坑记录能对遇到类似问题的同学有所帮助!