Google 推荐在项目中使用 sealed 和 RemoteMediator

简介: 这篇文章是对神奇宝贝(PokemonGo) 的部分功能做全面的分析

image.png


之前分享过一篇 Jetpack + MVVM 综合实战应用 神奇宝贝(PokemonGo)  眼前一亮的 Jetpack + MVVM 极简实战  主要包了以下功能:


  1. 自定义 RemoteMediator 实现 network + db 的混合使用 ( RemoteMediator 是 Paging3 当中重要成员 )
  2. 使用 Data Mapper 分离数据源 和 UI
  3. Kotlin Flow 结合  Retrofit2 + Room 的混合使用
  4. Kotlin Flow 与 LiveData 的使用
  5. 使用 Coil 加载图片
  6. 使用 ViewModel、LiveData、DataBinding 协同工作
  7. 使用 Motionlayout 做动画
  8. App Startup 与 Hilt 的使用
  9. 在 Flow 基础上封装成功或者失败处理


这篇文章是对 神奇宝贝(PokemonGo) 的部分功能做全面的分析,主要包含以下内容:


  • 如何在 Flow 基础上封装成功或者失败处理?
  • 如何自定义 RemoteMediator 实现 数据库 和 网络 加载数据?
  • Paging3 当中的 RemoteMediator 和 PagingSource 的区别?
  • Paging3 中的 cachedIn 是什么?它为我们解决了什么问题?


在开始阅读本文之前,建议更新 PokemonGo 最新的代码,对照着代码一起看,为了节省篇幅,文中只会列出核心代码。


如何在 Flow 基础上封装成功或者失败处理



之前有小伙们问过我,如何在 Flow 基础上封装成功或者失败处理逻辑,关于这个问题,其实 Google Android 团队的工程师在 medium 上发表过一篇文章 Sealed with a class 建议我们使用 sealed,在 Paging3 源码里面也大量用到了 sealed。


在分析封装逻辑之前,我们先来看一下 Paging3 源码是如何处理的,在 Paging3 中有个很重要的类 RemoteMediator,在 RemoteMediator 中有个重要的方法 load()


abstract suspend fun load(loadType: LoadType, state: PagingState<Key, Value>): MediatorResult


load() 方法返回值是 MediatorResult,我们来看一下 MediatorResult 源码的实现。


sealed class MediatorResult {
    class Error(val throwable: Throwable) : MediatorResult()
    class Success(
        @get:JvmName("endOfPaginationReached") val endOfPaginationReached: Boolean
    ) : MediatorResult()
}


其实 MediatorResult 是一个密封类,密封类有两个子类分别为 ErrorSuccess 封装了成功和失败处理逻辑。


我们在来看一下另外一个类 LoadState,在 Jetpack 新成员 Paging3 网络实践及原理分析(二)- 监听网路请求状态 文章中也提到 refresh、prepend 和 append 都是 LoadState 的对象,我们来看一下 LoadState 源码实现。


sealed class LoadState( val endOfPaginationReached: Boolean) {
    class NotLoading( endOfPaginationReached: Boolean) :LoadState(endOfPaginationReached) {
        ......
    }
    object Loading : LoadState(false) {
        ......
    }
    class Error(val error: Throwable) : LoadState(false) {
        ......
    }
}


LoadState 是一个密封类,它有三个子类 NotLoadingLoadingError 代表网络请求状态。


变量 作用
Error 表示加载失败
Loading 表示正在加载
NotLoading 表示当前未加载


正如你所见在 Paging3 源码中对于成功和失败处理都用到了 sealed,我们可以仿照 Paging3 源码,使用 sealed 在 Flow 基础上封装成功或者失败处理。


PokemonGo/app/src/main/java/com/hi/dhl/pokemon/data/remote/PokemonResult.kt


sealed class PokemonResult<out T> {
    data class Success<out T>(val value: T) : PokemonResult<T>()
    data class Failure(val throwable: Throwable?) : PokemonResult<Nothing>()
}


PokemonResult 是一个密封类,同样它也有两个子类 SuccessFailure 分别表示成功和失败,我们来看一下如何使用。


PokemonGo/app/src/main/java/com/hi/dhl/pokemon/data/repository/PokemonRepositoryImpl.kt


override suspend fun featchPokemonInfo(name: String): Flow<PokemonResult<PokemonInfoModel>> {
    return flow {
        try {
            emit(PokemonResult.Success(model)) // 成功
        } catch (e: Exception) {
            emit(PokemonResult.Failure(e.cause)) // 失败
        }
    }.flowOn(Dispatchers.IO) // 通过 flowOn 切换到 io 线程
}


  • 如果请求成功返回 PokemonResult.Success(model)
  • 如果出现错误返回 PokemonResult.Failure(e.cause)


这只是一个简单的封装,可以在这个基础上,针对于不同的场景进行二次封装,接下来看一下在 ViewModel 中如何处理。


PokemonGo/app/src/main/java/com/hi/dhl/pokemon/ui/detail/DetailViewModel.kt


when (result) {
    is PokemonResult.Failure -> {
        _failure.value = result.throwable?.message ?: "failure"
    }
    is PokemonResult.Success -> {
        _pokemon.postValue(result.value)
    }
}


使用强大的 when 表达式,针对于成功或者失败进行不同的处理,在 Pokemon 项目中,如果没有网,进入详情页,会弹出一个失败的 toast。


when 表达式虽然强大,但是有一个问题,在一个项目中进行网络请求的地方会有很多,如果每次都要写 when 表达式,就会出现很多重复的代码,那么如何减少这样的模板代码呢,可以利用 Kotlin 提供的强大的扩展函数,代码如下所示:


inline fun <reified T> PokemonResult<T>.doSuccess(success: (T) -> Unit) {
    if (this is PokemonResult.Success) {
        success(value)
    }
}
inline fun <reified T> PokemonResult<T>.doFailure(failure: (Throwable?) -> Unit) {
    if (this is PokemonResult.Failure) {
        failure(throwable)
    }
}


使用扩展函数进一步封装的目的是减少模板代码,我们重新修改一下之前使用 when 表达式的地方。


result.doFailure { throwable ->
    _failure.value = throwable?.message ?: "failure"
}
result.doSuccess { value ->
    _pokemon.postValue(value)
    emit(value)
}


如果在其他地方也需要进行成功 或者 失败处理,只需要调用对应的扩展函数即可,到这里关于如何在 Flow 基础上封装成功或者失败处理就分析完了。


接下来我们一起来分析一下今天的主角 如何自定义 RemoteMediator 实现 数据库 和 网络 加载数据,建议在了解这部分内容之前,先看一下之前的两篇文章,因为它们都是关联在一起的。



RemoteMediator 主要用来实现加载网络分页数据并更新到数据库中,在开始分析之前,我们先来了解一下基本概念。


Paging3 类的职能


  • PagingData :用于分页数据的容器,每次数据刷新都有一个单独的对应 PagingData
  • Pager :是 Paging3 的主要的入口,在其构造方法中接受 PagingConfiginitialKeyremoteMediatorpagingSourceFactory
  • Pager.flow :将会构建一个 Flow<PagingData>,在 PagingConfig 构造方法中定义了 pageSize、prefetchDistance、initialLoadSize 等等
  • PagingDataAdapter :是一个处理分页数据的可回收视图适配器,可以使用 AsyncPagingDataDiffer 组件来构建自己的自定义适配器
  • PagingSource :每个 PagingSource 对象定义一个数据源以及如何从该数据源查找数据
  • RemoteMediatorRemoteMediator 实现加载网络分页数据并更新到数据库中


到这里小伙伴们应该会有一个疑惑 RemoteMediator 和 PagingSource 都是用来加载数据源的数据,那么它们有什么区别?


RemoteMediator 和 PagingSource 的区别



  • RemoteMediator:实现加载网络分页数据并更新到数据库中,但是数据源的变动不能直接映射到 UI 上
  • PagingSource:实现单一数据源以及如何从该数据源中查找数据,例如 Room,数据源的变动会直接映射到 UI 上


image.png


上图来自 Google 官网,正如你所见,使用 RemoteMediator 实现从网络加载分页数据更新到数据库中,使用 PagingSource 从数据库中查找数据并显示在 UI 上。


在项目中如何进行选择?


  • PagingSource:用于加载有限的数据集(本地数据库)例如手机通讯录等等 ,可以参考 Jetpack 成员 Paging3 数据库的实践以及源码分析(一) 这篇文章的实现
  • RemoteMediator:主要用来加载网络分页数据并更新到数据库中,当我们没有更多的数据时,我们向网络请求更多的数据,结合 PagingSource 当保存更多数据时会直接映射到 UI 上


注意:


  1. RemoteMediator 目前是实验性的 API ,所有实现 RemoteMediator 的类都需要添加 @OptIn(ExperimentalPagingApi::class) 注解。
  2. 当我们使用 OptIn 注解,需要在 App 模块下的 build.gradle 文件内添加以下代码


android {
    kotlinOptions {
        freeCompilerArgs += ["-Xopt-in=kotlin.RequiresOptIn"]
    }
}


当我们了解完基本概念之后,接下来一起来分析一下如何实现 RemoteMediator在这里建议更新 PokemonGo 最新代码,对照着项目中的代码一起看,为了节省篇幅文章中只会列出核心代码。


三步实现 RemoteMediator



image.png


如上面图片所示在 Repository 中通过 RemoteMediator 获取网络分页数据并更新到数据库中,PagingSource 当保存更多数据时会直接映射到 UI 上。


其实实现一个 RemoteMediator 贯穿了数据源、Repository、ViewModel,接下来我们来分析一下如何在每层中,分三步实现一个 RemoteMediator。


1. 定义数据源


使用 Room 作为本地的数据源,将网络分页数据存储在本地数据库中。


PokemonGo/app/src/main/java/com/hi/dhl/pokemon/data/local/PokemonDao.kt


@Dao
interface PokemonDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertPokemon(pokemonList: List<PokemonEntity>)
    @Query("SELECT * FROM PokemonEntity")
    fun getPokemon(): PagingSource<Int, PokemonEntity>
}


  • 在 Paging3 中使用的是 Flow,所以 insertPokemon 方法前需要添加 suspend 修饰符。
  • 需要注意的是 getPokemon() 方法返回了一个 PagingSource<Key, Value>,意味着数据源更新时会映射到 UI 上,其中 Key 和 Value 和实现 RemoteMediator 有很大关系,后面会提到。


2. 在 Repository 中实现 RemoteMediator


RemoteMediator 和 PagingSource 相似,都需要覆盖 load() 方法,但是不同的是 RemoteMediator 不是加载分页数据到 RecyclerView 列表上,而是获取网络分页数据并更新到数据库中。


PokemonGo/app/src/main/java/com/hi/dhl/pokemon/data/repository/PokemonRemoteMediator.kt


注意:


刚才我们在数据源中定义 getPokemon() 方法,其返回值是 PagingSource<Int, PokemonEntity>,那我们在实现 RemoteMediator<Key, Value> 的时候,其中 Key 和 Value,应该和 PagingSource<Int, PokemonEntity> Key 和 Value 相同,代码如下所示。


@OptIn(ExperimentalPagingApi::class)
class PokemonRemoteMediator(
    val api: PokemonService,
    val db: AppDataBase
) : RemoteMediator<Int, PokemonEntity>() {
    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, PokemonEntity>
    ): MediatorResult {
       /**
         * 在这个方法内将会做三件事
         *
         * 1. 参数 LoadType 有个三个值,关于这三个值如何进行判断
         *      LoadType.REFRESH
         *      LoadType.PREPEND
         *      LoadType.APPEND
         *
         * 2. 请问网络数据
         *
         * 3. 将网络数据插入到本地数据库中
         */
    }
}


load() 方法有两个重要的参数,它们的意思如下所示:


  • PagingState:这个类当中有两个重要的变量
  • pages: List<Page<Key, Value>> 返回的上一页的数据,主要用来获取上一页最后一条数据作为下一页的开始位置
  • config: PagingConfig 返回的初始化设置的 PagingConfig 包含了 pageSize、prefetchDistance、initialLoadSize 等等
  • LoadType 是一个枚举类,里面定义了三个值,如下所示


类名 作用
LoadType.Refresh 在初始化刷新的使用
LoadType.Append 在加载更多的时候使用
LoadType.Prepend 在当前列表头部添加数据的时候使用


load() 的返回值 MediatorResult,MediatorResult 是一个密封类,根据不同的结果返

回不同的值


  • 请求出现错误,返回 MediatorResult.Error(e)
  • 请求成功且有数据,返回 MediatorResult.Success(endOfPaginationReached = true)
  • 请求成功但是没有数据,返回 MediatorResult.Success(endOfPaginationReached = false)
  • 参数 endOfPaginationReached 表示是否还有更多数据


load() 方法里面将会做三件事 1. 如何判断参数 LoadType2. 请问网络数据3. 将网络数据插入到本地数据库中


1. 如何判断参数 LoadType


val pageKey = when (loadType) {
   // 首次访问 或者调用 PagingDataAdapter.refresh()
   LoadType.REFRESH -> null
   // 在当前加载的数据集的开头加载数据时
   LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
   LoadType.APPEND -> { // 下来加载更多时触发
       /**
        * 方式一:这种方式比较简单,当前页面最后一条数据是下一页的开始位置
        * 通过 load 方法的参数 state 获取当页面最后一条数据
        */
//       val lastItem = state.lastItemOrNull()
//       if (lastItem == null) {
//           return MediatorResult.Success(
//             endOfPaginationReached = true
//          )
//         }
//        lastItem.page
       /**
        * 方式二:比较麻烦,当前分页数据没有对应的远程 key,这个时候需要我们自己建表
        */
       val remoteKey = db.withTransaction {
           db.remoteKeysDao().getRemoteKeys(remotePokemon)
       }
       if (remoteKey == null || remoteKey.nextKey == null) {
           return MediatorResult.Success(endOfPaginationReached = true)
       }
       remoteKey.nextKey
   }
}


  • LoadType.REFRESH首次访问 或者调用 PagingDataAdapter.refresh() 触发,加载第一页数据,这里不需要做任何操作,返回 null 就可以。
  • LoadType.PREPEND:在当前列表头部添加数据的时候时触发,需要注意的是当 LoadType.REFRESH 触发了,LoadType.PREPEND 也会触发,所以为了避免重复请求,直接返回  MediatorResult.Success(endOfPaginationReached = true) 即可
  • LoadType.APPEND:下拉加载更多时触发,这里获取下一页的 key,如果 key 不存在,直接返回 MediatorResult.Success(endOfPaginationReached = true) 不会在进行请求


2. 请问网络数据


val page = pageKey ?: 0
val result = api.fetchPokemonList(
    state.config.pageSize,
    page * state.config.pageSize
).results


这里不需要调用 withContext(Dispatcher.IO) { ... } 因为 Retrofit 的协程是发生在 worker thread 中的


3. 将网络分页数据并更新到数据库中


remoteKeysDao.insertAll(entity)
pokemonDao.insertPokemon(item) 


所有实现 RemoteMediator 的类都需要重写 load() 方法,在 load()  方法内按照如上三步实现即可,具体逻辑需要根据需求而定。


PokemonRemoteMediator 完整代码太长了,这里就不贴了,可以点击 PokemonRemoteMediator 前去查看。


3. 在 Repository 中构建 Pager


Pager 是 Paging3 的主要的入口,是从数据源获取数据的入口,其构造方法接受 pagingConfig 、initialKey 、remoteMediator 、pagingSourceFactory,其中 initialKey、remoteMediator 是可选的,pageConfig 和 pagingSourceFactory 必填的,代码如下所示。


PokemonGo/app/src/main/java/com/hi/dhl/pokemon/data/repository/PokemonRepositoryImpl.kt


Pager(
    config = pageConfig,
    remoteMediator = PokemonRemoteMediator(api, db)
) {
    db.pokemonDao().getPokemon()
}.flow.map { pagingData ->
    pagingData.map { mapper2ItemMolde.map(it) }
}


  • config :初始化 Pager 参数 pageSize、prefetchDistance、initialLoadSize 等等
  • remoteMediator :提供 RemoteMediator 的实现类,这里是 PokemonRemoteMediator
  • pagingSourceFactory :是一个 lambda 表达式,在 Kotlin 中可以直接用花括号表示,在花括号内执行加载分页数据,这里直接调用 db.pokemonDao().getPokemon()
  • 调用 getPokemon() 方法返回的是一个 PagingSource,在 PokemonRemoteMediator 中获取网络分页数据,更新数据库的时候,这里返回的是你请求的网络分页数据


到这里关于 如何自定义 RemoteMediator 实现 数据库 和 网络 加载数据 就分析完了,接下来就是在 ViewModel 中调用 Repository 获取数据。


4. 在 ViewModel 获取数据


在 ViewModel 中调用 Repository 请求数据,通过构建 Pager 加载网络分页数据并更新到数据库中,当数据库更新时,会映射到 UI 上。


PokemonGo/app/src/main/java/com/hi/dhl/pokemon/ui/main/MainViewModel.kt


fun postOfData(): LiveData<PagingData<PokemonItemModel>> =
    polemonRepository.featchPokemonList().cachedIn(viewModelScope).asLiveData()


正如你所见在 ViewModel 中就两行代码,结合着 DataBinding 一起使用,在 Activity 或者 Fragment 只需要不到 20 行代码甚至更少。


注意: 在 ViewModel 中的 postOfData 方法中调用了 cachedIn() 方法


Paging3 中的 cachedIn 是什么?它为我们解决了什么问题?


cachedIn()Flow<PagingData> 的扩展方法,主要用来缓存 Flow<PagingData> 返回的内容,当我们在使用 Flow 进行 map 或者 filter 操作后调用 cachedIn() 是为了确保不需要再次触发它们,我们来看一下 cachedIn() 方法的源码。


fun <T : Any> Flow<PagingData<T>>.cachedIn(
    scope: CoroutineScope
) 


正如你所见 cachedIn()Flow<PagingData> 的扩展方法,cachedIn() 方法接受一个 CoroutineScope,CoroutineScope 表示协程的作用域,在 ViewModel 中对应的是 androidx.lifecycle.viewModelScope.,也就意味在作用域内防止不需要再次触发它们,在屏幕旋转的时候也可以复用。


全文到这里就结束了,在这里强烈建议至少体验一次,结合 Kotlin Flow + DataBinding + Jetpack + MVVM


神奇宝贝 (PokemonGo) 基于 Jetpack + MVVM + Repository + Data Mapper + Kotlin Flow 的实战项目,我也正在为 PokemonGo 项目设计更多的场景,也会加入更多的 Jetpack 成员,在 PokemonGo 项目首页增加了更新记录,可以点击下方链接前往查看 PokemonGo 项目的更新记录。


PokemonGo GitHub 地址:https://github.com/hi-dhl/PokemonGo


image.png


结语



公众号开通了:ByteCode , 欢迎小伙伴们前去查看 Android 10 系列源码,Jetpack ,Kotlin ,译文,LeetCode / 剑指 Offer / 国内外大厂算法题 等等一系列文章,如果对你有帮助,请帮我点个 star,感谢!!!,欢迎一起来学习,在技术的道路上一起前进。


正在建立一个最全、最新的 AndroidX Jetpack 相关组件的实战项目 以及 相关组件原理分析文章,目前已经包含了 App Startup、Paging3、Hilt 等等,正在逐渐增加其他 Jetpack 新成员,仓库持续更新,可以前去查看:AndroidX-Jetpack-Practice, 如果这个仓库对你有帮助,请仓库右上角帮我点个赞。


算法


由于 LeetCode 的题库庞大,每个分类都能筛选出数百道题,由于每个人的精力有限,不可能刷完所有题目,因此我按照经典类型题目去分类、和题目的难易程度去排序。


  • 数据结构: 数组、栈、队列、字符串、链表、树……
  • 算法: 查找算法、搜索算法、位运算、排序、数学、……


每道题目都会用 Java 和 kotlin 去实现,并且每道题目都有解题思路、时间复杂度和空间复杂度,如果你同我一样喜欢算法、LeetCode,可以关注我 GitHub 上的 LeetCode 题解:Leetcode-Solutions-with-Java-And-Kotlin,一起来学习,期待与你一起成长。


Android 10 源码系列


正在写一系列的 Android 10 源码分析的文章,了解系统源码,不仅有助于分析问题,在面试过程中,对我们也是非常有帮助的,如果你同我一样喜欢研究 Android 源码,可以关注我 GitHub 上的 Android10-Source-Analysis,文章都会同步到这个仓库。



Android 应用系列



精选译文


目前正在整理和翻译一系列精选国外的技术文章,不仅仅是翻译,很多优秀的英文技术文章提供了很好思路和方法,每篇文章都会有译者思考部分,对原文的更加深入的解读,可以关注我 GitHub 上的 Technical-Article-Translation,文章都会同步到这个仓库。



工具系列




目录
相关文章
|
6月前
|
编解码 人工智能 数据库
Google Earth Engine(GEE)——全球道路盘查项目全球道路数据库
Google Earth Engine(GEE)——全球道路盘查项目全球道路数据库
143 0
|
机器学习/深度学习 存储 人工智能
Google Earth Engine(GEE)——TensorFlow支持深度学习等高级机器学习方法(非免费项目)
Google Earth Engine(GEE)——TensorFlow支持深度学习等高级机器学习方法(非免费项目)
1366 0
|
6月前
|
人工智能 自然语言处理 负载均衡
这款 AI 网关项目简直太棒了,轻松接入OpenAI、LLama2、Google Gem)ini等 100 多种大语言模型!
这款 AI 网关项目简直太棒了,轻松接入OpenAI、LLama2、Google Gem)ini等 100 多种大语言模型!
239 1
|
移动开发 前端开发 小程序
为了偷懒,我用google/zx一键自动打包编译了前后端项目并发布到指定环境
由于正在负责的一个项目,就说前端涉及到PC端、公众号端、APP端的H5、小程序端、可视化大屏端,而PC和APP又通过qiankun引入了微前端的理念。整体一圈下来可能光前端编译打包就要build差不多二十次。而有时候经常性的bug改动,这个时候便只需要进行测试后需要进行小范围的测试。
213 0
|
算法 Android开发 开发者
被 Google Play “判定”违反政策:开源软件 FairEmail 开发者一怒之下关停所有项目
被 Google Play “判定”违反政策:开源软件 FairEmail 开发者一怒之下关停所有项目
234 0
被 Google Play “判定”违反政策:开源软件 FairEmail 开发者一怒之下关停所有项目
|
编解码
Google Earth Engine——真彩色数据集Planet labs Inc. SkySat卫星在2015年为实验性的 “Skybox for Good Beta “项目,以及各种危机应对事件
Google Earth Engine——真彩色数据集Planet labs Inc. SkySat卫星在2015年为实验性的 “Skybox for Good Beta “项目,以及各种危机应对事件
388 0
Google Earth Engine——真彩色数据集Planet labs Inc. SkySat卫星在2015年为实验性的 “Skybox for Good Beta “项目,以及各种危机应对事件
|
大数据 atlas
Google Earth Engine——流苏帽亮度(TCB)数据集该数据集由Malaria Atlas项目的Harry Gibson和Daniel Weiss制作
Google Earth Engine——流苏帽亮度(TCB)数据集该数据集由Malaria Atlas项目的Harry Gibson和Daniel Weiss制作
124 0
Google Earth Engine——流苏帽亮度(TCB)数据集该数据集由Malaria Atlas项目的Harry Gibson和Daniel Weiss制作
|
编解码 知识图谱
Google Earth Engine——NCEP/NCAR再分析项目是美国国家环境预测中心(NCEP,前身为 “NMC“)和美国国家大气研究中心(NCAR)地表降水数据
Google Earth Engine——NCEP/NCAR再分析项目是美国国家环境预测中心(NCEP,前身为 “NMC“)和美国国家大气研究中心(NCAR)地表降水数据
275 0
Google Earth Engine——NCEP/NCAR再分析项目是美国国家环境预测中心(NCEP,前身为 “NMC“)和美国国家大气研究中心(NCAR)地表降水数据
|
编解码
Google Earth Engine——NCEP/NCAR再分析项目是美国国家环境预测中心(NCEP,前身为 “NMC“)和美国国家大气研究中心(NCAR)全球气温数据集
Google Earth Engine——NCEP/NCAR再分析项目是美国国家环境预测中心(NCEP,前身为 “NMC“)和美国国家大气研究中心(NCAR)全球气温数据集
661 0
Google Earth Engine——NCEP/NCAR再分析项目是美国国家环境预测中心(NCEP,前身为 “NMC“)和美国国家大气研究中心(NCAR)全球气温数据集
|
编解码 数据挖掘
Google Earth Engine ——全球人类住区层(GHSL)项目由欧盟委员会人口的分布和密度250米分辨率数据集
Google Earth Engine ——全球人类住区层(GHSL)项目由欧盟委员会人口的分布和密度250米分辨率数据集
394 0
Google Earth Engine ——全球人类住区层(GHSL)项目由欧盟委员会人口的分布和密度250米分辨率数据集

热门文章

最新文章