Android平台的基础是Linux内核。例如:Android Runtime(ART)
依靠Linux内核来执行底层功能,例如线程和底层内存管理。
使用Linux内核可让Android利用主要安全功能,并且允许设备制造商为著名的内核开发硬件驱动程序
你可以通过JAVA语言编写的Android OS的整个功能集。这些API形成创建Android应用所需的构建块,他们可以简化核心模块化系统组件和服务的重复使用,包括以下组件和服务:
开发者可以完全访问 Android 系统应用使用的框架 API
Android 随附一套用于电子邮件、短信、日历、互联网浏览和联系人等的核心应用。平台随附的应用与用户可以选择安装的应用一样,没有特殊状态。因此第三方应用可成为用户的默认网络浏览器、短信 Messenger 甚至默认键盘(有一些例外,例如系统的“设置”应用)。
系统应用可用作用户的应用,以及提供开发者可从其自己的应用访问的主要功能。例如,如果您的应用要发短信,您无需自己构建该功能,可以改为调用已安装的短信应用向您指定的接收者发送消息。
drawable
开头的目录都是用来放图片的mipmap
开头的目录都是用来放应用图标的values
开头的目录都是用来放字符串、样式、颜色等配置的layout
开头的目录都是用来放布局文件的之所以这么多的mipmap
开头的目录,其实主要是为了让程序能够更好的兼容各种设备。drawable
目录也是相同的道理,虽然Android Studio没有帮我们自动生成,但是我们也应该自己创建drawable-hdpi, drawable-xhdpi, drawable-xxhdpi等目录,在制作程序的时候,最好能够给同一张图片提供几个不同分辨率的版本,分别放在这些目录下,然后程序运行的时候,会自动根据当前运行设备分辨率的高低选择家在哪个目录下的图片。当然这只是理想情况,更多时候美工只会提供给我们一份图片,这是你把所有图片都放在drawable-xxhdpi目录下就行了,因为这是最主流的设别分辨率目录
下面来看下如何使用这些资源
<resources>
<string name="app_name">hello worldstring>
resources>
可以看到这里定义了一个应用程序名的字符串,我么你有以下两种方式来引用它
R.string.app_name
可以获得该字符串的引用@string/app_name
可以获得该字符串的引用基本语法就是上面这两种方式,其中string部分是可以替换的,如果是引用的图片资源就可以替换成drawable,如果是引用的应用图标就可以替换成mipmap,如果是引用的布局文件,就可以替换策划给你layout,以此类推
勾选Generate Layout File
表示会自动为FirstActivity创建一个对应的布局文件
勾选Launcher Activity
表示会自动讲FristActivity设置为当前项目的主Activity
此时不选择上述两者,得到文件代码如下:
class FirstActivity : AppCompatActivity(){
override fun onCreate(savedInstanceState : Builde?){
super.onCreate(SaceInstanceState)
}
}
可以看到,onCreate()方法非常简单,就是调用了弗雷德onCreate()方法,当然这只是默认的实现,后面我们还需要在里面加入很多自己的逻辑
<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
方法即可
所有的Activity都要在ANdroidManifest.xml中进行注册才能生效,实际上FirstActivity已经在AndroidManisest.xml中注册过了。
<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>
在
标签中,我们使用了andoird:name
来指定具体注册哪一个Activity
,那么这里填入的.FristActivity
是什么意思呢,其实这不过是cn.wenhe9.helloworld.FristActivity
的缩写而已,由于在最外层的
标签中已经通过package
属性智定乐程序的包名是cn.wenhe9.helloworld
,因此咋注册Actitivy时,这一部分可以省略,直接使用.FristActivity
即可
为程序配置主Activity
,在
标签的内容部加入
标签,并在这个标签里添加
和
这两句声明即可
除此之外,我们还可以使用android:label
指定Activity中标题栏中的内容,标题栏是显示在Activity最顶部的,待会儿运行的时候,你就会看到,需要注意的是,给主Activity指定的label不仅会成为标题栏中的内容,还会成为启动器(Launcher)中应用程序显示的名称
如果你的应用程序中没有声明任何一个人Activity作为主Activity,这个程序仍然是可以正常安装的,只是你无法在启动器中看到或者打开这个程序,这种程序一般是作为第三方服务供其他应用在内部进行调用的、
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_SHORT
和Toast.LENGTH_LONG
<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>
这里我们创建了两个菜单项,其中
标签用来创建具体的某一个菜单项,然后通过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
}
按一下back键
调用finish()
button1.setOnClickListener {
finish()
}
button1.setOnClickListener {
val intent = Intent(this, FirstActivity::class.java)
startActivity(intent)
}
SecondActivity::class.java
作为目标Activity,这样我们“意图“就非常明显了。注意,Kotlin中SecondActivity::class.java
法就相当于Java中的SecondActivity.class
的写法相对于显示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,不仅可以启动自己的程序内的Activity,还可以启动其他程序的Activity,这就使得多个应用程序之间的功能共享成为了可能比如你的应用程序需要展示一个网页,这时你没有必要去自己实现一个浏览器,只需要调用系统的浏览器来打开这个网页就可以了
button1.setOnClickListener{
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse("https://www.baidu.com")
startActivity(intent)
}
可以在
标签中再配置一个标签,用于更精确的指定当前Activity能够响应的数据,
标签中主要可以配置以下内容:
android:scheme
andorid:host
www.baiduc.com
部分andorid:port
andorid:path
只有当标签中指定的内容和Intent中携带的Data完全一致时,当前的Activity才能够响应该intent,不过在
标签中一般不会指定过多的内容,例如在上面的浏览器示例中,其实只需要指定
andorid:scheme
为https
,就可以响应所有https
协议的intent了
除了https
协议外,我们还可以指定很多其他协议,比如geo表示地理位置,tel表示拨打电话
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只需要按一下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_OK
或RESULT_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()
}
返回栈(back stack)
。栈是一种后进先出的数据结构,在默认情况下,每当我们启动了一个新的Activity,他就会在返回栈中入栈,并处于栈顶的位置,而每当我们按下Back键或调用finish()
方法去销毁一个Activity时,处于栈顶的Activity就会出栈,前一个入栈的Activity就会重新处于栈顶的位置,系统总是显示处于栈顶的Activity给用户每个Activity在其生命周期中最多可能会有四种状态
运行状态
暂停状态
停止状态
销毁状态
onPause()
方法的区别在于,如果启动的新Activity是一个对话框式的Activity,那么onPause
方法会得到执行,而onStop()
方法并不会执行以上方法除了onRestart
方法,其他都是两两相对的,从而又可以将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")
}
}
standard
、singleTop
,singleTask
和singleInstance
,可以在AndroidManifset.xml
中通过给
标签指定能够android:launchModel
属性来选择启动模式standard
是Activity默认的启动模式,在不进行显示指定的情况下,所有Activity都会自动使用这种模式standard
模式下,每当启动一个新的Activity,他就会在返回栈中入栈,并处于栈顶的位置,对于使用standard
模式的Activity,系统不会在乎这个Activity是否已经在返回栈中存在,每次启动都会创建一个Activity的新实例standard
模式的原理如图:
singleTop
,在启动Activity时如果发现返回栈的栈顶已经是该Activity,则认为可以直接使用它,不会创建新的Activity实例singleTop
模式的原理如图:
当Activity的启动模式指定为singleTask
,每次启动该Activity时,系统首先会在烦恼会展中检查是否存在该Activity的实例,如果发现已经存在则直接使用该实例,并把在这个Activity之上的所有其他Activity统统出栈,如果没有发现就会创建一个新的Activity的实例
singleTask
模式的原理如图:
singleInstance
模式下,会有一个单独的返回栈来管理这个Activity,不管是哪个应用程序来访问这个Activity,都共用一个返回栈。singleInstance
模式的Activity,又在或者调用一个前者模式的Activity,那么第一个和第三个Activity是在同一个返回栈里,第二个是在一个单独的返回栈中,当在第三个Activity点击了Back按钮后,会直接从第三个回到的第一个Activity,这是因为在这个返回栈里只有一和三,三出栈后就是一了,当在一又点击了Back后,会进入二的Activity,这是因为在一和三的返回栈里在一出栈后已经没有Activity了,于是就显示另一个返回栈的栈顶Activity,最后再按下back键,这时所有的返回栈都为空了,也就自然的退出程序了singleInstance
模式的原理如图:
编写布局文件
编写自定义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)
}
}
在另一个布局中引入fragment
<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得到实例
获取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()
}
之前向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()
}
Activity调用Fragment方法,可以使用findViewById()
或者视图绑定获取Fragment对象调用他的方法
Fragment中使用getActivity()
方法获取Activity对象调用他的方法
if (activity != null){
val mainActivity = activity as MainActivity
}
不同的Fragment之间进行通信,首先在一个Fragment中可以得到与他相关联的Activity,然后再通过这个Activity去获取另一个Fragment的实例,这样就实现了不同的Fragment之间的通信
运行状态
暂停状态
停止状态
remove()
,replace()
方法将Fragment从Activity中移除,但在事务提交之前调用了addToBackStack()
方法,这时的Fragment也会进入停止状。总的来说,进入停止状态的Fragment对用户来说是完全不可见的,有可能会被系统回收销毁状态
FragmentTransaction
的remove()
、replace()
方将Fragment从Activity中移除,但在事务提交之前没有调用addToBackStack()
方法,这时的Fragment也会进入销毁状态onAttach():当Fragment和Activity建立关联时调用。
onCreateView():为Fragment创建视图(加载布局)时调用。
onActivityCreated():确保与Fragment相关联的Activity已经创建完毕时调用。
onDestroyView():当与Fragment关联的视图被移除时调用。
onDetach():当Fragment和Activity解除关联时调用。
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系统之后,所有隐式广播都不允许使用静态注册的方式接收了,隐式广播值得是那些没有具体制定发送给哪个应用程序的广播,大多数系统广播属于隐式广播,但是少数特殊的系统广播仍然允许使用静态注册的方式来接收
<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()
}
}
Context类中提供了一个opneFileOutput()
方法,可以用于将数据存储到指定的文件中,这个方法接收两个参数
/data/data//files
目录下MODE_PRIVATE
MODE_PRIVATE
,表示当指定相同文件名的时候,所写入的内容会覆盖原文件中的内容MODE_APPEND
MODE_WORLD_READABLE
和MODE_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/
目录下加载这个文件,并返回一个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()
}
Context类的getSharedPreferences()
方法
/data/data//shared_prefs/
目录下的MODE_PRIVATE
一种模式可选,他和直接传入0的效果是相同的,表示只有当前的应用程序才可以对这个SharedPreferences文件进行读写,其他集中操作模式均已被废弃,MODE_WORLD_READABLE
和MODE_WORLD_WRITEABLE
这两种模式是在Android 4.2版本中被废弃的,MODE_MULTI_PROCESS模式是在Android 6.0版本中被废弃的。Activity类中的getPreferences()
方法
getSharedPreferences()
方法很相似,不过它只接收一个操作模式参数,因为使用这个方法时会自动将当前Activity的类名作为SharedPreferences的文件名edit()
方法获取一个SharedPreferences.Editor
对象SharedPreferences.Editor
对象中添加数据,比如添加一个布尔型数据就是用putBoolean()
方法,添加一个字符串则使用putString()
方法,以此类推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()
}
binding.addNum.setOnClickListener {
val prefs = getSharedPreferences("data", MODE_PRIVATE)
val data = prefs.getString("key", "default")
}
android为了让我们能够鞥家方便的管理数据库,专门提供了一个SQLiteOpenHelper
帮助类,借助这个类可以非常简单的对数据库进行创建和升级
首先SQLiteOpenHelper
是一个抽象类,所有需要创建一个自己的类去继承他,SQLiteOpenHelper
中有两个抽象方法:onCreate()
和onUpgrade()
,我们必须在自己的帮助类里重写这两个方法,然后分别在这两个方法中实现创建和升级数据库的逻辑
SQLiteOpenHelper
中还有两个非常重要的实例方法,这两个方法都可以创建或打开一个现有的数据库(如果数据库已存在则直接打开,否则要创建一个新的数据库)并返回一个可对数据库进行读写操作的对象
gerReadableDatabase()
getWritableDatabase()
getReadableDatabase()
方法返回的对象将以只读的方式打开数据库,而getWritableDatabase()
方法则将出现异常SQLiteOpebHelper
中有两个构造方法可供重写,一般使用参数少点的那个构造方法即可,这个构造方法中接收4个参数
构造出SQLiteOpenHelper的实例之后,再调用他的getReadableDatabase()
或getWritabledatabase()
方法就能够创建数据库了,数据库文件会存放在/data/data/
目录下,此时,重写的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()
写升级的逻辑,创建数据库时提供更高的版本号调用SQLiteOpenHelper
的getReadableDatabase()
和getWriteableDatabase()
方法除了可以用于创建和升级数据库,他们还都会返回一个SQLiteDatabase
对象,借助这个对象可以对数据进行CRUD操作
SQLiteDatabase
中提供了一个insert()
方法,专门用于添加数据,他接收三个参数
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个参数
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。
添加数据
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的权限机制。我们在第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所示的提醒。这样用户就可以清楚地知晓该程序一共申请了哪些权限,从而决定是否要安装这个程序。
另一方面,用户可以随时在应用程序管理界面查看任意一个程序的权限申请情况,如图8.2所示。这样该程序申请的所有权限就尽收眼底,什么都瞒不过用户的眼睛,以此保证应用程序不会出现各种滥用权限的情况。
这种权限机制的设计思路其实非常简单,就是用户如果认可你所申请的权限,就会安装你的程序,如果不认可你所申请的权限,那么拒绝安装就可以了。
但是理想是美好的,现实却很残酷。很多我们离不开的常用软件普遍存在着滥用权限的情况,不管到底用不用得到,反正先把权限申请了再说。比如微信所申请的权限列表如图8.3所示。
这还只是微信所申请的一半左右的权限,因为权限太多,一屏截不全。其中有一些权限我并不认可,比如微信为什么要读取我手机的短信和彩信?但是不认可又能怎样,难道我拒绝安装微信?没错,这种例子比比皆是,一些软件在让用户产生依赖以后就会容易 “店大欺客”,反正这个权限我就是要了,你自己看着办吧!
Android开发团队当然也意识到了这个问题,于是在Android 6.0系统中加入了运行时权限功能。也就是说,用户不需要在安装软件的时候一次性授权所有申请的权限,而是可以在软件的使用过程中再对某一项权限申请进行授权。比如一款相机应用在运行时申请了地理位置定位权限,就算我拒绝了这个权限,也应该可以使用这个应用的其他功能,而不是像之前那样直接无法安装它。
当然,并不是所有权限都需要在运行时申请,对于用户来说,不停地授权也很烦琐。Android现在将常用的权限大致归成了两类,一类是普通权限,一类是危险权限。准确地讲,其实还有一些特殊权限,不过这些权限使用得相对较少,因此不在本书的讨论范围之内。普通权限指的是那些不会直接威胁到用户的安全和隐私的权限,对于这部分权限申请,系统会自动帮我们进行授权,不需要用户手动操作,比如在BroadcastTest项目中申请的权限就是普通权限。危险权限则表示那些可能会触及用户隐私或者对设备安全性造成影响的权限,如获取设备联系人信息、定位设备的地理位置等,对于这部分权限申请,必须由用户手动授权才可以,否则程序就无法使用相应的功能。
但是Android中一共有上百种权限,我们怎么从中区分哪些是普通权限,哪些是危险权限呢?其实并没有那么难,因为危险权限总共就那么些,除了危险权限之外,剩下的大多就是普通权限了。表8.1列出了到Android 10系统为止所有的危险权限,一共是11组30个权限。
这张表格你看起来可能并不会那么轻松,因为里面的权限全都是你没使用过的。不过没有关系,你并不需要了解表格中每个权限的作用,只要把它当成一个参照表来查看就行了。每当要使用一个权限时,可以先到这张表中查一下,如果是这张表中的权限,就需要进行运行时权限处理,否则,只需要在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()方法拨打电话;如果用户拒绝的话,我们只能放弃操作,并且弹出一条失败提示。
ContentProvier
是Android实跨程序共享数据的标准方式对于每一个应用程序来说,如果想要访问ContentProvider中共享的数据,就一定要借助ContentResolver
类,可以通过Context中的getContentResolver()
方法获取该类的实例,ContentResolver
中提供了一系列的方法用于对数据进行增删改查操作,其中insert()
方法用于添加数据,update()
方法用于更新数据,delete()
方法用于删除数据,query()
用于查询数据
不同于SQLiteDatabase,ContentResolver中的增删改查方法都是不接收表名参数的,而是使用一个Uri参数代替,这个参数被称为内容URI。内容URI给ContentProvider中的数据建立了唯一标识符,它主要由两部分组成:authority
和path
。authority
是用于对不同的应用程序做区分的,一般为了避免冲突,会采用应用包名的方式进行命名。比如某个应用的包名是com.example.app
,那么该应用对应的authority
就可以命名为com.example.app.provider
。path
则是用于对同一应用程序中不同的表做区分的,通常会添加到authority
的后面。比如某个应用的数据库里存在两张表table1和table2,这时就可以将path
分别命名为/table1和/table2,然后把authority
和path
进行组合,内容URI就变成了com.example.app.provider/table1
和com.example.app.provider/table2
。不过,目前还很难辨认出这两个字符串就是两个内容URI,我们还需要在字符串的头部加上协议声明,因此,内容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表中的数据了
最后将数据取出即可
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
类中有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
参数用于确定查询哪些列selection
和selectionArgs
参数用于约定查询哪些行sortOrder
参数用于对结果进行排序insert()
update()
delete()
getType()
一个标准的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的格式主要就只有以上两种
可以使用通配符分别匹配这两种格式的内容URI,规则如下:
*
表示匹配任意长度的字符串#
表示匹配任意长度的数字所以,一个能够匹配人意表的内容的URI格式就可以写成
content://com.example.app.provider/*
一个能够匹配table1表中任意一行数据的内容URI格式就可以写成:
content://com.example.app.provider/table1/#
接着,我们再借助UriMatcher
这个类就可以轻松地实现匹配内容URI的功能
UriMathcer
中提供了一个addUri()
方法,这个方法接收3个参数,可以分别把authority
、path
、和一个自定义代码传进去。这样当调用UriMatcher
的match()
方法的时候,就可以将一个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
开头android.cursor.dir/
;如果内容URI以id结尾,则后接android.cursor.item/
vnd..
所以对于content://com.example.app.provider/table1
这个内容URI,他所对应的MIME类型就可以写成:
vnd.android.cursor.dir/vnd.com.example.app.provider/table1
所以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
}
}
自己看去,到这了还不会线程基础?
有一点需要注意的是Kotlin的thread{}
开头是小写!不要和java的Thread{}
混了,后面这个要加start()
的
thread {
}
Thread {
}.start()
和很多的其他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对象发送出去
}
}
}
}
what
字段,还可以使用arg1
和arg2
字段来携带一些整型,使用obj
字段携带一个Object对象sendMessage()
方法、post()
方法等,而发出的消息经过一系列的辗转处理后,最终会传递到Handelr的handleMessage()
方法中handleMessage()
方法中,每个线程中只会有一个Looper对象被弃用了
首先来看一下AsyncTask的基本用法。由于AsyncTask是一个抽象类,所以如果我们想使用它,就必须创建一个子类去继承它。在继承时我们可以为AsyncTask类指定3个泛型参数,这3个参数的用途如下
Params。在执行AsyncTask时需要传入的参数,可用于在后台任务中使用。
Progress。在后台任务执行时,如果需要在界面上显示当前的进度,则使用这里指定的泛型作为进度单位。
Result。当任务执行完毕后,如果需要对结果进行返回,则使用这里指定的泛型作为返回值类型。
因此,一个最简单的自定义AsyncTask就可以写成如下形式:
class DownloadTask : AsyncTask<Unit, Int, Boolean>() {
...
}
这里我们把AsyncTask的第一个泛型参数指定为Unit,表示在执行AsyncTask的时候不需要传入参数给后台任务。第二个泛型参数指定为Int,表示使用整型数据来作为进度显示单位。第三个泛型参数指定为Boolean,则表示使用布尔型数据来反馈执行结果。
当然,目前我们自定义的DownloadTask还是一个空任务,并不能进行任何实际的操作,我们还需要重写AsyncTask中的几个方法才能完成对任务的定制。经常需要重写的方法有以下4个。
因此,一个比较完整的自定义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()
}
}
}
onBind()
onCreate()
onStartCommand()
onDestory()
启动和挺值得方法也是借助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
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)
}
}
}
从Android 8.0系统开始,只有当应用保持在前台可见状态的情况下,Service才能保证稳定运行,一旦应用进入后台之后,Service随时都有可能被系统回收。而如果你希望Service能够一直保持运行状态,就可以考虑使用前台Service。前台Service和普通Service最大的区别就在于,它一直会有一个正在运行的图标在系统的状态栏显示,下拉状态栏后可以看到更加详细的信息,非常类似于通知的效果,如图
由于状态栏中一直有一个正在运行的图标,相当于我们的应用以另外一种形式保持在前台可见状态,所以系统不会倾向于回收前台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()
方法,这方法接收两个参数:
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>
在一开始我们就知道,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是不是停止了。
什么是通知渠道
创建通知渠道的步骤
首先需要一个NotificationManager
对通知进行管理,可以通过调用Cotnext
的getSystemService()
方法获取。getSystemService()
方法接收一个字符串参数用于确定获取系统的哪个服务,这里我们传入Contxt.NOTIFICATION_SERCICE
即可,因此,获取NotificationManager
的实例既可以写成:
val manager getSystenService(Context.NOTIFICATION_SERICE) as NotificationManager
接下里要使用NotificationChannel
类构造一个通知渠道,并调用NotificationManager
的createNotificatinChannel()
方法完成构造,由于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、渠道名称以及重要等级这三个参数
IMPORTANCE_HIGH
、IMPORTANCE_DEFAULT
、IMPORTANCE_LOW
、IMPORTANCE_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
的构造函数中接收两个参数
当然,上述代码只是创建了一个空的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()
方法用于设置通知的大图标,当下拉系统状态栏就可以看到设置的大图标了最后只需要调用NotificationManager
的notify()
方法就可以让通知显示出来了,notify()
方法接收这两个参数:
第一个参数是Id,要保证为每个通知指定的id都是不同的
第二个参数则是Notification对昂,这里直接将创建好的Notification对象传入即可
manager.notify(1, notification)
通知的点击效果
使用PendingIntent
PendingIntent
的用法很简单,它主要提供了几个静态方法用于获取PendingIntent
的实例,可以根据需求来选择是使用getActivity()
方法、getBroadcast()
,还是getService()
方法
这几个方法所接收的参数都是相同的:
Context
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()
一种是显示地调用NotificationManager
的cancel()
方法将他取消
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就可以了
PendingIntent
和Intent
的区别和联系
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/
。那么为什么要使用应用关联缓存目录来存放图片呢?因为从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文件中的内容,如下所示:
<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)
}
...
}
播放音频
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()
}
}
播放视频
很可惜的是,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()
}
}
当我们的应用程序需要展示一些网页,除了使用系统浏览器外,我们还可以使用Android提供的WebView
控件,借助它实现在自己的应用程序里嵌入一个浏览器
<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>
127.0.0.1
在过去,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请求所使用的方法,常用的方法主要有两个:GET
和POST
。
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之前,需要先进行导包
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()
之后调用OkHttpClient
的newCall()
方法来常见一个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
}
}
}
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
对象,然后调用XmlPullParser
的setInput()
方法将服务器返回的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文件中的内容,如下所示:
<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>
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()
}
}
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()
}
}
GSON可以将一段JSON格式的字符串自动映射成一个对象,从而不需要我们再手动编写代码进行解析
比如这样一段JSON格式的数据
{"username” : "马尔扎哈", "password" : "12346"}
那我们就可以定义一个Person类,并加入username
和age
这两个字段,然后只需要的简单的调用如下代码就可以将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语句返回数据的,因此这里我们将服务器响应的数据传入了HttpCallbackListener
的onFinish()
方法中,如果出现了异常,就将异常原因传入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,我们需要在项目中添加必要的依赖库
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类,并加入username
和password
这两个字段,如下所示:
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
,当然,Retrofit还提供了强大的Call Adapters 功能来允许我们自定义方法返回值的类型,比如Retrofit结合RxJava使用就可以将返回值声明称Observable
、Flowable
等类型定义好了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
对象,这时我们再调用一下它的>
enqueue()
方法,Retrofit就会根据注解中配置的服务器接口地址去进行网络请求了,服务器响应的数据会回调到enqueue()方法中传入的Callback实现里面。需要注意的是,当发起请求的时候,Retrofit会自动在内部开启子线程,当数据回调到Callback中之后,Retrofit又会自动切换回主线程,整个操作过程中我们都不用考虑线程切换问题。在Callback的onResponse()
方法中,调用response.body()
方法将会得到Retrofit解析后的对象,也就是List类型的数据,最后遍历List,将其中的数据打印出来即可。
需要注意的是,和之前一样,这里也需要对网络安全进行配置才行
大多数情况下,服务器不可能总是给我们提供静态类型的接口,在很多情境下,接口地址中的部分内容可能会是动态变化的,比如如下的接口地址:
GET http://example.com/<page>/get_data.json
这个接口中,
代表页数,我们传入不同的页数,服务器返回的数据也会不同,这种接口地址对应到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值的功能。
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)
}
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.NoActionBar
和Theme.AppCompat.Light.NoActionBar
这两种主题可选。
Theme.AppCompat.NoActionBar
表示深色主题,它会将界面的主体颜色设成深色,陪衬颜色设成浅色。
Theme.AppCompat.Light.NoActionBar
表示浅色主题,它会将界面的主体颜色设成浅色,陪衬颜色设成深色。
<resources>
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
- "colorPrimary"
>@color/colorPrimary
- "colorPrimaryDark"
>@color/colorPrimaryDark
- "colorAccent">@color/colorAccent
style>
resources>
观察一下AppTheme中的属性重写,这里重写了colorPrimary
、colorPrimaryDark
和colorAccent
这3个属性的颜色。那么这3个属性分别代表什么位置的颜色呢?我用语言比较难描述清楚,还是通过一张图来理解一下吧,如图所示
除了上述3个属性之外,我们还可以通过textColorPrimary
、windowBackground
和navigationBarColor
等属性控制更多位置的颜色。不过唯独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一致了。
接下来我们再学习一些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()方法中处理各个按钮的点击事件。现在重新运行一下程序,效果如图所示
可以看到,Toolbar上现在显示了两个action按钮,这是因为Backup按钮指定的显示位置是always,Delete按钮指定的显示位置是ifRoom,而现在屏幕空间很充足,因此两个按钮都会显示在Toolbar中。另外一个Settings按钮由于指定的显示位置是never,所以不会显示在Toolbar中,点击一下最右边的菜单按钮来展开菜单项,你就能找到Settings按钮了。另外,这些action按钮都是可以响应点击事件的
<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
}
}
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="[email protected]"
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
}
}
...
}
<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()
}
}
...
}
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()
}
}
...
}
<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>
<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>
<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>
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
}
}
}
...
}
ViewModel应该可以算是Jetpack中最重要的组件之一了。其实Android平台上之所以会出现诸如MVP、MVVM之类的项目架构,就是因为在传统的开发模式下,Activity的任务实在是太重了,既要负责逻辑处理,又要控制UI展示,甚至还得处理网络回调,等等。在一个小型项目中这样写或许没有什么问题,但是如果在大型项目中仍然使用这种写法的话,那么这个项目将会变得非常臃肿并且难以维护,因为没有任何架构上的划分。
而ViewModel的一个重要作用就是可以帮助Activity分担一部分工作,它是专门用于存放与界面相关的数据的。也就是说,只要是界面上能看得到的数据,它的相关变量都应该存放在ViewModel中,而不是Activity中,这样可以在一定程度上减少Activity中的逻辑。
另外,ViewModel还有一个非常重要的特性。我们都知道,当手机发生横竖屏旋转的时候,Activity会被重新创建,同时存放在Activity中的数据也会丢失。而ViewModel的生命周期和Activity不同,它可以保证在手机屏幕发生旋转的时候不会被重新创建,只有当Activity退出的时候才会跟着Activity一起销毁。因此,将与界面相关的变量存放在ViewModel当中,这样即使旋转手机屏幕,界面上显示的数据也不会丢失。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都会创建一个新的实例,这样当手机屏幕发生旋转的时候,就无法保留其中的的数据了
上一小节中创建的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的构造函数传递数据了,前面已经说了需要借助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)
}
}
}
在编写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_CREATE
、ON_START
、ON_RESUME
、ON_PAUSE
、ON_STOP
和ON_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
返回的生命周期状态是一个枚举类型,一共有INITIALIZED
、DESTROYED
、CREATED
、STARTED
、RESUMED这5种状态类型,它们与Activity的生命周期回调所对应的关系如图13.8所示。
也就是说,当获取的生命周期状态是CREATED的时候,说明onCreate()方法已经执行了,但是onStart()方法还没有执行。当获取的生命周期状态是STARTED的时候,说明onStart()方法已经执行了,但是onResume()方法还没有执行,以此类推。
之前我们编写的那个计数器虽然功能非常简单,但其实是存在问题的。目前的逻辑是,当每次点击“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()
方法。
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()方法接收两个参数:
需要注意的是,如果你需要在子线程中给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的数据封装性
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的整体结构,它主要由Entity、Dao和Database这三部分组成,每隔部分都有明确的职责:
使用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在数据库升级方面设计得非常烦琐,基本上没有比使用原生的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,需要添加依赖
dependencies {
...
implementation 'android.arch.work:work-runtime:1.0.1'
}
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.Builder
的setBackoffCriterial()
方法来重新执行任务
这样一个后台任务就定义好了,接下来进入第二部,配置该后台任务的运行条件和约束信息
这一步其实也是最复杂的一步,因为可配置的内容非常多,不过目前我们还只是学习WrokManager的基本用法,因此只进行最基本的配置就可以了,代码如下所示:
val request = OneTimeWorkRequest.Builder(simpleWorker::ckass.java).build()
可以看到,只需要把刚才创建的后台任务所对应的的Class对象传入OneTimeWorkRequest.Builder
的构造函数中,然后调用build()
方法即可完成构建
OneTimeWorkRequest.Builder
是WorkRequest.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还允许我们控制许多其他方面的东西,下面就来具体看一下吧。
首先从最简单的看起,让后台任务在指定的延迟时间后运行,只需要借助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秒钟;第一个参数则用于指定如果任务再次执行失败,下次重试的时间应该以什么样的形式延迟。这其实很好理解,假如任务一直执行失败,不断地重新执行似乎并没有什么意义,只会徒增设备的性能消耗。而随着失败次数的增多,下次重试的时间也应该进行适当的延迟,这才是更加合理的机制。第一个参数的可选值有两种,分别是LINEAR
和EXPONENTIAL
,前者代表下次重试时间以线性的方式延迟,后者代表下次重试时间以指数的方式延迟。
了解了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
android:text="hello world"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
按钮
如果按钮的内容使用的是英文,那么运行后的显示会全部是大写字母,如果需要原样使用的话,则需要指定一个属性
android:textAllCaps="false"
android:hint
来指定输入框默认显示的内容,类似placeHolder
android:maxLines
来指定输入框的最大行数,当输入的内容超过两行时,文本就会像上滚动,EditText则不会继续拉伸用于在界面显示一个进度条
可以通过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一般用于提示一些非常重要的内容或者警告信息
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键关闭对话框
layout布局引入
Activity设置adapter、点击事件
提升效率
class FruitAdapter(activity: Activity, val resourceId: Int, data: List<Fruit>) :
ArrayAdapter<Fruit>(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库
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
外,还有GridLayoutManager
和StaggeredGridLayoutManager
其中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
}
...
}
<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>
visibility
visible
invisible
gone
gravity
和 layout_gravity
gravity
是文字在控件内部的对齐方式layout_gravity
是控件在布局内部的对齐方式layout_weight
layout_weight
值相加,得到一个总值,然后每个控件所占大小的比例就是使用该控件的layout_weight
值除以刚才算出的总值vertical
时,水平方向上可以使用layout_weight
,当使用的是horizontal
时,垂直方向上可以使用layout_weight
,另外所指定的方向的width或height需要指定为0dporientation
指定布局的方向相对布局,他可以通过相对定位的方式让控件出现在布局的任何位置
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
layout_gravity
指定对齐方式class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
supportActionBar?.hide()
}
}
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所示的警告提示。
但是由于这里获取的不是Activity或Service中的Context,而是Application中的Context,它全局只会存在一份实例,并且在整个应用程序的生命周期内都不会回收,因此是不存在内存泄漏风险的。那么我们可以使用如下注解,让Android Studio忽略上述警告提示:
class MyApplication : Application() {
companion object {
@SuppressLint("StaticFieldLeak")
lateinit var context: Context
}
...
}
接下来我们还需要告知系统,当程序启动的时候应该初始化MyApplication类,而不是默认的Application类。这一步也很简单,在AndroidManifest.xml文件的
标签下进行指定就可以了,代码如下所示:
<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就可以了。
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后再将其反序列化成一个新的对象。虽然这两个对象中存储的数据完全一致,但是它们实际上是不同的对象,这一点希望你能了解清楚。
除了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就可以了。