第5章〓多线程与网络应用


思想引领




视频讲解

5.1使用多线程与Handler
5.1.1任务说明
本任务的演示效果如图51
所示。在该应用中,活动页面视图根节点为垂直的LinearLayout,在布局中依次放置1个TextView,用于显示个人信息; 1个TextView(id为tv_result),用于显示计数器的值; 两个水平放置的Button,分别为START和STOP。单击START按钮,启动一个后台线程每隔0.01s计数一次,并在tv_result上更新计数值,同时START按钮使能禁止,变为灰色,STOP按钮使能开启; 单击STOP按钮,后台线程结束,停止计数,同时STOP按钮使能禁止,START按钮使能开启。单击START按钮,重新开启线程,计数器从0开始计数。



图51多线程计数器演示效果


5.1.2任务相关知识点
1. 线程在Android中的重要性
Android的UI优化机制,使系统在UI线程中(即Activity、Fragment等,能修改UI的活动线程)连续更新UI时,只取最后一次更新的结果而忽略过程中UI的更新。因此,若在Button的单击事件中使用for循环对TextView进行文本内容更新,则只能更新最后一次的结果。例如,for循环执行100次,每次调用Thread.sleep()方法睡眠10ms后对计数器自增,并将其更新到TextView上,在视觉效果上,用户只能看到for循环最后一次的更新结果,并且系统检测到UI长时间不能响应还可能直接报错。

对于Android编程而言,最佳的实践方式是将耗时的数据获取与处理(如网络连接、网络数据获取、较复杂的数据计算等)交给后台Thread(线程)或者Service(服务),而Activity或者Fragment等UI线程中则负责接收处理后的数据,渲染更新UI,Android的后台线程以及Service是不允许直接更新UI的。

Thread的使用,主要是改写run()方法,并对Thread对象调用start()方法启动线程,使之在后台执行run()方法。当run()方法运行结束,线程消亡,即使线程定义为Activity的全局变量,当线程运行结束后,线程对象也是不可靠的,因此线程对象往往将其视为局部变量,用完即释。如果在主UI线程中调用后台线程对象的run()方法,能执行对应的代码,但是并不是在后台线程中执行,而是在UI线程中直接执行后台线程的run()方法,后台线程角色失效。

当后台线程启动后,对Android编程而言,亟须解决的一个问题是,后台线程产生的数据与Android UI前端之间如何交互。在本任务中,后台线程每隔0.01s会更新一次计数值,那么,Activity前端UI线程中如何获知?对于编写C语言的开发者,最“勤劳”的做法是对线程设置某个标记变量,在发布数据时,将标记变量置1,而前端在for循环中一直读取该标记变量,若为1,则取计数值并将标记重置为0; “高级”一点的做法是设置中断,在中断中处理数据。显然这些做法都不适合Android,前者将导致Android的CPU一直处于忙碌状态,耗能增大,且系统响应迟钝; 后者没有对应API,难以实现。

对于Android系统,目前常用的前后台多线程之间的数据交互有3种方法。

(1) 通过Handler(句柄)实现事件传递和数据交互。

(2) 通过自定义接口,在后台线程中切换主UI线程,并产生接口回调,在主UI线程中实现接口并响应回调。


(3) 通过Android的LiveData,利用生产者和观察者模式,在后台线程中对LiveData数据通过postValue()方法修改值,在主UI线程中则对LiveData调用Observer接口侦听数据变动。

2. 句柄Handler

本任务使用Handler实现后台线程与前端的交互。Handler(android.os.Handler,不是Java包中的java.util.logging.Handler)是Android所提供的一个特殊类,负责消息传递。

Handler工作方式如图52
所示。Handler对象在主UI中(Activity或Fragment等)定义,并且一般是全局变量。后台线程需要用到主UI中定义的Handler对象,因此Handler对象往往会作为构造参数传递给后台线程,当后台线程需要发送数据给前端UI时,则通过Handler对象的obtainMessage()方法获得消息对象Message,Message可以使用Bundle放置多个字段的数据,进而Handler使用Message在线程间传输数据。Bundle是Android提供的数据封装类,通过keyvalue的方式放置数据,可将其理解为超级HashMap,提供了诸如putFloat()、getFloat()、putString()、getString()、putStringArray()、getStringArray()等常用数据的存取方法,也提供了putSerializable()、getSerializable()等方法支持自定义类数据(需要实现Serializable序列化接口)的存取。



图52Handler的工作方式


后台线程通过Bundle对象,按keyvalue的方式调用对应的方法存入数据后,使用主UI传进来的Handler对象获得消息对象Message,对Message对象调用setData()方法将Bundle对象放入消息中,最后通过Handler对象的sendMessage()方法将消息发送出去。消息进入消息队列MessageQueue中,系统Looper会自动调度消息队列,当存在Handler对象对应的消息时,会从队列中取出消息发送给Handler对象,此时,Handler对象会产生handleMessage()回调。由于Handler是在前端UI线程中定义的,其handleMessage()回调也在UI线程中执行。在handleMessage()回调中会传入对应的Message对象,进而可从消息对象中通过getData()方法获得Bundle对象,Bundle对象则可通过key取出对应数据将之渲染到UI上。

总而言之,前端定义Handler对象,并处理handleMessage()回调从消息中获得数据,用于更新UI; 后台线程通过Handler对象获得Message对象,将数据装入Message,并通过Handler发送消息,两者配合实现了后台线程与前端UI的数据交互。当Message仅需要传递int数据时,可以不使用Bundle,而是直接使用Message对象的arg1和arg2传递简单的int数据。

5.1.3任务实现
1. 实现UI布局
MainActivity的布局my_main.xml如代码51
所示,其中Button可通过android:enabled属性控制初始使能状态,当属性值为false时,则Button使能被禁止,无法被单击。


代码51MainActivity的布局文件my_main.xml



1LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"

2  xmlns:app="http://schemas.android.com/apk/res-auto"

3  android:orientation="vertical"

4  android:layout_width="match_parent"

5  android:layout_height="match_parent"

6  TextView

7android:layout_width="match_parent"

8android:layout_height="wrap_content"

9android:text="Your name and ID" /

10 TextView

11 android:id="@+id/tv_result"

12 android:layout_width="match_parent"

13 android:layout_height="wrap_content"

14 android:text="0.00" /

15 LinearLayout

16 android:layout_width="match_parent"

17 android:layout_height="wrap_content"

18 android:orientation="horizontal"

19 Button

20 android:id="@+id/bt_start"

21 android:layout_width="0dp"

22 android:layout_height="wrap_content"

23 android:layout_weight="1"

24 android:text="Start" /

25 Button

26 android:id="@+id/bt_stop"

27 android:layout_width="0dp"

28 android:layout_height="wrap_content"

29 android:layout_weight="1"

30 android:enabled="false"

31 android:text="Stop" /


32!-- 通过设置android:enabled属性可控制Button是否可用--

33 /LinearLayout

34/LinearLayout





2. 实现自定义线程CounterThread

自定义后台线程CounterThread如代码52
所示。在线程中通过构造方法传递主UI线程中定义的Handler对象,此外Bundle需要通过keyvalue方式存取float类型的计数值,建议将key定义为常量,本任务中,将计数值的key定义成私有常量KEY_COUNTER。

线程的核心工作是改写run()方法。在run()方法中,对状态isRunning进行while循环判断,若isRunning为true,则循环执行睡眠10ms后对计数值counter自增0.01,并通过Handler对象将封装有counter的消息发送出去,从而主UI中的Handler对象会响应handleMessage()回调,在回调方法中取出counter并更新UI。Bundle对象的取数涉及key操作,为了进一步减少耦合,可将取数作为后台线程的公开方法,即本任务中的getMessageData()方法,供使用者调用,此时宜将Handler回调的Message对象作为getMessageData()方法的传参,使getMessageData()方法从Message对象中取出Bundle,进而使用类中所定义的KEY_COUNTER取出float变量。考虑到线程可能消亡,因此getMessageData()方法应该设计为静态的方法,使之不依赖于具体的线程对象。

在后台线程中,变量isRunning被定义为原子变量AtomicBoolean,并且其取数和改值均调用原子变量所提供的方法,之所以这样做是因为在多线程中,如果多个线程共同访问(写值)某无锁变量是非线程安全的。在极端情况下,若多个线程试图同时修改同一个无锁变量,则会导致其出乎逻辑的错误,尤其是具有多字段的自定义类变量,变量的修改往往无法在一条机器指令周期内完成,会出现变量不同字段被不同线程同时修改,导致数据面目全非。而原子操作则能保证某线程访问该变量时,自动加锁,操作完后,释放锁,从而使该变量在加锁期间无法被其他线程修改,保证数据完整性安全。

按照C语言的逻辑,在run()方法中,变量isRunning被设为true之后,有读者认为在while判断时,该循环是死循环。事实上,变量isRunning可被线程所提供的stopCounter()方法修改为false。只要外部的线程调用了stopCounter()方法,随时可将变量isRunning修改为false,使while循环结束,run()方法执行完毕,线程消亡。Thread.sleep()方法提供睡眠功能,使用时需要trycatch语句捕捉异常。注意,Thread.sleep()方法的睡眠时间并不是严格精确的,不能胜任精确定时的场合。


代码52自定义线程CounterThread.java



1import android.os.Bundle;

2import android.os.Handler;

3import android.os.Message;

4import java.util.concurrent.atomic.AtomicBoolean;

5public class CounterThread extends Thread{

6private static final String KEY_COUNTER ="key_counter" ;

7private AtomicBoolean isRunning;//原子变量,使之多线程操作安全

8private Handler handler; //handler由外部传入,在UI线程中定义handler

9//注意导入包是android.os.Handler,而非java.util.logging.Handler

10 public CounterThread(Handler handler) {


11this.handler = handler;

12 }

13 @Override

14 public void run() {

15isRunning=new AtomicBoolean(true);

16float counter=0.0f;

17while (isRunning.get()){

18  try {

19 Thread.sleep(10);//睡眠10ms,可能会有异常,用try-catch捕捉

20 //非精准计时,与实际时间有误差

21  } catch (InterruptedException e) {

22e.printStackTrace();

23  }

24  counter+=0.01f;

25  Bundle bundle = new Bundle();

26  //Handler对象利用Bundle打包数据

27  //Bundle可理解为一个超级HashMap,通过key-value存放数据

28  //Bundle支持常用数据存取的指定方法,避免强制类型转换

29  bundle.putFloat(KEY_COUNTER,counter);

30  Message msg = handler.obtainMessage();//从Handler对象中取出消息对象







31  msg.setData(bundle); //将Bundle对象放入消息对象

32  handler.sendMessage(msg); //将消息通过Handler对象发送出去

33  //句柄对象Handler在主UI中回调handleMessage()处理消息,并更新UI

34}

35 }

36 public void stopCounter(){

37isRunning.set(false);

38//isRunning设置为false,使得run()方法中while条件不再成立,循环结束

39}

40public static float getMessageData(Message msg){

41//定义为static方法,使之不依赖于实例对象

42//解析消息数据直接在该类中实现有利于解耦,外部无需关心Bundle对象存放数据的key

43Bundle bundle = msg.getData();

44float v = bundle.getFloat(KEY_COUNTER);

45return v;

46 }

47}





3. 实现MainActivity

MainActivity的实现如代码53
所示。线程在run()方法执行完毕时即消亡,一般情况下被定义为局部变量,但是由于MainActivity类的onCreate()方法中有匿名回调需要调用Thread对象的方法,而匿名回调要访问局部变量,就需要将该局部变量声明为final,而final修饰的变量不能被多次重新创建,因此本任务中将线程对象设为成员变量,使之能被MainActivity类中的各方法访问。

Handler对象创建时,可直接匿名实现handleMessage()回调,在回调中调用CounterThread静态方法从Message对象中获得计数器值,并更新给TextView对象,从而实现了后台线程发送消息传递数据,UI线程中回调handleMessage()方法接收数据并更新UI。

MainActivity中,两个Button的使能状态须在单击事件中互相反转,其原因是,Button对象bt_start负责创建并启动线程,Button对象bt_stop负责停止线程,若线程还没有启动,单击bt_stop无意义,因此须在bt_start启动线程后才可以使能bt_stop,使之具有可停止的线程对象。此外,本任务中只需要1个后台线程,因此bt_start启动线程后不能再启动新线程,需将bt_start自身使能禁止,避免反复单击创建新线程。


代码53MainActivity.java



1import androidx.annotation.NonNull;

2import androidx.appcompat.app.AppCompatActivity;

3import android.os.Bundle;

4import android.os.Handler;

5import android.os.Message;

6import android.view.View;

7import android.widget.Button;

8import android.widget.TextView;

9import android.widget.Toast;

10public class MainActivity extends AppCompatActivity {

11Handler handler;

12CounterThread thread;

13@Override

14protected void onCreate(Bundle savedInstanceState) {







15  super.onCreate(savedInstanceState);

16  setContentView(R.layout.my_main);

17  TextView tv=findViewById(R.id.tv_result);

18  Button bt_start=findViewById(R.id.bt_start);

19  Button bt_stop=findViewById(R.id.bt_stop);

20  handler=new Handler(new Handler.Callback() {

21@Override

22public boolean handleMessage(@NonNull Message msg) {

23 //后台线程通过Handler对象调用sendMessage()方法发送消息后产生的回调

24 float f = CounterThread.getMessageData(msg);

25 tv.setText(String.format("%.2f",f));//更新计数器值

26 return true;//Handler事件不再往下传递

27}

28  });

29  bt_start.setOnClickListener(new View.OnClickListener() {

30@Override

31public void onClick(View v) {

32  bt_start.setEnabled(false); //反转两个Button的使能状态

33  bt_stop.setEnabled(true);

34  thread=new CounterThread(handler);

35  thread.start();//启动线程run()方法,run()结束时线程消亡

36  //不能调用thread.run(),该方法会在主UI中执行,而不是开辟后台线程执行

37}

38  });

39  bt_stop.setOnClickListener(new View.OnClickListener() {

40@Override


41public void onClick(View v) {

42  bt_start.setEnabled(true);

43  bt_stop.setEnabled(false);

44  thread.stopCounter();

45  //修改CounterThread的isRunning为false,循环结束,线程运行结束

46}

47  });

48}

49}





5.1.4验证变量的线程安全性
1. 验证原子变量的线程安全性
为了进一步验证变量的线程安全与否,可自定义1个线程类TestAtomicThread,如代码54
所示。在线程中定义1个静态的原子变量计数器counter供多个后台线程共享,以及可控的增减方向变量isAdd。当isAdd为true时,在循环中对counter自增20,反之则自减20,随后睡眠1ms,循环程序执行5000次。TestAtomicThread类提供getCounter()方法获取计数器值,resetCounter()方法对计数器清零。


代码54自定义线程类TestAtomicThread.java



1import java.util.concurrent.atomic.AtomicLong;

2public class TestAtomicThread extends Thread {

3private static AtomicLong counter = new AtomicLong(0);

4private boolean isAdd;

5public TestAtomicThread(boolean isAdd) {

6this.isAdd = isAdd;

7}







8@Override

9public void run() {

10 for (int i = 0; i  5000; i++) {

11if (isAdd) {

12  counter.getAndAdd(20l);

13} else {

14  counter.getAndAdd(-20l);

15}

16try {

17  sleep(1);

18} catch (InterruptedException e) {

19  e.printStackTrace();

20}

21 }

22 }

23 public static AtomicLong getCounter() {

24return counter;

25 }

26 public static void resetCounter(){

27counter.set(0l);


28}

29}






在MainActivity的布局中增加1个Button(id为bt_test),在MainActivity的onCreate()方法中增加相应代码,所增加的内容如代码55
所示。单击bt_test按钮,生成两个TestAtomicThread线程实例,在启动线程前,先对计数器清零。启动线程后,两个线程在各自的循环程序中对共享的计数器改值,其中,线程t1对计数器值每次自增20,线程t2对计数器值每次自减20,主UI线程中调用线程对象的join()方法等待线程t1和线程t2运行结束。基于join()方法是阻塞式操作,等待线程对象执行结束才能继续往下执行这一原理,在主UI中会一直等待线程t1和线程t2运行结束,并且在等待期间,主UI线程被阻塞,不能执行UI操作。当线程t1和线程t2执行结束时,主UI不被阻塞,此时Toast会显示TestAtomicThread的计数器值。鉴于原子变量是线程安全的,线程t1对计数器5000次的自增操作与线程t2对计数器的5000次自减操作的累计影响相抵消,因此,Toast显示的计数器结果为0。


代码55MainActivity的onCreate()方法对TestAtomicThread的验证代码



1Button bt_test=findViewById(R.id.bt_test);

2bt_test.setOnClickListener(new View.OnClickListener() {

3@Override

4public void onClick(View v) {

5TestAtomicThread t1=new TestAtomicThread(true);

6TestAtomicThread t2=new TestAtomicThread(false);

7TestAtomicThread.resetCounter();

8t1.start();

9t2.start();

10 try {

11 t1.join();//阻塞操作,在主UI中等待线程t1运行结束

12 t2.join();//阻塞操作,在主UI中等待线程t2运行结束

13 Toast.makeText(MainActivity.this,

14"Counter="+ TestAtomicThread.getCounter(),

15 Toast.LENGTH_LONG).show();







16 } catch (InterruptedException e) {

17 e.printStackTrace();

18}

19}

20});





2. 验证普通变量的非线程安全性

定义1个非线程安全类TestCommonThread,如代码56
所示,该类中,将计数器counter更改为Long变量,并且在run()方法中对counter无加锁操作,因此线程的run()方法对counter操作是非线程安全的。

在TestCommonThread中还定义了resetCounter()静态方法,使用了synchronized关键字修饰,静态的synchronized方法相当于对TestCommonThread.class加锁,不管TestCommonThread生成多少个线程实例对象,在调用resetCounter()方法时,只有1个线程实例能对其操作,该实例完成resetCounter()方法调用后,其他线程实例才能调用该方法。


代码56自定义线程TestCommonThread.java



1public class TestCommonThread extends Thread{

2private static Long counter=0l;

3private Boolean isAdd;

4public TestCommonThread(boolean isAdd) {

5this.isAdd = isAdd;

6}

7@Override

8public void run() {

9for (int i = 0; i  5000; i++) {

10  if (isAdd) {

11 counter += 20l;

12  } else {

13 counter -= 20l;

14  }

15  try {

16 sleep(1);

17  } catch (InterruptedException e) {

18 e.printStackTrace();

19  }

20}

21}

22public static long getCounter() {

23return counter;

24}

25public static synchronized void resetCounter(){

26//静态方法加锁,等效于给TestCommonThread.class加锁

27//所有调用该方法的线程实例等待该方法解锁后才能继续调用resetCounter()

28counter=0l;

29}

30}





MainActivity中的验证程序如代码57
所示,在onCreate()方法中运行。当线程t1和线程t2运行结束后,Toast显示计数器值,运行结果验证了计数器值在多数情况下不为0。造成该结果的原因是: 在线程的run()方法中,线程t1对counter自增操作尚未结束时,有可能线程t2对counter同时进行自减操作,导致读写数据不一致,即非线程安全操作,导致最终结果与预期不一致。通俗地说,假设某时刻计数器counter=100,线程t1在自增操作时读取了counter并在CPU中进行了加法运算,正要写回给内存(但还没有写入,此时CPU计算结果为120,内存结果为100)时,线程t2又访问了counter,此时读取的值依然是100,并做了减法运算(结果为80),随后线程t1在内存中写回120,紧接着线程t2写回内存80。此时预期的计数器值为100,由于没有对变量加锁的原因,导致计数器的真实值为80,与预期不一致。在测试中,循环用了5000次是为了增加两个线程写回数据时产生冲突的概率。


代码57MainActivity中对TestCommonThread的验证代码



1Button bt_test=findViewById(R.id.bt_test);

2bt_test.setOnClickListener(new View.OnClickListener() {

3@Override

4public void onClick(View v) {

5TestCommonThread t1=new TestCommonThread(true);

6TestCommonThread t2=new TestCommonThread(false);

7TestCommonThread.resetCounter();

8t1.start();

9t2.start();

10 try {

11t1.join();//阻塞操作,在主UI中等待线程t1运行结束

12t2.join(); //阻塞操作,在主UI中等待线程t2运行结束

13Toast.makeText(MainActivity.this,

14"Counter="+ TestCommonThread.getCounter(),

15 Toast.LENGTH_LONG).show();

16 } catch (InterruptedException e) {

17e.printStackTrace();

18 }

19 }

20});





3. 使用synchronized修饰解决多线程访问冲突

解决多线程访问冲突问题,不一定都要使用原子变量。代码58
对TestCommonThread进行修改,在run()方法中对变量counter加了synchronized关键字修饰(见粗体代码),使对应的代码块具备加锁保护功能。当运行该代码块时,只有1个线程能运行,只有该线程运行完毕后,其他线程才能运行该代码块,此时对counter自增或自减操作是线程安全的,实际运行结果和预期结果均为0。


代码58在TestCommonThread中给变量counter加锁



1@Override

2public void run() {

3for (int i = 0; i  5000; i++) {

4synchronized (counter) {

5if (isAdd) {

6  counter += 20l;

7} else {

8 counter -= 20l;

9}

10 }

11 try {

12 sleep(1);

13 } catch (InterruptedException e) {







14 e.printStackTrace();

15 }

16}

17}





为了测量两个线程的运行时间,在MainActivity中修改了相关代码,具体见代码59
的粗体内容。在线程启动之前,对bt_start按钮调用performClick()方法,相当于对bt_start按钮产生单击操作,等到t1和t2两个线程结束后,调用bt_stop按钮的performClick()方法,即结束定时,此时TextView上显示的即为两个线程跑完的时间(非精确测量)。


代码59MainActivity中模拟Button的单击行为



1bt_test.setOnClickListener(new View.OnClickListener() {

2@Override

3public void onClick(View v) {

4TestCommonThread t1=new TestCommonThread(true);

5TestCommonThread t2=new TestCommonThread(false);

6TestCommonThread.resetCounter();

7bt_start.performClick();//用代码控制bt_start按钮产生单击行为

8t1.start();

9t2.start();

10 try {

11 t1.join();//阻塞操作,在主UI中等待线程t1运行结束

12 t2.join();//阻塞操作,在主UI中等待线程t2运行结束

13 bt_stop.performClick();//用代码控制bt_stop按钮产生单击行为

14 //tv上显示t1和t2两个线程运行结束时产生的后台计数时间

15 Toast.makeText(MainActivity.this,

16"Counter="+ TestCommonThread.getCounter(),

17 Toast.LENGTH_LONG).show();

18 } catch (InterruptedException e) {

19e.printStackTrace();

20 }

21}

22});





多次运行的结果是: 计数器counter最终值为0。因此代码58
的操作可行,TextView上显示的时间约为10.80s。如果指令运算的时间忽略不计,线程t1和线程t2几乎同时运行,理论值应该在5.00s左右(5000次循环,每次循环消耗1ms),但是由于线程的sleep()方法为非精确定时,加上线程切换调动的开销,使得实际消耗时间远大于理论值。

作为比较,运行代码56
,软测量的时间约为10.70s,与加了同步锁的代码58
相差不大。若在synchronized代码块中,不小心将sleep()方法的相关代码放在同步代码块,如代码510
所示,则在计数器counter访问冲突时,sleep()方法的执行也被包含在同步锁中,需等当前线程sleep()方法结束才能将代码块释放给另一个线程,此时会无形中增加线程之间的消耗时间。运行代码510
的软测量结果为12.20s,显然在访问冲突时由于sleep()方法的执行,增加了总运行时间。作为参考,对Atomic变量的测量结果为10.80s左右,与代码58
相当。

修改代码510
,使用synchronized (TestCommonThread.class),即对TestCommonThread类加锁,当该类的线程实例t1和t2运行同步代码块时,只能有1个线程运行,此时counter变量最终结果依然是0,符合预期,但是t1和t2几乎全程不能并行执行,运行消耗的时间大幅增加,软测量结果为21.40s。

若将锁加在线程的变量isAdd上,即改成synchronized (isAdd),由于isAdd不是静态变量,线程实例t1和t2都有各自的变量isAdd,此时,同步锁针对的是两个不同的变量isAdd,从而导致线程t1和线程t2相互独立运行,同步锁不起作用,计数器最终值不为0,运行软测量结果为10.76s。若将同步锁改成线程实例,即synchronized (this),由于线程t1和线程t2不是同一个实例,同样,同步锁不起作用,计数器最终值不为0,运行软测量结果为10.76s。

由此可见,synchronized同步代码的“陷阱”比较多,稍不注意,就可能发生预期之外的错误或者牺牲了性能。
对于提供了原子操作的变量,尽量在多线程访问冲突场合使用原子变量。


代码510牺牲性能的同步代码



1synchronized (counter) {

2  if (isAdd) {

3counter += 20l;

4  } else {

5counter -= 20l;

6  }

7  try {

8 sleep(1);

9  } catch (InterruptedException e) {

10  e.printStackTrace();

11  }

12}





5.2使用多线程与自定义接口
5.2.1任务说明
本任务功能同5.1节的任务,但在实现方式上,采用了自定义接口,利用接口回调方法实现后台线程向前端UI传递数据。

自定义接口回调避免了通过Handler传递数据,因此后台线程的写法更像传统Java的写法,但在细节上依然存在差异,后台线程自定义接口回调的方法虽然是在前端UI线程中实现的,但本质上,却是在后台线程中执行的,若前端实现的回调方法中涉及UI更新,则实际上是在后台线程中更新UI,这在Android系统中是不允许的。

接口回调可以理解成一种特殊的方法占位符,定义和使用接口回调的地方占位了回调方法,并传递了所需要的数据,而实现接口回调的地方对回调方法所传递的数据进行处理。通过这种方式,就能实现代码的抽象与解耦。占位接口回调的只用关心什么时候需要触发该接口回调,并通过接口回调传递什么数据出去,而不必关心使用者如何实现该接口回调,对传递的数据做如何处理; 实现接口回调的只用关心什么时候接收接口回调的数据,如何使用所传递的数据或更新UI,而不必关心接口回调是怎么触发的、触发机制是什么、在具体的哪一行代码触发。因此,接口具有生产者和观察者特性,生产者产生数据,并占位接口回调传递数据,观察者实现接口回调,使用所传递的数据做事务逻辑处理或者更新UI。

鉴于实现接口回调的代码本质上是在接口占位中执行的,若接口回调的实现方法中涉及UI更新,则后台线程接口占位不能直接写在线程的run()方法中,而是需要将接口占位放在主UI线程中。Android提供了对应的解决办法,可将Activity对象传递到后台线程,利用Activity对象所提供的runOnUiThread()方法将后台线程切换至Activity所在的UI线程,进而可在runOnUiThread()方法的匿名Runnable中占位自定义的接口回调,解决了后台线程不能更新UI的问题。

5.2.2任务实现
1. 实现后台线程CounterThread
MainActivity的布局文件参考5.1节。后台线程CounterThread如代码511
所示,鉴于后台线程需要Activity对象的runOnUiThread()方法进行UI线程切换,因此将Activity对象作为类成员,并通过构造方法传递该对象。

在CounterThread中,自定义了OnUpdateListener接口和对应的onUpdate(float counter)方法,通过该方法将计数器值counter传递给使用线程的Activity。此外,由于Activity对象需要调用runOnUiThread()切换线程,并将匿名实现的Runnable接口作为切换线程方法的参数,涉及了匿名回调访问计数器值,若counter作为final的局部变量,则无法被重新赋值,因此counter只能作为类成员变量实现全局访问。CounterThread类提供了setOnUpdateListener()方法,使Activity在使用线程时,可通过该方法实现OnUpdateListener接口,并在接口回调方法中使用计数器值更新UI。

CounterThread的run()方法中,依然是睡眠10ms,更新一次计数器值,并通过Activity对象的runOnUiThread()切换线程。为了防止Activity实例因没有调用setOnUpdateListener()方法实现OnUpdateListener接口,而导致接口对象为null产生空对象错误,占位接口回调时,须先对接口对象判断是否为空,再调用接口对象所提供的onUpdate(counter)方法,将计数器值counter传递出去。当Activity的OnUpdateListener接口被触发时,本质上,在Activity中实现的OnUpdateListener接口和回调方法,在代码511第36
行处执行,而这些代码是在runOnUiThread()方法中执行的,已从后台线程切换到Activity对象的UI线程,既保证了后台线程传递数据,又保证了在UI线程中接收数据更新UI。


代码511使用自定义接口的线程CounterThread.java



1import android.app.Activity;

2import java.util.concurrent.atomic.AtomicBoolean;

3public class CounterThread extends Thread{

4private AtomicBoolean isRunning;//原子变量多线程操作是安全的

5private Activity activity; //使用Activity对象切换UI线程

6private OnUpdateListener onUpdateListener; //定义自定义接口对象

7private float counter;//全局变量counter在切换线程的匿名run()方法中被访问

8public CounterThread(Activity activity) {

9this.activity = activity;  //Activity对象从外部传入

10 //可直接传MainActivity.this

11 }

12 public interface OnUpdateListener {

13 void onUpdate(float counter); //利用自定义接口传递计数器值

14 }


15 public void setOnUpdateListener(OnUpdateListener onUpdateListener) {







16 this.onUpdateListener = onUpdateListener;//传递外部实现的接口

17 //外部实现的接口得到计数器值,并更新UI

18 }

19 @Override

20 public void run() {

21 isRunning=new AtomicBoolean(true);

22 counter=0.0f;

23 while (isRunning.get()){

24 try {

25Thread.sleep(10);//睡眠10ms,可能会有异常,用try-catch捕捉异常

26 } catch (InterruptedException e) {

27e.printStackTrace();

28 }

29 counter+=0.01f;

30 activity.runOnUiThread(new Runnable() {

31//利用Activity对象切换到主UI线程

32//后台线程Thread不能更新UI

33@Override

34public void run() {

35 if(onUpdateListener!=null){

36onUpdateListener.onUpdate(counter);

37//通过自定义接口将计数器值传给接口实现者,并更新UI

38 }

39}

40});

41 }

42 }

43 public void stopCounter(){

44 isRunning.set(false);

45 //将isRunning设置为false,使得run()循环结束

46}

47}





2. 实现MainActivity

MainActivity的实现如代码512
所示。MainActivity不需要使用Handler传递数据,其实现逻辑相比5.1节要简单,启动后台线程后,对线程对象调用setOnUpdateListener()方法,并匿名实现OnUpdateListener接口,在接口回调中获得后台线程的计数器值,并更新到TextView对象上。


代码512MainActivity.java



1import androidx.appcompat.app.AppCompatActivity;

2import android.os.Bundle;

3import android.view.View;

4import android.widget.Button;

5import android.widget.TextView;

6public class MainActivity extends AppCompatActivity {

7  CounterThread thread;


8  @Override

9  protected void onCreate(Bundle savedInstanceState) {

10super.onCreate(savedInstanceState);

11setContentView(R.layout.my_main);

12TextView tv=findViewById(R.id.tv_result);

13Button bt_start=findViewById(R.id.bt_start);

14Button bt_stop=findViewById(R.id.bt_stop);







15bt_start.setOnClickListener(new View.OnClickListener() {

16 @Override

17 public void onClick(View v) {

18bt_start.setEnabled(false);//反转两个Button的使能状态

19bt_stop.setEnabled(true);

20thread=new CounterThread(MainActivity.this);

21thread.setOnUpdateListener(new CounterThread.OnUpdateListener() {

22//设置线程的接口回调,在回调方法中得到计数器值,并更新UI

23@Override

24public void onUpdate(float counter) {

25tv.setText(String.format("%.2f",counter));

26}

27});

28thread.start();//启动后台线程

29 }

30});

31bt_stop.setOnClickListener(new View.OnClickListener() {

32 @Override

33 public void onClick(View v) {

34bt_start.setEnabled(true);

35bt_stop.setEnabled(false);

36thread.stopCounter();

37 }

38});

39 }

40}





5.3使用多线程与LiveData
5.3.1任务说明
本任务的演示效果如图53
所示,相比5.1节的任务,在功能逻辑上要复杂一些。单击START按钮后,启动后台线程,每隔0.01s更新1次计数值,通过LiveData的实现类MutableLiveData感知数据并在UI中更新。START按钮被单击后会变成PAUSE按钮,同时使能STOP按钮。PAUSE按钮有两种行为,具体如下所述。



图53任务的演示效果


(1) 在PAUSE按钮状态下,单击STOP按钮,定时器结束,PAUSE按钮变成START按钮,STOP按钮禁止。

(2) 单击PAUSE按钮,该按钮变成RESUME按钮,并且STOP按钮禁止,定时器暂停。

单击RESUME按钮,定时器工作,并且继续在上一次计数值的基础上计数,RESUME按钮变成PAUSE按钮,STOP按钮使能。

相比5.1节的任务,START按钮身兼数职,具有重新启动计数、暂停计数、继续计数的功能。在逻辑上,通过STOP按钮重置START按钮,而START按钮的单击行为则会进入PAUSE和RESUME的循环状态。

在实现上,若将后台线程计数器值设为静态变量,不管是通过Handler还是自定义接口,均能实现类似的功能,但是所有的计数器线程共享1个静态变量,无法做到多个Activity或者Fragment拥有各自独立的后台计数器。本任务使用了全新的方法,即LiveData来实现后台线程与UI线程的交互,LiveData与视图模型绑定后,可让不同的Activity或者Fragment拥有独立的LiveData数据。


5.3.2任务实现
1. LiveData的特点与使用方法
LiveData是Jetpack提供的一种响应式编程组件,它可以包含任何类型的数据,并在数据发生变化的时候通知观察者,适合与ViewModel(视图模型)结合在一起使用,可以让ViewModel将数据的变化主动通知给Activity或者Fragment。

LiveData本身是抽象类,一般会使用实现类MutableLiveDataT定义LiveData数据,其中T是泛型,即将需要被观察的对象的数据类型作为MutableLiveData的泛型定义该数据,MutableLiveData对象具有setValue()和postValue()两种常用的改值方法,其中setValue()方法在UI线程中使用,postValue()方法在后台线程和UI线程中均可使用。UI线程获得MutableLiveData对象,并对其实现Observer接口,则在接口回调onChanged()方法中,会获得MutableLiveData对象setValue()或者postValue()的更新值,进而可将更新值用于UI渲染。

一般而言,MutableLiveData不会单独使用,而是放在继承了ViewModel的自定义视图模型中,使之具有生命周期的管理功能。当视图模型的拥有者(Activity或者Fragment)没有消亡,则视图模型中的MutableLiveData也不会消亡。从另外一个角度理解,当后台线程和前端UI均使用相同的视图拥有者,则后台线程不管生成消亡多少次,其从视图模型中获得的MutableLiveData与前端UI的MutableLiveData始终是同一个对象,从后台线程的角度看,多次线程的创建和消亡不会导致MutableLiveData消亡,使之具有静态变量的特征。但是MutableLiveData比静态变量更强大,当多个Activity使用后台线程时,由于不同Activity对视图模型而言属于不同的拥有者,因此对应的MutableLiveData属于不同的对象,从而不同Activity对象调用后台线程,只要后台线程处理好与对应Activity的拥有者关系,就能实现不同Activity以及所对应的后台线程具有相互独立的MutableLiveData对象。对本任务而言,即不同的Activity拥有各自独立的后台线程计数器,每个Activity均可以对其实现重启、暂停、继续和停止操作,互不影响,若是使用后台线程的静态变量则难以实现该功能,各个Activity只能共享同一个静态变量,无法做到互不干扰。

2. 创建自定义视图模型CounterViewModel

自定义视图模型CounterViewModel如代码513
所示,该类继承自ViewModel。CounterViewModel的设计比较简单,定义了1个私有变量计数器counter,使用MutableLiveDataFloat类型定义,即该数据是Float泛型的MutableLiveData数据,在CounterViewModel类中实现getCounter()方法,使之返回该私有数据。泛型只接受类数据,float类型的计数器在MutableLiveData中须使用Float泛型。在MutableLiveData构造方法中,对counter实例化,并且设置了计数器初始值0。若采用无构造方法的视图模型,可在声明成员变量时直接对其实例化。


代码513自定义视图模型CounterViewModel.java



1import androidx.lifecycle.MutableLiveData;

2import androidx.lifecycle.ViewModel;

3public class CounterViewModel extends ViewModel {

4//在自定义的CounterViewModel中定义MutableLiveData

5private MutableLiveDataFloat counter;

6//MutableLiveData数据需要泛型定义数据类型,泛型只接受类,float对应类是Float

7public CounterViewModel() {

8counter=new MutableLiveData(); //对counter实例化

9counter.setValue(0.0f);//设置初始值

10 }

11 public MutableLiveDataFloat getCounter() {

12 return counter;

13 }

14}





取视图模型中的变量counter,需要先取得视图模型,可通过以下方式获得视图模型。



CounterViewModel counterViewModel = new ViewModelProvider(owner)

.get(CounterViewModel.class);





其中,owner是拥有者,可用Activity(或者Fragment)的实例,如MainActivity.this。得到视图模型对象后,可调用getCounter()方法,获得使用MutableLiveDataFloat定义的计数器counter。变量counter具有数据感知能力,可在后台线程中被改值,并在UI线程中被观测。

3. 实现后台线程CounterThread

CounterThread的实现如代码514
所示,在构造方法中,传入视图拥有者ViewModelStoreOwner对象,以便于通过拥有者获得视图模型以及对应的LiveData数据。后台线程相比之前的任务,略有不同,即计数器的处理有两种模式。

(1) 清零模式,将计数器清零,重新开始计数。

(2) 暂停模式,计数器在原有基础上计数。

为了便于识别后台线程计数器的处理模式,在CounterThread中定义了计数模式变量mode和两个常量MODE_RESTART(清零模式)以及MODE_RESUME(暂停模式)。当构造方法使用单参数构造时,计数器默认为清零模式。

后台线程的run()方法中,通过拥有者获得视图模型,进而调用视图模型的getCounter()方法获得LiveData的计数器对象counter。计数任务中,对线程模式进行判断,若是清零模式,则计数器counter通过postValue()方法重新设为
0; 若是暂停模式,则利用视图模型数据不会消亡的特点,可在原有值基础上计数。在循环计数中,计数器的更新值使用postValue()方法。在UI线程中,对通过视图模型获得的计数器counter实现了Observer侦听接口,当后台线程的计数器通过postValue()方法更新值后,UI线程中的计数器则能通过Observer接口感知数据变动,进而触发onChanged()回调方法,回调方法的传参就是计数器的更新值,可被UI线程取出用于UI更新。


代码514使用LiveData的后台线程CounterThread.java



1import androidx.lifecycle.MutableLiveData;

2import androidx.lifecycle.ViewModelProvider;

3import androidx.lifecycle.ViewModelStoreOwner;

4import java.util.concurrent.atomic.AtomicBoolean;

5public class CounterThread extends Thread{

6private ViewModelStoreOwner owner;

7private AtomicBoolean isRunning;

8private MutableLiveDataFloat counter;

9private int mode;

10 public static final int MODE_RESTART=0;//计数器清零模式

11 public static final int MODE_RESUME=1; //计数器暂停模式

12 public CounterThread(ViewModelStoreOwner owner,int mode) {

13this.owner = owner;

14 //可以不传owner,而是直接传ViewModel对象

15 //使用传参owner,验证后台线程与前端UI使用同一个owner获得的视图模型是否相同

16 //相同视图模型实例对应的LiveData是同一个对象

17this.mode=mode;

18 }

19 public CounterThread(ViewModelStoreOwner owner) {

20this.owner = owner;

21mode=MODE_RESTART;

22 }

23 @Override

24 public void run() {

25isRunning=new AtomicBoolean(true);

26CounterViewModel counterViewModel = new ViewModelProvider(owner)

27.get(CounterViewModel.class);

28 //从CounterViewModel中获得计数器变量,该变量具有Observer接口

29 //在主UI中,可对该变量实现Observer接口,感知变量的变动

30counter = counterViewModel.getCounter();

31 //counter不会随线程消亡,而是在owner的生命周期中一直存在

32if(mode==MODE_RESTART) {

33//在MODE_RESTART模式,计数器重置为0

34counter.postValue(0.0f);

35//在线程中使用postValue()改值,在主UI中,setValue()和postValue()均可

36}


37while (isRunning.get()){

38try {

39  Thread.sleep(10);

40} catch (InterruptedException e) {

41  e.printStackTrace();

42}

43counter.postValue(counter.getValue()+0.01f);







44//在主UI中通过counter的Observer接口和回调方法取出更新值,更新UI

45}

46}

47public void stopCounter(){

48  isRunning.set(false);

49}

50}





4. 实现MainActivity

本任务MainActivity的布局文件同5.1节。MainActivity的实现如代码515
所示。在onCreate()方法中,视图模型通过ViewModelProvider获得,进而通过视图模型得到MutableLiveDataFloat类型的计数器对象counter,此时,由于MainActivity和CounterThread使用的视图模型拥有者均是MainActivity实例,因此两者是相同的视图模型,对应的计数器counter也是相同的对象实例。后台线程的计数器通过postValue()方法改值后,MainActivity前端UI通过Observer侦听感知数据变动,并通过onChanged()回调方法获得更新后的值,进而更新UI。

按钮bt_start的行为逻辑相比之前的任务略显复杂,初始状态是START,被单击后,按钮文本更新为PAUSE,即可用该按钮暂停计数; 当bt_start再次被单击,PAUSE按钮变为RESUME按钮,并调用线程的stopCounter()方法结束线程,此时视图模型中的计数器counter生命周期跟随拥有者MainActivity,不会因线程消亡而消亡; 当RESUME按钮被单击时,后台线程不对计数器counter清零,而是直接在上一次取值基础上改值,从而实现计数器继续计数的功能,此时线程并不是暂停,而是被重新创建并启动。单击STOP按钮,线程结束,bt_start恢复为START按钮,计数器处于清理模式,若再次启动线程,计数器从0开始计数。


代码515使用LiveData的MainActivity.java



1import androidx.appcompat.app.AppCompatActivity;

2import androidx.lifecycle.MutableLiveData;

3import androidx.lifecycle.Observer;

4import androidx.lifecycle.ViewModelProvider;

5import android.content.Context;

6import android.content.Intent;

7import android.os.Bundle;

8import android.view.View;

9import android.widget.Button;

10import android.widget.TextView;

11public class MainActivity extends AppCompatActivity {

12 CounterThread thread;


13 @Override

14 protected void onCreate(Bundle savedInstanceState) {

15 super.onCreate(savedInstanceState);

16 setContentView(R.layout.my_main);

17 CounterViewModel counterViewModel = new ViewModelProvider(this)

18 .get(CounterViewModel.class);

19 //从CounterViewModel中获得MutableLiveData数据,生命周期同MainActivity

20 MutableLiveDataFloat counter = counterViewModel.getCounter();

21 TextView tv=findViewById(R.id.tv_result);

22 //MutableLiveData数据具有侦听功能,在Observer接口中感知数据的变动







23 counter.observe(this, new ObserverFloat() {

24@Override

25public void onChanged(Float aFloat) {

26  String s = String.format("%.2f", aFloat);

27  tv.setText(s);

28}

29 });

30 Button bt_start=findViewById(R.id.bt_start);

31 Button bt_stop=findViewById(R.id.bt_stop);

32 bt_start.setOnClickListener(new View.OnClickListener() {

33@Override

34public void onClick(View v) {

35  //状态: Start-Pause-Resume-Pause-Resume...

36  //或者 Start-(Stop)-Start

37  String start_tag = bt_start.getText().toString();

38  if(start_tag.equalsIgnoreCase("Start")) {

39thread = new CounterThread(MainActivity.this);

40bt_stop.setEnabled(true);

41bt_start.setText("Pause");

42thread.start();

43  }else if(start_tag.equalsIgnoreCase("Pause")){

44thread.stopCounter();

45bt_stop.setEnabled(false);

46bt_start.setText("Resume");

47  }else {

48thread=new CounterThread(MainActivity.this,

49CounterThread.MODE_RESUME);

50//使用MODE_RESUME,计数器值不清0,在上一次取值基础上计数

51bt_start.setText("Pause");

52bt_stop.setEnabled(true);

53thread.start();

54  }

55}

56 });

57 bt_stop.setOnClickListener(new View.OnClickListener() {

58@Override

59public void onClick(View v) {

60  bt_start.setText("Start"); //bt_start须恢复成START按钮

61  bt_stop.setEnabled(false);

62  thread.stopCounter();


63}

64 });

65 }

66}





5. 使用两个Activity测试视图模型

感兴趣的读者可尝试创建两个不同的Activity,各自拥有自身的视图模型,使之拥有各自独立的LiveData计数器,并观察两个计数器是否存在相互干扰(事实上没有干扰)。具体做法,可在my_main.xml布局文件上增加1个Button(id为bt_jump),用于跳转至另一个Activity,并在MainActivity的onCreate()方法中增加如下代码: 



1findViewById(R.id.bt_jump).setOnClickListener(new View.OnClickListener() {

2//R.id.bt_jump为my_main.xml布局中跳转Activity所需的Button

3@Override

4public void onClick(View v) {







5Context ctx = getApplicationContext();

6Intent i=new Intent(ctx,MainActivity2.class);

7startActivity(i);

8}

9});





其中,getApplicationContext()方法可获得当前Activity的上下文,Intent为意图,用于启动Activity或者Service对象。本任务中,使用Intent双参数构造方法,第1个参数为上下文,第2个参数为要启动的Activity类,得到Intent对象后,可通过startActivity()方法,将该意图作为Activity启动对象,从而实现不同活动页面之间的跳转功能。

MainActivity2的实现可直接复制自MainActivity文件,利用Refactor的Rename功能,修改为MainActivity2,并将Button对象bt_jump的事件触发代码改为: 



1findViewById(R.id.bt_jump).setOnClickListener(new View.OnClickListener() {

2 @Override

3 public void onClick(View v) {

4Context ctx = getApplicationContext();

5Intent i=new Intent(ctx,MainActivity.class);

6startActivity(i);

7}

8});





修改后,项目中有两个Activity: MainActivity和MainActivity2,若要使两者均能被应用启动,则需要在配置文件中增加activity声明。打开AndroidManifest.xml文件,修改成如下配置。



1application

2  android:allowBackup="true"

3  android:icon="@mipmap/ic_launcher"

4  android:label="@string/app_name"

5  android:roundIcon="@mipmap/ic_launcher_round"

6  android:supportsRtl="true"


7  android:theme="@style/Theme.Tcf_task4_1V3"

8  activity

9 android:name=".MainActivity"

10  android:launchMode="singleInstance"

11  android:exported="true"

12  intent-filter

13action android:name="android.intent.action.MAIN" /

14category android:name="android.intent.category.LAUNCHER" /

15  /intent-filter

16 /activity

17 activity android:name=".MainActivity2" 

18  android:parentActivityName=".MainActivity"

19  android:launchMode="singleInstance"

20 /activity

21/application





在AndroidManifest.xml文件中,activity标签是活动页面(Activity)的申明标签,若应用中定义了若干个Activity,每个Activity均需要使用activity标签进行申明,其中,android:name指向的是Activity的类名称,android:launchMode="singleInstance"是启动单例模式,即应用中启动对应Activity时,若该Activity已存在,则切换到对应任务栈显示该Activity,而不是重新生成一个新的Activity。

action android:name="android.intent.action.MAIN"/设置该Activity是应用的默认启动的活动页面,类似于main()函数。“category android:name="android.intent.category.LAUNCHER"/”设置该应用在应用列表中可见。在MainActivity2的标签中,还增加了android:parentActivityName=".MainActivity"属性,指明MainActivity2是由MainActivity跳转过来的,因此MainActivity2活动页面的动作栏上有左箭头,单击左箭头即可从当前活动页面跳转回parentActivity指向的Activity,即MainActivity。

5.4使用Okhttp和Gson获取Web API数据
5.4.1任务说明
本任务的演示效果如图54
所示。在该应用中,活动页面视图根节点为垂直的LinearLayout,在布局中依次放置1个TextView,用于显示个人信息; 1个EditText,用于显


图54获取Web API数据

演示效果


示获取数据的网址; 1个ASYNC MODE按钮,id为bt_async,以异步方式调用Okhttp获取数据; 1个BLOCK MODE按钮,id为bt_block,以同步(阻塞)方式调用Okhttp获取数据; 1个ListView,用于显示Web API所获取的数据。


Web API服务可通过运行本书所提供的基于Node.js所写的Web后端应用提供,网址需根据读者运行Web服务的设备IP地址进行更改,端口号默认为8080。此外,读者还可以使用Android经典书籍《第一行代码Android》(作者: 郭霖)中提供的中国城市数据Web API (网址请扫描前言中的二维码获取)。Web API回传数据为JSON格式,通过该API可获得中国省份列表、省内地级市列表和地级市内各区县列表。本任务将使用第三方库Okhttp访问Web API,并使用Gson解析JSON数据,最终将JSON数据转为列表数据在ListView中显示。

5.4.2任务实现
1. 导入第三方库


本任务使用了第三方库Okhttp和Gson,须在项目中导入后,方能使用。第三方库有多种导入方法,最常用的方式是在Module Gradle文件中导入对应资源。第三方库的导入地址和版本号可在GitHub官网(网址请扫描前言
中的二维码获取)或者MVN Repository官网(网址请扫描前言中的二维码获取)等代码仓库中搜索。这里以MVN Repository为例,搜索Gson,并选择2.9.0版本,即可进入对应仓库界面,如图55
所示,资源包既可通过下载jar文件进行离线配置,也可通过Maven、Gradle、SBT和Ivy等方式进行在线配置,选择Gradle (Short)选项,即可得到对应配置语句
implementation 'com.google.code.gson:gson:2.9.0',进而可在Gradle文件中进行在线配置。



图55通过MVN Repository搜索Gson所获得的Gradle配置


本任务所需的Okhttp和Gson第三方依赖库,选择在Module Gradle文件中进行在线配置。如图56
所示,

图56Android项目中的Gradle文件
在Android项目中有多个Gradle文件,依赖包以及SDK等配置主要在Module Gradle文件中完成。


在Module Gradle文件的dependencies节点中,已存在若干依赖库,不同版本Android Studio所创建的项目,其依赖库的版本号甚至依赖库的名称均可能发生变化,这就会导致同一项目在不同版本Android Studio中打开时,会使其自动下载对应的Gradle文件和依赖库。本任务的依赖库配置如代码516
所示,Gradle文件一旦检测到依赖库发生了变化,会在IDE右上角出现Sync Now按钮,单击该按钮,即可在线下载相关依赖库。


代码516Module Gradle文件添加Okhttp和Gson依赖



1dependencies {

2implementation 'androidx.appcompat:appcompat:1.3.0'

3//appcompat是项目默认创建的依赖库,不同Android Studio版本号会有区别

4…

5//以下是项目额外增加的第三方依赖库

6implementation 'com.squareup.okhttp3:okhttp:4.9.3'

7implementation 'com.google.code.gson:gson:2.9.0'

8//在线加载Okhttp和Gson第三方依赖库,单击IDE右上角Sync Now按钮进行更新

9…

10}





2. 添加Internet上网权限

Android系统会对应用限制访问数据或执行操作的权限,若应用没有在声明文件中声明Internet权限,默认无法访问Internet资源。上网权限属于低级权限,只需要在AndroidManifest.xml文件中添加静态权限即可。若是读写系统通讯录则属于高级权限,对于Android 6.0及以上版本的系统,除了在AndroidManifest中配置权限外,还需要在代码中调用Runtime Permission(运行时权限)。

在AndroidManifest.xml中添加静态权限,既可添加在application标签之前,也可在该标签之后。对于Android 10.0及以上系统,出于安全考虑,默认只能访问Https协议网络资源,不能访问Http协议网络资源,因此还需要在application标签中添加android:usesCleartextTraffic="true"属性,使其能访问Http资源。

本任务的AndroidManifest配置文件修改部分如代码517
所示,在application标签内添加了android:usesCleartextTraffic属性,使得Android 10.0及以上系统能访问Http资源,并在application标签之外通过usespermission标签添加了Internet访问权限。


代码517AndroidManifest.xml文件中配置Internet权限



1manifest …

2 application

3…

4android:usesCleartextTraffic="true"

5…

6activity

7 …

8/activity

9…

10/application

11uses-permission android:name="android.permission.INTERNET"

12/uses-permission

13/manifest





3. 解析JSON数据

JSON(JavaScript Object Notation)是一种轻量级的数据交换格式,比XML更小、更快、更易于解析,常在Web API中使用。JSON数据分为对象和数组,对象使用花括号({})、数组使用方括号([])包围。对象中各个字段以keyvalue的键值形式定义,key与value之间用冒号(:)隔开,一个对象可拥有多个字段,不同字段间用逗号(,)隔开。对象字段中的key使用字符串数据,由双引号包围; value可以是数值型数据(整数或浮点数,无双引号包围),字符串数据(由双引号包围),逻辑值数据(true或false),数组数据(由方括号包围,元素间用逗号隔开),null以及对象数据(由花括号包围),数组数据中的元素也可以是对象,从而使JSON能构成复杂的嵌套数据。需要注意的是,以上提到的符号都是在半角状态下输入的。

以本任务回传的Web API数据为例,有以下所述的两种数据格式。

(1) 查询全国省份(第1级查询)和查询某省内地级市(第2级查询)的数据如代码518
所示。查询返回的JSON数据为数组数据,数组中每个元素为对象数据,对象数据具有相同的字段,其id字段为整型数据,name字段为字符串数据。第1级查询的网址为“http://服务器IP地址:端口号/api/china”,假设运行Web API服务的IP地址为10.5.14.14,则对应访问网址为“http://10.5.14.14:8080/api/china”,若端口号使用80,则端口号可省略。第2级查询的网址为“http://服务器IP地址:端口号/api/china/{省份id}”,本任务中,浙江省的id为17,则可使用“http://10.5.14.14:8080/api/china/17”查询浙江省的地级市列表数据。

(2) 查询某地级市内各区县(第3级查询)的数据如代码519
所示。其结构同样为数组数据,相比第1级和第2级查询结果,其对象数据多了1个weather_id字段,值为字符串数据。第3级查询的网址为“http://服务器IP地址:端口号/api/china/{省份id}/{地级市id}”,例如,浙江省的id为17,杭州市的id为126,则可使用“http://10.5.14.14:8080/api/china/17/126”查询杭州市的区县列表数据。

注意,例中所用的IP地址须更改成读者运行Web API服务的实际IP地址。


代码518Web API第1级和第2级查询返回的JSON数据



1[

2{"id":1,"name":"北京"},

3…

4{"id":17,"name":"浙江"},

5…

6]






代码519Web API第3级查询返回的JSON数据



1[

2{"id":999,"name":"杭州","weather_id":"CN101210101"},

3{"id":1000,"name":"萧山","weather_id":"CN101210102"},

4…

5{"id":1006,"name":"富阳","weather_id":"CN101210108"}

6]





本任务对JSON返回的数据进行解析,并将解析结果封装成City.class类,类描述如代码520
所示,类成员定义符合Gson解析要求。City类中,成员变量name的变量名称和变量类型匹配了JSON对象的name字段,成员变量id则匹配了JSON对象的id字段。注意,成员变量weatherId与JSON对象的weather_id字段不匹配,需使用注解
@SerializedName("weather_id"),使变量weatherId匹配由注解标注的JSON字段。这样设计的好处是,在设计类时可使类成员名称与JSON数据字段不一致,项目维护中,即使Web API的JSON对象字段发生了变化,项目解析端只需更改映射即可工作,提高了项目的可维护性。Gson可使用所定义的City类解析JSON数据,并返回类对象,若City类中存在某些字段无法匹配JSON对象,默认会忽略,不影响解析结果。例如,用City类匹配代码518
的JSON对象,类成员变量weatherId无匹配项,用Gson解析对象时,则会使用weatherId的默认值(null)生成City类对象。在本任务中,City类没有使用private关键字修饰,事实上,将成员变量修改成private类型,并定义相关的getter和setter方法,并不影响解析结果。City类改写了toString()方法,使之支持字符串化,可直接用于ArrayAdapter。


代码520JSON解析结果的封装类City.java



1import com.google.gson.annotations.SerializedName;

2public class City {

3public String name;

4public int id;

5@SerializedName("weather_id")

6public String weatherId;







7//若JSON数据的字段"weather_id"与类成员weatherId不符,可用@SerializedName映射

8@Override

9public String toString() {

10 return String.format("%s, id=%d",name,id);

11  }

12  public City() {

13 name="";

14 weatherId="";

15  }

16  public City(String name, int id, String weatherId) {

17 this.name = name;

18 this.id = id;

19 this.weatherId = weatherId;

20  }

21}





JSON数据解析工具类CityParsingUtils如代码521
所示,在该类中给出三个静态方法,对应了三种解析方法,用户可调用任何一种方法进行数据解析。三种方法均使用了throws关键字抛出异常,表示在处理数据过程中,若有异常,直接抛出,而调用者则需要使用trycatch捕捉异常。三者解析方法的使用和实现如下表述。

(1) json2ListByJsonObj()方法使用最原始的方式解析JSON数据。经分析可知,Web API返回数据是JSON数组,因此使用源数据构造JSONArray对象,并使用for循环从数组中逐一取出JSON对象,再利用JSON对象的字段名称和字段类型调用对应方法进行取值。本任务第3级Web API回传的JSON对象有weather_id字段,而前两级则没有,因此,处理过程中,还需要将JSON对象源数据转成字符串,并判断字符串中是否包含了weather_id字段,再进行相应的取值处理。显然这种原生处理方法比较被动,一旦数据源或者模型发生变化,需要改动较多的代码,不利于后期维护。

(2) json2ListByGson()方法使用Gson解析数据(Gson需要在Gradle中添加依赖)。在该方法中,首先使用源数据构造JSONArray对象,在遍历中取出JSON对象,再转换为字符串作为Gson源数据。Gson可直接将源数据转换为预先定义的数据类并返回类对象,当源数据与类字段存在未匹配项或缺项,并不影响转换,一旦数据结构发生变动,只需要修改City类使之与新的数据匹配,大大提高了可维护性。

(3) json2ListByGsonList()方法使用了TypeToken反射,可直接将源数据转换为列表类对象,使得代码更简洁,也更适合复杂嵌套的结构化数据的解析。


代码521自定义JSON解析工具类CityParsingUtils.java



1import android.text.TextUtils;

2import com.google.gson.Gson;

3import com.google.gson.reflect.TypeToken;


4import org.json.JSONArray;

5import org.json.JSONException;

6import org.json.JSONObject;

7import java.lang.reflect.Type;

8import java.util.ArrayList;

9import java.util.List;

10public class CityParsingUtils {

11/** 需要在Module Gradle文件的dependencies中增加Gson

12 implementation 'com.google.code.gson:gson:2.9.0'







13 */

14public static ListCity json2ListByJsonObj(String s) throws JSONException {

15//若遇到错误,抛出异常,由调用者使用try-catch捕捉异常

16//直接采用JsonObject解析

17ListCity list=new ArrayList();

18if(!TextUtils.isEmpty(s)){

19JSONArray jsonArray = new JSONArray(s);

20for (int i = 0; i  jsonArray.length(); i++) {

21JSONObject jsonObject = jsonArray.getJSONObject(i);

22int id = jsonObject.getInt("id");

23String name = jsonObject.getString("name");

24String weatherId="";

25if(jsonObject.toString().toLowerCase().contains("weather_id")){

26//JSON数据有些包含weather_id字段,有些不包含,处理比较被动

27//需要将JSON对象转换成string,对字段进行包含判断

28weatherId=jsonObject.getString("weather_id");

29}

30City city = new City(name, id, weatherId);

31list.add(city);

32}

33}

34return list;

35}

36public static ListCity json2ListByGson(String s) throws JSONException {

37ListCity list=new ArrayList();

38if(!TextUtils.isEmpty(s)){

39JSONArray jsonArray = new JSONArray(s);

40for (int i = 0; i  jsonArray.length(); i++) {

41//从JSON数组中遍历JSON对象,对JSON对象利用Gson转换成对应类

42String s1 = jsonArray.get(i).toString();

43//JSON对象再转换成JSON字符串供Gson转换

44City city = new Gson().fromJson(s1, City.class);

45//若City.class中定义的某些字段和JSON字段没有匹配,只取能匹配的字段

46list.add(city);

47}

48}

49return list;

50}

51public static ListCity json2ListByGsonList(String s) throws Exception {

52//定义方法时,增加throws Exception,以便于调用时捕捉异常

53ListCity list=new ArrayList();


54if(!TextUtils.isEmpty(s)){

55Type type=new TypeTokenListCity(){}.getType();

56//利用com.google.gson.reflect.TypeToken反射构造ListCity列表对象类数据

57list=new Gson().fromJson(s,type); //直接利用TypeToken转换为列表对象

58}

59return list;

60 }

61}





4. 使用Okhttp获取Internet数据

Okhttp是一套处理Http网络请求的依赖库,由Square公司设计研发并开源,目前可以在Java和Kotlin中使用。Okhttp支持Web常用的GET和POST操作,并且有拦截器和鉴权功能。当Web API需要用户登录鉴权时,Okhttp可利用authentication()方法检测是否鉴权失效和自动登录鉴权,在前后端分离的应用场景,Okhttp作为前端角色,负责与后端进行数据交互。在本任务中,只使用了Okhttp的GET操作,并介绍了异步和同步两种操作方法。

使用Okhttp前,需要在Gradle中添加对应依赖。为了方便调用者使用,将Okhttp对Web的请求封装到一个自定义工具类WebApiUtils中,如代码522
所示。Okhttp的使用需要通过new OkHttpClient()构造方法得到一个OkHttpClient客户端对象。考虑到调用者访问Web,多次网络请求均会使用同一个客户端对象,因此在工具类中将OkHttpClient对象设计成静态成员变量,使之成为单例模式,即不管外部调用者调用多少次WebApiUtils,均由同一个OkHttpClient客户端提供服务。当OkHttpClient对象为null时,为了避免有多个线程同时调用getClient()方法生成对象,导致OkHttpClient对象不一致,在getClient()方法中使用了synchronized修饰,并且锁的对象是工具类本身,从而保证了getClient()方法的原子性,即在同一时刻只能有一个线程能访问该方法。在WebApiUtils类中,getClient()方法是private类型,外部类不能直接调用,只能通过WebApiUtils提供的其他public方法间接调用。

异步调用需要将获取的Web数据回传,可通过Handler、自定义接口或者LiveData等方式实现,本任务中,采用自定义接口OnReadFinishedListener实现,其中onFinished()方法传递解析后的City列表数据,onFail()方法则传递错误信息字符串,接口的两个方法由调用者实现。

Okhttp支持异步调用和同步调用。异步调用时,Okhttp会自动启动一个后台线程去访问Web资源,调用者的调用代码尚未执行结束就会继续往下执行其他代码,并通过回调事件告知调用者出错或者完成情况。Okhttp后台线程处理过程中,若遇到异常,则会回调onFailure()方法,在该方法中可接入自定义接口OnReadFinishedListener的onFail()回调方法。考虑到调用者是在UI线程中实现自定义接口的onFail()回调方法,并且涉及UI修改,因此,在Okhttp的onFailure()方法中处理onFail()回调时,需要切换到UI线程。鉴于此,异步调用方法需要传递Activity对象,利用Activity对象切换UI线程。

如代码522
所示,getApiDataAsync()方法为自定义的Okhttp异步调用方法。在该方法中,首先通过getClient()方法获得OkHttpClient对象,并根据网址传参url生成Request请求对象,在生成过程中使用get()方法表明Web访问采用GET方式,最后调用OkHttpClient对象的newCall()方法,将Request对象作为方法的参数,得到Call对象。Call对象可理解为OkHttpClient对Request请求产生的调用对象,支持同步调用和异步调用,同步调用时,代码进入阻塞,执行完毕才会继续执行后续代码; 异步调用则不等待调用结果,可直接执行后续代码,通过回调得到执行结果或者出错信息。

getApiDataAsync()方法通过Call对象的enqueue()方法实现异步调用功能,在enqueue()方法中需要实现Callback接口的两个回调: onFailure()方法,在程序异常时触发; onResponse()方法,在成功获取网络数据时触发。两个回调方法都在OkHttpClient自身管理的后台线程中执行,无法直接更新UI,若用户在回调中涉及UI更新,则需要切换到UI线程中进行处理。getApiDataAsync()方法给出的解决方案是通过Activity对象的runOnUiThread()方法进行线程切换,在UI线程中调用自定义接口的方法,其中onFailure()回调负责调用自定义接口的onFail()方法,将错误信息转换成字符串传递给外部调用者进行处理,onResponse()回调则获取网络响应的文本数据,并调用JSON数据解析工具类的相关方法将其转换成City类型的列表数据,进而切换到主UI线程通过自定义接口的onFinished()方法将列表数据传递给调用者更新UI。在onResponse()回调中,原生方法有throws语句用于抛出异常,本任务中,删除了throws语句,直接在回调中通过trycatch处理异常,并且将catch中捕捉的异常通过自定义接口的onFail()方法传递给调用者处理。该实现方式的好处是,调用者只需要实现自定义接口的onFinished()方法和onFail()方法,进行两类问题的处理即可,无须再额外处理各类异常抛出的问题。

同步调用使用getApiDataBlock()方法,不同于getApiDataAsync()方法,它阻塞网络请求代码并在所有代码执行结束后才返回结果,因此该方法定义了返回类型ListCity。而异步调用是在接口回调中返回结果,使异步方法是void类型,无返回结果。同步和异步方法均声明成static静态方法,外部调用者可直接使用WebApiUtils类对应的方法,无须生成WebApiUtils对象。在getApiDataBlock()方法中还使用了throws Exception语句抛出异常,使调用者能通过trycatch捕捉异常并处理异常。getApiDataBlock()方法中,如何生成OkHttpClient和Request对象,以及将两者关联成Call对象,与getApiDataAsync()方法处理方式相同,区别在于Call对象的调用方式,异步调用使用enqueue()方法加入队列,同步方法则直接调用execute()方法执行请求并返回结果。CityParsingUtils提供了3种解析JSON数据的方法,在同步调用中选择了不同于异步调用的解析方法,用于验证各种解析方法的有效性,并无特别用意,读者可任意更换解析方法。

WebApiUtils工具类提供了异步调用和同步调用两种方式,通过OkHttp访问网址,得到结果,并解析成对应的列表数据。同步调用看似比异步调用使用了更少的代码,但是同步调用在UI线程中并不能直接使用,其原因是同步调用Okhttp的所有处理均会在调用者所在的线程中执行,而UI线程并不能直接执行访问网络等耗时操作。因此,在UI线程中,若要使用同步调用,依然需要生成一个后台线程去调用同步方法,而异步调用的enqueue()方法会使OkHttp自动开启后台线程处理相关事务,对调用者而言,异步调用无须额外的后台线程。由以上分析可知,在UI线程中更适合调用WebApiUtils工具类的异步方法,而同步方法适用于后台线程或者Service服务中对网络资源进行遍历访问等无UI交互的场合。


代码522自定义Okhttp调用工具类WebApiUtils.java



1import android.app.Activity;

2import androidx.annotation.NonNull;

3import java.io.IOException;

4import java.util.List;

5import okhttp3.Call;

6import okhttp3.Callback;

7import okhttp3.OkHttpClient;

8import okhttp3.Request;

9import okhttp3.Response;

10public class WebApiUtils {

11 /** 需要在Module Gradle文件的dependencies标签中增加Okhttp组件

12  implementation 'com.squareup.okhttp3:okhttp:4.9.3'

13  */

14 private static OkHttpClient client;

15 //client在WebApiUtils中是单例模式,始终只有1个对象

16 private static OkHttpClient getClient() {

17synchronized (WebApiUtils.class) {

18  //加锁避免多个线程同时调用getClient(),在client==null时重复生成实例







19  //对WebApiUtils.class加锁,多个线程只能有1个线程能访问此代码

20  if (client == null) {

21  client = new OkHttpClient();

22}

23return client;

24}

25 }

26 public interface OnReadFinishedListener{

27//自定义接口,定义两个回调,分别用于读取成功和出错处理

28public void onFinished(ListCity readOutList);

29//读取成功,将Json数据转换为类列表数据返回

30public void onFail(String e);

31//读取失败,回传错误信息

32 }

33 public static void getApiDataAsync(Activity activity,String url,

34OnReadFinishedListener l){

35//传入Activity对象,用于切换线程

36OkHttpClient c = getClient();//通过调用getClient()得到OkHttpClient单例

37Request request = new Request.Builder().url(url).get().build();

38//构造一个请求对象Request,url()传url网址,get()是GET请求的方法

39//请求方法有get()、post()、delete()、put()等方法

40Call call = client.newCall(request);//利用Request生成一个call对象

41//每一次访问对应一个call对象,异步访问时将call对象加入队列

42call.enqueue(new Callback() {

43@Override

44public void onFailure(@NonNull Call call, @NonNull IOException e) {

45  activity.runOnUiThread(new Runnable() {//切换到UI线程

46 @Override

47 public void run() {

48l.onFail(e.toString());

49//通过自定义接口将错误信息通过onFail()传递到UI线程

50 }


51  });

52}

53@Override

54public void onResponse(@NonNull Call call,

55@NonNull Response response){

56  //修改原回调方法,去除throws异常语句,直接捕捉处理

57  try {

58 String s = response.body().string();//得到响应的文本

59 //注意是string()方法,不是toString()方法

60 ListCity list = CityParsingUtils.json2ListByGsonList(s);

61 //CityParsingUtils提供了3种解析方法,可调用任何一种

62 activity.runOnUiThread(new Runnable() {//切换到UI线程

63@Override

64public void run() {

65  l.onFinished(list);

66}

67 });

68  } catch (Exception e) {

69 e.printStackTrace();

70 activity.runOnUiThread(new Runnable() {//切换到UI线程

71@Override

72public void run() {

73  l.onFail(e.toString()); //错误信息通过接口回调传给调用者

74}

75 });







76  }

77}

78});

79}

80public static ListCity getApiDataBlock(String url) throws Exception{

81  //在运行过程中遇到异常,将抛出异常给调用者处理

82  //采用同步的方式,代码将阻塞,适合其他后台线程循环调用此方法获取批量请求结果

83  OkHttpClient client = getClient();

84  Request request = new Request.Builder().url(url).get().build();

85  Call call = client.newCall(request);

86  //生成Request和Call对象,与异步调用相同,区别的是call的处理方式

87  Response response = call.execute();

88  //同步方法,运行该代码将进入阻塞,直至call执行完毕

89  String s = response.body().string();

90  ListCity list = CityParsingUtils.json2ListByGson(s);

91  //调用CityParsingUtils第二种解析方法,可尝试更换成其他方法

92  return list;

93 }

94}





5. 实现MainActivity

MainActivity的布局文件如代码523
所示,内容比较简单,不再赘述。


代码523MainActivity的布局文件my_main.xml



1LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"

2  android:orientation="vertical"

3  android:layout_width="match_parent"

4  android:layout_height="match_parent"

5  TextView

6 android:layout_width="match_parent"

7 android:layout_height="wrap_content"

8 android:text="Your name and ID" /

9  EditText

10  android:id="@+id/et_url"

11  android:layout_width="match_parent"

12  android:layout_height="wrap_content"

13  android:ems="10"

14  android:inputType="textPersonName"

15  android:text="http://guolin.tech/api/china/" /

16 Button

17  android:id="@+id/bt_async"

18  android:layout_width="match_parent"

19  android:layout_height="wrap_content"

20  android:text="Async mode" /

21 Button

22  android:id="@+id/bt_block"

23  android:layout_width="match_parent"

24  android:layout_height="wrap_content"

25  android:text="Block mode" /

26 ListView

27  android:id="@+id/listView"

28  android:layout_width="match_parent"

29  android:layout_height="match_parent" /

30/LinearLayout





MainActivity的实现如代码524
所示。应用在调用WebApiUtils类所提供的方法之前,需要在AndroidManifest.xml文件中配置Internet权限,并且对于Android 10.0及以上版本的系统,访问Http资源需要在application标签内增加android:usesCleartextTraffic属性。在MainActivity页面中,用户可通过EditText更改网址参数,获取不同的城市列表。

MainActivity布局文件中有2个Button,分别用于调用WebApiUtils的异步方法和同步阻塞方法。在异步方法中,对WebApiUtils.OnReadFinishedListener接口匿名实现,分别处理onFinished()回调和onFail()回调。onFinished()回调方法回传城市列表数据,并通过showList()方法生成适配器,进而在ListView中显示网络请求的回传数据; onFail()方法则将回传的出错信息使用Toast显示,告知用户程序异常的具体信息。同步方法不能直接在UI线程中调用,需要开启后台线程,并在子线程中调用。本任务中,直接匿名实现后台线程,在后台线程中获得同步方法的返回结果后,再利用MainActivity对象切换到UI线程,在UI线程中调用showList()方法更新ListView。WebApiUtils.getApiDataBlock()方法使用throws语句抛出异常,被调用时需要使用trycatch捕捉,并在catch中处理错误信息。注意,同步方法是在后台线程中调用的,因此,打印错误信息也需要切换到UI线程中执行。由此可见,对调用者而言,在UI线程中,使用异步调用比同步调用更简洁易用。


代码524MainActivity.java



1import androidx.appcompat.app.AppCompatActivity;

2import android.os.Bundle;

3import android.view.View;

4import android.widget.ArrayAdapter;

5import android.widget.EditText;

6import android.widget.ListView;

7import android.widget.Toast;

8import java.util.List;

9public class MainActivity extends AppCompatActivity {

10/** manifests/AndroidManifest.xml中需要增加上网权限相关配置

11 * 在application标签之后增加Internet访问权限

12 Android10.0+系统,需要在AndroidManifest的application标签内增加以下属性: 

13 android:usesCleartextTraffic="true"

14 */

15ListView lv;

16@Override

17protected void onCreate(Bundle savedInstanceState) {

18  super.onCreate(savedInstanceState);

19  setContentView(R.layout.my_main);

20  EditText et=findViewById(R.id.et_url);

21  lv=findViewById(R.id.listView);

22  findViewById(R.id.bt_async)

23.setOnClickListener(new View.OnClickListener() {

24@Override

25public void onClick(View view) {

26  String url = et.getText().toString().trim();

27  //trim()方法将字符串头尾多余空格去除

28  //以下是异步调用的方法

29  WebApiUtils.getApiDataAsync(MainActivity.this, url,

30new WebApiUtils.OnReadFinishedListener() {

31 @Override

32 public void onFinished(ListCity readOutList) {

33showList(readOutList);

34 }







35 @Override

36 public void onFail(String e) {

37showToast(e);

38 }

39 });

40 }

41});

42findViewById(R.id.bt_block)

43 .setOnClickListener(new View.OnClickListener() {

44 @Override

45 public void onClick(View view) {

46 String url = et.getText().toString().trim();

47 //同步调用,在主UI中无法直接调用,需要启动一个线程来调用

48 new Thread(new Runnable() {


49 @Override

50 public void run() {

51try {

52  ListCity list = WebApiUtils.getApiDataBlock(url);

53  MainActivity.this.runOnUiThread(new Runnable() {

54  @Override

55  public void run() {

56 showList(list);

57  }

58  });

59} catch (Exception exception) {

60  exception.printStackTrace();

61  MainActivity.this.runOnUiThread(new Runnable() {

62  @Override

63  public void run() {

64 showToast(exception.toString());

65  }

66  });

67 }

68 }

69}).start(); //直接启动所定义的线程

70}

71});

72}

73private void showList(ListCity readOutList) {

74//将列表数据生成适配器,将适配器显示到ListView组件上

75ArrayAdapterCity adapter=new ArrayAdapter(this,

76 android.R.layout.simple_list_item_1,readOutList);

77lv.setAdapter(adapter);

78}

79private void showToast(String e) {

80Toast.makeText(this,e,Toast.LENGTH_LONG).show();

81}

82}





5.5Activity的页面跳转与数据传递
5.5.1任务说明
本任务在5.4节基础上完成,具体效果如图57
所示,应用通过Activity之间的跳转实现分级城市列表的显示。应用由两个Activity页面构成,分别为MainActivity和MainActivity2。应用的默认主页面为MainActivity,通过访问Web API获取数据,在ListView中显示浙江省的地级市列表,单击列表项,获得所单击城市的id,生成第3级城市列表网址,传递网址并跳转到MainActivity2。MainActivity2显示区县列表,单击列表项则结束当前页面,并将所单击的列表项数据传回给MainActivity。MainActivity接收到MainActivity2所传递的数据后,使用SnackBar显示MainActivity2回传的数据。此外,MainActivity2可通过动作栏左上角的返回键返回到上一级的Activity页面,即MainActivity页面。



图57通过Activity跳转实现分级城市列表显示


5.5.2任务实现
1. Activity类在应用中的声明
根据模板创建的Android应用,默认只有1个Activity类,并且已在AndroidManifest.xml文件中设置了相关声明。若要在应用中跳转到同个项目的其他Activity,则须在AndroidManifest中做相应的声明,否则由于权限原因无法跳转到对应的Activity。本任务共创建了两个Activity,分别为MainActivity和MainActivity2,因此在AndroidManifest中须添加对应的声明,如代码525
所示,每一个活动页面(Activity)使用一个activity标签,其android:name属性值为Activity对应的类名称。


代码525关于Activity的声明文件AndroidManifest.xml



1activity

2 android:name=".MainActivity"

3 android:exported="true"

4 android:launchMode="singleTask"

5 intent-filter

6  action android:name="android.intent.action.MAIN" /

7  category android:name="android.intent.category.LAUNCHER" /

8 /intent-filter







9/activity

10activity

11android:name=".MainActivity2"

12android:launchMode="singleTask"

13android:parentActivityName=".MainActivity"

14/activity





2. Activity类的启动模式

一个Android应用通常会被拆分成多个Activity,各个Activity之间通过Intent实现跳转。在Android系统中,通过栈结构来保存整个应用的Activity,当一个应用启动时,如果当前环境中不存在该应用的任务栈,系统就会创建一个任务栈。此后,这个应用所启动的Activity都将在这个任务栈中被管理,这个栈也被称为一个Task,即若干个Activity的集合,他们组合在一起形成一个Task。

在标准模式下,当一个Activity启动了另一个Activity的时候,新启动的Activity就会置于任务栈的顶部,而启动它的Activity则处于停止状态,保留在任务栈中。当用户按下返回键或者调用finish()方法时,系统会移除Task顶部的Activity,让后面的Activity恢复活动状态(回调onResume()方法)。当然Activity也可以有不同的启动模式,可在声明文件AndroidMainifest中,对activity标签添加android:launchMode属性来设置启动模式,或者在代码中通过Intent对象的flag属性来设置启动模式。

在项目的声明文件中,activity标签内的额外属性android:launchMode常用有如下四种选项,分别对应一种启动模型。

(1) 标准模式—standard。标准模式是默认模式,即没有添加android:launchMode属性时采用的模式。在该模式下,每次启动Activity,均会在任务栈中创建新的Activity实例,因此,多次启动Activity后,任务栈中会有多个相同类名的Activity实例,并且各个Activity实例与调用者在同一个任务栈中。

(2) 栈顶单例—singleTop。在该模式下,若任务栈中没有被启动的Activity,则创建Activity实例,并置于栈顶; 若任务栈中已有Activity实例,并且在栈顶,则不会创建对应实例(standard模式会继续创建Activity实例),此时不会回调onCreate()方法,但会调用Activity的onNewIntent()方法; 若任务栈中已有Activity实例,但不在栈顶,则会重新创建Activity实例于栈顶(与standard模式相同)。栈顶单例所创建的Activity实例与调用者在同一个任务栈中。

(3) 栈内单例—singleTask。在该模式下,若任务栈中没有被启动的Activity,则创建Activity实例,并置于栈顶; 若任务栈中已有Activity实例,并且在栈顶,则不会创建该实例,不会回调onCreate()方法,但会调用Activity的onNewIntent()方法; 若任务栈中已有该Activity实例,但不在栈顶,则会将任务栈中该Activity之上的活动全部销毁,使被启动的Activity能处于栈顶,此时不会回调onCreate()方法,但会回调onNewIntent()方法。栈内单例模式,所创建的Activity与调用者在同一个任务栈中。

(4) 全局单例—singleInstance。在该模式下,若没有对应Activity实例,会重新创建1个新的任务栈,并在新的任务栈中创建Activity实例使之处于栈顶; 若已有该Activity实例,则切换任务栈,将该Activity置于任务栈前台,使该Activity实例可见,此时Activity不会回调onCreate()方法,但会回调onNewIntent()方法。全局单例模式,所创建的Activity与调用者不在同一个任务栈中。

为了更形象地说明这4种启动模式的区别,设计4个Activity和对应的启动模式,A: standard; B: singleTop; C: singleTask; D: singleInstance。这4个Activity都能按指定的方式启动各个Activity。为了便于观察,每个Activity设计1个静态计数器,初始值为0,Activity在onCreate()回调方法中会将计数器自增1,在onNewIntent()回调方法中则不改变计数器值,使观测到的现象是: 重建的Activity,其计数器自增1,复用原有Activity时则计数器值不变。

(1) 操作1: A→A→B→B→A→A→B→B,其中箭头方向表示Activity的启动顺序。此时共有1个任务栈,栈中的活动页面过程状态为: A(1)→A(2)→B(1)→B(1)→A(3)→A(4)→B(2)→B(2),任务栈最后的状态为: A(1)→A(2)→B(1)→A(3)→A(4)→B(2),共创建了6个Activity,其中括号内的值表示对应Activity的计数器值。若单击应用的返回键,则会按B(2)→A(4)→A(3)→B(1)→A(2)→A(1)的顺序依次关闭Activity。活动B不在栈顶时会被重新创建,在栈顶时则会被复用,活动A每次均被重新创建。

(2) 操作2: A→A→C→C→A→A→C→C。此时共有1个任务栈,栈中的活动页面过程状态为: A(1)→A(2)→C(1)→C(1)→A(3)→A(4)→C(1)→C(1),活动栈的最终状态为: A(1)→A(2)→C(1),共创建了5个Activity,并在第7个操作时,由于C是singleTask模式,在任务栈中找到实例C(1),此时C(1)之上有A(3)和A(4),会将其销毁,使C(1)处于任务栈顶。因此,任务栈最终只保留了3个活动: A(1)、A(2)和C(1)。若单击应用的返回键,则会按C(1)→A(2)→A(1)的顺序依次关闭Activity。操作2与操作1的最大区别是活动C被创建后,每次启动C时会将C之上的活动销毁,并且C在任务栈中是唯一的,不会被重复创建。

(3) 操作3: A→A→D→D→A→A→D→D。此时共有两个任务栈,所有活动A在同一个任务栈中,活动D则在另一个任务栈中保持单例。两个任务栈中的过程状态为: A(1)→A(2)→D(1)→D(1)→A(3)→A(4)→D(1)→D(1),任务栈1的最终状态为: A(1)→A(2)→A(3)→A(4),任务栈2的最终状态为: D(1),并且任务栈2在任务栈1之上。若单击应用的返回键,则会关闭任务栈2的D(1),此时任务栈2退出,将任务栈1置于前台,继续按返回键,会按A(4)→A(3)→A(2)→A(1)的顺序依次关闭Activity。由于活动A和活动D处在两个不同的任务栈中,当用户通过Home键或者其他方式进行任务切换后,并不能保证任务栈2在任务栈1之上,此时单击活动D的应用返回键,可能存在结束任务栈2时无法回到任务栈1的情况。

综上所述,本节任务的应用须使MainActivity和MainActivity2保持在同一个任务栈中,使MainActivity2的返回键能有效回退到MainActivity,此时应将声明文件中的所有Activity启动模式设置成栈内单例(singleTask)模式,使各Activity在同一个任务栈中保持单例。

3. Gradle依赖和权限

在Module Gradle文件dependencies节点中添加Okhttp和Gson的依赖。



implementation 'com.squareup.okhttp3:okhttp:4.9.3'

implementation 'com.google.code.gson:gson:2.9.0'





在声明文件AndroidManifests.xml中添加上网权限。



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





对于Android 10.0及以上系统,在AndroidManifests.xml的application标签中添加Http明码访问模式。



android:usesCleartextTraffic="true"





本任务会复用5.4节项目中写好的封装类,将5.4节项目的City.java、CityParsingUtils.java和WebApiUtils.java复制到本项目MainActivity.java所在的文件夹中。

4. 实现第1个活动MainActivity

本任务中,2个Activity使用同一个布局文件my_main.xml,如代码526
所示,布局中仅有1个TextView和1个ListView。


代码526活动页面的布局文件my_main.xml



1LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"

2  android:orientation="vertical"

3  android:layout_width="match_parent"

4  android:layout_height="match_parent"

5  TextView

6android:layout_width="match_parent"

7android:layout_height="wrap_content"

8android:text="Your name and ID" /

9  ListView

10android:id="@+id/listView"

11android:layout_width="match_parent"

12android:layout_height="match_parent" /

13/LinearLayout





MainActivity的实现如代码527
所示。在MainActivity中,变量baseUrl为获取浙江省城市列表的Web API网址,读者需要根据实际运行设备的IP进行更改。在ListView单击事件onItemClick()方法中,取出被单击城市的id,与变量baseUrl拼接成一个完整的资源网址tempUrl,启动MainActivity2时,作为Activity之间的传递数据。Activity之间传递数据,建议使用Bundle,既能传递自定义类,也能传递常用的变量。

本任务创建一个自定义类CommonValues,如代码528
所示,专门用于定义应用中需要的常量。Bundle的key可从CommonValues类中获得。

启动另一个Activity可使用Intent(意图)实现。Intent的构造方法有很多种,有动作+数据资源的方式,让系统选择对应的应用启动; 也有调用者+类的方式,指定启动哪个类。本任务使用调用者+类的方式定义Intent,在构造方法中,第1个参数是调用者上下文,可用MainActivity.this明确指定,也可以通过getApplicationContext()方法获得应用上下文,第2个参数即为需要启动的活动类名称。Intent携带的数据可通过putExtras()方法将Bundle对象传入,被启动的活动则可通过getIntent().getExtras()方法取得Bundle对象,进而可通过Bundle解析所需数据。

创建Intent对象后,有两种方式启动Activity,具体表述如下。

(1) 不带返回结果启动。通过startActivity(Intent intent)方法调用,参数即为意图对象intent。假设活动A通过该方式启动了活动B,则活动B不负责将处理结果返回给活动A。

(2) 带返回结果启动。早期的SDK中通过startActivityForResult(Intent intent,int requestCode)方法调用,除了Intent对象,还需要请求码requestCode,请求码的值可由用户自定义。当前,用于启动Intent的startActivityForResult()方法已被弃用,推荐使用registerForActivityResult()方法生成ActivityResultLauncher对象用于启动Intent。registerForActivityResult()方法直接在参数中设置回调接口,可匿名实现接口的回调方法,其好处是不需要用户定义请求码,并且该方法功能更强大,对Kotlin编程更简洁友好。

ActivityResultLauncher作为带返回结果的Intent启动器,在onCreate()方法中注册,然后在需要调用的地方调用launch()方法启动Intent,ActivityResultLauncher对象在代码527
中被定义为成员变量,并通过iniActivityLauncher()方法初始化注册。ActivityResultLauncher的构造方法中传入两个参数,第1个参数是Contract(合约)对象,有多种类型; 第2个参数是结果回调,可在回调方法中处理返回的数据。

常用的Contract如下所列。

(1) StartActivityForResult(),最常用的Contract合约,启动带返回结果的Intent。

(2) RequestMultiplePermissions(),用于请求一组运行时权限。

(3) RequestPermission(),用于请求单个运行时权限。

(4) TakePicturePreview(),调用MediaStore.ACTION_IMAGE_CAPTURE拍照,返回值为Bitmap图片。

(5) TakePicture(),调用MediaStore.ACTION_IMAGE_CAPTURE拍照,并将图片保存到给定的Uri地址,返回true表示保存成功。

(6) TakeVideo(),调用MediaStore.ACTION_VIDEO_CAPTURE拍摄视频,保存到给定的Uri地址,返回一张缩略图。

(7) PickContact(),从系统通讯录应用中获取联系人。

(8) 文档内容操作合约,常见的有: CreateDocument(),OpenDocumentTree(),OpenMultipleDocuments(),OpenDocument(),GetMultipleContents(),GetContent()等。

在MainActivity中,ActivityResultLauncher采用StartActivityForResult()方法生成的合约,用于启动MainActivity2,并处理MainActivity2的返回结果。ActivityResultLauncher对象初始化时,第2个参数直接处理MainActivity2的返回结果,Bundle对象携带的是City对象,City类需要实现序列化接口(Serializable),才能在Bundle中通过putSerializable()方法存入数据以及getSerializable()方法取出数据。对City类的序列化,只需对类增加implements Serializable修饰即可实现,其他保持不变。

City类的序列化实现如下。



1import java.io.Serializable;

2public class City implements Serializable {

3…//类相关成员和方法保持不变

4}





SnackBar是高级版的Toast,其使用方式与Toast类似,但是额外增加了按钮功能。SnackBar构造方法的第1个参数是View对象,表示SnackBar在哪个视图上生成,即依赖在哪个视图上,而Toast的第1个参数则是上下文。SnackBar的按钮可通过setAction()方法设置,该方法中第1个参数是按钮上显示的文本,第2个参数是按钮单击响应回调。


代码527活动页面1——MainActivity.java



1import android.content.Intent;

2import android.os.Bundle;

3import android.util.Log;

4import android.view.View;

5import android.widget.AdapterView;

6import android.widget.ArrayAdapter;

7import android.widget.ListView;

8import android.widget.Toast;

9import androidx.activity.result.ActivityResult;

10import androidx.activity.result.ActivityResultCallback;


11import androidx.activity.result.ActivityResultLauncher;

12import androidx.activity.result.contract.ActivityResultContracts;

13import androidx.appcompat.app.AppCompatActivity;

14import com.google.android.material.snackbar.Snackbar;

15import java.util.List;

16public class MainActivity extends AppCompatActivity {

17 String baseUrl="http://10.5.14.14:8080/api/china/17";

18 //baseUrl须更换成运行Web API的实际设备地址

19 ListView lv;

20 ArrayAdapterCity adapter;

21 ActivityResultLauncherIntent launcher;//定义能回调返回结果的意图启动器

22 @Override

23 protected void onCreate(Bundle savedInstanceState) {

24super.onCreate(savedInstanceState);

25setContentView(R.layout.my_main);

26lv=findViewById(R.id.listView);

27WebApiUtils.getApiDataAsync(this, baseUrl,

28  new WebApiUtils.OnReadFinishedListener() {

29 @Override

30 public void onFinished(ListCity readOutList) {

31showList(readOutList);

32 }

33 @Override

34 public void onFail(String e) {

35showToast(e);

36 }

37  });

38//ActivityResultLauncher取代startActivityForResult()

39iniActivityLauncher();//对ActivityResultLauncher初始化

40lv.setOnItemClickListener(new AdapterView.OnItemClickListener() {

41@Override

42public void onItemClick(AdapterView? adapterView, View view,

43 int i, long l) {

44  City item = adapter.getItem(i);

45  String tempUrl = String.format("%s/%d", baseUrl, item.id);

46  //打印新的网址,获取城市对应县城或行政区列表

47  Bundle bundle = new Bundle();

48  bundle.putString(CommonValues.KEY_URL,tempUrl);

49  //利用Bundle封装需要传递的数据

50  Intent intent=new Intent(getApplicationContext(),

51MainActivity2.class);







52  intent.putExtras(bundle);//给待启动的Intent携带Bundle数据

53  //registerForActivityResult()是推荐的带返回结果的意图启动方法

54  //使用ActivityResultLauncher对象的launch()方法启动意图

55  launcher.launch(intent);

56}

57});

58  }

59  private void iniActivityLauncher() {

60launcher =registerForActivityResult(


61  new ActivityResultContracts.StartActivityForResult(),

62  new ActivityResultCallbackActivityResult() {

63 @Override

64 public void onActivityResult(ActivityResult result) {

65Intent data = result.getData();

66//获得意图对象data

67int resultCode = result.getResultCode();

68//获得结果码resultCode

69if (resultCode == RESULT_OK) {

70 //判断结果码是否为RESULT_OK

71 Bundle b = data.getExtras();//获得Intent对象的Bundle

72 City city =(City) b.getSerializable(

73CommonValues.KEY_CITY);

74 //从Bundle对象中取得自定义的City数据

75 showSnackBar(city);//调用SnackBar显示city信息

76}

77  }

78});

79}

80@Override

81protected void onNewIntent(Intent intent) {

82  //解决跳转到MainActivity没有回调onCreate()方法的问题

83  super.onNewIntent(intent);

84  Log.d("onNewIntent",this.getLocalClassName()); //打印日志观察回调

85  //this.getLocalClassName()获取当前类的名称

86}

87private void showList(ListCity readOutList) {

88  //将列表数据生成适配器,显示到ListView对象上

89  adapter=new ArrayAdapter(this,

90 android.R.layout.simple_list_item_1,readOutList);

91  lv.setAdapter(adapter);

92}

93private void showToast(String e) {

94  Toast.makeText(this,e,Toast.LENGTH_LONG).show();

95}

96private void showSnackBar(City city) {

97  Snackbar.make(lv, "City=" + city.name, Snackbar.LENGTH_LONG)

98 .setAction("WeatherId", new View.OnClickListener() {

99@Override

100 public void onClick(View view) {

101showToast("Weather_id=" + city.weatherId);

102 }

103}).show();

104 //使用Snackbar显示内容,Snackbar至多可设置1个Action(按钮)

105}

106}






代码528常量定义类CommonValues.java



1public class CommonValues {

2public static final String KEY_URL="key_url";

3public static final String KEY_CITY="key_city";

4}





5. 实现第2个活动MainActivity2

MainActivity2的实现如代码529
所示,其主要功能是根据MainActivity传过来的第3级城市列表网址,获取数据并显示在ListView上,当用户单击ListView时,将单击数据回传给MainActivity。在updateWebData()方法中,MainActivity2通过getIntent().getExtras()方法获得MainActivity所传递的Bundle对象,并从Bundle中解析出携带的网址,调用WebApiUtils.getApiDataAsync()异步方法获取Web API数据用于更新ListView。在ListView的列表项单击事件处理中,获取对应城市数据,并通过putSerializable()方法将City数据放入Bundle对象。Intent对象可通过getIntent()方法取得,进而将所需回传的数据通过Bundle放入Intent对象上。回传数据可通过setResult()方法实现,该方法的第1个参数为结果码,对确定性操作,使用RESULT_OK,该常量由Android SDK提供; 第2个参数为携带数据的Intent对象,将Bundle数据回传至启动该Intent的调用者。MainActivity2调用setResult()方法后,则MainActivity中ActivityResultLauncher初始化时第2个参数的接口回调onActivityResult()方法得以响应,进而在响应中可通过Intent获得Bundle对象,从而获得Bundle携带的数据。MainActivity2回传结果后,可通过finish()方法销毁自身,此时任务栈中MainActivity处于栈顶,变为可见。

感兴趣的读者,可在本任务基础上,完善项目,设计3个Activity,使之分别显示全国各省份、地级市和区县的城市信息,形成完整的城市分级列表应用。


代码529活动页面2——MainActivity2.java



1import android.content.Intent;

2import android.os.Bundle;

3import android.view.View;

4import android.widget.AdapterView;

5import android.widget.ArrayAdapter;

6import android.widget.ListView;

7import android.widget.Toast;

8import androidx.appcompat.app.AppCompatActivity;

9import java.util.List;

10public class MainActivity2 extends AppCompatActivity {

11 ListView lv;

12 ArrayAdapterCity adapter;

13 @Override

14 protected void onCreate(Bundle savedInstanceState) {

15super.onCreate(savedInstanceState);

16setContentView(R.layout.my_main);

17lv=findViewById(R.id.listView);

18updateWebData();

19lv.setOnItemClickListener(new AdapterView.OnItemClickListener() {


20 @Override

21 public void onItemClick(AdapterView? adapterView, View view,

22  int i, long l) {

23 City item = adapter.getItem(i);







24 Intent intent = getIntent();//获取启动本Activity的Intent对象

25 Bundle b = new Bundle();

26 b.putSerializable(CommonValues.KEY_CITY,item);

27 //通过putSerializable()放置City数据

28 //City类需要implements Serializable实现序列化

29 //取数可通过Bundle对象的getSerializable()方法并强制类型转换获得

30 intent.putExtras(b);

31 setResult(RESULT_OK,intent);

32 //返回数据给MainActivity,RESULT_OK是结果码,表示一种状态

33 finish(); //结束MainActivity2

34}

35});

36}

37private void updateWebData() {

38//从Bundle中取出网址,访问资源,更新ListView

39Bundle bundle = getIntent().getExtras();

40String url = bundle.getString(CommonValues.KEY_URL);

41//获取上一个Activity传过来的url值

42WebApiUtils.getApiDataAsync(this, url,

43 new WebApiUtils.OnReadFinishedListener() {

44@Override

45public void onFinished(ListCity readOutList) {

46  showList(readOutList);

47}

48@Override

49public void onFail(String e) {

50  showToast(e);

51}

52});

53}

54//showList(), showToast()等方法同MainActivity.java

55…

56}





5.6使用RxHttp获取Web API数据
5.6.1任务说明
RxHttp是基于RxJava+Retrofit+OkHttp实现的轻量级,完美兼容MVVM架构的网络请求封装类库,小巧精致,简单易用。RxHttp支持GET、POST、PUT、DELETE等请求方式,支持文件上传下载及进度侦听,与RxJava结合,在实现相同的功能时,其代码量更少,非常受开发者青睐。RxHttp在Kotlin编程环境中使用较多,本着Java入门的初衷,在本任务中将介绍如何在Java开发环境下配置RxHttp,以及使用RxHttp实现Web API数据的获取。本任务需要实现的功能以及运行效果与5.4节的任务类似,在此不赘述。

5.6.2任务实现
1. Gradle配置

使用RxHttp时,在Module Gradle文件中需要做较多的配置,具体按以下步骤完成。

步骤1: 在android节点的defaultConfig标签内增加以下内容。



1javaCompileOptions {

2annotationProcessorOptions {

3  arguments = [

4//使用asXxx方法时必须,传入所依赖的RxJava版本

5rxhttp_rxjava: '3.1.4',

6rxhttp_okhttp: '4.9.1',

7rxhttp_package: 'rxhttp', //指定RxHttp类包名,可随意指定

8  ]

9}

10}





步骤2: 检查android节点是否有compileOptions配置,若没有,增加以下编译选项配置。



1compileOptions {

2  sourceCompatibility JavaVersion.VERSION_1_8

3  targetCompatibility JavaVersion.VERSION_1_8

4}





步骤3: 在dependencies节点中增加以下依赖库。



1//以下是必要的RxHttp组件

2implementation 'com.squareup.okhttp3:okhttp:4.9.1'

3implementation 'com.github.liujingxing.rxhttp:rxhttp:2.7.3'

4annotationProcessor 'com.github.liujingxing.rxhttp:rxhttp-compiler:2.7.3'

5

6//以下是rxlife管理所需的组件

7implementation 'com.github.liujingxing.rxlife:rxlife-coroutine:2.1.0'

8//rxlife管理协程生命周期,页面销毁,关闭请求

9

10//以下是需要使用as方法时所需的组件

11implementation 'io.reactivex.rxjava3:rxjava:3.1.4'

12implementation 'io.reactivex.rxjava3:rxandroid:3.0.0'

13implementation 'com.github.liujingxing.rxlife:rxlife-rxjava3:2.2.2'

14//rxlife-rxjava3管理RxJava3生命周期,页面销毁,关闭请求





在settings.gradle文件中增加maven{url"https://jitpack.io"}代码仓库,以便依赖库能优先从指定仓库下载对应文件。具体配置如下所示。



1pluginManagement {

2  repositories {

3 maven { url "https://jitpack.io" }

4 gradlePluginPortal()

5 google()

6 mavenCentral()

7  }

8}

9dependencyResolutionManagement {

10repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)

11repositories {

12maven { url "https://jitpack.io" }

13google()

14mavenCentral()

15}

16}





2. 上网权限配置

在AndroidManifest中增加上网权限如下所示。



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





若是Android 10.0及以上版本,还须在application标签内增加访问Http资源属性,如下所示。



android:usesCleartextTraffic="true"





3. 实现MainActivity

MainActivity的布局文件my_main.xml和解析数据类City.java可参考5.4节。MainActivity的实现如代码530
所示。在MainActivity中,从网页获取数据并解析数据的核心代码由getCityList()方法实现,与5.4节相比,使用RxHttp能用更少的代码实现相同的功能。

RxHttp采用链式写法,get()方法是使用GET获取网络数据,asClass()方法和asList()方法能将获取结果直接转换为对应类或者类列表,observeOn()方法指定观察者回调的线程。RxHttp会在获取数据时自行创建后台线程,若无observeOn()方法指定,默认使用RxHttp所在线程处理观察者回调,若RxHttp是在后台线程调用的,则需要observeOn()方法指定UI线程,否则RxHttp的观察者回调不能直接用于更新UI。观察者回调使用subscribe()方法,在解析数据完成后,产生该回调,将所解析的数据回传。subscribe()方法的使用方式比较类似JavaScript,若使用双参数,前者是解析返回的数据,后者是出错返回的错误对象,各参数均采用箭头函数匿名实现回传参数的处理。本任务中,subscribe()方法中的第1个箭头函数处理解析结果cities(City类的列表数据),第2个箭头函数处理出错信息,箭头前的变量为传递给箭头函数的传参。


代码530MainActivity.java



1import androidx.appcompat.app.AppCompatActivity;

2import android.os.Bundle;

3import android.view.View;

4import android.widget.ArrayAdapter;

5import android.widget.EditText;

6import android.widget.ListView;

7import android.widget.Toast;

8import java.util.List;

9import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;

10import rxhttp.RxHttp;

11public class MainActivity extends AppCompatActivity {

12 ListView lv;

13  @Override

14 protected void onCreate(Bundle savedInstanceState) {

15super.onCreate(savedInstanceState);

16setContentView(R.layout.my_main);

17lv=findViewById(R.id.listView);

18EditText et=findViewById(R.id.et_url);

19findViewById(R.id.bt_async)

20.setOnClickListener(new View.OnClickListener(){

21@Override

22public void onClick(View view) {







23  String url = et.getText().toString();

24  getCityList(url);

25}

26});

27 }

28 private void getCityList(String url) {

29RxHttp.get(url)

30  .asList(City.class)//直接将JSON数组转换成类列表

31  .observeOn(AndroidSchedulers.mainThread())//在主线程中调用观察者

32  .subscribe(cities - {//采用观察者订阅模式回调

33 showList(cities);

34  },e-{

35 showToast(e.toString());

36  });

37 }

38 private void showList(ListCity readOutList) {

39//将列表数据生成适配器,显示到ListView对象上

40ArrayAdapterCity adapter=new ArrayAdapter(this,

41  android.R.layout.simple_list_item_1,readOutList);

42lv.setAdapter(adapter);

43 }

44 private void showToast(String e) {

45Toast.makeText(this,e,Toast.LENGTH_LONG).show();

46 }

47}





RxHttp功能非常强大,这里仅仅是抛砖引玉,给出最基本的GET使用方法,读者可自行尝试下载文件以及POST操作等高级应用。

5.7使用Jsoup实现网页数据提取
5.7.1任务说明




图58解析清华大学出版社新书推
荐网页接口数据的演示效果


本任务使用Jsoup实现网页数据提取,应用的演示效果如图58
所示。在该应用中,活动页

面视图根节点为垂直的LinearLayout,布局中依次放置1个TextView,用于显示个人信息; 1个Button,用于开启后台线程获取网页数据; 1个ScrollView(滚动视图),视图中内嵌1个TextView,用于显示网页的解析数据。

本任务使用第三方库Jsoup解析清华大学出版社新书推荐网页接口(网址请扫描前言中的二维码获取)中的HTML数据,并提取出网页中的图书书名、图书作者、图书详情链接,以及图书图片链接等关键信息。如图58
所示,在ScrollView内嵌的TextView中,显示了Jsoup的解析结果,每条图书信息用分隔线隔开,并且所解析的链接支持单击跳转。当TextView中显示的内容很长,超出一屏幕的显示内容时,推荐将TextView嵌入ScrollView视图中,用户则可通过滑动屏幕阅览TextView的剩余内容。

本任务需要后台线程访问网页,提取网页关键信息,并在Activity的UI线程中更新数据。后台线程与前端UI的数据交互根据之前的任务可知,主流有3种方式: ①通过Handler传递; ②通过自定义接口和Activity对象的线程切换实现; ③通过LiveData和视图模型实现。本任务采用第3种方式实现,事实上选取上述任何一种方法均能实现相同功能。


5.7.2任务实现
1. Gradle和权限配置
在Module Gradle文件的dependencies节点中增加Jsoup依赖,如下所示。



implementation 'org.jsoup:jsoup:1.14.3'





在AndroidManifest的application标签之外增加上网权限,如下所示。



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





对于Android 10.0及以上系统,还须在AndroidManifest的application标签中增加访问Http使能属性,如下所示。



android:usesCleartextTraffic="true"





2. 视图模型与图书数据封装类

使用Jsoup访问网页对Android系统而言是一项耗时的操作,因此Jsoup解析网页数据等相关操作宜放在后台线程中操作,不能直接在UI线程中操作。考虑到后台线程与前端UI是通过视图模型的LiveData交互数据的,本任务还需自定义相关视图模型。

自定义视图模型MainViewModel如代码531
所示。在MainViewModel中,定义了两个MutableLiveData成员变量,其中变量bookList用于交互图书列表数据,变量errMessage用于交互错误信息。变量errMessage在成员变量声明时直接被初始化。变量bookList在构造方法中被初始化,初始化时,实际的数据载体ListBookItem对象依然是null,此时可通过setValue()方法设置ArrayList对象,为变量bookList赋值。


代码531自定义视图模型MainViewModel.java



1import androidx.lifecycle.MutableLiveData;

2import androidx.lifecycle.ViewModel;

3import java.util.ArrayList;

4import java.util.List;

5public class MainViewModel extends ViewModel {

6  private MutableLiveDataListBookItem bookList;

7  private MutableLiveDataString errMessage=new MutableLiveData();

8  //bookList是后台线程解析出的图书列表数据

9  //errMessage是后台线程工作过程中的出错信息

10  public MainViewModel() {

11 bookList =new MutableLiveData();

12 bookList.setValue(new ArrayList()); //为bookList设置初始化列表对象

13  }

14  public MutableLiveDataListBookItem getBookList() {







15 return bookList;

16  }

17  public MutableLiveDataString getErrMessage() {

18 return errMessage;

19  }

20}





BookItem是自定义的图书数据封装类,如代码532所示
,该类有4个字段: 图书书名title、图书作者author、图书详情链接href、图书图片链接imgSrc。为了方便BookItem类数据的字符串打印,在BookItem类中改写了toString()方法,使用String.format()方法将相关字段转换为所需字符串。BookItem实现了Serializable接口,使之能用于Bundle序列化传数。


代码532自定义图书数据BookItem.java



1import java.io.Serializable;

2public class BookItem implements Serializable{

3private String title;

4private String author;

5private String href;

6private String imgSrc;

7public BookItem(String title, String author, String href, String imgSrc) {

8this.title = title;

9this.author = author;

10 this.href = href;

11 this.imgSrc = imgSrc;

12}


13@Override

14public String toString() {//BookItem对象转字符串的方法,打印各属性值

15 return String.format("Title=%s\nAuthor=%s\nHref=%s\nImgSrc=%s\n",

16title,author,href,imgSrc);

17}

18public String getTitle() {

19 return title;

20}

21public void setTitle(String title) {

22 this.title = title;

23}

24public String getAuthor() {

25 return author;

26}

27public void setAuthor(String author) {

28 this.author = author;

29}

30public String getHref() {

31 return href;

32}

33public void setHref(String href) {

34 this.href = href;

35}

36public String getImgSrc() {

37 return imgSrc;

38}

39public void setImgSrc(String imgSrc) {

40 this.imgSrc = imgSrc;

41}

42}





3. 实现网页解析后台线程

网页解析后台线程BookItemGetThread如代码533
所示,通过构造方法传递视图模型对象ViewModel和待解析网址url。网页中解析的图书详情链接是相对链接,需要与网站前缀地址拼接成完整链接,因此定义了常量BASE_URL用于拼接相对链接网址。

BookItemGetThread后台线程的run()方法中,通过所传递的视图模型对象获得用于前端和后台交互的图书列表数据维持对象bookList,注意,bookList是LiveData数据对象,而对应的列表对象list则通过bookList的getValue()方法获取。本任务中,视图模型的拥有者设成MainActivity对象,即使线程消亡,只要MainActivity没有消亡,变量bookList依然存在,所维持的列表对象list不会消亡,因此在后台线程启动后,须对list调用clear()方法将之前保留的列表数据清除。变量errMessage为错误信息,当Jsoup解析过程中出错时,可将错误信息传给errMessage,当UI线程中对errMessage设置了观察侦听,则可通过对应的回调方法及时处理错误信息。

Jsoup访问网页可通过connect()方法接受对应网址,注意网址字符串必须包含“http://”或“https://”等协议。若要设置访问超时,可对Jsoup链式操作增加timeout()方法,参数是ms单位的超时时间,最后通过get()方法获得Document文档对象。Jsoup提取文档对象中的节点可通过select(String tag)方法实现,传参tag为节点的标签类型,返回值为Elements对象。Elements对象需要先定位后使用,常用的定位方式有: first()方法,取得首个元素; last()方法,取得最后一个元素; get(int index)方法,取得传参index指定索引位的元素。Elements对象定位方法返回的元素为Element,即节点对象。

为了更好地说明问题,现将待解析网页中关键HTML内容抽取出,如代码534
所示,并使用该内容用于分析Jsoup解析网页的相关方法。由代码534
可见,一条图书信息在class=" n_b_product"的dl节点中,可用Jsoup提供的select("dl[class*=product]")方法匹配dl节点进行提取,传参dl[class*=product]表示只提取具有class属性,且属性值包含了product字符串的dl节点。select()方法返回的是Elements对象,获得所需的dl节点合集之后,需要对其进行for循环展开,对每个dl节点进一步提取相关信息。本任务中,对dl节点合集使用for each展开,从Elements对象中取得Element元素,用于进一步的解析处理。

由代码534
所示的网页内容可知,图书详情链接在a节点的href属性中,可通过dl节点对象的select("a").first().attr("href")方法获得href属性值,并且与前缀网址BASE_URL拼接成完整的链接地址。此外,href属性值还可以通过attr("abs:href")直接获得绝对网址,从而避免了与前缀网址的拼接操作。图书信息字段imgSrc和title可以使用类似的方法提取。图书作者、图书价格和图书简介在p节点中,每个p节点构成一个字段数据,其中图书作者在首个p节点中,可通过select("p").first().text()方法提取,其中text()方法为节点对象去标签后的纯文本。从HTML中提取的关键信息用于构造BookItem对象,并加到列表list中。遍历结束,通过LiveData对象bookList的postValue()方法更新值,则Activity的UI线程中同一个LiveData对象可在Observer接口回调中取出更新后的值,用于更新ListView。在处理过程中,若遇到异常,则通过trycatch捕捉异常,将错误信息转成字符串,并通过LiveData对象errMessage更新值,从而在Activity中可对errMessage调用Observer接口处理异常。


代码533网页解析后台线程BookItemGetThread.java



1import androidx.lifecycle.MutableLiveData;

2import org.jsoup.Jsoup;

3import org.jsoup.nodes.Document;

4import org.jsoup.nodes.Element;

5import org.jsoup.select.Elements;

6import java.io.IOException;

7import java.util.List;

8public class BookItemGetThread extends Thread{

9private MainViewModel viewModel;

10private String url;

11public static final String BASE_URL

12="http://www.tup.tsinghua.edu.cn/booksCenter/";

13//BASE_URL为href提取链接地址的前缀地址,与href拼接成绝对网址才能被访问

14public BookItemGetThread(MainViewModel viewModel, String url) {

15this.viewModel = viewModel;

16this.url = url;

17}


18@Override

19public void run() {

20MutableLiveDataListBookItem bookList = viewModel.getBookList();

21ListBookItem list = bookList.getValue();

22list.clear(); //将列表对象list数据清空

23MutableLiveDataString errMessage = viewModel.getErrMessage();

24try {

25Document doc = Jsoup.connect(url).timeout(10000).get();

26Elements dls = doc.select("dl[class*=product]");

27//取dl标签,标签具有属性class="*product*"的才被匹配,*表示任意字符

28for (Element dl : dls) {

29String href0 = dl.select("a").first().attr("href");

30String href=BASE_URL+href0;//拼接成的href是绝对网址

31//href可以使用attr("abs:href")获取绝对网址

32String imgSrc = dl.select("img").first().attr("abs:src");

33//abs:src或者abs:href可以取得绝对网址

34String title = dl.select("span")

35.first().attr("title");

36//取得span标签中title的属性值

37String author = dl.select("p").first().text();

38//取首个段落,对应的是作者信息

39list.add(new BookItem(title,author,href,imgSrc));

40}

41bookList.postValue(list); //通过ViewModel传递数据

42} catch (IOException e) {

43e.printStackTrace();

44errMessage.postValue(e.toString());

45//将错误信息传递给观察者

46}

47}

48}






代码534待提取的HTML关键内容



1dl class="n_b_product"

2 dt

3a href="book_08766201.html" target="_blank"

4 img src="../upload/smallbookimg/087662-01.jpg" width="100" height="146" /







5/a

6/dt

7dd

8span title="Python从菜鸟到高手(第2版)"Python从菜鸟到高手(第2.../span

9p李宁/p

10p class="ft_purple"定价: 95元/p

11p本书从实战角度系统讲解了Python核心知识点以及Python在We.../p

12/dd

13/dl

14dl class="n_b_product"

15dt


16a href="book_09175301.html" target="_blank"

17img src="../upload/smallbookimg/091753-01.jpg" width="100" height="146" /

18/a

19/dt

20dd

21 span title="动手学推荐系统——基于PyTorch的算法实现(微课视频版)"动手学推荐
系统
——基于P.../span

22 p於方仁/p

23 p class="ft_purple"定价: 79元/p

24 p本书从理论结合实践编程来学习推荐系统。由浅入深,先基础.../p

25/dd

26/dl

27…





4. 实现MainActivity

MainActivity的布局文件my_main.xml如代码535
所示。在布局中,id为tv_result的TextView组件所显示的内容将超过屏幕高度,若期望能通过上下滑动屏幕显示剩余内容,应将tv_result嵌在ScrollView中。同时,tv_result显示的超链接期望能被系统自动识别为网址,当用户单击该链接时,会自动调用相应应用(浏览器)访问网址,该功能需要额外属性android:autoLink进行控制,属性值web用于识别网址链接、email用于识别邮箱地址、map用于识别地理位置、phone用于识别电话号码、all则识别所有类型,不同类型的数据会匹配相应的应用。


代码535MainActivity的布局文件my_main.xml



1?xml version="1.0" encoding="utf-8"?

2LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"

3 android:orientation="vertical"

4 android:layout_width="match_parent"

5 android:layout_height="match_parent"

6 TextView

7android:layout_width="match_parent"

8android:layout_height="wrap_content"

9android:text="Your name and ID" /

10 Button

11  android:id="@+id/button"

12  android:layout_width="match_parent"

13  android:layout_height="wrap_content"

14  android:text="Get data" /

15 ScrollView

16  android:layout_width="match_parent"

17  android:layout_height="match_parent"

18  LinearLayout







19android:layout_width="match_parent"

20android:layout_height="wrap_content"

21android:orientation="vertical" 

22TextView

23android:id="@+id/tv_result"


24android:layout_width="match_parent"

25android:layout_height="wrap_content"

26android:autoLink="web"

27android:text="TextView" /

28/LinearLayout

29 /ScrollView

30/LinearLayout





MainActivity的实现如代码536
所示。在MainActivity中,通过ViewModelProvider获得视图模型对象viewModel,并从中取得两个LiveData数据bookList和errMessage,分别对其设置观察侦听。当后台线程对LiveData数据通过postValue()方法更新后,MainActivity的UI线程中对应数据会通过Observer接口响应onChanged()回调,通过回调传参取得更新后的数据并进行处理。在LiveData数据bookList的onChanged()回调中,取得图书列表数据bookItems,并调用printBookList()方法将其转换为所需格式的字符串显示到文本对象tv上。错误信息使用Toast显示,通过LiveData数据errMessage的Observer接口实现。网页解析后台线程的生成和启动在Button单击事件回调中实现。MainActivity的成员变量url为获取图书信息的网址,网址中,参数pageIndex是页索引值,pageSize是返回一页信息的图书数目,两者均可被修改。在printBookList()方法中,通过遍历图书列表,将每个BookItem对象转为字符串,并在每条图书信息前后增加分隔线,该方法中涉及较多的字符串拼接操作,适合使用StringBuilder对象和append()方法进行字符串拼接,以避免产生过多的字符串内存碎片。


代码536MainActivity.java



1import androidx.appcompat.app.AppCompatActivity;

2import androidx.lifecycle.MutableLiveData;

3import androidx.lifecycle.Observer;

4import androidx.lifecycle.ViewModelProvider;

5import android.os.Bundle;

6import android.view.View;

7import android.widget.TextView;

8import android.widget.Toast;

9import java.util.List;

10public class MainActivity extends AppCompatActivity{

11 MainViewModel viewModel;

12 String url="http://www.tup.tsinghua.edu.cn/booksCenter/" +

13"new_book.ashx?pageIndex=0&pageSize=15&id=0&jcls=0";

14 //获取图书数据的网址,pageIndex为页面索引值,pageSize为一个页面的数据数目

15 //可根据需要修改pageIndex和pageSize的参数值

16 @Override

17 protected void onCreate(Bundle savedInstanceState){

18super.onCreate(savedInstanceState);

19setContentView(R.layout.my_main);

20TextView tv=findViewById(R.id.tv_result);

21viewModel= new ViewModelProvider(this).get(MainViewModel.class);







22MutableLiveDataListBookItem bookList = viewModel.getBookList();

23//通过视图模型数据观察者模式响应数据动态更新

24bookList.observe(this, new ObserverListBookItem(){

25  @Override

26  public void onChanged(ListBookItem bookItems){

27 String s = printBookList(bookItems);

28 tv.setText(s);

29  }

30});

31MutableLiveDataString errMessage = viewModel.getErrMessage();

32//响应后台线程通过视图模型发送的错误信息

33errMessage.observe(this, new ObserverString(){

34  @Override

35  public void onChanged(String s){

36 showToast(s);

37  }

38});

39findViewById(R.id.button).setOnClickListener(new View.OnClickListener(){

40  @Override

41  public void onClick(View view){

42 new BookItemGetThread(viewModel,url).start();

43 //生成并启动后台线程解析网页数据

44  }

45});

46 }

47 private void showToast(String s){

48Toast.makeText(this,s,Toast.LENGTH_LONG).show();

49 }

50 private String printBookList(ListBookItem bookItems){

51//将传参bookItems转换成字符串

52StringBuilder sb=new StringBuilder();

53for (int i = 0; i  bookItems.size(); i++) {

54  BookItem bookItem = bookItems.get(i);

55  sb.append("------------"+i+"----------------\n");

56  sb.append(bookItem.toString());

57  sb.append("---------------------------------\n");

58}

59return sb.toString();

60  }

61}





5.8使用Jsoup和Glide实现网页数据渲染
5.8.1任务说明
本任务在5.7节的基础上完成,效果如图59
所示。活动页面中,将5.7节my_main.xml布局文件中的ScrollView以及子视图更改为ListView。ListView每个行视图显示了图书书名、图书作者和图书封面图片。本节任务需要对图书数据类BookItem实现对应的自定义适配器,并使用第三方库Glide将图片网址的图片数据加载到适配器的ImageView组件中。



图59使用ListView显示图文并茂的图书信息


5.8.2任务实现
1. 实现自定义适配器
复制5.7节项目的所有文件,包括Gradle设置和AndroidManifest的上网相关设置。在Module Gradle的dependencies节点中增加Glide依赖库,如下所示。



implementation 'com.github.bumptech.glide:glide:4.13.2'





自定义适配器的行视图row_view.xml如代码537
所示。布局中,id为row_view_tv_title的TextView用于显示图书书名,该组件不仅设置了文本颜色和字体大小,还增加了额外属性android:ellipsize,用于控制文本内容过长时的处理方式,当属性值为end时,截断过长内容的尾部,并用三点省略号“…”取代剩余文本。TextView可使用行数控制属性android:maxLines设置文本框的最大行数。


代码537适配器行视图row_view.xml



1?xml version="1.0" encoding="utf-8"?

2LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"

3 xmlns:app="http://schemas.android.com/apk/res-auto"

4 android:layout_width="match_parent"

5 android:gravity="center"

6 android:padding="5dp"

7 android:layout_height="wrap_content"

8 LinearLayout

9 android:layout_width="wrap_content"

10  android:layout_height="wrap_content"

11  android:layout_weight="1"

12  android:orientation="vertical"

13  TextView

14 android:id="@+id/row_view_tv_title"

15 android:layout_width="match_parent"







16 android:layout_height="wrap_content"

17 android:textColor="#000"

18 android:textSize="16sp"

19 android:ellipsize="end"

20 android:maxLines="3"

21 android:text="TextView" /

22  TextView

23 android:id="@+id/row_view_tv_author"

24 android:layout_width="match_parent"

25 android:layout_height="wrap_content"


26 android:text="TextView" /

27/LinearLayout

28ImageView

29  android:id="@+id/row_view_iv"

30  android:layout_width="80dp"

31  android:layout_height="80dp"

32  app:srcCompat="@drawable/ic_launcher_background" /

33/LinearLayout





自定义图书适配器BookItemAdapter如代码538
所示。在适配器的getView()方法中,取出图书数据的图片链接赋给变量imgSrc,并使用Uri.parse()方法将其转换为标准Uri资源,提供给Glide对象加载网络数据。Glide的使用相对比较简单,可通过链式操作完成图片加载,其中,with()方法设置的是Context对象或者Activity对象; load()方法加载图片数据源,图片网址可通过Uri.parse()方法转换为标准Uri资源,也可以直接使用String类型的网址; placeholder()方法指定加载过程中替换的图片,该方法是可选项; into()方法指定加载图片赋予的ImageView对象。Glide会自动开启后台线程加载网络图片,无须用户干预,并且具有缓存功能,当加载同一个网址图片时,可利用缓存数据加速加载。


代码538自定义适配器BookItemAdapter.java



1import android.content.Context;

2import android.net.Uri;

3import android.view.LayoutInflater;

4import android.view.View;

5import android.view.ViewGroup;

6import android.widget.ArrayAdapter;

7import android.widget.ImageView;

8import android.widget.TextView;

9import androidx.annotation.NonNull;

10import androidx.annotation.Nullable;

11import com.bumptech.glide.Glide;

12import java.util.List;

13public class BookItemAdapter extends ArrayAdapterBookItem {

14 private Context context;

15 private ListBookItem list;

16 public BookItemAdapter(@NonNull Context context, ListBookItem list) {

17super(context, android.R.layout.simple_list_item_1,list);

18this.context = context;

19this.list = list;

20 }

21 @NonNull

22 @Override

23 public View getView(int position, @Nullable View convertView,

24@NonNull ViewGroup parent) {







25View v=convertView;

26if(v==null){

27  v= LayoutInflater.from(context)

28  .inflate(R.layout.row_view,parent, false);


29}

30BookItem bookItem = list.get(position);

31//取出行位置对应的对象,填充给行视图各UI

32TextView tv_title=v.findViewById(R.id.row_view_tv_title);

33TextView tv_author=v.findViewById(R.id.row_view_tv_author);

34ImageView iv=v.findViewById(R.id.row_view_iv);

35tv_title.setText(bookItem.getTitle());

36tv_author.setText(bookItem.getAuthor());

37String imgSrc = bookItem.getImgSrc();

38Glide.with(context).load(Uri.parse(imgSrc)).into(iv);

39//使用Glide加载图片网址,并把图片数据渲染到ImageView对象iv上

40return v;

41  }

42}





2. 实现MainActivity

MainActivity的布局文件my_main.xml如代码539
所示,相比5.7节的项目,活动页面布局中,将ScrollView及子视图改成了ListView。


代码539MainActivity的布局文件my_main.xml



1?xml version="1.0" encoding="utf-8"?

2LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"

3  android:orientation="vertical"

4  android:layout_width="match_parent"

5  android:layout_height="match_parent"

6  TextView

7android:layout_width="match_parent"

8android:layout_height="wrap_content"

9android:text="Your name and ID" /

10 Button

11  android:id="@+id/button"

12  android:layout_width="match_parent"

13  android:layout_height="wrap_content"

14  android:text="Get data" /

15 ListView

16  android:id="@+id/listView"

17  android:layout_width="match_parent"

18  android:layout_height="match_parent" /

19/LinearLayout





MainActivity的实现如代码540
所示。在onCreate()方法中,ListView设置了列表项单击侦听,在侦听回调中,取得图书数据item,并将item的href值转换为标准Uri资源,通过动作+数据的方式定义了意图对象intent,以Activity的方式启动该意图,使其调用默认浏览器访问对应网页,达到跳转到图书详情页面的目的。LiveData对象bookList通过observe()方法实现数据侦听,取得后台线程解析网页所获取的图书列表数据,生成对应适配器对象,用于更新ListView。


代码540MainActivity.java



1import androidx.appcompat.app.AppCompatActivity;

2import androidx.lifecycle.MutableLiveData;

3import androidx.lifecycle.Observer;

4import androidx.lifecycle.ViewModelProvider;

5import android.content.Intent;

6import android.net.Uri;

7import android.os.Bundle;

8import android.view.View;

9import android.widget.AdapterView;

10import android.widget.ListView;

11import android.widget.Toast;

12import java.util.List;

13public class MainActivity extends AppCompatActivity {

14 MainViewModel viewModel;

15 String url="http://www.tup.tsinghua.edu.cn/booksCenter/" +

16  "new_book.ashx?pageIndex=0&pageSize=15&id=0&jcls=0";

17 @Override

18 protected void onCreate(Bundle savedInstanceState) {

19super.onCreate(savedInstanceState);

20setContentView(R.layout.my_main);

21viewModel= new ViewModelProvider(this).get(MainViewModel.class);

22MutableLiveDataListBookItem bookList = viewModel.getBookList();

23ListView lv=findViewById(R.id.listView);

24lv.setOnItemClickListener(new AdapterView.OnItemClickListener() {

25  @Override

26  public void onItemClick(AdapterView? adapterView, View view,

27  int i, long l) {

28 BookItem item= (BookItem) adapterView.getItemAtPosition(i);

29 Intent intent = new Intent(Intent.ACTION_VIEW,

30  Uri.parse(item.getHref()));

31 startActivity(intent);//调用内置浏览器跳转到图书详情页面

32  }

33});

34//通过视图模型数据观察者模式响应数据动态更新

35bookList.observe(this, new ObserverListBookItem() {

36  @Override

37  public void onChanged(ListBookItem bookItems) {

38 BookItemAdapter adapter=new BookItemAdapter(MainActivity.this,

39bookItems);

40 lv.setAdapter(adapter);

41  }

42});

43MutableLiveDataString errMessage = viewModel.getErrMessage();

44//响应后台线程通过视图模型发送的错误信息

45errMessage.observe(this, new ObserverString() {

46  @Override

47  public void onChanged(String s) {

48 showToast(s);

49  }

50});


51findViewById(R.id.button).setOnClickListener(new View.OnClickListener() {

52  @Override

53  public void onClick(View view) {

54 new BookItemGetThread(viewModel,url).start();

55 //生成并启动后台线程解析图书数据







56  }

57});

58}

59private void showToast(String s) {

60Toast.makeText(this,s,Toast.LENGTH_LONG).show();

61}

62}





5.9使用SwipeRefreshLayout和WebView
5.9.1任务说明
本任务在5.8节的基础上完成,演示效果如图510
所示。本任务的应用由两个Activity构成,分别为MainActivity和BookItemActivity。MainActivity用于显示图书列表,BookItemActivity用于显示图书详情。



图510任务的演示效果


在MainActivity布局中,对ListView增加父视图SwipeRefreshLayout,使其具有下拉刷新功能,在下拉过程中,将指向下一页的图书列表网址给待启动的后台线程,图书列表加载完成后通过LiveData更新ListView,从而实现了图书列表换页的功能。MainActivity布局中还增加了SeekBar组件,采用离散进度样式,拖动SeekBar组件,可更改网址中的页索引参数值,实现网址换页。SeekBar和SwipeRefreshLayout联动,在SwipeRefreshLayout下拉刷新侦听接口中,同步更改SeekBar的当前进度值。ListView实现了列表项单击事件侦听,在侦听回调中,获得对应图书数据,传递并跳转至BookItemActivity。

BookItemActivity取得MainActivity所传递的图书数据,使用WebView组件显示图书详情链接对应的HTML数据。BookItemActivity的动作栏标题显示图书书名,并且有应用返回键,用户单击返回键可返回至MainActivity。


5.9.2任务实现
1. Gradle配置
本任务中,Jsoup和Glide的配置以及AndroidManifest上网权限等静态配置同5.8节。SwipeRefreshLayout在多数Android Studio版本中不存在,需要以第三方库的形式导入。在Module Gradle文件的dependencies节点中导入SwipeRefreshLayout,如下所示。



implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'





2. SwipeRefreshLayout使用要点

SwipeRefreshLayout作为Android的第三方库,使用方式较为简单。SwipeRefreshLayout通过setOnRefreshListener()方法设置下拉刷新侦听器,用于启动后台线程获取新数据。在刷新侦听接口中,通过onRefresh()回调方法启动后台线程,此时SwipeRefreshLayout以转圈的方式悬浮在屏幕上方,若没有通过setRefreshing(false)方法取消刷新状态,SwipeRefreshLayout则会一直以刷新转圈的状态悬浮在应用视图上。基于异步工作的原理,SwipeRefreshLayout的setRefreshing(false)方法建议在后台线程数据回传的回调方法中执行。

3. 实现MainActivity

MainActivity的布局文件my_main.xml如代码541
所示,在布局中,将ListView嵌入到SwipeRefreshLayout组件中,使用户下拉ListView时能触发SwipeRefreshLayout的刷新回调。在编辑XML时,UI面板中没有SwipeRefreshLayout组件,需要用户通过Gradle加载对应库后,才能在XML节点中通过手动输入组件名称添加SwipeRefreshLayout。SwipeRefreshLayout的宽度与ListView相同,可用match_parent属性值设置; 该组件的高度包围ListView,可用wrap_content属性值设置; 此外还需要添加android:id属性。布局文件中增加了SeekBar组件,用于显示和控制网址中的页索引参数pageIndex,SeekBar具有连续模式和离散模式,使用style属性控制,本任务中使用了离散模式,使SeekBar的拖动值只能是整数值。SeekBar的android:max属性用于控制拖动条的最大进度值,android:progress属性则控制拖动条的当前进度值。


代码541MainActivity的布局文件my_main.xml



1?xml version="1.0" encoding="utf-8"?

2LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"

3  android:layout_width="match_parent"

4  android:layout_height="match_parent"

5  android:orientation="vertical"

6  TextView

7android:layout_width="match_parent"

8android:layout_height="wrap_content"

9android:text="Your name and ID" /

10SeekBar

11  android:id="@+id/seekBar"

12  style="@style/Widget.AppCompat.SeekBar.Discrete"

13  android:layout_width="match_parent"

14  android:layout_height="wrap_content"

15  android:max="10"

16  android:progress="0" /







17!-- android:max设置SeekBar的最大进度值,android:progress则设置当前进度值 --

18androidx.swiperefreshlayout.widget.SwipeRefreshLayout

19  android:id="@+id/swipeRefreshLayout"

20  android:layout_width="match_parent"

21  android:layout_height="wrap_content"

22  ListView

23android:id="@+id/listView"


24android:layout_width="match_parent"

25android:layout_height="match_parent" /

26/androidx.swiperefreshlayout.widget.SwipeRefreshLayout

27/LinearLayout





MainActivity的实现如代码542
所示。在MainActivity中,常量KEY_DATA作为Bundle对象存取数据的key,通过静态方法getIntentBookItem()从Bundle对象中取得所传输的BookItem对象,实现不同Activity之间对Bundle对象字段的解耦。变量urlList用于存储图书列表数据的网址,并使用常量MAX_PAGE控制图书列表网址参数pageIndex的最大值,变量page_i则为当前网址的页索引,与变量urlList配合使用。自定义方法iniUrlList()用于初始化网址列表变量urlList,在for循环中更改网址参数pageIndex的值,将生成的网址添加到变量urlList中。

SeekBar对象通过setMax()方法设置进度最大值,并且最大值由常量MAX_PAGE控制。SeekBar对象通过setProgress()方法设置SeekBar的当前进度值,该值由变量page_i控制。SeekBar对象通过setOnSeekBarChangeListener()方法设置进度值改变侦听,在侦听接口中共有3个回调方法,其中,onProgressChanged()方法在进度值发生改变时触发; onStartTrackingTouch()方法在用户刚开始拖拽拖动条时触发; onStopTrackingTouch()方法在用户释放拖动条时触发。显然,更新页面索引,并启动后台线程加载新的图书列表应该在onStopTrackingTouch()方法中处理,若在onProgressChanged()方法中处理,每改变一次进度值,将启动一次后台线程,若用户连续拖拽拖动条,将产生多次没有必要的后台线程调用。在onStopTrackingTouch()方法中,对SeekBar对象调用getProgress()方法取得拖动条当前值,并赋给变量page_i,再调用updateWebPage()方法,从网址列表urlList中取得page_i索引的网址,进而启动后台线程获取图书列表数据。

SwipeRefreshLayout的下拉刷新侦听可通过setOnRefreshListener()方法进行匿名实现,在刷新回调onRefresh()方法中,页面索引值page_i自增,并判断是否越界,若越界则将page_i重置为0,最后通过调用updateWebPage()方法启动线程,实现图书列表数据的异步加载。

MainActivity没有使用按钮触发后台线程获取图书列表,而是直接在onCreate()方法中调用updateWebPage()方法获取图书数据,此时page_i默认为0,从而实现应用启动时,直接获取首页的图书列表数据。对ListView每做一次下拉刷新,变量page_i会自增1次,并获取page_i指向的图书数据用于更新ListView,此时SwipeRefreshLayout会一直处于刷新状态,须在后台数据回调时结束刷新状态,因此,在变量bookList和errMessage的观察回调中,须将SwipeRefreshLayout的刷新状态设为false,使刷新状态消失。

ListView设置了列表项单击侦听,在onItemClick()回调方法中,取得被单击的BookItem数据,将之封装在Bundle对象中,用于Intent数据传输。Bundle对象没有直接设置自定义类的存取数方法,可以使用putSerializable()方法来存数,使用getSerializable()方法来取数。这两个方法的使用前提是自定义类需要实现序列化(Serializable)接口,并且getSerializable()方法返回的是Serializable对象,需要强制转换成用户指定的类对象。MainActivity跳转至BookItemActivity通过Intent实现,并在Intent中通过Bundle携带了所需要传输的BookItem数据。MainActivity实现了getIntentBookItem()方法,可从传参Intent对象中取得BookItem数据,从而使BookItemActivity可以直接调用getIntentBookItem()方法取得Activity之间所传递的数据。


代码542MainActivity.java



1import androidx.appcompat.app.AppCompatActivity;

2import androidx.lifecycle.MutableLiveData;

3import androidx.lifecycle.Observer;

4import androidx.lifecycle.ViewModelProvider;

5import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;

6import android.content.Intent;

7import android.os.Bundle;

8import android.view.View;

9import android.widget.AdapterView;

10import android.widget.ListView;

11import android.widget.SeekBar;

12import android.widget.Toast;

13import java.util.ArrayList;

14import java.util.List;

15public class MainActivity extends AppCompatActivity {

16 public static final String KEY_DATA="key_data";

17 MainViewModel viewModel;

18 ArrayListString urlList;

19 int MAX_PAGE=20;//控制网址参数pageIndex的最大值

20 int page_i=0;

21 SwipeRefreshLayout refreshLayout;

22 @Override

23 protected void onCreate(Bundle savedInstanceState) {

24super.onCreate(savedInstanceState);

25setContentView(R.layout.my_main);

26viewModel= new ViewModelProvider(this).get(MainViewModel.class);

27MutableLiveDataListBookItem bookList = viewModel.getBookList();

28ListView lv=findViewById(R.id.listView);

29iniUrlList();

30SeekBar seekBar=findViewById(R.id.seekBar);

31seekBar.setMax(MAX_PAGE); //设置SeekBar对象进度最大值

32seekBar.setProgress(page_i); //设置SeekBar对象当前进度值

33seekBar.setOnSeekBarChangeListener(

34  new SeekBar.OnSeekBarChangeListener() {

35  @Override

36  public void onProgressChanged(SeekBar seekBar, int progress,

37  boolean fromUser) {

38  

39  }

40  @Override

41  public void onStartTrackingTouch(SeekBar seekBar) {

42 

43  }

44  @Override

45  public void onStopTrackingTouch(SeekBar seekBar) {







46  //用户释放拖动SeekBar后,再加载数据

47  page_i= seekBar.getProgress();

48  //从SeekBar对象取得进度值,用于更新page_i

49  updateWebPage();

50  }

51});

52refreshLayout=findViewById(R.id.swipeRefreshLayout);

53refreshLayout.setOnRefreshListener(

54  new SwipeRefreshLayout.OnRefreshListener() {

55 @Override

56 public void onRefresh() {

57page_i++;

58if(page_iMAX_PAGE){//防止页面索引值越界

59page_i=0;

60}

61seekBar.setProgress(page_i);

62updateWebPage();//根据page_i值取相应网址更新图书数据

63 }

64  }

65 );

66updateWebPage(); //更新page_i指向的页面

67lv.setOnItemClickListener(new AdapterView.OnItemClickListener() {

68 @Override

69 public void onItemClick(AdapterView? adapterView, View view,

70 int i, long l) {

71BookItem item= (BookItem) adapterView.getItemAtPosition(i);

72Intent intent = new Intent(MainActivity.this,

73  BookItemActivity.class);

74Bundle b = new Bundle();

75b.putSerializable(KEY_DATA,item);

76//BookItem类需要实现Serializable接口

77intent.putExtras(b); //对Intent对象添加需要传递的Bundle数据

78startActivity(intent); //跳转到BookItemActivity

79}

80});

81//通过视图模型数据观察者模式响应数据动态更新

82bookList.observe(this, new ObserverListBookItem() {

83  @Override

84  public void onChanged(ListBookItem bookItems) {

85refreshLayout.setRefreshing(false);//取消刷新状态

86BookItemAdapter adapter=new BookItemAdapter(MainActivity.this,

87   bookItems);

88lv.setAdapter(adapter);

89  }

90});

91MutableLiveDataString errMessage = viewModel.getErrMessage();

92//响应后台线程通过视图模型发送的错误信息

93errMessage.observe(this, new ObserverString() {

94  @Override

95  public void onChanged(String s) {


96refreshLayout.setRefreshing(false); //取消刷新状态

97showToast(s);

98  }

99});

100}

101private void updateWebPage() {

102 refreshLayout.setRefreshing(true);







103 //设置SwipeRefreshLayout组件处于刷新状态

104 String url = urlList.get(page_i);

105 new BookItemGetThread(viewModel,url).start();

106 //生成并启动后台线程解析图书数据

107}

108private void iniUrlList() {//生成图书信息网址列表

109 urlList=new ArrayList();

110 String url_p="http://www.tup.tsinghua.edu.cn/booksCenter/" +

111  "new_book.ashx?pageIndex=%d&pageSize=15&id=0&jcls=0";

112 //url_p为用于打印网址的临时变量,pageIndex值由%d控制

113 for (int i = 0; i =MAX_PAGE ; i++) {

114String url = String.format(url_p, i);

115urlList.add(url);

116 }

117}

118private void showToast(String s) {

119 Toast.makeText(this,s,Toast.LENGTH_LONG).show();

120}

121public static BookItem getIntentBookItem(Intent intent){

122 //传参为Intent对象,从Intent对象中获得Bundle对象,进而获得BookItem数据

123 //通过静态方法解耦Bundle数据的key

124 Bundle b = intent.getExtras();

125 return (BookItem) b.getSerializable(KEY_DATA);

126}

127}





4. 实现BookItemActivity

BookItemActivity的布局文件activity_book_item.xml如代码543
所示,在垂直的LinearLayout中嵌入WebView组件,用于显示图书详情的网页内容。


代码543BookItemActivity的布局文件activity_book_item.xml



1?xml version="1.0" encoding="utf-8"?

2LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"

3  android:layout_width="match_parent"

4  android:layout_height="match_parent"

5  android:orientation="vertical"

6  WebView

7android:id="@+id/webView"

8android:layout_width="match_parent"

9android:layout_height="match_parent" /

10/LinearLayout





BookItemActivity的实现如代码544
所示。WebView通过getSettings()方法得到设置对象,并通过该对象使能JavaScript和缩放功能。WebView默认没有开启JavaScript功能,若不通过setJavaScriptEnabled()方法开启使能,WebView将无法运行加载网页中的JavaScript代码,使部分功能无法正常工作。此外,WebView显示的HTML页面,由于样式原因,会造成页面中文字过大,无法显示完整页面的情况,此时需要通过setBuiltInZoomControls()方法使之支持缩放模式,以及setUseWideViewPort()方法设置WebView处于WideViewPort模式,使页面匹配WebView的尺寸。BookItemActivity的动作栏标题可通过getSupportActionBar()方法得到ActionBar对象,进而通过setTitle()方法设置标题。对于有些Web网站,会检测用户的浏览器类型,若是移动端的,访问网站时则会发生重定向,重新加载专门针对移动端的网址。WebView发生重定向会调用系统的浏览器加载重定向网址,此时BookItemActivity和系统浏览器是独立的两个Activity,若要强制WebView加载重定向网址,则需要对WebView对象的setWebViewClient()方法以及改写的shouldOverrideUrlLoading()方法处理重定向问题。


代码544BookItemActivity.java



1import androidx.appcompat.app.ActionBar;

2import androidx.appcompat.app.AppCompatActivity;

3import android.os.Bundle;

4import android.webkit.WebSettings;

5import android.webkit.WebView;

6import android.webkit.WebViewClient;

7public class BookItemActivity extends AppCompatActivity {

8@Override

9protected void onCreate(Bundle savedInstanceState) {

10super.onCreate(savedInstanceState);

11setContentView(R.layout.activity_book_item);

12WebView webView=findViewById(R.id.webView);

13WebSettings settings = webView.getSettings();

14//得到WebView的设置对象

15settings.setJavaScriptEnabled(true);//对WebView使能JavaScript功能

16settings.setBuiltInZoomControls(true); //设置支持内置的缩放模式

17settings.setUseWideViewPort(true);

18//设置WideViewPort模式,使得网页匹配WebView宽度

19BookItem bookItem = MainActivity.getIntentBookItem(getIntent());

20String url = bookItem.getHref();

21String title = bookItem.getTitle();

22ActionBar actionBar = getSupportActionBar();

23actionBar.setTitle(title); //设置Activity的标题

24webView.loadUrl(url);

25//当页面发生重定向,默认调用系统浏览器加载url,不会在WebView视图中加载网页

26webView.setWebViewClient(new WebViewClient(){

27//使用布局中的WebView组件加载重定向网页

28@Override

29public boolean shouldOverrideUrlLoading(WebView view, String url) {

30  view.loadUrl(url);


31  return super.shouldOverrideUrlLoading(view, url);

32}

33});

34}

35}





5. 设置声明文件

本节的项目中具有两个Activity,若不对项目的声明文件AndroidManifest.xml进行配置,MainActivity无法启动BookItemActivity。项目声明文件如代码545
所示,代码中仅列出需要修改的内容。项目中每一个活动页面均需要activity标签进行声明,其中BookItemActivity的父活动页面是MainActivity,通过android:parentActivityName属性进行设置,只有设置了该属性后,BookItemActivity的动作栏上才会出现返回键,用户可通过单击返回键跳转至MainActivity。BookItemActivity没有设置启动模式,默认是standard模式,每启动一次BookItemActivity均会创建新的活动页面。与之相反的是,MainActivity通过android:launchMode属性设置为singleTask模式,使之在任务栈中唯一,BookItemActivity通过返回键跳转至MainActivity时,MainActivity不会被重建。


代码545项目的声明文件AndroidManifest.xml



1application

2…

3  android:usesCleartextTraffic="true"

4  activity

5android:name=".BookItemActivity"

6android:parentActivityName=".MainActivity"

7android:exported="false" /

8  activity

9android:name=".MainActivity"

10  android:launchMode="singleTask"

11  android:exported="true"

12  intent-filter

13action android:name="android.intent.action.MAIN" /

14category android:name="android.intent.category.LAUNCHER" /

15  /intent-filter

16/activity

17/application





5.10本章综合作业

编写一个新闻App,具有2个Activity,分别用于显示新闻列表和新闻详情页面。新闻源可用Jsoup解析HTML,或者使用Web API获取JSON新闻数据。

(1) 新闻列表Activity的页面布局中有ListView或者RecyclerView,可显示新闻的图片、新闻标题和新闻发布时间等信息,并支持下拉刷新换页。新闻列表响应列表项单击事件,在回调方法中获得对应新闻的链接,并启动新闻详情页面Activity,显示单击项的新闻详情。

(2) 新闻详情页面Activity,可采用WebView组件或者自定义布局实现,显示新闻的标题、发布时间、分段新闻详情内容和新闻图片。单击新闻详情页面动作栏的返回键,可返回新闻列表Activity。

读者可根据兴趣,自由发挥,完善新闻App的界面设计和功能开发。