图示+注释实现一个简单的Jetpack Demo!

1,476 阅读11分钟

简介

从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中学到的点,给我自己做一个备忘,也给大家做一个参考。希望大家批评指正,如果对您有帮助,点个赞吧!

如果大家不想直接逐行看官方文档的话,我把项目对应点的官方文档连接都标注在提及的位置

最好还是看看官方文档

功能简介

QueryFragment

DisplayFragment

DetailFragment

整体架构

我的确是第一次接触MVVM,对于MVVM的认识非常浅薄,我们通过下图认识一下本项目中的几个角色:

截屏2021-06-29 14.08.03.png Model在本项目中对应的包为data,包括网络请求对应的pojo类,封装向外暴露数据的Repository;ViewModel层就是对应着viewModel包, view主要是Fragment Activity类。 此外,注意ViewModel中不应该持有View的实例。

各个库都干了什么?

Hilt

使用Hilt依赖注入的所有对象都需要根据要求注解,比如:

// 注解到app类上
@HiltAndroidApp
class MainApplication: Application(){
//     todo configure WorkManager
}

使用Hilt进行依赖注入时,Hilt需要知道怎么提供这些类的实现类。我们举一条线索来说: 截屏2021-06-29 14.13.06.png 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

截屏2021-06-29 14.22.42.png

Navigation是提供一个Fragment之间跳转的可视化视图,还可以传递数据。Android Navigation 其他的部分等到具体说到了再提。


开始按照MVVM讲解项目结构:

Model

作为一个使用数据驱动的Demo,本文将先从Model部分开始说起。

结构交互图

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在绑定值的时候有一些要求,如果是要传递复杂数据类型需要满足一些条件,其中一条就是需要实现SerializableNavigation passing data。关于Databinding的细节在后面View部分会详细说说。

使用Paging处理数据

Paging库的主要角色和他们之间的关系如图所示: 截屏2021-06-29 14.30.56.png 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)
        }
    }
}

加载完成的数据,通过AdapterRecyclerView中显示出来。

class DisplayAdapter: PagingDataAdapter<UnsplashPhoto, DisplayAdapter.DisplayViewHolder>(DisplayDiffCallback()) 

具体的细节会在后面ViewModel和View的交互阶段说。

ViewModel

ViewModel按照官网的说法是一种注重生命周期的方式存储和管理界面相关的数据的类。ViewModel对象的存在范围和在建立ViewModel对象时传递给ViewModelProviderLifeCycle相关,当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当完成ModelViewModel之后,按照设计稿应该开始构建界面之间的关系,使用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中有三点值得注意:

  • FragmentContainerViewFragmentContainerView就是作为Fragment的容器,在name中必须写上实现功能的类的全限定名称android:name="androidx.navigation.fragment.NavHostFragment";同时指定navGraph
  • layout: layoutDatabinding库联系密切,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的图像如图所示 截屏2021-06-29 14.38.13.png

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中说明),还可以绑定views,之前的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>