一文看懂现代 Android 开发最佳实践
Posted fundroid_方卓
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一文看懂现代 Android 开发最佳实践相关的知识,希望对你有一定的参考价值。
What is MAD?
MAD 的全称是 Modern android Development , 它是一系列技术栈和工具链的集合,涵盖了从编程语言到开发框架等各个环节。
Android 自 08 年诞生之后的多年间 SDK 变化一直不大,开发方式较为固定。13 年起技术更新逐渐加速,特别是 17年之后, 随着 Kotlin 及 Jetpack 等新技术的出现 Android 开发方式发生了很大变化,去年推出的 Jetpack Compose 更是将这种变化推向了新阶段。Goolge 将这些新技术下的开发方式命名为 MAD ,以此区别于旧有的低效的开发方式。
https://developer.android.com/series/mad-skills
MAD 可以指导开发者更高效地开发出优秀的移动应用,它的优势这主要体现在以下几点:
- 稳定可靠:相对于众多三方库,Google 的类库会长期维护,值得信赖
- 接入友好:提供大量示例代码和参考文档,可以帮助你快速上手
- 灵活适配:框架种类丰富多样,适用于不同阶段不同规模的项目
- 体验一致:不同设备不同版本系统下具备一致的开发体验
MAD 助力应用出海
在 MAD 的指导下项目的代码架构也更加合理、更具可维护性。下图是项目中 MAD 的整体应用情况
接下来,本文将分享一些我们在对 MAD 实践过程中的心得和案例
1. Kotlin
Kotlin 是 Andorid 认可的首选开发语言,我们的项目中,所有代码都使用 Kotlin 开发。Kotlin 的语法十分简洁,相对于 Java 同等功能的代码规模可以减少 25%。此外 Kotlin 还具有很多 Java 所不具备的优秀特性:
1.1 Safety
Kotlin 在安全性方面有很多优秀的设计,比如空安全以及数据的不可变性。
Null Safety
Kotlin 的空安全特性让很多运行时 NPE 提前到编译期暴露和发现,有效降低线上崩溃的发生。我们在代码中重视对 Nullable 类型的判断和处理,我们在数据结构定义时都力求避免出现可空类型,最大限度降低判空成本;
interface ISelectedStateController<DATA>
fun getStateOrNull(data: DATA): SelectedState?
fun selectAndGetState(data: DATA): SelectedState
fun cancelAndGetState(data: DATA): SelectedState
fun clearSelectState()
// 使用 Elvis 提前处理 Nullable
fun <DATA> ISelectedStateController<DATA>.getSelectState(data: DATA): SelectedState
return getStateOrNull(data) ?: SelectedState.NON_SELECTED
Java 时代我们只能通过 getStateOrNull
这类的命名规范来提醒返回值的可空,Kotlin 通过 ?
让我们可以更好地感知 Nullable 的风险;我们还可以使用 Elvis 操作符 ?:
将 Nullable
转成 NonNull
便于后续使用;Kotlin 的 !!
让我们更容易发现 NPE 的潜在风险并可以诉诸静态检查给予警告。
Kotlin 的默认参数值特性也有助于防止 NPE 的出现。像下面这样的结构体定义,在反序列化等场景中不必担心 Null
的出现。
data class BannerResponse(
@SerializedName("data") val data: BannerData = BannerData(),
@SerializedName("message") val message: String = "",
@SerializedName("status_code") val statusCode: Int = 0
)
我们在全面拥抱 Kotlin 之后,NPE 方面的崩溃率只有 0.3‰,而通常 Java 项目的 NPE 会超过 1‰
Immutable
Kotlin 的安全性还体现在数据不会被随意修改。我们在代码中大量使用 data class 并且要求属性使用 val
而非 var
定义,这有利于单向数据流范式在项目中的推广,在架构层面实现数据的读写分离。
data class HomeUiState(
val bannerList: Result<BannerItemModel> = Result.Success(emptyList()),
val contentList: Result<ContentViewModel> = Result.Success(emptyList()),
)
sealed class Result<T>
data class Success<T>(val list: List<T> = emptyList()) : Result<T>()
data class Error<T>(val message: String) : Result<T>()
如上,我们使用 data class 定义 UiState
用在 ViewModel
中。 val
声明属性保证了 State 的不可变性。使用密封类定义 Result
有利于对各种请求结果进行枚举,简化逻辑。
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
_uiState.value =
_uiState.value.copy(bannerList = Result.Success(it))
需要更新 State 时,借助 data class 的 copy
方法可以快捷地拷贝构造一个新实例。
Immutable 还体现在集合类的类型上。我们在项目中提倡非必要不使用 MutableList
这样的 Mutable 类型,可以减少 ConcurrentModificationException
等多线程问题的发生,同时更重要的是避免了因为 Item 篡改带来的数据一致性问题:
viewModel.uiState.collect
when (it)
Result.Success -> bannerAdapter.updateList(it.list)
else ...
fun updateList(newList: List<BannerItemModel>)
val diffResult = DiffUtil.calculateDiff(BannerDiffCallback(mList, newList), true)
diffResult.dispatchUpdatesTo(this)
比如上面例子中 UI 侧接收到 UiState
更新通知后,提交 DiffUtil
刷新列表。DiffUtil
正常运作的基础正是因为 mList
和 newList
保持了 Immutable
类型。
1.2 Functional
函数在 Kotlin 中是一等公民,可以作为参数或返回值的类型组成高阶函数,高阶函数可以在集合操作符等场景下提供更加易用的 API。
Collection operations
val bannerImageList: List<BannerImageItem> =
bannerModelList.sortedBy
it.bType
.filter
!it.isFrozen()
.map
it.image
上面的代码中我们对 BannerModelList
依次完成排序、过滤,并转换成 BannerImageItem
类型的列表,集合操作符的使用让代码一气呵成。
Scope functions
作用域函数是一系列 inline
的高阶函数。它们可以作为代码的粘合剂,减少临时变量等多余代码的出现。
GalleryFragment().apply
setArguments(arguments ?: Bundle().apply
putInt("layoutId", layoutId())
)
.let fragment ->
supportFragmentManager.beginTransaction()
.apply
if (needAdd) add(R.id.fragment_container, fragment, tag)
else replace(R.id.fragment_container, fragment, tag)
.also
it.setCustomAnimations(R.anim.slide_in, R.anim.slide_out)
.commit()
当我们创建并启动一个 Fragment 时,可以基于作用域函数完成各种初始化工作,就像上面例子那样。这个例子同时也提醒我们过度使用这些作用域函数(或集合操作符),也会影响代码的可读性和可调试性,只有“恰到好处”的使用函数式编程才能真正发挥 Kotlin 的优势。
1.3 Corroutine
Kotlin 协程让开发者摆脱了回调地狱的出现,同时结构化并发的特性也有助于对子任务更好地管理,Android 的各种原生库和三方库在处理异步任务时都开始转向 Kotlin 协程。
Suspend function
在项目中,我们倡导使用挂起函数封装异步逻辑。在数据层 Room 或者 Retorfit 使用挂起函数风格的 API 自不必说,一些表现层逻辑也可以基于挂起函数来实现:
suspend fun doShare(
activity: Activity,
contentBuilder: ShareContent.Builder.() -> Unit
): ShareResult = suspendCancellableCoroutine cont ->
val shareModel = ShareContent.Builder()
.setEventCallBack(object : ShareEventCallback.EmptyShareEventCallBack()
override fun onShareResultEvent(result: ShareResult)
super.onShareResultEvent(result)
if (result.errorCode == 0)
cont.resume(result)
else
cont.cancel()
).apply(contentBuilder)
.build()
ShareSdk.showPanel(createPanelContent(activity, shareModel))
上例的 doShare
用挂起函数处理照片的分享逻辑:弹出分享面板供用户选择分享渠道,并将分享结果返回给调用方。调用方启动分享并同步获取分享成功或失败的结果,代码风格更符合直觉。
Flow
项目中使用 Flow 替代 RxJava 处理流式数据,减少包体积的同时,CoroutineScope
可以有效避免数据泄露:
fun CoroutineScope.getBannerList(): Flow<List<BannerItemModel>> =
DatabaseManager.db.bannerDao::getAll.asFlow()
.onCompletion
this@Repository::getRemoteBannerList.asFlow().onEach
launch
DatabaseManager.db.bannerDao.deleteAll()
DatabaseManager.db.bannerDao.insertAll(*(it.toTypedArray()))
.distinctUntilChanged()
上面的例子用于从多个数据源获取 BannerList
。我们增加了磁盘缓存的策略,先请求本地数据库数据,再请求远程数据。Flow 的使用可以很好地满足这类涉及多数据源请求的场景。而另一面在调用侧,只要提供合适的 CoroutineScope
就不必担心泄露的发生。
1.4 KTX
一些原本基于 Java 实现的 Android 库通过 KTX 提供了针对 Kotlin 的扩展 API,让它们在 Kotlin 工程中更容易地被使用。
我们的项目使用 Jetpack Architecture Components 搭建 App 基础架构,KTX 帮助我们大大降低了 Kotlin 项目中的 API 使用成本,举几个最常见的 KTX 的例子:
fragment-ktx
fragment-ktx 提供了一些针对 Fragment 的 Kotlin 扩展方法,比如 ViewModel 的创建:
class HomeFragment : Fragment()
private val homeViewModel : HomeViewModel by viewModels()
...
相对于 Java 代码在 Fragment 中创建 ViewMoel 变得极其简单,其背后的是现实活用了各种 Kotlin 特性,十分巧妙。
inline fun <reified VM : ViewModel> Fragment.viewModels(
noinline ownerProducer: () -> ViewModelStoreOwner = this ,
noinline factoryProducer: (() -> Factory)? = null
) = createViewModelLazy(VM::class, ownerProducer().viewModelStore , factoryProducer)
viewModels
是 Fragment 的 inline
扩展方法,通过 reified
关键字在运行时获取泛型类型用来创建具体 ViewModel 实例:
fun <VM : ViewModel> Fragment.createViewModelLazy(
viewModelClass: KClass<VM>,
storeProducer: () -> ViewModelStore,
factoryProducer: (() -> Factory)? = null
): Lazy<VM>
val factoryPromise = factoryProducer ?:
defaultViewModelProviderFactory
return ViewModelLazy(viewModelClass, storeProducer, factoryPromise)
createViewModelLazy
返回了一个 Lazy<VM>
实例,这似的我们可以通过 by
关键字创建 ViewModel
,这里借助 Kotlin 的代理特性实现了实例的延迟创建。
viewmodle-ktx
viewModel-ktx 提供了针对 ViewModel 的扩展方法, 例如 viewModelScope
,可以随着 ViewModel 的销毁及时终止过期的异步任务,让 ViewModel 更安全地作为数据层与表现层之间的桥梁使用。
viewModelScope.launch
//监听数据层的数据
repo.getMessage().collect
//向表现层发送消息
_messageFlow.emit(message)
实现原理也非常简单
val ViewModel.viewModelScope: CoroutineScope
get()
val scope: CoroutineScope? = this.getTag(JOB_KEY)
if (scope != null)
return scope
return setTagIfAbsent(JOB_KEY,
CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate))
viewModelScope
本质上是 ViewModle 的扩展属性,通过 custom get
创建 CloseableCoroutineScope
的同时,记录到 JOB_KEY
的位置中
internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope
override val coroutineContext: CoroutineContext = context
override fun close()
coroutineContext.cancel()
CloseableCoroutineScope
其实是一个 Closeable
,在 ViewModel 的 onClear
时查找 JOB_KEY
并被调用 close
以取消 SupervisorJob
,终止所有子协程。KTX 活用了 Kotlin 的各种特性和语法糖 ,后面 Jetpack 章节会看到更多 KTX 的使用。
2. Android Jetpack
Android 通过 Jetpack 为开发者提供 AOSP 之上的基础能力支持,其范围覆盖了从 UI 到 Data 各个层级,降低了开发者们自造轮子的需求。近期 Jetpack 组件的架构规范又进行了全面升级,帮助我们在开发过程中能更好地贯彻关注点分离这一设计目标。
2.1 Architecture
Android 倡导表现层和数据层分离的架构设计,并使用单向数据流(Unidirectional Data Flow)完成数据通信。Jetpack 通过一系列 Lifecycle-aware 的组件支持了 UDF 在 Android 中的落地。
UDF 的主要特点和优势如下:
- 唯一真实源(SSOT):UI State 在 ViewModel 集中管理,降低了多数据源之间的同步成本
- 数据自上而下流动:UI 的更新来 VM 的状态变化,UI 自身不持有状态、不耦合业务逻辑
- 事件自下而上传递:UI 发送 event 给 VM 对状态集中修改,状态变化可回溯、利于单测
项目中凡是涉及 UI 的业务场景都是基于 UDF 打造的。以 HomePage
为例,其中包括 BannerList
和 ContentList
两组数据展示,所有的数据集中管理在 UiState
中
class HomeViewModel() : ViewModel()
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
fun fetchHomeData()
fetchJob?.cancel()
fetchJob = viewModelScope.launch
with(repo)
//request BannerList
try
getBannerList().collect
_uiState.value =
_uiState.value.copy(bannerList = Result.Success(it))
catch (ioe: IOException)
// Handle the error and notify the UI when appropriate.
_uiState.value =
_uiState.value.copy(
bannerList = Result.Error(getMessagesFromThrowable(ioe))
)
//request ContentList
try
getContentList().collect
_uiState.value =
_uiState.value.copy(contentList = Result.Success(it))
catch (ioe: IOException)
_uiState.value =
_uiState.value.copy(
contentList = Result.Error(getMessagesFromThrowable(ioe))
)
如上代码所示,HomeViewModel
从 Repo 获取数据并更新 UiState
,View 订阅此状态并刷新 UI。viewModelScope.launch
提供的 CoroutineScope
可以随着 ViewModel 的 onClear
结束运行中的协程,避免泄露。
数据层我们使用 Repository Pattern 封装本地数据源和远程数据源的具体实现:
class Repository
fun CoroutineScope.getBannerList(): Flow<List<BannerItemModel>>
return DatabaseManager.db.bannerDao::getAll.asFlow()
.onCompletion
this@Repository::getRemoteBannerList.asFlow().onEach
launch
DatabaseManager.db.bannerDao.deleteAll()
DatabaseManager.db.bannerDao.insertAll(*(it.toTypedArray()))
.distinctUntilChanged()
private suspend fun getRemoteBannerList(): List<BannerItemModel>
TODO("Not yet implemented")
以 getBannerList
为例,先从数据库请求本地数据加速显示,然后再请求远程数据源更新数据,同时进行持久化,便于下次请求。
UI 层的逻辑很简单,订阅 ViewModel 的数据并刷新 UI 即可
@AndroidEntryPoint
class HomeFragment : Fragment()
@Inject
lateinit var viewModel : HomeViewModel
override fun onViewCreated(view: View, savedInstanceState: Bundle?)
super.onViewCreated(view, savedInstanceState)
lifecycleScope.launch
repeatOnLifecycle(Lifecycle.State.STARTED)
viewModel.uiState.collect
// Update UI elements
我们使用 Flow 代替 LiveData 对 UiState 进行封装,lifecycleScope
使得 Flow 变身 Lifecycle-aware 组件;repeatOnLifecycle
让 Flow 像 LiveData 一样在 Fragment 前后台切换时自动停止数据流的发射,节省资源开销。
2.2 Navigation
作为“单 Activity 架构”的实践者,我们选择了使用 Jetpack Navigation 作为 App 的导航组件。Navigation 组件实现了导航设计原则,为跨应用切换或应用内页面间的切换提供了一致的用户体验,并且提供了各种优势,包括:
- 处理 Fragment 事务;
- 默认情况下,正确处理往返操作;
- 为动画和转场提供标准化资源;
- 实现和处理深层链接;
- 包括导航界面模式(例如抽屉式导航栏和底部导航),开发者只需完成极少的额外工作;
- 提供 Gradle 插件用以保证在不同页面传递参数时类型安全;
- 提供了导航图范围的 ViewModel,以在同导航图内的页面进行数据共享;
TODO
Navigation 提供了 XML 以及 Kotlin DSL 两种配置方式。我们在项目中发挥 Kotin 的优势,基于类型安全的 DSL 创建导航图,同时通过函数提取为页面统一指定转场动画:
fun NavHostFragment.initGraph() = run
createGraph(nav_graph.id, nav_graph.dest.home)
fragment<HomeFragment>(nav_graph.dest.effect_detail)
action(nav_graph.action.home_to_effect_detail)
destinationId = nav_graph.dest.effect_detail
navOptions
applySlideInOut()
//统一指定转场动画
internal fun NavOptionsBuilder.applySlideInOut()
anim
enter = R.anim.slide_in
exit = R.anim.slide_out
popEnter = R.anim.slide_in_pop
popExit = R.anim.slide_out_pop
在 Activity 中,调用 initGraph()
为 Root Fragment 初始化导航图:
@AndroidEntryPoint
class MainActivity : AppCompatActivity()
private val navHostFragment: NavHostFragment by lazy
supportFragmentManager.findFragmentById(R.id.nav_host) as NavHostFragment
override fun onCreate(savedInstanceState: Bundle?)
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
navHostFragment.navController.apply
graph = navHostFragment.initGraph()
而在 Fragment 中,使用 navigation-fragment-ktx 提供的 findNavController()
可以随时基于当前 Destination
进行正确的页面跳转:
@AndroidEntryPoint
class EffectDetailFragment : Fragment()
/* ... */
override fun onViewCreated(view: View, savedInstanceState: Bundle?)
nextButton.setOnClickListener
findNavController().navigate(nav_graph.action.effect_detail_to_loading))
// Back to previous page
backButton.setOnClickListener
一文看懂 OAuth 2.0 (附实践案例)