这篇指南源自于Google开发文档,有兴趣的朋友去官网进行查看。我们不生产代码,我们只是代码的搬运工!
本指南包含一些最佳做法和推荐架构,有助于构建强大而优质的应用。
移动应用用户体验
在大多数情况下,桌面应用会在桌面或程序启动器中有一个入口点,且作为一个单体式进程运行。Android 应用则不然,它们的结构要复杂得多。典型的 Android 应用包含多个应用组件,包括 Activity
、Fragment
、Service
、ContentProvider
和广播接收器
。
您需要在应用清单
中声明其中的大多数应用组件。Android 操作系统随后会使用此文件来决定如何将您的应用集成到设备的整体用户体验中。鉴于正确编写的 Android 应用包含多个组件,并且用户经常会在短时间内与多个应用进行互动,因此应用需要适应不同类型的用户驱动型工作流和任务。
例如,思考一下当您在自己喜欢的社交网络应用中分享照片时会发生什么:
- 该应用将触发相机 intent。Android 操作系统随后会启动相机应用来处理请求。此时,用户已离开社交网络应用,但他们的体验仍然是无缝的。
- 相机应用可能会触发其他 intent(如启动文件选择器),而这可能会再启动一个应用。
- 最后,用户返回社交网络应用并分享照片。
在此过程中,用户随时可能会被电话或通知打断。处理之后,用户希望能够返回并继续分享照片。这种应用跳跃行为在移动设备上很常见,因此您的应用必须正确处理这些流程。
请注意,移动设备的资源也很有限,因此操作系统可能会随时终止某些应用进程,以便为新的进程腾出空间。
鉴于这种环境条件,您的应用组件可以不按顺序地单独启动,并且操作系统或用户可以随时销毁它们。由于这些事件不受您的控制,因此您不应在应用组件中存储任何应用数据或状态,并且应用组件不应相互依赖。
常见的架构原则
如果您不应使用应用组件存储应用数据和状态,那么您应该如何设计应用呢?
分离关注点
要遵循的最重要的原则是分离关注点。一种常见的错误是在一个 Activity
或 Fragment
中编写所有代码。这些基于界面的类应仅包含处理界面和操作系统交互的逻辑。您应使这些类尽可能保持精简,这样可以避免许多与生命周期相关的问题。
请注意,您并非拥有 Activity 和 Fragment 的实现;它们只是表示 Android 操作系统与应用之间关系的粘合类。操作系统可能会根据用户互动或因内存不足等系统条件随时销毁它们。为了提供令人满意的用户体验和更易于管理的应用维护体验,您最好尽量减少对它们的依赖。
通过模型驱动界面
另一个重要原则是您应该通过模型驱动界面(最好是持久性模型)。模型是负责处理应用数据的组件。它们独立于应用中的 View
对象和应用组件,因此不受应用的生命周期以及相关的关注点的影响。
持久性是理想之选,原因如下:
- 如果 Android 操作系统销毁应用以释放资源,用户不会丢失数据。
- 当网络连接不稳定或不可用时,应用会继续工作。
应用所基于的模型类应明确定义数据管理职责,这样将使应用更可测试且更一致。
推荐应用架构
在本部分,我们将通过一个端到端用例演示如何使用架构组件构建应用。
注意:任何应用编写方式都不可能是每种情况的最佳选择。话虽如此,但推荐的这个架构是个不错的起点,适合大多数情况和工作流。如果您已经有编写 Android 应用的好方法(遵循常见的架构原则),则无需更改。
假设我们要构建一个用于显示用户个人资料的界面。我们使用私有后端和 REST API 获取给定个人资料的数据。
概览
首先,请查看下图,该图显示了设计应用后所有模块应如何彼此交互:
请注意,每个组件仅依赖于其下一级的组件。例如,Activity 和 Fragment 仅依赖于视图模型。存储区是唯一依赖于其他多个类的类;在本例中,存储区依赖于持久性数据模型和远程后端数据源。
这种设计打造了一致且愉快的用户体验。无论用户上次使用应用是在几分钟前还是几天之前,现在回到应用时都会立即看到应用在本地保留的用户信息。如果此数据已过时,则应用的存储区模块将开始在后台更新数据。
构建界面
界面由 Fragment UserProfileFragment 及其对应的布局文件 user_profile_layout.xml 组成。
如需驱动该界面,数据模型需要存储以下数据元素:
- 用户 ID:用户的标识符。最好使用 Fragment 参数将此类信息传递到相应 Fragment 中。如果 Android 操作系统销毁我们的进程,此类信息将保留,以便下次重启应用时 ID 可用。
- 用户对象:用于存储用户详细信息的数据类。
我们将使用 UserProfileViewModel(基于 ViewModel 架构组件)存储这些信息。
ViewModel
对象为特定的界面组件(如 Fragment 或 Activity)提供数据,并包含数据处理业务逻辑,以与模型进行通信。例如,ViewModel
可以调用其他组件来加载数据,还可以转发用户请求来修改数据。ViewModel
不了解界面组件,因此不受配置更改(如在旋转设备时重新创建 Activity)的影响。
我们现在定义了以下文件:
- user_profile.xml:屏幕的界面布局定义。
- UserProfileFragment:显示数据的界面控制器。
- UserProfileViewModel:准备数据以便在 UserProfileFragment 中查看并对用户互动做出响应的类。
以下代码段显示了这些文件的起始内容(为简单起见,省略了布局文件)。
UserProfileViewModel
class UserProfileViewModel : ViewModel() {
val userId : String = TODO()
val user : User = TODO()
}
UserProfileFragment
class UserProfileFragment : Fragment() {
// To use the viewModels() extension function, include
// "androidx.fragment:fragment-ktx:latest-version " in your app
// module's build.gradle file.
private val viewModel: UserProfileViewModel by viewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,savedInstanceState: Bundle?): View {
return inflater.inflate(R.layout.main_fragment, container, false)
}
}
现在,我们有了这些代码模块,如何将它们连接起来?毕竟,在 UserProfileViewModel 类中设置 user 字段时,我们需要一种方法来通知界面。
要获取 user
,我们的 ViewModel
需要访问 Fragment 参数。我们可以通过 Fragment 传递它们,或者更好的办法是使用 SavedState 模块,我们可以让 ViewModel 直接读取参数:
注意:SavedStateHandle 允许 ViewModel 访问相关 Fragment 或 Activity 的已保存状态和参数。
// UserProfileViewModel
class UserProfileViewModel(
savedStateHandle: SavedStateHandle
) : ViewModel() {
val userId : String = savedStateHandle["uid"] ?:
throw IllegalArgumentException("missing user id")
val user : User = TODO()
}
// UserProfileFragment
private val viewModel: UserProfileViewModel by viewModels(
factoryProducer = { SavedStateVMFactory(this) }
...
)
获取用户对象后,我们需要通知 Fragment。这就是 LiveData 架构组件的用武之地。
LiveData 是一种可观察的数据存储器。应用中的其他组件可以使用此存储器监控对象的更改,而无需在它们之间创建明确且严格的依赖路径。LiveData 组件还遵循应用组件(如 Activity、Fragment 和 Service)的生命周期状态,并包括清理逻辑以防止对象泄漏和过多的内存消耗。
为了将 LiveData 组件纳入应用,我们将 UserProfileViewModel
中的字段类型更改为 LiveData<User>
。现在,更新数据时,系统会通知 UserProfileFragment
。
此外,由于此 LiveData
字段具有生命周期感知能力,因此当不再需要引用时,会自动清理它们。
UserProfileViewModel
class UserProfileViewModel(
savedStateHandle: SavedStateHandle
) : ViewModel() {
val userId : String = savedStateHandle["uid"] ?:
throw IllegalArgumentException("missing user id")
val user : LiveData<User> = TODO()
}
现在,我们修改 UserProfileFragment 以观察数据并更新界面:
UserProfileFragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.user.observe(viewLifecycleOwner) {
// update UI
}
}
每次更新用户个人资料数据时,系统都会调用 onChanged()
回调并刷新界面。
如果您熟悉使用可观察回调的其他库,那么您可能已经意识到,我们并未替换 Fragment 的 onStop()
方法以停止观察数据。使用 LiveData 时没必要执行此步骤,因为它具有生命周期感知能力。这意味着,除非 Fragment 处于活跃状态(即,已接收 onStart()
但尚未接收 onStop()
,否则它不会调用 onChanged()
回调。当调用 Fragment 的 onDestroy()
方法时,LiveData 还会自动移除观察者。
此外,我们也没有添加任何逻辑来处理配置更改(例如,用户旋转设备的屏幕)。UserProfileViewModel
会在配置更改后自动恢复,所以一旦创建新的 Fragment,它就会接收相同的 ViewModel
实例,并且会立即使用当前的数据调用回调。鉴于 ViewModel
对象应该比它们更新的相应 View
对象存在的时间更长,因此 ViewModel
实现中不得包含对 View
对象的直接引用。
获取数据
现在,我们已使用 LiveData 将 UserProfileViewModel 连接到 UserProfileFragment,那么如何获取用户个人资料数据?
在本例中,我们假定后端提供 REST API。我们使用 Retrofit 库访问后端,不过您可以随意使用起着相同作用的其他库。
下面是与后端进行通信的 Webservice 的定义:
Webservice
interface Webservice {
/**
* @GET declares an HTTP GET request
* @Path("user") annotation on the userId parameter marks it as a
* replacement for the {user} placeholder in the @GET path
*/
@GET("/users/{user}")
fun getUser(@Path("user") userId: String): Call<User>
}
实现 ViewModel
的第一个想法可能是直接调用 Webservice
来获取数据,然后将该数据分配给 LiveData
对象。这种设计行得通,但如果采用这种设计,随着应用的扩大,应用会变得越来越难以维护。这样会使 UserProfileViewModel
类承担太多的责任,这就违背了分离关注点原则。此外,ViewModel
的时间范围与 Activity
或 Fragment
生命周期相关联,这意味着,当关联界面对象的生命周期结束时,会丢失 Webservice
的数据,进而影响用户体验。
ViewModel 会将数据获取过程委派给一个新的模块,即存储区。
存储区模块会处理数据操作。它们会提供一个干净的 API,以便应用的其余部分可以轻松检索该数据。数据更新时,它们知道从何处获取数据以及进行哪些 API 调用。您可以将存储区视为不同数据源(如持久性模型、网络服务和缓存)之间的媒介。
UserRepository 类(如以下代码段中所示)使用 WebService 实例来获取用户的数据:
UserRepository
class UserRepository {
private val webservice: Webservice = TODO()
// ...
fun getUser(userId: String): LiveData<User> {
// This isn't an optimal implementation. We'll fix it later.
val data = MutableLiveData<User>()
webservice.getUser(userId).enqueue(object : Callback<User> {
override fun onResponse(call: Call<User>, response: Response<User>) {
data.value = response.body()
}
// Error case is left out for brevity.
override fun onFailure(call: Call<User>, t: Throwable) {
TODO()
}
})
return data
}
}
虽然存储区模块看起来不必要,但它起着一项重要的作用:它会从应用的其余部分中提取数据源。现在,UserProfileViewModel 不知道如何获取数据,因此我们可以为视图模型提供从几个不同的数据获取实现获得的数据。
注意:为简单起见,我们省去了网络错误情况。有关公开错误和加载状态的替代实现,后面会介绍如何处理错误等状态
连接 ViewModel 与存储区
现在,我们修改 UserProfileViewModel 以使用 UserRepository 对象:
UserProfileViewModel
class UserProfileViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
userRepository: UserRepository
) : ViewModel() {
val userId : String = savedStateHandle["uid"] ?:
throw IllegalArgumentException("missing user id")
val user : LiveData<User> = userRepository.getUser(userId)
}
缓存数据
UserRepository 实现会抽象化对 Webservice 对象的调用,但由于它只依赖于一个数据源,因此不是很灵活。
UserRepository 实现的关键问题是,它从后端获取数据后,不会将该数据存储在任何位置。因此,如果用户在离开 UserProfileFragment 后再返回该类,则应用必须重新获取数据,即使数据未发生更改也是如此。
这种设计不够理想,原因如下:
- 浪费了宝贵的网络带宽。
- 迫使用户等待新的查询完成。
为了弥补这些缺点,我们向 UserRepository 添加了一个新的数据源,用于将 User 对象缓存在内存中:
UserRepository
// Informs Dagger that this class should be constructed only once.
@Singleton
class UserRepository @Inject constructor(
private val webservice: Webservice,
// Simple in-memory cache. Details omitted for brevity.
private val userCache: UserCache
) {
fun getUser(userId: String): LiveData<User> {
val cached : LiveData<User> = userCache.get(userId)
if (cached != null) {
return cached
}
val data = MutableLiveData<User>()
// The LiveData object is currently empty, but it's okay to add it to the
// cache here because it will pick up the correct data once the query
// completes.
userCache.put(userId, data)
// This implementation is still suboptimal but better than before.
// A complete implementation also handles error cases.
webservice.getUser(userId).enqueue(object : Callback<User> {
override fun onResponse(call: Call<User>, response: Response<User>) {
data.value = response.body()
}
// Error case is left out for brevity.
override fun onFailure(call: Call<User>, t: Throwable) {
TODO()
}
})
return data
}
}
保留数据
使用我们当前的实现时,如果用户旋转设备或离开应用后立即返回应用,则现有界面将立即变为可见,因为存储区将从内存中的缓存检索数据。
不过,如果用户离开应用,数小时后当 Android 操作系统已终止进程后再回来,会发生什么?在这种情况下,如果依赖我们当前的实现,则需要再次从网络中获取数据。这一重新获取的过程不仅是一种糟糕的用户体验,而且很浪费资源,因为它会消耗宝贵的移动数据。
您可以通过缓存网络请求来解决此问题,但这样做会带来一个值得关注的新问题:如果相同的用户数据因另一种类型的请求(如获取好友列表)而显示出来,会发生什么?应用将会显示不一致的数据,这样比较容易让用户感到困惑。例如,如果用户在不同的时间发出好友列表请求和单一用户请求,应用可能会显示同一用户的数据的两个不同版本。应用将需要弄清楚如何合并这些不一致的数据。
处理这种情况的正确方法是使用持久性模型。这就是Room
持久性库的用武之地。
Room
是一个对象映射库,可利用最少的样板代码实现本地数据持久性。在编译时,它会根据数据架构验证每个查询,这样损坏的 SQL 查询会导致编译时错误而不是运行时失败。Room 可以抽象化处理原始 SQL 表格和查询的一些底层实现细节。它还允许您观察对数据库数据(包括集合和连接查询)的更改,并使用 LiveData 对象公开这类更改。它甚至明确定义了解决一些常见线程问题(如访问主线程上的存储空间)的执行约束。
注意:如果您的应用已使用 SQLite 对象关系映射 (ORM) 等其他持久性解决方案,那么您无需将现有解决方案替换为 Room。不过,如果您正在编写新应用或重构现有应用,那么我们建议您使用 Room 保留应用数据。这样一来,您便可以利用该库的抽象和查询验证功能。
要使用 Room,我们需要定义本地架构。首先,我们向 User
数据模型类添加 @Entity
注释,并向该类的 id
字段添加 @PrimaryKey
注释。这些注释会将 User
标记为数据库中的表格,并将 id
标记为该表格的主键:
User
@Entity
data class User(
@PrimaryKey private val id: String,
private val name: String,
private val lastName: String
)
然后,我们通过为应用实现 RoomDatabase
来创建一个数据库类:
UserDatabase
@Database(entities = [User::class], version = 1)
abstract class UserDatabase : RoomDatabase()
请注意,UserDatabase 是抽象类。Room 将自动提供它的实现。
现在,我们需要一种将用户数据插入数据库的方法。为了完成此任务,我们创建一个数据访问对象 (DAO)
。
UserDao
@Dao
interface UserDao {
@Insert(onConflict = REPLACE)
fun save(user: User)
@Query("SELECT * FROM user WHERE id = :userId")
fun load(userId: String): LiveData<User>
}
请注意,load 方法将返回一个 LiveData<User> 类型的对象。Room 知道何时修改了数据库,并会自动在数据发生更改时通知所有活跃的观察者。由于 Room 使用 LiveData,因此此操作很高效;仅当至少有一个活跃的观察者时,它才会更新数据。
定义 UserDao 类后,从我们的数据库类引用该 DAO:
UserDatabase
@Database(entities = [User::class], version = 1)
abstract class UserDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
现在,我们可以修改 UserRepository 以纳入 Room 数据源:
// Informs Dagger that this class should be constructed only once.
@Singleton
class UserRepository @Inject constructor(
private val webservice: Webservice,
// Simple in-memory cache. Details omitted for brevity.
private val executor: Executor,
private val userDao: UserDao
) {
fun getUser(userId: String): LiveData<User> {
refreshUser(userId)
// Returns a LiveData object directly from the database.
return userDao.load(userId)
}
private fun refreshUser(userId: String) {
// Runs in a background thread.
executor.execute {
// Check if user data was fetched recently.
val userExists = userDao.hasUser(FRESH_TIMEOUT)
if (!userExists) {
// Refreshes the data.
val response = webservice.getUser(userId).execute()
// Check for errors here.
// Updates the database. The LiveData object automatically
// refreshes, so we don't need to do anything else here.
userDao.save(response.body()!!)
}
}
}
companion object {
val FRESH_TIMEOUT = TimeUnit.DAYS.toMillis(1)
}
}
请注意,虽然我们在 UserRepository 中更改了数据的来源,但不需要更改 UserProfileViewModel 或 UserProfileFragment。这种小范围的更新展示了我们的应用架构所具有的灵活性。这也很适合测试,因为我们可以提供虚假的 UserRepository,与此同时测试正式版 UserProfileViewModel。
如果用户等待几天后再返回使用此架构的应用,他们很可能会看到过时的信息,直到存储区可以获取更新的信息。根据您的用例,您可能不希望显示这些过时的信息。您可以改为显示占位符数据,此类数据将显示示例值并指示您的应用当前正在获取并加载最新信息。
显示正在执行的操作
在某些用例(如下拉刷新)中,界面务必要向用户显示当前正在执行某项网络操作。将界面操作与实际数据分离开来是一种很好的做法,因为数据可能会因各种原因而更新。例如,如果我们获取了好友列表,可能会程序化地再次获取相同的用户,从而触发 LiveData<User>
更新。从界面的角度来看,传输中的请求只是另一个数据点,类似于 User
对象本身中的其他任何数据。
我们可以使用以下某个策略,在界面中显示一致的数据更新状态(无论更新数据的请求来自何处):
- 更改
getUser()
以返回一个LiveData
类型的对象。此对象将包含网络操作的状态。
有关示例,请参阅 Android 架构组件 GitHub 项目中的NetworkBoundResource
实现。 - 在
UserRepository
类中再提供一个可以返回User
刷新状态的公共函数。如果您只想在数据获取过程源自于显式用户操作(如下拉刷新)时在界面中显示网络状态,使用此策略效果会更好。
最佳做法
编程是一个创造性的领域,构建 Android 应用也不例外。无论是在多个 Activity 或 Fragment 之间传递数据,检索远程数据并将其保留在本地以在离线模式下使用,还是复杂应用遇到的任何其他常见情况,解决问题的方法都会有很多种。
虽然以下建议不是强制性的,但根据我们的经验,从长远来看,遵循这些建议会使您的代码库更强大、可测试性更高且更易维护:
- 避免将应用的入口点(如 Activity、Service 和广播接收器)指定为数据源。
- 在应用的各个模块之间设定明确定义的职责界限。
- 尽量少公开每个模块中的代码
- 考虑如何使每个模块可独立测试。
- 专注于应用的独特核心,以使其从其他应用中脱颖而出。
- 保留尽可能多的相关数据和最新数据。
附录:公开网络状态
在上文的推荐应用架构
部分中,我们省略了网络错误和加载状态以简化代码段。
本部分将演示如何使用可封装数据及其状态的 Resource 类来公开网络状态。
以下代码段提供了 Resource 的实现示例:
// A generic class that contains data and status about loading this data.
sealed class Resource<T>(
val data: T? = null,
val message: String? = null
) {
class Success<T>(data: T) : Resource<T>(data)
class Loading<T>(data: T? = null) : Resource<T>(data)
class Error<T>(message: String, data: T? = null) : Resource<T>(data, message)
}
有一个很常见的情况是,一边从网络加载数据,一边显示这些数据在磁盘上的副本,因此建议您创建一个可在多个地方重复使用的辅助程序类。在本例中,我们创建一个名为 NetworkBoundResource 的类。
下图显示了 NetworkBoundResource 的决策树:
它首先观察资源的数据库。首次从数据库中加载条目时,NetworkBoundResource 会检查结果是好到足以分派,还是应从网络中重新获取。请注意,考虑到您可能会希望在通过网络更新数据的同时显示缓存的数据,这两种情况可能会同时发生。
如果网络调用成功完成,它会将响应保存到数据库中并重新初始化数据流。如果网络请求失败,NetworkBoundResource 会直接分派失败消息。
注意:在将新数据保存到磁盘后,我们会重新初始化来自数据库的数据流。不过,通常我们不需要这样做,因为数据库本身正好会分派更改。
此外,不要分派来自网络的结果,因为这样将违背单一可信来源原则。毕竟,数据库可能包含在“保存”操作期间更改数据值的触发器。同样,不要在没有新数据的情况下分派SUCCESS
,因为如果这样做,客户端会接收错误版本的数据。
以下代码段显示了 NetworkBoundResource 类为其子类提供的公共 API:
NetworkBoundResource.kt
// ResultType: Type for the Resource data.
// RequestType: Type for the API response.
abstract class NetworkBoundResource<ResultType, RequestType> {
// Called to save the result of the API response into the database
@WorkerThread
protected abstract fun saveCallResult(item: RequestType)
// Called with the data in the database to decide whether to fetch
// potentially updated data from the network.
@MainThread
protected abstract fun shouldFetch(data: ResultType?): Boolean
// Called to get the cached data from the database.
@MainThread
protected abstract fun loadFromDb(): LiveData<ResultType>
// Called to create the API call.
@MainThread
protected abstract fun createCall(): LiveData<ApiResponse<RequestType>>
// Called when the fetch fails. The child class may want to reset components
// like rate limiter.
protected open fun onFetchFailed() {}
// Returns a LiveData object that represents the resource that's implemented
// in the base class.
fun asLiveData(): LiveData<ResultType> = TODO()
}
请注意有关该类定义的以下重要细节:
- 它定义了两个类型参数(ResultType 和 RequestType),因为从 API 返回的数据类型可能与本地使用的数据类型不匹配。
- 它对网络请求使用了一个名为 ApiResponse 的类。ApiResponse 是 Retrofit2.Call 类的一个简单封装容器,可将响应转换为 LiveData 实例。
NetworkBoundResource
类的完整实现作为 Android 架构组件 GitHub 项目的一部分出现。
创建 NetworkBoundResource 后,我们可以使用它在 UserRepository 类中编写磁盘和网络绑定的 User 实现:
UserRepository
// Informs Dagger that this class should be constructed only once.
@Singleton
class UserRepository @Inject constructor(
private val webservice: Webservice,
private val userDao: UserDao
) {
fun getUser(userId: String): LiveData<User> {
return object : NetworkBoundResource<User, User>() {
override fun saveCallResult(item: User) {
userDao.save(item)
}
override fun shouldFetch(data: User?): Boolean {
return rateLimiter.canFetch(userId) && (data == null || !isFresh(data))
}
override fun loadFromDb(): LiveData<User> {
return userDao.load(userId)
}
override fun createCall(): LiveData<ApiResponse<User>> {
return webservice.getUser(userId)
}
}.asLiveData()
}
}
今天的内容就介绍到这里,相信大家对应用架构有了大致的了解,但是真正的运用到自己的项目中肯定还是无从下手的,尤其是还在使用Java
开发Android的朋友来说。下一篇文章我会用具体的代码介绍如何用Java
配合应用架构
进行实战,敬请期待~~~