0%

Android编程权威指南学习笔记

最近准备学习安卓开发,选择学习书籍的时候,在《第一行代码》第2版和《Android编程权威指南》第3版之间选择了后者,这篇文章将记录下学习过程中的重点和我学习中遇到的问题和完成的示例。2020年7月5日整理为这一篇文章

第1章 Android开发初体验

定制Toast

使用Toast类的setGravity方法

1
2
3
Toast.makeText(MainActivity.this,R.string.incorrect_toast,Toast.LENGTH_SHORT);
toast.setGravity(Gravity.TOP,0,50);
toast.show();

第2章 MVC设计模式

配置Android Studio识别成员变量的m前缀

File → Settings → Editor→ Code Style→ Code Generation

  1. 在Naming表单的Field一行中,添加m作为前缀 ;

  2. 添加s作为Static field的前缀

作用

需要Android Studio为mText生成get方法时,它生成的是getText()而不是getMText()

模型、视图、控制器

1

模型对象

模型对象存储着应用的数据和业务逻辑

  1. 模型对象不关心用户界面,它为存储和管理应用数据而生
  2. 模型类通常就是我们创建的定制类
  3. 应用的全部模型对象组成了模型层

视图对象

视图对象知道如何在屏幕上绘制自己,以及如何响应用户的输入

  1. 凡是能够在屏幕上看见的对象,就是视图对象
  2. 自带很多视图类
  3. 可自己定制开发其他视图类
  4. 应用的全部视图对象组成了视图层

控制器对象

控制器对象含有应用的逻辑单元,是视图对象与模型对象的联系纽带

  • 控制器通常是ActivityFragmentService的子类

MVC设计模式的优点

  1. 可以按类而不是按变量和方法思考设计开发问题
  2. 可以按层而非一个个类来考虑设计开发
  3. 便于复用类

ImageView

android:contentDescription属性

  1. 该属性能为视力障碍用户提供方便
  2. 设置文字属性值后,如果设备的可访问性选项作了相应设置,那么在用户点击图形按钮时,设备便会读出属性值的内容

第3章 Activity的生命周期

每个Activity实例都有其生命周期。在其生命周期内, activity在运行、暂停、停止和不存在这四种状态间转换

1-2

覆盖onCreate(Bundle)方法可以完成的工作

切记,千万不要自己去调用onCreate(Bundle)方法或任何其他activity生命周期方法

  1. 实例化组件并将它们放置在屏幕上(调用setContentView(int)方法)
  2. 引用已实例化的组件
  3. 为组件设置监听器以处理用户交互
  4. 访问外部模型数据

使用@Override注解

就是要求编译器保证当前类拥有你要覆盖的方法,而不会出现因拼写错误等而出现奇怪的问题

部分手机Log日志不输出

国内的部分厂商定制的手机对于应用中的 Log 日志默认做了打印限制,需要手动设置才能打印出特殊级别的日志

魅族

打开【设置】中的【开发者选项】,页面底部找到【性能优化】,打开【高级日志输出】,勾选【全部允许】

华为

打开拨号界面的拨号盘,输入##2846579##,系统会自动打开【工程菜单】界面,依次打开【后台设置】 -> 【LOG设置】,勾选【AP日志】即可

设备旋转屏幕时

设备旋转时,系统会销毁当前Activity实例,创建一个新的Activity实例

  1. 改变设备配置( device configuration)
  2. 新建landscape资源

Bundle对象

Bundle是存储字符串键与限定类型值之间映射关系(键值对)的一种结构

  1. 只能是基本类型
  2. 可以实现Serializable或Parcelable接口的对象

第4章 Android应用的调试

异常与栈跟踪

  1. 直接查看崩溃日志

  2. 记录栈跟踪日志

1
Log.d(TAG, MSG,new Exception());
  1. 设置断点

  2. 使用异常断点

    ① 打开Run - View Breakpoints 或者使用快捷键c+s+f8

    ② 单击新增断点按钮(+)设置一个新断点

    ③ 选择下拉列表中的Java Exception Breakpoints选项

    ④ 输 入 RuntimeException并选择RuntimeException是NullPointerException、 ClassCastException及其他常见异常的超类,因此该设置基本适用于所有异常

    ⑤ 点击Done按钮完成设置

    ⑥ 调试应用

Android特有的调试工具

Android Lint

选择Analyze → Inspect Code…

R类

经常清理:Build → Clean Project

第5章 第二个activity

xml文件

tools:text属性的命名空间会在预览时显示文字内容,而在运行时不会显示

c+s+n以快速打开某一文件

AndroidManifes.xml

android:name: 必须属性,表示activity文件路径

启动activity

实际过程

activity调用startActivity(Intent)方法时,调用请求发送给了操作系统的ActivityManager,ActivityManager负责创建Activity实例并调用其onCreate(Bundle)方法

1-3

基于Intent的通信

intent对象是component用来与操作系统通信的一种媒介工具

component

  1. activity
  2. service
  3. broadcast receiver
  4. content provider

intent是一种多用途通信工具

显式intent与隐式intent

显式intent

通过指定Context与Class对象,然后调用intent的构造方法来创建的Intent

隐式intent

一个应用的activity如需启动另一个应用的activity,可通过创建隐式intent来处理

activity间的数据传递

intent extra

  1. extra是一种键值结构

  2. 将extra数据信息添加给intent

public Intent putExtra(String name, boolean value)

activity可能启动自不同的地方,所以,应该在获取和使用extra信息的activity那里,为它定义键

1
private static final String EXTRA_ANSWER_IS_TRUE = "com.suqir.android.geoquiz.answer_is_true";// 可避免来自不同应用的extra间发生命名冲突

newIntent(...)方法中封装处理extra信息的逻辑

1
2
3
4
5
public static Intent newIntent(Context pakageContext, boolean answerIsTrue){
Intent intent = new Intent(pakageContext, CheatActivity.class);
intent.putExtra(EXTRA_ANSWER_IS_TRUE, answerIsTrue);
return intent;
}

要传递多个参数可以需要在newIntent方法里添加多个参数

  1. 从extra获取数据

public boolean getBooleanExtra(String name, boolean defaultValue)

1
getIntent().getBooleanExtra(EXTRA_ANSWER_IS_TRUE, false)

从子 activity 获取返回结果

public void startActivityForResult(Intent intent, int requestCode)

设置返回结果

public final void setResult(int resultCode)
public final void setResult(int resultCode, Intent data)

  1. 父activity依据子activity的完成结果采取不同操作
  2. 默认的结果代码 -Activity.RESULT_CANCELED

返还intent

  1. 创建一个Intent
  2. 附加上extra信息
  3. 调用Activity.setResult(int, Intent)方法
  4. 添加一个方法协助解析出父activity能用的信息

处理返回结果

  1. 父类覆盖onActivityResult(...)方法获取子activity回传的值
  2. 检查请求代码和返回代码是否符合预期

第6章 Android SDK版本与兼容

Android SDK版本

所有的设置都保存在应用模块的build.gradle文件中。编译版本独占该文件,最低版本和目标版本在该文件中的作用是覆盖和设置配置文件AndroidManifest.xml

最低版本(minSdkVersion)

以最低版本设置值为标准,操作系统会拒绝将应用安装在系统版本低于标准的设备上。

目标版本(targetSdkVersion)

目标版本的设定值告知Android:应用是为哪个API级别设计的。大多数情况下,目标版本即最新发布的Android版本。

编译版本(compileSdkVersion)

SDK最低版本和目标版本会通知给操作系统,而SDK编译版本只是你和编译器之间的私有信息。

  • 编译目标的最佳选择为最新的API级别

兼容性问题

将高API级别代码置于检查Android设备版本的条件语句中

1
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
  • Build.VERSION.SDK_INT常量代表了Android设备的版本号

第7章 UI fragment与fragment管理器

引入Fragment

采用fragment而不是activity来管理应用UI,可绕开Android系统activity使用规则的限制

fragment是一种控制器对象, activity可委派它执行任务。这些任务通常就是管理用户界面。
受管的用户界面可以是一整屏或是整屏的一部分

创建 UI Fragment

  1. Fragment.onCreate(Bundle)是公共方法,而Activity.onCreate(Bundle)是受保护方法。Fragment.onCreate(Bundle)方法及其他Fragment生命周期方法必须是公共方法,因为托管fragment的activity要调用它们
  2. 类似于activityfragment同样具有保存及获取状态的bundle。如同使用Activity. onSaveInstanceState(Bundle)方法那样,你也可以根据需要覆盖Fragment.onSaveInstanceState(Bundle)方法
  3. fragment的视图是在onCreateView(LayoutInflater inflater, ViewGroup container方法里生成的。该 方 法 实 例 化 fragment 视 图 的 布 局 , 然 后 将 实 例 化 的View返 回 给 托 管activityLayoutInflaterViewGroup是实例化布局的必要参数。 Bundle用来存储恢复数据,可供该方法从保存状态下重建视图

FragmentManager添加 UI fragment

  1. 在Activity中的onCreate(...)里获取FragmentManager

    1
    FragmentManager fm = getSupportFragmentManager();
  2. 获取FragmentManager之后,再获取一个fragment交给它管理

    1
    2
    3
    4
    5
    6
    7
    Fragment fragment = fm.findFragmentById(R.id.fragment_container);
    if (fragment == null) {
    fragment = new CrimeFragment();
    fm.beginTransaction()
    .add(R.id.fragment_container, fragment)
    .commit();
    }

    (创建一个新的fragment事务,执行一个fragment添加操作,然后提交该事务)

    fragment事务被用来添加、移除、附加、分离或替换fragment队列中的fragment。

    FragmentManager.beginTransaction()方法创建并返回FragmentTransaction实例。FragmentTransaction类支持流接口(fluent interface)的链式方法调用,以此配置FragmentTransaction再返回它。

    其中add(...)方法是整个事务的核心,它有两个参数:容器视图资源ID和新创建的CrimeFragment。容器视图资源ID有两个作用:

    ①. 告诉FragmentManagerfragment视图应该出现在activity视图的什么位置;

    ②. 唯一标识FragmentManager队列中的fragment。

采用fragment的应用架构

设计应用时,正确使用fragment非常重要。fragment是用来封装关键组件以方便复用。实践证明,应用单屏最多使用2~3个fragment

拓展:极限编程方法论中有个YAGNI原则。 YAGNI( You Aren’t Gonna Need It)的意思是“你不会需要它”,该原则鼓励大家不要去实现那些有可能需要的东西。为什么呢?因为你不会需要它。

对于fragment,我们坚持AUF( Always Use Fragments)原则,即“总是使用fragment”。不值得为使用fragment还是activity伤脑筋。

第8章 使用RecyclerView显示列表

单例与数据集中存储

要创建单例,需创建一个带有私有构造方法及get()方法的类。如果实例已存在, get()方法就直接返回它;如果实例还不存在, get()方法就会调用构造方法创建它。

1
2
3
4
5
6
7
8
9
10
11
public class CrimeLab {
private static CrimeLab sCrimeLab;
public static CrimeLab get(Context context) {
if (sCrimeLab == null) {
sCrimeLab = new CrimeLab(context);
}
return sCrimeLab;
}
private CrimeLab(Context context) {
}
}

首先,注意sCrimeLab变量的s前缀。这是Android开发的命名约定,一看到此前缀,我们就知道sCrimeLab是一个静态变量。

其次,再来看CrimeLab的私有构造方法。显然,其他类无法创建CrimeLab对象,除非调用get()方法。

最后,在get()方法里,我们传入的是Context对象(第14章会用到)。

RecyclerView、 ViewHolder 和 Adapter

RecyclerView

RecyclerViewViewGroup的子类,每一个列表项都是作为一个View子对象显示的。RecyclerView所做的就是回收再利用,循环往复

ViewHolder

ViewHolder只做一件事:容纳View视图。如图:

2-1

RecyclerView自 身 不 会 创 建 视 图 , 它 创 建 的 是 ViewHolder, 而 ViewHolder 引 用 着itemView

2-2

Adapter

Adapter是一个控制器对象,从模型层获取数据,然后提供给RecyclerView显示,是沟通的桥梁

Adapter负责:

​ ①. 创建必要的ViewHolder

​ ②. 绑定ViewHolder至模型层数据。

使用RecyclerView

  1. RecyclerView类来自于Google支持库。要使用它,首先要添加RecyclerView依赖库。单击FileProject Structure....菜单项切换至项目结构窗口,选择左边的app模块,然后单击Dependencies选项页。单击+按钮弹出依赖库添加窗口。找到并选择recyclerview-v7支持库,单击OK按钮完成依赖库添加。如图:

    2-3

  2. 配置CrimeListFragment的视图文件,根视图使用RecyclerView

  3. 修改CrimeListFragment类文件,使用布局并找到布局中的RecyclerView视图:

    1
    2
    3
    4
    5
    6
    7
    8
    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
    View v = inflater.inflate(R.layout.fragment_crime_list, container, false);
    mCrimeRecyclerView = v.findViewById(R.id.crime_recycler_view);
    mCrimeRecyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));
    return v;
    }

    注意,没有LayoutManager的支持,不仅RecyclerView无法工作,还会导致应用崩溃。所以, RecyclerView视图创建完成后,就立即转交给了LayoutManager对象。

    LayoutManager负责在屏幕上摆放列表项和定义屏幕滚动行为。

    这里使用的是LinearLayoutManager类,它支持以竖直列表的形式展示列表项;还有GridLayoutManager类,以网格形式展示列表项。

    到这里运行应用,看到的是一个RecyclerView空视图。要显示出crime列表项,还需要完成AdapterViewHolder的实现

列表项视图

新建列表项的布局文件list_item_crime

实现 ViewHolder 和 Adapter

到这一步发现了一些书中的问题,书中说到:

7

但在我使用的Android Studio 3.5中,自动生成的构造方法却是一个参数的:

2-5

这是为什么呢?因为Google将实例化 list_item_crime 布局的过程挪到了之后实现 AdapteronCreateViewHolder 方法。也就是说,我们需要做的就是将书中原本在 CrimeHolder实例化 list_item_crime 的过程挪到 onCreateViewHolder 方法之中

代码如下:

9

接下来创建Adapter,需要显示新创建的ViewHolder或让Crime对象和已创建的ViewHolder关联时, RecyclerView会去找Adapter(调用它的方法)。 RecyclerView不关心也不了解具体的Crime对象,这是
Adapter要做的事

接下来,在CrimeAdapter中实现三个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@NonNull
@Override
public CrimeHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
View view = LayoutInflater.from(getActivity()).inflate(R.layout.list_item_crime, viewGroup, false);
return new CrimeHolder(view);
}

@Override
public void onBindViewHolder(@NonNull CrimeHolder crimeHolder, int i) {
}

@Override
public int getItemCount() {
return mCrimes.size();
}

(注意:onCreateViewHolder方法已经添加了上文的修改内容)

RecyclerView需要新的ViewHolder来显示列表项时,会调用onCreateViewHolder方法。在这个方法内部,我们创建一个LayoutInflater,然后用它创建CrimeHolder

搞定了Adapter,最后要做的就是将它和RecyclerView关联起来。实现一个设置CrimeListFragment用户界面的updateUI方法,该方法创建CrimeAdapter,然后设置给RecyclerView

2-7

绑定列表项

CrimeHolder还需要一个bind(Crime)方法:

1
2
3
4
5
6
7
private Crime mCrime;
···
public void bind(Crime crime){
mCrime = crime;
mTitleTextView.setText(crime.getTitle());
mDateTextView.setText(crime.getDate().toString());
}

每次有新的Crime要在CrimeHolder中显示时,都要调用它一次,并在Adapter中的onbindViewHolder方法中使用它:

1
2
3
4
5
@Override
public void onBindViewHolder(@NonNull CrimeHolder crimeHolder, int i) {
Crime crime = mCrimes.get(i);
crimeHolder.bind(crime);
}

最后运行效果将会显示每个Crime的Title和Date

响应点击

我们通过修改CrimeHolder类来处理用户点击事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private class CrimeHolder extends RecyclerView.ViewHolder implements View.OnClickListener {
···
private Crime mCrime;

public CrimeHolder(@NonNull View itemView) {
super(itemView);
···
itemView.setOnClickListener(this);
}

@Override
public void onClick(View v) {
Toast.makeText(getActivity(),mCrime.getTitle() + "Clicked", Toast.LENGTH_SHORT).show();
}
}

挑战练习: RecyclerView ViewType

请在RecyclerView中创建两类列表项:一般性crime,以及需警方介入的crime。要完成这个
挑战,你需要用到RecyclerView.Adapter的视图类别功能( view type)。在Crime对象里,再添
加一个mRequiresPolice实例变量,使用它并借助getItemViewType(int)方法( https://developer.android.com/reference/android/support/v7/widget/RecyclerView.Adapter.html#getItemViewType(int)),确定该加载哪个视图到`CrimeAdapter`。
onCreateViewHolder(ViewGroup, int)方法里,基于getItemViewType(int)方法返回的viewType值,需要返回不同的ViewHolder。如果是一般性crime,就仍然使用原始布局;如果是需警方介入的crime,就使用一个带联系警方按钮的新布局

  1. 新建需警方介入的crime列表项视图list_item_crime_police

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal"
    android:padding="8dp">

    <LinearLayout
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <TextView
    android:id="@+id/crime_title"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="Crime Title" />

    <TextView
    android:id="@+id/crime_date"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="Crime Date" />
    </LinearLayout>

    <TextView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:textSize="20dp"
    android:gravity="right"
    android:textColor="#ff0000"
    android:text="Police"/>

    </LinearLayout>
  2. Crime类中添加是否报警的属性:

    1
    2
    3
    4
    5
    6
    7
    8
    // 是否报警
    private boolean mRequiresPolice;
    public boolean isRequiresPolice() {
    return mRequiresPolice;
    }
    public void setRequiresPolice(boolean requiresPolice) {
    mRequiresPolice = requiresPolice;
    }
  3. CrimeLab中设置每隔3项需要报警:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    private CrimeLab(Context context) {
    mCrimes = new ArrayList<>();
    for (int i = 0; i < 100; i++) {
    Crime crime = new Crime();
    crime.setTitle("Crime # " + i);
    crime.setSolved(i % 2 == 0);
    // 设置每隔2项需要报警
    crime.setRequiresPolice(i % 3 == 0);
    mCrimes.add(crime);
    }
    }
  4. 修改CrimeListFragment类:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    // 删除泛型
    private class CrimeAdapter extends RecyclerView.Adapter {
    private List<Crime> mCrimes;

    public CrimeAdapter(List<Crime> crimes) {
    mCrimes = crimes;
    }

    // 重写方法
    @Override
    public int getItemViewType(int position) {
    if (mCrimes.get(position).isRequiresPolice()){
    return 1;
    } else {
    return 0;
    }
    }

    @NonNull
    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int type) {
    View view;
    // 分情况展示视图
    if (type == 0){
    view = LayoutInflater.from(getActivity()).inflate(R.layout.list_item_crime, viewGroup, false);
    } else {
    view = LayoutInflater.from(getActivity()).inflate(R.layout.list_item_crime_police, viewGroup, false);
    }
    return new CrimeHolder(view);
    }

    @Override
    public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int i) {
    Crime crime = mCrimes.get(i);
    ((CrimeHolder) viewHolder).bind(crime);
    }

    @Override
    public int getItemCount() {
    return mCrimes.size();
    }
    }
  5. 最终效果:

    11

第9章 使用布局与组件创建用户界面

引入ConstraintLayout

约束编辑器

12

13

TextView水平方向和竖直方向的尺寸是分别由宽度设置和高度设置决定的。能设置的值有以下三种:

2-11

约束的XML形式

凡是以layout_开头的属性都属于布局参数( layout parameter)。与其他属性不同的是,组件的布局参数是用来向其父组件做指示的,即用于告诉父布局如何安排自己,如layout_widthlayout_height

深入学习布局属性

dp、 sp 以及屏幕像素密度

  • px: 英文pixel的缩写,即像素。无论屏幕密度多少,一个像素单位对应一个屏幕像素单位。不推荐使用px,因为它不会根据屏幕密度自动缩放
  • dp(或dip):英文density-independent pixel的缩写,意为密度无关像素。在设置边距、内边距或任何不
    打算按像素值指定尺寸的情况下,通常都使用dp这种单位。如果屏幕密度较高,密度无关像素会相应扩展至整个屏幕。 1dp在设备屏幕上总是等于1/160英寸。使用dp的好处是,无论屏幕密度如何,总能获得同样的尺寸
  • sp:英文scale-independent pixel的缩写, 意为缩放无关像素。它是一种与密度无关的像素,这种像素会受用户字体偏好设置的影响。通常使用sp来设置屏幕上的字体大小
  • pm、mm、in:类似于dp的缩放单位,允许以点( 1/72英寸)、毫米或英寸为单位指定用户界面尺寸。但
    在实际开发中不建议使用这些单位,因为并非所有设备都能按照这些单位进行正确的尺寸缩放配置

样式、主题及主题属性

样式( style)是XML资源文件,含有用来描述组件行为和外观的属性定义。例如,使用下列
样式配置组件,就能显示比正常大小更大的文字:

1
2
3
4
<style name="BigTextStyle">
<item name="android:textSize">20sp</item>
<item name="android:padding">3dp</item>
</style>

你可以创建自己的样式文件(第22章会这样做)。具体做法是将属性定义添加并保存在res/values/目录下的样式文件中,然后在布局文件中以@style/my_own_style(样式文件名)的形式引用

主题是各种样式的集合。从结构上来说,主题本身也是一种样式资源,只不过它的属性指向了其他样式资源。Android自带了一些供应用使用的平台主题。

使用主题属性引用,可将预定义的应用主题样式添加给指定组件

Android 应用的设计原则

边距属性, Android Studio默认使用的值是16dp或8dp。设定这两种值遵循了Android的material design原则。访问https://developer.android.com/design/index.html,可看到所有的Android设计规范。

开发Android应用都应严格遵循这些设计原则。不过,这些设计原则严重依赖于SDK较新版本的功能,旧版本设备往往无法获得或实现这些功能。不过有些设计可借助AppCompat库实现,详见第13章

挑战练习:日期格式化

与其说Date对象是普通日期,不如说是时间戳。调用Date对象的toString()方法,就能得到一个时间戳。所以, RecyclerView视图上显示的就是它。时间戳虽然凑合能用,但如果能显示人们习惯看到的日期应该会更好,如“ Jul 22, 2016” 。要实现此目标,可使用android.text.format.DateFormat类实例。具体怎么用,请查阅Android文档库中有关该类的说明。
使用DateFormat类中的方法,可获得常见格式的日期;也可以自己定制字符串格式。最后,
再来一个更有挑战的练习:创建一个包含星期的字符串格式,如“Friday, Jul 22, 2016”。

1
2
3
4
5
6
7
8
public void bind(Crime crime){
mCrime = crime;
mTitleTextView.setText(crime.getTitle());
// 格式化日期 //星期,月份 几号,几年 例如:星期一,三月 30 2020
CharSequence date = DateFormat.format("EEEE,MMMM dd yyyy", crime.getDate());
mDateTextView.setText(date);
mSolvedImageView.setVisibility(mCrime.isSolved() ? View.VISIBLE : View.GONE);
}

运行效果

image-20200330182711213

第10章 使用fragment argument

从 fragment 中启动 activity

从 fragment 中 启 动 activity 类 似 于 从 activity 中 启 动 activity 。 我 们 调 用 Fragment.startActivity(Intent)方法,由它在后台再调用对应的Activity方法

1
2
Intent intent = new Intent(getActivity(), CrimeActivity.class);
startActivity(intent);

由于不知道该显示哪个Crime对象,因此CrimeFragment没有显示出具体的Crime信息

附加 extra 信息

启动CrimeActivity时,传递附加到Intent extra上的crime ID, CrimeFragment就能知道该
显示哪个Crime。这需要在CrimeActivity中新增newIntent方法.创建了显式intent后,调用putExtra(...)方法,传入匹配crimeId的字符串键与键值

1
2
3
4
5
6
7
8
9
public class CrimeActivity extends SingleFragmentActivity {
public static final String EXTRA_CRIME_ID = "com.bignerdranch.android.criminalintent.crime_id";
public static Intent newIntent(Context packageContext, UUID crimeId) {
Intent intent = new Intent(packageContext, CrimeActivity.class);
intent.putExtra(EXTRA_CRIME_ID, crimeId);
return intent;
}
...
}

更新CrimeHolder,使用newIntent方法

1
2
3
4
5
6
7
8
private class CrimeHolder extends RecyclerView.ViewHolder implements View.OnClickListener {
...
@Override
public void onClick(View view) {
Intent intent = CrimeActivity.newIntent(getActivity(), mCrime.getId());
startActivity(intent);
}
}

获取 extra 信息

crime ID现已安全存储到CrimeActivity的intent中。然而,要获取和使用extra信息的是CrimeFragment

fragment有两种方式获取intent中的数据:一种简单直接,另一种复杂但比较灵活(涉及fragment argument的概念)

  • 简单的方法

    CrimeFragment直接使用getActivity()方法获取CrimeActivityintent

    缺点:这种方式破坏了fragment的封装,CrimeFragment不再是可复用的构建单元,因为它现在由某个特定的activity托管着,该特定activity的Intent又定义了名为com.bignerdranch.android.criminalintent.crime_id的extra

  • 复杂但灵活的方法(fragment argument)

    附加 argument 给 fragment

    每个fragment实例都可附带一个Bundle对象。该bundle包含键值对,我们可以像附加extra到Activity的intent中那样使用它们。一个键-值对即一个argument

    要创建fragment argument,首先需创建Bundle对象。然后,使用Bundle限定类型的put方法(类似于Intent的方法),将argument添加到bundle中

    1
    2
    3
    4
    Bundle args = new Bundle();
    args.putSerializable(ARG_MY_OBJECT, myObject);
    args.putInt(ARG_MY_INT, myInt);
    args.putCharSequence(ARG_MY_STRING, myString);

    要附加argument bundle给fragment,需调用Fragment.setArguments(Bundle)方法。而且,还必须在fragment创建后、添加给activity前完成

    为满足以上要求, Android开发人员采取的习惯做法是:添加名为newInstance()的静态方法给Fragment类。使用该方法,完成fragment实例及Bundle对象的创建,然后将argument放入bundle中,最后再附加给fragment

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class CrimeFragment extends Fragment {
    private static final String ARG_CRIME_ID = "crime_id";
    public static CrimeFragment newInstance(UUID crimeId) {
    Bundle args = new Bundle();
    args.putSerializable(ARG_CRIME_ID, crimeId);
    CrimeFragment fragment = new CrimeFragment();
    fragment.setArguments(args);
    return fragment;
    }
    ...
    }

    现在, 需创建CrimeFragment时, CrimeActivity应调用CrimeFragment.newInstance(UUID)方法,并传入从它的extra中获取的UUID参数值

    1
    2
    3
    4
    protected Fragment createFragment() {
    UUID crimeId = (UUID) getIntent().getSerializableExtra(EXTRA_CRIME_ID);
    return CrimeFragment.newInstance(crimeId);
    }

    注意, activity和 fragment不需要也无法同时相互保持独立。托管activity应该知道这些细节,以便托管fragment;但fragment不一定需要知道其托管activity的细节问题,至少在需要保持fragment通用独立的时候如此

    获取 argument

    fragment需要获取它的argument时,会先调用Fragment类的getArguments()方法,再调用Bundle限定类型的get方法,如getSerializable(...)方法

    1
    2
    3
    4
    5
    6
    @Override
    public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    UUID crimeId = (UUID) getArguments().getSerializable(ARG_CRIME_ID);
    mCrime = CrimeLab.get(getActivity()).getCrime(crimeId);
    }

刷新显示列表

CrimeListFragment启动CrimeActivity实例后, CrimeActivity被置于回退栈顶。这导致原先处于栈顶的CrimeListActivity实例被暂停并停止。用户点击后退键返回到列表项界面, CrimeActivity随即弹出栈外并被销毁。此时, CrimeListActivity立即重新启动并恢复运行:

3-1

CrimeListActivity恢复运行后,操作系统会发出调用onResume()生命周期方法的指令。CrimeListActivity接到指令后,它的FragmentManager会调用当前被activity托管的fragment的onResume()方法。这里的fragment就是指CrimeListFragment

在CrimeListFragment中,覆盖onResume()方法,触发调用updateUI()方法刷新显示列表项

如果已配置好CrimeAdapter,就调用notifyDataSetChanged()方法来修改updateUI()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public void onResume() {
super.onResume();
updateUI();
}
private void updateUI() {
CrimeLab crimeLab = CrimeLab.get(getActivity());
List<Crime> crimes = crimeLab.getCrimes();

if (mAdapter == null) {
mAdapter = new CrimeAdapter(crimes);
mCrimeRecyclerView.setAdapter(mAdapter);
} else {
mAdapter.notifyDataSetChanged();
}
}

一般来说,要保证fragment视图得到刷新,在onResume()方法内更新代码是最安全的选择

通过 fragment 获取返回结果

如需从已启动的activity获取返回结果,可调用Fragment.startActivityForResult(...)方法,并覆盖Fragment.onActivityResult(...)方法。

fragment能够从activity中接收返回结果,但其自身无法持有返回结果。只有activity拥有返回结果。因此,尽管Fragment有自己的startActivityForResult(...)方法和onActivityResult(...)方法,但没有setResult(...)方法。相反,应让托管activity返回结果值

1
2
3
4
5
6
public class CrimeFragment extends Fragment {
...
public void returnResult() {
getActivity().setResult(Activity.RESULT_OK, null);
}
}

深入学习:为何要用 fragment argument

fragment argument的使用有点复杂。为什么不直接在CrimeFragment里创建一个实例变量呢?

创建实例变量的方式并不可靠。这是因为,在操作系统重建fragment时(设备配置发生改变)用户暂时离开当前应用(操作系统按需回收内存),任何实例变量都将不复存在。尤其是内存不够,操作系统强制杀掉应用的情况,可以说是无人能挡。

因此,可以说, fragment argument就是为应对上述场景而生

还有另一个方法应对上述场景,那就是使用实例状态保存机制。具体来说,就是将crime ID赋 值 给 实 例 变 量 , 然 后 在onSaveInstanceState(Bundle)方 法 中 保 存 下 来 。 要 用 时 , 从onCreate(Bundle)方法中的Bundle中取回。然而,这种解决方案的维护成本高。举例来说,如果你在若干年后要修改fragment代码以添加其他argument,很可能会忘记在onSaveInstanceState(Bundle)方法里保存新增的argument

挑战练习:实现高效的 RecyclerView 刷新

Adapter的notifyDataSetChanged方法会通知RecyclerView刷新全部的可见列表项。

在CriminalIntent应用里,这个方法不够高效。这是因为,返回CrimeListFragment时,最多只有一个Crime实例会发生变化。

只需要刷新列表项中的单个crime项的话,应该使用RecyclerView.AdapternotifyItemChanged(int)方法。修改代码调用这个方法很简单,但如何定位并刷新具体位置的列表项呢?这是一个挑战!

  1. 在CrimeListFragment里面定义一个全局变量

    1
    private int itemPosition;
  2. 修改CrimeListFragment里的onClick方法

    1
    2
    3
    4
    5
    6
    @Override
    public void onClick(View v) {
    itemPosition = getAdapterPosition();// 获取当前position
    Intent intent = CrimeActivity.newIntent(getActivity(), mCrime.getId());
    startActivity(intent);
    }
  3. 修改updateUI方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    private void updateUI() {
    CrimeLab crimeLab = CrimeLab.get(getActivity());
    List<Crime> crimes = crimeLab.getCrimes();

    if (mAdapter == null) {
    mAdapter = new CrimeAdapter(crimes);
    mCrimeRecyclerView.setAdapter(mAdapter);
    } else {
    // 一次刷新所有列表项
    // mAdapter.notifyDataSetChanged();
    // 只刷新某一项
    mAdapter.notifyItemChanged(itemPosition);
    }
    }

挑战练习:优化 CrimeLab 的表现

CrimeLab的getCrime(UUID)方法没毛病,但匹配要找的crime ID这个过程还可以再优化。请优化匹配逻辑,不过重构代码时,不要搞坏了CriminalIntent应用

这里可使用LinkedHashMap ,然后用UUID作为key来存储数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class CrimeLab {

private static CrimeLab sCrimeLab;
private Map<UUID, Crime> mCrimes;

public static CrimeLab get(Context context) {
if (sCrimeLab == null) {
sCrimeLab = new CrimeLab(context);
}
return sCrimeLab;
}

private CrimeLab(Context context) {
mCrimes = new LinkedHashMap<>();
for (int i = 0; i < 100; i++) {
Crime crime = new Crime();
crime.setTitle("Crime #" + i);
crime.setSolved(i % 2 == 0);
mCrimes.put(crime.getId(), crime);
}
}

public List<Crime> getCrimes() {
return new ArrayList<>(mCrimes.values());
}

public Crime getCrime(UUID id) {
return mCrimes.get(id);
}
}

第11章 使用ViewPager

ViewPager在某种程度上类似于RecyclerView。 RecyclerView需借助于Adapter提供视图。同样, ViewPager需要PagerAdapter的支持

Google提供了PagerAdapter的子类FragmentStatePagerAdapter,它能协助处理许多细节问题

FragmentStatePagerAdapter化繁为简,提供了两个有用的方法: getCount()getItem(int)。调用getItem(int)方法,获取并显示crime数组中指定位置的Crime时,它会返回配置过的CrimeFragment来显示指定的Crime

FragmentStatePagerAdapter 与 FragmentPagerAdapter

FragmentPagerAdapter是另外一种可用的PagerAdapter,其用法与FragmentStatePagerAdapter基本一致。唯一的区别在于,卸载不再需要的fragment时, 各自采用的处理方法有所不同

FragmentStatePagerAdapter会销毁不需要的fragment。事务提交后, activity的FragmentManager中的fragment会被彻底移除。 FragmentStatePagerAdapter类名中的“state”表明:在销毁fragment时,可在onSaveInstanceState(Bundle)方法中保存fragment的Bundle信息。用户切换回来时,保存的实例状态可用来生成新的fragment

3-2

相比之下, FragmentPagerAdapter有不同的做法。对于不再需要的fragment, FragmentPagerAdapter会选择调用事务的detach(Fragment)方法来处理它,而非remove(Fragment)方法。也就是说, FragmentPagerAdapter只是销毁了而fragment的视图,而fragment实例还保留在FragmentManager中。因此FragmentPagerAdapter创建的fragment永远不会被销毁

3-3

选择哪种adapter取决于应用的要求。通常来说,使用FragmentStatePagerAdapter更节省内存。

另一方面,如果用户界面只需要少量固定的fragment,则FragmentPagerAdapter是安全、合适的选择。最常见的例子为使用tab选项页显示用户界面。例如,某些应用的明细视图所含内容较多,通常需分两页显示。这时就可以将这些明细信息分拆开来,以多页面的形式展现。显然,为用户界面添加支持滑动切换的ViewPager,能增强应用的触摸体验。此外,将fragment保存在内存中,更易于管理控制器层的代码。对于这种类型的用户界面,每个activity通常只有两三个fragment,基本不用担心有内存不足的风险

深入学习:以代码的方式创建视图

以代码的方式创建视图很简单:调用视图类的构造方法,并传入Context参数。不创建任何布局文件,用代码就能创建完整的视图层级结构

但最好不要这样做

使用布局文件的好处:

  1. 布局文件能很好地分离控制器层和视图层对象:视图定义在XML布局文件中,控制器层对象定义在Java代码中。这样,假设控制器层有代码修改的话,代码变更管理相对容易很多;反之亦然
  2. 使用布局文件,我们还能使用Android的资源适配系统,实现按设备属性自动调用合适的布局文件

当然,布局文件也不是毫无缺点。如果应用只需一个视图,估计没人愿意麻烦地创建并实例化布局XML文件

挑战练习:恢复 CrimeFragment 的边距

可能你已经注意到了, CrimeFragment的边距没有了。奇怪啊,在fragment_crime.xml文件里,明明已指定过16dp的边距:

1
2
3
4
5
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="16dp"
android:orientation="vertical">

发生了什么?原来, ViewPager的布局参数是不支持边距设置的。请修改fragment_crime.xml布局文件,让边距能够显示出来。

android:layout_margin="16dp"修改为android:padding="16dp"

挑战练习:添加 Jump to First 按钮和 Jump to Last 按钮

给CrimePagerActivity添加两个按钮。允许使用它们快速跳至第一条和最后一条crime记录。当然,要注意控制,查看第一条记录时应禁用Jump to First按钮,查看最后一条时禁用Jump to Last按钮

  1. 修改activity_crime_pager.xml添加两个Button并分别设置id为btn_firstbtn_last

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    <?xml version="1.0" encoding="utf-8"?>
    <android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <android.support.v4.view.ViewPager
    android:id="@+id/activity_crime_pager_view_pager"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent">

    </android.support.v4.view.ViewPager>

    <Button
    android:id="@+id/btn_first"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginStart="16dp"
    android:layout_marginBottom="32dp"
    android:text="Jump to First"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintStart_toStartOf="@+id/activity_crime_pager_view_pager" />

    <Button
    android:id="@+id/btn_last"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginEnd="16dp"
    android:layout_marginBottom="32dp"
    android:text="Jump to Last"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="@+id/activity_crime_pager_view_pager" />

    </android.support.constraint.ConstraintLayout>
  2. 修改CrimePagerActivity绑定两个按钮并设置点击事件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    public class CrimePagerActivity extends AppCompatActivity implements View.OnClickListener {
    ···
    private Button mFirstButton;
    private Button mLastButton;
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
    ···
    mFirstButton = findViewById(R.id.btn_first);
    mFirstButton.setOnClickListener(this);
    mLastButton = findViewById(R.id.btn_last);
    mLastButton.setOnClickListener(this);
    }
    ···
    @Override
    public void onClick(View v) {
    switch (v.getId()) {
    case R.id.btn_first:
    mViewPager.setCurrentItem(0);
    break;
    case R.id.btn_last:
    mViewPager.setCurrentItem(mCrimes.size() - 1);
    break;
    }
    }
    }
  3. 给mViewPager添加页面监听:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    mViewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
    @Override
    public void onPageScrolled(int i, float v, int i1) {

    }

    @Override
    public void onPageSelected(int i) {
    mLastButton.setVisibility(View.VISIBLE);
    mFirstButton.setVisibility(View.VISIBLE);
    if (i == 0) {
    mFirstButton.setVisibility(View.GONE);
    } else if (i == mCrimes.size() - 1) {
    mLastButton.setVisibility(View.GONE);
    }
    }

    @Override
    public void onPageScrollStateChanged(int i) {

    }
    });
  4. 在CrimePagerActivity的onCreate(@Nullable Bundle savedInstanceState)方法里设置显示当前项:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    for (int i = 0; i < mCrimes.size(); i++) {
    if (mCrimes.get(i).getId().equals(uuid)) {
    mViewPager.setCurrentItem(i);
    if (i == 0){
    mFirstButton.setVisibility(View.GONE);
    }
    if (i == mCrimes.size()-1){
    mLastButton.setVisibility(View.GONE);
    }
    break;
    }
    }
  5. 运行效果

滑至第一项滑至中间项滑至最后项
3-43-53-6

第12章 对 话 框

创建 DialogFragment

使用FragmentManager管理对话框,可以更灵活地显示对话框

如果旋转设备,单独使用的AlertDialog会消失,而封装在fragment中的AlertDialog则不会有此问题(旋转后,对话框会被重建恢复)

要显示对话框,首先应完成以下任务:

  • 创建DatePickerFragment类;
  • 创建AlertDialog;
  • 借助FragmentManager在屏幕上显示对话框

创建DatePickerFragment新类,并设置其DialogFragment超类为支持库中的android.support.v4.app.DialogFragment类。

DialogFragment类有如下方法:

public Dialog onCreateDialog(Bundle savedInstanceState)

为了在屏幕上显示DialogFragment,托管activity的FragmentManager会调用它

1
2
3
4
5
6
7
8
9
public class DatePickerFragment extends DialogFragment {
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
return new AlertDialog.Builder(getActivity())
.setTitle(R.string.date_picker_title)
.setPositiveButton(android.R.string.ok, null)
.create();
}
}

调用setPositiveButton(…)方法,需传入两个参数:字符串资源和实现DialogInterface.OnClickListener接口的对象。(Android有3种可用于对话框的按钮: positive按钮、 negative按钮以及neutral按钮。用户点击positive按钮接受对话框展现信息。如果同一对话框上放置有多个按钮,按钮的类型与命名决定着它们在对话框上显示的位置。)。最后,调用AlertDialog.Builder.create()方法,返回配置完成的AlertDialog实例,完成对话框的创建

显示 DialogFragment

要将DialogFragment添加给FragmentManager管理并放置到屏幕上,可调用fragment实例的以下方法:

public void show(FragmentManager manager, String tag)
public void show(FragmentTransaction transaction, String tag)

String参数可唯一识别FragmentManager队列中的DialogFragment。两个方法都可以:如果传入FragmentTransaction参数,你自己负责创建并提交事务;如果传入FragmentManager参数,系统会自动创建并提交事务

在CrimeFragment中,为DatePickerFragment添加一个tag常量。为mDateButton按钮添加OnClickListener监听器接口,实现点击日期按钮展现DatePickerFragment界面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class CrimeFragment extends Fragment {
private static final String ARG_CRIME_ID = "crime_id";
private static final String DIALOG_DATE = "DialogDate";
...
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
...
mDateButton = (Button) v.findViewById(R.id.crime_date);
mDateButton.setText(mCrime.getDate().toString());
mDateButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
FragmentManager manager = getFragmentManager();
DatePickerFragment dialog = new DatePickerFragment();
dialog.show(manager, DIALOG_DATE);
}
});
mSolvedCheckBox = (CheckBox) v.findViewById(R.id.crime_solved);
...
return v;
}
}

设置对话框的显示内容

使用AlertDialog.Builder的setView(...)方法,给AlertDialog对话框添加DatePicker组件:

public AlertDialog.Builder setView(View view)

该方法配置对话框,实现在标题栏与按钮之间显示传入的View对象

在项目工具窗口中,以DatePicker为根元素,创建名为dialog_date.xml的布局文件。新布局仅包含一个View对象,即我们生成并传给setView(…)方法的DatePicker视图

3-7

在DatePickerFragment.onCreateDialog(Bundle)方法中,实例化DatePicker视图并添
加给对话框:

1
2
3
4
5
6
7
8
9
10
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
View v = LayoutInflater.from(getActivity())
.inflate(R.layout.dialog_date, null);
return new AlertDialog.Builder(getActivity())
.setView(v)
.setTitle(R.string.date_picker_title)
.setPositiveButton(android.R.string.ok, null)
.create();
}

运行程序,会显示如下界面。如果使用旧版本系统 ,DatePicker组件会使用calendarViewShown属性,显示图12-7:

3-8

至此,显示对话框的工作就完成了。下一节,我们实现显示Crime日期,并支持用户对其进行修改

fragment 间的数据传递

前面,我们实现了activity之间以及基于fragment的activity之间的数据传递。现在需实现同一activity托管的两个fragment之间的数据传递

要传递crime的日期给DatePickerFragment,需新建一个newInstance(Date)方法,然后将Date作为argument附加给fragment。为返回新日期给CrimeFragment,并更新模型层以及对应视图,需将日期打包为extra并附加到Intent上,然后调用CrimeFragment.onActivityResult(…)方法,并传入准备好的Intent参数

传递数据给 DatePickerFragment

要传递crime日期给DatePickerFragment,需将它保存在DatePickerFragment的argument bundle中。这样, DatePickerFragment就能直接获取它。

创建和设置fragment argument通常是在newInstance()方法中完成的(代替fragment构造方法):

1
2
3
4
5
6
7
8
9
10
11
12
public class DatePickerFragment extends DialogFragment {
private static final String ARG_DATE = "date";
private DatePicker mDatePicker;
public static DatePickerFragment newInstance(Date date) {
Bundle args = new Bundle();
args.putSerializable(ARG_DATE, date);
DatePickerFragment fragment = new DatePickerFragment();
fragment.setArguments(args);
return fragment;
}
...
}

然 后 , 在 CrimeFragment 中 , 用 DatePickerFragment.newInstance(Date) 方 法 替 换DatePickerFragment的构造方法:

1
2
3
4
5
6
7
8
9
10
mDateButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
FragmentManager manager = getFragmentManager()
DatePickerFragment dialog = new DatePickerFragment();
DatePickerFragment dialog = DatePickerFragment
.newInstance(mCrime.getDate());
dialog.show(manager, DIALOG_DATE);
}
});

DatePickerFragment使用Date中的信息来初始化DatePicker对象。然而, DatePicker对象的初始化需整数形式的月、日、年。 Date是时间戳,无法直接提供整数

要达到目的,必须首先创建一个Calendar对象,然后用Date对象配置它,再从Calendar对象中取回所需信息

在onCreateDialog(Bundle)方法内,从argument中获取Date对象,然后用它和Calendar对象初始化DatePicker:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
Date date = (Date) getArguments().getSerializable(ARG_DATE);
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
int year = calendar.get(Calendar.YEAR);
int month = calendar.get(Calendar.MONTH);
int day = calendar.get(Calendar.DAY_OF_MONTH);
View v = LayoutInflater.from(getActivity())
.inflate(R.layout.dialog_date, null);
mDatePicker = (DatePicker) v.findViewById(R.id.dialog_date_picker);
mDatePicker.init(year, month, day, null);
return new AlertDialog.Builder(getActivity())
.setView(v)
.setTitle(R.string.date_picker_title)
.setPositiveButton(android.R.string.ok, null)
.create();
}

返回数据给 CrimeFragment

要让CrimeFragment接收DatePickerFragment返回的日期数据,首先需要清楚它们之间的关系。
如果是activity的数据回传,我们调用startActivityForResult(…)方法, ActivityManager负责跟踪管理activity父子关系。回传数据后,子activity被销毁,但ActivityManager知道接收数据的是哪个activity。

  1. 设置目标fragment

    类似于activity间的关联,可将CrimeFragment设置成DatePickerFragment的目标fragment。
    这样,在CrimeFragment和DatePickerFragment被销毁并重建后,操作系统会重新关联它们。
    调用以下Fragment方法可建立这种关联:

    public void setTargetFragment(Fragment fragment, int requestCode)

    该方法有两个参数:目标fragment以及类似于传入startActivityForResult(…)方法的请求代码

    目标fragment和请求代码由FragmentManager负责跟踪管理,我们可调用fragment(设置目标fragment的fragment)的getTargetFragment()方法和getTargetRequestCode()方法获取它们

    在CrimeFragment.java中,创建请求代码常量,然后将CrimeFragment设为DatePickerFragment实例的目标fragment:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    public class CrimeFragment extends Fragment {
    private static final String ARG_CRIME_ID = "crime_id";
    private static final String DIALOG_DATE = "DialogDate";
    private static final int REQUEST_DATE = 0;
    ...
    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
    Bundle savedInstanceState) {
    ...
    mDateButton.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
    FragmentManager manager = getFragmentManager();
    DatePickerFragment dialog = DatePickerFragment
    .newInstance(mCrime.getDate());
    dialog.setTargetFragment(CrimeFragment.this, REQUEST_DATE);
    dialog.show(manager, DIALOG_DATE);
    }
    });
    ...
    return v;
    }
    }
  2. 传递数据给目标fragment

    建立CrimeFragment与DatePickerFragment之间的联系后,需将数据回传给CrimeFragment。回传日期将作为extra附加给Intent

    使 用 什 么 方 法 发 送 intent 信 息 给 目 标 fragment ? 虽 然 令 人 难 以 置 信 , 但 是 我 们 会 让DatePickerFragment类调用CrimeFragment.onActivityResult(int, int, Intent)方法

    Activity.onActivityResult(...)方法是ActivityManager在子activity被销毁后调用的父activity方法。处理activity间的数据返回时, ActivityManager会自动调用Activity.onActivityResult(...)方法。父activity接收到Activity.onActivityResult(...)方法调用命令后,其FragmentManager会调用对应fragment的Fragment.onActivityResult(...)方法

    处理由同一activity托管的两个fragment间的数据返回时,可借用Fragment.onActivityResult(…)方法。因此,直接调用目标fragment的Fragment.onActivityResult(...)方法,就能实现数据的回传

    在DatePickerFragment类中,新建sendResult(...)私有方法,创建intent并将日期数据作为extra附加到intent上。最后调用CrimeFragment.onActivityResult(...)方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public class DatePickerFragment extends DialogFragment {
    public static final String EXTRA_DATE = "com.bignerdranch.android.criminalintent.date";
    private static final String ARG_DATE = "date";
    ...
    @Override
    public Dialog onCreateDialog(Bundle savedInstanceState) {
    ...
    }
    private void sendResult(int resultCode, Date date) {
    if (getTargetFragment() == null) {
    return;
    }
    Intent intent = new Intent();
    intent.putExtra(EXTRA_DATE, date);
    getTargetFragment().onActivityResult(getTargetRequestCode(), resultCode, intent);
    }
    }

    现在来使用sendResult(…)私有方法。用户点击对话框中的positive按钮时,需要从DatePicker中获取日期并回传给CrimeFragment。在onCreateDialog(…)方法中,替换掉setPositiveButton(…)的null参数值,实现DialogInterface.OnClickListener监听器接口。在监听器接口的onClick(…)方法中,获取日期并调用sendResult(…)方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    @Override
    public Dialog onCreateDialog(Bundle savedInstanceState) {
    ...
    return new AlertDialog.Builder(getActivity())
    .setView(v)
    .setTitle(R.string.date_picker_title)
    .setPositiveButton(android.R.string.ok, null);
    .setPositiveButton(android.R.string.ok,
    new DialogInterface.OnClickListener() {
    @Override
    public void onClick(DialogInterface dialog, int which) {
    int year = mDatePicker.getYear();
    int month = mDatePicker.getMonth();
    int day = mDatePicker.getDayOfMonth();
    Date date = new GregorianCalendar(year, month, day).getTime();
    sendResult(Activity.RESULT_OK, date);
    }
    })
    .create();
    }

    在CrimeFragment中,覆盖onActivityResult(…)方法,从extra中获取日期数据,设置对应Crime的记录日期,然后刷新日期按钮的显示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public class CrimeFragment extends Fragment {
    ...
    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,Bundle savedInstanceState) {
    ...
    }
    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data) {
    if (resultCode != Activity.RESULT_OK) {
    return;
    }
    if (requestCode == REQUEST_DATE) {
    Date date = (Date) data
    .getSerializableExtra(DatePickerFragment.EXTRA_DATE);
    mCrime.setDate(date);
    mDateButton.setText(mCrime.getDate().toString());
    }
    }
    }

    在onCreateView(…)和onActivityResult(…)这两个方法中,设置按钮显示文字的代码完全一样。为了避免代码冗余,可以将其封装到updateDate()私有方法中,然后分别调用。除 手 动 封 装 代 码 的 方 式 外 , 还 可 以 使 用 Android Studio 的 内 置 工 具 。 高 亮 选 取 设 置mDateButton显示文字的代码 →右键单击并选择Refactor → Extract → Method…菜单项,设置方法为私有并将其命名为updateDate。点击OK按钮, Android Studio会提示还有其他地方使用了这段代码。点击Yes允许它自动处理。然后确认updateDate方法封装完成并在相应地方调用

    日期数据的双向传递完成了。

  3. 更为灵活的DialogFragment视图展现

    编写需要用户大量输入以及要求更多空间显示输入的应用,并且要让应用同时支持手机和平板设备时,使用onActivityResult(...)方法返回数据给目标fragment是比较方便的

    编写同样的代码用于全屏fragment或对话框fragment时,可选择覆盖DialogFragment.onCreateView(...)方法,而非onCreateDialog(...)方法,以实现不同设备上的信息呈现

挑战练习:更多对话框

首先看一个简单的练习。另写一个名为TimePickerFragment的对话框fragment,允许用户使用TimePicker组件选择crime发生的具体时间。在CrimeFragment用户界面上再添加一个按钮,以显示TimePickerFragment视图界面

  1. 新建TimePickerFragment:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    public class TimePickerFragment extends DialogFragment {

    private static final String ARG_TIME = "time";
    public static final String EXTRA_TIME = "com.suqir.android.criminalintent.time";

    private TimePicker mTimePicker;

    @NonNull
    @Override
    public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
    View view = LayoutInflater.from(getActivity()).inflate(R.layout.dialog_time, null);
    mTimePicker = view.findViewById(R.id.dialog_time_picker);
    Date date = (Date) getArguments().getSerializable(ARG_TIME);
    Calendar calendar = Calendar.getInstance();
    calendar.setTime(date);
    final int year = calendar.get(Calendar.YEAR);
    final int month = calendar.get(Calendar.MONTH);
    final int day = calendar.get(Calendar.DAY_OF_MONTH);
    int hour = calendar.get(Calendar.HOUR);
    int minute = calendar.get(Calendar.MINUTE);
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
    mTimePicker.setHour(hour);
    mTimePicker.setMinute(minute);
    } else {
    mTimePicker.setCurrentHour(hour);
    mTimePicker.setCurrentMinute(minute);
    }
    return new AlertDialog.Builder(getActivity())
    .setTitle(R.string.time_picker_title)
    .setView(mTimePicker)
    .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
    @Override
    public void onClick(DialogInterface dialog, int which) {
    int hour, minute;
    if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
    hour = mTimePicker.getHour();
    minute = mTimePicker.getMinute();
    } else {
    hour = mTimePicker.getCurrentHour();
    minute = mTimePicker.getCurrentMinute();
    }
    Date time = new GregorianCalendar(year, month, day, hour, minute).getTime();
    sendResult(Activity.RESULT_OK, time);
    }
    })
    .create();
    }

    public static TimePickerFragment newInstance(Date time) {
    Bundle args = new Bundle();
    args.putSerializable(ARG_TIME, time);
    TimePickerFragment fragment = new TimePickerFragment();
    fragment.setArguments(args);
    return fragment;
    }

    private void sendResult(int resultCode, Date time){
    if (getTargetFragment() == null){
    return;
    }
    Intent intent = new Intent();
    intent.putExtra(EXTRA_TIME, time);
    getTargetFragment().onActivityResult(getTargetRequestCode(), resultCode, intent);
    }
    }
  1. 新建视图文件dialog_time.xml:

    1
    2
    3
    4
    5
    <?xml version="1.0" encoding="utf-8"?>
    <TimePicker xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:id="@+id/dialog_time_picker"/>
  2. 新建字符串资源:

    1
    <string name="time_picker_title" translatable="true">Time Of Crime</string>
  3. 修改fragment_crime.xml增加一个Button:

    1
    2
    3
    4
    <Button
    android:id="@+id/crime_time"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"/>
  1. 修改CrimeFragment:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    public class CrimeFragment extends Fragment {
    ···
    public static final String DIALOG_TIME = "DialogTime";
    public static final int REQUEST_TIME = 1;
    private Button mTimeButton;
    ···
    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
    ···
    mTimeButton = v.findViewById(R.id.crime_time);
    mTimeButton.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
    FragmentManager manager = getFragmentManager();
    TimePickerFragment dialog = TimePickerFragment.newInstance(mCrime.getDate());
    dialog.setTargetFragment(CrimeFragment.this, REQUEST_TIME);
    dialog.show(manager, DIALOG_TIME);
    }
    });
    updateTime();
    ···
    }
    ···
    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data) {
    ···
    if (requestCode == REQUEST_TIME) {
    Date date = (Date) data.getSerializableExtra(TimePickerFragment.EXTRA_TIME);
    mCrime.setDate(date);
    updateTime();
    }
    }
    ···
    private void updateTime() {
    mTimeButton.setText(DateFormat.format("kk:mm", mCrime.getDate()));
    }
    }
  2. 运行效果:

TimePickerFragmentCrimeFragment
3-93-10

挑战练习:实现响应式 DialogFragment

再来看一个有些难度的练习:优化DatePickerFragment的呈现方式。

要完成这个挑战,初步分析需三大步。第一步,替换掉onCreateDialog(Bundle)方法,改用onCreateView(…)方法来创建DatePickerFragment的视图。以这种方式创建DialogFragment的话,在对话框界面上看不到标题区域,同样也没有放置按钮的空间。这需要你自行在dialog_date.xml布局中创建OK按钮。

有了DatePickerFragment视图,接下来就能以对话框或以在activity中内嵌的方式展现。第二步,创建SingleFragmentActivity子类。它的任务就是托管DatePickerFragment。

选择这种方式展现DatePickerFragment,就要使用startActivityForResult(…)方法回传日期给CrimeFragment。在DatePickerFragment中,如果目标fragment不存在,就调用托管activity的setResult(int, intent)方法回传日期给CrimeFragment。

最 后 , 修 改 CriminalIntent 应 用 : 如 果 是 手 机 设 备 , 就 以 全 屏 activity 的 方 式 展 现
DatePickerFragment;如果是平板设备,就以对话框的方式展现DatePickerFragment。想知道如何按设备屏幕大小优化应用,请提前学习第17章的相关内容

  1. 修改dialog_time.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    <?xml version="1.0" encoding="utf-8"?>
    <android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_gravity="center">

    <DatePicker
    android:id="@+id/dialog_date_picker"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginStart="16dp"
    android:layout_marginTop="16dp"
    android:layout_marginEnd="16dp"
    android:calendarViewShown="false"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent" />

    <Button
    android:id="@+id/tv_ok"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:layout_marginStart="32dp"
    android:layout_marginTop="16dp"
    android:layout_marginEnd="32dp"
    android:background="@color/design_default_color_primary"
    android:text="@string/tv_ok"
    android:textAlignment="center"
    android:textAppearance="@style/TextAppearance.AppCompat.Body1"
    android:textColor="@android:color/white"
    android:textSize="18sp"
    android:textStyle="bold"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintHorizontal_bias="0.0"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@+id/dialog_date_picker" />
    </android.support.constraint.ConstraintLayout>
  2. DatePickerFragment重写onCreateView方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
    View inflate = inflater.inflate(R.layout.dialog_date, container, false);
    mDatePicker = inflate.findViewById(R.id.dialog_date_picker);
    mButton = inflate.findViewById(R.id.tv_ok);
    mButton.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
    int year = mDatePicker.getYear();
    int month = mDatePicker.getMonth();
    int day = mDatePicker.getDayOfMonth();
    Date date = new GregorianCalendar(year, month, day).getTime();
    sendResult(Activity.RESULT_OK, date);
    }
    });
    Date date = (Date) getArguments().getSerializable(ARG_DATE);
    Calendar calendar = Calendar.getInstance();
    calendar.setTime(date);
    int year = calendar.get(Calendar.YEAR);
    int month = calendar.get(Calendar.MONTH);
    int day = calendar.get(Calendar.DAY_OF_MONTH);
    mDatePicker.init(year, month, day, null);
    return inflate;
    }
  3. 修改DatePickerFragment中的sendResult方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    private void sendResult(int resultCode, Date date) {
    Intent intent = new Intent();
    intent.putExtra(EXTRA_DATE, date);
    if (getTargetFragment() == null) {
    getActivity().setResult(resultCode, intent);
    getActivity().finish();
    return;
    }
    intent.putExtra(EXTRA_DATE, date);
    getTargetFragment().onActivityResult(getTargetRequestCode(), resultCode, intent);
    }
  1. 新建SingleFragmentActivity的子类DialogActivity:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public class DialogActivity extends SingleFragmentActivity {
    private static final String EXTRA_DATE = "com.suqir.android.criminalintent.date";
    private Crime mCrime;

    @Override
    protected Fragment createFragment() {
    UUID crimeId = (UUID) getIntent().getSerializableExtra(EXTRA_DATE);
    mCrime = CrimeLab.get(this).getCrime(crimeId);
    return DatePickerFragment.newInstance(mCrime.getDate());
    }

    public static Intent newIntent(Context pakageContext, UUID crimeId) {
    Intent intent = new Intent(pakageContext, DialogActivity.class);
    intent.putExtra(EXTRA_DATE, crimeId);
    return intent;
    }
    }
  2. 修改CrimeFragmentmDateButton的点击事件:

    1
    2
    3
    4
    5
    6
    7
    mDateButton.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
    Intent intent = DialogActivity.newIntent(getActivity(), mCrime.getId());
    startActivityForResult(intent, REQUEST_DATE);
    }
    });
  3. 运行效果:

3-11

第13章 工具栏

优秀的Android应用都注重工具栏设计。工具栏可放置菜单选项、提供应用导航,还能帮助统一设计风格、塑造品牌形象

AppCompat

CriminalIntent应用最低只支持API 19级,原生工具栏无法支持更老版本的系统。 不过, Google已将它移植到了AppCompat库。这样一来,老版本系统( API 9级、 Android 2.3以上)就都能使用Lollipop上的工具栏了

使用 AppCompat 库

如果想给老项目添加AppCompat库,该如何做呢?

  • 添加AppCompat依赖项;
    • 使用一种AppCompat主题;
  • 确保所有activity都是AppCompatActivity子类。
  1. 更新主题

    AppCompat库自带以下三种主题:

    • Theme.AppCompat:黑色主题
  • Theme.AppCompat.Light:浅色主题

    • Theme.AppCompat.Light.DarkActionBar:带黑色工具栏的浅色主题

      应用级别的主题设置在AndroidManifest.xml文件中进行。主题也可按activity配置。打开AndroidManifest.xml文件,查看application标签的android:theme属性:

      1
      2
      3
      4
      5
         <application
      ···
      android:theme="@style/AppTheme"
      ···
      </application>
AppTheme定义在res/values/styles.xml文件中。打开这个文件,参照代码清单13-2设置应用的主题:

1
2
3
4
5
6
7
8
9
   <resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
</resources>
  1. 使用AppCompatActivity

    最后一步是让activity类继承AppCompatActivity类

  2. 运行应用

    工具栏菜单

    工具栏菜单由菜单项(又称操作项)组成,它占据着工具栏的右上方区域。菜单项的操作应用于当前屏幕,甚至整个应用

    在 XML 文件中定义菜单

    菜单是一种类似于布局的资源。创建菜单定义文件并将其放置在res/menu目录下, Android会自动生成相应的资源ID。随后,在代码中实例化菜单时,就可以直接使用

    在项目工具窗口中,右键单击res目录,选择New → Android resource file菜单项。在弹出的窗口界面, 选择Menu资源类型,并命名资源文件为fragment_crime_list,点击OK按钮确认

    这里,菜单定义文件遵循了与布局文件一样的命名原则。这个文件和CrimeListFragment的布局文件同名,但分别位于不同的目录。修改内容如下:

    1
    2
    3
    4
    5
    6
    7
    8
       <menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <item
    android:id="@+id/new_crime"
    android:icon="@android:drawable/ic_menu_add"
    android:title="@string/new_crime"
    app:showAsAction="ifRoom|withText"/>
    </menu>

    showAsAction属性用于指定菜单项是显示在工具栏上,还是隐藏于溢出菜单( overflow menu)。该属性当前设置为ifRoom和withText的组合值。因此,只要空间足够,菜单项图标及其文字描述都会显示在工具栏上。如果空间仅够显示菜单项图标,文字描述就不会显示。如果空间大小不够显示任何项,菜单项就会隐藏到溢出菜单中。如果溢出菜单包含其他项,它们就会以三个点表示(位于工具栏最右端):

    13-1

    属性showAsAction还有另外两个可选值: always和never。不推荐使用always,应尽量使用ifRoom属性值,让操作系统决定如何显示菜单项。对于那些很少用到的菜单项, never属性值是个不错的选择。总之,为了避免用户界面混乱,工具栏上只应放置常用菜单项。

  3. app命名空间

    注意,不同于常见的android命名空间声明, fragment_crime_list.xml文件使用xmlns标签定义了全新的app命名空间。指定showAsAction属性时,就用了这个新定义的命名空间。

    出于兼容性考虑, AppCompat库需要使用app命名空间。操作栏API随Android 3.0引入。为了支持各种旧系统版本设备,早期创建的AppCompat库捆绑了兼容版操作栏。这样一来,不管新旧,所有设备都能用上操作栏。在运行Android 2.3或更早版本系统的设备上,菜单及其相应的XML文件确实是存在的,但是android:showAsAction属性是随着操作栏的发布才添加的。

    AppCompat库不希望使用原生showAsAction属性,因此,它提供了定制版showAsAction属
    性( app:showAsAction)。

  4. 使用Android Asset Studio

    应用使用的图标有两种: 系统图标和项目资源图标。 系统图标( system icon)是Android操作系统内置的图标。 android:icon属性值@android:drawable/ic_menu_add就引用了系统图标。

    在应用原型设计阶段,使用系统图标不会有什么问题;而在应用发布时,无论用户运行什么设备,最好能统一应用的界面风格。要知道,不同设备或操作系统版本间,系统图标的显示风格差异很大。有些设备的系统图标甚至与应用的整体风格完全不搭。

    一种解决方案是创建定制图标。这需要针对不同屏幕显示密度或各种可能的设备配置,准备不同版本的图标

    另一种解决方案是找到适合应用的系统图标,将它们直接复制到项目的drawable资源目录中

    还有第三个、也是最容易的解决方案:使用Android Studio内置的Android Asset Studio工具。你可以用它为工具栏创建或定制图片:

    在项目工具窗口中,右键单击drawable目录,选择New → Image Asset菜单项。弹出如图所示的Asset Studio窗口:

    13-2

    创建菜单

    在代码中, Activity类提供了管理菜单的回调函数。需要选项菜单时, Android会调用Activity的onCreateOptionsMenu(Menu)方法

    Fragment 有 一 套 自 己 的 选 项 菜 单 回 调 函 数

    以下为创建菜单和响应菜单项选择事件的两个回调方法:

    1
    2
       public void onCreateOptionsMenu(Menu menu, MenuInflater inflater)
    public boolean onOptionsItemSelected(MenuItem item)

    在CrimeListFragment.java中,覆盖onCreateOptionsMenu(Menu, MenuInflater)方法,实例化fragment_crime_list.xml中定义的菜单:

    1
    2
    3
    4
    5
       @Override
    public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
    super.onCreateOptionsMenu(menu, inflater);
    inflater.inflate(R.menu.fragment_crime_list, menu);
    }

    注意,我们也调用了超类的onCreateOptionsMenu(...)方法,也可以不调。调用该超类方法,任何超类定义的选项菜单功能在子类方法中都能获得应用

    Fragment.onCreateOptionsMenu(Menu, MenuInflater)方法是由FragmentManager负责调用的。因此,当activity接收到操作系统的onCreateOptionsMenu(...)方法回调请求时,我们必须明确告诉FragmentManager:其管理的fragment应接收onCreateOptionsMenu(...)方法的调用指令。要通知FragmentManager,需调用以下方法:

    1
    public void setHasOptionsMenu(boolean hasMenu)

    定 义CrimeListFragment.onCreate(Bundle)方 法 , 让 FragmentManager知 道CrimeListFragment需接收选项菜单方法回调:

    1
    2
    3
    4
    5
       @Override
    public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setHasOptionsMenu(true);
    }

    响应菜单项选择

    用户点击菜单中的菜单项时, fragment会收到onOptionsItemSelected(MenuItem)方法的回调请求。传入该方法的参数是一个描述用户选择的MenuItem实例

    菜单通常包含多个菜单项。通过检查菜单项ID,可确定被选中的是哪个菜单项,然后作出相应的响应。这个ID实际就是在菜单定义文件中赋予菜单项的资源ID

    注意, onOptionsItemSelected(MenuItem)方法返回的是布尔值。一旦完成菜单项事件处理,该方法应返回true值以表明任务已完成。另外,默认case表达式中,如果菜单项ID不存在,超类版本方法会被调用

    实现层级式导航

    目前为止, CriminalIntent应用主要靠后退键在应用内导航。后退键导航又称为临时性导航,只能返回到上一次浏览过的用户界面;而层级式导航( hierarchical navigation,有时又称为ancestral navigation)可在应用内逐级向上导航。有了层级式导航,用户可点击工具栏左边的向上按钮向上导航

    打 开 AndroidManifest.xml , 参 照 代 码 清 单 13-11 添 加 parentActivityName 属 性 , 开 启CriminalIntent应用的层级式导航:

    1
    2
    3
    4
       <activity
    android:name=".CrimePagerActivity"
    android:parentActivityName=".CrimeListActivity">
    </activity>

    运行应用并创建新的crime记录。在屏幕的左上方,可看到向上按钮。点击按钮可向上一级导航至CrimeListActivity用户界面

  • 层级式导航的工作原理

    后退键导航和向上按钮导航执行同样的操作。虽然结果一样,但它们各自的后台实现机制大不相同。知道这一点很重要,因为取决于具体应用,向上导航很可能会让用户迷失在众多activity中(这里指回退栈内的众多activity)

    用户点击向上按钮自CrimePagerActivity界面向上导航时,如下的intent会被创建:

    1
    2
    3
    4
       Intent intent = new Intent(this, CrimeListActivity.class);
    intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
    startActivity(intent);
    finish();

    FLAG_ACTIVITY_CLEAR_TOP指示Android在回退栈中寻找指定的activity实例。如果实例存在,则弹出栈内所有其他activity,让启动的目标activity出现在栈顶(显示在屏幕上):

    13-3

    可选菜单项

    添加一个菜单项来实现显示或隐藏CrimeListActivity工具栏的子标题(用来显示crime记录条数)。修改menu视图文件fragment_crime_list.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
       <menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <item
    android:id="@+id/new_crime"
    android:icon="@android:drawable/ic_menu_add"
    android:title="@string/new_crime"
    app:showAsAction="ifRoom|withText"/>
    <item
    android:id="@+id/show_subtitle"
    android:title="@string/show_subtitle"
    app:showAsAction="ifRoom"/>
    </menu>

    CrimeListFragment创建新方法updateSubtitle()

    1
    2
    3
    4
    5
    6
    7
       private void updateSubtitle() {
    CrimeLab crimeLab = CrimeLab.get(getActivity());
    int crimeCount = crimeLab.getCrimes().size();
    String subtitle = getString(R.string.subtitle_format, crimeCount);
    AppCompatActivity activity = (AppCompatActivity) getActivity();
    activity.getSupportActionBar().setSubtitle(subtitle);
    }

    getString(int resId, Object…formatArgs)方法接受字符串资源中占位符的替换值,updateSubtitle()用它生成子标题字符串。接着,托管CrimeListFragment的activity被强制类型转换为AppCompatActivity。既然CriminalIntent应用使用了AppCompat库,所有activity就都是AppCompatActivity的子类,自然也能访问工具栏。(由于兼容性问题,在AppCompat库中,工具栏在很多地方仍被称为操作栏。)

    在onOptionsItemSelected(…)方法中,调用updateSubtitle()方法响应新增菜单项的单击事件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
       @Override
    public boolean onOptionsItemSelected(MenuItem item) {
    switch (item.getItemId()) {
    case R.id.new_crime:
    ...
    return true;
    case R.id.show_subtitle:
    updateSubtitle();
    return true;
    default:
    return super.onOptionsItemSelected(item);
    }
    }

    切换菜单项标题

    调用onOptionsItemSelected(MenuItem)方法时,传入的参数是用户点击的MenuItem。虽然可以在这个方法里更新SHOW SUBTITLE菜单项的文字,但设备旋转并重建工具栏时,子标题的变化会丢失。

    比较好的解决方法是在onCreateOptionsMenu(...)方法内更新SHOW SUBTITLE菜单项,并在用户点击子标题菜单项时重建工具栏。对于用户选择菜单项或重建工具栏的场景,都可以使用这段菜单项更新代码

    首先新增跟踪记录子标题状态的成员变量:

    1
    private boolean mSubtitleVisible;

    接着,用户点击SHOW SUBTITLE菜单项时,在onCreateOptionsMenu(...)方法内更新子标题,同时重建菜单项

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
       @Override
    public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
    super.onCreateOptionsMenu(menu, inflater);
    inflater.inflate(R.menu.fragment_crime_list, menu);
    MenuItem subtitleItem = menu.findItem(R.id.show_subtitle);
    if (mSubtitleVisible) {
    subtitleItem.setTitle(R.string.hide_subtitle);
    } else {
    subtitleItem.setTitle(R.string.show_subtitle);
    }
    }
    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
    switch (item.getItemId()) {
    case R.id.new_crime:
    ...
    case R.id.show_subtitle:
    mSubtitleVisible = !mSubtitleVisible;
    getActivity().invalidateOptionsMenu();
    updateSubtitle();
    return true;
    default:
    return super.onOptionsItemSelected(item);
    }
    }

    最后,根据mSubtitleVisible变量值,联动菜单项标题与子标题:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
       private void updateSubtitle() {
    CrimeLab crimeLab = CrimeLab.get(getActivity());
    int crimeCount = crimeLab.getCrimes().size();
    String subtitle = getString(R.string.subtitle_format, crimeCount);
    if (!mSubtitleVisible) {
    subtitle = null;
    }
    AppCompatActivity activity = (AppCompatActivity) getActivity();
    activity.getSupportActionBar().setSubtitle(subtitle);
    }

    “还有个问题”

  1. 新建crime记录后,使用后退键回到CrimeListActivity界面,子标题显示的总记录数不会更新

    在返回CrimeListActivity界面时,再次刷新子标题显示就能解决这个问题,即在onResume方法里再次调用updateSubtitle方法。既然onResume方法和onCreateView方法会调用updateUI方法,那就在updateUI方法里直接调用updateSubtitle方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
       private void updateUI() {
    CrimeLab crimeLab = CrimeLab.get(getActivity());
    List<Crime> crimes = crimeLab.getCrimes();
    if (mAdapter == null) {
    mAdapter = new CrimeAdapter(crimes);
    mCrimeRecyclerView.setAdapter(mAdapter);
    } else {
    mAdapter.notifyDataSetChanged();
    }
    updateSubtitle();
    }

    运行CriminalIntent应用。显示子标题,然后新建crime记录并按后退键返回到CrimeListActivity界面。可以看到,工具栏显示的总记录数没问题了。

    但改用向上按钮返回时,子标题显示被重置了

    这是Android实现层级式导航带来的问题:导航回退到的目标activity会被完全重建。既然父activity是全新的,实例变量值以及保存的实例状态显然会彻底丢失

    一种方案是覆盖向上导航的机制,而实际开发的应用绝大多数都需要多层级导航

    另一种方案是在启动CrimePagerActivity时,把子标题状态作为extra信息传给它。然后,在CrimePagerActivity中覆盖getParentActivityIntent()方法,用附带了extra信息的intent重建CrimeListActivity。这需要CrimePagerActivity类知道父类工作机制的细节。

    上述两种方案都不够理想,但目前没有更好的方法

    1. 修改CrimePagerActivity,新增一个字段,修改newIntent方法,并覆盖getParentActivityIntent()方法:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
         public class CrimePagerActivity extends AppCompatActivity implements View.OnClickListener {
      public static final String EXTRA_SUBTITLE_STATUS = "com.suqir.android.criminalintent.subtitle_status";
      ···
      public static Intent newIntent(Context pakageContext, UUID crimeId, boolean subtitleStatus) {
      Intent intent = new Intent(pakageContext, CrimePagerActivity.class);
      intent.putExtra(EXTRA_CRIME_ID, crimeId);
      // 新增的
      intent.putExtra(EXTRA_SUBTITLE_STATUS, subtitleStatus);
      return intent;
      }
      ···
      @Nullable
      @Override
      public Intent getParentActivityIntent() {
      Intent parentActivityIntent = super.getParentActivityIntent();
      Boolean subtitleStatus = getIntent().getBooleanExtra(EXTRA_SUBTITLE_STATUS, false);
      parentActivityIntent.putExtra(EXTRA_SUBTITLE_STATUS, subtitleStatus);
      return parentActivityIntent;
      }
      }
    2. 修改CrimeListActivity中启动CrimePagerActivity的内容:

      1
      2
         Intent intent = CrimePagerActivity.newIntent(getActivity(), mCrime.getId(), mSubtitleVisible);
      startActivity(intent);
    3. 修改CrimeListActivity的onCreate()方法:

      1
      2
      3
      4
      5
      6
         @Override
      public void onCreate(@Nullable Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);
      mSubtitleVisible = getActivity().getIntent().getBooleanExtra(CrimePagerActivity.EXTRA_SUBTITLE_STATUS, false);
      setHasOptionsMenu(true);
      }
  1. 子标题显示后,旋转设备,显示的子标题会消失

    只要利用实例状态保存机制,保存mSubtitleVisible实例变量值就能解决问题:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
       public class CrimeListFragment extends Fragment {
    private static final String SAVED_SUBTITLE_VISIBLE = "subtitle";
    ...
    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
    Bundle savedInstanceState) {
    ...
    if (savedInstanceState != null) {
    mSubtitleVisible = savedInstanceState.getBoolean(SAVED_SUBTITLE_VISIBLE);
    }
    ···
    }
    ···
    @Override
    public void onSaveInstanceState(Bundle outState) {
    super.onSaveInstanceState(outState);
    outState.putBoolean(SAVED_SUBTITLE_VISIBLE, mSubtitleVisible);
    }
    }

    深入学习:工具栏与操作栏

    工具栏和操作栏究竟有什么区别呢?

    13-4

  • 工具栏界面更美观
    • 工具栏比操作栏更灵活
  • 工具栏还能支持内嵌视图和调整高度

    挑战练习:删除 crime 记录

    CriminalIntent应用目前不支持删除现有crime记录。请为CrimeFragment添加菜单项,允许
    用户删除当前crime记录。用户点击删除菜单项后,记得调用CrimeFragment托管活动的finish()
    方法回退到前一个activity界面。

  1. 新建菜单资源,在res/menu目录下添加fragment_crime.xml文件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
       <?xml version="1.0" encoding="utf-8"?>
    <menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <item
    android:id="@+id/delete_crime"
    android:icon="@drawable/ic_menu_delete"
    android:title="@string/delete_crime"
    app:showAsAction="ifRoom|withText"/>
    </menu>
  2. 在CrimeFragment中覆盖onCreateOptionsMenu(Menu, MenuInflater)方法,实例化fragment_crime.xml中定义的菜单:

    1
    2
    3
    4
    5
       @Override
    public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
    super.onCreateOptionsMenu(menu, inflater);
    inflater.inflate(R.menu.fragment_crime, menu);
    }
  3. 定义CrimeFragment.onCreate(Bundle) 方法, 调用setHasOptionsMenu方法让FragmentManager知道CrimeFragment需接收选项菜单方法回调:

    1
    2
    3
    4
    5
    6
       @Override
    public void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    ···
    setHasOptionsMenu(true);
    }
  4. 在CrimeLab.java中,新增一个removeCrime()方法:

    1
    2
    3
       public void removeCrime(Crime crime){
    mCrimes.remove(crime);
    }
  5. 在CrimeFragment.java中,实现onOptionsItemSelected(MenuItem)方法,以响应菜单项的选择事件,通过检查菜单项ID判断选的是哪个菜单项:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
       @Override
    public boolean onOptionsItemSelected(MenuItem item) {
    switch (item.getItemId()) {
    case R.id.delete_crime:
    CrimeLab.get(getActivity()).removeCrime(mCrime);
    getActivity().finish();
    return true;
    default:
    return super.onOptionsItemSelected(item);
    }
    }
  6. 运行效果:

    13-5

    挑战练习:复数字符串资源

    只有一条crime记录的时候,显示总记录数的子标题会显示: 1 crimes。单词crime仍用了复数形式。请改正这个粗心的语法错误。

    实现思路上,你可以在代码中准备不同的字符串资源并分情况使用,但这会给应用本地化制造麻烦。比较好的做法是使用复数字符串资源(又称为量化字符串)。

    首先,在strings.xml文件中定义复数字符串资源。

    1
    2
    3
    4
       <plurals name="subtitle_plural">
    <item quantity="one">%1$d crime</item>
    <item quantity="other">%1$d crimes</item>
    </plurals>

    然后,使用getQuantityString方法正确处理单复数问题。

    1
    2
       int crimeSize = crimeLab.getCrimes().size();
    String subtitle = getResources().getQuantityString(R.plurals.subtitle_plural, crimeSize, crimeSize);

    挑战练习:用于 RecyclerView 的空视图

    当前, CriminalIntent应用启动后,会显示一个空白列表。从用户体验上来讲,即使crime列表是空的,也应展示提示或解释类信息。

    请设置空视图并展示类似“没有crime记录可以显示”的信息。再添加一个按钮,方便用户直接创建新的crime记录。

    判断crime列表是否包含数据,然后使用任何类都有的setVisibility方法控制占位视图的显示。

  7. 修改fragment_crime_list.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
       <?xml version="1.0" encoding="utf-8"?>
    <android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <android.support.v7.widget.RecyclerView
    android:id="@+id/crime_recycler_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:layout_editor_absoluteX="0dp"
    tools:layout_editor_absoluteY="0dp">

    </android.support.v7.widget.RecyclerView>

    <TextView
    android:id="@+id/tv_no_crime"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="@string/no_crime"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent" />

    <Button
    android:id="@+id/btn_add_crime"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginTop="16dp"
    android:text="@string/new_crime"
    app:layout_constraintEnd_toEndOf="@+id/tv_no_crime"
    app:layout_constraintStart_toStartOf="@+id/tv_no_crime"
    app:layout_constraintTop_toBottomOf="@+id/tv_no_crime" />
    </android.support.constraint.ConstraintLayout>
  8. string.xml里新增

    1
    <string name="no_crime" translatable="true">没有Crime记录可以显示</string>
  9. 修改CrimeListFragment.java

    增加两个变量

    1
    2
       private TextView mNoCrimeTv;
    private Button mNoCrimeBtn;

    修改onCreateView方法为上面两个变量绑定id组件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
       NoCrimeTv = v.findViewById(R.id.tv_no_crime);
    mNoCrimeBtn = v.findViewById(R.id.btn_add_crime);
    mNoCrimeBtn.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
    Crime crime = new Crime();
    CrimeLab.get(getActivity()).addCrime(crime);
    Intent intent = CrimePagerActivity.newIntent(getActivity(), crime.getId(), mSubtitleVisible);
    startActivity(intent);
    }
    });

    修改CrimeListFragment.java的updateUI方法,判断crime为空时textview和button控件可见

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
       private void updateUI() {
    ···
    if (crimes.size() != 0){
    mNoCrimeTv.setVisibility(View.GONE);
    mNoCrimeBtn.setVisibility(View.GONE);
    } else {
    mNoCrimeTv.setVisibility(View.VISIBLE);
    mNoCrimeBtn.setVisibility(View.VISIBLE);
    }
    }
  10. 运行效果

|                 Crime记录为空                 |                添加Crime记录后                |

| :——————————————-: | :——————————————-: |
| 13-6 | 13-7 |

第14章 SQLite数据库

Android设备上的应用都有一个沙盒目录。将文件保存在沙盒中,可阻止其他应用甚至是设备用户的访问和窥探。(当然,如果设备被root了的话,用户就可以为所欲为。)

应用的沙盒目录是/data/data/[应用的包名称]。例如, CriminalIntent应用的沙盒目录是/data/data/com.bignerdranch.android.criminalintent

需要保存大量数据时,大多数应用都不会使用类似txt这样的普通文件。原因很简单:假设将crime记录写入了这样的文件,在仅需要修改crime标题时,就得首先读取整个文件的内容,完成修改后再全部保存。数据量大的话,这将非常耗时

SQLite是类似于MySQL和PostgreSQL的开源关系型数据库。不同于其他数据库的是, SQLite使用单个文件存储数据,读写数据时使用SQLite库。 Android标准库包含SQLite库以及配套的一些Java辅助类

本章仅学习如何运用SQLite基本辅助类,打开应用沙盒中的数据库,读取或写入数据

定义 schema

创建数据库前,首先要清楚存储什么样的数据。 CriminalIntent应用要保存的是一条条crime记录,这需要定义如图14-1所示的crimes数据表

14-1

多花时间思考复用代码的编写和调用,避免在应用中到处使用重复代码

首先创建定义schema的Java类。再定义一个描述数据表的CrimeTable内部类:

1
2
3
4
5
public class CrimeDbSchema {
public static final class CrimeTable {
public static final String NAME = "crimes";
}
}

接下来定义数据表字段:

1
2
3
4
5
6
7
8
9
10
11
public class CrimeDbSchema {
public static final class CrimeTable {
public static final String NAME = "crimes";
public static final class Cols {
public static final String UUID = "uuid";
public static final String TITLE = "title";
public static final String DATE = "date";
public static final String SOLVED = "solved";
}
}
}

有了这些数据表元素,就可以在Java代码中安全地引用了。例如, CrimeTable.Cols.TITLE就是指crime记录的title字段。此外,这还给修改字段名称或新增表元素带来了方便

创建初始数据库

openOrCreateDatabase(…)和databaseList()是Android提供的Context底层方法,用来打开数据库文件并将其转换为SQLiteDatabase实例

实践中,建议总是遵循以下步骤:

  1. 确认目标数据库是否存在
  2. 如果不存在,首先创建数据库,然后创建数据表并初始化数据
  3. 如果存在,打开并确认CrimeDbSchema是否为最新( CriminalIntent后续版本可能有改动)
  4. 如果是旧版本,就先升级到最新版本

以上工作可借助Android的SQLiteOpenHelper类处理。 在数据库包中创建CrimeBaseHelper类:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class CrimeBaseHelper extends SQLiteOpenHelper {
private static final int VERSION = 1;
private static final String DATABASE_NAME = "crimeBase.db";
public CrimeBaseHelper(Context context) {
super(context, DATABASE_NAME, null, VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
}
}

有了SQLiteOpenHelper类,打开SQLiteDatabase的繁杂工作就简单多了。在CrimeLab中用它创建crime数据库:

1
2
3
4
5
6
7
8
9
10
11
12
public class CrimeLab {
private static CrimeLab sCrimeLab;
private List<Crime> mCrimes;
private Context mContext;
private SQLiteDatabase mDatabase;
...
private CrimeLab(Context context) {
mContext = context.getApplicationContext();
mDatabase = new CrimeBaseHelper(mContext).getWritableDatabase();
mCrimes = new ArrayList<>();
}
}

调用getWritableDatabase()方法时, CrimeBaseHelper会做如下工作:

  1. 打开/data/data/com.bignerdranch.android.criminalintent/databases/crimeBase.db数据库;如果不存在,就先创建crimeBase.db数据库文件
  2. 如果是首次创建数据库,就调用onCreate(SQLiteDatabase)方法,然后保存最新的版本号
  3. 如果已创建过数据库,首先检查它的版本号。如果CrimeBaseHelper中的版本号更高,就调用onUpgrade(SQLiteDatabase, int, int)方法升级

总结: onCreate(SQLiteDatabase)方法负责创建初始数据库; onUpgrade(SQLiteDatabase, int, int)方法负责与升级相关的工作

在CrimeBaseHelper的onCreate(…)方法中创建数据表。这需要导入CrimeDbSchema类的CrimeTable内部类:

1
2
3
4
5
6
7
8
9
10
11
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL("create table " + CrimeTable.NAME + "(" +
" _id integer primary key autoincrement, " +
CrimeTable.Cols.UUID + ", " +
CrimeTable.Cols.TITLE + ", " +
CrimeTable.Cols.DATE + ", " +
CrimeTable.Cols.SOLVED +
")"
);
}

使用 Android Device Monitor 查看文件

选择Tools → Android → Android Device Monitor菜单项,如果看到要求禁用ADB整合的对话框,请选Yes

等Android Device Monitor窗口弹出后,选择File Explorer选项页。浏览至/data/data/com.bignerdranch.android.criminalintent/databases/目录,即可看到CriminalIntent刚创建的数据库文件

(禁用ADB整合后,如果运行应用,你会看到Instant Run requires ‘Tools | Android | Enable ADB integration’ to be enabled这样的错误提示。要解决这个问题,请选择Android Studio → Preferences菜单项,在弹出的首选项界面的左上角输入“Instant Run”搜索,然后,去除Enable Instant Run to hot swap code/resource changes on deploy (default enabled)选项,点选Apply按钮应用并点OK按钮完成。)

处理数据库相关问题

编写SQLite数据库操作代码时,经常会碰到要调整数据库表结构的情况。处理时,比较常规的做法是,在SQLiteOpenHelper中记录版本号,然后在onUpgrade(…)方法中升级数据表。这种常规方法涉及不少代码量。而且,编写和维护很少更新版本的代码也很伤脑筋。所以,实践中,最好的做法是直接删除数据库文件,再让SQLiteOpenHelper.onCreate(…)方法重建

修改 CrimeLab 类

创建完数据库,接下来是调整CrimeLab类代码,改用mDatabase存储数据。首先要删除CrimeLab中所有与mCrimes相关的代码。

写入数据库

使用 ContentValues

负责处理数据库写入和更新操作的辅助类是ContentValues,它是一个键值存储类。ContentValues只能用于处理SQLite数据

将Crime记录转换为ContentValues,实际就是在CrimeLab中创建ContentValues实例。这需要新建一个私有方法:

1
2
3
4
5
6
7
8
private static ContentValues getContentValues(Crime crime) {
ContentValues values = new ContentValues();
values.put(CrimeTable.Cols.UUID, crime.getId().toString());
values.put(CrimeTable.Cols.TITLE, crime.getTitle());
values.put(CrimeTable.Cols.DATE, crime.getDate().getTime());
values.put(CrimeTable.Cols.SOLVED, crime.isSolved() ? 1 : 0);
return values;
}

ContentValues的键就是数据表字段

插入和更新记录

向数据库写入数据。在addCrime(Crime) 方法中新增数据插入实现代码:

1
2
3
4
public void addCrime(Crime c) {
ContentValues values = getContentValues(c);
mDatabase.insert(CrimeTable.NAME, null, values);
}

第一个参数是数据表名( CrimeTable.NAME);第二个很少用到;第三个是要写入的数据

第二个参数称为nullColumnHack。它有什么用途呢?

假设你想调用insert(…)方法,但传入了ContentValues空值。这时, SQLite不干了, insert(…)方法调用只能以失败告终。然而,如果能以uuid值作为nullColumnHack传入的话, SQLite就可以忽略ContentValues空值,而且还会自动传入一个带uuid且值为null的ContentValues。结果, insert(…)方法得以成功调用并插入了一条新记录

完成了数据插入,下面继续使用ContentValues,新增数据库记录更新方法:

1
2
3
4
5
6
7
public void updateCrime(Crime crime) {
String uuidString = crime.getId().toString();
ContentValues values = getContentValues(crime);
mDatabase.update(CrimeTable.NAME, values,
CrimeTable.Cols.UUID + " = ?",
new String[] { uuidString });
}

update(String, ContentValues, String, String[])方法类似于insert(…)方法,向其传入要更新的数据表名和为表记录准备的ContentValues。然而,与insert(…)方法不同的是,你要确定该更新哪些记录。具体的做法是: 创建where子句(第三个参数),然后指定where子句中的参数值( String[]数组参数)

使用?的话,就不用关心String包含什么,代码执行的效果肯定就是我们想要的,防止SQL注入

用户可能会在CrimeFragment中修改Crime实例。修改完成后,你需要刷新CrimeLab中的Crime数据。这可以通过覆盖CrimeFragment.onPause()方法完成:

1
2
3
4
5
@Override
public void onPause() {
super.onPause();
CrimeLab.get(getActivity()).updateCrime(mCrime);
}

读取数据库

读取数据需要用到SQLiteDatabase.query(…)方法。我们使用:

1
2
3
4
5
6
7
8
9
public Cursor query(
String table,
String[] columns,
String where,
String[] whereArgs,
String groupBy,
String having,
String orderBy,
String limit)

参数table是要查询的数据表。参数columns指定要依次获取哪些字段的值。参数where和whereArgs的作用与update(…)方法中的一样

新增一个便利方法,调用query(…)方法查询CrimeTable中的记录

1
2
3
4
5
6
7
8
9
10
11
12
private Cursor queryCrimes(String whereClause, String[] whereArgs) {
Cursor cursor = mDatabase.query(
CrimeTable.NAME,
null, // Columns - null selects all columns
whereClause,
whereArgs,
null, // groupBy
null, // having
null // orderBy
);
return cursor;
}

使用 CursorWrapper

Cursor是个神奇的表数据处理工具。其功能就是封装数据表中的原始字段值

创建Cursor子类最简单的方式是使用CursorWrapper。顾名思义,你可以用CursorWrapper封装Cursor的对象,然后再添加有用的扩展方法

在数据库包中新建CrimeCursorWrapper类:

1
2
3
4
5
public class CrimeCursorWrapper extends CursorWrapper {
public CrimeCursorWrapper(Cursor cursor) {
super(cursor);
}
}

这样封装的目的就是定制新方法,以方便操作内部Cursor。新增获取相关字段值的getCrime()方法:

1
2
3
4
5
6
7
public Crime getCrime() {
String uuidString = getString(getColumnIndex(CrimeTable.Cols.UUID));
String title = getString(getColumnIndex(CrimeTable.Cols.TITLE));
long date = getLong(getColumnIndex(CrimeTable.Cols.DATE));
int isSolved = getInt(getColumnIndex(CrimeTable.Cols.SOLVED));
return null;
}

我们需要返回具有UUID的Crime。在Crime.java中新增一个有此用途的构造方法:

1
2
3
4
5
6
7
public Crime() {
this(UUID.randomUUID());
}
public Crime(UUID id) {
mId = id;
mDate = new Date();
}

最后,完成getCrime()方法:

1
2
3
4
5
6
7
8
9
10
11
public Crime getCrime() {
String uuidString = getString(getColumnIndex(CrimeTable.Cols.UUID));
String title = getString(getColumnIndex(CrimeTable.Cols.TITLE));
long date = getLong(getColumnIndex(CrimeTable.Cols.DATE));
int isSolved = getInt(getColumnIndex(CrimeTable.Cols.SOLVED));
Crime crime = new Crime(UUID.fromString(uuidString));
crime.setTitle(title);
crime.setDate(new Date(date));
crime.setSolved(isSolved != 0);
return crime;
}

创建模型层对象

使用CrimeCursorWrapper类,可直接从CrimeLab中取得List。大致思路无外乎将查询返回的cursor封装到CrimeCursorWrapper类中,然后调用getCrime()方法遍历取出Crime

首先,让queryCrimes(…)方法返回CrimeCursorWrapper对象:

private Cursor queryCrimes(String whereClause, String[] whereArgs) {

1
2
3
4
5
6
7
8
9
10
11
12
private CrimeCursorWrapper queryCrimes(String whereClause, String[] whereArgs) {
Cursor cursor = mDatabase.query(
CrimeTable.NAME,
null, // Columns - null selects all columns
whereClause,
whereArgs,
null, // groupBy
null, // having
null // orderBy
);
return new CrimeCursorWrapper(cursor);
}

然后,完善getCrime()方法:遍历查询取出所有的crime,返回Crime数组对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public List<Crime> getCrimes() {
List<Crime> crimes = new ArrayList<>();
CrimeCursorWrapper cursor = queryCrimes(null, null);
try {
cursor.moveToFirst();
while (!cursor.isAfterLast()) {
crimes.add(cursor.getCrime());
cursor.moveToNext();
}
} finally {
cursor.close();
}
return crimes;
}

数据库cursor之所以被称为cursor,是因为它内部就像有根手指似的,总是指向查询的某个地方。要从cursor中取出数据,首先要调用moveToFirst()方法移动虚拟手指指向第一个元素。读取行记录后,再调用moveToNext()方法,读取下一行记录,直到isAfterLast()说没有数据可取为止

最后,别忘了调用Cursor的close()方法关闭它,否则会出错:轻则应用报错,重则应用崩溃

CrimeLab.getCrime(UUID)方法类似于getCrimes()方法,唯一区别就是,它只需要取出已存在的首条记录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Crime getCrime(UUID id) {
CrimeCursorWrapper cursor = queryCrimes(
CrimeTable.Cols.UUID + " = ?",
new String[] { id.toString() }
);
try {
if (cursor.getCount() == 0) {
return null;
}
cursor.moveToFirst();
return cursor.getCrime();
} finally {
cursor.close();
}
}

刷新模型层数据

虽然Crime记录已存入数据库,但数据读取还未完善。例如,编辑完新的crime后,尝试点击后退键,你会发现CrimeListActivity并没有相应刷新

现在,getCrimes()方法返回的List是Crime对象的快照。要刷新CrimeListActivity界面,首先要更新这个快照。

要刷新crime显示,首先添加一个setCrimes(List)方法给CrimeAdapter:

1
2
3
4
5
6
7
8
9
10
private class CrimeAdapter extends RecyclerView.Adapter<CrimeHolder> {
...
@Override
public int getItemCount() {
return mCrimes.size();
}
public void setCrimes(List<Crime> crimes) {
mCrimes = crimes;
}
}

然后在updateUI()方法中调用setCrimes(List)方法:

1
2
3
4
5
6
7
8
9
10
11
12
private void updateUI() {
CrimeLab crimeLab = CrimeLab.get(getActivity());
List<Crime> crimes = crimeLab.getCrimes();
if (mAdapter == null) {
mAdapter = new CrimeAdapter(crimes);
mCrimeRecyclerView.setAdapter(mAdapter);
} else {
mAdapter.setCrimes(crimes);
mAdapter.notifyDataSetChanged();
}
updateSubtitle();
}

深入学习:应用上下文

只要有activity在, Android肯定也创建了application对象。用户在应用的不同界面间导航时,各个activity时而存在时而消亡,但application对象不会受任何影响。可以说,它的生命周期要比任何activity都长

挑战练习:删除 crime 记录

如果为应用添加过Delete Crime菜单项的话,就可以直接调用CrimeLab的deleteCrime-(Crime)方法,继而调用mDatabase.delete(…)方法来实现删除功能。

如果还没有,那就先给CrimeFragment的工具栏添加一个Delete Crime菜单项,然后调用CrimeLab.deleteCrime(Crime)方法实现删除功能。

  1. 修改CrimeLab.java中的removeCrime()方法:

    1
    2
    3
    4
    5
    6
    public void removeCrime(Crime crime){
    String uuidString = crime.getId().toString();
    mDatabase.delete(CrimeTable.NAME,
    CrimeTable.Cols.UUID + " = ?",
    new String[]{uuidString});
    }

第15章 隐式Intent

在Android系统中,可利用隐式intent启动其他应用的activity。在显式intent中,我们指定要启动的activity类,操作系统会负责启动它。在隐式intent中,我们只要描述要完成的任务,操作系统就会找到合适的应用,并在其中启动相应的activity。

使用格式化字符串

1
<string name="crime_report">%1$s! The crime was discovered on %2$s. %3$s, and %4$s</string>

%1$s、 %2$s等特殊字符串即为占位符,它们接受字符串参数。在代码中,我们将调用getString(…)方法,并传入格式化字符串资源ID以及另外四个字符串参数(与要替换的占位符顺序一致)。

使用隐式 intent

Intent对象用来向操作系统说明需要处理的任务。而使用隐式intent时,只需告诉操作系统你想要做什么,操作系统就会去启动能够胜任工作任务的activity。如果找到多个符合的activity,用户会看到一个可选应用列表,然后就看用户如何选择了。

隐式 intent 的组成

  1. 要执行的操作

    通常以Intent类中的常量来表示。例如,要访问某个URL,可以使用Intent.ACTION_VIEW;要发送邮件,可以使用Intent.ACTION_SEND。

  2. 待访问数据的位置

    这可能是设备以外的资源,如某个网页的URL,也可能是指向某个文件的URI,或者是指向ContentProvider中某条记录的某个内容URI( content URI)。

  3. 操作涉及的数据类型

    这指的是MIME形式的数据类型,如text/html或audio/mpeg3。如果一个intent包含数据位置,那么通常可以从中推测出数据的类型。

  4. 可选类别

    操作用于描述具体要做什么,而类别通常用来描述你打算何时、 何地或者如何使用某个activity。例如, Android的android.intent.category.LAUNCHER类别表明, activity应该显示在顶级应用启动器中;而android.intent.category.INFO类别表明,虽然activity向用户显示了包信息,但它不应该出现在启动器中。

    一个查看某个网址的简单隐式intent会包括一个Intent.ACTION_VIEW操作,以及某个具体URL网址的Uri数据。

通过配置文件中的intent过滤器设置, activity会对外宣称自己是适合处理ACTION_VIEW的activity。例如,如果想开发一款浏览器应用,为了响应ACTION_VIEW操作,你会在activity声明中包含以下intent过滤器:

1
2
3
4
5
6
7
8
9
<activity
android:name=".BrowserActivity"
android:label="@string/app_name" >
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="http" android:host="www.bignerdranch.com" />
</intent-filter>
</activity>

必须在intent过滤器中明确地设置DEFAULT类别。 action元素告诉操作系统, activity能够胜任指定任务。 DEFAULT类别告诉操作系统(问谁可以做时), activity愿意处理某项任务。 DEFAULT类别实际隐含于所有隐式intent中。(当然也有例外,详见第24章。)

注意,显式intent也可以使用隐式intent的操作和数据部分。这相当于要求特定的activity去做特定的事。

获取联系人信息

操作为Intent.ACTION_PICK。 联系人数据获取位置为ContactsContract.Contacts.CONTENT_URI。 简而言之, 就是请Android帮忙从联系人数据库里获取某个具体的联系人。

  1. 从联系人列表中获取联系人数据

    很多应用都会共享联系人信息,因此Android提供了一个深度定制的API用于处理联系人信息: ContentProvider类。该类的实例封装了联系人数据库并提供给其他应用使用。我们可以通过ContentResolver访问ContentProvider。

  2. 联系人信息使用权限

    如何获得读取联系人数据库的权限呢?实际上,这是联系人应用将其权限临时赋予了我们。联系人应用有使用联系人数据库的全部权限。联系人应用返回包含在intent中的URI数据给父activity时,会添加一个Intent.FLAG_GRANT_READ_URI_PERMISSION标志。该标志告诉Android,CriminalIntent应用中的父activity可以使用联系人数据一次。这很有用,因为不需要访问整个联系人数据库,我们只需要访问其中的一条联系人信息。

检查可响应任务的 activity

有些设备上根本就没有联系人应用。如果操作系统找不到匹配的activity,应用就会崩溃。

解决办法是首先通过操作系统中的PackageManager类进行自检。在onCreateView(…)方法中实现检查:

1
2
3
4
PackageManager packageManager = getActivity().getPackageManager();
if (packageManager.resolveActivity(pickContact, PackageManager.MATCH_DEFAULT_ONLY) == null){
mSuspectButton.setEnabled(false);
}

Android设备上安装了哪些组件以及包括哪些activity, PackageManager类全都知道。调用resolveActivity(Intent, int)方法,可以找到匹配给定Intent任务的activity。 flag标志MATCH_DEFAULT_ONLY限定只搜索带CATEGORY_DEFAULT标志的activity。这和startActivity(Intent)方法类似。

如果搜到目标,它会返回ResolveInfo告诉我们找到了哪个activity。如果找不到的话,必须禁用嫌疑人按钮,否则应用就会崩溃。

挑战练习: ShareCompat

这个练习比较简单。 Android支持库有个叫作ShareCompat的 类,它有一个内部类叫IntentBuilder。使用这个内部类创建发送消息的Intent略微方便一些。

因此,请在mReportButton的监听器中,改用ShareCompat.IntentBuilder来创建你的Intent。

1
2
3
4
5
6
7
8
9
/**
* 挑战练习
*/
ShareCompat.IntentBuilder.from(getActivity())
.setType("text/plain")
.setText(getCrimeReport(mCrime))
.setSubject(getString(R.string.crime_report_subject))
.setChooserTitle(R.string.send_report)
.startChooser();

挑战练习:又一个隐式 intent

相较于发送消息,愤怒的用户可能更倾向于直接责问陋习嫌疑人。新增一个按钮,允许用户直接拨打陋习嫌疑人的电话。

要完成这个挑战,首先需要联系人数据库中的手机号码。这需要查询ContactsContract数据库中的CommonDataKinds.Phone表。如何查询,请查看它们的参考文档。

小提示:你应该使用android.permission.READ_CONTACTS权限。利用这个权限,可以查询到ContactsContract.Contacts._ID。然后再使用联系人ID查询CommonDataKinds.Phone表。

搞定了电话号码,就可以使用电话URI创建一个隐式intent:

Uri number = Uri.parse(“tel:5551234”)

与打电话相关的Intent操作有两种: Intent.ACTION_DIAL和Intent.ACTION_CALL。ACTION_CALL直接调出手机应用并拨打来自intent的电话号码;而ACTION_DIAL则拨好电话号码,然后等用户发起通话。

推荐使用ACTION_DIAL操作。这样的话,用户就有了冷静下来改变主意的机会。这种贴心的设计应该会受到欢迎的。

  1. 在AndroidManifest.xml文件里面添加android.permission.READ_CONTACTS 允许程序读取用户联系人数据:

    1
    <uses-permission android:name="android.permission.READ_CONTACTS"/>
  2. 在fragment_crime.xml里面添加一个新的按钮用于打电话:

    1
    2
    3
    4
    5
    <Button
    android:id="@+id/btn_call"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="@string/call_crimer" />
  3. 为了持久性存储电话号码,在crime类里面添加新属性以及getter方法和setter方法便于电话号码的保存和赋值:

    1
    2
    3
    4
    5
    6
    7
    private String phone;
    public String getPhone() {
    return phone;
    }
    public void setPhone(String phone) {
    this.phone = phone;
    }
  4. 在CrimeDbSchema里面的内部类Cols里面添加新的字段表示电话:

    1
    public static final String PHONE = "phone";
  5. 在CrimeBaseHelper的onCreate创建表时添加创建新的列表示电话(记得把模拟器或者手机里面旧的应用卸载了再重新运行安装,不然会报错,旧的表没有新添加的列):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @Override
    public void onCreate(SQLiteDatabase db) {
    db.execSQL("create table " + CrimeDbSchema.CrimeTable.NAME + "(" +
    "_id integer primary key autoincrement, " + CrimeTable.Cols.UUID + ", " +
    CrimeTable.Cols.TITLE + ", " +
    CrimeTable.Cols.DATE + ", " +
    CrimeTable.Cols.SOLVED + ", " +
    CrimeTable.Cols.SUSPECT + ", " +
    CrimeTable.Cols.PHONE + ")"
    );
    }
  6. 修改CrimeCursorWrapper类中的getCrime()方法,新增:

    1
    2
    String phone = getString(getColumnIndex(CrimeTable.Cols.PHONE));
    crime.setPhone(phone);
  7. 修改CrimeLab类中的getContentValues()方法,新增:

    1
    contentValues.put(CrimeTable.Cols.PHONE, crime.getPhone());
  8. 在CrimeFragment里面添加全局变量打电话按钮:

    1
    private Button mCallButton;
  9. 在CrimeFragment的onCreateView方法里面数据绑定按钮并且添加打电话按钮的点击事件和添加一个if判断当电话不为空时赋值给打电话按钮的文本属性:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    mCallButton = v.findViewById(R.id.btn_call);
    mCallButton.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
    Intent intent = new Intent(Intent.ACTION_DIAL);
    Uri phone = Uri.parse("tel:" + mCrime.getPhone());
    intent.setData(phone);
    startActivity(intent);
    }
    });
    if (mCrime.getPhone() != null){
    mCallButton.setText(mCrime.getPhone());
    }
  10. 在CrimeFragment里面修改onActivityResult()方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data) {
    ···
    else if (requestCode == REQUEST_CONTACT && data != null){
    Uri contactUri = data.getData();
    // 新增ContactsContract.Contacts._ID目的是为了得到目标联系人ID
    String[] queryFileds = new String[]{
    ContactsContract.Contacts.DISPLAY_NAME,
    ContactsContract.Contacts._ID
    };
    ···
    try {
    ···
    /**
    * 挑战练习
    */
    String contactId = c.getString(1);
    // 查询通讯录数据库
    Cursor phone = getActivity().getContentResolver().query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
    null,
    ContactsContract.CommonDataKinds.Phone.CONTACT_ID +" = ?",
    new String[]{contactId},
    null);
    if (phone.moveToNext()){
    String p = phone.getString(phone.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER));
    mCrime.setPhone(p);
    mCallButton.setText(p);
    }
    } finally {
    c.close();

    }

    }
    }

第16章 使用intent拍照

文件存储

Context类提供的基本文件和目录处理方法如下:

  • File getFilesDir()

    获取/data/data/<包名>/files目录

  • FileInputStream openFileInput(String name)

    打开现有文件进行读取

  • FileOutputStream openFileOutput(String name, int mode)

    打开文件进行写入,如果不存在就创建它

  • File getDir(String name, int mode)

    获取/data/data/<包名>/目录的子目录(如果不存在就先创建它)

  • String[] fileList()

    获取主文件目录下的文件列表。可与其他方法配合使用,如openFileInput(String)

  • File getCacheDir()

    获取/data/data/<包名>/cache目录。应注意及时清理该目录,并节约使用

如果存储的文件仅供应用内部使用,使用上述各类方法就够了

如果想共享文件给其他应用,或是接收其他应用的文件(如相机应用拍摄的照片),可以通过ContentProvider把要共享的文件暴露出来。 ContentProvider允许你暴露内容URI给其他应用。这样,这些应用就可以从内容URI下载或向其中写入文件。

使用 FileProvider

如果只想从其他应用接收一个文件,Google提供了一个名叫FileProvider的便利类。这个类能帮你搞定一切,而你只要做做参数配置就行了

  1. 首先, 声明FileProvider为ContentProvider,并给予一个指定的权限:

    1
    2
    3
    4
    5
    6
    <provider
    android:name="android.support.v4.content.FileProvider"
    android:authorities="com.bignerdranch.android.criminalintent.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true">
    </provider>

    这里的权限是指一个位置:文件保存地。把FileProvider和你指定的位置关联起来,就相当于你给发出请求的其他应用一个目标地。添加exported = “false”属性就意味着,除了你自己以及你给予授权的人,其他任何人都不允许使用你的FileProvider。而grantUriPermissions属性用来给其他应用授权,允许它们向你指定位置的URI写入文件

  2. 既然已让Android知道FileProvider在哪,还需要配置FileProvider,让它知道该暴露哪些文件。这个配置用另外一个XML资源文件处理(res/xml/files.xml):

    1
    2
    3
    <paths>
    <files-path name="crime_photos" path="."/>
    </paths>

    这是个描述性XML文件,其表达的意思是,把私有存储空间的根路径映射为crime_photos。这个名字仅供FileProvider内部使用

  3. 最后,在AndroidManifest.xml文件中,添加一个meta-data标签,让FileProvider能找到files.xml文件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <provider
    android:name="android.support.v4.content.FileProvider"
    android:authorities="com.bignerdranch.android.criminalintent.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
    android:name="android.support.FILE_PROVIDER_PATHS"
    android:resource="@xml/files"/>
    </provider>

使用相机 intent

触发拍照

我们需要的intent操作是定义在MediaStore类中的ACTION_IMAGE_CAPTURE。 MediaStore类定义了一些公共接口,可用于处理图像、视频以及音乐这些常见的多媒体任务。当然,这也包括触发相机应用的拍照intent

ACTION_IMAGE_CAPTURE打开相机应用,默认只能拍摄缩略图这样的低分辨率照片,而且照片会保存在onActivityResult(...)返回的Intent对象里

要 想 获 得 全 尺 寸 照 片 , 就 要 让 它 使 用 文 件 系 统 存 储 照 片 。 这 可 以 通 过 传 入 保 存 在MediaStore.EXTRA_OUTPUT中的指向存储路径的Uri来完成。这个Uri会指向FileProvider提供的位置

编写用于拍照的隐式intent:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
final Intent captureImage = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
boolean canTakePhoto = mPhotoFile != null && captureImage.resolveActivity(packageManager) != null;
mPhotoButton.setEnabled(canTakePhoto);
mPhotoButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Uri uri = FileProvider.getUriForFile(getActivity(),"com.bignerdranch.android.criminalintent.fileprovider", mPhotoFile);
captureImage.putExtra(MediaStore.EXTRA_OUTPUT, uri);
List<ResolveInfo> cameraActivities = getActivity().getPackageManager().queryIntentActivities(captureImage,PackageManager.MATCH_DEFAULT_ONLY);
for (ResolveInfo activity : cameraActivities) {
getActivity().grantUriPermission(activity.activityInfo.packageName,
uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
}
startActivityForResult(captureImage, REQUEST_PHOTO);
}
});

调用FileProvider.getUriForFile(...)会把本地文件路径转换为相机能看见的Uri形式。要实际写入文件,还需要给相机应用权限。为了授权,我们授予FLAG_GRANT_WRITE_URI_PERMISSION给所有cameraImage intent的目标activity,以此允许它们在Uri指定的位置写文件。当然,还有个前提条件: 在声明FileProvider的时候添加过android:grantUriPermissions属性

缩放和显示位图

现在,终于可以拍摄陋习现场的照片并保存了

有了照片,接下来就是找到并加载它,然后展示给用户看。在技术实现上,这需要加载照片到大小合适的Bitmap对象中。Bitmap是个简单对象,它只存储实际像素数据。也就是说,即使原始照片已压缩过,但存入Bitmap对象时,文件并不会同样压缩。因此,一张1600万像素24位的相机照片(存为JPG格式大约5MB),一旦载入Bitmap对象,就会立即膨胀至48MB!

这个问题可以设法解决,但需要手动缩放位图照片。具体做法就是,首先确认文件到底有多大,然后考虑按照给定区域大小合理缩放文件。最后,重新读取缩放后的文件,创建Bitmap对象

创建名为PictureUtils的新类,并在其中添加getScaledBitmap(String, int, int)缩放方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class PictureUtils {
public static Bitmap getScaledBitmap(String path, int destWidth, int destHeight) {
// Read in the dimensions of the image on disk
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFile(path, options);
float srcWidth = options.outWidth;
float srcHeight = options.outHeight;
// Figure out how much to scale down by
int inSampleSize = 1;
if (srcHeight > destHeight || srcWidth > destWidth) {
float heightScale = srcHeight / destHeight;
float widthScale = srcWidth / destWidth;
inSampleSize = Math.round(heightScale > widthScale ? heightScale :
widthScale);
}
options = new BitmapFactory.Options();
options.inSampleSize = inSampleSize;
// Read in and create final bitmap
return BitmapFactory.decodeFile(path, options);
}
}

上述方法中,inSampleSize值很关键。它决定着缩略图像素的大小。假设这个值是1的话,就表明缩略图和原始照片的水平像素大小一样。如果是2的话,它们的水平像素比就是1∶ 2。因此, inSampleSize值为2时,缩略图的像素数就是原始文件的四分之一

但问题又来了

fragment刚启动时,无人知道PhotoView究竟有多大。 onCreate(…)、 onStart()和onResume()方法启动后,才会有首个实例化布局出现。也就在此时,显示在屏幕上的视图才会有大小尺寸

解决方案有两个:要么等布局实例化完成并显示,要么干脆使用保守估算值。特定条件下,尽管估算比较主观,但确实是唯一切实可行的办法

再添加一个getScaledBitmap(String, Activity)静态Bitmap估算方法

1
2
3
4
5
public static Bitmap getScaledBitmap(String path, Activity activity) {
Point size = new Point();
activity.getWindowManager().getDefaultDisplay().getSize(size);
return getScaledBitmap(path, size.x, size.y);
}

该方法先确认屏幕的尺寸,然后按此缩放图像

功能声明

假如应用要用到诸如相机、 NFC,或者任何其他的随设备走的功能时,都应该要让Android系统知道。 这样, 假如设备缺少这样的功能,类似Google Play商店的安装程序就会拒绝安装应用

为了声明应用要使用相机,在AndroidManifest.xml中加入标签:

1
<uses-feature android:name="android.hardware.camera" android:required="false" />

设置android:required属性为false, Android系统因此就知道,尽管不带相机的设备会导致应用功能缺失,但应用仍然可以正常安装和使用

挑战练习:优化照片显示

现在虽然能够看到拍摄的照片,但没法看到照片细节。

请 创 建 能 显 示 放 大 版 照 片 的 DialogFragment 。 只 要 点 击 缩 略 图 , 就 会 弹 出 这 个DialogFragment,让用户查看放大版的照片

  1. 新建视图文件dialog_picture:

    1
    2
    3
    4
    5
    6
    <?xml version="1.0" encoding="utf-8"?>
    <ImageView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/big_imageview"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:contentDescription="picture" />
  2. 新建PictureDialogFragment用于展示放大后的图像:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    public class PictureDialogFragment extends DialogFragment {

    public static final String ARG_PATH = "path";

    private ImageView mImageView;

    @NonNull
    @Override
    public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
    View view = LayoutInflater.from(getActivity()).inflate(R.layout.dialog_picture, null);
    mImageView = view.findViewById(R.id.big_imageview);
    String path = getArguments().getString(ARG_PATH);
    Bitmap bitmap = PictureUtils.getScaledBitmap(path, getActivity());
    mImageView.setImageBitmap(bitmap);
    mImageView.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
    dismiss();
    }
    });
    return new AlertDialog.Builder(getActivity())
    .setView(view)
    .create();
    }

    public static PictureDialogFragment newInstance(String path) {

    Bundle args = new Bundle();
    args.putString(ARG_PATH, path);
    PictureDialogFragment fragment = new PictureDialogFragment();
    fragment.setArguments(args);
    return fragment;
    }
    }
  3. 在CrimeFragment中新增:

    1
    public static final String DIALOG_PIC = "DialogPic";
  4. 设置ImageView的点击事件:

    1
    2
    3
    4
    5
    6
    7
    8
    mPhotoView.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
    PictureDialogFragment dialog = PictureDialogFragment.newInstance(mPhotoFile.getPath());
    FragmentManager manager = getChildFragmentManager();
    dialog.show(manager, DIALOG_PIC);
    }
    });

挑战练习:优化缩略图加载

本章,我们只能大致估算缩略图的目标尺寸。虽说这种做法可行且实施迅速,但还不够理想。

Android有个现成的API工具可用,叫作ViewTreeObserver。你可以从Activity层级结构中获取任何视图的ViewTreeObserver对象:

1
ViewTreeObserver observer = mImageView.getViewTreeObserver();

你可以为ViewTreeObserver对象设置包括OnGlobalLayoutListener在内的各种监听器。使用OnGlobalLayoutListener监听器,可以监听任何布局的传递,控制事件的发生。

调整代码,使用有效的mPhotoView尺寸,等到有布局切换时再调用updatePhotoView()方法。

  1. 删除原来调用updatePhotoView()方法的代码

  2. 在CrimeFragment中的onCreateView方法中,新增:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    ViewTreeObserver observer = mPhotoView.getViewTreeObserver();
    observer.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
    @Override
    public void onGlobalLayout() {
    // 踩坑:一定要判断getActivity()是否为空,否则会在view加载完毕前调用此方法,
    if (getActivity() != null){
    updatePhotoView();
    }
    }
    });

第17章 双版面主从用户界面

为了适应平板设备,我们改造CriminalIntent应用的用户界面,让用户能同时看到列表和明细界面并与它们交互

17-1

增加布局灵活性

@LayoutRes注解。这告诉Android Studio,任何时候,该实现方法都应该返回有效的布局资源ID

使用别名资源

别名资源是一种指向其他资源的特殊资源。它存放在res/values/目录下,并按照约定定义在refs.xml文件中

接下来的任务就是让CrimeListActivity基于不同的设备使用不同的布局文件。这实际类似于前面章节对水平布局和竖直布局的选择和控制:使用资源修饰符

让res/layout/目录中的文件使用资源修饰符虽然可行,但也有缺点。最明显的缺点就是数据冗余,因为每个布局文件都要复制一份

要解决上述问题,可以使用别名资源

新建refs.xml:

1
2
3
<resources>
<item name="activity_masterdetail" type="layout">@layout/activity_fragment</item>
</resources>

别名资源指向了单版面布局资源文件。别名资源自身也具有资源ID: R.layout.activity_masterdetail。注意,别名的type属性决定资源ID属于什么内部类。即使别名资源自身在res/values/目录中,它的资源ID依然属于R.layout内部类

修改CrimeListActivity类,以R.layout.activity_masterdetail资源ID替换R.layout.activity_fragment:

1
2
3
4
@Override
protected int getLayoutResId() {
return R.layout.activity_masterdetail;
}

创建平板设备专用可选资源

创建一个大屏幕设备专用的可选别名资源res/values-sw600dp/refs.xml,让activity_masterdetail别名资源指向activity_twopane.xml双版面布局资源。添加activity_masterdetail别名资源指向activity_twopane.xml:

1
2
3
<resources>
<item name="activity_masterdetail" type="layout">@layout/activity_twopane</item>
</resources>

对于上述新增别名资源,我们的目标是:

  • 对于小于指定尺寸的设备,使用activity_fragment.xml资源文件;
  • 对于大于指定尺寸的设备,使用activity_twopane.xml资源文件。

Android只提供一部分的资源适配机制。配置修饰符-sw600dp的作用是:如果设备尺寸大于某个指定值,就使用对应的资源文件。 sw是smallest width(最小宽度)的缩写。虽然字面上是宽度的含义,但它实际指的是屏幕的最小尺寸( dimension),因而sw与设备的当前方向无关

在确定可选资源时, -sw600dp配置修饰符表明:对任何最小尺寸为600dp或更高dp的设备,都使用该资源。对于指定平板的屏幕尺寸规格来说,这是一种非常好的做法

对于设备尺寸小于-sw600dp配置修饰符的指定值,就使用默认的activity_fragment.xml资源文件

activity: fragment 的托管者

处理完单双版面布局的显示,就可以着手将CrimeFragment添加给crime明细fragment容器,让CrimeListActivity展示一个完整的双版面用户界面

你可能会认为,只需再为平板设备实现一个CrimeHolder.onClick(View)监听器方法就行了。这样,无需启动新的CrimePagerActivity, onClick(View)方法会获取CrimeListActivity的FragmentManager,然后提交一个fragment事务,将CrimeFragment添加到明细fragment容器中:

1
2
3
4
5
6
7
8
public void onClick(View view) {
// Stick a new CrimeFragment in the activity's layout
Fragment fragment = CrimeFragment.newInstance(mCrime.getId());
FragmentManager fm = getActivity().getSupportFragmentManager();
fm.beginTransaction()
.add(R.id.detail_fragment_container, fragment)
.commit();
}

虽然行得通,但做法很老套。 fragment天生是个独立的开发构件。如果要开发fragment用来添加其他fragment到activity的FragmentManager,那么这个fragment就必须知道托管activity是如何工作的。结果,该fragment就再也无法作为独立的开发构件使用了

以上述代码为例, CrimeListFragment将CrimeFragment添加给了CrimeListActivity,并且 认 为 CrimeListActivity 的 布 局 里 包 含 detail_fragment_container 。 但 实 际 上 ,CrimeListFragment根本就不应关心这些,这都是其托管activity应该处理的事情

为了让fragment独立,我们可以在fragment中定义回调接口,委托托管activity来完成那些不应由fragment处理的任务。托管activity将实现回调接口,履行托管fragment的任务

fragment 回调接口

要委托工作任务给托管activity,通常的做法是由fragment定义名为Callbacks的回调接口。回调接口定义了fragment委托给托管activity处理的工作任务。任何打算托管目标fragment的activity都必须实现它

有了回调接口,就不用关心谁是托管者, fragment可以直接调用托管activity的方法

  1. 实现CrimeListFragment.Callbacks回调接口

    activity赋值是在Fragment的生命周期方法中处理的:

    1
    public void onAttach(Context context);

    该方法是在fragment附加给activity时调用的。Activity是Context的子类,所以, onAttach可以传入Context参数

    类似地,在相应的生命周期销毁方法中,将Callbacks变量设置为null:

    1
    public void onDetach();

    这里将变量清空的原因是,随后再也无法访问该activity或指望它继续存在了

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    public class CrimeListFragment extends Fragment {
    ...
    private boolean mSubtitleVisible;
    private Callbacks mCallbacks;
    /**
    * Required interface for hosting activities
    */
    public interface Callbacks {
    void onCrimeSelected(Crime crime);
    }
    @Override
    public void onAttach(Activity activity) {
    super.onAttach(Context);
    mCallbacks = (Callbacks) activity;
    }

    @Override
    public void onDetach() {
    super.onDetach();
    mCallbacks = null;
    }
    }

    现在, CrimeListFragment有办法调用托管activity方法了。另外,它也不关心托管activity是谁。只要托管activity实现了CrimeListFragment.Callbacks接口, CrimeListFragment中的一切代码行为就都可以保持不变

    接下来,在CrimeListActivity类中,实现CrimeListFragment.Callbacks接口:

    1
    2
    3
    4
    5
    6
    7
    public class CrimeListActivity extends SingleFragmentActivity
    implements CrimeListFragment.Callbacks {
    ···
    @Override
    public void onCrimeSelected(Crime crime) {
    }
    }

    现在,先思考如何实现CrimeListActivity.onCrimeSelected(Crime)方法。

    onCrimeSelected(Crime)方法被调用时, CrimeListActivity需要完成以下二选一任务:

    • 如果使用手机用户界面布局,启动新的CrimePagerActivity;
    • 如果使用平板设备用户界面布局,将CrimeFragment放入detail_fragment_container中。

    是实例化手机界面布局还是平板界面布局,可以检查布局ID;但是推荐检查布局是否包含detail_ fragment_container。这是因为布局文件名随时会变,并且我们也不关心布局是从哪个文件实例化产生。我们只需知道,布局文件是否包含可以放入CrimeFragment的detail_fragment_container

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @Override
    public void onCrimeSelected(Crime crime) {
    if (findViewById(R.id.detail_fragment_container) == null) {
    Intent intent = CrimePagerActivity.newIntent(this, crime.getId());
    startActivity(intent);
    } else {
    Fragment newDetail = CrimeFragment.newInstance(crime.getId());
    getSupportFragmentManager().beginTransaction()
    .replace(R.id.detail_fragment_container, newDetail)
    .commit();
    }
    }

    最后,在CrimeListFragment类中,在启动新的CrimePagerActivity处调用onCrimeSelected(Crime)方法

    不过,还不够完美:如果修改crime明细内容,列表项并不会显示最新内容

  2. 实现CrimeFragment.Callbacks回调接口

    CrimeFragment类中定义的接口如下:

    1
    2
    3
    public interface Callbacks {
    void onCrimeUpdated(Crime crime);
    }

    CrimeFragment如果要刷新数据,需要做两件事。首先,既然CriminalIntent应用的数据源是SQLite数据库,那么它需要把Crime保存到CrimeLab里。然后, CrimeFragment类会调用托管activity的onCrimeUpdated(Crime)方法。 CrimeListActivity类会负责实现onCrimeUpdated(Crime)方法,从数据库获取并展示最新数据,重新加载CrimeListFragment的列表

    在CrimeFragment.java中,添加回调方法接口以及mCallbacks成员变量,并实现onAttach(…)方法和onDetach()方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    private Callbacks mCallbacks;
    /**
    * Required interface for hosting activities
    */
    public interface Callbacks {
    void onCrimeUpdated(Crime crime);
    }
    @Override
    public void onAttach(Context context) {
    super.onAttach(context);
    mCallbacks = (Callbacks) context;
    }
    @Override
    public void onDetach() {
    super.onDetach();
    mCallbacks = null;
    }

    托管CrimeFragment的所有activity都必须实现CrimeFragment.Callbacks接口。因而在CrimePagerActivity中提供一个空接口实现:

    1
    2
    3
    4
    5
    6
    public class CrimePagerActivity extends AppCompatActivity implements CrimeFragment.Callbacks {
    ...
    @Override
    public void onCrimeUpdated(Crime crime) {
    }
    }

深入学习:设备屏幕尺寸的确定

Android 3.2之前,屏幕大小修饰符是基于设备的屏幕大小来提供可选资源的。屏幕大小修饰符将不同的设备分成了四大类别: small、 normal、 large及xlarge。

名称最低屏幕大小
small320×426dp
normal320×470dp
large480×640dp
xlarge720×960dp

顺应允许开发者测试设备尺寸的新修饰符的推出,屏幕大小修饰符已在Android 3.2中弃用。 下面是新的修饰符:

修饰符格式描述
wXXXdp有效宽度:宽度大于或等于XXX dp
hXXXdp有效高度:高度大于或等于XXX dp
swXXXdp最小宽度:宽度或高度(两者中最小的那个)大于或等于XXX dp

假设想指定某个布局仅适用于屏幕宽度至少为300dp的设备,可以使用宽度修饰符,并将布局文件放入res/layout-w300dp目录下( w代表屏幕宽度)。类似地,我们也可以使用“hXXXdp”修饰符( h代表屏幕高度)

挑战练习:添加滑动删除功能

为了改善用户体验,请为CriminalIntent应用的RecyclerView添加滑动删除功能。也就是说,用户向右滑动一下,就能删除一条crime记录。

为 了实现这个 功能,你需 要使用ItemTouchHelper类 ( developer.android.com/reference/android/support/v7/widget/helper/ItemTouchHelper.html)。这个类提供了滑动删除实现,包含在RecyclerView支持库中。

  1. 新建CrimeItemTouchHelperCallback类继承自ItemTouchHelper.Callback并重写必要其方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    public class CrimeItemTouchHelperCallback extends ItemTouchHelper.Callback {

    private final CrimeLab mCrimeLab;

    public CrimeItemTouchHelperCallback(CrimeLab crimeLab) {
    mCrimeLab = crimeLab;
    }

    @Override
    public int getMovementFlags(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
    // 上下拖动
    // int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN;
    int dragFlags = 0;
    // 左右滑动
    int swipeFlags = ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT;
    return makeMovementFlags(dragFlags, swipeFlags);
    }

    @Override
    public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder viewHolder1) {
    // 拖动
    return false;
    }

    @Override
    public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int i) {
    // 滑动
    // 从数据源中删除相应数据
    CrimeListFragment.CrimeHolder crimeHolder = ((CrimeListFragment.CrimeHolder) viewHolder);
    mCrimeLab.removeCrime(crimeHolder.getCrime());
    crimeHolder.updateItems();
    }
    }
  2. 在CrimeHolder类中增加公共方法updateItems()用于更新UI:

    1
    2
    3
    public void updateItems(){
    updateUI();
    }
  3. 在CrimeListFragment中的onCreateView方法中调用:

    1
    2
    ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new CrimeItemTouchHelperCallback(CrimeLab.get(getActivity())));
    itemTouchHelper.attachToRecyclerView(mCrimeRecyclerView);
  4. 运行程序就可以看到滑动删除效果啦

参考链接:RecyclerView 扩展(二) - 手把手教你认识ItemTouchHelper

第18章 应用本地化

本地化是一个基于设备语言设置,为应用提供合适资源的过程。

资源本地化

语言设置是设备配置的一部分(详见第3章的“设备配置与备选资源”小节)。和处理屏幕方向、屏幕尺寸以及其他配置因素改变一样, Android也提供了用于不同语言的配置修饰符。本地化处理因而变得简单:创建带目标语言配置修饰符的资源子目录,并放入备选资源。其余工作就交给Android资源系统自动处理

默认资源

提供默认资源非常重要。没有配置修饰符的资源就是Android的默认资源。如果无法找到匹配当前配置的资源, Android就会使用默认资源。默认资源至少能保证应用正常运行

例外的屏幕显示密度

Android默认资源使用规则并不适用于屏幕显示密度。项目的drawable目录通常按屏幕显示密度要求,带有-mdpi、 -xxhdpi这样的修饰符。不过, Android决定使用哪一类drawable资源并不是简单地匹配设备的屏幕显示密度,也不是在没有匹配的资源时直接使用默认资源

最终的选择取决于对屏幕尺寸和显示密度的综合考虑。不要在res/drawable/目录下放置默认的drawable资源。

检查资源本地化完成情况

Android Studio提供了资源翻译编辑器这个工具。这个便利工具能集中化查看资源翻译完成情况。在项目工具窗口,右键点击某个语言版本的strings.xml,选择OpenTranslations Editor菜单项打开资源翻译编辑器

当定义了translatable=“false”属性时,代表这个字符串不用翻译成其它语言

区域修饰符

修饰资源目录也可以使用语言加区域修饰符,这样可以让资源使用更有针对性。例如,西班牙语可以使用-es-rES修饰符。其中, r代表区域, ES是西班牙语的ISO 3166-1-alpha-2标准码。配置修饰符对大小写不敏感。但最好遵守Android命名约定:语言代码小写,区域代码大写,但前面加个小写的r

注意,语言区域修饰符,如-es-rES,看上去像两个不同的修饰符的合体,实际不是这样。这是因为,区域本身不能单独用作修饰符

Android不同系统版本的区域资源匹配策略

资源应尽可能通用,最好是使用仅限语言的修饰目录,尽量少用区域修饰。即,与其维护多种不同区域中文的
资源,不如只提供values-zh版资源。这样,不仅方便开发维护,也方便适配不同版本的系统

测试定制区域

不同设备不同版本的Android会支持不同的区域。你可以使用模拟器上的定制区域工具

配置修饰符

目前为止,我们已见过好几个配置修饰符,它们都用于提供可选资源,如语言( values-zh/)、屏幕方位( layout-land/)、屏幕显示密度( drawable-mdpi/)以及屏幕尺寸( layout-sw600dp/)

下表列出了一些设备配置特征。针对它们, Android提供配置修饰符以更好地匹配资源

可带配置修饰符的设备配置特征

18-3

可用资源优先级排定

考虑到有那么多匹配资源的配置修饰符,有时,会出现设备配置与好几个可选资源都匹配的情况。遇到这种状况,Android会基于表18-1的顺序确定修饰符的使用优先级

多重配置修饰符

可以在同一资源目录上使用多个配置修饰符。这需要各配置修饰符按照优先级别顺序排列。因此, values-zh-land是一个有效的资源目录名,而values-land-zh目录名则无效。(在新建资源文件对话框中,工具会自动配置正确的目录名。)

寻找最匹配的资源

  1. 排除不兼容的目录

    要找到最匹配的资源, Android首先排除不兼容当前设备配置的资源目录

  2. 按优先级表排除不兼容的目录

    筛掉不兼容的资源目录后,自优先级最高的MCC(移动国家码)开始, Android逐项查看并按优先级表继续筛查不兼容目录(表18-1)

测试备选资源

开发应用时,为了查看布局以及其他资源的使用效果,一定要针对不同设备配置做好测试。在虚拟设备或实体设备上测试都行,还可以使用图形布局工具测试

图形布局工具有很多选项,用以预览布局在不同配置下的显示效果。这些选项有屏幕尺寸、设备类型、 API级别以及设备语言等

要查看这些选项,可在图形布局工具中打开fragment_crime.xml文件,试用如图所示的功能:

使用图形布局工具预览资源

如果想确认项目是否包括所有必需的默认资源,可设置设备使用未提供本地化资源的语言

挑战练习:日期本地化

你可能已经注意到了,不管设备locale怎么调整, CriminalIntent应用的日期依然是美国格式。请按照设备locale设置,进一步本地化,让日期以中文年月日显示。这个练习应该难不倒你。

查阅开发者文档有关DateFormat类的用法和指导。 DateFormat类有个日期格式化工具,支持按locale做日期格式化。使用该类内置的配置常量,还可以进一步定制日期显示。

  1. 修改CrimeFragment类中的updateDate()方法:
1
2
3
4
private void updateDate() {
String format = DateFormat.getBestDateTimePattern(Locale.getDefault(), "yyyy-MM-dd");
mDateButton.setText(DateFormat.format(format, mCrime.getDate()));
}

第19章 Android辅助功能 学习笔记记录

一个易用的应用适合所有人使用,即便是视力、行动、听力有障碍的人也能使用

TalkBack

TalkBack是Google开发的Android屏幕阅读器。基于用户的操作,它能读出屏幕上的内容。TalkBack需要在辅助功能里面启用

设备启用TalkBack后,点操作会给予UI元素焦点,连点两下会激活。所以,当向上按钮获得焦点后,连点两下就回到上一屏了。如果是checkbox获得焦点,连点两下就是切换勾选状态。(同样,如果设备锁屏了,点锁屏按钮,然后连点两次屏幕任何地方就会解锁。)

实现非文字型元素可读

添加内容描述

可以给ImageButton添加内容描述,这样TalkBack就有内容可读了。内容描述是一段针对组件的文字说明,供TalkBack朗读

要添加组件内容描述,可以在组件的布局XML文件里,添加android:contentDescription属性。当然, 也可以在布局实例化代码里,使用someView.setContentDescription(someString)方法

添加内容描述时,文字表述要简洁明了

实现组件可聚焦

ImageView组件没有做可聚焦登记。有些框架组件,如Button、 CheckBox等,默认是可聚焦的;而像ImageView和TextView这样的框架组件需要手动登记。设置android:focusable属性值为true或使用监听器都可以让组件可聚焦

提升辅助体验

有些UI组件,如ImageView,虽然会给用户提供一些信息,但没有文字性内容。你也应该给这些组件添加内容描述。如果某个组件提供不了任何有意义的说明,应该把它的内容描述设置为null,让TalkBack直接忽略它

你可能会认为,反正用户看不见,管它是不是图片,知道了又如何?这种想法不对。作为开发人员,理应让所有用户都能用到应用的全部功能,获得同样的信息。要有不同,那也是自身体验和使用方式上的差异。

好的辅助易用设计不是一字不漏地读屏幕。相反,应注重用户体验的一致性。重要的信息和上下文一定要全部传达

使用 label 提供上下文

如果输入了其他文字, TalkBack使用者就失去了上下文,不知道EditText框到底是做什么的。这对于视力好的人来说一目了然,因为上面有标题文字标签。如果就输入了简单标题,有视力障碍的用户就要费力猜测了。显然,使用体验就有了大差异。

可以很容易地标明EditText和TextView的关系,让TalkBack掌握同样的上下文关系。只要给TextView添加android:labelFor属性就可以了

1
2
3
4
5
6
<TextView
style="?android:listSeparatorTextViewStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/crime_title_label"
android:labelFor="@+id/crime_title"/>

android:labelFor属性告诉TalkBack, TextView是以某个ID值指定的视图( EditText)的标签。注意,这里必须使用@+id这 样 的 语 法 , 因 为 我 们 正 引 用 了 一 个 当 前 还 没 定 义 的 ID 值

至此, CriminalIntent应用完成了。历经13章,我们创建了一个复杂的应用,它使用fragment,支持应用间通信,可以拍照,可以保存数据,甚至说中文

深入学习:使用辅助功能扫描器

本章,我们专注于让TalkBack用户更方便地使用应用。不过,这还没完,照顾视力障碍人群只是做了辅助工作的一小部分。

理论上,测试应用的辅助功能得靠真正每天在用辅助服务的用户。但即使现实不允许,也应竭尽所能。

为此, Google提供了一个辅助功能扫描器。它能评估应用在辅助功能方面做得如何并给出改进意见。现在拿CriminalIntent应用做个测试。

首先,访问play.google.com/store/apps/details?id=com.google.android.apps.accessibility.auditor,按指导安装扫描器。

安装完成后,手机屏幕上会出现一个蓝色的打勾图标。好戏开始了,启动CriminalIntent应用,先不用管蓝色的打勾图标,直接进入应用的crime明细页面,如图19-11所示。

点按蓝色的打勾图标,辅助功能扫描器开始工作。分析时会看到进度条。一旦完成,会弹出一个窗口给出建议,如图19-12所示。

19-1

想更深入了解扫描器的推荐,可点按右边的向下箭头查看细节,再点Learn More链接

挑战练习:优化列表项

在crime列表页面, TalkBack会读出每条crime记录的标题和发生日期,但漏了crime是否已解决这一信息。给手铐图标添加内容描述,解决这个问题。

  1. 给strings新增字符资源项:
1
<string name="crime_solved_description">Crime is solved</string>
  1. 给手铐图片添加内容描述:
1
2
3
4
5
6
7
8
9
10
11
12
<ImageView
android:id="@+id/crime_solved"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:contentDescription="@string/crime_solved_description"
app:srcCompat="@drawable/ic_solved" />

对于每条crime记录, TalkBack都要花点时间来读,这是因为日期格式那么长,而且是否已解决标志位于最右边。现在,再挑战一下自己,为屏幕上的每条记录都动态添加一个待读数据的汇总内容描述。

  1. 新增汇总内容字符资源:
1
<string name="crime_item_description">陋习标题是%1$s,陋习发生于 %2$s. 嫌疑人是%3$s, %4$s</string>
  1. 在CrimeHolder中的band()方法里使用资源:
1
2
3
4
5
itemView.setContentDescription(getString(R.string.crime_item_description,
mCrime.getTitle(),
DateFormat.format(DateFormat.getBestDateTimePattern(Locale.getDefault(),"EEEE"), mCrime.getDate()),
mCrime.getSuspect(),
mCrime.isSolved()?getString(R.string.crime_report_solved):getString(R.string.crime_report_unsolved)));

挑战练习:补全上下文信息

日期按钮和选择联系人按钮都有类似标题EditText的问题。无论是否使用TalkBack,用户都不是太明确按钮上是什么的日期。同样,选了联系人作为嫌疑人后,用户也可能不知道联系人按钮的作用是什么。用户也许能猜测出来

但为什么要让用户猜呢?这就是设计的微妙之处。你或设计团队应该拿出最好的方案,平衡易用和简约的关系。作为练习,请修改明细页面的设计,让用户充分把握数据和按钮间的上下文关系。和处理EditText标题一样,你可以为每个组件都添加label标签,或者完全重新设计明细页面。怎么做,自己选。总之,不要止步于眼前,要敢于对不好的说不,应用优化无止境。

  1. 新增字符资源:
1
2
3
4
<string name="btn_call_label">Call</string>
<string name="crime_suspect_label">suspect</string>
<string name="crime_time_label">Time</string>
<string name="crime_date_label">Date</string>
  1. 修改crime详情页布局文件fragment_crime.xml,为按钮增加label:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp">

<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">

<ImageView
android:id="@+id/crime_photo"
android:layout_width="80dp"
android:layout_height="80dp"
android:scaleType="centerInside"
android:cropToPadding="true"
android:focusable="true"
android:contentDescription="@string/crime_photo_no_image_description"
android:background="@android:color/darker_gray"/>

<ImageButton
android:id="@+id/crime_camera"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:contentDescription="@string/crime_photo_button_description"
android:src="@android:drawable/ic_menu_camera"/>

</LinearLayout>

<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">

<TextView
style="?android:listSeparatorTextViewStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:labelFor="@+id/crime_title"
android:text="@string/crime_title_label"/>

<EditText
android:id="@+id/crime_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/crime_title_hint"
android:minHeight="48dp"
android:inputType="text"
android:importantForAutofill="no" />
</LinearLayout>

</LinearLayout>

<TextView
style="?android:listSeparatorTextViewStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/crime_details_label"/>

<TextView
style="?android:listSeparatorTextViewStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:labelFor="@+id/crime_date"
android:text="@string/crime_date_label"/>
<Button
android:id="@+id/crime_date"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>

<TextView
style="?android:listSeparatorTextViewStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:labelFor="@+id/crime_time"
android:text="@string/crime_time_label"/>
<Button
android:id="@+id/crime_time"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>

<CheckBox
android:id="@+id/crime_solved"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/crime_solved_label"/>

<TextView
style="?android:listSeparatorTextViewStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:labelFor="@+id/crime_suspect"
android:text="@string/crime_suspect_label"/>

<Button
android:id="@+id/crime_suspect"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/crime_suspect_text"/>

<Button
android:id="@+id/crime_report"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/crime_report_text"/>

<TextView
style="?android:listSeparatorTextViewStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:labelFor="@+id/btn_call"
android:text="@string/btn_call_label"/>
<Button
android:id="@+id/btn_call"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/call_crimer" />

</LinearLayout>
  1. 运行效果

19-2

挑战练习:事件主动通知

给ImageView添加动态内容描述后, crime缩略图组件的TalkBack体验获得极大改善。但是,TalkBack用户必须等点按并聚焦ImageView之后,才知道照片是否已拍或已更新。而视力正常的用户在从相机应用返回时就能看到照片更新情况

你可以提供类似体验,让TalkBack用户在相机关闭时就能掌握照片更新情况。查阅文档研究一下View.announceForAccessibility(…)方法,看看怎么在CriminalIntent应用里使用

或许你也考虑过在onActivityResult(…)方法里通知。若要这样做,会有与activity生命周期相关的时间点掌控方面的问题要处理。不过,启动一个Runnable(详见第26章),做个延时处理可以绕开这个问题。以下给出参考代码:

1
2
3
4
5
6
mSomeView.postDelayed(new Runnable() {
@Override
public void run() {
// Code for making announcement goes here
}
}, SOME_DURATION_IN_MILLIS);

你也可以避开使用Runnable,想个办法确定发出通知的准确时间点。例如,可考虑在onResume()方法里通知。当然,前提是,你需要跟踪掌握用户是否刚从相机应用退出

  1. 在CrimeFragment类onCreateView()方法中为View设置通知消息:
1
mPhotoView.announceForAccessibility("图片已更新");
  1. 发送通知事件
1
mPhotoView.sendAccessibilityEvent(AccessibilityEvent.TYPE_ANNOUNCEMENT);

第20章 数据绑定与MVVM

为何要用 MVVM 架构

MVC架构比较适合小规模、简单型的应用

和所有项目一样,需求不断提出,应用一天比一天复杂。 fragment和activity开始膨胀,逐渐变得难以理解和扩展。添加新功能或改bug需要耗费很长时间。这个时候,控制器层就需要做功能拆分了

MVVM架构很好地把控制器里的臃肿代码抽到布局文件里,让开发人员很容易看出哪些是动态界面。同时,它也抽出部分动态控制器代码放入ViewModel类,这大大方便了开发测试和验证

每个视图模型应控制成多大规模,这要具体情况具体分析。如果视图模型过大,你还可以继续拆分。总之,你的架构你把控。即使大家都用MVVM架构,业务不同,场景不一样,每个人的具体实现方法都有差异

简单的数据绑定

  1. 首先,在应用的build.gradle文件里启用数据绑定:
1
2
3
4
5
6
7
8
9
10
11
12
android {
···
buildTypes {
···
}
dataBinding {
enabled = true
}
}
dependencies {
···
}

这会打开IDE的整合功能,允许你使用数据绑定产生的类,并把它们整合到编译里去。

  1. 要在布局里使用数据绑定,首先要把一般布局改造为数据绑定布局。具体做法就是把整个布局定义放入<layout>标签
1
2
3
4
5
6
7
8
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent">
</androidx.recyclerview.widget.RecyclerView>
</layout>

<layout>告诉数据绑定工具:“这个布局由你来处理。”接到任务,数据绑定工具会帮你生成一个绑定类(binding class)。新产生的绑定类默认以布局文件命名

现在, fragment_beat_box.xml已经有了一个叫FragmentBeatBoxBinding的绑定类。这就是要用来做数据绑定的类:现在,实例化视图层级结构时,不再使用LayoutInflater,而是实例化FragmentBeatBoxBinding类。在一个叫作getRoot()的getter方法里, FragmentBeatBoxBinding引用着布局视图结构,而且也会引用那些在布局文件里以android:id标签引用的其他视图

  1. 下面开始使用这个绑定类

在BeatBoxFragment里,覆盖onCreateView(…)方法,然后使用DataBindingUtil实例化FragmentBeatBoxBinding:

1
2
3
4
5
6
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
FragmentBeatBoxBinding binding = DataBindingUtil
.inflate(inflater, R.layout.fragment_beat_box, container, false);
return binding.getRoot();
}

实例化绑定类后,就可以获取并配置RecyclerView了:

1
binding.recyclerView.setLayoutManager(new GridLayoutManager(getActivity(),3));
  1. 数据绑定完成了,这就是我们所说的简单的数据绑定:不用findViewById(…)方法,转而使用数据绑定获取视图。稍后,我们还会学习数据绑定的高级用法

导入 assets

可以把assets想象为经过精简的资源:它们也像资源那样打入APK包,但不需要配置系统工具管理

使用assets有两面性:一方面,无需配置管理,可以随意命名assets,并按自己的文件结构组织它们;另一方面,没有配置管理,无法自动响应屏幕显示密度、语言这样的设备配置变更,自然也就无法在布局或其他资源里自动使用它们了

总体上讲,资源系统是更好的选择。然而,如果只想在代码中直接调用文件,那么assets就有优势了。大多数游戏就是使用assets加载大量图片和声音资源

现在开始导入assets

  1. 创建assets目录

    右键单击app模块,选择New → Folder → AssetsFolder菜单项

    不勾选Change Folder Location选项,保持Target Source Set的main选项不变,单击Finish按钮完成

  2. 右键单击assets目录,选择New → Directory菜单项,为声音资源创建sample_sounds子目录

    assets目录中的所有文件都会随应用打包。为了方便组织文件,我们创建了sample_sounds子目录。与资源不同,子目录不是必需的,这里是为了组织声音文件

处理 assets

  1. assets导入后,还要能在应用中进行定位、管理记录以及播放。这需要新建一个名为BeatBox的资源管理类:
1
2
3
4
public class BeatBox {
private static final String TAG = "BeatBox";
private static final String SOUNDS_FOLDER = "sample_sounds";
}
  1. 使用AssetManager类访问assets。可以从Context中获取它

    通常,在访问assets时,可以不用关心究竟使用哪个Context对象。这是因为,在实践中的任何场景下,所有Context中的AssetManager都管理着同一套assets资源

  2. 要取得assets中的资源清单,可以使用list(String)方法

1
2
3
4
5
6
7
8
9
10
private void loadSounds() {
String[] soundNames;
try {
soundNames = mAssets.list(SOUNDS_FOLDER);
Log.i(TAG, "Found " + soundNames.length + " sounds");
} catch (IOException ioe) {
Log.e(TAG, "Could not list assets", ioe);
return;
}
}

AssetManager.list(String)方法能列出指定目录下的所有文件名。因此,只要传入声音资源所在的目录,就能看到其中的所有.wav文件

使用 assets

获取到资源文件名之后,要显示给用户看,最终还需要播放这些声音文件。所以,需要创建一个对象,让它管理资源文件名、用户应该看到的文件名以及其他一些相关信息

  1. 创建一个这样的Sound管理类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Sound {
private String mAssetPath;
private String mName;
public Sound(String assetPath) {
mAssetPath = assetPath;
String[] components = assetPath.split("/");
String filename = components[components.length - 1];
mName = filename.replace(".wav", "");
}
public String getAssetPath() {
return mAssetPath;
}
public String getName() {
return mName;
}
}

为了有效显示声音文件名,在构造方法中对其做一下处理。首先使用String.split(String)方法分离出文件名,再使用String.replace(String, String)方法删除.wav后缀

  1. 接下来,在BeatBox.loadSounds()方法中创建一个Sound列表:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class BeatBox {
...
private AssetManager mAssets;
private List<Sound> mSounds = new ArrayList<>();
public BeatBox(Context context) {
...
}
private void loadSounds() {
String[] soundNames;
try {
...
} catch (IOException ioe) {
...
}
for (String filename : soundNames) {
String assetPath = SOUNDS_FOLDER + "/" + filename;
Sound sound = new Sound(assetPath);
mSounds.add(sound);
}
}
public List<Sound> getSounds() {
return mSounds;
}
}
  1. 再让SoundAdapter与Sound列表关联起来:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
private class SoundAdapter extends RecyclerView.Adapter<SoundHolder> {
private List<Sound> mSounds;
public SoundAdapter(List<Sound> sounds) {
mSounds = sounds;
}
...
@Override
public void onBindViewHolder(SoundHolder soundHolder, int position) {
}
@Override
public int getItemCount() {
return mSounds.size();
}
}
  1. 最后,在onCreateView(…)方法中传入BeatBox声音资源
1
2
3
4
5
6
7
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
FragmentBeatBoxBinding binding = DataBindingUtil.inflate(inflater, R.layout.fragment_beat_box, container, false);
binding.recyclerView.setlayoutManager(new GridlayoutManager(getActivity(), 3));
binding.recyclerView.setAdapter(new SoundAdapter(mBeatBox.getSounds()));
return binding.getRoot();
}

要显示按钮文字,还需要使用新的数据绑定小工具

绑定数据

使用数据绑定,我们还可以在布局文件中声明数据对象:

1
2
3
4
5
6
7
8
9
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="crime"
type="com.bignerdranch.android.criminalintent.Crime"/>
</data>
...
</layout>

然后,使用绑定操作符@{}就可以在布局文件中直接使用这些数据对象的值:

1
2
3
4
5
6
<CheckBox android:id="@+id/list_item_crime_solved_check_box"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:checked="@{crime.isSolved()}"
android:padding="4dp"/>

在对象关系图中,可以这样表示:

20-1

我 们 的 目 标 是 在 按 钮 上 显 示 声 音 文 件 名 。 使 用 数 据 绑 定 , 最 直 接 的 方 式 就 是 绑 定list_item_sound.xml布局文件中的Sound对象

20-2

然而,这似乎有架构问题。首先从MVC视角看看问题在哪

20-3

不管是哪种架构,有一个指导原则都一样:责任单一性原则。也就是说,每个类应该只负责一件事情。按此原则, MVC是这样落实的:模型表明应用是如何工作的;控制器决定如何显示应用;视图显示你想看到的结果

使用如图20-8所示的数据绑定,就破坏了责任划分。这是因为Sound模型对象不可避免地需要关心显示问题。代码也就此开始混乱了。模型层代码和控制器层代码里都是Sound.java

为了避免Sound这样破坏单一性原则的情况,我们引入一种叫作视图模型的新对象(配合数据绑定使用)。视图模型负责如何显示视图

20-4

这种架构称为MVVM。从前控制器对象格式化视图数据的工作就转给了视图模型对象。现在,使用数据绑定,组件关联数据就能直接在布局文件里处理了。控制器对象( activity或fragment)开始负责初始化布局绑定类和视图模型对象,同时也是它们之间的联系纽带

创建视图模型

首先来创建视图模型类。创建一个名为SoundViewModel的新类,然后添加两个属性:一个Sound对象,一个播放声音文件的BeatBox对象

1
2
3
4
5
6
7
8
9
10
11
12
13
public class SoundViewModel {
private Sound mSound;
private BeatBox mBeatBox;
public SoundViewModel(BeatBox beatBox) {
mBeatBox = beatBox;
}
public Sound getSound() {
return mSound;
}
public void setSound(Sound sound) {
mSound = sound;
}
}

新添加的属性是adapter要用到的接口。

对于布局,还需要一个额外的方法获取按钮要用的文件名,加上这个方法:

1
2
3
public String getTitle() {
return mSound.getName();
}

绑定至视图模型

现在,把视图模型整合到布局文件里。第一步是在布局文件里声明属性:

1
2
3
4
5
6
7
8
9
10
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="viewModel"
type="com.bignerdranch.android.beatbox.SoundViewModel"/>
</data>
<Button
···/>
</layout>

这在绑定类上定义了一个叫viewModel的属性,同时还包括getter方法和setter方法。在绑定类里,可以用绑定表达式使用viewModel

1
2
3
4
5
<Button
android:layout_width="match_parent"
android:layout_height="120dp"
android:text="@{viewModel.title}"
tools:text="Sound name"/>

上述viewModel.title实际就是viewModel.getTitle()的简写形式。数据绑定知道怎么帮你翻译

在绑定表达式里,可以写一些简单的Java表达式,如链式方法调用、数学计算等

最后一步就是关联使用视图模型。创建一个SoundViewModel,把它添加给绑定类,然后在SoundHolder里添加一个绑定方法

1
2
3
4
5
6
7
8
9
10
11
12
private class SoundHolder extends RecyclerView.ViewHolder {
private listitemSoundBinding mBinding;
private SoundHolder(listitemSoundBinding binding) {
super(binding.getRoot());
mBinding = binding;
mBinding.setViewModel(new SoundViewModel(mBeatBox));
}
public void bind(Sound sound) {
mBinding.getViewModel().setSound(sound);
mBinding.executePendingBindings();
}
}

在SoundHolder构造方法里,我们创建并添加了一个视图模型。然后,在绑定方法里,更新视图模型要用到的数据

一般不需要调用executePendingBindings()方法。然而在这里,我们正在RecyclerView里更新绑定数据。考虑到RecyclerView刷新视图极快,我们迫使布局立即刷新。这样,RecyclerView的表现就更为流畅

最后,实现onBindViewHolder(…)方法以使用视图模型

1
2
3
4
5
@Override
public void onBindViewHolder(SoundHolder holder, int position) {
Sound sound = mSounds.get(position);
holder.bind(sound);
}

运行应用,可以看到按钮可以显示文件名了

绑定数据观察

一切看上去很美,不过这只是表面

在SoundHolder.bind(Sound)方法里,我们更新了SoundViewModel的Sound,但布局不知道。而且,视图模型并不会给布局反馈信息,所以会在显示的时候出现重复项

现在任务明确了,我们需要让它们沟通起来。这需要视图模型实现数据绑定的Observable接口。这个接口可以让绑定类在视图模型上设置监听器。这样,只要视图模型有变化,绑定类立即会接到回调

实现这个接口理论上可行,但工作量太大。有没有其他好办法呢?答案是肯定的。现在就一起来看个聪明的做法(使用数据绑定的BaseObservable类)

使用BaseObservable类需要三个步骤:

  1. 在视图模型里继承BaseObservable类;
  2. 使用@Bindable注解视图模型里可绑定的属性;
  3. 每次可绑定的属性值改变时,就调用notifyChange()方法或notifyPropertyChanged(int)方法

在 SoundViewModel 里 , 让 它 继 承 BaseObservable 类 , 注 解 可 绑 定 的 属 性 并 调 用notifyChange()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class SoundViewModel extends BaseObservable {
private Sound mSound;
private BeatBox mBeatBox;
public SoundViewModel(BeatBox beatBox) {
mBeatBox = beatBox;
}
@Bindable
public String getTitle() {
return mSound.getName();
}
public Sound getSound() {
return mSound;
}
public void setSound(Sound sound) {
mSound = sound;
notifyChange();
}
}

这里,调用notifyChange()方法,就是通知绑定类,视图模型对象上所有可绑定属性都已更新。据此,绑定类会再次运行绑定表达式更新视图数据。所以, setSound(Sound)方法一被调用, ListItemSoundBinding就立即知道,并调用list_item_sound.xml布局里指定的Button.setText(String)方法

上面,我们提到过另一个方法: notifyPropertyChanged(int)。 这个方法和notifyChange()方法做同样的事,但覆盖面不一样。调用notifyChange()方法,相当于是说:“所有的可绑定属性都变了,请全部更新。”调用notifyPropertyChanged(int)方法,相当于是说:“只有getTitle()方法的值有变化。”

再次运行应用,将会一切正常

访问 assets

本章的工作全部完成了。下一章还会继续完善BeatBox应用,播放assets声音资源。继续学习之前,一起花点时间深入探讨一下assets的工作原理。

Sound对象定义了assets文件路径。尝试使用File对象打开资源文件是行不通的。正确的方式是使用AssetManager:

1
2
String assetPath = sound.getAssetPath();
InputStream soundData = mAssets.open(assetPath);

这样才能得到标准的InputStream数据流。随后,和Java中的其他InputStream一样,该怎么用就怎么用

不过,有些API可能还需要FileDescriptor。(下一章的SoundPool类就会用到。)这也好办,改用AssetManager.openFd(String)方法就行了

1
2
3
4
5
String assetPath = sound.getAssetPath();
// AssetFileDescriptors are different from FileDescriptors,
AssetFileDescriptor assetFd = mAssets.openFd(assetPath);
// but you get can a regular FileDescriptor easily if you need to.
FileDescriptor fd = assetFd.getFileDescriptor();

深入学习:数据绑定再探

数据绑定可学的还有很多,本书无法全部覆盖。不过,如果你有兴趣,不妨再多了解一下。

lambda 表达式

在布局文件里,还可以使用lambda表达式写点短回调。以下是一些简化版的Java lambda表达式:

1
2
3
4
5
6
<Button
android:layout_width="match_parent"
android:layout_height="120dp"
android:text="@{viewModel.title}"
android:onClick="@{(view) -> viewModel.onButtonClick()}"
tools:text="Sound name"/>

和Java 8 lambda表达式差不多,上述表达式会转成目标接口实现(这里是View.OnClickListener)。和Java 8 lambda表达式不同的是,这些表达式的语法有些特殊:参数必须在括号里,最右边一定要有一个表达式

另外,还有一点和Java 8 lambda表达式不同:如果用不到, lambda参数可以不写。所以,下面这个写法也可以:

1
android:onClick="@{() -> viewModel.onButtonClick()}"

更多语法糖

数据绑定还有一些方便的语法可用。最方便的一个是使用单引号代替双引号:

1
android:text="@{`File name: ` + viewModel.title}"

这里, File name:和”File name: “是一样的

绑定表达式也有一个遇null值就合并的操作符:

1
android:text="@{`File name: ` + viewModel.title ?? `No file`}"

如果title是null, ??操作符就返回”No file”值

此外,数据绑定还有null自动处理机制。在上面的代码中,即使viewModel有null值,数据绑定也会给出null值判断( viewModel.title子表达式会给出”null”),保证应用不会因为这个原因而崩溃

BindingAdapter

数据绑定默认会把绑定表达式解读为属性方法调用。所以,以下代码会被翻译为setText(String)方法调用

1
android:text="@{`File name: ` + viewModel.title ?? `No file`}"

然而,这还不算什么。有时候,你可能会想给某些特别属性赋予一些定制行为。一般的做法是写一个BindingAdapter:

1
2
3
4
5
6
public class BeatBoxBindingAdapter {
@BindingAdapter("app:soundName")
public static void bindAssetSound(Button button, String assetFileName) {
...
}
}

很简单,在项目的任何类里创建一个静态类,再使用@BindingAdapter,并传入想绑定作为参数的属性名即可。(是的,这就可以了。)数据绑定只要想用那个属性,它就会调用这个静态方法

对于标准库里的组件,很可能你也想用数据绑定做点什么。实际上,有一些常见的操作已经定义了绑定adapter。例如, TextViewBindingAdapter就为TextView提供了一些特别的属性操作 。你可以看 看它们的源 码。所以, 若想自己写 解决方案, 不妨先按Command+Shift+O( Ctrl+Shift+O)搜一搜,打开其关联的绑定adapter看看,也许已经有你想要的了

深入学习:为何使用 assets

资源系统做不到的,就是assets大显身手的地方。 assets可以看作随应用打包的微型文件系统,支持任意层次的文件目录结构。因为这个优点, assets常用来加载大量图片和声音资源,如游戏这样的应用

深入学习:什么是 non-assets

AssetManager类还有像openNonAssetFd(…)这样的方法

前面说过, Android有assets和resources两大资源系统。 resources系统有良好的检索机制,但无法处理大资源。这些大资源,如声音文件和图像文件,通常会保存在assets系统里。在后台,Android就是使用openNonAsset方法来打开它们的。不过,这样的方法有不少没对用户开放。

现在了解了吧!有机会用到它吗?永远没有。

第21章 音频播放与单元测试

MVVM架构极大方便了一项关键编程工作:单元测试。单元测试是指编写小程序去验证应用各个单元的独立行为

Android的大部分音频API都比较低级,不易掌握。可以使用SoundPool这个定制版实用工具。 SoundPool能加载一批声音资源到内存中,并能控制同时播放的音频文件的个数

创建 SoundPool

首先实现音频播放功能,这需要创建一个SoundPool对象:

1
private SoundPool mSoundPool = new SoundPool(MAX_SOUNDS, AudioManager.STREAM_MUSIC, 0);

Lollipop引入了新的SoundPool创建方式:使用SoundPool.Builder。不过,为了兼容API 19这一最低级别,还是要用SoundPool(int, int, int)这个老构造方法

  • 第一个参数MAX_SOUNDS指定同时播放多少个音频
  • 第二个参数AudioManager.STREAM_MUSIC确定音频流类型

Android有很多不同的音频流,它们都有各自独立的音量控制选项。这就是为什么调低音乐音量,闹钟音量却不受影响的原因。STREAM_MUSIC是音乐和游戏常用的音量控制常量

  • 最后一个参数指定采样率转换品质。参考文档说这个参数不起作用,所以这里传入0

加载音频文件

接下来使用SoundPool加载音频文件。相比其他音频播放方法, SoundPool还有个快速响应的优势:指令刚一发出,它就立即开始播放,一点都不拖沓

不过反应快也要付出代价,那就是在播放前必须预先加载音频。 SoundPool加载的音频文件都有自己的Integer型ID

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Sound {
private String mAssetPath;
private String mName;
private Integer mSoundId;
...
public String getName() {
return mName;
}
public Integer getSoundId() {
return mSoundId;
}
public void setSoundId(Integer soundId) {
mSoundId = soundId;
}
}

mSoundId用了Integer类型而不是int。这样,在mSoundId没有值时,可以设置其为null值

现在处理音频加载。在BeatBox中添加load(Sound)方法载入音频:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class BeatBox{
private void loadSounds() {
...
}
private void load(Sound sound) throws IOException {
AssetFileDescriptor afd = mAssets.openFd(sound.getAssetPath());
int soundId = mSoundPool.load(afd, 1);
sound.setSoundId(soundId);
}
public list<Sound> getSounds() {
return mSounds;
}
}

调用mSoundPool.load(AssetFileDescriptor, int)方法可以把文件载入SoundPool待播。为了方便管理、重播或卸载音频文件, mSoundPool.load(…)方法会返回一个int型ID。这实际就是存储在mSoundId中的ID。调用openFd(String)方法有可能抛出IOException, load(Sound)方法也是如此

现在,在BeatBox.loadSounds()方法中,调用load(Sound)方法载入全部音频文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
private void loadSounds() {
...
for (String filename : soundNames) {
try {
String assetPath = SOUNDS_FOLDER + "/" + filename;
Sound sound = new Sound(assetPath);
load(sound);
mSounds.add(sound);
} catch (IOException ioe) {
Log.e(TAG, "Could not load sound " + filename, ioe);
}
}
}

运行应用确认音频都已正确加载。否则,会看到LogCat中的红色异常日志

播放音频

最后一步是播放音频。在BeatBox中添加play(Sound)方法:

1
2
3
4
5
6
7
public void play(Sound sound) {
Integer soundId = sound.getSoundId();
if (soundId == null) {
return;
}
mSoundPool.play(soundId, 1.0f, 1.0f, 1, 0, 1.0f);
}

播放前,要检查并确保soundId不是null值。 Sound加载失败会出现null值的情况

检查通过后,就可以调用SoundPool.play(int, float, float, int, int, float)方法播放音频了。这些参数依次是:音频ID左音量右音量优先级(无效)是否循环以及播放速率。我们需要最大音量和常速播放,所以传入值1.0。是否循环参数传入0,代表不循环。(如果想无限循环,可以传入1 )

现在,可以把音频播放功能整合进SoundViewModel了。不过,我们打算先做单元测试再整合。具体做法是这样:先写个肯定会失败的单元测试,然后整合,让单元测试成功通过

添加测试依赖

要编写测试代码,首先需要添加两个测试工具: Mockito和Hamcrest。

Mockito是一个方便创建虚拟对象的Java框架。有了虚拟对象,就可以单独测试SoundViewModel,不用担心会因代码关联关系测到其他对象

Hamcrest是个规则匹配器工具库。匹配器可以方便地在代码里模拟匹配条件。如果不能按预期匹配条件定义,测试就通不过。这可以验证代码是否按预期工作

有这两个依赖库就可以做单元测试

添加依赖库方法:

右键单击app模块,选择Open Module Settings菜单项。选择弹出界面里的Dependencies选项页,然后点击+按钮弹出选择依赖库窗口,输入mockito后搜索。选 择 org.mockito:mockito-core 依 赖 库 , 点击OK按钮完成添加

由于只允许指定整合测试范围,因此需要手动修改build.gradle文件。打开build.gradle文件,把依赖项作用范围从compile改为testCompile(在新版Android Studio中,已经废弃testCompile而改用testImplementation)

testCompile(testImplementation)作用范围表示,这两个依赖项只需包括在应用的测试编译里

创建测试类

写单元测试最方便的方式是使用测试框架。使用测试框架可以集中编写和运行测试案例,并支持在Android Studio里看到测试结果

JUnit是最常用的Android单元测试框架,能和Android Studio无缝整合。要用它测试,首先要创建一个用作JUnit测试的测试类。打开SoundViewModel.java文件,使用Command+Shift+T( Ctrl+Shift+T)组合键。 Android Studio会尝试寻找这个类关联的测试类。如果找不到,它就会提示新建

21-1

选择Create New Test…创建一个新测试类。测试库选择JUnit4,勾选setUp/@Before,其他保持默认设置

21-2

点击OK按钮,进入下一个对话框

最后一步是选择创建哪种测试类,或者说选择哪个测试目录存放测试类( androidTest和test)。

在androidTest目录下的都是整合测试类。整合测试可以运行在设备或虚拟设备上。这样做有优点:可以在运行时动态测试应用行为。但也有缺点:需要编译打包为APK在设备上运行,浪费资源

在test目录下的是单元测试类。单元测试运行在本地开发机上,可以脱离Android运行时环境,因此速度会快很多

单元测试的规模最小:测试单个类。所以,单元测试不需要运行整个应用或支持设备,可以不影响手头工作,快速反复地执行。考虑到这个因素,我们选择test目录存放测试类

实现测试类

和大多数对象一样,测试类也需要创建对象实例以及它依赖的其他对象。为了避免为每一个测试类写重复代码, JUnit提供了@Before这个注解。以@Before注解的包含公共代码的方法会在所有测试之前运行一次。按照约定,所有单元测试类都要有以@Before注解的setUp()方法

使用虚拟依赖项

要用Mockito创建虚拟对象,需要传入要虚拟的类,调用mock(Class)静态方法。创建一个虚拟BeatBox对象并存入mBeatBox变量

1
2
3
4
5
6
7
public class SoundViewModelTest {
private BeatBox mBeatBox;
@Before
public void setUp() throws Exception {
mBeatBox = mock(BeatBox.class);
}
}

使用mock(Class)方法需要导入支持包。mock(Class)方法会自动创建一个虚拟版本的BeatBox。这确实很方便

有了虚拟依赖对象,现在来完成SoundViewModel测试类。创建一个SoundViewModel和一个Sound备用( Sound是简单的数据对象,不容易出问题,这里就虚拟它了)

1
2
3
4
5
6
7
8
9
10
11
12
public class SoundViewModelTest {
private BeatBox mBeatBox;
private Sound mSound;
private SoundViewModel mSubject;
@Before
public void setUp() throws Exception {
mBeatBox = mock(BeatBox.class);
mSound = new Sound("assetPath");
mSubject = new SoundViewModel(mBeatBox);
mSubject.setSound(mSound);
}
}

注意,在本书的其他地方,声明SoundViewModel类型变量时,命名一般是mSoundViewModel。这里,我们用了mSubject。这是一种习惯约定,这样做的原因有两点:

  • 很清楚就知道, mSubject是要测试的对象(与其他对象区别开来);
  • 如果SoundViewModel里有任何方法要移到其他类,比如BeatBoxSoundViewModel,那么测试方法可以直接复制过去,省了mSoundViewModel到mBeatBoxSoundViewModel重命名的麻烦

编写测试方法

setUp()支持方法完成了,现在可以写测试代码了。实际上,就是在测试类里写一个以@Test注解的测试方法

首先写一个方法,断定SoundViewModel里的getTitle()属性和Sound里的getName()属性是有关系的:

1
2
3
4
@Test
public void exposesSoundNameAsTitle() {
assertThat(mSubject.getTitle(), is(mSound.getName()));
}

这个测试方法使用了Hamcrest匹配器的is(…)方法和JUnit的assertThat(…)方法。断定测试对象获取标题方法和sound的获取文件名方法返回相同的值。如果不同,单元测试失败

为了运行测试,右键点击app/java/com.bignerdranch.android.beatbox (test),然后选择Run ‘Tests in ‘beatbox’’。随后,一个结果窗口弹出

21-3

测试结果窗口默认只会显示失败的测试。所以,你知道,测试通过了。

测试对象交互

刚才做了测试热身,现在处理关键任务:整合SoundViewModel和BeatBox.play(Sound)方法。实践中,通常的做法是,在写新方法之前,先写一个测试验证这个方法的预期结果。我们需要在SoundViewModel类里写onButtonClicked()方法去调用BeatBox.play(Sound)方法。

写一个测试方法调用onButtonClicked()方法

1
2
3
4
@Test
public void callsBeatBoxPlayOnButtonClicked() {
mSubject.onButtonClicked();
}

创建onButtonClicked()方法

1
2
public void onButtonClicked() {
}

先不管这个空方法。单元测试方法会调用这个方法,而且,也应验证这个方法的实际作用:调用BeatBox.play(Sound)方法。这种繁琐的事就交给Mockito吧!对于每次调用,所有的Mockito虚拟对象都能自我跟踪管理哪些方法调用了,以及都传入了哪些参数

调用verify(Object)方法,确认onButtonClicked()方法调用了BeatBox.play(Sound)方法:

1
2
3
4
5
@Test
public void callsBeatBoxPlayOnButtonClicked() {
mSubject.onButtonClicked();
verify(mBeatBox).play(mSound);
}

类似于前面的AlertDialog.Builder类, verify(Object)使用了流接口,分开写就像这样:

1
2
verify(mBeatBox);
mBeatBox.play(mSound);

调用verify(mBeatBox)方法就是说:“我要验证mBeatBox对象的某个方法是否调用了。”紧跟的mBeatBox.play(mSound)方法是说:“验证这个方法是这样调用的。”所以,合起来就是说:“验证以mSound作为参数,调用了mBeatBox对象的play(…)方法。”

运行测试看结果:

21-4

测试结果表明,测试方法要调用mBeatBox.play(mSound),但没成功

现在实现onButtonClicked()方法,让测试符合预期:

1
2
3
public void onButtonClicked() {
mBeatBox.play(mSound);
}

再次运行测试。这次一路绿灯,测试顺利通过

21-5

数据绑定回调

按钮要响应事件还差最后一步:关联按钮对象和onButtonClicked()方法

和前面使用数据绑定关联数据和UI一样,你也可以使用lambda表达式,让数据绑定帮忙关联按钮和点击监听器

在布局文件里,添加数据绑定lambda表达式,让按钮对象和onButtonClicked()方法关联起来:

1
2
3
4
5
6
<Button
android:layout_width="match_parent"
android:layout_height="120dp"
android:onClick="@{() -> viewModel.onButtonClicked()}"
android:text="@{viewModel.title}"
tools:text="Sound name"/>

现在,如果运行应用,按钮就能播放声音

释放音频

音频播放完毕,应调用SoundPool.release()方法释放SoundPool。添加BeatBox.release()清理方法:

1
2
3
public void release() {
mSoundPool.release();
}

在BeatBoxFragment中,完成释放

1
2
3
4
5
@Override
public void onDestroy() {
super.onDestroy();
mBeatBox.release();
}

再次运行应用,确认添加release()方法后,应用工作正常。尝试播放一长段声音,同时旋转设备或点按后退键,声音播放应该会停止

设备旋转和对象保存

分 析 一下 这 个 问 题: 设 备 旋 转时 , BeatBoxActivity随 即 被 销 毁 。与 此 同 时 ,FragmentManager也会销毁BeatBoxFragment。在销毁过程中,它会逐一调用BeatBoxFragment的生命周期方法onPause()、 onStop()和onDestroy()。在BeatBoxFragment.onDestroy()方法中, BeatBox.release()方法会被调用。这会释放SoundPool,音频播放自然也就停止了

前面,我们遇到过Activity和Fragment因设备旋转而被销毁的问题。当时使用onSaveInstanceState(Bundle)方解决了问题。然而,老办法在这里行不通,因为需要首先保存数据,然后再使用Bundle中的Parcelable恢复数据

类似于Serializable, Parcelable是一个把对象以字节流的方式保存的API。对于可保存对象,可以让它实现Parcelable接口。在Java世界,要保存对象,要么将其放入Bundle中,要么实现Serializable接口或者Parcelable接口。无论采用哪种方式,对象首先要是可保存对象

BeatBox的某些部分可以保存,例如, Sound类中的一切都可以保存;而SoundPool就无法保存了。虽然可以新建包含同样音频文件的SoundPool,甚至能从音频播放中断处继续,你还是会体验到被打断的滋味。这是改变不了的事实。所以说, SoundPool是无法保存的

不可保存性有向外传递的倾向。如果一个对象重度依赖另一个不可保存的对象,那么这个对象很可能也无法保存

普通的savedInstanceState机制只适用于可保存的对象数据,但BeatBox不可保存。在Activity创建和销毁时, BeatBox实例需要持续可用

保留 fragment

为了应对设备配置变化, fragment有一个特殊方法可确保BeatBox实例不被销毁,这个方法就是retainInstance。覆盖BeatBoxFragment.onCreate(…)方法并设置fragment的属性值

1
2
3
4
5
6
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setRetainInstance(true);
mBeatBox = new BeatBox(getActivity());
}

fragment的retainInstance属性值默认为false,这表明其不会被保留。因此,设备旋转时fragment会随托管activity一起被销毁并重建。调用setRetainInstance(true)方法可保留fragment。 已保留的fragment不会activity一起被销毁。相反,它会一直保留,并在需要时原封不动地转给新的activity

对于已保留的fragment实例,其全部实例变量(如mBeatBox)的值也会保持不变,因此可放心继续使用

设备旋转和已保留的 fragment

解决了问题之后,我们来看看保留fragment的工作原理。 fragment之所以能保留,是因为这样一个事实:可以销毁和重建fragment的视图,但fragment自身可以不被销毁

设备配置发生改变时, FragmentManager首先销毁队列中fragment的视图。在设备配置改变时,总是销毁与重建fragment与activity的视图,这都是基于同样的理由:新的配置可能需要新的资源来匹配;当有更合适的资源可用时,则应重建视图。

紧接着, FragmentManager检查每个fragment的retainInstance属性值。如果属性值为false(初始默认值), FragmentManager会立即销毁该fragment实例。随后,为了适应新的设备配置,新activity的新FragmentManager会创建一个新的fragment及其视图

21-6

如果属性值为true,则该fragment的视图立即被销毁,但fragment本身不会被销毁。为了适应新的设备配置,新activity创建后,新FragmentManager会找到已保留的fragment,并重新创建它的视图

21-7

虽然已保留的fragment没有被销毁,但它已脱离消亡中的activity并处于保留状态。尽管此时的fragment还在,但已没有任何activity托管它

21-8

必须同时满足以下两个条件, fragment才能进入保留状态:

  • 已调用了fragment的setRetainInstance(true)方法;
  • 因设备配置改变(通常为设备旋转),托管activity正在被销毁。

fragment只能保留非常短的时间,即从fragment脱离旧activity到重新附加给快速新建的activity之间的一段时间

深入学习:是否保留 fragment

你可能会疑惑:为什么不保留所有fragment?为什么fragment的retainInstance默认属性值不是true?这是因为,除非万不得已,最好不要使用这种机制

首先,相比非保留fragment,已保留fragment用起来更复杂

其次, fragment在使用保存实例状态的方式处理设备旋转时,也能够应对所有生命周期场景;但保留的fragment只能应付activity因设备旋转而被销毁的情况

深入学习: Espresso 与整合测试

在测试SoundViewModel时,我们创建了SoundViewModelTest单元测试类。实际上,我们也可以选择创建整合测试

在单元测试里,受测对象都是单个类。在整合测试里,受测对象是整个应用

Espresso是Google开发的一个UI测试框架,可用来测试Android应用。在app/build.gradle文件中,添加com.android.support.test.espresso:espresso-core依赖项,作用范围改为androidTestCompile,就可以引入它

引入Espresso之后,就可以用它来测试某个activity的行为。例如,如果想断定屏幕上某个视图显示了第一个sample_sounds受测文件的文件名,就可以编写如下的测试用例:

1
2
3
4
5
6
7
8
9
10
@RunWith(AndroidJUnit4.class)
public class BeatBoxActivityTest {
@Rule
public ActivityTestRule<BeatBoxActivity> mActivityRule = new ActivityTestRule<>(BeatBoxActivity.class);
@Test
public void showsFirstFileName() {
onView(withText("65_cjipie"))
.check(matches(anything()));
}
}

首先看其中的注解。 @RunWith(AndroidJUnit4.class)表明,这是一个Android工具测试,需要activity和其他Android运行时环境支持。之后, mActivityRule上的@Rule注解告诉JUnit,运行测试前,要启动一个BeatBoxActivity实例

准 备 工 作 做完 , 接 下 来就 可 以 在 测试 方 法 里 对BeatBoxActivity做 断 定 测 试 了 。 在showsFirstFileName() 方 法 里 , onView(withText(“65_cjipie”))这 行 代 码 会 找 到 显 示“65_cjipie”的视图,然后对其执行测试。 check(matches(anything()))用来判定有这样的视图。如果没有,则测试失败。相较于JUnit的assertThat(…)断言方法, check(…)方法是Espresso版的断言方法

有时,你可能还想点击某个视图,然后使用断言验证点击结果。可以让Espresso点击这个视图,或者使用下面这样的代码交互:

1
2
onView(withText("65_cjipie"))
.perform(click());

与视图交互时, Espresso会等待应用闲置再执行下一个测试。 Espresso有一套探测UI是否已更新完毕的方法。如果需要,也可使用IdlingResource的一个子类告诉Espresso:多等一会儿,应用还在忙

有关如何使用Espresso做UI测试的更详细的信息,请阅读Espresso的文档( google.github.io/android-testing-support-library/docs/espresso)

单元测试和整合测试用处各异。单元测试简单快速,多用用就会形成习惯,所以能让大多数人接受并喜欢。整合测试需要花很多时间,不适合做经常性的测试。然而,不管怎样,这两类测试都很重要,各自能从不同视角检验应用。所以,只要有条件,二者都不能少

深入学习:虚拟对象与测试

相比单元测试,虚拟对象在整合测试中扮演了更为不寻常的角色。虚拟对象假扮成其他不相干的组件,其作用就是隔离受测对象。单元测试的受测对象是单个类;每个类都有自己不同的依赖关系,所以,每个受测类也有一套不同于其他类的虚拟对象。既然都是些不同的虚拟对象,那么它们各自的具体行为怎么样,怎么实现,一点也不重要。所以,对于单元测试来说,一些虚拟化框架,比如能快速创建虚拟对象的Mockito,就非常有用了

在整合测试场景中,虚拟对象显然不能用来隔离应用,相反,我们用它把应用和可能的外部交互对象隔离开来,如提供web service假数据和假反馈。如果是在BeatBox应用里,你很可能就要提供虚拟SoundPool,让它告诉你某个声音文件何时播放。显然,相比常见的行为虚拟,这种虚拟太重了,而且还要在很多整合测试里共享。这真不如手动写假对象。所以,做整合测试时,最好避免使用像Mockito这样的自动虚拟测试框架

挑战练习:播放进度控制

让用户快速多听一些声音,请给BeatBox应用添加播放进度控制功能。完成后的界面如图所示。提示:在BeatBoxFragment中,使用SeekBar组件( developer.android.com/reference/android/widget/SeekBar.html)控制SoundPool的play(int, float, float, int, int, float)方法的播放速率参数值

21-9

  1. 在fragment_beat_box.xml布局文件里新增SeekBar和TextView:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<TextView
android:id="@+id/speed_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginStart="16dp"
android:text="@string/speed_label"
android:labelFor="@+id/seek_bar"/>
<SeekBar
android:id="@+id/seek_bar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:progress="100"
android:max="200" />
  1. 新增字符资源speed_label:
1
<string name="speed_label">Playback Speed:  %d%%</string>
  1. BeatBox类新增mSpeed属性和setPlaySpeed(int speed)方法:
1
2
3
4
private float mSpeed = 1.0f;
public void setPlaySpeed(int speed) {
mSpeed = speed / 100f;
}
  1. 在BeatBoxFragment类中的onCreateView方法里找到SeekBar并设置监听事件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
binding.speedLabel.setText(getString(R.string.speed_label, 100));
binding.seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
binding.speedLabel.setText(getString(R.string.speed_label, progress));
mBeatBox.setPlaySpeed(progress);
}

@Override
public void onStartTrackingTouch(SeekBar seekBar) {

}

@Override
public void onStopTrackingTouch(SeekBar seekBar) {

}
});

第22章 样式与主题

颜色资源

首先,我们来定义本章要用到的颜色资源,在res/values中编辑colors.xml文件:

1
2
3
4
5
6
7
8
9
10
<resources>
<color name="colorPrimary">#008577</color>
<color name="colorPrimaryDark">#00574B</color>
<color name="colorAccent">#D81B60</color>
<color name="red">#F44336</color>
<color name="dark_red">#C3352B</color>
<color name="gray">#607D8B</color>
<color name="soothing_blue">#0083BF</color>
<color name="dark_blue">#005A8A</color>
</resources>

样式

样式是能够应用于视图组件的一套属性

打开res/values/styles.xml样式文件,添加BeatBoxButton新样式:

1
2
3
<style name="BeatBoxButton">
<item name="android:background">@color/dark_blue</item>
</style>

新建样式名叫BeatBoxButton。该样式仅定义了android:background属性,属性值为深蓝色。样式可以为很多组件共用,更新修改属性时,只修改公共样式定义就行了

定义好样式,把它添加给各个按钮:

1
2
3
4
5
6
7
<Button
style="@style/BeatBoxButton"
android:layout_width="match_parent"
android:layout_height="120dp"
android:onClick="@{() -> viewModel.onButtonClicked()}"
android:text="@{viewModel.title}"
tools:text="Sound name"/>

样式继承

样式支持继承。一个样式能继承并覆盖其他样式的属性

创建一个名叫BeatBoxButton.Strong的新样式。除了继承BeatBoxButton样式的按钮背景属性,再添加自己的android:textStyle属性,用粗体显示按钮文字

1
2
3
<style name="BeatBoxButton.Strong">
<item name="android:textStyle">bold</item>
</style>

新 样 式 的 命 名 有 点 特 别 。 BeatBoxButton.Strong 的 命 名 表 明 , 这 个 新 样 式 继 承 了BeatBoxButton样式的属性

除了通过命名表示样式继承关系,也可以采用指定父样式的方式:

1
2
3
<style name="StrongBeatBoxButton" parent="@style/BeatBoxButton">
<item name="android:textStyle">bold</item>
</style>

更新list_item_sound.xml布局,用上新的粗体文字样式:

1
2
3
4
5
6
7
<Button
style="@style/BeatBoxButton.Strong"
android:layout_width="match_parent"
android:layout_height="120dp"
android:onClick="@{() -> viewModel.onButtonClicked()}"
android:text="@{viewModel.title}"
tools:text="Sound name"/>

主题

在styles.xml公共文件中,可以为所有组件定义一套样式属性共用。可惜,定义公共样式属性虽方便,实际应用却很麻烦:需要逐个为所有组件添加它们要用到的样式

主题可看作样式的进化加强版。同样是定义一套公共主题属性,样式属性需要逐个添加,而主题属性则会自动应用于整个应用

修改默认主题

创建BeatBox项目时,向导给了它默认主题。找到并打开AndroidManifest.xml文件,可以看到application标签下的theme属性

1
2
3
4
5
6
7
8
9
10
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.bignerdranch.android.beatbox" >
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppTheme">
...
</application>
</manifest>

theme属性指向的主题叫AppTheme。它也定义在styles.xml文件中

可见,主题实际就是一种样式。但是主题指定的属性有别于样式。既然能在manifest文件中声明它,主题威力大增。同时解释了为什么主题可以自动应用于整个应用

打开res/values/styles.xml文件

1
2
3
4
5
6
7
8
9
<resources>
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
...
</style>
<style name="BeatBoxButton">
<item name="android:background">@color/dark_blue</item>
</style>
...
</resources>

AppTheme现在继承Theme.AppCompat.Light.DarkActionBar的全部属性。如有需要,可以添加自己的属性值,或是覆盖父主题的某些属性值

AppCompat库自带三大主题:

  • Theme.AppCompat——深色主题
  • Theme.AppCompat.Light——浅色主题
  • Theme.AppCompat.Light.DarkActionBar——带深色工具栏的浅色主题

添加主题颜色

主题属性则适用所有使用同一主题的组件。例如,工具栏会以主题的colorPrimary属性设置自己的背景色

  1. colorPrimary属性主要用于工具栏。由于应用名称是显示在工具栏上的, colorPrimary也可以称为应用品牌色
  2. colorPrimaryDark用于屏幕顶部的状态栏

注意,只有Lollipop以后的系统支持状态栏主题色。对于之前的系统,无论指定什么主题色,状态栏都是不变的黑底色

  1. colorAccent应该和colorPrimary形成反差效果,主要用于给EditText这样的组件着色

按钮组件不支持着色,所以colorAccent主题色在BeatBox项目中没有效果

覆盖主题属性

主题已经设置了背景色,在此基础上再设置其他颜色,就是自己给自己找事。而且,在应用里到处复制使用背景属性设置代码也不利于后期维护

主题探秘

要解决上述问题,应设法覆盖主题背景色属性

修改按钮属性

你可以在主题中定义一个用于所有按钮的样式

逐级定位查找主题, 我们找到Base.V7.Theme.AppCompat里的buttonStyle属性,这个属性指定应用中普通按钮的样式

继承Widget.AppCompat.Button样式,就是首先让所有按钮都继承常规按钮的属性。然后根据需要,有选择性地修改一些属性

如果不指定BeatBoxButton样式的父样式,所有按钮会变得不再像个按钮,连按钮中间显示的文字都会丢失

深入学习:样式继承拾遗

碰到Platform.AppCompat这个主题:

1
2
3
<style name="Platform.AppCompat" parent="android:Theme">
...
</style>

这里,继承是直接使用parent属性来表示的。为什么呢?

要以主题名的形式指定父主题,有继承关系的两个主题都应处于同一个包中。因此,对于Android操作系统内部主题间的继承,就可以直接使用主题名继承表示法。同理, AppCompat库内部也是这样。然而,一旦AppCompat库要跨库继承,就一定要明确使用parent属性

在开发自己的应用时,应遵守同样的规则。如果是继承自己内部的主题,使用主题名指定父主题即可;如果是继承Android操作系统中的样式或主题,记得使用parent属性

深入学习:引用主题属性

在主题中定义好属性后,可以在XML或代码中直接使用它们

在XML中引用具体值(如颜色值)时,我们使用@符号。 @color/gray指向某个特定资源

在主题中引用资源时,使用?符号

1
2
3
4
5
6
7
<Button xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/list_item_sound_button"
android:layout_width="match_parent"
android:layout_height="120dp"
android:background="?attr/colorAccent"
tools:text="Sound name"/>

第23章 XML drawable

在Android世界里,凡是要在屏幕上绘制的东西都可以叫作drawable,比如抽象图形、 Drawable类的子类代码、位图图像等。state list drawable、 shape drawable和layer list drawable。这三个drawable都定义在XML文件中,可以归为一类,统称为XML drawable

shape drawable

使用ShapeDrawable,可以把按钮变成圆。 XML drawable和屏幕像素密度无关,所以无需考虑创建特定像素密度目录,直接把它放入默认的drawable文件夹就可以了

打开项目工具窗口,在res/drawable目录下创建一个名为button_beat_box_normal.xml的文件:

1
2
3
4
5
<shape xmlns:android="http://schemas.android.com/apk/res/android" 
android:shape="oval">
<solid
android:color="@color/dark_blue"/>
</shape>

该XML文件定义了一个背景为深蓝色的圆形。也可使用shape drawable定制其他各种图形,如长方形、线条以及梯形等

state list drawable

首先定义一个用于按钮按下状态的shape drawable。在res/drawable目录下再创建一个名为button_beat_box_pressed.xml的文件

1
2
3
4
5
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid
android:color="@color/red"/>
</shape>

接下来,要在按钮按下时使用这个新建的shape drawable。这需要用到state list drawable

根据按钮的状态, state list drawable可以切换指向不同的drawable。按钮没有按下的时候指向button_beat_box_normal, 按下的时候就指向button_beat_box_pressed

在drawable目录中,定义一个state list drawable

1
2
3
4
5
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/button_beat_box_pressed"
android:state_pressed="true"/>
<item android:drawable="@drawable/button_beat_box_normal" />
</selector>

注意:这里一点更要注意顺序,要把按下的效果放在第一项,否则没有效果

除了按下状态, state list drawable还支持禁用、聚焦以及激活等状态。若想详细了解,请访问网页: developer.android.com/guide/topics/resources/drawable-resource.html#StateList

layer list drawable

layer list drawable能让两个XML drawable合二为一

借助这个工具,可以为按下状态的按钮添加一个深色的圆环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid
android:color="@color/red"/>
</shape>
</item>
<item>
<shape
android:shape="oval">
<stroke
android:width="4dp"
android:color="@color/dark_red"/>
</shape>
</item>
</layer-list>

现在, layer list drawable中指定了两个drawable。第一个是和以前一样的红圈。第二个则会绘制在第一个圈上,它定义了一个4dp粗的深红圈。这会产生一个暗红的圈

这两个drawable可以组成一个layer list drawable。多个当然也可以,会获得一些更复杂的效果

深入学习:为什么要用 XML drawable

应用总需要切换按钮状态,所以state list drawable是Android开发不可或缺的工具。那shape drawable和layer list drawable呢?应该用吗?

XML drawable用起来方便灵活,不仅用法多样,还易于更新维护。搭配使用shape drawable和layer list drawable可以做出复杂的背景图,连图像编辑器都省了。更改BeatBox应用的配色更是简单,直接修改XML drawable中的颜色就行了

XML drawable独立于屏幕像素密度,可在不带屏幕密度资源修饰符的drawable目录中直接定义。如果是普通图像,就需要准备多个版本,以适配不同屏幕像素密度的设备;而XMLdrawable只要定义一次,就能在任何设备的屏幕上表现出色

深入学习:使用 mipmap 图像

资源修饰符和drawable用起来都很方便。应用要用到图像,就针对不同的设备尺寸准备不同尺寸的图片,再分别放入drawable-mdpi和drawable-hdpi这样的文件夹。然后,按名字引用它们。剩下的就交给Android了,它会根据当前设备的屏幕密度调用相应的图片

但是,发布应用到Google应用商店时, APK文件包含了项目drawable目录里的所有图片。这里面有些图片甚至从来不会用到。这是个负担

为解决这个问题,有人想到针对设备定制APK,比如mdpi APK一个, hdpi APK一个,等等。(有关APK分包的详细信息,可参阅工具文档网页: tools.android.com/tech-docs/new-build-system/user-guide/apk-splits。)

但问题解决得不够彻底。假如想保留各个屏幕像素密度的启动图标呢?Android启动器是个常驻主屏幕的应用(详见第24章)。按下设备的主屏幕键,会回到启动器应用界面

有些新版启动器会显示大尺寸应用图标。想让大图标清晰好看,启动器就需要使用更高分辨率的图标。对于hdpi设备,要显示大图标,启动器就会使用xhdpi图标。找不到的话,就只能使用低分辨率的图标。可想而知,放大拉伸后的图标肯定很糟

Android的另一解决办法是使用mipmap目录

据此,我们有个推荐做法:把应用启动器图标放在mipmap目录中,其他图片都放在drawable目录中

深入学习:使用 9-patch 图像

9-patch图像是一种特别处理过的文件,能让Android知道图像的哪些部分可以拉伸,哪些部分不可以。只要处理得当,就能确保背景图的边角与原始图像保持一致

为什么要叫作9-patch呢? 9-patch图像分成3× 3的网格,即由9部分或9 patch组成的网格。网格角落部分不会被缩放,边缘部分的4个patch只按一个维度缩放,而中间部分则按两个维度缩放

23-1

9-patch图像和普通PNG图像十分相似,只有两处不同: 9-patch图像文件名以.9.png结尾,图像边缘具有1像素宽度的边框。这个边框用以指定9-patch图像的中间位置。边框像素绘制为黑线,以表明中间位置,边缘部分则用透明色表示

任意图形编辑器都可用来创建9-patch图像,但Android SDK自带的draw9patch工具用起来更方便

  1. 首先,把两张新背景图转换为9-patch图像(后缀名改为.9.png)

  2. 然后,双击默认图片在Android Studio内置的9-patch工具中打开

  3. 在9-patch工具中,首先,为让图片更醒目,勾选上Show patches选项。然后,把图像顶部和左边框填充为黑色,以标记图像的可伸缩区域

    23-2

    图片的顶部黑线指定了水平方向的可拉伸区域。左边的黑线标记在竖直方向哪些像素可以拉伸

使用内容区让按钮上的文字居中。现在继续编辑ic_button_beat_box_default.9.png,在图片上添加上右边和底部两条线。同时勾选上Show content选项。这个选项会让预览器高亮显示图片的文字显示区

23-3

挑战练习:按钮主题

完成应用9-patch图片更新后,你可能已注意到按钮的背景图有点不对劲:图片折角后面似乎有阴影。你甚至还注意到,只有在Lollipop或更高系统版本上运行应用时,图片折角后面才会出现阴影。

实际上,这个阴影是按钮默认在Lollipop或更高系统版本获得的一种浮层效果。按下按钮时,它会向你的手指靠拢(详见第35章)。

现在,不替换背景图,去掉这个阴影。回顾前面学的主题相关知识,看看这个阴影是怎么产生的。再思考思考:要解决这个问题,有没有其他按钮样式可用(作为BeatBoxButton样式的父样式)?

  1. 产生阴影的原因是继承的父样式Widget.Material.Button中指定了动画文件button_state_list_anim_material

    1
    <item name="stateListAnimator">@anim/button_state_list_anim_material</item>

    button_state_list_anim_material中设置了按下按钮的动画效果:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <item android:state_pressed="true" android:state_enabled="true">
    <set>
    <objectAnimator android:propertyName="translationZ"
    android:duration="@integer/button_pressed_animation_duration"
    android:valueTo="@dimen/button_pressed_z_material"
    android:valueType="floatType"/>
    <objectAnimator android:propertyName="elevation"
    android:duration="0"
    android:valueTo="@dimen/button_elevation_material"
    android:valueType="floatType"/>
    </set>
    </item>
  1. 要解决这个问题,可覆盖android:stateListAnimator
1
<item name="android:stateListAnimator">@null</item>
  1. 或者直接使用Base.Widget.AppCompat.Button.Borderless作为BeatBoxButton的父样式:
1
2
3
<style name="BeatBoxButton" parent="Base.Widget.AppCompat.Button.Borderless">
<item name="android:background">@drawable/button_beat_box</item>
</style>

第24章 深入学习intent和任务

本章将使用隐式intent创建一个替换Android默认启动器的应用。新建应用名为NerdLauncher

解析隐式 intent

要实现列出设备上的可启动应用,(可启动应用是指点击主屏幕或启动器界面上的图标就能打开的应用。)会使用PackageManager获取所有可启动主activity。可启动主activity都带有包含MAIN操作和LAUNCHER类别的intent 过滤器

1
2
3
4
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

创建一个隐式intent并从PackageManager那里获取匹配它的所有activity。最后,记录下PackageManager返回的activity总数

1
2
3
4
5
6
7
private void setupAdapter() {
Intent startupIntent = new Intent(Intent.ACTION_MAIN);
startupIntent.addCategory(Intent.CATEGORY_LAUNCHER);
PackageManager pm = getActivity().getPackageManager();
List<ResolveInfo> activities = pm.queryIntentActivities(startupIntent, 0);
Log.i(TAG, "Found " + activities.size() + " activities.");
}

在CriminalIntent应用中,为使用隐式intent发送crime报告,我们先创建隐式intent,再将其封装在选择器intent中,最后调用startActivity(Intent)方法发送给操作系统:

1
2
3
4
Intent i = new Intent(Intent.ACTION_SEND);
... // Create and put intent extras
i = Intent.createChooser(i, getString(R.string.send_report));
startActivity(i);

这里没有使用上述处理方式,是不是很费解?原因很简单: MAIN/LAUNCHER intent过滤器可能无法与通过startActivity(…)方法发送的MAIN/LAUNCHER隐式intent相匹配

事实上, startActivity(Intent)方法意味着“启动匹配隐式intent的默认activity”,而不是想当然的“启动匹配隐式intent的activity”。调用startActivity(Intent)方法(或startActivityForResult(…)方法)发送隐式intent时,操作系统会悄悄为目标intent添加Intent.CATEGORY_DEFAULT类别

因此,如果希望intent过滤器匹配startActivity(…)方法发送的隐式intent,就必须在对应的intent过滤器中包含DEFAULT类别

定义了MAIN/LAUNCHER intent过滤器的activity是应用的主要入口点。它只负责做好作为应用主要入口点要处理的工作。它通常不关心自己是否为默认的主要入口点,所以可以不包含CATEGORY_DEFAULT类别

所以,我们转而使用intent直接向PackageManager查询带有MAIN/LAUNCHER intent过滤器的activity

接下来,需要在NerdLauncherFragment的RecyclerView视图中显示查询到的activity标签。activity标签是用户可以识别的展示名称。既然查询到的activity都是启动activity,标签名通常也就是应用名

在PackageManager返回的ResolveInfo对象中,可以获取activity标签和其他一些元数据

  1. 首先,使用ResolveInfo.loadLabel(PackageManager)方法,对ResolveInfo对象中的activity标签按首字母排序
1
2
3
4
5
6
7
8
Collections.sort(activities, new Comparator<ResolveInfo>() {
public int compare(ResolveInfo a, ResolveInfo b) {
PackageManager pm = getActivity().getPackageManager();
return String.CASE_INSENSITIVE_ORDER.compare(
a.loadLabel(pm).toString(),
b.loadLabel(pm).toString());
}
});
  1. 然后,定义一个ViewHolder用来显示activity标签名
1
2
3
4
5
6
7
8
9
10
11
12
13
14
private class ActivityHolder extends RecyclerView.ViewHolder {
private ResolveInfo mResolveInfo;
private TextView mNameTextView;
public ActivityHolder(View itemView) {
super(itemView);
mNameTextView = (TextView) itemView;
}
public void bindActivity(ResolveInfo resolveInfo) {
mResolveInfo = resolveInfo;
PackageManager pm = getActivity().getPackageManager();
String appName = mResolveInfo.loadLabel(pm).toString();
mNameTextView.setText(appName);
}
}
  1. 接下来实现RecyclerView.Adapter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private class ActivityAdapter extends RecyclerView.Adapter<ActivityHolder> {
private final List<ResolveInfo> mActivities;
public ActivityAdapter(List<ResolveInfo> activities) {
mActivities = activities;
}
@Override
public ActivityHolder onCreateViewHolder(ViewGroup parent, int viewType) {
LayoutInflater layoutInflater = LayoutInflater.from(getActivity());
View view = layoutInflater
.inflate(android.R.layout.simple_list_item_1, parent, false);
return new ActivityHolder(view);
}
@Override
public void onBindViewHolder(ActivityHolder holder, int position) {
ResolveInfo resolveInfo = mActivities.get(position);
holder.bindActivity(resolveInfo);
}
@Override
public int getItemCount() {
return mActivities.size();
}
}
  1. 最后,更新setupAdapter()方法,创建一个ActivityAdapter实例并配置给RecyclerView
1
mRecyclerView.setAdapter(new ActivityAdapter(activities));

在运行时创建显式 intent

要创建启动activity的显式intent,需要从ResolveInfo对象中获取activity的包名与类名。这些信息可以从ResolveInfo对象的ActivityInfo中获取

  1. 更新ActivityHolder类实施一个点击监听器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private class ActivityHolder extends RecyclerView.ViewHolder implements View.OnClickListener {
···
public ActivityHolder(View itemView) {
super(itemView);
mNameTextView = (TextView) itemView;
mNameTextView.setOnClickListener(this);
}
···
@Override
public void onClick(View v) {
ActivityInfo activityInfo = mResolveInfo.activityInfo;
Intent i = new Intent(Intent.ACTION_MAIN)
.setClassName(activityInfo.applicationInfo.packageName,activityInfo.name);
startActivity(i);
}
}

注意,作为显式intent的一部分,我们还发送了ACTION_MAIN操作。发送的intent是否包含操作,对于大多数应用来说没有什么差别。不过,有些应用的启动行为可能会有所不同。取决于不同的启动要求,同样的activity可能会显示不同的用户界面。开发人员最好能明确启动意图,以便让activity完成它应该完成的任务

在这里,使用包名和类名创建显式intent时,我们使用了以下Intent方法:

1
public Intent setClassName(String packageName, String className)

这不同于以往创建显式intent的方式。之前,我们使用的是接受Context和Class对象的Intent构造方法:

1
public Intent(Context packageContext, Class<?> cls)

该构造方法使用传入的参数来获取Intent需要的ComponentName。 ComponentName由包名和类名共同组成。传入Activity和Class创建Intent时,构造方法会通过Activity类自行确定全路径包名

也可以自己通过包名和类名创建ComponentName,然后使用下面的Intent方法创建显式intent:

1
public Intent setComponent(ComponentName component)

不过, setClassName(…)方法能够自动创建组件名,用它可以少写不少代码呢

任务与回退栈

应用运行时, Android使用任务来跟踪用户的状态

任务是一个activity栈。栈底部的activity通常称为基activity。栈顶的activity用户能看得到。如果按后退键,栈顶activity会弹出栈外。如果用户看到的是基activity,按后退键,系统就会回到主屏幕

默认情况下,新activity都在当前任务中启动

24-1

在当前任务中启动activity的好处是,用户可以在任务内而不是在应用层级间导航返回

24-2

在任务间切换

在不影响各个任务状态的情况下, overview screen可以让我们在任务间切换

启动新任务

有时你需要在当前任务中启动activity,而有时又需要在新任务中启动activity

为了在启动新activity时启动新任务,需要为intent添加一个标志Intent.FLAG_ACTIVITY_NEW_TASK

1
2
3
4
Intent i = new Intent(Intent.ACTION_MAIN)
.setClassName(activityInfo.applicationInfo.packageName,
activityInfo.name).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(i);

FLAG_ACTIVITY_NEW_TASK标志控制每个activity仅创建一个任务

使用 NerdLauncher 应用作为设备主屏幕

开NerdLauncher项目的AndroidManifest. xml,向intent主过滤器添加以下节点定义

1
2
3
4
5
6
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>

添加HOME和DEFAULT类别定义后, NerdLauncher应用的activity会成为可选的主界面

挑战练习:应用图标

前面,为在启动器应用中显示各个activity的名称,你使用了ResolveInfo.loadLabel(…)方法。 loadIcon()是ResolveInfo类的另一个方法,可以用它为每个应用加载显示图标。作为练习,请给NerdLauncher应用中显示的所有应用添加图标

  1. 创建列表项的视图文件item_app.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:orientation="horizontal">
<ImageView
android:id="@+id/app_icon"
android:layout_width="48dp"
android:layout_height="48dp" />
<TextView
android:id="@+id/app_name"
android:textStyle="bold"
android:gravity="center|start"
android:layout_marginStart="16dp"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
  1. 在ActivityAdapter中绑定视图
1
2
3
4
5
public ActivityHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
LayoutInflater inflater = LayoutInflater.from(getActivity());
View view = inflater.inflate(R.layout.item_app, parent, false);
return new ActivityHolder(view);
}
  1. 在ActivityHolder中找到组件并初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private class ActivityHolder extends RecyclerView.ViewHolder implements View.OnClickListener {
private ResolveInfo mResolveInfo;
private TextView mNameTextView;
private ImageView mIconImageView;
···
public ActivityHolder(@NonNull View itemView) {
super(itemView);
mIconImageView = itemView.findViewById(R.id.app_icon);
mNameTextView = itemView.findViewById(R.id.app_name);
itemView.setOnClickListener(this);
}
public void bindActivity(ResolveInfo resolveInfo){
mResolveInfo = resolveInfo;
PackageManager pm = getActivity().getPackageManager();
Drawable icon = mResolveInfo.loadIcon(pm);
String appName = mResolveInfo.loadLabel(pm).toString();
mIconImageView.setImageDrawable(icon);
mNameTextView.setText(appName);
}
}

深入学习:进程与任务

对象需要内存和虚拟机的支持才能生存。 进程是操作系统创建的、供应用对象生存以及应用运行的地方

进程通常会占用由操作系统管理着的系统资源,如内存、网络端口以及打开的文件等。进程还拥有至少一个(可能多个)执行线程。在Android系统中,每个进程都需要一个虚拟机来运行

尽管存在未知的异常情况,但总的来说, Android世界里的每个应用组件都仅与一个进程相关联。应用伴随着自己的进程一起完成创建,该进程同时也是应用中所有组件的默认进程

每一个activity实例都仅存在于一个进程之中,同一个任务关联。这也是进程与任务的唯一相似之处

24-3

本章,我们创建了任务并实现了任务间的切换。有没有想过替换Android默认的overview screen呢?很遗憾,做不到, Android没告诉我们怎么做。另外,你应该知道, Google Play商店中那些自称为任务终止器的应用,实际上都是进程终止器

深入学习:并发文档

在Lollipop设备上,对以android.intent.action.SEND或action.intent.action.SEND_MULTIPLE启动的activity,隐式intent选择器会创建独立的新任务

这种现象要归因于Lollipop中叫作并发文档( concurrent document)的新概念。有了并发文档,就可以为运行的应用动态创建任意数目的任务。在Lollipop之前,应用任务只能预先定义好,而且还要在manifest文件中指明

在Lollipop设备上,如果需要应用启动多个任务,可采用两种方式:给intent打上Intent.FLAG_ACTIVITY_NEW_DOCUMENT标签,再调用startActivity(…)方法;或者在manifest文件中,为activity设置如下documentLaunchMode:

1
2
3
4
5
<activity
android:name=".CrimePagerActivity"
android:label="@string/app_name"
android:parentActivityName=".CrimeListActivity"
android:documentLaunchMode="intoExisting" />

使用上述方法,一份文档只会对应一个任务。(如果发送带有和已存在任务相同数据的intent,系统就不会再创建新任务。)如果无论如何都想创建新任务,那就给intent同时打上Intent.FLAG_ACTIVITY_NEW_DOCUMENT和Intent.FLAG_ACTIVITY_MULTIPLE_TASK标签,或者把manifest文件中的documentLaunchMode属性值改为always

第25章 HTTP与后台任务

为学习Android网络应用的开发,我们来创建一个名为PhotoGallery的应用。 PhotoGallery是图片共享网站Flickr的一个客户端应用,它能获取并展示Flickr网站的最新公共图片

说明:以上是书中原文,由于众所周知的原因,书中的Demo程序使用的Flickr网站国内访问不了,所以这里我将使用干货集中营里的妹子图API

本章,我们首先学习应用级HTTP网络编程。当前,几乎所有网络服务的开发都是以HTTP网络协议为基础的

创建 PhotoGallery 应用

PhotoGallery应用继续沿用前面一直使用的设计架构。让PhotoGalleryActivity继承SingleFragmentActivity,其视图为activity_fragment.xml中定义的容器视图。它会负责托管稍后会创建的PhotoGalleryFragment实例

网络连接基本

PhotoGallery应用中,我们需要一个网络连接专用类。应用要访问的是gank.io网站,因此新建一个名为GankFetchr的Java类。

GankFetchr类一开始只有getUrlBytes(String)和getUrlString(String)两个方法。getUrlBytes(String)方法能从指定URL获取原始数据并返回一个字节流数组。 getUrlString(String)方法则将getUrlBytes(String)方法返回的结果转换为String。(苏乞儿注:除此之外新增getRedirecUrl(String)方法,因为gank.io网站的api返回的图片url需要两次重定向)

在GankFetchr.java中,实现这三个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public byte[] getUrlBytes(String urlSpec) throws IOException {
URL url = new URL(urlSpec);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
try {
ByteArrayOutputStream out = new ByteArrayOutputStream();
InputStream in = connection.getInputStream();
if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) {
throw new IOException(connection.getResponseMessage() + ": with " + urlSpec);
}
int len = 0;
byte[] buffer = new byte[1024];
while ((len = in.read(buffer)) > 0) {
out.write(buffer, 0, len);
}
out.close();
return out.toByteArray();
} finally {
connection.disconnect();
}
}
public String getUrlString(String urlSpec) throws IOException {
return new String(getUrlBytes(urlSpec));
}
private String getRedirectUrl(String path){
String url = null;
try {
HttpURLConnection connection = (HttpURLConnection) new URL(path).openConnection();
connection.setInstanceFollowRedirects(false);
String location = connection.getHeaderField("Location");
connection = (HttpURLConnection) new URL(location).openConnection();
connection.setInstanceFollowRedirects(false);
url = connection.getHeaderField("Location");
} catch (IOException e) {
e.printStackTrace();
}
return url;
}

在getUrlBytes(String)方法中,首先根据传入的字符串参数,如https://www.bignerdranch.com,创建一个URL对象。然后调用openConnection()方法创建一个指向要访问URL的连接对象。URL.openConnection()方法默认返回的是URLConnection对象,但要连接的是http URL,因此需将其强制类型转换为HttpURLConnection对象。 这让我们得以调用它的getInputStream()、getResponseCode()等方法

虽然HttpURLConnection对象提供了一个连接,但只有在调用getInputStream()方法时(如果是POST请求,则调用getOutputStream()方法),它才会真正连接到指定的URL地址,才会给你反馈代码

创建了URL并打开网络连接之后,便可循环调用read()方法读取网络数据,直到取完为止。只要还有数据, InputStream类就会不断地输出字节流数据。数据全部取回后,关闭网络连接,并将他们写入ByteArrayOutputStream字节数组中

getUrlString(String)负责将getUrlBytes(String)方法获取的字节数据转换为String

获取网络使用权限

要连接网络,还需完成一件事:取得使用网络的权限。正如用户怕被偷拍一样,他们也不想应用偷偷下载图片。
要取得网络使用权限,先要在AndroidManifest.xml文件中添加它:

1
2
3
4
5
6
7
8
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.bignerdranch.android.photogallery" >
<uses-permission android:name="android.permission.INTERNET" />
<application
...
</application>
</manifest>

用户下载应用时(比如PhotoGallery),会看到一个注明需要网络连接权限的对话框,用户可以选择接受或拒绝安装

如今,大部分应用都需要联网,所以, Android视INTERNET权限为非危险性权限。这样一来,你只要在manifest文件里做个声明,就可以直接使用它了。而有些危险性权限(如获取设备地理位置信息权限),既需要声明又需要运行时动态申请(详见第33章)

使用 AsyncTask 在后台线程上运行代码

不要直接在PhotoGalleryFragment类中调用FlickrFetchr.getURLString(String)方法。正确的做法是,创建一个后台线程,然后在该线程中运行代码

使用后台线程最简便的方式是使用AsyncTask工具类。 AsyncTask创建后台线程后,我们便可在该线程上调用doInBackground(…)方法运行代码

在PhotoGalleryFragment.java中,添加一个名为FetchItemsTask的内部类。覆盖AsyncTask.doInBackground(…)方法,从目标网站获取数据并记录日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static final String TAG = "PhotoGalleryFragment";
private class FetchItemsTask extends AsyncTask<Void,Void,Void> {
@Override
protected Void doInBackground(Void... params) {
try {
String result = new FlickrFetchr()
.getUrlString("https://www.bignerdranch.com");
Log.i(TAG, "Fetched contents of URL: " + result);
} catch (IOException ioe) {
Log.e(TAG, "Failed to fetch URL: ", ioe);
}
return null;
}
}

然后,在PhotoGalleryFragment.onCreate(…)方法中,调用FetchItemsTask新实例的execute()方法

1
new FetchItemsTask().execute();

调用execute()方法会启动AsyncTask,进而触发后台线程并调用doInBackground(…)方法。运行PhotoGallery应用,查看LogCat窗口,可以看到一大堆Big Nerd Ranch网站主页HTML代码

既然已创建了后台线程,并成功完成了网络连接代码的测试,接下来,我们来深入学习Android线程的知识

线程与主线程

Android禁止 任 何主线程网 络连 接行 为 。即 使强 行 为之 , Android也 会抛 出NetworkOnMainThreadException异常

线程是个单一执行序列。单个线程中的代码会逐步执行。所有Android应用的运行都是从主线程开始的。然而,主线程不是线程那样的预定执行序列。相反,它处于一个无限循环的运行状态,等着用户或系统触发事件。一旦有事件触发,主线程便执行代码做出响应

25-1

主线程运行着所有更新UI的代码,其中包括响应activity的启动、按钮的点击等不同UI相关事件的代码。(由于响应的事件基本都与用户界面相关,主线程有时也叫作UI线程。)

事件处理循环让UI代码总是按顺序执行。这样,事件就能一件件处理,不用担心互相冲突,同时代码也能够快速执行,及时响应。目前为止,我们编写的所有代码(刚刚使用AsyncTask工具类完成的代码除外)都是在主线程中执行的

超越主线程

连接网络相比其他任务,它更耗时。等待响应期间,用户界面毫无反应,这可能会导致应用无响应( application not responding, ANR)现象发生

如果Android系统监控服务确认主线程无法响应重要事件,如按下后退键等,则应用无响应会发生。用户就会看到下面的画面

25-2

要解决问题,我们需要创建一个后台线程,然后从该线程访问网络

怎样使用后台线程最容易?使用AsyncTask工具类

从 Flickr 获取 JSON 数据

JSON( JavaScript Object Notation)是近年流行开来的一种数据格式,尤其适用于Web服务。Android提供了标准的org.json包,可以利用包里的一些类创建和解析JSON数据。 Android开发者文档有其详细信息。要详细了解JSON数据格式,请访问json.org网站

干货集中营提供了方便而强大的JSON API。可从https://gank.io/api文档页查看使用细节

在GankFetchr类构建请求URL并获取内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void flickrItems() {
try {
String url = Uri.parse("https://gank.io/api/v2/data/category/")
.buildUpon()
.appendPath(CATEGORY)
.appendPath("type")
.appendPath(TYPE)
.appendPath("page")
.appendPath(PAGE)
.appendPath("count")
.appendPath(COUNT)
.build()
.toString();
String jsonString = getUrlString(url);
Log.i(TAG, "Received JSON: " + jsonString);
} catch (IOException e) {
Log.i(TAG, "Failed to fetch items", e);
}
}

这里,我们使用Uri.Builder构建了完整的Flickr API请求URL。便利类Uri.Builder可创建正确转义的参数化URL。 Uri.Builder.appendPath(String,String)可增加资源路径,除此之外还有Uri.Builder.appendQueryParameter(String,String)可自动转义查询字符串等

最后,修改PhotoGalleryFragment类中的AsyncTask内部类,调用新的fetchItems()方法

1
2
3
4
5
6
7
public class FetchItemsTask extends AsyncTask<Void, Void, Void>{
@Override
protected Void doInBackground(Void... params) {
new GankFetchr().flickrItems();
return null;
}
}

运行PhotoGallery应用。可看到LogCat窗口中的Flickr JSON数据

成功取得Flickr JSON返回结果后,该如何使用呢?和处理其他数据一样,将其存入一个或多个模型对象中。稍后会为PhotoGallery应用创建的模型类名为GalleryItem。下图为PhotoGallery应用的对象图解:

25-3

注意,为聚焦fragment和网络连接代码,上图并没有显示托管activity

创建GalleryItem类并添加有关代码

1
2
3
4
5
6
7
8
9
public class GalleryItem {
private String mCaption;
private String mId;
private String mUrl;
@Override
public String toString() {
return mCaption;
}
}

利用Android Studio自动为mCaption、 mId和mUrl变量生成getter与setter方法

完成模型层对象的创建后,接下来的任务就是塞入JSON解析数据

解析 JSON 数据

浏览器和LogCat中显示的JSON数据难以阅读。如果用空格回车符格式化后再打印出来,结果大致如图所示

格式化后的json数据

JSON对象是一系列包含在{ }中的名值对。 JSON数组是包含在[ ]中用逗号隔开的JSON对象列表。对象彼此嵌套形成层级关系

json.org API 提 供 有 对 应 JSON 数 据 的 Java 对 象 , 如 JSONObject和 JSONArray。 使 用JSONObject(String)构造函数,可以很方便地把JSON数据解析进相应的Java对象。更新fetchItems()方法执行解析任务

1
2
3
4
5
6
7
8
9
10
11
public void fetchItems() {
try {
...
Log.i(TAG, "Received JSON: " + jsonString);
JSONObject jsonBody = new JSONObject(jsonString);
} catch (IOException ioe) {
Log.e(TAG, "Failed to fetch items", ioe);
} catch (JSONException je){
Log.e(TAG, "Failed to parse JSON", je);
}
}

JSONObject构造方法解析传入的Flickr JSON数据后,会生成与原始JSON数据对应的对象树

写一个parseItems(…)方法,取出每张图片的信息,生成一个个GalleryItem对象,再将它们添加到List中

1
2
3
4
5
6
7
8
9
10
11
12
13
private void parseItems(List<GalleryItem> items, JSONObject jsonBody) throws JSONException {
JSONArray photoJsonArray = jsonBody.getJSONArray("data");
for (int i = 0; i < photoJsonArray.length(); i++) {
JSONObject photoJsonObject = photoJsonArray.getJSONObject(i);
GalleryItem item = new GalleryItem();
item.setId(photoJsonObject.getString("_id"));
item.setCapton(photoJsonObject.getString("title"));
String path = photoJsonObject.getString("url");
item.setUrl(getRedirectUrl(path));
item.setDesc(photoJsonObject.getString("desc"));
items.add(item);
}
}

解析JSONObject层级结构时,上述代码用了getJSONObject(String name)和getJSONArray(String name)这两个便利方法

parseItems(…)方法需要List和JSONObject参数。因此,还要更新fetchItems()方法,让它返回一个包含GalleryItem的List

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public List<GalleryItem> flickrItems() {
List<GalleryItem> items = new ArrayList<>();
try {
String url = Uri.parse("https://gank.io/api/v2/data/category/")
.buildUpon()
.appendPath(CATEGORY)
.appendPath("type")
.appendPath(TYPE)
.appendPath("page")
.appendPath(PAGE)
.appendPath("count")
.appendPath(COUNT)
.build()
.toString();
String jsonString = getUrlString(url);
Log.i(TAG, "Received JSON: " + jsonString);
JSONObject jsonBody = new JSONObject(jsonString);
parseItems(items, jsonBody);
} catch (IOException e) {
Log.i(TAG, "Failed to fetch items", e);
} catch (JSONException e) {
Log.i(TAG, "Failed to parse json", e);
}
return items;
}

运行PhotoGallery应用,测试JSON解析代码。现在, PhotoGallery应用还无法展示List中的内容。因此,要确认代码是否正确,需设置合适的断点,使用调试器来检查代码逻辑

从 AsyncTask 回到主线程

为完成本章的既定目标,我们回到视图层部分,实现在PhotoGalleryFragment类的RecyclerView中显示图片标题。首先定义一个ViewHolder内部类

1
2
3
4
5
6
7
8
9
10
private class PhotoHolder extends RecyclerView.ViewHolder {
private TextView mTitleTextView;
public PhotoHolder(View itemView) {
super(itemView);
mTitleTextView = (TextView) itemView;
}
public void bindGalleryItem(GalleryItem item) {
mTitleTextView.setText(item.toString());
}
}

接 下 来 , 添 加 一 个 RecyclerView.Adapter实 现 , 提 供 基 于 GalleryItem对 象 List的PhotoHolder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private class PhotoAdapter extends RecyclerView.Adapter<PhotoHolder> {
private List<GalleryItem> mGalleryItems;
public PhotoAdapter(List<GalleryItem> galleryItems) {
mGalleryItems = galleryItems;
}
@Override
public PhotoHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) {
TextView textView = new TextView(getActivity());
return new PhotoHolder(textView);
}
@Override
public void onBindViewHolder(PhotoHolder photoHolder, int position) {
GalleryItem galleryItem = mGalleryItems.get(position);
photoHolder.bindGalleryItem(galleryItem);
}
@Override
public int getItemCount() {
return mGalleryItems.size();
}
}

既然RecyclerView要显示的数据已准备就绪,那么接下来编码完成adapter的配置和关联

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class PhotoGalleryFragment extends Fragment {
private static final String TAG = "PhotoGalleryFragment";
private RecyclerView mPhotoRecyclerView;
private List<GalleryItem> mItems = new ArrayList<>();
...
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
View v = inflater.inflate(R.layout.fragment_photo_gallery, container, false);
mPhotoRecyclerView = (RecyclerView) v.findViewById(R.id.photo_recycler_view);
mPhotoRecyclerView.setLayoutManager(new GridLayoutManager(getActivity(), 3));
setupAdapter();
return v;
}
private void setupAdapter() {
if (isAdded()) {
mPhotoRecyclerView.setAdapter(new PhotoAdapter(mItems));
}
}
...
}

根据当前模型数据( GalleryItem对象List)的状态,刚才添加的setupAdapter()方法会自动配置RecyclerView的adapter。应在onCreateView(…)方法中调用该方法,这样每次因设备旋转重新生成RecyclerView时,可重新为其配置对应的adapter。另外,每次模型层对象发生变化时,也应及时调用该方法

注意,配置adapter前,应检查isAdded()的返回值是否为true。该检查确认fragment已与目标activity相关联,从而保证getActivity()方法返回结果非空

既然在用AsyncTask,说明正在从后台进程触发回调指令。因而不能确定fragment是否关联着activity。那就必须确认fragment是否仍与activity关联。如果没有关联,依赖于activity的操作(如创建PhotoAdapter,进而还会使用托管activity作为context来创建TextView)就会失败。所以,设置adapter之前,你需要确认isAdded()方法返回值

现在,从Flickr成功获取数据后,就需要调用setupAdapter()方法。你的第一反应可能是在FetchItemsTask的doInBackground(…)方法尾部调用它。这不是个好主意

在计算机里,内存对象相互踩踏会让应用崩溃。因此,安全起见,不推荐也不允许从后台线程更新UI

AsyncTask还有另一个可覆盖的onPostExecute(…)方法。onPostExecute(…)方法在doInBackground(…)方法执行完毕后才会运行。更为重要的是,它是在主线程而非后台线程上运行的。因此,在该方法中更新UI比较安全

修改FetchItemsTask类以新的方式更新mItems,并在成功获取图片后调用setupAdapter()方法更新RecyclerView的数据源

1
2
3
4
5
6
7
8
9
10
11
private class FetchItemsTask extends AsyncTask<Void,Void,List<GalleryItem>> {
@Override
protected List<GalleryItem> doInBackground(Void... params) {
return new FlickrFetchr().fetchItems();
}
@Override
protected void onPostExecute(List<GalleryItem> items) {
mItems = items;
setupAdapter();
}
}

上述代码有三处调整。首先,我们改变了FetchItemsTask类第三个泛型参数的类型。该参数是AsyncTask返回结果的数据类型。也就是doInBackground(…)方法返回结果的数据类型,以及onPostExecute(…)方法输入参数的数据类型

其次,我们让doInBackground(…)方法返回了GalleryItem对象List。这样既修正了代码编译错误,还将GalleryItem对象List传递给了onPostExecute(…)方法

最后,我们添加了onPostExecute(…)方法实现代码。该方法接收doInBackground(…)方法 返 回 的 GalleryItem 数 据 , 并 放 入 mItems 变 量 , 然 后 调 用 setupAdapter() 方 法 更 新RecyclerView视图的adapter

至此,本章任务就完成了。运行PhotoGallery应用,可看到屏幕上显示出全部已下载GalleryItem的标题

清理 AsyncTask

本章, AsyncTask运用得还算得当,因此不用去管理AsyncTask实例了。例如,我们保留了fragment(调用setRetainInstance(true)方法),这样即使设备旋转,也不会重复创建新的AsyncTask去获取JSON数据。然而,有些情况下,必须好好掌控它,必要时,甚至要能撤销或重新运行AsyncTask

针对某些复杂应用场景,我们需要将AsyncTask赋值给实例变量。这样,一旦掌控了它,就能随时调用AsyncTask.cancel(boolean)方法,撤销运行中的AsyncTask

AsyncTask.cancel(boolean)方法有两种工作模式:粗暴的和温和的。

  • 如果调用cancel(false)方法,它只是简单地设置isCancelled()的状态为true。随后, AsyncTask会检查isCancelled()状态,然后选择提前结束运行
  • 然而,如果调用cancel(true)方法,它会立即终止doInBackground(…)方法当前所在的线程。 AsyncTask.cancel(true)方法停止AsyncTask的方式简单粗暴,如果可能,应尽量避免

应该在什么时候、什么地方撤销AsyncTask呢?这要看情况了。先问问自己,如果fragment或 activity已销毁了或是看不到了, AsyncTask当 前的工作可以停止吗?如果可以,就 在onStop(…)方法里(看不到视图),或者在onDestroy(…)方法里( fragment/activity实例已销毁)撤销AsyncTask实例

即使fragment/activity已销毁了(或者视图已看不到了),也可以不撤销AsyncTask,让它运行至结束把事情做完。不过,这可能会引发内存泄漏(比如,没用的Activity实例本应销毁,但一直还在内存里),也可能会出现UI更新问题(因为UI已失效)。如果不管用户怎么操作,要确保重要工作能完成,那最好考虑其他解决方案,比如使用Service(详见第28章)

深入学习: AsyncTask 再探

你已知道如何使用AsyncTask的第三个类型参数,那另外两个类型参数呢?

  • 第一个类型参数可指定将要转给execute(…)方法的输入参数的类型,进而确定doInBackground(…)方法输入参数的类型。具体用法可参考以下示例:
1
2
3
4
5
6
7
8
AsyncTask<String,Void,Void> task = new AsyncTask<String,Void,Void>() {
public Void doInBackground(String... params) {
for (String parameter : params) {
Log.i(TAG, "Received parameter: " + parameter);
}
return null;
}
};

输入参数传入execute(…)方法(可接受一个或多个参数):

1
task.execute("First parameter", "Second parameter", "Etc.");

然后,再把这些变量参数传递给doInBackground(…)方法

  • 第二个类型参数可指定发送进度更新需要的类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
final ProgressBar gestationProgressBar = /* 进度条 */;
gestationProgressBar.setMax(42); /* 进度条允许的最大进度 */
AsyncTask<Void,Integer,Void> haveABaby = new AsyncTask<Void,Integer,Void>() {
public Void doInBackground(Void... params) {
while (!babyIsBorn()) {
Integer weeksPassed = getNumberOfWeeksPassed();
publishProgress(weeksPassed);
patientlyWaitForBaby();
}
}
public void onProgressUpdate(Integer... params) {
int progress = params[0];
gestationProgressBar.setProgress(progress);
}
};
/* 要执行异步任务时调用 */
haveABaby.execute();

进度更新通常发生在后台进程执行中途。问题是,在后台进程中无法完成必要的UI更新。因此AsyncTask提供了publishProgress(…)和onProgressUpdate(…)方法

其 工 作 方 式 是 这 样 的 : 在 后 台 线 程 中 , 从 doInBackground(…) 方 法 中 调 用publishProgress(…)方法。这样onProgressUpdate(…)方法便能够在UI线程上调用。因此,在onProgressUpdate(…)方法中执行UI更新就可行了,但必须在doInBackground(…)方法中使用publishProgress(…)方法对它们进行管控

深入学习: AsyncTask 的替代方案

在使用AsyncTask加载数据时,如果遇到设备配置变化,比如设备旋转,你得负责管理它的生命周期,同时还要保存好数据,不让其因旋转丢失。虽然调用Fragment的setRetainInstance(true)方法来保存数据可以解决问题,但它不是万能的。很多时候,你还得介入,编写特殊场景应对代码,让应用无懈可击。这些特殊场景有:用户在AsyncTask运行时按后退键,以及启动AsyncTask的fragment因内存紧张而被销毁

使用Loader是另一种可行的解决方案。它可以代劳很多(并非全部)棘手的事情。 Loader用来从某些数据源加载数据(对象)。数据源可以是磁盘、数据库、 ContentProvider、网络,甚至是另一进程

AsyncTaskLoader是个抽象Loader。它可以使用AsyncTask把数据加载工作转移到其他线程上。我们创建的loader类几乎都是AsyncTaskLoader的子类。 AsyncTaskLoader能在不阻塞主线程的前提下获取到数据,并把结果发送给目标对象。

相比AsyncTask,为什么要推荐使用loader呢?最重要的原因是,遇到类似设备旋转这样的场景时,LoaderManager会帮我们妥善管理loader及其加载的数据。而且, LoaderManager还负责启动和停止loader,以及管理loader的生命周期。怎么样?理由充足吧!设备配置改变后,如果初始化一个已经加载完数据的loader,它能立即提交数据,而不是再次尝试获取数据。无论fragment是否得到保留,它都会这样做。这下放心多了,从此再也不用考虑因保留fragment而产生的生命周期问题了

挑战练习: Gson

无论什么平台,把JSON数据转化为Java对象都是应用开发的常见任务,于是,聪明的开发者就创建了一些工具库,希望能简化JSON数据和Java对象的互转

Gson就是这样的一个工具库( github.com/google/gson)。不用写任何解析代码, Gson就能自动把JSON数据映射为Java对象。因为这个特性, Gson现在是开发者最喜爱的JSON解析库。

挑战自己,在应用中整合Gson库,简化FlickrFetchr中的JSON解析

  1. json数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"data": [
{
"_id": "5e958f3b17bf93950887f20a",
"author": "\u9e22\u5a9b",
"category": "Girl",
"createdAt": "2020-04-20 08:00:00",
"desc": "\u5355\u8eab\u7684\u65f6\u5019\u597d\u597d\u7ecf\u8425\u81ea\u5df1\uff0c\n\u672a\u6765\u9047\u5230\u90a3\u4e2a\u503c\u5f97\u4f60\u7231\u7684\u4eba\uff0c\u624d\u4e0d\u4f1a\u602f\u573a\u3002",
"images": ["http://gank.io/images/5a29ab0fc093408c82febe7c7e42e156"],
"likeCounts": 0,
"publishedAt": "2020-04-20 08:00:00",
"stars": 1,
"title": "\u7b2c61\u671f",
"type": "Girl",
"url": "http://gank.io/images/5a29ab0fc093408c82febe7c7e42e156",
"views": 46
}],
"page": 1,
"page_count": 7,
"status": 100,
"total_counts": 61
}
  1. 添加Gson依赖
  2. 新建Gallery.java
1
2
3
4
5
6
public class Gallery<T> {
private int page;
private int page_count;
private int status;
// 省略getter和setter方法
}
  1. 修改GankFetchr类中的parseItems方法
1
2
3
4
5
6
7
8
private void parseItems(List<GalleryItem> items, String jsonString) {
Gson gson = new Gson();
Type type = new TypeToken<Gallery<List<GalleryItem>>>() {
}.getType();
// 将json字符串转换成java对象
Gallery<List<GalleryItem>> gallery = gson.fromJson(jsonString, type);
items = gallery.getData();
}

参考链接:GSON

挑战练习:分页

gank.io的api中有个叫作page的参数,可以用它返回第二页、第三页等更多页数据

请实现一个RecyclerView.OnScrollListener方法,只要用户看完当前页,就使用下页返回结果替换当前页。想更有挑战的话,可以尝试把后续结果页添加到当前结果页后面

  1. 在onCreateView为RecyclerView添加滑动监听:
1
2
3
4
5
6
7
8
9
10
mPhotoRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
if (!mLoading && !recyclerView.canScrollVertically(1)) {
mLoading = true;
loadMore();
}
}
});
  1. 新建loadMore方法:
1
2
3
4
5
6
7
8
9
10
11
private void loadMore() {
if (mItems.size() % 10 != 0){
// 无更多数据时直接返回
Toast.makeText(getActivity(), "没有更多数据了", Toast.LENGTH_SHORT).show();
mLoading = false;
return;
}
// 下一页
int page = mItems.size() / 10 + 1;
new FetchItemsTask().execute(String.valueOf(page));
}
  1. 修改FetchItemsTask:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class FetchItemsTask extends AsyncTask<String, Void, List<GalleryItem>> {
@Override
protected List<GalleryItem> doInBackground(String... params) {
String param = params[0];
return new GankFetchr().flickrItems(param);
}

@Override
protected void onPostExecute(List<GalleryItem> galleryItems) {
if (mItems == null) {
mItems = galleryItems;
} else {
mItems.addAll(galleryItems);
}
setAdapter();
mLoading = false;
}
}
  1. 修改GankFetchr类重载flickrItems方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public List<GalleryItem> flickrItems() {
return flickrItems("1");
}

public List<GalleryItem> flickrItems(String page){
List<GalleryItem> items = new ArrayList<>();
try {
String url = Uri.parse("https://gank.io/api/v2/data/category/")
.buildUpon()
.appendPath(CATEGORY)
.appendPath("type")
.appendPath(TYPE)
.appendPath("page")
.appendPath(page)
.appendPath("count")
.appendPath(COUNT)
.build()
.toString();
Log.i(TAG, "The Url: " + url);
String jsonString = getUrlString(url);
Log.i(TAG, "Received JSON: " + jsonString);
parseItems(items, jsonString);
} catch (IOException e) {
Log.i(TAG, "Failed to fetch items", e);
}
return items;
}

挑战练习:动态调整网格列

当前,显示图片标题的网格固定有3列。编写代码动态调整网格列数,实现在横屏或大屏幕设备上显示更多列标题。

实现这个目标有个简单方法:分别为不同的设备配置或屏幕尺寸提供整数修饰资源。这实际和第17章中为不同尺寸屏幕提供不同布局的方式差不多。整数修饰资源应放置在res/values目录中。具体实施细节可参阅Android开发者文档

提供整数修饰资源的方式不太好确定网格列细分粒度(只能凭经验预先定义列数)。下面再介绍一个颇具挑战的方法:在fragment的视图创建时就计算并设置好网格列数。显然,这种方式更加灵活实用。基于RecyclerView的当前宽度和预定义网格列宽,就可以计算出列数

实施前还有个问题要解决:你不能在onCreateView()方法中计算网格列数,因为这个时候RecyclerView还没有改变。不过,可以实现ViewTreeObserver.OnGlobalLayoutListener监听器方法和计算列数的onGlobalLayout()方法,然后使用addOnGlobalLayoutListener()把监听器添加给RecyclerView视图

  1. 在onCreateView为RecyclerView添加监听器:
1
2
3
4
5
6
7
8
9
10
11
12
mPhotoRecyclerView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
// 获取RecyclerView宽度
int width = mPhotoRecyclerView.getWidth();
// 预定义网格列宽
int itemWidth = 300;
mPhotoRecyclerView.setLayoutManager(new GridLayoutManager(getActivity(), width/itemWidth));
// 将OnGlobalLayoutListener注销掉
mPhotoRecyclerView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
}
});

需要注意的是OnGlobalLayoutListener可能会被多次触发,因此在得到了高度之后,要将OnGlobalLayoutListener注销掉

第26章 Looper、Handler和HandlerThread

批量下载缩略图

一次性下载全部缩略图存在两个问题。首先,下载比较耗时,而且在下载完成前,UI都无法完成更新

其次,保存缩略图也是个问题。 100张缩略图保存在内存中固然轻松,但是1000张呢?如果还需要实现无限滚动来显示图片呢?显然,内存会耗尽

考虑到这类问题,很多应用通常会选择仅在需要显示图片时才去下载

AsyncTask是执行后台线程的最简单方式,但它不适用于那些重复且长时间运行的任务

放弃AsyncTask吧, 我们来创建一个专用的后台线程。这是实现按需下载的最常用方式

与主线程通信

Android系统中,线程使用的收件箱叫作消息队列( message queue)。使用消息队列的线程叫作消息循环( message loop)。 消息循环会循环检查队列上是否有新消息

26-1

消息循环由线程和looper组成。 Looper对象管理着线程的消息队列

主线程就是个消息循环,因此也拥有looper。主线程的所有工作都是由其looper完成的。 looper不断从消息队列中抓取消息,然后完成消息指定的任务

接下来,我们将创建一个消息循环作为后台线程。准备需要的looper时,我们会使用名为HandlerThread的类

创建并启动后台线程

继承HandlerThread类,创建一个名为ThumbnailDownloader的新类。然后,添加一个构造方法,一个名为queueThumbnail()的存根方法以及一个quit()覆盖方法(线程退出通知方法,稍后会用到)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class ThumbnailDownloader<T> extends HandlerThread {
/**
* 这里使用了<T>泛型
* ThumbnailDownloader类的使用者需要使用某些对象来识别每次下载,并确定该使用已下载图片
* 更新哪个UI元素。有了泛型参数,实施起来方便了很多
*/
private final static String TAG = "ThumbnailDownloader";
private boolean mHasQuit = false;

public ThumbnailDownloader() {
// 设置新线程的名字
super(TAG);
}

@Override
public boolean quit() {
/**
* 线程退出的通知方法
*/
mHasQuit = true;
return super.quit();
}

public void queueThumbnail(T target, String url) {
/**
* 存根方法
*/
Log.i(TAG, "Got an URL: " + url);
}
}

queueThumbnail()方法需要一个T类型对象(标识具体哪次下载)和一个String参数( URL下载链接)。 同时, 它也是PhotoAdapter在其onBindViewHolder(…)实现方法中要调用的方法

打 开 PhotoGalleryFragment.java 文 件 , 为 PhotoGalleryFragment 添 加 一 个 ThumbnailDownloader类型的成员变量。然后,在onCreate(…)方法中,创建并启动线程。最后,覆盖onDestroy()方法退出线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class PhotoGalleryFragment extends Fragment {
private static final String TAG = "PhotoGalleryFragment";
private RecyclerView mPhotoRecyclerView;
private List<GalleryItem> mItems = new ArrayList<>();
private ThumbnailDownloader<PhotoHolder> mThumbnailDownloader;
...
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setRetainInstance(true);
new FetchItemsTask().execute();
mThumbnailDownloader = new ThumbnailDownloader<>();
mThumbnailDownloader.start();
mThumbnailDownloader.getLooper();
Log.i(TAG, "Background thread started");
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
...
}
@Override
public void onDestroy() {
super.onDestroy();
mThumbnailDownloader.quit();
Log.i(TAG, "Background thread destroyed");
}
...
}

ThumbnailDownloader的泛型参数支持任何对象,但在这里, PhotoHolder最合适,因为该视图是最终显示下载图片的地方

上述代码有两点安全考虑,值得一说:

  1. ThumbnailDownloader的getLooper()方法是在start()方法之后调用的。(稍后会学习更多有关Looper的知识。)这能保证线程就绪,避免潜在竞争(尽管极少发生)。因为getLooper()方法能执行成功,说明onLooperPrepared()方法肯定早已完成。这样,queueThumbnail()方法因Handler为空而调用失败的情况就能避免了
  2. 在onDestroy()方法内调用quit()方法结束线程。这非常关键。如不终止HandlerThread,它会一直运行下去,成为僵尸

最后,在PhotoAdapter.onBindViewHolder(…)方法中,调用线程的queueThumbnail()方法,并传入放置图片的PhotoHolder和GalleryItem的URL

1
2
3
4
5
6
7
@Override
public void onBindViewHolder(PhotoHolder photoHolder, int position) {
GalleryItem galleryItem = mGalleryItems.get(position);
Drawable placeholder = getResources().getDrawable(R.drawable.bill_up_close);
photoHolder.bindDrawable(placeholder);
mThumbnailDownloader.queueThumbnail(photoHolder, galleryItem.getUrl());
}

成功创建并运行HandlerThread线程后,接下来的任务是:使用传入queueThumbnail()方法的信息创建消息,并放置在ThumbnailDownloader的消息队列中

Message 与 message handler

剖析 Message

消息是Message类的一个实例,它有好几个实例变量,其中有三个需要你定义:

  • What:用户定义的int型消息代码,用来描述消息
  • obj:用户指定,随消息发送的对象
  • target:处理消息的Handler

Message的目标( target)是一个Handler类实例。 Handler可看作message handler的简称。创建Message时,它会自动与一个Handler相关联。 Message待处理时, Handler对象负责触发消息处理事件

剖析 Handler

要处理消息以及消息指定的任务,首先需要一个Handler实例。 Handler不仅仅是处理Message的目标( target),也是创建和发布Message的接口

26-2

Looper拥有Message对象的收件箱,所以Message必须在Looper上发布或处理。既然有这层关系,为协同工作, Handler总是引用着Looper

一个Handler仅与一个Looper相关联,一个Message也仅与一个目标Handler(也称作Message目标)相关联

26-3

Looper拥有整个Message队列。多个Message可以引用同一目标Handler。多个Handler也可与一个Looper相关联。这意味着一个Handler的Message可能与另一个Handler的Message存放在同一消息队列中

使用 handler

一般来讲,不应手动设置消息的目标Handler。创建信息时,最好调用Handler.obtainMessage (…)方法。传入其他必要消息字段后,该方法会自动设置目标Handler

为避免反复创建新的Message对象, Handler.obtainMessage(…)方法会从公共回收池里获取消息。相比创建新实例,这样更加高效

一旦取得Message,就可以调用sendToTarget()方法将其发送给它的Handler。然后,Handler会将这个Message放置在Looper消息队列的尾部

Looper取得消息队列中的特定消息后,会将它发送给消息的目标Handler去处理。消息一般是在目标Handler的Handler.handleMessage(…)实现方法中进行处理的

26-4

这里,稍后要创建的handleMessage(…)实现方法将使用FlickrFetchr从URL下载图片字节数据,然后再转换为位图

首先添加一些常量和成员变量:

1
2
3
4
5
6
7
8
public class ThumbnailDownloader<T> extends HandlerThread {
private static final String TAG = "ThumbnailDownloader";
private static final int MESSAGE_DOWNLOAD = 0;
private boolean mHasQuit = false;
private Handler mRequestHandler;
private ConcurrentMap<T,String> mRequestMap = new ConcurrentHashMap<>();
...
}

MESSAGE_DOWNLOAD用来标识下载请求消息。 ( ThumbnailDownloader会把它设为任何新创建下载消息的what属性。)

mRequestHandler用来存储对Handler的引用。这个Handler负责在ThumbnailDownloader后台线程上管理下载请求消息队列。还负责从消息队列里取出并处理下载请求消息

mRequestMap是个ConcurrentHashMap。这是一种线程安全的HashMap。这里,使用一个标记下载请求的T类型对象作为key,我们可以存取和请求关联的URL下载链接

接下来,在queueThumbnail(…)方法中添加代码,更新mRequestMap并把下载消息放到后台线程的消息队列中去:

1
2
3
4
5
6
7
8
9
public void queueThumbnail(T target, String url) {
Log.i(TAG, "Got a URL: " + url);
if (url == null) {
mRequestMap.remove(target);
} else {
mRequestMap.put(target, url);
mRequestHandler.obtainMessage(MESSAGE_DOWNLOAD, target).sendToTarget();
}
}

从mRequestHandler直接获取到消息后, mRequestHandler也就自动成为了这个新Message对象的target。这表明mRequestHandler会负责处理从消息队列中取出的这个消息。这个消息的what属性是MESSAGE_DOWNLOAD。它的obj属性是传递给queueThumbnail(…)方法的Ttarget值(这里指PhotoHolder)

新消息就代表指定为T target( RecyclerView中的PhotoHolder)的下载请求

注意,消息自身不包含URL信息。我们的做法是使用PhotoHolder和URL的对应关系更新mRequestMap。 随 后 , 我 们 会 从 mRequestMap中 取 出 图 片 URL , 以 保 证 总 是 使 用 了 匹 配PhotoHolder实例的最新下载请求URL。(这很重要,因为RecyclerView中的ViewHolder是会不断回收重用的。)

最后,初始化mRequestHandler并定义该Handler在得到消息队列中的下载消息后应执行的任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Override
protected void onLooperPrepared() {
mRequestHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
if (msg.what == MESSAGE_DOWNLOAD) {
T target = (T) msg.obj;
Log.i(TAG, "Got a request for URL: " + mRequestMap.get(target));
handleRequest(target);
}
}
};
}

private void handleRequest(final T target) {
try {
final String url = mRequestMap.get(target);
if (url == null) {
return;
}
byte[] bitmapBytes = new GankFetchr().getUrlBytes(url);
final Bitmap bitmap = BitmapFactory
.decodeByteArray(bitmapBytes, 0, bitmapBytes.length);
Log.i(TAG, "Bitmap created");
} catch (IOException ioe) {
Log.e(TAG, "Error downloading image", ioe);
}
}

上述代码中,我们是在onLooperPrepared()方法里实现Handler.handleMessage(…)方法的。HandlerThread.onLooperPrepared()是在Looper首次检查消息队列之前调用,所以该方法是创建Handler实现的好地方

在Handler.handleMessage(…)方法中,首先检查消息类型,再获取obj值( T类型下载请求),然后将其传递给handleRequest(…)方法处理。(前面说过,队列中的下载消息取出并可以处理时,就会触发调用Handler.handleMessage(…)方法。)

handleRequest()方法是下载执行的地方。在这里,确认URL有效后,就将它传递给GankFetchr新实例

最后,使用BitmapFactory把getUrlBytes(…)返回的字节数组转换为位图

注意

Handler handler = new Handler()这种写法在Android studio上会提示你如下的警告信息:

warning

Handler 类应该为static类型,否则有可能造成泄露。在程序消息队列中排队的消息保持了对目标Handler类的应用。如果Handler是个内部类,那 么它也会保持它所在的外部类的引用。为了避免泄露这个外部类,应该将Handler声明为static嵌套类,并且使用对外部类的弱引用

我这里使用新建一个自定义的Handler类,然后在ThumbnailDownloader中实例化它,就不会出现这个警告信息了,这涉及到java的垃圾回收机制。

  1. 新建DownloadHandler类,它继承自Handler类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class DownloadHandler<T> extends Handler {
private static final String TAG = "DownloadHandler";
// MESSAGE_DOWNLOAD用来标识下载请求消息
// ( ThumbnailDownloader会把它设为任何新创建下载消息的what属性。)
public static final int MESSAGE_DOWNLOAD = 0;
private ConcurrentMap<T, String> mRequestMap;

public DownloadHandler(ConcurrentMap<T, String> requestMap) {
mRequestMap = requestMap;
}

@Override
public void handleMessage(Message msg) {
if (msg.what == MESSAGE_DOWNLOAD) {
T target = (T) msg.obj;
Log.i(TAG, "Got a request for URL: " + mRequestMap.get(target));
handleRequest(target);
}
}

private void handleRequest(T target) {
try {
String url = mRequestMap.get(target);
if (url == null) {
return;
}
byte[] bitmaoBytes = new GankFetchr().getUrlBytes(url);
Bitmap bitmap = BitmapFactory.decodeByteArray(bitmaoBytes, 0, bitmaoBytes.length);
Log.i(TAG, "Bitmap created");
} catch (IOException e) {
Log.i(TAG, "Error download image: " + e);
}
}
}
  1. 然后在ThumbnailDownloader中实例化它:
1
2
3
4
5
6
private DownloadHandler<T> mRequestHandler;
···
@Override
protected void onLooperPrepared() {
mRequestHandler = new DownloadHandler<>(mRequestMap);
}
  1. 修改Handler获取消息的方法:
1
mRequestHandler.obtainMessage(DownloadHandler.MESSAGE_DOWNLOAD, target).sendToTarget();

运行PhotoGallery应用,通过LogCat窗口的日志确认代码工作正常

目前为止,所有的工作就是在线程上使用handler和消息——ThumbnailDownloader把消息放入自己的收件箱。下一节要学习的内容是: ThumbnailDownloader如何使用Handler向主线程发请求

传递 handler

当前,使用ThumbnailDownloader的mRequestHandler,我们已可以从主线程安排后台线程任务

26-5

反过来,也可以从后台线程使用与主线程关联的Handler,安排主线程任务

26-6

主线程是一个拥有handler和Looper的消息循环。主线程上创建的Handler会自动与它的Looper相关联。主线程上创建的这个Handler也可以传递给另一线程。传递出去的Handler与创建它的线程Looper始终保持着联系。因此,已传出Handler负责处理的所有消息都将在主线程的消息队列中处理

在ThumbnailDownloader.java中,添加上图中的mResponseHandler变量,以存放来自于主线程的Handler。然后,以一个能接受Handler的构造方法替换原构造方法,并设置变量的值。最后新增一个监听器接口响应请求(主线程发请求,响应结果是下载的图片)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ThumbnailDownloader<T> extends HandlerThread {
private static final String TAG = "ThumbnailDownloader";
private static final int MESSAGE_DOWNLOAD = 0;
private boolean mHasQuit = false;
private Handler mRequestHandler;
private ConcurrentMap<T,String> mRequestMap = new ConcurrentHashMap<>();
private Handler mResponseHandler;
private ThumbnailDownloadListener<T> mThumbnailDownloadListener;
public interface ThumbnailDownloadListener<T> {
void onThumbnailDownloaded(T target, Bitmap thumbnail);
}
public void setThumbnailDownloadListener(ThumbnailDownloadListener<T> listener) {
mThumbnailDownloadListener = listener;
}
public ThumbnailDownloader(Handler responseHandler) {
super(TAG);
mResponseHandler = responseHandler;
}
...
}

在图片下载完成,可以交给UI去显示时,定义在ThumbnailDownloadListener新接口中的onThumbnailDownloaded(…)方法就会被调用。稍后,为了把下载任务和UI更新任务(把图片放入ImageView)分开,代替ThumbnailDownloader, 我们会使用这个监听器方法把处理已下载图片的任务委托给另一个类(这里指PhotoGalleryFragment)。这样, ThumbnailDownloader就可以把下载结果传给其他视图对象

接下来,修改PhotoGalleryFragment类,将主线程关联的Handler传递给ThumbnailDownloader。 另外,再设置一个ThumbnailDownloadListener处理已下载图片

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setRetainInstance(true);
new FetchItemsTask().execute();
Handler responseHandler = new Handler();
mThumbnailDownloader = new ThumbnailDownloader<>(responseHandler);
mThumbnailDownloader.setThumbnailDownloadListener(
new ThumbnailDownloader.ThumbnailDownloadListener<PhotoHolder>() {
@Override
public void onThumbnailDownloaded(PhotoHolder photoHolder, Bitmap bitmap) {
Drawable drawable = new BitmapDrawable(getResources(), bitmap);
photoHolder.bindDrawable(drawable);
}
});
mThumbnailDownloader.start();
mThumbnailDownloader.getLooper();
Log.i(TAG, "Background thread started");
}

前面说过, Handler默认与当前线程的Looper相关联。这个Handler是在onCreate(…)方法中创建的,所以它会与主线程的Looper相关联

现在,通过mResponseHandler, ThumbnailDownloader能够使用与主线程Looper绑定的Handler。同时,还有ThumbnailDownloadListener使用返回的Bitmap执行UI更新操作。具体来说,就是通过onThumbnailDownloaded实现,使用新下载的Bitmap来设置PhotoHolder的Drawable

和在后台线程上把图片下载请求放入消息队列类似,我们也可以发送定制Message给主线程,要求显示已下载图片。不过,这需要另一个Handler子类,以及一个handleMessage(…)覆盖方法

方便起见,我们转而使用另一个Handler方法——post(Runnable)

Handler.post(Runnable)是一个发布Message的便利方法

1
2
3
4
5
6
7
8
Runnable myRunnable = new Runnable() {
@Override
public void run() {
/* Your code here */
}
};
Message m = mHandler.obtainMessage();
m.callback = myRunnable;

Message设有回调方法属性后,取出队列的消息是不会发给target Handler的。相反,存储在回调方法中的Runnable的run()方法会直接执行

在ThumbnailDownloader.handleRequest()方法中,添加代码

1
2
3
4
5
6
7
8
9
10
mResponseHandler.post(new Runnable() {
public void run() {
if (mRequestMap.get(target) != url ||
mHasQuit) {
return;
}
mRequestMap.remove(target);
mThumbnailDownloadListener.onThumbnailDownloaded(target, bitmap);
}
});

因为mResponseHandler与主线程的Looper相关联,所以UI更新代码会在主线程中完成

那么上述代码有什么作用呢?首先,它再次检查requestMap。 这很有必要,因为RecyclerView会循环使用其视图。在ThumbnailDownloader下载完成Bitmap之后, RecyclerView可能循环使用了PhotoHolder并相应请求了一个不同的URL。该检查可保证每个PhotoHolder都能获取到正确的图片,即使中间发生了其他请求也无妨

接下来,检查mHasQuit值。如果ThumbnailDownloader已经退出,运行任何回调方法可能都不太安全

最后, 从requestMap中删除配对的PhotoHolder-URL, 然后将位图设置到目标PhotoHolder上

在运行应用并欣赏图片前,还应考虑一个风险点。如果用户旋转屏幕,因PhotoHolder视图的失效, ThumbnailDownloader可能会挂起。如果点击这些ImageView,就会发生异常

新增clearQueue()方法清除队列中的所有请求

1
2
3
4
public void clearQueue() {
mRequestHandler.removeMessages(MESSAGE_DOWNLOAD);
mRequestMap.clear();
}

既然视图已销毁,别忘了在PhotoGalleryFragment中清空mThumbnailDownloader

1
2
3
4
5
@Override
public void onDestroyView() {
super.onDestroyView();
mThumbnailDownloader.clearQueue();
}

至此,本章的所有任务都完成了

深入学习: AsyncTask 与线程

理解了Handler和Looper之后, AsyncTask也就没有当初看上去那么神奇了。不过就本章所做的线程相关工作来看,要用AsyncTask能省不少事。那么为什么要用HandlerThread,而不用它呢?

原因有好几个。最基本的一个是AsyncTask的工作方式并不适用于本章的使用场景。它主要应用于那些短暂且较少重复的任务。上一章的应用场景才是AsyncTask大展身手的地方。如果创建了大量的AsyncTask,或者长时间在运行AsyncTask,那么很可能就是错用了它

有一个技术层面的理由更让人信服:在Android 3.2系统版本中, AsyncTask的内部实现有了重大变化。自Android 3.2版本起, AsyncTask不再为每一个AsyncTask实例单独创建线程。相反,它使用一个Executor在单一的后台线程上运行所有AsyncTask后台任务。这意味着每个AsyncTask都需要排队顺序执行。显然,长时间运行的AsyncTask会阻塞其他AsyncTask。

使用一个线程池executor虽然可安全地并发运行多个AsyncTask,但不推荐这么做。如果真的考虑这么做,最好自己处理线程相关的工作,必要时就使用Handler与主线程通信

深入学习:解决图片下载问题

本书使用的都是Android官方库中的工具。如有需要,还可以使用各种第三方库。这些库专用于一些特定场景(比如PhotoGallery中的图片下载),可以节约大量开发时间

必须承认, PhotoGallery应用的图片下载解决方案远不够完美。如果还想优化性能,实现棘手的缓存功能,很自然就会想到是否别人已有更好的解决方案。答案是肯定的。有好几个高性能图片下载库可供使用。例如,在开发生产应用时,我们就用了Picasso库( square.github.io/ picasso/)

使用Picasso库,一条语句就能实现本章的图片下载功能:

1
2
3
4
5
6
7
8
9
10
private class PhotoHolder extends RecyclerView.ViewHolder {
...
public void bindGalleryItem(GalleryItem galleryItem) {
Picasso.with(getActivity())
.load(galleryItem.getUrl())
.placeholder(R.drawable.bill_up_close)
.into(mItemImageView);
}
...
}

上述代码中,流接口需要使用with(Context)指定一个context。 load(String)用于指定要下载图片的URL。 into(ImageView)用于指定加载下载结果的ImageView对象。当然,还有一些其他配置选项可用,比如指定占位图片(使用placeholder(int)和placeholder(drawable))

在PhotoGallery应用中,只要引入Picasso依赖库,并在PhotoAdapter.onBindViewHolder(…)方法中用bindGalleryItem(…)方法替换原有代码,就用上了Picasso库的强大下载功能

Picasso包办了ThumbnailDownloader(还有ThumbnailDownloader.ThumbnailDownloadListener回调方法)的所有工作以及GankFetchr中的图片处理相关工作,所以可以直接删除ThumbnailDownloader实现( FlickrFetchr中的JSON数据下载还是需要的)。 使用Picasso,不仅能简化代码,还能轻松使用它的图片动画、磁盘缓存等高级功能

可以在项目结构窗口中将Picasso作为库依赖项添加在项目中,就像添加RecyclerView等其他依赖项一样

当然, Picasso也不是万能的,为追求小而美,它也有功能取舍,比如,它无法支持下载动态图片。如果你有这个需求,可以考虑使用Google的Glide或Facebook的Fresco。它们各有特点, Glide比较小巧, Fresco性能好

深入学习: StrictMode

开发应用时,有些东西最好要避免,比如,让应用崩溃的代码漏洞、安全漏洞等。举例来讲,网络条件不好的情况下,在主线程上发送网络请求很可能就会导致设备出现ANR错误

表现在后台的话,你应该会看到NetworkOnMainThread异常以及其他大量日志信息。这实际是StrictMode就错误在警告你。 Android引入的StrictMode可以帮助开发者探测代码问题。像在主线程上发起网络请求、编码漏洞以及安全漏洞这样的问题都是它探测的对象

无需配置, StrictMode就会阻止在主线程上发起网络请求这样的代码问题。它还能探测影响系统性能的代码问题。想启用StrictMode默认防御策略的话, 调用StrictMode.enableDefaults()方法就行了( developer.android.com/reference/android/os/StrictMode.html#enableDefaults())

一旦调用了StrictMode.enableDefaults()方法,如果代码有相关问题,就能在Logcat看到以下提醒:

  • 在主线程上发起网络请求
  • 在主线程上做了磁盘读写
  • Activity未及时销毁(又称为activity泄露)
  • SQLite数据库游标未关闭
  • 网络通信使用了明文(未使用SSL/TLS加密)

假 如应 用 违 反 了防 御 策 略 ,你 想 定 制 应对 行 为 , 可使 用 ThreadPolicy.Builder 和VmPolicy.Builder类定制。你可以定制的应对行为有:控制是否抛出异常,弹出对话框或是日志记录违反策略警示信息

挑战练习:预加载以及缓存

应用中并非所有任务都能即时完成,对此,大多用户表示理解。不过,即使是这样,开发者们也一直在努力做到最好

为了让应用反应更快,大多数严肃应用都可以采用以下方式:增加缓存层,预加载图片

缓存指存储一定数目Bitmap对象的地方。这样,即使不再使用这些对象,它们也依然存储在那里。缓存的存储空间有限,因此,在缓存空间用完的情况下,需要某种策略对保存的对象做一定的取舍。许多缓存机制使用一种叫作LRU( least recently used,最近最少使用)的存储策略。基于该种策略,当存储空间用尽时,缓存会清除最近最少使用的对象

Android支持库中的LruCache类实现了LRU缓存策略。作为第一个练习,请使用LruCache为ThumbnailDownloader增加简单的缓存功能。这样,每次下载完Bitmap时,将其存入缓存中。随后,准备下载新图片时,应首先查看缓存,确认是否已经有了。缓存实现完成后,即可使用它进行预加载。预加载是指在实际使用对象前,就预先将它加载到缓存中。这样,在显示Bitmap时,就不会存在下载延迟

完美的预加载虽然不容易做,但对用户来说,这会带来截然不同的使用体验。作为第二个稍有难度的练习,请在显示GalleryItem时,为前十个和后十个GalleryItem预加载Bitmap

练习一 缓存

  1. ThumbnailDownloader新增属性mCache:
1
2
//实际开发中,LruCache分配的内存应该进行动态计算
private LruCache<String, byte[]> mCache = new LruCache<>(24 * 1024);
  1. 修改handleRequest()方法:
1
2
3
4
5
6
7
8
9
10
byte[] bitmapBytes;
byte[] bitmapCache = mCache.get(url);
if (bitmapCache == null) {
Log.i(TAG, "No cache,start downloading");
bitmapBytes = new GankFetchr().getUrlBytes(url);
mCache.put(url, bitmapBytes);
} else {
Log.i(TAG, "Has cache");
bitmapBytes = bitmapCache;
}

练习二 预加载

  1. 修改onBindViewHolder():
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public void onBindViewHolder(@NonNull PhotoHolder holder, int position) {
GalleryItem item = mGalleryItems.get(position);
Drawable placeholder = getResources().getDrawable(R.drawable.bill_up_close);
holder.bindDrawble(placeholder);
mThumbnailDownloader.queueThumbnail(holder, item.getUrl());
for (int i = position - 10; i < position + 10; i++) {
//这里必须要put进LruCache之后才会显示图片。十分耗时
if (i < 0 || i == position || i >= mItems.size()) {
continue;
}
GalleryItem galleryItem = mGalleryItems.get(i);
mThumbnailDownloader.putUrl(galleryItem.getUrl());
}
}
  1. 在ThumbnailDownloader中新增putUrl()方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void putUrl(final String url) {
if (mCache.get(url) != null){
new Thread(new Runnable() {
@Override
public void run() {
try {
byte[] bitmapBytes = new GankFetchr().getUrlBytes(url);
mCache.put(url, bitmapBytes);
} catch (IOException e) {
e.printStackTrace();
}
}
});
}
}
您的支持将鼓励我的创作!

欢迎关注我的其它发布渠道