Android 异步加载布局的几种实现

场景如下:当我们启动一个 Activity 的时候,如果此页面的布局太过复杂,或者是一个很长的表单,此时加载布局,执行页面转场动画,等操作都是在主线程,可能会抢Cpu资源,导致主线程block住,感知就是卡顿。

要么是点了跳转按钮,但是等待1S才会出现动画,要么是执行动画的过程中卡顿。有没有什么方式能优化此等复杂页面的启动速度,达到秒启动?

我们之前讲动画的时候就知道,转场动画是无法异步执行的,那么我们能不能再异步加载布局呢?试试!

1.异步加载布局

LayoutInflater 的 inflate 方法的几种重载方法,大家应该都会的。这里我直接把布局加载到容器中试试。

lifecycleScope.launch {

    val start = System.currentTimeMillis()

    async(Dispatchers.IO) {
        YYLogUtils.w("开始异步加载真正的跟视图")

        val view = layoutInflater.inflate(R.layout.include_pensonal_turn_up_rate, mBinding.rootView,false)

        val end = System.currentTimeMillis()

        YYLogUtils.w("加载真正布局耗时:" + (end - start))

    }

}

果不其然是报错的,不能在子线程添加View。

android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.

因为线程操作UI有 checkThread的校验,添加布局操作改变了UI,校验线程就无法通过。

那么我们只在子线程创建布局,然后再主线程添加到容器中行不行?试试!

lifecycleScope.launch {

    val start = System.currentTimeMillis()

    val rootView = async(Dispatchers.IO) {
        YYLogUtils.w("开始异步加载真正的跟视图")

        val view =  mBinding.viewStubRating.viewStub?.inflate()
        val end = System.currentTimeMillis()

        YYLogUtils.w("加载真正布局耗时:" + (end - start))

        view
    }


    if (rootView.await() != null) {
        val start1 = System.currentTimeMillis()
        mBinding.llRootContainer.addView(rootView.await(), 0)
        val end1 = System.currentTimeMillis()
        YYLogUtils.w("添加布局耗时:" + (end1 - start1))

}

这样还真行,打印日志如下:

开始异步加载真正的跟视图 加载真正布局耗时:809 添加布局耗时:22

既然可行,那我们是不是就可以通过异步网络请求+异步加载布局,实现这样一样效果,进页面展示Loading占位图,然后异步网络请求+异步加载布局,当两个异步任务都完成之后展示布局,加载数据。

private fun inflateRootAndData() {

    showStateLoading()

    lifecycleScope.launch {

        val start = System.currentTimeMillis()

        val rootView = async(Dispatchers.IO) {
            YYLogUtils.w("开始异步加载真正的跟视图")
            val view = layoutInflater.inflate(R.layout.include_pensonal_turn_up_rate, null)
            val end = System.currentTimeMillis()

            YYLogUtils.w("加载真正布局耗时:" + (end - start))

            view
        }

        val request = async {
            YYLogUtils.w("开始请求用户详情数据")
            delay(1500)
            true
        }

        if (request.await() && rootView.await() != null) {
            mBinding.llRootContainer.addView(rootView.await(), 0)
            showStateSuccess()

            popupProfile()
        }

    }
}

完美实现了秒进复杂页面的功能。当然有同学说了,自己写的行不行哦,会不会太Low,好吧,其实官方自己也出了一个异步加载布局框架,一起来看看。

2.AsyncLayoutInflater

部分源码如下:

public final class AsyncLayoutInflater {
    private static final String TAG = "AsyncLayoutInflater";

    LayoutInflater mInflater;
    Handler mHandler;
    InflateThread mInflateThread;

    public AsyncLayoutInflater(@NonNull Context context) {
        mInflater = new BasicInflater(context);
        mHandler = new Handler(mHandlerCallback);
        mInflateThread = InflateThread.getInstance();
    }

    @UiThread
    public void inflate(@LayoutRes int resid, @Nullable ViewGroup parent,
            @NonNull OnInflateFinishedListener callback) {
        if (callback == null) {
            throw new NullPointerException("callback argument may not be null!");
        }
        InflateRequest request = mInflateThread.obtainRequest();
        request.inflater = this;
        request.resid = resid;
        request.parent = parent;
        request.callback = callback;
        mInflateThread.enqueue(request);
    }

    private Callback mHandlerCallback = new Callback() {
        @Override
        public boolean handleMessage(Message msg) {
            InflateRequest request = (InflateRequest) msg.obj;
            if (request.view == null) {
                request.view = mInflater.inflate(
                        request.resid, request.parent, false);
            }
            request.callback.onInflateFinished(
                    request.view, request.resid, request.parent);
            mInflateThread.releaseRequest(request);
            return true;
        }
    };

}

其实也没有什么魔法,就是启动了一个线程去加载布局,然后通过handler发出回调,只是线程内部多了一些任务队列和任务池。和我们直接用协程异步加载布局主线程添加布局是一样样的。

既然说到这里了,我们就用 AsyncLayoutInflater 实现一个一样的效果。

var mUserProfile: String? = null
var mRootBinding: IncludePensonalTurnUpRateBinding? = null

private fun initData() {
    showStateLoading()

    YYLogUtils.w("开始异步加载真正的跟视图")
    if (mBinding.llRootContainer.childCount <= 1) {
        AsyncLayoutInflater(mActivity).inflate(R.layout.include_pensonal_turn_up_rate, null) { view, _, _ ->
            mRootBinding = DataBindingUtil.bind<IncludePensonalTurnUpRateBinding>(view)?.apply {
                click = clickProxy
            }
            mBinding.llRootContainer.addView(view, 0)

            popupData2View()
        }
    }

    YYLogUtils.w("开始请求用户详情数据")
    CommUtils.getHandler().postDelayed({
        mUserProfile = "xxx"
        showStateSuccess()
        popupData2View()
    }, 1200)
}

private fun popupData2View() {
    if (mUserProfile != null && mRootBinding != null) {
        //加载数据
    }
}

同样的是并发异步任务,异步加载布局和异步请求网络数据,然后都完成之后展示成功的布局,并显示数据。

他的效果和性能与上面协程自己写的是一样的。这里就不多说了。

当然 AsyncLayoutInflater 也有很多限制,相关的改进大家可以看看这里。

https://www.jianshu.com/p/f0c0eda06ae4

3.ViewStub 的占位

看到这里大家心里应该有疑问,你说的这种复杂的布局,我们都是使用 ViewStub 来占位,让页面能快速进入,完成之后再进行 ViewStub 的 inflate ,你整那么多花活有啥用!

确实,相信大家在这样的场景下确实用的比较多的都是使用 ViewStub 来占位,但是当 ViewStub 的布局比较大的时候 还是一样卡主线程,只是从进入页面前卡顿,转到进入页面后卡顿而已。

那我们再异步加载 ViewStub 不就行了嘛。

 private fun inflateRootAndData() {

        showStateLoading()

        lifecycleScope.launch {

            val start = System.currentTimeMillis()

            val rootView = async(Dispatchers.IO) {
                YYLogUtils.w("开始异步加载真正的跟视图")

                val view =  mBinding.viewStubRating.viewStub?.inflate()
                val end = System.currentTimeMillis()

                YYLogUtils.w("加载真正布局耗时:" + (end - start))

                view
            }

            val request = async {
                YYLogUtils.w("开始请求用户详情数据")
                delay(1500)
                true
            }

            if (request.await() && rootView.await() != null) {
                val start1 = System.currentTimeMillis()
                mBinding.llRootContainer.addView(rootView.await(), 0)
                val end1 = System.currentTimeMillis()
                YYLogUtils.w("添加布局耗时:" + (end1 - start1))
                showStateSuccess()

                popupPartTimeProfile()
            }

        }
    }

是的,和 LayoutInflater 的 inflate 一样,无法在子线程添加布局。

android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views. at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:10750) at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:2209)

ViewStub 的 inflate() 方法内部, replaceSelfWithView() 调用了 requestLayout,这部分checkThread。

那我们像 LayoutInflater 那样,子线程加载布局,在主线程添加进去?

这个嘛,好像还真没有。

那我们自己写一个?好像还真能。

4.AsyncViewStub 的定义与使用

其实很简单的实现,我们就是仿造 LayoutInflater 那样子线程加载布局,在主线程添加布局嘛。

自定义View如下,继承实现一个协程作用域,内部实现子线程加载布局,主线程替换占位View。

/**
 *  异步加载布局的 ViewStub
 */
class AsyncViewStub @JvmOverloads constructor(context: Context?, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
    View(context, attrs, defStyleAttr), CoroutineScope by MainScope() {

    var layoutId: Int = 0
    var mView: View? = null

    init {
        initAttrs(attrs, context)//初始化属性
    }

    private fun initAttrs(attrs: AttributeSet?, context: Context?) {
        val typedArray = context!!.obtainStyledAttributes(
            attrs,
            R.styleable.AsyncViewStub
        )

        layoutId = typedArray.getResourceId(
            R.styleable.AsyncViewStub_layout,
            0
        )

        typedArray.recycle()
    }


    fun inflateAsync(block: (View) -> Unit) {

        if (layoutId == 0) throw RuntimeException("没有找到加载的布局,你必须在xml中设置layout属性")

        launch {

            val view = withContext(Dispatchers.IO) {
                LayoutInflater.from(context).inflate(layoutId, null)
            }

            mView = view

            //添加到父布局
            val parent = parent as ViewGroup
            val index = parent.indexOfChild(this@AsyncViewStub)
            val vlp: ViewGroup.LayoutParams = layoutParams
            view.layoutParams = vlp //把 LayoutParams 给到新view

            parent.removeViewAt(index) //删除原来的占位View
            parent.addView(view, index) //把新有的View替换上去

            block(view)
        }
    }

    fun isInflate(): Boolean {
        return mView != null
    }

    fun getInflatedView(): View? {
        return mView
    }

    override fun onDetachedFromWindow() {
        cancel()
        super.onDetachedFromWindow()
    }
}

自定义属性

<!--  异步加载布局  -->
<declare-styleable name="AsyncViewStub">
    <attr name="layout" format="reference" />
</declare-styleable>

使用

<FrameLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.guadou.cs_cptservices.widget.AsyncViewStub
        android:id="@+id/view_stub_root"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout="@layout/include_part_time_job_detail_activity" />


    <ImageView .../>

    <TextView .../>   

    ...

</FrameLayout>         

那么我们之前怎么使用 ViewStub 的 inflate,现在就怎么使用 AsyncViewStub ,只是从之前的主线程加载布局改变为子线程加载布局。

//请求工作详情数据-并加载真正的布局
private fun initDataAndRootView() {
    if (!mBinding.viewStubRoot.isInflate()) {
        val start1 = System.currentTimeMillis()
        mBinding.viewStubRoot.inflateAsync { view ->
            val end1 = System.currentTimeMillis()
            YYLogUtils.w("添加布局耗时:" + (end1 - start1))
            mRootBinding = DataBindingUtil.bind<IncludePartTimeJobDetailActivityBinding>(view)?.apply {
                click = mClickProxy
            }

            initRV()
            checkView2Showed()
        }
    }

    //并发网络请求
    requestDetailData()
}

//这里请求网络数据完成,只展示顶部图片和标题和TabView和ViewPager
private fun requestDetailData() {
    mViewModel.requestJobDetail().observe(this) {
        checkView2Showed()
    }
}

//查询异步加载的布局和异步的远端数据是否已经准备就绪
private fun checkView2Showed() {
    if (mViewModel.mPartTimeJobDetail != null && mRootBinding != null) {

        mRootBinding?.setVariable(BR.viewModel, mViewModel)

        showStateSuccess()

        initPager()
        popupData2Top()
    }
}

重点讲解了几种可以实用的启动优化方案:

1.异步启动器加快初始化速度
官方提供了一个类,可以来进行异步的inflate,但是有两个缺点:

1.每次都要现场new一个出来

2.异步加载的view只能通过callback回调才能获得,使用不方便(死穴)

3.如果在Activity中进行初始化,通过callback回调时,并没有减少加载时间,仍然需要等待

由于以上问题,一个思考方向就是,能不能提前在子线程inflate布局,然后在Activity中通过id取出来

核心思想如下

1.初始化时在子线程中inflate布局,存储在缓存中

2.Activity初始化时,先从缓存结果里面拿View,拿到了view直接返回

3.没拿到view,但是子线程在inflate中,等待返回

4.如果还没开始inflate,由UI线程进行inflate

这种方案的优点:

可以大大减少View创建的时间,使用这种方案之后,获取View的时候基本在 10ms 之内的。

缺点

1.由于View是提前创建的,并且会存在在一个map,需要根据自己的业务场景将View从map中移除,不然会发生内存泄露

2.View如果缓存起来,记得在合适的时候重置view的状态,不然有时候会发生奇奇怪怪的现象。

总得来说,优缺点都很明显,读者可根据实际情况(主要是项目中inflate的时间长不长,改用提前加载后收益明不明显?),根据实际情况决定是否使用.

神奇的的预加载(预加载View,而不是data)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,458评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,454评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,171评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,062评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,440评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,661评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,906评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,609评论 0 200
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,379评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,600评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,085评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,409评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,072评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,088评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,860评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,704评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,608评论 2 270

推荐阅读更多精彩内容