Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Best practices for dealing with expensive view height calculation?

I keep running into a sizing and layout problem for custom views and I'm wondering if anyone can suggest a "best practices" approach. The problem is as follows. Imagine a custom view where the height required for the content depends on the width of the view (similar to a multi-line TextView). (Obviously, this only applies if the height isn't fixed by the layout parameters.) The catch is that for a given width, it's rather expensive to compute the content height in these custom views. In particular, it's too expensive to be computed on the UI thread, so at some point a worker thread needs to be fired up to compute the layout and when it is finished, the UI needs to be updated.

The question is, how should this be designed? I've thought of several strategies. They all assume that whenever the height is calculated, the corresponding width is recorded.

The first strategy is shown in this code:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int width = measureWidth(widthMeasureSpec);
    setMeasuredDimension(width, measureHeight(heightMeasureSpec, width));
}

private int measureWidth(int widthMeasureSpec) {
    // irrelevant to this problem
}

private int measureHeight(int heightMeasureSpec, int width) {
    int result;
    int specMode = MeasureSpec.getMode(measureSpec);
    int specSize = MeasureSpec.getSize(measureSpec);
    if (specMode == MeasureSpec.EXACTLY) {
        result = specSize;
    } else {
        if (width != mLastWidth) {
            interruptAnyExistingLayoutThread();
            mLastWidth = width;
            mLayoutHeight = DEFAULT_HEIGHT;
            startNewLayoutThread();
        }
        result = mLayoutHeight;
        if (specMode == MeasureSpec.AT_MOST && result > specSize) {
            result = specSize;
        }
    }
    return result;
}

When the layout thread finishes, it posts a Runnable to the UI thread to set mLayoutHeight to the calculated height and then call requestLayout() (and invalidate()).

A second strategy is to have onMeasure always use the then-current value for mLayoutHeight (without firing up a layout thread). Testing for changes in width and firing up a layout thread would be done by overriding onSizeChanged.

A third strategy is to be lazy and wait to fire up the layout thread (if necessary) in onDraw.

I would like to minimize the number of times a layout thread is launched and/or killed, while also calculating the required height as soon as possible. It would probably be good to minimize the number of calls to requestLayout() as well.

From the docs, it's clear that onMeasure might be called several times during the course of a single layout. It's less clear (but seems likely) that onSizeChanged might also be called several times. So I'm thinking that putting the logic in onDraw might be the better strategy. But that seems contrary to the spirit of custom view sizing, so I have an admittedly irrational bias against it.

Other people must have faced this same problem. Are there approaches I've missed? Is there a best approach?

like image 275
Ted Hopp Avatar asked Aug 10 '11 17:08

Ted Hopp


1 Answers

I think the layout system in Android wasn't really designed to solve a problem like this, which would probably suggest changing the problem.

That said, I think the central issue here is that your view isn't actually responsible for calculating its own height. It is always the parent of a view that calculates the dimensions of its children. They can voice their "opinion", but in the end, just like in real life, they don't really have any real say in the matter.

That would suggest taking a look at the parent of the view, or rather, the first parent whose dimensions are independent of the dimensions of its children. That parent could refuse layouting (and thus drawing) its children until all children have finished their measuring phase (which happens in a separate thread). As soon as they have, the parent requests a new layout phase and layouts its children without having to measure them again.

It is important that the measurements of the children don't affect the measurement of said parent, so that it can "absorb" the second layout phase without having to remeasure its children, and thus settle the layout process.

[edit] Expanding on this a bit, I can think of a pretty simple solution that only really has one minor downside. You could simply create an AsyncView that extends ViewGroup and, similar to ScrollView, only ever contains a single child which always fills its entire space. The AsyncView does not regard the measurement of its child for its own size, and ideally just fills the available space. All that AsyncView does is wrap the measurement call of its child in a separate thread, that calls back to the view as soon as the measurement is done.

Inside of that view, you can pretty much put whatever you want, including other layouts. It doesn't really matter how deep the "problematic view" is in the hierarchy. The only downside would be that none of the descendants would get rendered until all of the descendants have been measured. But you probably would want to show some kind of loading animation until the view is ready anyways.

The "problematic view" would not need to concern itself with multithreading in any way. It can measure itself just like any other view, taking as much time as it needs.

[edit2] I even bothered to hack together a quick implementation:

package com.example.asyncview;

import android.content.Context;
import android.os.AsyncTask;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;

public class AsyncView extends ViewGroup {
    private AsyncTask<Void, Void, Void> mMeasureTask;
    private boolean mMeasured = false;

    public AsyncView(Context context) {
        super(context);
    }

    public AsyncView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        for(int i=0; i < getChildCount(); i++) {
            View child = getChildAt(i);
            child.layout(0, 0, child.getMeasuredWidth(), getMeasuredHeight());
        }
    }

    @Override
    protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        if(mMeasured)
            return;
        if(mMeasureTask == null) {
            mMeasureTask = new AsyncTask<Void, Void, Void>() {
                @Override
                protected Void doInBackground(Void... objects) {
                    for(int i=0; i < getChildCount(); i++) {
                        measureChild(getChildAt(i), widthMeasureSpec, heightMeasureSpec);
                    }
                    return null;
                }

                @Override
                protected void onPostExecute(Void aVoid) {
                    mMeasured = true;
                    mMeasureTask = null;
                    requestLayout();
                }
            };
            mMeasureTask.execute();
        }
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        if(mMeasureTask != null) {
            mMeasureTask.cancel(true);
            mMeasureTask = null;
        }
        mMeasured = false;
        super.onSizeChanged(w, h, oldw, oldh);
    }
}

See https://github.com/wetblanket/AsyncView for a working example

like image 122
Timo Ohr Avatar answered Oct 09 '22 17:10

Timo Ohr