NkkinSoft: practical notes on building, publishing and monetizing Android apps with .NET for Android, Google Play policies, AdMob and app development tools.

Google Play publishing & policy

.NET for Android

Post Page Advertisement [Top]

Fixing a SwipeRefreshLayout ClassCastException in .NET for Android

Fixing a SwipeRefreshLayout ClassCastException in .NET for Android

For months one of our news apps showed a crash in Android vitals that we could never reproduce on our own phones. The stack trace always ended in the same place:

java.lang.ClassCastException: android.view.AbsSavedState$1 cannot be cast to
androidx.swiperefreshlayout.widget.SwipeRefreshLayout$SavedState
    at androidx.swiperefreshlayout.widget.SwipeRefreshLayout.onRestoreInstanceState

It only happened to a small share of users, and only on one screen: the author profile. Here is how we tracked it down, and the fix we now ship in every app built on our template.

When does Android restore view state?

Android saves the state of every view that has an ID when your activity goes to the background, and restores it when the activity is recreated. Recreation happens after a configuration change, but also when the system kills a backgrounded app to free memory and the user comes back later. That second case is why we could not reproduce the bug: our test phones had plenty of memory, so the activity was never destroyed.

You can force it during testing. Enable Developer options > Don't keep activities, open the screen, press Home, and return. Within a minute we had the crash on our desk.

The real cause: two views with the same ID

Saved state is stored in a SparseArray keyed by view ID. If two views in one hierarchy share an ID, the second one saved overwrites the first, and on restore each view receives whatever is stored under its ID.

Our profile layout reused a card layout with an include:

<include android:id="@+id/CardMain" layout="@layout/card_main" />

An android:id on an <include> replaces the ID of the included layout's root view. The root of card_main was a SwipeRefreshLayout, so it became CardMain. But inside card_main there was a ConstraintLayout that already used the ID CardMain. Two views, one ID. On restore, the SwipeRefreshLayout received the ConstraintLayout's saved state and failed the cast.

The fix

Renaming the IDs is the cleanest fix, but this layout is shared by many apps and screens, so we wanted something that also protects us from the next accidental duplicate. We did three things.

1. A SwipeRefreshLayout that ignores state that is not its own. In C#:

[Register("wordpress.helpers.SafeSwipeRefreshLayout")]
public class SafeSwipeRefreshLayout : SwipeRefreshLayout
{
    const string OwnState = "androidx.swiperefreshlayout.widget.SwipeRefreshLayout$SavedState";
    public SafeSwipeRefreshLayout(Context c) : base(c) { }
    public SafeSwipeRefreshLayout(Context c, IAttributeSet? a) : base(c, a) { }
    protected SafeSwipeRefreshLayout(IntPtr h, JniHandleOwnership t) : base(h, t) { }
    protected override void DispatchRestoreInstanceState(SparseArray? container)
    {
        if (container != null && Id != View.NoId
            && container.Get(Id) is Java.Lang.Object s
            && s.Class.Name != OwnState)
            container.Remove(Id);   // not ours: drop it instead of crashing
        base.DispatchRestoreInstanceState(container);
    }
}

The [Register] attribute gives the class a stable Java name, so the XML can use it directly: <wordpress.helpers.SafeSwipeRefreshLayout ...>.

2. android:saveEnabled="false" on the refresh layout. Pull-to-refresh has nothing worth restoring, so we simply stop saving it.

3. Skip view-state restore on screens that rebuild from their Intent. Our article and profile screens reload everything from the Intent anyway, so before base.OnCreate we drop the saved hierarchy:

savedInstanceState?.Remove("android:viewHierarchyState");

Lessons

  • Crashes in onRestoreInstanceState almost always mean duplicate IDs or a view whose type changed between versions.
  • An ID on an <include> renames the included root. Check the included layout for the same ID.
  • Test with "Don't keep activities" before every release. It catches a whole class of bugs that never show up on a well-equipped test phone.

The crash disappeared from Android vitals in the next release, along with a measurable drop in our user-perceived crash rate.

No comments:

Post a Comment

Bottom Ad [Post Page]