Android笔记

Android

系统架构

Android 软件堆栈

Linux内核层

  • Android平台的基础是Linux内核。例如:Android Runtime(ART)依靠Linux内核来执行底层功能,例如线程和底层内存管理。

  • 使用Linux内核可让Android利用主要安全功能,并且允许设备制造商为著名的内核开发硬件驱动程序

硬件抽象层(HAL)

  • 硬件抽象层层(HAL)提供标准界面,向更高级别的JAVA API框架显示设备硬件功能。HAL包含多个库模块。其中每个模块为特定类型的硬件组件实现一个界面,例如相机或蓝牙模块。当框架API要求访问设备硬件时,Android将为该硬件组件加载库模块

系统运行库层

  • Android Runtime
    • 对于运行Android5.0(API级别21)或更高版本的设备,每个应用都在其自己的进程中运行,并且有其自己的Android Runtime(ART)实例。ART编写为通过执行DEX文件在低内存设备上运行多个虚拟机,DEX文件是一种专为Android设计的字节码格式,经过优化,使用的内存很少。编译工具链(例如Jack)将Java源代码编译为DEX字节码,使其可在Android平台运行
    • ART的部分主要功能包括:
      • 预先(AOT)和即时(JIT)编译
      • 优化的垃圾回收(GC)
      • 在Android 9 (API级别28)及更高版本的系统中,支持将应用软件包中的Dalvik Executable 格式 (DEX) 文件转换为更紧凑的机器代码。
      • 更好的调试支持,包括专用采用分析器、详细的诊断报告和崩溃报告,并且能够设置观察点以监控特定字段
    • 在 Android 版本 5.0(API 级别 21)之前,Dalvik 是 Android Runtime。如果您的应用在 ART 上运行效果很好,那么它应该也可在 Dalvik 上运行,但反过来不一定
    • Android还包含一套核心运行时库,可提供Java API框架所使用的Java编程语言中的大部分功能, 包括一些Java8语言功能
  • 原生C/C++库
    • 许多核心 Android 系统组件和服务(例如 ART 和 HAL)构建自原生代码,需要以 C 和 C++ 编写的原生库。Android 平台提供 Java 框架 API 以向应用显示其中部分原生库的功能。例如,您可以通过 Android 框架的 Java OpenGL API 访问 OpenGL ES,以支持在应用中绘制和操作 2D 和 3D 图形。
    • 如果开发的是需要 C 或 C++ 代码的应用,可以使用 Android NDK 直接从原生代码访问某些原生平台库。

JAVA API 框架

  • 你可以通过JAVA语言编写的Android OS的整个功能集。这些API形成创建Android应用所需的构建块,他们可以简化核心模块化系统组件和服务的重复使用,包括以下组件和服务:

    • 丰富、可拓展的视图系统,可用以构建应用的 UI,包括列表、网格、文本框、按钮甚至可嵌入的网络浏览器
    • 资源管理器,用于访问非代码资源,例如本地化的字符串、图形和布局文件
    • 通知管理器,可让所有应用在状态栏中显示自定义提醒
    • Activity管理器,用于管理应用的生命周期,提供常见的导航返回栈
    • 内容提供程序,可让应用访问其他应用(例如“联系人”应用)中的数据或者共享其自己的数据

    开发者可以完全访问 Android 系统应用使用的框架 API

系统应用

  • Android 随附一套用于电子邮件、短信、日历、互联网浏览和联系人等的核心应用。平台随附的应用与用户可以选择安装的应用一样,没有特殊状态。因此第三方应用可成为用户的默认网络浏览器、短信 Messenger 甚至默认键盘(有一些例外,例如系统的“设置”应用)。

  • 系统应用可用作用户的应用,以及提供开发者可从其自己的应用访问的主要功能。例如,如果您的应用要发短信,您无需自己构建该功能,可以改为调用已安装的短信应用向您指定的接收者发送消息。

应用开发特色

四大组件

  • Activity
    • 是所有Android应用程序的门面,凡是在应用你看到的东西,都是放在activity中的
  • Service
    • 你无法看到他,但他会在后台默默的运行,即使用户退出了应用,service仍然是可以运行的
  • BroadcastReceiver允许你的应用接受来自各处的广播信息,比如电话、短信等,当然你的应用也可以向外发出广播信息
  • ContentProvider
    • 是为应用程序之间共享数据提供了可能,比如你想要好读取系统通讯录中的联系人,就需要通过ContentProvider来实现

丰富的系统控件

  • Android系统为开发者提供了丰富的系统控件,使得我们可以很轻松的编写出漂亮的界面,当然如果你品味比较高,不满足系统自带的空间效果,完全可以定制属于自己的控件

SQLite数据库

  • Android系统还自带了这种轻量级、运算速度极快的嵌入式关系型数据库,他不仅支持标准的SQL语法,还可以通过Android封装好的API记性操作,让存储和读取数据变得非常方便

强大的多媒体

  • Android系统还提供了丰富的多媒体服务,如音乐、视频、录音、拍照等,这一切你都可以在程序中通过代码进行控制,让你的应用变得更加丰富多彩

项目结构

image-20210717184629920

  • .gradle和.idea
    • 这两个目录下放置的是都是Android Studio自动生成的一些文件,我们无须关心他,也不要去手动编辑
  • app
    • 项目中的代码、资源等内容都是放置在这个目录下的,我们后面的开发工作也基本是在这个目录下进行的
  • build
    • 这个目录重要包含了一些在编译时自动生成的文件,你也不需要过多关心
  • gradle
    • 这个目录下包含了gradle wrapper的配置文件,使用gradle wrapper的方式不需要提前将gradle下载好,而是会自动根据本地的缓存情况决定是否需要联网下载gradle。Android Studio默认就是启动gradle wrapper方式的
  • .gitignore
    • 这个文件是用来将指定的文件活目录排除在版本控制之外的
  • build.gradle
    • 这是项目全聚德gradle构建脚本,通常这个文件中的内容是不需要修改的
  • gradle.properties
    • 这个文件是全局的gradle配置文件,在这里配置的属性将会影响到项目中的所有的gradle编译脚本
  • gradlew和gradlew.bat
    • 这两个文件是用来命令行界面中执行gradle命令的,其中gradlew是在linux活mac系统中使用的,gradlew.bat是在windows中使用的
  • HelloWorld.iml
    • iml文件是所有IntelliJ IDEA项目都会自动生成的一个文件(Android Studio是基于IDEA开发的),用于表示这是一个IntelliJ IDEA项目,我们不需要修改这个文件中的任何内容
  • local.properties
    • 这个文件用于制定本机中的Android SDK路径,通常内容是自动生成的,我们并不需要修改,除非你本机中的Android SDK位置发生了变化,那么就将这个文件中的路径改成新的位置即可
  • settings.gradle
    • 这个文件用于指定项目中所用引入的模块,由于HelloWorld项目中只有一个app模块,因此该文件中也就之引入了app这个一个模块,通常情况下,模块的引入是自动完成的,需要我们手动修改这个文件得到场景可能比较少

image-20210717184704648

  • build
    • 这个目录和外层的build目录类似,也包含了一些在编译是自动生成的文件,不过它里面的内容会更加复杂,我们不需要过多关心
  • libs
    • 如果你的项目中使用到了第三方jar包,就需要把这些jar包都放在libs目录下,放在这个目录下的jar包会被自动添加到项目的构建路径里
  • androidTest
    • 此处是用来编写Android Test测试用例的,可以对项目进行一些自动化测试
  • java
    • 毫无疑问,java目录是放置我们所有Java代码的地方(Kotlin代码也放在这里),展开该目录,你将看到系统帮我们自动生成了一个MainActivity文件
  • res
    • 这个目录下的内容就有点多了,简单点数哦,就是你在项目中使用到的所有图片、布局、字符串等资源都要存放在这个目录下,当然这个目录下还有很多子目录
      • 图片放在drawable目录下
      • 布局放在layout目录下
      • 字符串放在values目录下
    • 所以你不用担心会把整个res目录弄得乱七八糟
  • AndroidMainfest.xml
    • 这是整个Android项目的配置文件,你在程序重定义的所有四大组件都需要在这个文件里面注册,另外还可以在这个文件给应用程序添加权限声明
  • test
    • 此处是用来编写Unit Test测试用例的,是对项目进行自动化测试的另一种方式
  • .gitignore
    • 这个文件用于将app模块内制定的目录或文件排除在版本控制之外,作用和外层的.gitignore文件类似
  • app.iml
    • IntelliJ IDEA项目自动生成的文件,我们不需要关心或修改这个文件的内容
  • build.gradle
    • 这是app模块的gradle构建脚本,这个文件会指定很多项目构建将相关的配置
  • proguard-rules.pro
    • 这个文件用于指定项目代码的混淆规则,当代码开发完成以后打包成安装包问价你,如果不希望代码被别人破解,荣昌会将代码进行混淆,从而让破解者难以阅读

项目中的资源

  • 所有以drawable开头的目录都是用来放图片的
  • 所有以mipmap开头的目录都是用来放应用图标的
  • 所有以values开头的目录都是用来放字符串、样式、颜色等配置的
  • 所有以layout开头的目录都是用来放布局文件的

image-20210717184731513

  • 之所以这么多的mipmap开头的目录,其实主要是为了让程序能够更好的兼容各种设备。drawable目录也是相同的道理,虽然Android Studio没有帮我们自动生成,但是我们也应该自己创建drawable-hdpi, drawable-xhdpi, drawable-xxhdpi等目录,在制作程序的时候,最好能够给同一张图片提供几个不同分辨率的版本,分别放在这些目录下,然后程序运行的时候,会自动根据当前运行设备分辨率的高低选择家在哪个目录下的图片。当然这只是理想情况,更多时候美工只会提供给我们一份图片,这是你把所有图片都放在drawable-xxhdpi目录下就行了,因为这是最主流的设别分辨率目录

  • 下面来看下如何使用这些资源

    • <resources>
      	<string name="app_name">hello world</string>
      </resources>
      
    • 可以看到这里定义了一个应用程序名的字符串,我么你有以下两种方式来引用它

      • 在代码中通过R.string.app_name可以获得该字符串的引用
      • 在XMl中通过@string/app_name可以获得该字符串的引用
    • 基本语法就是上面这两种方式,其中string部分是可以替换的,如果是引用的图片资源就可以替换成drawable,如果是引用的应用图标就可以替换成mipmap,如果是引用的布局文件,就可以替换策划给你layout,以此类推

Activity

Activity是什么

  • Activity是最吸引用户的地方,它是一种可以包含用户界面的组件,主要用于和用于进行交互,一个应用程序中可以包含零个或多个Activity,但不包含Activity的应用程序很少见

手动创建Activity

image-20210717185717443

  • 勾选Generate Layout File表示会自动为FirstActivity创建一个对应的布局文件

  • 勾选Launcher Activity表示会自动讲FristActivity设置为当前项目的主Activity

  • 此时不选择上述两者,得到文件代码如下:

    • class FirstActivity : AppCompatActivity(){
          override fun onCreate(savedInstanceState : Builde?){
              super.onCreate(SaceInstanceState)
          }
      }
      
  • 可以看到,onCreate()方法非常简单,就是调用了弗雷德onCreate()方法,当然这只是默认的实现,后面我们还需要在里面加入很多自己的逻辑

创建和加载布局

  • 前面我们说过,Android程序的设计讲究逻辑和视图分离,最好每一个Activity都能对应一个布局。布局是用来显示界面内容的,我们现在就来手动创建一个布局文件。
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:orientation="vertical"
              android:layout_width="match_parent"
              android:layout_height="match_parent">

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

</LinearLayout>
  • 其中android:id是给当前的元素定义了一个唯一的标识符,之后可以在代码中对这个元素进行操作。你可能会对@+id/button1这种语法感到陌生,但如果把加好去掉,变成@id/button1,你就会觉得熟悉了吧。这不就是XML中引用资源的语法吗?追不过是把string替换成了id。是的,如果你需要在XML中引用一个id,就是用@id/button这种语法,而如果你需要在XML中定义一个id,则要使用@+id/button1这种语法。

  • 在Activity中加载布局

    • class FirstActivity : AppCompatActivity(){
          override fun onCreate(saveInstanceState : Bundle?){
              super.onCreate(saceInstanceState)
              setContentView(R.layout.first_layout)
          }
      }
      
  • 可以看到,这里调用了setContentView方法来给当前的Activity家在一个布局,而在setContenView方法中,我们一般会传入一个布局文件的id,项目中添加的任何资源都会在R文件中生成一个相应的资源id,因此我们刚才创建的first_layout.xml布局的id现在已经提哦按驾到R文件中了。在代码中引用布局文件的方法,就是这样,只需要调用R.layout.first_layout就可以得到first_layout.xml布局的id,然后将这个值传入setContentView方法即可

在AndroidManifest文件中注册

  • 所有的Activity都要在ANdroidManifest.xml中进行注册才能生效,实际上FirstActivity已经在AndroidManisest.xml中注册过了。

  • <?xml version="1.0" encoding="utf-8"?>
    <manifest xmlns:android="http://schemas.android.com/apk/res/android"
              package="cn.wenhe9.helloworld">
    
        <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.HelloWorld">
            <activity android:name=".FirstActivity">
            </activity>
            <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>
    
  • <activity>标签中,我们使用了andoird:name来指定具体注册哪一个Activity,那么这里填入的.FristActivity是什么意思呢,其实这不过是cn.wenhe9.helloworld.FristActivity的缩写而已,由于在最外层的<manifest>标签中已经通过package属性智定乐程序的包名是cn.wenhe9.helloworld,因此咋注册Actitivy时,这一部分可以省略,直接使用.FristActivity即可

  • 为程序配置主Activity,在<activity>标签的内容部加入<intent-filter>标签,并在这个标签里添加<action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/>这两句声明即可

  • 除此之外,我们还可以使用android:label指定Activity中标题栏中的内容,标题栏是显示在Activity最顶部的,待会儿运行的时候,你就会看到,需要注意的是,给主Activity指定的label不仅会成为标题栏中的内容,还会成为启动器(Launcher)中应用程序显示的名称

  • 如果你的应用程序中没有声明任何一个人Activity作为主Activity,这个程序仍然是可以正常安装的,只是你无法在启动器中看到或者打开这个程序,这种程序一般是作为第三方服务供其他应用在内部进行调用的、

在Activity中使用Toast

  • Toast是ANdroid系统提供的一种非常好的提醒方式,在程序中可以使用他将一些短小的信息通知给用户,这些信息会在一段时间后自动消失,并且不会占用任何屏幕空间没我们现在就尝试一下如果在Activity中使用Toast

  • class FirstActivity : AppCompatActivity(){
        override fun onCreate(saveInstanceState : Bundle?){
            super.onCreate(saceInstanceState)
            setContentView(R.layout.first_layout)
            val button1 : Button = findViewById(R.id.button1)
            button1.setOnClickListener{
                Toast.makeTest(this, "你点击了一个按钮", Toast.LENGTH_SHORT).show()
            }
        }
    }
    
  • 这里需要注意的是,makeText()方法需要传入三个桉树,第一个参数是Context,也就是Toast要求的上下文,由于Activity本身就是一个Context对象,所以这里直接传入this即可,第二个参数是Toast显示的文本内容,第三个参数是Toast显示的时长,有两个内置常量可以选择:Toast.LENGTH_SHORTToast.LENGTH_LONG

在Activtiy中使用Menu

  • 手机毕竟和电脑个,他的屏幕空间非常有限,因此充分利用屏幕空间在手机界面设计中就显得非常重要了,如果你的Activity中有大量的菜单需要显示,界面设计就会比较尴尬,因为仅这些菜单就可能占据奖金三分之一的空间
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:id="@+id/add_item"
        android:title="Add">
    </item>
    <item
        android:id="@+id/remove_item"
        android:title="Remove">
    </item>
</menu>
  • 这里我们创建了两个菜单项,其中<item>标签用来创建具体的某一个菜单项,然后通过androidLid给这个菜单项制定一个唯一的标识符,通过android:title给这个菜单项制定一个名称

  • 接着回到FirstActivity中重写onCreateOptionsMenu()方法

    • override fun onCreateOptionsMenu(menu: Menu?): Boolean {
              menuInflater.inflate(R.menu.main, menu)
              return true
      }
      
  • menuInflater的inflate可以给当前Activity创建菜单,infalte()方法接受两个参数,第一个参数用于指定我们通过哪一个资源文件来创建菜单,第二个参数用于指定我们的菜单项添加到哪一个Menu对象中去,最后这个方法返回true,表示允许创建的菜单显示出来,如果返回了false,床架你的菜单讲无法显示

  • 菜单不是看的,所以还需要定义菜单响应事件

  • override fun onOptionsItemSelected(item: MenuItem): Boolean {
            when(item.itemId){
                R.id.add_item -> makeText(this, "点击了添加", Toast.LENGTH_SHORT).show()
                R.id.remove_item -> makeText(this, "点击了删除", Toast.LENGTH_SHORT).show()
            }
    
            return true
        }
    

销毁一个Activity

  • 按一下back键

  • 调用finish()

    • button1.setOnClickListener {
          finish()
      }
      

使用Intent在Activity之间穿梭

  • Intent是Android程序中各组件之间进行交互的一种重要方式,它不仅可以指明当前组件想要执行的动作,还可以在不同组件之间传递数据,Intent一般可用于启动Activity,启动Service,以及发送广播等场景
使用显式Intent
button1.setOnClickListener {
    val intent = Intent(this, FirstActivity::class.java)
    startActivity(intent)
}
  • 首先构建了一个Intent对象,第一个参数传入this,也就是FirstActivity作为上下文,第二个参数传入SecondActivity::class.java作为目标Activity,这样我们“意图“就非常明显了。注意,Kotlin中SecondActivity::class.java法就相当于Java中的SecondActivity.class的写法
使用隐式Intent
  • 相对于显示Intent,隐式Intent则含蓄了很多,他并不明确的指出想要启动哪一个Activity,而是指定了一系列更为抽象的action和category等信息,然后交由系统去分析这个Intent,并帮我们找出合适的Activity去启动

  • 什么叫做合适的Activity?简单来说就是可以响应这个隐式Intent的Activity

    • <activity android:name=".SecondActivity">
          <intent-filter>
              <action android:name="cn.wenhe9.testIntent"/>
              <category android:name="android.intent.category.DEFAULT"/>
          </intent-filter>
      </activity>
      
    • val intent = Intent("cn.wenhe9.testIntent")
      startActivity(intent)
      
  • 满足action和category即可,DEFAULT是默认的分类,在activity可以不去指定,指定的话,代码为:

    • val intent = Intent("cn.wenhe9.testIntent")
      intent.addCategory("cn.wenhe9.category")
      startActivity(intent)
      
更多隐式Intent的用法
  • 使用隐式Intent,不仅可以启动自己的程序内的Activity,还可以启动其他程序的Activity,这就使得多个应用程序之间的功能共享成为了可能比如你的应用程序需要展示一个网页,这时你没有必要去自己实现一个浏览器,只需要调用系统的浏览器来打开这个网页就可以了

  • button1.setOnClickListener{
        val intent = Intent(Intent.ACTION_VIEW)
        intent.data = Uri.parse("https://www.baidu.com")
    	startActivity(intent)
    }
    
  • 可以在<intent-filter>标签中再配置一个<data>标签,用于更精确的指定当前Activity能够响应的数据,<data>标签中主要可以配置以下内容:

    • android:scheme
      • 用于指定数据的协议部分,如上例的https部分
    • andorid:host
      • 用于指定数据的主机名部分,如上例的www.baiduc.com部分
    • andorid:port
      • 用于指定数据的端口部分,一般紧随在主机名之后
    • andorid:path
      • 用于指定主机名和端口之后的部分,如一段网址中跟在域名之后的内容
    • ``andorid:mimeType`
      • 用于指定可以处理的数据类型,允许使用通配符的方式进行指定
  • 只有当<data>标签中指定的内容和Intent中携带的Data完全一致时,当前的Activity才能够响应该intent,不过在<data>标签中一般不会指定过多的内容,例如在上面的浏览器示例中,其实只需要指定andorid:schemehttps,就可以响应所有https协议的intent了

    • <activity android:name=".ThirdActivity">
          <intent-filter tool:ignore="AppLinkUrlError">
              <action android:name="android.intent.action.VIEW"/>
              <category android:name="android.intent.category.DEFAULT"/>
              <data android:scheme="https"/>
          </intent-filter>
      </activity>
      
    • image-20210923203520016

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

    • button1.setOnClickListener{
          val intent = Intent(Intent.ACTION_DIAL)
          intent.data = Uri.parse("tel:10086")
          startActivity(intent)
      }
      
    • image-20210923204305535

向下一个Activity传递数据
  • Intent中提供了一系列的putExtra()方法的重载,可以把我们想要传递的数据暂存在Intent中,在启动另一个Activity后,只需要把这些数据从Intent中取出就可以了。在这里,第一个参数是键,用于之后从 Intent中取值,第二个参数才是真正要传递的数据

    • button1.setOnclickListener{
          val data = "hello world"
          val intent = Intent(this, SecondActivity::class.java)
          intent.putExtra("extra_data", data)
          startActivity(intent)
      }
      
    • class SecondActivity : AppCompatActivity() {
          override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
              setContentView(R.layout.activity_second)
      
              val extraData = intent.getStringExtra("data_extra")
              Log.d("SecondActivity", "extra data is $extraData")
          }
      }
      
返回数据给上一个Activity
  • 返回上一个Activity和返回数据给下一个Activity不同,返回上一个Activity只需要按一下Back键就可以了,并没有一个用于启动Activity的intent来传递数据,Activity类中有一个用于启动Activity的startActivityForResult()方法,但他期望在Activity销毁的时候能够返回一个结果给上一个Activity

  • startActivityForResult方法接收两个参数,第一个参数还是intent,第二个参数是请求吗,用于在之后的回调中判断数据的来源,请求码需要是一个唯一的

    • button1.setOnclickListener{
          val intent = Intent(this, SencondActivity::class.java)
      	startActivityForResult(intent, 1)
      }
      
    • binding.button2.setOnClickListener {
          val intent = Intent()
      
          intent.putExtra("data_return", "hello firstActivity")
          setResult(RESULT_OK, intent)
          finish()
      }
      
  • 第二个代码中,Intent只是用于传递数据,没有指定任何意图,把要传递的数据存放在Intent中,然后调用了SetResult()方法,这个方法用于向上一个Activity返回数据,这个方法接收两个参数,第一个参数用于向上一个Activity返回处理结果,一般只使用RESULT_OKRESULT_CANCELED这两个值;第二个参数则把带有数据的Intent传递回去。最后调用```finish()``方法来销毁当前Activity

  • 因为最后使用startAcitivityForResult方法来启动SecondACtivity的,在SecondActivity被销毁之后,会回调上一个Activity的onAcitivityResult()方法

    • override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
          super.onActivityResult(requestCode, resultCode, data)
      
          when(requestCode){
              1 -> if (resultCode == Activity.RESULT_OK){
                  val returnData = data?.getStringArrayExtra("data_return")
                  Log.d("FirstActivity", "returned data is  $returnData")
              }
          }
      }
      
  • onActivityResult()方法带有三个参数,第一个参数requetCode,即我们启动Activity时传入的请求码,第二个参数resultCode,即我们在返回数据时传入的处理结果,第三个参数data,即携带着返回数据的Intent,由于在一个Activity中有可能启动很多不同的Activity,每一个Activity返回的数据都会回调到onActivityResult这个方法中,因此,首先需要做的就是检查requestCode的值来判断数据来源,确定数据是从secondActivity返回的之后,再通过resultCode的值来判断处理结果是否成功。最后从data取值并打印出来,这样就完成了向上一个Activity返回数据的工作

  • 如果,在SecondActivity中不是点击按钮,而是点击返回键Back返回的话,之前的返回数据的代码将执行不到,解决办法是,重写SecondActivity的onBackPressed()方法,在这里处理逻辑

    • override fun onBackPressed() {
          val intent = Intent()
      
          intent.putExtra("return_data", "hello world")
      
          setResult(RESULT_OK, intent)
          finish()
      }
      

Activity的生命周期

返回栈
  • Android是使用任务(Task)来管理Activity的,一个任务就是一组存放在栈里的Activity的集合,这个栈也被称为返回栈(back stack)。栈是一种后进先出的数据结构,在默认情况下,每当我们启动了一个新的Activity,他就会在返回栈中入栈,并处于栈顶的位置,而每当我们按下Back键或调用finish()方法去销毁一个Activity时,处于栈顶的Activity就会出栈,前一个入栈的Activity就会重新处于栈顶的位置,系统总是显示处于栈顶的Activity给用户
  • image-20210923212324220
Activity状态
  • 每个Activity在其生命周期中最多可能会有四种状态

  • 运行状态

    • 当一个Activity位于返回栈的栈顶时,Activity就处于运行状态。系统最不愿意回收的就是处于运行状态的Activity
  • 暂停状态

    • 当一个Activity不再处于栈顶位置,但仍然可见时,Activity就进入了暂停状态。
    • 为什么既然Activity已经不在栈顶了,怎么会可见呢?
      • 这是因为并不是每一个Activity都会占满整个屏幕,比如对话框形式的Activity只会占用屏幕中间的部分区域
    • 处于暂停状态的Activity仍然是完全存活着的,系统也不愿意回收这种Activity(因为他还是可见的,回收可见的东西都会在用户体验方面有不好的影响),只有在内存极低的情况下,系统才会考虑回收这种Activity
  • 停止状态

    • 当一个Activity不再处于栈顶位置,并且完全不可见的时候,就进入了停止状态, 系统仍然会为这种Activity保存相应的状态和成员变量,但是这并不是完全可靠的,当其他地方需要内存时,处于停止状态的Activity有可能会被系统回收
  • 销毁状态

    • 一个Activity从返回栈中移除后就变成了销毁状态。系统最倾向于回收这种状态的
      Activity,以保证手机的内存充足
Activity的生存期
  • Activity类中定义了7个回调方法,覆盖了Activity生命周期的每一个环节
    • onCreate()
      • 他会在Activity第一次被创建的时候调用,在这个方法中完成Activity的初始化操作,比如加载布局,绑定事件等
    • onStart()
      • 这个方法在Activity由不可见变为可见的时候调用
    • onResume()
      • 这个方法在Activity准备好和用户进行交互的时候调用,此时的Activity一定位于返回栈的栈顶,并且处于运行状态
    • onPause()
      • 这个方法在系统准备去启动或者恢复另一个Activity的时候调用,我们通常会在这个方法中将一些消耗CPU的资源释放掉,以及保存一些关键数据,但这 个方法的执行速度一定要快,不然会影响到新的栈顶Activity的使用
    • onStop()
      • 这个方法在Activity完全不可见的时候调用,他和onPause()方法的区别在于,如果启动的新Activity是一个对话框式的Activity,那么onPause方法会得到执行,而onStop()方法并不会执行
    • onDestroy()
      • 这个方法在Activity被销毁之前调用,之后Activity的状态将变为销毁状态
    • onRestart()
      • 这个方法在Activity由停止状态变为运行状态之前调用,也就是Activity被重新启动了

以上方法除了onRestart方法,其他都是两两相对的,从而又可以将Activity分为一下三种生存期

  • 完整生存期
    • Activity在onCreate()方法和onDestroy()方法之间所经历的就是完整生存期。一般情况下,一个Activity会在onCreate()方法中完成各种初始化操作,而在onDestroy()方法中完成释放内存的操作。
  • 可见生存期
    • Activity在onStart()方法和onStop()方法之间所经历的就是可见生存期。在可见生存期内,Activity对于用户总是可见的,即便有可能无法和用户进行交互。我们可以通过这两个方法合理地管理那些对用户可见的资源。比如在onStart()方法中对资源进行加载,而在onStop()方法中对资源进行释放,从而保证处于停止状态的Activity不会占用过多内存。
  • 前台生存期
    • Activity在onResume()方法和onPause()方法之间所经历的就是前台生存期。在前台生存期内,Activity总是处于运行状态,此时的Activity是可以和用户进行交互的,我们平时看到和接触最多的就是这个状态下的Activity。

image-20210923223847936

Activity被回收的数据保存
  • 使用onSaveInstanceState()方法,这个方法保证在Activity被回收之前一定会被调用,onSaveInstanceState()方法会携带一个Bundle类型额参数,Bundle提供了一系列用于保存数据的方法,和之前的intent语法一样

    • override fun onSaveInstanceState(outState: Bundle) {
          super.onSaveInstanceState(outState)
          val tempData = "something you just typed"
          outState.putString("data_key", tempData)
      }
      
  • 取得数据是在onCreate方法,这个方法其实也有一个Bundle类型的参数,这个参数在一般情况下都是null,但是如果在Activity被系统回收之前,通过saveInstanceState()方法保存数据,这参数就会带有之前保存的全部数据,只需要使用相应的取值方法取出即可

    • override fun onCreate(savedInstanceState: Bundle?) {
          super.onCreate(savedInstanceState)
          setContentView(R.layout.activity_third)
      
          savedInstanceState?.let {
              val str = it.getString("data_key")
              Log.d("data", "tempData $str")
          }
      }
      

Activity的启动模式

  • 启动模式一共有四种,分别是standardsingleTopsingleTasksingleInstance,可以在AndroidManifset.xml中通过给<activity>标签指定能够android:launchModel属性来选择启动模式
standard
  • standard是Activity默认的启动模式,在不进行显示指定的情况下,所有Activity都会自动使用这种模式
  • standard模式下,每当启动一个新的Activity,他就会在返回栈中入栈,并处于栈顶的位置,对于使用standard模式的Activity,系统不会在乎这个Activity是否已经在返回栈中存在,每次启动都会创建一个Activity的新实例
  • standard模式的原理如图:
    • image-20210924182629608
singleTop
  • 当Activity的启动模式指定为singleTop,在启动Activity时如果发现返回栈的栈顶已经是该Activity,则认为可以直接使用它,不会创建新的Activity实例
  • singleTop模式的原理如图:
    • image-20210924182611955
singleTask
  • 当Activity的启动模式指定为singleTask,每次启动该Activity时,系统首先会在烦恼会展中检查是否存在该Activity的实例,如果发现已经存在则直接使用该实例,并把在这个Activity之上的所有其他Activity统统出栈,如果没有发现就会创建一个新的Activity的实例

  • singleTask模式的原理如图:

    • image-20210924183131813
singleInstance
  • singleInstance模式下,会有一个单独的返回栈来管理这个Activity,不管是哪个应用程序来访问这个Activity,都共用一个返回栈。
  • 在一个前三种模式的Activity调用一个singleInstance模式的Activity,又在或者调用一个前者模式的Activity,那么第一个和第三个Activity是在同一个返回栈里,第二个是在一个单独的返回栈中,当在第三个Activity点击了Back按钮后,会直接从第三个回到的第一个Activity,这是因为在这个返回栈里只有一和三,三出栈后就是一了,当在一又点击了Back后,会进入二的Activity,这是因为在一和三的返回栈里在一出栈后已经没有Activity了,于是就显示另一个返回栈的栈顶Activity,最后再按下back键,这时所有的返回栈都为空了,也就自然的退出程序了
  • singleInstance模式的原理如图:
    • image-20210924184213574

Fragment

静态添加Fragment

  1. 编写布局文件

  2. 编写自定义Fragment类继承自Fragment,在onCreateView中加载布局

    • class TestFragment : Fragment() {
          override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
              return inflater.inflate(R.layout.activity_third, container, false)
          }
      }
      
  3. 在另一个布局中引入fragment

    • <?xml version="1.0" encoding="utf-8"?>
      <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
                    android:orientation="vertical"
                    android:layout_width="match_parent"
                    android:layout_height="match_parent">
      
          <fragment
                  android:id="@+id/fragment1"
                  android:name="cn.wenhe9.testmenu.TestFragment"
                  android:layout_width="match_parent"
                  android:layout_height="match_parent"/>
      
      </LinearLayout>
      

动态引入Fragment

  • 创建待添加Fragment得到实例

  • 获取FragmentManager,在Activity中可以直接调用getSupportFragmentManager()方法获取

  • 开启一个事务,通过调用beginTransaction()方法开启

  • 向容器内添加或替换Fragment,一般使用replace()方法实现,需要传入容器的id和待添加的Fragment实例

  • 提交事务,调用commit()方法来完成

  •     fun replaceFragment(fragment: TestFragment){
            val fragmentManager = supportFragmentManager
    
            val transaction = fragmentManager.beginTransaction()
    
            transaction.replace(R.id.testFragment, fragment)
    
            transaction.commit()
        }
    

在Fragment中实现返回栈

  • 之前向Activity动态的添加了Fragment,当我们按下Back键程序就会直接退出,如果需要实现了类似返回栈的效果,可以使用FragmentTrasaction中提供了一个addToBackStack()方法,可以用于将一个事务添加到返回栈中。这个方法可以接收一个名字用于描述返回栈的状态,一般传入null即可

  • fun replaceFragment(fragment: TestFragment){
        val fragmentManager = supportFragmentManager
    
        val transaction = fragmentManager.beginTransaction()
    
        transaction.replace(R.id.testFragment, fragment)
    
        transaction.addToBackStack(null)
    
        transaction.commit()
    }
    

Fragment和Activity之间的交互

  • Activity调用Fragment方法,可以使用findViewById()或者视图绑定获取Fragment对象调用他的方法

  • Fragment中使用getActivity()方法获取Activity对象调用他的方法

    • if (activity != null){
          val mainActivity = activity as MainActivity
      }
      
  • 不同的Fragment之间进行通信,首先在一个Fragment中可以得到与他相关联的Activity,然后再通过这个Activity去获取另一个Fragment的实例,这样就实现了不同的Fragment之间的通信

Fragment的生命周期

Fragment的状态和回调
  • 运行状态

    • 当一个Fragment所关联的Activity正处于运行状态时,该Fragment也处于运行状态
  • 暂停状态

    • 当一个Activity进入暂停状态时(由于另一个未占满屏幕的Activity被添加到了栈顶),与它相关联的Fragment就会进入暂停状态
  • 停止状态

    • 当一个Activity进入停止状态时,与他相关联的Fragment就会进入停止状态,或者通过调用FragmentTransaction的remove()replace()方法将Fragment从Activity中移除,但在事务提交之前调用了addToBackStack()方法,这时的Fragment也会进入停止状。总的来说,进入停止状态的Fragment对用户来说是完全不可见的,有可能会被系统回收
  • 销毁状态

    • Fragment总是依附于Activity而存在,因此当Activity被销毁时,与他相关联的Fragment就会进入销毁状态,或者通过调用FragmentTransactionremove()replace()方将Fragment从Activity中移除,但在事务提交之前没有调用addToBackStack()方法,这时的Fragment也会进入销毁状态
  • onAttach():当Fragment和Activity建立关联时调用。

  • onCreateView():为Fragment创建视图(加载布局)时调用。

  • onActivityCreated():确保与Fragment相关联的Activity已经创建完毕时调用。

  • onDestroyView():当与Fragment关联的视图被移除时调用。

  • onDetach():当Fragment和Activity解除关联时调用。

  • image-20210924211529617

广播

广播的机制

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

接收系统广播

  • 我们可以根据自己的需要,自由的注册BroadcastReceiver,这样当有相应的广播发出时,相应的BroadcastReceiver就能够收到该广播,并可以在内部进行逻辑处理
  • 注册BroadcastReceiver的方式一般有两种:
    • 在代码中注册
    • AndroidManifest.xml中注册
  • 前者也被称为动态注册,后者也被称为静态注册
动态注册监听时间变化
  • 创建一个类继承自BroadcastReceiver,重写onReceive()方法,执行具体的逻辑

  • class MainActivity : AppCompatActivity() {
    
        private lateinit var binding : ActivityMainBinding
    
        private lateinit var timeChangeReceiver: TimeChangeReceiver
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
    
            binding = ActivityMainBinding.inflate(layoutInflater)
            setContentView(binding.root)
            
            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, "时间在流失", Toast.LENGTH_SHORT).show()
            }
        }
    }
    
  • 需要注意的是

    • 动态注册的BroadcastReceiver一定要取消注册才行,在onDestroy()方法中通过调用unregisterReceiver()方法来实现
静态注册实现开机启动
  • 动态注册的BroadcastReceiver可以自由的控制注册与注销,在灵活性有很多的有事,但是他存在着一个缺点,即必须在程序启动弄之后才能接收广播,因为注册的逻辑是写在onCreate()方法中的,而如果想要让程序在未启动的情况下也能接收到广播,就需要使用静态注册的方式

  • 在Android 8.0系统之后,所有隐式广播都不允许使用静态注册的方式接收了,隐式广播值得是那些没有具体制定发送给哪个应用程序的广播,大多数系统广播属于隐式广播,但是少数特殊的系统广播仍然允许使用静态注册的方式来接收

    • 可以从这个网址查看这些特殊的系统广播
      • https://developer.android.google.cn/guide/components/broadcast-exceptions.html
  • <?xml version="1.0" encoding="utf-8"?>
    <manifest xmlns:android="http://schemas.android.com/apk/res/android"
              xmlns:tool="http://schemas.android.com/tools"
              package="cn.wenhe9.testmenu">
    
        <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.TestMenu">
            <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>
    
  • 需要注意的是,如果程序需要进行一些对用户比较敏感的操作,必须在``AndroidManifest.xml`中进行权限声明,否则程序会直接崩溃

发送自定义广播

发送标准广播
  • binding.testBroadcast.setOnClickListener {
        val intent = Intent("cn.wenhe9.testmenu.MY_Broadcast")
        intent.setPackage(packageName)
        sendBroadcast(intent)
    }
    
  • 需要注意的是,静态注册的BroadcastReceiver是无法接收隐式广播的,而默认情况下,我们发出的自定义广播恰恰都是隐式广播,因此这里一定要调用setPackage()方法,指定这条广播是发送给哪个应用程序的,从而让他变成一条显示广播,否则静态注册的BroadcastReceiver将无法接收到这条广播

  • 当然,如果你的BroadcastReceiver是动态注册的,就不用了

发送有序广播
  • binding.testBroadcast.setOnClickListener {
        val intent = Intent("cn.wenhe9.testmenu.MY_Broadcast")
        intent.setPackage(packageName)
        sendOrderedBroadcast(intent, null)
    }
    
  • 设置优先级

    • 静态注册

      • <receiver
                  android:name=".MyBroadcastReceiver"
                  android:enabled="true"
                  android:exported="true">
            <intent-filter android:priority="100">
                <action android:name="cn.wenhe9.testmenu.MY_Broadcast"/>
            </intent-filter>
        </receiver>
        
    • 动态注册

      • val intentFilter = IntentFilter()
        intentFilter.addAction("android.intent.action.TIME_TICK")
        intentFilter.priority = 100
        timeChangeReceiver = TimeChangeReceiver()
        registerReceiver(timeChangeReceiver, intentFilter)
        
  • 截断 abortBroadcast()

    • class MyBroadcastReceiver : BroadcastReceiver() {
      
          override fun onReceive(context: Context, intent: Intent) {
            Toast.makeText(context, "自定义广播", Toast.LENGTH_SHORT).show()
              abortBroadcast()
          }
      }
      

持久化技术

文件存储

  • 文件存储是Android中最近本的数据存储方式,他不对存储的内容进行任何格式化处理,所有数据都是原封不动的把偶才能到文件中,因而他比较适合存储一些简单的文本数据或二进制数据
将数据存储到文件中
  • Context类中提供了一个opneFileOutput()方法,可以用于将数据存储到指定的文件中,这个方法接收两个参数

    • 第一个参数是文件名,在文件创建的时候使用,注意这里指定的文件名不可以包含路径,因为所有的文件都默认存储在/data/data/<package name>/files目录下
    • 第二个参数是文件的操作模式,主要有
      • MODE_PRIVATE
        • 默认是MODE_PRIVATE,表示当指定相同文件名的时候,所写入的内容会覆盖原文件中的内容
      • MODE_APPEND
        • 表示如果该文件已存在,就往文件里面追加内容,不存在就创建新文件
      • 其实文件的操作模式本来还有另外两种,MODE_WORLD_READABLEMODE_WORLD_WRITEABLE,这两种模式表示允许其他应用程序对我们程序的文件进行读写操作,不过由于这两种模式过于危险,很容易引起应用的安全漏洞,在Android 4.2版本中被废弃
  • openFileOutput()方法返回的是一个FileOutputSteram对象,得到这个对象之后就可以使用JAVA流的方式将数据写入文件中了

    • fun save(inputText : String){
          try {
              val output = openFileOutput("data", Context.MODE_PRIVATE)
      		val writer = BufferedWriter(OutputStream(output))
              write.use {
                  it.write(inputText)
              }
          }catch(e : IOException){
              e.printStackTrace()
          }
      }
      
从文件中读取数据
  • 类似于将数据存储到文件中,Context类中还提供了一个openFileInput()方法,用于从文件中读取数据,这个方法要比openFileOutput()简单一些,他只接收一个参数,即要读取的文件名,然后系统会自动到/data/data/<package name>/files/目录下加载这个文件,并返回一个FileInputStream对象,得到这个对象之后,再通过流的方式就可以将数据读取出来

  • fun load() : String {
        va content = StringBuilder()
        
        try {
            val input = openFileInput()
            val reader = BufferedReader(InputStream(input))
            reader.use {
                reader.forEachLine {
                    content.append(it)
                }
            }
        }catch(e : IOException){
                e.printStackTrace()
        }
        
        return content.toString()
    }
    

SharedPreferences存储

  • 不同于文件的存储方式,SharedPreferences是使用键值对的方式来存储数据的。也就是说,当保存一条数据的时候,需要给这条数据提供一个对应的键,这样在读取数据的时候就可以通过这个键把相应的值取出来。而且SharedPreferences还支持多种不同的数据类型存储,如果存储的数据类型是整型,那么读取出来的数据也是整型的;如果存储的数据是一个字符串,那么读取出来的数据仍然是字符串。
获取SharedPreferences的两种方法
  • Context类的getSharedPreferences()方法

    • 此方法接收两个参数
      • 第一个参数用于指定SharedPreferences文件的名称,如果指定的文件不存在则会创建一个,SharedPreferences文件都是存放在/data/data/<package name>/shared_prefs/目录下的
      • 第二个参数用于指定操作模式,目前只有默认的MODE_PRIVATE一种模式可选,他和直接传入0的效果是相同的,表示只有当前的应用程序才可以对这个SharedPreferences文件进行读写,其他集中操作模式均已被废弃,MODE_WORLD_READABLEMODE_WORLD_WRITEABLE这两种模式是在Android 4.2版本中被废弃的,MODE_MULTI_PROCESS模式是在Android 6.0版本中被废弃的。
  • Activity类中的getPreferences()方法

    • 这个方法和Context中的getSharedPreferences()方法很相似,不过它只接收一个操作模式参数,因为使用这个方法时会自动将当前Activity的类名作为SharedPreferences的文件名
    • 得到了SharedPreferences对象之后,就可以开始向SharedPreferences文件中存储数据了,主要可以分为三步实现
      1. 调用SharedPreferences对象的edit()方法获取一个SharedPreferences.Editor对象
      2. SharedPreferences.Editor对象中添加数据,比如添加一个布尔型数据就是用putBoolean()方法,添加一个字符串则使用putString()方法,以此类推
      3. 调用apply()方法将添加的数据提交,从而完成数据存储操作
  • binding.addNum.setOnClickListener {
        val editor = getSharedPreferences("data", Context.MODE_PRIVATE).edit()
    
        editor.putString("key", "value")
        editor.putBoolean("true", false)
        editor.putInt("1", -1)
    
        editor.apply()
    }
    
从SharedPreferences中读取数据
binding.addNum.setOnClickListener {
    val prefs = getSharedPreferences("data", MODE_PRIVATE)
    val data = prefs.getString("key", "default")
}

SQLite数据库存储

创建数据库
  • android为了让我们能够鞥家方便的管理数据库,专门提供了一个SQLiteOpenHelper帮助类,借助这个类可以非常简单的对数据库进行创建和升级

  • 首先SQLiteOpenHelper是一个抽象类,所有需要创建一个自己的类去继承他,SQLiteOpenHelper中有两个抽象方法:onCreate()onUpgrade(),我们必须在自己的帮助类里重写这两个方法,然后分别在这两个方法中实现创建和升级数据库的逻辑

  • SQLiteOpenHelper中还有两个非常重要的实例方法,这两个方法都可以创建或打开一个现有的数据库(如果数据库已存在则直接打开,否则要创建一个新的数据库)并返回一个可对数据库进行读写操作的对象

    • gerReadableDatabase()
    • getWritableDatabase()
    • 需要注意的是:
      • 当数据库不可写入的时候(如磁盘空间已满),getReadableDatabase()方法返回的对象将以只读的方式打开数据库,而getWritableDatabase()方法则将出现异常
  • SQLiteOpebHelper中有两个构造方法可供重写,一般使用参数少点的那个构造方法即可,这个构造方法中接收4个参数

    • 第一个参数是Context,这个没有好说的,必须有他才能对数据库进行操作
    • 第二个是数据库名,创建数据库时使用的就是这里指定的名称
    • 第三个参数允许我们在查询数据的时候返回一个自定的Cursor,一般穿入null即可
    • 第四个参数表示当前数据库的版本号,可用于对数据库进行升级操作
  • 构造出SQLiteOpenHelper的实例之后,再调用他的getReadableDatabase()getWritabledatabase()方法就能够创建数据库了,数据库文件会存放在/data/data/<package name>/databases/目录下,此时,重写的onCreate()方法也会得到执行,所以通常会在这里处理一些创建表的逻辑

  • class MyDatabaseHelper(context : Context, name : String, version : Int) : SQLiteOpenHelper(context, name, null, version) {
    
        private val createBook = "create table Book(\n" +
                "\tid int primary key autoincrement,\n" +
                "\tusername varchar(20),\n" +
                "\tpassword varchar(20)\n" +
                ");"
    
        override fun onCreate(db: SQLiteDatabase?) {
            db?.execSQL(createBook)
        }
    
        override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
            if (oldVersion <= 1){
                db?.execSQL(createBook)
            }
        }
    }
    
  • dbHelper = MyDatabaseHelper(this, "Book.db", 1)
    dbHelper.writableDatabase
    
升级数据库
  • onUpgrade()写升级的逻辑,创建数据库时提供更高的版本号
添加数据
  • 调用SQLiteOpenHelpergetReadableDatabase()getWriteableDatabase()方法除了可以用于创建和升级数据库,他们还都会返回一个SQLiteDatabase对象,借助这个对象可以对数据进行CRUD操作

  • SQLiteDatabase中提供了一个insert()方法,专门用于添加数据,他接收三个参数

    • 第一个参数是表名,我们希望向哪张表添加数据,这里就传入该表的名字
    • 第二个参数用于在未指定添加数据的情况下个某系可为空的列自动赋值NULL,一般我们用不到这个功能,直接传入null即可
    • 第三个参数是一个ContentValues对象,他提供了一些列的put()方法重载,用于向ContentValues中添加数据,只需要将表中的每个列名以及相应的待添加数据传入即可
  • binding.addNum.setOnClickListener {
        val db = dbHelper.writableDatabase
        val values = ContentValues().apply{
            put("username", "沙扬娜拉")
            put("password", "123456")
        }
        //val values = contentValuesOf("username" to "沙扬娜拉", "passsword" to "1234456")
        db.insert("book", null, values)
    }
    
更新数据
  • SQLiteOpenHelper提供了update()方法,用于对数据进行更新,这个方法接收四个参数

    • 第一个参数和insert一样,也是表名,指定更新那张表里的数据
    • 第二个参数是ContentValues对象,要把更新数据在这里组装进去
    • 第三、四个参数用于约束更新某一行中的数据,不指定的话默认会更新所有行
  • binding.addNum.setOnClickListener {
        val db = dbHelper.writableDatabase
        val values = contentValuesOf("username" to "沙扬娜拉", "password" to "123456")
        db.update("book", values, "id = ?", arrayOf("1"))
    }
    
删除数据
  • SQLiteDatabase中提供了一个delete()方法,专门用于删除数据,这个方法接收三个参数:

    • 第一个参数仍然是表名
    • 第二、三个参数用于约束删除某一行或某几行的数据,不指定的话默认删除所有行
  • binding.addNum.setOnClickListener {
        val db = dbHelper.writableDatabase
        db.delete("book", "pages > ?", arrayOf("500"))
    }
    
查询数据
  • SQLiteDatabase中提供了一个query()方法对数据进行查询,这个方法的参数非常复杂, 最短的一个方法重载也需要传入7个参数

    • 第一个参数,表名,表示我们希望从哪张表中查询数据
    • 第二个参数用于指定查询哪几列,如果不指定默认查询所有列
    • 第三、四个参数用于约束查询某一行或某几行的数据,不指定的话则默认查询所有行的数据
    • 第五个参数用于指定需要去group by的列,不指定则表示不对查询结果进行group by操作
    • 第六个参数用于对group by之后的数据进行进一步的过滤,不指定则不进行过滤
    • 第七个参数用于指定查询结果的排序方式,不指定则表示使用默认的排序方式
    • image-20210925154905877
  • binding.addNum.setOnClickListener {
        val db = dbHelper.readableDatabase
    
        val cursor = db.query("book", null, null, null, null, null, null)
    
        val dataList = mutableListOf<Book>()
        if (cursor.moveToFirst()){
            do {
    
                val username = cursor.getString(cursor.getColumnIndex("username"))
                val password= cursor.getString(cursor.getColumnIndex("password"))
    
                dataList.add(Book(username, password))
    
            }while (cursor.moveToNext())
        }
    
        cursor.close()
    }
    
  • 我们首先在查询按钮的点击事件里面调用了SQLiteDatabase的query()方法查询数据。这里的query()方法非常简单,只使用了第一个参数指明查询Book表,后面的参数全部为null。这就表示希望查询这张表中的所有数据,虽然这张表中目前只剩下一条数据了。查询完之后就得到了一个Cursor对象,接着我们调用它的moveToFirst()方法,将数据的指针移动到第一行的位置,然后进入一个循环当中,去遍历查询到的每一行数据。在这个循环中可以通过Cursor的getColumnIndex()方法获取某一列在表中对应的位置索引,然后将这个索引传入相应的取值方法中,就可以得到从数据库中读取到的数据了。接着我们使用Log将取出的数据打印出来,借此检查读取工作有没有成功完成。最后别忘了调用close()方法来关闭Cursor。

使用SQL操作数据库
  • 添加数据

    • db.execSQL("insert into Book (name, author, pages, price) values(?, ?, ?, ?)",
          arrayOf("The Da Vinci Code", "Dan Brown", "454", "16.96")
      )
      db.execSQL("insert into Book (name, author, pages, price) values(?, ?, ?, ?)",
          arrayOf("The Lost Symbol", "Dan Brown", "510", "19.95")
      )
      
  • 更新数据

    • db.execSQL("update Book set price = ? where name = ?", arrayOf("10.99", "The Da Vinci Code"))
      
  • 删除数据

    • db.execSQL("delete from Book where pages > ?", arrayOf("500"))
      
  • 查询数据

    • val cursor = db.rawQuery("select * from Book", null)
      

运行时权限

Android权限机制详解

  • 首先回顾一下过去Android的权限机制。我们在第6章写BroadcastTest项目的时候第一次接触了Android权限相关的内容,当时为了要监听开机广播,我们在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" />
          ...
      </manifest>
      
    • 因为监听开机广播涉及了用户设备的安全,因此必须在AndroidManifest.xml中加入权限声明,否则我们的程序就会崩溃。

  • 那么现在问题来了,加入了这句权限声明后,对于用户来说到底有什么影响呢?为什么这样就可以保护用户设备的安全了呢?

  • 其实用户主要在两个方面得到了保护。一方面,如果用户在低于Android 6.0系统的设备上安装该程序,会在安装界面给出如图8.1所示的提醒。这样用户就可以清楚地知晓该程序一共申请了哪些权限,从而决定是否要安装这个程序。

    • image-20210925162855446
  • 另一方面,用户可以随时在应用程序管理界面查看任意一个程序的权限申请情况,如图8.2所示。这样该程序申请的所有权限就尽收眼底,什么都瞒不过用户的眼睛,以此保证应用程序不会出现各种滥用权限的情况。

    • image-20210925162918467
  • 这种权限机制的设计思路其实非常简单,就是用户如果认可你所申请的权限,就会安装你的程序,如果不认可你所申请的权限,那么拒绝安装就可以了。

  • 但是理想是美好的,现实却很残酷。很多我们离不开的常用软件普遍存在着滥用权限的情况,不管到底用不用得到,反正先把权限申请了再说。比如微信所申请的权限列表如图8.3所示。

    • image-20210925162959295
  • 这还只是微信所申请的一半左右的权限,因为权限太多,一屏截不全。其中有一些权限我并不认可,比如微信为什么要读取我手机的短信和彩信?但是不认可又能怎样,难道我拒绝安装微信?没错,这种例子比比皆是,一些软件在让用户产生依赖以后就会容易 “店大欺客”,反正这个权限我就是要了,你自己看着办吧!

  • Android开发团队当然也意识到了这个问题,于是在Android 6.0系统中加入了运行时权限功能。也就是说,用户不需要在安装软件的时候一次性授权所有申请的权限,而是可以在软件的使用过程中再对某一项权限申请进行授权。比如一款相机应用在运行时申请了地理位置定位权限,就算我拒绝了这个权限,也应该可以使用这个应用的其他功能,而不是像之前那样直接无法安装它。

  • 当然,并不是所有权限都需要在运行时申请,对于用户来说,不停地授权也很烦琐。Android现在将常用的权限大致归成了两类,一类是普通权限,一类是危险权限。准确地讲,其实还有一些特殊权限,不过这些权限使用得相对较少,因此不在本书的讨论范围之内。普通权限指的是那些不会直接威胁到用户的安全和隐私的权限,对于这部分权限申请,系统会自动帮我们进行授权,不需要用户手动操作,比如在BroadcastTest项目中申请的权限就是普通权限。危险权限则表示那些可能会触及用户隐私或者对设备安全性造成影响的权限,如获取设备联系人信息、定位设备的地理位置等,对于这部分权限申请,必须由用户手动授权才可以,否则程序就无法使用相应的功能。

  • 但是Android中一共有上百种权限,我们怎么从中区分哪些是普通权限,哪些是危险权限呢?其实并没有那么难,因为危险权限总共就那么些,除了危险权限之外,剩下的大多就是普通权限了。表8.1列出了到Android 10系统为止所有的危险权限,一共是11组30个权限。

    • img
  • 这张表格你看起来可能并不会那么轻松,因为里面的权限全都是你没使用过的。不过没有关系,你并不需要了解表格中每个权限的作用,只要把它当成一个参照表来查看就行了。每当要使用一个权限时,可以先到这张表中查一下,如果是这张表中的权限,就需要进行运行时权限处理,否则,只需要在AndroidManifest.xml文件中添加一下权限声明就可以了。

  • 另外注意,表格中每个危险权限都属于一个权限组,我们在进行运行时权限处理时使用的是权限名。原则上,用户一旦同意了某个权限申请之后,同组的其他权限也会被系统自动授权。但是请谨记,不要基于此规则来实现任何功能逻辑,因为Android系统随时有可能调整权限的分组。

在程序运行时申请权限

  • package cn.wenhe9.testmenu
    
    import android.Manifest
    import android.content.Intent
    import android.content.pm.PackageManager
    import android.net.Uri
    import androidx.appcompat.app.AppCompatActivity
    import android.os.Bundle
    import android.widget.Toast
    import androidx.core.app.ActivityCompat
    import androidx.core.content.ContextCompat
    import cn.wenhe9.testmenu.databinding.ActivityForthBinding
    import cn.wenhe9.testmenu.databinding.ActivityMainBinding
    
    class ForthActivity : AppCompatActivity() {
    
        private lateinit var binding : ActivityForthBinding
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            binding = ActivityForthBinding.inflate(layoutInflater)
            setContentView(binding.root)
    
            binding.callPhone.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()
                    }
                }
            }
        }
    
        fun call(){
            val intent = Intent(Intent.ACTION_VIEW)
            intent.data = Uri.parse("tel:10086")
            startActivity(intent)
        }
    }
    
  • 上面的代码覆盖了运行时权限的完整流程,下面我们具体解析一下。说白了,运行时权限的核心就是在程序运行过程中由用户授权我们去执行某些危险操作,程序是不可以擅自做主去执行这些危险操作的。因此,第一步就是要先判断用户是不是已经给过我们授权了,借助的是ContextCompat.checkSelfPermission()方法。checkSelfPermission()方法接收两个参数:第一个参数是Context,这个没什么好说的;第二个参数是具体的权限名,比如打电话的权限名就是Manifest.permission.CALL_PHONE。然后我们使用方法的返回值和PackageManager.PERMISSION_GRANTED做比较,相等就说明用户已经授权,不等就表示用户没有授权。

  • 如果已经授权的话就简单了,直接执行拨打电话的逻辑操作就可以了,这里我们把拨打电话的逻辑封装到了call()方法当中。如果没有授权的话,则需要调用ActivityCompat.requestPermissions()方法向用户申请授权。requestPermissions()方法接收3个参数:第一个参数要求是Activity的实例;第二个参数是一个String数组,我们把要申请的权限名放在数组中即可;第三个参数是请求码,只要是唯一值就可以了,这里传入了1。

  • 调用完requestPermissions()方法之后,系统会弹出一个权限申请的对话框,用户可以选择同意或拒绝我们的权限申请。不论是哪种结果,最终都会回调到onRequestPermissionsResult()方法中,而授权的结果则会封装在grantResults参数当中。这里我们只需要判断一下最后的授权结果:如果用户同意的话,就调用call()方法拨打电话;如果用户拒绝的话,我们只能放弃操作,并且弹出一条失败提示。

ContentProvider

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

访问其他程序中的数据

  • ContentProvider的用法一般有两种:
    • 使用现有的ContentProvider读取和操作相应程序中的数据
    • 另一种是创建自己的ContentProvider,给程序的数据提供外部访问接口

ContentResolver的基本用法

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

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

    • content://com.example.app.provider/table1
      content://com.example.app.provider/table2
      
  • 在得到了内容URI字符串之后,我们还需要将他解析成Uri对象才可以作为参数传入,解析的方法如下:

    • val uri = Uri.parse("content://com.example.app.provider/table1")
      
    • 只需要调用Uri.parse()方法,就可以将内容URI字符串解析成Uri对象了

  • 然后既可以使用这个Uri对象查询table1表中的数据了

    • val cursor = contentResolver.query(
      	uri,
          projection,
          selection,
          selectionArgs,
          sortOrder
      )
      
    • img

  • 最后将数据取出即可

    • while(cursor.moveToNext)
          val column= cursor.getString(cursor.getColumnIndex("column1")
      }
      cursor.close()
      
  • 添加数据

    • val values = contentValuesof("column1" to "text")
      contentResolver.insert(uri, values)
      
  • 更新数据

    • val values = contentValuesof("column1" to "text")
      contentResolver.update(uri, values, "id = ?", arrayOf("1"))
      
  • 删除数据

    • contentResolver.delete(uri, "id = ?", arrayOf("1"))
      

创建自己的ContentProvider

  • 如果想要实现跨程序共享数据的功能,可以通过新建一个类去继承ContentProvider的方式来实现,ContentProvider类中有6个抽象方法,我们在使用子类继承他的时候,需要将这6个方法全部重写

    • package cn.wenhe9.testmenu
      
      import android.content.ContentProvider
      import android.content.ContentValues
      import android.database.Cursor
      import android.net.Uri
      
      class MyContentProvider : ContentProvider() {
      
          override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int {
              TODO("Implement this to handle requests to delete one or more rows")
          }
      
          override fun getType(uri: Uri): String? {
              TODO(
                  "Implement this to handle requests for the MIME type of the data" +
                          "at the given URI"
              )
          }
      
          override fun insert(uri: Uri, values: ContentValues?): Uri? {
              TODO("Implement this to handle requests to insert a new row.")
          }
      
          override fun onCreate(): Boolean {
              TODO("Implement this to initialize your content provider on startup.")
          }
      
          override fun query(
              uri: Uri, projection: Array<String>?, selection: String?,
              selectionArgs: Array<String>?, sortOrder: String?
          ): Cursor? {
              TODO("Implement this to handle query requests from clients.")
          }
      
          override fun update(
              uri: Uri, values: ContentValues?, selection: String?,
              selectionArgs: Array<String>?
          ): Int {
              TODO("Implement this to handle requests to update one or more rows.")
          }
      }
      
    • onCreate(),初始化ContentProvider的时候调用,通常会在这里完成对数据库的创建和升级等操作,返回true表示ContentProvider初始化成功,返回false则表示失败

    • query(),从ContentProvider中查询数据

      • uri参数用于确定查询哪张表,
      • projection参数用于确定查询哪些列
      • selectionselectionArgs参数用于约定查询哪些行
      • sortOrder参数用于对结果进行排序
      • 查询的结果存放在Cursor对象中返回
    • insert()

      • 向ContentProvider中添加一条数据,uri参数用于确定要添加的表,待添加的数据保存在values参数中,待添加完成后,返回一个用于表示这条新纪录的URI
    • update()

      • 更新ContentProvider中已有的数据,uri参数用于确定更新哪一张表中的数据,新数据保存在values参数中,selelction和selectionArgs参数用于约束更新哪些行,受影响的行数将作为返回值返回
    • delete()

      • 从ContentProvider中删除数据,uri参数用于确定删除哪一行中的数据,selection和selectionArgs参数用于约束删除哪些行,被删除的行数将作为返回值返回
    • getType()

      • 根据传入的内容URI返回相应的MINE类型
  • 一个标准的URI写法是

    • content://com.example.app.provider/table1
      
    • 这就表示期望访问的是com.example.app这个应用的table1表中的数据

  • 除此之外,我们还可以在这个内容的URI后面加上一个id,例如:

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

  • 内容URI的格式主要就只有以上两种

    • 以路径结尾表示期望访问该表中的所有数据
    • 以id结尾表示期望访问该表中拥有相应id的数据
  • 可以使用通配符分别匹配这两种格式的内容URI,规则如下:

    • *表示匹配任意长度的字符串
    • #表示匹配任意长度的数字
  • 所以,一个能够匹配人意表的内容的URI格式就可以写成

    • content://com.example.app.provider/*
      
  • 一个能够匹配table1表中任意一行数据的内容URI格式就可以写成:

    • content://com.example.app.provider/table1/#
      
  • 接着,我们再借助UriMatcher这个类就可以轻松地实现匹配内容URI的功能

  • UriMathcer中提供了一个addUri()方法,这个方法接收3个参数,可以分别把authoritypath、和一个自定义代码传进去。这样当调用UriMatchermatch()方法的时候,就可以将一个Uri对象传入,返回值是某一个能够匹配这个Uri对象所对应的自定义代码,利用这个代码,我们就可以判断出调用方期望访问的是哪张表中的数据了

  • class MyProvider : ContentProvider() {
    
        private val table1Dir = 0
        private val table1Item = 1
        private val table2Dir = 2
        private val table2Item = 3
    
        private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH)
    
        init {
            uriMatcher.addURI("com.example.app.provider", "table1", table1Dir)
            uriMatcher.addURI("com.example.app.provider ", "table1/#", table1Item)
            uriMatcher.addURI("com.example.app.provider ", "table2", table2Dir)
            uriMatcher.addURI("com.example.app.provider ", "table2/#", table2Item)
        }
        ...
        override fun query(uri: Uri, projection: Array<String>?, selection: String?,
                selectionArgs: Array<String>?, sortOrder: String?): Cursor? {
            when (uriMatcher.match(uri)) {
                table1Dir -> {
                    // 查询table1表中的所有数据
                }
                table1Item -> {
                    // 查询table1表中的单条数据
                }
                table2Dir -> {
                    // 查询table2表中的所有数据
                }
                table2Item -> {
                    // 查询table2表中的单条数据
                }
            }
            ...
        }
        ...
    }
    
  • getType()方法,他是所有的ContentProvider都必须提供的一个方法,用于获取Uri对象所对应的MIME类型,一个内容URI所对应的MIME字符串主要由三部分组成,Android对这三个部分做了如下格式规定:

    • 必须以vnd开头
    • 如果内容URI以路径结尾,则后接android.cursor.dir/;如果内容URI以id结尾,则后接android.cursor.item/
    • 最后接上vnd.<authority>.<path>
  • 所以对于content://com.example.app.provider/table1这个内容URI,他所对应的MIME类型就可以写成:

    • vnd.android.cursor.dir/vnd.com.example.app.provider/table1
      
  • 所以getType()方法就可以这么写:

    • class MyProvider : ContentProvider() {
          ...
          override fun getType(uri: Uri) = when (uriMatcher.match(uri)) {
              table1Dir -> "vnd.android.cursor.dir/vnd.com.example.app.provider.table1"
              table1Item -> "vnd.android.cursor.item/vnd.com.example.app.provider.table1"
              table2Dir -> "vnd.android.cursor.dir/vnd.com.example.app.provider.table2"
              table2Item -> "vnd.android.cursor.item/vnd.com.example.app.provider.table2"
              else -> null
          }
      }
      

Service

Service是什么

  • Service是Android中实现程序后台运行的解决方案,它非常适合执行那些不需要和用户交互而且还要求长期运行的任务。Service的运行不依赖于任何用户界面,即使程序被切换到后台,或者用户打开了另外一个应用程序,Service仍然能够保持正常运行。
  • 不过需要注意的是,Service并不是运行在一个独立的进程当中的,而是依赖于创建Service时所在的应用程序进程。当某个应用程序进程被杀掉时,所有依赖于该进程的Service也会停止运行。
  • 另外,也不要被Service的后台概念所迷惑,实际上Service并不会自动开启线程,所有的代码都是默认运行在主线程当中的。也就是说,我们需要在Service的内部手动创建子线程,并在这里执行具体的任务,否则就有可能出现主线程被阻塞的情况

Android多线程编程

线程的基本用法

自己看去,到这了还不会线程基础?

  • 有一点需要注意的是Kotlin的thread{}开头是小写!不要和java的Thread{}混了,后面这个要加start()

    • thread {
          
      }
      
    • Thread {
          
      }.start()
      
在子线程中更新UI
  • 和很多的其他GUI库一样,Android的UI也是线程不安全的,所以如果想要更新应用程序里的UI元素,必须在主线程中进行,否则就会出现异常

  • 使用Android提供的异步消息处理机制

    • class MainActivity : AppCompatActivity() {
      
          val updateText = 1
      
          val handler = object : Handler(Looper.getMaininLooper()) {
              override fun handleMessage(msg: Message) {
                  // 在这里可以进行UI操作
                  when (msg.what) {
                      updateText -> textView.text = "Nice to meet you"
                  }
              }
          }
      
          override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
              setContentView(R.layout.activity_main)
              changeTextBtn.setOnClickListener {
                  thread {
                      val msg = Message()
                      msg.what = updateText
                      handler.sendMessage(msg) // 将Message对象发送出去
                  }
              }
          }
      
      }
      
解析异步消息处理机制
  • Message
    • Message是在多线程之间传递的消息,他可以在内部携带少量的信息,用于在不同的线程之间传递数据,除了上面使用的what字段,还可以使用arg1arg2字段来携带一些整型,使用obj字段携带一个Object对象
  • Handler
    • Handler顾名思义就是处理者的意思,它主要用于发送和处理消息,发送消息一般是使用Handler的sendMessage()方法、post()方法等,而发出的消息经过一系列的辗转处理后,最终会传递到Handelr的handleMessage()方法中
  • MessageQueue
    • MessageQueue是消息队列的意思,它主要用于存放所有通过Handelr发送的消息,这部分消息会一直存放在消息队列中,等待被处理,每隔线程中只会有一个MessageQueue对象
  • Looper
    • Looper是每个线程中的MessageQueue的管家,调用Looper的loop()方法后,就会进入一个无限循环中,然后每当发现MessageQueue中存在一条消息时,就回将它取出,并传递到Handler的handleMessage()方法中,每个线程中只会有一个Looper对象
  • 首先需要在主线程当中创建一个Handler对象,并重写handleMessage()方法。然后当子线程中需要进行UI操作时,就创建一个Message对象,并通过Handler将这条消息发送出去。之后这条消息会被添加到MessageQueue的队列中等待被处理,而Looper则会一直尝试从MessageQueue中取出待处理消息,最后分发回Handler的handleMessage()方法中。由于Handler的构造函数中我们传入了Looper.getMainLooper(),所以此时handleMessage()方法中的代码也会在主线程中运行,于是我们在这里就可以安心地进行UI操作了
  • image-20210926173456241
使用AsyncTask
  • 被弃用了

  • 首先来看一下AsyncTask的基本用法。由于AsyncTask是一个抽象类,所以如果我们想使用它,就必须创建一个子类去继承它。在继承时我们可以为AsyncTask类指定3个泛型参数,这3个参数的用途如下

  • Params。在执行AsyncTask时需要传入的参数,可用于在后台任务中使用。

  • Progress。在后台任务执行时,如果需要在界面上显示当前的进度,则使用这里指定的泛型作为进度单位。

  • Result。当任务执行完毕后,如果需要对结果进行返回,则使用这里指定的泛型作为返回值类型。

  • 因此,一个最简单的自定义AsyncTask就可以写成如下形式:

    • class DownloadTask : AsyncTask<Unit, Int, Boolean>() {
          ...
      }
      
  • 这里我们把AsyncTask的第一个泛型参数指定为Unit,表示在执行AsyncTask的时候不需要传入参数给后台任务。第二个泛型参数指定为Int,表示使用整型数据来作为进度显示单位。第三个泛型参数指定为Boolean,则表示使用布尔型数据来反馈执行结果。

  • 当然,目前我们自定义的DownloadTask还是一个空任务,并不能进行任何实际的操作,我们还需要重写AsyncTask中的几个方法才能完成对任务的定制。经常需要重写的方法有以下4个。

    • onPreExecute()
      • 这个方法会在后台任务开始执行之前调用,用于进行一些界面上的初始化操作,比如显示一个进度条对话框等。
    • doInBackground(Params…)
      • 这个方法中的所有代码都会在子线程中运行,我们应该在这里去处理所有的耗时任务。任务一旦完成,就可以通过return语句将任务的执行结果返回,如果AsyncTask的第三个泛型参数指定的是Unit,就可以不返回任务执行结果。注意,在这个方法中是不可以进行UI操作的,如果需要更新UI元素,比如说反馈当前任务的执行进度,可以调用publishProgress (Progress…)方法来完成。
    • onProgressUpdate(Progress…)
      • onProgressUpdate(Progress…)当在后台任务中调用了publishProgress(Progress…)方法后,onProgressUpdate (Progress…)方法就会很快被调用,该方法中携带的参数就是在后台任务中传递过来的。在这个方法中可以对UI进行操作,利用参数中的数值就可以对界面元素进行相应的更新。
    • onPostExecute(Result)
      • 当后台任务执行完毕并通过return语句进行返回时,这个方法就很快会被调用。返回的数据会作为参数传递到此方法中,可以利用返回的数据进行一些UI操作,比如说提醒任务执行的结果,以及关闭进度条对话框等。
  • 因此,一个比较完整的自定义AsyncTask就可以写成如下形式:

    • class DownloadTask : AsyncTask<Unit, Int, Boolean>() {
      
          override fun onPreExecute() {
              progressDialog.show() // 显示进度对话框
          }
      
          override fun doInBackground(vararg params: Unit?) = try {
              while (true) {
                  val downloadPercent = doDownload() // 这是一个虚构的方法
                  publishProgress(downloadPercent)
                  if (downloadPercent >= 100) {
                      break
                  }
              }
              true
          } catch (e: Exception) {
              false
          }
      
          override fun onProgressUpdate(vararg values: Int?) {
              // 在这里更新下载进度
              progressDialog.setMessage("Downloaded ${values[0]}%")
          }
      
          override fun onPostExecute(result: Boolean) {
              progressDialog.dismiss()// 关闭进度对话框
              // 在这里提示下载结果
              if (result) {
                  Toast.makeText(context, "Download succeeded", Toast.LENGTH_SHORT).show()
              } else {
                  Toast.makeText(context, " Download failed", Toast.LENGTH_SHORT).show()
              }
          }
      
      }
      

Service基本用法

  • 新建一个类继承Service类,重写相应的方法
    • onBind()
      • 必须在子类中实现
    • onCreate()
      • 在Service创建的时候调用
    • onStartCommand()
      • 会在每次Service启动的时候调用
      • 如果希望Service一旦启动就立刻执行某个动作
    • onDestory()
      • 会在Service销毁的时候调用
      • 回收那些不再使用的资源
启动和停止Service
  • 启动和挺值得方法也是借助Intent实现的

    • package cn.wenhe9.testmenu
      
      import android.content.Intent
      import androidx.appcompat.app.AppCompatActivity
      import android.os.Bundle
      import cn.wenhe9.testmenu.databinding.ActivityTestServiceBinding
      
      class TestService : AppCompatActivity() {
      
          private lateinit var binding : ActivityTestServiceBinding
      
          override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
              binding = ActivityTestServiceBinding.inflate(layoutInflater)
              setContentView(binding.root)
      
              binding.startService.setOnClickListener {
                  val intent = Intent(this, MyService::class.java)
      
                  startService(intent)
              }
      
              binding.stopService.setOnClickListener {
                  val intent = Intent(this, MyService::class.java)
      
                  stopService(intent)
              }
          }
      }
      
  • 值得注意的是

    • onCreate()方法是在Service第一次创建的时候调用的
    • onStartCommand()方法则在每次启动Service的时候都会调用
  • Android 8.0系统开始,应用的后台功能被大幅削减。现在只有当应用保持在前台可见状态的情况下,Service才能保证稳定运行,一旦应用进入后台之后,Service随时都有可能被系统回收。之所以做这样的改动,是为了防止许多恶意的应用程序长期在后台占用手机资源,从而导致手机变得越来越卡。当然,如果你真的非常需要长期在后台执行一些任务,可以使用前台Service或者WorkManager

Activity和Service进行通信
  • package cn.wenhe9.testmenu
    
    import android.app.Service
    import android.content.Intent
    import android.os.Binder
    import android.os.IBinder
    import android.util.Log
    
    class MyService : Service() {
    
        private val mBinder = DownLoadBinder()
    
        class DownLoadBinder : Binder(){
            fun startDownload(){
                Log.d("MyService", "开始下载")
            }
    
            fun getProgess() : Int{
                Log.d("MyService", "获取进度")
                return 0
            }
        }
    
        override fun onBind(intent: Intent): IBinder {
            return mBinder
        }
    
        override fun onCreate() {
            Log.d("MyService", "创建了")
        }
    
        override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
            Log.d("MyService", "启动了")
            return super.onStartCommand(intent, flags, startId)
        }
    
        override fun onDestroy() {
            Log.d("MyService", "销毁了")
            super.onDestroy()
        }
    }
    
  • package cn.wenhe9.testmenu
    
    import android.content.ComponentName
    import android.content.Context
    import android.content.Intent
    import android.content.ServiceConnection
    import androidx.appcompat.app.AppCompatActivity
    import android.os.Bundle
    import android.os.IBinder
    import cn.wenhe9.testmenu.databinding.ActivityTestServiceBinding
    
    class TestService : AppCompatActivity() {
    
        private lateinit var binding : ActivityTestServiceBinding
    
        private lateinit var downloadBinder : MyService.DownLoadBinder
    
        private val connection = object : ServiceConnection{
            override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
                downloadBinder = service as MyService.DownLoadBinder
    
                downloadBinder.startDownload()
                downloadBinder.getProgess()
            }
    
            override fun onServiceDisconnected(name: ComponentName?) {
                TODO("Not yet implemented")
            }
    
        }
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            binding = ActivityTestServiceBinding.inflate(layoutInflater)
            setContentView(binding.root)
    
            binding.startService.setOnClickListener {
                val intent = Intent(this, MyService::class.java)
    
                startService(intent)
            }
    
            binding.stopService.setOnClickListener {
                val intent = Intent(this, MyService::class.java)
    
                stopService(intent)
            }
    
            binding.bindServiceBtn.setOnClickListener {
                val intent = Intent(this, MyService::class.java)
                bindService(intent, connection, Context.BIND_AUTO_CREATE)
            }
    
            binding.unBindServiceBtn.setOnClickListener {
                unbindService(connection)
            }
    
    
        }
    }
    
    • 这里我们首先创建了一个ServiceConnection的匿名类实现,并在里面重写了onServiceConnected()方法和onServiceDisconnected()方法。onServiceConnected()方法方法会在Activity与Service成功绑定的时候调用,而onServiceDisconnected()方法只有在Service的创建进程崩溃或者被杀掉的时候才会调用,这个方法不太常用。那么在onServiceConnected()方法中,我们又通过向下转型得到了DownloadBinder的实例,有了这个实例,Activity和Service之间的关系就变得非常紧密了。现在我们可以在Activity中根据具体的场景来调用DownloadBinder中的任何public方法,即实现了指挥Service干什么Service就去干什么的功能。这里仍然只是做了个简单的测试,在onServiceConnected()方法中调用了DownloadBinder的startDownload()和getProgress()方法。
    • 当然,现在Activity和Service其实还没进行绑定呢,这个功能是在“Bind Service”按钮的点击事件里完成的。可以看到,这里我们仍然构建了一个Intent对象,然后调用bindService()方法将MainActivity和MyService进行绑定。bindService()方法接收3个参数,第一个参数就是刚刚构建出的Intent对象,第二个参数是前面创建出的ServiceConnection的实例,第三个参数则是一个标志位,这里传入BIND_AUTO_CREATE表示在Activity和Service进行绑定后自动创建Service。这会使得MyService中的onCreate()方法得到执行,但onStartCommand()方法不会执行。
    • 另外需要注意,任何一个Service在整个应用程序范围内都是通用的,即MyService不仅可以和MainActivity绑定,还可以和任何一个其他的Activity进行绑定,而且在绑定完成后,它们都可以获取相同的DownloadBinder实例。

Service的生命周期

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

Service的更多技巧

使用前台Service
  • 从Android 8.0系统开始,只有当应用保持在前台可见状态的情况下,Service才能保证稳定运行,一旦应用进入后台之后,Service随时都有可能被系统回收。而如果你希望Service能够一直保持运行状态,就可以考虑使用前台Service。前台Service和普通Service最大的区别就在于,它一直会有一个正在运行的图标在系统的状态栏显示,下拉状态栏后可以看到更加详细的信息,非常类似于通知的效果,如图

    • image-20210927195402015
  • 由于状态栏中一直有一个正在运行的图标,相当于我们的应用以另外一种形式保持在前台可见状态,所以系统不会倾向于回收前台Service。另外,用户也可以通过下拉状态栏清楚地知道当前什么应用正在运行,因此也不存在某些恶意应用长期在后台偷偷占用手机资源的情况。

  • override fun onCreate() {
        Log.d("MyService", "创建了")
    
        val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
    
        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O){
            val channel = NotificationChannel("test_fore", "前台Service", NotificationManager.IMPORTANCE_DEFAULT)
    
            manager.createNotificationChannel(channel)
        }
    
        val intent = Intent(this, MainActivity::class.java)
    
        val notification = NotificationCompat.Builder(this, "test_fore")
        .setContentTitle("前台")
        .setContentText("前台内容")
        .setAutoCancel(true)
        .setSmallIcon(R.drawable.ic_launcher_background)
        .setLargeIcon(BitmapFactory.decodeResource(resources, R.drawable.ic_launcher_background))
        .setContentIntent(PendingIntent.getActivity(this, 0, intent, 0))
        .build()
    
        startForeground(1, notification)
    
    }
    
    • 可以看到这就是使用的通知的写法,只是在租后没有使用notigy()将通知显示出来,而是使用startForeground()方法,这方法接收两个参数:
      • 第一个参数是通知的id,类似与notify()方法的第一个参数,唯一即可
      • 第二个参数则是构建的Notification对象
    • 调用startForeground()方法后就会让MyService变成一个前台Service,并在系统状态栏显示出来
  • 另外从Android 9.0系统开始,使用前台Service必须在AndroidManifest.xml文件中进行权限声明才行:

    • <manifest xmlns:android="http://schemas.android.com/apk/res/android"
                package="com.example.servicetest">
          <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
          ...
      </manifest>
      
使用IntentService
  • 在一开始我们就知道,Service中的代码都是默认运行在主线程当中的,如果直接在Service里处理一些耗时的逻辑,就很容易出现ANR(Application Not Responding)的情况

  • 所以这个后就需要用到Android多线程的技术了,我们应该在Service的每隔具体的方法里开启一个子线程,然后在这里处理那些耗时的的逻辑,因此一个标准的Service就可以携程如下形式:

    • class MyService : Service() {
          ...
          override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
              thread {
                  // 处理具体的逻辑
              }
              return super.onStartCommand(intent, flags, startId)
          }
      }
      
  • 但是,这种Service一旦启动,就会一直处于运行状态,必须调用stopService()stioSelf()方法或者被系统回收,Service才会停止,所以,如果想要实现让一个Service在执行完毕后自动停止的,就可以这样写:

    • class MyService : Service() {
          ...
          override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
              thread {
                  // 处理具体的逻辑
                  stopSelf()
              }
              return super.onStartCommand(intent, flags, startId)
          }
      }
      
  • 虽说这种写法并不复杂,但是总会有一些程序员忘记开启线程,或者忘记调用stopSelf()方法。为了可以简单地创建一个异步的、会自动停止的Service,Android专门提供了一个IntentService类,这个类就很好地解决了前面所提到的两种尴尬,下面我们就来看一下它的用法。

    • class MyIntentService : IntentService("MyIntentService") {
      
          override fun onHandleIntent(intent: Intent?) {
              // 打印当前线程的id
              Log.d("MyIntentService", "Thread id is ${Thread.currentThread().name}")
          }
      
          override fun onDestroy() {
              super.onDestroy()
              Log.d("MyIntentService", "onDestroy executed")
          }
      
      }
      
  • 这里首先要求必须先调用父类的构造函数,并传入一个字符串,这个字符串可以随意指定,只在调试的时候有用。然后要在子类中实现onHandleIntent()这个抽象方法,这个方法中可以处理一些耗时的逻辑,而不用担心ANR的问题,因为这个方法已经是在子线程中运行的了。这里为了证实一下,我们在onHandleIntent()方法中打印了当前线程名。另外,根据IntentService的特性,这个Service在运行结束后应该是会自动停止的,所以我们又重写了onDestroy()方法,在这里也打印了一行日志,以证实Service是不是停止了。

多媒体

使用通知

  • 通知(notification)是anndroid系统中比较有特色的一个功能,当某个应用程序希望向用户发出一些提示信息,而该应用程序又不在前台运行时,就可以借助通知来实现,发出一条通知后,手机最上方的状态栏中会显示一个通知的图标,下拉状态栏后可以看到通知的详细内容
创建通知渠道
  • 什么是通知渠道

    • 就是每一条通知都要属于一个对应的渠道,每个应用程序都可以自由的创建当前应用拥有哪些通知渠道,但是这些通知渠道的控制权是掌握在用户手上的,用户可以自由的选择这些同志渠道的重要程度,是否响铃、是否震动或者是否要关闭这个渠道的通知
  • 创建通知渠道的步骤

    • 首先需要一个NotificationManager对通知进行管理,可以通过调用CotnextgetSystemService()方法获取。getSystemService()方法接收一个字符串参数用于确定获取系统的哪个服务,这里我们传入Contxt.NOTIFICATION_SERCICE即可,因此,获取NotificationManager的实例既可以写成:

      • val manager getSystenService(Context.NOTIFICATION_SERICE) as NotificationManager
        
    • 接下里要使用NotificationChannel类构造一个通知渠道,并调用NotificationManagercreateNotificatinChannel()方法完成构造,由于NotificationChannel类和createNotificationChannel()方法都是Android 8.0系统中新增的API,因此我们在使用的时候还需要进行版本判断才可以,写法如下:

      • if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O){
            val channel = NotificationChannel(channelId, channelName, importance)
        	manager.createNotificationChannel(channel)
        }
        
    • 创建一个通知渠道至少需要渠道ID、渠道名称以及重要等级这三个参数

      • 渠道ID可以随便定义,只要保证全局唯一性就可以
      • 渠道名称是给用户看的,需要清楚地表达这个渠道的用途
      • 通知的重要等级主要IMPORTANCE_HIGHIMPORTANCE_DEFAULTIMPORTANCE_LOWIMPORTANCE_MIN这几种,对应的重要程度依次从高到低,不同的重要等机会决定通知的不同行为,当然这里只是初始状态下的重要等级,用户可以随时手动更改某个通知渠道的重要等级,开发者是无法干预的
通知的基本用法
  • 通知 的用法还是比较灵活的,既可以在Activity里创建,也可以在BroadcastRecevier中床架你,当然还可以在Service里创建,相比于BroadcastReceiver和Service,在Activity里创建通知的场景是比较少的,因为一般只有程序进入后台的时候才需要使用通知

  • 创建通知的步骤:

    • 首先需要使用一个Builder构造器来创建Notification对象,但问题在于,Android系统的没有个版本都会对通知功能进行或多或少的修改,API不稳定的问题在通知上凸显的尤为严重,解决办法就是使用AndroidX库中提供的兼容API,AndroidX库中提供了一个NotificationCompat类,使用这个类的构造器创建Notificatin对象,就可以保证我们的程序在所有Android系统版本上都能正常工作了,代码如下:

      • val notification = NotificationCompat.Builder(contxext, channelId).build()
        
      • NotificationCompat.Builder的构造函数中接收两个参数

        • 第一个参数是context
        • 第二个参数是渠道ID,需要和我们在创建爱你通知渠道时指定的渠道ID相匹配才行
    • 当然,上述代码只是创建了一个空的Notification对象,并没有什么实际作用,我们可以在最终的build()方法之前连缀任意多的设置方法来创建一个丰富的Notification对象,先来看一些最基本的设置:

      • val notification = NotificationCompat.Builder(context, channelId)
        	.setContentTitle("This is content title")
        	.setSmallIcon(R.drawable.small_icon)
        	.setLargeIcon(BitmapFactory.decodeResouce(getResouces(), R.drawable.large_icon))
        	.build()
        
        • setContentTitle()方法用于指定通知的标题内容,下拉系统状态来就可以看到这部分内容
        • setConentText()方法用于指定通知的正文内容,同样下来系统装天蓝就可以看到这部分内容
        • setSmallIcon()方法用于设置通知的小图标,注意,只能使用纯aplha图层的图片进行设置,小图标会显示在系统状态栏
        • setlargeIcon()方法用于设置通知的大图标,当下拉系统状态栏就可以看到设置的大图标了
    • 最后只需要调用NotificationManagernotify()方法就可以让通知显示出来了,notify()方法接收这两个参数:

      • 第一个参数是Id,要保证为每个通知指定的id都是不同的

      • 第二个参数则是Notification对昂,这里直接将创建好的Notification对象传入即可

      • manager.notify(1, notification)
        
  • 通知的点击效果

    • 使用PendingIntent

    • PendingIntent的用法很简单,它主要提供了几个静态方法用于获取PendingIntent的实例,可以根据需求来选择是使用getActivity()方法、getBroadcast(),还是getService()方法

    • 这几个方法所接收的参数都是相同的:

      • 第一个参数依旧是Context
      • 第二个参数一般用不到,传入0即可
      • 第三个参数是一个Intent对象,我们可以通过这个对象构建出PendingIntent的"意图"
      • 第四个参数用于确定PendingIntent的行为,有以下四种,通常情况下传入0就可以了
        • FLAG_ONE_SHOT
        • FLAG_NO_CREATE
        • FLAG_CANCEL_CURRENT
        • FLAG_UPDATE_CURRENT
    • 这时再看NotificationCompat.Builder(),这个构造器还可以连缀一个setContentIntent()方法,因此,这里就可以通过PendingIntent构建一个延迟执行的意图,当用户点击这条通知的时就会执行相应的逻辑

      •     val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
              
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O){
                val channel = NotificationChannel("test_notify", "测试通知", NotificationManager.IMPORTANCE_DEFAULT)
              
                manager.createNotificationChannel(channel)
            }
              
            val intent = Intent(this, MainActivity::class.java)
        
                val notification = NotificationCompat.Builder(this, "test_notify")
                    .setContentTitle("这是标题")
                    .setContentText("这是内容")
                    .setSmallIcon(R.drawable.ic_launcher_background)
                    .setLargeIcon(BitmapFactory.decodeResource(resources, R.drawable.ic_launcher_background))
                    .setContentIntent(PendingIntent.getActivity(this, 0, intent, 0))
                    .build()
        
                manager.notify(1, notification)
        
        
        
    • 此时,可以看到,虽然跳转界面了,但是系统状态栏的通知图标还没有消息,这是因为我们没有在代码中对该同志进行取消,他就会一直显示在系统的状态栏上,解决方法有两种

      • 一种是在NotificationCompat.Builder()中再连缀一个setAutoCancel()方法、

      • val notification = NotificationCompat.Builder(this, "normal")
                ...
                .setAutoCancel(true)
                .build()
        
      • 一种是显示地调用NotificationManagercancel()方法将他取消

      • class NotificationActivity : AppCompatActivity() {
        
            override fun onCreate(savedInstanceState: Bundle?) {
                super.onCreate(savedInstanceState)
                setContentView(R.layout.activity_notification)
                val manager = getSystemService(Context.NOTIFICATION_SERVICE) as
                    NotificationManager
                manager.cancel(1)
            }
        
        }
        
      • 注意,在cancel()方法中传入了1,这个1是我么在创建通知的时候给每条通知指定的id,取消哪条通知,在cancel()方法中传入该通知的id就可以了

  • PendingIntentIntent的区别和联系

    • 联系
      • 他们都可以指明某一个“意图”,都可以用于启动Activity、启动Service以及发送广播
    • 区别
      • Intent倾向于立即执行某个动作
      • PendingIntent倾向于某个合适的时机执行某个动作
    • 所以,也可以把PendintIntent简单地理解为延迟执行的Intent
通知的进阶技巧
  • setStyle()

    • 这个方法允许我们构造出富文本的内容,也就是说,通知中不光可以有文字和图标,还可以包含更多的东西,setStyle()方法接收一个NotificationCompat.style参数,这个参数就是用来构造具体的富文本信息的,如文字、图片

    • 在通知中显示一段长文

      • val notification = NotificationCompat.Builder(this, "test_notify")
        .setStyle(NotificationCompat.BigTextStyle().bigText("nihaodadjiaosjdklasjdlkajskldjakldjialjdklajdkljaskldjlkasjdklasjdkljaskldjaljdklasjdkljakjdakljl"))
        .build()
        
    • 在通知中显示一张大图片

      • val notification3 = NotificationCompat.Builder(this, "test_notify")
        .setStyle(NotificationCompat.BigPictureStyle().bigPicture(BitmapFactory.decodeResource(resources, R.drawable.ic_launcher_background)))
        .build()
        
  • 不同重要的顶级的通知渠道对通知的行为具体有什么影响

    • 其实简单来讲,就是通知渠道的重要等级越高,发出的通知就越容易获得用户的注意。比如高重要等级的通知渠道发出的通知可以弹出横幅、发出声音,而低重要等级的通知渠道发出的通知不仅可能会在某些情况下被隐藏,而且可能会被改变显示的顺序,将其排在更重要的通知之后。
    • 但需要注意的是,开发者只能在创建通知渠道的时候为它指定初始的重要等级,如果用户不认可这个重要等级的话,可以随时进行修改,开发者对此无权再进行调整和变更,因为通知渠道一旦创建就不能再通过代码修改了。

调用摄像头和相册

  • 调用摄像头拍照

    • class MainActivity : AppCompatActivity() {
      
          val takePhoto = 1
          lateinit var imageUri: Uri
          lateinit var outputImage: File
      
          override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
              setContentView(R.layout.activity_main)
              takePhotoBtn.setOnClickListener {
                  // 创建File对象,用于存储拍照后的图片
                  outputImage = File(externalCacheDir, "output_image.jpg")
                  if (outputImage.exists()) {
                      outputImage.delete()
                  }
                  outputImage.createNewFile()
                  imageUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                      FileProvider.getUriForFile(this, "com.example.cameraalbumtest.
                          fileprovider", outputImage)
                  } else {
                      Uri.fromFile(outputImage)
                  }
                  // 启动相机程序
                  val intent = Intent("android.media.action.IMAGE_CAPTURE")
                  intent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri)
                  startActivityForResult(intent, takePhoto)
              }
          }
      
          override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
              super.onActivityResult(requestCode, resultCode, data)
              when (requestCode) {
                  takePhoto -> {
                      if (resultCode == Activity.RESULT_OK) {
                          // 将拍摄的照片显示出来
                          val bitmap = BitmapFactory.decodeStream(contentResolver.
                              openInputStream(imageUri))
                          imageView.setImageBitmap(rotateIfRequired(bitmap))
                      }
                  }
              }
          }
      
          private fun rotateIfRequired(bitmap: Bitmap): Bitmap {
              val exif = ExifInterface(outputImage.path)
              val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION,
                  ExifInterface.ORIENTATION_NORMAL)
              return when (orientation) {
                  ExifInterface.ORIENTATION_ROTATE_90 -> rotateBitmap(bitmap, 90)
                  ExifInterface.ORIENTATION_ROTATE_180 -> rotateBitmap(bitmap, 180)
                  ExifInterface.ORIENTATION_ROTATE_270 -> rotateBitmap(bitmap, 270)
                  else -> bitmap
              }
          }
      
          private fun rotateBitmap(bitmap: Bitmap, degree: Int): Bitmap {
              val matrix = Matrix()
              matrix.postRotate(degree.toFloat())
              val rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height,
                  matrix, true)
              bitmap.recycle() // 将不再需要的Bitmap对象回收
              return rotatedBitmap
          }
      }
      
    • 首先这里创建了一个File对象,用于存放摄像头拍下的图片,这里我们把图片命名为output_image.jpg,并存放在手机SD卡的应用关联缓存目录下。什么叫作应用关联缓存目录呢?就是指SD卡中专门用于存放当前应用缓存数据的位置,调用getExternalCacheDir()方法可以得到这个目录,具体的路径是/sdcard/Android/data/<packagename>/cache。那么为什么要使用应用关联缓存目录来存放图片呢?因为从Android 6.0系统开始,读写SD卡被列为了危险权限,如果将图片存放在SD卡的任何其他目录,都要进行运行时权限处理才行,而使用应用关联目录则可以跳过这一步。另外,从Android 10.0系统开始,公有的SD卡目录已经不再允许被应用程序直接访问了,而是要使用作用域存储才行.

      • 作用于存储

        • https://mp.weixin.qq.com/s/_CV68KeQolJQqvUFo10ZVw

        • https://mp.weixin.qq.com/s/4L1VzNtqertBGI-Q9W0M9w

    • 接着会进行一个判断,如果运行设备的系统版本低于Android 7.0,就调用Uri的fromFile()方法将File对象转换成Uri对象,这个Uri对象标识着output_image.jpg这张图片的本地真实路径。否则,就调用FileProvider的getUriForFile()方法将File对象转换成一个封装过的Uri对象。getUriForFile()方法接收3个参数:第一个参数要求传入Context对象,第二个参数可以是任意唯一的字符串,第三个参数则是我们刚刚创建的File对象。之所以要进行这样一层转换,是因为从Android 7.0系统开始,直接使用本地真实路径的Uri被认为是不安全的,会抛出一个FileUriExposedException异常。而FileProvider则是一种特殊的ContentProvider,它使用了和ContentProvider类似的机制来对数据进行保护,可以选择性地将封装过的Uri共享给外部,从而提高了应用的安全性。

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

    • 由于刚才我们是使用startActivityForResult()启动Activity的,因此拍完照后会有结果返回到onActivityResult()方法中。如果发现拍照成功,就可以调用BitmapFactory的decodeStream()方法将output_image.jpg这张照片解析成Bitmap对象,然后把它设置到ImageView中显示出来。

    • 需要注意的是,调用照相机程序去拍照有可能会在一些手机上发生照片旋转的情况。这是因为这些手机认为打开摄像头进行拍摄时手机就应该是横屏的,因此回到竖屏的情况下就会发生90度的旋转。为此,这里我们又加上了判断图片方向的代码,如果发现图片需要进行旋转,那么就先将图片旋转相应的角度,然后再显示到界面上。

    • 不过现在还没结束,刚才提到了ContentProvider,那么我们自然要在AndroidManifest.xml中对它进行注册才行,代码如下所示:

      • <manifest xmlns:android="http://schemas.android.com/apk/res/android"
                package="com.example.cameraalbumtest">
            <application
                android:allowBackup="true"
                android:icon="@mipmap/ic_launcher"
                android:label="@string/app_name"
                android:supportsRtl="true"
                android:theme="@style/AppTheme">
                ...
                <provider
                    android:name="androidx.core.content.FileProvider"
                    android:authorities="com.example.cameraalbumtest.fileprovider"
                    android:exported="false"
                    android:grantUriPermissions="true">
                    <meta-data
                        android:name="android.support.FILE_PROVIDER_PATHS"
                        android:resource="@xml/file_paths" />
                </provider>
            </application>
        </manifest>
        
    • android:name属性的值是固定的,而android:authorities属性的值必须和刚才FileProvider.getUriForFile()方法中的第二个参数一致。另外,这里还在标签的内部使用指定Uri的共享路径,并引用了一个@xml/file_paths资源。当然,这个资源现在还是不存在的,下面我们就来创建它。

    • 右击res目录→New→Directory,创建一个xml目录,接着右击xml目录→New→File,创建一个file_paths.xml文件。然后修改file_paths.xml文件中的内容,如下所示:

      • <?xml version="1.0" encoding="utf-8"?>
        <paths xmlns:android="http://schemas.android.com/apk/res/android">
            <external-path name="my_images" path="/" />
        </paths>
        
    • external-path就是用来指定Uri共享路径的,name属性的值可以随便填,path属性的值表示共享的具体路径。这里使用一个单斜线表示将整个SD卡进行共享,当然你也可以仅共享存放output_image.jpg这张图片的路径。

  • 从相册中获取图片

    • class MainActivity : AppCompatActivity() {
          ...
          val fromAlbum = 2
      
          override fun onCreate(savedInstanceState: Bundle?) {
              ...
              fromAlbumBtn.setOnClickListener {
                  // 打开文件选择器
                  val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
                  intent.addCategory(Intent.CATEGORY_OPENABLE)
                  // 指定只显示图片
                  intent.type = "image/ *"
                  startActivityForResult(intent, fromAlbum)
              }
          }
      
          override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
              super.onActivityResult(requestCode, resultCode, data)
              when (requestCode) {
                  ...
                  fromAlbum -> {
                      if (resultCode == Activity.RESULT_OK && data != null) {
                          data.data?.let { uri ->
                              // 将选择的图片显示
                              val bitmap = getBitmapFromUri(uri)
                              imageView.setImageBitmap(bitmap)
                          }
                      }
                  }
              }
          }
      
          private fun getBitmapFromUri(uri: Uri) = contentResolver
              .openFileDescriptor(uri, "r")?.use {
              BitmapFactory.decodeFileDescriptor(it.fileDescriptor)
          }
          ...
      }
      

播放多媒体文件

  • 播放音频

    • image-20210926164542810
  • Android Studio允许我们在项目工程中创建一个assets目录,并在这个目录下存放任意文件和子目录,这些文件和子目录在项目打包时会一并被打包到安装文件中,然后我们在程序中就可以借助AssetManager这个类提供的接口对assets目录下的文件进行读取。

  • class MainActivity : AppCompatActivity() {
    
        private val mediaPlayer = MediaPlayer()
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_main)
            initMediaPlayer()
            play.setOnClickListener {
                if (!mediaPlayer.isPlaying) {
                    mediaPlayer.start() // 开始播放
                }
            }
            pause.setOnClickListener {
                if (mediaPlayer.isPlaying) {
                    mediaPlayer.pause() // 暂停播放
                }
            }
            stop.setOnClickListener {
                if (mediaPlayer.isPlaying) {
                    mediaPlayer.reset() // 停止播放
                    initMediaPlayer()
                }
            }
        }
    
        private fun initMediaPlayer() {
            val assetManager = assets
            val fd = assetManager.openFd("music.mp3")
            mediaPlayer.setDataSource(fd.fileDescriptor, fd.startOffset, fd.length)
            mediaPlayer.prepare()
        }
    
        override fun onDestroy() {
            super.onDestroy()
            mediaPlayer.stop()
            mediaPlayer.release()
        }
    
    }
    
  • 播放视频

    • image-20210926164706056

    • 很可惜的是,VideoView不支持直接播放assets目录下的视频资源,所以我们只能寻找其他的解决方案。res目录下允许我们再创建一个raw目录,像诸如音频、视频之类的资源文件也可以放在这里,并且VideoView是可以直接播放这个目录下的视频资源的。

    • class MainActivity : AppCompatActivity() {
      
          override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
              setContentView(R.layout.activity_main)
              val uri = Uri.parse("android.resource://$packageName/${R.raw.video}")
              videoView.setVideoURI(uri)
              play.setOnClickListener {
                  if (!videoView.isPlaying) {
                      videoView.start() // 开始播放
                  }
              }
              pause.setOnClickListener {
                  if (videoView.isPlaying) {
                      videoView.pause() // 暂停播放
                  }
              }
              replay.setOnClickListener {
                  if (videoView.isPlaying) {
                      videoView.resume() // 重新播放
                  }
              }
          }
      
          override fun onDestroy() {
              super.onDestroy()
              videoView.suspend()
          }
      
      }
      

网络技术

WebView的用法

  • 当我们的应用程序需要展示一些网页,除了使用系统浏览器外,我们还可以使用Android提供的WebView控件,借助它实现在自己的应用程序里嵌入一个浏览器

    • <?xml version="1.0" encoding="utf-8"?>
      <LinearLayout
              xmlns:android="http://schemas.android.com/apk/res/android"
              android:orientation="vertical"
              android:layout_width="match_parent"
              android:layout_height="match_parent">
      
      
          <WebView
                  android:id="@+id/webView"
                  android:layout_width="match_parent"
                  android:layout_height="match_parent"/>
      
      </LinearLayout>
      
    • package cn.wenhe9.testmenu
      
      import androidx.appcompat.app.AppCompatActivity
      import android.os.Bundle
      import android.webkit.WebView
      import android.webkit.WebViewClient
      import cn.wenhe9.testmenu.databinding.ActivityTestWebViewBinding
      
      class TestWebView : AppCompatActivity() {
      
          private lateinit var binding : ActivityTestWebViewBinding
      
          override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
              binding = ActivityTestWebViewBinding.inflate(layoutInflater)
              setContentView(binding.root)
      
              val webView = binding.webView as WebView
      
              webView.settings.javaScriptEnabled = true
              webView.webViewClient = WebViewClient()
              webView.loadUrl("https://www.baidu.com")
      
      //        webView.apply {
      //            settings.javaScriptEnabled = true
      //            webViewClient = WebViewClient()
      //            webView.loadUrl("https://www.baidu.com")
      //        }
          }
      }
      
    • 调用setJavaScriptEnabled()方法,让webView支持JavaScript脚本

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

    • 最后一步调用webView的loadUrl()方法,并将网址传入,即可展示相应网页的内容

    • 最最后,值得注意的是,我们使用了网络功能,而访问网络是需要声明权限的,因此我们还得修改AndroidManifest.xml文件,加入权限声明:

      • <manifest xmlns:android="http://schemas.android.com/apk/res/android"
            package="com.example.webviewtest">
        
            <uses-permission android:name="android.permission.INTERNET" />
        
            ...
        
        </manifest>
        

使用HTTP访问网络

  • 注意
    • 使用Android虚拟机访问本机服务器时,需要使用本机的ip地址,而不是127.0.0.1
使用HttpURLConnection
  • 在过去,Android上发送HTTP请求一般有两种方式:HttpURLConnection和HttpClient。不过由于HttpClient存在API数量过多、扩展困难等缺点,Android团队越来越不建议我们使用这种方式。终于在Android 6.0系统中,HttpClient的功能被完全移除了,标志着此功能被正式弃用

  • HttpURLConnection的用法

    • 首先需要获取HttpURLConnection的实例,一般只需要创建一个URL对象,并传入目标的网络地址,然后调用一下openConnection()方法即可,如下所示:

      • val url = URL("https://www.baidu.com")
        val connection = url.openConnection() as HttpURLConnection
        
    • 在得到了HttpURLConnection的实例之后,我们可以设置一下HTTP请求所使用的方法,常用的方法主要有两个:GETPOST

      • GET表示希望从服务器那里获取数据

      • POST表示希望提交数据给服务器

      • connection.requestMethod = "GET"
        
    • 接下里就可以进行一些自由的定制了,比如设置连接超时,读取超时的毫秒数,以及服务器希望得到的一些消息头等,这部分内容根据自己的实际情况进行编写,示例如下:

      • connection.connectTimeout = 8000
        connection.readTimeout = 8000
        
    • 之后再调用getInputStream()方法就可以获取到服务器的输入流了,剩下的任务就是对输入流进行读取

      • val input = connection.inputStream
        
    • 最后可以调用disConnect()

  • package cn.wenhe9.testmenu
    
    import androidx.appcompat.app.AppCompatActivity
    import android.os.Bundle
    import android.util.Log
    import cn.wenhe9.testmenu.databinding.ActivityTestHttpUrlconnectionBinding
    import java.io.BufferedReader
    import java.io.InputStreamReader
    import java.lang.Exception
    import java.net.HttpURLConnection
    import java.net.URL
    import kotlin.concurrent.thread
    
    class TestHttpURLConnection : AppCompatActivity() {
    
        private lateinit var binding : ActivityTestHttpUrlconnectionBinding
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            binding = ActivityTestHttpUrlconnectionBinding.inflate(layoutInflater)
            setContentView(binding.root)
    
            binding.sendRequestBtn.setOnClickListener {
                thread {
                    var connection : HttpURLConnection? = null
                    val response = StringBuilder()
    
                    try {
                        val url = URL("https://www.baidu.com")
                        connection = url.openConnection() as HttpURLConnection
    
                        connection.requestMethod = "GET"
                        connection.connectTimeout = 8000
                        connection.readTimeout = 8000
    
                        val input = connection.inputStream
    
                        val reader = BufferedReader(InputStreamReader(input))
    
                        reader.use {
                            reader.forEachLine {
                                response.append(it)
                            }
                        }
    
                        updateUI(response.toString())
    
                    }catch (e : Exception){
                        e.printStackTrace()
                    }finally {
                        connection?.disconnect()
                    }
                }
            }
        }
    
        private fun updateUI(response : String){
            runOnUiThread {
                binding.responseText.text = response
            }
        }
    }
    
  • 而如果想要提交数据给服务器的话,只需要将http请求的方法改成POST,并在获取输入流之前把要提交的数据写出即可

    • 注意:

      • 每条数据都要以键值对的形式存在,数据与数据之间用&符号隔开,比如我们想要向服务器提交用户名和密码,就可以这样写:

        • connection.requestMethod = "POST"
          val output = DataOutputStream(connection.outputStream)
          output.writeBytes("username=admin&password=123456")
          

使用OkHttp

  • 在用OkHttp之前,需要先进行导包

    • implementation 'com.squareup.okhttp3:okhttp:4.9.1'
      
  • 具体用法:

    • 创建一个OkHttpClient的实例

      • val client = OkHttpClient()
        
    • 如果想要发起一条HTTP请求,就需要创建一个Request对象

      • val request = Request.Builder().build()
        
    • 当然上述代码只是创建了一个空的Request对象,并没有什么实际用处,我们可以在最终的build()方法之前连缀很多其他方法来丰富这个Request对象,比如可以通过url()方法来设置目标的网络地址

      • val request = Request.Builder()
        	.url("https://www.baidu.com")
        	.build()
        
    • 之后调用OkHttpClientnewCall()方法来常见一个Call对象,并调用他的execute()方法来发送请求并获取服务器返回的数据

      • val response = client.newCall(request).execute()
        
    • Response对象就是服务器返回的数据了,我们可以使用如下写法来得到返回的具体内容:

      • val responseData = response.body?.string()
        
    • 如果发起一条POST请求,会比GET请求稍微复杂一点,我们需要先构建一个RequestBody对象来存放待提交的参数

      • val requestBody = FormBody.Builder()
        	.add("username", "admin")
        	.add("password", "123456")
        	.build()
        
    • 然后在Request.Builder中调用一下post()方法,并将RequestBody对象传入:

      • val request = Request.Builder()
        	.url("https://www.baidu.com")
        	.post(requestBody)
        	.build()
        
    • 接下来的操作的就和GET请求一样了,调用execute()方法来发送请求并获取服务器返回的数据即可

    • package cn.wenhe9.testmenu
      
      import androidx.appcompat.app.AppCompatActivity
      import android.os.Bundle
      import cn.wenhe9.testmenu.databinding.ActivityTestOkHttpClientBinding
      import okhttp3.OkHttpClient
      import okhttp3.Request
      import kotlin.concurrent.thread
      
      class TestOkHttpClient : AppCompatActivity() {
      
          private lateinit var binding : ActivityTestOkHttpClientBinding
      
          override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
              binding = ActivityTestOkHttpClientBinding.inflate(layoutInflater)
              setContentView(binding.root)
      
              binding.sendRequestBtn.setOnClickListener {
                  thread {
                      val client = OkHttpClient()
      
                      val request = Request.Builder()
                          .url("https://www.baidu.com")
                          .build()
      
                      val response = client.newCall(request).execute()
      
      
                      uodateUI(response.body?.string())
                  }
              }
          }
      
          private fun uodateUI(data : String?){
              runOnUiThread {
                  binding.responseText.text = data
              }
          }
      }
      
    • package cn.wenhe9.testmenu
      
      import androidx.appcompat.app.AppCompatActivity
      import android.os.Bundle
      import cn.wenhe9.testmenu.databinding.ActivityTestOkHttpClientBinding
      import okhttp3.FormBody
      import okhttp3.OkHttpClient
      import okhttp3.Request
      import kotlin.concurrent.thread
      
      class TestOkHttpClient : AppCompatActivity() {
      
          private lateinit var binding : ActivityTestOkHttpClientBinding
      
          override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
              binding = ActivityTestOkHttpClientBinding.inflate(layoutInflater)
              setContentView(binding.root)
      
              binding.sendRequestBtn.setOnClickListener {
                  thread {
                      val client = OkHttpClient()
      
                      val body = FormBody.Builder()
                          .add("username", "admin")
                          .add("password", "123456")
                          .build()
      
                      val request = Request.Builder()
                          .url("https://www.baidu.com")
                          .post(body)
                          .build()
      
                      val response = client.newCall(request).execute()
      
                      
                      updateUI(response.body?.string())
                  }
              }
          }
      
          private fun updateUI(data : String?){
              runOnUiThread {
                  binding.responseText.text = data
              }
          }
      }
      

解析XML格式数据

Pull解析方式
  • package cn.wenhe9.testmenu
    
    import androidx.appcompat.app.AppCompatActivity
    import android.os.Bundle
    import android.util.Log
    import cn.wenhe9.testmenu.databinding.ActivityTestOkHttpClientBinding
    import okhttp3.FormBody
    import okhttp3.OkHttpClient
    import okhttp3.Request
    import org.xmlpull.v1.XmlPullParser
    import org.xmlpull.v1.XmlPullParserFactory
    import java.lang.Exception
    import kotlin.concurrent.thread
    
    class TestOkHttpClient : AppCompatActivity() {
    
        private lateinit var binding : ActivityTestOkHttpClientBinding
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            binding = ActivityTestOkHttpClientBinding.inflate(layoutInflater)
            setContentView(binding.root)
    
            binding.sendRequestBtn.setOnClickListener {
                thread {
                    val client = OkHttpClient()
    
                    val body = FormBody.Builder()
                        .add("username", "admin")
                        .add("password", "123456")
                        .build()
    
                    val request = Request.Builder()
                        .url("https://www.baidu.com")
                        .post(body)
                        .build()
    
                    val response = client.newCall(request).execute()
    
    
                }
            }
        }
    
        private fun parseXMLWithPull(xmlData : String){
            try {
                val factory = XmlPullParserFactory.newInstance()
    
                val xmlPullParser = factory.newPullParser()
    
                var eventType = xmlPullParser.eventType
    
                var id = ""
                var name = ""
                var version = ""
    
                while (eventType != XmlPullParser.END_DOCUMENT){
                    val nodeName= xmlPullParser.name
    
                    when(eventType){
                        //开始解析某个节点
                        XmlPullParser.START_TAG -> {
                            when(nodeName){
                                "id" -> id= xmlPullParser.nextText()
                                "name" -> name = xmlPullParser.nextText()
                                "version" -> version = xmlPullParser.nextText()
                            }
                        }
                        //完成某个节点
                        XmlPullParser.END_TAG -> {
                            if ("app" == nodeName){
                                Log.d("TestPull", "id is $id")
                                Log.d("TestPull", "name is $name")
                                Log.d("TestPull", "version is $version")
                            }
                        }
                    }
                    eventType = xmlPullParser.next()
                }
            }catch (e : Exception){
                e.printStackTrace()
            }
        }
    
    }
    
  • 下面就来仔细看下parseXMLWithPull()方法中的代码吧。这里首先要创建一个XmlPullParserFactory的实例,并借助这个实例得到XmlPullParser对象,然后调用XmlPullParsersetInput()方法将服务器返回的XML数据设置进去,之后就可以开始解析了。解析的过程也非常简单,通过getEventType()可以得到当前的解析事件,然后在一个while循环中不断地进行解析,如果当前的解析事件不等于XmlPullParser.END_DOCUMENT,说明解析工作还没完成,调用next()方法后可以获取下一个解析事件。

  • 在while循环中,我们通过getName()方法得到了当前节点的名字。如果发现节点名等于id、name或version,就调用nextText()方法来获取节点内具体的内容,每当解析完一个app节点,就将获取到的内容打印出来。

  • 不过在程序运行之前还得再进行一项额外的配置。从Android 9.0系统开始,应用程序默认只允许使用HTTPS类型的网络请求,HTTP类型的网络请求因为有安全隐患默认不再被支持

  • 那么为了能让程序使用HTTP,我们还要进行如下配置才可以。右击res目录→New→Directory,创建一个xml目录,接着右击xml目录→New→File,创建一个network_config.xml文件。然后修改network_config.xml文件中的内容,如下所示:

    • <?xml version="1.0" encoding="utf-8"?>
      <network-security-config>
          <base-config cleartextTrafficPermitted="true">
              <trust-anchors>
                  <certificates src="system" />
              </trust-anchors>
          </base-config>
      </network-security-config>
      
    • 这段配置文件的意思就是允许我们以明文的方式在网络上传输数据,而HTTP使用的就是明文传输方式。

  • 接下来修改AndroidManifest.xml中的代码来启用我们刚才创建的配置文件:

    • <manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="com.example.networktest">
          ...
          <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/AppTheme"
              android:networkSecurityConfig="@xml/network_config">
              ...
          </application>
      </manifest>
      
SAX解析方式
  • yaoshiyongSAX解析,通常情况下,我们会新建一个类继承自DefaultHandler,并重写父类的5个方法:

    • package cn.wenhe9.testmenu
      
      import org.xml.sax.Attributes
      import org.xml.sax.helpers.DefaultHandler
      
      /**
       *@author DuJinliang
       *2021/9/28
       */
      class MyHandler : DefaultHandler() {
          override fun startDocument() {
              super.startDocument()
          }
      
          override fun startElement(uri: String?, localName: String?, qName: String?, attributes: Attributes?) {
              super.startElement(uri, localName, qName, attributes)
          }
      
          override fun characters(ch: CharArray?, start: Int, length: Int) {
              super.characters(ch, start, length)
          }
      
          override fun endElement(uri: String?, localName: String?, qName: String?) {
              super.endElement(uri, localName, qName)
          }
      
          override fun endDocument() {
              super.endDocument()
          }
      }
      
      • startDocuemnt()方法会在开始XML解析的时候调用
      • startElement()方法会在开始解析某个节点的时候调用
      • characters()方法会在获取节点中内容的时候调用
      • endElement()方法会在完成解析某个节点的时候调用
      • endDocument()方法会在完成整个XML解析的时候调用
    • 其中startElement()characters()endElement()这3个方法是有参数的,从XML中解析出的数据就会以参数的形式传入这些方法中。需要注意的是,在获取节点中的内容时,characters()方法可能会被调用多次,一些换行符也被当作内容解析出来,我们需要针对这种情况在代码中做好控制。

  • package cn.wenhe9.testmenu
    
    import android.util.Log
    import org.xml.sax.Attributes
    import org.xml.sax.helpers.DefaultHandler
    
    /**
     *@author DuJinliang
     *2021/9/28
     */
    class ContentHandler : DefaultHandler() {
    
        private var nodeName = ""
    
        private lateinit var id : StringBuilder
    
        private lateinit var name : StringBuilder
    
        private lateinit var version : StringBuilder
    
        override fun startDocument() {
            id = StringBuilder()
            name = StringBuilder()
            version = StringBuilder()
        }
    
        override fun startElement(uri: String, localName: String, qName: String, attributes: Attributes) {
            //记录当前节点名
            nodeName = localName
    
            Log.d("ContentHandler", "uri is $uri")
            Log.d("ContentHandler", "localName is $localName")
            Log.d("ContentHandler", "qName is $qName")
            Log.d("ContentHandler", "attributes is $attributes")
        }
    
        override fun characters(ch: CharArray?, start: Int, length: Int) {
            //根据当前节点名称判断将内容添加到哪一个StrignBuilder对象中
    
            when(nodeName){
                "id" -> id.append(ch, start, length)
    
                "name" -> name.append(ch, start, length)
    
                "version" -> version.append(ch, start, length)
            }
        }
    
        override fun endElement(uri: String?, localName: String?, qName: String?) {
            if("app" == localName){
                Log.d("ContentHandler", "id is ${id.toString().trim()}")
                Log.d("ContentHandler", "name is ${name.toString().trim()}")
                Log.d("ContentHandler", "version is ${version.toString().trim()}")
                //最后要将StringBuilder清空
                id.setLength(0)
                name.setLength(0)
                version.setLength(0)
            }
        }
    
        override fun endDocument() {
        }
    }
    
    • 注意,因为内容中的回车和换行符也会被解析,所以需要在最后使用trim()方法
  •     private fun parseXMLWithSAX(xmlData : String){
            try {
                val factory = SAXParserFactory.newInstance()
    
                val xmlReader = factory.newSAXParser().xmlReader
    
                val handler = ContentHandler()
    
                xmlReader.contentHandler = handler
    
                xmlReader.parse(InputSource(StringReader(xmlData)))
            }catch (e : Exception){
                e.printStackTrace()
            }
        }
    
    • parseXMLWithSAX()方法中先是创建了一个SAXParserFactory的对象,然后再获取XMLReader对象,接着将我们编写的ContentHandler的实例设置到XMLReader中,最后调用parse()方法开始执行解析。
DOM解析方式

解析JSON格式数据

  • 比起XML,JSON的主要优势在于它的体积更小,在网络上传输的时候更省流量。但缺点在于,它的语义性较差,看起来不如XML直观
使用JSONObject
  •     private fun parseJSONWithJSONObject(jsonData : String?){
            try {
                val jsonArray = JSONArray(jsonData)
    
                for(i in 0 until jsonArray.length()){
                    val jsonObject = jsonArray.getJSONObject(i)
    
                    val username = jsonObject.getString("username")
                    val password = jsonObject.getString("password")
    
                    Log.d("TestJson", "username is $username")
                    Log.d("TestJson", "password is $password")
    
                }
            }catch (e : Exception){
                e.printStackTrace()
            }
        }
    
    • 由于我们在服务器中定义的是一个JSON数组,因此这里首先将服务器返回的数据传入一个JSONArray对象中。然后循环遍历这个JSONArray,从中取出的每一个元素都是一个JSONObject对象,每个JSONObject对象中又会包含id、name和version这些数据。接下来只需要调用getString()方法将这些数据取出,并打印出来即可
使用GSON
  • GSON可以将一段JSON格式的字符串自动映射成一个对象,从而不需要我们再手动编写代码进行解析

  • 比如这样一段JSON格式的数据

    • {"username” : "马尔扎哈", "password" : "12346"}
      
  • 那我们就可以定义一个Person类,并加入usernameage这两个字段,然后只需要的简单的调用如下代码就可以将JSON数据自动解析成一个Person对象

    • val gson = Gson()
      val person = gson.fromJson(jsonData, Person::class.java)
      
  • 如果需要解析的是一段JSON数组,会稍微麻烦一点,比如如下格式:

    • [{"username” : "马尔扎哈", "password" : "12346"}, {"username” : "马尔扎哈", "password" : "12346"}, {"username” : "马尔扎哈", "password" : "12346"}]
      
  • 这个时候,我们需要借助TypeToken将期望解析成的数据类型传入fromJson()方法中,如下所示:

    • val typeof = object : TypeToken<List<Person>>() {}.type
      val people = gson.fromJson<List<Person>>(jsonData, typeof)
      
  •     private fun parseJSONWithGson(jsonData : String?){
            try {
                val gson = Gson()
    
                val typeOf = object : TypeToken<List<User>>() {}.type
    
                val people = gson.fromJson<List<User>>(jsonData, typeOf)
    
                for(item in 0 until  people.size){
                    Log.d("TestJson", "people is ${people.get(item)}")
                }
            }catch (e : Exception){
                e.printStackTrace()
            }
        }
    
    

网络请求回调的实现方式

  • 通常情况下,我们应该将这些通用的网络操作提取到一个公共的类里,并提供一个通用方法,当想要发起网络请求的时候,只需简单的调用一下这个方法即可,比如使用如下的写法:

    • package cn.wenhe9.testmenu
      
      import java.io.BufferedReader
      import java.io.InputStreamReader
      import java.lang.Exception
      import java.net.HttpURLConnection
      import java.net.URL
      
      /**
       *@author DuJinliang
       *2021/9/28
       */
      object HttpUtil {
          fun sendHttpRequest(address : String) : String{
      
              var connection : HttpURLConnection? = null
      
              try {
                  val response = StringBuilder()
                  val url = URL(address)
                  connection = url.openConnection()
       as HttpURLConnection
                  connection.connectTimeout = 8000
                  connection.readTimeout = 8000
      
                  val input = connection.inputStream
      
                  val reader = BufferedReader(InputStreamReader(input))
      
                  reader.use {
                      reader.forEachLine {
                          response.append(it)
                      }
                  }
      
                  return response.toString()
              }catch (e : Exception){
                  e.printStackTrace()
              }finally {
                  connection?.disconnect()
              }
      
              return ""
          }
      }
      
  • 以后每当需要发起一条HTTP请求的时候,就可以这样写:

    • val address = "https://www.baidu.com"
      val response = HttpUtil.sendHttpRequest(address)
      
  • 在获取到服务器响应的数据后,我们就可以对他进行解析和处理了,但是需要注意,网络请求通常属于耗时操作,而sendHttpRequest()方法的内部并没有开启线程,这样就可有可能导致在调用的sendHttpRequest()方法的时候主线程被阻塞

  • 你可能会说,很简单嘛,在sendHttpRequest()方法内部开启一个线程,不就解决这个问题了吗?其实没有你想象中那么容易,因为如果我们在sendHttpRequest()方法中开启一个线程来发起HTTP请求,服务器响应的数据是无法进行返回的。这是由于所有的耗时逻辑都是在子线程里进行的,sendHttpRequest()方法会在服务器还没来得及响应的时候就执行结束了,当然也就无法返回响应的数据了。

  • 那么要如何解决呢?使用回调机制就可以了

  • 首先需要定义一个接口,比如将他命名成HttpCallbackListener,代码如下所示:

    • interface HttpCallbackListener {
          fun onFinish(response : String)
          fun onError(e : Exception)
      }
      
  • 可有看到,我们在接口中定义了两个方法

    • onFinish(response : String)方法表示当服务器成功相应我们请求的时候调用,其中的参数代表服务器返回的数据
    • onError(e : Exception)方法表示当进行网络操作出现错误时调用,其中得到参数记录着错误的详细信息
  • 修改HttpUtil中的代码,如下所示:

    • package cn.wenhe9.testmenu
      
      import java.io.BufferedReader
      import java.io.InputStreamReader
      import java.lang.Exception
      import java.net.HttpURLConnection
      import java.net.URL
      import kotlin.concurrent.thread
      
      /**
       *@author DuJinliang
       *2021/9/28
       */
      object HttpUtil {
          fun sendHttpRequest(address : String, listener : HttpCallbackListener) {
      
              thread {
                  var connection : HttpURLConnection? = null
      
                  try {
                      val response = StringBuilder()
                      val url = URL(address)
                      connection = url.openConnection()
                              as HttpURLConnection
                      connection.connectTimeout = 8000
                      connection.readTimeout = 8000
      
                      val input = connection.inputStream
      
                      val reader = BufferedReader(InputStreamReader(input))
      
                      reader.use {
                          reader.forEachLine {
                              response.append(it)
                          }
                      }
      
                      listener.onFinish(response.toString())
      
                  }catch (e : Exception){
                      e.printStackTrace()
                      listener.onError(e)
                  }finally {
                      connection?.disconnect()
                  }
              }
      
          }
      }
      
  • 我们首先给sendHttpRequest()方法添加了一个HttpCallbackListener参数,并在方法的内部开启了一个子线程,然后在子线程里执行具体的网络操作。注意,子线程中是无法通过return语句返回数据的,因此这里我们将服务器响应的数据传入了HttpCallbackListeneronFinish()方法中,如果出现了异常,就将异常原因传入onError()方法中。

  • 现在sendHttpRequest()方法接收两个参数,因此我们在调用它的时候还需要将HttpCallbackListener的实例传入,如下所示:

    • HttpUtil.sendHttpRequest(address, object : HttpCallbackListener {
          override fun onFinish(response: String) {
              // 得到服务器返回的具体内容
          }
      
          override fun onError(e: Exception) {
              // 在这里对异常情况进行处理
          }
      })
      
  • 这样当服务器成功响应的时候,我们就可以在onFinish()方法里对响应数据进行处理了。类似地,如果出现了异常,就可以在onError()方法里对异常情况进行处理。如此一来,我们就巧妙地利用回调机制将响应数据成功返回给调用方了。

    • fun sendOkHttpRequest(address : String, callback : okhttp3.Callback){
          val client = OkHttpClient()
      
          val request = Request.Builder()
          .url(address)
          .build()
      
          client.newCall(request).enqueue(callback)
      }
      
  • 可以看到,sendOkHttpRequest()方法中有一个okhttp3.Callback参数,这个是OkHttp库中自带的回调接口,类似于我们刚才自己编写的HttpCallbackListener。然后在client.newCall()之后没有像之前那样一直调用execute()方法,而是调用了一个enqueue()方法,并把okhttp3.Callback参数传入。相信聪明的你已经猜到了,OkHttp在enqueue()方法的内部已经帮我们开好子线程了,然后会在子线程中执行HTTP请求,并将最终的请求结果回调到okhttp3.Callback当中。

  • 那么我们在调用sendOkHttpRequest()方法的时候就可以这样写:

    • HttpUtil.sendOkHttpRequest(address, object : Callback {
          override fun onResponse(call: Call, response: Response) {
              // 得到服务器返回的具体内容
              val responseData = response.body?.string()
          }
      
          override fun onFailure(call: Call, e: IOException) {
              // 在这里对异常情况进行处理
          }
      })
      
  • 另外,需要注意的是,不管是使用HttpURLConnection还是OkHttp,最终的回调接口都还是在子线程中运行的,因此我们不可以在这里执行任何的UI操作,除非借助runOnUiThread()方法来进行线程转换。

网络库Retrofit

Retrofit的基本用法
  • Retrofit的基本设计思想

    • 同一款应用程序中所发起的网络请求绝大多数指向的是同一个服务器域名,这个很好理解,因为任何公司的产品,客户端和服务器都是配套的,很难想象一个客户端一会去这个服务器获取数据,一会又要去另外一个服务器获取数据吧?
    • 另外,服务器提供的接口通常是可以根据功能来归类的,比如新增用户、修改用户数据、查询用户数据这几个接口就可以归为一类,上架新书、销售图书、查询可供销售图书这几个接口也可以归为一类。将服务器接口合理归类能够让代码结构变得更加合理,从而提高可阅读性和可维护性
    • 最后,开发者肯定更加习惯于“调用一个借口,获取他的返回值”这样的编码方式,但当调用的是服务器接口时,却很难想象该如何使用这样的编码方式,其实大多数人并不关心网络的具体通信细节,但是传统网络库的用法却需要编写太多网络相关的代码
    • 而Retrofit的用法就是基于以上几点来设计的,首先我们可以配置好一个根路径,然后在指定服务器接口地址时只需要使用相对路径即可,这样就不用每次都指定完整的URL地址了
    • 另外,Retrofit允许我们对服务器接口进行匪类,将功能同属一类的服务器接口定义到同一个接口文件当中,从而让代码结构变得更加合理
    • 最后,我们也完全不用关心网络通信细节,只需要在接口文件中声明一系列的方法和返回值,然后通过注解的方式指定该方法对应哪个服务器接口, 以及需要提供哪些参数,当我们在程序中调用该方法时,并将相应的数据解析成返回值声明的类型,这就使得我们可以使用更加面向对象的思维来进行网络操作
  • 要想使用Retrofit,我们需要在项目中添加必要的依赖库

    • dependencies {
          implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
          implementation 'com.squareup.retrofit2:retrofit:2.9.0'
      }
      
  • 由于Retrofit是基于OkHttp开发的,因此添加上述第一条依赖会自动将Retrofit、OkHttp和Okio这几个库一起下载,我们无须再手动引入OkHttp库。另外,Retrofit还会将服务器返回的JSON数据自动解析成对象,因此上述第二条依赖就是一个Retrofit的转换库,它是借助GSON来解析JSON数据的,所以会自动将GSON库一起下载下来,这样我们也不用手动引入GSON库了。除了GSON之外,Retrofit还支持各种其他主流的JSON解析库,包括Jackson、Moshi等,不过毫无疑问GSON是最常用的。

  • 由于Retrofit会借助GSON将JSON数据转换成对象,因此这里同样需要新增一个User类,并加入usernamepassword这两个字段,如下所示:

    • data class User(val username : String, val password : String)
      
  • 接下来,我们可以根据服务器接口的功能进行归类,创建不同种类的接口文件,并在其中定义对应具体服务器接口的方法,不过由于我们本机的服务器上其实只有一个获取JSON数据的接口,因此这里只需要定义一个接口文件,并包含一个方法即可,新建UserService接口,代码如下所示:

    • interface UserService {
          @GEt("get_data.json")
          fun getUserData() : Call<List<User>>
      }
      
  • 通常Retrofit的接口文件建议以具体的功能种类名开头,以Service结尾,这是一种比较好的命名习惯

  • 上述代码中有两点需要我们注意

    • 第一就是在getUserData()方法上面添加的注解这里使用了一个@GET注解,表示当调用getAppData()方法时REtrofit会发起一条GET请求,请求的地址就是我们在@GET注解中传入的具体参数,注意,这里只需要传入请求地址的相对路径即可,根路径我们会在稍后设置
    • 第二就是getAppData()方法的返回值必须声明成Retrofit中内中的Call类型,并通过泛型来指定服务器响应的数据应该转换成什么对象,由于服务器响应的是一个包含User数据的JSON数组,因此这里我们将泛型声明成List<User>,当然,Retrofit还提供了强大的Call Adapters 功能来允许我们自定义方法返回值的类型,比如Retrofit结合RxJava使用就可以将返回值声明称ObservableFlowable等类型
  • 定义好了AppService接口后,就该使用它了

    • package cn.wenhe9.testmenu
      
      import androidx.appcompat.app.AppCompatActivity
      import android.os.Bundle
      import android.util.Log
      import cn.wenhe9.testmenu.databinding.ActivityTestRetrofitBinding
      import retrofit2.Call
      import retrofit2.Callback
      import retrofit2.Response
      import retrofit2.Retrofit
      import retrofit2.converter.gson.GsonConverterFactory
      
      class TestRetrofit : AppCompatActivity() {
      
          private lateinit var binding : ActivityTestRetrofitBinding
      
          override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
              binding = ActivityTestRetrofitBinding.inflate(layoutInflater)
              setContentView(binding.root)
      
              binding.testRetrofit.setOnClickListener {
      
                  val retrofit = Retrofit.Builder()
                      .baseUrl("http://192.168.31.87:8000/user/")
                      .addConverterFactory(GsonConverterFactory.create())
                      .build()
      
                  val service = retrofit.create(UserService::class.java)
      
                  service.getUserData().enqueue(object : Callback<List<User>>{
                      override fun onResponse(call: Call<List<User>>, response: Response<List<User>>) {
                          val list = response.body()
      
                          if(list != null){
                              for (user in list){
                                  Log.d("TestRetrofit", "username is ${user.username}")
                                  Log.d("TestRetrofit", "passsword is ${user.password}")
                              }
                          }
                      }
      
                      override fun onFailure(call: Call<List<User>>, t: Throwable) {
                          t.printStackTrace()
                      }
      
                  })
      
              }
          }
      }
      
    • 可以看到,在“Get App Data”按钮的点击事件当中,首先使用了Retrofit.Builder来构建一个Retrofit对象,其中baseUrl()方法用于指定所有Retrofit请求的根路径,addConverterFactory()方法用于指定Retrofit在解析数据时所使用的转换库,这里指定成GsonConverterFactory。注意这两个方法都是必须调用的。

    • 有了Retrofit对象之后,我们就可以调用它的create()方法,并传入具体Service接口所对应的Class类型,创建一个该接口的动态代理对象。如果你并不熟悉什么是动态代理也没有关系,你只需要知道有了动态代理对象之后,我们就可以随意调用接口中定义的所有方法,而Retrofit会自动执行具体的处理就可以了。

    • 对应到上述的代码当中,当调用了AppService的getAppData()方法时,会返回一个Call<List<App>>对象,这时我们再调用一下它的enqueue()方法,Retrofit就会根据注解中配置的服务器接口地址去进行网络请求了,服务器响应的数据会回调到enqueue()方法中传入的Callback实现里面。需要注意的是,当发起请求的时候,Retrofit会自动在内部开启子线程,当数据回调到Callback中之后,Retrofit又会自动切换回主线程,整个操作过程中我们都不用考虑线程切换问题。在Callback的onResponse()方法中,调用response.body()方法将会得到Retrofit解析后的对象,也就是List类型的数据,最后遍历List,将其中的数据打印出来即可。

  • 需要注意的是,和之前一样,这里也需要对网络安全进行配置才行

处理复杂的接口地址类型
  • 大多数情况下,服务器不可能总是给我们提供静态类型的接口,在很多情境下,接口地址中的部分内容可能会是动态变化的,比如如下的接口地址:

    • GET http://example.com/<page>/get_data.json
      
  • 这个接口中,<page>代表页数,我们传入不同的页数,服务器返回的数据也会不同,这种接口地址对应到Retrofit当中应该怎么写呢?

    • interface EamlpeService{
          @GET("{page}/get_data.json")
          fun getData(@Path("page") page : Int) : Call<Data>
      }
      
  • @GET注解指定的接口地址当中,这里使用了{page}的占位符,然后又在getData()方法中添加一个page参数,并使用@Path("page")注解来声明这个参数,这样当调用getData()方法发起请求时,Retrofit就会自动将page参数的值替换到占位符的位置,从而组成一个合法的请求地址

  • 另外,很多服务器接口还有要求我们传入一系列的参数,格式如下:

    • GET http://example.com/get_data.json?u=<user>&t=<token>
      
  • 这是一种标准的带参数GET请求的格式。接口地址的最后使用问号来连接参数部分,每个参数都是一个使用等号连接的键值对,多个参数之间使用&符号进行分隔。那么很显然,在上述地址中,服务器要求我们传入user和token这两个参数的值。对于这种格式的服务器接口,我们可以使用刚才所学的@Path注解的方式来解决,但是这样会有些麻烦,Retrofit针对这种带参数的GET请求,专门提供了一种语法支持:

    • interface ExampleService {
      
          @GET("get_data.json")
          fun getData(@Query("u") user: String, @Query("t") token: String): Call<Data>
      
      }
      
  • 这里在getData()方法中添加了user和token这两个参数,并使用@Query注解对它们进行声明。这样当发起网络请求的时候,Retrofit就会自动按照带参数GET请求的格式将这两个参数构建到请求地址当中。

  • HTTP并不是只有GET请求这一种类型,而是有很多种,其中比较常用的有GET、POST、PUT、PATCH、DELETE这几种。它们之间的分工也很明确,简单概括的话,GET请求用于从服务器获取数据,POST请求用于向服务器提交数据,PUT和PATCH请求用于修改服务器上的数据,DELETE请求用于删除服务器上的数据。

  • 而Retrofit对所有常用的HTTP请求类型都进行了支持,使用@GET、@POST、@PUT、@PATCH、@DELETE注解,就可以让Retrofit发出相应类型的请求了。

  • 比如服务器提供了如下接口地址:

    • DELETE http://example.com/data/<id>
      
  • 这种接口通常意味着要根据id删除一条指定的数据,而我们在Retrofit当中想要发出这种请求就可以这样写:

    • interface ExampleService {
      
          @DELETE("data/{id}")
          fun deleteData(@Path("id") id: String): Call<ResponseBody>
      
      }
      
  • 这里使用了@DELETE注解来发出DELETE类型的请求,并使用了@Path注解来动态指定id,这些都很好理解。但是在返回值声明的时候,我们将Call的泛型指定成了ResponseBody,这是什么意思呢?

  • 由于POST、PUT 、PATCH、DELETE这几种请求类型与GET请求不同,它们更多是用于操作服务器上的数据,而不是获取服务器上的数据,所以通常它们对于服务器响应的数据并不关心。这个时候就可以使用ResponseBody,表示Retrofit能够接收任意类型的响应数据,并且不会对响应数据进行解析。

  • 那么如果我们需要向服务器提交数据该怎么写呢?比如如下的接口地址:

    • POST http://example.com/data/create
      {"id": 1, "content": "The description for this data."}
      
  • 使用POST请求来提交数据,需要将数据放到HTTP请求的body部分,这个功能在Retrofit中可以借助@Body注解来完成:

    • interface ExampleService {
      
          @POST("data/create")
          fun createData(@Body data: Data): Call<ResponseBody>
      
      }
      
  • 可以看到,这里我们在createData()方法中声明了一个Data类型的参数,并给它加上了@Body注解。这样当Retrofit发出POST请求时,就会自动将Data对象中的数据转换成JSON格式的文本,并放到HTTP请求的body部分,服务器在收到请求之后只需要从body中将这部分数据解析出来即可。这种写法同样也可以用来给PUT、PATCH、DELETE类型的请求提交数据。

  • 最后,有些服务器接口还可能会要求我们在HTTP请求的header中指定参数,比如:

    • GET http://example.com/get_data.json
      User-Agent: okhttp
      Cache-Control: max-age=0
      
  • 这些header参数其实就是一个个的键值对,我们可以在Retrofit中直接使用@Headers注解来对它们进行声明。

    • interface ExampleService {
      
          @Headers("User-Agent: okhttp", "Cache-Control: max-age=0")
          @GET("get_data.json")
          fun getData(): Call<Data>
      
      }
      
  • 但是这种写法只能进行静态header声明,如果想要动态指定header的值,则需要使用@Header注解,如下所示:

    • interface ExampleService {
      
          @GET("get_data.json")
          fun getData(@Header("User-Agent") userAgent: String,
              @Header("Cache-Control") cacheControl: String): Call<Data>
      
      }
      
  • 现在当发起网络请求的时候,Retrofit就会自动将参数中传入的值设置到User-Agent和Cache-Control这两个header当中,从而实现了动态指定header值的功能。

Retrofit构造器的最佳写法

  • 使用泛型实化
package cn.wenhe9.testmenu

import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

/**
 *@author DuJinliang
 *2021/9/29
 */
object ServiceCreator {
    private const val baseUrl = "http://192.168.31.87:8000/user"

    private val retrofit = Retrofit.Builder()
        .baseUrl(baseUrl)
        .addConverterFactory(GsonConverterFactory.create())
        .build()

    fun <T> create(serviceClass : Class<T>) : T = retrofit.create(serviceClass)

    inline fun<reified T> create() : T = create(T::class.java)
}

Material Design

Toolbar

  • ActionBar由于其设计的原因,被限定只能位于Activity的顶部,从而不能实现一些MaterialDesign的效果,因此官方现在已经不再建议使用ActionBar了

  • Toolbar的强大之处在于,它不仅继承了ActionBar的所有功能,而且灵活性很高,可以配合其他控件完成一些Material Design的效果

  • 首先你要知道,任何一个新建的项目,默认都是会显示ActionBar的,这个想必你已经见识过太多次了。那么这个ActionBar到底是从哪里来的呢?其实这是根据项目中指定的主题来显示的。打开AndroidManifest.xml文件看一下,如下所示:

    • <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/AppTheme">
          ...
      </application>
      
  • 可以看到,这里使用android:theme属性指定了一个AppTheme的主题。那么这个AppTheme又是在哪里定义的呢?打开res/values/styles.xml文件,代码如下所示:

    • <resources>
      
          <!-- Base application theme. -->
          <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
              <!-- Customize your theme here. -->
              <item name="colorPrimary">@color/colorPrimary</item>
              <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
              <item name="colorAccent">@color/colorAccent</item>
          </style>
      
      </resources>
      
  • 这里定义了一个叫AppTheme的主题,然后指定它的parent主题是Theme.AppCompat.Light.DarkActionBar。这个DarkActionBar是一个深色的ActionBar主题,我们之前所有的项目中自带的ActionBar就是因为指定了这个主题才出现的。

  • 而现在我们准备使用Toolbar来替代ActionBar,因此需要指定一个不带ActionBar的主题,通常有Theme.AppCompat.NoActionBarTheme.AppCompat.Light.NoActionBar这两种主题可选。

    • Theme.AppCompat.NoActionBar表示深色主题,它会将界面的主体颜色设成深色,陪衬颜色设成浅色。

    • Theme.AppCompat.Light.NoActionBar表示浅色主题,它会将界面的主体颜色设成浅色,陪衬颜色设成深色。

    • <resources>
      
          <!-- Base application theme. -->
          <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
              <!-- Customize your theme here. -->
              <item name="colorPrimary">@color/colorPrimary</item>
              <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
              <item name="colorAccent">@color/colorAccent</item>
          </style>
      
      </resources>
      
  • 观察一下AppTheme中的属性重写,这里重写了colorPrimarycolorPrimaryDarkcolorAccent这3个属性的颜色。那么这3个属性分别代表什么位置的颜色呢?我用语言比较难描述清楚,还是通过一张图来理解一下吧,如图所示

    • image-20210929184212315
  • 除了上述3个属性之外,我们还可以通过textColorPrimarywindowBackgroundnavigationBarColor等属性控制更多位置的颜色。不过唯独colorAccent这个属性比较难理解,它不只是用来指定这样一个按钮的颜色,而是更多表达了一种强调的意思,比如一些控件的选中状态也会使用colorAccent的颜色。

  • 现在我们已经将ActionBar隐藏起来了,那么接下来看一看如何使用Toolbar来替代ActionBar。修改activity_main.xml中的代码,如下所示:

    • <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
          xmlns:app="http://schemas.android.com/apk/res-auto"
          android:layout_width="match_parent"
          android:layout_height="match_parent">
      
          <androidx.appcompat.widget.Toolbar
              android:id="@+id/toolbar"
              android:layout_width="match_parent"
              android:layout_height="?attr/actionBarSize"
              android:background="@color/colorPrimary"
              android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
              app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />
      
      </FrameLayout>
      
  • 虽然这段代码不长,但是里面着实有不少技术点是需要我们仔细琢磨一下的。首先看一下第2行,这里使用xmlns:app指定了一个新的命名空间。思考一下,正是由于每个布局文件都会使用xmlns:android来指定一个命名空间,我们才能一直使用android:id、android: layout_width等写法。这里指定了xmlns:app,也就是说现在可以使用app:attribute这样的写法了。但是为什么这里要指定一个xmlns:app的命名空间呢?这是由于许多Material属性是在新系统中新增的,老系统中并不存在,那么为了能够兼容老系统,我们就不能使用android:attribute这样的写法了,而是应该使用app:attribute。

  • 接下来定义了一个Toolbar控件,这个控件是由appcompat库提供的。这里我们给Toolbar指定了一个id,将它的宽度设置为match_parent,高度设置为actionBar的高度,背景色设置为colorPrimary。不过下面的部分就稍微有点难理解了,由于我们刚才在styles.xml中将程序的主题指定成了浅色主题,因此Toolbar现在也是浅色主题,那么Toolbar上面的各种元素就会自动使用深色系,从而和主体颜色区别开。但是之前使用ActionBar时文字都是白色的,现在变成黑色的会很难看。那么为了能让Toolbar单独使用深色主题,这里我们使用了android:theme属性,将Toolbar的主题指定成了ThemeOverlay.AppCompat.Dark.ActionBar。但是这样指定之后又会出现新的问题,如果Toolbar中有菜单按钮(我们在3.2.5小节中学过),那么弹出的菜单项也会变成深色主题,这样就再次变得十分难看了,于是这里又使用了app:popupTheme属性,单独将弹出的菜单项指定成了浅色主题。

  • 写完了布局,接下来我们修改MainActivity,代码如下所示:

    • class MainActivity : AppCompatActivity() {
      
          override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
              setContentView(R.layout.activity_main)
              setSupportActionBar(toolbar)
          }
      }
      
  • 这里关键的代码只有一句,调用setSupportActionBar()方法并将Toolbar的实例传入,这样我们就做到既使用了Toolbar,又让它的外观与功能都和ActionBar一致了。

    • image-20210929190040493
  • 接下来我们再学习一些Toolbar比较常用的功能吧,比如修改标题栏上显示的文字内容。这段文字内容是在AndroidManifest.xml中指定的,如下所示:

    • <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/AppTheme">
          <activity
              android:name=".MainActivity"
              android:label="Fruits">
              ...
          </activity>
      </application>
      
  • 这里给activity增加了一个android:label属性,用于指定在Toolbar中显示的文字内容,如果没有指定的话,会默认使用application中指定的label内容,也就是我们的应用名称。

  • 不过只有一个标题的Toolbar看起来太单调了,我们还可以再添加一些action按钮来让Toolbar更加丰富一些。这里我提前准备了几张图片作为按钮的图标,将它们放在了drawable-xxhdpi目录下(资源下载方式见前言)。现在右击res目录→New→Directory,创建一个menu文件夹。然后右击menu文件夹→New→Menu resource file,创建一个toolbar.xml文件,并编写如下代码:

    • <menu xmlns:android="http://schemas.android.com/apk/res/android"
            xmlns:app="http://schemas.android.com/apk/res-auto">
          <item
              android:id="@+id/backup"
              android:icon="@drawable/ic_backup"
              android:title="Backup"
              app:showAsAction="always" />
          <item
              android:id="@+id/delete"
              android:icon="@drawable/ic_delete"
              android:title="Delete"
              app:showAsAction="ifRoom" />
          <item
              android:id="@+id/settings"
              android:icon="@drawable/ic_settings"
              android:title="Settings"
              app:showAsAction="never" />
      </menu>
      
  • 可以看到,我们通过标签来定义action按钮,android:id用于指定按钮的id,android:icon用于指定按钮的图标,android:title用于指定按钮的文字。

  • 接着使用app:showAsAction来指定按钮的显示位置,这里之所以再次使用了app命名空间,同样是为了能够兼容低版本的系统。showAsAction主要有以下几种值可选:always表示永远显示在Toolbar中,如果屏幕空间不够则不显示;ifRoom表示屏幕空间足够的情况下显示在Toolbar中,不够的话就显示在菜单当中;never则表示永远显示在菜单当中。注意,Toolbar中的action按钮只会显示图标,菜单中的action按钮只会显示文字。

    • class MainActivity : AppCompatActivity() {
          ...
          override fun onCreateOptionsMenu(menu: Menu?): Boolean {
              menuInflater.inflate(R.menu.toolbar, menu)
              return true
          }
      
          override fun onOptionsItemSelected(item: MenuItem): Boolean {
              when (item.itemId) {
                  R.id.backup -> Toast.makeText(this, "You clicked Backup",
                                     Toast.LENGTH_SHORT).show()
                  R.id.delete -> Toast.makeText(this, "You clicked Delete",
                                     Toast.LENGTH_SHORT).show()
                  R.id.settings -> Toast.makeText(this, "You clicked Settings",
                                       Toast.LENGTH_SHORT).show()
              }
              return true
          }
      
      }
      
  • 非常简单,我们在onCreateOptionsMenu()方法中加载了toolbar.xml这个菜单文件,然后在onOptionsItemSelected()方法中处理各个按钮的点击事件。现在重新运行一下程序,效果如图所示

    • image-20210929190303212
  • 可以看到,Toolbar上现在显示了两个action按钮,这是因为Backup按钮指定的显示位置是always,Delete按钮指定的显示位置是ifRoom,而现在屏幕空间很充足,因此两个按钮都会显示在Toolbar中。另外一个Settings按钮由于指定的显示位置是never,所以不会显示在Toolbar中,点击一下最右边的菜单按钮来展开菜单项,你就能找到Settings按钮了。另外,这些action按钮都是可以响应点击事件的

DrawerLayout

<androidx.drawerlayout.widget.DrawerLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/drawerLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="@color/colorPrimary"
            android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
            app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />

    </FrameLayout>

    <TextView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="start"
        android:background="#FFF"
        android:text="This is menu"
        android:textSize="30sp" />

</androidx.drawerlayout.widget.DrawerLayout>
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        setSupportActionBar(toolbar)
        supportActionBar?.let {
            it.setDisplayHomeAsUpEnabled(true)
            it.setHomeAsUpIndicator(R.drawable.ic_menu)
        }
    }
    ...
    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        when (item.itemId) {
            android.R.id.home -> drawerLayout.openDrawer(GravityCompat.START)
            ...
        }
        return true
    }

}

NavigationView

dependencies {
    ...
    implementation 'com.google.android.material:material:1.1.0'
    implementation 'de.hdodenhof:circleimageview:3.0.1'
}
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <group android:checkableBehavior="single">
        <item
            android:id="@+id/navCall"
            android:icon="@drawable/nav_call"
            android:title="Call" />
        <item
            android:id="@+id/navFriends"
            android:icon="@drawable/nav_friends"
            android:title="Friends" />
        <item
            android:id="@+id/navLocation"
            android:icon="@drawable/nav_location"
            android:title="Location" />
        <item
            android:id="@+id/navMail"
            android:icon="@drawable/nav_mail"
            android:title="Mail" />
        <item
            android:id="@+id/navTask"
            android:icon="@drawable/nav_task"
            android:title="Tasks" />
    </group>
</menu>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="180dp"
    android:padding="10dp"
    android:background="@color/colorPrimary">

    <de.hdodenhof.circleimageview.CircleImageView
        android:id="@+id/iconImage"
        android:layout_width="70dp"
        android:layout_height="70dp"
        android:src="@drawable/nav_icon"
        android:layout_centerInParent="true" />

    <TextView
        android:id="@+id/mailText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:text="tonygreendev@gmail.com"
        android:textColor="#FFF"
        android:textSize="14sp" />

    <TextView
        android:id="@+id/userText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_above="@id/mailText"
        android:text="Tony Green"
        android:textColor="#FFF"
        android:textSize="14sp" />

</RelativeLayout>
<androidx.drawerlayout.widget.DrawerLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/drawerLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="@color/colorPrimary"
            android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
            app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />

    </FrameLayout>

    <com.google.android.material.navigation.NavigationView
        android:id="@+id/navView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="start"
        app:menu="@menu/nav_menu"
        app:headerLayout="@layout/nav_header"/>

</androidx.drawerlayout.widget.DrawerLayout>
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        setSupportActionBar(toolbar)
        supportActionBar?.let {
            it.setDisplayHomeAsUpEnabled(true)
            it.setHomeAsUpIndicator(R.drawable.ic_menu)
        }
        navView.setCheckedItem(R.id.navCall)
        navView.setNavigationItemSelectedListener {
            drawerLayout.closeDrawers()
            true
        }
    }
    ...
}

FloatingActionButton

<androidx.drawerlayout.widget.DrawerLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/drawerLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="@color/colorPrimary"
            android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
            app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />

        <com.google.android.material.floatingactionbutton.FloatingActionButton
            android:id="@+id/fab"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="bottom|end"
            android:layout_margin="16dp"
            android:src="@drawable/ic_done" />

    </FrameLayout>
    ...
</androidx.drawerlayout.widget.DrawerLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
    android:id="@+id/fab"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="bottom|end"
    android:layout_margin="16dp"
    android:src="@drawable/ic_done"
    app:elevation="8dp" />
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        ...
        fab.setOnClickListener {
            Toast.makeText(this, "FAB clicked", Toast.LENGTH_SHORT).show()
        }
    }
    ...
}

Snackbar

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        ...
        fab.setOnClickListener { view ->
            Snackbar.make(view, "Data deleted", Snackbar.LENGTH_SHORT)
                .setAction("Undo") {
                    Toast.makeText(this, "Data restored", Toast.LENGTH_SHORT).show()
                }
                .show()
        }
    }
    ...
}

CoordinatorLayout

<androidx.drawerlayout.widget.DrawerLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/drawerLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.coordinatorlayout.widget.CoordinatorLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="@color/colorPrimary"
            android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
            app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />

        <com.google.android.material.floatingactionbutton.FloatingActionButton
            android:id="@+id/fab"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="bottom|end"
            android:layout_margin="16dp"
            android:src="@drawable/ic_done" />

    </androidx.coordinatorlayout.widget.CoordinatorLayout>
    ...
</androidx.drawerlayout.widget.DrawerLayout>

卡片式布局

MaterialCardView
<com.google.android.material.card.MaterialCardView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:cardCornerRadius="4dp"
    app:elevation="5dp">
    <TextView
        android:id="@+id/infoText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>
</com.google.android.material.card.MaterialCardView>
AppBarLayout
<androidx.drawerlayout.widget.DrawerLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/drawerLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.coordinatorlayout.widget.CoordinatorLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <com.google.android.material.appbar.AppBarLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <androidx.appcompat.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                android:background="@color/colorPrimary"
                android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
                app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
                app:layout_scrollFlags="scroll|enterAlways|snap" />

        </com.google.android.material.appbar.AppBarLayout>
        ...
    </androidx.coordinatorlayout.widget.CoordinatorLayout>
    ...
</androidx.drawerlayout.widget.DrawerLayout>

下拉刷新

SwiperRefreshLayout
dependencies {
...
implementation?"androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
}
<androidx.drawerlayout.widget.DrawerLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/drawerLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.coordinatorlayout.widget.CoordinatorLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        ...
        <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
            android:id="@+id/swipeRefresh"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_behavior="@string/appbar_scrolling_view_behavior">

            <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/recyclerView"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                app:layout_behavior="@string/appbar_scrolling_view_behavior" />

        </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
        ...
    </androidx.coordinatorlayout.widget.CoordinatorLayout>
    ...
</androidx.drawerlayout.widget.DrawerLayout>
class MainActivity : AppCompatActivity() {
    ...
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        ...
        swipeRefresh.setColorSchemeResources(R.color.colorPrimary)
        swipeRefresh.setOnRefreshListener {
            refreshFruits(adapter)
        }
    }

    private fun refreshFruits(adapter: FruitAdapter) {
        thread {
            Thread.sleep(2000)
            runOnUiThread {
                initFruits()
                adapter.notifyDataSetChanged()
                swipeRefresh.isRefreshing = false
            }
        }
    }
    ...
}

可折叠式标题栏

CollapsingToolbarLayout
充分利用系统状态栏空间

Jetpack

  • Jetpack是一个开发组件工具集,它的主要目的是帮助我们编写出更加简洁的代码,并简化我们的开发过程。Jetpack中的组件有一个特点,它们大部分不依赖于任何Android系统版本,这意味着这些组件通常是定义在AndroidX库当中的,并且拥有非常好的向下兼容性。

ViewModel

  • ViewModel应该可以算是Jetpack中最重要的组件之一了。其实Android平台上之所以会出现诸如MVP、MVVM之类的项目架构,就是因为在传统的开发模式下,Activity的任务实在是太重了,既要负责逻辑处理,又要控制UI展示,甚至还得处理网络回调,等等。在一个小型项目中这样写或许没有什么问题,但是如果在大型项目中仍然使用这种写法的话,那么这个项目将会变得非常臃肿并且难以维护,因为没有任何架构上的划分。

  • 而ViewModel的一个重要作用就是可以帮助Activity分担一部分工作,它是专门用于存放与界面相关的数据的。也就是说,只要是界面上能看得到的数据,它的相关变量都应该存放在ViewModel中,而不是Activity中,这样可以在一定程度上减少Activity中的逻辑。

  • 另外,ViewModel还有一个非常重要的特性。我们都知道,当手机发生横竖屏旋转的时候,Activity会被重新创建,同时存放在Activity中的数据也会丢失。而ViewModel的生命周期和Activity不同,它可以保证在手机屏幕发生旋转的时候不会被重新创建,只有当Activity退出的时候才会跟着Activity一起销毁。因此,将与界面相关的变量存放在ViewModel当中,这样即使旋转手机屏幕,界面上显示的数据也不会丢失。ViewModel的生命周期如图所示

    • image-20210929195306031
ViewModel的基本用法
  • 由于Jetpack中的组件通常是以AndroidX库的形式发布的,因此一些常用的Jetpack组件会在创建Android项目时自动被包含进去。不过如果我们想要使用ViewModel组件,还需要在app/build.gradle文件中添加如下依赖:

    • dependencies {
          ...
          implementation "androidx.lifecycle:lifecycle-extensions:2.2.0"
      }
      
  • 通常来讲,比较好的变成规范是给每一个Activity和Fragment都创建一个对应的ViewModel,因此这里我们就为MainActivity创建一个对应的MainViewModel类,并让他继承自ViewModel

    • package cn.wenhe9.testmenu
      
      import androidx.lifecycle.ViewModel
      
      /**
       *@author DuJinliang
       *2021/9/29
       */
      class MainViewModel : ViewModel() {
          var counter = 0
      }
      
  • MainActivity中的使用

    • package cn.wenhe9.testmenu
      
      import androidx.appcompat.app.AppCompatActivity
      import android.os.Bundle
      import androidx.lifecycle.ViewModelProvider
      import cn.wenhe9.testmenu.databinding.ActivityTestViewModelBinding
      
      class TestViewModel : AppCompatActivity() {
      
          private lateinit var viewModel: MainViewModel
          private lateinit var binding : ActivityTestViewModelBinding
      
          override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
              binding = ActivityTestViewModelBinding.inflate(layoutInflater)
              setContentView(binding.root)
      
              viewModel =  ViewModelProvider(this).get(MainViewModel::class.java)
      
              binding.testViewModel.setOnClickListener {
                  viewModel.counter++
                  refreshCounter()
              }
      
              refreshCounter()
          }
      
          private fun refreshCounter(){
              binding.infoText.text = viewModel.counter.toString()
          }
      }
      
  • 需要注意的是,我们绝对不可以直接去创建ViewModel的实例,而是一定要通过ViewModelProvider来获取ViewModel的实例,具体语法规则如下:

    • viewModel = ViewModelProvider(你的Activity或Fragmetn实例).get(<你的ViewModel>::class.java)
      
  • 之所以这样,是因为ViewModel有其独立的生命周期,并且其生命周期要长于Activity,如果我们在onCreate()方法中创建爱你ViewModel的实例,那么每次onCreate()方法执行的时候,ViewModel都会创建一个新的实例,这样当手机屏幕发生旋转的时候,就无法保留其中的的数据了

向ViewModel传递参数
  • 上一小节中创建的MainViewModel的构造函数中没有任何参数,但是思考一下,如果我们确实需要通过构造函数来传递一些参数,应该怎么办呢?由于所有ViewModel的实例都是通过ViewModelProvider来获取的,因此我们没有任何地方可以向ViewModel的构造函数中传递参数。

  • 当然,这个问题也不难解决,只需要借助ViewModelProvider.Factory就可以实现了

  • 现在的计数器虽然在屏幕旋转的时候不会丢失数据,但是如果退出程序之后再重新打开,那么之前的计数就会被清零了。接下来我们就对这一功能进行升级,保证即使在退出程序后又重新打开的情况下,数据仍然不会丢失。

  • package cn.wenhe9.testmenu
    
    import androidx.lifecycle.ViewModel
    
    /**
     *@author DuJinliang
     *2021/9/29
     */
    class MainViewModel(countReserved : Int) : ViewModel(){
        var counter = countReserved
    }
    
    • 现在我们给MainViewModel的构造函数添加了一个countReserved参数,这个参数用于记录之前保存的计数值,并在初始化的时候赋值给counter变量。
  • 接下来的问题就是如何向MainViewModel的构造函数传递数据了,前面已经说了需要借助ViewModelProvider.Factory,新建一个MainViewModelFactory类,并让它实现ViewModelProvider.Factory接口,代码如下所示:

    • package cn.wenhe9.testmenu
      
      import androidx.lifecycle.ViewModel
      import androidx.lifecycle.ViewModelProvider
      
      /**
       *@author DuJinliang
       *2021/9/29
       */
      class MainViewModelFactory(private val countReserved : Int) : ViewModelProvider.Factory {
          override fun <T : ViewModel?> create(modelClass: Class<T>): T {
              return MainViewModel(countReserved) as T
          }
      }
      
  • package cn.wenhe9.testmenu
    
    import androidx.lifecycle.ViewModel
    import androidx.lifecycle.ViewModelProvider
    
    /**
     *@author DuJinliang
     *2021/9/29
     */
    class MainViewModelFactory(private val countReserved : Int) : ViewModelProvider.Factory {
        override fun <T : ViewModel?> create(modelClass: Class<T>): T {
            return MainViewModel(countReserved) as T
        }
    }
    
  • package cn.wenhe9.testmenu
    
    import android.content.Context
    import android.content.SharedPreferences
    import androidx.appcompat.app.AppCompatActivity
    import android.os.Bundle
    import androidx.core.content.edit
    import androidx.lifecycle.ViewModelProvider
    import cn.wenhe9.testmenu.databinding.ActivityTestViewModelBinding
    
    class TestViewModel : AppCompatActivity() {
    
        private lateinit var viewModel: MainViewModel
        private lateinit var binding : ActivityTestViewModelBinding
        private lateinit var sp : SharedPreferences
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            binding = ActivityTestViewModelBinding.inflate(layoutInflater)
            setContentView(binding.root)
    
            sp = getPreferences(Context.MODE_PRIVATE)
    
            val contReserved = sp.getInt("count", 0)
    
            viewModel =  MainViewModelFactory(contReserved).create(MainViewModel::class.java)
    
            binding.testViewModel.setOnClickListener {
                viewModel.counter++
                refreshCounter()
            }
    
            refreshCounter()
        }
    
        private fun refreshCounter(){
            binding.infoText.text = viewModel.counter.toString()
        }
    
        override fun onPause() {
            super.onPause()
    
            sp.edit {
                putInt("count", viewModel.counter)
            }
        }
    }
    
    • 在onCreate()方法中,我们首先获取了SharedPreferences的实例,然后读取之前保存的计数值,如果没有读到的话,就使用0作为默认值。接下来在ViewModelProvider中,额外传入了一个MainViewModelFactory参数,这里将读取到的计数值传给了MainViewModelFactory的构造函数。注意,这一步是非常重要的,只有用这种写法才能将计数值最终传递给MainViewModel的构造函数。

LifeCycles

  • 在编写Android应用程序的时候,可能会经常遇到需要感知Activity生命周期的情况。比如说,某个界面中发起了一条网络请求,但是当请求得到响应的时候,界面或许已经关闭了,这个时候就不应该继续对响应的结果进行处理。因此,我们需要能够时刻感知到Activity的生命周期,以便在适当的时候进行相应的逻辑控制。

  • 问题在于,在一个Activity中去感知它的生命周期非常简单,而如果要在一个非Activity的类中去感知Activity的生命周期,应该怎么办呢?

  • 而Lifecycles组件就是为了解决这个问题而出现的,它可以让任何一个类都能轻松感知到Activity的生命周期,同时又不需要在Activity中编写大量的逻辑处理。

  • 那么下面我们就通过具体的例子来学习Lifecycles组件的用法。新建一个MyObserver类,并让它实现LifecycleObserver接口,代码如下所示:

    • class MyObserver : LifecycleObserver {
      }
      
  • LifecycleObserver是一个空方法接口,只需要进行一下接口实现声明就可以了,而不去重写任何方法。

  • 接下来我们可以在MyObserver中定义任何方法,但是如果想要感知到Activity的生命周期,还得借助额外的注解功能才行。比如这里还是定义activityStart()和activityStop()这两个方法,代码如下所示:

    • class MyObserver : LifecycleObserver {
          @OnLifecycleEvent(Lifecycle.Event.ON_START)
          fun activityStart(){
              Log.d("MyObserver", "activityStart")
          }
      
          @OnLifecycleEvent(Lifecycle.Event.ON_STOP)
          fun activityStop(){
              Log.d("MyObserver", "activityStop")
          }
      }
      
  • 可以看到,我们在方法上使用了@OnLifecycleEvent注解,并传入了一种生命周期事件。生命周期事件的类型一共有7种:ON_CREATEON_STARTON_RESUMEON_PAUSEON_STOPON_DESTROY分别匹配Activity中相应的生命周期回调;另外还有一种ON_ANY类型,表示可以匹配Activity的任何生命周期回调。

  • 因此,上述代码中的activityStart()和activityStop()方法就应该分别在Activity的onStart()和onStop()触发的时候执行。

  • 但是代码写到这里还是无法正常工作的,因为当Activity的生命周期发生变化的时候并没有人去通知MyObserver,而我们又不想像刚才一样在Activity中去一个个手动通知。

  • 这个时候就得借助LifecycleOwner这个好帮手了,它可以使用如下的语法结构让MyObserver得到通知:

    • lifecyclerOwner.lifecycle.addObserver(MyObserver())
      
  • 首先调用LifecycleOwner的getLifecycle()方法,得到一个Lifecycle对象,然后调用它的addObserver()方法来观察LifecycleOwner的生命周期,再把MyObserver的实例传进去就可以了。

  • 只要你的Activity是继承自AppCompatActivity的,或者你的Fragment是继承自androidx.fragment.app.Fragment的,那么它们本身就是一个LifecycleOwner的实例,这部分工作已经由AndroidX库自动帮我们完成了。也就是说,在MainActivity当中就可以这样写:

    • class MainActivity : AppCompatActivity() {
          ...
          override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
              setContentView(R.layout.activity_main)
              ...
              lifecycle.addObserver(MyObserver())
          }
          ...
      }
      
  • 这些就是Lifecycles组件最常见的用法了。不过目前MyObserver虽然能够感知到Activity的生命周期发生了变化,却没有办法主动获知当前的生命周期状态。要解决这个问题也不难,只需要在MyObserver的构造函数中将Lifecycle对象传进来即可,如下所示

    • class MyObserver(val lifecycle: Lifecycle) : LifecycleObserver {
          ...
      }
      
  • 有了Lifecycle对象之后,我们就可以在任何地方调用lifecycle.currentState来主动获知当前的生命周期状态。lifecycle.currentState返回的生命周期状态是一个枚举类型,一共有INITIALIZEDDESTROYEDCREATEDSTARTED、RESUMED这5种状态类型,它们与Activity的生命周期回调所对应的关系如图13.8所示。

    • image-20210929204241826
  • 也就是说,当获取的生命周期状态是CREATED的时候,说明onCreate()方法已经执行了,但是onStart()方法还没有执行。当获取的生命周期状态是STARTED的时候,说明onStart()方法已经执行了,但是onResume()方法还没有执行,以此类推。

LiveData

  • LiveData是Jetpack提供的一种响应式编程组件,它可以包含任何类型的数据,并在数据发生变化的时候通知给观察者。LiveData特别适合与ViewModel结合在一起使用,虽然它也可以单独用在别的地方,但是在绝大多数情况下,它是使用在ViewModel当中的。
LiveData的基本用法
  • 之前我们编写的那个计数器虽然功能非常简单,但其实是存在问题的。目前的逻辑是,当每次点击“Plus One”按钮时,都会先给ViewModel中的计数加1,然后立即获取最新的计数。这种方式在单线程模式下确实可以正常工作,但如果ViewModel的内部开启了线程去执行一些耗时逻辑,那么在点击按钮后就立即去获取最新的数据,得到的肯定还是之前的数据

  • 你会发现,原来我们一直使用的都是在Activity中手动获取ViewModel中的数据这种交互方式,但是ViewModel却无法将数据的变化主动通知给Activity。

  • 或许你会说,我把Activity的实例传给ViewModel,这样ViewModel不就能主动对Activity进行通知了吗?注意,千万不可以这么做。不要忘了,ViewModel的生命周期是长于Activity的,如果把Activity的实例传给ViewModel,就很有可能会因为Activity无法释放而造成内存泄漏,这是一种非常错误的做法。

  • 而这个问题的解决方案也是显而易见的,就是使用我们本节即将学习的LiveData。正如前面所描述的一样,LiveData可以包含任何类型的数据,并在数据发生变化的时候通知给观察者。也就是说,如果我们将计数器的计数使用LiveData来包装,然后在Activity中去观察它,就可以主动将数据变化通知给Activity了。

  • 介绍完了工作原理,接下来我们开始编写具体的代码,修改MainViewModel中的代码,如下所示:

    • class MainViewModel(countReserved : Int) : ViewModel(){
          var counter = MutableLiveData<Int>()
      
          init {
              counter.value = countReserved
          }
      
          fun plusOne(){
              val count = counter.value ?: 0
              counter.value = count + 1
          }
      
          fun clear(){
              counter.value = 0
          }
      }
      
  • 这里我们将counter变量修改成了一个MutableLiveData对象,并指定它的泛型为Int,表示它包含的是整型数据。MutableLiveData是一种可变的LiveData,它的用法很简单,主要有3种读写数据的方法,分别是getValue()setValue()postValue()方法。

    • ``getValue()`方法用于获取LiveData中包含的数据;
    • setValue()方法用于给LiveData设置数据,但是只能在主线程中调用;
    • postValue()方法用于在非主线程中给LiveData设置数据。
    • 而上述代码其实就是调用getValue()setValue()方法对应的语法糖写法。
  • 可以看到,这里在init结构体中给counter设置数据,这样之前保存的计数值就可以在初始化的时候得到恢复。接下来我们新增了plusOne()和clear()这两个方法,分别用于给计数加1以及将计数清零。plusOne()方法中的逻辑是先获取counter中包含的数据,然后给它加1,再重新设置到counter当中。注意调用LiveData的getValue()方法所获得的数据是可能为空的,因此这里使用了一个?:操作符,当获取到的数据为空时,就用0来作为默认计数。

  • 这样我们就借助LiveData将MainViewModel的写法改造完了,接下来开始改造MainActivity,代码如下所示:

    • package cn.wenhe9.testmenu
      
      import android.content.Context
      import android.content.SharedPreferences
      import androidx.appcompat.app.AppCompatActivity
      import android.os.Bundle
      import androidx.core.content.edit
      import androidx.lifecycle.Observer
      import androidx.lifecycle.ViewModelProvider
      import cn.wenhe9.testmenu.databinding.ActivityTestViewModelBinding
      
      class TestViewModel : AppCompatActivity() {
      
          private lateinit var viewModel: MainViewModel
          private lateinit var binding : ActivityTestViewModelBinding
          private lateinit var sp : SharedPreferences
      
          override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
              binding = ActivityTestViewModelBinding.inflate(layoutInflater)
              setContentView(binding.root)
      
              sp = getPreferences(Context.MODE_PRIVATE)
      
              val contReserved = sp.getInt("count", 0)
      
              viewModel =  MainViewModelFactory(contReserved).create(MainViewModel::class.java)
      
              viewModel.counter.observe(this, Observer{ count ->
                  binding.infoText.text = count.toString()
              })
      
              binding.testViewModel.setOnClickListener {
                  viewModel.plusOne()
              }
      
          }
      
          override fun onPause() {
              super.onPause()
      
              sp.edit {
                  putInt("count", viewModel.counter.value?:0)
              }
          }
      }
      
  • 很显然,在“Plus One”按钮的点击事件中我们应该去调用MainViewModel的plusOne()方法,而在“Clear”按钮的点击事件中应该去调用MainViewModel的clear()方法。另外,在onPause()方法中,我们将获取当前计数的写法改造了一下,这部分内容还是很好理解的。

  • 接下来到最关键的地方了,这里调用了viewModel.counter的observe()方法来观察数据的变化。经过对MainViewModel的改造,现在counter变量已经变成了一个LiveData对象,任何LiveData对象都可以调用它的observe()方法来观察数据的变化。observe()方法接收两个参数:

    • 第一个参数是一个LifecycleOwner对象,有没有觉得很熟悉?没错,Activity本身就是一个LifecycleOwner对象,因此直接传this就好;
    • 第二个参数是一个Observer接口,当counter中包含的数据发生变化时,就会回调到这里,因此我们在这里将最新的计数更新到界面上即可。
  • 需要注意的是,如果你需要在子线程中给LiveData设置数据,一定要调用postValue()方法,而不能再使用setValue()方法,否则会发生崩溃。

  • observe()方法是一个Java方法,如果你观察一下Observer接口,会发现这是一个单抽象方法接口,只有一个待实现的onChanged()方法。既然是单抽象方法接口,为什么在调用observe()方法时却没有使用Java函数式API的写法呢?

  • 这是一种非常特殊的情况,因为observe()方法接收的另一个参数LifecycleOwner也是一个单抽象方法接口。当一个Java方法同时接收两个单抽象方法接口参数时,要么同时使用函数式API的写法,要么都不使用函数式API的写法。由于我们第一个参数传的是this,因此第二个参数就无法使用函数式API的写法了

  • 不过在2019年的Google I/O大会上,Android团队官宣了Kotlin First,并且承诺未来会在Jetpack中提供更多专门面向Kotlin语言的API。其中,lifecycle-livedata-ktx就是一个专门为Kotlin语言设计的库,这个库在2.2.0版本中加入了对observe()方法的语法扩展。我们只需要在app/build.gradle文件中添加如下依赖:

    • dependencies {
          ...
          implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0"
      }
      
  • 然后就可以使用如下语法结构的observe()方法了:

    • viewModel.counter.observe(this) { count ->
          infoText.text = count.toString()
      }
      
  • 以上就是LiveData的基本用法。虽说现在的写法可以正常工作,但其实这仍然不是最规范的LiveData用法,主要的问题就在于我们将counter这个可变的LiveData暴露给了外部。这样即使是在ViewModel的外面也是可以给counter设置数据的,从而破坏了ViewModel数据的封装性,同时也可能带来一定的风险。

  • 比较推荐的做法是,永远只暴露不可变的LiveData给外部。这样在非ViewModel中就只能观察LiveData的数据变化,而不能给LiveData设置数据。下面我们就看一下如何改造MainViewModel来实现这样的功能:

    • class MainViewModel(countReserved : Int) : ViewModel(){
      
          val counter : LiveData<Int>
              get() = _counter
      
          private var _counter = MutableLiveData<Int>()
      
          init {
              _counter.value = countReserved
          }
      
          fun plusOne(){
              val count = _counter.value ?: 0
              _counter.value = count + 1
          }
      
          fun clear(){
              _counter.value = 0
          }
      }
      
  • 可以看到,这里先将原来的counter变量改名为_counter变量,并给它加上private修饰符,这样_counter变量对于外部就是不可见的了。然后我们又新定义了一个counter变量,将它的类型声明为不可变的LiveData,并在它的get()属性方法中返回_counter变量。

  • 这样,当外部调用counter变量时,实际上获得的就是_counter的实例,但是无法给counter设置数据,从而保证了ViewModel的数据封装性

map和switchMap
  • LiveData的基本用法虽说可以满足大部分的开发需求,但是当项目变得复杂之后,可能会出现一些更加特殊的需求。LiveData为了能够应对各种不同的需求场景,提供了两种转换方法:map()switchMap()方法

  • 先来看map()方法,这个方法的作用是将实际包含数据的LiveData和仅用于观察数据的LiveData进行转换。那么什么情况下会用到这个方法呢?下面我来举一个例子。

  • 比如说有一个User类,User中包含用户的姓名和年龄,定义如下:

    • data class User(var firstName: String, var lastName: String, var age: Int)
      
  • 我们可以在ViewModel中创建一个相应的LiveData来包含User类型的数据,如下所示:

    • class MainViewModel(countReserved: Int) : ViewModel() {
      
          val userLiveData = MutableLiveData<User>()
          ...
      }
      
  • 到目前为止,这和我们在上一小节中学习的内容并没有什么区别。可是如果MainActivity中明确只会显示用户的姓名,而完全不关心用户的年龄,那么这个时候还将整个User类型的LiveData暴露给外部,就显得不那么合适了。

  • 而map()方法就是专门用于解决这种问题的,它可以将User类型的LiveData自由地转型成任意其他类型的LiveData,下面我们来看一下具体的用法:

    • class MainViewModel(countReserved: Int) : ViewModel() {
      
          private val userLiveData = MutableLiveData<User>()
      
          val userName: LiveData<String> = Transformations.map(userLiveData) { user ->
              "${user.firstName} ${user.lastName}"
          }
          ...
      }
      
  • 可以看到,这里我们调用了Transformations的map()方法来对LiveData的数据类型进行转换。map()方法接收两个参数:第一个参数是原始的LiveData对象;第二个参数是一个转换函数,我们在转换函数里编写具体的转换逻辑即可。这里的逻辑也很简单,就是将User对象转换成一个只包含用户姓名的字符串。

  • 另外,我们还将userLiveData声明成了private,以保证数据的封装性。外部使用的时候只要观察userName这个LiveData就可以了。当userLiveData的数据发生变化时,map()方法会监听到变化并执行转换函数中的逻辑,然后再将转换之后的数据通知给userName的观察者。

  • 接下来,我们开始学习switchMap()方法,虽然他的使用场景非常固定,但是可能比map()方法要更加常用

  • 前面我们所需的所有内容都有一个前提:LiveData对象的实例都是在ViewModel中创建的,然而在实际的项目中,不可能一直是这种理想情况,很有坑VIewMOdel中的某个LiveData对象是调用另外的方法获取的

  • 下面就来模拟一下这种情况,新建一个Repository单例类

    • object Repository {
          fun getUser(userId : String) : LiveData<User>{
              val liveData = MutableLiveData<User>()
              liveData.value = User(userId, userId, 0)
              return liveData
          }
      }
      
  • 这里我们在Repository类中添加了一个getUser()方法,这个方法接收一个userId参数,按照正常的编码逻辑,我们应该传入的userId参数去服务器请求或者到数据库中查找相应的User对象,但是这里只是模拟实例,因此每次将传入的userId当做用户名来创建一个新的User对象即可

  • 需要注意的是,getUser()方法返回的是一个包含User数据的LiveData对象,而且每次调用getUser()方法都会返回一个新的LiveData实例

  • 然后我们在MainViewModel中也定义一个getUser()方法,并且让他调用Repository的getUser()方法来获取LiveData对象

    • class MainViewModel(countReserved : Int) : ViewModel(){
          ...
          fun getUser(userId : String) : LiveData<User> {
              return Repositroy.getUser(userId)
          }
      }
      
  • 接下来的问题就是,在Activity中如何观察LiveData的数据变化呢?既然getUser()方法返回的就是一个LiveData对象,那么我们可以不可以直接在Activity中使用如下写法呢?

    • viewModel.getUser(userId).observe(this){ user->
          
      }
      
  • 请注意,这么做是完全错误的,因为每次调用getUser()方法返回的都是一个新的LiveData实例,而上述写法会一直观察老的LiveData实例,从而根本无法观察到数据的变化,你会发现这种情况下的LiveData是不可观察的

  • 这个时候switchMap()方法就可以派上用场了,正如前面所说,他的使用场景非常固定:如果ViewModel中的某个LiveData对象是调用另外的方法获取的,那么我们就可以借助switchMap方法将这个LiveData对象转换成另外一个可观察的LiveData

  • 修改MainViewModel中的代码,如下所示:

    • class MainViewModel(countReserved : Int) : ViewModel(){
          ...
          
          private val userIdLiveData = MutableLiveData<String>()
          
          val user: LiveData<User> = Transformations.switchMap(userIdLiveData){ userId ->
                     Repositroy.getUser(userId)                   }
          
          fun getUser(userId : String){
              userIdLiveData.value = userId
          }
      }
      
  • 这里我们定义了一个新的userIdLiveData对象,用来观察userId的数据变化,然后调用了Transformations的switchMap()方法,用来对另一个可观察数据的LiveData对象进行转换

  • switchMap()方法同样接收两个参数:第一个参数传入我们新增的userLiveData,switchMap()方法会对他进行观察,第二个参数是一个转换函数,注意,我们必须在这个转换函数中返回一个LiveData对象,因为switchMap()方法的工作原理就是要将转换函数中返回的LiveData对象住那换成另一个可观察的LiveData对象,那么很显然,我们只需要在转换函数中调用Repository的getUser()方法来得到LiveData对象,并将他返回就可以了

  • 为了让你能更清晰地理解switchMap()的用法,我们再来梳理一遍它的整体工作流程。首先,当外部调用MainViewModel的getUser()方法来获取用户数据时,并不会发起任何请求或者函数调用,只会将传入的userId值设置到userIdLiveData当中。一旦userIdLiveData的数据发生变化,那么观察userIdLiveData的switchMap()方法就会执行,并且调用我们编写的转换函数。然后在转换函数中调用Repository.getUser()方法获取真正的用户数据。同时,switchMap()方法会将Repository.getUser()方法返回的LiveData对象转换成一个可观察的LiveData对象,对于Activity而言,只要去观察这个LiveData对象就可以了。

  • class MainActivity : AppCompatActivity() {
        ...
        override fun onCreate(savedInstanceState: Bundle?) {
            ...
            getUserBtn.setOnClickListener {
                val userId = (0..10000).random().toString()
                viewModel.getUser(userId)
            }
            viewModel.user.observe(this, Observer { user ->
                infoText.text = user.firstName
            })
        }
        ...
    }
    
  • 具体的用法就是这样了,我们在“Get User”按钮的点击事件中使用随机函数生成了一个userId,然后调用MainViewModel的getUser()方法来获取用户数据,但是这个方法现在不会有任何返回值了。等数据获取完成之后,可观察LiveData对象的observe()方法将会得到通知,我们在这里将获取的用户名显示到界面上。

  • 最后再介绍一个我当初学习switchMap()方法时产生疑惑的地方。在刚才的例子当中,我们调用MainViewModel的getUser()方法时传入了一个userId参数,为了能够观察这个参数的数据变化,又构建了一个userIdLiveData,然后在switchMap()方法中再去观察这个LiveData对象就可以了。但是ViewModel中某个获取数据的方法有可能是没有参数的,这个时候代码应该怎么写呢?

  • 其实这个问题并没有想象中复杂,写法基本上和原来是相同的,只是在没有可观察数据的情况下,我们需要创建一个空的LiveData对象,示例写法如下:

    • class MyViewModel : ViewModel() {
      
          private val refreshLiveData = MutableLiveData<Any?>()
      
          val refreshResult = Transformations.switchMap(refreshLiveData) {
              Repository.refresh()  // 假设Repository中已经定义了refresh()方法
          }
      
          fun refresh() {
              refreshLiveData.value = refreshLiveData.value
          }
      
      }
      
  • 可以看到,这里我们定义了一个不带参数的refresh()方法,又对应地定义了一个refreshLiveData,但是它不需要指定具体包含的数据类型,因此这里我们将LiveData的泛型指定成Any?即可。

  • 接下来就是点睛之笔的地方了,在refresh()方法中,我们只是将refreshLiveData原有的数据取出来(默认是空),再重新设置到refreshLiveData当中,这样就能触发一次数据变化。是的,LiveData内部不会判断即将设置的数据和原有数据是否相同,只要调用了setValue()或postValue()方法,就一定会触发数据变化事件。

  • 然后我们在Activity中观察refreshResult这个LiveData对象即可,这样只要调用了refresh()方法,观察者的回调函数中就能够得到最新的数据。

  • 可能你会说,学到现在,只看到了LiveData与ViewModel结合在一起使用,好像和我们上一节学的Lifecycles组件没什么关系嘛。

  • 其实并不是这样的,LiveData之所以能够成为Activity与ViewModel之间通信的桥梁,并且还不会有内存泄漏的风险,靠的就是Lifecycles组件。LiveData在内部使用了Lifecycles组件来自我感知生命周期的变化,从而可以在Activity销毁的时候及时释放引用,避免产生内存泄漏的问题。

  • 另外,由于要减少性能消耗,当Activity处于不可见状态的时候(比如手机息屏,或者被其他的Activity遮挡),如果LiveData中的数据发生了变化,是不会通知给观察者的。只有当Activity重新恢复可见状态时,才会将数据通知给观察者,而LiveData之所以能够实现这种细节的优化,依靠的还是Lifecycles组件。

  • 还有一个小细节,如果在Activity处于不可见状态的时候,LiveData发生了多次数据变化,当Activity恢复可见状态时,只有最新的那份数据才会通知给观察者,前面的数据在这种情况下相当于已经过期了,会被直接丢弃。

Room

  • ORM(Object Relational Mapping)也叫对象关系映射。简单来讲,我们使用的编程语言是面向对象语言,而使用的数据库则是关系型数据库,将面向对象的语言和面向关系的数据库之间建立一种映射关系,这就是ORM了。
  • 它赋予了我们一个强大的功能,就是可以用面向对象的思维来和数据库进行交互,绝大多数情况下不用再和SQL语句打交道了,同时也不用担心操作数据库的逻辑会让项目的整体代码变得混乱。
使用Room进行增删改查
  • 先来看一下Room的整体结构,它主要由Entity、Dao和Database这三部分组成,每隔部分都有明确的职责:

    • Entity
      • 用于定义封装实际数据的实体类,每个实体类都会在数据库中有一张对应的表,并且表中的列是根据实体类中的字段自动生成的
    • Dao
      • Dao是数据访问对象的意思,通常会在这里对数据库的各项操作进行封装,在实际编程的时候,逻辑层就不需要和地城数据库打交道了,直接和Dao层进行交互即可
    • Database
      • 用于定义数据库中的关键信息,包括数据库的版本号、包含哪些实体类以及提供Dao层的访问实例
  • 使用Room需要添加依赖

    • apply plugin: 'com.android.application'
      apply plugin: 'kotlin-android'
      apply plugin: 'kotlin-kapt'
      
      dependencies {
          ...
          implementation "androidx.room:room-runtime:2.1.0"
          kapt "androidx.room:room-compiler:2.1.0"
      }
      
  • 这里新增了一个kotlin-kapt插件,同时在dependencies闭包中添加了两个Room的依赖库,由于Room会根据我们在项目中声明的注解来动态生成代码,因此这里一定要使用kapt引入Romm的编译时注解库,而用编译时注解功能则一定一定要先添加kotlin-kapt插件,注意,kapt只能在Kotlin项目中使用,如果是Java项目的话,使用anitationProcessor即可

  • 下面我们就按照刚擦介绍的Room的三个组成部分一一来进行实现,首先是定义Entity,也就是实体类

  • 一个良好的 数据库编程建议是,给每个实体类都添加一个id字段,并将这个字段设置为主键,于是我们对User类进行如下改造,并完成实体类的声明

    • @Entity
      data class User(val firstName : String, val lastName : String, var age : Int){
          @PrimaryKey(autoGenerate = true)
          var id : Long = 0
      }
      
  • 可以拿到我们在User的类名上使用@Entity注解,将他声明成了一个实体类,然后在User类中添加了一个id字段,并使用@PrimaryKey注解将他设为了主键,并把autoGenerate参数指定成true,使得主键的值是自动生成的

  • 接下来,开始定义Dao,这部分也是R Room用法中最挂件的地方,因为所有的访问数据库的操作都是在这里封装的

  • 访问数据库的操作无非就是增删改查这四种,但是也无需求确实千变万化的,而Dao要做的就是覆盖所有的业务需求,是的业务方永远只需要与Dao进行交互,而不必和底层的数据库打交道

  • 那么下面我们就来看一下一个Dao具体是如何实现的,新建一个UserDao接口,注意必须使用接口,这点和Retrofit是类似的,然后在接口中编写如下代码:

    • package cn.wenhe9.testmenu
      
      import androidx.room.Delete
      import androidx.room.Insert
      import androidx.room.Query
      
      /**
       *@author DuJinliang
       *2021/9/30
       */
      interface UserDao {
          @Insert
          fun insertUser(user : User) : Long
      
          @Query("select * from User")
          fun laodAllUsers() : List<User>
      
          @Query("select * from User where age > :age")
          fun loadUsersOlderThan(age : Int) : List<User>
      
          @Delete
          fun deleteUser(user : User)
      
          @Query("delete from User where username = :lastName")
          fun deleteUserByLastName(lastName : String)
      }
      
  • UserDao接口的上面上用了一个@Dao注解,这样Room才能将他识别成一个Dao,UserDao的内部就是根据业务需求对各种数据库操作进行的封装,数据库操作通常有增删改查这四种,因此Room提供了@Insert@Delete@update、和@Query这四种相应的注解

  • 可以看到,InsertUser()方法上面石宏了@Insert注解,表示会将参数中传入User对象插入数据库中,插入完成后还会将自动生成的主键id值返回,updateUser()方法上面使用了@Update注解,表示会将参数中传入的USer对象更新到数据库当中,delteUser()方法上面使用了@Delete注解,表示会将参数传入的User独享从数据库中删除

  • 但是如果想要从数据库中查询数据,或者使用非实体类参数来增删改查,那么就必须编写SQL语句了,比如说我们在UserDao接口重定义了一个loadAllUsers()方法,用于从数据库中查询所有的用户,如果只是用一个@Query注解,Room将无法知道我们想要查询那些数据,因为此必须在@Query注解中编写出具体的SQL语句才行,我们还可以将方法中传入的参数指定到SQL语句道中,比如laodUsersOlderThan()方法就可以查询所有年龄大于指定参数的用户,另外,如果是使用非实体类的参数来增删改数据,那么也要编写SQL语句才行,而且这个时候不能使用@Insert@Delete@Update注解,而是都要使用@Query注解才行,参考deleteUserByLastName()方法的写法

  • 接下来我们进入最后一个环节,定义Database,这部分内容 的写法是非常固定的,只需要定义好三个部分的内容,数据库的版本号,包含哪些实体类,以及提供Dao层的访问实例,新建一个AppDatabase.kt文件,代码如下所示:

    • package cn.wenhe9.testmenu
      
      import android.content.Context
      import androidx.room.Database
      import androidx.room.Room
      import androidx.room.RoomDatabase
      
      /**
       *@author DuJinliang
       *2021/9/30
       */
      
      @Database(version = 1, entities = [User::class])
      abstract class AppDatabase : RoomDatabase(){
      
          abstract fun userDao() : UserDao
      
          companion object{
              private var instance : AppDatabase? = null
      
              @Synchronized
              fun getDatabase(context : Context) : AppDatabase{
                  instance?.let {
                      return it
                  }
      
                  return Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app_database")
                      .build().apply {
                          instance = this
                      }
              }
          }
      
      }
      
  • 可以看到,这里我们在AppDatabase类的头部使用了@Database注解,并在注解中声明了数据库的版本号以及包含哪些实体类,多个实体类之间用逗号隔开即可。

  • 另外,AppDatabase类必须继承自RoomDatabase类,并且一定要使用abstract关键字将它声明成抽象类,然后提供相应的抽象方法,用于获取之前编写的Dao的实例,比如这里提供的userDao()方法。不过我们只需要进行方法声明就可以了,具体的方法实现是由Room在底层自动完成的。

  • 紧接着,我们在companion object结构体中编写了一个单例模式,因为原则上全局应该只存在一份AppDatabase的实例。这里使用了instance变量来缓存AppDatabase的实例,然后在getDatabase()方法中判断:如果instance变量不为空就直接返回,否则就调用Room.databaseBuilder()方法来构建一个AppDatabase的实例。databaseBuilder()方法接收3个参数,注意第一个参数一定要使用applicationContext,而不能使用普通的context,否则容易出现内存泄漏的情况,关于applicationContext的详细内容我们将会在第14章中学习。第二个参数是AppDatabase的Class类型,第三个参数是数据库名,这些都比较简单。最后调用build()方法完成构建,并将创建出来的实例赋值给instance变量,然后返回当前实例即可

  • class MainActivity : AppCompatActivity() {
        ...
        override fun onCreate(savedInstanceState: Bundle?) {
            ...
            val userDao = AppDatabase.getDatabase(this).userDao()
            val user1 = User("Tom", "Brady", 40)
            val user2 = User("Tom", "Hanks", 63)
            addDataBtn.setOnClickListener {
                thread {
                    user1.id = userDao.insertUser(user1)
                    user2.id = userDao.insertUser(user2)
                }
            }
            updateDataBtn.setOnClickListener {
                thread {
                    user1.age = 42
                    userDao.updateUser(user1)
                }
            }
            deleteDataBtn.setOnClickListener {
                thread {
                    userDao.deleteUserByLastName("Hanks")
                }
            }
            queryDataBtn.setOnClickListener {
                thread {
                    for (user in userDao.loadAllUsers()) {
                        Log.d("MainActivity", user.toString())
                    }
                }
            }
        }
        ...
    }
    
  • 另外,由于数据库操作属于耗时操作,Room默认是不允许在主线程中进行数据库操作的,因此上述代码中我们将增删改查的功能都放到了子线程中。不过为了方便测试,Room还提供了一个更加简单的方法,如下所示:

    • Room.databaseBuilder(context.applicationContext, AppDatabase::class.java,"app_database")
          .allowMainThreadQueries()
          .build()
      
  • 在构建AppDatabase实例的时候,加入一个allowMainThreadQueries()方法,这样Room就允许在主线程中进行数据库操作了,这个方法建议只在测试环境下使用。

Room的数据库升级
  • 当然了,我们的数据库结构不可能在设计好了之后就永远一成不变,随着需求和版本的变更,数据库也是需要升级的。不过遗憾的是,Room在数据库升级方面设计得非常烦琐,基本上没有比使用原生的SQLiteDatabase简单到哪儿去,每一次升级都需要手动编写升级逻辑才行。相比之下,我(郭霖)个人编写的数据库框架LitePal则可以根据实体类的变化自动升级数据库,感兴趣的话,你可以通过搜索去了解一下。

  • 不过,如果你目前还只是在开发测试阶段,不想编写那么烦琐的数据库升级逻辑,Room倒也提供了一个简单粗暴的方法,如下所示:

    • Room.databaseBuilder(context.applicationContext, AppDatabase::class.java,"app_database")
          .fallbackToDestructiveMigration()
          .build()
      
  • 在构建AppDatabase实例的时候,加入一个fallbackToDestructiveMigration()方法。这样只要数据库进行了升级,Room就会将当前的数据库销毁,然后再重新创建,随之而来的副作用就是之前数据库中的所有数据就全部丢失了。

  • 假如产品还在开发和测试阶段,这个方法是可以使用的,但是一旦产品对外发布之后,如果造成了用户数据丢失,那可是严重的事故。因此接下来我们还是老老实实学习一下在Room中升级数据库的正规写法。

  • 随着业务逻辑的升级,现在我们打算在数据库中添加一张Book表,那么首先要做的就是创建一个Book的实体类,如下所示:

    • @Entity
      data class Book(var name: String, var pages: Int) {
      
          @PrimaryKey(autoGenerate = true)
          var id: Long = 0
      
      }
      
  • 可以看到,Book类中包含了主键id、书名、页数这几个字段,并且我们还使用@Entity注解将它声明成了一个实体类。

  • 然后创建一个BookDao接口,并在其中随意定义一些API:

    • @Dao
      interface BookDao {
      
          @Insert
          fun insertBook(book: Book): Long
      
          @Query("select * from Book")
          fun loadAllBooks(): List<Book>
      
      }
      
  • 接下来修改AppDatabase中的代码,在里面编写数据库升级的逻辑,如下所示:

    • @Database(version = 2, entities = [User::class, Book::class])
      abstract class AppDatabase : RoomDatabase() {
      
          abstract fun userDao(): UserDao
      
          abstract fun bookDao(): BookDao
      
          companion object {
      
              val MIGRATION_1_2 = object : Migration(1, 2) {
                  override fun migrate(database: SupportSQLiteDatabase) {
                           database.execSQL("create table Book (id integer primary
                               key autoincrement not null, name text not null,
                               pages integer not null)")
                  }
              }
      
              private var instance: AppDatabase? = null
      
              fun getDatabase(context: Context): AppDatabase {
                  instance?.let {
                      return it
                  }
                  return Room.databaseBuilder(context.applicationContext,
                          AppDatabase::class.java, "app_database")
                      .addMigrations(MIGRATION_1_2)
                      .build().apply {
                      instance = this
                  }
              }
          }
      
      }
      
  • 观察一下这里的几处变化。首先在@Database注解中,我们将版本号升级成了2,并将Book类添加到了实体类声明中,然后又提供了一个bookDao()方法用于获取BookDao的实例。

  • 接下来就是关键的地方了,在companion object结构体中,我们实现了一个Migration的匿名类,并传入了1和 2这两个参数,表示当数据库版本从1升级到2的时候就执行这个匿名类中的升级逻辑。匿名类实例的变量命名也比较有讲究,这里命名成MIGRATION_1_2,可读性更高。由于我们要新增一张Book表,所以需要在migrate()方法中编写相应的建表语句。另外必须注意的是,Book表的建表语句必须和Book实体类中声明的结构完全一致,否则Room就会抛出异常。

  • 最后在构建AppDatabase实例的时候,加入一个addMigrations()方法,并把MIGRATION_1_2传入即可。

  • 现在当我们进行任何数据库操作时,Room就会自动根据当前数据库的版本号执行这些升级逻辑,从而让数据库始终保证是最新的版本。

  • 不过,每次数据库升级并不一定都要新增一张表,也有可能是向现有的表中添加新的列。这种情况只需要使用alter语句修改表结构就可以了,我们来看一下具体的操作过程。

  • 现在Book的实体类中只有id、书名、页数这几个字段,而我们想要再添加一个作者字段,代码如下所示:

    • @Entity
      data class Book(var name: String, var pages: Int, var author: String) {
      
          @PrimaryKey(autoGenerate = true)
          var id: Long = 0
      
      }
      
  • 既然实体类的字段发生了变动,那么对应的数据库表也必须升级了,所以这里修改AppDatabase中的代码,如下所示:

    • @Database(version = 3, entities = [User::class, Book::class])
      abstract class AppDatabase : RoomDatabase() {
         ...
          companion object {
              ...
              val MIGRATION_2_3 = object : Migration(2, 3) {
                  override fun migrate(database: SupportSQLiteDatabase) {
                      database.execSQL("alter table Book add column author text not null
                          default 'unknown'")
                  }
              }
      
              private var instance: AppDatabase? = null
      
              fun getDatabase(context: Context): AppDatabase {
                  ...
                  return Room.databaseBuilder(context.applicationContext,
                      AppDatabase::class.java, "app_database")
                      .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
                      .build().apply {
                      instance = this
                  }
              }
          }
      
      }
      
  • 升级步骤和之前是差不多的,这里先将版本号升级成了3,然后编写一个MIGRATION_2_3的升级逻辑并添加到addMigrations()方法中即可。比较有难度的地方就是每次在migrate()方法中编写的SQL语句,不过即使写错了也没关系,因为程序运行之后在你首次操作数据库的时候就会直接触发崩溃,并且告诉你具体的错误原因,对照着错误原因来改正你的SQL语句即可。

WorkManager

  • 在很早之前,Android系统的后台功能是非常开放的,Service的优先级也很高,仅次于Activity,那个时候可以在Service中做很多事情。但由于后台功能太过于开放,每个应用都想无限地占用后台资源,导致手机的内存越来越紧张,耗电越来越快,也变得越来越卡。为了解决这些情况,基本上Android系统每发布一个新版本,后台权限都会被进一步收紧。
  • 我印象中与后台相关的API变更大概有这些:从4.4系统开始AlarmManager的触发时间由原来的精准变为不精准,5.0系统中加入了JobScheduler来处理后台任务,6.0系统中引入了Doze和AppStandby模式用于降低手机被后台唤醒的频率,从8.0系统开始直接禁用了Service的后台功能,只允许使用前台Service。当然,还有许许多多小细节的修改,我没能全部列举出来。
  • 这么频繁的功能和API变更,让开发者就很难受了,到底该如何编写后台代码才能保证应用程序在不同系统版本上的兼容性呢?为了解决这个问题,Google推出了WorkManager组件。WorkManager很适合用于处理一些要求定时执行的任务,它可以根据操作系统的版本自动选择底层是使用AlarmManager实现还是JobScheduler实现,从而降低了我们的使用成本。另外,它还支持周期性任务、链式任务处理等功能,是一个非常强大的工具。
  • 不过,我们还得先明确一件事情:WorkManager和Service并不相同,也没有直接的联系。Service是Android系统的四大组件之一,它在没有被销毁的情况下是一直保持在后台运行的。而WorkManager只是一个处理定时任务的工具,它可以保证即使在应用退出甚至手机重启的情况下,之前注册的任务仍然将会得到执行,因此WorkManager很适合用于执行一些定期和服务器进行交互的任务,比如周期性地同步数据,等等。
  • 不过,我们还得先明确一件事情:WorkManager和Service并不相同,也没有直接的联系。Service是Android系统的四大组件之一,它在没有被销毁的情况下是一直保持在后台运行的。而WorkManager只是一个处理定时任务的工具,它可以保证即使在应用退出甚至手机重启的情况下,之前注册的任务仍然将会得到执行,因此WorkManager很适合用于执行一些定期和服务器进行交互的任务,比如周期性地同步数据,等等。
WorkManager的基本用法
  • 要想使用WorkManager,需要添加依赖

    • dependencies {
      	...
          implementation 'android.arch.work:work-runtime:1.0.1'
      }
      
  • WorkManager的基本用法其实非常简单,主要分为以下三步

    1. 定义一个后台任务,并实现具体的任务逻辑
    2. 配置该后台任务的运行条件和约束信息,并构建后任务请求
    3. 将该后台任务请求传入WorkManager的enqueue()方法,系统会在合适的时间运行
  • 第一步要定义一个后台任务,这里创建一个SimpleWorker类,代码如下所示:

    • package cn.wenhe9.testmenu
      
      import android.content.Context
      import android.util.Log
      import androidx.work.Worker
      import androidx.work.WorkerParameters
      
      /**
       *@author DuJinliang
       *2021/9/30
       */
      class SimpleWorker(context : Context, params : WorkerParameters) : Worker(context, params) {
          override fun doWork(): Result {
              Log.d("SimpleWorker", "do work in SimpleWorker")
              return Result.success()
          }
      }
      
  • 后台任务的写方法非常固定,也很好理解,首先每一个后台任务都必须继承自Worker类,并调用他唯一的构造函数,然后重写父类中的doWork()方法,在这个方法中编写具体的后台任务逻辑即可

  • doWork()方法不会运行在主线程中,因此比可以放心的在这里执行耗时逻辑,不过这里简单期间只是打印了一行日志,另外doWork()方法要求返回一个Result对象,用于表示任务的与运行结果,成功就返回Result.success(),失败就返回Result.failure,除此之外,还有一个Result.retry()方法,他其实也代表着失败,只是可以结合WorkRequest.BuildersetBackoffCriterial()方法来重新执行任务

  • 这样一个后台任务就定义好了,接下来进入第二部,配置该后台任务的运行条件和约束信息

  • 这一步其实也是最复杂的一步,因为可配置的内容非常多,不过目前我们还只是学习WrokManager的基本用法,因此只进行最基本的配置就可以了,代码如下所示:

    • val request = OneTimeWorkRequest.Builder(simpleWorker::ckass.java).build()
      
  • 可以看到,只需要把刚才创建的后台任务所对应的的Class对象传入OneTimeWorkRequest.Builder的构造函数中,然后调用build()方法即可完成构建

  • OneTimeWorkRequest.BuilderWorkRequest.Builder的子类,用于构建单次运行的后台请求,WorkRequest.Builder还有另外一个子类PeriodicWorkRequest.Builder,可用于构建周期性运行的后台任务请求,但是为了降低设备的性能消耗,PeriodicWorkRequest.Builder构造函数中传入的运行周期补鞥呢短于15分钟,示例代码如下:

    • val request = PeriodicWorkRequest.Builder(SimpleWorker::class.java, 15, TimeUnit.MINUTES).build()
      
  • 最后一步,将构建出的后台任务请求传入WorkManager的enqueue()方法中,系统就会在合适的时间去运行了:

    • WorkManager.getInstance(context).enqueue(request)
      
  • class MainActivity : AppCompatActivity() {
        ...
        override fun onCreate(savedInstanceState: Bundle?) {
            ...
            doWorkBtn.setOnClickListener {
                val request = OneTimeWorkRequest.Builder(SimpleWorker::class.java).build()
                WorkManager.getInstance(this).enqueue(request)
            }
        }
        ...
    }
    
  • 代码非常简单,就是在“Do Work”按钮的点击事件中构建后台任务请求,并将请求传入WorkManager的enqueue()方法中。后台任务的具体运行时间是由我们所指定的约束以及系统自身的一些优化所决定的,由于这里没有指定任何约束,因此后台任务基本上会在点击按钮之后立刻运行

使用WorkManager处理复杂的任务
  • 在上一小节中,虽然我们成功运行了一个后台任务,但是由于不能控制它的具体运行时间,因此并没有什么太大的实际用处。当然,WorkManager是不可能没有提供这样的接口的,事实上除了运行时间之外,WorkManager还允许我们控制许多其他方面的东西,下面就来具体看一下吧。

  • 首先从最简单的看起,让后台任务在指定的延迟时间后运行,只需要借助setInitialDelay()方法就可以了,代码如下所示:

    • val request = OneTimeWorkRequest.Builder(SimpleWorker::class.java)
      	.setInitialDelay(5, TimeUnit.MINUTES)
      	.build()
      
  • 这就表示我们希望让SimpleWorker这个后台任务在5分钟后运行。你可以自由选择时间的单位,毫秒、秒、分钟、小时、天都可以。

  • 可以控制运行时间之后,我们再增加一些别的功能,比如说给后台任务请求添加标签:

    • val request = OneTimeWorkRequest.Builder(SimpleWorker::class.java)
          ...
          .addTag("simple")
          .build()
      
  • 那么添加了标签有什么好处呢?最主要的一个功能就是我们可以通过标签来取消后台任务请求:

    • WorkManager.getInstance(this).cancelAllWorkByTag("simple")
      
  • 当然,即使没有标签,也可以通过id来取消后台任务请求:

    • WorkManager.getInstance(this).cancelWorkById(request.id)
      
  • 但是,使用id只能取消单个后台任务请求,而使用标签的话,则可以将同一标签名的所有后台任务请求全部取消,这个功能在逻辑复杂的场景下尤其有用。

  • 除此之外,我们也可以使用如下代码来一次性取消所有后台任务请求:

    • WorkManager.getInstance(this).cancelAllWork()
      
  • 另外,我们在上一小节中讲到,如果后台任务的doWork()方法中返回了Result.retry(),那么是可以结合setBackoffCriteria()方法来重新执行任务的,具体代码如下所示:

    • val request = OneTimeWorkRequest.Builder(SimpleWorker::class.java)
          ...
          .setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.SECONDS)
          .build()
      
  • setBackoffCriteria()方法接收3个参数:第二个和第三个参数用于指定在多久之后重新执行任务,时间最短不能少于10秒钟;第一个参数则用于指定如果任务再次执行失败,下次重试的时间应该以什么样的形式延迟。这其实很好理解,假如任务一直执行失败,不断地重新执行似乎并没有什么意义,只会徒增设备的性能消耗。而随着失败次数的增多,下次重试的时间也应该进行适当的延迟,这才是更加合理的机制。第一个参数的可选值有两种,分别是LINEAREXPONENTIAL,前者代表下次重试时间以线性的方式延迟,后者代表下次重试时间以指数的方式延迟。

  • 了解了Result.retry()的作用之后,你一定还想知道,doWork()方法中返回Result.success()Result.failure()又有什么作用?这两个返回值其实就是用于通知任务运行结果的,我们可以使用如下代码对后台任务的运行结果进行监听:

    • WorkManager.getInstance(this)
          .getWorkInfoByIdLiveData(request.id)
          .observe(this) { workInfo ->
              if (workInfo.state == WorkInfo.State.SUCCEEDED) {
                  Log.d("MainActivity", "do work succeeded")
              } else if (workInfo.state == WorkInfo.State.FAILED) {
                  Log.d("MainActivity", "do work failed")
              }
          }
      
  • 这里调用了getWorkInfoByIdLiveData()方法,并传入后台任务请求的id,会返回一个LiveData对象。然后我们就可以调用LiveData对象的observe()方法来观察数据变化了,以此监听后台任务的运行结果。另外,你也可以调用getWorkInfosByTagLiveData()方法,监听同一标签名下所有后台任务请求的运行结果,用法是差不多的,这里就不再进行解释了。

  • 接下来,我们再来看一下WorkManager中比较有特色的一个功能——链式任务。

  • 假设这里定义了3个独立的后台任务:同步数据、压缩数据和上传数据。现在我们想要实现先同步、再压缩、最后上传的功能,就可以借助链式任务来实现,代码示例如下:

    • val sync = ...
      val compress = ...
      val upload = ...
      WorkManager.getInstance(this)
          .beginWith(sync)
          .then(compress)
          .then(upload)
          .enqueue()
      
  • 这段代码还是比较好理解的,相信你一看就能懂。beginWith()方法用于开启一个链式任务,至于后面要接上什么样的后台任务,只需要使用then()方法来连接即可。另外WorkManager还要求,必须在前一个后台任务运行成功之后,下一个后台任务才会运行。也就是说,如果某个后台任务运行失败,或者被取消了,那么接下来的后台任务就都得不到运行了。

  • 前面所介绍的WorkManager的所有功能,在国产手机上都有可能得不到正确的运行。这是因为绝大多数的国产手机厂商在进行Android系统定制的时候会增加一个一键关闭的功能,允许用户一键杀死所有非白名单的应用程序。而被杀死的应用程序既无法接收广播,也无法运行WorkManager的后台任务。这个功能虽然与Android原生系统的设计理念并不相符,但是我们也没有什么解决办法。或许就是因为有太多恶意应用总是想要无限占用后台,国产手机厂商才增加了这个功能吧。因此,这里给你的建议就是,WorkManager可以用,但是千万别依赖它去实现什么核心功能,因为它在国产手机上可能会非常不稳定。

常用控件

TextView

  • 用于在界面显示一段文本信息

    • <TextView
                android:text="hello world"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"/>
      

Button

  • 按钮

  • 如果按钮的内容使用的是英文,那么运行后的显示会全部是大写字母,如果需要原样使用的话,则需要指定一个属性

    • android:textAllCaps="false"
      

EditText

  • 允许用户在控件里输入和编辑内容
  • 可以使用android:hint来指定输入框默认显示的内容,类似placeHolder
  • 可以使用android:maxLines来指定输入框的最大行数,当输入的内容超过两行时,文本就会像上滚动,EditText则不会继续拉伸

ImageView

  • 用于在界面上展示图片

ProgressBar

  • 用于在界面显示一个进度条

  • 可以通过style属性指定成水平进度条,在指定成水平进度条后,还可以使用max属性个进度条设置一个最大值,然后在代码中动态的更改进度条的进度

    • <Button
              android:id="@+id/addNum"
              android:text="add Num"
              android:layout_width="match_parent"
              android:layout_height="wrap_content"/>
      
      binding.addNum.setOnClickListener {
      	binding.progressBar.progress = binding.progressBar.progress + 10
      }
      

AlertDialog

  • 在当前界面弹出一个对话框,这个对话框是置顶于所有界面元素之上的,能够评比其他控件的交互能力,因此AlertDialog一般用于提示一些非常重要的内容或者警告信息

  • binding.addNum.setOnClickListener {
        val that = this
        AlertDialog.Builder(this).apply {
            setTitle("警告")
            setMessage("你确定要这么做吗")
            setCancelable(false)
            setPositiveButton("是的"){ dialog, which ->
                                    Toast.makeText(that, "我不能原谅你", Toast.LENGTH_SHORT).show()
                                   }
    
            setNegativeButton("不了"){dialog, which ->
                                    Toast.makeText(that, "这不是真的", Toast.LENGTH_SHORT).show()
                                   }
        }
    }
    
  • 其中,setCancelable是指定是否可以使用Back键关闭对话框

ListView

  • layout布局引入

  • Activity设置adapter、点击事件

  • 提升效率

    • class FruitAdapter(activity: Activity, val resourceId: Int, data: List&lt;Fruit&gt;) :
              ArrayAdapter&lt;Fruit&gt;(activity, resourceId, data) {
      
          inner class ViewHolder(val fruitImage: ImageView, val fruitName: TextView)
      
          override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
              val view: View
              val viewHolder: ViewHolder
              if (convertView == null) {
                  view = LayoutInflater.from(context).inflate(resourceId, parent, false)
                  val fruitImage: ImageView = view.findViewById(R.id.fruitImage)
                  val fruitName: TextView = view.findViewById(R.id.fruitName)
                  viewHolder = ViewHolder(fruitImage, fruitName)
                  view.tag = viewHolder
              } else {
                  view = convertView
                  viewHolder = view.tag as ViewHolder
              }
      
              val fruit = getItem(position) // 获取当前项的Fruit实例
              if (fruit != null) {
                  viewHolder.fruitImage.setImageResource(fruit.imageId)
                  viewHolder.fruitName.text = fruit.name
              }
              return view
          }
      
      }
      

RecyclerView

  • 引入recyclerView库

    • implementation 'androidx.recyclerview:recyclerview:1.2.0'
      
  • 布局文件

    • <androidx.recyclerview.widget.RecyclerView android:id="@+id/recyclerView"   android:layout_width="match_parent" android:layout_height="match_parent"/>
      
  • 准备适配器,继承自RecyclerView.Adapter,并将泛型指定为FruitAdapter.ViewHolder,其中ViewHolder是我们在FruitAdapter中定义的一个内部类

    • class FruitAdapter(val fruitList: List<Fruit>) :
              RecyclerView.Adapter<FruitAdapter.ViewHolder>() {
      
          inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
              val fruitImage: ImageView = view.findViewById(R.id.fruitImage)
              val fruitName: TextView = view.findViewById(R.id.fruitName)
          }
      
          override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
              val view = LayoutInflater.from(parent.context)
              .inflate(R.layout.fruit_item, parent, false)
              return ViewHolder(view)
          }
      
          override fun onBindViewHolder(holder: ViewHolder, position: Int) {
              val fruit = fruitList[position]
              holder.fruitImage.setImageResource(fruit.imageId)
              holder.fruitName.text = fruit.name
          }
      
          override fun getItemCount() = fruitList.size
      
      }
      
  • Activity中指定

    • class MainActivity : AppCompatActivity() {
      
          private val fruitList = ArrayList<Fruit>()
      
          override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
              setContentView(R.layout.activity_main)
              initFruits() // 初始化水果数据
              val layoutManager = LinearLayoutManager(this)
              recyclerView.layoutManager = layoutManager
              val adapter = FruitAdapter(fruitList)
              recyclerView.adapter = adapter
          }
      
          private fun initFruits() {
              repeat(2) {
                  fruitList.add(Fruit("Apple", R.drawable.apple_pic))
                  fruitList.add(Fruit("Banana", R.drawable.banana_pic))
                  fruitList.add(Fruit("Orange", R.drawable.orange_pic))
                  fruitList.add(Fruit("Watermelon", R.drawable.watermelon_pic))
                  fruitList.add(Fruit("Pear", R.drawable.pear_pic))
                  fruitList.add(Fruit("Grape", R.drawable.grape_pic))
                  fruitList.add(Fruit("Pineapple", R.drawable.pineapple_pic))
                  fruitList.add(Fruit("Strawberry", R.drawable.strawberry_pic))
                  fruitList.add(Fruit("Cherry", R.drawable.cherry_pic))
                  fruitList.add(Fruit("Mango", R.drawable.mango_pic))
              }
          }
      
      }
      
  • 如果需要制度能够为横向滚动的话,需要给layoutManager指定方向

    • layoutMangaer.orientation = LinearLayoutManager.HORIZONTAL
      
  • 除了LinearLayoutManager外,还有GridLayoutManagerStaggeredGridLayoutManager

    • 其中GridLatoutManager用于实现网格布局

    • 其中StaggeredGridLayoutManager用于实现瀑布流布局

      • val layoutManager = StaggeredGridLayoutManager(3, StaggeredGridLayoutManager.VERTICAL)
        recycler.layoutManager = layoutManager
        
      • 第一个参数指定列数,第二个指定方向

  • 点击事件

    • class FruitAdapter(val fruitList: List<Fruit>) :
              RecyclerView.Adapter<FruitAdapter.ViewHolder>() {
          ...
          override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
              val view = LayoutInflater.from(parent.context)
              .inflate(R.layout.fruit_item, parent, false)
              val viewHolder = ViewHolder(view)
              viewHolder.itemView.setOnClickListener {
                  val position = viewHolder.adapterPosition
                  val fruit = fruitList[position]
                  Toast.makeText(parent.context, "you clicked view ${fruit.name}",
                      Toast.LENGTH_SHORT).show()
              }
              viewHolder.fruitImage.setOnClickListener {
                  val position = viewHolder.adapterPosition
                  val fruit = fruitList[position]
                  Toast.makeText(parent.context, "you clicked image ${fruit.name}",
                      Toast.LENGTH_SHORT).show()
              }
              return viewHolder
          }
          ...
      }
      

ScrollView

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

    <Button
        android:id="@+id/sendRequestBtn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Send Request" />

    <ScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent" >

        <TextView
            android:id="@+id/responseText"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />

    </ScrollView>

</LinearLayout>
  • 由于手机屏幕的空间一般比较小,有些时候过多的内容一屏是显示不下的,借助ScrollView控件,我们就可以以滚动的形式查看屏幕外的内容

需要注意的属性

visibility

  • 这个属性可以指定控件的可见性
  • 他的可选属性有以下三种
    • visible
      • View.VISIBLE
      • 表示控件可见
    • invisible
      • View.INVISIBLE
      • 表示控件不可见,但是占据着原来的大小和位置
    • gone
      • View.GONE
      • 表示空间不可见,而且不再占用任何屏幕空间

gravitylayout_gravity

  • gravity是文字在控件内部的对齐方式
  • layout_gravity是控件在布局内部的对齐方式

layout_weight

  • 系统会先把Linearlayout下所有空间指定的layout_weight值相加,得到一个总值,然后每个控件所占大小的比例就是使用该控件的layout_weight值除以刚才算出的总值
  • 需要注意的是,当Linearlayout使用的vertical时,水平方向上可以使用layout_weight,当使用的是horizontal时,垂直方向上可以使用layout_weight,另外所指定的方向的width或height需要指定为0dp

基本布局

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

LinearLayout

  • Linearlayout又称为线性布局,这个布局会将他所包含的控件在线性方向上依次排列。
  • 可以通过orientation指定布局的方向

RelativeLayout

  • 相对布局,他可以通过相对定位的方式让控件出现在布局的任何位置

  • android:layout_alignParentLeft、android:layout_alignParentTop、android:layout_alignParentRight、android:layout_alignParentBottom、android:layout_centerInParent、
    android:layout_above
    android:layout_below
    android:layout_toLeftOf
    android:layout_toRightOf
    android:layout_alignLeft
    android:layout_alignRight
    android:layout_alignTop
    android:layout_alignBottom
    

FrameLayout

  • 帧布局,所有的控件都会默认摆放在布局的左上角
  • 可以使用layout_gravity指定对齐方式

小技巧

隐藏系统自带的标题栏

  • class MainActivity : AppCompatActivity() {
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_main)
            supportActionBar?.hide()
        }
    
    }
    

动态加载布局

  • 希望程序能够根据设备的分辨率或屏幕大小,在运行时决定加载哪个布局,这就涉及到Android中动态加载布局的技巧
使用限定符
  • image-20210924212049336

  • 示例

    • 在res下建立一个layout-large的文件夹,将我们需要动态加载的布局在这个文件夹下新建一份,那么当屏幕大小满足large这个限定符的时候,就会动态的加载这里的布局文件
使用最小宽度限定符
  • 虽然上面使用large很方便,但是large到底是多大呢?有时候我们希望可以更加灵活的为不同设备加载布局,不管他们是不是被系统认定为large,这时就可以使用最小宽度限定符
  • 最小宽度限定符允许我们对屏幕的宽度制定一个最小值(以dp为单位),然后以这个最小值为临界点,屏幕宽度大于这个值的设备就在一个布局,屏幕宽度小于这个值的设备就加载另一个布局
  • 示例:
    • 在res下建立一个layout-sw600dp文件夹,将我们需要动态加载的怒局在这个文件夹下新建一份,当程序运行在屏幕宽度大于等于600dp的设备上时,就会加载这个文件夹下的内容,当程序运行在小于600dp设备上时,则会加载默认的布局文件

加载图片的库

  • Glide

全局获取Context的技巧

  • Android提供了一个Application类,每当应用程序启动的时候,系统就会自动将这个类进行初始化。而我们可以定制一个自己的Application类,以便于管理程序内一些全局的状态信息,比如全局Context。

  • 定制一个自己的Application其实并不复杂,首先需要创建一个MyApplication类继承自Application,代码如下所示:

    • class MyApplication : Application() {
      
          companion object {
              lateinit var context: Context
          }
      
          override fun onCreate() {
              super.onCreate()
              context = applicationContext
          }
      
      }
      
  • 可以看到,MyApplication中的代码非常简单。这里我们在companion object中定义了一个context变量,然后重写父类的onCreate()方法,并将调用getApplicationContext()方法得到的返回值赋值给context变量,这样我们就可以以静态变量的形式获取Context对象了。

  • 需要注意的是,将Context设置成静态变量很容易会产生内存泄漏的问题,所以这是一种有风险的做法,因此Android Studio会给出如图14.1所示的警告提示。

    • image-20210930193015572
  • 但是由于这里获取的不是Activity或Service中的Context,而是Application中的Context,它全局只会存在一份实例,并且在整个应用程序的生命周期内都不会回收,因此是不存在内存泄漏风险的。那么我们可以使用如下注解,让Android Studio忽略上述警告提示:

    • class MyApplication : Application() {
      
          companion object {
              @SuppressLint("StaticFieldLeak")
              lateinit var context: Context
          }
          ...
      }
      
  • 接下来我们还需要告知系统,当程序启动的时候应该初始化MyApplication类,而不是默认的Application类。这一步也很简单,在AndroidManifest.xml文件的<application>标签下进行指定就可以了,代码如下所示:

    • <manifest xmlns:android="http://schemas.android.com/apk/res/android"
                package="com.example.materialtest">
          <application
              android:name=".MyApplication"
              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/AppTheme">
              ...
          </application>
      </manifest>
      
  • 这样我们就实现了一种全局获取Context的机制,之后不管你想在项目的任何地方使用Context,只需要调用一下MyApplication.context就可以了。

使用Intent传递对象

Serializables方式
  • Serializable是序列化的意思,表示将一个对象转换成可存储或可传输的状态。序列化后的对象可以在网络上进行传输,也可以存储到本地。至于序列化的方法非常简单,只需要让一个类去实现Serializable这个接口就可以了。

  • 比如说有一个Person类,其中包含了name和age这两个字段,如果想要将它序列化,就可以这样写:

    • class Person : Serializable {
          var name = ""
          var age = 0
      }
      
  • 这里我们让Person类实现了Serializable接口,这样所有的Person对象都是可序列化的了。

  • 然后在FirstActivity中只需要这样写:

    • val person = Person()
      person.name = "Tom"
      person.age = 20
      val intent = Intent(this, SecondActivity::class.java)
      intent.putExtra("person_data", person)
      startActivity(intent)
      
  • 可以看到,这里我们创建了一个Person的实例,并将它直接传入了Intent的putExtra()方法中。由于Person类实现了Serializable接口,所以才可以这样写。

  • 接下来在SecondActivity中获取这个对象也很简单,写法如下:

    • val person = intent.getSerializableExtra("person_data") as Person
      
  • 这里调用了Intent的getSerializableExtra()方法来获取通过参数传递过来的序列化对象,接着再将它向下转型成Person对象,这样我们就成功实现了使用Intent传递对象的功能。

  • 需要注意的是,这种传递对象的工作原理是先将一个对象序列化成可存储或可传输的状态,传递给另外一个Activity后再将其反序列化成一个新的对象。虽然这两个对象中存储的数据完全一致,但是它们实际上是不同的对象,这一点希望你能了解清楚。

Parcelable方式
  • 除了Serializable之外,使用Parcelable也可以实现相同的效果,不过不同于将对象进行序列化,Parcelable方式的实现原理是将一个完整的对象进行分解,而分解后的每一部分都是Intent所支持的数据类型,这样就能实现传递对象的功能了。

  • 下面我们来看一下Parcelable的实现方式,修改Person中的代码,如下所示:

    • class Person : Parcelable {
          var name = ""
          var age = 0
      
          override fun writeToParcel(parcel: Parcel, flags: Int) {
              parcel.writeString(name) // 写出name
              parcel.writeInt(age) // 写出age
          }
      
          override fun describeContents(): Int {
              return 0
          }
      
          companion object CREATOR : Parcelable.Creator<Person> {
              override fun createFromParcel(parcel: Parcel): Person {
                  val person = Person()
                  person.name = parcel.readString() ?: "" // 读取name
                  person.age = parcel.readInt() // 读取age
                  return person
              }
      
              override fun newArray(size: Int): Array<Person?> {
                  return arrayOfNulls(size)
              }
          }
      }
      
  • Parcelable的实现方式要稍微复杂一些。可以看到,首先我们让Person类实现了Parcelable接口,这样就必须重写describeContents()和writeToParcel()这两个方法。其中describeContents()方法直接返回0就可以了,而在writeToParcel()方法中,我们需要调用Parcel的writeXxx()方法,将Person类中的字段一一写出。注意,字符串型数据就调用writeString()方法,整型数据就调用writeInt()方法,以此类推。

  • 除此之外,我们还必须在Person类中提供一个名为CREATOR的匿名类实现。这里创建了Parcelable.Creator接口的一个实现,并将泛型指定为Person。接着需要重写createFromParcel()和newArray()这两个方法,在createFromParcel()方法中,我们要创建一个Person对象进行返回,并读取刚才写出的name和age字段。其中name和age都是调用Parcel的readXxx()方法读取到的,注意这里读取的顺序一定要和刚才写出的顺序完全相同。而newArray()方法中的实现就简单多了,只需要调用arrayOfNulls()方法,并使用参数中传入的size作为数组大小,创建一个空的Person数组即可。

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

    • val person = intent.getParcelableExtra("person_data") as Person
      
  • 注意,这里不再是调用getSerializableExtra()方法,而是调用getParcelableExtra()方法来获取传递过来的对象,其他的地方完全相同。

  • 不过,这种实现方式写起来确实比较复杂,为此Kotlin给我们提供了另外一种更加简便的用法,但前提是要传递的所有数据都必须封装在对象的主构造函数中才行。

  • 修改Person类中的代码,如下所示:

    • @Parcelize
      class Person(var name: String, var age: Int) : Parcelable
      
  • 没错,就是这么简单。将name和age这两个字段移动到主构造函数中,然后给Person类添加一个@Parcelize注解即可,是不是比之前的用法简单了好多倍?

  • 这样我们就把使用Intent传递对象的两种实现方式都学习完了。对比一下,Serializable的方式较为简单,但由于会把整个对象进行序列化,因此效率会比Parcelable方式低一些,所以在通常情况下,还是更加推荐使用Parcelable的方式来实现Intent传递对象的功能。

定制自己的日志工具

  • 最理想的情况是能够自由地控制日志的打印,当程序处于开发阶段时就让日志打印出来,当程序上线之后就把日志屏蔽掉。

  • 看起来好像是挺高级的一个功能,其实并不复杂,我们只需要定制一个自己的日志工具就可以轻松完成了。新建一个LogUtil单例类,代码如下所示:

    • object LogUtil {
      
          private const val VERBOSE = 1
      
          private const val DEBUG = 2
      
          private const val INFO = 3
      
          private const val WARN = 4
      
          private const val ERROR = 5
      
          private var level = VERBOSE
      
          fun v(tag: String, msg: String) {
              if (level <= VERBOSE) {
                  Log.v(tag, msg)
              }
          }
      
          fun d(tag: String, msg: String) {
              if (level <= DEBUG) {
                  Log.d(tag, msg)
              }
          }
      
          fun i(tag: String, msg: String) {
              if (level <= INFO) {
                  Log.i(tag, msg)
              }
          }
      
          fun w(tag: String, msg: String) {
              if (level <= WARN) {
                  Log.w(tag, msg)
              }
          }
      
          fun e(tag: String, msg: String) {
              if (level <= ERROR) {
                  Log.e(tag, msg)
              }
          }
      
      }
      
  • 可以看到,我们在LogUtil中首先定义了VERBOSE、DEBUG、INFO、WARN、ERROR这5个整型常量,并且它们对应的值都是递增的。然后又定义了一个静态变量level,可以将它的值指定为上面5个常量中的任意一个。

  • 接下来,我们提供了v()、d()、i()、w()、e()这5个自定义的日志方法,在其内部分别调用了Log.v()、Log.d()、Log.i()、Log.w()、Log.e()这5个方法来打印日志,只不过在这些自定义的方法中都加入了一个if判断,只有当level的值小于或等于对应日志级别值的时候,才会将日志打印出来。

  • 这样就把一个自定义的日志工具创建好了,之后在项目里,我们可以像使用普通的日志工具一样使用LogUtil。比如打印一行DEBUG级别的日志可以这样写:

    • LogUtil.d("TAG", "debug log")
      
  • 打印一行WARN级别的日志可以这样写:

    • LogUtil.w("TAG", "warn log")
      
  • 我们只需要通过修改level变量的值,就可以自由地控制日志的打印行为。比如让level等于VERBOSE就可以把所有的日志都打印出来,让level等于ERROR就可以只打印程序的错误日志。

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

  • 0
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Android笔记系统是一种可以帮助用户记录、整理和分享笔记的应用程序。以下是一些常见的Android笔记系统的特点和功能: 1. 笔记编辑:大多数Android笔记系统都提供了基本的文本编辑功能,用户可以输入、编辑和保存笔记。一些系统还允许用户插入图片、附件和链接等元素,以丰富笔记内容。 2. 分类和标签:许多Android笔记系统允许用户将笔记进行分类和标签,以便更好地组织和查找。这有助于用户根据主题、日期或其他标准将笔记分组,以便快速找到所需的资料。 3. 笔记共享:一些Android笔记系统允许用户将笔记分享到社交媒体平台或与其他人共享。用户可以选择将笔记以链接、邮件或其他形式发送给朋友或同事,以便他们能够方便地查看和评论笔记。 4. 搜索功能:许多Android笔记系统提供强大的搜索功能,用户可以根据关键词、标签或分类快速找到所需的笔记。 5. 提醒和日历集成:一些Android笔记系统与日历应用程序集成,允许用户设置提醒,以便在特定日期或时间提醒用户查看或更新笔记。 6. 云同步:大多数Android笔记系统都支持云同步功能,用户可以将笔记存储在云端,并在多个设备上访问。这有助于用户在不同设备之间同步笔记,并确保数据的安全性和可访问性。 7. 多平台支持:一些Android笔记系统还支持在多个平台上使用,包括iOS、Windows和Mac等。这为用户提供了更多的灵活性和选择。 总之,Android笔记系统为用户提供了一个方便、高效的方式来记录、整理和分享笔记。它们提供了各种功能和工具,以帮助用户更好地组织和查找信息,并与其他人共享和协作。选择一个适合自己的Android笔记系统,可以帮助您更有效地管理您的学习、工作或个人笔记
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值