Android基础知识-2

Service

服务(Service)是Android中实现程序后台运行的解决方案,它非常适合执行那些不需要和用户交互而且还要求长期运行的任务。服务的运行不依赖于任何用户界面,即使程序被切换到后台,或者用户打开了另外一个应用程序,服务仍然能够保持正常运行。

Android多线程

所有的代码都是默认运行咋主线程当中,我们需要在服务的内部手动创建子线程,并在这里执行具体的任务,否则就有可能出现主线程被阻塞住的情况。
当我们需要执行一些耗时操作,比如发起一条网络请求时,考虑到网速等其它原因,服务器未必会立刻响应我们的请求,如果不将这类操作放在子线程里去运行,就会导致主线程被阻塞住,从而影响用户对软件的正常使用。

线程的基本用法

  1. 继承自Thread
    定义一个线程:新建一个类继承自Thread,然后重写父类的 run() 方法,并在里面编写耗时逻辑。
    最后 new 出 MyThread的实例,然后调用它的 start() 方法,这样 run() 方法中的代码就会在子线程当中运行了。
class MyThread extends Thread{
   @Override
   public void run() {
       // 具体处理逻辑
   }
}

new MyThread().start();
  1. 实现Runnable接口
    使用继承的方式耦合性有点高,更多的时候我们都会选择使用实现 Runnable接口的方式来定义一个线程。
    Thread构造函数接收一个Runnable参数,而我们 new 出的MyThread正是一个实现了Runnable接口的对象,所以可以直接将它传入到Thread的构造函数里。接着调用Thread的 start() 方法中的代码就会在子线程当中运行了。
class MyThread implements Runnable{
   @Override
   public void run() {
       // 具体处理逻辑
   }
}

MyThread myThread = new MyThread();
new Thread(myThread).start();
  1. 匿名类
    匿名类本质和实现Runnable接口一样,只不过表达方式略有区别,这种写法更为常见。
new Thread(new Runnable() {
   @Override
   public void run(){
           // 具体处理逻辑
       }        
}).start();

在子线程更新UI

和许多其他GUI库一样,Android的UI也是线程不安全的。也就是说,如果想要更新应用程序里的UI元素,则必须在主线程中进行,否则就会异常。

activity_main.xml :

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

   <Button
       android:id="@+id/change_text"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:text="Change Text"/>
   <TextView
       android:id="@+id/text"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_centerInParent="true"
       android:text="hello world"
       android:textSize="20sp"/>
</RelativeLayout>

MainActivity.java :

public class FirstActivity extends AppCompatActivity implements View.OnClickListener {
   private TextView text;

   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       setContentView(R.layout.first_layout); // 给当前的活动加载一个布局
       text = (TextView) findViewById(R.id.text);
       Button changeText = (Button) findViewById(R.id.change_text);
       changeText.setOnClickListener(this);
   }

   @Override
   public void onClick(View view) {
       switch (view.getId()){
           case R.id.change_text:
               new Thread(new Runnable() {
                   @Override
                   public void run() {
                       text.setText("Nice to meet you");
                   }
               }).start();
               break;
           default:
               break;
       }
   }
} 

基本逻辑:点击Button来改变TextView内的内容。
我们在 Change Text按钮的点击事件里面开启了一个子线程(new Thread),然后在子线程中调用TextView的 setText() 方法将显示的字符串改成“Nice to meet you”。
但是在运行时会出现崩溃:

android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.

可以看出是由于子线程中更新UI所导致的。由此证实了,Android确实是不允许在子线程中进行UI操作的。但是有些时候,我们必须在子线程去执行一些耗时的任务,然后根据任务的执行结果来更新相应的UI控件。
对于这种情况,Android提供了一套异步消息处理机制。可将代码改为:

public class FirstActivity extends AppCompatActivity implements View.OnClickListener {
   public static final int UPDATE_TEXT = 1;
   private TextView text;
   private Handler handler = new Handler(){
       public void handleMessage(Message msg){
           switch (msg.what){
               case UPDATE_TEXT:
                   // 这里可以进行UI 操作
                   text.setText("Nice to see you");
                   break;
               default:
                   break;
           }
       }
   };

   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       setContentView(R.layout.first_layout); // 给当前的活动加载一个布局
       text = (TextView) findViewById(R.id.text);
       Button changeText = (Button) findViewById(R.id.change_text);
       changeText.setOnClickListener(this);
   }

   @Override
   public void onClick(View view) {
       switch (view.getId()){
           case R.id.change_text:
               new Thread(new Runnable() {
                   @Override
                   public void run() {
                       Message message = new Message();
                       message.what = UPDATE_TEXT;
                       handler.sendMessage(message); // 将Message对象发送出去
                   }
               }).start();
               break;
           default:
               break;
       }
   }
}

这里先定义了一个整型常量 UPDATE_TEXT ,用于表示更新TextView这个动作。然后新增一个Handler对象,并重写父类的 handleMessage() 方法,在这里对具体的Message进行处理,如果发现Message的 what 字段的值等于 UPDATE_TEXT ,就将 TextView显示的内容改成 “Nice to see you”。
下面看一下 Change Text按钮的点击事件中的代码。可以看到,这次没有在子线程里直接进行UI操作,而是创建了一个 Message(android.os.Message) 对象,并将它的what字段的值指定为 UPDATE_TEXT ,然后调用 Handler的sendMessage() 方法将这条Message发送出去。很快,Handler收到了这条Message,并在 handleMessage() 方法中对它进行处理。**注意,此时 handleMessage() 方法中的代码就是在主线程当中运行了,所以我们可以放心地在这里进行UI操作,接下来对 Message 携带的 what 字段的值进行判断,如果等于 UPDATE_TEXT ,就将TextView 显示的内容改成 “Nice to see you”。

解析异步消息处理机制

Android中的异步消息处理主要由4个部分组成:Message,Handler,MessageQueue和Looper

  • Message
    Message是在线程之间传递的消息,它可以在内部携带少量的信息,用于在不同线程之间传递数据。例如Message的 what 字段,除此之外,还可以使用 arg1 和 arg2 字段来携带一些整型数据,使用 obj 字段携带一个Object对象。
  • Handler
    Handler就是处理者的意思,它主要是用于发送和处理消息的。发送消息一般是使用Handler的 sendMessage() 方法、 post() 方法等,而发出的消息经过一系列地辗转处理后,最终会传递到 handleMessage() 方法中。
  • MessageQueue
    MessageQueue是消息队列的意思,主要用于存放所有通过Handler发送的消息。这部分消息会一直存在于消息队列中,等待被处理。每个线程中只会有一个MessageQueue对象。
  • Looper
    Looper是每个线程中MessageQueue的管家,调用Looper的 loop() 方法后,就会进入一个无线循环当中,然后每当发现MessageQueue中存在一条消息时,就会将它取出,并传递到Handler的 handleMessage() 方法中。每个线程只会有一个Looper对象。
    一条Message经过以上流程的辗转调用后,也就从子线程进入主线程了,从不能更新UI变成了可以更新UI,整个异步消息处理的核心思想就是如此。

使用AsyncTask

为了方便在子线程中对UI进行操作,Android还提供了另外一些好用的工具,例如 AsyncTask。借助AsyncTask,即使对异步消息处理机制完全不了解,也可以十分简单地从子线程切换到主线程。AsyncTask背后的实现原理也是基于异步消息处理机制的,只是Android帮我们做了很好的封装。

Service 的基本用法

定义一个Service

class MyService : Service() {

   override fun onBind(intent: Intent): IBinder {
       TODO("Return the communication channel to the service.")
   }

   // 创建Service的时候调用
   override fun onCreate() {
       super.onCreate()
   }

   // 每次启动Service的时候调用
   override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
       return super.onStartCommand(intent, flags, startId)
   }

   // Service销毁的时候调用
   override fun onDestroy() {
       super.onDestroy()
   }
}

可以看到,这里我们又重写了 onCreate() 、 onStartCommand() 和 onDestroy() 这3个方法,它们是每个Service中最常用到的3个方法了。其中 onCreate() 方法会在Service创建的时候调用, onStartCommand() 方法会在每次Service启动的时候调用, onDestroy() 方法会在Service销毁的时候调用。

启动和停止Service

启动和停止Service的方法和启动Activity类似,主要是借助Intent来实现的。
在布局文件中加入了两个按钮,分别于用于启动和停止Service。
activity_main.xml

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools"
   android:orientation="vertical"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <Button
       android:id="@+id/startServiceBtn"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:text="Start Service"/>

   <Button
       android:id="@+id/stopServiceBtn"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:text="Stop Service"/>
</LinearLayout>

然后修改 MainActiviy.kt

class MainActivity : AppCompatActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)
       startServiceBtn.setOnClickListener{
           val intent = Intent(this, MyService::class.java) // 写法相当于 MyService.class
           startService(intent) // 启动Service
       }
       stopServiceBtn.setOnClickListener{
           val intent = Intent(this, MyService::class.java)
           stopService(intent) // 停止Service
       }
   }
}

在 “Start Service”按钮的点击事件里,构建了一个Intent对象,并调用 startService() 方法来启动MyService。“Stop Service”同理。

在 MyService.kt 中的三个fun加入 Log.d() 可以在Logcat中看到打印日志。

以上为Service的基本用法,但是从Android 8.0开始,应用后台功能被大幅削减,只有当应用保持在前台可见的状态下,Service才能保证稳定运行,一旦进入后台,Service随时都有可能被系统回收。这样做是为了防止许多恶意的应用程序长期在后台占用手机资源。

onCreate() 和 onStartCommand() 区别:
onCreate() 是在第一次Service创建的时候调用,而 onStartCommand() 在每次启动时都会调用。

ContentProvider

ContentProvider主要用于在不同的应用程序之间实现数据共享的功能,它提供了一套完整的机制,允许一个程序访问另一个程序中的数据,同时还能保证被访问数据的安全性。目前,使用ContentProvider是Android实现跨程序共享数据的标准方式。
不同于文件存储和 SharedPreferencese 存储中的两种全局可读写操作模式,ContentProvider可以选择只对哪一部分数据进行共享,从而保证我们程序中的隐私数据不会有泄漏的风险。

运行时权限

Android6.0 之后加入了运动时权限功能,也就是说,用户不需要在安装时一次性授权所有申请的权限,而是可以在软件的使用过程中再对某一项权限进行申请。
所以Android现在将常用权限大致归成两类:一类是普通权限,一类是危险权限。
普通权限指的是不会直接威胁到用户安全和隐私的权限。危险权限则反之。
在AndroidManifest.xml中加入:

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

MainActivity.kt

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        makeCall.setOnClickListener{
            // 先判断是否授过权
            if(ContextCompat.checkSelfPermission(this,
                            Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED){
                ActivityCompat.requestPermissions(this,
                arrayOf(Manifest.permission.CALL_PHONE),1) // 申请授权
            }else{
                call()
            }
        }
    }

    // 根据用户反馈结果做出相应反馈
    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        when(requestCode){
            1 -> {
                if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED){
                    call()
                }else{
                    Toast.makeText(this,"You denied the permission", Toast.LENGTH_SHORT).show()
                }
            }
        }
    }

    private fun call(){
        try {
            val intent = Intent(Intent.ACTION_CALL)
            intent.data = Uri.parse("tel:10086")
            startActivity(intent)
        }catch (e: SecurityException){
            e.printStackTrace()
        }
    }
}

fun call()
在按钮的点击事件中,我们构建了一个隐式Intent,Intent的action指定为 Intent.ACTION_CALL 这是一个系统内置的打电话的动作,然后在data部分指定了协议是 tel ,号码是10086。action中还有一个类似的 Intent.ACTION_DIAL ,表示打开拨号界面,这个是不需要声明权限的,而 Intent.ACTION_CALL 则表示直接拨打电话,因此必须声明权限,为了防止程序崩溃,将所有操作都放在了异常捕获代码当中。

本质上,运行时权限的核心就是在程序过程中由用户授权我们去执行某些危险操作,程序时不可以擅自做主去执行这些危险操作。

第一步,判断用户是否已经给过授权,借助 ContextCompat.checkSelfPermission() 方法 checkSelfPermission() 方法接收两个参数:第一个参数时Context,第二个参数时具体的权限名,例如打电话就是 Manifest.permission.CALL_PHONE 。然后使用方法的返回值和 PackageManager.PERMISSION_GRANTED 作比较,相等说明已经授权,不等就表示用户没有授权。如果已经授权,直接进入 else{} 的 call() 方法。如果没有授权,需要调用 ActivityCompat.requestPermissions() 方法向用户申请授权。它需要3个参数,第一个是Activity的实例,第二个是String数组,我们把要申请的权限名放在数组中即可,第三个是请求码,只要是唯一值就可以了,需要大于0,这里传入了1,需要和后面 fun onRequestPermissionsResult 的 requestCode 相对应。

调用完 requestPermissions() 方法后,系统会弹出一个权限申请的对话框,用户可以选择同一或者拒绝,无论哪一种结果,最终都会回调到 onRequestPermissionsResult() 方法,而授权结果会封装在 grantResults 参数当中。这里我们只判断一下最后的结果,如果用户同意:就调用 call() 如果拒绝,则弹出一条 Toast 。
如果拒绝了,再次点击Button,仍会弹出权限申请对话框。

访问其他程序中的数据

ContentProvider的用法一般有两种:一种是使用现有的ContentProvider读取和操作相应程序中的数据;另一种是创建自己的ContentProvider,给程序的数据提供外部访问接口。
如果一个应用程序通过ContentProvider对其数据提供了外部访问接口,那么任何其他的应用程序都可以对这部分数据进行访问。Android系统中自带的通讯录、短信、媒体库等程序都提供了类似的访问接口

ContentResolver的基本用法

如果想要访问ContentProvider中共享的数据,就一定要借助ContentResolver类,可以通过Context 中的 getContentResolver() 方法获取该类的实例。ContentResolver中提供了一系列的方法用于对数据进行增删改查操作,其中 insert() 方法用于添加数据, update() 方法用于更新数据, delete() 方法用于删除数据, query() 方法用于查询数据。

ContentResolver中的增删改查方法都是不接收表名参数的,而是使用一个Uri参数代替,这个参数被称为内容URI。内容URI给ContentProvider中的数据建立了唯一标识符,它主要由两部分组成:authority和path。
authority是用于对不同应用程序做区分的,一般为了避免冲突,会采用应用包名的方式进行命名。比如某个应用的包名是 com.example.app ,那么该应用对应的authority就可以为 com.example.app.provider 。
path是用于对同一应用程序中不同的表做区分的,通常会添加到authority的后面。比如某个应用的数据库里存在两张表table1和table2,这时就可以将path分别命名为 /table1和 /table2,然后把authority和path进行组合,内容URI就变成了 com.example.app.provider/table1 和 com.example.app.provider/table2 不过为了辨认两个字符串就是两个内容的URI,我们还需要在字符串的头部加上协议声明。

content://com.example.app.provider/table1
content://com.example.app.provider/table2

我们还需要将它解析成Uri对象才可以作为参数传入。解析的方法也很简单:

val uri = Uri.parse("content://com.example.app.provider/table1")

现在我们可以使用Uri对象查询table1表中的数据了:

val cursor = contentResolver.query(
       uri, 
       projection,
       selection,
       selectionArgs,
       sortOrder)
query()方法参数 对应SQL部分 描述
uri from table_name 指定查询某个应用程序下的某一张表
projection select column1, column2 指定查询的列名
selction where column = value 指定where的约束条件
selectionArgs - 为 where中的占位符提供具体的值
sortOrder order by column1, column2 指定查询结果的排序方式

查询完成之后返回的仍然是一个Cursor对象,这时我们就可以将数据从Cursor对象中逐个读取出来了。读取思路仍然是通过移动游标的位置遍历Cursor的所有行,然后取出每一行中相应列的数据。

while(cursor.moveToNext()){
   val column1 = cursor.getString(cursor.getColumnIndex("column1"))
   val column2 = cursor.getInt(cursor.getColumnIndex("column2"))
}
cursor.close()

读取系统联系人

class MainActivity : AppCompatActivity() {
   private val contactsList = ArrayList<String>()
   private lateinit var adapter: ArrayAdapter<String>

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)
       adapter = ArrayAdapter(this,android.R.layout.simple_list_item_1,contactsList)
       contactsView.adapter = adapter
       if(ContextCompat.checkSelfPermission(this,
                       Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED){
           ActivityCompat.requestPermissions(this,
           arrayOf(Manifest.permission.READ_CONTACTS),1)
       }else{
           readContacts()
       }
   }

   override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
       super.onRequestPermissionsResult(requestCode, permissions, grantResults)
       when(requestCode){
           1 -> {
               if(grantResults.isNotEmpty()
                       && grantResults[0] == PackageManager.PERMISSION_GRANTED){
                   readContacts()
               }else{
                   Toast.makeText(this, "You denied the permission", Toast.LENGTH_SHORT).show()
               }
           }
       }
   }


   private fun readContacts(){
       // 查询联系人数据
       contentResolver.query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
               null,null,null,null)?.apply {
           while (moveToNext()){
               // 获取联系人姓名
               val displayName = getString(getColumnIndexOrThrow(
                       ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME))
               // 获取联系人手机号
               val number = getString(getColumnIndexOrThrow(
                       ContactsContract.CommonDataKinds.Phone.NUMBER))
               contactsList.add("$displayName\n$number")
           }
           adapter.notifyDataSetChanged()
           close()
       }
   }
}

在 onCreate() 方法中,先按照ListView的标准用法对其初始化,然后开始调用运行时权限的处理逻辑,因为READ_CONTACTS权限属于危险权限。关于运行时权限的处理流程,可以参照上一个例子。

终点看一下 readContacts() 方法,这里使用了 ContentResolver 的 query() 方法查询系统的联系人数据。不过传入的Uri的参数比较不一样,并没有调用 Uri.parse() 方法去解析一个内容URI字符串。那是因为 ContactsContract.CommonDataKinds.Phone 类已经帮我们做好了封装,提供了一个 CONTENT_URI 常量,而这个常量就是使用 Uri.parse() 方法解析出来的结果。接着,我们对 query() 方法返回的Cursor对象进行遍历,这里使用的 ?. 操作符和 apply 函数来简化遍历的代码。在 apply 函数中将联系人姓名和手机号逐个取出,联系人姓名对应的常量是 ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME ,手机号对应常量是 ContactsContract.CommonDataKinds.Phone.NUMBER 。将两个数据取出后进行拼接,并且在中间加上换行符,然后将进行拼接后的数据添加到LsitView的数据源里,并通知ListView刷新,最后关闭Cursor对象。

BroadcastReceiver

广播机制简介

Android中的每个应用程序都可以对自己感兴趣的广播进行注册,这样该程序就只会收到自己所关心的广播内容,这些广播可能是来自系统的、也可能是来自于其他应用程序的。Android提供了一套完整的API,允许应用程序自由地发送和接收广播。发送广播的方法其实之前已经涉及过,就是Intent。而接收广播的方法则需要一个新的概念——BroadcastReceiver。
广播的类型

  • 标准广播(normal broadcasts)是一种完全异步执行的广播,在广播发出之后,所有的BroadcastReceiver几乎会在同一时刻收到这条广播消息,因此它们之间没有任何先后顺序可言。这种广播效率会比较高,但同时也意味着它是无法被截断的。
  • 有序广播(ordered broadcasts)是一种同步执行的广播,在广播发出之后,同一时刻只会有一个BroadcastReceiver能够收到这条广播消息,当这个BroadcastReceiver中的逻辑执行完毕后,广播才会继续传递。所以此时的BroadcastReceiver 是有先后顺序的,优先级高的BroadcastReceiver就可以先收到广播消息,并且前面的BroadcastReceiver还可以截断正在传递的广播,这样后面的BroadcastReceiver就无法收到广播消息了。

接收系统广播

Android内置了很多系统级别的广播,我们可以在应用程序中通过监听这些广播来得到各种系统的状态信息,比如手机开机完成后会发出一条广播,电池的电量发生变化会发出一条广播,系统时间发生改变也会发出一条广播。

动态注册监听时间变化

我们可以根据自己感兴趣的广播,自由地注册BroadcastReceiver,这样当有相应的广播发出时,相应的BroadcastReceiver就能够收到该广播,并可以在内部进行逻辑处理。注册BroadcastReceiver的方式一般有两种:在代码中注册和在AndroidManifest.xml 中注册。其中前者也被称为动态注册,后者也被称为静态注册。

创建BroadcastReceiver只需要新建一个类,让它继承自BroadcastReceiver,并重写父类的 onReceive() 方法就行。这样当有广播到来时, onReceive() 方法就会得到执行,具体的逻辑就可以在这个方法中处理。

我们就先通过动态注册的方式编写一个能够监听时间变化的程序,借此学习一下BroadcastReceiver的基本用法。

class MainActivity : AppCompatActivity() {
   lateinit var timeChangeReceiver: TimeChangeReceiver
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)
       val intentFilter = IntentFilter()
       intentFilter.addAction("android.intent.action.TIME_TICK")
       timeChangeReceiver = TimeChangeReceiver()
       registerReceiver(timeChangeReceiver,intentFilter)
   }

   override fun onDestroy() {
       super.onDestroy()
       unregisterReceiver(timeChangeReceiver)
   }

   inner class TimeChangeReceiver: BroadcastReceiver(){
       override fun onReceive(context: Context?, intent: Intent?) {
           Toast.makeText(context,"Time has changed", Toast.LENGTH_SHORT).show()
       }
   }
}

我们在MainActivity中定义了一个内部类TimeChangeReceiver,这个类是继承自BroadcastReceiver的,并重写了父类的 onReceive() 方法。这样每当系统时间发生变化时,onReceive() 方法就会得到执行。
观察 onCreate() 方法,首先我们创建了一个IntentFilter的实例,并给它添加了一个值为 android.intent.action.TIME_TICK 的action。因为当系统时间发生变化时,系统发出的正是一条值为 android.intent.action.TIME_TICK 的广播,也就是说我们的BroadcastReceiver想要监听什么广播,就在这里添加响应的action。接下来创建了一个TimeChangeReceiver的实例,然后调用 registerReceiver() 方法进行注册,将TimeChangeReceiver的实例和IntentFilter的实例都传了进去,这样TimeChangeReceiver就会收到所有值为android.intent.action.TIME_TICK的广播,也就实现了监听系统时间变化的功能。

最后,动态注册的BroadcastReceiver一定要取消注册,这里是在 onDestroy() 方法中通过调用 unregisterReceiver() 方法来实现的。

这就是动态注册BroadcastReceiver的基本用法,虽然这里我们只使用了一种系统广播来举例,但是接收其他系统广播的用法是一模一样的。Android系统还会在亮屏熄屏、电量变化、网络变化等场景下发出广播。如果你想查看完整的系统广播列表,可以到如下的路径中去查看:

<Android SDK>/platforms/<任意android api版本>/data/broadcast_actions.txt

静态注册实现开机启动

动态注册的BroadcastReceiver可以自由地控制注册与注销,在灵活性方面有很大的优势,但它存在一个缺点,即必须在程序启动之后才能接收广播,因为注册的逻辑是写在 onCreate() 方法中。
下面以一条开机广播 android.intent.action.BOOT_COMPLETED 来进行举例学习。
这里我们准备实现一个开机启动的功能。在开机的时候,我们的应用程序肯定是没有启动的,因此这个功能显然不能使用动态注册的方式来实现,而应该使用静态注册的方式来接收开机广播,然后在 onReceive() 方法里执行相应的逻辑。

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
   package="com.example.broadcasttest">

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

   <application
       android:allowBackup="true"
       android:icon="@mipmap/ic_launcher"
       android:label="@string/app_name"
       android:roundIcon="@mipmap/ic_launcher_round"
       android:supportsRtl="true"
       android:theme="@style/Theme.BroadcastTest">
       <receiver
           android:name=".BootCompleteReceiver"
           android:enabled="true"
           android:exported="true">
           <intent-filter>
               <action android:name="android.intent.action.BOOT_COMPLETED"/>
           </intent-filter>
       </receiver>

       <activity android:name=".MainActivity">
           <intent-filter>
               <action android:name="android.intent.action.MAIN" />

               <category android:name="android.intent.category.LAUNCHER" />
           </intent-filter>
       </activity>
   </application>

</manifest>

当在项目中真正使用它的时候,可以在BroadcastReceiver的 onReceive() 方法中只是简单地使用Toast提示了一段文本信息,之后我们可以在里面编写自己的逻辑。注意不要在 onReceive() 方法中添加过多的逻辑或者进行任何的耗时操作,因为BroadcastReceiver中是不允许开启线程的,当 onReceive() 方法运行了较长时间而没有结束时,程序就会出现错误。

发送自定义广播

本章主要介绍如何在应用程序中发送自定义的广播。广播主要分为两种类型:标准广播和有序广播。

发送标准广播

MyBroadcastReceiver.kt

class MyBroadcastReceiver: BroadcastReceiver() {

   override fun onReceive(context: Context, intent: Intent) {
       // This method is called when the BroadcastReceiver is receiving an Intent broadcast.
       Toast.makeText(context,"received in MyBroadcastReceiver", Toast.LENGTH_SHORT).show()
   }
}

AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
   package="com.example.broadcasttest">

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

   <application
       android:allowBackup="true"
       android:icon="@mipmap/ic_launcher"
       android:label="@string/app_name"
       android:roundIcon="@mipmap/ic_launcher_round"
       android:supportsRtl="true"
       android:theme="@style/Theme.BroadcastTest">
       <receiver
           android:name=".MyBroadcastReceiver"
           android:enabled="true"
           android:exported="true">
           <intent-filter>
               <action android:name="com.example.broadcasttest.MY_BROADCAST"/>
           </intent-filter>
       </receiver>

       <activity android:name=".MainActivity">
           <intent-filter>
               <action android:name="android.intent.action.MAIN" />

               <category android:name="android.intent.category.LAUNCHER" />
           </intent-filter>
       </activity>
   </application>

</manifest>

MainActivity.kt

class MainActivity : AppCompatActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)
       button.setOnClickListener{
           val intent = Intent("com.example.broadcasttest.MY_BROADCAST")
           intent.setPackage(packageName)
           sendBroadcast(intent)
       }
   }
}

首先构建了一个Intent对象,并把要发送的广播的值传入。然后调用Intent的 setPackage() 方法,并传入当前应用程序的包名。packageName是 getPackageName() 的语法糖写法,用于获取当前应用程序的包名。最后调用sendBroadcast()方法将广播发送出去,这样所有监听 com.example.broadcasttest.MY_BROADCAST 这条广播的BroadcastReceiver就会收到消息了。此时发出去的广播就是一条标准广播。

这里我还得对第2步调用的 setPackage() 方法进行更详细的说明。前面已经说过,在Android8.0系统之后,静态注册的BroadcastReceiver是无法接收隐式广播的,而默认情况下我们发出的自定义广播恰恰都是隐式广播。因此这里一定要调用 setPackage() 方法,指定这条广播是发送给哪个应用程序的,从而让它变成一条显式广播,否则静态注册的BroadcastReceiver将无法接收到这条广播。

发送有序广播

MyBroadcastReceiver 第一条广播

class MyBroadcastReceiver: BroadcastReceiver() {

   override fun onReceive(context: Context, intent: Intent) {
       // This method is called when the BroadcastReceiver is receiving an Intent broadcast.
       Toast.makeText(context,"received in MyBroadcastReceiver", Toast.LENGTH_SHORT).show()
       abortBroadcast() //表示将这条广播截断,后面的BroadcastReceiver将无法再接收到这条广播
   }
}

AnotherMyBroadcastReceiver 第二条广播

class AnotherMyBroadcastReceiver: BroadcastReceiver() {

   override fun onReceive(context: Context, intent: Intent) {
       // This method is called when the BroadcastReceiver is receiving an Intent broadcast.
       Toast.makeText(context,"received in AnotherMyBroadcastReceiver", Toast.LENGTH_SHORT).show()
   }
}

Manifest 注意第一条广播的优先级为100

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
   package="com.example.broadcasttest">

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

   <application
       android:allowBackup="true"
       android:icon="@mipmap/ic_launcher"
       android:label="@string/app_name"
       android:roundIcon="@mipmap/ic_launcher_round"
       android:supportsRtl="true"
       android:theme="@style/Theme.BroadcastTest">
       <receiver
           android:name=".MyBroadcastReceiver"
           android:enabled="true"
           android:exported="true">
           <intent-filter android:priority="100">
               <action android:name="com.example.broadcasttest.MY_BROADCAST"/>
           </intent-filter>
       </receiver>

       <receiver
           android:name=".AnotherMyBroadcastReceiver"
           android:enabled="true"
           android:exported="true">
           <intent-filter>
               <action android:name="com.example.broadcasttest.MY_BROADCAST"/>
           </intent-filter>
       </receiver>

       <activity android:name=".MainActivity">
           <intent-filter>
               <action android:name="android.intent.action.MAIN" />

               <category android:name="android.intent.category.LAUNCHER" />
           </intent-filter>
       </activity>
   </application>

</manifest>

MainActiviy.kt

class MainActivity : AppCompatActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)
       button.setOnClickListener{
           val intent = Intent("com.example.broadcasttest.MY_BROADCAST")
           intent.setPackage(packageName)
           sendOrderedBroadcast(intent,null) //发送顺序广播
       }
   }
}

执行后会发现,如果第一条广播发送后抛弃,则永远都不会收到第二条广播。

事件处理机制

参考:Android事件分发机制详解:史上最全面、最易懂

事件分发

事件分发的事件是指点击事件(Touch事件)
具体使用:Touch事件的相关细节(如发生触摸的位置、时间等)会被封装成MotionEvent对象。
事件类型:

  • MotionEvent.ACTION_DOWN
    按下View(所有事件的开始)
  • MotionEvent.ACTION_UP
    抬起View(与DOWN对应)
  • MotionEvent.ACTION_MOVE
    滑动View
  • MotionEvent.ACTION_CANCEL
    结束事件(非人为原因)

此处需要特别说明:事件列,即指从手指接触屏幕至手指离开屏幕这个过程产生的一系列事件。事件列都是以DOWN事件开始、UP事件结束,中间有无数的MOVE事件。


事件分发的本质
将点击事件(MotionEvent)传递到某个具体的 View 并处理的整个过程。即事件传递的过程就是分发过程。

事件在哪些对象之间进行传递
Activity -> ViewGroup -> View 以及其派生类组成。

类型 简介 备注
Activity 控制生命周期&处理事件 统筹视图的添加&显示
通过其他回调方法与Window、View交互
View 所有UI组件的基类 一般Button、TextView等控件都是继承父类View
ViewGroup 一组View的集合 - 其本身也是View的子类
- 是Android所有布局的父类:LinearLayout等
- 区别于普通View:ViewGroup实际上也是1个View,只是多了可包含子View & 定义布局参数的功能

事件分发的顺序
一个点击事件发生后,事件先传到 Activity 、再传到 ViewGroup 、最终再传到 View 。

事件分发过程由哪些方法协作完成
dispatchTouchEvent() , onInterceptTouchEvent() 和
onTouchEvent()

方法 作用 调用时刻
dispatchTouchEvent() 分发(传递)点击事件 当点击事件能够传递给当前View时,该方法就会被调用
onInterceptTouchEvent() 处理点击事件 在 dispatchTouchEvent() 内部调用
onTouchEvent() 判断是否拦截了某个事件
只存在于ViewGroup
普通的View无该方法
在ViewGroup的 dispatchTouchEvent() 内部调用

小结


下面,将详细介绍Android事件分发机制。

事件分发机制流程概述

Android事件分发流程:Activity -> ViewGroup -> View

理解Android分发机制,本质上是要理解:

  • Activity 对点击事件的分发机制
  • ViewGroup 对点击事件的分发机制
  • View 对点击事件的分发机制

Activity 对点击事件的分发机制

Android事件分发机制首先会将点击事件传递到Activity中,具体是执行 dispatchTouchEvent() 进行事件分发。

源码分析

/**
 * 源码分析:Activity.dispatchTouchEvent()
 */ 
 public boolean dispatchTouchEvent(MotionEvent ev) {

   // 仅贴出核心代码

   // ->>分析1
   if (getWindow().superDispatchTouchEvent(ev)) {

       return true;
       // 若getWindow().superDispatchTouchEvent(ev)的返回true
       // 则Activity.dispatchTouchEvent()就返回true,则方法结束。即 :该点击事件停止往下传递 & 事件传递过程结束
       // 否则:继续往下调用Activity.onTouchEvent

   }
   // ->>分析3
   return onTouchEvent(ev);
 }

/**
 * 分析1:getWindow().superDispatchTouchEvent(ev)
 * 说明:
 *     a. getWindow() = 获取Window类的对象
 *     b. Window类是抽象类,其唯一实现类 = PhoneWindow类
 *     c. Window类的superDispatchTouchEvent() = 1个抽象方法,由子类PhoneWindow类实现
 */
 @Override
 public boolean superDispatchTouchEvent(MotionEvent event) {

     return mDecor.superDispatchTouchEvent(event);
     // mDecor = 顶层View(DecorView)的实例对象
     // ->> 分析2
 }

/**
 * 分析2:mDecor.superDispatchTouchEvent(event)
 * 定义:属于顶层View(DecorView)
 * 说明:
 *     a. DecorView类是PhoneWindow类的一个内部类
 *     b. DecorView继承自FrameLayout,是所有界面的父类
 *     c. FrameLayout是ViewGroup的子类,故DecorView的间接父类 = ViewGroup
 */
 public boolean superDispatchTouchEvent(MotionEvent event) {

     return super.dispatchTouchEvent(event);
     // 调用父类的方法 = ViewGroup的dispatchTouchEvent()
     // 即将事件传递到ViewGroup去处理,详细请看后续章节分析的ViewGroup的事件分发机制

 }
 // 回到最初的分析2入口处

/**
 * 分析3:Activity.onTouchEvent()
 * 调用场景:当一个点击事件未被Activity下任何一个View接收/处理时,就会调用该方法
 */
 public boolean onTouchEvent(MotionEvent event) {

       // ->> 分析5
       if (mWindow.shouldCloseOnTouch(this, event)) {
           finish();
           return true;
       }
       
       return false;
       // 即 只有在点击事件在Window边界外才会返回true,一般情况都返回false,分析完毕
   }

/**
 * 分析4:mWindow.shouldCloseOnTouch(this, event)
 * 作用:主要是对于处理边界外点击事件的判断:是否是DOWN事件,event的坐标是否在边界内等
 */
 public boolean shouldCloseOnTouch(Context context, MotionEvent event) {

 if (mCloseOnTouchOutside && event.getAction() == MotionEvent.ACTION_DOWN
         && isOutOfBounds(context, event) && peekDecorView() != null) {

       // 返回true:说明事件在边界外,即 消费事件
       return true;
   }

   // 返回false:在边界内,即未消费(默认)
   return false;
 } 
机制总结

当一个点击事件发生时,从Activity的事件分发开始( Activity.dispatchTouchEvent() ),流程总结如下:

核心方法总结

ViewGroup 对点击事件的分发机制

从上面Activity的事件分发机制可知,在 Activity.dispatchTouchEvent() 实现了将事件从Activity->ViewGroup的传递,ViewGroup的事件分发机制从 dispatchTouchEvent() 开始。

源码分析

/**
 * 源码分析:ViewGroup.dispatchTouchEvent()
 */ 
 public boolean dispatchTouchEvent(MotionEvent ev) { 

 // 仅贴出关键代码
 ... 

 if (disallowIntercept || !onInterceptTouchEvent(ev)) {  
 // 分析1:ViewGroup每次事件分发时,都需调用onInterceptTouchEvent()询问是否拦截事件
   // 判断值1-disallowIntercept:是否禁用事件拦截的功能(默认是false),可通过调用requestDisallowInterceptTouchEvent()修改
   // 判断值2-!onInterceptTouchEvent(ev) :对onInterceptTouchEvent()返回值取反
       // a. 若在onInterceptTouchEvent()中返回false,即不拦截事件,从而进入到条件判断的内部
       // b. 若在onInterceptTouchEvent()中返回true,即拦截事件,从而跳出了该条件判断
       // c. 关于onInterceptTouchEvent() ->>分析1

 // 分析2
   // 1. 通过for循环,遍历当前ViewGroup下的所有子View
   for (int i = count - 1; i >= 0; i--) {  
       final View child = children[i];  
       if ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE  
               || child.getAnimation() != null) {  
           child.getHitRect(frame);  

           // 2. 判断当前遍历的View是不是正在点击的View,从而找到当前被点击的View
           if (frame.contains(scrolledXInt, scrolledYInt)) {  
               final float xc = scrolledXFloat - child.mLeft;  
               final float yc = scrolledYFloat - child.mTop;  
               ev.setLocation(xc, yc);  
               child.mPrivateFlags &= ~CANCEL_NEXT_UP_EVENT;  

               // 3. 条件判断的内部调用了该View的dispatchTouchEvent()
               // 即 实现了点击事件从ViewGroup到子View的传递(具体请看下面章节介绍的View事件分发机制)
               if (child.dispatchTouchEvent(ev))  { 

               // 调用子View的dispatchTouchEvent后是有返回值的
               // 若该控件可点击,那么点击时dispatchTouchEvent的返回值必定是true,因此会导致条件判断成立
               // 于是给ViewGroup的dispatchTouchEvent()直接返回了true,即直接跳出
               // 即该子View把ViewGroup的点击事件消费掉了

               mMotionTarget = child;  
               return true; 
                     }  
                 }  
             }  
         }  
     }  
   }  

 ...

 return super.dispatchTouchEvent(ev);
 // 若无任何View接收事件(如点击空白处)/ViewGroup本身拦截了事件(复写了onInterceptTouchEvent()返回true)
 // 会调用ViewGroup父类的dispatchTouchEvent(),即View.dispatchTouchEvent()
 // 因此会执行ViewGroup的onTouch() -> onTouchEvent() -> performClick() -> onClick(),即自己处理该事件,事件不会往下传递
 // 具体请参考View事件分发机制中的View.dispatchTouchEvent()

 ... 

}

/**
 * 分析1:ViewGroup.onInterceptTouchEvent()
 * 作用:是否拦截事件
 * 说明:
 *     a. 返回false:不拦截(默认)
 *     b. 返回true:拦截,即事件停止往下传递(需手动复写onInterceptTouchEvent()其返回true)
 */
 public boolean onInterceptTouchEvent(MotionEvent ev) {  
   
   // 默认不拦截
   return false;

 } 
 // 回到调用原处

源码总结

Android事件分发传递到Acitivity后,总是先传递到ViewGroup、再传递到View。流程总结如下:(假设已经经过了Acitivity事件分发传递并传递到ViewGroup)


核心方法总结

主要包括: dispatchTouchEvent() 、 onTouchEvent() 、 onInterceptTouchEvent() 总结如下

实例分析

布局文件: activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
   android:id="@+id/my_layout"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:orientation="vertical"
   android:focusableInTouchMode="true">

   <Button
       android:id="@+id/button1"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="按钮1"/>

   <Button
       android:id="@+id/button2"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="按钮2"/>

</LinearLayout>

核心代码: MainActivity.java

public class MainActivity extends AppCompatActivity {

   Button button1,button2;
   ViewGroup myLayout;

   @Override
   public void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       setContentView(R.layout.activity_main);

       button1 = (Button)findViewById(R.id.button1);
       button2 = (Button)findViewById(R.id.button2);
       myLayout = (LinearLayout)findViewById(R.id.my_layout);

       // 1.为ViewGroup布局设置监听事件
       myLayout.setOnClickListener(new View.OnClickListener() {
           @Override
           public void onClick(View v) {
               Log.d("TAG", "点击了ViewGroup");
           }
       });

       // 2. 为按钮1设置监听事件
       button1.setOnClickListener(new View.OnClickListener() {
           @Override
           public void onClick(View v) {
               Log.d("TAG", "点击了button1");
           }
       });

       // 3. 为按钮2设置监听事件
       button2.setOnClickListener(new View.OnClickListener() {
           @Override
           public void onClick(View v) {
               Log.d("TAG", "点击了button2");
           }
       });
   }
}

测试结果

// 点击按钮1,输出如下
点击了button1

// 点击按钮2,输出如下
点击了button2

// 点击空白处,输出如下
点击了ViewGroup

结果分析

  • 点击Button时,因为ViewGroup默认不拦截,所以事件会传递到子View Button,于是执行 Button.onClick() 。
  • 此时 ViewGroup.dispatchTouchEvent() 会直接返回true,所以ViewGroup自身不会处理该事件,于是ViewGroupLayout的 dispatchTouchEvent() 不会执行,所以注册的 onTouch() 不会执行,即 onTouchEvent() -> performClick() -> onClick() 整个链路都不会执行,所以最后不会执行ViewGroup设置的 onClick() 里。
  • 点击空白区域时, ViewGroup.dispatchTouchEvent() 里遍历所有子View希望找到被点击子View时找不到,所以ViewGroup自身会处理该事件,于是执行 onTouchEvent() -> performClick() -> onClick() ,最终执行ViewGroupLayout的设置的 onClick() 。

View 对点击事件的分发机制

从上面ViewGroup事件分发机制知道,View事件分发机制从 dispatchTouchEvent() 开始

源码分析

/**
 * 源码分析:View.dispatchTouchEvent()
 */
 public boolean dispatchTouchEvent(MotionEvent event) {  

      
       if ( (mViewFlags & ENABLED_MASK) == ENABLED && 
             mOnTouchListener != null &&  
             mOnTouchListener.onTouch(this, event)) {  
           return true;  
       } 

       return onTouchEvent(event);  
 }
 // 说明:只有以下3个条件都为真,dispatchTouchEvent()才返回true;否则执行onTouchEvent()
 //   1. (mViewFlags & ENABLED_MASK) == ENABLED
 //   2. mOnTouchListener != null
 //   3. mOnTouchListener.onTouch(this, event)
 // 下面对这3个条件逐个分析

/**
 * 条件1:(mViewFlags & ENABLED_MASK) == ENABLED
 * 说明:
 *    1. 该条件是判断当前点击的控件是否enable
 *    2. 由于很多View默认enable,故该条件恒定为true(除非手动设置为false)
 */

/**
 * 条件2:mOnTouchListener != null
 * 说明:
 *   1. mOnTouchListener变量在View.setOnTouchListener()里赋值
 *   2. 即只要给控件注册了Touch事件,mOnTouchListener就一定被赋值(即不为空)
 */
 public void setOnTouchListener(OnTouchListener l) { 

   mOnTouchListener = l;  

} 

/**
 * 条件3:mOnTouchListener.onTouch(this, event)
 * 说明:
 *   1. 即回调控件注册Touch事件时的onTouch();
 *   2. 需手动复写设置,具体如下(以按钮Button为例)
 */
 button.setOnTouchListener(new OnTouchListener() {  
     @Override  
     public boolean onTouch(View v, MotionEvent event) {  
  
       return false;  
       // 若在onTouch()返回true,就会让上述三个条件全部成立,从而使得View.dispatchTouchEvent()直接返回true,事件分发结束
       // 若在onTouch()返回false,就会使得上述三个条件不全部成立,从而使得View.dispatchTouchEvent()中跳出If,执行onTouchEvent(event)
       // onTouchEvent()源码分析 -> 分析1
     }  
 });

/**
 * 分析1:onTouchEvent()
 */
 public boolean onTouchEvent(MotionEvent event) {  

   ... // 仅展示关键代码

   // 若该控件可点击,则进入switch判断中
   if (((viewFlags & CLICKABLE) == CLICKABLE || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)) {  

       // 根据当前事件类型进行判断处理
       switch (event.getAction()) { 

           // a. 事件类型=抬起View(主要分析)
           case MotionEvent.ACTION_UP:  
                   performClick(); 
                   // ->>分析2
                   break;  

           // b. 事件类型=按下View
           case MotionEvent.ACTION_DOWN:  
               postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());  
               break;  

           // c. 事件类型=结束事件
           case MotionEvent.ACTION_CANCEL:  
               refreshDrawableState();  
               removeTapCallback();  
               break;

           // d. 事件类型=滑动View
           case MotionEvent.ACTION_MOVE:  
               final int x = (int) event.getX();  
               final int y = (int) event.getY();  

               int slop = mTouchSlop;  
               if ((x < 0 - slop) || (x >= getWidth() + slop) ||  
                       (y < 0 - slop) || (y >= getHeight() + slop)) {  
                   removeTapCallback();  
                   if ((mPrivateFlags & PRESSED) != 0) {  
                       removeLongPressCallback();  
                       mPrivateFlags &= ~PRESSED;  
                       refreshDrawableState();  
                   }  
               }  
               break;  
       }  

       // 若该控件可点击,就一定返回true
       return true;  
   }  
 // 若该控件不可点击,就一定返回false
 return false;  
}

/**
 * 分析2:performClick()
 */  
 public boolean performClick() {  

     if (mOnClickListener != null) {
         // 只要通过setOnClickListener()为控件View注册1个点击事件
         // 那么就会给mOnClickListener变量赋值(即不为空)
         // 则会往下回调onClick() & performClick()返回true
         playSoundEffect(SoundEffectConstants.CLICK);  
         mOnClickListener.onClick(this);  
         return true;  
     }  
     return false;  
 }  

源码总结


这里需要注意的是, onTouch() 的执行先于 onClick()

核心方法总结

实例分析

在本示例中,将分析两种情况:

  • 注册Touch事件监听 且 在onTouch()返回false
  • 注册Touch事件监听 且 在onTouch()返回true

分析1:注册Touch事件监听 且 在onTouch()返回false

// 1. 注册Touch事件监听setOnTouchListener 且 在onTouch()返回false
button.setOnTouchListener(new View.OnTouchListener() {

     @Override
     public boolean onTouch(View v, MotionEvent event) {
         System.out.println("执行了onTouch(), 动作是:" + event.getAction());
         return false;
     }
});

// 2. 注册点击事件OnClickListener()
button.setOnClickListener(new View.OnClickListener() {

     @Override
     public void onClick(View v) {
         System.out.println("执行了onClick()");
     }
});

输出结果

执行了onTouch(), 动作是:0
执行了onTouch(), 动作是:1
执行了onClick()

测试结果说明

  • 点击按钮会产生两个类型的事件-按下View与抬起View,所以会回调两次onTouch();
  • 因为onTouch()返回了false,所以事件无被消费,会继续往下传递,即调用View.onTouchEvent();
  • 调用View.onTouchEvent()时,对于抬起View事件,在调用performClick()时,因为设置了点击事件,所以会回调onClick()。

分析2:注册Touch事件监听 且 在onTouch()返回true

// 1. 注册Touch事件监听setOnTouchListener 且 在onTouch()返回false
button.setOnTouchListener(new View.OnTouchListener() {

       @Override
       public boolean onTouch(View v, MotionEvent event) {
           System.out.println("执行了onTouch(), 动作是:" + event.getAction());
           return true;
       }
   });

// 2. 注册点击事件OnClickListener()
button.setOnClickListener(new View.OnClickListener() {

       @Override
       public void onClick(View v) {
           System.out.println("执行了onClick()");
       }
       
 });
执行了onTouch(), 动作是:0
执行了onTouch(), 动作是:1

测试结果说明

  • 点击按钮会产生两个类型的事件-按下View与抬起View,所以会回调两次onTouch();
  • 因为onTouch()返回了true,所以事件被消费,不会继续往下传递,View.dispatchTouchEvent()直接返回true;
  • 所以最终不会调用View.onTouchEvent(),也不会调用onClick()。

总结

在本章节中,将采用大量的图表从各个角度对Android事件分发机制进行总结。主要包括:

  • 工作流程总结
  • 业务流程总结
  • 以分发对象为核心的总结
  • 以方法为核心的总结

工作流程总结

Android事件分发流程 = Activity -> ViewGroup -> View,即:1个点击事件发生后,事件先传到Activity、再传到ViewGroup、最终再传到View。


工作流程

以分发对象为核心的总结

分发对象主要包括:Activity、ViewGroup、View。


以方法为核心的总结

事件分发的方法主要包括:dispatchTouchEvent()、onInterceptTouchEvent()和onTouchEvent()。


业务流程图

这里需要特别注意的是:

  • 注意点1:左侧虚线代表具备相关性及逐层返回;
  • 注意点2:各层dispatchTouchEvent() 返回true的情况保持一致(图中虚线)
    原因是:上层dispatchTouchEvent() 的返回true情况 取决于 下层dispatchTouchEvent() 是否返回ture,如Activity.dispatchTouchEvent() 返回true的情况 = ViewGroup.dispatchTouchEvent() 返回true
  • 注意点3:各层dispatchTouchEvent() 与 onTouchEvent()的返回情况保持一致
    原因:最下层View的dispatchTouchEvent()的返回值 取决于 View.onTouchEvent()的返回值;结合注意点1,逐层往上返回,从而保持一致。

下面,将针对该3个方法,分别针对默认执行逻辑、返回true、返回false的三种情况进行流程图示意。

方法1:dispatchTouchEvent()
默认执行逻辑、返回true、返回false 这三种情况的返回逻辑分别如下所示。



方法2:onInterceptTouchEvent()
默认执行逻辑、返回true、返回false 这三种情况的返回逻辑分别如下所示。


这里需要特别注意的是: Activity 、 View 都无该方法,仅 ViewGroup 特有。

方法3:onTouchEvent()
默认执行逻辑、返回true、返回false 这三种情况的返回逻辑分别如下所示。


三者关系

下面,我用一段伪代码来阐述上述3个方法的关系 & 事件传递规则

// 点击事件产生后
// 步骤1:调用dispatchTouchEvent()
public boolean dispatchTouchEvent(MotionEvent ev) {

   boolean consume = false; //代表 是否会消费事件

   // 步骤2:判断是否拦截事件
   if (onInterceptTouchEvent(ev)) {
     // a. 若拦截,则将该事件交给当前View进行处理
     // 即调用onTouchEvent()去处理点击事件
     consume = onTouchEvent (ev) ;

   } else {

     // b. 若不拦截,则将该事件传递到下层
     // 即 下层元素的dispatchTouchEvent()就会被调用,重复上述过程
     // 直到点击事件被最终处理为止
     consume = child.dispatchTouchEvent (ev) ;
   }

   // 步骤3:最终返回通知 该事件是否被消费(接收 & 处理)
   return consume;
}

常见事件分发场景

下面,我将通过实例说明常见的事件传递情况 & 流程

背景描述

  • 情景
    用户先触摸到屏幕上 view c 上的某个点(图中黄区)

Action_DOWN 事件在此处产生

用户移动手指
最后离开屏幕

一般的事件传递情况

一般的事件传递场景有:

  • 默认情况
  • 处理事件
  • 拦截 DOWN 事件
  • 拦截后续事件( MOVE 、 UP )

场景1:默认

  • 即不对控件里的方法(dispatchTouchEvent()、onTouchEvent()、onInterceptTouchEvent())进行重写 或 更改返回值
  • 那么调用的是这3个方法的默认实现:调用下层的方法 & 逐层返回
  • 事件传递情况:(呈U型)
  1. 从上往下 dispatchTouchEvent()

Activity A ->> ViewGroup B ->> View C

  1. 从下往上调用 onTouchEvent()

View C ->> ViewGroup B ->> Activity A

注:虽然 ViewGroup B 的 onInterceptTouchEvent() 对 DOWN 事件返回了 false ,但后续的事件( MOVE 、 UP )依然会传递给它的 onInterceptTouchEvent()
这一点与 onTouchEvent() 的行为是不一样的:不再传递 & 接收该事件列的其他事件

场景2:处理事件
设 View C 希望处理该点击事件,即:设置 View C 为可点击的( Clickable ) 或 复写其 onTouchEvent() 返回 true

最常见的:设置Button按钮来响应点击事件

事件传递情况:(如下图)

  • DOWN 事件被传递给C的 onTouchEvent 方法,该方法返回 true ,表示处理该事件
  • 因为 View C 正在处理该事件,那么 DOWN 事件将不再往上传递给 ViewGroup B 和 Activity A 的 onTouchEvent() ;
  • 该事件列的其他事件( Move 、 Up )也将传递给 View C 的 onTouchEvent()

会逐层往 dispatchTouchEvent() 返回,最终事件分发结束。

场景3:拦截DOWN事件
假设 ViewGroup B 希望处理该点击事件,即 ViewGroup B 复写了 onInterceptTouchEvent() 返回 true 、 onTouchEvent() 返回 true
事件传递情况:(如下图)

  • DOWN 事件被传递给 ViewGroup B 的 onInterceptTouchEvent() ,该方法返回 true ,表示拦截该事件,即自己处理该事件(事件不再往下传递)
  • 调用自身的 onTouchEvent() 处理事件( DOWN 事件将不再往上传递给 Activity A 的 onTouchEvent() )
  • 该事件列的其他事件( Move 、 Up )将直接传递给 ViewGroup B 的 onTouchEvent()

注:
该事件列的其他事件(Move、Up)将不会再传递给ViewGroup B的onInterceptTouchEvent();因:该方法一旦返回一次true,就再也不会被调用
逐层往dispatchTouchEvent() 返回,最终事件分发结束

场景4:拦截DOWN的后续事件
结论

  • 若 ViewGroup 拦截了一个半路的事件(如 MOVE ),该事件将会被系统变成一个 CANCEL 事件 & 传递给之前处理该事件的子 View ;
  • 该事件不会再传递给 ViewGroup 的 onTouchEvent()
  • 只有再到来的事件才会传递到 ViewGroup 的 onTouchEvent()
    场景描述
    ViewGroup B 无拦截 DOWN 事件(还是 View C 来处理 DOWN 事件),但它拦截了接下来的 MOVE 事件

即 DOWN 事件传递到 View C 的 onTouchEvent() ,返回了 true

实例讲解

  • 在后续到来的 MOVE 事件, ViewGroup B 的 onInterceptTouchEvent() 返回 true 拦截该 MOVE 事件,但该事件并没有传递给 ViewGroup B ;这个 MOVE 事件将会被系统变成一个 CANCEL 事件传递给 View C的onTouchEvent()
  • 后续又来了一个 MOVE 事件,该 MOVE 事件才会直接传递给 ViewGroup B 的 onTouchEvent()

后续事件将直接传递给 ViewGroup B 的 onTouchEvent() 处理,而不会再传递给 ViewGroup B 的 onInterceptTouchEvent() ,因该方法一旦返回一次 true ,就再也不会被调用了。

  • View C 再也不会收到该事件列产生的后续事件

绘制机制 (待完成)

视图(View)定义

View具体表现为显示在屏幕上的各种视图控件,如TextView,LinearLayout等

视图(View)分类

视图View主要分为两类:

  • 单一视图(View):即一个View、不包含子View,如TextView
  • 视图组(ViewGroup):即多个View组成的ViewGroup、包含子View,如LinearLayout
    Android中的UI组件都由View、ViewGroup共同组成。

View类简介

View的构造函数共有4个:

自定义View必须重写至少一个构造函数:

// 构造函数1
// 调用场景:View是在Java代码里面new的
public ViewTest(Context context) {
   super(context);
}

// 构造函数2
// 调用场景:View是在.xml里声明的
// 自定义属性是从AttributeSet参数传进来的
public  ViewTest(Context context, AttributeSet attrs) {
   super(context, attrs);
}

// 构造函数3
// 应用场景:View有style属性时
// 一般是在第二个构造函数里主动调用;不会自动调用
public  ViewTest(Context context, AttributeSet attrs, int defStyleAttr) {
   super(context, attrs, defStyleAttr);
}

// 构造函数4
// 应用场景:View有style属性时、API21之后才使用
// 一般是在第二个构造函数里主动调用;不会自动调用
public  ViewTest(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
   super(context, attrs, defStyleAttr, defStyleRes);
}

View视图结构


这里需要特别注意的是:在View的绘制过程中,永远都是从View树结构的根节点开始(即从树的顶端开始),一层一层、一个个分支地自上而下遍历进行(即树形递归),最终计算整个View树中各个View,从而最终确定整个View树的相关属性。

Android坐标系

Android的坐标系定义为:

  • 屏幕的左上角为坐标原点
  • 向右为x轴增大方向
  • 向下为y轴增大方向
    具体如下图:


View位置(坐标)描述

视图的位置由四个顶点决定,如图1-3所示的A、B、C、D。



视图的位置是相对于父控件而言的,四个顶点的位置描述分别由四个与父控件相关的值决定:

  • 顶部(Top):视图上边界到父控件上边界的距离;
  • 左边(Left):视图左边界到父控件左边界的距离;
  • 右边(Right):视图右边界到父控件左边界的距离;
  • 底部(Bottom):视图下边界到父控件上边界的距离。

可根据视图位置的左上顶点、右下顶点进行记忆:

  • 顶部(Top):视图左上顶点到父控件上边界的距离;
  • 左边(Left):视图左上顶点到父控件左边界的距离;
  • 右边(Right):视图右下顶点到父控件左边界的距离;
  • 底部(Bottom):视图右下顶点到父控件上边界的距离。

位置获取方式

视图的位置获取是通过 View.getXXX() 方法进行获取。

获取顶部距离(Top):getTop()
获取左边距离(Left):getLeft()
获取右边距离(Right):getRight()
获取底部距离(Bottom):getBottom()

绘制机制 Measure

作用

测量 View 的宽/高

  1. 在某些情况下,需要多次测量(measure)才能确定View最终的宽/高;
  2. 该情况下,measure过程后得到的宽 / 高可能不准确;
  3. 此处建议:在layout过程中onLayout()去获取最终的宽 / 高

储备知识

了解 measure 过程前,需要3个储备知识:

  1. 自定义 View 基础知识
  2. ViewGroup.LayoutParams 类()
  3. MeasureSpecs 类

measure 过程详解

measure 过程 根据View的类型分为2种情况:

单一View的measure过程

具体流程


源码总结
对于单一View的测量流程(Measure)各个方法说明如下所示。

测量宽高的关键在于getDefaultSize(),该方法的测量逻辑如下图所示。

ViewGroup 的 measure过程

应用场景
利用现有的多个组件根据特定的布局方式组成一个新的组件(即包含多个子View)。

如:底部导航条中的条目,一般都是上图标(ImageView)、下文字(TextView),那么这两个就可以用自定义ViewGroup组合成为一个Veiw,提供两个属性分别用来设置文字和图片,使用起来会更加方便。


测量原理

从ViewGroup至子View、自上而下遍历进行(即树形递归),通过计算整个ViewGroup中各个View的属性,从而最终确定整个ViewGroup的属性。即:

  1. 遍历测量所有子View的尺寸(宽/高);
  2. 合并所有子View的尺寸(宽/高),最终得到ViewGroup父视图的测量值。


具体流程


需要特别注意的是:若需进行自定义ViewGroup,则需重写onMeasure()。

复写 onMeasure()

针对Measure流程,自定义ViewGroup的关键在于:根据需求复写onMeasure(),从而实现子View的测量逻辑。复写onMeasure()的步骤主要分为三步:

  1. 遍历所有子View及测量:measureChildren()
  2. 合并所有子View的尺寸大小,最终得到ViewGroup父视图的测量值:需自定义实现
  3. 存储测量后View宽/高的值:setMeasuredDimension()
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {  

     //仅展示关键代码
     ...

     // 步骤1:遍历所有子View & 测量 -> 分析1
     measureChildren(widthMeasureSpec, heightMeasureSpec);

     // 步骤2:合并所有子View的尺寸大小,最终得到ViewGroup父视图的测量值
      void measureCarson{
          ... // 需自定义实现
      }

     // 步骤3:存储测量后View宽/高的值
     setMeasuredDimension(widthMeasure,  heightMeasure);  
     // 类似单一View的过程,此处不作过多描述
}



/**
 * 分析1:measureChildren()
 * 作用:遍历子View & 调用measureChild()进行下一步测量
 */ 
 protected void measureChildren(int widthMeasureSpec, int heightMeasureSpec) {
     // 参数说明:父视图的测量规格(MeasureSpec)

     final int size = mChildrenCount;
     final View[] children = mChildren;

     // 遍历所有子view
     for (int i = 0; i < size; ++i) {

         final View child = children[i];

         // 调用measureChild()进行下一步的测量 ->分析2
         measureChild(child, widthMeasureSpec, heightMeasureSpec);

     }
 }

/**
 * 分析2:measureChild()
 * 作用:1. 计算单个子View的MeasureSpec
 *      2. 测量每个子View最后的宽 / 高:调用子View的measure()
 */ 
 protected void measureChild(View child, int parentWidthMeasureSpec,int parentHeightMeasureSpec) {

     // 1. 获取子视图的布局参数
     final LayoutParams lp = child.getLayoutParams();

     // 2. 根据父视图的MeasureSpec & 布局参数LayoutParams,计算单个子View的MeasureSpec
     final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,mPaddingLeft + mPaddingRight, lp.width);
     final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,mPaddingTop + mPaddingBottom, lp.height);

     // 3. 将计算好的子View的MeasureSpec值传入measure(),进行最后的测量
     // 下面的流程即类似单一View的过程,此处不作过多描述
     child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}

流程总结

对于视图组ViewGroup的测量流程(Measure)各个方法说明总结如下所示。


实例解析

为了更好理解ViewGroup的measure过程(特别是复写onMeasure()),本小节将用ViewGroup的子类LinearLayout来分析ViewGroup的measure过程。

此处主要分析的是LinearLayout的onMeasure(),具体如下所示。

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

   // 根据不同的布局属性进行不同的计算
   // 此处只选垂直方向的测量过程,即measureVertical() ->分析1
   if (mOrientation == VERTICAL) {
       measureVertical(widthMeasureSpec, heightMeasureSpec);
   } else {
       measureHorizontal(widthMeasureSpec, heightMeasureSpec);
   }
}

/**
 * 分析1:measureVertical()
 * 作用:测量LinearLayout垂直方向的测量尺寸
 */ 
 void measureVertical(int widthMeasureSpec, int heightMeasureSpec) {
     
     // 获取垂直方向上的子View个数
     final int count = getVirtualChildCount();

     // 遍历子View获取其高度,并记录下子View中最高的高度数值
     for (int i = 0; i < count; ++i) {
         final View child = getVirtualChildAt(i);

         // 子View不可见,直接跳过该View的measure过程,getChildrenSkipCount()返回值恒为0
         // 注:若view的可见属性设置为VIEW.INVISIBLE,还是会计算该view大小
         if (child.getVisibility() == View.GONE) {
            i += getChildrenSkipCount(child, i);
            continue;
         }

         // 记录子View是否有weight属性设置,用于后面判断是否需要二次measure
         totalWeight += lp.weight;

         if (heightMode == MeasureSpec.EXACTLY && lp.height == 0 && lp.weight > 0) {

           // 如果LinearLayout的specMode为EXACTLY且子View设置了weight属性,在这里会跳过子View的measure过程
           // 同时标记skippedMeasure属性为true,后面会根据该属性决定是否进行第二次measure
           // 若LinearLayout的子View设置了weight,会进行两次measure计算,比较耗时
           // 这就是为什么LinearLayout的子View需要使用weight属性时候,最好替换成RelativeLayout布局
           final int totalLength = mTotalLength;
           mTotalLength = Math.max(totalLength, totalLength + lp.topMargin + lp.bottomMargin);
           skippedMeasure = true;

         } else {
             
             int oldHeight = Integer.MIN_VALUE;

             // 步骤1:该方法内部最终会调用measureChildren(),从而 遍历所有子View & 测量
             measureChildBeforeLayout(child, i, widthMeasureSpec, 0, heightMeasureSpec,totalWeight == 0 ? mTotalLength : 0);
             
             ...
           }

       // 步骤2:合并所有子View的尺寸大小,最终得到ViewGroup父视图的测量值(需自定义实现)
       final int childHeight = child.getMeasuredHeight();
       // 1. mTotalLength用于存储LinearLayout在竖直方向的高度
       final int totalLength = mTotalLength;
       // 2. 每测量一个子View的高度, mTotalLength就会增加
       mTotalLength = Math.max(totalLength, totalLength + childHeight + lp.topMargin +
              lp.bottomMargin + getNextLocationOffset(child));
       // 3. 记录LinearLayout占用的总高度
       // 即除了子View的高度,还有本身的padding属性值
       mTotalLength += mPaddingTop + mPaddingBottom;
       int heightSize = mTotalLength;

       // 步骤3:存储测量后View宽/高的值
       setMeasureDimension(resolveSizeAndState(maxWidth,width))
       ...
 }

总结

  • 测量流程(Measure)根据视图(View)的类型分为两种情况:单一View和视图组ViewGroup;
  • 二者最大的区别在于:单一View的measure过程对onMeasure()有作统一实现,而ViewGroup的Measuer过程没有;
  • 具体测量流程总结如下所示



图形图像的基本用法

Drawable

参考:Android Drawable 详解

image

Drawable资源使用注意事项

  • Drawable分为两种: 一种是我们普通的图片资源,在Android Studio中我们一般放到 res/mipmap 目录下, 另外我们如果把工程切换成Android项目模式,我们直接 往mipmap目录下丢图片即可,AS会自动分hdpi,xhdpi...! 另一种是我们编写的XML形式的Drawable资源,我们一般把他们放到 res/drawable 目录 下,比如最常见的按钮点击背景切换的Selector!
  • 在XML我们直接通过 @mipmap 或者 @drawable 设置Drawable即可 比如: android:background = "@mipmap/iv_icon_zhu"/"@drawable/btn_back_selctor" 而在Java代码中我们可以通过Resource的 getDrawable(R.mipmap.xxx) 可以获得drawable资源 如果是为某个控件设置背景,比如ImageView,我们可以直接调用控件 .getDrawale() 同样 可以获得drawable对象!
  • Android中drawable中的资源名称有约束,必须是:[a-z0-9_.](即:只能是字母数字及和.), 而且不能以数字开头

Drawable 在 Android 中的继承关系如下,其中,红框标注的几种 Drawable 是我们在开发中比较常用的一些:


常用Drawable

Drawable 中比较重要的方法

Drawable
   |- createFromPath
   |- createFromResourceStream
   |- createFromStream
   |- createFromXml
   |
   |- inflate   : 从XML中解析属性,子类需重写
   |- setAlpha  : 设置绘制时的透明度
   |- setBounds : 设置Canvas为Drawable提供的绘制区域
   |- setLevel  : 控制Drawable的Level值,这个值在ClipDrawable、RotateDrawable、ScaleDrawable、AnimationDrawable等Drawable中有重要作用;区间为[0, 10000]
   |- draw(Canvas) : 绘制到Canvas上,子类必须重写

其中,比较重要的方法是 inflate 和 draw 。inflate 方法用于从 XML 中读取 Drawable 的配置, draw 方法则实现了把一个 Drawable 确切的绘制到一个 Canvas 上面——draw 方法为一个 abstract 抽象方法,子类必须进行重写。inflate 方法在 Drawable.createFromXmlInner 中被调用:

createFromXmlInner

我们可以看出,在从 XML 中创建一个 Drawable 时,步骤如下:

  1. 先根据 XML 节点名称来决定创造什么类型的 Drawable;然后 new 出相应的 Drawable;
  2. 再为该 Drawable 调用 inflate 方法,让其把配置加载起来——因为每种 Drawable 会重写 inflate 方法,所以,可以正确加载到各项配置及属性。

BitmapDrawable

BitmapDrawable 以 <bitmap> 作为根节点:

bitmap
   |- src="@drawable/res_id"
   |- antialias="[true | false]"
   |- dither="[true | false]"
   |- filter="[true | false]"
   |- tileMode="[disabled | clamp | repeat | mirror]"
   |- gravity="[top | bottom | left | right | center_vertical |
   |            fill_vertical | center_horizontal | fill_horizontal |
   |            center | fill | clip_vertical | clip_horizontal]"
   |

这个比较复杂一点了,我们逐一介绍各个属性:

  • src:表示该 BitmapDrawable 引用的位图,该图片为 png、jpg 或者 gif;
  • antialias:表示是否开启抗锯齿;
  • dither:表示当位图和屏幕的像素配置不同时,是否允许抖动。比如一张位图的像素为 ARGB_8888 32 位色,而屏幕像素为 RGB_565;
  • filter:是否允许为位图进行滤波以获取平滑的缩放效果;
  • gravity:定义位图的 gravity,当位图小于容器时,该属性指定了位图在容器中的停靠位置和绘制方式。
  • tileMode:表示当位图小于容器时,执行“平铺”模式,并且指定铺砖的方法。该属性覆盖 gravity 属性——当指定了该属性后,gravity 属性即使设置了,也将不起作用。

其中, gravity 和 tileMode 这两个属性比较有意思,我们着重来进行介绍。 gravity 的默认值为 fill ——亦即在水平和垂直方向均进行缩放,使得图片可以填充到整个 View 里面。

为了比较好的展现 clamp 钳位模式,注意这张图,我们在右边缘和下边缘用了黑白交替的边线。我们的 XML 配置极其简单,以 <bitmap> 作为根节点:

<bitmap
   xmlns:android="http://schemas.android.com/apk/res/android"
   android:src="@drawable/car"
   android:tileMode="repeat"/>

NinePatchDrawable

.9.png 图片资源

这是一种比较高端的 Drawable。其实就是九宫贴图——这种 Drawable 契合了 Android 中的 “.9.png” 文件。这种图片资源的特点在于:

  1. 在一张普通的 png 图片四周,分别向外扩展了一个像素;
  2. 用这些扩展的像素,可以描边,描边用来规定可缩放区域和内容padding区域;
.9.png 的扩展区域

比如我们现在有一张 .9.png 图片如下:

pic

我们在四周看到了一像素的黑点,这些黑点分别在四周围成四个边线。四个圆角处都是透明的。那么,左、上两条边规定了当按钮被缩放时的可缩放区域。比如下面红色边框圈出的矩形内的区域,就是可缩放区域,这个区域外的区域,在执行缩放时均保留原来的像素比例。
patch

比如一个按钮各个角度拉伸,都可以保留圆角的圆润,而不会发生锯齿或者糊掉。我们的布局文件如下:

<LinearLayout
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
   android:orientation="horizontal">
   
   <Button
       android:layout_width="100dp"
       android:layout_height="40dp"
       android:background="@drawable/btn_normal"/>

   <Button
       android:layout_width="150dp"
       android:layout_height="80dp"
       android:layout_marginLeft="10dp"
       android:background="@drawable/btn_normal"/>
</LinearLayout>

StateListDrawable

这个 Drawable 类型几乎是我们开发中最常用的类型了,为什么呢?因为它是根据一系列的状态来控制绘制表现的,这一系列状态契合了我们界面控件的各个状态。界面控件的状态一般有:获取焦点、失去焦点、普通状态、按下状态、可点击状态、不可点击状态、选中状态、未选中状态、勾选状态、未被勾选状态、激活状态、未被激活状态等等。

StateListDrawable 以 <selector> 作为根节点:

selector
   |- item
   |    |- drawable="@drawable/drawable_id"
   |    |- state_pressed="[true | false]"
   |    |- state_focused="[true | false]"
   |    |- state_selected="[true | false]"
   |    |- state_hovered="[true | false]"
   |    |- state_checked="[true | false]"
   |    |- state_checkable="[true | false]"
   |    |- state_enabled="[true | false]"
   |    |- state_activated="[true | false]"
   |    |- state_window_focused="[true | false]"
   |
属性 说明
android:drawable 引用的 Drawable 位图, 把放到最前面,就表示组件的正常状态
android:state_focused 是否获得焦点
android:state_window_focused 是否获得窗口焦点
android:state_enabled 控件是否可用
android:state_checkable 控件可否被勾选,比如 checkbox
android:state_checked 控件是否被勾选
android:state_selected 控件是否被选择,针对有滚轮的情况
android:state_pressed 控件是否被按下
android:state_active 控件是否处于活动状态,eg:slidingTab
android:state_single 控件包含多个子控件时,确定是否只显示一个子控件
android:state_first 控件包含多个子控件时,确定第一个子控件是否处于显示状态
android:state_middle 控件包含多个子控件时,确定中间一个子控件是否处于显示状态
android:state_last 控件包含多个子控件时,确定最后一个子控件是否处于显示状态

Bitmap/BitmapFactory

关于 Bitmap

在Android中 Bitamp 指的就是一张图片,一般是 png 和 jpeg 格式。
Bitmap 类中有一个 enum 类型的 Config ,其中有4个值

  • ALPHA_8
    8位位图;1 个字节,只有透明度,没有颜色值

  • RGB_565
    16位位图;2 个字节,r = 5,g = 6,b = 5,一个像素点 5+6+5 = 16

  • ARGB_4444
    16位位图;2 个字节,a = 4,r = 4,g = 4,b = 4,一个像素点 4+4+4+4 = 16

  • ARGB_8888
    32 位位图; 4个字节,a = 8,r = 8,g = 8, b = 8,一个像素点 8 + 8 + 8 + 8 = 32
    一张 1024 * 1024 像素,采用 ARGB8888 格式,一个像素32位,每个像素就是4字节,占有内存就是4M。
    若采用 RGB565 ,一个像素16位,每个像素就是2字节,占有内存就是2M。
    Glide 加载图片默认格式 RGB565 , Picasso 为 ARGB8888 ,默认情况下, Glide 占用内存会比 Picasso 低,色彩不如 Picasso 鲜艳,自然清晰度就低

  • BitmaFactory

Creates Bitmap objects from various sources, including files, streams, and byte-arrays.

通过 BitmapFactory 从文件系统,资源,输入流,字节数组中加载得到一个 Bitmap 对象。

  • decodeByteArray()
  • decodeFile()
  • decodeResource()
  • decodeStream()
  • decodeFileDescriptor()
  • decodeResourceStream()
    BitmapFactory 所有 public method 都是静态方法。一共也就6个方法,后两个用的几率不如前4个高。

Bitmap 的高效加载

核心思想: 利用 BitmapFactory.Options 来加载实际所需的尺寸

BitmapFactory.Options

这个类中只有一个方法 requestCancelDecode() ,剩下全是一些常量值

BitmapFactory.Options 缩放图片主要用到 inSample 采样率

inSample = 1 ,采样后图片的宽高为原始宽高
inSample > 1 ,例如2,宽高均为原图的宽高的1/2

一个采用 ARGB8888 的 1024 * 1024 的图片
inSample = 1 ,占用内存就 1024 * 1024 * 4 = 4M
inSample = 2 ,占用内存就 512 * 512 * 4 = 1M

缩小规律就是: 1 /(inSample ^ 2)

inSample 的值最小为1,低于1时无效的。 inSample 的值最好为2,4,8,16,2的指数。在某些时候,系统会向下取整,例如为3时,系统会用2来代替。2 的指数,可以一定程度上避免图片拉伸变形。

获取采样率的流程

以读取资源文件为例:

  1. 创建 BitmapFactory.Options 对象 options
  2. 将 options 的 inJustDecodeBounds 参数设为 true ,然后使用 BitmapFactory.decodeResource(res,resId,options) 加载图片
  3. 利用 options 取出图片的原始宽高信息, outWidth,outHeight
  4. 根据采样率的规则并结合实际需要显示的宽高计算出 inSample
  5. 将 options 的 inJustDecodeBounds 参数设为 false ,并再次使用 BitmapFactory.decodeResource(res,resId,options) 返回采样后的 Bitmap

inJustDecodeBounds 设为 true , BitmapFactory 只会解析图片的原始信息,并不会真正的加载图片

BitmapFactory 读取图片的宽高的信息受图片所在 drawable 文件夹和设备屏幕本身的像素密度影响。

public class MainActivity extends AppCompatActivity {

   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       setContentView(R.layout.activity_main);
       initView();
   }

   private void initView() {
       final ImageView iv = (ImageView) findViewById(R.id.iv_main);
       iv.post(new Runnable() {
           @Override
           public void run() {
               int width  = iv.getWidth();
               int height = iv.getHeight();
               iv.setImageBitmap(decodeBitmap(getResources(),R.drawable.test,width,height));
           }
       });
   }

   /**
    * 对图片进行压缩
    *
    * @param res
    * @param resId
    * @param targetWidth
    * @param targetHeight
    * @return
    */
   private Bitmap decodeBitmap(Resources res , int resId, int targetWidth, int targetHeight){
       final BitmapFactory.Options options = new BitmapFactory.Options();
       options.inJustDecodeBounds = true;
34行    options.inPreferredConfig = Bitmap.Config.RGB_565;//将Config设为RGB565
       BitmapFactory.decodeResource(res,resId,options);
       options.inSampleSize = calculateInSample(options,targetWidth,targetWidth);
       options.inJustDecodeBounds = false;
       return BitmapFactory.decodeResource(res,resId,options);
   }

   /**
    * 计算inSample
    *
    * @param options
    * @param targetWidth
    * @param targetHeight
    * @return
    */

   private int calculateInSample(BitmapFactory.Options options, int targetWidth, int targetHeight) {
       final int rawWidth  = options.outWidth;
       final int rawHeight = options.outHeight;
       int inSample = 1;
54行    if (rawWidth > targetWidth || rawHeight > targetHeight){
           final int halfWidth  = rawWidth / 2;//为了避免过分压缩 例如 图片大小为 250 * 250 view 200 * 200
           final int halfHeight = rawHeight / 2;
57行        while((halfWidth / intSample) >= targetWidth && (halfHeight / intSample) >= targetHeight){
               inSample *= 2;
           }
       }
       return inSample;
   }
}

代码就是按照流程走的。 只是加入了34行 Bitmap 色彩格式的修改

34行,通过 options 把 Bitmap 的格式设为 RGB565 。设置成 RGB565 后,占用内存会少一半,也会减少 OOM 。个人感觉,除非是专门的图像处理app,大部分时候都可以用 RGB565 代替 ARGB8888 ,牺牲图像的清晰度,换来一半的占用内存,个人感觉还是比较划算的。并且,清晰度的差别,不同时放在一起比较是不会有很大直观差别的。

Bitmap 中的方法

主要是查看api文档,想了解下都有哪些方法

compress 方法

  • compress(Bitmap.CompressFormat format, int quality, OutputStream stream)

将 bitmap 数据质量压缩并转换成流,若 format 参数设置为了 png 格式, quality 设置无效

  • format 图片的格式,支持3种 JPEG , PNG , WEBP
  • quality 压缩质量压缩率,0-100,0表示压缩程度最大,100为原质量,但 png 无效
  • stream 输出流
  • 返回值, boolean
private void initView() {
   Bitmap bitmap =  BitmapFactory.decodeResource(getResources(),R.drawable.cc);
   ByteArrayOutputStream outputStream =  new ByteArrayOutputStream(1024 * 8);
   bitmap.compress(Bitmap.CompressFormat.PNG,100,outputStream);
   BitmapFactory.Options options = new BitmapFactory.Options();
   options.inPreferredConfig = Bitmap.Config.RGB_565;
   bitmap =BitmapFactory.decodeByteArray(outputStream.toByteArray(),0,outputStream.size(),options);
   Log.e(TAG,"++"+outputStream.size()+","+bitmap.getConfig().name()+","+bitmap.getByteCount()+","+bitmap.getHeight()+","+bitmap.getWidth());
   }

在变换成输出流的过程中,把 Bitmap 的 Config 变为了 RGB565 ,这里遇到个情况, mipmap 文件夹下的图片,这种方法并不能改变其 Config ,一直都是默认 ARGB8888

copy方法

  • copy(Bitmap.Config config, boolean isMutable)

拷贝一个Bitmap的像素到一个新的指定信息配置的Bitmap

  • config 配置信息
  • isMutable 是否支持可改变可写入
  • 返回值, bitmap ,成功返回一个新的 bitmap ,失败就 null

简单实用:

private void initView() {
   Bitmap bitmap =  BitmapFactory.decodeResource(getResources(),R.drawable.m);
   bitmap = bitmap.copy(Bitmap.Config.RGB_565,true);
   Log.e(TAG,"++"+bitmap.getConfig().name()+","+bitmap.getByteCount()+","+bitmap.getHeight()+","+bitmap.getWidth());
}

Canvas

image

简介

  • 定义: Canvas(画布),是一种绘制时的规则。

是安卓平台2D图形绘制的基础

  • 作用:规定绘制内容时的规则&内容。
  1. 绘制内容时根据画布的规定绘制在屏幕上的
  2. 理解为:画布只是绘制时的规则,但内容实际上是绘制在屏幕上的。

Canvas的本质

  • 绘制内容是根据画布(Canvas)的规定 绘制在屏幕 上的
  • 画布(Canvas)只是绘制时的规则,但 内容实际上是绘制在屏幕上的
    为了更好地说明绘制内容的本质和Canvas,请看下面例子:

实例

  • 实例情况:先画一个矩形(蓝色);然后移动画布;再画一个矩形(红色)
  • 代码分析:
最后编辑于 :
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

  • ORA-00001: 违反唯一约束条件 (.) ORA-00017: 请求会话以设置跟踪事件 ORA-00018:...
    小白白程序猿阅读 2,173评论 0赞 0
  • 3D打印新技术推动传统古建文化传承 来源:科技日报 2021-09-15 08:40 分享 赏心悦目的中国古建筑模...
    水来木生阅读 261评论 0赞 0
  • 一、冒泡排序 1、什么是排序,应用广泛吗? 排序: 就是对一组具备可比性的数组进行重新排列顺序(升序 or 降序)...
    望穿秋水小作坊阅读 583评论 0赞 0
  • 🔥5.20静安支行🔥 #五星之争“静”请期待# 🌻静安支行零售及会计条线在晨会中整肃仪容,整装待发,创建辅导顾问陈...
    SugarHsu阅读 573评论 0赞 0
  • 16宿命:用概率思维提高你的胜算 以前的我是风险厌恶者,不喜欢去冒险,但是人生放弃了冒险,也就放弃了无数的可能。 ...
    yichen大刀阅读 9,400评论 0赞 4

友情链接更多精彩内容