安卓平板化趟过的坑

两个月前我们的app开始平板化,要全面支持平板横屏。之前不支持的时候,app只支持竖屏,在平板上感觉就像在大手机上一样,对于习惯横屏使用平板的用户来说体验非常不好。在GP上我们的平板用户也占了很大的比例,于是将平板化提到了日程。

本文介绍了仅支持手机设备的app,从没有考虑平板设备到实现平板化的过程中,需要改造哪些内容,以及平板设备上典型的分栏布局的实现要点。

目录

  • 平板的特点
  • 如何判断是平板
  • 关于尺寸(dimen)的定义
  • 用代码判断是否平板
  • 使用Fragment
  • 实现列表的右侧详情

平板的特点

  • 屏幕比较宽,像素密度比较低,宽度和长度总dp值比较大。比如一款华为的平板,density=2,像素宽度为1200,宽度总dp是600,而手机设备宽度一般都是300多。
  • 平板比手机更需要横向屏幕的适配。在平板设备上旋转屏幕是频繁使用的场景,而且一些专用平板设备设计之初默认就是横向的界面。手机设备一般不需要横屏,只有一些像浏览器、视频播放等一些由于手机屏幕小而使用横屏的app才需要支持横屏。横屏不仅仅是简单地把屏幕旋转一下,还需要充分利用横屏空间大的特点,对界面做一些调整。而且有些平板出厂默认的方向就是固定横向。

如何判断是平板

安卓在3.2版本引入了最小宽度的限定符(Qualifier),可以用来指定资源适用于最小宽度的版本。我们使用7寸屏幕的最小宽度600dp来定义平板,即sw600dp。这个限定符可以用在各种资源上,从而定义一套平板专用的资源。

  • values-sw600dp/dimens.xml 平板尺寸定义
  • values-sw600dp/colors.xml 平板颜色定义
  • values-sw600dp/layouts.xml 平板布局别名定义

要利用安卓本身提供的资源机制,将平板专用的资源放在sw600dp修饰的目录中,让系统自己去选择加载哪个目录下的resource,最好不要用java代码判断是否平板来选择资源。

错误用法:


360dp
268dp
// in java code:
lp = mList.getLayoutParam();
if (isPad()) {
    lp.width = getResources().getDimensionPixelSize(R.dimen.left_fragment_width);
} else {
    lp.width = getResources().getDimensionPixelSize(R.dimen.left_fragment_width_pad);
}

正确用法:


360dp

268dp
// in java code:
lp = mList.getLayoutParam();
lp.width = getResources().getDimensionPixelSize(R.dimen.left_fragment_width);

关于尺寸(dimen)的定义

平板开发可能涉及到三种尺寸,对应三个目录:

  • 默认尺寸,values/dimens.xml
  • 平板尺寸,values-sw600dp/dimens.xml
  • 平板横屏尺寸,values-sw600dp-land/dimens.xml。

一个dimen可能有以下几种情况:

  1. 只有默认版本,这个值可以适应手机设备和平板设备,并与横竖屏无关,只需要定义在默认目录里。
  2. 可能有两个版本,手机版和平板版,需要同时定义在默认目录里和平板目录里。
  3. 可能有三个版本,就需要定义在以上三个目录中。

需要注意的是,有些dimen虽然只会在平板设备上使用,但仍需要定义默认版本,如果代码里不小心使得手机设备引用了这些资源,会由于找不到默认资源而crash。针对只在平板设备上使用的dimen,比较简单的办法是只定义在默认资源目录中,这样肯定不会导致crash,但如果在手机设备上引用了这些资源,界面可能会有显示上的问题。其他资源的使用也会出现这个问题,这个crash长这样:

Process: com.myapp.sample, PID: 30703
    android.content.res.Resources$NotFoundException: Resource ID #0x7f08003a
        at android.content.res.Resources.getValue(Resources.java:2305)
        at android.content.res.Resources.getDimensionPixelSize(Resources.java:1790)

有些情况View的宽度需要 match_parent 或者 wrap_content,而在平板上需要一个确定的dp值,而dimen中是无法定义 match_parent 或者 wrap_content的,因此就需要另一种机制:style。style中可以定义属性的值,可以设置layout_width 为 match_parent 或者 wrap_content。于是可以创建pad版本的style文件:values-sw600dp/styles.xml,将确定的dp值写在pad版本的style内。例如:

// values/styles.xml


// values-sw600dp/styles.xml


// layout.xml

    ...

用代码判断是否平板

虽然资源可以通过安卓本身的机制来自动使用平板版本,但仍然有部分代码是平板上才需要运行,而手机是不能运行的,例如这个方法:

public void onConfigurationChanged(Configuration newConfig)

这个方法需要在manifest中的activity的定义中添加 android:configChanges 才会调用。平板上横竖屏发生切换时可以用这个方法来捕获,需要在 android:configChanges 中添加 screenSize 和 orientation。同时还有其他 config 的配置,例如 locale。这个方法被调用的时候并不知道具体是哪个config 改变了,只能根据新的 config 全部更新一遍。如果横竖屏切换需要做一些事情,代码写在了这里,即使不旋转屏幕,在手机设备上这段代码也是有可能运行的。因此需要判断是否是平板。

判断的方法有很多种,但我们一直用的是sw600dp的方法,应该也利用这个方式来判断,这样就与资源的选择是一致的,不会导致资源使用了平板版本的,而代码的判断结果是手机设备。



    true



    false

Java代码里可以这样判断:

public static boolean isPad(Context context) {
    return context.getResources().getBoolean(R.bool.isPad);
}

横竖屏切换后,安卓并不会自动更新 resource,因此如果平板上横竖屏使用了不同的尺寸,就需要用代码重新设置一下。如下代码所示,在发生横竖屏切换时,更新左侧fragment的宽度:

@Override
public void onConfigurationChanged(Configuration newConfig) {
    super.onConfigurationChanged(newConfig);
    if (isPad(this)) {
        ViewGroup.LayoutParams layoutParams = mRecyclerView.getLayoutParams();
        layoutParams.width = getResources().getDimensionPixelSize(R.dimen.left_fragment_width);
        mRecyclerView.setLayoutParams(layoutParams);
    }
}

使用Fragment

适配平板就绕不开Fragment,我们的app幸好之前的界面很多都使用了Fragment,但也有一部分还没有使用。于是在进行平板化之前,对一些界面进行了重构,将在Activity中的大部分代码都抽出,放到Fragment中。这个步骤其实是计划外的,是在实现平板化过程中发现,然后暂停平板化,回头先替换成Fragment,然后再继续的。于是决定以后:

都使用Fragment写界面,而不是直接写在Activity中。

我们的app中有很多列表,这些列表就是可以改造的重点对象,在手机设备中一个页面只显示一个列表是很合理的。然而在平板中,还可以更好地利用大屏幕的空间,将列表项的详情显示出来。于是就有了左边列表,右侧详情的典型布局方式,我们的app也是这样改造的。

实现这种结构首先遇到的问题就是如何使代码同时在手机和平板上使用不同的layout,方法有两种:一种是定义layout-sw600dp目录,将为平板设计的layout文件放在里面;另一种是将两套layout都放在默认目录下,再定义layout别名来使平板引用另一个layout。理论上别名的方式灵活性更大,我们选用的是这种方式。

假设原 layout 为 fragment_list.xml,为平板新设计的为fragment_list_two_panes.xml,将其放入默认 layout 资源目录中。创建 values-sw600dp/layouts.xml,内容如下:


    @layout/fragment_list_two_panes

这样,在手机设备上使用原来的 fragment_list.xml,在平板上会去加载 fragment_list_two_panes.xml。使用别名的好处是只用定义一份layout就可以用别名的方式适配很多套资源,比如xlarge版本也使用与sw600dp同样的layout,用别名的方式引用就不用在layouts-xlarge目录下也创建一个layout文件了。

这两个版本的layout文件的内容应该是基本一样的,只是平板的版本多了一些右侧详情的元素。两个layout文件有很多相同的代码,还可以进一步抽象,将相同的部分抽象为另一个layout文件,然后用 include 的方式引入进来。

实现列表的右侧详情

判断分栏还是单栏

最直观的方式是判断是否是平板,但更好的方式是判断右侧详情栏是否存在,也就是说判断平板的layout右侧独有的ViewGroup是否存在。这样的好处是并不一定需要是平板上才可以分栏。

mRightFragmentContainer = v.findViewById(R.id.right_fragment);
mTwoPanesMode = mRightFragmentContainer != null;

修改点击行为

原本在手机上的点击行为是打开一个新的详情界面,而在平板上应该在右侧打开详情界面。通常 RecyclerView 中的点击事件都会在绑定条目的时候,设置给 itemView 一个 tag,原手机版的代码大概是这样的:

@Override
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
    View view = LayoutInflater.from(mContext).inflate(R.layout.item, parent, false);
    ViewHolder holder = new ViewHolder(view);
    holder.itemView.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            Object tag = v.getTag();
            if (tag instanceof DataBean) {
                DataBean bean = (DataBean) tag;
                DetailActivity.openDetail(bean);
            }
        }
    });
    return holder;
}
@Override
public void onBindViewHolder(ViewHolder holder, int position) {
    DataBean bean = mDataBeanList.get(position);
    holder.itemView.setTag(bean);
    ...
}

适配平板的代码后,主要改变是点击事件的变化,以及新的打开当前条目详情的方法:

@Override
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
    View view = LayoutInflater.from(mContext).inflate(R.layout.item, parent, false);
    ViewHolder holder = new ViewHolder(view);
    holder.itemView.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            Object tag = v.getTag();
            if (tag instanceof DataBean) {
                DataBean bean = (DataBean) tag;
                if (mTwoPanesMode) {
                    mSelectedId = bean.getId();
                    openSelectedDetail();
                } else {
                    DetailActivity.openDetail(bean);
                }
            }
        }
    });
    return holder;
}

初始化、更新Fragment

右侧 DetailFragment 应该只创建一次,每次改变当前选择的条目时,更新 Fragment 中的数据,而不是重新创建一次。因此只需要第一次初始化,并在后面的操作中不断更新数据。这样就要求 DetailFragment 具有更新的能力:

public static DetailFragment getInstance(DataBean bean) {
    DetailFragment fragment = new DetailFragment();
    fragment.setData(bean);
    return fragment;
}

@Nullable
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
    View v = inflater.inflate(R.layout.fragment_detail, container, false);
    initViews(v);
    update(getData());
    return v;
}

public void update(DataBean bean) {
    // 与内容相关的各种 callback,需要重新注册
    resetCallbacks();
    // 需要重新初始化所有数据相关的变量,并设置为新的值
    updateData(bean);
    // 根据数据更新View
    updateViews();
}

在列表的 Activity 中:

private openSelectedDetail() {
    if (!mTwoPanesMode) {
        return;
    }
    DataBean bean = getDataBeanById(mSelectedId);
    if (mDetailFragment == null) {
        mDetailFragment = DetailFragment.getInstance(bean);
        getSupportFragmentManager().beginTransaction()
                .add(R.id.right_fragment, mDetailFragment)
                .commit();
    } else {
        mDetailFragment.update(bean);
    }
}

openSelectedDetail方法中,如果 DetailFragment 没有初始化,则先初始化然后再用FragmentManager 显示在界面中。这里会有一个问题,当 Activity 不可见的时候,运行 Fragment Transaction 相关方法会出现异常

java.lang.IllegalStateException: Can not perform this action after onSaveInstanceState
    at android.support.v4.app.FragmentManagerImpl.checkStateLoss(FragmentManager.java:1341)
    at android.support.v4.app.FragmentManagerImpl.enqueueAction(FragmentManager.java:1352)
    at android.support.v4.app.BackStackRecord.commitInternal(BackStackRecord.java:595)
    at android.support.v4.app.BackStackRecord.commit(BackStackRecord.java:574)

这个异常产生的缘由和解决办法最后在这篇文章里找到了答案:Fragment Transactions & Activity State Loss。简单地说,一旦 Activity.onSaveInstanceState 被调用,再执行的 Fragment Transaction 产生的 state 变化就无法被 Save,因为 Save 的时机已经过了。比较简单的方法是忽略,将 FragmentTransaction.commit 替换成 FragmentTransaction.commitAllowingStateLoss,但这种方法可能会有隐患,而且当界面不可见的时候,并不需要它能更新界面。

因此使用了比较复杂的后台记录进入前台再更新的方式:

private boolean mIsStopped;
private boolean mHasPendingAction;

@Override
public void onStart() {
    super.onStart();
    mIsStopped = false;
    if (mHasPendingAction) {
        openSelectedDetail();
    }
}

@Override
public void onStop() {
    super.onStop();
    mIsStopped = true;
}

private openSelectedDetail() {
    if (!mTwoPanesMode) {
        return;
    }
    if (mIsStopped) {
        mHasPendingAction = true;
        return;
    }
    DataBean bean = getDataBeanById(mSelectedId);
    if (mDetailFragment == null) {
        mDetailFragment = DetailFragment.getInstance(bean);
        getSupportFragmentManager().beginTransaction()
                .add(R.id.right_fragment, mDetailFragment)
                .commit();
    } else {
        mDetailFragment.update(bean);
    }
    mHasPendingAction = false;
}

不同的颜色

手机上的列表是没有选中状态的,而平板上是有当前选中的概念的,为了让用户知道当前右侧详情对应在列表中是那一条,就有必要改变当前选中的条目的样式。同时右侧详情不再是手机设备上统一的背景色,而是与列表的背景色有轻微的色差以表示两个区域是递进关系而不是一体的。

条目的背景,在 adapter 中判断是否是当前选择的条目来区分:

@Override
public void onBindViewHolder(ViewHolder holder, int position) {
    DataBean bean = mDataBeanList.get(position);
    ...
    if (mTwoPanesMode && bean.getId() == mSelectedId) {
        holder.itemView.setBackgroundColor(SELECTED_BACKGROUND_COLOR);
    } else {
        holder.itemView.setBackgroundColor(NORMAL_BACKGROUND_COLOR);
    }
}

详情界面的背景色不但要考虑平板,还要考虑手机上的情况。之前所有的界面的背景色都是统一定义在 colors.xml 中的


#FAFAFA

但在平板上大多数界面也还是这个背景色,只是详情界面显示在右侧分栏中时需要改变。于是给右侧分栏定义一个颜色:


@color/background

#EEEEEE

应用到详情界面的 layout 中:


    ...

结束语

在学习平板化的过程中我们也走了很多弯路,也是在实现的过程中逐渐学习到平板的特点以及与手机的不同之处。多利用安卓本身的资源选择机制,能节省很多不必要的java代码,而且代码更简洁,更不容易出错。写越少的java代码去定制化平板,出现bug的几率就越小,未来的维护就会越容易。走过的路趟过的坑,都是成长。

你可能感兴趣的:(安卓平板化趟过的坑)