简介
从2019年以来,很久没有摸过Android项目了。听到最火的词除了Kotlin就是Jetpack。Kotlin我倒是从开始就接触,而且随着接触Java系之外的语言之后对这门语言有了更深入的了解,但是Jetpack对我而言真的非常新鲜,同时也非常陌生。我在课业之外花了大概一周的时间简单入门了Jetpack相关的库,同时也看了官方的示例app Sunflower
的源代码。Sunflower
说实话写的真心不错,但是其中有些逻辑有点奇怪,我自己没有琢磨透,可能是因为这个项目还在不断迭代,有些内容没有删除干净吧。
Anyway,我使用Sunflower项目的配置实现了一个使用Unsplash api输入关键字查看图片的Demo。
项目地址MySunflower
相较于原始的Sunflower,MySunflower没有使用以下库(或者说省去了以下功能):
- Room:使用Room存储种植的植物和植物Gallery
- WorkManager:Sunflower中使用WorkManager作为Job的替代,在后台加载Room的Database,没有用Room也就没有使用WorkManager
- LiveData:好像没有这个需求 MySunflower使用了这些库(列举主要:
- Hilt:功能非常强大的DI库,真的是在Jetpack中所向披靡!
- ViewModel:ViewModel在本Demo中有点大材小用了,主要是体验MVVM
- Paging:返回的结果进行分页
- Navigation:相对于之前的Fragment以及Activity的操作,Navigation使用可视化的方式更加清晰,同时使用safeargs传值方便!
- Databinding:Databinding不止能够绑定Data,还能够绑定View
本文会通过图示+注释的方式,作为一个简单入门Jetpack的文章,给大家分享一下我学习Jetpack,然后通过一天时间完成这个小Demo中学到的点,给我自己做一个备忘,也给大家做一个参考。希望大家批评指正,如果对您有帮助,点个赞吧!
如果大家不想直接逐行看官方文档的话,我把项目对应点的官方文档连接都标注在提及的位置
最好还是看看官方文档
功能简介
整体架构
我的确是第一次接触MVVM,对于MVVM的认识非常浅薄,我们通过下图认识一下本项目中的几个角色:
Model在本项目中对应的包为data
,包括网络请求对应的pojo类,封装向外暴露数据的Repository;ViewModel
层就是对应着viewModel
包,
view
主要是Fragment Activity类。
此外,注意ViewModel中不应该持有View的实例。
各个库都干了什么?
Hilt
使用Hilt依赖注入的所有对象都需要根据要求注解,比如:
// 注解到app类上
@HiltAndroidApp
class MainApplication: Application(){
// todo configure WorkManager
}
使用Hilt进行依赖注入时,Hilt需要知道怎么提供这些类的实现类。我们举一条线索来说:
UnsplashService
实现网络请求,这个Service
类本身是被Retrofit
实现的(这里Hilt不知道这个类如何注入,所以需要提供Module + Provides的方法)。这个类被Repository类使用,则Repository中需要说明:
// @Inject在构造器上注解
class UnsplashRepository @Inject constructor(private val service: UnsplashService)
DisplayViewModel本身注解了@HiltViewModel
,它的实现交给Hilt,之后再Fragment和Activity中可以使用by viewModels()
获取实例 Inject ViewModel objects with Hilt。
Navigation
Navigation是提供一个Fragment之间跳转的可视化视图,还可以传递数据。Android Navigation 其他的部分等到具体说到了再提。
开始按照MVVM讲解项目结构:
Model
作为一个使用数据驱动的Demo,本文将先从Model部分开始说起。
结构交互图
使用Retrofit进行网络请求
UnsplashService
使用Retrofit根据关键字query: String
展开网络请求,然后通过GsonFactory
映射成Java类型。我直呼爷青回!Retrofit还是主流的框架!
interface UnsplashService {
// .....
companion object {
private const val BASE_URL = "https://api.unsplash.com/"
fun create(): UnsplashService {
val interceptor = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }
val client = OkHttpClient.Builder()
.addInterceptor(interceptor)
.build()
return Retrofit.Builder()
.baseUrl(BASE_URL)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(UnsplashService::class.java)
}
}
}
请求的服务提供类UnsplashService
的构建过程如上。
Module + Provides
上面说过UnsplashService
通过Retrofit
初始化,Hilt不知道怎么依赖注入,因此,我们需要手动说明一下:
@InstallIn(SingletonComponent::class)
@Module
class NetworkModule {
// 生成一个UnsplashService单例
@Singleton
@Provides
fun provideUnsplashService(): UnsplashService {
return UnsplashService.create()
}
}
Pojo
data class UnsplashUser(
@field:SerializedName("name") val name: String,
@field:SerializedName("username") val username: String
)
举个映射后的Java类型的例子,其中@SerializedName
表示json数据的字段。@SerializedName
注解在data class的constructor中,这样会导致最后在Java字节码中注解生成的位置有多个(比如注解在setter和getter上)使用@field
字段唯一确定注解在Java字段上。Kotlin注解
data class UnsplashPhoto(
@field:SerializedName("id") val id: String,
@field:SerializedName("urls") val urls: UnsplashPhotoUrls,
@field:SerializedName("user") val user: UnsplashUser
): Serializable
实现Serializable
接口并不是必须的,这里主要是为了使用safeargs
传值方便。因为Databinding
在绑定值的时候有一些要求,如果是要传递复杂数据类型需要满足一些条件,其中一条就是需要实现Serializable
Navigation passing data。关于Databinding的细节在后面View部分会详细说说。
使用Paging处理数据
Paging库的主要角色和他们之间的关系如图所示:
PageList/PagingData
表示数据,他们从PagingSource
中不断取出。取出的数据通过PaingAdapter
进行UI可视化。注意,这里提供数据的过程需要在后台执行。
对于PagingSource而言,官方在文档中给出了两种示例,这里使用pageNumber来查询:
private const val UNSPLASH_STARTING_PAGE_INDEX = 1
class UnsplashPagingSource(
private val service: UnsplashService,
private val query: String
): PagingSource<Int, UnsplashPhoto>() {
// suspend 提供数据需要在后台执行
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, UnsplashPhoto> {
// 在最开始加载时key为null,具体可以看看key的文档
val page = params.key ?: UNSPLASH_STARTING_PAGE_INDEX
return try{
// 这里的查询通过page数字决定,同时,suspend 函数 searchPhotos不需要放在 scope中
// 因为Retrofit的Coroutine Adapter会自动将网络请求放在一个工作线程中
val response = service.searchPhotos(query, page, params.loadSize)
val photos = response.results
LoadResult.Page(
data = photos,
prevKey = if (page == UNSPLASH_STARTING_PAGE_INDEX) null else page - 1,
nextKey = if (page == response.totalPages) null else page + 1
)
}catch(e : Exception){
LoadResult.Error(e)
}
}
}
加载完成的数据,通过Adapter
在RecyclerView
中显示出来。
class DisplayAdapter: PagingDataAdapter<UnsplashPhoto, DisplayAdapter.DisplayViewHolder>(DisplayDiffCallback())
具体的细节会在后面ViewModel和View的交互阶段说。
ViewModel
ViewModel
按照官网的说法是一种注重生命周期的方式存储和管理界面相关的数据的类。ViewModel
对象的存在范围和在建立ViewModel
对象时传递给ViewModelProvider
的LifeCycle
相关,当Activity
没有被finish()
时,ViewModel
一直存活在内存中。使用ViewModel可以处理同一个Activity
包含的两个Fragment
传值的问题。在这种情况下ViewModel
属于这两个Fragment
对应的Activity
,两个Fragment
会获取到相同的ViewModel
。
在本项目中Fragment
传值比较简单,使用的是safeargs
传值的方式,关于使用ViewModel
传值参考passing data between fragments via ViewModel。
MySunflower中的ViewModel
本项目中只有一个ViewModel
--DisplayViewModel
,他的作用是连接Model
层的数据,同时给View
层传递数据
@HiltViewModel
class DisplayViewModel @Inject constructor(
private val repository: UnsplashRepository
): ViewModel() {
fun search(query: String): Flow<PagingData<UnsplashPhoto>>
= repository.getSearchResultStream(query).cachedIn(viewModelScope)
}
使用cachedIn()
做了一个缓存。ViewModel
会绑定到一个ViewModelScope
上,当ViewModel
不存在了ViewModelScope
也会被取消,这样做的原因应该是避免不必要的后台工作,及时处理,防止内存泄漏。
DisplayViewModel
的初始化在DisplayFragment
中被委托给了viewModels()
:
@AndroidEntryPoint
class DisplayFragment: Fragment() {
// ...
private val viewModel: DisplayViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
// ...
}
private fun search(query: String){
searchJobs?.cancel() // 防止重复请求
// 和lifeScope关联,当Fragment detach时工作没有取消内存泄漏
searchJobs = lifecycleScope.launch {
// 开启的CoroutineScope工作在主线程上
// 但是还是要开一个CoroutineScope,因为collectLastest是Flow的terminator操作符,是suspend方法
viewModel.search(query).collectLatest {data -> adapter.submitData(data) }
}
}
}
View
一个数据驱动的App当完成Model
,ViewModel
之后,按照设计稿应该开始构建界面之间的关系,使用Navigation
能够轻松做到这一点。
Navigation Graph
一个Single Activity App,应该只有一个Activity作为Fragment的容器。
所以主要的Activity,MainActivity
的xml文件如下所示:
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_graph"/>
</layout>
activity_main.xml
中有三点值得注意:
FragmentContainerView
:FragmentContainerView
就是作为Fragment的容器,在name中必须写上实现功能的类的全限定名称android:name="androidx.navigation.fragment.NavHostFragment"
;同时指定navGraph
。layout
:layout
和Databinding
库联系密切,Databinding
会根据xml生成对应名称的类,比如fragment_query.xml
->FragmentQueryBinding
navGraph
:navGraph
对应我们使用的Navigation的nav_graph
:
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
app:startDestination="@id/QueryFragment"
android:id="@+id/nav_graph" >
<!-- 指定首先开始的界面为QueryFragment-->
<!-- 声明Fragment,需要指定全限定名称的类, tools:layout在Design工具方便查看我们设计的 -->
<fragment
android:id="@+id/QueryFragment"
android:name="com.example.mysunflower.QueryFragment"
tools:layout="@layout/fragment_query">
<!--和跳转相关,从QueryFragment到DisplayFragment-->
<action
android:id="@+id/action_query_to_display"
app:destination="@id/DisplayFragment" />
</fragment>
<fragment
android:id="@+id/DisplayFragment"
android:name="com.example.mysunflower.DisplayFragment"
android:label="DisplayFragment"
tools:layout="@layout/fragment_display">
<!-- 表示从其他的Fragment过来需要接受一个参数,这个参数的类型是string(对应String)-->
<argument
android:name="query"
app:argType="string" />
<action
android:id="@+id/action_display_to_detail"
app:destination="@id/DetailFragment" />
</fragment>
<fragment
android:id="@+id/DetailFragment"
android:name="com.example.mysunflower.DetailFragment"
android:label="DetailFragment">
<!-- 如果是一个复杂类型,需要指定全限定名称-->
<argument
android:name="photo"
app:argType="com.example.mysunflower.data.UnsplashPhoto"/>
</fragment>
</navigation>
nav_graph
的图像如图所示
QueryFragment
QueryFragment
用于接收用户输入的查询关键字,这个Fragment并不做网络请求。QueryFragment
将收到的关键字传递给DisplayFragment
。
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<EditText
android:id="@+id/edit_query"
android:layout_marginTop="300dp"
android:layout_centerHorizontal="true"
android:layout_width="200dp"
android:layout_height="wrap_content"/>
<Button
android:id="@+id/button_query"
android:text="Enter"
android:layout_marginTop="30dp"
android:layout_below="@+id/edit_query"
android:layout_centerHorizontal="true"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</RelativeLayout>
</layout>
Databinding
库同样会针对在xml指定的layout生成具体的Databinding。如果需要使用这个类必须在写好xml之后进行Rebuild project:
@AndroidEntryPoint
class QueryFragment: Fragment() {
// 委托生成ViewModel
private val displayViewModel: DisplayViewModel by viewModels()
// 生成Databinding
private lateinit var binding: FragmentQueryBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
// 使用Databinding库自动生成的类
binding = FragmentQueryBinding.inflate(inflater, container, false)
// binding 能够获取定义在xml中的view
binding.buttonQuery.setOnClickListener {
navigateToDisplay()
}
// 返回根视图
return binding.root
}
private fun navigateToDisplay(){
val query = binding.editQuery.text.toString()
// Navigation生成的Direction类,类的命名是xxxDirections,并且通过safeargs进行传值
val direction = QueryFragmentDirections.actionQueryToDisplay(query)
findNavController().navigate(direction)
}
我们可以发现Databinding不仅绑定了Data(这点会在DisplayFragment中说明),还可以绑定view
s,之前的findViewById
无了,爷青结!
同时binding
获取的view会将id转换成驼峰命名的变量button_query
->buttonQuery
。
DisplayFragment
通过QueryFragment
发送到DisplayFragment
的值通过Safeargs来接受。然后DisplayFragment
根据这个关键字加载网络请求。
@AndroidEntryPoint
class DisplayFragment: Fragment() {
private val adapter = DisplayAdapter()
// 初始化safeargs 接受数据query
private val args: DisplayFragmentArgs by navArgs()
// 用于取消正在进行的工作,避免内存泄漏或者UI异常
private var searchJobs: Job? = null
private lateinit var binding: FragmentDisplayBinding
private val viewModel: DisplayViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
// 获取binding实例
binding = FragmentDisplayBinding.inflate(inflater, container, false)
// Databinding 绑定xml中的photoList (RecyclerView),并且传递adapter
binding.photoList.adapter = adapter
// 接受传递的数据
search(args.query)
return binding.root
}
private fun search(query: String){
searchJobs?.cancel()
searchJobs = lifecycleScope.launch {
// lifecycleScope 是在主线程上工作的,但是viewModel.search()追溯到最下面的Retrofit实现会将
// 网络请求在后台协程上实现,这里不需要担心阻塞线程的问题
Log.d("thread",Thread.currentThread().name)
// 将数据填充到adapter
viewModel.search(query).collectLatest {data -> adapter.submitData(data) }
}
}
}
DisplayFragment
的xml就是一个简简单单的RecyclerView。
<layout
// .....
>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
// 转换成驼峰式命名法,可以在Kotlin类中被引用
android:id="@+id/photo_list"
// 主要的加载工作是交给list_item_photo 以及对应的类完成
tools:listitem="@layout/list_item_photo"/>
</RelativeLayout>
</layout>
具体的加载网络图片,显示名字等等任务是交给Adapter和ViewHolder
实现的。(虽然xml中不使用这种方式表示注释,但是我偷懒了哈,大家意会就行)
Databinding绑定数据
list_item_photo.xml
作为真正的内容显示的布局文件,它负责通过Glide
加载网络图片,显示Pojo中的name
数据;Databinding
则负责将这些内容绑定到对应的位置上,它的具体实现如下:
<layout
// ...
>
// 使用Databinding传递进来的类型 也需要使用全限定名称
<data>
<variable
name="clickListener"
type="android.view.View.OnClickListener"/>
<variable
name="photo"
type="com.example.mysunflower.data.UnsplashPhoto"/>
</data>
<androidx.cardview.widget.CardView
// ...
>
<androidx.constraintlayout.widget.ConstraintLayout
// ..
>
<ImageView
android:id="@+id/unsplash_photo"
// ...
app:imageFromUrl="@{photo.urls.small}"
// ...
/>
<TextView
android:id="@+id/photographer"
// ...
android:text="@{photo.user.name}"
// ...
>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
</layout>
看完以上的xml文件,这里也有三点需要注意
- Databindind如何将传入的值设置到对应的字段中: 简单来说,就是Databinding是如何调用setter函数的,我这里简单说明一下。比如上面在TextView中的这一句话:
android:text="@{photo.user.name}"
Databinding会去找对应的接受String类型参数的setText(string:String)
类型的方法。binding adapters
- BindingAdapter的使用
app:imageFromUrl="@{photo.urls.small}"
很明显,ImageView
是没有这个方法的,但是在xml布局中,这样的写法格外的好用,通过传值的方式可以减少在界面代码中多余的逻辑。可以通过BindAdapter
实现。注意,如果这样写的话,命名空间需要改成app
// 属性名
@BindingAdapter("imageFromUrl")
// 对应的逻辑
// 第一个参数对应的是添加到的View的类型,后面是参数
fun bindLoadImageFromUrl(view: ImageView, imageUrl: String?){
if (!imageUrl.isNullOrEmpty()){
Glide.with(view.context)
.load(imageUrl)
.transition(DrawableTransitionOptions.withCrossFade())
.into(view)
}
}
BindAdapter
还可以整一些花活,具体参考reference。
- Databinding值的设置 最后一点,如何在xml外面将传到xml里面我们设置好的结果上。
PagingDataAdapter
在Adapter
中使用Databinding将一列数据绑定到对应的ListItem
上:
class DisplayAdapter: PagingDataAdapter<UnsplashPhoto, DisplayAdapter.DisplayViewHolder>(DisplayDiffCallback()) {
private lateinit var binding: ListItemPhotoBinding
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DisplayViewHolder {
// 构建binding对象,ListItemPhotoBinding也是自动生成的
binding = ListItemPhotoBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return DisplayViewHolder(binding)
}
override fun onBindViewHolder(holder: DisplayViewHolder, position: Int) {
val photo = getItem(position)
if (photo != null) {
holder.bind(photo)
// 将lambda(OnClickListener对象)设置到binding上
// binding.clickListener 也可以
binding.setClickListener { holder.navigate(photo) }
}
}
class DisplayViewHolder(
private val binding: ListItemPhotoBinding
) : RecyclerView.ViewHolder(binding.root) {
fun navigate(item: UnsplashPhoto){
// 设置每一个item的点击行为,跳转到DetailFragment中看高清大图
val direction = DisplayFragmentDirections.actionDisplayToDetail(photo = item)
binding.root.findNavController().navigate(direction)
}
fun bind(item: UnsplashPhoto) {
// 将photo对象设置到binding上
binding.apply {
photo = item
// 立刻刷新,不留到下一帧!
executePendingBindings()
}
}
}
// 这个callback确定是否要继续加载后面的内容
private class DisplayDiffCallback: DiffUtil.ItemCallback<UnsplashPhoto>() {
override fun areItemsTheSame(oldItem: UnsplashPhoto, newItem: UnsplashPhoto): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: UnsplashPhoto, newItem: UnsplashPhoto): Boolean {
return oldItem == newItem
}
}
}
数据通过在DisplayFragment
中的search()
函数就填充到Adapter中了:
private fun search(query: String){
searchJobs?.cancel()
searchJobs = lifecycleScope.launch {
// lifecycleScope 是在主线程上工作的,但是viewModel.search()追溯到最下面的Retrofit实现会将
// 网络请求在后台协程上实现,这里不需要担心阻塞线程的问题
Log.d("thread",Thread.currentThread().name)
// 将数据填充到adapter
viewModel.search(query).collectLatest {data -> adapter.submitData(data) }
}
}
DetailFragment
当点击item的时候可以跳转到DetailFragment
中查看高清大图,具体代码非常简单,就不展开啦。
@AndroidEntryPoint
class DetailFragment: Fragment() {
private lateinit var binding: FragmentDetailBinding
private val args: DetailFragmentArgs by navArgs()
// 不需要进行网络请求,只需要从点击事件中获取传过来的photo量
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentDetailBinding.inflate(inflater, container, false)
binding.photo = args.photo
return binding.root
}
}
xml加载图片的逻辑和上面是相同的:
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="photo"
type="com.example.mysunflower.data.UnsplashPhoto"/>
</data>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:imageFromUrl="@{photo.urls.raw}"/>
<TextView
android:id="@+id/name"
android:text="@{photo.user.name}"
android:layout_below="@+id/image"
android:layout_centerHorizontal="true"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</RelativeLayout>
</layout>