《第一行代码Android》笔记

第1章 开始启程,你的第一行Android代码

1.1 了解全貌,Android王国简介

  1. Android系统是基于Linux 2.6内核的,这一层为Android设备的各种硬件提供了底层的驱动,如显示驱动、音频驱动、照相机驱动、蓝牙驱动、Wi-Fi驱动、电源管理等。
  2. 另外Android运行时库还包含了Dalvik虚拟机,它使得每一个Android应用都能运行在独立的进程当中,并且拥有一个自己的Dalvik虚拟机实例。
  3. 2011年2月,谷歌发布了Android 3.0系统,这个系统版本是专门为平板电脑设计的,但也是Android为数不多比较失败的版本,推出之后一直不见什么起色,市场份额也少的可怜。
  4. 广播接收器可以允许你的应用接收来自各处的广播消息,比如电话、短信等,当然你的应用同样也可以向外发出广播消息。内容提供器则为应用程序之间共享数据提供了可能,比如你想要读取系统电话簿中的联系人,就需要通过内容提供器来实现。

1.2 手把手带你搭建开发环境

  1. 但我觉得Eclipse最吸引人的地方是超强的插件功能。Eclipse支持极多的插件工具,使得它不仅仅可以用来开发Java,还可以很轻松地支持几乎所有主流语言的开发,当然也非常适合Android开发。

1.3 创建你的第一个Android项目

  1. 接着Package Name代码项目的包名,Android系统就是通过包名来区分不同应用程序的,因此包名一定要有唯一性,这里我们填入com.test.helloworld。
  2. Target SDK是指你在该目标版本上已经做过了充分的测试,系统不会再帮你在这个版本上做向前兼容的操作了,这里我们选择最高版本Android 4.4。
  3. 点击Eclipse导航栏中的Window->Open Perspective->DDMS,这时你会进入到DDMS的视图中去。DDMS中提供了很多我们开发Android程序时需要用到的工具,不过目前你只需要关注Devices窗口中有没有Online的设备就行了。
  4. 如果你的Devices窗口中虽然有设备,但是显示Offline,说明你的模拟器掉线了,这种情况概率不高,但是如果出现了,你只需要点击Reset adb就好了。
  5. gen这个目录里的内容都是自动生成的,主要有一个R.java文件,你在项目中添加的任何资源都会在其中生成一个相应的资源id。这个文件永远不要手动去修改它。
  6. AndroidManifest.html是你整个Android项目的配置文件,你在程序中定义的所有四大组件都需要在这个文件里注册。另外还可以在这个文件中给应用程序添加权限声明,也可以重新指定你创建项目时指定的程序最低兼容版本和目标版本。
  7. 其中intent-filter里的两行代码非常重要,表示HelloWorldActivity是这个项目的主活动,在手机上点击应用图标,首先启动的就是这个活动。
  8. 当然这只是理想情况,更多的时候美工只会提供给我们一份图片,这时你就把所有图片都放在drawable-hdpi文件夹下就好了。
  9. 比如刚刚在strings.xml中找到的Hello world!字符串,我们有两种方式可以引用它:1. 在代码中通过R.string.hello_world可以获得该字符串的引用;2. 在XML中通过@string/hello_world可以获得该字符串的引用。

1.4 行前必备,掌握日志工具的使用

  1. 点击Eclipse导航栏中的Window->Show View->Other,会弹出一个Show View对话框。你在Show View对话框中展开Android目录,会看到有一个LogCat的子项。
  2. Log.d方法中传入了两个参数,第一个参数是tag,一般传入当前的类名就好,主要用于对打印信息过滤。第二个参数是msg,即想要打印的具体的内容。
  3. 如果你的LogCat中并没有打印出任何信息,有可能是因为你当前的设备失去焦点了。这时你只需要进入到DDMS视图,在Devices窗口中点击一下你当前的设备,打印信息就会出来了。
  4. 不知道你有没有体会到使用过滤器的好处,可能现在还没有吧。不过当你的程序打印出成百上千行日志的时候,你就会迫切地需要过滤器了。
  5. 当前我们选中的级别是verbose,也就是最低等级。这意味着不管我们使用哪一个方法打印日志,这条日志都一定会显示出来。而如果我们将级别选中为debug,这时只有我们使用debug级别以上方法打印的日志才会显示出来,依此类推。Log.v() verbose;Log.d() debug;Log.i() info;Log.w() warn;Log.e() error

第2章 先从看得到的入手,探究活动

2.2 活动的基本用法

2.2.1 手动创建活动

  1. 目前ActivityTest项目的src目录应该是空的,你应该在src目录下先添加一个包。点击Eclipse导航栏中的File->New->Package,在弹出窗口中填入我们新建项目时使用的默认包名com.example.activitytest,点击Finish。

2.2.2 创建和加载布局

  1. 右击res/layout目录->New->Android XML File,会弹出创建布局文件的窗口。我们给这个布局文件命名为first_layout,根元素就默认选择为LinearLayout。
  2. 如果你需要在XML中引用一个id,就使用@id/id_name这种语法,而如果你需要在XML中定义一个id,则要使用@+id/id_name这种语法。

2.2.3 在AndroidManifest文件中注册

  1. 由于最外层的标签中已经通过package属性指定了程序的包名是com.example.activitytest,因此在注册活动时这一部分就可以省略了,直接使用.FirstActivity就足够了。
  2. 另外需要注意,如果你的应用程序中没有声明任何一个活动作为主活动,这个程序仍然是可以正常安装的,只是你无法在启动器中看到或者打开这个程序。这种程序一般都是作为第三方服务供其他的应用在内部进行调用的,如支付宝快捷支付服务。

2.2.4 隐藏标题栏

  1. 隐藏标题栏:
// 隐藏标题栏
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    requestWindowFeature(Window.FEATURE_NO_TITLE);
    setContentView(R.layout.first)layout);
}

2.2.5 在活动中使用Toast

Toast是Android系统提供的一种非常好的提醒方式,在程序中可以使用它将一些短小的信息通知给用户,这些信息会在一段时间后自动消失,并且不会占用任何屏幕空间:

// 定义一个弹出Toast的触发点
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    requestWindowFeature(Window.FEATURE_NO_TITLE);
    setContentView(r.layout.first_layout);
    Button button1 = (Button) findViewById(R.id.button_1);
    button1.setOnClickListener(new OnClickListener() {
        @Override
        public void onClick(View v) {
            Toast.makeText(FirstActivity.this, "You clicked Button 1",
                        Toast.LENGTH_SHORT).show();
        }
    });
}

findViewById()方法返回的是一个View对象,我们需要向下转型将它转成Button对象。

makeText(0方法需要传入三个参数。第一个参数是Context,也就是Toast要求的上下文,由于活动本身就是一个Context对象,因此这里直接传入FirstActivity.this即可。第二个参数是Toast显示的文本内容,第三个参数是Toast显示的时长,有两个内置常量可以选择Toast.LENGTH_SHORT和Toast.LENGTH_LONG。

2.2.6 在活动中使用Menu

首先在res目录下新建一个menu文件夹,右击res目录->New->Folder,输入文件夹名menu,点击Finish。接着在这个文件夹下再新建一个名叫main的菜单文件,右击menu文件夹->New->Android XML File:

<menu xmlns:android="http://schemas.android.com/apk/res/android" >
    <item
        android:id="@+id/add_item"
        android:title="Add" />
    <item
        android:id="@+id/remove_item"
        android:title="Remove" />
menu>
// 打开FirstActivity,重写onCreateOptionsMenu()方法
public boolean onCreateOptionsMenu(Menu menu) {
    getMenuInflater().inflate(R.menu.main, menu);
    return true;
}
// 仅仅让菜单显示出来是不够的,还要再定义菜单响应事件,重写onOptionsItemSelected()方法
public boolean onOptionsItemSelected(MenuItem item) {
    switch(item.getItemId()) {
        case R.id.add_item:
            Toast.makeText(this, "You clicked Add", Toast.LENGTH_SHORT).show();
            break;
        case R.id.remove_item:
            Toast.makeText(this, "You clicked Remove", Toast.LENGTH_SHORT).show();
            break;
        default:
    }
    return true;
}

可以看到,菜单默认是不会显示出来的,只有按下了Menu键,菜单才会在底部显示出来,这样我们就可以放心地使用菜单了,因为它不会占用任何活动的空间。

2.2.7 销毁一个活动

只要按一下Back键就可以销毁当前的活动了。Activity类提供了一个finish()方法,我们在活动中调用一下这个方法就可以销毁当前活动了。

2.3 使用Intent在活动之间穿梭

2.3.1 使用显式Intent

  1. 由于SecondActivity不是主活动,因此不需要配置标签里的内容,注册活动的代码也是简单了许多。
  2. Intent是Android程序中各组件之间进行交互的一种重要方式,它不仅可以指明当前组件想要执行的动作,还可以在不同组件之间传递数据。Intent一般可被用于启动活动、启动服务、以及发送广播等场景。
  3. Intent有多个构造函数的重载,其中一个是Intent(Context packageContext, Class cls)。这个构造函数接收两个参数,第一个参数Context要求提供一个启动活动的上下文,第二个参数Class则是指定想要启动的目标活动,通过这个构造函数就可以构建出Intent的“意图”。
// 用intent启动Activity
button1.setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View v) {
        Intent intent = new Intent(FirstActivity.this, SecondActivity.class);
        startActivity(intent);
    }
});

如果你想要回到上一个活动怎么办呢?很简单,按下Back键就可以销毁当前活动,从而回到上一个活动了。

2.3.2 使用隐式Intent

相比于显式Intent,隐式Intent则含蓄了许多,它并不明确指出我们想要启动哪一个活动,而是指定了一系列更为抽象的action和category等信息,然后交由系统去分析这个Intent,并帮我们找出合适的活动去启动。


<activity android:name=".SecondActivity" >
    <intent-filter>
        <action android:name="com.example.activitytest.ACTION_START" />
        <category android:name="android.intent.category.DEFAULT" />
    intent-filter>
activity>

只有中的内容同时能够匹配上Intent中指定的action和category时,这个活动才能响应该Intent。

// 隐式的intent
button1.setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View v) {
        Intent intent = new Intent("com.example.activitytest.ACTION_START");
        startActivity(intent);
    }
});
// 每个Intent中只能指定一个action,但却能指定多个category
button1.setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View v) {
        Intent intent = new Intent("com.example.activitytest.ACTION_START");
        intent.addCategory("com.example.activitytest.MY_CATEGORY");
        startActivity(intent);
    }
});

2.3.3 更多隐式Intent的用法

使用隐式Intent,我们不仅可以启动自己程序内的活动,还可以启动其他程序的活动,这使得Android多个应用程序之间的功能共享成为了可能。比如说你的应用程序中需要展示一个网页,这时你没有必要自己去实现一个浏览器(事实上也不太可能),而是只需要调用系统的浏览器来打开这个网页就行了。

// intent启动浏览器
button1.setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View v) {
        Intent intent = new Intent(Intent.ACTION_VIEW);
        intent.setData(Uri.parse("http://www.baidu.com"));
        startActivity(intent);
    }
});

这里我们首先指定了Intent的action是Intent.ACTION_VIEW,这是一个Android系统内置的动作,其常量值为android.intent.action.VIEW。

与此对应,我们还可以在标签中再配置一个标签,用于更精确地指定档期那活动能够响应什么类型的数据。标签中主要可以配置以下内容。

android:scheme 用于指定数据的协议部分,如http
android:host 用于指定数据的主机名部分,如www.baidu.com
android:port 用于指定数据的端口部分,一般紧随在主机名之后
android:path 用于指定主机名和端口之后的部分,如一段网址中跟在域名之后的内容
android:mimeType 用于指定可以处理的数据类型,允许使用通配符的方式进行指定

不过一般在标签中都不会指定过多的内容,如上面浏览器示例中,其实只需要指定android:scheme为http,就可以响应所有的http协议的Intent了。

<activity android:name=".ThirdActivity" >
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <data android:scheme="http" />
    intent-filter>
activity>

可以看到,系统自动弹出了一个列表,显示了目前能够响应这个Intent的所有程序。点击Browser还会像之前一样打开浏览器,并显示百度的主页,而如果点击了ActivityTest,则会启动ThirdActivity。需要注意的是,虽然我们声明了ThirdActivity是可以响应打开网页的Intent的,但实际上这个活动并没有加载并显示网页的功能,所以在真正的项目中尽量不要去做这种有可能误导用户的行为,不然会让用户对我们的应用产生负面的印象。

除了http协议外,我们还可以指定很多其他协议,比如geo表示显示地理位置、tel表示拨打电话。

// 在程序中调用系统拨号界面
button1.setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View v) {
        Intent intent = new Intent(Intent.ACTION_DIAL);
        intent.setData(Uri.parse("tel:10086"));
        startActivity(intent);
    }
});

2.3.4 向下一个活动传递数据

// 用intent发送数据
button1.setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View v) {
        String data = "Hello SecondActivity";
        Intent intent = new Intent(FirstActivity.this, SecondActivity.class);
        intent.putExtra("extra_data", data);
        startActivity(intent);
    }
});
// 取出数据
public class SecondActivity extends Activity {
     
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        setContentView(R.layout.second_layout);
        Intent intent = getIntent();
        String data = intent.getStringExtra("extra_data");
        Log.d("SecondActivity", data);
    }
}

这里由于我们传递的是字符串,所以使用getStringExtra()方法来获取传递的数据,如果传递的是整型数据,则使用getIntExtra()方法,传递的是布尔型数据,则使用getBooleanExtra()方法,以此类推。

2.3.5 返回数据给上一个活动

Activity中还有一个startActivityForResult()方法也是用于启动活动的,但这个方法期望在活动销毁的时候能够返回一个结果给上一个活动。

// 使用startActivityForResult启动intent
button1.setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View v) {
        Intent intent = new Intent(FirstActivity.this, SecondActivity.class);
        startActivityForResult(intent, 1);
    }
});

// 添加返回数据
public class SecondActivity extends Activity {
     
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        setContentView(R.layout.second_layout);
        Button button2 = (Button) findViewById(R.id.button_2);
        button2.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                Intent intent = new Intent();
                intent.putExtra("data_return", "Hello FirstActivity");
                setResult(RESULT_OK, intent);
                finish();
            }
        });
    }
}

// 由于我们是使用startActivityForResult()方法来启动SecondActivity的,在SecondActivity被销毁
// 之后会回调上一个活动的onActivityResult方法,因此我们需要在FirstActivity中重写这个方法来得到
// 返回的数据
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    switch(requestCode) {
        case 1:
            if (resultCode==RESULT_OK) {
                String returnedData = data.getStringExtra("data_return");
                Log.d("FirstActivity", returnedData);
            }
            break;
        default:
    }
}

// 这时候你会问,如果用户在SecondActivity中并不是通过点击按钮,而是通过按下Back键回到FirstActivity
// 这样数据不就没法返回了吗?没错,不过这种情况还是很好处理的,重写onBackPressed()
@Override
public void onBackPressed() {
    Intent intent = new Intent();
    intent.putExtra("data_return", "Hello FirstActivity");
    setResult(RESULT_OK, intent);
    finish();
}

2.4 活动的生命周期

2.4.1 返回栈

  1. 其实Android是使用任务(Task)来管理活动的,一个任务就是一组存放在栈里的活动的集合,这个栈也被称作返回栈(Back Stack)。
  2. 系统总是会显示处于栈顶的活动给元素。

2.4.2 活动状态

  1. 当一个活动不再处于栈顶位置,但仍然可见时,这时活动就进入了暂停状态。你可能会觉得既然活动已经不在栈顶了,还怎么会可见呢?这是因为并不是每一个活动都会占满整个屏幕的,比如对话框形式的活动只会占用屏幕中间的部分区域。

2.4.3 活动的生存期

  1. onCreate()会在活动第一次被创建的时候调用。你应该在这个方法中完成活动的初始化操作,比如说加载布局、绑定事件等。
  2. onStart()方法在活动由不可见变为可见的时候调用。
  3. onPause()方法在系统准备去启动或者恢复另一个活动的时候调用。
  4. onStop()方法在活动完全不可见的时候调用。它和onPause()方法的主要区别在于,如果启动的新活动是一个对话框式的活动,那么onPause()方法会得到执行,而onStop()方法并不会执行。
  5. 可见生存期:活动在onStart()方法和onStop()方法之间所经历的,就是可见生存期。在可见生存期内,活动对于用户总是可见的,即便有可能无法和用户进行交互。我们可以通过这两个方法,合理地管理那些对用户可见的资源。比如在onStart()方法中对资源进行加载,而在onStop()方法中队资源进行释放,从而保证处于停止状态的活动不会占用过多内存。
  6. 前台生存期:活动在onResume()方法和onPause()方法之间所经历的,就是前台生存期。

2.4.4 体验活动的生命周期


<activity android:name=".NormalActivity" >
activity>
<activity android:name=".DialogActivity" android:theme="@android:style/Theme.Dialog" >
activity>

由于NormalActivity已经把MainActivity完全遮挡住,因此onPause()和onStop()方法都会得到执行。

可以看到,只有onPause()方法得到了执行,onStop()方法并没有执行,这是因为DialogActivity并没有完全遮挡住MainActivity,此时MainActivity只是进入了暂停状态,并没有进入停止状态。

2.4.5 活动被回收了怎么办

想象以下场景,应用中有一个活动A,用户在活动A的基础上启动了活动B,活动A就进入了停止状态,这个时候由于系统内存不足,将活动A回收掉了,然后用户按下Back键返回活动A,会出现什么情况呢?其实还是会正常显示活动A的,只不过这时并不会执行onRestart()方法,而是会执行活动A的onCreate()方法。

Activity中还提供了一个onSaveInstanceState()回调方法,这个方法会保证一定在活动被回收之前调用,因此我们可以通过这个方法来解决活动被回收时临时数据得不到保存的问题。

onSaveInstanceState()方法会携带一个Bundle类型的参数,Bundle提供了一系列的方法用于保存数据,比如可以使用putString()方法来保存字符串,使用putInt()方法保存整型数据,以此类推。

// 保存临时数据到Bundle中
@Override
protected void onSaveInstanceState(Bundle outState) {
    super.onSaveInstanceState(outState);
    String tempData = "Something you just typed";
    outState.putString("data_key", tempData);
}

数据是已经保存下来了,那么我们应该在哪里进行恢复呢?细心的你也许早就发现,我们一直使用的onCreate()方法其实也有一个Bundle类型的参数。这个参数在一般情况下都是null,但是当活动被系统回收之前有通过onSaveInstanceState()方法来保存数据的话,这个参数就会带有之前所保存的全部数据,我们只需要再通过相应的取值方法将数据取出即可:

// 从Bundle中取出保存临时数据
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    Log.d(TAG, "onCreate");
    requestWindowFeature(Window.FEATURE_NO_TITLE);
    setContentView(R.layout.activity_main);
    if (savedInstanceState != null) {
        String tempData = savedInstanceState.getString("data_key");
        Log.d(TAG, tempData);
    }
    ...
}

Intent还可以结合Bundle一起用于传递数据的,首先可以把需要传递的数据都保存在Bundle对象中,然后再将Bundle对象存放在Intent里。

2.5 活动的启动模式

2.5.1 standard

  1. 启动模式一共有四种,分别是standard、singleTop、singleTask和singleInstance,可以在AndroidManifest.xml中通过给标签指定android:launchMode属性来选择启动模式。
  2. 对于使用standard模式的活动,系统不会在乎这个活动是否已经在返回栈中存在,每次启动都会创建该活动的一个新的实例。
  3. 从打印信息中我们可以看出,每点击一次按钮就会创建出一个新的FirstActivity实例。此时返回栈中也会存在三个FirstActivity的实例,因此你需要连按三次Back键才能退出程序。

2.5.2 singleTop

  1. 当活动的启动模式指定为singleTop,在启动活动时如果发现返回栈的栈顶已经是该活动,则认为可以直接使用它,不会再创建新的活动实例。
  2. 不过当FirstActivity并未处于栈顶位置时,这时再启动FirstActivity,还是会创建新的实例的。

2.5.3 singleTask

  1. 当活动的启动模式指定为singleTask,每次启动该活动时系统首先会在返回栈中检查是否存在该活动的实例,如果发现已经存在则直接使用该实例,并把在这个活动之上的所有活动统统出栈,如果没有发现就会创建一个新的活动实例。
  2. 其实从打印信息中就可以明显看书了,在SecondActivity中启动FirstActivity时,会发现返回栈中已经存在一个FirstActivity的实例,并且是在SecondActivity的下面,于是SecondActivity会从返回栈中出栈,而FirstActivity重新成为了栈顶活动,因此FirstActivity的onRestart()方法和SecondActivity的onDestroy()方法会得到执行。

2.5.4 singleInstance

  1. 不同于以上三种启动模式,指定为singleInstance模式的活动会启动一个新的返回栈来管理这个活动(其实如果singleTask模式指定了不同的taskAffinity,也会启动一个新的返回栈)。
  2. 想象以下场景,假设我们的程序中有一个活动是允许其他程序调用的,如果我们想实现其他程序和我们的程序可以共享这个活动的实例,应该如何实现呢?使用前面三种启动模式肯定是做不到的,因为每个应用程序都会有自己的返回栈,同一个活动在不同的返回栈中入栈时必然是创建了新的实例。而使用singleInstance模式就可以解决这个问题,在这种模式下会有一个单独的返回栈来管理这个活动,不管是哪个应用程序来访问这个活动,都共用的同一个返回栈,也就解决了共享活动实例的问题。
  3. 可以看到,SecondActivity的Task id不同于FirstActivity和ThirdActivity,这说明SecondActivity确实是存放在一个单独的返回栈里的,而且这个栈中只有SecondActivity这一个活动。
  4. 然后我们按下Back键进行返回,你会发现ThirdActivity竟然直接返回到了FirstActivity,再按下Back键又会返回到SecondActivity,再按下Back键才会退出程序。(郭霖还是没有举一个共享Activity的例子,他只是证明了用singleInstance启动的Activity放在一个不同的返回栈里。)

2.6 活动的最佳实践

2.6.2 随时随地退出程序

// 新建一个ActivityCollector类作为活动管理器
public class ActivityCollector {
     
    public static List activities = new ArrayList();
    public static void addActivity(Activity activity) {
        activities.add(activity);
    }
    public static void removeActivity(Activity activity) {
        activities.remove(activity);
    }
    public static void finishAll() {
        for (Activity activity : activities) {
            if (!activity.isFinishing()) {
                activity.finish();
            }
        }
    }
}
// 接下来修改BaseActivity中的代码
public class BaseActivity extends Activity {
     
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d("BaseActivity", getClass().getSimpleName());
        ActivityCollector.addActivity(this);
    }
    @Override
    protected void onDestroy() {
        super.onDestroy();
        ActivityCollector.removeActivity(this);
    }
}
// 从此以后,不管你想在什么地方退出程序,只需要调用ActivityCollector.finishAll()方法就可以了
public class ThirdActivity extends BaseActivity {
     
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d("ThirdActivity", "Task id is " + getTaskId());
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        setContentView(R.layout.third_layout);
        Button button3 = (Button) findViewById(R.id.button_3);
        button3.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                ActivityCollector.finishAll();
            }
        });
    }
}

第3章 软件也要拼脸蛋,UI开发的点点滴滴

3.1 该如何编写程序界面

  1. 不过以上的方式我都不推荐你使用,因为使用可视化编辑工具并不利于你去真正了解界面背后的实现原理,通常这种方式制作出的界面都不具有很好的屏幕适配性,而且当需要编写较为复杂的界面时,可视化编辑工作将很难胜任。因此本书中所有的界面我们都将使用最基本的方式去实现,即编写XML代码。

3.2 常见控件的使用方法

3.2.1 TextView

  1. 然后使用android:layout_width指定了控件的宽度,使用android:layout_height指定了控件的高度。Android中所有的控件都具有这两个属性,可选值有三种:match_parent、fill_parent和wrap_content,其中**match_parent和fill_parent的意义相同,现在官方更加推荐使用match_parent。**match_parent表示让当前控件的大小和父布局的大小一样,也就是由父布局来决定当前控件的大小。wrap_content表示让当前控件的大小能够刚好包含住里面的内容,也就是由控件内容决定当前控件的大小。
  2. 我们使用android:gravity来指定文字的对齐方式,可选值有top、bottom、left、right、center等,可以用“|”来同时指定多个值,这里我们指定的“center”,效果等同于“center_vertical|center_horizontal”,表示文字在垂直和水平方向都居中对齐。

3.2.2 Button

  1. 如果你不喜欢使用匿名类的方式来注册监听器,也可以使用实现接口的方式来进行注册:
public class MainActivity extends Activity implements OnClickListener {
     
    private Button button;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        button = (Button) findViewById(R.id.button);
        button.setOnClickListener(this);
    }

    @Override
    public void onClick(View v) {
        switch (v.getId()) {
        case R.id.button:
            // 在此处添加逻辑
            break;
        default:
            break;
        }
    }
}

3.2.3 EditText

  1. 其实看到这里,我估计你已经总结出Android控件的使用规律了,基本上用法都很相似,给控件定义一个id,再指定下控件的宽度和高度,然后再适当加入些控件特有的属性就差不多了。
  2. 不过随着输入的内容不断增多,EditText会被不断拉长。这时由于EditText的高度指定的是wrap_content,因此它总能包含住里面的内容,但是当输入的内容过多时,界面就会变得非常难看。我们可以使用android:maxLines属性来解决问题。
  3. 这里通过android:maxLines指定了EditText的最大行数为两行,这样当输入的内容超过两行时,文本就会向上滚动,而EditText则不会再继续拉伸。

3.2.4 ImageView

  1. 可以看到,这里使用android:src属性给ImageView指定了一张图片,并且由于图片的宽和高都是未知的,所以将ImageView的宽和高都设定为wrap_content,这样保证了不管图片的尺寸是多少都可以完整地展示出来。

3.2.5 ProgressBar

  1. 这时你可能会问,旋转的进度条表明我们的程序正在加载数据,那数据总会有加载完的时候吧,如何才能让进度条在数据加载完成时消失呢?这里我们就需要用到一个新的知识点,Android控件的可见属性。所有的Android控件都具有这个属性,可以通过android:visibility进行指定,可选值有三种,visible、invisible和gone。visible表示控件是可见的,这个值是默认值,不指定android:visibility时,控件都是可见的。invisible表示控件不可见,但是它仍然占据着原来的位置和大小,可以理解成控件变成透明状态了。gone则表示控件不仅不可见,而且不再占用任何屏幕空间。我们还可以通过代码来设置控件的可见性,使用的是setVisibility()方法,可以传入View.VISIBLE、View.INVISIBLE和View.GONE三种值。

3.2.6 AlertDialog

  1. AlertDialog可以在当前的界面弹出一个对话框,这个对话框是置顶于所有界面元素之上的,能够屏蔽掉其他控件的交互能力,因此一般AlertDialog都是用于提示一些非常重要的内容或者警告信息。比如为了防止用户误删重要内容,在删除前弹出一个确认对话框。
public class MainActivity extends Activity implements OnClickListener {
     
    ...
    @Override
    public void onClick(View v) {
        switch (v.getId()) {
        case R.id.button:
            AlertDialog.Builder dialog = 
                new AlertDialog.Builder(MainActivity.this);
            dialog.setTitle("This is Dialog");
            dialog.setMessage("Something important.");
            dialog.setCancelable(false);
            dialog.setPositiveButton("OK", new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int which) { }
            });
            dialog.setNegativeButton("Cancel", new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int which) { }
            });
            dialog.show();
            break;
        default:
            break;
        }
    }
}

3.2.7 ProgressDialog

  1. 不同的是,ProgressDialog会在对话框中显示一个进度条,一般是用于表示当前操作比较耗时,让用户耐心等待。
public class MainActivity extends Activity implements OnClickListener {
     
    ...
    @Override
    public void onClick(View v) {
        switch(v.getId()) {
        case R.id.button:
            ProgressDialog progressDialog = new ProgressDialog(MainActivity.this);
            progressDialog.setTitle("This is ProgressDialog");
            progressDialog.setMessage("Loading...");
            progressDialog.setCancelable(true);
            progressDialog.show();
            break;
        default:
            break;
        }
    }
}

如果在setCancelable()中传入了false,表示ProgressDialog是不能通过Back键取消掉的,这时你就一定要在代码中做好控制,当数据加载完成后必须要调用ProgressDialog的dismiss()方法来关闭对话框,否则ProgressDialog将会一直存在。

3.3 详解四种基本布局

布局是一种可用于放置很多控件的容器,它可以按照一定的规律调整内部控件的位置,从而编写出精美的界面。当然,布局的内部除了放置控件外,也可以放置布局,通过多层布局的嵌套,我们就能够完成一些比较复杂的界面实现。

3.3.1 LinearLayout

  1. 从名字就可以看出,android:gravity是用于指定文字在控件中的对齐方式,而android:layout_gravity是用于指定控件在布局中的对齐方式。
  2. 当LinearLayout的排列方向是horizontal时,只有垂直方向上的对齐方式才会生效,因为此时水平方向上长度是不固定的,每添加一个控件,水平方向上的长度都会改变,因而无法指定该方向上的对齐方式。同样的道理,当LinearLayout的排列方向是vertical时,只有水平方向上的对齐方式才会生效。
  3. 你会发现,这里竟然将EditText和Button的宽度都指定成了0,这样文本编辑框和按钮还能显示出来吗?不用担心,由于我们使用了android:layout_weight属性,此时控件的宽度就不应该再由android:layout_width来决定,这里指定成0是一种比较规范的写法。
  4. 然后我们在EditText和Button里都将android:layout_weight属性的值指定为1,这表示EditText和Button将在水平方向平分宽度。
  5. 这里我们仅指定了EditText的android:layout_weight属性,并将Button的宽度改回wrap_content。这表示Button的宽度仍然按照wrap_content来计算,而EditText则会占满屏幕所有的剩余空间。

3.3.2 RelativeLayout

  1. android:layout_alignParentLeft、android:layout_alignParentTop、android:layout_alignParentRight、android:layout_alignParentBottom、android:layout_centerInParent这几个属性都是相对于父布局进行定位的,那控件可不可以相对于控件进行定位呢?当然是可以的:
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent" >

    <Button
        android:id="@+id/button3"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:text="Button 3" />
    <Button
        android:id="@+id/button1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_above="@id/button3"
        android:layout_toLeftOf="@id/button3"
        android:text="Button 1" />
    
    <Button
        android:id="@+id/button5"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_below="@id/button3"
        android:layout_toRightOf="@id/button3"
        android:text="Button 5" />
RelativeLayout>

注意,当一个控件去引用另一个控件的id时,该控件一定要定义在引用控件的后面,不然会出现找不到id的情况。

3.3.3 FrameLayout

  1. 这种布局没有任何定位方式,所有的控件都会摆放在布局的左上角。

3.3.4 TableLayout

<TableLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent" >

    <TableRow>
        <TextView
            android:layout_height="wrap_content"
            android:text="Account:" />
        <EditText
            android:id="@+id/account"
            android:layout_height="wrap_content"
            android:hint="Input your account" />
    TableRow>
    <TableRow>
        <TextView
            android:layout_height="wrap_content"
            android:text="Password:" />
        <EditText
            android:id="@+id/password"
            android:layout_height="wrap_content"
            android:inputType="textPassword" />
    TableRow>
    <TableRow>
        <Button
            android:id="@+id/login"
            android:layout_height="wrap_content"
            android:layout_span="2"
            android:text="Login" />
    TableRow>
TableLayout>

在TableLayout中每加入一个TableRow就表示在表格中添加了一行,然后在TableRow中每加入一个控件,就表示在该行中加入了一列,TableRow中的控件是不能指定宽度的。

不过从图中可以看出,当前登录界面没有充分利用屏幕的宽度,右侧还空出了一块区域,这也难怪,因为在TableRow中我们无法指定空间的宽度。这时使用android:stretchColumns属性就可以很好地解决这个问题,它允许将TableLayout中的某一列进行拉伸,以达到自动适应屏幕宽度的作用。

这里将android:stretchColumns的值指定为1,表示如果表格不能完全占满屏幕宽度,就将第二列进行拉伸。没错!指定成1就是拉伸第二列,指定成0就是拉伸第一列。

3.4 系统控件不够用?创建自定义控件

可以看到,我们所用的所有控件都是直接或间接继承自View的,所用的所有布局都是直接或间接继承自ViewGroup的。View是Android中一种最基本的UI组件,它可以在屏幕上绘制一块矩形区域,并能响应这块区域的各种事件,因此,我们使用的各种控件其实就是在View的基础之上又添加了各自特有的功能。而ViewGroup则是一种特殊的View,它可以包含很多的子View和子ViewGroup,是一个用于放置控件和布局容器。

3.4.1 引入布局

  1. 为了避免在每个活动中的布局中都编写一遍同样的标题栏代码,我们可以使用引入布局的方式,新建一个布局title.xml。两个Button和一个TextView。

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent" >

    <include layout="@layout/title" />
LinearLayout>

3.4.2 创建自定义控件

// 新建TitleLayout继承自LinearLayout,让它成为我们自定义的标题栏控件
public class TitleLayout extends LinearLayout {
     
    public TtileLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        // 已经利用了前面写的title.xml
        LayoutInflater.from(context).inflate(R.layout.title, this); 
    }
}

首先我们重写了LinearLayout中的带有两个参数的构造函数,在布局中引入TitleLayout控件就会调用这个构造函数。然后在构造函数中需要对标题栏布局进行动态加载,这就要借助LayoutInflater来实现了。通过LayoutInflater的from()方法可以构建出一个LayoutInflater对象,然后调用inflate()方法就可以动态加载一个布局文件,inflate()方法接收两个参数,第一个参数是要加载的布局文件的id,这里我们传入R.layout.title,第二个参数是给加载好的布局再添加一个父布局,这里我们想要指定为TitleLayout,于是直接传入this。


<LinearLayout xmlns:android=... >

    <com.example.uicustomviews.TitleLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        >com.example.uicustomviews.TitleLayout>
LinearLayout>

添加自定义控件和添加普通控件的方式是一样的,只不过在添加自定义控件的时候我们需要指明控件的完整类名,包名在这里是不可以省略的。

重新运行程序,你会发现此时效果和使用引入布局方式的效果是一样的。

// 然后我们为标题栏中的按钮注册点击事件,修改TitleLayout中的代码
public class TitleLayout extends LinearLayout {
     
    public TitleLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        LayoutInflater.from(context).inflate(R.layout.title, this);
        Button titleBack = (Button) findViewById(R.id.title_back);
        Button titleEdit = (Button) findViewById(R.id.title_edit);
        titleBack.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                ((Activity) getContext()).finish();
            }
        });
        titleEdit.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                Toast.makeText(getContext(), "You clicked Edit button",
                    Toast.LENGTH_SHORT).show();
            }
        });
    }
}

这样的话,每当我们在一个布局中引入TitleLayout,返回按钮和编辑按钮的点击事件就已经自动实现好了,也是省去了很多编写重复代码的工作。

3.5 最常用和最难用的控件——ListView

3.5.1 ListView的简单用法

public class MainActivity extends Activity {
     
    private String[] data = { "Apple", "Banana", "Orange", "Watermelon",
            "Pear", "Grape", "Pineapple", "Strawberry", "Cherry", "Mango" };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        ArrayAdapter adapter = new ArrayAdapter(
                MainActivity.this, android.R.layout.simple_list_item_1, data);
        ListView listView = (ListView) findViewById(R.id.list_view);
        listView.setAdapter(adapter);
    }
}

不过,数组中的数据是无法直接传递给ListView的,我们还需要借助适配器来完成。Android提供了很多适配器的实现类,其中我认为最好用的就是ArrayAdapter。

这里由于我们提供的数据都是字符串,因此将ArrayAdapter的泛型指定为String,然后在ArrayAdapter的构造函数中依次传入当前上下文、ListView子项布局的id,以及要适配的数据。注意我们使用了android.R.layout.simple_list_item_1作为ListView子项布局的id,这是一个Android内置的布局文件,里面只有一个TextView,可用于简单地显示一段文本。这样适配器对象就构建好了。

最后,还需要调用ListView的setAdapter()方法,将构建好的适配器对象传递进去,这样ListView和数据之间的关联就建立完成了。

3.5.2 定制ListView的界面

  1. Fruit类中只有两个字段,name表示水果的名字,imageId表示水果对应图片的资源id。(资源id去本质上全是int)
  2. 然后需要为ListView的子项指定一个我们自定义的布局,在layout目录下新建fruit_item.xml
<LinearLayout ... 
    android:layout_width="match_parent"
    android:layout_height="match_parent" >
    <ImageView
        android:id="@+id/fruit_image"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />
    <TextView
        android:id="@+id/fruit_name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_marginLeft="10dip" />
LinearLayout>
// 接下来创建自定义的适配器
// 要知道其实适配器的处理单元好像是Item,并非整个ListView
public class FruitAdapter extends ArrayAdapter<Fruit> {
     
    private int resourceId;
    // textViewResourceId指的是ListView子项布局的id
    public FruitAdapter(Context context, int textViewResourceId,
            List objects) {
        super(context, textViewResourceId, objects);
        resourceId = textViewResourceId;
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        Fruit fruit = getItem(position); //获取当前项的Fruit实例
        View view = LayoutInflater.from(getContext()).inflate(resourceId, null);
        ImageView fruitImage = (ImageView) view.findViewById(R.id.fruit_image);
        TextView fruitName = (TextView) view.findViewById(R.id.fruit_name);
        fruitImage.setImageResource(fruit.getImageId());
        fruitName.setText(fruit.getName());
        return view;
    }
}

FruitAdapter重写了父类的一组构造函数,用于将上下文、ListView子项布局的id和数据都传递进来。另外又重写了getView()方法,这个方法在每个子项被滚动到屏幕内的时候会被调用。在getView方法中,首先通过getItem()方法得到当前项的Fruit实例,然后使用LayoutInflater来为这个子项加载我们传入的布局,接着调用View的findViewById()方法分别获取到ImageView和TextView的实例,并分别调用它们的setImageResource()和setText()方法来设置显示的图片和文字,最后将布局返回,这样我们自定义的适配器就完成了。

public class MainActivity extends Activity {
     
    private List fruitList = new ArrayList();
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        initFruits(); //初始化水果数据
        FruitAdapter adapter = new FruitAdapter(MainActivity.this,
                R.layout.fruit_item, fruitList);
        ListView listView = (ListView) findViewById(R.id.list_view);
        listView.setAdapter(adapter);
    }
    private void initFruits () {
        Fruit apple = new Fruit("Apple", R.drawable.apple_pic);
        fruitList.add(apple);
        ...
        Fruit mango = new Fruit("Mango", R.drawable.mango_pic);
        fruitList.add(mango);
    }
}
// 这样定制ListView界面的任务就完成了

虽然目前我们定制的界面还是很简单,但是相信你已经领悟到了诀窍,只要修改fruit_item.xml中的内容,就可以定制出各种复杂的界面了。

3.5.3 提升ListView的运行效率

  1. 目前我们ListView的运行效率是很低的,因为在FruitAdapter的getView()方法每次都将布局重新加载一遍,当ListView快速滚动的时候这就会成为性能的瓶颈。
  2. 仔细观察,getView()方法中还有一个convertView参数,这个参数用于将之前加载好的布局进行缓存,以便以后可以进行重用。
  3. 虽然现在已经不会再重复去加载布局,但是每次在getView()方法中还是会调用View的findViewById()方法来获取一次控件的实例。我们可以借助一个V内部类iewHolder来对控件的实例进行缓存。
// 在适配器中应用这两个优化措施
public class FruitAdapter extends ArrayAdapter<Fruit> {
     
    ...
    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        Fruit fruit = getItem(position);
        View view;
        ViewHolder viewHolder;
        if (convertView == null) {
            view = LayoutInflater.from(getContext()).inflate(resourceId, null);
            viewHolder = new ViewHolder();
            viewHolder.fruitImage = (ImageView) view.findViewById(R.id.fruit_image);
            viewHolder.fruitName = (TextView) view.findViewById(R.id.fruit_name);
            view.setTag(viewHolder); //将ViewHolder存储在View中
        } else {
            view = convertView;
            viewHolder = (ViewHolder) view.getTag(); //重新获取viewHolder
        }
        viewHolder.fruitImage.setImageResource(fruit.getImageId());
        viewHolder.fruitName.setText(fruit.getName());
        return view;
    }
    class ViewHolder {
        ImageView fruitImage;
        TextView fruitImage;
    }
}
// 通过这两步优化之后,我们ListView的运行效率就已经非常不错了

3.5.4 ListView的点击事件

// ListView响应用户的点击事件,修改MainActivity
public class MainActivity extends Activity {
     
    private List fruitList = new ArrayList();
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        initFruits();
        FruitAdapter adapter = new FruitAdapter(MainActivity.this,
                R.layout.fruit_item, fruitList);
        ListView listView = (ListView) findViewById(R.id.list_view);
        listView.setAdapter(adapter);
        listView.setOnItemClickListener(new OnItemClickListener() {
            @Override
            public void onItemClick(AdapterView parent, View view,
                    int position, long id) {
                Fruit fruit = fruitList.get(position);
                Toast.makeText(MainActivity.this, fruit.getName(),
                        Toast.LENGTH_SHORT).show();
            }
        });
    }
    ...
}

可以看到,我们使用了setOnItemClickListener()方法来为ListView注册了一个监听器,当用户点击了ListView中的任何一个子项时就会回调onItemClick()方法,在这个方法中可以通过position参数判断出用户点击的是哪一个子项,然后获取到相应的水果,并通过Toase将水果的名字显示出来。

3.6 单位和尺寸

3.6.1 px和pt的窘境

  1. pt是磅数的意思,1磅等于1/72英寸,一般pt都会作为字体的单位来使用。
  2. 可是现在到了手机上,这两个单位就显得有些力不从心了,因为手机的分辨率各不相同,一个200px宽的按钮在低分辨率的手机上可能将近占据满屏,而到了高分辨率的手机上可能只占据屏幕的一半。
  3. 可以明显看出,同样200px宽的按钮在不同分辨率的屏幕上显示的效果是完全不同的,pt的情况和px差不多,这导致这两个单位在手机领域上面很难有所发挥。

3.6.2 dp和sp来帮忙

  1. dp是密度无关像素的意思,也被称作dip,和px相比,它在不同密度的屏幕中的显示比例将保持一致。
  2. 这里有一个新名词需要引起我们的注意,什么叫密度?Android中的密度就是屏幕每英寸所包含的像素数,通常以dpi为单位。比如一个手机屏幕的宽是2英寸长是3英寸,如果它的分辨率是320*480像素,那这个屏幕的密度就是160dpi,如果它的分辨率是640*960,那这个屏幕的密度就是320dpi,因此密度值越高的屏幕显示的效果就越精细。
// 我们可以通过代码来得知当前屏幕的密度值是多少
public class MainActivity extends Activity {
     
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        float xdpi = getResources().getDisplayMetrics().xdpi;
        float ydpi = getResources().getDisplayMetrics().ydpi;
        Log.d("MainActivity", "xdpi is " + xdpi);
        Log.d("MainActivity", "ydpi is " + ydpi);
    }
}

根据Android的规定,在160dpi的屏幕上,1dp等于1px,而在320dpi的屏幕上,1dp就等于2px。因此,使用dp来指定控件的宽和高,就可以保证控件在不同密度的屏幕中的显示比例保持一致。


<LinearLayout ... >
    <Button
        android:id="@+id/button"
        android:layout_width="200dp"
        android:layout_height="wrap_content"
        android:text="Button"
        />
LinearLayout>

sp的原理和dp是一样的,它主要是用于指定文字的大小,这里就不再进行介绍了。

总结一下,在编写Android程序的时候,尽量将控件或布局的大小指定成match_parent或wrap_content,如果必须要指定一个固定值,则使用dp来作为单位,指定文字大小的时候使用sp作为单位。

3.7 编写界面的最佳实践

3.7.1 制作Nine-Patch图片

  1. 先学习一下如何制作Nine-Patch图片。它是一种被特殊处理过的png图片,能够指定哪些区域可以被拉伸而哪些区域不可以。

<RelativeLayout ...
    android:layout_width="match_parent"
    android:layout_height="match_parent" >
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@drawable/message_left" >
    LinearLayout>
RelativeLayout>

可以看到,由于message_left的宽度不足以填满整个屏幕的宽度,整张图片被均匀地拉伸了!这种效果非常差,用户肯定是不能容忍的,这时我们就可以使用Nine-Patch图片来进行改善。

在Android sdk目录下有一个tools文件夹,在这个文件夹中找到draw9patch.bat文件,我们就是使用它来制作Nine-Patch图片的。双击打开之后,在导航栏点击File->Open 9-patch将message_left.png加载进来。

我们可以在图片的四个边框绘制一个个的小黑点,在上边框和左边框绘制的部分就表示当图片需要拉伸时就拉伸黑点标记的区域,在下边框和右边框绘制的部分则表示内容会被放置的区域。

这样当图片需要拉伸的时候,就可以只拉伸指定的区域,程序在外观上也是有了很大的改进。

3.7.2 编写精美的聊天页面


<LinearLayout ...
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#d8e0e8"
    android:orientation="vertical" >

    
    
    <ListView 
        android:id="@+id/msg_list_view"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        android:divider="#0000" >
    ListView>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content" >
        <EditText
            android:id="@+id/input_text"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:hint="Type something here"
            android:maxLines="2" />
        <Button
            android:id="@+id/send"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Send" />
    LinearLayout>
LinearLayout>
// 接着定义消息的实体类,新建Msg
public class Msg {
     
    public static final int TYPE_RECEIVED = 0;
    public static final int TYPE_SENT = 1;
    private String content;
    private int type;
    public Msg(String content, int type) {
        this.content = content;
        this.type = type;
    }
    public String getContent() {
        return content;
    }
    public int getType() {
        return type;
    }
}

<LinearLayout ...
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="10dp" >
    <LinearLayout
        android:id="@+id/left_layout"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="left"
        android:background="@drawable/message_left" >
        <TextView
            android:id="@+id/left_msg"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:layout_margin="10dp"
            android:textColor="#fff" />
    LinearLayout>
    <LinearLayout
        android:id="@+id/right_layout"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="right"
        android:background="@drawable/message_right" >
        <TextView
            android:id="@+id/right_msg"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:layout_margin="10dp" />
    LinearLayout>
LinearLayout>

你可能会有些疑虑,怎么能让收到的消息和发出的消息都放在同一个布局里呢?不用担心,可以利用可见属性,只要稍后在代码中根据消息的类型来决定隐藏和显示哪种消息就可以了。

// 接下来需要创建ListView的适配器类,让它继承ArrayAdapter,并将泛型指定为Msg类
public class MsgAdapter extends ArrayAdapter<Msg> {
     
    // ViewHolder的域要包含ListView子项布局msg_item.xml中所有控件
    class ViewHolder {
        LinearLayout leftLayout;
        LinearLayout rightLayout;
        TextView leftMsg;
        TextView rightMsg;
    }
    private int resourceId;
    public MsgAdapter(Context context, int textViewResourceId, List objects) {
        super(context, textViewResourceId, objects);
        resourceId = textViewResourceId;
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        Msg msg = getItem(position); // 这个方法太重要
        View view;
        ViewHolder viewHolder;
        if (convertView == null) {
            view = LayoutInflater.from(getContext()).inflate(resourceId, null); // 这个方法太重要
            viewHolder = new ViewHolder();
            viewHolder.leftLayout = (LinearLayout) view.findViewById(R.id.left_layout);
            viewHolder.rightLayout = (LinearLayout) view.findViewById(R.id.right_layout);
            viewHolder.leftMsg = (TextView) view.findViewById(R.id.left_msg);
            viewHolder.rightMsg = (TextView) view.findViewById(R.id.right_msg);
            view.setTag(viewHolder);
        } else {
            view = convertView;
            viewHolder = (ViewHolder) view.getTag();
        }
        if (msg.getType() == Msg.TYPE_RECEIVED) {
            // 如果是收到的消息,则显示左边的消息布局,将右边的消息布局隐藏
            viewHolder.leftLayout.setVisibility(View.VISIBLE);
            viewHolder.rightLayout.setVisibility(View.GONE);
            viewHolder.leftMsg.setText(msg.getContent());
        } else if(msg.getType() == Msg.TYPE_SENT) {
            // 如果是发出的消息,则显示右边的消息布局,将左边的消息布局隐藏
            viewHolder.rightLayout.setVisibility(View.VISIBLE);
            viewHolder.leftLayout.setVisibility(View.GONE);
            viewHolder.rightMsg.setText(msg.getContent());
        }
        return view;
    }
}
// 最后修改MainActivity中的代码,来为ListView初始化一些数据,并给发送按钮加入事件响应
public class MainActivity extends Activity {
     
    private ListView msgListView;
    private EditText inputText;
    private Button send;
    private MsgAdapter adapter;
    private List msgList = new ArrayList();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        setContentView(R.layout.activity_main);
        initMsgs();
        adapter = new MsgAdapter(MainActivity.this, R.layout.msg_item, msgList);
        inputText = (EditText) findViewById(R.id.input_text);
        send = (Button) findViewById(R.id.send);
        msgListView = (ListView) findViewById(R.id.msg_list_view);
        msgListView.setAdapter(adapter);
        send.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                String content = inputText.getText().toString();
                if(!"".equals(content)) {
                    Msg msg = new Msg(content, Msg.TYPE_SENT);
                    msgList.add(msg);
                    adapter.notifyDataSetChanged(); // 当有新消息时,刷新ListView中的显示
                    // 调用ListView的setSelection()方法将显示的数据定位到最后一行,
                    // 以保证一定可以看到最后发出的一条消息
                    msgListView.setSelection(msgList.size());
                    inputText.setText("");
                }
            }
        });
    }
    private void initMsgs() {
        Msg msg1 = new Msg("Hello guy.", Msg.TYPE_RECEIVED);
        msgList.add(msg1);
        ...
    }
} 

第4章 手机平板要兼顾,探究碎片

4.1 碎片是什么

  1. 碎片(Fragment)是一种可以嵌入在活动当中的UI片段,它能让程序更加合理和充分地利用大屏幕的空间,因而在平板上应用的非常广泛。
  2. 想象我们正在开发一个新闻应用,其中一个界面使用ListView展示了一组新闻的标题,当点击了其中一个标题,就打开另一个界面显示新闻的详细内容。如果是在手机中设计,我们可以将新闻标题列表放在一个活动中,将新闻的详细内容放在另一个活动中。
  3. 而如果是在平板上设计,更好的方案是将新闻标题列表界面和新闻详细内容界面分别放在两个碎片中,然后在同一个活动里引入这两个碎片,这样就可以将屏幕空间充分地利用起来了。

4.2 碎片的使用方式

4.2.1 碎片的简单用法

  1. 新建一个左侧碎片布局left_fragment.xml。只放置了一个按钮,并让它水平居中显示。然后新建右侧碎片布局right_fragment.xml。这个布局的背景被设置为绿色,并放置了一个TextView用于显示一段文本。
  2. 接着新建一个LeftFragment类,继承自Fragment。注意,这里可能会有两个不同包下的Fragment供你选择,建议使用android.app.Fragment,因为我们的程序是面向Android 4.0以上系统的,另一个包下的Fragment主要用于兼容低版本的Android系统。
// LeftFragment类
public class LeftFragment extends Fragment {
     
    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
            Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.left_fragment, container, false);
        return view;
    }
}
// RightFragment类与此相似

<LinearLayout ...
    android:layout_width="match_parent"
    android:layout_height="match_parent" >
    <fragment
        android:id="@+id/left_fragment"
        android:name="com.example.fragmenttest.LeftFragment"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1" />
    <fragment
        android:id="@+id/right_fragment"
        android:name="com.example.fragmenttest.RightFragment"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1" />
LinearLayout>

可以看到,我们使用了标签在布局中添加碎片,其中指定的大多数属性你都是熟悉的,只不过这里还需要通过android:name属性来显式指明要添加的碎片类名,注意一定要将类的包名也加上。(在xml里放类名其实还蛮特别的,我记得之前还有一个地方是这样做的)

4.2.2 动态添加碎片

  1. 碎片真正的强大之处在于,它可以在程序运行时动态地添加到活动当中。
  2. 新建another_right_fragment.xml。然后新建AnotherRightFragment作为另一个右侧碎片。

<LinearLayout ...
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <fragment
        android:id="@+id/left_fragment"
        android:name="com.example.fragmenttest.LeftFragment"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1" />
    <FrameLayout
        android:id="@+id/right_layout"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1" >
        <fragment
            android:id="@+id/right_fragment"
            android:name="com.example.fragmenttest.RightFragment"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    FrameLayout>
LinearLayout>

FrameLayout是Android中最简单的一种布局,它没有任何定位方式,所有的控件都会摆放在布局的左上角。由于这里仅需要在布局里放入一个碎片,因此非常适合使用FrameLayout。

// 之后我们将在代码中替换FrameLayout中的内容,从而实现动态添加碎片的功能
public class MainActivity extends Activity implements OnClickListener {
     
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Button button = (Button) findViewById(R.id.button);
        button.setOnClickListener(this);
    }
    @Override
    public void onClick(View v) {
        switch(v.getId()) {
        case R.id.button:
            // 动态添加碎片的五步
            AnotherRightFragment fragment = new AnotherRightFragment();
            FragmentManager fragmentManager = getFragmentManager();
            FragmentTransaction transaction = fragmentManager.beginTransaction();
            transaction.replace(R.id.right_layout, fragment);
            tracsaction.commit();
            break;
        default:
            break;
        }
    }
}

4.2.3 在碎片中模拟返回栈

// FragmentTransaction中提供了一个addToBackStack()方法,可以用于将一个事务添加到返回栈中,
// 修改MainActivity中的代码
public class MainActivity extends Activity implements OnClickListener {
     
    ...
    @Override
    public void onClick(View v) {
        switch (v.getId()) {
        case R.id.button:
            AnotherRightFragment fragment = new AnotherRightFragment();
            FragmentManager fragmentManager = getFragmentManager();
            FragmentTransaction transaction = fragmentManager.beginTransaction();
            transaction.replace(R.id.right_layout, fragment);
            transaction.addToBackStack(null);
            transaction.commit();
            break;
        default:
            break;
        }
    }
}

这里我们在事务提交之前调用了FragmentTransaction的addToBackStack()方法,它可以接收一个名字用于描述返回栈的状态,一般传入null即可。现在重新运行程序,并点击按钮将AnotherRightFragment添加到活动中,然后按下Back键,你会发现程序并没有退出,而是回到了RightFragment界面,再次按下Back键程序才会退出。

4.2.4 碎片和活动之间进行通信

  1. 虽然碎片都是嵌入在活动中显示的,可是实际上它们的关系并没有那么亲密。你可以看到,碎片和活动都是各自存在于一个独立的类当中的,它们之间并没有那么明显的方式来直接进行通信。如果想要在活动中调用碎片里的方法,或者在碎片中调用活动里的方法,应该如何实现呢?
  2. 为了方便碎片和活动之间通信,FragmentManager提供了一个类似于findViewById()的方法,专门用于从布局文件中获取碎片的实例,代码如下所示:
RightFragment rightFragment = (RightFragment) getFragmentManager()
        .findFragmentById(R.id.right_fragment);

掌握了如何在活动中调用碎片的方法,那在碎片中又如何调用活动里的方法呢?其实这就更简单了,在每个碎片中都可以通过调用getActivity()方法来得到和当前碎片相关联的活动实例,代码如下所示:

MainActivity activity = (MainActivity) getActivity();

另外当碎片中需要使用Context对象时,也可以使用getActivity()方法,因为获取到的活动本身就是一个Context对象了。

4.3 碎片的生命周期

4.3.1 碎片的状态和回调

  1. 停止状态:当一个活动进入停止状态时,与它相关联的碎片就会进入到停止状态。或者通过调用FragmentTransaction的remove()、replace()方法将碎片从活动中移除,但有在事务提交之前调用addToStack()方法,这时的碎片也会进入到停止状态。总的来说,进入停止状态的碎片对用户来说是完全不可见的,有可能会被系统回收。
  2. 销毁状态:碎片总是依附于活动而存在的,因此当活动被销毁时,与它相关联的碎片就会进入到销毁状态。或者通过调用FragmentTransaction的remove()、replace()方法将碎片从活动中移除,但在事务提交之前并没有调用addToBackStack()方法,这时的碎片也会进入到销毁状态。
  3. onAttach():当碎片和活动建立关联的时候调用。
  4. onCreateView():为碎片创建视图(加载布局)时调用。
  5. onActivityCreated():确保与碎片相关联的活动一定已经创建完毕的时候调用。
  6. onDestroyView():当与碎片关联的视图被移除的时候调用。
  7. onDetach():当碎片和活动解除关联的时候调用。

4.3.2 体验碎片的生命周期

  1. 当RightFragment第一次被加载到屏幕上时,会依次执行onAttach()、onCreate()、onCreateView()、onActivityCreated()、onStart()和onResume()方法。
  2. 由于AnotherRightFragment替换了RightFragment,此时的RightFragment进入了停止状态,因此onPause()、onStop()和onDestroyView()方法会得到执行。当然如果在替换的时候没有调用addToBackStack()方法,此时的RightFragment就会进入销毁状态,onDestroy()和onDetach()方法会得到执行。
  3. 由于RightFragment重新回到了运行状态,因此onActivityCreated()、onStart()和onResume()方法会得到执行,注意此时onCreate()和onCreateView()方法不会执行,因为我们借助了addToBackStack()方法使得RightFragment和它的视图并没有销毁。
  4. 再次按下Back键退出程序,依次会执行onPause()、onStop()、onDestroyView()、onDestroy()和onDetach()方法,最终将活动和碎片一起销毁。
  5. 另外值得一提的是,在碎片中你也是可以通过onSaveInstanceState()方法来保存数据的,因为进入停止状态的碎片有可能在系统内存不足的时候被回收。保存下来的数据在onCreate()、onCreateView()和onActivityCreated()这三个方法中你都可以重新得到,它们都含有一个Bundle类型的savedInstanceState参数。

4.4 动态加载布局的技巧

4.4.1 使用限定符

  1. 那么怎样才能在运行时判断程序应该是使用双页模式还是单页模式呢?这就需要借助限定符(Qualifiers)来实现了。
  2. 这里将多余的代码都删掉,只留下一个左侧碎片,并让它充满整个父布局。接着在res目录下新建layout-large文件夹,在这个文件夹下新建一个布局,也叫做activity_main.xml。
  3. 可以看到,layout/activity_main布局只包含了一个碎片,即单页模式,而layout-large/activity_main布局包含了两个碎片,即双页模式。其中large就是一个限定符,那些屏幕被认为是large的设备就会自动加载layout-large文件夹下的布局,而小屏幕的设备则还是会加载layout文件夹下的布局。
  4. Android中一些常见的限定符:
    大小:
    small 提供给小屏幕设备的资源
    normal 提供给中等屏幕设备的资源
    large 提供给大屏幕设备的资源
    xlarge 提供给超大屏幕设备的资源
    分辨率:
    ldpi 提供给低分辨率设备的资源(120dpi以下)
    mdpi 提供给中等分辨率设备的资源(120dpi到160dpi)
    hdpi 提供给高分辨率设备的资源(160dpi到240dpi)
    xhdpi 提供给超高分辨率设备的资源(240dpi到320dpi)
    方向:
    land 提供给横屏设备的资源
    port 提供给竖屏设备的资源

4.4.2 使用最小宽度限定符

  1. 有的时候我们希望可以更加灵活地为不同设备加载布局,不管它们是不是被系统认定为“large”,这时就可以使用最小宽度限定符(Smallest-width Qualifier)了。
  2. 最小宽度限定符允许我们对屏幕的宽度指定一个最小值(以dp为单位),然后以这个最小值为临界点,屏幕宽度大于这个值得设备就加载一个布局,屏幕宽度小于这个值得设备就加载另一个布局。
  3. 在res目录下新建layout-sw600dp文件夹,然后再这个文件夹下新建activity_main.xml布局
  4. 这就意味着,当程序运行在屏幕宽度大于600dp的设备上时,会加载layout-sw600dp/activity_main布局,当程序运行在屏幕宽度小于600dp的设备上时,则仍然加载默认的layout/activity_main布局。
  5. 需要注意一点,最小宽度限定符是在Android 3.2版本引入的,由于这里我们最低兼容的系统版本是4.0,所以可以放心地使用它。

4.5 碎片的最佳实践——一个简易版的新闻应用

// 第一步准备好一个新闻的实体类,新建类News
public class News {
     
    private String title;
    private String content;
    public String getTitle() {
        return title;
    }
    public void setTitle(String title) {
        this.title = title;
    }
    public String getContent() {
        return content;
    }
    public void setContent(String content) {
        this.content = content;
    }
}

<LinearLayout ...
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical" >
    <TextView
        android:id="@+id/news_title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:singleLine="true"
        android:ellipsize="end"
        android:textSize="18sp"
        android:paddingLeft="10dp"
        android:paddingRight="10dp"
        android:paddingTop="15dp"
        android:paddingBottom="15dp"
        />
LinearLayout>

android:padding表示给控件的周围加上补白,这样不至于让文本内容会紧靠在边缘上。android:singleLine设置为true表示让这个TextView只能单行显示。android:ellipsize用于设定当文本内容超出控件宽度时,文本的缩略方式,这里指定成end表示在尾部进行缩略。

// 第三步,创建新闻列表的适配器
public class NewsAdapter extends ArrayAdapter<News> {
     
    private int resourceId;
    public NewsAdapter(Context context, int textViewResourceId, List objects) {
        super(context, textViewResourceId, objects);
        resourceId = textViewResourceId;
    }
    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        News news = getItem(position); // 最重要的一个方法了,这个方法到底是何时使用的?数据是从哪儿来的?
        View view;
        if (convertView == null) {
            view = LayoutInflater.from(getContext()).inflate(resourceId, null);
        } else {
            view = convertView;
        }
        TextView newsTitleText = (TextView) view.findViewById(R.id.news_title);
        newsTitleText.setText(news.getTitle());
        return view;
    }
}
// 在getView()方法中,我们获取到了相应位置上的News类,并让新闻的标题在列表中进行显示

<RelativeLayout ...
    android:layout_width="match_parent"
    android:layout_height="match_parent" >
    
    <LinearLayout
        android:id="@+id/visibility_layout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:visibility="invisible" >
        
        <TextView
            android:id="@+id/news_title"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:padding="10dp"
            android:textSize="20sp" />
        
        <ImageView
            android:layout_width="match_parent"
            android:layout_height="1dp"
            android:scaleType="fitXY"
            android:src="@drawable/split_line" />
        <TextView
            android:id="@+id/news_content"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1"
            android:padding="15dp"
            android:textSize="18sp" />
    LinearLayout>
    
    <ImageView
        android:layout_width="1dp"
        android:layout_height="match_parent"
        android:layout_alignParentLeft="true"
        android:scaleType="fitXY"
        android:src="@drawable/split_line_vertical" />
RelativeLayout>
// 第五步,新建一个NewsContentFragment类,是新闻内容的Fragment 
public class NewsContentFragment extends Fragment {
     
    private View view;
    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        view = inflater.inflate(R.layout.news_content_frag, container, false);
        return view;
    }
    public void refresh(String newsTitle, String newsContent) {
        View visibilityLayout = view.findViewById(R.id.visibility_layout);
        visibilityLayout.setVisibility(View.VISIBLE);
        TextView newsTitleText = (TextView) view.findViewById(R.id.news_title);
        TextView newsContentText = (TextView) view.findViewById(R.id.news_content);
        newsTitleText.setText(newsTitle);
        newsContentText.setText(newsContent);
    }
}

<LinearLayout ...
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical" >
    
    <fragment
        android:id="@+id/news_content_fragment"
        android:name="com.example.fragmentbestpractice.NewsContentFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        />
LinearLayout>




// 第七步,新建NewsContentActivity,作为显示新闻内容的活动
// 这个显然是在单页模式的时候使用的!因为这是个Activity啊!
public class NewsContentActivity extends Activity {
      
    // 这个方法是static的,所以我们可以在程序的任何地方启动NewsContentActivity
    // 同时,还告诉你的小伙伴,启动这个活动所需的参数
    public static void actionStart(Context context, String newsTitle, String newsContent) {
        Intent intent = new Intent(context, NewsContentActivity.class);
        intent.putExtra("news_title", newsTitle);
        intent.putExtra("news_content", newsContent);
        context.startActivity(intent);
    }
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        // 看,在单页模式里,加载的布局是news_content.xml
        setContentView(R.layout.news_content);
        String newsTitle = getIntent().getStringExtra("news_title");
        String newsContent = getIntent().getStringExtra("news_content");
        NewsContentFragment newsContentFragment = (NewsContentFragment) 
                getFragmentManager().findViewById(R.id.news_content_fragment);
        newsContentFragment.refresh(newsTitle, newsContent);
    }
}

<LinearLayout ...
    android:layout_width="match_parent"
    adnroid:layout_height="match_parent"
    android:orientation="vertical" >
    <ListView
        android:id="@+id/news_title_list_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent" >
    ListView>
LinearLayout>
// 第九步,新建NewsTitleFragment类,用来加载news_title_frag.xml布局 
public class NewsTitleFragment extends Fragment implements OnItemClickListener {
     
    private ListView newsTitleListView;
    private List newsList;
    private NewsAdapter adapter;
    private boolean isTwoPane;
    @Override
    public void onAttach(Activity activity) {
        super.onAttach(activity);
        newsList = getNews(); //初始化新闻数据
        adapter = new NewsAdapter(activity, R.layout.news_item, newsList);
    }
    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.news_title_frag, container, false);
        newsTitleListView = (ListView) view.findViewById(R.id.news_title_list_view);
        newsTitleListView.setAdapter(adapter);
        newsTitleListView.setOnItemClickListener(this);
        return view;
    }
    @Override
    public void onActivityCreated(Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        // 在activity_main.xml里找news_content_layout这个控件,如果有,双页,没有,单页
        if (getActivity().findViewById(R.id.news_content_layout) != null) {
            isTwoPane = true;
        } else {
            isTwoPane = false;
        }
    }
    @Override
    public void onItemClick(AdapterView parent, View view, int position, long id) {
        News news = newsList.get(position);
        if (isTwoPane) {
            // 如果是双页模式,则刷新NewsContentFragment中的内容
            NewsContentFragment newsContentFragment = (NewsContentFragment)
                    getFragmentManager().findFragmentById(R.id.news_content_fragment);
            newsContentFragment.refresh(news.getTitle(), news.getContent());
        } else {
            // 如果是单页模式。则直接启动NewsContentActivity
            NewsContentActivity.actionStart(getActivity(), news.getTitle(), news.getContent());
        }
    }
    private List getNews() {
        List newList = new ArrayList();
        News news1 = new News();
        news1.setTitle("...");
        news1.setContent("......");
        newsList.add(news1);
        News news2 = new News();
        news2.setTitle("...");
        news2.setContent("......");
        newsList.add(news2);
        return newsList;
    }
}

<LinearLayout ...
    android:layout_width="match_parent"
    android:layout_height="match_parent" >
    <fragment
        android:id="@+id/news_title_fragment"
        android:name="com.example.fragmentbestpractice.NewsTitleFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        />
LinearLayout>

<LinearLayout ...
    android:layout_width="match_parent"
    android:layout_height="match_parent" >
    <fragment
        android:id="@+id/news_title_fragment"
        android:name="com.example.fragmentbestpractice.NewsTitleFragment"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1" />
    <FrameLayout
        android:id="@+id/news_content_layout"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="3" />
        <fragment
            android:id="@+id/news_content_fragment"
            android:name="com.example.fragmentbestpractice.NewsContentFragment"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    FrameLayout>
LinearLayout>
// 第十二步,将MainActivity稍作修改,把标题栏去除掉
public class MainActivity extends Activity {
     
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        setContentView(R.layout.activity_main);
    }
}

第5章 全局大喇叭,详解广播机制

5.1 广播机制简介

  1. 为什么说Android中的广播机制更加灵活呢?这是因为Android中的每个应用程序都可以对自己感兴趣的广播进行注册,这样该程序就只会接收到自己所关心的广播内容,这些广播可能是来自于系统的,也可能是来自于其他应用程序的。
  2. 标准广播(Normal broadcasts)是一种完全异步执行的广播,在广播发出之后,所有的广播接收器几乎都会在同一时刻接收到这条广播消息,因此它们之间没有任何先后顺序可言。这种广播的效率会比较高,但同时也意味着它是无法被截断的。
  3. 有序广播(Ordered broadcasts)则是一种同步执行的广播,在广播发出之后,同一时刻只会有一个广播接收器收到这条广播消息,当这个广播接收器中的逻辑执行完毕后,广播才会继续传递。所以此时的广播接收器是有先后顺序的,优先级高的广播接收器就可以先收到广播消息,并且前面的广播接收器还可以截断正在传递的广播,这样后面的广播接收器就无法收到广播消息了。

5.2 接收系统广播

  1. 比如手机开机完成后会发出一条广播,电池的电量发生变化会发出一条广播,时间或时区发生改变也会发出一条广播等等。

5.2.1 动态注册监听网络变化

  1. 注册广播的方式一般有两种,在代码中注册和在AndroidManifest.xml中注册,其中前者也被称为动态注册,后者也被称为静态注册。
// 通过动态注册的方式编写一个能够监听网络变化的程序
public class MainActivity extends Activity {
     
    class NetworkChangeReceiver extends BroadcastReceiver {
        @Override
        public void onReceive(Context context, Intent intent) {
            Toast.makeText(context, "network changes", Toast.LENGTH_SHORT).show();
        }
    }

    private IntentFilter intentFilter;
    private NetworkChangeReceiver networkChangeReceiver;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        intentFilter = new IntentFilter();
        intentFilter.addAction("android.net.conn.CONNECTIVITY_CHANGE");
        networkChangeReceiver = new NetworkChangeReceiver();
        registerReceiver(networkChangeReceiver, intentFilter);
    }
    @Override
    protected void onDestroy() {
        super.onDestroy();
        unregisterReceiver(networkChangeReceiver);
    }
}

当网络状态发生变化时,系统发出的正是一条值为android.net.conn.CONNECTIVITY_CHANGE的广播,也就是说我们的广播接收器想要监听什么广播,就在这里添加相应的action就行了。

// 最好是能准确地告诉用户当前是有网络还是没有网络,我们再修改MainActivity
public class MainActivity extends Activity {
     
    ...
    class NetworkChangeReceiver extends BroadcastReceiver {
        @Override
        public void onReceive(Context context, Intent intent) {
            ConnectivityManager connectionManager = (ConnectivityManager) 
                    getSystemService(Context.CONNECTIVITY_SERVICE);
            NetworkInfo networkInfo = connectionManager.getActiveNetworkInfo();
            if (networkInfo!=null && networkInfo.isAvailable()) {
                Toast.makeText(context, "network is available",
                        Toast.LENGTH_SHORT).show();
            } else {
                Toast.makeText(context, "network is unavailable",
                        Toast.LENGTH_SHORT).show();
            }
        }
    }
}

Android系统为了保证应用程序的安全性做了规定,如果程序需要访问一些系统的关键性信息,必须在配置文件中声明权限才可以,否则程序将会直接崩溃,比如这里查询系统的网络状态就是需要声明权限的。


<manifest ...
    package="com.example.broadcasttest"
    android:versionCode="1"
    android:versionName="1.0" >
    <uses-sdk
        android:minSdkVersion="14"
        android:targetSdkVersion="19" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    ...
manifest>

5.2.2 静态注册实现开机启动

  1. 动态注册的广播接收器可以自由地控制注册与注销,在灵活性方面有很大的优势,但是它也存在着一个缺点,即必须要在程序启动之后才能接收到广播,因为注册的逻辑是写作onCreate()方法中的。
  2. 那么有没有什么办法可以让程序在未启动的情况下就能接收到广播呢?这就需要静态注册了。
// 这里我们让程序接收一条开机广播,当收到这条广播时就可以在onReceive()方法里执行相应的逻辑
// 从而实现开机启动的功能!!!这也太牛了!
// 新建一个BootCompleteReceiver
public class BootCompleteReceiver extends BroadcastReceiver {
     
    @Override
    public void onReceive(Context context, Intent intent) {
        Toast.makeText(context, "Boot Complete", Toast.LENGTH_SHORT).show();
    }
}
// 这里我们不再使用内部类的方式定义广播接收器,因为我们需要在AndroidManifest.xml中将这个广播接收器的类名注册进去

<manifest ... >
    ...
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
    <application
        android:allowBackup="true"
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme" >
        ...
        <receiver android:name=".BootCompleteReceiver" >
            <intent-filter>
                <action android:name="android.intent.action.BOOT_COMPLETED" />
            intent-filter>
        receiver>
    application>
manifest>

首先打开到应用程序管理界面来查看一下当前程序所拥有的权限。在桌面按下Menu键->System settings->Apps,然后点击BroadcastTest。可以看到它目前拥有访问网络状态和开机自动启动的权限。

需要注意的是,不要在onReceive()方法中添加过多的逻辑或者进行任何的耗时操作,因为在广播接收器中是不允许开启线程的,当onReceive()方法运行了较长时间而没有结束时,程序就会报错。因此广播接收器更多的是扮演一种打开程序其他组件的角色,比如创建一条状态栏通知,或者启动一个服务等。

5.3 发送自定义广播

5.3.1 发送标准广播

  1. 在发送广播之前,我们还是需要先定义一个广播接收器来准备接收此广播才行,不然发出去也是白发。因此新建一个MyBroadcastReceiver继承自BroadcastReceiver。
  2. 然后再AndroidManifest.xml中对这个广播接收器进行注册。可以看到,这里让MyBroadcastReceiver接收一条值为com.example.broadcasttest.MY_BROADCAST的广播,因此待会儿在发送广播的时候,我们就需要发出这样的一条广播。
// 点击按钮,发送自定义广播
public class MainActivity extends Activity {
     
    ...
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Button button = (Button) findViewById(R.id.button);
        button.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                Intent intent = new Intent("com.example.broadcasttest.MY_BROADCAST");
                sendBroadcast(intent);
            }
        });
        ...
    }
    ...
}

5.3.2 发送有序广播

  1. 广播是一种可以跨进程的通信方式,为了验证这一点,我们需要再创建一个BroadcastTest2项目。
  2. 可以看到,AnotherBroadcastReceiver同样接收的是com.example.broadcasttest.MY_BROADCAST这条广播。
  3. 发送有序广播只需要改动一行代码,即将sendBroadcast()方法改成sendOrderedBroadcast()方法。sendOrderedBroadcast()方法接收两个参数,第一个参数仍然是Intent,第二个参数是一个与权限相关的字符串,这里传入null就行了。
  4. 那么该如何设定广播接收器的先后顺序呢?在注册的时候。

<manifest ... >
    ...
    <application ...>
        <receiver android:name=".MyBroadcastReceiver">
            <intent-filter android:priority="100" >
                <action android:name="com.example.broadcasttest.MY_BROADCAST" />
            intent-filter>
        receiver>
    application>
manifest>
// 既然已经获得了接收广播的优先权,那么MyBroadcastReceiver就可以选择是否允许广播继续传递了
public class MyBroadcastReceiver extends BroadcastReceiver {
     
    @Override
    public void onReceive(Context context, Intent intent) {
        Toast.makeText(context, "received in MyBroadcastReceiver",
                Toast.LENGTH_SHORT).show();
        abortBroadcast();
    }
}

5.4 使用本地广播

  1. 这样很容易会引起安全性的问题,比如说我们发送的一些携带关键性数据的广播有可能被其他的应用程序截获,或者其他的程序不停地向我们的广播接收器发送各种垃圾广播。
  2. 为了能够简单地解决广播的安全性问题,Android引入了一套本地广播机制,使用这个机制发出的广播只能够在应用程序的内部进行传递,并且广播接收器也只能接收来自本应用程序发出的广播,这样所有的安全性问题就都不存在了。
  3. 本地广播的用法并不复杂,主要就是使用了一个LocalBroadcastManager来对广播进行管理,并提供了发功广播和注册广播接收器的方法。
  4. 另外,本地广播是无法通过静态注册的方式来接收的。(当然)

5.5 广播的最佳实践——实现强制下线功能

  1. 强制下线功能应该算是比较常见的了,很多的应用程序都具备这个功能,比如你的QQ号在别处登陆了,就会将你强制挤下线。其实实现强制下线功能的思路也比较简单,只需要在界面上弹出一个对话框,让用户无法进行任何其他操作,必须要点击对话框中的确定按钮,然后回到登陆界面即可。可是这样就存在一个问题,因为我们被通知需要强制下线时可能正处于任何一个界面,难道需要在每个界面上都编写一个弹出对话框的逻辑?
  2. 强制下线功能需要先关闭掉所有的活动,然后回到登陆界面。如果你的反应足够快的话,应该会想到我们在第2章的最佳实践部分已经实现过关闭所有活动的功能了,因此这里只需要使用同样的方案即可。
  3. 第一步,先创建一个ActivityCollector类用于管理所有的活动。
// 第二步,创建BaseActivity类作为所有活动的父类
public class BaseActivity extends Activity {
     
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        ActivityCollector.addActivity(this);
    }
    @Override
    protected void onDestroy() {
        super.onDestroy();
        ActivityCollector.removeActivity(this);
    }
}

第三步,创建一个登陆界面的布局,我们在3.3.4节理已经编写过登陆界面了,新建布局文件login.xml。

// 第四步,登陆界面的布局已经完成,接下来就应该去编写登陆界面的活动,新建LoginActivity继承自BaseActivity
public class LoginActivity extends BaseActivity {
     
    private EditText accountEdit;
    private EditText passwordEdit;
    private Button login;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.login);
        accountEdit = (EditText) findViewById(R.id.account);
        passwordEdit = (EditText) findViewById(R.id.password);
        login = (Button) findViewById(R.id.login);
        login.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                String account = accountEdit.getText().toString();
                String password = passwordEdit.getText().toString();
                if (account.equals("admin") && password.equals("123456")) {
                    Intent intent = new Intent(LoginActivity.this, MainActivity.class);
                    startActivity(intent);
                    finish();
                } else {
                    Toast.makeText(LoginActivity.this, "account or password is invalid",
                            Toast.LENGTH_SHORT).show();
                }
            }
        });
    }
}

第五步,你可以将MainActivity理解成是登陆成功后进入的程序主界面了,这里我们只需要加入强制下线功能就可以了,修改activity_main.xml,只有一个按钮。

// 第六步,修改MainActivity,在按钮事件里发送一条广播,通知程序强制用户下线
public class MainActivity extends BaseActivity {
     
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Button forceOffline = (Button) findViewById(R.id.force_offline);
        forceOffline.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                Intent intent = new Intent("com.example.broadcastbestpractice.FORCE_OFFLINE");
                sendBroadcast(intent);
            }
        });
    }
}

也就是说强制用户下线的逻辑并不是写在MainActivity里的,而是应该写在接收这条广播的广播接收器里面,这样强制下线的功能就不会依附于任何的界面,不管是在程序的任何地方,只需要发出这样一条广播,就可以完成强制下线的操作了。

// 第七步,那么毫无疑问,我们需要创建一个广播接收器了,新建ForceOfflineReceiver
public class ForceOfflineReceiver extends BroadcastReceiver {
     
    @Override
    public void onReceive(final Context context, Intent intent) {
        AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(context);
        dialogBuilder.setTitle("Warning");
        dialogBuilder.setMessage("You are forced to be offline. Please try to login again.");
        // 一定要调用setCancelable()方法将对话框设为不可取消,否则用户按一下Back键就可以关闭对话框继续使用程序了
        dialogBuilder.setCancelable(false);
        dialogBuilder.setPositiveButton("OK",
                new DialogInterface.OnClickListener() {
                    @Override
                    public void onClick(DialogInterface dialog, int which) {
                        ActivityCollector.finishAll(); // 销毁所有活动
                        // 启动LoginActivity,回到Login界面
                        Intent intent = new Intent(context, LoginActivity.class);
                        // 由于是在广播接收器里启动活动的,因此一定要给Intent加入FLAG_ACTIVITY_NEW_TASK标志
                        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                        context.startActivity(intent);
                    }
                });
        AlertDialog alertDialog = dialogBuilder.create();
        // 需要把对话框类型设为TYPE_SYSTEM_ALERT,不然它将无法在广播接收器里弹出
        alertDialog.getWindow().setType(WindowManager.LayoutParams.TYPE_SYSTEM_ALERT);
        alertDialog.show();
    }
}

<manifest ...>
    <uses-sdk ... />
    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
    <application ...>
        <activity
            android:name=".LoginActivity"
            android:label="@string/app_name" >
            <intent-filter>
            
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            intent-filter>
        activity>
        <activity android:name=".MainActivity" >
        activity>
        <receiver android:name=".ForceOfflineReceiver" >
            <intent-filter>
                <action android:name="com.example.broadcastbestpractice.FORCE_OFFLINE" />
            intent-filter>
        receiver>
    application>
manifest>

5.6 Git时间,初识版本控制工具

Git是一个开源的分布式版本控制工具,它的开发者就是鼎鼎大名的Linux操作系统的作者Linus Torvalds。Git被开发出来的初衷本是为了更好地管理Linux内核,而现在却早已被广泛应用于全球各种大中小型的项目中。

5.6.2 创建代码仓库

  1. 首先应该配置一下你的身份,这样在提交代码的时候Git就可以知道是谁提交的了,命令如下所示:
git config --global user.name "Thomas"
git config --global user.email "[email protected]"

配置完成后你还可以使用相同的命令来查看是否配置成功,只需要将最后的名字和邮箱地址去掉即可。

仓库创建完成后,会在BroadcastBestPractice项目的根目录下生成一个隐藏的.git文件夹,这个文件夹就是用来记录本地所有的Git操作的,可以通过ls -al命令来查看一下。

第6章 数据存储全方案,详解持久化技术

6.1 持久化技术简介

  1. Android系统主要提供了三种方式用于简单地实现数据持久化功能,即文件存储、SharedPreference存储以及数据库存储。

6.2 文件存储

6.2.1 将数据存储到文件中

  1. Context类中提供了一个openFileOutput()方法,可以用于将数据存储到指定的文件中。这个方法接收两个参数,第一个参数是文件名,在文件创建的时候使用的就是这个名称,注意这里指定的文件名不可以包含路径,因为所有的文件都是默认存储到/data/data//files/目录下的。第二个参数是文件的操作模式,主要有两种模式可选,MODE_PRIVATE和MODE_APPEND。
  2. openFileOutput()方法返回的是一个FileOutputStream对象,得到了这个对象之后就可以使用Java流的方式将数据写入到文件中了。
// 输出到文件
public void save() {
    String data = "Data to save";
    FileOutputStream out = null;
    BufferedWriter writer = null;
    try {
        out = openFileOutput("data", Context.MODE_PRIVATE);
        writer = new BufferedWriter(new OutputStreamWriter(out));
        writer.write(data);
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        try {
            if (writer != null) {
                writer.close();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

我们可以借助DDMS的File Explorer来查看一下。切换到DDMS视图,并点击File Explorer切换卡,在这里进入到/data/data/com.example.filepersistencetest/files/目录下,可以看到生成了一个data文件。

6.2.2 从文件中读取数据

// openFileInput()
public String load() {
    FileInputStream in = null;
    BufferedReader reader = null;
    StringBuilder content = new StringBuilder();
    try {
        in = openFileInput("data");
        reader = new BufferedReader(new InputStreamReader(in));
        String line = "";
        while ((line = reader.readLine()) != null) {
            content.append(line);
        }
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        if (reader!=null) {
            try {
                reader.close();
            } catch(IOException e) {
                e.printStackTrace();
            }
        }
    }
    return content.toString();
}

我们在对字符串进行非空判断时使用了TextUtils.isEmpty()方法,这是一个非常好的方法,它可以一次性进行两种空值的判断。当传入的字符串等于null或者等于空字符串的时候,这个方法都会返回true,从而使得我们不需要单独去判断这两种空值,再使用逻辑运算符连接起来了。

6.3 SharedPreferences存储

6.3.1 将数据存储到SharedPreferences中

  1. Android中主要提供了三种方法用于得到SharedPreferences对象。

    Context类中的getSharedPreferences()方法:此方法接收两个参数,第一个参数用于指定SharedPreferences文件的名称,如果指定的文件不存在则会创建一个,SharedPreferences文件都是存放在/data/data//shared prefs/目录下的。MODE_MULTI_PROCESS则一般是用于会有多个进程中对同一个SharedPreferences文件进行读写的情况。
    Activity类中的getPreferences()方法:这个方法和Context中的getSharedPreferences()方法相似,不过它只接收一个操作模式参数,因为使用这个方法时会自动将当前活动的类名作为SharedPreferences的文件名。
    PreferenceManager类中的getDefaultSharedPreferences()方法:这是一个静态方法,它接收一个Context参数,并自动使用当前应用程序的包名作为前缀来命名SharedPreferences文件。


向SharedPreferences文件中存储数据,分为三步:
调用SharedPreferences对象的edit()方法来获取一个SharedPreferences.Editor对象。
向SharedPreferences.Editor对象中添加数据,比如添加一个布尔型数据就使用putBoolean方法,添加一个字符串则使用putString()方法,以此类推。
调用commit()方法将添加的数据提交,从而完成数据存储操作。
// 修改MainActivity,向SharedPreferences文件存储数据
public class MainActivity extends Activity {
     
    private Button saveData;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        saveData = (Button) findViewById(R.id.save_data);
        saveData.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                SharedPreferences.Editor editor = getSharedPreferences("data", MODE_PRIVATE).edit();
                editor.putString("name", "Tom");
                editor.putInt("age", 28);
                editor.putBoolean("married", false);
                editor.commit();
            }
        });
    }
}
**SharedPreferences文件是使用XML格式来对数据进行管理的**:

<map>
<string name="name">Tomstring>
<int name="age" value="28" />
<boolean name="married" value="false" />
map>

6.3.2 从SharedPreferences中读取数据

SharedPreferences pref = getSharedPreferences("data", MODE_PRIVATE);
String name = pref.getString("name", "");
int age = pref.getInt("age", 0);
boolean married = pref.getBoolean("married", false);

6.3.3 实现记住密码功能


<TableLayout ...
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:stretchColumns="1" >
    ...
    <TableRow>
        <CheckBox
            android:id="@+id/remember_pass"
            android:layout_height="wrap_content" />
        <TextView
            android:layout_height="wrap_content"
            android:text="Remember password" />
    TableRow>
    <TableRow>
        <Button
            android:id="@+id/login"
            android:layout_height="wrap_content"
            android:layout_span="2"
            android:text="Login" />
    TableRow>
TableLayout>

6.4 SQLite数据库

  1. SQLite是一款轻量级的关系型数据库,它的运算速度非常快,占用资源很少,通常只需要几百K的内存就足够了,因而特别适合在移动设备上使用。
  2. 而SQLite又比一般的数据库要简单得多,它甚至不用设置用户名和密码就可以使用。

6.4.1 创建数据库

  1. Android提供了SQLiteOpenHelper帮助类,借助这个类就可以非常简单地对数据库进行创建和升级。
  2. 首先SQLiteOpenHelper是一个抽象类。SQLiteOpenHelper有两个抽象方法,分别是onCreate()和onUpgrade(),实现创建、升级数据库的逻辑。
  3. SQLiteOpenHelper中还有两个非常重要的实例方法,getReadableDatabase()和getWritableDatabase()。这两个方法都可以创建或打开一个现有的数据库(如果数据库已存在则直接打开,否则创建一个新的数据库),并返回一个可对数据库进行读写操作的对象。不同的是,当数据库不可写入的时候(如磁盘空间已满)getReadableDatabase()方法返回的对象将以只读的方式去打开数据库,而getWritableDatabase()方法则将出现异常。
  4. SQLiteOpenHelper构造方法中接收四个参数,第一个参数是Context,第二个是数据库名,第三个参数允许我们在查询数据的时候返回一个自定义的Cursor,一般都是传入null。第四个参数表示当前数据库的版本号,可用于对数据库进行升级操作。构建出SQLiteOpenHelper的实例之后,再调用它的getReadableDatabase()或getWritableDatabase()方法就能够创建数据库了,数据库文件会放在/data/data//databases/目录下。此时,重写的onCreate()方法也会得到执行,所有通常会在这里去处理一些创建表的逻辑。(onCreate()方法只有在创建数据库的时候才会调用,如果数据库已经存在,则onCreate()不会调用,也就不会再执行里面的建表操作了)
  5. SQLite不像其他的数据库拥有众多繁杂的数据类型,它的数据类型很简单,integer表示整型,real表示浮点型,text表示文本型,blob表示二进制类型。
// 定义MyDatabaseHelper,继承SQLiteOpenHelper
public class MyDatabaseHelper extends SQliteOpenHelper {
     
    public static final String CREATE_BOOK = "create table Book ("
            + "id integer primary key autoincrement, "
            + "author text, "
            + "price real, "
            + "pages integer, "
            + "name text)";
    private Context mContext;
    public MyDatabaseHelper(Context context, String name, CursorFactory factory, int version) {
        super(context, name, factory, version);
        mContext = context;
    }
    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL(CREATE_BOOK);
        Toast.makeText(mContext, "Create succeeded", Toast.LENGTH_SHORT).show();
    }
    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
    }
}
// 创建数据库
public class MainActivity extends Activity {
     
    private MyDatabaseHelper dbHelper;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        dbHelper = new MyDatabaseHelper(this, "BookStore.db", null, 1);
        Button createDatabase = (Button) findViewById(R.id.create_database);
        createDatabase.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                dbHelper.getWritableDatabase();
            }
        });
    }
}

第一次点击Create database按钮时,就会检测到当前程序中并没有BookStore.db这个数据库,于是会创建该数据库并调用MyDatabaseHelper中的onCreate()方法,这样Book表也就得到了创建,然后会弹出一个Toast提示创建成功。

如果还是使用File Explorer,那么最多你只能看到databases目录下出现了一个Bookstore.db文件,Book表是无法通过File Explorer看到的。因此这次我们使用adb shell来对数据库和表的创建情况进行检查。

adb是Android SDK中自带的一个调试工具,使用这个工具可以直接对连接在电脑上的手机或模拟器进行调试操作。它存放在sdk的platform-tools目录下,如果想要在命令行中使用这个工具,就需要先把它的路径配置到环境变量里。

如果你使用Windows,可以右击我的电脑->属性->高级->环境变量,然后在系统变量里找出Path并点击编辑,将platform-tools目录配置进去。

如果是Linux,可以在home路径下编辑.bash_profile文件,将platform-tools目录配置进去即可

export PATH=$PATH:$HOME/android-sdk-linux/platform-tools

配置好环境变量后,就可以使用adb了。打开命令行界面,输入adb shell。然后cd进入/data/data/com.example.databasetest/databases/目录下,查看该目录里的文件。

借助sqlite命令来打开数据库,只需要键入sqlite3,后面加上数据库名即可。.table查看目前数据库中有哪些表。.schema查看它们的建表语句。.exit或.quit退出数据库的编辑。再exit就可以退出设备控制台。

6.4.2 升级数据库

  1. 其实没有创建成功的原因不难思考,因为此时Bookstore.db数据库已经存在了,之后不管我们怎样点击Create database按钮,MyDatabaseHelper中的onCreate()方法都不会再次执行,因此新添加的表也无法得到创建了。
  2. 不过通过卸载程序的方式来新增一张表毫无疑问是很极端的做法,其实我们只需要巧妙地运用SQLiteOpenHelper的升级功能就可以解决这个问题。修改MyDatabaseHelper:
public class MyDatabaseHelper extends SQLiteOpenHelper {
     
    ...
    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        db.execSQL("drop table if exists Book");
        db.execSQL("drop table if exists Category");
        onCreate(db);
    }
}

(这里有个很重要的问题,什么叫重新运行,什么叫卸载?我觉得:重新运行就是重新run这个项目,重新编译。可能这样不同于卸载的地方,就是重新运行不会删掉已有的持久化存储,但是卸载就会。想想升级程序的时候的情景。)

这里先将已经存在的表删除掉,是因为如果在创建表时发现这张表已经存在了,就会直接报错。

接下来就是如何让onUpgrade()方法能够执行了,还记得SQLiteOpenHelper的构造方法里接收的第四个参数吗?它表示当前数据库的版本号,之前我们传入的是1,现在只要传入一个比1大的数,就可以让onUpgrade()方法得到执行了。

这里将数据库版本号指定为2,表示我们对数据库进行升级了。现在重新运行程序,并点击Create database按钮,这时就会再次弹出创建成功的提示。

6.4.3 添加数据

// 用SQLiteDatabase的insert()方法
SQLiteDatabase db = dbHelper.getWritableDatabase();
ContentValues values = new ContentValues();
values.put("name", "The Da Vinci Code");
values.put("author", "Dan Brown");
values.put("pages", 454);
values.put("price", 16.96);
// 第二个参数用于在未指定添加数据的情况下给某些可为空的列自动赋值NULL,一般我们用不到这个功能,直接传入null即可
db.insert("Book", null, values);
values.clear();
values.put("name", "The Lost Symbol");
values.put("author", "Dan Brown");
values.put("pages", 510);
values.put("price", 19.95);
db.insert("Book", null, values);

6.4.4 更新数据

// 用SQLiteDatabase的update()方法
SQLiteDatabase db = dbHelper.getWritableDatabase();
ContentValues values = new ContentValues();
values.put("price", 10.99);
// 第三个参数对应的是SQL语句的where部分,?是一个占位符,给第四个参数占的
db.update("Book", values, "name = ?", new String[] { "The Da Vinci Code" });

6.4.5 删除数据

// 用SQLiteDatabase的delete()方法
SQLiteDatabase db = dbHelper.getWritableDatabase();
db.delete("Book", "pages > ?", new String[] { "500" });

6.4.6 查询数据

query()方法的七个参数及对应SQL部分:

table - from table_name
columns - select column1, column2
selection - where column = value
selectionArgs - -
groupBy - group by column
having - having column = value
orderBy - order by column1, column2

调用query()方法后返回一个Cursor对象,查询到的所有数据都将从这个对象中取出。

// 查询所有数据
SQLiteDatabase db = dbHelper.getWritableDatabase();
Cursor cursor = db.query("Book", null, null, null, null, null, null);
if (cursor.moveToFirst()) {
    do {
        String name = cursor.getString(cursor.getColumnIndex("name"));
        String author = cursor.getString(cursor.getColumnIndex("author"));
        int pages = cursor.getInt(cursor.getColumnIndex("pages"));
        double price = cursor.getDouble(cursor.getColumnIndex("pages"));
    } while (cursor.moveToNext());
}
cursor.close();

6.4.7 使用SQL操作数据库

添加数据:

db.execSQL("insert into Book (name, author, pages, price) values(?, ?, ?, ?)",
        new String[] { "The Da Vinci Code", "Dan Brown", "454", "16.96" });
db.execSQL("insert into Book (name, author, pages, price) values(?, ?, ?, ?)",
        new String[] { "The Lost Symbol", "Dan Brown", "510", "19.95" });

更新数据:

db.execSQL("update Book set price = ? where name = ?", 
        new String[] { "10.99", "The Da Vinci Code" });

删除数据:

db.execSQL("delete from Book where pages > ?", new String[] { "500" });

查询数据:

// 第二个参数null是什么意思,是说查询语句里没有占位符?所以不用填充?
db.rawQuery("select * from Book", null);

(虽然还是怪怪的,但就这样吧)

6.5 SQLite数据库的最佳实践

6.5.1 使用事务

SQLiteDatabase db = dbHelper.getWritableDatabase();
db.beginTransaction();
try {
    db.delete("Book", null, null);
    /* if (true) {
        // 在这里手动抛出一个异常,让事务失败,那么上面的delete也会被回滚恢复
        throw new NullPointerException();
    } */
    ContentValues values = new ContentValues();
    values.put("name", "Game of Thrones");
    values.put("author", "George Martin");
    values.put("pages", 720);
    values.put("price", 20.85);
    db.insert("Book", null, values);
    db.setTransactionSuccessful(); // 指commit?
} catch (Exception e) {
    e.printStackTrace();
} finally {
    db.endTransaction(); // 在MySQL里有start transaction和commit,除此之外就没了啊...
}

6.5.2 升级数据库的最佳写法

  1. 在6.4.2节中我们学习的升级数据库的方式是非常粗暴的,为了保证数据库中的表是最新的,我们只能简单地在onUpgrade()方法中删除掉了当前所有的表,然后强制重新执行了一遍onCreate()方法。
  2. 现在由于添加新功能的原因,使得数据库也需要一起升级,然后用户更新了这个版本之后发现以前程序中存储的本地数据全部丢失了!
// 第一版本,只需要创建一张Book表,那MyDatabaseHelper中的onCreate和onUpgrade这样
@Override
public void onCreate(SQLiteDatabase db) {
    db.execSQL(CREATE_BOOK);
}
@Override
public void onUpgrade(SQLiteDatabse db, int oldVersion, int newVersion) {
}
// 第二版,需要向数据库中再添加一张Category表,于是MyDatabaseHelper变成这样
@Override
public void onCreate(SQLiteDatabase db) {
    db.execSQL(CREATE_BOOK);
    db.execSQL(CREATE_CATEGORY);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
    switch (oldVersion) {
    case 1:
        db.execSQL(CREATE_CATEGORY);
    default:
    }
}
// 第三版,要给Book表和Category表之间建立关联,需要在Book表中添加一个category_id的字段
// 首先就是修改CREATE_BOOK这个String啦,添加一个category_id integer
// onCreate()方法不用变,新用户反正还是要建这两张表
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
    switch (oldVersion) {
    case 1:
        db.execSQL(CREATE_CATEGORY);
    case 2:
        db.execSQL("alter table Book add column category_id integer");
    default:
    }
}

这里请注意一个非常重要的细节,switch中每一个case的最后都是没有使用break的,为什么要这么做呢?这是为了保证在跨版本升级的时候,每一次的数据库修改都能被全部执行到。比如用户当前是从第二版程序升级到第三版程序,那么case 2中的逻辑就会执行。而如果用户是直接从第一版程序升级到第三版程序的,那么case 1和case 2中的逻辑都会执行。使用这种方式来维护数据库的升级,不管版本怎样更新,都可以保证数据库的表结构是最新的,而且表中的数据也完全不会丢失了。

第7章 跨程序共享数据,探究内容提供器

一些可以让其他程序进行二次开发的基础性数据,我们还是可以选择将其共享的。例如系统的电话簿程序,它的数据库中保存了很多的联系人信息,如果这些数据都不允许第三方的程序进行访问的话,恐怕很多应用的功能都要大打折扣了。除了电话簿之外,还有短信、媒体库等程序都实现了跨程序数据共享的功能,而使用的技术当然就是内容提供器了。

7.1 内容提供器简介

  1. 内容提供器(Content Provider)主要用于在不同的应用程序之间实现数据共享的功能,它提供了一套完整的机制,允许一个程序访问另一个程序中的数据,同时还能保证被访问数据的安全性。目前,使用内容提供器是Android实现跨程序共享数据的标准方式。
  2. 内容提供器的用法一般有两种,一种是使用现有的内容提供器来读取和操作相应程序中的数据,另一种是创建自己的内容提供器给我们程序的数据提供外部访问接口。

7.2 访问其他程序中的数据

7.2.1 ContentResolver的基本用法

  1. 对于每一个应用程序来说,如果想要访问内容提供器中共享的数据,就一定要借助ContentResolver类,可以通过Context中的getContentResolver()方法获取到该类的实例。
  2. 不同于SQLiteDatabase,ContentResolver中的增删改查方法都不接收表名参数,而是使用一个Uri参数代替,这个参数被称为内容URI。内容URI给内容提供器中的数据建立了唯一标识符,它主要由两部分组成,权限(authority)和路径(path)。权限是用于对不同的应用程序做区分的,一般为了避免冲突,都会采用程序包名的方式来进行命名。比如某个程序的包名是com.example.app,那么该程序对应的权限就可以命名为com.example.app.provider。
  3. 内容URI最标准的格式写法如下:
content://com.example.app.provider/table1
content://com.example.app.provider/table2
Uri uri = Uri.parse("content://com.example.app.provider/table1");

getContentResolver().query()方法的参数及对应SQL部分

uri - from table_name
projection - select column1, column2
selection - where column = value
selectionArgs - 为where中的占位符提供具体的值
orderBy - order by column1, column2

getContentResolver().insert(uri, values);
getContentResolver().update(uri, values, "column1 = ? and column2 = ?", 
        new String[] {
    "text", "1"});
getContentResolver().delete(uri, "column2= ?", new String[] { "1" });

7.2.2 读取系统联系人

// LinearLayout里就只放了一个ListView,修改MainActivity
public class MainActivity extends Activity {
     
    ListView contactsView;
    ArrayAdapter adapter;
    List contactsList = new ArrayList();
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        contactsView = (ListView) findViewById(R.id.contacts_view);
        adapter = new ArrayAdapter(this, android.R.layout.simple_list_item_1, contactsList);
        contactsView.setAdapter(adapter);
        readContacts();
    }
    private void readContacts() {
        Cursor cursor = null;
        try {
            cursor = getContentResolver().query(
                    ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
                    null, null, null, null);
            while (cursor.moveToNext()) {
                String displayName = cursor.getString(cursor.getColumnIndex(
                        ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME));
                String number = cursor.getString(cursor.getColumnIndex(
                        ContactsContract.CommonDataKinds.Phone.NUMBER));
                contactsList.add(displayName + "\n" + number);
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (cursor != null) {
                cursor.close();
            }
        }
    }
}

<manifest ...>
    ...
    <uses-permission android:name="android.permission.READ_CONTACTS" />
    ...
manifest>

7.3 创建自己的内容提供器

7.3.1 创建内容提供器的步骤

  1. ContentProvider类有六个抽象方法,分别是增删改查,onCreate()和getType()。
  2. onCreate():初始化内容提供器的时候调用。通常会在这里完成对数据库的创建和升级等操作,返回true表示内容提供器初始化成功,返回false表示失败。注意,只有当存在ContentResolver尝试访问我们程序中的数据时,内容提供器才会被初始化。
  3. getType():根据传入的内容URI来返回相应的MIME类型。
  4. 我们可以在内容URI的后面加上一个id:
content://com.example.app.provider/table1/1

这就表示调用方期望访问的是com.example.app这个应用的table1表中id为1的数据。

内容URI的格式主要就只有两种,以路径结尾就表示期望访问该表中所有的数据,以id结尾就表示期望访问该表中拥有相应id的数据。我们可以使用通配符的方式来分别匹配来匹配这两种格式的内容URI:

*:表示匹配任意长度的任意字符
#:表示匹配任意长度的数字

当调用UriMatcher的match()方法时,就可以将一个Uri对象传入,返回值是某个能够匹配这个Uri对象所对应的自定义代码,利用这个代码,我们就可以判断出调用方期望访问的是哪张表中的数据了:

// MyProvider中使用UriMatcher,分析URI,来判断该采取的动作
public static final int TABLE1_DIR = 0;
public static final int TABLE1_ITEM = 1;
public static final int TABLE2_DIR = 2;
public static final int TABLE2_ITEM = 3;
private static UriMatcher uriMatcher;
static {
    uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
    uriMatcher.addURI("com.example.app.provider", "table1", TABLE1_DIR);
    uriMatcher.addURI("com.example.app.provider", "table1/#", TABLE1_ITEM);
    uriMatcher.addURI("com.example.app.provider", "table2", TABLE2_DIR);
    uriMatcher.addURI("com.example.app.provider", "table2/#", TABLE2_ITEM);
}
...
@Override
public Cursor query(Uri uri, ...) {
    switch (uirMatcher.match(uri)) {
    case TABLE1_DIR:
        // 查询table1表中的所有数据
        break;
    case TABLE1_ITEM:
        // 查询table1表中的单条数据
        break;
    case TABLE2_DIR:
        // 查询table2表中的所有数据
        break;
    case TABLE2_ITEM:
        // 查询table2表中的单条数据
        break;
    default:
        break;
    }
    ...
}
...

一个内容URI所对应的MIME字符串主要由三部分组成,Android对这三个部分做了如下格式规定:

必须以vnd开头。
如果内容URI以路径结尾,则后接android.cursor.dir/,如果内容URI以id结尾,则后接android.cursor.item/。
最后接上vnd..

所以对于content://com.example.app.provider/table1这个内容URI,它所对应的MIME类型可以写成:

vnd.android.cursor.dir/vnd.com.example.app.provider.table1

对于content://com.example.app.provider/table1/1这个内容URI,它所对应的MIME类型可以写成:

vnd.android.cursor.item/vnd.com.example.app.provider.table1
// 依照上面的,在MyProvider里实现getType()的逻辑,缺break吧??
@Override
public String getType(Uri uri) {
    switch (uriMatcher.match(uri)) {
    case TABLE1_DIR:
        return "vnd.android.cursor.dir/vnd.com.example.app.provider.table1";
    case TABLE1_ITEM:
        return "vnd.android.cursor.item/vnd.com.example.app.provider.table1";
    case TABLE2_DIR:
        return "vnd.android.cursor.dir/vnd.com.example.app.provider.table2";
    case TABLE2_ITEM:
        return "vnd.android.cursor.item/vnd.com.example.app.provider.table2";
    default:
        break;
    }
    return null;
}

因为所有的CRUD操作都一定要匹配到相应的内容URI格式才能进行的,而我们当然不可能向UriMatcher中添加隐私数据的URI,所以这部分数据根本无法被外部程序访问到,安全问题也就不存在了。

7.3.2 实现跨程序数据共享

  1. 首先将MyDatabaseHelper中使用Toast弹出创建数据库成功的提示去除掉,因为跨程序访问时我们不能直接使用Toast。
// 添加DatabaseProvider类
public class DatabaseProvider extends ContentProvider {
     
    // 提供UriMatcher,为BOOK_DIR,BOOK_ITEM,CATEGORY_DIR,CATEGORY_ITEM添加URI
    // 需要MyDatabaseHelper
    private MyDatabaseHelper dbHelper;
    // 重写boolean onCreate(), Cursor query(),
    // 重写 Uri insert(), int update()
    // 重写 int delete(), String getType()

Uri对象的getPathSegments()方法,它会将内容URI权限之后的部分以“/”符号进行分割,并把分割后的结果放入到一个字符串列表中,那这个列表的第0个位置存放的就是路径,第1个位置存放的就是id了。

还要在AndroidManifest.xml中注册内容提供器。

<provider
    android:name="com.example.databasetest.DatabaseProvider"
    android:authorities="com.example.databasetest.provider"
    android:exported="true">
provider>

现在,就可以在ProviderTest程序的MainActivity里通过URI访问到DatabaseTest程序数据库中的表了。并继而可以增删改查。

7.4 Git时间,版本控制工具进阶

7.4.1 忽略文件

  1. 在ProviderTest项目根目录创建一个名为.gitignore的文件,如果编辑里面的内容为:
bin/
gen/

那么当我们git add .时,bin目录和gen目录下的文件都会被忽略,不会被添加。

7.4.2 查看修改内容

在项目根目录下输入:

git status

会提示哪些文件发生了更改。例如MainActivity.java发生过更改,要查看更改的内容:

git diff src/com/example/providertest/MainActivity.java

减号代表删除的部分,加号代表添加的部分。

7.4.3 撤销未提交的修改

例如,我修改了MainActivity.java,但还没有提交(还没有add),那么要撤销这个修改:

git checkout src/com/example/providertest/MainActivity.java

不过这种撤销方式只适用于那些还没有执行过add命令的文件,如果某个文件已经被添加过了,这种方式就无法撤销其更改的内容。

现在我们要先对齐取消添加,然后才可以撤回提交。取消添加使用reset命令:

git reset HEAD src/com/example/providertest/MainActivity.java

之后就能用checkout来撤销更改了。

7.4.4 查看提交记录

git log

每次提交记录都会包含提交id,提交人,提交日期,以及提交描述(就是git commit -m 后面的字符串)这四个信息。

如果我们只想看一行记录,可以指定该记录的提交id,-l表示我们只想看到一行记录。如果要查看这条记录具体修改了什么内容,再加入-p参数。同样,减号表示删除的部分,加号代表添加的部分。

第8章 丰富你的程序,运用手机多媒体

8.1 使用通知

通知(Notification)是Android系统中比较有特色的一个功能,当某个应用程序希望向用户发出一些提示信息,而该应用程序又不在前台运行时,就可以借助通知来实现。发出一条通知后,手机最上方的状态栏中会显示一个通知的图标,下拉状态栏后可以看到通知的详细内容。Android的通知功能获得了大量用户的认可和喜爱,就连iOS系统也在5.0版本之后加入了类似的功能。

8.1.1 通知的基本用法

  1. 相比于广播接收器和服务,在活动里创建通知的场景还是比较少的,因为一般只有当程序进入到后台的时候我们才需要使用通知。
  2. 首先需要一个NotificationManager来对通知进行管理,可以调用Context的getSystemService()方法获取到。getSystemService()方法接收一个字符串参数用于确定获取系统的哪个服务,这里我们传入Context.NOTIFICATION_SERVICE即可。
  3. Notification的有参构造函数接收三个参数,第一个参数用于指定通知的图标,比如项目的res/drawable目录下有一张icon.png图片,那么这里就可以传入R.drawable.icon。第二个参数用于指定通知的ticker内容,当通知刚被创建的时候,它会在系统的状态栏一闪而过,属于一种瞬时的提示信息。第三个参数用于指定通知被创建的时间,以毫秒为单位,当下拉系统状态栏时,这里指定的时间会显示在相应的通知上。
  4. 创建好了Notification对象后,我们还需要对通知的布局进行设定,这里只需要调用Notification的setLatestEventInfo()方法就可以给通知设置一个标准的布局。这个方法接收四个参数,第一个参数是Context,第二个参数用于指定通知的标题内容,下拉系统状态栏就可以看到这部分内容。第三个参数用于指定通知的正文内容,同样下拉系统状态栏就可以看到这部分内容。第四个参数是PendingIntent,先传入null。
  5. 以上工作完成后,只需要调用NotificationManager的notify()方法就可以让通知显示出来了。notify()方法接收两个参数,第一个参数是id,要保证为每个通知所指定的id都是不同的。第二个参数则是Notification对象,这里直接将我们刚刚创建好的Notification对象传入即可。
// 在Activity里实现点击按钮发送通知
public class MainActivity extends Activity implements OnClickListener {
     
    private Button sendNotice;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        sendNotice = (Button) findViewById(R.id.send_notice);
        sendNotice.setOnClickListener(this);
    }
    @Override
    public void onClick(View v) {
        switch (v.getId()) {
        case R.id.send_notice:
            // 创建通知的逻辑啦!
            NotificationManager manager = (NotificationManager) 
                    getSystemService(NOTIFICATION_SERVICE);
            Notification notification = new Notification(R.drawable.ic_launcher,
                    "This is ticker text", System.currentTimeMillis());
            notification.setLatestEventInfo(this, "This is content title",
                    "This is content text", null);
            // 这里我们指定notification的id是1,后面cancel掉它的时候要用到
            manager.notify(1, notification);
            break;
        default:
            break;
        }
    }
}

如果你使用过Android手机,此时应该会下意识地认为这条通知是可以点击的。但是当你去点击它的时候,你会发现没有任何效果。其实想要实现通知的点击效果,我们还需要在代码中进行相应的设置,这就涉及到了一个新的概念,PendingIntent。

不同的是,Intent更加倾向于去立即执行某个动作,而PendingIntent则更加倾向于在某个合适的时机去执行某个动作。所以,也可以把PendingIntent简单地理解为延迟执行的Intent。

PendingIntent的用法同样很简单,它主要提供了几个静态方法用于获取PendingIntent的实例,可以根据需求来选择是使用getActivity()方法、getBroadcast()方法、还是getService()方法。第二个参数一般用不到,通常都是传入0即可。第三个参数是一个Intent对象,我们可以通过这个对象构建出PendingIntent的“意图”。第四个参数用于确定PendingIntent的行为,有FLAG_ONE_SHOT、FLAG_NO_CREATE、FLAG_CANCEL_CURRENT和FLAG_UPDATE_CURRENT这四个值可选。

刚才我们将setLatestEventInfo()方法的第四个参数忽略掉了,直接传入了null,现在仔细观察一下,发现第四个参数正是一个PendingIntent对象。因此,这里就可以通过PendingIntent构建出一个延迟执行的“意图”,当用户点击这条通知时就会执行相应的逻辑。

// 给通知加入点击功能
public class MainActivity extends Activity implements OnClickListener {
     
    ...
    @Override
    public void onClick(View v) {
        switch (v.getId()) {
        case R.id.send_notice:
            NotificationManager manager = (NotificationManager)
                    getSystemService(NOTIFICATION_SERVICE);
            Notification notification = new Notification(R.drawable.ic_launcher,
                    "This is ticker text", System.currentTimeMillis());
            // 添加intent,点击通知后,启动NotificationActivity
            Intent intent = new Intent(this, NotificationActivity.class);
            PendingIntent pi = PendingIntent.getActivity(this, 0, intent, 
                    PendingIntent.FLAG_CANCEL_CURRENT);
            notifcation.setLatestEventInfo(this, "This is content title",
                    "This is content text", pi);
            manager.notify(1, notification);
            break;
        default:
            break;
        }
    }
}

怎么系统状态上的通知图标还没消失呢?是这样的,如果我们没在代码中对该通知进行取消,它就会一直显示在系统的状态栏上。所以,我们在NotificationActivity中调用NotificationManager的cancel()方法:

public class NotificationActivity extends Activity {
     
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.notification_layout);
        NotificationManager manager = (NotificationManager)
                getSystemService(NOTICATION_SERVICE);
        // 1就是notification的id
        manager.cancel(1);
    }
}

8.1.2 通知的高级技巧

  1. 先来看看sound这个属性吧,它可以在通知发出的时候播放一段音频,这样就能够更好地告知用户有通知到来。sound这个属性是一个Uri对象,所以在指定音频文件的时候还需要先获取到音频文件对应的URI。
// 音频Uri
Uri soundUri = Uri.fromFile(new File("/system/media/audio/ringtones/Basic_tone.ogg"));
notification.sound = soundUri;

除了允许播放音频外,我们还可以在通知到来的时候让手机进行振动,使用的是vibrate这个属性。它是一个长整型的数组,用于设置手机静止和振动的时长,以毫秒为单位。下标为0的值表示手机静止的时长,下标为1的值表示手机振动的时长,下标为2的值又表示手机静止的时长,以此类推。所以,如果想要让手机在通知到来的时候立刻振动1秒,然后静止1秒,再振动1秒,代码就可以写成:

// long[] vibrates = {0, 1000, 1000, 1000};
notification.vibrate = vibrates;

不过,想要控制手机振动还需要声明权限的:

<uses-permission android:name="android.permission.VIBRATE" />

现在的手机基本上都会前置一个LED灯,当有未接电话或未读短信,而此时手机又处于锁屏状态时,LED灯就会不同地闪烁,提醒用户去查看。我们可以使用ledARGB、ledOnMS、ledOffMS以及flags这几个属性来实现这种效果。ledARGB用于控制LED灯的颜色,一般有红绿蓝三种颜色可选。ledOnMS用于指定LED灯亮起的时长,以毫秒为单位。ledOffMS用于指定LED灯暗去的时长,也是以毫秒为单位。flags可用于指定通知的一些行为,其中就包括显示LED灯这一选项。所以,当通知到来时,如果想要实现LED灯以绿色的灯光一闪一闪的效果,就可以写成:

notification.ledARGB = color.GREEN;
notification.ledOnMS = 1000;
notification.ledOffMS = 1000;
notification.flags = Notification.FLAG_SHOW_LIGHTS;

当然,如果你不想进行这么多繁杂的设置,也可以直接使用通知的默认效果,它会根据当前手机的环境来决定播放什么铃声,以及如何振动:

notification.defaults = Notification.DEFAULT_ALL;

注意,以上所涉及的这些高级技巧都要在手机上运行才能看得到效果,模拟器是无法表现出振动、以及LED灯闪烁等功能的。

8.2 接收和发送短信

8.2.1 接收短信

  1. 其实接收短信主要是利用了广播机制。当手机接收到一条短信的时候,系统会发出一条值为android.provider.Telephony.SMS_RECEIVED的广播,这条广播里携带着与短信相关的所有数据。每个应用程序都可以在广播接收器里对它进行监听,收到广播时再从中解析出短信的内容即可。
  2. 在activity_main.xml里,我们在根元素下面放置了两个LinearLayout,用于显示两行数据。第一个LinearLayout中有两个TextView,用于显示短信的发送方。第二个LinearLayout中也有两个TextView,用于显示短信的内容。
  3. 然后我们需要创建一个广播接收器来接收系统发出的短信广播。在MainActivity中新建MessageReceiver内部类继承自BroadcastReceiver,并在onReceive()方法中编写获取短信数量的逻辑(我将不断只在这一个代码块里完善,就不分隔不同小节的功能了)
// 在MainActivity里不断完善功能
public class MainActivity extends Activity {
     
    private TextView sender;
    private TextView content;
    private IntentFilter receiveFilter;
    private MessageReceiver messageReceiver;
    private IntentFilter sendFilter;
    private SendStatusReceiver sendStatusReceiver;
    private EditText to;
    private EditText msgInput;
    private Button send;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        sender = (TextView) findViewById(R.id.sender);
        content = (TextView) findViewById(R.id.content);
        // 第二步,注册MessageReceiver,以及在onDestroy()里取消注册
        receiveFilter = new IntentFilter();
        receiveFilter.addAction("android.provider.Telephony.SMS_RECEIVED");
        messageReceiver = new MessageReceiver();
        registerReceiver(messageReceiver, receiveFilter);

        // 第四步,注册SendStatusReceiver,以及在onDestroy()里取消注册
        sendFilter = new IntentFilter();
        sendFilter.addAction("SENT_SMS_ACTION");
        sendStatusReceiver = new SendStatusReceiver();
        registerReceiver(sendStatusReceiver, sendFilter);

        // 第三步,加入发送短信的处理逻辑
        to = (EditText) findViewById(R.id.to);
        msgInput = (EditText) findViewById(R.id.msg_input);
        send = (Button) findViewById(R.id.send);
        send.setOnClickListener(new OnClickListener(0 {
            @Override
            public void onClick(View v) {
                SmsManager smsManager = SmsManager.getDefault();
                // 第四步,利用senTextMessage()方法的第四个参数来对短信的发送状态进行监控
                Intent sentIntent = new Intent("SENT_SMS_ACTION");
                PendingIntent pi = PendingIntent.getBroadcast
                        (MainActivity.this, 0, sentIntent, 0);
                // smsManager.sendTextMessage(to.getText().toString(), null,
                        msgInput.getText().toString(), null, null);
                smsManager.sendTextMessage(to.getText().toString(), null,
                        msgInput.getText().toString(), pi, null);
            }
        });
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        unregisterReceiver(messageReceiver);
        unregisterReceiver(sendStatusReceiver);
    }

    // 第一步,创建内部类广播接收器MessageReceiver,处理收到短信之后的逻辑
    class MessageReceiver extends BroadcastReceiver {
        @Override
        public void onReceive(Context context, Intent intent) {
            Bundle bundle = intent.getExtras();
            Object[] pdus = (Object[]) bundle..get("pdus"); // 提取短信消息
            SmsMessage[] messages = new SmsMessage[pdus.length];
            for(int i=0; ibyte[]) pdus[i]);
            String address = messages[0].getOriginatingAddress(); // 获取发送方号码
            /* String fullMessage = "";
            for(SmsMessage message : messages)
                这么写显然不好,显然应该用StringBuilder
                fullMessage += message.getMessageBody(); // 获取短信内容 */
            StringBuilder sb = new StringBuilder();
            for (SmsMessage message : messages)
                sb.append(message.getMessageBody());
            sender.setText(address);
            sender.setText(sb.toString());
        }
    }

    // 第四步,创建内部类广播接收器SendStatusReceiver来监控短信发送状态
    class SendStatusReceiver extends BroadcastReceiver {
        @Override
        public void onReceive(Context context, Intent intent) {
            if (getResultCode() == RESULT_OK) {
                // 短信发送成功
                Toast.makeText(context, "Send succeeded", Toast.LENGTH_LONG).show();
            } else {
                Toast.makeText(context, "Send failed", Toast.LENGTH_LONG).show();
            }
        }
    }
}

第一步:首先我们从Intent参数中取出了一个Bundle对象,然后使用pdu密钥来提取一个SMS pdus数组,其中每一个pdu都表示一条短信消息。接着使用SmsMessage的createFromPdu()方法将每一个pdu字节数组转换为SmsMessage对象,调用这个对象的getOriginatingAddress()方法就可以获取到短信的发送方号码,调用getMessageBody()方法就可以获取到短信的内容,然后将每一个SmsMessage对象中的短信内容拼接起来,就组成了一条完成的短信。(意思是一条短信呗分割成了多个SmsMessage?)

第二步:还需要给程序声明一个接收短信的权限:

<uses-permission android:name="android.permission.RECEIVE_SMS" />

我们使用的是模拟器,模拟器上怎么可能会收得到短信呢?不用担心,DDMS提供了非常充分的模拟环境,使得我们不需要支付真正的短信费用也可以模拟收发短信的场景。将Eclipse切换到DDMS视图下,然后点击Emulator Control切换卡,在这里就可以向模拟器发送短信了。

8.2.2 发送短信(代码合并在上一节中)

在activity_main.xml里我们又新增了两个LinearLayout,分别处于第三行和第四行的位置。第三行中放置了一个EditText,用于输入接收方的手机号。第四行中放置了一个EditText和一个Button,用于输入短信内容和发送短信。

第三步,发送短信也是需要声明权限的:

<uses-permission android:name="android.permission.SEND_SMS" />

第四步,不过点击Send按钮虽然可以将短信发送出去,但是我们不知道到底发送成功了没有,这个时候就可以利用sendTextMessage()方法的第四个参数来对短信的发送状态进行监控。

8.3 调用摄像头和相册

8.3.2 调用摄像头拍照

  1. 很多应用程序都可能会使用到调用摄像头拍照的功能,比如说程序里需要上传一张图片作为用户的头像,这时打开摄像头拍张照是最简单快捷的。
  2. 新建一个ChoosePicTest项目:

<LinearLayout ...>
    <Button
        android:id="@+id/take_photo"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Take Photo" />
    <ImageView
        android:id="@+id/picture"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal" />
LinearLayout>
public class MainActivity extends Activity {
     
    // 第二步,实现调用摄像头的具体逻辑
    public static final int TAKE_PHOTO = 1;
    public static final int CROP_PHOTO = 2;
    private Button takePhoto;
    private ImageView picture;
    private Uri imageUri;
    public static final int CHOOSE_PHOTO = 3;
    private Button chooseFromAlbum;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        takePhoto = (Button) findViewById(R.id.take_photo);
        picture = (ImageView) findViewById(R.id.picture);
        takePhoto.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                // 创建File对象,用于存储拍照后的图片
                File outputImage = new File(Environment.getExternalStorageDirectory(),
                        "output_image.jpg");
                try {
                    if (outputImage.exists()) {
                        outputImage.delete();
                    }
                    outputImage.createNewFile();
                } catch (IOException e) {
                    e.printStackTrace();
                }
                imageUri = Uri.fromFile(outputImage);
                // 使用隐式intent,相机程序会响应这个intent的
                Intent intent = new Intent("android.media.action.IMAGE_CAPTURE");
                intent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri);
                // TAKE_PHOTO作为onActivityResult()的requestCode
                startActivityForResult(intent, TAKE_PHOTO); // 启动相机程序
            }
        });

        // 第三步,点击chooseFromAlbum按钮,从相册选择照片的逻辑
        chooseFromAlbum = (Button) findViewById(choose_from_album);
        chooseFromAlbum.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                // 相册程序会响应这个intent的
                Intent intent = new Intent("android.intent.action.GET_CONTENT");
                intent.setType("image/*");
                startActivityForResult(intent, CHOOSE_PHOTO); // 打开相册
            }
        });
    }
    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        switch(requestCode) {
        case TAKE_PHOTO:
            if (resultCode == RESULT_OK) {
                Intent intent = new Intent("com.android.camera.action.CROP");
                // 这句是将imageUri置为"image/*"吧
                intent.setDataAndType(imageUri, "image/*");
                intent.putExtra("scale", true);
                intent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri);
                startActivityForResult(intent, CROP_PHOTO); // 启动裁剪程序
            }
            break;
        case CROP_PHOTO:
            if (resultCode == RESULT_OK) {
                try {
                    // 用到了getContentResolver(),说明image目录下的图片都是系统的ContentProvider吧
                    Bitmap bitmap = BitmapFactory.decodeStream(
                            getContentResolver().openInputStream(ImageUri));
                    picture.setImageBitmap(bitmap); // 显示剪裁后的图片
                } catch (FileNotFoundException e) {
                    e.printStackTrace();
                }
            }
            break;
        // 第三步,打开相册之后的处理逻辑
        case CHOOSE_PHOTO:
            if (resultCode == RESULT_OK) {
                // 判断手机系统版本号
                if (Build.VERSION.SDK_INIT >= 19) {
                    // 4.4及以上系统使用这个方法处理图片
                    handleImageOnKitKat(data);
                } else {
                    // 4.4以下系统使用这个方法处理图片
                    handleImageBeforeKitKat(data);
                }
            }
            break;
        default:
            break;
        }
    }
    @TargetApi(19)
    private void handleImageOnKitKat(Intent data) {
        String imagePath = null;
        Uri uri = data.getData();
        if (DocumentsContract.isDocumentUri(this, uri)) {
            // 如果是document类型的Uri,则通过document id处理
            String docId = DocumentsContract.getDocumentId(uri);
            if("com.android.providers.media.documents".equals(uri.getAuthority())) {
                String id = docId.split(":")[1]; // 解析出数字格式的id
                String selection = MediaStore.Images.Media._ID + "=" + id;
                imagePath = getImagePath(
                        MediaStore.Images.Media.EXTERNAL_CONTENT_URI, selection);
            } else if ("com.android.providers.downloads.documents".equals(
                    uri.getAuthority())) {
                Uri contenUri = ContentUri.withAppendedId(Uri.parse(
                        "content://downloads/public_downloads"), Long.valueOf(docId));
                imagePath = getImagePath(contentUri, null);
            }
        } else if ("content".equalsIgnoreCase(uri.getScheme())) {
            // 如果不是document类型的Uri,则使用普通方式处理
            imagePath = getImagePath(uri, null);
        }
        displayImage(imagePath);
    }
    private void handleImageBeforeKitKat(Intent data) {
        Uri uri = data.getData();
        String imagePath = getImagePath(uri, null);
        displayImage(imagePath);
    }
    private String getImagePath(Uri uri, String selection) {
        String path = null;
        // 通过Uri和selection来获取真实的图片路径
        Cursor cursor = getContentResolver().query(uri, null, selection, null, null);
        if (cursor!=null) {
            if(cursor.moveToFirst()) {
                path = cursor.getString(cursor.getColumnIndex(Media.DATA));
            }
            cursor.close();
        }
        return path;
    }
    private void displayImage(String imagePath) {
        if (imagePath!=null) {
            Bitmap bitmap = BitmapFactory.decodeFile(imagePath);
            picture.setImageBitmap(bitmap);
        } else {
            Toast.makeText(this, "failed to get image", Toast.LENGTH_SHORT).show();
        }
    }
}

第二步:首先这里创建了一个File对象,用于存储摄像头拍下的图片,这里我们把图片命名为output_image.jpg,并将它存放在手机SD卡的根目录下,调用Environment的getExternalStorageDirectory()方法获取的就是手机SD卡的根目录。

接着构建出一个Intent对象,并将这个Intent的action指定为android.media.action.IMAGE_CAPTURE,再调用Intent的putExtra()方法指定图片的输出地址,这里填入刚刚得到的Uri对象,最后调用startActivityForResult()来启动活动。由于我们使用的是一个隐式Intent,系统会找出能够响应这个Intent的活动去启动,这样照相机程序就会被打开,拍下的照片将会输出到output_image.jpg中。

我们是使用startActivityForResult()来启动活动的,因此拍完照后会在结果返回到onActivityResult()方法中。如果发现拍照成功,则会再次构建出一个Intent对象,并把它的action指定为com.android.camera.action.CROP。这个Intent是用于对拍出的照片进行剪裁的,因为摄像头拍出的照片都比较大,而我们可能只希望截取其中的一小部分。然后给这个Intent设置一些必要的属性,并再次调用startActivityForResult()来启动剪裁程序。裁剪后的照片同样会输出到output_image.jpg中。

裁剪操作完成后,程序又会回调到onActivityResult()方法中,这个时候我们就可以调用BitmapFactory的decodeStream()方法将output_image.jpg这张照片解析成Bitmap对象,然后把它设置到ImageView中显示出来。

向SD卡中写数据需要申明权限:

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

点击Take Photo按钮就可以进行拍照,拍照完成后点击确定则可以对照片进行剪裁,点击完成,就回到我们程序的界面,同时,裁剪后的照片当然也会显示出来。

8.3.3 从相册中选择照片(代码合并在上一小节)

  1. 还是在ChoosePicTest项目的基础上进行修改,不过这里我们首先要修改一下project.properties文件,把项目编译的版本号改成android-19或以上。因为Android从4.4版本开始将从相册中选取图片返回的Uri进行了改动,我们需要对这个改动进行适配。
  2. 第三步,首先为了兼容新老版本的手机,我们做了一个判断,如果是4.4及以上系统的手机就调用handleImageOnKitKat()方法来处理图片,否则就调用handleImageBeforeKitKat()方法来处理图片。之所以要这么做,是因为Android系统从4.4版本开始,选取相册中的图片不再返回图片真实的Uri了,而是一个封装过的Uri,因此,如果是4.4版本以上的手机就需要对这个Uri进行解析才行。
  3. 如果返回的Uri是document类型的话,那就取出document id进行处理,如果不是就使用普通的方式处理。另外,如果Uri的authority是media格式的话,document id还需要再进行一次解析,要通过字符串分割的方式取出后半部分才能得到真正的数字id。取出的id用于构建新的Uri和条件语句,然后把这些值作为参数传入到getImagePath()方法当中,就可以获取图片的真实路径了。拿到图片的真实路径之后,再调用displayImage()方法将图片显示到界面上。
  4. 相比于handleImageOnKitKat()方法,handleImageBeforeKitKat()方法中的逻辑就简单的多,因为它的Uri没有封装过,不需要任何解析,直接将Uri传入到getImagePath()方法中就能获取到图片的真实路径了。

8.4 播放多媒体文件

8.4.1 播放音频

  1. 在Android中播放音频文件一般都是使用MediaPlayer类来实现的,它对多种格式的音频文件提供了非常全面的控制方法,从而使得播放音乐的工作变得十分简单。
private void initMediaPlayer() {
    try {
        File file = new File(Environment.getExternalStorageDirectory(), "music.mp3");
        mediaPlayer.setDataSource(file.getPath());
        mediaPlayer.prepare();
    } catch ...
}
mediaPlayer.start();
mediaPlayer.pause();
mediaPlayer.reset(); //停止播放
@Override
protected void onDestroy() {
    super.onDestroy();
    if (mediaPlayer != null) {
        mediaPlayer.stop();
        mediaPlayer.release();
    }
}

8.4.2 播放视频

  1. 播放视频文件其实并不比播放音频文件复杂,主要是使用VideoView类来实现的。
private void initVedioPath() {
    File file = new File(Environment.getExternalStorageDirectory(), "movie.3gp");
    videoView.setVideoPath(file.getPath());
}
videoView.start();
videoView.pause();
videoView.resume(); // 重新播放
@Override
protected void onDestroy() {
    super.onDestroy();
    if(videoView!=null)
        videoView.suspend();
}

其实VideoView只是帮我们做了一个很好的封装而已,它的背后仍然是使用MediaPlayer来对视频文件进行控制的。另外需要注意,VideoView并不是一个万能的视频播放工具类,它在视频格式的支持以及播放效率方面都存在着较大的不足。

第9章 后台默默的劳动者,探究服务

在这三大智能手机操作系统中,iOS是不支持后台的,当应用程序不在前台运行时就会进入到挂起状态。Android则是沿用了Symbian的老习惯,加入了后台功能,这使得应用程序即使在关闭的情况下仍然可以在后台继续运行。而Windows Phone则是经历了一个由不支持到支持后台的过程,目前Windows Phone 8系统也是具备后台功能的。

9.1 服务是什么

  1. 不过需要注意的是,服务并不是运行在一个独立的进程中的,而是依赖于创建服务时所在的应用程序进程。当某个应用程序进程被杀掉时,所有依赖于该进程的服务也会停止运行。
  2. 另外,也不要被服务的后台概念所迷惑,实际上服务并不会自动开启线程,所有的代码都是默认运行在主线程中的。也就是说,我们需要在服务的内部手动创建子线程,并在这里执行具体的任务,否则就有可能出现主线程被阻塞住的情况。

9.2 Android多线程编程

9.2.1 线程的基本用法

  1. 当然,使用继承的方式耦合性有点高,更多的时候我们都会选择使用实现Runnable接口的方式来定义一个线程。(其实是定义一个任务,Runnable作为任务,然后让Thread去执行)
new Thread(new Runnabl() {
    @Override
    public void run() {
        // 处理逻辑
    }
}).start();

9.2.2 在子线程中更新UI

  1. 和许多其他的GUI库一样,Android的UI也是线程不安全的。也就是说,如果想要更新应用程序里的UI元素,则必须在主线程中进行,否则会出现异常。
  2. 代码的逻辑非常简单,只不过我们是在子线程中更新UI的。现在运行一下程序,并点击Change Text按钮,你会发现程序果然崩溃了。
  3. 由此证实了Android确实是不允许在子线程中进行UI操作的。但是有些时候,我们必须在子线程里去执行一些耗时任务,然后根据任务的执行结果来更新相应的UI控件,这该如何是好呢?
  4. 对于这种情况,Android提供了一套异步消息处理机制,完美地解决了在子线程中进行UI操作的问题。
// 使用Android异步消息处理机制,在子线程构造Message并用handler发送,在主线程用Handler处理Message并更改UI
public class MainActivity extends Activity implements OnClickListener {
     
    public static final int UPDATE_TEXT = 1;
    private TextView text;
    private Button changeText;
    // 主线程构造Handler
    private Handler handler = new Handler() {
        public void handleMessage(Message msg) {
            switch(msg.what) {
            case UPDATE_TEXT:
                // 在这里可以进行UI操作,因为这是在主线程
                text.setText("Nice to meet you");
                break;
            default:
                break;
            }
        }
    };
    ...
    @Override
    public void onClick(View v) {
        switch(v.getId()) {
        case R.id.change_text:
            new Thread(new Runnable() {
                @Override
                public void run() {
                    // 在子线程创建并发送消息
                    Message message = new Message();
                    message.what = UPDATE_TEXT;
                    // 发送message是handler的方法,这样也就可以顺便决定哪个handler可以处理这个消息了
                    handler.sendMessage(message);
                }
            }).start();
            break;
        default:
            break;
        }
    }
}

9.2.3 解析异步消息处理机制

  1. Android中的异步消息处理主要由四个部分组成,Message、Handler、MessageQueue和Looper。
  2. Message:Message是在线程之间传递的消息,它可以在内部携带少量的信息,用于在不同线程之间交换数据。
  3. Handler:Handler顾名思义也就是处理者的意思,它主要是用于发送和处理消息的。发送消息一般是使用Handler的sendMessage()方法,而发出的消息经过一系列地辗转处理后,最终会传递到Handler的handleMessage()方法中。
  4. MessageQueue:是消息队列的意思,它主要用于存放所有通过Handler发送的消息。这部分消息会一直存在于消息队列中,等待被处理。每个线程只会有一个MessageQueue。(MessageQueue里的Message可以是被不同的Handler处理的,Looper会调用dispatchMessage()方法将Message交给正确的Handler)
  5. Looper:Looper是每个线程中的MessageQueue的管家,调用Looper的loop()方法后,就会进入到一个无线循环中,然后每当发现MessageQueue中存在一条消息,就会将它取出,并传递到Handler的handleMessage()方法中。每个线程中也只会有一个Looper对象。
  6. 首先需要在主线程当中创建一个Handler对象,并重写handleMessage()方法。然后当子线程需要进行UI操作时,就创建一个Message对象,并通过Handler将这条消息发送出去。之后这条消息会被添加到MessageQueue的队列中等待被处理,而Looper则会一直尝试从MessageQueue中取出待处理消息,最后分发回Handler的handleMessage()方法中。由于Handler是在主线程中创建的,所以此时handleMessage()方法中的代码也会在主线程中运行,于是我们在这里可以安心地进行UI操作了。
  7. 一条Message经过这样一个流程的辗转调用后,也就从子线程进入到了主线程,从不能更新UI变成了可以更新UI,整个异步消息处理的核心思想也就是如此。

9.2.4 使用AsyncTask

  1. 在继承时我们可以为AsyncTask类指定三个泛型参数,Params:在执行AsyncTask时需要传入的参数,可用于在后台任务中使用。Progress:后台任务执行时,如果需要在界面上显示当前的进度,则使用这里指定的泛型作为进度单位。Result:当任务执行完毕后,如果需要对结果进行返回,则使用这里指定的泛型作为返回值的类型。
class DownloadTask extends AsyncTask {
    ...
}

AsyncTask类需要经常重写的几个方法:onPreExecute();doInBackground(Params…),这个方法中的所有代码都会在子线程中运行,我们应该在这里去处理所有的耗时任务。注意,这个方法中是不可以进行UI操作的,如果需要更新UI元素,比如说反馈当前任务的执行速度,可以调用publishMessage(Progress…)方法来完成;onProgressUpdate(Progress…),当在后台任务中调用了publishProgress(Progress…)方法后,这个方法就会很快被调用,方法中携带的参数就是在后台任务中传递过来的。在这个方法中可以对UI进行操作,利用参数中的数值可以对界面元素进行相应的更新;onPostExecute(Result)。

// 一个比较完整的自定义AsyncTask
class DownloadTask extends AsyncTask {
    @Override
    protected void onPreExecute() {
        progressDialog.show(); // 显示进度对话框
    }
    @Override
    protected Boolean doInBackground(Void... params) {
        try {
            while(true) {
                int downloadPercent = doDownload(); // 这是一个虚构的方法
                publishProgress(downloadPercent);
                if (downloadPercent>=100)
                    break;
            }
        } catch (Exception e) {
            return false;
        }
        return true;
    }
    @Override
    protected void onProgressUpdate(Integer... values) {
        // 在这里更新下载进度
        progressDialog.setMessage("Download " + values[0] + "%");
    }
    @Override
    // 这个result就是doInBackground返回的
    protected void onPostExecute(Boolean result) {
        progressDialog.dismiss(); // 关闭进度对话框
    }
}

要启动这个AsyncTask,只要

new DownloadTask().execute();

9.3 服务的基本用法

9.3.1 定义一个服务

  1. 目前MyService中可以算是空空如也,但有一个onBind()方法特别醒目。这个方法是Service中唯一的一个抽象方法,所以必须要在子类里实现。
  2. 这里我们又重写了onCreate()、onStartCommand()和onDestroy()这三个方法,它们是每个服务中最常用到的三个方法了。其中onCreate()方法会在服务创建的时候调用,onStartCommand()方法会在每次服务启动的时候调用。
  3. 通常情况下, 如果我们希望服务一旦启动就立刻去执行某个动作,就可以将逻辑写在onStartCommand()方法里。
  4. 另外需要注意,每一个服务都需要在AndroidManifest.xml文件中进行注册才能生效,这是Android四大组件共有的特点。
<service android:name=".MyService" >
service>

9.3.2 启动和停止服务

@Override
public void onClick(View v) {
    switch(v.getId()) {
    case R.id.start_service:
        Intent startIntent = new Intent(this, MyService.class);
        startService(startIntent);
        break;
    case R.id.stop_service:
        Intent stopIntent = new Intent(this, MyService.class);
        stopService(stopIntent);
        break;
    default:
        break;
    }
}
  1. startService()和stopService()方法都是定义在Context类中的,所以我们在活动里可以直接调用这两个方法。
  2. 那服务有没有什么办法让自己停止下来呢?当然可以,只需要在MyService的任何一个位置调用stopSelf()方法就能让这个服务停止下来了。
  3. 其实onCreate()方法是在服务第一次创建的时候调用的,而onStartCommand()方法则在每次启动服务的时候都会调用,由于刚才我们是第一次点击Start Service按钮,服务此时还未创建过,所以两个方法都会执行,之后如果你再连续多点击几次Start Service按钮,你就会发现只有onStartCommand()方法可以得到执行了。

9.3.3 活动和服务进行通信

  1. 那么有没有什么办法能让活动和服务的关系更紧密一些呢?例如在活动中指挥服务去干什么,服务就去干什么。当然可以,这就需要借助我们刚刚忽略的onBind()方法了。
  2. 比如说目前我们希望在MyService里提供一个下载功能,然后在活动中可以决定何时开始下载,以及随时查看下载进度。实现这个功能的思路是创建一个专门的Binder对象来对下载功能进行管理,修改MyService:
// 在Service中自定义Binder,Binder里的方法可以在Activity里使用
public class MyService extends Service {
     
    private DownloadBinder mBinder = new DownloadBinder();
    class DownloadBinder extends Binder {
        public void startDownload() {
            Log.d("MyService", "startDownload executed");
        }
        public int getProgress() {
            Log.d("MyService", "getProgress executed");
            return 0;
        }
    }
    @Override
    public IBinder onBind(Intent intent) {
        return mBinder;
    }
    ...
}

下面就要看一看,在活动中如何去调用服务里的这些方法了。

这两个按钮分别是用于绑定服务和取消绑定服务的,那到底谁需要去和服务绑定呢?当然就是活动了。当一个活动和服务绑定了之后,就可以调用该服务里的Binder提供的方法了。修改MainActivity:

// 在Activity中绑定服务,然后就能调用服务里的Binder提供的方法了
public class MainActivity extends Activity implements OnClickListener {
     
    ...
    private Button bindService;
    private Button unbindService;
    private MyService.DownloadBinder downloadBinder;
    private ServiceConnection connection = new ServiceConnection() {
        @Override
        public void onServiceDisconnected(ComponentName name) {
        }
        @Override
        public void onServiceConnected(ComponentName name, IBinder service) {
            downloadBinder = (MyService.DownloadBinder) service;
            downloadBinder.startDownload();
            downloadBinder.getProgress();
        }
    };
    ...
    @Override
    public void onClick(View v) {
        switch (v.getId()) {
        case R.id.bind_service:
            Intent bindIntent = new Intent(this, MyService.class);
            bindService(bindIntent, connection, BIND_AUTO_CREATE); // 绑定服务
            break;
        case R.id.unbind_service:
            unbindService(connection); // 解绑服务
            break;
        ...
        }
    }
}

我们首先创建了一个ServiceConnection的匿名类,在里面重写了onServiceConnected()方法和onServiceDisconnected()方法,这两个方法分别会在活动与服务成功绑定及解除绑定的时候调用。在onServiceConnected()方法里,我们又通过向下转型得到了DownloadBinder的实例,有了这个实例,活动和服务之间的关系就变得非常紧密了。现在我们可以在活动中根据具体的场景来调用DownloadBinder中的任何public方法,即实现了指挥服务干什么,服务就去干什么的功能。

bindService()方法接收三个参数,第一个参数就是刚刚创建出的Intent对象,第二个参数是ServiceConnection的实例,第三个参数是一个标志位,这里传入BIND_AUTO_CREATE表示在活动和服务进行绑定后自动创建服务。这会使得MyService中的onCreate()方法得到执行,但onStartCommand()方法不会执行。

另外需要注意,任何一个服务在整个应用程序范围内都是通用的,即MyService不仅可以和MainActivity绑定,还可以和任何一个其他的活动进行绑定,而且在绑定完成后它们都可以获取到相同的DownloadBinder实例。

9.4 服务的生命周期

  1. 注意虽然每调用一次startService()方法,onStartCommand()就会执行一次,但实际上每个服务都只会存在一个实例。所以不管你调用了多少次startService()方法,只需调用一次stopService()或stopSelf()方法,服务就会停止下来了。
  2. 另外,还可以调用Context的bindService()来获取一个服务的持久连接,这时就会回调服务中的onBind()方法。类似地,如果这个服务之前还没有创建过,onCreate()方法会先于onBind()方法执行。
  3. 当调用了startService()方法后,又去调用stopService()方法,这时服务中的onDestroy()方法就会执行,表示服务已经销毁了。类似地,当调用了bindService()方法后,又去调用unbindService()方法,onDestroy()方法也会执行,这两种情况都很好理解。但是需要注意,我们完全有可能对一个服务既调用了startService()方法,又调用了bindService()方法的,这种情况下该如何才能让服务销毁掉呢?根据Android系统的机制,一个服务只要被启动或者被绑定之后,就会一直处于运行状态,必须要让以上两种条件同时不满足,服务才能被销毁。所以,这种情况下要同时调用stopService()和unbindService()方法,onDestroy()方法才会执行。

9.5 服务的更多技巧

9.5.1 使用前台服务

  1. 如果你希望服务可以一直保持运行状态,而不会由于系统内存不足的原因导致被回收,就可以考虑使用前台服务。前台服务和普通服务最大的区别就在于,它会一直有一个正在运行的图标在系统的状态栏显示,下拉状态栏后可以看到更加详细的信息,非常类似于通知的效果。当然有时候你也可能不仅仅是为了防止服务被回收掉才使用前台服务的,有些项目由于特殊的需求会要求必须使用前台服务,比如说墨迹天气,它的服务在后台更新天气数据的同时,还会在系统状态栏一直显示当前的天气信息。
public class MyService extends Service {
     
    ...
    @Override
    protected void onCreate() {
        super.onCreate();
        Notification notification = new Notification(R.drawable.ic_launcher, 
                "Notification comes", System.currentTimeMillis());
        Intent notificationIntent = new Intent(this, MainActivity.class);
        PendingIntent pendingIntent = PendingIntent.getActivity(this, 0,
                notificationIntent, 0);
        notification.setLatestEventInfo(this, "This is title", "This is content", 
                pendingIntent);
        startForeground(1, notification);
        Log.d("MyService", "onCreate executed");
    }
    ...
}

只不过这次在构建出Notification对象后并没有使用NotificationManager来将通知显示出来,而是调用了startForeground()方法。这个方法接收两个参数,第一个参数是通知的id,类似于notify()方法的第一个参数,第二个参数是构建出的Notification对象。调用startForeground()方法后就会让MyService变成一个前台服务,并在系统状态栏显示出来。

9.5.2 使用IntentService

  1. 所以这个时候就需要用到Android多线程编程的技术了,我们应该在服务的每个具体的方法里开启一个子线程。
  2. 但是,这种服务一旦启动后,就会一直处于运行状态,必须调用stopService()或者stopSelf()方法才能让服务停止下来。
  3. 为了可以简单地创建一个异步的、会自动停止的服务,Android专门提供了一个IntentService类。
public class MyIntentService extends IntentService {
     
    public MyIntentService() {
        super("MyIntentService"); // 调用父类的有参构造函数
    }
    @Override
    protected void onHandleIntent(Intent intent) {
        // 打印当前线程的id
        Log.d("MyIntentService", "Thread id is " + Thread.currentThread().getId());
    }
    @Override
    public void onDestroy() {
        super.onDestroy();
        Log.d("MyIntentService", "onDestroy executed");
    }
}

然后要在子类中去实现onHandleIntent()这个抽象方法,在这个方法中可以去处理一些具体的逻辑,而且不用担心ANR问题,因为这个方法已经是在子线程中运行的了。这里为了证实一下,我们在onHandleIntent()方法中打印了当前线程的id。另外根据IntentService的特性,这个服务在运行结束后应该是会自动停止的,所以我们又重写了onDestroy()方法,在这里也打印了一行日志,以证实服务是不是停止掉了。

你会发现,其实IntentService的用法和普通的服务没什么两样。

9.6 服务的最佳实践——后台执行的定时任务

  1. Android中的定时任务一般有两种实现方式,一种是使用Java API里提供的Timer类,一种是使用Android的Alarm机制。这两种方式在多数情况下都能实现类似的效果,但Timer有一个明显的短板,它并不太适合于那些需要长期在后台运行的定时任务。我们都知道,为了能让电池更加耐用,每种手机都会有自己的休眠策略,Android手机就会在长时间不操作的情况下自动让CPU进入到睡眠状态,这就有可能导致Timer中的定时任务无法正常运行。而Alarm机制则不存在这种情况,它具有唤醒CPU的功能,即可以保证每次需要执行定时任务的时候CPU都能正常工作。需要注意,这里唤醒CPU和唤醒屏幕完全不是同一个概念,千万不要产生混淆。
  2. AlarmManager类和NotificationManager有点类似,都是通过Context的getSystemService()方法来获取实例的,只是这里需要传入的参数是Context.ALARM_SERVICE。
  3. 接下来调用AlarmManager的set()方法就可以设置一个定时任务了,比如说想要设定一个任务在10秒钟后执行,就可以写成:
long triggerAtTime = SystemClock.elapsedRealtime() + 10*1000;
manager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, triggerAtTime, pendingIntent);

第一个参数是一个整型参数,用于指定AlarmManager的工作类型,有四种值可选,分别是ELAPSED_REALTIME、ELAPSED_REALTIME_WAKEUP、RTC和RTC_WAKEUP。其中ELAPSED_REALTIME表示让定时任务的触发时间从系统开机算起,但不会唤醒CPU。ELAPSED_REALTIME_WAKEUP同样表示让定时任务的触发时间从系统开机开始算起,但会唤醒CPU。RTC表示让定时任务的触发时间从1970年1月1日0点开始算起,但不会唤醒CPU。RTC_WAKEUP同样表示让定时任务的触发时间从1970年1月1日0点开始算起,但会唤醒CPU。使用SytemClock.elapsedRealtime()方法可以获取到系统开机至今所经历时间的毫秒数,使用System.currentTimeMillis()方法可以获取到1970年1月1日0点至今所经历时间的毫秒数。

第三个参数是一个PendingIntent,这里我们一般会调用getBroadcast()方法来获取一个能够执行广播的PendingIntent。这样,当定时任务被触发的时候,广播接收器的onReceive()方法就可以得到执行。(PendingIntent其实就是到点了要去启动的活动啦,广播接收器啦,服务啦)

// 创建一个可以长期在后台执行定时任务的服务。第一步,新增一个LongRunningService
public class LongRunningService extends Service {
     
    ... // onBind()
    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                Log.d("LongRunningService", "executed at " + new Date().toString());
            }
        }).start();
        AlarmManager manager = (AlarmManager) getSystemService(ALARM_SERVICE);
        int anHour = 60*60*1000;
        long triggerAtTime = SystemClock.elapsedRealtime() + anHour;
        // PendingIntent包含一个启动AlarmReceiver的intent
        Intent i = new Intent(this, AlarmReceiver.class);
        PendingIntent pi = PendingIntent.getBroadcast(this, 0, i, 0);
        manager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, triggerAtTime, pi);
        return super.onStartCommand(intent, flags, startId);
    }
}
// 第二步,新建AlarmReceiver
public class AlarmReceiver extends BroadcastReceiver {
     
    @Override
    public void onReceive(Context context, Intent intent) {
        // 又回头启动LongRunningService,循环了
        Intent i = new Intent(context, LongRunningService.class);
        context.startService(i);
    }
}

onReceive()方法里的代码非常简单,就是构建出一个Intent对象,然后去启动LongRunningService这个服务。为什么这样写?其实在不知不觉中,这就已经将一个长期在后台定时运行的服务完成了。因为一旦启动LongRunningService,就会在onStartCommand()方法里设定一个定时任务,这样一小时后AlarmReceiver的onReceive()方法就将得到执行,然后我们在这里再次启动LongRunningService,这样就形成了一个永久的循环,保证LongRunningService可以每隔一小时就会启动一次,一个长期在后台定时运行的服务自然也就完成了。

// 第三步,在MainActivity打开程序的时候启动一次LongRunningService,之后LongRunningService就可以一直运行了
public class MainActivity extends Activity {
     
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Intent intent = new Intent(this, LongRunningService.class);
        startService(intent);
    }
}

第四步,注册服务和广播接收器。

从Android 4.4版本开始,系统会自动检测目前有多少Alarm任务存在,然后将触发时间相近的几个任务放在一起执行,这就可以大幅度地减少CPU被唤醒的次数,从而有效延长电池的使用时间。

使用AlarmManager的setExact()方法来替代set()方法,可以保证任务准时执行。

第10章 看看精彩的世界,使用网络技术

10.1 WebView的用法

  1. 不用担心,Android早就已经考虑到了这种需求,并提供了一个WebView控件,借助它我们就可以在自己的应用程序里嵌入一个浏览器,从而非常轻松地展示各种各样的网页。
<LinearLayout ...>
    <WebView
        android:id="@+id/web_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
LinearLayout>
public class MainActivity extends Activity {
     
    private WebView webView;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...
        webView = (WebView) findViewById(R.id.web_view);
        webView.getSettings().setJavaScriptEnabled(true);
        webView.setWebViewClient(new WebViewClient());
        webView.loadUrl("http://www.baidu.com");
    }
}

我们调用了WebView的setWebViewClient()方法,并传入了一个WebViewClient的实例。这段代码的作用是,当需要从一个网页跳转到另一个网页时,我们希望目标网页仍然在当前的WebView中显示,而不是打开系统浏览器。

访问网络也是需要声明权限的:

<uses-permission android:name="android.permission.INTERNET" />

10.2 使用HTTP协议访问网络

10.2.1 使用HttpURLConnection

  1. 在Android上发送HTTP请求的方式一般有两种,HttpURLConnection和HttpClient。
  2. 注意我们使用了一个新的控件,ScrollView,它是用来?由于手机屏幕的空间一般都比较小,有些时候过多的内容一屏是显示不下的,借助ScrollView控件就可以允许我们以滚动的形式查看屏幕外的那部分内容。
public class MainActivity extends Activity implements OnClickListener {
     
    public static final int SHOW_RESPONSE = 0;
    ...
    private Handler handler = new Handler() {
        public void handleMessage(Message msg) {
            switch(msg.what) {
            case SHOW_RESPONSE:
                String response = (String) msg.obj;
                responseText.setText(response);
            }
        }
    };
    ...
    @Override
    public void onClick(View v) {
        if (v.getId() == R.id.send_request) {
            sendRequestWithHttpURLConnection();
        }
    }
    private void sendRequestWithHttpURLConnection() {
        // 开启线程来发起网络请求
        new Thread(new Runnable() {
            @Override
            public void run() {
                HttpURLConnection connection = null;
                try {
                    URL url = new URL("http://www.baidu.com");
                    connection = (HttpURLConnection) url.openConnection();
                    connection.setRequestMethod("GET");
                    connection.setConnectTimeout(8000);
                    connection.setReadTimeout(8000);
                    InputStream in = connection.getInputStream();
                    BufferedReader reader = new BufferedReader(new InputStreamReader(in));
                    StringBuilder response = new StringBuilder();
                    String line;
                    while (line==reader.readLine())!=null) 
                        response.append(line);
                    Message message = new Message();
                    message.what = SHOW_RESPONSE;
                    message.obj = response.toString();
                    handler.sendMessage(message);
                } catch ...
                } finally {
                    if (connection!=null) 
                        connection.disconnect();
                }
            }
        }).start();
    }
}

那么如果是想要提交数据给服务器应该怎么办呢?其实也不复杂,只需要将HTTP请求的方法改成POST,并在获取输入流之前把要提交的数据写出即可。注意每条数据都要以键值对的形式存在,数据与数据之间用&符号隔开,比如说我们想要向服务器提交用户名和密码,就可以这样写:

connection.setRequestMethod("POST");
DataOutputStream out = new DataOutputStream(connection.getOutputStream());
out.writeBytes("username=admin&password=123456");

10.2.2 使用HttpClient

  1. HttpClient是Apache提供的HTTP网络访问接口,从一开始的时候就被引入到了Android API中。
  2. 首先你需要知道,HttpClient是一个接口,因此无法创建它的实例,通常情况下都会创建一个DefaultHttpClient的实例:
HttpClient httpClient = new DefaultHttpClient();
HttpGet httpGet = new HttpGet("http://www.baidu.com");
httpClient.execute(httpGet);

然后通过一个NameValuePair集合来存放待提交的参数,并将这个参数集合传入到一个UrlEncodedFormEntity中,然后调用HttpPost的setEntity()方法将构建好的UrlEncodedFormEntity传入:

HttpPost httpPost = new HttpPost("http://www.baidu.com");
List params = new ArrayList();
params.add(new BasicNameValuePair("username", "admin"));
params.add(new BasicNameValuePair("password", "123456"));
UrlEncodedFormEntity entity = new UrlEncodedFormEntity(params, "utf-8");
httpPost.setEntity(entity);
httpClient.execute(httpPost);

执行execute()方法后会返回一个HttpResponse对象,服务器所返回的所有信息就会包含在这里面。

if (httpResponse.getStatusLine().getStatusCode() == 200) {
    // 请求和响应都成功了
}
HttpEntity entity = httpResponse.getEntity();
String response = EntityUtils.toString(entity);
// 有中文
String response = EntityUtils.toString(entity, "utf-8");
private void sendRequestWithHttpClient() {
    new Thread(new Runnable() {
        @Override
        public void run() {
            try {
                HttpClient httpClient = new DefaultHttpClient();
                HttpGet httpGet = new HttpGet("http://www.baidu.com");
                HttpResponse httpResponse = httpClient.execute(httpGet);
                if (httpResponse.getStatusLine().getStatusCode() == 200) {
                    // 请求和响应都成功了
                    HttpEntity entity = httpReponse.getEntity();
                    String response = EntityUtils.toString(entity, "utf-8");
                    Message message = new Message();
                    message.what = SHOW_RESPONSE;
                    message.obj = response.toString();
                    handler.sendMessage(message);
                }
            } catch ...
        }
    }).start();
}                   

10.3 解析XML格式数据

  1. 接下来进入到C:/Apache/Apache2/htdocs目录下,在这里新建一个名为get_data.xml的文件:
<apps>
    <app>
        <id>1id>
        <name>Google Mapsname>
        <version>1.0version>
    app>
    <app>
        <id>2id>
        <name>Chromename>
        <version>2.1version>
    app>
    <app>
        <id>3id>
        <name>Google Playname>
        <version>2.3version>
    app>

10.3.1 Pull解析方式

// xmlData是用httpGet得来的response
private void parseXMLWithPull(String xmlData) {
    try {
        XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
        XmlPullParser xmlPullParser = factory.newPullParser();
        xmlPullParser.setInput(new StringReader(xmlData));
        // getEventType()得到当前的解析事件
        int eventType = xmlPullParser.getEventType();
        String id = "";
        String name = "";
        String version = "";
        while (eventType != XmlPullParser.END_DOCUMENT) {
            // getName()获得当前节点的名字,可能是"id","name","version"
            String nodeName = xmlPullParser.getName();
            switch (eventType) {
            // 开始解析某个结点
            case XmlPullParser.SMART_TAG: {
                if ("id".equals(nodeName)) {
                    // nextText()获得具体内容
                    id = xmlPullParser.nextText();
                } else if ("name".equals(nodeName)) {
                    name = xmlPullParser.nextText();
                } else if ("version".equals(nodeName)) {
                    version = xmlPullParser.nextText());
                }
                break;
            }
            // 完成解析某个结点
            case XmlPullParser.END_TAG: {
                if ("app".equals(nodeName)) {
                    Log.d("MainActivity", "id is " + id);
                    Log.d("MainActivity", "name is " + name);
                    Log.d("MainActivity", "version is " + version);
                }
                break;
            }
            default:
                break;
            }
            eventType = xmlPullParser.next();
        }
    } catch ...
}

10.3.2 SAX解析方式

  1. SAX解析也是一种特别常用的XML解析方式,虽然它的用法比Pull解析要复杂一些,但在语义方面会更加的清楚。
  2. 通常情况下,我们都会新建一个类继承自DefaultHandler,重写五个方法。startDocument()方法会在开始XML解析的时候调用,startElement()方法会在开始解析某个结点的时候调用(例如,遇到触发一次,localName被置为”app”;遇到触发一次,遇到触发一次,遇到触发一次),characters()方法会在获取结点中内容的时候调用(遇到数据会被触发,例如1这个id),endElement()方法会在完成解析某个结点的时候调用(例如,遇到触发一次,localName被置为”app”),endDocument()方法会在完成整个XML解析的时候调用。
// 新建ContentHandler继承自DefaultHandler,重写五个方法,解析逻辑全在里边
public class ContentHandler extends DefaultHandler {
     
    private String nodeName;
    private StringBuilder id;
    private StringBuilder name;
    private StringBuilder version;
    @Override
    public void startDocument() throws SAXException {
        id = new StringBuilder();
        name = new StringBuilder();
        version = new StringBuilder();
    }
    @Override
    // 当遇到例如时调用,这时localName就被置为"app"
    public void startElement(String uri, String localName, String qName,
            Attributes attributes) throws SAXException {
        // 记录当前结点名
        nodeName = localName;
    }
    @Override
    // 遇到数据部分就被触发,这时的nodeName就是startElement时候的localName
    public void characters(char[] ch, int start, int length) throws SAXException {
        // 根据当前的结点判断将内容添加到哪一个StringBuilder对象中
        if ("id".equals(nodeName)) {
            id.append(ch, start, length);
        } else if ("name".equals(nodeName)) {
            name.append(ch, start, length);
        } else if ("version".equals(nodeName)) {
            version.append(ch, start, length);
        }
    }
    @Override
    public void endElement(String uri, String localName, String qName) throws SAXException {
        // 注意这里是和locaName比较,不是和nodeName比较!!!
        // 当遇到比如时,localName会自动被置为"app"
        if ("app".equals(localName)) {
            Log.d("ContentHandler", "id is " + id.toString().trim());
            Log.d("ContentHandler", "name is " + name.toString().trim());
            Log.d("ContentHandler", "version is " + version.toString().trim());
            // 最后要将StringBuilder清空掉
            id.setLength(0);
            name.setLength(0);
            version.setLength(0);
        }
    }
    @Override
    public void endDocument() throws SAXException {
    }
}
// 然后我们在MainActivity里用SAX解析
private void parseXMLWithSAX(String xmlData) {
    try {
        SAXParserFactory factory = SAXParserFactory.newInstance();
        XMLReader xmlReader = factory.newSAXParser().getXMLReader();
        ContentHandler handler = new ContentHandler();
        // 将ContentHandler的实例设置到XMLReader中
        xmlReader.setContentHandler(handler);
        // 开始执行解析
        xmlReader.parse(new InputSource(new StringReader(xmlData)));
    } catch ...
}

10.4 解析JSON格式数据

  1. 比起XML,JSON的主要优势在于它的体积更小,在网络上传输时更省流量。但缺点在于,它的语义性较差,看起来不如XML直观。
[{"id":"5","version":"5.5","name":"Angry Birds"},
{"id":"6", "version":"7.0","name":"Clash of Clans"},
{"id":"7", "version":"3.5","name":"Hey Day"}]

10.4.1 使用JSONObject

  1. 解析JSON数据有很多种方法,可以使用官方提供的JSONObject,也可以使用谷歌开源库GSON。另外,一些第三方的开源库如Jackson、FastJSON也非常不错。
// 用JSONObject解析JSON
private void parseJSONWithJSONObject(String jsonData) {
    try {
        JSONArray jsonArray = new JSONArray(jsonData);
        for (int i=0; i"id");
            String name = jsonObject.getString("name");
            String version = jsonObject.getString("version");
            Log.d("MainActivity", "id is " + id);
            Log.d("MainActivity", "name is " + name);
            Log.d("MainActivity", "version is " + version);
        }
    } catch ...
}

10.4.2 使用GSON

  1. 谷歌提供的GSON开源库可以让解析JSON数的工作简单到让你不敢想象的地步。不过GSON并没有被添加到Android官方的API中,因此如果想使用这个功能的话,则必须要在项目中添加一个GSON的Jar包。
  2. 那么GSON库究竟神奇在哪里呢?其实它主要就是可以将一段JSON格式的字符串自动映射成一个对象,从而不需要我们再去手动编写代码进行解析了。比如说一段JSON格式的数据{“name”:”Tom”,”age”:20}。那我们就可以定义一个Person类,并加入name和age这两个字段,然后只需简单地调用如下代码就可以将JSON数据自动解析成一个Person对象了:
Gson gson = new Gson();
Person person = gson.fromJson(jsonData, Person.class);

如果需要解析的是一段JSON数组会稍微麻烦一点,我们需要借助TyperToken将期望解析成的数据类型传入到fromJson()方法中

// 其实倒是和泛型容器的初始化很像啊,你看TypeToken的泛型参数就是people容器的类型,还是很美观哒
List people = gson.fromJson(jsonData, new TypeToken>(){}.getType());
// 新增一个App类,并加入id、name和version这三个字段
public class App {
     
    private String id;
    private String name;
    private String version;
    ... // 剩下是get和set函数
// 然后就可以解析啦,在MainActivity里
private void parseJSONWithGSON(String jsonData) {
    Gson gson = new Gson();
    List appList = gson.fromJson(jsonData, new TypeToken>() {}.getType());
    for (App app : appList) {
        Log.d("MainActivity", "id is " + app.getId());
        Log.d("MainActivity", "name is " + app.getName());
        Log.d("MainActivity", "version is " + app.getVersion());
    }
}

10.5 网络编程的最佳实践

  1. 通常情况下我们都应该将这些通用的网络操作提取到一个公共的类里,并提供一个静态方法,当想要发起网络请求的时候只需简单地调用一下这个方法即可。
public class HttpUtil {
     
    public static String sendHttpRequest(String address) {
        HttpURLConnection connection = null;
        try {
            URL url = new URL(address);
            connection = (HttpURLConnection) url.openConnection();
            connection.setRequestMethod("GET");
            connection.setConnectTimeout(8000);
            connection.setReadTimeout(8000);
            connection.setDoInput(true);
            connection.setDoOutput(true);
            InputStream in = connection.getInputStream();
            BufferedReader reader = new BufferedReader(new InputStreamReader(in));
            StringBuilder response = new StringBuilder();
            String line;
            while((line=reader.readLine())!=null) {
                response.append(line);
            }
            return response.toString();
        } catch (Exception e) {
            e.printStackTrace();
            return e.getMessage();
        } finally {
            if (connection != null) {
                connection.disconnect();
            }
        }
    }
}

以后每当需要发起一条HTTP请求的时候就可以这样写:

String address = "http://www.baidu.com";
String response = HttpUtil.sendHttpRequest(address);

如果我们在sendHttpRequest()方法中开启了一个线程来发起HTTP请求,那么服务器响应的数据是无法进行返回的,所有的耗时逻辑都是在子线程中进行的,sendHttpRequest()方法会在服务器还没来得及响应的时候就执行结束了,当然也就无法返回响应的数据了。

那么遇到这种情况应该怎么办呢?其实解决方法并不难,只需要使用Java的回调机制就可以了。

// 第一步,要定义一个接口,比如把它命名成HttpCallbackListener
public interface HttpCallbackListener {
     
    void onFinish(String response);
    void onError(Exception e);
}

第二步,修改HttpUtil的静态方法sendHttpRequest(),就是创建子线程来做这些操作,并调用上面接口的方法:

public class HttpUtil {
     
    // 注意子线程是无法通过return语句返回数据的,因而我们将数据传入HttpCallbackListener的onFinish()方法中
    public static void sendHttpRequest(final String address, final HttpCallbackListner listener) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                ... // 做发起HTTP请求的操作,见上面的代码
                while ((line = reader.readLine()) != null) {
                    response.append(line);
                }
                if (listener != null) {
                    // 回调onFinish()方法
                    listener.onFinish(response.toString());
                }
            } catch (Exception e) {
                if (listener != null) {
                    // 回调onError()方法
                    listener.onError(e);
                }
            } finally ...
        }).start();
    }
}
// 第三步,调用sendHttpRequest()的时候传入一个HttpCallbackListener()的实例,也就是要有onFinish()和onError()的实现
HttpUtil.sendHttpRequest(address, new HttpCallbackListener() {
    @Override
    public void onFinish(String response) {
        // 在这里根据返回内容执行具体的逻辑
    }
    @Override
    public void onError(Exception e) {
        // 在这里执行对异常的处理
    }
});
// onFinish()方法和onError()方最终还是在子线程中执行的,所以还是不能更新UI,要更新的话,在里面构造Message,并调用handler.sendMessage(msg),然后在主线程里通过Handler更新UI

第11章 Android特色开发,基于位置的服务

11.2 找到自己的位置

11.2.1 LocationManager的基本用法

  1. 向getSystemService()传入Context.LOCATION_SERVICE即可获得LocationManager的实例。
  2. 接着我们需要选择一个位置提供器来确定设备当前的位置。Android中一般有三种位置提供器可供选择,GPS_PROVIDER、NETWORK_PROVIDER和PASSIVE_PROVIDER。
  3. 这两种定位方式各有特点,GPS定位的精确度比较高,但是非常耗电,而网络定位的精确度稍差,但耗电量比较少。
  4. 将选择好的位置提供器传入到getLastKnownLocation()方法中,就可以得到一个Location对象。
  5. 但是用户是完全有可能带着设备随时移动的,那么我们怎样才能在设备位置发生改变的时候获取到最新的位置信息呢?不用担心,LocationManager还提供了一个requestLocationUpdates()方法,只要传入一个LocationListener的实例,并简单配置几个参数就可以实现上述功能。
  6. 这里requestLocationListener()方法接收四个参数,第一个参数是位置提供器的类型,第二个参数是监听位置变化的时间间隔,以毫秒为单位,第三个参数是监听位置变化的距离间隔,以米为单位,第四个参数则是LocationListener监听器。这样的话,LocationManager每隔5秒钟会检测一下位置的变化情况,当移动距离超过10米的时候,就会调用LocationListener的onLocationChanged()方法,并把新的位置信息作为参数传入。

11.2.2 确定自己位置的经纬度

public class MainActivity extends Activity {
     
    private TextView positionTextView;
    private LocationManager locationManager;
    private String provider;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        positionTextView = (TextView) finViewById(R.id.position_text_view);
        locationManager = (LocationManager) getSystemService(Context.LOCATION_SERVICE);
        // 获取所有可用的位置提供器
        List providerList = locationManager.getProviders(true);
        if (providerList.contains(LocationManager.GPS_PROVIDER))
            provider = LocationManager.GPS_PROVIDER;
        else if (providerList.contains(LocationManager.NETWORK_PROVIDER))
            provider = LocationManager.NETWORK_PROVIDER;
        else {
            Toast.makeText(this, "No location provider to use", Toast.LENGTH_SHORT).show();
            return;
        }
        // 这个Location对象是动态的哦
        Location location = locationManager.getLastKnowLocation(provider);
        if (location != null) {
            // 显示当前设备的位置信息
            showLocation(location);
        }
        locationManager.requestLocationUpdates(provider, 5000, 1, locationListener);
    }
    protected void onDestroy() {
        super.onDestroy();
        if (locationManager != null) {
            // 关闭程序时将监听器移除
            locationManager.removeUpdates(locationListener);
        }
    }
    LocationListener locationListener = new LocationListener() {
        @Override
        public void onStatusChanged(String provider, int status, Bundle extras) { }
        @Override
        public void onProviderEnabled(String provider) { }
        @Override
        public void onProviderDisabled(String provider) { }
        @Override
        public void onLocationChanged(Location location) {
            // 更新当前设备的位置信息
            showLocation(location);
        }
    };
    private void showLocation(Location location) {
        String currentPosition = "latitude is " + location.getLatitude() + "\n"
                + "longitude is " + location.getLongitude();
        positionTextView.setText(currentPosition);
    }
}

获取当前的位置信息也需要声明权限:

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

之后如果你拿着手机随处移动,就可以看到界面上的经纬度信息是会变化的。

11.3 反向地理编码,看得懂的位置信息

11.3.1 Geocoding API的用法

  1. 其实Android本身就提供了地理编码的API,主要是使用GeoCoder这个类来实现的。它可以非常简单地完成正向和反向的地理编码功能,从而轻松地将一个经纬值转换成看得懂的位置信息。
  2. 不过,非常遗憾的是,GeoCoder长期存在这一些较为严重的bug,在反向地理编码的时候会有一定的概率不能解析出位置的信息,这样就无法保证位置解析的稳定性,那次我们不得不去寻找GeoCoder的替代方案。
  3. 谷歌又提供了一套Geocoding API,使用它也可以完成反向地理编码的工作,只不过它的用法稍微复杂了一些,但稳定性要比GeoCoder强得多。
  4. Geocoding API的工作原理:在手机端我们可以向谷歌的服务器发起一条HTTP请求,并将经纬度的值作为参数一同传递过去,然后服务器帮我们将这个经纬值转换成看得懂的位置信息,再将这些信息返回给手机端,最后手机端去解析服务器返回的信息,并进行处理就可以了。
  5. Geocoding API中规定了很多接口,其中反向地理编码的接口如下:
http://maps.googleapis.com/maps/api/geocode/json?latlng=40.714224,-73.961452&sensor=true_or_false

json表示希望服务器能够返回JSON格式的数据,这里也可以指定成xml。sensor=true_or_false表示这条请求是否来自于某个设备的位置传感器,通常指定成false即可。

这样一条请求给服务器,我们将会得到一段非常长的JSON格式的数据,其中会包括如下部分内容:

"formatted_address" : "277 Bedford Avenue, 布鲁克林纽约州 11211美国"

11.3.2 对经纬度进行解析

  1. 使用Geocoding API进行反向地理编码的流程相信你已经很清楚了,我们先要发送一个HTTP请求给谷歌的服务器,然后在对返回的JSON数据进行解析。发送HTTP请求的方式我们准备使用HttpClient,解析JSON数据的方式使用JSONObject。修改MainActivity:
public class MainActivity extends Activity {
     
    public static final int SHOW_LOCATION = 0;
    ...
    // 永远在子线程里做Http操作
    private void showLocation(final Location location) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    // 组装反向地理编码的接口地址
                    StringBuilder url = new StringBuilder();
                    url.append("http://maps.googleapis.com/maps/api/geocode/json?latlng=");
                    url.append(location.getLatitude()).append(",");
                    url.append(location.getLongitude());
                    url.append("&sensor=false");
                    HttpClient httpClient = new DefaultHttpClient();
                    HttpGet httpGet = new HttpGet(url.toString());
                    // 在请求消息头中指定语言,保证服务器会返回中文数据
                    httpGet.addHeader("Accept-Language", "zh-CN");
                    HttpResponse httpResponse = httpClient.execute(httpGet);
                    if (httpResponse.getStatusLine().getStatusCode() == 200) {
                        HttpEntity entity = httpResponse.getEntity();
                        String response = EntityUtils.toString(entity, "utf-8");
                        JSONObject jsonObject = new JSONObject(response);
                        // 获取results节点下的位置信息
                        JSONArray resultArray = jsonObject.getJSONArray("results");
                        if (resultArray.length() > 0) {
                            JSONObject subObject = resultArray.getJSONObject(0);
                            // 取出格式化后的位置信息
                            String address = subObject.getString("formatted_address");
                            Message message = new Message();
                            message.what = SHOW_LOCATION;
                            message.obj = address;
                            handler.sendMessage(message);
                        }
                    }
                } catch ...
            }
        }).start();
    }
    private Handler handler = new Handler() {
        public void handleMessage(Message msg) {
            switch (msg.what) {
            case SHOW_LOCATION:
                String currentPosition = (String) msg.obj;
                positionTextView.setText(currentPosition);
                break;
            default:
                break;
            }
        }
    };
}

由于一个经纬度的值有可能包含了好几条街道,因此服务器通常会返回一组位置信息,这些信息都是存放在results节点下的。在得到了这些位置信息后只需要取其中的第一条就可以了,通常这也是最接近我们位置的那一条。

当然,在这个例子中我们只是对服务器返回的JSON数据进行了最简单的解析,位置信息作为整体取出,其实你还可以进行更精确的解析,将国家名、城市名、街道名、甚至邮政编码等作为独立的信息取出。

11.4 使用百度地图

只不过谷歌地图在2013年3月的时候全面停用了第一版的API Key,而第二版的API Key在中国使用的时候又有诸多限制,因此这里我们就不准备使用谷歌地图了。

11.4.1 申请API Key

  1. 要想在自己的应用程序中加入百度地图的功能,首先必须申请一个API Key。
  2. 那么剩下一个安全码是什么意思呢?这是我们申请API Key所必须填写的一个字段,它的组成方式是数字签名+;+包名。这里数字签名指的是我们打包程序时所用keystore的SHA1指纹,可以在Eclipse中查看到。点击Eclipse导航栏的Window->Preferences->Android->Build。
  3. 因此一个完整的安全码就是:4F:7D:6B:ED:7D:DF:B8:46:D6:96:E6:2F:7C:0B:C0:B7:D5:99:C1:D7;com.example.baidumaptest。我们将这个值填入到安全码输入框中,然后点击提交。这样的话就已经申请成功了。其中LTLdkcQP1XMZr3m6bujDB47v就是申请到的API Key。

11.4.2 让地图显示出来

  1. 百度地图Android版的SDK有三个压缩包,Docs包中含有百度地图的使用文档,Sample包中含有一个使用百度地图的工程样例,Lib包中含有使用百度地图所必须依赖的库文件。
  2. baidumapapi_v3_1_0.jar和libBaiduMapSDK_v3_1_0.so这两个文件都是使用百度地图所必不可少的。现在将baidumapapi_v3_1_0.jar复制到项目的libs目录下,然后在libs目录下新建一个armeabi目录,并将libBaiduMapSDK_v3_1_0.so复制到新建的目录下。libs目录你已经知道是专门用于存放第三方Jar包的地方,而armeabi目录则是专门用于存放so文件的地方。so文件是用C/C++语言进行编写,然后再用NDK编译出来的。

<LinearLayout ...>
    <com.baidu.mapapi.map.MapView
        android:id="@+id/map_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:clickable="true" />
LinearLayout>
// 第二步,在MainActivity中初始化地图
public class MainActivity extends Activity {
     
    private MapView mapView;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        // 初始化操作一定要在setContentView()方法前调用
        SDKInitializer.initialize(getApplicationContext());
        setContentView(R.layout.activity_main);
        mapView = (MapView) findViewById(R.id.map_view);
    }
    @Override
    protected void onDestroy() {
        super.onDestroy();
        mapView.onDestroy();
    }
    @Override
    protected void onPause() {
        super.onPause();
        mapView.onPause();
    }
    @Override
    protected void onResume() {
        super.onResume();
        mapView.onResume();
    }
}
<manifest ...>
    ...
    
    <uses-permission android:name="com.android.launcher.permission.READ_SETTINGS" />
    <uses-permission android:name="android.permission.GET_ACCOUNTS" />
    <... USE_CREDENTIALS" />
    <... MANAGE_ACCOUNTS" />
    <... AUTHENTICATE_ACCOUNTS" />
    <... ACCESS_NETWORK_STATE" />
    <... INTERNET" />
    <... CHANGE_WIFI_STATE" />
    <... ACCESS_WIFI_STATE" />
    <... READ_PHONE_STATE" />
    <... WRITE_EXTERNAL_STORAGE" />
    <... BROADCAST_STICKY" />
    <... WRITE_SETTINGS" />
    <... READ_PHONE_STATE" />
    <... ACCESS_FINE_LOCATION" />
    ...
    
    <application ...>
        <meta-data
            android:name="com.baidu.lbsapi.API_KEY"
            androir:value="LTLdkcQP1XMZr3m6bujDB47v" />
        ...
    application>
manifest>

11.4.3 定位到我的位置

BaiduMap baiduMap = mapView.getMap();

有了BaiduMap后,我们就能对地图进行各种各样的操作了,比如设置地图的缩放级别以及将地图定位到某一个经纬度上。

百度地图将缩放级别的取值范围限定在3到19之间,其中小数点位的值也是可以取得,值越大,地图显示的信息就越精细。比如我们想要将缩放级别设置为12.5:

MapStatusUpdate update = MapStatusUpdateFactory.zoomTo(12.5f);
baiduMap.animateMapStatus(update);

那么怎么才能让地图定位到某一个经纬度上呢?

LatLng ll = new LatLng(39.915, 116.404);
MapStatusUpdate update = MapStatusUpdateFactory.newLatLng(ll);
baiduMap.animateMapStatus(update);

上述代码就实现了将地图定位到北纬39.916度、东经116.404度这个位置的功能。

// 第一步,修改11.2.2 确定自己位置的经纬度,把showLocation(location)方法换成navigateTo(location)
public class MainActivity extends Activity {
     
    ...
    pirvate BaiduMap baiduMap;
    ...
    private boolean isFirstLocate = true;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...
        SDKInitializer.initialize(getApplicationContext());
        ...
        baiduMap = mapView.getMap();
        ...
        Location location = locationManager.getLastKnownLocation(provider);
        if (location != null) {
            navigateTo(location);
        }
        locationManager.requestLocationUpdates(provider, 5000, 1, locationListener);
    }
    private void navigateTo(Location location) {
        if (isFirstLocate) {
            LatLng ll = new LatLng(location.getLatitude(), location.getLongitude());
            MapStatusUpdate update = MapStatusUpdateFactory.newLatLng(ll);
            baiduMap.animateMapStatus(update);
            update = MapStatusUpdateFactory.zoomTo(16f);
            baiduMap.animateMapStatus(update);
            isFirstLocate = false;
        }
    }
    LocationListener locationListener = new LocationListener() {
        ...
        @Override
        public void onLocationChanged(Location location) {
            // 更新当前设备的位置信息
            if (location != null) {
                navigateTo(location);
            }
        }
    };
    ...
}

另外还有一点要注意,上述代码中我们使用了一个isFirstLocate变量,这个变量的作用是为了防止多次调用animateMapStatus()方法,因为将地图移动到我们当前的位置只需要在程序第一次定位的时候调用一次就可以了。

11.4.4 让我显示在地图上

  1. MyLocationData.Builder类还提供了一个build()方法,当我们把要封装的信息都设置完成之后只需要调用它的build()方法,就会生成一个MyLocationData的实例,然后再将这个实例传入到BaiduMap的setMyLocationData()方法当中就可以让设备当前的位置显示在地图上了。
public class MainActivity extends Activity {
     
    ...
    // 添加让我显示在地图上的功能,修改11.4.3的代码
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...
        baiduMap.setMyLocationEnabled(true);
        ...
    }
    private void navigateTo(Location location) {
        if (isFirstLocate) {
            ...
            isFirstLocate = false;
        }
        MyLocationData.Builder locationBuilder = new MyLocationData.Builder();
        locationBuilder.latitude(location.getLatitude());
        locationBuilder.longitude(location.getLongitude());
        MyLocationData locationData = locationBuilder.build();
        baiduMap.setMyLocationData(locationData);
    }
    ...
    @Override
    protected void onDestroy() {
        super.onDestroy();
        baiduMap.setMyLocationEnabled(false);
        mapView.onDestroy();
        if (locationManager != null) {
            locationManager.removeUpdates(locationListener);
        }
    }
    ...
}

注意这段逻辑必须写在isFirstLocate这个if条件语句的外面,因为让地图移动到我们当前的位置只需要在第一次定位的时候执行,但是设备在地图上显示的位置却应该是随着设备的移动而实时改变的。

11.5 Git时间,版本控制工具的高级用法

11.5.1 分支的用法

  1. 你只需要在发布1.0版本的时候建立一个分支,然后在主干线上继续开发1.1版本的功能。当1.0版本上发现任何bug的时候,就在分支线上进行修改,然后发布新的1.0版本,并记得将修改后的代码合并到主干线上。这样的话,不仅可以轻松解决掉1.0版本存在的bug,而且保证了主干线上的代码也已经修复了这些bug,当1.1版本发布时就不会有同样的bug存在了。
  2. 分支的英文是branch,如果想要查看当前的版本库里有哪些分支,可以使用:
git branch -a

由于目前BaiduMapTest项目中还没有创建过任何分支,因此只有一个master分支存在,这也就是前面所说的主干线。接下来我们尝试去创建一个分支:

git branch version1.0

这样就创建了一个名为version1.0的分支,我们再次输入git branch -a这个命令来检查一下,可以看到果然有一个叫做version1.0的分支出现了。你会发现,master分支的前面有一个*号,说明目前我们的代码还是在master分支上的,那么怎样才能切换到version1.0这个分支上呢?其实也很简单,只需要使用checkout命令即可

git checkout version1.0

因此,如果我们在version1.0分支上修改了一个bug,在master分支上这个bug仍然是存在的。这时将修改的代码一行行复制到master分支上显然不是一个聪明的做法,最好的办法就是使用merge命令来完成合并操作:

git checkout master
git merge version1.0

当然,在合并分支的时候还有可能出现代码冲突的情况,这个时候你就需要静下心来慢慢地找出并解决这些冲突,Git在这里就无法帮助你了。

最后,当我们不需要version1.0分支的时候,可以删除这个分支:

git branch -D version1.0

11.5.2 与远程版本库协作

  1. 知道了将本地的修改同步到远程版本库上的方法,接下来我们看一下如何将远程版本库上的修改同步到本地。Git提供了两种命令来完成此功能,分别是fetch和pull,fetch的语法规则和push是差不多的,如下所示:
git fetch origin master

执行这个命令后,就会将远程版本库上的代码同步到本地,不过同步下来的代码并不会合并到任何分支上去,而是会存放在一个origin/master分支上,这时我们可以通过diff命令来查看远程版本库上到底修改了哪些东西:

git diff origin/master

之后再调用merge命令将origin/master分支上的修改合并到主分支上即可

git merge origin/master

而pull命令则相当于将fetch和merge这两个命令放在一起执行了,他可以从远程版本库上获取最新的代码并合并到本地

git pull origin master

第12章 Android特色开发,使用传感器

12.2 光照传感器

12.2.1 光照传感器的用法

  1. 当传感器的精度发生变化的时候就会调用onAccuracyChanged()方法,当传感器检测到的数值发生变化时就会调用onSensorChanged()方法。可以看到onSensorChanged()方法中传入了一个SensorEvent参数,这个参数里又包含了一个values数组,所有传感器输出的信息都是存放在这里的。

12.2.2 制作简易光照探测器

public class MainActivity extends Activity {
     
    private SensorManager sensorManager;
    private TextView lightLevel;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...
        sensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE);
        Sensor sensor = sensorManager.getDefaultSensor(Sensor.TYPE_LIGHT);
        sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL);
    }
    @Override
    protected void onDestroy() {
        super.onDestroy();
        if (sensorManager != null) {
            sensorManager.unregisterListener(listener);
        }
    }
    private SensorEventListener listener = new SensorEventListener() {
        @Override
        public void onSensorChanged(SensorEvent event) {
            float value = event.values[0];
            lightLevel.setText("Current light level is " + value + " lx");
        }
        @Override
        public void onAccuracyChanged(Sensor sensor, int accuracy) { }
    };
}

12.3 加速度传感器

12.3.2 模拟微信摇一摇

  1. 其实主题逻辑也非常简单,只需要检测手机在X轴、Y轴和Z轴的加速度,当达到了预定值的时候就可以认为用户摇动了手机,从而触发摇一摇的逻辑。那么这个值必定要大于9.8 m/s2 的,这里我们就设定为15 m/s2 吧。
public class MainActivity ... {
     
    ...
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...
        Sensor sensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
        ...
    }
    ...
    private SensorEventListener listener = new SensorEventListener() {
        @Override
        public void onSensorChanged(SensorEvent event) {
            float xValue = Math.abs(event.values[0]);
            float yValue = Math.abs(event.values[1]);
            float zValue = Math.abs(event.values[2]);
            if (xValue > 15 || yValue > 15 || zValue > 15) {
                Toast.makeText(MainActivity.this, "摇一摇", Toast.LENGTH_SHORT).show();
            }
        }
        ...
    };
}

12.4 方向传感器

12.4.1 方向传感器的用法

  1. 遗憾的是,Android早就废弃了Sensor.TYPE_ORIENTATION这种传感器类型了,虽然代码还是有效的,但已经不再推荐这么写了。事实上,Android获取手机旋转的方向和角度是通过加速度传感器和地磁传感器共同计算得出的,这也是Android目前推荐使用的方式。
  2. 接下来在onSensorChanged()方法中可以获取到SensorEvent的values数组,分别记录着加速度传感器和地磁传感器输出的值。然后将这两个值传入到SensorManager的getRotationMatrix()方法中就可以得到一个包含旋转矩阵的R数组。
SensorManager.getRotationMatrix(R, null, accelerometerValues, magneticValues);

得到了R数组之后,接着就可以调用SensorManager的getOrientation()方法来计算手机的旋转数据了。

SensorManager.getOrientation(R, values);

values是一个长度为3的float数组,手机在各个方向上的旋转数据都会被存放到这个数组当中。

12.4.2 制作简易指南针

// 第一步,它把手机绕Z轴的旋转角度作为指南针的角度显然不对,先这么着吧
public class MainActivity extends Activity {
     
    private SensorManager sensorManager;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        sensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE);
        Sensor magneticSensor = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD);
        Sensor accelerometerSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
        sensorManager.registerListener(listener, magneticSensor, SensorManager.SENSOR_DELAY_GAME);
        sensorManager.registerListener(listener, accelerometerSensor, SensorManager.SENSOR_DELAY_GAME);
    }
    ... // onDestroy
    private SensorEventListener listener = new SensorEventListener() {
        float[] accelerometerValues = new float[3];
        float[] magneticValues = new float[3];
        @Override
        public void onSensorChanged(SensorEvent event) {
            if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) {
                accelerometerValues = event.values.clone();
            } else if (event.sensor.getType() == Sensor.TYPE_MAGNETIC_FIELD) {
                // 赋值要用clone方法,否则accelerometerValues和magneticValues会指向同一个引用
                magneticValues = event.values.clone();
            }
            float[] R = new float[9];
            float[] values = new float[3];
            SensorManager.getRotationMatrix(R, null, accelerometerValues, magneticValues);
            SensorManager.getOrientation(R, values);
            Log.d("MainActivity", "value[0] is " + Math.toDegrees(values[0]));
        }
        ...
    };
}

<RelativeLayout ...>
    <ImageView
        android:id="@+id/compass_img"
        android:layout_width="250dp"
        android:layout_height="250dp"
        android:layout_centerInParent="true"
        android:src="@drawable/compass" />
    <ImageView
        android:id="@+id/arrow_img"
        android:layout_width="60dp"
        android:layout_height="110dp"
        android:layout_centerInParent="true"
        android:src="@drawable/arrow" />
RelativeLayout>
// 第三步,修改MainActivity,旋转背景图片咯
private ImageView compassImg;
private SensorEventListener listener = new SensorEventListener() {
    ...
    // 会自动初始化为0.0
    private float lastRotateDegree;
    @Override
    public void onSensorChanged(SensorEvent event) {
        ...
        // 将计算出的旋转角度取反,用于旋转指南针背景图
        float rotateDegree = -(float) Math.toDegrees(values[0]);
        if (Math.abs(rotateDegree - lastRotateDegree) > 1) {
            RotateAnimation animation = new RotateAnimation(lastRotateDegree, 
                    rotateDegree, Animation.RELATIVE_TO_SELF, 0.5f, 
                    Animation.RELATIVE_TO_SELF, 0.5f);
            animation.setFillAfter(true);
            compassImg.startAnimation(animation);
            lastRotateDegree = rotateDegree;
        }
    }
    ...
};

然后在onSensorChanged()方法中使用到了旋转动画技术,我们创建了一个RotateAnimation的实例,并给它的构造方法传入了六个参数,第一个参数表示旋转的起始角度,第二个参数表示旋转的终止角度,后面四个参数用于指定旋转的中心点。这里我们把从传感器中获取到的旋转角度取反,传递给RotateAnimation,并指定旋转的中心点为指南针背景图的中心,然后调用ImageView的startAnimation()方法来执行旋转动画。

第13章 继续进阶,你还应该掌握的高级技巧

13.1 全局获取Context的技巧

  1. 回想这么久以来我们所学的内容,你会发现很多地方都需要用到Context,弹出Toast的时候需要、启动活动的时候需要、发送广播的时候需要、操作数据库的时候需要、使用通知的时候需要等等等等。
  2. 但现在我们想对sendHttpRequest()方法进行一些优化,当检测到网络不存在的时候就给用户一个Toast提示,并且不再执行后面的代码。看似一个挺简单的功能,可是却存在一个让人头疼的问题,弹出Toast提示需要一个Context参数,而我们在HttpUtil类中显然是获取不到Context对象的,这该怎么办呢?
  3. 其实想要快速解决这个问题也很简单,大不了在sendHttpRequest()方法中添加一个Context参数就行了。虽说这也确实是一种解决方案,但是却有点推卸责任的嫌疑,因为我们将获取Context的任务转移给了sendHttpRequest()方法的调用方,至于调用方能不能得到Context对象,那就不是我们需要考虑的问题了。
  4. Android提供了一个Application类,每当应用程序启动的时候,系统就会自动将这个类进行初始化。而我们可以定制一个自己的Application类,以便于管理程序内一些全局的状态信息,比如说全局Context。
// 定制自己的MyApplication,管理全局的状态信息的好方法,比如全局Context
public class MyApplication extends Application {
     
    // 静态context,以使只有一个副本
    private static Context context;
    @Override
    public void onCreate() {
        context = getApplicationContext();
    }
    public static Context getContext() {
        return context;
    }
}

我们重写了父类的onCreate()方法,并通过调用getApplicationContext()方法得到了一个应用程序级别的Context,然后又提供了一个静态的getContext()方法,在这里将刚才获取到的Context进行返回。

接下来我们需要告知系统,当程序启动的时候应该初始化MyApplication类,而不是默认的Application类。

<application
    android:name="com.example.networktest.MyApplication"
    ...>
    ...
application>

这样我们就已经实现了一种全局获取Context的机制,之后不管你想在项目的任何地方使用Context,只需要调用一下MyApplication.getContext()就可以了。

13.2 使用Intent传递对象

但是不知道你有没有发现,putExtra()方法中所支持的数据类型是有限的,虽然常用的一些数据类型它都会支持,但是当你想去传递一些自定义对象的时候就会发现无从下手。

13.2.1 Serializable方式

  1. 使用Intent来传递对象通常有两种实现方式,Serializable和Parcelable。
  2. 可以看到,这里我们创建了一个Person实例,然后就直接将它传入到putExtra()方法中了。由于Person类实现了Serializable接口,所以才可以这样写。
  3. 接下来在SecondActivity中获取这个对象也很简单,写法如下:
Person person = (Person) getIntent().getSerializableExtra("person_data");

13.2.2 Parcelable方式

  1. Parcelable方式的实现原理是将一个完整的对象进行分解,而分解后的每一部分都是Intent所支持的类型,这样也就实现传递对象的功能了。(谁说分解后的每一部分就是Intent所支持的类型?去看看Parcel的那些方法的名字吧,writeInt(), writeString()… 看来这句话说的是对的)
public class Person implements Parcelable {
     
    private String name;
    private int age;
    ...
    // 实现Parcelable接口必须重写describeContents和writeToParcel
    @Override
    public int describeContents() {
        return 0;
    }
    @Override
    public void writeToParcel(Parcel dest, int flags) {
        dest.writeString(name); // 写出name
        dest.writeInt(age); // 写出age
    }
    public static final Parcelable.Creator CREATOR = new Parcelable.Creator() {
        @Override
        public Person createFromParcel(Parcel source) {
            Person person = new Person();
            person.name = source.readString(); // 读取name
            person.age = source.readInt(); // 读取age
            return person;
        }
        @Override
        // 这个方法是做什么??
        public Person[] newArray(int size) {
            return new Person[size];
        }
    };
}

接下来在FirstActivity中我们仍然可以使用相同的代码来传递Person对象,只不过在SecondActivity中获取对象的时候需要稍加改动,如下:

Person person = (Person) getIntent().getParcelableExtra("person_data");

(这一节我觉得作者的理解是片面的。第一,他说Parcelable方式和Serializable方式不同是前者分解,后者序列化,不知道作者知不知道Serializable接口有writeObject方法和readObject方法,它们的参数分别是ObjectOutputStream和ObjectInputStream,而这两个流写出和读入的方法名正是和Parcelable一模一样,你怎么能说两者不同点就在此呢?你用默认序列化方式,就想当然说Serializable是一团,不是啊,人家也是一个域一个域分开的!)

13.3 定制自己的日志工具

  1. 因此,最理想的情况是能够自由地控制日志的打印,当程序处于开发阶段就让日志打印出来,当程序上线了之后就把日志屏蔽掉。
public class LogUtil {
     
    public static final int VERBOSE = 1;
    public static final int DEBUG = 2;
    public static final int INFO = 3;
    public static final int WARN = 4;
    public static final int ERROR = 5;
    public static final int NOTHING = 6;
    public static final int LEVEL = VERBOSE;
    public static void v(String tag, String msg) {
        if (LEVEL <= VERBOSE) 
            Log.v(tag, msg);
    }
    public static void d(String tag, String msg) {
        if (LEVEL <= DEBUG)
            Log.d(tag, msg);
    }
    public static void i(String tag, String msg) {
        if (LEVEL <= INFO)
            Log.i(tag, msg);
    }
    public static void w(String tag, String msg) {
        if (LEVEL <= WARN)
            Log.w(tag, msg);
    }
    public static void e(String tag, String msg) {
        if (LEVEL <= ERROR)
            Log.e(tag, msg);
    }
}
// 现在我们可以这样使用日志打印
LogUtil.d("TAG", "debug log");

然后我们只需要修改LEVEL常量的值,就可以自由地控制日志的打印行为了。比如让LEVEL等于VERBOSE就可以把所有日志都打印出来,让LEVEL等于NOTHING就可以把所有日志都屏蔽掉。

使用了这种方法之后,刚才所说的那个问题就不复存在了,你只需要在开发阶段将LEVEL指定为VERBOSE,当项目正式上线的时候将LEVEL指定成NOTHING就可以了。

13.4 调试Android程序

  1. 设好断点。Debug as。跳转到Debug视图。接下来每按一下F6键,代码就会向下执行一行,并通过Variables视图还可以看到内存中的数据。
  2. Run as。然后进入DDMS视图,在Devices窗口找到这个程序的进程。选中这个进程之后,点击最上面一行的第一个按钮,是个小虫子。就会让这个进程进入到调试模式,进入调试模式的进城名前会有一个虫子样式图标。接下来点击Login,Eclipse同样也会跳转到Debug视图,之后流程就相同了。

13.5 编写测试用例

  1. 由于项目里的很多代码都是公用的,你为了完成一个功能而去修改某行代码,完全有可能因此而导致另一个功能无法正常工作。
  2. 所以,当项目比较庞大的时候,一般都应该去编写测试用例的。如果我们给项目的每一项功能都编写了测试用例,每当修改或新增任何功能之后,就将所有的测试用例都跑一遍,只要有任何测试用例没有通过,就说明修改或新增的这个功能影响到现有功能了,这样就可以及早地发现问题,避免事故的出现。

13.5.1 创建测试工程

  1. 在创建之前你需要知道,测试工程通常都不是独立存在的,而是依赖于某个现有工程的,一般比较常见的做法是在现有工程下新建一个tests文件夹,测试工程就存放在这里。
  2. 在Eclipse的导航栏中点击File->New->Other,会打开一个对话框,展开Android目录,在里面选中Android Test Project。
  3. 按照惯例,我们将路径选择为BroadcastBestPractice项目的tests文件夹下。
  4. 观察测试工程中AndroidManifest.xml文件中的代码
<manifest ...>
    <uses-sdk .../>
    
    <instrumentation
        android:name="android.test.InstrumentationTestRunner"
        android:targetPackage="com.example/broadcastbestpractice" />
    
    <application ...>
        <uses-library android:name="android.test.runner" />
    application>
manifest>

13.5.2 进行单元测试

  1. 单元测试是指对软件中最小的功能模块进行测试,如果软件中的每一个单元都能通过测试,说明代码的健壮性就已经非常好了。
public class ActivityCollectorTest extends AndroidTestCase {
     
    @Override
    protected void setUp() throws Exception {
        super.setUp();
    }
    @Override
    protected void tearDown() throws Exception {
        super.tearDown();
    }
}

那么该如何编写测试用例呢?其实也很简单,只需要定义一个以test开头的方法,测试框架就会自动调用这个方法了。然后我们在方法中可以通过断言(assert)的形式来期望一个运行结果,再和实际的运行结果进行对比,这样一条测试用例就完成了。

// 在ActivityCollectorTest类里添加一个测试用例,其实就是一个方法
public class ActivityCollectorTest extends AndroidTest {
     
    ...
    public void testAddActivity() {
        assertEquals(0, ActivityCollector.activities.size());
        LoginActivity loginActivity = new LoginActivity();
        ActivityCollector.addActivity(loginActivity);
        assertEquals(1, ActivityCollector.acitivities.size());
    }
    ...
}

现在可以右击测试工程->Run as->Android JUnit Test来运行这个测试用例。

连续添加两次相同活动的实例,这应该算是一种比较特殊的情况了。这是我们觉得ActivityCollector有能力去过滤掉重复的数据,因此在断言的时候认为目前ActivityCollector中的活动个数仍然是1。重新运行一遍测试用例,没跑通。从这个测试用例中我们发现,addActivity()方法中的代码原来是不够健壮的,这个时候就应该对代码进行优化了。(加上一个contains的判断)

你可能感兴趣的:(Android)