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

Posted

技术标签:

【中文标题】如何防止自定义视图在屏幕方向更改时丢失状态【英文标题】:How to prevent custom views from losing state across screen orientation changes 【发布时间】:2011-04-02 07:45:06 【问题描述】:

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

但看起来,当方向改变时,我的自定义视图正在从头开始重新创建。这是有道理的,尽管在我的情况下这很不方便,因为有问题的自定义视图是 X/Y 图,并且绘制的点存储在自定义视图中。

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

【问题讨论】:

【参考方案1】:

我认为这是一个更简单的版本。 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);
  

【讨论】:

如果onSaveInstanceState返回一个Bundle,为什么不会用一个Bundle调用onRestoreInstanceState OnRestoreInstance 被继承。我们无法更改标题。 Parcelable 只是一个接口,Bundle 是一个实现。 感谢这种方式更好,并且在将 SavedState 框架用于自定义视图时避免了 BadParcelableException,因为保存的状态似乎无法为您的自定义 SavedState 正确设置类加载器! 我在一个活动中有多个相同视图的实例。它们在 xml 中都有唯一的 id。但仍然所有这些都获得了最后一个视图的设置。有什么想法吗? 这个解决方案可能没问题,但绝对不安全。通过实现这一点,您假设基本View 状态不是Bundle。当然,目前确实如此,但您依赖的是当前的实现事实,但不能保证是真的。【参考方案2】:

您可以通过实现 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 和 View 的 SavedState 类之间进行拆分。您应该在SavedState 类中完成与Parcel 之间的所有读写工作。然后,您的 View 类可以完成提取状态成员的工作,并完成使类恢复到有效状态所需的工作。

注意:View#onSavedInstanceStateView#onRestoreInstanceState 如果View#getId 返回值 >= 0,则会自动为您调用。当您在 xml 中给它一个 id 或手动调用 setId 时,会发生这种情况。否则,您必须调用 View#onSaveInstanceState 并将 Parcelable 返回到您在 Activity#onSaveInstanceState 中获得的包裹中以保存状态,然后读取它并将其从 Activity#onRestoreInstanceState 传递给 View#onRestoreInstanceState

另一个简单的例子是CompoundButton

【讨论】:

对于那些到达这里的人,因为在使用带有 v4 支持库的 Fragments 时这不起作用,我注意到支持库似乎没有为您调用视图的 onSaveInstanceState/onRestoreInstanceState;您必须自己从 FragmentActivity 或 Fragment 中方便的位置显式调用它。 请注意,您应用此的 CustomView 应该有一个唯一的 id 集,否则它们将相互共享状态。 SavedState 是针对 CustomView 的 id 存储的,因此如果您有多个具有相同 id 或没有 id 的 CustomView,则保存在最终 CustomView.onSaveInstanceState() 中的包裹将传递给所有对 CustomView.onRestoreInstanceState() 的调用,当视图已恢复。 此方法不适用于我的两个自定义视图(一个扩展另一个)。恢复视图时,我不断收到 ClassNotFoundException。我不得不在 Kobor42 的回答中使用 Bundle 方法。 onSaveInstanceState()onRestoreInstanceState() 应该是 protected(就像它们的超类),而不是 public。没有理由暴露他们...... 当为扩展 RecyclerView 的类保存自定义 BaseSaveState 时,这效果不佳,您会得到 Parcel﹕ Class not found when unmarshalling: android.support.v7.widget.RecyclerView$SavedState java.lang.ClassNotFoundException: android.support.v7.widget.RecyclerView$SavedState,因此您需要进行编写的错误修复下面:github.com/ksoichiro/Android-ObservableScrollView/commit/…(使用 RecyclerView.class 的 ClassLoader 加载超级状态)【参考方案3】:

使用 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
    

【讨论】:

在这种情况下扩展View.BaseSavedState 有什么好处?有什么区别吗?【参考方案4】:

这是另一个混​​合使用上述两种方法的变体。 将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;
    

【讨论】:

这不起作用。我得到一个 ClassCastException... 那是因为它需要一个公共静态 CREATOR 以便它从包裹中实例化您的State。请看:charlesharley.com/2012/programming/…【参考方案5】:

这里的答案已经很好了,但不一定适用于自定义 ViewGroup。要让所有自定义视图保持其状态,您必须在每个类中覆盖 onSaveInstanceState()onRestoreInstanceState(Parcelable state)。 您还需要确保它们都有唯一的 id,无论它们是从 xml 膨胀还是以编程方式添加。

我想出的结果与 Kobor42 的答案非常相似,但错误仍然存​​在,因为我是以编程方式将视图添加到自定义 ViewGroup 而不是分配唯一 ID。

mato 共享的链接可以使用,但这意味着没有一个单独的 View 管理自己的状态 - 整个状态都保存在 ViewGroup 方法中。

问题是当多个 ViewGroups 添加到布局中时,它们在 xml 中的元素的 id 不再是唯一的(如果它在 xml 中定义)。在运行时,您可以调用静态方法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 确实是个问题,但我认为应该在视图初始化时处理,而不是在状态保存时处理。 好点。您是否建议在构造函数中设置 mViewIds,然后在状态恢复时覆盖? 这是对充气自定义布局有效的最完整答案。不幸的是,答案只为直接孩子生成 id,但通常情况并非如此。例如。子项可以是 TextInputLayout 内的 TextInputEditText。在这种情况下,解决方案会复杂得多。【参考方案6】:

我遇到的问题是 onRestoreInstanceState 使用最后一个视图的状态恢复了我的所有自定义视图。我通过将这两种方法添加到我的自定义视图来解决它:

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


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

【讨论】:

dispatchFreezeSelfOnly 和 dispatchThawSelfOnly 方法属于 ViewGroup,而不是 View。因此,以防万一,您的自定义视图是从内置视图扩展而来的。您的解决方案不适用。【参考方案7】:

为了补充其他答案 - 如果您有多个具有相同 ID 的自定义复合视图,并且它们都在配置更改时恢复为最后一个视图的状态,您需要做的就是告诉视图仅调度保存/通过覆盖几个方法将事件恢复到自身。

class MyCompoundView : ViewGroup 

    ...

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

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

要了解正在发生的事情以及为什么会这样,see this blog post。基本上,您的复合视图的子视图 ID 由每个复合视图共享,并且状态恢复会变得混乱。通过只为复合视图本身分派状态,我们可以防止他们的孩子从其他复合视图中获得混合消息。

【讨论】:

【参考方案8】:

我发现this answer 在 Android 版本 9 和 10 上造成了一些崩溃。我认为这是一个很好的方法,但是当我查看一些Android code 时,我发现它缺少一个构造函数。答案已经很老了,所以当时可能不需要它。当我添加缺少的构造函数并从创建者调用它时,崩溃已修复。

所以这里是编辑后的代码:

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 你说得对,我使用了另一个答案的代码并且没有正确编辑它。您应该只扩展 LinearLayoutViewGroup 而不是 View 就可以了。我确实在我的应用程序中使用了这个解决方案的稍微改动的版本,它工作正常。我会尝试编辑我的答案。【参考方案9】:

您也可以使用ViewModel,而不是使用onSaveInstanceStateonRestoreInstanceState。让您的数据模型扩展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 的更多信息:

Android Developer Guide, Handle configuration changes Android Developer Guide, Saving UI States, Use ViewModel to handle configuration changes Tutorial ViewModels : A Simple Example

【讨论】:

请看Android ViewModel Architecture Component considered harmful @JJD 我同意您发布的文章,仍然需要正确处理保存和恢复。 ViewModel 如果在状态更改(例如屏幕旋转)期间要保留大量数据集,则特别方便。我更喜欢使用ViewModel 而不是将其写入Application,因为它的范围很明确,而且我可以让同一应用程序的多个活动正常运行。【参考方案10】:

根据@Fletcher Johns 我想出的答案:

自定义布局 可以从 XML 膨胀 能够保存/恢复直接和间接子级。我改进了@Fletcher Johns 的答案,将 ID 保存在 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"
    

【讨论】:

以上是关于如何防止自定义视图在屏幕方向更改时丢失状态的主要内容,如果未能解决你的问题,请参考以下文章

在 Laravel 中安全地编辑第三方作曲家(供应商)包并防止在发布新版本的包时丢失自定义更改

如何更改iOS自定义键盘的高度?

iOS 8 自定义键盘方向更改

防止自定义 UINavigationBar 元素在视图之间更改

如何防止 AppBar 覆盖自定义状态栏颜色

Xcode 6 自定义启动屏幕