如何防止自定义视图在屏幕方向更改时丢失状态

269

我已经成功地在我的主Activity中实现了onRetainNonConfigurationInstance(),以便在屏幕方向更改时保存和恢复某些关键组件。

但是,当屏幕方向改变时,似乎我的自定义视图会被从头创建。这是有道理的,尽管在我的情况下很不方便,因为受影响的自定义视图是一个 X/Y 图形,而绘制的点是存储在该自定义视图中的。

是否有巧妙的方法可以为自定义视图实现类似于 onRetainNonConfigurationInstance() 的功能,或者我需要在自定义视图中实现允许我获取和设置其“状态”的方法?

10个回答

482

我认为这是一个更简单的版本。 Bundle 是一个内置类型,实现了 Parcelable

public class CustomView extends View
{
  private int stuff; // stuff

  @Override
  public Parcelable onSaveInstanceState()
  {
    Bundle bundle = new Bundle();
    bundle.putParcelable("superState", super.onSaveInstanceState());
    bundle.putInt("stuff", this.stuff); // ... save stuff 
    return bundle;
  }

  @Override
  public void onRestoreInstanceState(Parcelable state)
  {
    if (state instanceof Bundle) // implicit null check
    {
      Bundle bundle = (Bundle) state;
      this.stuff = bundle.getInt("stuff"); // ... load stuff
      state = bundle.getParcelable("superState");
    }
    super.onRestoreInstanceState(state);
  }
}

5
如果onSaveInstanceState返回了一个Bundle,为什么onRestoreInstanceState不会被调用呢? - Qwertie
5
OnRestoreInstance 是继承而来的。我们不能改变头文件。Parcelable 只是一个接口,Bundle 是其实现。 - Kobor42
5
谢谢,这种方法更好,可以避免在为自定义视图使用SavedState框架时出现BadParcelableException,因为似乎无法正确设置自定义SavedState的类加载器! - Ian Warwick
16
这个解决方案可能可行,但绝对不安全。通过实施此方案,您假设基本“视图”状态不是一个“Bundle”。当然,目前是这样的,但您正在依赖于这个当前实现事实,而这并不能保证始终如此。 - Dmitry Zaytsev
3
@Christoffer帮我解决了一个问题:正确保存Android视图状态。最后他使用了dispatchSaveInstanceStatedispatchRestoreInstanceState - EmmanuelMess
显示剩余13条评论

424

您可以通过实现View#onSaveInstanceStateView#onRestoreInstanceState方法,并扩展View.BaseSavedState类来完成此操作。

public class CustomView extends View {

  private int stateToSave;

  ...

  @Override
  public Parcelable onSaveInstanceState() {
    //begin boilerplate code that allows parent classes to save state
    Parcelable superState = super.onSaveInstanceState();

    SavedState ss = new SavedState(superState);
    //end

    ss.stateToSave = this.stateToSave;

    return ss;
  }

  @Override
  public void onRestoreInstanceState(Parcelable state) {
    //begin boilerplate code so parent classes can restore state
    if(!(state instanceof SavedState)) {
      super.onRestoreInstanceState(state);
      return;
    }

    SavedState ss = (SavedState)state;
    super.onRestoreInstanceState(ss.getSuperState());
    //end

    this.stateToSave = ss.stateToSave;
  }

  static class SavedState extends BaseSavedState {
    int stateToSave;

    SavedState(Parcelable superState) {
      super(superState);
    }

    private SavedState(Parcel in) {
      super(in);
      this.stateToSave = in.readInt();
    }

    @Override
    public void writeToParcel(Parcel out, int flags) {
      super.writeToParcel(out, flags);
      out.writeInt(this.stateToSave);
    }

    //required field that makes Parcelables from a Parcel
    public static final Parcelable.Creator<SavedState> CREATOR =
        new Parcelable.Creator<SavedState>() {
          public SavedState createFromParcel(Parcel in) {
            return new SavedState(in);
          }
          public SavedState[] newArray(int size) {
            return new SavedState[size];
          }
    };
  }
}

这项工作分为视图(View)和视图的SavedState类。您应该在SavedState类中完成所有读取和写入Parcel的工作。然后,您的View类可以提取状态成员并执行必要的工作,使该类恢复到有效状态。
注意:如果View#getId返回值> = 0,则会自动为您调用View#onSavedInstanceState和View#onRestoreInstanceState。当您在xml中给它一个id或手动调用setId时,就会发生这种情况。否则,您必须调用View#onSaveInstanceState,并将返回的可重用对象写入Activity#onSaveInstanceState中的Parcel,以保存状态,并随后将其读取并传递给Activity#onRestoreInstanceState中的View#onRestoreInstanceState。
另一个简单的例子是 CompoundButton

15
对于那些因为在使用v4支持库的Fragments时出现问题而来到这里的人,我注意到支持库似乎不会自动调用View的onSaveInstanceState/onRestoreInstanceState方法;你需要在FragmentActivity或Fragment中的一个方便的位置显式地调用它。 - magneticMonster
74
请注意,您应该为应用程序中的每个CustomView设置唯一的id,否则它们将共享状态。SavedState是针对CustomView的id存储的,因此如果您有多个具有相同id或没有id的CustomView,则在最终CustomView.onSaveInstanceState()中保存的包裹将传递给所有CustomView.onRestoreInstanceState()的调用,当这些视图被恢复时。 - Nick Street
5
这种方法对我来说不起作用,因为我有两个自定义视图(其中一个扩展另一个)。当我恢复我的视图时,我一直收到ClassNotFoundException的错误。我不得不使用Kobor42答案中的Bundle方法。 - Chris Feist
3
onSaveInstanceState()onRestoreInstanceState() 应该像它们的父类一样是 protected,而不是 public。没有必要将它们公开暴露出来。 - XåpplI'-I0llwlg'I -
7
当为扩展了RecyclerView的类保存自定义的BaseSaveState时,这种方法效果不佳,你会遇到Parcel﹕ Class not found when unmarshalling: android.support.v7.widget.RecyclerView$SavedState java.lang.ClassNotFoundException: android.support.v7.widget.RecyclerView$SavedState 的错误。因此,你需要进行修复,具体操作可以参考这里:https://github.com/ksoichiro/Android-ObservableScrollView/commit/6f915f7482635c1f4889281c7941a586f2cfb611(使用RecyclerView.class的ClassLoader加载超级状态)。 - EpicPandaForce
显示剩余10条评论

25

用 Kotlin 很简单

@Parcelize
class MyState(val superSavedState: Parcelable?, val loading: Boolean) : View.BaseSavedState(superSavedState), Parcelable


class MyView : View {

    var loading: Boolean = false

    override fun onSaveInstanceState(): Parcelable? {
        val superState = super.onSaveInstanceState()
        return MyState(superState, loading)
    }

    override fun onRestoreInstanceState(state: Parcelable?) {
        val myState = state as? MyState
        super.onRestoreInstanceState(myState?.superSaveState ?: state)

        loading = myState?.loading ?: false
        //redraw
    }
}

1
在这种情况下,通过扩展View.BaseSavedState你能获得什么?这会有任何区别吗? - arekolek
我也对此感兴趣。我建议您尝试带和不带的方式。 - Andre Thiele

18

这里有另一种变体,它使用了上述两种方法的组合。将Parcelable的速度和正确性与Bundle的简单性相结合:

@Override
public Parcelable onSaveInstanceState() {
    Bundle bundle = new Bundle();
    // The vars you want to save - in this instance a string and a boolean
    String someString = "something";
    boolean someBoolean = true;
    State state = new State(super.onSaveInstanceState(), someString, someBoolean);
    bundle.putParcelable(State.STATE, state);
    return bundle;
}

@Override
public void onRestoreInstanceState(Parcelable state) {
    if (state instanceof Bundle) {
        Bundle bundle = (Bundle) state;
        State customViewState = (State) bundle.getParcelable(State.STATE);
        // The vars you saved - do whatever you want with them
        String someString = customViewState.getText();
        boolean someBoolean = customViewState.isSomethingShowing());
        super.onRestoreInstanceState(customViewState.getSuperState());
        return;
    }
    // Stops a bug with the wrong state being passed to the super
    super.onRestoreInstanceState(BaseSavedState.EMPTY_STATE); 
}

protected static class State extends BaseSavedState {
    protected static final String STATE = "YourCustomView.STATE";

    private final String someText;
    private final boolean somethingShowing;

    public State(Parcelable superState, String someText, boolean somethingShowing) {
        super(superState);
        this.someText = someText;
        this.somethingShowing = somethingShowing;
    }

    public String getText(){
        return this.someText;
    }

    public boolean isSomethingShowing(){
        return this.somethingShowing;
    }
}

3
这个不起作用。我得到了一个ClassCastException(类转换异常)......那是因为它需要一个public static CREATOR,以便从包裹中实例化你的“State”。请参阅:http://charlesharley.com/2012/programming/views-saving-instance-state-in-android/ - mato

11
这里已经有很好的答案,但不一定适用于自定义ViewGroups。为了让所有自定义视图保留它们的状态,必须在每个类中重写onSaveInstanceState()onRestoreInstanceState(Parcelable state)方法。您还需要确保它们都具有唯一的ID,无论是从xml中膨胀还是以编程方式添加。
我想出来的方法与Kobor42的答案非常相似,但错误仍然存在,因为我是将Views以编程方式添加到自定义ViewGroup中,而没有分配唯一的ID。
mato分享的链接可以起作用,但这意味着没有任何单个视图管理它们自己的状态-整个状态保存在ViewGroup方法中。
问题在于,当将多个这些ViewGroups添加到布局中时,从xml定义的元素的id不再是唯一的。在运行时,可以调用静态方法View.generateViewId()为视图获取唯一ID。此选项仅适用于API 17及更高版本。
以下是我的ViewGroup代码(它是抽象的,并且mOriginalValue是类型变量):
public abstract class DetailRow<E> extends LinearLayout {

    private static final String SUPER_INSTANCE_STATE = "saved_instance_state_parcelable";
    private static final String STATE_VIEW_IDS = "state_view_ids";
    private static final String STATE_ORIGINAL_VALUE = "state_original_value";

    private E mOriginalValue;
    private int[] mViewIds;

// ...

    @Override
    protected Parcelable onSaveInstanceState() {

        // Create a bundle to put super parcelable in
        Bundle bundle = new Bundle();
        bundle.putParcelable(SUPER_INSTANCE_STATE, super.onSaveInstanceState());
        // Use abstract method to put mOriginalValue in the bundle;
        putValueInTheBundle(mOriginalValue, bundle, STATE_ORIGINAL_VALUE);
        // Store mViewIds in the bundle - initialize if necessary.
        if (mViewIds == null) {
            // We need as many ids as child views
            mViewIds = new int[getChildCount()];
            for (int i = 0; i < mViewIds.length; i++) {
                // generate a unique id for each view
                mViewIds[i] = View.generateViewId();
                // assign the id to the view at the same index
                getChildAt(i).setId(mViewIds[i]);
            }
        }
        bundle.putIntArray(STATE_VIEW_IDS, mViewIds);
        // return the bundle
        return bundle;
    }

    @Override
    protected void onRestoreInstanceState(Parcelable state) {

        // We know state is a Bundle:
        Bundle bundle = (Bundle) state;
        // Get mViewIds out of the bundle
        mViewIds = bundle.getIntArray(STATE_VIEW_IDS);
        // For each id, assign to the view of same index
        if (mViewIds != null) {
            for (int i = 0; i < mViewIds.length; i++) {
                getChildAt(i).setId(mViewIds[i]);
            }
        }
        // Get mOriginalValue out of the bundle
        mOriginalValue = getValueBackOutOfTheBundle(bundle, STATE_ORIGINAL_VALUE);
        // get super parcelable back out of the bundle and pass it to
        // super.onRestoreInstanceState(Parcelable)
        state = bundle.getParcelable(SUPER_INSTANCE_STATE);
        super.onRestoreInstanceState(state);
    } 
}

自定义 ID 确实是一个问题,但我认为应该在视图初始化时处理它,而不是在状态保存时处理。 - Kobor42
好主意。您是否建议在构造函数中设置mViewIds,然后在恢复状态时进行覆盖? - Fletcher Johns
这是针对可充气自定义布局的最完整答案。不幸的是,该答案仅为直接子项生成ID,但通常情况并非如此。例如,子项可能是TextInputLayout内部的TextInputEditText。在这种情况下,解决方案将更加复杂。 - WindRider

4

我曾经遇到一个问题,就是onRestoreInstanceState方法会将所有自定义视图都还原为最后一个视图的状态。我通过在自定义视图中添加下面这两个方法来解决这个问题:

@Override
protected void dispatchSaveInstanceState(SparseArray<Parcelable> container) {
    dispatchFreezeSelfOnly(container);
}

@Override
protected void dispatchRestoreInstanceState(SparseArray<Parcelable> container) {
    dispatchThawSelfOnly(container);
}

dispatchFreezeSelfOnly和dispatchThawSelfOnly方法属于ViewGroup,而不是View。因此,如果您的自定义View是从内置View扩展的,则您的解决方案不适用。 - Harvey

2

补充其他答案 - 如果您有多个具有相同ID的自定义复合视图,并且它们都使用配置更改后最后一个视图的状态进行恢复,那么您只需要告诉视图仅通过覆盖几个方法将保存/恢复事件分派给自身即可。

class MyCompoundView : ViewGroup {

    ...

    override fun dispatchSaveInstanceState(container: SparseArray<Parcelable>) {
        dispatchFreezeSelfOnly(container)
    }

    override fun dispatchRestoreInstanceState(container: SparseArray<Parcelable>) {
        dispatchThawSelfOnly(container)
    }
}

为了解释这是怎么回事以及它为什么有效,请参阅这篇博客文章。基本上,您的复合视图的子视图ID由每个复合视图共享,并且状态恢复会混淆。通过仅为复合视图本身分派状态,我们防止它们的子视图从其他复合视图中获得混淆信息。


2
我发现这个答案在Android 9和10版本上会导致一些崩溃。我认为这是一个不错的方法,但当我查看一些Android代码时,我发现它缺少了一个构造函数。答案相当古老,所以当时可能没有必要。当我添加了缺失的构造函数并从创建者中调用它时,崩溃问题得到解决。

因此,以下是编辑后的代码:

public class CustomView extends LinearLayout {

    private int stateToSave;

    ...

    @Override
    public Parcelable onSaveInstanceState() {
        Parcelable superState = super.onSaveInstanceState();
        SavedState ss = new SavedState(superState);

        // your custom state
        ss.stateToSave = this.stateToSave;

        return ss;
    }

    @Override
    protected void dispatchSaveInstanceState(SparseArray<Parcelable> container)
    {
        dispatchFreezeSelfOnly(container);
    }

    @Override
    public void onRestoreInstanceState(Parcelable state) {
        SavedState ss = (SavedState) state;
        super.onRestoreInstanceState(ss.getSuperState());

        // your custom state
        this.stateToSave = ss.stateToSave;
    }

    @Override
    protected void dispatchRestoreInstanceState(SparseArray<Parcelable> container)
    {
        dispatchThawSelfOnly(container);
    }

    static class SavedState extends BaseSavedState {
        int stateToSave;

        SavedState(Parcelable superState) {
            super(superState);
        }

        private SavedState(Parcel in) {
            super(in);
            this.stateToSave = in.readInt();
        }

        // This was the missing constructor
        @RequiresApi(Build.VERSION_CODES.N)
        SavedState(Parcel in, ClassLoader loader)
        {
            super(in, loader);
            this.stateToSave = in.readInt();
        }

        @Override
        public void writeToParcel(Parcel out, int flags) {
            super.writeToParcel(out, flags);
            out.writeInt(this.stateToSave);
        }    
        
        public static final Creator<SavedState> CREATOR =
            new ClassLoaderCreator<SavedState>() {
          
            // This was also missing
            @Override
            public SavedState createFromParcel(Parcel in, ClassLoader loader)
            {
                return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N ? new SavedState(in, loader) : new SavedState(in);
            }

            @Override
            public SavedState createFromParcel(Parcel in) {
                return new SavedState(in, null);
            }

            @Override
            public SavedState[] newArray(int size) {
                return new SavedState[size];
            }
        };
    }
}

你自己试过这段代码吗?dispatchFreezeSelfOnlydispatchFreezeSelfOnly 方法属于 ViewGroup,而不是 View - zeleven
@zeleven 你说得对,我使用了另一个答案的代码,但没有正确地进行编辑。你应该只是扩展LinearLayoutViewGroup而不是View,这样就可以了。我在我的应用程序中使用了稍微修改过的版本,并且它运行良好。我会尝试编辑我的答案。 - Wirling

1

根据@Fletcher Johns的答案,我得出了以下结论:

  • 自定义布局
  • 可以从XML中膨胀
  • 能够保存/还原直接和间接子视图。我改进了@Fletcher Johns的答案,将ids保存在String->Id映射中,而不是IntArray。
  • 唯一的小缺点是您必须预先声明可保存的子视图。

open class AddressView @JvmOverloads constructor(
        context: Context,
        attrs: AttributeSet? = null,
        defStyleAttr: Int = 0,
        defStyleRes: Int = 0
) : LinearLayout(context, attrs, defStyleAttr, defStyleRes) {

    protected lateinit var countryInputLayout: TextInputLayout
    protected lateinit var countryAutoCompleteTextView: CountryAutoCompleteTextView
    protected lateinit var cityInputLayout: TextInputLayout
    protected lateinit var cityEditText: CityEditText
    protected lateinit var postCodeInputLayout: TextInputLayout
    protected lateinit var postCodeEditText: PostCodeEditText
    protected lateinit var streetInputLayout: TextInputLayout
    protected lateinit var streetEditText: StreetEditText
    
    init {
        initView()
    }

    private fun initView() {
        val view = inflate(context, R.layout.view_address, this)

        orientation = VERTICAL

        countryInputLayout = view.findViewById(R.id.countryInputLayout)
        countryAutoCompleteTextView = view.findViewById(R.id.countryAutoCompleteTextView)

        streetInputLayout = view.findViewById(R.id.streetInputLayout)
        streetEditText = view.findViewById(R.id.streetEditText)

        cityInputLayout = view.findViewById(R.id.cityInputLayout)
        cityEditText = view.findViewById(R.id.cityEditText)

        postCodeInputLayout = view.findViewById(R.id.postCodeInputLayout)
        postCodeEditText = view.findViewById(R.id.postCodeEditText)
    }

    // Declare your direct and indirect child views that need to be saved
    private val childrenToSave get() = mapOf<String, View>(
            "coutryIL" to countryInputLayout,
            "countryACTV" to countryAutoCompleteTextView,
            "streetIL" to streetInputLayout,
            "streetET" to streetEditText,
            "cityIL" to cityInputLayout,
            "cityET" to cityEditText,
            "postCodeIL" to postCodeInputLayout,
            "postCodeET" to postCodeEditText,
    )
    private var viewIds: HashMap<String, Int>? = null

    override fun onSaveInstanceState(): Parcelable? {
        // Create a bundle to put super parcelable in
        val bundle = Bundle()
        bundle.putParcelable(SUPER_INSTANCE_STATE, super.onSaveInstanceState())
        // Store viewIds in the bundle - initialize if necessary.
        if (viewIds == null) {
            childrenToSave.values.forEach { view -> view.id = generateViewId() }
            viewIds = HashMap<String, Int>(childrenToSave.mapValues { (key, view) -> view.id })
        }

        bundle.putSerializable(STATE_VIEW_IDS, viewIds)

        return bundle
    }

    override fun onRestoreInstanceState(state: Parcelable?) {
        // We know state is a Bundle:
        val bundle = state as Bundle
        // Get mViewIds out of the bundle
        viewIds = bundle.getSerializable(STATE_VIEW_IDS) as HashMap<String, Int>
        // For each id, assign to the view of same index
        if (viewIds != null) {
            viewIds!!.forEach { (key, id) -> childrenToSave[key]!!.id = id }
        }
        super.onRestoreInstanceState(bundle.getParcelable(SUPER_INSTANCE_STATE))
    }

    companion object {
        private const val SUPER_INSTANCE_STATE = "saved_instance_state_parcelable"
        private const val STATE_VIEW_IDS = "state_view_ids"
    }
}

1

不必使用onSaveInstanceStateonRestoreInstanceState,您也可以使用ViewModel。将数据模型扩展为ViewModel,然后您可以使用ViewModelProviders在每次重建Activity时获取相同的模型实例:

class MyData extends ViewModel {
    // have all your properties with getters and setters here
}

public class MyActivity extends FragmentActivity {
    @Override
    public void onCreate(Bundle savedInstanceState) {

        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // the first time, ViewModelProvider will create a new MyData
        // object. When the Activity is recreated (e.g. because the screen
        // is rotated), ViewModelProvider will give you the initial MyData
        // object back, without creating a new one, so all your property
        // values are retained from the previous view.
        myData = ViewModelProviders.of(this).get(MyData.class);

        ...
    }
}

要使用ViewModelProviders,请在app/build.gradle中的dependencies中添加以下内容:
implementation "android.arch.lifecycle:extensions:1.1.1"
implementation "android.arch.lifecycle:viewmodel:1.1.1"

请注意,您的MyActivity应继承FragmentActivity而不是仅继承Activity
您可以在此处阅读有关ViewModel的更多信息:

2
@JJD 我同意你发布的文章,一个人仍然必须正确处理保存和恢复。如果您有大量数据集需要在状态更改期间保留,例如屏幕旋转,则ViewModel特别方便。我喜欢使用ViewModel而不是将其编写到Application中,因为它具有明确定义的范围,并且我可以使同一应用程序的多个活动正常运行。 - Benedikt Köppel

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接