注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

MVP 架构最终审判 —— MVP 解决了哪些痛点,又引入了哪些坑?(二)

MVP
复杂度Android 架构演进系列是围绕着复杂度向前推进的。软件的首要技术使命是“管理复杂度” —— 《代码大全》因为低复杂度才能降低理解成本和沟通难度,提升应对变更的灵活性,减少重复劳动,最终提高代码质量。架构的目的在于“将复杂度分层”复杂度为什么要被分层?...
继续阅读 »

复杂度

Android 架构演进系列是围绕着复杂度向前推进的。

软件的首要技术使命是“管理复杂度” —— 《代码大全》

因为低复杂度才能降低理解成本和沟通难度,提升应对变更的灵活性,减少重复劳动,最终提高代码质量。

架构的目的在于“将复杂度分层”

复杂度为什么要被分层?

若不分层,复杂度会在同一层次展开,这样就太 ... 复杂了。

举一个复杂度不分层的例子:

小李:“你会做什么菜?”

小明:“我会做用土鸡生的土鸡蛋配上切片的番茄,放点油盐,开火翻炒的番茄炒蛋。”

听了小明的回答,你还会和他做朋友吗?

小明把不同层次的复杂度以不恰当的方式揉搓在一起,让人感觉是一种由“没有必要的具体”导致的“难以理解的复杂”。

小李其实并不关心土鸡蛋的来源、番茄的切法、添加的佐料、以及烹饪方式。

这样的回答除了难以理解之外,局限性也很大。因为它太具体了!只要把土鸡蛋换成洋鸡蛋、或是番茄片换成块、或是加点糖、或是换成电磁炉,其中任一因素发生变化,小明就不会做番茄炒蛋了。

再举个正面的例子,TCP/IP 协议分层模型自下到上定义了五层:

  1. 物理层
  2. 数据链路成
  3. 网络层
  4. 传输层
  5. 应用层

其中每一层的功能都独立且明确,这样设计的好处是缩小影响面,即单层的变动不会影响其他层。

这样设计的另一个好处是当专注于一层协议时,其余层的技术细节可以不予关注,同一时间只需要关注有限的复杂度,比如传输层不需要知道自己传输的是 HTTP 还是 FTP,传输层只需要专注于端到端的传输方式,是建立连接,还是无连接。

有限复杂度的另一面是“下层的可重用性”。当应用层的协议从 HTTP 换成 FTP 时,其下层的内容不需要做任何更改。

引子

该系列的前三篇结合“搜索”这个业务场景,讲述了不使用架构写业务代码会产生的痛点:

  1. 低内聚高耦合的绘制:控件的绘制逻辑散落在各处,散落在各种 Activity 的子程序中(子程序间相互耦合),分散在现在和将来的逻辑中。这样的设计增加了界面刷新的复杂度,导致代码难以理解、容易改出 Bug、难排查问题、无法复用。
  2. 耦合的非粘性通信:Activity 和 Fragment 通过获取对方引用并互调方法的方式完成通信。这种通信方式使得 Fragment 和 Activity 耦合,从而降低了界面的复用度。并且没有一种内建的机制来轻松的实现粘性通信。
  3. 上帝类:所有细节都在界面被铺开。比如数据存取,网络访问这些和界面无关的细节都在 Activity 被铺开。导致 Activity 代码不单纯、高耦合、代码量大、复杂度高、变化源不单一、改动影响范围大。
  4. 界面 & 业务:界面展示和业务逻辑耦合在一起。“界面该长什么样?”和“哪些事件会触发界面重绘?”这两个独立的变化源没有做到关注点分离。导致 Activity 代码不单纯、高耦合、代码量大、复杂度高、变化源不单一、改动影响范围大、易改出 Bug、界面和业务无法单独被复用。

详细分析过程可以点击下面的链接:

  1. 写业务不用架构会怎么样?(一)

  2. 写业务不用架构会怎么样?(二)

  3. 写业务不用架构会怎么样?(三)

这一篇试着引入 MVP 架构(Model-View-Presenter)进行重构,看能不能解决这些痛点。

在重构之前,先介绍下搜索的业务场景,该功能示意图如下:

1662106805162.gif

业务流程如下:在搜索条中输入关键词并同步展示联想词,点联想词跳转搜索结果页,若无匹配结果则展示推荐流,返回时搜索历史以标签形式横向铺开。点击历史可直接发起搜索跳转到结果页。

将搜索业务场景的界面做了如下设计:

微信截图_20220902171024.png

搜索页用Activity来承载,它被分成两个部分,头部是常驻在 Activity 的搜索条。下面的“搜索体”用Fragment承载,它可能出现三种状态 1.搜索历史页 2.搜索联想页 3.搜索结果页。

Fragment 之间的切换采用 Jetpack 的Navigation。关于 Navigation 详细的介绍可以点击关于 Navigation 更详细的介绍可以点击Navigation 组件使用入门  |  Android 开发者  |  Android Developers

业务和访问数据分离

上一篇使用 MVP 重构了搜索条,引出了 MVP 中的一些基本概念,比如业务接口,View 层接口,双向通信。

这一篇开始对搜索联想进行重构,它的交互如下:

1664533978856.gif

输入关键词的同时请求网络拉取联想词并展示为列表,点击联想词跳转到搜索结果页。再次点击输入框时,对当前词触发联想。

新增了一个业务场景,就在 SearchPresenter 中新增接口:

interface SearchPresenter {
fun init()
fun backPress()
fun touchSearchBar(text: String, isUserInput: Boolean)
fun clearKeyword()
fun search(keyword: String, from: SearchFrom)
fun inputKeyword(input: Input)
// 拉取联想词
suspend fun fetchHint(keyword: String): List<String>
// 展示联想页
fun showHintPage(hints: List<SearchHint>)
}

若每次输入框内容发生变化都请求网络则浪费流量,所以得做限制。使用响应式编程使得问题的求解变得简单,详细讲解可以点击写业务不用架构会怎么样?(三)

现套用这个解决方案,并将它和 Presenter 结合使用:

// TemplateSearchActivity.kt
etSearch.textChangeFlow { isUserInput, char -> Input(isUserInput, char.toString()) }
// 键入内容后高亮搜索按钮并展示 X
.onEach { searchPresenter.inputKeyword(it) }
.filter { it.keyword.isNotEmpty() }
.debounce(300)
// 拉取联想词
.flatMapLatest { flow { emit(searchPresenter.fetchHint(it.keyword)) } }
.flowOn(Dispatchers.IO)
// 跳转到联想页并展示联想词列表
.onEach { searchPresenter.showHintPage(it.map { SearchHint(etSearch.text.toString(), it) }) }
.launchIn(lifecycleScope)

其中textChangeFlow() 是一个 EditText 的扩展方法,该方法把监听输入框内容变化的回调转换为一个Flow,而Input是一个 data class:

fun <T> EditText.textChangeFlow(elementCreator: (Boolean, CharSequence?) -> T): Flow<T> = callbackFlow {
val watcher = object : TextWatcher {
private var isUserInput = true
override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
}

override fun onTextChanged(char: CharSequence?, p1: Int, p2: Int, p3: Int) {
isUserInput = this@textChangeFlow.hasFocus()
}

override fun afterTextChanged(p0: Editable?) {
trySend(elementCreator(isUserInput, p0?.toString().orEmpty()))
}

}
addTextChangedListener(watcher)
awaitClose { removeTextChangedListener(watcher) }
}
//用于表达用户输入内容
data class Input(val isUserInput: Boolean, val keyword: String)

SearchPresenter.fetchHint()对界面屏蔽了访问网络的细节:

class SearchPresenterImpl(private val searchView: SearchView) : SearchPresenter {
private val retrofit = Retrofit.Builder()
.baseUrl("https://XXX")
.addConverterFactory(MoshiConverterFactory.create())
.client(OkHttpClient.Builder().build())
.build()
private val searchApi = retrofit.create(SearchApi::class.java)

override suspend fun fetchHint(keyword: String): List<String> = suspendCancellableCoroutine { continuation ->
searchApi.fetchHints(keyword)
.enqueue(object : Callback<SearchHintsBean>() {
override fun onResponse(call: Call<SearchHintsBean>, response: Response<SearchHintsBean>) {
if (response.body()?.result?.hints?.isNotEmpty() == true) {
val hints = if (result.data.hints.contains(keyword))
result.data.hints
else listOf(keyword, *result.data.hints.toTypedArray())
continuation.resume(hints, null)
} else {
continuation.resume(listOf(keyword), null)
}
}

override fun onFailure(call: Call<SearchHintsBean>, t: Throwable) {
continuation.resume(listOf(keyword), null)
}
})
}
}

访问网络的细节包括如何将 url 转换为请求对象、如何发起 Http 请求、怎么变换响应、如何将响应的异步回调转换为 suspend 方法。这些细节都被隐藏在 Presenter 层,界面无感知,它只要关心如何绘制。

按照这个思路,访问数据库,访问文件的细节也都不应该让界面感知。有没有必要把这些访问数据的细节再抽取出来成为新的一层叫“数据访问层”?

这取决于数据访问是否可供其他模块复用,或者数据访问的细节是否会发生变化。

若另一个 Presenter 也需要做同样的网络请求(新业务界面请求老接口还是挺常见的),像上面这种写,请求的细节就无法被复用。此时只能祭出复制粘贴。

而且搜索可以发生在很多业务场景,这次是搜索模板,下次可能是搜索素材。它们肯定不是一个服务端接口。这就是访问的细节发生变化。若新的搜索场景想复用这次的 SearchPresenter,则访问网络的细节就不该出现在 Presenter 层。

为了增加 Presenter 和网络请求细节的复用性,通常的做法是新增一层 Repository:

class SearchRepository {
private val retrofit = Retrofit.Builder()
.baseUrl("https://XXX")
.addConverterFactory(MoshiConverterFactory.create())
.client(OkHttpClient.Builder().build())
.build()
private val searchApi = retrofit.create(SearchApi::class.java)

override suspend fun fetchHint(keyword: String): List<String> = suspendCancellableCoroutine { continuation ->
searchApi.fetchHints(keyword)
.enqueue(object : Callback<SearchHintsBean>() {
override fun onResponse(call: Call<SearchHintsBean>, response: Response<SearchHintsBean>) {
if (response.body()?.result?.hints?.isNotEmpty() == true) {
val hints = if (result.data.hints.contains(keyword)) result.data.hints else listOf(keyword, *result.data.hints.toTypedArray())
continuation.resume(hints, null)
} else {
continuation.resume(listOf(keyword), null)
}
}

override fun onFailure(call: Call<SearchHintsBean>, t: Throwable) {
continuation.resume(listOf(keyword), null)
}
})
}
}

然后 Presenter 通过持有 Repository 具备访问数据的能力:

class SearchPresenterImpl(private val searchView: SearchView) : SearchPresenter {
private val searchRepository: SearchRepository = SearchRepository()
// 将访问数据委托给 repository
override suspend fun fetchHint(keyword: String): List<String> {
return searchRepository.fetchSearchHint(keyword)
}
}

又引入了一个新的复杂度数据访问层,它封装了所有访问数据的细节,比如怎样读写内存缓存、怎样访问网络、怎样访问数据库、怎样读写文件。数据访问层通常向上层提供“原始数据”,即不经过任何业务封装的数据,这样的设计使得它更容易被复用于不同的业务。Presenter 会持有数据访问层并将所有访问数据的工作委托给它,并将数据做相应的业务转换,最终传递给界面。

Model 去哪了?

至此业务架构表现为如下状态:

微信截图_20220930212344.png

业务架构分为三层:

  1. 界面层:是 MVP 中的 V,它只描述了界面如何绘制,通过实现 View 层接口表达。它会持有 Presenter 的实例,用以发送业务请求。
  2. 业务层:是 MVP 中的 P,它只描述业务逻辑,通过实现业务接口表达。它会持有 View 层接口的实例,以指导界面如何绘制。它还会持有带有数据存储能力的 Repository。
  3. 数据存取层:它在 MVP 中找不到自己的位置。它描述了操纵数据的能力,包括读和写。它向上层屏蔽了读写数据的细节,是从网络读,还是从文件,数据库,上层都不需要关心。

MVP 中的 M 在哪里?难道是 Repository 吗?我不觉得!

若 Repository 代表 M,那就意味着 M 不仅代表了数据本身,还包含了获取数据的方式。

但 M 明明是 Model,模型(名词)。Trygve Reenskaug,MVC 概念的发明者,在 1979 年就对 MVC 中的 M 下过这样的结论:

The View observes the Model for changes

M 是用来被 View 观察的,而 Repository 获取的数据是原始数据,需要经过一次包装或转换才能指导界面绘制。

按照这个定义当前架构中的 M 应该如下图所示:

微信截图_20220930213304.png

每一个从 Presenter 通过 View 层接口传递出去的参数才是 Model,因为它才直接指导界面该如何绘制。

正因为 Presenter 向界面提供了多个 Model,才导致上一节“有限内聚的界面绘制”,界面绘制无法内聚到一点的根本原因是因为有多个 Model。MVI 在这一点上做了一次升级,叫“唯一可信数据源”,真正地做到了界面绘制内聚于一点。(后续篇章会展开分析)

下面这个例子再一次展示出“多 Model 导致有限内聚的界面刷新”的缺点。

当前输入框的 Flow 如下:

微信截图_20221001115809.png

整个流上有两个刷界面的点,一个在流的上游,一个在流的下游。所以不得不把上游切换到主线程执行,否则会报:

E CrashReport: android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.

这也是“有限的内聚”引出的没有必要的线程切换,理想状态下,刷界面应该内聚在一点且处于整个流的末端。(后续篇章会展开)

跨界面通信?

触发拉取联想词的动作在搜索页 Activity 中发生,联想接口的拉取也在 Activity 中进行。这就产生了一个跨界面通信场景,得把 Activity 中获取的联想词传递给联想页 Fragment。

当拉取联想词结束后,数据会流到 SearchPresenter.showHintPage():

class SearchPresenterImpl(private val searchView: SearchView) : SearchPresenter {
override fun showHintPage(hints: List<SearchHint>) {
searchView.gotoHintPage(hints) // 跳转到联想页
}
}

interface SearchView {
fun gotoHintPage(hints: List<SearchHint>) // 跳转到联想页
}

为 View 层接口新增了一个界面跳转的方法,待 Activity 实现之:

class TemplateSearchActivity : AppCompatActivity(), SearchView {
override fun gotoHintPage(hints: List<SearchHint>) {
// 跳转到联想页,联想词作为参数传递给联想页
findNavController(NAV_HOST_ID.toLayoutId())
.navigate(R.id.action_to_hint, bundleOf("hints" to hints))
}
}

为了将联想词传递给联想页,得序列化之:

@Parcelize // 序列化注解
data class SearchHint( val keyword: String, val hint: String ):Parcelable

然后在联想页通过 getArguement() 就能获取联想词:

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 获取联想词
val hints = arguments?.getParcelableArrayList<SearchHint>("hints").orEmpty()
}

当前传递的数据简单,若复杂数据采用这种方式传递,可能发生性能上的损耗,首先序列化和反序列化是耗时的。再者当通过 Intent 传递大数据时可能发生TransactionTooLargeException

展示联想词的场景是“界面跳转”和“数据传递”同时发生,可以借用界面跳转携带数据。但有些场景下不发生界面跳转也得传递数据。比如下面这个场景:

1664612322946.gif

点击联想词也记为一次搜索,也得录入搜索历史。

当点击联想词时发生的界面跳转是从联想页 Fragment 跳到搜索结果 Fragment,但数据传递却需要从联想页到历史页。在这种场景下无法通过界面跳转来携带参数。

因为 Activity 和 Fragment 都能轻松地拿到对方的引用,所以通过直接调对方的方法实现参数传递也不是不可以。只是这让 Activity 和 Fragment 耦合在一起,使得它们无法单独被复用。

正如写业务不用架构会怎么样?(三)中描述的那样,界面之间需要一种解耦的、高性能的、最好还带粘性能力的通信方式。

MVP 并未内建这种通信机制,只能借助于第三方库 EventBus:

class TemplateSearchActivity : AppCompatActivity(), SearchView {
override fun sendHints(searchHints: List<SearchHint>) {
findNavController(NAV_HOST_ID.toLayoutId()).navigate(R.id.action_to_hint, bundleOf("hints" to hints))
EventBus.getDefault().postSticky(SearchHintsEvent(searchHints))// 发送粘性广播
}
}
// 将联想词封装成实体类便于广播发送
data class SearchHintsEvent(val hints: List<SearchHint>)

class SearchHintFragment : BaseSearchFragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
EventBus.getDefault().register(this) // 注册
}

override fun onDestroy() {
super.onDestroy()
EventBus.getDefault().unregister(this)// 注销
}

@Subscribe(threadMode = ThreadMode.MAIN,sticky = true)
fun onHints(event: SearchHintsEvent) {
hintsAdapter.dataList = event.hints // 接收粘性消息并刷新列表
}
}

而 MVVM 和 MVI 就内建了粘性通信机制。(会在后续文章展开)

一切从头来过

产品需求:增加搜索条的过渡动画

1664627407399.gif

搜索业务的入口是另一个 Activity,其中也有一个长得一模一样的搜索条,点击它会跳转到搜索页 Activity。在跳转过程中,两个 Activity 的搜索条有一个水平+透明度的过渡动画。

这个动画的加入引入了一个 Bug:进入搜索页键盘不再自动弹起,搜索历史页没加载出来。

那是因为原先初始化是在 onCreate() 中触发的:

// TemplateSearchActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
searchPresenter.init()
}

加入过渡动画后,onCreate() 执行的时候,动画还未完成,即初始化时机就太早了。解决方案是监听过渡动画结束后才初始化:

// TemplateSearchActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window?.sharedElementEnterTransition?.doOnEnd {
searchPresenter.init()
}
}

做了这个调整之后,又引入了一个新 Bug:当在历史页横竖屏切换后,历史不见了。

那是因为横竖屏切换会重新构建 Activity,即重新执行 onCreate() 方法,但这次并没有产生过渡动画,所以初始化方法没有调用。解决办法如下:

// TemplateSearchActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window?.sharedElementEnterTransition?.doOnEnd {
searchPresenter.init()
}
// 横竖屏切换时也得再次初始化
if(savedInstanceState != null) searchPresenter.init()
}

即当发生横竖屏切换时,也手动触发一下初始化。

虽然这样写代码就有点奇怪,因为有两个不同的初始化时机(增加了初始化的复杂度),不过问题还是是解决了。

但每一次横竖屏切换都会触发一次读搜索历史的 IO 操作。当前场景数据量较小,也无大碍。若数据量大,或者初始化操作是一个网络请求,这个方案就不合适了。

究其原因是因为没有一个生命周期比 Activity 更长的数据持有者在横竖屏切换时暂存数据,待切换完成后恢复之。

很可惜 Presenter 无法成为这样的数据持有者,因为它在 Activity 中被构建并被其持有,所以它的生命周期和 Activity 同步,即横竖屏切换时,Presenter 也重新构建了一次。

而 MVVM 和 MVI 就没有这样的烦恼。(后续篇章展开分析)

总结

  • 在 MVP 中引入数据访问层是有必要的,这一层封装了存取数据的细节,使得访问数据的能力可以单独被复用。
  • MVP 中没有内建一种解耦的、高性能的、带粘性能力的通信方式。
  • MVP 无法应对横竖屏切换的场景。当横竖屏切换时,一切从头来过。
  • MVP 中的 Model 表现为若干 View 层接口中传递的数据。这样的实现导致了“有限内聚的界面绘制”,增加了界面绘制的复杂度。


作者:唐子玄
链接:https://juejin.cn/post/7151808170120183815
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

MVP 架构最终审判 —— MVP 解决了哪些痛点,又引入了哪些坑?(一)

MVP
复杂度 Android 架构演进系列是围绕着复杂度向前推进的。 软件的首要技术使命是“管理复杂度” —— 《代码大全》 因为低复杂度才能降低理解成本和沟通难度,提升应对变更的灵活性,减少重复劳动,最终提高代码质量。 架构的目的在于“将复杂度分层” 复杂...
继续阅读 »

复杂度


Android 架构演进系列是围绕着复杂度向前推进的。



软件的首要技术使命是“管理复杂度” —— 《代码大全》



因为低复杂度才能降低理解成本和沟通难度,提升应对变更的灵活性,减少重复劳动,最终提高代码质量。



架构的目的在于“将复杂度分层”



复杂度为什么要被分层?


若不分层,复杂度会在同一层次展开,这样就太 ... 复杂了。


举一个复杂度不分层的例子:


小李:“你会做什么菜?”


小明:“我会做用土鸡生的土鸡蛋配上切片的番茄,放点油盐,开火翻炒的番茄炒蛋。”


听了小明的回答,你还会和他做朋友吗?


小明把不同层次的复杂度以不恰当的方式揉搓在一起,让人感觉是一种由“没有必要的具体”导致的“难以理解的复杂”。


小李其实并不关心土鸡蛋的来源、番茄的切法、添加的佐料、以及烹饪方式。


这样的回答除了难以理解之外,局限性也很大。因为它太具体了!只要把土鸡蛋换成洋鸡蛋、或是番茄片换成块、或是加点糖、或是换成电磁炉,其中任一因素发生变化,小明就不会做番茄炒蛋了。


再举个正面的例子,TCP/IP 协议分层模型自下到上定义了五层:



  1. 物理层

  2. 数据链路成

  3. 网络层

  4. 传输层

  5. 应用层


其中每一层的功能都独立且明确,这样设计的好处是缩小影响面,即单层的变动不会影响其他层。


这样设计的另一个好处是当专注于一层协议时,其余层的技术细节可以不予关注,同一时间只需要关注有限的复杂度,比如传输层不需要知道自己传输的是 HTTP 还是 FTP,传输层只需要专注于端到端的传输方式,是建立连接,还是无连接。


有限复杂度的另一面是“下层的可重用性”。当应用层的协议从 HTTP 换成 FTP 时,其下层的内容不需要做任何更改。


引子


该系列的前三篇结合“搜索”这个业务场景,讲述了不使用架构写业务代码会产生的痛点:



  1. 低内聚高耦合的绘制:控件的绘制逻辑散落在各处,散落在各种 Activity 的子程序中(子程序间相互耦合),分散在现在和将来的逻辑中。这样的设计增加了界面刷新的复杂度,导致代码难以理解、容易改出 Bug、难排查问题、无法复用。

  2. 耦合的非粘性通信:Activity 和 Fragment 通过获取对方引用并互调方法的方式完成通信。这种通信方式使得 Fragment 和 Activity 耦合,从而降低了界面的复用度。并且没有一种内建的机制来轻松的实现粘性通信。

  3. 上帝类:所有细节都在界面被铺开。比如数据存取,网络访问这些和界面无关的细节都在 Activity 被铺开。导致 Activity 代码不单纯、高耦合、代码量大、复杂度高、变化源不单一、改动影响范围大。

  4. 界面 & 业务:界面展示和业务逻辑耦合在一起。“界面该长什么样?”和“哪些事件会触发界面重绘?”这两个独立的变化源没有做到关注点分离。导致 Activity 代码不单纯、高耦合、代码量大、复杂度高、变化源不单一、改动影响范围大、易改出 Bug、界面和业务无法单独被复用。


详细分析过程可以点击下面的链接:




  1. 写业务不用架构会怎么样?(一)




  2. 写业务不用架构会怎么样?(二)




  3. 写业务不用架构会怎么样?(三)




这一篇试着引入 MVP 架构(Model-View-Presenter)进行重构,看能不能解决这些痛点。


在重构之前,先介绍下搜索的业务场景,该功能示意图如下:


1662106805162.gif


业务流程如下:在搜索条中输入关键词并同步展示联想词,点联想词跳转搜索结果页,若无匹配结果则展示推荐流,返回时搜索历史以标签形式横向铺开。点击历史可直接发起搜索跳转到结果页。


将搜索业务场景的界面做了如下设计:


微信截图_20220902171024.png


搜索页用Activity来承载,它被分成两个部分,头部是常驻在 Activity 的搜索条。下面的“搜索体”用Fragment承载,它可能出现三种状态 1.搜索历史页 2.搜索联想页 3.搜索结果页。


Fragment 之间的切换采用 Jetpack 的Navigation。关于 Navigation 详细的介绍可以点击关于 Navigation 更详细的介绍可以点击Navigation 组件使用入门  |  Android 开发者  |  Android Developers


高耦合+低内聚


MVP 能否成为高耦合低内聚的终结者?


先来看看高耦合低内聚的代码长什么样。以搜索条为例,它的交互如下:


1664442211986.gif


当输入框键入内容后,显示X按钮并高亮搜索按钮。点击搜索跳转到搜索结果页,同时搜索条拉长并隐藏搜索按钮。点击X时清空输入框并从搜索结果页返回,搜索条还原。


引用上一篇无架构的实现代码:


class TemplateSearchActivity : AppCompatActivity() {
private fun initView() {
// 搜索按钮初始状态
tvSearch.apply {
isEnabled = false
textColor = "#484951"
}
// 初始状态下,清空按钮不展示
ivClear.visibility = gone
// 初始状态下,弹出搜索框
KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
// 监听输入框,当有内容时更新搜索和X按钮状态
etSearch.addTextChangedListener(object :TextWatcher{
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(char: CharSequence?, start: Int, before: Int, count: Int) {
val input = char?.toString() ?: ""
if(input.isNotEmpty()) {
ivClear.visibility = visible
tvSearch.apply {
textColor = "#F2F4FF"
isEnabled = true
}
}else {
ivClear.visibility = gone
tvSearch.apply {
textColor = "#484951"
isEnabled = false
}
}
}
override fun afterTextChanged(s: Editable?) { }
})
// 监听键盘搜索按钮
etSearch.setOnEditorActionListener { v, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
val input = etSearch.text.toString() ?: ""
if(input.isNotEmpty()) { searchAndHideKeyboard() }
true
} else false
}
// 监听搜索条搜索按钮
tvSearch.setOnClickListener { searchAndHideKeyboard() }
}
// 跳转到搜索页 + 拉长搜索条 + 隐藏搜索按钮 + 隐藏键盘
private fun searchAndHideKeyboard() {
vInputBg.end_toEndOf = parent_id // 拉长搜索框(与父亲右边对齐)
// 跳转到搜索结果页
findNavController(NAV_HOST_ID.toLayoutId()).navigate(
R.id.action_history_to_result,
bundleOf("keywords" to etSearch?.text.toString())
)
tvSearch.visibility = gone
KeyboardUtils.hideSoftInput(etSearch)
}
}

这样写的坏处如下:


1. 业务 & 界面耦合



  • “界面长什么样”和“哪些事件会触发界面重绘”是两个不同的关注点,它们可以独立变化,前者由 UI 设计发起变更,后者由产品发起变更。

  • 耦合增加代码量以及复杂度,高复杂度增加理解难度且容易出错。比如当别人接手该模块看着 1000+ 的 Activity 无所适从时。再比如你修改了界面展示,而另一个同学修改了业务逻辑,合代码时,你俩可能发生冲突,冲突解决不好就会产生 Bug。

  • 高耦合还降低了复用性。界面和业务耦合在一起,使得它们都无法单独被复用。即界面无法复用于另一个业务,而业务也无法复用于另一个界面。


2. 低内聚的界面绘制



  • 同一个控件的绘制逻辑散落在各个地方,分散在不同的方法中,分散在现在和将来的逻辑中(回调)。

  • 低内聚同样也增加了复杂度。就好比玩剧本杀,线索散落在场地的各个角落,你得先搜出线索,然后再将他们拼凑起来,才能形成完整的认知。再比如 y=f(x),唯一x决定唯一y,而低内聚的代码就好比y=f(a,b,c,d),任意一个变化源的改变的都会影响界面状态。当UI变更时极易产生“没改全”的 Bug,对于一个小的 UI 改动,不得不搜索整段代码,找出所有对控件的引用,漏掉一个就是 Bug。


搜索条的业务相对简单,initView()看上去也没那么复杂。如果延续“高业务耦合+低绘制内聚”的写法,当界面越来越复杂之后,1000+ 行的 Activity 不是梦。


用一张图来表达所有的复杂度在 Activity 层铺开:


微信截图_20220903170226.png


业务和界面分离


业务逻辑和界面绘制是两个不同的关注点,它们本可以不在一个层次中被铺开。


MVP 架构引入了 P(Presenter)层用于承载业务逻辑,实现了复杂度分层:


interface SearchPresenter {
// 初始化
fun init()
// 返回
fun backPress()
// 清空关键词
fun clearKeyword()
// 发起搜索
fun search(keyword: String, from: SearchFrom)
// 输入关键词
fun inputKeyword(keyword: String)
}

Presenter 称为业务接口,它将所有界面可以发出的动作都表达成接口中的方法。接口是编程语言中表达“抽象”的手段。这是个了不起的发明,因为它把“做什么”和“怎么做”隔离。


界面会持有一个 Presenter 的实例,把业务逻辑委托给它,这使得界面只需要关注“做什么”,而不需要关注“怎么做”。所以业务接口做到了界面绘制和业务逻辑的解耦。


业务逻辑最终会指导界面如何绘制,在 MVP 中通过View 层界面来表达:


interface SearchView {
fun onInit(keyword: String)
fun onBackPress()
fun onClearKeyword()
fun onSearch()
fun onInputKeyword(keyword:String)
}

Presenter 的实现者会持有一个 View 层接口实例:


class SearchPresenterImpl(private val searchView: SearchView) :SearchPresenter{
override fun init() {
searchView.onInit("")
}
override fun backPress() {
searchView.onBackPress()
}
override fun clearKeyword() {
searchView.onClearKeyword()
}
override fun search(keyword: String, from: SearchFrom) {
searchView.onSearch()
}
override fun inputKeyword(keyword: String) {
searchView.onInputKeyword(keyword)
}
}

Presenter 调用 View 层接口指导界面绘制,界面通过实现 View 层接口实现绘制:


class TemplateSearchActivity : AppCompatActivity(), SearchView {
private val searchPresenter: SearchPresenter = SearchPresenterImpl(this)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
searchPresenter.init()
}
override fun onBackPressed() {
super.onBackPressed()
searchPresenter.backPress()
}
// 实现 View 层接口进行界面绘制
override fun onInit(keyword: String) {...}
override fun onBackPress() {...}
override fun onClearKeyword() {...}
override fun onSearch() {...}
override fun onInputKeyword(keyword:String) {...}
}

分离了个寂寞?


这样的实现太脱裤子放屁了。就好比三楼同事想给五楼同事一样东西,非得叫顺丰快递,然后顺丰又托运给了申通快递。


非也!当持有一个“抽象”而不是“具体实现”时,好事就会发生!


Activity 和抽象的 SearchPresenter 接口互动,就能发生多态,即动态地替换业务逻辑的实现。


比如产品希望做一个实验,把用户分成A/B两组,A组在进入搜索页的同时把上一次用户搜索的历史直接展示在输入框中,B组则是展示今天的搜索热词。


同样的初始化动作,同样的在输入框中键入内容,不同的是获取数据的方式,A组从本地磁盘获取搜索历史,而B组从网络获取搜索热词。


初始化动作对应“做什么”,输入框中键入内容对应“展示什么”,获取数据的方式对应“怎么做”。如果这些逻辑没有分层而都写在一起,那只能通过在 Activity 中的 if-else 实现:


class TemplateSearchActivity : AppCompatActivity() {
val abtest by lazy { intent.getStringExtra("ab-test") }
fun initView() {
if(abTest == "A"){
// 输入框展示搜索历史
} else {
// 输入框展示搜索热词
}
}
}

若这种分类讨论用上瘾,Activity 代码会以极快的速度膨胀,可读性骤降,最糟糕的是一改就容易出 Bug。因为界面绘制没有内聚在一点,而是散落在各种逻辑分支中,不同分支之间的逻辑可能是互斥,或是协同。。。等等总之极其复杂。


有了抽象的 SearchPresenter 就好办了,抽象意味着可以发生多态。


多态是编程语言支持的一种特性,这种特性使得静态的代码运行时可能产生动态的行为,这样一来编程时不需要为类型所烦恼,可以编写统一的处理逻辑而不是依赖特定的类型。”


可见使用多态可以解耦,通过语言内建的机制实现 if-else 的效果:


class TemplateSearchActivity : AppCompatActivity(), SearchView {
private val abtest by lazy { intent.getStringExtra("ab-test") }
// 根据命中实验组构建 SearchPresenter 实例
private val searchPresenter:SearchPresenter by lazy {
when(type){
"A" -> SearchPresenterImplA(this)
"B" -> SearchPresenterImplB(this)
else -> SearchPresenterImplA(this) // 默认进A实验组
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
searchPresenter.init() // 不用做任何修改,也没有 if-else
}

override fun onInit(keyword: String){
etSearch.setText(keyword, TextView.BufferType.EDITABLE)// 不用做任何修改,也没有 if-else
}
}

然后只要实现两个不同的 SearchPresenter 即可:


class SearchPresenterImplA(private val searchView: SearchView) :SearchPresenter{
override fun init() {
val keyword = loadFromLocal()// 拉取持久化的搜索历史
searchView.onInit(keyword)
}
}

class SearchPresenterImplB(private val searchView: SearchView) :SearchPresenter{
override fun init() {
val keyword = loadFromRemote()// 从网络拉取搜索热词
searchView.onInit(keyword)
}
}

若使用依赖注入框架,比如 Dagger2 或 Hilt,还能把根据AB测实验组分类讨论构建 Presenter 实例的逻辑简化,真正做到业务代码中无分类讨论。


如果 SearchPresenter 中只有 init() 的逻辑在 AB 测场景下不同,那上述方案中其余相同的逻辑需要实现两份?


不需要,用装饰者模式就可以复用剩余的行为:


class SearchPresenterImplA(
private val searchView: SearchView,
private val presenter: SearchPresenter // 自己持有自己
) :SearchPresenter{
override fun init() {
val keyword = loadFromLocal()// 拉取持久化的搜索历史
searchView.onInit(keyword)
}
override fun backPress() {
presenter.backPress()// 实现委托给presenter
}
override fun touchSearchBar(text: String, isUserInput: Boolean) {
presenter.touchSearchBar(text, isUserInput)// 实现委托给presenter
}
override fun clearKeyword() {
presenter.clearKeyword()// 实现委托给presenter
}
override fun search(keyword: String, from: SearchFrom) {
presenter.search(keyword, from)// 实现委托给presenter
}
override fun inputKeyword(keyword: String) {
presenter.search(keyword)// 实现委托给presenter
}
}

// 像这样构建 SearchPresenterImplA
class TemplateSearchActivity : AppCompatActivity(), SearchView {
val presenter = SearchPresenterImplA(this, SearchPresenterImplB(this))
}

SearchPresenterImplA 持有另一个 SearchPresenter,并且把剩余方法的实现委托给它。


关于装饰者模式更详细的介绍可以点击使用组合的设计模式 | 美颜相机中的装饰者模式


这样一来,就把“界面长什么样”和“AB测试”解耦,它们分处于不同的层次,前者在 Activity 属于 View 层,后者属于 Presenter 层。解耦的同时也发生了内聚,关于界面绘制的知识都内聚在 Activity,关于业务逻辑的知识都内聚在 Presenter。


假设界面和业务耦合在一起,后果不堪设想。因为业务的变化是飞快的,今天是 AB 测,明天可能是从不同入口进入搜索页,上报不同的埋点。类似这种情况 Activity 的逻辑会被成堆的 if-else 玩坏。


阶段性总结:



界面和业务分层之后(复杂度被分层),它们就能独立变化(高扩展性),独立复用(高复用性),再配合上“面向抽象编程”,使得业务的逻辑分支被巧妙的隐藏起来(复杂度被隐藏)。



有限的内聚


这样的 View 层接口定义会产生一个问题:


class TemplateSearchActivity : AppCompatActivity() {
override fun onBackPress() {
vInputBg.end_toStartOf = ID_SEARCH // 搜索框右侧对齐搜索按钮
ivClear.visibility = visible
}

override fun onClearKeyword() {
vInputBg.end_toStartOf = ID_SEARCH // 搜索框右侧对齐搜索按钮
ivClear.visibility = gone
}

override fun onSearch() {
vInputBg.end_toStartOf = parent_id // 搜索框右侧对齐父容器
}

override fun onInputKeyword(keyword: String) {
ivClear.visibility = if(keyword.isNotEmpty()) visible else gone
}
}
复制代码

一个控件应该长成什么样的代码依然散落在不同方法中,就像上一篇描述的一样。


这样容易发生“改不全”或“功能衰退”的 Bug,比如搜索页新增了一个业务逻辑,一个新的 View 层接口被实现,该接口的实现需要非常小心,因为它修改的控件也会在其他 View 层接口被修改,你得确保它们不会发生冲突。


之所以会这样,是因为“View 层接口面向业务进行抽象”,其实从接口的命名就可以看出。


更好的做法是“在 View 层接口屏蔽业务动作,只关心做怎么样的绘制”:


interface SearchView {
fun initView() // 初始化
fun showClearButton(show: Boolean)// 展示X
fun highlightSearchButton(show: Boolean) // 高亮搜索按钮
fun gotoSearchPage(keyword: String, from: SearchFrom) // 跳转到搜索结果页
fun stretchSearchBar(stretch: Boolean) // 拉伸搜索框
fun showSearchButton(highlight: Boolean, show: Boolean) // 展示搜索按钮
fun clearKeyword(clear:Boolean) // 清空关键词
fun gotoHistoryPage()// 返回历史页
}

这下 View 层接口描述的都是展示怎么样的界面,Presenter 和 Activity 的代码得做相应的修改:


class SearchPresenterImpl(private val searchView: SearchView) :SearchPresenter{
override fun init() {
searchView.initView()
}
override fun backPress() {
searchView.stretchSearchBar(false)
searchView.showSearchButton(true)
searchView.clearKeyword(true)
}
override fun clearKeyword() {
searchView.highlightSearchButton(false)
searchView.showClearButton(false)
searchView.showSearchButton(true)
searchView.stretchSearchBar(false)
searchView.clearKeyword(true)
searchView.gotoHistoryPage()
}
override fun search(keyword: String, from: SearchFrom) {
searchView.gotoSearchPage(keyword, from)
searchView.stretchSearchBar(true)
searchView.showSearchButton(false)
}
override fun inputKeyword(keyword: String) {
if (keyword.isNotEmpty()) {
searchView.showClearButton(true)
searchView.highlightSearchButton(true)
} else {
searchView.showClearButton(false)
searchView.highlightSearchButton(false)
}
}
}

这样的 Presenter 看上去就没那么“脱裤子放屁”了,它不仅仅是一个界面动作的转发者,它包含了一点业务逻辑。


对应的 Activity 修改如下:


class TemplateSearchActivity : AppCompatActivity(), SearchView {
private val searchPresenter: SearchPresenter = SearchPresenterImpl(this)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
searchPresenter.init()
}

override fun onBackPressed() {
super.onBackPressed()
searchPresenter.backPress()
}

override fun initView() {
etSearch.setOnTouchListener { v, event ->
if (event.action == MotionEvent.ACTION_DOWN) {
if (etSearch.text.toString().isNotEmpty())
searchPresenter.onSearchBarTouch(etSearch.text.toString(), true)
}
false
}

tvSearch.onClick = {
searchPresenter.search(etSearch.text.toString(), SearchFrom.BUTTON)
}

etSearch.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}

override fun onTextChanged(char: CharSequence?, start: Int, before: Int, count: Int) {
val input = char?.toString() ?: ""
searchPresenter.inputKeyword(input)
}

override fun afterTextChanged(s: Editable?) {
}
})
etSearch.requestFocus()
KeyboardUtils.showSoftInput(etSearch)
}

override fun showClearButton(show: Boolean) {
ivClear.visibility = if (show) visible else gone
}

override fun gotoSearchPage(keyword: String, from: SearchFrom) {
runCatching {
findNavController(NAV_HOST_ID.toLayoutId()).navigate(
R.id.action_to_result,
bundleOf("keywords" to keyword)
)
}
KeyboardUtils.hideSoftInput(etSearch)
StudioReport.reportSearchButtonClick(keyword, from.typeInt)
}

override fun stretchSearchBar(stretch: Boolean) {
vInputBg.apply {
if (stretch) end_toEndOf = parent_id
else end_toStartOf = ID_SEARCH
}
}

override fun showSearchButton(highlight: Boolean, show: Boolean) {
tvSearch.apply {
visibility = if(show) visible else gone
textColor = if(highlight) "#F2F4FF" else "#484951"
isEnable = highlight
}
}

override fun clearKeyword(clear: Boolean) {
etSearch.apply {
text = null
requestFocus()
KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
}
}

override fun gotoHistoryPage(clear: Boolean) {
findNavController(NAV_HOST_ID.toLayoutId()).popBackStack()
}
}

同一控件的绘制逻辑总算内聚到一个方法中了,但不同控件的绘制逻辑还是散落在不同的方法。


不同控件的显示是有协同或互斥关系的,比如搜索条拉长时,搜索按钮得隐藏。但拉长搜索条和搜索按钮的绘制分处于不同的 View 层接口,这里就有一个潜规则:“在调拉长搜索条方法的同时,必须同时调用隐藏搜索按钮方法”。当 Presenter 中充斥着这种潜规则时,就会发生界面状态不一致的问题。(最常见的比如,列表加载成功后,loading 还在转圈圈)


之所以会这样是因为 MVP 只是在“低内聚的界面绘制”基础上往前进了一小步,做到了单个控件绘制逻辑的内聚。而 MVI 又进了一步,做到了整个界面绘制逻辑的内聚。(实现细节在后面的篇章展开)


经过 MVP 的重构,现在架构如下图所示:


微信截图_20221004170848.png


为啥看上去,比无架构方案还要复杂一点?


没错,MVP 架构引入了新的复杂度。首先是新增一个 Presenter 类,接着还引入了两个接口:业务接口+ View 层接口。这是实现解耦的必要代价。


引入 Presenter 层也有收益,与“复杂度在 View 层被铺开”相比,现在的 View 层要精简得多,也单纯的多。但复杂度被不是凭空消失了,而是被分层,被转移。从图中可以看出现在的复杂度聚集在 Presenter 中业务接口和 View 层接口的交互。MVI 用了一种新的思想方法来化解这个复杂度。(后续篇章会展开分析)


总结



  • MVP 引入了业务逻辑层 P(Presenter),使得界面绘制和业务逻辑分开,降低了它们的耦合,形成相互独立的界面层 V 和业务逻辑层 P。界面代码的复杂度得以降低也变得更加单纯。

  • MVP 通过接口实现界面层和业务逻辑层的双向通信,界面层通过业务接口向业务逻辑层发起请求。业务逻辑层通过 View 层接口指导界面绘制。接口是一种抽象手段,它把做什么和怎么做分离,为发生多态提供了便利。

  • MVP 中 View 层接口的抽象应该面向“界面绘制”而不是“面向业务”。这样做不仅可以让界面绘制逻辑变得内聚,也让增加了代码的复用性。

作者:唐子玄
链接:https://juejin.cn/post/7151809622586687524
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

用 Jetpack Compose 写一个 BottomSheetDialog

BottomSheetDialog 是 Android Material 库中提供的一个弹窗类,其特点就是会从屏幕底部弹出,支持拖拽回弹效果,以及拖拽关闭弹窗,在 Android 应用开发中广泛应用 Jetpack Compose 也提供了一个同样的弹窗效果,...
继续阅读 »

BottomSheetDialog 是 Android Material 库中提供的一个弹窗类,其特点就是会从屏幕底部弹出,支持拖拽回弹效果,以及拖拽关闭弹窗,在 Android 应用开发中广泛应用


Jetpack Compose 也提供了一个同样的弹窗效果,即 Compose Material 库中的 BottomSheetScaffold,其将整体页面分为了 contentsheetContent 两个区域,content 代表的是常驻状态的主屏幕布局,sheetContent 代表的是想从底部弹出的布局


import androidx.compose.material.BottomSheetScaffold

@Composable
private fun BottomSheetScaffoldDemo() {
   BottomSheetScaffold(sheetContent = {

  }, content = {

  })
}

BottomSheetScaffold 完全足以拿来实现 BottomSheetDialog 的效果了,但目前 Google 已经推出了 Material 设计的最新版本,也即 Compose Material 3,而 Material 3 目前并没有提供 BottomSheetScaffold,因此在只想要使用 Material 3 的情况下,我只能自己来实现一个 Compose 版本的 BottomSheetDialog 了


最终的效果如下所示



此 Compose 版本的 BottomSheetDialog 和原生的 Dialog 一样,也支持 cancelablecanceledOnTouchOutside 两个属性,用于控制:是否允许通过点击返回键关闭弹窗、是否允许拖拽关闭弹窗、是否允许通过点击弹窗外部区域来关闭弹窗。此外,此弹窗也无需强制嵌套在某个布局以内,相对 BottomSheetScaffold 来说使用上会更加灵活


来讲下具体的实现思路


先定义好所有需要的参数。visible 属性用于控制弹窗当前是否可见,根据声明式 UI 的特点,该属性就需要交由外部来维护,BottomSheetDialog 再通过 onDismissRequest 方法将关闭弹窗的请求交由外部处理。content 代表的就是弹窗的具体布局


@Composable
fun BottomSheetDialog(
   modifier: Modifier = Modifier,
   visible: Boolean,
   cancelable: Boolean = true,
   canceledOnTouchOutside: Boolean = true,
   onDismissRequest: () -> Unit,
   content: @Composable () -> Unit
)

BottomSheetDialog 通过 BackHandler 来拦截点击返回键的事件,BackHandler 内部是通过原生的 OnBackPressedDispatcher 来实现的,这里设置其只在弹窗可见时才进行拦截,在 cancelable 为 true 时才将拦截的事件转交由外部处理


BackHandler(enabled = visible, onBack = {
   if (cancelable) {
       onDismissRequest()
  }
})

之后需要为弹窗设置一个淡入淡出的半透明背景色,通过 AnimatedVisibility 来实现即可。再通过 clickableNoRipple 拦截页面整体的点击事件,在 canceledOnTouchOutside 为 true 时才将拦截的事件转交由外部处理


AnimatedVisibility(
   visible = visible,
   enter = fadeIn(animationSpec = tween(durationMillis = 400, easing = LinearEasing)),
   exit = fadeOut(animationSpec = tween(durationMillis = 400, easing = LinearEasing))
) {
   Box(
       modifier = Modifier
          .fillMaxSize()
          .background(color = Color(0x99000000))
          .clickableNoRipple {
               if (canceledOnTouchOutside) {
                   onDismissRequest()
              }
          }
  )
}

由于 Compose 提供的 clickable 方法默认会带上水波纹的效果,点击弹窗背景时并不需要,因此我通过自定义的 clickable 方法去掉了水波纹


private fun Modifier.clickableNoRipple(onClick: () -> Unit): Modifier =
   composed {
       clickable(
           indication = null,
           interactionSource = remember { MutableInteractionSource() }) {
           onClick()
      }
  }

由于弹窗的背景色和弹窗内容区域 InnerDialog 应该是上下层叠的关系,所以两者应该位于同个 Box 下,Box 的 Modifier 开放给外部使用者


@Composable
fun BottomSheetDialog(
   modifier: Modifier = Modifier,
   visible: Boolean,
   cancelable: Boolean = true,
   canceledOnTouchOutside: Boolean = true,
   onDismissRequest: () -> Unit,
   content: @Composable () -> Unit
) {
   BackHandler(enabled = visible, onBack = {
       if (cancelable) {
           onDismissRequest()
      }
  })
   Box(modifier = modifier) {
       AnimatedVisibility(
           visible = visible,
           enter = fadeIn(animationSpec = tween(durationMillis = 400, easing = LinearEasing)),
           exit = fadeOut(animationSpec = tween(durationMillis = 400, easing = LinearEasing))
      ) {
           Box(
               modifier = Modifier
                  .fillMaxSize()
                  .background(color = Color(0x99000000))
                  .clickableNoRipple {
                       if (canceledOnTouchOutside) {
                           onDismissRequest()
                      }
                  }
          )
      }
       InnerDialog()
  }
}

InnerDialog 需要有从下往上弹出,并从上往下消失的效果,通过自定义 AnimatedVisibility 的 enterexit 动画即可实现


@Composable
private fun BoxScope.InnerDialog(
   visible: Boolean,
   cancelable: Boolean,
   onDismissRequest: () -> Unit,
   content: @Composable () -> Unit
) {
   AnimatedVisibility(
       modifier = Modifier
          .clickableNoRipple {

          }
          .align(alignment = Alignment.BottomCenter),
       visible = visible,
       enter = slideInVertically(
           animationSpec = tween(durationMillis = 400, easing = LinearOutSlowInEasing),
           initialOffsetY = { 2 * it }
      ),
       exit = slideOutVertically(
           animationSpec = tween(durationMillis = 400, easing = LinearOutSlowInEasing),
           targetOffsetY = { it }
      ),
  ) {
       content()
  }
}

为了能够拖拽弹窗上下移动,这里通过 draggable 方法来检测拖拽手势,用 offsetY 来记录弹窗的 Y 坐标偏移量,同时通过 animateFloatAsState 以动画的形式平滑过度不同的 offsetY 值并触发重组,从而实现弹窗随用户的手势而上下滑动。此外,当用户松手 onDragStopped 时,再将 offsetY 重置为 0,从而实现弹窗拖拽回调的效果


@Composable
private fun BoxScope.InnerDialog(
   visible: Boolean,
   cancelable: Boolean,
   onDismissRequest: () -> Unit,
   content: @Composable () -> Unit
) {
   var offsetY by remember {
       mutableStateOf(0f)
  }
   val offsetYAnimate by animateFloatAsState(targetValue = offsetY)
   AnimatedVisibility(
       modifier = Modifier
          .clickableNoRipple {

          }
          .align(alignment = Alignment.BottomCenter)
          .offset(offset = {
               IntOffset(0, offsetYAnimate.roundToInt())
          })
          .draggable(
               state = rememberDraggableState(
                   onDelta = {
                       offsetY = (offsetY + it.toInt()).coerceAtLeast(0f)
                  }
              ),
               orientation = Orientation.Vertical,
               onDragStarted = {

              },
               onDragStopped = {
                   offsetY = 0f
              }
          ),
       visible = visible,
       enter = slideInVertically(
           animationSpec = tween(durationMillis = 400, easing = LinearOutSlowInEasing),
           initialOffsetY = { 2 * it }
      ),
       exit = slideOutVertically(
           animationSpec = tween(durationMillis = 400, easing = LinearOutSlowInEasing),
           targetOffsetY = { it }
      ),
  ) {
       content()
  }
}

此外,原生的 BottomSheetDialog 还有个特点:当用户向下拖拽的距离不超出某个界限值时,弹窗会有向上回弹恢复的效果;当超出界限值时,则会直接关闭整个弹窗。为了实现这个效果,我们可以定义当用户向下拖拽的偏移量大于弹窗的一半高度时就直接关闭弹窗,否则就让其回弹


通过查看 BottomSheetScaffold 的源码,可以看到其是通过 onGloballyPositioned 方法来拿到整个 sheetContent 的高度,这里可以仿照其思路拿到整个 InnerDialog 的高度 bottomSheetHeight ,在 onDragStopped 方法对比拖拽距离即可


@Composable
private fun BoxScope.InnerDialog(
   visible: Boolean,
   cancelable: Boolean,
   onDismissRequest: () -> Unit,
   content: @Composable () -> Unit
) {
   var offsetY by remember {
       mutableStateOf(0f)
  }
   val offsetYAnimate by animateFloatAsState(targetValue = offsetY)
   var bottomSheetHeight by remember { mutableStateOf(0f) }
   AnimatedVisibility(
       modifier = Modifier
          .clickableNoRipple {

          }
          .align(alignment = Alignment.BottomCenter)
          .onGloballyPositioned {
               bottomSheetHeight = it.size.height.toFloat()
          }
          .offset(offset = {
               IntOffset(0, offsetYAnimate.roundToInt())
          })
          .draggable(
               state = rememberDraggableState(
                   onDelta = {
                       offsetY = (offsetY + it.toInt()).coerceAtLeast(0f)
                  }
              ),
               orientation = Orientation.Vertical,
               onDragStarted = {

              },
               onDragStopped = {
                   if (cancelable && offsetY > bottomSheetHeight / 2) {
                       onDismissRequest()
                  } else {
                       offsetY = 0f
                  }
              }
          ),
       visible = visible,
       enter = slideInVertically(
           animationSpec = tween(durationMillis = 400, easing = LinearOutSlowInEasing),
           initialOffsetY = { 2 * it }
      ),
       exit = slideOutVertically(
           animationSpec = tween(durationMillis = 400, easing = LinearOutSlowInEasing),
           targetOffsetY = { it }
      ),
  ) {
       content()
  }
}

此外,还有个小细节需要注意。当用户向下拖拽关闭了弹窗时,offsetY 可能还不等于 0,这就会导致下次弹出时弹窗还会保持该偏移量,导致弹窗只展示了部分。因此需要当 InnerDialog 退出重组时,手动将 offsetY 重置为 0


DisposableEffect(key1 = null) {
   onDispose {
       offsetY = 0f
  }
}

至此,BottomSheetDialog 就完成了,向 BottomSheetDialog 传入想要展示的布局即可


BottomSheetDialog(
   modifier = Modifier,
   visible = viewState.visible,
   cancelable = true,
   canceledOnTouchOutside = true,
   onDismissRequest = viewState.onDismissRequest
) {
   DialogContent()
}


@Composable
private fun DialogContent(onDismissRequest: () -> Unit) {
   Column(
       modifier = Modifier
          .fillMaxWidth()
          .fillMaxHeight(fraction = 0.7f)
          .clip(shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp))
          .background(color = Color(0xFF009688)),
       verticalArrangement = Arrangement.Center
  ) {
       Button(
           modifier = Modifier
              .align(alignment = Alignment.CenterHorizontally),
           onClick = {
               onDismissRequest()
          }) {
           Text(
               modifier = Modifier.padding(all = 4.dp),
               text = "dismissDialog",
               fontSize = 16.sp
          )
      }
  }
}

这里给出完整的源码:ComposeBottomSheetDialog


作者:业志陈
链接:https://juejin.cn/post/7151792921698631717
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Gradle 缓存那些事儿~

前言 Gradle是Android的构建工具,它的主要目标就是实现快速的编译构建,而这主要就是通过缓存实现的。本文主要介绍Gradle的缓存机制,具体包括以下内容 Gradle缓存机制 Gradle内存缓存 Gradle项目缓存 Gradle本机缓存 Gra...
继续阅读 »

前言


GradleAndroid的构建工具,它的主要目标就是实现快速的编译构建,而这主要就是通过缓存实现的。本文主要介绍Gradle的缓存机制,具体包括以下内容



  1. Gradle缓存机制

  2. Gradle内存缓存

  3. Gradle项目缓存

  4. Gradle本机缓存

  5. Gradle远程缓存


Gradle缓存机制


说起Gradle缓存,我们首先想到的可能就是build-cache,但是Gradle缓存机制远没有这么简单,如下图所示:



纵向来划分的话,Gradle缓存可以划分为配置阶段缓存,执行阶段缓存与依赖缓存三部分


横向来划分的话,Gradle缓存可以划分为内存缓存,项目缓存,本机缓存,远程缓存四个级别


下面我们就按照横向划分的方式来详细介绍一下Gradle的缓存机制


Gradle内存缓存


Gradle内存缓存主要是通过Gradle Daemon进程(即守护进程)实现的


那么Gradle守护进程是什么呢?起什么作用?



守护进程是作为后台进程运行的计算机程序,而不是在交互式用户的直接控制之下



GradleJava 虚拟机 (JVM) 上运行,并使用多个需要大量初始化时间的支持库。因此,有时启动起来似乎有点慢。


而这个问题的解决方案就是 Gradle Daemon:一个长期存在的后台进程,它可以更快地执行构建。主要是通过避免耗时的初始化操作,以及将有关的项目数据保存在内存中来实现


同时是否使用Daemon来执行构建对于使用都是透明的,它们使用起来基本一致,用户只需要配置是否使用它


获取守护进程状态


由于守护进程对用户来说几乎是透明的,因此我们在平常几乎不会接触到Daemon进程,但是当我们执行构建时可能会看到以下提示:


Starting a Gradle Daemon, 1 busy and 6 stopped Daemons could not be reused, use --status for details

这是说目前有6个已经终止的守护进程与一个忙碌的守护进程,因此需要重新启动一个守护进程,我们可以使用./gradlew --status命令来获取守护进程状态,可以获取以下输出


   PID STATUS   INFO
82904 IDLE 7.3.3
81804 STOPPED (stop command received)
50304 STOPPED (by user or operating system)
59118 STOPPED (by user or operating system)

你可能会好奇,为什么我们的机器上会有多个守护进程?


Gradle 将创建一个新的守护进程而不是使用一个已经在运行的守护进程有几个原因。基本规则是,如果没有现有的空闲或兼容的守护程序可用,Gradle 将启动一个新的守护程序。Gradle 将杀死任何闲置 3 小时或更长时间的守护进程,因此您不必担心手动清理它们。


如何停止现有守护进程


如前所述,守护进程是一个后台进程。每个守护进程都会监控其内存使用量与系统总内存的比较,如果可用系统内存不足,则会在空闲时自行停止。如果您出于任何原因想明确停止运行守护进程,只需使用命令./gradlew --stop


或者如果你想直接禁用守护程序的话,您可以通过命令行选项添加--no-daemon,或者在gradle.properties中添加org.gradle.daemon=false


Gradle 3.0之后守护进程默认开启,构建速度得到了很大的提升,因此在通常情况下不建议关闭守护进程


守护进程如何使构建更快?


Gradle 守护进程是一个长期存在的进程。在多次构建之间,它将空闲地等待下一个构建。这有一个明显的好处,即多个构建只需要初始化一次,而不是每个构建一次。


同时现代 JVM 性能优化的一个重要部分是运行时代码优化(即JIT)。例如,HotSpotOracle 提供的 JVM 实现)在代码运行时将对其进行优化。优化是渐进的而不是瞬时的。也就是说,代码在执行过程中逐渐优化,这意味着后续构建可以更快的执行。使用 HotSpot 的实验表明,JIT优化通常需要 5 到 10 次构建才能稳定。因此守护进程的第一次构建和第十次构建之间的构建时间差异可能非常大。


守护进程还允许在多次构建之间进行内存缓存。例如,构建所需的类(例如插件、构建脚本)可以在构建之间保存在内存中。同样,Gradle 可以维护构建数据的内存缓存,例如任务输入和输出的哈希值,用于增量构建。


为了检测文件系统的变化并计算需要重新构建建的内容,Gradle 会在每次构建期间收集有关文件系统状态的大量信息。守护进程可以重用从上次构建中收集的信息并计算出需要重新构建的文件。这可以为增量构建节省大量时间,其中两次构建之间对文件系统的更改次数通常很少


总得来说,守护进程主要做了以下工作:



  1. 在多次构建之间重用,只需初始化一次,节省初始化时间

  2. 虚拟机JIT优化,代码越执行越快,因此在同一个守护进程中构建,后续构建也将越快

  3. 多次构建之中可以对构建脚本,构建插件,构建数据等进行内存缓存,以加快构建速度

  4. 可以检测两次构建之间的文件系统的变化,并计算出需要重新构建的文件,方便增量构建


Gradle项目缓存


在内存缓存之后,就是项目级别的缓存,项目级别的缓存主要存储在根目录的.gradle与各个模块的build目录中,其中configuration-cache存储在.gradle目录中,而各个Task的执行结果存储在我们熟悉的build目录中


配置阶段缓存


我们知道,Gradle 的生命周期可以分为大的三个部分:初始化阶段(Initialization Phase),配置阶段(Configuration Phase),执行阶段(Execution Phase)。



其中任务执行的部分只要处理恰当,已经能够很好的进行缓存和重用——重用已有的缓存是加快编译速度十分关键的一环,如果把这个机制运用到其他阶段当然也能带来一些收益。


仅次于执行阶段耗时的一般是配置阶段, AGP现在也支持了配置阶段缓存 Configuration Cache ,它使得配置阶段的主要产出物:Task Graph 可以被重用


在越大的项目中配置阶段缓存的收益越大,module比较多的项目可能每次执行都要先配置20到30秒,尤其是增量编译时,配置的耗时可能都跟执行的耗时差不多了,而这正是configuration-cache的用武之地


目前configuration-cache还是实验特性,如果你想要开启的话可以在gradle.properties中添加以下代码


# configuration cache
org.gradle.unsafe.configuration-cache=true
org.gradle.unsafe.configuration-cache-problems=warn

第一次使用时会看到计算 Task Graph 的提示:


Calculating task graph as no configuration cache is available for tasks: 

成功后会在 Build 结束时提示:


Configuration cache entry stored.

之后 Cache 就可以被下一次构建复用(如果没有构建脚本修改):


Reusing configuration cache.

...

51 actionable tasks: 2 executed, 49 up-to-date

Configuration cache entry reused.

当然打开Configuration Cache之后可能会有一些适配问题,如果是第三方插件,发现常用插件出现不支持的情况,可先搜索是否有相同的问题已经出现并修复


如果是项目中自定义Task不支持的话,还需要适配一下Configuration Cache,适配Configuration Cache的核心思路其实很简单:不要在Task执行阶段调用外部不可序列化的对象(比如ProjectVariant)


android {
applicationVariants.all { variant ->
def mergeAssetTask = variant.getMergeAssetsProvider().get()
mergeAssetTask.doLast {
project.logger(variant.buildType.name)
}
}
}

如上所示,在doLast阶段调用了projectvariant对象,这两个对象是在配置阶段生成的,但是又无法序列化,因此这段代码无法适配Configuration Cache,需要修改如下:


android {
applicationVariants.all { variant ->
def buildTypeName = variant.buildType.name
def mergeAssetTask = variant.getMergeAssetsProvider().get()
mergeAssetTask.doLast {
logger(buildTypeName)
}
}
}

如上所示,提前读取出buildTypeName,因为它是String类型,可以被序列化,后续在执行阶段调用也没有问题了


总得来说,Configuration Cache适配并不复杂,但如果你的项目中自定义Task比较多的等方面,那可能就是个体力活了,比如 AGP 兼容 Configuration Cache 就修了 400 多个 ISSUE


Task输出缓存


Task输出缓存即我们最熟悉的各模块build目录,当我们调用./gradlew clean时清理的也是这部分缓存



任何构建工具的一个重要部分是避免重复工作。在编译过程中,就是在编译源文件后,除非发生了影响输出的更改(例如源文件的修改或输出文件的删除),无需重新编译它们。因为编译可能会花费大量时间,因此在不需要时跳过该步骤可以节省大量时间。


如上图所示,Task最基本的功能就是接受一些输入,进行一系列运算后生成输出。比如在编译过程中,Java源文件是输入,生成的classes文件是输出。Task的输出通常在build目录


Task的输入没有发生变化,则理论上它的输出也没有发生变化,那么此时该Task就可以标记up-to-date,跳过执行阶段,直接复用上次执行的输出,相信你在多次执行构建的时候看到过这个标记


当然,自定义Task要支持up-to-date需要明确输入与输出,关于具体的细节可以查看:Gradle 进阶(一):深入了解 Tasks


Gradle本机缓存


Gradle本机缓存即Gradle User Home路径下的caches目录,有时当我们运行./gradlew clean之后,重新编译项目还是很快,这是因为还有本机Build Cache的原因


本质上Build Cache与项目内up-to-date检查类似,都是在判断输入没有发生变化时可以直接跳过Task,不同之处在于,Build Cache可以在多个项目间复用


Build Cache开启


默认情况下,Build Cache并未启用。您可以通过以下几种方式启用Build Cache



  1. 在命令行添加--build-cacheGradle 将只为此构建使用Build Cache

  2. gradle.properties中添加org.gradle.caching=true,Gradle 将尝试为所有构建重用以前构建的输出,除非通过--no-build-cache明确禁用.


启用构建缓存后,它将在 Gradle 用户主目录中存储构建输出。


可缓存Task


由于Task描述了它的所有输入和输出,Gradle 可以计算一个构建缓存KeyKey基于其输入唯一地定义任务的输出。该构建缓存Key用于从构建缓存请求先前的输出或将新输出存储在构建缓存中。如果之前的构建输出已经被其他人存储在缓存中,那你就可以直接复用之前的结果


构建缓存Key由以下属性组成,与up-to-date检查类似:



  • Task类型及其classpath

  • 输出属性的名称

  • DSL 通过TaskInputs添加的属性的名称和值

  • Gradle 发行版、buildSrc 和插件的类路径

  • 构建脚本影响任务执行时的内容


同时Task还需要添加@CacheableTask注解以支持构建缓存,需要注意的是@CacheableTask注解不会被子类继承


如果查看源码的话,可以发现JavaCompileKotlinCompileTask都添加了@CacheableTask注解


总得来说,支持构建缓存的Task与支持up-to-dateTask基本一致,只需要添加一个@CacheableTask注解,当up-to-date检查失效时(比如项目内缓存被清除),则会尝试使用构建缓存,如下所示:


> gradle --build-cache assemble 
:compileJava FROM-CACHE
:processResources
:classes
:jar
:assemble

BUILD SUCCESSFUL

如上所示,当build cache命中时,该Task会被标记为FROM-CACHE


本地依赖缓存


除了Build Cache之外,Gradle User Home目录还包括本地依赖缓存,所有远程下载的aar都在cache/modules-2目录下


这些aar可以在本地所有项目间共享,通过这种方式可以有效避免不同项目之间相同依赖的反复下载


需要注意的是,我们应该尽量使用稳定依赖,避免使用动态(Dynamic) 或者快照(SNAPSHOT) 版本依赖


当我们使用稳定依赖版本,当下载成功后,后续再有引用该依赖的地方都可以从缓存读取, 避免缓慢的网络下载


而动态和快照这两种版本引用会迫使 Gradle 链接远程仓库检查是否有更新的依赖可用, 如果有则下载后缓存到本地.默认情况下,这种缓存有效期为 24 小时. 可以通过以下方式调整缓存有效期:


configurations.all {
resolutionStrategy.cacheDynamicVersionsFor(10, "minutes") // 动态版本缓存时效
resolutionStrategy.cacheChangingModulesFor(4, "hours") // 快照版本缓存时效
}

动态版本和快照版本会影响编译速度, 尤其在网络状况不佳的情况下以及该依赖仅仅出现在内部repo的情况下. 因为Gradle会串行查询所有repo, 直到找到该依赖才会下载并缓存. 然而这两种依赖方式失效后就需要重新查询和下载.


同时这动态版本与快照版本也会导致Configuration Cache失效,因此应该尽量使用稳定版本


Gradle远程缓存


镜像repo


Gradle下载aar有时非常耗时,一种常见的操作时添加镜像repo,比如公开的阿里镜像等。或者部署公司内部的镜像repo,以加快在公司网络的访问速度,也是很常见的操作。


关于Gradle仓库配置还有一些小技巧:Gradle 在查找远程依赖的时候, 会串行查询所有repo中的maven地址, 直到找到可用的aar后下载. 因此把最快和最高命中率的仓库放在前面, 会有效减少configuration阶段所需的时间.


除了顺序以外, 并不是所有的仓库都提供所有的依赖, 尤其是有些公司会将业务aar放在内部搭建的仓库上. 这种情况下如果盲目增加repository会让Configuration时间变得难以接受. 我们通常需要将内部仓库放在最前, 同时明确指定哪些依赖可以去这里下载:


repositories {
maven {
url = uri("http://repo.mycompany.com/maven2")
content {
includeGroup("com.test")
}
}
...
}

如上所示,指定了com.testgroup可以去指定的仓库下载


远程Build Cache


上面介绍了本地的Build CacheBuild Cache 可以把之前构建过的 task 结果缓存起来, 一旦后面需要执行该 task 的时候直接使用缓存结果. 与增量编译不同的是, cache 是全局的, 对所有构建都生效.


Build Cache 不仅可以保存在本地($GRADLE_USER_HOME/caches), 也可以使用网络路径。


settings.gradle 中加入如下代码:


// settings.gradle.kts
buildCache {
local<DirectoryBuildCache> {
directory = File(rootDir, "build-cache")

// 编译结果是否同步到本地缓存. local cache 默认 true
push = true

// 无用缓存清理时间
removeUnusedEntriesAfterDays = 30
}

remote<HttpBuildCache> {
url = uri("https://example.com:8123/cache/")

// 编译结果是否同步到远程缓存服务器. remote cache 默认 false
push = false

credentials {
username = "build-cache-user"
password = "some-complicated-password"
}

// 如果遇到 https 不授信问题, 可以关闭校验. 默认 false
isAllowUntrustedServer = true
}
}

通常我们在 CI 编译脚本中 push = true, 而开发人员的机器上 push = false 避免缓存被污染.


当然,要实现Build Cache在多个机器上的共享,需要一个缓存服务器,官方提供了两种方式搭建缓存服务器: Docker镜像和jar包,详情可参考Build Cache Node User Manual,这里就不缀述了


总得来说,远程Build Cache应该也是一个可行的方案,试想如果我们有一个高性能的打包机,当每次打码提交时,都自动编译生成Build Cache,那么开发人员都可以高效地复用同一份Build Cache,以加快编译速度,而不是每次更新代码都需要在本机重新编译


总结


本文主要从内存缓存,项目缓存,本机缓存,远程缓存四个级别详细介绍了Gradle的缓存机制,以及如何使用与配置它们。如果使用得当,相信可以有效地加快你的编译速度。如果本文对你有所帮助,欢迎点赞~


作者:程序员江同学
链接:https://juejin.cn/post/7151931374784479263
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

新来个技术总监,把限流实现的那叫一个优雅,佩服!

大家好,我是楼仔! 在电商高并发场景下,我们经常会使用一些常用方法,去应对流量高峰,比如限流、熔断、降级,今天我们聊聊限流。 什么是限流呢?限流是限制到达系统的并发请求数量,保证系统能够正常响应部分用户请求,而对于超过限制的流量,则通过拒绝服务的方式保证整体系...
继续阅读 »

大家好,我是楼仔!


在电商高并发场景下,我们经常会使用一些常用方法,去应对流量高峰,比如限流、熔断、降级,今天我们聊聊限流。


什么是限流呢?限流是限制到达系统的并发请求数量,保证系统能够正常响应部分用户请求,而对于超过限制的流量,则通过拒绝服务的方式保证整体系统的可用性。


根据限流作用范围,可以分为单机限流和分布式限流;根据限流方式,又分为计数器、滑动窗口、漏桶限令牌桶限流,下面我们对这块详细进行讲解。


常用限流方式


计数器


计数器是一种最简单限流算法,其原理就是:在一段时间间隔内,对请求进行计数,与阀值进行比较判断是否需要限流,一旦到了时间临界点,将计数器清零。


这个就像你去坐车一样,车厢规定了多少个位置,满了就不让上车了,不然就是超载了,被交警叔叔抓到了就要罚款的,如果我们的系统那就不是罚款的事情了,可能直接崩掉了。


程序执行逻辑:



  • 可以在程序中设置一个变量 count,当过来一个请求我就将这个数 +1,同时记录请求时间。

  • 当下一个请求来的时候判断 count 的计数值是否超过设定的频次,以及当前请求的时间和第一次请求时间是否在 1 分钟内。

  • 如果在 1 分钟内并且超过设定的频次则证明请求过多,后面的请求就拒绝掉。

  • 如果该请求与第一个请求的间隔时间大于计数周期,且 count 值还在限流范围内,就重置 count。


那么问题来了,如果有个需求对于某个接口 /query 每分钟最多允许访问 200 次,假设有个用户在第 59 秒的最后几毫秒瞬间发送 200 个请求,当 59 秒结束后 Counter 清零了,他在下一秒的时候又发送 200 个请求。


那么在 1 秒钟内这个用户发送了 2 倍的请求,这个是符合我们的设计逻辑的,这也是计数器方法的设计缺陷,系统可能会承受恶意用户的大量请求,甚至击穿系统。这种方法虽然简单,但也有个大问题就是没有很好的处理单位时间的边界。



不过说实话,这个计数引用了锁,在高并发场景,这个方式可能不太实用,我建议将锁去掉,然后将 l.count++ 的逻辑通过原子计数处理,这样就可以保证 l.count 自增时不会被多个线程同时执行,即通过原子计数的方式实现限流。



为了不影响阅读,代码详见:github.com/lml20070115…



滑动窗口


滑动窗口是针对计数器存在的临界点缺陷,所谓滑动窗口(Sliding window)是一种流量控制技术,这个词出现在 TCP 协议中。滑动窗口把固定时间片进行划分,并且随着时间的流逝,进行移动,固定数量的可以移动的格子,进行计数并判断阀值。



上图中我们用红色的虚线代表一个时间窗口(一分钟),每个时间窗口有 6 个格子,每个格子是 10 秒钟。每过 10 秒钟时间窗口向右移动一格,可以看红色箭头的方向。我们为每个格子都设置一个独立的计数器 Counter,假如一个请求在 0:45 访问了那么我们将第五个格子的计数器 +1(也是就是 0:40~0:50),在判断限流的时候需要把所有格子的计数加起来和设定的频次进行比较即可。


那么滑动窗口如何解决我们上面遇到的问题呢?来看下面的图:



当用户在 0:59 秒钟发送了 200 个请求就会被第六个格子的计数器记录 +200,当下一秒的时候时间窗口向右移动了一个,此时计数器已经记录了该用户发送的 200 个请求,所以再发送的话就会触发限流,则拒绝新的请求。


其实计数器就是滑动窗口啊,只不过只有一个格子而已,所以想让限流做的更精确只需要划分更多的格子就可以了,为了更精确我们也不知道到底该设置多少个格子,格子的数量影响着滑动窗口算法的精度,依然有时间片的概念,无法根本解决临界点问题。



为了不影响阅读,代码详见:github.com/RussellLuo/…



漏桶


漏桶算法(Leaky Bucket),原理就是一个固定容量的漏桶,按照固定速率流出水滴。


用过水龙头都知道,打开龙头开关水就会流下滴到水桶里,而漏桶指的是水桶下面有个漏洞可以出水,如果水龙头开的特别大那么水流速就会过大,这样就可能导致水桶的水满了然后溢出。




图片如果看不清,可单击图片并放大。



一个固定容量的桶,有水流进来,也有水流出去。对于流进来的水来说,我们无法预计一共有多少水会流进来,也无法预计水流的速度。但是对于流出去的水来说,这个桶可以固定水流出的速率(处理速度),从而达到流量整形和流量控制的效果。


漏桶算法有以下特点:



  • 漏桶具有固定容量,出水速率是固定常量(流出请求)

  • 如果桶是空的,则不需流出水滴

  • 可以以任意速率流入水滴到漏桶(流入请求)

  • 如果流入水滴超出了桶的容量,则流入的水滴溢出(新请求被拒绝)


漏桶限制的是常量流出速率(即流出速率是一个固定常量值),所以最大的速率就是出水的速率,不能出现突发流量。



为了不影响阅读,代码详见:github.com/lml20070115…



令牌桶


令牌桶算法(Token Bucket)是网络流量整形(Traffic Shaping)和速率限制(Rate Limiting)中最常使用的一种算法。典型情况下,令牌桶算法用来控制发送到网络上的数据的数目,并允许突发数据的发送。




图片如果看不清,可单击图片并放大。



我们有一个固定的桶,桶里存放着令牌(token)。一开始桶是空的,系统按固定的时间(rate)往桶里添加令牌,直到桶里的令牌数满,多余的请求会被丢弃。当请求来的时候,从桶里移除一个令牌,如果桶是空的则拒绝请求或者阻塞。


令牌桶有以下特点:



  • 令牌按固定的速率被放入令牌桶中

  • 桶中最多存放 B 个令牌,当桶满时,新添加的令牌被丢弃或拒绝

  • 如果桶中的令牌不足 N 个,则不会删除令牌,且请求将被限流(丢弃或阻塞等待)


令牌桶限制的是平均流入速率(允许突发请求,只要有令牌就可以处理,支持一次拿3个令牌,4个令牌...),并允许一定程度突发流量,所以也是非常常用的限流算法。



为了不影响阅读,代码详见:github.com/lml20070115…



Redis + Lua 分布式限流


单机版限流仅能保护自身节点,但无法保护应用依赖的各种服务,并且在进行节点扩容、缩容时也无法准确控制整个服务的请求限制。


而分布式限流,以集群为维度,可以方便的控制这个集群的请求限制,从而保护下游依赖的各种服务资源。


分布式限流最关键的是要将限流服务做成原子化,我们可以借助 Redis 的计数器,Lua 执行的原子性,进行分布式限流,大致的 Lua 脚本代码如下:


local key = "rate.limit:" .. KEYS[1] --限流KEY
local limit = tonumber(ARGV[1])        --限流大小
local current = tonumber(redis.call('get', key) or "0")
if current + 1 > limit then --如果超出限流大小
  return 0
else  --请求数+1,并设置1秒过期
  redis.call("INCRBY", key,"1")
   redis.call("expire", key,"1")
   return current + 1
end

限流逻辑(Java 语言):


public static boolean accquire() throws IOException, URISyntaxException {
    Jedis jedis = new Jedis("127.0.0.1");
    File luaFile = new File(RedisLimitRateWithLUA.class.getResource("/").toURI().getPath() + "limit.lua");
    String luaScript = FileUtils.readFileToString(luaFile);

    String key = "ip:" + System.currentTimeMillis()/1000; // 当前秒
    String limit = "5"; // 最大限制
    List<String> keys = new ArrayList<String>();
    keys.add(key);
    List<String> args = new ArrayList<String>();
    args.add(limit);
    Long result = (Long)(jedis.eval(luaScript, keys, args)); // 执行lua脚本,传入参数
    return result == 1;
}

聊聊其它


上面的限流方式,主要是针对服务器进行限流,我们也可以对容器进行限流,比如 Tomcat、Nginx 等限流手段。


Tomcat 可以设置最大线程数(maxThreads),当并发超过最大线程数会排队等待执行;而 Nginx 提供了两种限流手段:一是控制速率,二是控制并发连接数。


对于 Java 语言,我们其实有相关的限流组件,比如大家常用的 RateLimiter,其实就是基于令牌桶算法,大家知道为什么唯独选用令牌桶么?


对于 Go 语言,也有该语言特定的限流方式,比如可以通过 channel 实现并发控制限流,也支持第三方库 httpserver 实现限流,详见这篇 《Go 限流的常见方法》


在实际的限流场景中,我们也可以控制单个 IP、城市、渠道、设备 id、用户 id 等在一定时间内发送的请求数;如果是开放平台,需要为每个 appkey 设置独立的访问速率规则。


限流对比


下面我们就对常用的线程策略,总结它们的优缺点,便于以后选型。


计数器:



  • 优点:固定时间段计数,实现简单,适用不太精准的场景;

  • 缺点:对边界没有很好处理,导致限流不能精准控制。


滑动窗口:



  • 优点:将固定时间段分块,时间比“计数器”复杂,适用于稍微精准的场景;

  • 缺点:实现稍微复杂,还是不能彻底解决“计数器”存在的边界问题。


漏桶:



  • 优点:可以很好的控制消费频率;

  • 缺点:实现稍微复杂,单位时间内,不能多消费,感觉不太灵活。


令牌桶:



  • 优点:可以解决“漏桶”不能灵活消费的问题,又能避免过渡消费,强烈推荐;

  • 缺点:实现稍微复杂,其它缺点没有想到。


Redis + Lua 分布式限流:



  • 优点:支持分布式限流,有效保护下游依赖的服务资源;

  • 缺点:依赖 Redis,对边界没有很好处理,导致限流不能精准控制。



作者:楼仔
链接:https://juejin.cn/post/7145435951899574302
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

动态规划之打家劫舍二

动态规划(Dynamic Programming)是一种分阶段求解决策问题的数学思想,它通过把原问题分解为简单的子问题来解决复杂问题。 打家劫舍 II 你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着...
继续阅读 »

动态规划(Dynamic Programming)是一种分阶段求解决策问题的数学思想,它通过把原问题分解为简单的子问题来解决复杂问题。


打家劫舍 II


你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。


给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。


示例 1:


输入:nums = [2,3,2]
输出:3
解释:你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。


示例 2:


输入:nums = [1,2,3,1]


输出:4


解释:你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。
  偷窃到的最高金额 = 1 + 3 = 4 。


示例 3:
输入:nums = [1,2,3]
输出:3


思路


与上一篇打家劫舍类似,只不过这里的房屋是环形的,首尾相连,所以第一间房屋和最后一间房屋相邻,因此不能在同一晚上偷窃,那么我们只需要保证不同时偷窃第一间和最后一件就可以了,即:



  1. 偷窃第一间,那么就不能偷窃最后一间,能偷窃的房屋范围就是[0-(n-2)],n为房屋总数。

  2. 不偷窃第一间,那么就可以偷窃最后一件,能偷窃的房屋范围就是[1-(n-1)]。


最后根绝这两种情况下各自偷到的最高金额取出一个最大值,即为全局最高金额。


代码如下:


fun rob(nums: IntArray): Int {
if (nums.isEmpty()) return 0
return if (nums.size == 1) nums[0] else Math.max(
rob198(nums.copyOfRange(0, nums.size - 1)),
rob198(nums.copyOfRange(1, nums.size))
)
}
//上一篇[打家劫舍] https://juejin.cn/post/7150957966324301832
fun rob198(nums: IntArray): Int {
if (nums.isEmpty()) {
return 0
}
val length = nums.size
if (length == 1) {
return nums[0]
}
val dp = IntArray(length)
dp[0] = nums[0]
dp[1] = Math.max(nums[0], nums[1])
for (i in 2 until length) {
dp[i] = Math.max(dp[i - 2] + nums[i], dp[i - 1])
}
return dp[length - 1]
}

作者:晚来天欲雪_
链接:https://juejin.cn/post/7151303139973890078
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

动态规划之打家劫舍

动态规划(Dynamic Programming)是一种分阶段求解决策问题的数学思想,它通过把原问题分解为简单的子问题来解决复杂问题。 打家劫舍 你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连...
继续阅读 »

动态规划(Dynamic Programming)是一种分阶段求解决策问题的数学思想,它通过把原问题分解为简单的子问题来解决复杂问题。


打家劫舍


你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。


给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。


示例 1:
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
  偷窃到的最高金额 = 1 + 3 = 4 。


示例 2:
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
  偷窃到的最高金额 = 2 + 9 + 1 = 12 。


思路


根据题意我们知道不能不同偷窃相邻的两个房间,即:



  1. 如果偷窃第i间房屋,那么就不能偷窃第i-1间房屋,一夜之内能够偷窃到的最高金额为前 i-2 间房屋的最高总金额与第i间房屋的金额之和。

  2. 如果不偷窃第i间房屋,那么能够偷窃到的最高金额为前 i-1 间房屋的最高总金额。


所以如果房间总数为i,那么一晚上能偷到的最大金额为dp[i]=max(dp[i−2]+house[i],dp[i−1])。


考虑3种特殊情况:



  1. 没有房间,偷无可偷,最大金额为0。

  2. 只有一间房,那么直接偷窃该房屋。即dp[0]=house[0]。

  3. 总共有2间房,因为不能偷窃相邻的,所以只能选金额最大的偷窃,即dp[1]=math(house[0],house[1])。


代码如下:


fun rob(nums: IntArray): Int {
if (nums.isEmpty()) {
return 0
}
val length = nums.size
if (length == 1) {
return nums[0]
}
val dp = IntArray(length)
dp[0] = nums[0]
dp[1] = Math.max(nums[0], nums[1])
for (i in 2 until length) {
dp[i] = Math.max(dp[i - 2] + nums[i], dp[i - 1])
}
return dp[length - 1]
}

复杂度分析



  • 时间复杂度:O(n),只需跑一遍房屋即可。

  • 空间复杂度:O(n)。使用数组存储了房屋的最高总金额。

作者:晚来天欲雪_
链接:https://juejin.cn/post/7150957966324301832
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

什么时候要用到本地缓存,比Redis还要快?怎么用?

导言试想一下这么一个场景,一用户想要把他看了好长时间的极速版视频积攒的余额提现,于是他点击了提现按钮,哗啦声一响,他的钱就到银行卡了。这样一个对于用户很简单的动作但是对于后台往往牵扯到十几个服务(公司规模越大、规范性要求越高,整个调用链路的服务就越多),而你负...
继续阅读 »

导言

试想一下这么一个场景,一用户想要把他看了好长时间的极速版视频积攒的余额提现,于是他点击了提现按钮,哗啦声一响,他的钱就到银行卡了。这样一个对于用户很简单的动作但是对于后台往往牵扯到十几个服务(公司规模越大、规范性要求越高,整个调用链路的服务就越多),而你负责了一个交叉验证的服务,主要负责校验上游传递给你的记账标识、资金流标识、付款方账号、收款方账号是否和最初申请配置的一样。

为了产品的良好体验,大老板要求请求耗时最多1s要让用户看到结果,于是各个服务的负责人battle了一圈,给你的这个服务只预留了50ms的时间。你一想,这还不简单,直接Redis缓存走起来。Redis那一套霹雳啪撒一顿输出,测试环境也没有一点问题,结果上线后傻眼了,由于网络波动等原因你的服务经常超时,组长责令你尽快解决,再因为你的服务超时导致他被大老板骂,你的绩效就别想了。这种时候,你该怎么优化呢?

理论

要想做到比Redis还要快,首先要知道Redis为什么快,最直接的原因就是Redis所有的数据都在内存中,从内存中取数据库比从硬盘中取数据要快几个数量级。

那么想比Redis还要快,只能在数据传输上下功夫,把不同服务器之间、甚至不同进程之间的数据传输都省略掉,直接把数据放在JVM中,写个ConcurrentMap用于保存数据,但是既然是缓存,肯定还要整一套删除策略、最大空间限制、刷新策略等等。自己手撸一套代价太大了,肯定有大公司有类似的场景把这样的工作已经给做了,而且他还想赚个好名声,github里搜一搜,肯定有现成的解决方案。于是今天我们的主角就出场了,Guava Cache.

实践

首先用一段代码整体介绍一下Guava Cache的使用方式,Cache整体分为CacheBuilder和CacheLoader两个部分,CacheBuilder负责创建缓存对象,再创建的时候配置最大容量、过期方式、移除监听器,CacheLoader负责根据key来加载value。

LoadingCache<Key, Config> configs = CacheBuilder.newBuilder()
.maximumSize(5000)
.expireAfterWrite(30, TimeUnit.MINUTES)
.removalListener(MY_LISTENER)
.build(
new CacheLoader<Key, Config>() {
@Override
public Graph load(Key key) throws AnyException {
return loadFromRedis(key);
}
});

适用场景

  1. 凡事都是有代价的,你愿意接受占用内存空间的代价来提升速度
  2. 存储占据的数据量不至于太大,太大会导致Out of Memory异常
  3. 同一个Key会被访问很多次。

CacheLoader

CacheLoader并不是一定要在build的指定,如果你的数据有多种加载方式,可以使用callable的方式。

  cache.get(key, new Callable<Value>() {
@Override
public Value call() throws AnyException {
return doThingsTheHardWay(key);
}
});

过期策略

过期策略分为大小基准和时间基准,大小基准可以通过CacheBuilder.maximumSize(long)CacheBuilder.maximumWeight(long).指定,maximumSize(long)适用于每个值占用的空间基本上相等或者差异可以忽略不计,只看key的数量,而maximumWeight(long)则会计算出每个值所占据的权重,并保证总权重不大于设置值,其中每个值的计算方式可以通过weigher(Weigher)进行设置。

时间基础就很好理解,分为expireAfterAccess(long, TimeUnit)expireAfterWrite(long, TimeUnit),分别是读多久后失效和写入缓存后多久失效,读失效事每次读缓存都会为该值续命。

刷新策略

CacheBuilder.refreshAfterWrite(long, TimeUnit) 方法提供了自动刷新的能力,需要注意的是,如果没有重写reload方法,那么只有当重新查到该key的时候,才会进行刷新操作。

总结

抛出了问题总要给出答案,不然就太监了,那么导言中的问题我是如何做的呢,我通过guava cache和redis整了一套二级缓存,并且在服务启动时进行了扫表操作,将所有的配置内容都预先放到guava cache中。guava的刷新时间设置为五分钟,并重写了刷新操作强制进行刷新,redis的过期时间设置为一天,并且在数据库内容更新后,删除对应Redis缓存中的值。如此便可以保证,绝大多数情况下都能命中本地中的guava 缓存,且最多有5分钟的数据不一致(业务可以接受)。 凡事必有代价,作为一名后端开发就是要在各种选择之间进行选择,选出一条代价可接受、业务能接受的方案。


作者:日暮与星辰之间
链接:https://juejin.cn/post/7146946847465013278
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

Android实现定时任务的几种方案汇总

前言 相比Android倒计时的常用场景,定时任务相对来说使用的场景相对没那么多,除非一些特殊的设备或者一些特殊的场景我们会用到。 关于定时任务其实是分为2中作用范围,App内部范围和App外部范围,也就是说你是否需要App杀死了还能执行定时任务,需求不同实现...
继续阅读 »

前言


相比Android倒计时的常用场景,定时任务相对来说使用的场景相对没那么多,除非一些特殊的设备或者一些特殊的场景我们会用到。


关于定时任务其实是分为2中作用范围,App内部范围和App外部范围,也就是说你是否需要App杀死了还能执行定时任务,需求不同实现的方式也不同,我们来看看都如何实现。


一、App内部范围


其实App内部范围的定时任务,我们可以使用倒计时的方案,Handler天然就支持。其实我们并不是需要每一次都使用一些系统服务让App外部范围生效。


比如场景如下,机器放在公司前台常亮并且一直运行在前台的,我需要没间隔60秒去查询当前设备是否在线,顺便更新一下当前的时间,显示早上好,中午好,下午好。


这样的场景我不需要使用一些系统服务,使用App内部范围的一些定时任务即可,因为就算App崩溃了,就算有系统级别的定时任务,我App不在了也没有用了,所以使用内部范围的定时任务即可,杀鸡焉用牛刀。


之前的倒计时方案改造一番几乎都能实现这样的定时任务,例如:


    private var mThread: Thread = Thread(this)
private var mflag = false
private var mThreadNum = 60

override fun run() {
while (mflag && mThreadNum >= 0) {
try {
Thread.sleep(1000 * 60)
} catch (e: InterruptedException) {
e.printStackTrace()
}

val message = Message.obtain()
message.what = 1
message.arg1 = mThreadNum
handler.sendMessage(message)

mThreadNum--
}
}

private val handler = Handler(Looper.getMainLooper()) { msg ->

if (msg.what == 1) {
val num = msg.arg1
//由于需要主线程显示UI,这里使用Handler通信
YYLogUtils.w("当时计数:" + num)
}

true
}

//定时任务
fun backgroundTask() {

if (!mThread.isAlive) {

mflag = true

if (mThread.state == Thread.State.TERMINATED) {
mThread = Thread(this@DemoCountDwonActivity)
if (mThreadNum == -1) mThreadNum = 60
mThread.start()
} else {
mThread.start()
}
} else {

mflag = false

}

}


这样每60秒就能执行一次任务,并且不受到系统的限制,简单明了。(只能在App范围内使用)


倒计时的一些的一些方案我们都能改造为定时任务的逻辑,比如上面的Handler,还有Timer的方式,Thread的方式等。


除了倒计时的一些方案,我们额外的还能使用Java的线程池Api也能快速的实现定时任务,周期性的执行逻辑,例如:



val executorService: ScheduledExecutorService = Executors.newScheduledThreadPool(3)
val command = Runnable {
//dosth
}
executorService.scheduleAtFixedRate(command, 0, 3, TimeUnit.SECONDS)

// executorService.shutdown() //如果想取消可以随时停止线程池

定时执行任务在平常的开发中相对不是那么多,比如特殊场景下,我们需要轮询请求。


比如我做过一款应用放在公司前台,我就需要轮询请求每180秒调用服务器接口,告诉服务器当前设备是否在线。便于后台统计,这个是当前App内部生命周期执行的,用在这里刚刚好。


又比如我们使用DownloadManager来下载文件,因为不能直接回调进度,需要我们手动的调用Query去查询当前下载的消息,和文件的总大小,计算当前的下载进度,我们就可以使用轮询的方案,每一秒钟调用一次Query获取进度,模拟了下载进度的展示。


二、App外部范围


有内部范围的定时任务了,那么哪一种情况下我们需要使用外部范围的定时任务呢?又如何使用外部范围的定时任务呢?


还是上面的场景,机器放在公司前台常亮并且一直运行在前台的,这个App我们需要自动更新,并且检查是否崩溃了或者在前台,不管App是否存在我们都需要自行的定时任务,超过App的生命周期了,我们需要使用系统服务的定时任务来做这些事情。


都有哪些系统服务可以完成这样的功能呢?


2.1 系统服务的简单对比与原理

AlarmManager JobSchedule WorkManager !三者又有哪些不同呢?


AlarmManager 和 JobSchedule 虽然都是系统服务,但是方向又不同,AlarmManager 是通过 AlarmManagerService 控制RTC芯片。


说起Alar就需要说到RTC,说到RTC就需要讲到WakeLock机制。


都是一些比较底层的原理,我不会具体展开,大家有兴趣可以自行搜索,或者参考


Android对RTC时间的操作流程


话说回来,AlarmManage有一个 AlarmManagerService ,该服务程序主要维护 app 注册下来的各类Alarm, 并且一直监听 Alarm 设备, 一旦有 Alarm 触发,或者是 Alarm 事件发生,AlarmManagerService 就会遍历 Alarm 列表,找到相应的注册 Alarm 并发出广播. 首先, Alarm 是基于 RTC 实时时钟计时, 而不是CPU计时; 其次, Alarm 会维持一个 CPU 的 wake lock, 确保 Alarm 广播能被处理。


JobSchedule则是完全Android系统级别的定时任务,如有感兴趣的可以参考文章


Android之JobScheduler运行机制源码分析


他们之间的区别是,AlarmManager 最终是操作硬件,设备开机通电和关机就会丢失Alarm任务,而 JobSchedule 是系统级别的任务,就算重启设备也会继续执行。并且相较来说 AlarmManager 可以做到精准度可以比 JobSchedule 更加好点。


而 WorkManager 则是对JobSchedule的封装与兼容处理,6.0以上版本内部实现JobSchedule,一下的版本提供 AlarmManager 。提供的统一的Api实现相同的功能。


所以在2022年的今天,系统级别的定时任务就只推荐用 AlarmManager(短时间) 或者 WorkManager(长时间)了。


2.2 AlarmManager实现定时任务

由于不是基础教程,如果要这里要讲一下基本使用,我估计这一篇文章都讲不完,如果想看AlarmManager的使用教程,可以看这里


Android中的AlarmManager的使用


由于各版本的不同使用的方式不同
API > 19的时候不能设置为循环 需要设置为单次的发送 然后在广播中再次设置单次的发送。


当API >23 当前手机版本为6.0的时候有待机模式的省点优化 需要重新设置。


当设备为Android 12,如果使用到了AlarmManager来设置定时任务,并且设置的是精准的闹钟(使用了setAlarmClock()、setExact()、setExactAndAllowWhileIdle()这几种方法),则需要确保SCHEDULE_EXACT_ALARM权限声明且打开,否则App将崩溃。


需要在AndroidManifest.xml清单文件中声明 SCHEDULE_EXACT_ALARM 权限


最终我们兼容所有的做法是,只开启一个定时任务,然后触发到广播,然后再广播中再次启动一个定时任务,依次循环,嗯,很有Handler的味道。


例如我们设置一个 AlarmManager ,每180秒检查一下 App 是否存活,如果 App 不在了就拉起 App 跳转MainActivity。(需求是当App杀死了也能启动首页,所以不适用于App内的定时执行方案)


    //定时任务
fun backgroundTask() {

//开启3分钟的闹钟广播服务,检测是否运行了首页,如果退出了应用,那么重启应用
val alarmManager = applicationContext.getSystemService(ALARM_SERVICE) as AlarmManager

val intent1 = Intent(CommUtils.getContext(), AlarmReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(CommUtils.getContext(), 0, intent1, PendingIntent.FLAG_UPDATE_CURRENT)

//先取消一次
alarmManager.cancel(pendingIntent)

//再次启动,这里不延时,直接发送
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime(), pendingIntent)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
alarmManager.setExact(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime(), pendingIntent)
} else {
alarmManager.setRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime(), 18000, pendingIntent)
}

YYLogUtils.w("点击按钮-开启 alarmManager 定时任务啦")

}

点击按钮就发送一个立即生效的闹钟,逻辑走到广播中,然后再广播中再次开启闹钟。


class AlarmReceiver : BroadcastReceiver() {

override fun onReceive(context: Context, intent: Intent?) {

val alarmManager = context.getSystemService(ALARM_SERVICE) as AlarmManager

//执行的任务
val intent1 = Intent(CommUtils.getContext(), AlarmReceiver::class.java)
val pendingIntent: PendingIntent = PendingIntent.getBroadcast(context, 0, intent1, PendingIntent.FLAG_UPDATE_CURRENT)

// 重复定时任务,延时180秒发送
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + 180000, pendingIntent)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
alarmManager.setExact(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + 180000, pendingIntent)
} else {
alarmManager.setRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime(), 180000, pendingIntent);
}

YYLogUtils.w("AlarmReceiver接受广播事件 ====> 开启循环动作")

//检测Activity栈里面是否有MainActivity
if (ActivityManage.getActivityStack() == null || ActivityManage.getActivityStack().size == 0) {
//重启首页
// context.gotoActivity<DemoMainActivity>()
} else {
YYLogUtils.w("不需要重启,已经有栈在运行了 Size:" + ActivityManage.getActivityStack().size)
}
}

}

打印日志,由于间隔时间太长,我手机放者,直接把Log保持到Log文件,导出出来,截图如下:



最后是杀死App之后收到的广播。


杀死App手机放了一会,我去个洗手间回来,看看打印日志情况。



2.3 WorkManager实现定时任务

同样的如果不清楚WorkManager的基础使用,推荐大家看看教程


Android架构组件WorkManager详解


WorkManager的使用相对来说也比较简单, WorkManager组件库里面提供了一个专门做周期性任务的类PeriodicWorkRequest。但是PeriodicWorkRequest类有一个限制条件最小的周期时间是15分钟。


WorkManager 比较适合一些比较长时间的任务。还能设置一些约束条件,比如我们每24小时,在设备充电的时候我们就上传这一整天的Log文件到服务器,比如我们每隔12小时就检查应用是否需要更新,如果需要更新则自动下载安装(需要指定Root设备)。


场景如下,还是那个放在公司前台常亮并且一直运行在前台的平板,我们每12小时就检查自动更新,并自动安装,由于之前写了 AlarmManager 所以安装成功之后App会自动打开。


伪代码如下:


        Data inputData2 = new Data.Builder().putString("version", "1.0.0").build();
PeriodicWorkRequest checkVersionRequest =
new PeriodicWorkRequest.Builder(CheckVersionWork.class, 12, TimeUnit.HOURS)
.setInputData(inputData2).build();

WorkManager.getInstance().enqueue(checkVersionRequest);
WorkManager.getInstance().getWorkInfoByIdLiveData(checkVersionRequest.getId()).observe(this, workInfo -> {
assert workInfo != null;
WorkInfo.State state = workInfo.getState();

Data data = workInfo.getOutputData();
String url = data.getString("download_url", "");
//去下载并静默安装Apk
downLoadingApkInstall(url)
});

/**
* 间隔12个小时的定时任务,检测版本的更新
*/
public class CheckVersionWork extends Worker {

public CheckVersionWork(@NonNull Context context, @NonNull WorkerParameters workerParams) {
super(context, workerParams);
}

@Override
public void onStopped() {
super.onStopped();
}

@NonNull
@Override
public Result doWork() {
Data inputData = getInputData();
String version = inputData.getString("version");

//接口获取当前最新的信息

//对比当前版本与服务器版本,是否需要更新

//如果需要更新,返回更新下载的url

Data outputData = new Data.Builder().putString("key_name", inputData.getString("download_url", "xxxxxx")).build();
//设置输出数据
setOutputData(outputData);

return Result.success();
}
}

这个时间太长了不好测试,不过是我之前自用的代码,没什么问题,哪天有时间做个Demo把日志文件导出来看看才能看出效果。


那除此之外我们一些Log的上传,图片的更新,资源或插件的下载等,我们都可以通过WorkManager来实现一些后台的操作,使用起来也是很简单。


总结


这里我直接给出了一些特定的场景应该使用哪一种定时任务,如果大家的应用场景适合App内部的定时任务,应该优先选择内部的定时任务。


App外的定时任务,都是系统服务的定时任务,不一定保险,毕竟是和厂商(特别是国内的厂商)作对,厂商会想方设法杀死我们的定时任务,毕竟有风险。


关于系统服务的定时任务我感觉自己讲的不是很好,好在给出了一些方案和一些文章,大家如果对一些基础的使用或者底层原理感兴趣,可以自行了解一下。


关于系统服务的周期任务的使用如果有错误,或者版本兼容的问题,又或者有更多或更好的方法,也可以在评论区交流讨论。


如果感觉本文对你有一点点点的启发,还望你能点赞支持一下,你的支持是我最大的动力。


Ok,这一期就此完结。



作者:newki
链接:https://juejin.cn/post/7130793502933254180
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

给你的 Android App 添加自定义表情

原理 添加自定义表情的原理其实很简单,就是使用 ImageSpan 对文字进行替换。代码如下: ImageSpan imageSpan = new ImageSpan(this, R.drawable.emoji_kelian); SpannableStrin...
继续阅读 »

原理


添加自定义表情的原理其实很简单,就是使用 ImageSpan 对文字进行替换。代码如下:


ImageSpan imageSpan = new ImageSpan(this, R.drawable.emoji_kelian);
SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder("哈哈哈哈[可怜]");
spannableStringBuilder.setSpan(imageSpan, 4, spannableStringBuilder.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
textView.setText(spannableStringBuilder);

上面的代码把 [可怜] 文字替换成了对应的表情图片。效果如下图,可以看到图片的大小不符合预期,这是因为 ImageSpan 会显示成图片原来的大小。


image.png


ImageSpan 的继承关系图如下,出现了 ReplacementSpanDynamicDrawableSpan 两个新的类,先来看一下它们。MetricAffectingSpanCharacterStyle 接口在 Android Span 原理解析 介绍了,这里就不赘述了。


image.png


ReplacementSpan 接口


ReplacementSpan 是一个接口,看名字是用来替换文字的。它里面定义了两个方法,如下所示。



public abstract int getSize(@NonNull Paint paint,
CharSequence text,
@IntRange(from = 0) int start,
@IntRange(from = 0) int end,
@Nullable Paint.FontMetricsInt fm);

返回替换后 Span 的宽,上面的例子中就是返回图片的宽度,参数作用如下:



  • paint: Paint 的实例

  • text: 当前文本,上面的例子中它的值是是 哈哈哈哈[可怜]

  • start: Span 的开始位置,这里是 4

  • end: Span 的结束位置,这里是 8

  • fm: FontMetricsInt 的实例


FontMetricsInt 是描述给定文本大小的字体的各种度量的类。内部属性代表的含义如下图:



  • Top:图中紫线的位置

  • Ascent: 图中绿线的位置

  • Descent: 图中蓝线的位置

  • Bottom: 图中黄线的位置

  • Leading: 未在图中标出,是指上一行的 Bottom 与下一行的 Top 之间的距离。


图片来源 Meaning of top, ascent, baseline, descent, bottom, and leading in Android's FontMetrics


image.png


Baseline 是文字绘制的基准线。它不定义在 FontMetricsInt 中,但可以通过 FontMetricsInt 的属性获取。


上面讲到 getSize 方法只返回宽度,那高度是怎么确定的呢?其实它是通过 FontMetricsInt 来控制,不过这里有个坑,后面会说到。



public abstract void draw(@NonNull Canvas canvas,
CharSequence text,
@IntRange(from = 0) int start,
@IntRange(from = 0) int end,
float x,
int top,
int y,
int bottom,
@NonNull Paint paint);

在 Canvas 中绘制 Span。参数如下:



  • canvas:Canvas 实例

  • text:当前文本

  • start:Span 的开始位置

  • end:Span 的结束位置

  • x:[可怜] 的 x 坐标位置

  • top:当前行的 “Top“ 属性值

  • y:当前行的 Baseline

  • bottom: 当前行的 ”Bottom“ 属性值

  • paint:Paint 实例,可能为 null


这里需要特殊注意 TopBottom,跟上面说的有点不同这里先记住,后面会一起介绍。


DynamicDrawableSpan


DynamicDrawableSpan 实现了 ReplacementSpan 接口的方法。同时它是一个抽象类,定义了 getDrawable 抽象方法,由 ImageSpan 实现来获取 Drawable 实例。源码如下:


@Override

public int getSize(@NonNull Paint paint, CharSequence text,
@IntRange(from = 0) int start, @IntRange(from = 0) int end,
@Nullable Paint.FontMetricsInt fm) {

Drawable d = getCachedDrawable();
Rect rect = d.getBounds();

//设置图片的高
if (fm != null) {
fm.ascent = -rect.bottom;
fm.descent = 0;
fm.top = fm.ascent;
fm.bottom = 0;
}
return rect.right;
}

@Override

public void draw(@NonNull Canvas canvas, CharSequence text,
@IntRange(from = 0) int start, @IntRange(from = 0) int end, float x,
int top, int y, int bottom, @NonNull Paint paint) {

Drawable b = getCachedDrawable();
canvas.save();

int transY = bottom - b.getBounds().bottom;
//设置对齐方式,有三种分别是
//ALIGN_BOTTOM 底部对齐,默认
//ALIGN_BASELINE 基线对齐
//ALIGN_CENTER 居中对齐
if (mVerticalAlignment == ALIGN_BASELINE) {
transY -= paint.getFontMetricsInt().descent;
} else if (mVerticalAlignment == ALIGN_CENTER) {
transY = top + (bottom - top) / 2 - b.getBounds().height() / 2;
}

canvas.translate(x, transY);
b.draw(canvas);
canvas.restore();
}

public abstract Drawable getDrawable();

DynamicDrawableSpan 有两个坑需要特别注意。


第一个坑就是在 getSize 中的 Paint.FontMetricsInt 对象和 draw 方法中通过 paint.getFontMetricsInt() 获取的不是一个对象。也就是说,无论我们在 getSizePaint.FontMetricsInt 中设置什么值,都不会影响到 paint.getFontMetricsInt() 获取对象中的值。它影响的是 topbottom 的值,这也是刚才介绍参数时给 Top 和 Bottom 打引号的原因。


第二个坑是 ALIGN_CENTER图片大小超过文字大小时“不起作用”。如下图所示,为了方便显示我加了辅助线,白线是代表参数 top,bottom,但是 bottom 被其它颜色覆盖了。可以看到,图片是居中的,是文字没有居中让我们看上去 ALIGN_CENTER 没有效果一样。


image.png


去掉辅助线后,看上去更明显一些。


image.png


ImageSpan


ImageSpan 就简单多了,它只实现了 getDrawable() 方法来获取 Drawable 实例,代码如下:


@Override
public Drawable getDrawable() {

Drawable drawable = null;
if (mDrawable != null) {

drawable = mDrawable;

} else if (mContentUri != null) {

Bitmap bitmap = null;
try {
InputStream is = mContext.getContentResolver().openInputStream(
mContentUri);
bitmap = BitmapFactory.decodeStream(is);
drawable = new BitmapDrawable(mContext.getResources(), bitmap);
drawable.setBounds(0, 0, drawable.getIntrinsicWidth(),
drawable.getIntrinsicHeight());
is.close();
} catch (Exception e) {
Log.e("ImageSpan", "Failed to loaded content " + mContentUri, e);
}

} else {
try {
drawable = mContext.getDrawable(mResourceId);
drawable.setBounds(0, 0, drawable.getIntrinsicWidth(),
drawable.getIntrinsicHeight());
} catch (Exception e) {
Log.e("ImageSpan", "Unable to find resource: " + mResourceId);
}
}
return drawable;
}

这里代码很简单,我们唯一需要关注的就是获取 Drawable 时,需要设置它的宽高,让它别超过文字的大小。


实现


说完前面的原理后,实现起来就非常简单了。我们只需要继承 DynamicDrawableSpan,实现 getDrawable() 方法,让图片的宽高别超过文字的大小就行了。效果如下图所示:



public class EmojiSpan extends DynamicDrawableSpan {

@DrawableRes
private int mResourceId;

private Context mContext;

private Drawable mDrawable;

public EmojiSpan(@NonNull Context context, int resourceId) {
this.mResourceId = resourceId;
this.mContext = context;
}

@Override

public Drawable getDrawable() {

Drawable drawable = null;

if (mDrawable != null) {

drawable = mDrawable;

} else {
try {
drawable = mContext.getDrawable(mResourceId);
drawable.setBounds(0, 0, 48, 48);
} catch (Exception e) {
e.printStackTrace();
}
}
return drawable;
}
}

image.png


上面看上去很完美,但是事情没有那么简单。因为我们只是写死了图片的大小,并没有改变图片位置绘制的算法。如果其他地方使用了 EmojiSpan ,但是文字的大小小于图片大小时还是会出问题。如下图,当文字的 textsize 为 10sp 时的情况。


image.png


实际上,文字大于图片大小时也有问题。如下图所示,多行的情况下,只有表情的行间距明显小于其他行的间距。


image.png


如果大家对这个的解决办法感兴趣的话,点赞+收藏数 >= 40,我就复刻一下B站的自定义表情,加上会动的自定义表情(实际上是 Gif 图)。


作者:新一代螺丝工
链接:https://juejin.cn/post/7144527505394368525
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

如何解决Flutter在Android的适配错乱问题

前言大家好,我是未央歌,一个默默无闻的移动开发搬砖者~先给大家说说项目背景,项目原为 Android 原生开发,所用语言为 Java/Kotlin ;后面引入了 Flutter 混编,如果大家有兴趣,评论区留言,后面再单独开一篇文章为大家讲解一下如何实现 An...
继续阅读 »

前言

大家好,我是未央歌,一个默默无闻的移动开发搬砖者~

先给大家说说项目背景,项目原为 Android 原生开发,所用语言为 Java/Kotlin ;后面引入了 Flutter 混编,如果大家有兴趣,评论区留言,后面再单独开一篇文章为大家讲解一下如何实现 Android 和 Flutter 混编。

Android 适配

说到适配,Android 原生端大家通常采用今日头条适配方案:AndroidAutoSize 。而 AndroidAutoSize的使用也非常简单,按照以下步骤填写全局设计图尺寸 (单位 dp),无需另外操作。

step1

dependencies {
implementation 'com.github.JessYanCoding:AndroidAutoSize:v1.2.1'
}

step2

<manifest>
<application>
<meta-data
android:name="design_width_in_dp"
android:value="375"/>
<meta-data
android:name="design_height_in_dp"
android:value="667"/>
</application>
</manifest>

Flutter 适配

而 Flutter 大家常采用的适配方案则是 flutter_screenutil 。传入设计稿的宽度和高度,进行初始化(只需设置一次)即可。

step1

dependencies:
flutter:
sdk: flutter
# add flutter_screenutil
flutter_screenutil: ^{latest version}

step2

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
//填入设计稿中设备的屏幕尺寸,单位dp
return ScreenUtilInit(
designSize: const Size(375, 667),
minTextAdapt: true,
splitScreenMode: true,
builder: (context , child) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'First Method',
// You can use the library anywhere in the app even in theme
theme: ThemeData(
primarySwatch: Colors.blue,
textTheme: Typography.englishLike2018.apply(fontSizeFactor: 1.sp),
),
home: child,
);
},
child: const HomePage(title: 'First Method'),
);
}
}

遇到问题

在做项目需求的时候会遇到如下布局错乱情况,如下图:高空环景、VR、封面图都给距离右边有很大一片空白。每次打开第一次进来都会出现这样的布局错乱问题,第二次进来就恢复正常。

解决问题

当时一度以为是 flutter_screenutil 库在 Android 上的 bug ,后来单独写了一个混编的 demo ,发现不会出现布局错乱情况,于是把矛头对准了原生端的适配 AndroidAutoSize。只要是 Flutter 页面,取消今日头条适配方案(有提供 interface 接口),就不会出现布局错乱问题了。以上问题是两个适配方案相互影响导致!

import me.jessyan.autosize.internal.CancelAdapt

open class BaseFlutterActivity : FlutterActivity(), CancelAdapt {
...
}

实现 CancelAdapt 接口就可解决,如下图:布局错乱问题已解决,恢复正常。

总结

大家遇到疑难杂症问题,先思考可能导致这个问题的原因,然后逐个排查试错。 有时候项目太庞大,可以写一个 demo 来快速验证对错,从而得出原因,对症下药解决。


作者:未央歌
链接:https://juejin.cn/post/7147616629164081188
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

“雪糕刺客”你听说过,Bitmap这个“内存刺客”你也要小心(上)~

写在前面 雪糕刺客是最近被网友们玩坏了的梗,指的是那些以平平无奇的外表混迹于众多平价雪糕之中的贵价雪糕。由于没有明确标明价格,通常要等到结账的时候才会发现,犹如一个潜藏于普通人群中的刺客般,伺机对那些大意的顾客们的钱包刺上一剑,因此得名。 而在Android中...
继续阅读 »

写在前面


雪糕刺客是最近被网友们玩坏了的梗,指的是那些以平平无奇的外表混迹于众多平价雪糕之中的贵价雪糕。由于没有明确标明价格,通常要等到结账的时候才会发现,犹如一个潜藏于普通人群中的刺客般,伺机对那些大意的顾客们的钱包刺上一剑,因此得名。


而在Android中,也有这么一个内存刺客,其作为我们日常开发中经常接触的对象之一,却常常因为使用方式的不当,时不时地就会给我们有限的内存来上一个背刺,甚至毫不留情地就给我们抛出一个OOM,它,就是Bitmap


为了讲好Bitmap这个话题,本系列文章将分为上下两篇,上篇从图像基础知识出发,结合源码讲解Bitmap内存的计算方式;下篇则基于Android系统提供的API,讲解在实际开发中如何管理好Bitmap的内存,包括缩放、缓存、内存复用等,敬请期待。


本文为上篇,开始之前,先奉上的思维导图一张,方便后续复习:


Bitmap内存计算.png


从一个问题出发


假设有这么一张PNG格式的图片,其大小为15.3KB,尺寸为96x96,色深为32 bit,放到xhdpi目录下,并加载到一台dpi为480的Android设备上显示,那么请问,该图片实际会占用多大的内存?


实际会占用多大的内存.png


如果你回答不了这个问题,那你就有必要深入往下读了。


压缩格式大小≠占用内存大小


首先我们要明确的是,无论是JPEG还是PNG,它们本质上都是一种压缩格式,压缩的目的是为了降低存储和传输的成本


区别就在于:


JPEG是一种有损压缩格式,压缩比大,压缩后的体积比较小,但其高压缩率是通过去除冗余的图像数据进行的,因此解压后无法还原出完整的原始图像数据。


PNG则是一种无损压缩格式,不会损失图片质量,解压后能还原出完整的原始图像数据,但也因此压缩比小,压缩后的体积仍然很大。


开篇问题中所特意强调的图片大小,实际指的就是压缩格式文件的大小。而问题最后所问的图片实际占用的内存,指的则是解压缩后显示在设备屏幕上的原始图像数据所占用的内存


在实际的Android开发中,我们经常直接接触到的原始图像数据,就是通过各种decode方法解码出的Bitmap对象


Bitmap即位图,它还有另外一个名称叫做点阵图,相对来说,点阵图这个名称更能表述Bitmap的特征。


指的是像素点指的是阵列。点阵图,就是以像素为最小单位构成的图,缩放会失真。每个像素实则都是一个非常小的正方形,并被分配不同的颜色,然后通过不同的排列来构成像素阵列,最终呈现出完整的图像


放大12倍显示独立像素


那么每个像素是如何存储自己的颜色信息的呢?这涉及到图片的色深。


色深是什么?


色深,又叫色彩深度(Color Depth)。假设色深的数值为n,代表每个像素会采用n个二进制位来存储颜色信息**,也即2的n次方,表示的是每个像素能显示2^n种颜色


常见的色深有:




  • 1 bit:只能显示黑与白两个中的一个。因为在色深为1的情况下,每个像素只能存储2^1=2种颜色。




  • 8 bit:可以存储2^8=256种的颜色,典型的如GIF图像的色深就为8 bit。




  • 24 bit:可以存储2^24=16,777,216种的颜色。每个像素的颜色由红(Red)、绿(Green)、蓝(Blue)3个颜色通道合成,每个颜色通道用8bit来表示,其取值范围是:



    • 二进制:00000000~11111111

    • 十进制:0~255

    • 十六进制:00~FF


    这里很自然地就让人联想起Android中常用于表示颜色两种形式,即:



    • Color.rgb(float red, float green, float blue),对应十进制

    • Color.parceColor(String colorString),对应十六进制




  • 32 bit:在24位的基础上,增加多8个位的透明通道。




色深会影响图片的整体质量,我们可以来看同一张图片在不同色深下的表现:


24-bit color: 224 = 16,777,216 colors, 45 KB


8-bit color: 28 = 256 colors, 17 KB


4-bit color: 24 = 16 colors, 6 KB


2-bit color: 22 = 4 colors, 4 KB


1-bit color: 21 = 2 colors, 3 KB


可以看出,色深越大,能表示的颜色越丰富,图片也就越鲜艳,颜色过渡就越平滑。但相对的,图片的体积也会增加,因为每个像素必须存储更多的颜色信息


Android中与色深配置相关的类是Bitmap.Config,其取值会直接影响位图的质量(色彩深度)以及显示透明/半透明颜色的能力。在Android 2.3(API 级别 9)及更高版本中的默认配置是ARGB_8888,也即32 bit的色深,1 byte = 8 bit,因此该配置下每个像素的大小为4 byte。


位图内存 = 像素数量(分辨率) * 每个像素的大小,想要进一步计算加载位图所需要的内存,我们还需要得知像素的总数量,而描述像素数量的说法就是分辨率。


分辨率是什么?


如果说,色深决定了位图颜色的丰富程度,那么分辨率决定的则是位图图像细节的精细程度图像的分辨率越高,所包含的像素就越多,图像也就越清晰,同样的,它也会相应增加图片的体积


通常,我们用每一个方向上的像素数量来表示分辨率,也即水平像素数×垂直像素数,比如320×240,640×480,1280×1024等。


一张分辨率为640x480的图片,其像素数量就达到了307200,也就是我们常说的30万像素。


现在,我们明白了公式中2个变量的含义,就可以代入开篇问题中的例子来计算位图内存:


96 * 96 * 4 byte = 36864 bytes = 36KB


Bitmap提供了两个方法用于获取系统为该Bitmap存储像素所分配的内存大小,分别为:


public int getByteCount ()

public int getAllocationByteCount ()

一般情况下,两个方法返回的值是相同的。但如果我们手动重新配置了Bitmap的属性(宽、高、Bitmap.Config等),或者将BitmapFactory.Options.inBitmap属性设为true以支持其他更小的Bitmap复用其内存时,那么getAllocationByteCount ()返回的值就有可能会大于getByteCount()。


我们暂时不考虑以上两种场景,所以直接选择调用getByteCount方法 ()来获取为Bitmap分配的字节数,得到的结果是:82944 bytes = 81KB。


可以看到,getByteCount方法返回的值与我们的计算结果有差异,是我们的计算公式有问题吗?


探究getByteCount()的计算公式


为了验证我们的计算公式是否准确,我们需要深入getByteCount()方法的源码进行探究。


public final int getByteCount() {
if (mRecycled) {
Log.w(TAG, "Called getByteCount() on a recycle()'d bitmap! "
+ "This is undefined behavior!");
return 0;
}
// int result permits bitmaps up to 46,340 x 46,340
return getRowBytes() * getHeight();
}

可以看到,getByteCount()方法的返回值是每一行的字节数 * 高度,那么每一行的字节数又是怎么计算的呢?


public final int getRowBytes() {
if (mRecycled) {
Log.w(TAG, "Called getRowBytes() on a recycle()'d bitmap! This is undefined behavior!");
}
return nativeRowBytes(mFinalizer.mNativeBitmap);
}

正如你所见,getRowBytes()方法的实现是在Native层。先别灰心,接下来坐好扶稳了,我们省去一些不重要的步骤,乘坐飞船一路跨越Bitmap.cpp、SkBitmap.h,途径SkBitmap.cpp时稍微停下:


size_t SkBitmap::ComputeRowBytes(Config c, int width) {
return SkColorTypeMinRowBytes(SkBitmapConfigToColorType(c), width);
}

并最终到达SkImageInfo.h:


static int SkColorTypeBytesPerPixel(SkColorType ct) {
static const uint8_t gSize[] = {
0, // Unknown
1, // Alpha_8
2, // RGB_565
2, // ARGB_4444
4, // RGBA_8888
4, // BGRA_8888
1, // kIndex_8
};
SK_COMPILE_ASSERT(SK_ARRAY_COUNT(gSize) == (size_t)(kLastEnum_SkColorType + 1),
size_mismatch_with_SkColorType_enum);

SkASSERT((size_t)ct < SK_ARRAY_COUNT(gSize));
return gSize[ct];
}

static inline size_t SkColorTypeMinRowBytes(SkColorType ct, int width) {
return width * SkColorTypeBytesPerPixel(ct);
}

都说正确清晰的函数名有替代注释的作用,这就是优秀的典范。


让我们把目光停留在width * SkColorTypeBytesPerPixel(ct)这一行,不难看出,其计算方式是先根据颜色类型获取每个像素对应的字节数,再去乘以其宽度


那么,结合Bitmap.java的getByteCount()方法的实现,我们最终得出,系统为Bitmap存储像素所分配的内存大小 = 宽度 * 每个像素的大小 * 高度,与我们上面的计算公式一致。


公式没错,那问题究竟出在哪里呢?


其实,如果我们的图片是从磁盘、网络等地方获取的,理论上确实是按照上面的公式那样计算没错。但你还记得吗?我们在开篇的问题中,还特意强调了图片是放在xhdpi目录下的。在Android设备上,这种情况下计算位图内存,还有一个维度要考虑进来,那就是像素密度


像素密度是什么?


像素密度指的是屏幕单位面积内的像素数,称为dpi(dots per inch,每英寸点数)。当两个设备的尺寸相同而像素密度不同时,图像的效果呈现如下:


在尺寸相同但像素密度不同的两个设备上放大图像


是不是感觉跟分辨率的概念有点像?区别就在于,前者是屏幕单位面积内的像素数,后者是屏幕上的总像素数


由于Android是开源的,任何硬件制造商都可以制造搭载Android系统的设备,因此从手表、手机到平板电脑再到电视,各种屏幕尺寸和屏幕像素密度的设备层出不穷。


Android碎片化


为了优化不同屏幕配置下的用户体验,确保图像能在所有屏幕上显示最佳效果,Android建议应针对常见的不同的屏幕尺寸和屏幕像素密度,提供对应的图片资源。于是就有了Android工程res目录下,加上各种配置限定符的drawable/mipmap文件夹。


为了简化不同的配置,Android针对不同像素密度范围进行了归纳分组,如下:


适用于不同像素密度的配置限定符.png


我们通常选取中密度 (mdpi) 作为基准密度(1倍图),并保持ldpi~xxxhdpi这六种主要密度之间 3:4:6:8:12:16 的缩放比,来放置相应尺寸的图片资源。


例如,在创建Android工程时IDE默认为我们添加的ic_launcher图标,就遵循了这个规则。该图标在中密度 (mdpi)目录下的大小为48x48,在其他各种密度的目录下的大小则分别为:



  • 36x36 (0.75x) - 低密度 (ldpi)

  • 48x48(1.0x 基准)- 中密度 (mdpi)

  • 72x72 (1.5x) - 高密度 (hdpi)

  • 96x96 (2.0x) - 超高密度 (xhdpi)

  • 144x144 (3.0x) - 超超高密度 (xxhdpi)

  • 192x192 (4.0x) - 超超超高密度 (xxxhdpi)


当我们引用该图标时,系统就会根据所运行设备屏幕的dpi,与不同密度目录名称中的限定符进行比较,来选取最符合当前设备的图片资源。如果在该密度目录下没有找到合适的图片资源,系统会有对应的规则查找另外一个可能的匹配资源,并对其进行相应的缩放,以适配屏幕,由此可能造成图片有明显的模糊失真


不同密度大小的ic_launcher图标


那么,具体的查找规则是怎样的呢?


Android查找最佳匹配资源的规则


一般来说,Android会更倾向于缩小较大的原始图像,而非放大较小的原始图像。在此前提下:



  • 假设最接近设备屏幕密度的目录选项为xhdpi,如果图片资源存在,则匹配成功;

  • 如果不存在,系统就会从更高密度的资源目录下查找,依次为xxhdpi、xxxhdpi;

  • 如果还不存在,系统就会从像素密度无关的资源目录nodpi下查找;

  • 如果还不存在,系统就会向更低密度的资源目录下查找,依次为hdpi、mdpi、ldpi。


那么,当匹配到其他密度目录下的图片资源后,对于原始图像的放大或缩小,Android是怎么实现的呢?又会对加载位图所需要的内存有什么影响呢?


想解决这些疑惑,我们还是得从源码中找寻答案。


decode*方法的猫腻


众所周知,在Android中要读取drawable/mipmap目录下的图片资源,需要用到的是BitmapFactory类下的decodeResource方法:


    public static Bitmap decodeResource(Resources res, int id, Options opts) {
...
final TypedValue value = new TypedValue();
is = res.openRawResource(id, value);

bm = decodeResourceStream(res, value, is, null, opts);
...
}

decodeResource方法的主要工作,就只是调用Resource#openRawResource方法读取原始图片资源,同时传递一个TypedValue对象用于持有图片资源的相关信息,并返回一个输入流作为内部继续调用decodeResourceStream方法的参数。


    public static Bitmap decodeResourceStream(Resources res, TypedValue value,InputStream is, Rect pad, Options opts) {
if (opts == null) {
opts = new Options();
}

if (opts.inDensity == 0 && value != null) {
final int density = value.density;
if (density == TypedValue.DENSITY_DEFAULT) {
opts.inDensity = DisplayMetrics.DENSITY_DEFAULT;
} else if (density != TypedValue.DENSITY_NONE) {
opts.inDensity = density;
}
}

if (opts.inTargetDensity == 0 && res != null) {
opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
}

return decodeStream(is, pad, opts);
}

decodeResourceStream方法的主要工作,则是负责Options(解码选项)类2个重要参数inDensity和inTargetDensity的初始化,其中:



  • inDensity代表的是Bitmap的像素密度,取决于原始图片资源所存放的密度目录。

  • inTargetDensity代表的是Bitmap将绘制到的目标的像素密度,通常就是指屏幕的像素密度。


这两个参数起什么作用呢,让我们继续往下看:


public static Bitmap decodeStream(InputStream is, Rect outPadding, Options opts) {
···
if (is instanceof AssetManager.AssetInputStream) {
final long asset = ((AssetManager.AssetInputStream) is).getNativeAsset();
bm = nativeDecodeAsset(asset, outPadding, opts);
} else {
bm = decodeStreamInternal(is, outPadding, opts);
}
···
}

private static Bitmap decodeStreamInternal(InputStream is, Rect outPadding, Options opts) {
byte [] tempStorage = null;
if (tempStorage == null) tempStorage = new byte[DECODE_BUFFER_SIZE];
return nativeDecodeStream(is, tempStorage, outPadding, opts);
}

又见到熟悉的Native层方法了,让我们重新开动星际飞船再次跨越到BitmapFactory.cpp下查看:


static jobject nativeDecodeStream(JNIEnv* env, jobject clazz, jobject is, jbyteArray storage, jobject padding, jobject options) {
···
bitmap = doDecode(env, bufferedStream, padding, options);
···
}

static jobject doDecode(JNIEnv* env, SkStreamRewindable* stream, jobject padding, jobject options) {
····
float scale = 1.0f;
···
if (env->GetBooleanField(options, gOptions_scaledFieldID)) {
const int density = env->GetIntField(options, gOptions_densityFieldID);
const int targetDensity = env->GetIntField(options, gOptions_targetDensityFieldID);
const int screenDensity = env->GetIntField(options, gOptions_screenDensityFieldID);
if (density != 0 && targetDensity != 0 && density != screenDensity) {
scale = (float) targetDensity / density;
}
}
···
const bool willScale = scale != 1.0f;
···
int scaledWidth = decodingBitmap.width();
int scaledHeight = decodingBitmap.height();

if (willScale && decodeMode != SkImageDecoder::kDecodeBounds_Mode) {
scaledWidth = int(scaledWidth * scale + 0.5f);
scaledHeight = int(scaledHeight * scale + 0.5f);
}

if (options != NULL) {
env->SetIntField(options, gOptions_widthFieldID, scaledWidth);
env->SetIntField(options, gOptions_heightFieldID, scaledHeight);
env->SetObjectField(options, gOptions_mimeFieldID,
getMimeTypeString(env, decoder->getFormat()));
}
...
}

以上节选的doDecode方法的部分源码,就是Android系统如何对其他密度目录下的原始图像进行缩放的具体实现,我们来梳理一下它的执行逻辑:



  1. 首先,设置scale值也即初始的缩放比为1。

  2. 取出关键的density值以及targetDensity值,以目标像素密度/位图像素密度重新计算缩放比。

  3. 如果缩放比不再为1,则说明原始图像需要进行缩放。

  4. 取出待解码的位图的宽度,按int(scaledWidth * scale + 0.5f)计算缩放后的宽度,高度同理。

  5. 重新填充缩放后的宽高回Options。


基于以上内容,我们重新调整下我们的计算公式:


位图内存 = (位图宽度 * 缩放比) * 每个像素的大小 * (位图高度 * 缩放比)
= (96 * 1.5) * 4 * (96 * 1.5)
= 82944 bytes = 81KB


可以看到,这样计算得出来的结果则与Bitmap#getByteCount()返回的值一致。


总结


汇总上述的所有内容后,我们可以得出结论,即:


Android系统为Bitmap存储像素所分配的内存大小,取决于以下几个因素:



  • 色深,也即每个像素的大小,对应的是Bitmap.Config的配置。

  • 分辨率,也即像素的总数量,对应的是Bitmap的高度和宽度

  • 像素密度,对应的是图片资源所在的密度目录,以及设备的屏幕像素密度


由此我们还衍生出其他的结论,即:



  • 图片资源放到正确的密度目录很重要,否则可能对会较大尺寸的图片进行不合理的缩放,从而加大不必要的内存占用。

  • 如果是为了减少包体积而不想提供所有密度目录下不同尺寸的图片,应优先提供更高密度目录下的图片资源,可以避免图片失真。

  • ...

作者:椎锋陷陈
链接:https://juejin.cn/post/7123872753463066637
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

带着需求看源码《如何判断 Activity 上是否有弹窗》

今天来看个需求,如何判断 Activity 上面是否有弹窗,当然,简单的方式肯定有,例如在 Dialog show 的时候记录一下,但这种方式不够优雅,我们需要一款更通用的判断方式。 Android 目前的弹窗有如下几种: 普通的应用窗口,如 Dialog...
继续阅读 »

今天来看个需求,如何判断 Activity 上面是否有弹窗,当然,简单的方式肯定有,例如在 Dialog show 的时候记录一下,但这种方式不够优雅,我们需要一款更通用的判断方式。


Android 目前的弹窗有如下几种:



  1. 普通的应用窗口,如 Dialog

  2. 附加与普通窗口的子窗口,如 PopWindow

  3. 系统窗口,如 WindowManager type 在 FIRST_SYSTEM_WINDOW 与 LAST_SYSTEM_WINDOW 之间


通过图来简单来了解下 Window 和 View 的关系:




  • Activity 在 attach 阶段创建了 PhoneWindow,并将 AppToken 存储到 PhoneWindow 中,然后通过 createLocalWindowManager 创建了一个本地的 WindowManager,该实例是 WindowManagerImpl,构造传入的 parentWindow 为 PhoneWindow。在 onResume 阶段时,从 PhoneWindow 中获取 WindowManager 来 addView

  • Dialog 有自己的 PhoneWindow,但 Dialog 并没有从 PhoneWindow 中去 get WindowManager,而是直接使用 getSystemService 拿到 Activity 的 WindowManager 来 addView

  • PopWindow 内部是通过 getSystemService 来拿到 Activity WindowManager + 内置子窗口 type 来实现的弹框


方案 1、通过 mView 集合中的 Activity 区间来判断


从上面我们可以简单了解到,当前进程所有窗口 View,最终都会被存储到 WindowManagerGlobal 单例的 mViews 集合中,那我们是不是可以从 mView 这个集合入手?我们来简单画个 mView 的存储图:



WindowManager addView 时,都会往 mView 这个集合中进行添加。所以,我们只需要判断在 mView 集合中,两个 activity 之间是否有存在其他的 View,如果有,那就是有弹窗,开发步骤为:



  1. registerActivityLifecycleCallbacks 获取所有 Activity 的实例

  2. 传入想判断是否有弹窗的目标 Activity 实例,并获取该实例的 DecorView

  3. 拿到所有 Activity 实例的 DecorView 集合

  4. 遍历 mView 集合,并判断 mView 中的 View 是否与目标 Activity 的 DecorView 一致,是的话,说明找到了activity 的 index 位置

  5. 接下来从 index +1 的位置开始继续遍历 mView,判断 mView 中的 View 是否是 DecorView 集合中的实例,如果没有,则说明不是 Activity 的 View,继续遍历,直到 View 为 DecorView 集合中的实例为止


部分代码实现如下:


fun hasFloatingWindowByView(activity: Activity): Boolean {
return getFloatWindowView(activity).isNotEmpty()
}

fun getFloatWindowByView(activity: Activity): List<View> {
// 对应步骤 2
val targetDecorView = activity.window.decorView
// 对应步骤 3
val acDecorViews = lifecycle.getActivities().map { it.window.decorView }.toList()
// 对应步骤 4
val mView = Window.getViews().map { it }.toList()
val targetIndex = mView.first { it == targetDecorView }
// 对应步骤 5
val index = mView.indexOf(targetIndex)
val floatView = arrayListOf<View>()
for (i in index + 1 until mView.size) {
if (acDecorViews.contains(mView[i])) {
break
}
floatView.add(mView[i])
}
return floatView
}

具体演示可以参考 Demo,这里说个该方案的缺点,由于 mView 是个 List 集合,每次有新的 View add 进来,都是按 ArrayList.add 来添加 View 的,如果我们在启动第二个 Activity 的时候,触发第一个 Activity 来展示 Dialog,这时候的展示效果如下:



这时候如果拿第一个 Activity 来判断是否有弹窗的话,是存在误判的,因为这时候的两个 Activity 之间没有其他 View。


所以,通过区间来判断还是有缺点的。那有没有一种方法,可以直接遍历 mView 集合就能找到目标 Activity 是否有弹窗呢?还真有,那就是 AppToken。


方案二:通过 AppToken 来判断


在文章开头的概念中,我们了解到,PopWindow、Dialog 使用的都是 Activity 的 WindowManager,并且,该WindowManager 在初次创建时,构造函数传入的 parentWindow 为 PhoneWindow,这个 parentWindow 很重要,因为在 WindowManagerGlobal 的 addView 方法中,他会通过 parentWindow 来拿到 AppToken,然后设置到 WindowManager.LayoutParams 中,并参与最终的界面展示。
我们来看下设置 AppToken 的代码:


image.png


parentWindow 为 PhoneWindow,不为空,所以会进入到 PhoneWindow 父类 Window 的adjustLayoutParamsForSubWindow 方法:


image.png



  1. 子窗口判断:取 DecorView 里面的 WindowToken 设置到 wp 参数中。该 DecorView 为 Activity PhoneWindow 里的 DecorView,所以,该 windowToken 可以通过 Activity 的 DecorView 中拿到

  2. 系统弹窗判断:不设置 token,wp 中的 token 参数为 null

  3. 普通弹窗判断:将 AppToken 直接设置到 wp 参数中。该 AppToken 为 Activity PhoneWindow 里的 AppToken


通过这个三个判断我们了解到,子窗口的 windowToken 与普通弹窗的 AppToken 都可以与 Activity 挂钩了,这下,通过目标 Activity 就可以找到他们。至于系统弹窗,我们只需要 token 为 null 时即可。


wp 最终会被添加到 mParams 集合中,他与 mView 和 mRoot 的索引是一一对应的:


image.png


画个简单的图来概括下:



然后再结合 adjustLayoutParamsForSubWindow 对 token 的设置来描述下开发步骤:



  1. 传入想判断是否有弹窗的目标 Activity 实例,并获取该实例的 DecorView 与 windowToken

  2. 拿到 mView 集合,根据目标 Activity 的 DecorView 找到 index 位置

  3. 由于 mView 与mParams 集合是一一对应的,所以,可以根据该 index 位置去 mParams 集合里面找到目标 Activity 的 AppToken

  4. 遍历 mParams 集合中的所有 token,判断该 token 是否为目标 windowToken,目标 AppToken 或者是 null,只要能命中,则说明有弹窗


部分代码实现如下:


fun hasFloatWindowByToken(activity: Activity): Boolean {
// 获取目标 Activity 的 decorView
val targetDecorView = activity.window.decorView
// 获取目标 Activity 的 windowToken
val targetSubToken = targetDecorView.windowToken

// 拿到 mView 集合,找到目标 Activity 所在的 index 位置
val mView = Window.getViews().map { it }.toList()
val targetIndex = mView.indexOfFirst { it == targetDecorView }

// 获取 mParams 集合
val mParams = Window.getParams()
// 根据目标 index 从 mParams 集合中找到目标 token
val targetToken = mParams[targetIndex].token

// 遍历判断时,目标 Activity 自己不能包括,所以 size 需要大于 1
return mParams
.map { it.token }
.filter { it == targetSubToken || it == null || it == targetToken }
.size > 1
}

演示步骤:



  • 在第一个 Activity 打开系统弹窗,然后进入第二个 Activity,调用两种方式来获取当前是否有弹窗的结果如下


image.png



  • 第一种方案会判断失败,因为这时候的弹窗 View 在第一个 Activity 与 第二个 Activity 之间,所以,第二个 Activity 无法通过区间的方式判断到是否有弹窗

  • 第二种方案判断成功,因为这时候的弹窗 token 为 null,并通过 getFloatWindowViewByToken 方法,拿到了弹窗 View 对象


总结


本期通过提出需求的方式来探索方案的可行性,对于枯燥的源码来说,针对性的去看确实是个不错的主意


附上 demo 源码:github.com/MRwangqi/Fl…


作者:codelang
链接:https://juejin.cn/post/7136537294005075976
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Android壁纸还是B站玩得花

设置系统壁纸这个功能,对于应用层App来说,场景其实并不多,但在一些场景的周边活动中,确也是一种提升品牌粘性的方式,就好比某个活动中创建的角色的壁纸美图,这些就可以新增一个设置壁纸的功能。 从原始的Android开始,系统就支持设置两种方式的壁纸,一种是静态壁...
继续阅读 »

设置系统壁纸这个功能,对于应用层App来说,场景其实并不多,但在一些场景的周边活动中,确也是一种提升品牌粘性的方式,就好比某个活动中创建的角色的壁纸美图,这些就可以新增一个设置壁纸的功能。


从原始的Android开始,系统就支持设置两种方式的壁纸,一种是静态壁纸,另一种是动态壁纸。


静态壁纸


静态壁纸没什么好说的,通过系统提供的API一行代码就完事了。


最简单代码如下所示。


val wallpaperManager = WallpaperManager.getInstance(this)
try {
val bitmap = ContextCompat.getDrawable(this, R.drawable.ic_launcher_background)?.toBitmap()
wallpaperManager.setBitmap(bitmap)
} catch (e: Exception) {
e.printStackTrace()
}

除了setBitmap之外,系统还提供了setResource、setStream,一共三种方式来设置静态壁纸。


三种方式殊途同归,都是设置一个Bitmap给系统API。


动态壁纸


动态壁纸就有点意思了,很多手机ROM也内置了一些动态壁纸,别以为这些是什么新功能,从Android 1.5开始,就已经支持这种方式了。只不过做的人比较少,为啥呢,主要是没有什么特别合适的场景,而且动态壁纸,会比静态壁纸更加耗电,所以大部分时候,我们都没用这种方式。


壁纸作为一个系统服务,在系统启动时,不管是动态壁纸还是静态壁纸,都会以一个Service的形式运行在后台——WallpaperService,它的Window类型为TYPE_WALLPAPER,WallpaperService提供了一个SurfaceHolder来暴露给外界来对画面进行渲染,这就是设置壁纸的基本原理。


创建一个动态壁纸,需要继承系统的WallpaperService,并提供一个WallpaperService.Engin来进行渲染,下面这个就是一个模板代码。


class MyWallpaperService : WallpaperService() {
override fun onCreateEngine(): Engine = WallpaperEngine()

inner class WallpaperEngine : WallpaperService.Engine() {
lateinit var mediaPlayer: MediaPlayer

override fun onSurfaceCreated(holder: SurfaceHolder?) {
super.onSurfaceCreated(holder)
}

override fun onCommand(action: String?, x: Int, y: Int, z: Int, extras: Bundle?, resultRequested: Boolean): Bundle {
try {
Log.d("xys", "onCommand: $action----$x---$y---$z")
if ("android.wallpaper.tap" == action) {
}
} catch (e: Exception) {
e.printStackTrace()
}
return super.onCommand(action, x, y, z, extras, resultRequested)
}

override fun onVisibilityChanged(visible: Boolean) {
if (visible) {
} else {
}
}

override fun onDestroy() {
super.onDestroy()
}
}
}

然后在manifest中注册这个Service。


<service
android:name=".MyWallpaperService"
android:exported="true"
android:label="Wallpaper"
android:permission="android.permission.BIND_WALLPAPER">
<intent-filter>
<action android:name="android.service.wallpaper.WallpaperService" />
</intent-filter>

<meta-data
android:name="android.service.wallpaper"
android:resource="@xml/my_wallpaper" />
</service>

另外,还需要申请相应的权限。


<uses-permission android:name="android.permission.SET_WALLPAPER" />

最后,在xml文件夹中新增一个描述文件,对应上面resource标签的文件。


<?xml version="1.0" encoding="utf-8"?>
<wallpaper xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/app_name"
android:thumbnail="@mipmap/ic_launcher" />

动态壁纸只能通过系统的壁纸预览界面来进行设置。


val localIntent = Intent()
localIntent.action = WallpaperManager.ACTION_CHANGE_LIVE_WALLPAPER
localIntent.putExtra(
WallpaperManager.EXTRA_LIVE_WALLPAPER_COMPONENT,
ComponentName(applicationContext.packageName, MyWallpaperService::class.java.name))
startActivity(localIntent)

这样我们就可以设置一个动态壁纸了。


玩点花


既然是使用提供的SurfaceHolder来进行渲染,那么我们所有能够使用到SurfaceHolder的场景,都可以来进行动态壁纸的创建了。


一般来说,有三种比较常见的使用场景。



  • MediaPlayer

  • Camera

  • SurfaceView


这三种也是SurfaceHolder的常用使用场景。


首先来看下MediaPlayer,这是最简单的方式,可以设置一个视频,在桌面上循环播放。


inner class WallpaperEngine : WallpaperService.Engine() {
lateinit var mediaPlayer: MediaPlayer

override fun onSurfaceCreated(holder: SurfaceHolder?) {
super.onSurfaceCreated(holder)
mediaPlayer = MediaPlayer.create(applicationContext, R.raw.testwallpaper).also {
it.setSurface(holder!!.surface)
it.isLooping = true
}
}

override fun onVisibilityChanged(visible: Boolean) {
if (visible) {
mediaPlayer.start()
} else {
mediaPlayer.pause()
}
}

override fun onDestroy() {
super.onDestroy()
if (mediaPlayer.isPlaying) {
mediaPlayer.stop()
}
mediaPlayer.release()
}
}

接下来,再来看下使用Camera来刷新Surface的。


inner class WallpaperEngine : WallpaperService.Engine() {
lateinit var camera: Camera

override fun onVisibilityChanged(visible: Boolean) {
if (visible) {
startPreview()
} else {
stopPreview()
}
}

override fun onDestroy() {
super.onDestroy()
stopPreview()
}

private fun startPreview() {
camera = Camera.open()
camera.setDisplayOrientation(90)
try {
camera.setPreviewDisplay(surfaceHolder)
camera.startPreview()
} catch (e: IOException) {
e.printStackTrace()
}
}

private fun stopPreview() {
try {
camera.stopPreview()
camera.release()
} catch (e: Exception) {
e.printStackTrace()
}
}
}

同时需要添加下Camera的权限。


<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.SET_WALLPAPER" />

<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />

由于这里偷懒,没有使用最新的CameraAPI,也没有动态申请权限,所以你需要自己手动去授权。


最后一种,通过Surface来进行自绘渲染。


val holder = surfaceHolder
var canvas: Canvas? = null
try {
canvas = holder.lockCanvas()
if (canvas != null) {
canvas.save()
// Draw Something
}
} finally {
if (canvas != null) holder.unlockCanvasAndPost(canvas)
}

这里就可以完全使用Canvas的API来进行绘制了。


这里有一个比较复杂的绘制Demo,可以给大家参考。


http://www.developer.com/design/buil…


有意思的方法


虽然WallpaperService是一个系统服务,但它也提供了一些比较有用的回调函数来帮助我们做一些有意思的东西。


onOffsetsChanged


当用户在手机桌面滑动时,有的壁纸图片会跟着左右移动,这个功能就是通过这个回调来实现的,在手势滑动的每一帧都会回调这个方法。


xOffset:x轴滑动的百分比


yOffset:y轴滑动百分比


xOffsetStep:x轴桌面Page数进度


yOffsetStep:y轴桌面Page数进度


xPixelOffset:x轴像素偏移量


通过这个函数,就可以拿到手势的移动惯量,从而对图片做出一些修改。


onTouchEvent、onCommand


这两个方法,都可以获取用户的点击行为,通过判断点击类型,就可以针对用户的特殊点击行为来做一些逻辑处理,例如点击某些特定的地方时,唤起App,或者打开某个界面等等。


class MyWallpaperService : WallpaperService() {
override fun onCreateEngine(): Engine = WallpaperEngine()

private inner class WallpaperEngine : WallpaperService.Engine() {

override fun onTouchEvent(event: MotionEvent?) {
// on finder press events
if (event?.action == MotionEvent.ACTION_DOWN) {
// get the canvas from the Engine or leave
val canvas = surfaceHolder?.lockCanvas() ?: return
// TODO
// update the surface
surfaceHolder.unlockCanvasAndPost(canvas)
}
}
}
}

B站怎么玩的呢


不得不说,B站在这方面玩的是真的花,最近B站里面新加了一个异想少女系列,你可以设置一个动态壁纸,同时还带交互,有点意思。



其实类似这样的交互,基本上都是通过OpenGL或者是RenderScript来实现的,通过GLSurfaceView来进行渲染,从而实现了一些复杂的交互,下面这些例子,就是一些实践。


github.com/PavelDoGrea…


github.com/jinkg/live-…


http://www.cnblogs.com/YFEYI/categ…


code.tutsplus.com/tutorials/c…


但是B站的这个效果,显然比上面的方案更加成熟和完整,所以,通过调研可以发现,它们使用的是Live2D的方案。


http://www.live2d.com/


动态壁纸的Demo如下。


github.com/Live2D/Cubi…


这个东西是小日子的一个SDK,专业做2D可交互纸片人,这个东西已经出来很久了,前端之前用它来做网页的看板娘,现在客户端又拿来做动态壁纸,风水轮流换啊,想要使用的,可以参考它们官方的Demo。



但是官方的动态壁纸Demo在客户端是有Bug的,会存在各种闪的问题,由于我本身不懂OpenGL,所以也无法解决,通过回退Commit,发现可以直接使用这个CommitID : Merge pull request #2 from Live2D/create-new-function ,就没有闪的问题。


a9040ddbf99d9a130495e4a6190592068f2f7a77



好了,B站YYDS,但我觉得这东西的使用场景太有限了,而且特别卡,极端影响功耗,所以,要不要这么卷呢,你看着办吧。


作者:xuyisheng
链接:https://juejin.cn/post/7135998448720936968
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Flutter性能优化实践

前言 Flutter是谷歌的移动UI框架,可以快速在iOS和Android上构建高质量的原生用户界面。 Flutter可以与现有的代码一起工作。在全世界,Flutter正在被越来越多的开发者和组织使用,并且Flutter是完全免费、开源的,可以用一套代码同时构...
继续阅读 »

前言


Flutter是谷歌的移动UI框架,可以快速在iOS和Android上构建高质量的原生用户界面。 Flutter可以与现有的代码一起工作。在全世界,Flutter正在被越来越多的开发者和组织使用,并且Flutter是完全免费、开源的,可以用一套代码同时构建Android和iOS应用,性能可以达到原生应用一样的性能。但是,在较为复杂的 App 中,使用 Flutter 开发也很难避免产生各种各样的性能问题。在这篇文章中,我将介绍一些 Flutter 性能优化方面的应用实践。另外Flutter动态化框架已开源:


Github地址:github.com/wuba/fair


一、优化检测工具


Flutter编译模式


Flutter支持Release、Profile、Debug编译模式。




  1. Release模式,使用AOT预编译模式,预编译为机器码,通过编译生成对应架构的代码,在用户设备上直接运行对应的机器码,运行速度快,执行性能好;此模式关闭了所有调试工具,只支持真机。




  2. Profile模式,和Release模式类似,使用AOT预编译模式,此模式最重要的作用是可以用DevTools来检测应用的性能,做性能调试分析。




  3. Debug模式,使用JIT(Just in time)即时编译技术,支持常用的开发调试功能hot reload,在开发调试时使用,包括支持的调试信息、服务扩展、Observatory、DevTools等调试工具,支持模拟器和真机。




通过以上介绍我们可以知道,flutter为我们提供 profile模式启动应用,进行性能分析,profile模式在Release模式的基础之上,为分析工具提供了少量必要的应用追踪信息。


如何开启profile模式?


如果是独立flutter工程可以使用flutter run --profile启动。如果是混合 Flutter 应用,在 flutter/packages/flutter_tools/gradle/flutter.gradle 的 buildModeFor 方法中将 debug 模式改为 profile即可。


检测工具


1、Flutter Inspector (debug模式下)


Flutter Inspector有很多功能,其中有两个功能更值得我们去关注,例如:“Select Widget Mode” 和 “Highlight Repaints”。


Select Widget Mode点击 “Select Widget Mode” 图标,可以在手机上查看当前页面的布局框架与容器类型。



通过“Select Widget Mode”我们可以快速查看陌生页面的布局实现方式。



Select Widget Mode模式下,也可以在app里点击相应的布局控件查看


Highlight Repaints


点击 “Highlight Repaints” 图标,它会 为所有 RenderBox 绘制一层外框,并在它们重绘时会改变颜色。



这样做帮你找到 App 中频繁重绘导致性能消耗过大的部分。


例如:一个小动画可能会导致整个页面重绘,这个时候使用 RepaintBoundary Widget 包裹它,可以将重绘范围缩小至本身所占用的区域,这样就可以减少绘制消耗。



2、Performance Overlay(性能图层)


在完成了应用启动之后,接下来我们就可以利用 Flutter 提供的渲染问题分析工具,即性能图层(Performance Overlay),来分析渲染问题了。


我们可以通过以下方式开启性能图层



性能图层会在当前应用的最上层,以 Flutter 引擎自绘的方式展示 GPU 与 UI 线程的执行图表,而其中每一张图表都代表当前线程最近 300 帧的表现,如果 UI 产生了卡顿,这些图表可以帮助我们分析并找到原因。
下图演示了性能图层的展现样式。其中,GPU 线程的性能情况在上面,UI 线程的情况显示在下面,蓝色垂直的线条表示已执行的正常帧,绿色的线条代表的是当前帧:



如果有一帧处理时间过长,就会导致界面卡顿,图表中就会展示出一个红色竖条。下图演示了应用出现渲染和绘制耗时的情况下,性能图层的展示样式:



如果红色竖条出现在 GPU 线程图表,意味着渲染的图形太复杂,导致无法快速渲染;而如果是出现在了 UI 线程图表,则表示 Dart 代码消耗了大量资源,需要优化代码执行时间。


3、CPU Profiler(UI 线程问题定位)


在视图构建时,在 build 方法中使用了一些复杂的运算,或是在主 Isolate 中进行了同步的 I/O 操作。
我们可以使用 CPU Profiler 进行检测:



你需要手动点击 “Record” 按钮去主动触发,在完成信息的抽样采集后,点击 “Stop” 按钮结束录制。这时,你就可以得到在这期间应用的执行情况了。



其中:


x 轴:表示单位时间,一个函数在 x 轴占据的宽度越宽,就表示它被采样到的次数越多,即执行时间越长。


y 轴:表示调用栈,其每一层都是一个函数。调用栈越深,火焰就越高,底部就是正在执行的函数,上方都是它的父函数。


通过上述CPU帧图我们可以大概分析出哪些方法存在耗时操作,针对性的进行优化


一般的耗时问题,我们通常可以 使用 Isolate(或 compute)将这些耗时的操作挪到并发主 Isolate 之外去完成。


例如:复杂JSON解析子线程化


Flutter的isolate默认是单线程模型,而所有的UI操作又都是在UI线程进行的,想应用多线程的并发优势需新开isolate 或compute。无论如何await,scheduleTask 都只是延后任务的调用时机,仍然会占用“UI线程”, 所以在大Json解析或大量的channel调用时,一定要观测对UI线程的消耗情况。



二、Flutter布局优化


Flutter 使用了声明式的 UI 编写方式,而不是 Android 和 iOS 中的命令式编写方式。




  1. 声明式:简单的说,你只需要告诉计算机,你要得到什么样的结果,计算机则会完成你想要的结果,声明式更注重结果。




  2. 命令式:用详细的命令机器怎么去处理一件事情以达到你想要的结果,命令式更注重执行过程。




flutter声明式的布局方式通过三棵树去构建布局,如图:





  • Widget Tree: 控件的配置信息,不涉及渲染,更新代价极低。




  • Element Tree : Widget树和RenderObject树之间的粘合剂,负责将Widget树的变更以最低的代价映射到RenderObject树上。




  • RenderObject Tree : 真正的UI渲染树,负责渲染UI,更新代价极大。




1、常规优化


常规优化即针对 build() 进行优化,build() 方法中的性能问题一般有两种:耗时操作和 Widget 层叠。


1)、在 build() 方法中执行了耗时操作


我们应该尽量避免在 build() 中执行耗时操作,因为 build() 会被频繁地调用,尤其是当 Widget 重建的时候。
此外,我们不要在代码中进行阻塞式操作,可以将一般耗时操作等通过 Future 来转换成异步方式来完成。
对于 CPU 计算频繁的操作,例如图片压缩,可以使用 isolate 来充分利用多核心 CPU。


2)、build() 方法中堆叠了大量的 Widget


这将会导致三个问题:


1、代码可读性差:画界面时需要一个 Widget 嵌套一个 Widget,但如果 Widget 嵌套太深,就会导致代码的可读性变差,也不利于后期的维护和扩展。


2、复用难:由于所有的代码都在一个 build(),会导致无法将公共的 UI 代码复用到其它的页面或模块。


3、影响性能:我们在 State 上调用 setState() 时,所有 build() 中的 Widget 都将被重建,因此 build() 中返回的 Widget 树越大,那么需要重建的 Widget 就越多,也就会对性能越不利。


所以,你需要 控制 build 方法耗时,将 Widget 拆小,避免直接返回一个巨大的 Widget,这样 Widget 会享有更细粒度的重建和复用。


3)、尽可能地使用 const 构造器


当构建你自己的 Widget 或者使用 Flutter 的 Widget 时,这将会帮助 Flutter 仅仅去 rebuild 那些应当被更新的 Widget。
因此,你应该尽量多用 const 组件,这样即使父组件更新了,子组件也不会重新进行 rebuild 操作。特别是针对一些长期不修改的组件,例如通用报错组件和通用 loading 组件等。


4)、列表优化




  • 尽量避免使用 ListView默认构造方法


    不管列表内容是否可见,会导致列表中所有的数据都会被一次性绘制出来




  • 建议使用 ListView 和 GridView 的 builder 方法


    它们只会绘制可见的列表内容,类似于 Android 的 RecyclerView。





其实,本质上,就是对列表采用了懒加载而不是直接一次性创建所有的子 Widget,这样视图的初始化时间就减少了。


2、深入光栅化优化


优化光栅线程


屏幕显示器一般以60Hz的固定频率刷新,每一帧图像绘制完成后,会继续绘制下一帧,这时显示器就会发出一个Vsync信号,按60Hz计算,屏幕每秒会发出60次这样的信号。CPU计算好显示内容提交给GPU,GPU渲染好传递给显示器显示。
Flutter遵循了这种模式,渲染流程如图:



flutter通过native获取屏幕刷新信号通过engine层传递给flutter framework



所有的 Flutter 应用至少都会运行在两个并行的线程上:UI 线程和 Raster 线程。




  • UI 线程


    构建 Widgets 和运行应用逻辑的地方。




  • Raster 线程


    用来光栅化应用。它从 UI 线程获取指令将其转换成为GPU命令并发送到GPU。




我们通常可以使用Flutter DevTools-Performance 进行检测,步骤如下:




  • 在 Performance Overlay 中,查看光栅线程和 UI 线程哪个负载过重。




  • 在 Timeline Events 中,找到那些耗费时间最长的事件,例如常见的 SkCanvas::Flush,它负责解决所有待处理的 GPU 操作。




  • 找到对应的代码区域,通过删除 Widgets 或方法的方式来看对性能的影响。





三、Flutter内存优化


1、const 实例化


const 对象只会创建一个编译时的常量值。在代码被加载进 Dart Vm 时,在编译时会存储在一个特殊的查询表里,仅仅只分配一次内存给当前实例。


我们可以使用 flutter_lints 库对我们的代码进行检测提示


2、检测消耗多余内存的图片


Flutter Inspector:点击 “Highlight Oversizeded Images”,它会识别出那些解码大小超过展示大小的图片,并且系统会将其倒置,这些你就能更容易在 App 页面中找到它。


通过下面两张图可以清晰的看出使用“Highlight Oversizeded Images”的检测效果















12

针对这些图片,你可以指定 cacheWidth 和 cacheHeight 为展示大小,这样可以让 flutter 引擎以指定大小解析图片,减少内存消耗。



3、针对 ListView item 中有 image 的情况来优化内存


ListView 不会销毁那些在屏幕可视范围之外的那些 item,如果 item 使用了高分辨率的图片,那么它将会消耗非常多的内存。


ListView 在默认情况下会在整个滑动/不滑动的过程中让子 Widget 保持活动状态,这一点是通过 AutomaticKeepAlive 来保证,在默认情况下,每个子 Widget 都会被这个 Widget 包裹,以使被包裹的子 Widget 保持活跃。
其次,如果用户向后滚动,则不会再次重新绘制子 Widget,这一点是通过 RepaintBoundaries 来保证,在默认情况下,每个子 Widget 都会被这个 Widget 包裹,它会让被包裹的子 Widget 仅仅绘制一次,以此获得更高的性能。
但,这样的问题在于,如果加载大量的图片,则会消耗大量的内存,最终可能使 App 崩溃。



通过将这两个选项置为 false 来禁用它们,这样不可见的子元素就会被自动处理和 GC。


4、多变图层与不变图层分离


在日常开发中,会经常遇到页面中大部分元素不变,某个元素实时变化。如Gif,动画。这时我们就需要RepaintBoundary,不过独立图层合成也是有消耗,这块需实测把握。


这会导致页面同一图层重新Paint。此时可以用RepaintBoundary包裹该多变的Gif组件,让其处在单独的图层,待最终再一块图层合成上屏。



5、降级CustomScrollView,ListView等预渲染区域为合理值


默认情况下,CustomScrollView除了渲染屏幕内的内容,还会渲染上下各250区域的组件内容,例如当前屏幕可显示4个组件,实际仍有上下共4个组件在显示状态,如果setState(),则会进行8个组件重绘。实际用户只看到4个,其实应该也只需渲染4个, 且上下滑动也会触发屏幕外的Widget创建销毁,造成滚动卡顿。高性能的手机可预渲染,在低端机降级该区域距离为0或较小值。


四、总结


Flutter为什么会卡顿、帧率低?总的来说均为以下2个原因:




  • UI线程慢了-->渲染指令出的慢




  • GPU线程慢了-->光栅化慢、图层合成慢、像素上屏慢




所以我们一般使用flutter布局尽量按照以下原则


Flutter优化基本原则:




  • 尽量不要为 Widget 设置半透明效果,而是考虑用图片的形式代替,这样被遮挡的 Widget 部分区域就不需要绘制了;




  • 控制 build 方法耗时,将 Widget 拆小,避免直接返回一个巨大的 Widget,这样 Widget 会享有更细粒度的重建和复用;




  • 对列表采用懒加载而不是直接一次性创建所有的子 Widget,这样视图的初始化时间就减少了。




五、其他


如果大家对flutter动态化感兴趣,我们也为大家准备了flutter动态化平台-Fair


欢迎大家使用 Fair,也欢迎大家为我们点亮star



Github地址:github.com/wuba/fair

Fair官网:fair.58.com


作者:王猛猛
链接:https://juejin.cn/post/7146460160699924488
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

关于 MVI,我想聊的更明白些

MVI
前言 谈到 MVI,相信大家略有耳闻,由于该架构有一定门槛,导致开发者要么完全理解,要么完全不理解。 且由于存在门槛,理解的开发者往往受 “知识的诅咒”,很难体会不理解的人困惑之所在,也即容易在分享时遗漏关键点,这也使得该技术点的普及和传播更加困难。 故这期专...
继续阅读 »

前言


谈到 MVI,相信大家略有耳闻,由于该架构有一定门槛,导致开发者要么完全理解,要么完全不理解。


且由于存在门槛,理解的开发者往往受 “知识的诅咒”,很难体会不理解的人困惑之所在,也即容易在分享时遗漏关键点,这也使得该技术点的普及和传播更加困难。


故这期专为 MVI 打磨一篇 “通俗易懂、看完便理解来龙去脉、并能自行判断什么时候适用、是否非用不可”,相信阅读后你会耳目一新。


文章目录一览



  • 前言

  • 响应式编程

    • 响应式编程的好处

    • 响应式编程的漏洞

    • 响应式编程的困境



  • MVI 的存在意义

  • MVI 的实现

    • 函数式编程思想

    • MVI 怎样实现纯函数效果

    • 存在哪些副作用

    • 整体流程



  • 当下开发现状的反思

    • 从源头把问题消灭

    • 什么是过度设计,如何避免

    • 平替方案的探索



  • 综上


响应式编程


谈到 MVI,首先要提的是 “响应式编程”,响应式是 Reactive 翻译成中文叫法,对应 Java 语言实现是 RxJava,


ReactiveX 官方对 Rx 框架描述是:使用 “可观察流” 进行异步编程的 API,


翻译成人话即,响应式编程暗示人们 应当总是向数据源请求数据,然后在指定的观察者中响应数据的变化


常见的 “响应式编程” 流程用伪代码表示如下:



响应式编程的好处


通过上述代码易得,在响应式编程下,业务逻辑在 ViewModel / Presenter 处集中管理,过程中向 UI 回推状态,且 UI 控件在指定的 “粘性观察者” 中响应,该模式下很容易做单元测试,有输入必有回响


反之如像往常一样,将控件渲染代码分散在观察者以外的各个方法中,便很难做到这一点。


响应式编程的漏洞


随着业务发展,人们开始往 “粘性观察者” 回调中添加各种控件渲染,


如果同一控件实例(比如 textView)出现在不同粘性观察者回调中:


livedata_A.observe(this, dataA ->
textView.setText(dataA.b)
...
}

livedata_B.observe(this, dataB ->
textView.setText(dataB.b)
...
}

假设用户操作使得 textView 先接收到 liveData_B 消息,再接收到 liveData_A 消息,


那么旋屏重建后,由于 liveData_B 的注册晚于 liveData_A,textView 被回推的最后一次数据反而是来自 liveData_B,


给用户的感觉是,旋屏后展示老数据,不符预期。


响应式编程的困境


由此可得,响应式编程存在 1 个不显眼的关键细节:


一个控件应当只在同一个观察者中响应,也即同一控件实例不该出现在多个观察者中。


但如果这么做,又会产生新的问题。由于页面控件往往多达十数个,如此观察者也需配上十数个。


是否存在某种方式,既能杜绝 “一个控件在多个观察者中响应”,又能消除与日俱增的观察者?答案是有 —— 即接下来我们介绍的 MVI。


MVI 的存在意义


MVI 是 在响应式编程的前提下,通过 “将页面状态聚合” 来统一消除上述 2 个问题,


也即原先分散在各个 LiveData 中的 String、Boolean 等状态,现全部聚合到一个 JavaBean / data class 中,由唯一的粘性观察者回推,所有控件都在该观察者中响应数据的变化。


具体该如何实现?业界有个简单粗暴的解法 —— 遵循 “函数式编程思想”。


MVI 的实现


函数式编程思想


函数式编程的核心主要是纯函数,这种函数只有 “参数列表” 这唯一入口来传入初值,只有 “返回值” 这唯一出口来返回结果,且 “运算过程中” 不调用和影响函数作用域外的变量(也即 “无副作用”),


int a

public int calculate(int b){ //纯函数
return b + b
}

public int changeA(){ //非纯函数,因运算过程中调用和影响到外界变量 a
 int c = a = calculate(b)
 return c
}

public int changeB() { //纯函数
 int b = calculate(2)
 return b + 1
}

显而易见,纯函数的好处是 “可以闭着眼使用”,有怎样的输入,必有怎样的输出,且过程中不会有预料外的影响发生。



这里贴一张网上盛传的图来说明 Model、View、Intent 三者关系,


笔者认为,MVI 并非真的 “纯函数实现”,而只是 “纯函数思想” 的实现,


也即我们实际上都是以 “面向对象” 方式在编程,从效果上达到 “纯函数” 即可,


反之如钻牛角尖,看什么都 “有副作用、不纯”,则易陷入悲观,忽视本可改善的环节,有点得不偿失。


MVI 怎样实现纯函数效果


Model 通常是继承 Jetpack ViewModel 来实现,负责处理业务逻辑;


Intent 是指发起本次请求的意图,告诉 Model 本次执行哪个业务。它可以携带或不带参数;


View 通常对应 Activity/Fragment,根据 Model 返回的 UiStates 进行渲染。


也即我们让 Model 只暴露一个入口,用于输入 intent;只暴露一个出口,用于回调 UiStates;业务执行过程中不影响 UiStates 以外的结果;且 UiStates 的字段都设置为不可变(final / val)确保线程安全,即可达成 Model 的 “纯”,


Intent 达成 “纯” 比较简单,由于它只是个入参,字段都设置为不可变即可。


View 同样不难,只要确保 View 的入口就是 Model 的出口,也即 View 的控件都集中放置在 Model 的回调中渲染,即可达成 “纯”。


存在哪些副作用


存在争议的副作用


那有人可能会说,“不对啊,View 在入口中调用了控件实例,也即函数作用域外的成员变量,是副作用呀” …… 笔者认为这是误解,


因为 MVI 的 View 事实上就不是一个函数,而是一个类。如上文所述,MVI 实际上是 通过面向对象编程的方式实现 “纯函数” 效果,而非真的纯函数,


故我们可以站在类的角度重新审视 —— 控件是类成员,对应的是纯函数的自动变量,


换言之,控件渲染并没有调用和影响到 View 作用域外的元素,故不算副作用。


公认的副作用


与此同时,UiEvents 属于副作用,也即那些弹窗、页面跳转等 “一次性消费” 的情况,


为什么?笔者认为 “弹窗、页面跳转” 时,在当前 MVI-View 页面之外创建了新的 Window、或是在返回栈添加了新的页面,如此等于调用和影响了外界环境,所以这必是副作用,


不过这是符合预期的副作用,对此官方 Guide 也有介绍 “将 UiEvents 整合到 UiStates” 的方式来改善该副作用:界面事件 | Android 开发者 | Android Developers


与之相对的即 “不符预期的副作用” —— 例如控件实例被分散在观察者外的各个方法中,并在某个方法中被篡改和置空,其他方法并不知情,调用该实例即发生 NullPointException。


整体流程


至此 MVI 的代码实现已呼之欲出:


1.创建一个 UiStates,反映当前页面的所有状态。


data class UiStates {
val weather : Weather,
 val isLoading : Boolean,
 val error : List<UiEvent>,
}

2.创建一个 Intent,用于发送请求时携带参数,和指明当前想执行的业务。


sealed class MainPageIntent {
data class GetWeather(val cityCode) : MainPageIntent()
}

3.执行业务的过程,总是先从数据层获取数据,然后根据情况分流和回推结果,例如请求成功,便执行 Success 来回推结果,请求失败,则 Error,对此业内普遍的做法是,增设一个 Actions,


并且由于 UiStates 的字段不可变,且控件集中响应 UiStates,也即务必确保 UiStates 的延续,由此每个业务带来局部改变时(partialChange),需通过 copy 等方式,将上一次的 UiStates 拷贝一份,并为对应字段注入 partialChange。这个过程业内称为 reduce。


sealed class MainPageActions {
 fun reduce(oldStates : UiStates) : UiStates {
   return when(this){
     Loading -> oldStates.copy(isLoading = true)
     is Success -> oldStates.copy(isLoading = false, weather = this.weather)
     is Error -> oldStates.copy(isLoading = false, error = listOf(UiEvent(msg)))
  }
}
 
object Loading : MainPageActions()
data class Success(val weather : Weather) : MainPageActions()
data class Error(val msg : String) : MainPageActions()
}

4.创建当前页面使用的 MVI-Model。


class MainPageModel : MVI_Model<UiStates>() {
 private val _stateFlow = MutableStateFlow(UiStates())
 val stateFlow = _stateFlow.asStateFlow
 
 private fun sendResult(uiStates: S) = _stateFlow.emit(uiStates)
 
 fun input(intent: Intent) = viewModelScope.launch{ onHandle() }
 
 private suspend fun onHandle(intent: Intent) {
   when(intent){
  is GetWeather -> {
       sendResult(MainPageActions.Loading.reduce(oldStates)
  val response = api.post()
  if(response.isSuccess) sendResult(
        MainPageActions.Success(response.data).reduce(oldStates)
  else sendResult(
        MainPageActions.Error(response.message).reduce(oldStates)
  }
  }
}
}

5.创建 MVI-View,并在 stateFlow 中响应 MVI-Model 数据。


控件集中响应,带来不必要的性能开销,需要做个 diff,只响应发生变化的字段。


笔者通常是通过 DataBinding ObservableField 做防抖。后续如 Jetpack Compose 普及,建议是使用 Jetpack Compose,无需开发者手动 diff,其内部类似前端 DOM ,根据本次注入的声明树自行在内部差分合并渲染新内容。


class MainPageActivity : Android_Activity(){
private val model : MainPageModel
 private val views : MainPageViews
fun onCreate(){
   lifecycleScope.launch {
   repeatOnLifecycle(Lifecycle.State.STARTED) {
     model.stateFlow.collect {uiStates ->
 views.progress.set(uiStates.isLoading)
 views.weatherInfo.set(uiStates.weather.info)
      ...
    }
   }
   model.input(Intent.GetWeather(BEI_JING))
}
 class MainPageViews : Jetpack_ViewModel() {
val progress = ObservableBoolean(false)
   val weatherInfo = ObservableField<String>("")
  ...
}
}

整个流程用一张图表示即:



当下开发现状的反思


上文我们追溯了 MVI 来龙去脉,不难发现,MVI 是给 “响应式编程” 填坑的存在,通过状态聚合来消除 “不符预期回推、观察者爆炸” 等问题,


然而 MVI 也有其不便之处,由于它本就是要通过聚合 UiStates 来规避上述问题,故 UiStates 很容易爆炸,特别是字段极多情况下,每次回推都要做数十个 diff ,在高实时场景下,难免有性能影响,


MVI 许多页面和业务都需手写定制,难通过自动生成代码等方式半自动开发,故我们我们不如退一步,反思下为什么要用响应式编程?是否非用不可?


穷举所有可能,笔者觉得最合理的解释是,响应式编程十分便于单元测试 —— 由于控件只在观察者中响应,有输入必有回响,


也是因为这原因,官方出于完备性考虑,以响应式编程作为架构示例。


从源头把问题消灭


现实情况往往复杂。


Android 最初为了站稳脚跟,选择复用已有的 Java 生态和开发者,乃至使用 Java 作为官方语言,后来 Java 越来越难支持现代化移动开发,故而转向 Kotlin,


Kotlin 开发者更容易跟着官方文档走,一开始就是接受 Flow 那一套,且 Kotlin 抹平了语法复杂度,天然适合 “响应式编程” 开发,如此便有机会踩坑,乃至有动力通过 MVI 来改善。


然而 10 个 Android 7 个纯 Java ,其中 6 个从不用 RxJava ,剩下一个还是偶尔用用 RxJava 的线程调度切换,所以响应式编程在 Android Java 开发者中的推行不太理想,领导甚至可能为了照顾多数同事,而要求撤回响应式代码,如此便很难有机会踩坑,更谈不上使用 MVI,


也因此,实际开发中更多考虑的是,如何从根源上避免各种不可预期问题。


对此从软件工程角度出发,笔者在设计模式原则中找到答案 —— 任何框架,只要遵循单一职责原则,便能有效避免各种不可预期问题,反之过度设计则易引发不可预期问题。


什么是过度设计,如何避免


上文提到的 “粘性观察者”,对应的是 BehaviorSubject 实现,强调 “总是有一个状态”,比如门要么是开着,要么是关着,门在订阅 BehaviorSubject 时,会被自动回推最后一次 State 来反映状态。


常见 BehaviorSubject 实现有 ObservableField、LiveData、StateFlow 等。


反之是 PublishSubject 实现,对应的是一次性事件,常见 PublishSubject 实现有 SharedFlow 等。


笔者认为,LiveData/StateFlow 存在过度设计,因为它的观察者是开放式,一旦开了这口子,后续便不可控,一个良好的设计是,不暴露不该暴露的口子,不给用户犯错的机会


一个正面的案例是 DataBinding observableField,不向开发者暴露观察者,且一个控件只能在 xml 中绑定一个,从根源上杜绝该问题。


平替方案的探索


至此平替方案便也呼之欲出 —— 使用 ObservableField 来承担 BehaviorSubject,


也即直接在 ViewModel 中调用 ObservableField 通知所绑定的控件响应,且每个 ObservableField 都携带原子数据类型(例如 String、Boolean 等类型),


如此便无需声明 UiStates 数据类。由于无 UiStates、无聚合、也无线程安全问题,也就无需再 reduce 和 diff,简单做个 Actions 为结果分流即可。



综上


响应式编程便于单元测试,但其自身存在漏洞,MVI 即是来消除漏洞,


MVI 有一定门槛,实现较繁琐,且存在性能等问题,难免同事撂挑子不干,一夜回到解放前,


综合来说,MVI 适合与 Jetpack Compose 搭配实现 “现代化的开发模式”,


反之如追求 “低成本、复用、稳定”,可通过遵循 “单一职责原则” 从源头把问题消除。


作者:KunMinX
链接:https://juejin.cn/post/7145317979708735496
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Android 十六进制状态管理实战

背景最近需要实现一个状态管理类:在多种场景下,控制一系列的按钮是否可操作。不同场景下,在按钮不可操作的时候,点击弹出对应的Toast。随着场景数量的增加,这个管理类的实现,就可能会越来越复杂。刚好看到大佬的文章,顺便学习和实践一下。参考学习:就算不去火星种土豆...
继续阅读 »

背景

最近需要实现一个状态管理类:

  1. 在多种场景下,控制一系列的按钮是否可操作。
  2. 不同场景下,在按钮不可操作的时候,点击弹出对应的Toast。
  3. 随着场景数量的增加,这个管理类的实现,就可能会越来越复杂。

刚好看到大佬的文章,顺便学习和实践一下。
参考学习:就算不去火星种土豆,也请务必掌握的 Android 状态管理最佳实践

示例

13c85a24-3607-4d61-8bd2-6311d7200c84.png

还是用大佬那个例子。
例如,存在 3 种模式,和 3个按钮,按钮不可用的时候弹出对应的 Toast。
模式 A 下,要求 按钮1、按钮2 可用,按钮3不可用。点击按钮3,Toast 提示“A3”。
模式 B 下,要求 按钮2 可用,按钮1和按钮3不可用。点击按钮1,Toast 提示“B1”。点击按钮3,Toast 提示“B3”。
模式 C 下,要求 按钮1 可用,按钮2和按钮3不可用。点击按钮2,Toast 提示“C2”。点击按钮3,Toast 提示“C3”。

实现思路

  • Kotlin中的位操作
shl(bits) – 左移位 
shr(bits) – 右移位
and(bits) – 与
or(bits) – 或
  • 定义多个十六进制的状态常量,代表不同的状态。
    private const val STATE_IDIE = 1
private const val STATUS_A = 1 shl 1
private const val STATUS_B = 1 shl 2
private const val STATUS_C = 1 shl 3
  • 定义一个变量,用于存放当前的状态。
    当状态发生变化,需要切换状态的时候,只需要去修改这个变量就行了。
    private var currentStatus = STATE_IDIE

//测试代码
private fun changeStateToA(){
changeStateToA = STATUS_A
}
  • 定义多个十六进制的标志常量,代表对应的禁用操作。
    比如 DISABLE_BTN_1,代表禁用按钮1。
    //定义不可操作的一些行为
private const val DISABLE_BTN_1 = 1 shl 4
private const val DISABLE_BTN_2 = 1 shl 5
private const val DISABLE_BTN_3 = 1 shl 6
  • 定义模式状态集,由状态+多个禁用标志位组成。
    比如 MODE_A,就是在状态为 STATUS_A 的时候,按钮3禁用,那就将这两个数值进行或运算,结果就是 STATUS_A or DISABLE_BTN_3。
    private const val MODE_A = STATUS_A or DISABLE_BTN_3
private const val MODE_B = STATUS_B or DISABLE_BTN_1 or DISABLE_BTN_3
private const val MODE_C = STATUS_C or DISABLE_BTN_2 or DISABLE_BTN_3
private val modeList = listOf(MODE_A, MODE_B, MODE_C)
  • 定义按钮不可点击时的Toast文案 ,使用 HashMap 进行存储映射关系。
  1. key 为对应状态+禁用标志位的 或运算 结果。这样的计算结果,是可以保证key是唯一的,不会出现重复的情况。
  2. value 为对应的 Toast 文案。
  3. 只需要一个 HashMap 就可以实现所有的配置关系。
  4. 从代码阅读性来说,使用这样的代码进行配置,看起来也比较通俗易懂。
    比如 Pair(STATUS_A or DISABLE_BTN_3, "A3"),就是代表在状态A的时候,禁用按钮3,点击按钮的时候弹的Toast文案为 “A3”。
    private val toastMap = hashMapOf(
Pair(STATUS_A or DISABLE_BTN_3, "A3"),
Pair(STATUS_B or DISABLE_BTN_1, "B1"),
Pair(STATUS_B or DISABLE_BTN_3, "B3"),
Pair(STATUS_C or DISABLE_BTN_2, "C2"),
Pair(STATUS_C or DISABLE_BTN_3, "C3")
)
  • 核心逻辑:判断在当前模式下,按钮是否可用。
    是否可用的判断:判断当前所处的状态,是否包含对应定义的禁用操作。
currentStatus and action !=0

若可操作,返回 true。
若不可操作,通过 currentStatus or action 的运算结果作为key,通过上面配置的 HashMap 集合,拿到对应的 Toast 文案。

    /**
* 判断当前某个行为是否可操作
*
* @return true 可操作;false,不可操作。
*/
private fun checkEnable(action: Int): Boolean {
val result = modeList.filter {
(it and currentStatus) != 0
&& (it and action) != 0
}
if (result.isNotEmpty()) {
println("result is false, toast:${toastMap[currentStatus or action]}")
return false
}
println("result is true")
return true
}
复制代码
  • 完整代码
object SixTeenTest {
//定义状态常量
private const val STATE_IDIE = 1
private const val STATUS_A = 1 shl 1
private const val STATUS_B = 1 shl 2
private const val STATUS_C = 1 shl 3

//定义不可操作的一些行为
private const val DISABLE_BTN_1 = 1 shl 4
private const val DISABLE_BTN_2 = 1 shl 5
private const val DISABLE_BTN_3 = 1 shl 6

//定义模式状态集
private const val MODE_A = STATUS_A or DISABLE_BTN_3
private const val MODE_B = STATUS_B or DISABLE_BTN_1 or DISABLE_BTN_3
private const val MODE_C = STATUS_C or DISABLE_BTN_2 or DISABLE_BTN_3
private val modeList = listOf(MODE_A, MODE_B, MODE_C)

//定义Toast映射关系
private val toastMap = hashMapOf(
Pair(STATUS_A or DISABLE_BTN_3, "A3"),
Pair(STATUS_B or DISABLE_BTN_1, "B1"),
Pair(STATUS_B or DISABLE_BTN_3, "B3"),
Pair(STATUS_C or DISABLE_BTN_2, "C2"),
Pair(STATUS_C or DISABLE_BTN_3, "C3")
)

//当前状态
private var currentStatus = STATE_IDIE

/**
* 判断当前某个行为是否可操作
*
* @return true 可操作;false,不可操作。
*/
private fun checkEnable(action: Int): Boolean {
val result = modeList.filter {
(it and currentStatus) != 0
&& (it and action) != 0
}
if (result.isNotEmpty()) {
println("result is false, toast:${toastMap[currentStatus or action]}")
return false
}
println("result is true")
return true
}
}

代码测试

    fun main(args: Array<String>) {
//测试代码
currentStatus = STATUS_A
println("STATUS_A")
checkEnable(DISABLE_BTN_1)
checkEnable(DISABLE_BTN_2)
checkEnable(DISABLE_BTN_3)
currentStatus = STATUS_B
println("STATUS_B")
checkEnable(DISABLE_BTN_1)
checkEnable(DISABLE_BTN_2)
checkEnable(DISABLE_BTN_3)
currentStatus = STATUS_C
println("STATUS_C")
checkEnable(DISABLE_BTN_1)
checkEnable(DISABLE_BTN_2)
checkEnable(DISABLE_BTN_3)
}

输出测试结果

STATUS_A
result is true
result is true
result is false, toast:A3
STATUS_B
result is false, toast:B1
result is true
result is false, toast:B3
STATUS_C
result is true
result is false, toast:C2
result is false, toast:C3

十六进制

761a9a41-50f4-4855-9c0e-dc41aa62edad.png

  • 16进制多状态管理本质上是二进制管理,即‘1’所处的位数。
  • 比如上面定义的各种变量,都是通过1左移n位数之后的结果。
  • 这样能够保证,多个不同变量的与运算、或运算结果,可以是唯一的。比如上面,用这个特性,用来做一层 Toast 文案的映射关系。

总结

  • 确实,像类似的场景,随着业务迭代场景数增加,在没有使用十六进制之前,整体的代码可能是会比较复杂的。
  • 使用十六进制之后,可能需要多花一点时间,去理解一下十六进制相关的知识,但是在代码实现上确实简单了很多。


作者:入魔的冬瓜
链接:https://juejin.cn/post/7147860255370641445
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

乱打日志的男孩运气怎么样我不知道,加班肯定很多!

前言 线上出现问题,你的第一反应是什么? 如果是我的话,第一时间想的应该是查日志: if…else 到底进入了哪个分支? 关键参数是不是有缺失? 入参是不是有问题,没做好校验放进去了? 良好的日志能帮我们快速定位到问题所在,坑你的东西往往最为无形,良好的日...
继续阅读 »

前言


线上出现问题,你的第一反应是什么?


如果是我的话,第一时间想的应该是查日志:



  1. if…else 到底进入了哪个分支?

  2. 关键参数是不是有缺失?

  3. 入参是不是有问题,没做好校验放进去了?


良好的日志能帮我们快速定位到问题所在,坑你的东西往往最为无形,良好的日志就是要让这些玩意无所遁形!


日志级别


Java应用中,日志一般分为以下5个级别:



  • ERROR 错误信息

  • WARN 警告信息

  • INFO 一般信息

  • DEBUG 调试信息

  • TRACE 跟踪信息


1)ERROR


ERROR 级别的日志一般在 catch 块里面出现,用于记录影响当前线程正常运行的错误,出现 Exception 的地方就可以考虑打印 ERROR 日志,但不包括业务异常。


需要注意的是,如果你抛出了异常,就不要记录 ERROR 日志了,应该在最终的地方处理,下面这样做就是不对的:


try {
   int i = 1 / 0;
} catch (Exception e) {
   log.error("出错了,什么错我不知道,啊哈哈哈!", e);
   throw new CloudBaseException();
}

2)WARN


不应该出现,但是不会影响当前线程执行的情况可以考虑打印 WARN 级别的日志,这种情况有很多,比如:



  • 各种池(线程池、连接池、缓存池)的使用超过阈值,达到告警线

  • 记录业务异常

  • 出现了错误,但是设计了容错机制,因此程序能正常运行,但需要记录一下


3)INFO


使用最多的日志级别,使用范围很广,用来记录系统的运行信息,比如:



  • 重要模块中的逻辑步骤呈现

  • 客户端请求参数记录

  • 调用第三方时的参数和返回结构


4)DEBUG


Debug 日志用来记录自己想知道的所有信息,常常是某个功能模块运行的详细信息,已经中间的数据变化,以及性能信息。


Debug 信息在生产环境一般是关闭状态的,需要使用开关管理(比如 SpringBoot Admin 可以做到),一直开启会产生大量的 Debug,而 Debug 日志在程序正常运行时大部分时间都没什么用。


if (log.isDebugEnabled()) {
   log.debug("开始执行,开始时间:[{}],参数:[{}]", startTime, params);
   log.debug("通过计算,得到参数1:[{}],参数2:[{}]", param1, param2);
   log.debug("最后处理结果:[{}]", result);
}

5)TRACE


特别详细的系统运行完成信息,业务代码中一般不使用,除非有特殊的意义,不然一般用 DEBUG 代替,事实上,我编码到现在,也没有用过这个级别的日志。


使用正确的格式


如果你是这样打印日志的:


log.info("根据条件id:{}" + id + "查询用户信息");

不要这样做,会产生大量的字符串对象,占用空间的同时也会影响性能。


正确的做法是使用参数化信息的方式:


log.info("根据条件id:[{}],查询用户信息", id);

这样做除了能避免大量创建字符串之外,还能明确的把参数隔离出去,当你需要把参数复制出来的时候,只需要双击鼠标即可,而不是用鼠标慢慢对准再划拉一下。


这样打出来的日志,可读性强,对排查问题的帮助也很大!


小技巧


1)多线程


遇到多个线程一起执行的日志怎么打?


有些系统,涉及到并发执行,定时调度等等,就会出现多次执行的日志混在一起,出问题不好排查,我们可以把线程名打印进去,或者加一个标识用来表明这条日志属于哪一次执行:


if (log.isDebugEnabled()) {
   log.debug("执行ID=[{}],处理了ID=[{}]的消息,处理结果:[{}]", execId, id, result);
}

2)使用 SpringBoot Admin 灵活开关日志级别


image-20220727155526217


写在最后


一开始写代码的时候,没有规范日志的意识,不管哪里,都打个 INFO,打印出来的东西也没有思考过,有没有意义,其实让自己踩了不少坑,加了不少班,回过头,我想对学习时期的我说一句:”能让你加班的东西,都藏在各种细节里!写代码之前,先好好学习如何打日志!“


作者:你算哪块小蛋糕
链接:https://juejin.cn/post/7124958610123128839
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Android性能优化 - 包体积杀手之R文件内联原理与实现

前言&背景 包体积也是性能优化的常客之一了,在包体积简化的历史潮流中,已经涌现了很多包体积杀手级别方案,比如动态so方案,R文件内联等等,由于笔者已经在之前的文章中介绍过动态so方案,那么本次专栏就不重复,于是就介绍另一个包体积杀手方案,从R文件内联的...
继续阅读 »

前言&背景


包体积也是性能优化的常客之一了,在包体积简化的历史潮流中,已经涌现了很多包体积杀手级别方案,比如动态so方案,R文件内联等等,由于笔者已经在之前的文章中介绍过动态so方案,那么本次专栏就不重复,于是就介绍另一个包体积杀手方案,从R文件内联的角度出发,看看我们是怎么完成一次华丽的R文件精简


在android 日常开发中,我们对资源的引用都会用到R.xx.xx 去获取,R文件这个贯穿着整个项目周期,从而简化了我们很多工作。R文件带给我们便利的同时,也会带来很多对包体积的负面影响,其中有一个就是R文件过大导致的,一个中型项目的R文件,可能会达到10M,其中资源重复的部分/多余的部分,可能就有4/5M,下面我们从实战角度出发,探索一下R文件的“今生前世”


R文件产生


在Android编译打包的过程中,位于res/目录下的文件,就会通过aapt工具,对里面的资源进行编译压缩,从而生成相应的资源id,且生成R.java文件,用于保存当前的资源信息,同时生成resource.arsc文件,建立id与其对应资源的值,如图


image.png
其中R文件中还有几个static final 修饰的子类,比如anim,attr,layout,分别对应着res/目录下相应的子目录,同时资源id用一个16进制的int数值表示。比如0x7f010000,我们来解释一下具体含义



  1. 第一个字节7f:代表着这个资源属于本应用apk的资源,相应的以01代表开头的话(比如0x01010000)就代表这是一个与应用无关的系统资源。0x7f010000,表明abc_fade_in 属于我们应用的一个资源

  2. 第二个字节01:是指资源的类型,比如01就代表着这个资源属于anim类型

  3. 第三,四个字节0000:指资源的编号,在所属资源类型中,一般从0000开始递增


同时我们可以留意到,这个R文件的内部的属性,都是以static final 修饰的,这就意味着是一个常量类型,常量在编译过程中,可以被“内联化”,即R.anim.abc_fade_in 可以用0x7f010000所代表的数值进行直接替换。


正常情况下,即app目录下的R文件的过程就是如此,那么类似lib(子module),aar包的这种,生成的R文件也是这样吗?不是的!如果说lib/aar 也是这样生成R文件的话,那么最终的资源id也会是常量,比如lib中有个R.anim.other,按照默认模式的话,生成的id也应该是0x7f010000,这样一来就会产生了一个问题,就是资源id冲突的问题!因为app模块下R.anim.abc_fade_in 跟子module/aar中有个R.anim.other产生了同一个id。所以gradle给出了一个解决方法,就是在编译子module或者aar中,编译的时候产生一个R.def.txt(只在module有效,不同AGP版本会有不同实现,低版本是R.txt)文件,该文件记录着当前的资源映射关系,比如我在子module中有一个布局layout文件activity_my_test


image.png


此时生成的这个映射关系就不包含真正的资源id,而是把其当作一个输入传给app模块一起编译,从而得到最终的资源id,并且生成一个全局的R文件(该R文件包含了所有子module/aar的R文件与自身的R文件)与全局R.txt文件(记录所有id映射,这个大有用途,我们下面会讲),并且此时通过gralde中任务generateDebugRFile,生成一个专属于子module的R文件(此时的R文件就是static final的,因为经过了资源id的重新定位)所以此时子module中也就可以用R.xx.xx去引用到自己的资源,比如上述的activity_my_test所属于的子module生成的R文件如下:


image.png
全局的R文件也会包含子R文件的内容


image.png
我们再把大致的过程如图展示


image.png


R文件过大的原因


通过上述R文件生成的分析,我们可以看到R文件占用多余的原因,下层的module/aar生成的id除了自己会生成一个R文件外,同时也会在全局的R文件生成一个一个同样的属性,比如activity_my_test存在了两份,如果module多的话,R文件的数量同样会膨胀上升!在我们组件化过程中,多module肯定是很正常的,前文我们也说过,app module中的R文件会被内联化替代,所以appmodule 中的R文件内容如果没有被直接引用了,是可以通过proGuard去直接删除掉的,所以release环境下我们可以通过proGuard去移除不必要的R文件,但是被引用到的R文件(module/aar中的R文件)就无法这么做了,同时如果项目中存在(反向引用,比如其他模块依赖了app,情况比较少)那么所有的R文件就都无法被proGuard删除了。


R文件内联方案


R.txt


在上面讲解中,我们留下了一个疑问,就是R.txt,它记录了所有的资源映射关系,那么这个R.txt存放在哪里呢?我们以com.android.tools.build:gradle:3.4.1中的源码为例子:生成R.txt是在



TaskManager中

private void createNonNamespacedResourceTasks(
@NonNull VariantScope scope,
@NonNull File symbolDirectory,
InternalArtifactType packageOutputType,
@NonNull MergeType mergeType,
@NonNull String baseName,
boolean useAaptToGenerateLegacyMultidexMainDexProguardRules) {
File symbolTableWithPackageName =
FileUtils.join(
globalScope.getIntermediatesDir(),
FD_RES,
"symbol-table-with-package",
scope.getVariantConfiguration().getDirName(),
"package-aware-r.txt");
final TaskProvider<? extends ProcessAndroidResources> task;
// 重点
File symbolFile = new File(symbolDirectory, FN_RESOURCE_TEXT);

FN_RESOURCE_TEXT是个常量,代表着 R.txt,同时可以看到symbolDirectory就是路径,而这个赋值在createApkProcessResTask 中


private void createApkProcessResTask(@NonNull VariantScope scope,
InternalArtifactType packageOutputType) {
createProcessResTask(
scope,
new File(
globalScope.getIntermediatesDir(),
"symbols/" + scope.getVariantData().getVariantConfiguration().getDirName()),
packageOutputType,
MergeType.MERGE,
scope.getGlobalScope().getProjectBaseName());
}

所以路径我们一目了然了,就是在build/intermediates/symbols之后的子目录下(这个在4.多的版本有变化,4.多在runtime_symbol_list 子目录下,需要注意)这样一来,我们就得到了R.txt,之后解析的时候有用


R文件字节码替换


了解方案之前,我们看一下平常的R文件是怎么被使用的,我们以setContentView这个举例,当我们在app模块中,调用了


setContentView(R.layout.activity_main)

编译后的字节码是这样的:


 LDC 2131427356
INVOKEVIRTUAL com/example/spider/MainActivity.setContentView (I)V

但是当我们在子moudle中


setContentView(R.layout.activity_my_test)

同样看一下字节码


 GETSTATIC com/example/test/R$layout.activity_my_test : I
INVOKEVIRTUAL com/example/test/MyTestActivity.setContentView (I)V

看看我们发现了什么!同样是setContentView,入参是int类型情况下,在app模块中,是通过常量引入的方式 LDC 2131427356放入操作数栈,提供给setContentView消费的,那么这个2131427356是什么呢?其实就是资源id,我们可以在R.txt中找到


image.png
而7f0b001c换算成10进制,就是2131427356。


既然app模块能这样做,我们又知道了R.txt内容,所以我们能不能把子module的GETSTATIC换成跟app模块一样的实现呢?这样我们就不用依赖了R文件,后期就可以通过proGuard删除了!答案是可以的!这个时候只需要我们从R.txt 找到activity_my_test 对应的id(例子是0x7f0b001d),换算成10进制就是2131427357,我们再把GETSTATIC 指令替换成LDC指令就完事了,代码如下:


ASM tree api

if(node.opcode == Opcodes.GETSTATIC && node.desc == "I" && node.owner.substring(node.owner.lastIndexOf('/') + 1).startsWith('R$')&& !(node.owner.startsWith(COM_ANDROID_INTERNAL_R) || node.owner.startsWith(ANDROID_R))){
println("get node ")
def ldc = new LdcInsnNode(2131427357)
method.instructions.insertBefore(node,ldc)
method.instructions.remove(node)
}

LdcInsnNode(2131427357) 通过指令集替换,我们就实现了R文件内联的操作了,同时这里还有很多小玩法,比如LdcInsnNode(特定的id),我们甚至能够实现编译时替换布局,这里就不过多展开


扩展


看到这里,我们就能明白了R文件内联究竟干了什么,但是实际上我们也只是替换了一个R文件罢了,如果我们想要替换更多的R文件怎么办?没事!这里已经有很多成熟的开源库帮我们做啦!比如bytex booster


当然,R文件内联不一定适用所有场景,比如直接用到R文件id的场景就不适合了


public static int getId(String num){ 
try {
String name = "drawable" + num;
Field field = R.drawable.class.getField(name);
return field.getInt(null);
} catch (Exception e) {
e.printStackTrace();
}
return 0;
}

因为涉及到了R文件属性的读写,这种情况我们就不能用内联的方式了!同时常见的还有ConstraintLayout中,会涉及到id的直接使用,这部分就要加入Transform转换的白名单,避免被内联化啦!


总结


通过本文的阅读,相信你已经了解到包体积优化中-R文件内联的实现思路啦!我们在实际项目中也用到了R文件内联,收益大概是5M左右。如果你的项目是agp 4.1.0 的话,可以直接开启R 文件的内联,不需要引入三方库即可实现!!官方都引入了这个特性,这个稳定性是可以保证的啦!详细可查看:
developer.android.com/studio/rele…


作者:Pika
链接:https://juejin.cn/post/7146807432755281927
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

依赖反转原则到底反转了什么

问题SOLID是常用的用来设计类以及类和类之间关系的设计原则,它的主要作用就是告诉我们如何将数据和函数组织为类,以及如何将这些类链接为程序,从而使得软件容易扩展、容易理解以及容易复用。其中D代表的就是依赖反转原则(Dependence Inversion Pr...
继续阅读 »

问题

SOLID是常用的用来设计类以及类和类之间关系的设计原则,它的主要作用就是告诉我们如何将数据和函数组织为类,以及如何将这些类链接为程序,从而使得软件容易扩展、容易理解以及容易复用。其中D代表的就是依赖反转原则(Dependence Inversion Principle),常见的解释就是高层模块和低层模块都应该依赖抽象,而不应该依赖具体实现,这样就能更好的实现模块间的解耦。但是反转又体现在了哪里呢?

面向对象编程

为了解决这种问题,先看一下面向对象编程,面向对象编程是常见的编程范式之一,同时还有结构化编程与函数式编程,这三种编程范式对程序员的编写模式做了限定,与具体的编程语言关系较小。有时候我们会说Java是面向对象的语言,而C是面向结构的语言,我理解这些是在语言设计之初,设计者就对于语言的语法做了相关范式的选择,但是用C能不能进行面向对象的编程呢?我想也是可以的,只要遵守相应的规范。比如说,面向对象编程的三个特点:封装、继承、多态通过C其实也可以实现。

安全稳定的多态

这里主要对比一下C语言的多态和Java的多态方式,C语言通过函数指针实现多态,Java通过接口或者父类的实现类来复写方法来实现多态。这里以实现屏幕的展示功能为例。

//C
//screen.h
struct Screen{
void (*show)();
}

//huaweiscreen.h
#include "screen.h"
void show(){ //具体实现}
struct Screen huaiWeiScreen={show};

//main.c
include "huaweiscreen.h"
int main(){
struct Screen* screen=&huaiWeiScreen;
screen->show();
return 0;
}
//Java
interface Screen{
void show();
}

class HuaWeiScreen implements Screen{
@Override
public void show(){
//具体实现
}
}

public static void main(String[] args){
Screen screen=new HuaiWeiScreen();
screen.show();
}

Java通过接口实现的多态在语义上同C语言通过函数指针实现多态的对比来看,要清晰的多,同时对比函数指针,也更为安全,毕竟如果函数指针如果指错了实现,那么整个程序就会造成难以跟踪和消除的bug。由此可以看出类似Java这种面向对象的语言为我们提供了一种更加容易理解、更加安全的的多态实现方式。

依赖反转

在安全和易于理解的多态支持出现之前,软件的架构是什么样子呢?

依赖注入架构.png

业务逻辑模块依赖数据库模块和UI模块,数据流动的方向和源码依赖的方向是一致的,但是这其实带来一个问题,业务逻辑本身其实并不关心数据库和UI的具体实现,使用MySQL还是SQLite以及是用文字展示UI还是动画展示UI业务逻辑不应该感知,但是因为缺乏一个容易理解、安全的多态方式,导致数据流动的方向和源码依赖方向必需保持一致,也让业务逻辑本身和数据库的具体实现以及UI的具体实现进行了绑定,这是不利于后续功能的扩展和修改的。 看一下使用多态后,架构设计的调整。

依赖注入改.png

业务逻辑模块声明数据库接口类和UI接口类,数据库模块和UI模块通过依赖业务逻辑模块,实现对应的接口,并且在更高层模块注入业务逻辑,这样数据库模块和UI模块成为了业务逻辑的插件,这种插件式的架构方便了插件的替换,业务逻辑模块不再依赖数据库模块以及UI模块,可以进行独立的部署,数据库模块的修改和UI模块的修改也不会对业务逻辑造成影响。可以注意到数据流动的方向和源码依赖的方向不再保持一致了,这就是多态的好处,无论怎样的源代码级别的依赖关系,都可以将其反转。

结论

所谓依赖反转的反转,是指支持多态的语言相对于不支持多态的语言,数据流方向和源码依赖方向不用再保持一致,可以相反,源码依赖的方向完全由开发者控制


作者:滑板上的老砒霜
链接:https://juejin.cn/post/7147285415966277669
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

Kotlin协程:MutableStateFlow的实现原理

一.MutableStateFlow接口的实现 1.MutableStateFlow方法     在Kotlin协程:StateFlow的设计与使用中,讲到可以通过MutableSharedFlow方法创建一个Mutab...
继续阅读 »

一.MutableStateFlow接口的实现


1.MutableStateFlow方法


    在Kotlin协程:StateFlow的设计与使用中,讲到可以通过MutableSharedFlow方法创建一个MutableSharedFlow接口指向的对象,代码如下:


@Suppress("FunctionName")
public fun <T> MutableStateFlow(value: T): MutableStateFlow<T> =
StateFlowImpl(value ?: NULL)

...

@JvmField
@SharedImmutable
internal val NULL = Symbol("NULL")

    在MutableStateFlow方法中,根据参数value创建并返回一个类型为StateFlowImpl的对象,如果参数value为空,则传入对应的标识NULL。


二.StateFlowImpl类


    StateFlowImpl类是MutableStateFlow接口的核心实现,它的继承关系与SharedFlowImpl类的继承关系类似,如下图所示:
image.png



  • AbstractSharedFlow类:提供了对订阅者进行管理的方法。

  • CancellableFlow接口:用于标记StateFlowImpl类型的Flow对象是可取消的。

  • MutableStateFlow接口:表示StateFlowImpl类型的Flow对象是一个单数据更新的热流。

  • FusibleFlow接口:表示StateFlowImpl类型的Flow对象是可融合的。


1.发射数据的管理


    在StateFlowImpl中,当前的数据被保存在名为_state的全局变量中,_state表示StateFlowImpl类型对象的状态,当前代码如下:


private class StateFlowImpl<T>(
initialState: Any
) : AbstractSharedFlow<StateFlowSlot>(), MutableStateFlow<T>, CancellableFlow<T>, FusibleFlow<T> {
// 当前状态
private val _state = atomic(initialState)

...
}

    除此之外,StateFlowImpl不对其他的数据做缓存。


2.订阅者的管理


    由于StateFlowImpl类与SharedFLowImpl类都继承自AbstractSharedFlow类,因此二者订阅者管理的核心逻辑相同,这里不再赘述,详情可参考Kotlin协程:MutableSharedFlow的实现原理


    唯一不同的地方在,在SharedFlowImpl类中,订阅者数组中存储的对象类型为SharedFlowSlot,而在StateFlowImpl类中,订阅者数组存储的对象类型为StateFlowSlot。


1)StateFlowSlot类


    StateFlowSlot类与SharedFlowSlot类类似,都继承自AbstractSharedFlowSlot类。但相比于SharedFlowImpl类型的对象,StateFlowImpl类型的对象是有状态的。


    在StateFlowSlot类中,有一个名为_state的全局变量,代码如下:


@SharedImmutable
private val NONE = Symbol("NONE") // 状态标识

@SharedImmutable
private val PENDING = Symbol("PENDING") // 状态标识

private class StateFlowSlot : AbstractSharedFlowSlot<StateFlowImpl<*>>() {
// _state中默认值为null
private val _state = atomic<Any?>(null)

...
}

    根据_state保存对象的不同,可以确定StateFlowSlot类型的对象的状态。StateFlowSlot类型的对象共有四种状态:




  • null:如果_state保存的对象为空,表示当前StateFlowSlot类型的对象没有被任何订阅者使用。




  • NONE:如果_state保存的对象为NONE标识,表示当前StateFlowSlot类型的对象已经被对应的订阅者使用,但既没有挂起,也没有在处理当前的数据。




  • PENDING:如果_state保存的对象为PENDING标识,表示当前StateFlowSlot类型的对象已经被对应的订阅者使用,并且将开始处理当前的数据。




  • CancellableContinuationImpl<Unit>:如果_state保存的对象为续体,表示当前StateFlowSlot类型的对象已经被对应的订阅者使用,但是订阅者已处理完当前的数据,所在的协程已被挂起,等待新的数据到来。




a)订阅者状态的管理


    在StateFlowSlot类中,重写了AbstractSharedFlowSlot类的allocateLocked方法与freeLocked方法,顶用两个方法会对订阅者的初始状态和最终状态进行改变,代码如下:


// 新订阅者申请使用当前的StateFlowSlot类型的对象
override fun allocateLocked(flow: StateFlowImpl<*>): Boolean {
// 如果_state保存的对象不为空,
// 说明当前StateFlowSlot类型的对象已经被其他订阅者使用
// 返回false
if (_state.value != null) return false
// 走到这里,说明没有被其他订阅者使用,分配成功
// 修改状态值为NONE
_state.value = NONE
// 返回true
return true
}

// 订阅者释放已经使用的StateFlowSlot类型的对象
override fun freeLocked(flow: StateFlowImpl<*>): Array<Continuation<Unit>?> {
// 修改状态值为null
_state.value = null
// 返回空数组
return EMPTY_RESUMES
}


@JvmField
@SharedImmutable
internal val EMPTY_RESUMES = arrayOfNulls<Continuation<Unit>?>(0)

    为了实现上述对订阅者状态的管理,在StateFlowSlot类中,还额外提供了三个方法用于实现对订阅者的状态的切换,代码如下:


// 当有状态更新成功时,会调用makePending方法,通知订阅者可以开始处理新数据
@Suppress("UNCHECKED_CAST")
fun makePending() {
// 根据当前状态判断
_state.loop { state ->
when {
// 如果未被订阅者使用,则直接返回
state == null -> return
// 如果已经处于PENDING状态,则直接返回
state === PENDING -> return
// 如果当前状态为NONE
state === NONE -> {
// 通过CAS的方式,将状态修改为PENDPENDING,并返回
if (_state.compareAndSet(state, PENDING)) return
}
// 如果为挂起状态
else -> {
// 通过CAS的方法,将状态修改为NONE
if (_state.compareAndSet(state, NONE)) {
// 如果修改成功,则恢复对应续体的执行,并返回
(state as CancellableContinuationImpl<Unit>).resume(Unit)
return
}
}
}
}
}

// 当订阅者每次处理完新数据(不一定处理成功)后,会调用takePending方法,表示完成处理
// 获取当前的状态,并修改新状态为NONE
fun takePending(): Boolean = _state.getAndSet(NONE)!!.let { state ->
assert { state !is CancellableContinuationImpl<*> }
// 如果之前的状态为PENDING,则返回true
return state === PENDING
}

// 当订阅者没有新数据需要处理时,会调用awaitPending方法挂起
@Suppress("UNCHECKED_CAST")
// 直接挂起,获取续体
suspend fun awaitPending(): Unit = suspendCancellableCoroutine sc@ { cont ->
assert { _state.value !is CancellableContinuationImpl<*> }
// 通过CAS的方式,将当前的状态修改为挂起,并返回
if (_state.compareAndSet(NONE, cont)) return@sc
// 走到这里代表状态修改失败,说明又发射了新数据,当前的状态被修改为PENDING
assert { _state.value === PENDING }
// 唤起订阅者续体的执行
cont.resume(Unit)
}

3.数据的接收


    当调用StateFlow类型对象的collect方法,会触发订阅过程,接收emit方法发送的数据,这部分在
StateFlowImpl中实现,代码如下:


override suspend fun collect(collector: FlowCollector<T>) {
// 为当前的订阅者分配一个StateFlowSlot类型的对象
val slot = allocateSlot()
try {
// 如果collector类型为SubscribedFlowCollector,
// 说明订阅者监听了订阅过程的启动,则先回调
if (collector is SubscribedFlowCollector) collector.onSubscription()
// 获取订阅者所在的协程
val collectorJob = currentCoroutineContext()[Job]
// 局部变量,保存上一次发射的数据,初始值为null
var oldState: Any? = null
// 死循环
while (true) {
// 获取当前的数据
val newState = _state.value
// 判断订阅者所在协程是否是存活的,如果不是则抛出异常
collectorJob?.ensureActive()
// 如果订阅者是第一次处理数据或者当前数据与上一次数据不同
if (oldState == null || oldState != newState) {
// 将数据发送给下游
collector.emit(NULL.unbox(newState))
// 保存当前发射数据到局部变量
oldState = newState
}

// 修改状态,如果之前不是PENGDING状态
if (!slot.takePending()) {
// 则挂起等待新数据更新
slot.awaitPending()
}
}
} finally {
// 释放已分配的StateFlowSlot类型的对象
freeSlot(slot)
}
}

    在上述代码中,假设当前订阅者处于PENGDING状态,并在处理数据后,通过takePending方法,将自身状态修改为NONE,由于之前为PENGDING状态,因此不会执行awaitPending方法进行挂起。因此进行了第二次循环,而在第二次调用takePending方法之前,如果数据没有更新,则订阅者将一直处于NONE状态,当再次调用takePending方法时,会调用awaitPending方法,将订阅者所在协程挂起。


4.数据的发射


    在StateFlowImpl类中,当需要发射数据时,可以调用emit方法、tryEmit方法、compareAndSet方法,代码如下:


override fun tryEmit(value: T): Boolean {
this.value = value
return true
}

override suspend fun emit(value: T) {
this.value = value
}

override fun compareAndSet(expect: T, update: T): Boolean =
updateState(expect ?: NULL, update ?: NULL)

    compareAndSet方法内部调用updateState方法对数据进行更新,而emit方法与tryEmit方法内部通过value属性对数据进行更新,代码如下:


@Suppress("UNCHECKED_CAST")
public override var value: T
// 拆箱
get() = NULL.unbox(_state.value)
// 更新数据
set(value) { updateState(null, value ?: NULL) }

// 拆箱操作
@Suppress("UNCHECKED_CAST", "NOTHING_TO_INLINE")
inline fun <T> unbox(value: Any?): T = if (value === this) null as T else value as T

    可以发现,无论是通过emit方法、tryEmit方法还是compareAndSet方法,最终都是通过updateState方法实现数据的更新,代码如下:


// sequence是一个全局变量,当新的数据更新时,sequence会发生变化
// 当sequence为奇数时,表示当前数据正在更新
private var sequence = 0

// CAS方式更新当前数据的值
private fun updateState(expectedState: Any?, newState: Any): Boolean {
var curSequence = 0
// 获取所有的订阅者
var curSlots: Array<StateFlowSlot?>? = this.slots
// 加锁
synchronized(this) {
// 获取当前数据的值
val oldState = _state.value
// 如果期待数据不为空,同时当前数据不等于期待数据,则返回false
if (expectedState != null && oldState != expectedState) return false
// 如果新数据与老数据相同,即前后数据没有发生变化,则直接返回true
if (oldState == newState) return true

// 更新当前数据
_state.value = newState
// 获取全局变量
curSequence = sequence
// 如果为偶数,说明updateState方法没有被其他协程调用,没有并发
if (curSequence and 1 == 0) {
// 自增加1,表示当前正在更新数据
curSequence++
// 将新值保存到全局变量中
sequence = curSequence
} else { // 如果为奇数,说明updateState方法正在被其他协程调用,处于并发中
// 加2后不改变奇偶性,只是表示当前数据发生了变化
sequence = curSequence + 2
// 返回true
return true
}
// 获取当前所有的订阅者
curSlots = slots
}

// 走到这里,说明上面不是并发调用updateState方法的情况

// 循环,通知订阅者
while (true) {
// 遍历,修改订阅者的状态,通知订阅者
curSlots?.forEach {
it?.makePending()
}
// 加锁,判断在通知订阅者的过程中,数据是否又被更新了
synchronized(this) {
// 如果数据没有被更新
if (sequence == curSequence) {
// 加1,让sequence变成偶数,表示更新完毕
sequence = curSequence + 1
// 返回true
return true
}
// 如果数据有被更新,则获取sequence和订阅者
// 再次循环
curSequence = sequence
curSlots = slots
}
}
}

5.新订阅者获取缓存数据


    当新订阅者出现时,StateFlow会将当前最新的数据发送给订阅者。可以通过调用StateFlowImpl类重写的常量replayCache获取当前最新的数据,代码如下:


override val replayCache: List<T>
get() = listOf(value)

    在StateFlow中,清除replayCache是无效的,因为StateFlow中必须持有一个数据,因此调用
resetReplayCache方法会抛出异常,代码如下:


@Suppress("UNCHECKED_CAST")
override fun resetReplayCache() {
throw UnsupportedOperationException("MutableStateFlow.resetReplayCache is not supported")
}

6.热流的融合


    SharedFlowImpl类实现了FusibleFlow接口,重写了其中的fuse方法,代码如下:


// 内部调用了fuseStateFlow方法
override fun fuse(context: CoroutineContext, capacity: Int, onBufferOverflow: BufferOverflow) =
fuseStateFlow(context, capacity, onBufferOverflow)

...

internal fun <T> StateFlow<T>.fuseStateFlow(
context: CoroutineContext,
capacity: Int,
onBufferOverflow: BufferOverflow
): Flow<T> {
assert { capacity != Channel.CONFLATED }
// 如果容量为0、1、BUFFERED,同时溢出策略为DROP_OLDEST
if ((capacity in 0..1 || capacity == Channel.BUFFERED) && onBufferOverflow == BufferOverflow.DROP_OLDEST) {
// 返回自身
return this
}
// 调用fuseSharedFlow方法
return fuseSharedFlow(context, capacity, onBufferOverflow)
}

7.只读热流


    调用MutableStateFlow方法,可以得到一个类型为MutableStateFlow的对象。通过这个对象,我们可以调用它的collect方法来订阅接收,也可以调用它的emit方法来发射数据。但大多数的时候,我们需要统一数据的发射过程,因此需要对外暴露一个只可以调用collect方法订阅而不能调用emit方法发射的对象,而不是直接暴露MutableStateFlow类型的对象。


    根据上面代码的介绍,订阅的过程实际上是对数据的获取,而发射的过程实际上是数据的修改,因此如果一个流只能调用collect方法而不能调用emit方法,这种流这是一种只读流。


    事实上,在Kotlin协程:StateFlow的设计与使用分析接口的时候可以发现,MutableStateFlow接口继承了MutableSharedFlow接口,MutableSharedFlow接口继承了FlowCollector接口,emit方法定义在FlowCollector中。StateFlow接口继承了Flow接口,collect方法定义在Flow接口中。因此只要将MutableStateFlow接口指向的对象转换为StateFlow接口指向的对象就可以将读写流转换为只读流。


    在代码中,对MutableStateFlow类型的对象调用asStateFlow方法恰好可以实现将读写流转换为只读流,代码如下:


// 该方法调用了ReadonlyStateFlow方法,返回一个类型为StateFlow的对象
public fun <T> MutableStateFlow<T>.asStateFlow(): StateFlow<T> =
// 传入当前的MutableStateFlow类型的对象
ReadonlyStateFlow(this)

// 实现了FusibleFlow接口,
// 实现了StateFlow接口,并且使用上一步传入的MutableStateFlow类型的对象作为代理
private class ReadonlyStateFlow<T>(
flow: StateFlow<T>
) : StateFlow<T> by flow, CancellableFlow<T>, FusibleFlow<T> {
override fun fuse(context: CoroutineContext, capacity: Int, onBufferOverflow: BufferOverflow) =
// 用于流融合,也是通过fuseStateFlow方法实现
fuseStateFlow(context, capacity, onBufferOverflow)
}

作者:李萧蝶
链接:https://juejin.cn/post/7147236984963432456
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Kotlin协程:StateFlow的设计与使用

一.StateFlow的设计     StateFlow是一种单数据更新的热流,通过emit方法更新StateFlow的数据,通过value属性可以获取当前的数据。在StateFlow中,核心接口的继承关系如下图所示: ...
继续阅读 »

一.StateFlow的设计


    StateFlow是一种单数据更新的热流,通过emit方法更新StateFlow的数据,通过value属性可以获取当前的数据。在StateFlow中,核心接口的继承关系如下图所示:
image.png


1.StateFlow接口


    StateFlow接口继承自SharedFlow接口,代码如下:


public interface StateFlow<out T> : SharedFlow<T> {
// 当前的数据
public val value: T
}



  • 订阅过程:在StateFlow中,每个FlowCollecter类型的对象都被称为订阅者。调用StateFlow类型对象的collect方法会触发订阅。正常情况下,订阅不会自动结束,但订阅者可以取消订阅,当订阅者所在的协程被取消时,订阅过程就会取消。




  • 冷流转换热流:对于一个冷流,可以通过调用stateIn方法,转换为一个单数据更新的热流。




  • 相等判定:在StateFlow中,通过Any#equals方法来判断前后两个数据是否相等。当前后两个数据相等时,数据不会被更新,订阅者也不会处理。




  • 数据缓存:StateFlow必须要有一个初始值。当新订阅者出现时,StateFlow会将最新的数据发射给订阅者。StateFlow只保留最后发射的数据,除此之外不会缓存任何其他的数据。同时,StateFlow不支持resetReplayCache方法。




  • StateFlow并发: StateFlow中所有的方法都是线程安全的,并且可以在多协程并发的场景中使用且不必额外加锁。




  • 操作符使用:对StateFlow使用flowOn操作符、conflate操作符、参数为CONFLATED或RENDEZVOUS的buffer操作符、cancellable操作符是无效的。




  • 使用场景:使用StateFlow作为数据模型,可以表示任何状态。




  • StateFlow与SharedFlow的区别:StateFlow是SharedFlow的一种特定方向的、高性能的、高效的实现,广泛的用于单状态变化的场景,所有与SharedFlow相关基本规则、约束、操作符都适用于StateFlow。当使用如下的参数创建SharedFlow对象,并对其使用distinctUntilChanged操作符,可以得到一个与StateFlow行为相同的SharedFlow对象:




// StateFlow
val stateFlow = MutableStateFlow(initialValue)

// 与StateFlow行为相同的SharedFlow
// 注意参数
val sharedFlow = MutableSharedFlow(
replay = 1,
extraBufferCapacity = 0,
onBufferOverflow = BufferOverflow.DROP_OLDEST)

// 设置初始值
sharedFlow.tryEmit(initialValue)

// distinctUntilChanged方法,只有当前后发射的两个数据不同时才会将数据向下游发射
val state = sharedFlow.distinctUntilChanged()


  • StateFlow与ConflatedBroadcastChannel的区别:从概念上讲,StateFlow与ConflatedBroadcastChannel很相似,但二者也有很大的差别,推荐使用StateFlow,StateFlow设计的目的就是要在未来替代ConflatedBroadcastChannel:

    • StateFlow更简单,不需要实现一堆与Channel相关的接口。

    • StateFlow始终持有一个数据,并且无论在任何时间都可以安全的通过value属性获取。

    • StateFlow清楚地划分了只读的StateFlow和可读可写的StateFlow。

    • StateFlow对前后数据的比较是与distinctUntilChanged操作符类似的,而ConflatedBroadcastChannel对数据进行相等比较是基于标识引用。

    • StateFlow不能关闭,也不能表示失败,因此如果需要,所有的错误与完成信号都应该具体化。




2. MutableStateFlow接口


    MutableStateFlow接口继承自MutableSharedFlow接口与StateFlow接口,并在此基础上定义了一个新方法compareAndSet,代码如下:


public interface MutableStateFlow<T> : StateFlow<T>, MutableSharedFlow<T> {
// 当前数据
public override var value: T

// 通过CAS的方式,更新value
// 如果except与value相等,则将value更新为update,并返回true
// 如果except与value不相等,不做任何操作,直接返回false
// 如果except、value、update同时相等,不做任何操作,直接返回true
public fun compareAndSet(expect: T, update: T): Boolean
}

二.StateFlow的使用


1.MutableStateFlow方法


    在协程中,可以通过调用MutableStateFlow方法创建一个MutableStateFlow接口指向的对象,代码如下:


public fun <T> MutableStateFlow(value: T): MutableStateFlow<T> {
...
}

    通过MutableStateFlow方法可以创建一个类型为MutableStateFlow的对象,需要提供一个参数value,作为初始值。


    在并发场景下调用emit方法时,会使StateFlow的数据快速更新,对于处理数据慢的订阅者,将会跳过这些快速更新的数据,但当订阅者需要处理数据时,获取的一定是最新更新的数据。


2.使用示例


    代码如下:


private suspend fun test() {
// 创建一个热流,初始值为1
val flow = MutableStateFlow(1)

// 将MutableStateFlow对象转换为StateFlow对象
// StateFlow对象不能调用emit方法,因此只能用于接收
val onlyReadFlow = flow.asStateFlow()

// 接收者1
// 启动一个新的协程
GlobalScope.launch {
// 触发并处理接收的数据
onlyReadFlow.collect {
Log.d("liduozuishuai", "test1: $it")
}
}

// 接收者2
// 启动一个新协程
GlobalScope.launch {
// 订阅监听,当collect方法触发订阅时,会首先会调onSubscription方法
onlyReadFlow.onSubscription {
Log.d("liduozuishuai", "test2: ")
// 发射数据:2
// 向下游发射数据:2,其他接收者收不到
emit(2)
}.onEach {
// 处理接收的数据
Log.d("liduozuishuai", "test2: $it")
}.collect()
}

// 发送数据:3,多次发送
GlobalScope.launch {
flow.emit(3)
flow.emit(3)
flow.compareAndSet(3, 3)
}
}

    对于上面的示例,接收者1会依次打印出:1、3,接收者2会依次打印出2、3。接收者2由于在处理onSubscription方法发射的数据2时,MutableStateFlow对象内部的数据1变成了数据3,因此在处理完数据2后,直接处理数据3。


作者:李萧蝶
链接:https://juejin.cn/post/7147226015943622687
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Flutter 工程化框架选择 — 搞定 UI 生产力

这是 《Flutter 工程化框架选择》 系列的第二篇 ,就像之前说的,这个系列只是单纯告诉你,创建一个 Flutter 工程,或者说搭建一个 Flutter 工程脚手架,应该如何快速选择适合自己的功能模块,或者说这是一个指引系列,所以比较适合新手同学。 本篇...
继续阅读 »

这是 《Flutter 工程化框架选择》 系列的第二篇 ,就像之前说的,这个系列只是单纯告诉你,创建一个 Flutter 工程,或者说搭建一个 Flutter 工程脚手架,应该如何快速选择适合自己的功能模块,或者说这是一个指引系列,所以比较适合新手同学。


本篇主要介绍 UI 相关的,但是完全单纯介绍 UI 好像又有点水,那就是加一些小知识来吸吸水分吧


做为前端开发,我们的大部分职责就是开发 UI ,但是如果有人帮我们提前把 UI 做好,那岂不美哉?事实上很多时候 UI 确实是可以模版化,而前端领域也一直是这样,例如 Ant DesignElement-UI 等 ,那 Flutter 上是否也有这样的支持?


答案肯定是有的,但是在介绍它们之前,我们先聊一个 Flutter UI 的问题:嵌套



为了不太水,我们前言聊技术,后半部分推荐项目。



前言- UI 嵌套


谈到 Flutter 肯定就有人说嵌套,是的, Flutter 本身就是通过直接嵌套 Widget 来渲染 UI , 所以大家可能就会吐槽类似下面的代码,虽然这段代码没有意义,但是这里我们要先思考两个问题:



  • Flutter 怕不怕 Widget 嵌套影响性能

  • Flutter 有没有办法解决嵌套



首先第一点就是,Flutter 一般情况下不怕嵌套影响性能,因为 “众所周知” 的原因,Flutter 里的 Widget 并不是真正的控件,我更愿意说 Widget 是配置文件,真正的绘制和布局对象是它背后的 RenderObejct 等相关逻辑



嵌套层级看起来很多的原因,是因为 Widget 颗粒度太细。



当然这个还要看你嵌套的 Widget 做了什么?或者说是嵌套的 WidgetRenderObject 做了什么,例如:



  • PaddingRenderPadding 就是在 layout 时多了一个 SizechildParentData.offset 计算

  • ColoredBox_RenderColoredBox 就是多一句 drawRect

  • AlignRenderPositionedBox 就是多计算一个 childParentData.offset


所以这些 Widget 的颗粒度很细,但是也很轻,我们直接用的时候可能会一层一层嵌套,看起来不美观,但是实际渲染时对性能影响不大。



当然,并不是所有的 Widget 都很轻不怕嵌套,例如 ClipTransformOpacity 等,如果涉及到 pushLayer 等操作时,在需要做图层合成的时候,那确实对性能影响还是比较大的。



那第二个问题,如何解决嵌套?这时候你就需要 “配置模版” ,通过封装来优化代码结构


“配置模版”是什么意思?举个例子 : Container 应该用过吧? Container 其实就是官方给大家准备的 “模版” ,它本身只是一个 StatelessWidget ,也就是一个没有 RenderObjectWidget ,它靠的就是把各种功能的 Widget 组合起来使用,如下图就是使用 Container 的对比情况。










所以在使用 Flutter 构建 UI 时,就可以谈及到两个点:



  • Widget 是配置文件,它一般很轻,不怕嵌套,真正绘制渲染的是它背后的 RenderObject

  • 通过各种 UI 配置模版来解决嵌套,特别是抽象出适合自己业务逻辑的 UI 模版


举个例子,如下方的第三方开源布局包 dashboard ,在这种复杂的 UI 布局上难道就靠直接一级一级嵌套 ColumnRow 来解决?答案肯定不是!










dashboard 的是通过 Stack + Positioned 组成模版,在手势移动时计算,通过 AnimatedBuilder 实现动画偏移,自动计算控件位置。



其实也可以直接用 AnimatedPositioned ,具体可见 《Flutter 小技巧之有趣的动画技巧》



所以可以看到, Flutter 里你其实可以不那么嵌套,具体还是看你的封装方式,除了这样,还有就是直接在 RenderObject 上进行自定义布局,比如下方这两个例子:















cloudcustom_multi_render
image-20220922120414245

你说上面的布局用 Stack + Positioned 的模式能不能做?肯定是可以,但是在 RenderObject 层实现不是更优雅吗?把脏活累活写在 RenderObject , 通过 Widget 提供配置接口,这样就不会一上来就向用户露 “底裤” 不是么



所以,把眼界打开,不要什么都盯着 Widget 嵌套,往 RenderObject 层面探索,你会发现 Flutter 其实不像表面那么浮躁,再不济来尝试下 CustomMultiChildLayout ,它也可以帮助你解决一些复杂布局下的嵌套问题。



当然,还有一些项目另辟蹊径,比如 niku ,这个项目通过 typedef 和抽象拓展,利用语法对官方控件进行二次封装,实现创建了一个 “非正道” 的 UI 配置效果,具体如下图所示,喜不喜欢就看个人爱好了,但是它确实一定程度解决了嵌套可视化的问题。




作者是个二次元,但是看地址他应该是泰国哥们



当然,我们这一期的关键是提高 UI 生产力,单说源码实现就没劲了,所以重点让我们看后半部分。


UI 套件


在前端领域,使用统一的 UI 套件可以加快开发的节奏,减少开发和设计之间的摩擦,而且风格统一。一般情况下,在企业内部都是在不知不觉中沉淀下来各种组件,最后形成组件池,从而落地成 UI 套件。


比如贝壳的 bruno ,我愿意称它为 Flutter 界的 Element-UI ,目前已经支持到 Flutter 3 ,作为少有国内大厂维护的 Flutter UI 项目,甚至它还提供了 sketch 设计指引设计物料下载

















brunogetwidgetfsuper

当然,除了 bruno 之后,像 getwidgetfsuper 也提供了日常开发中常用的 UI 套件,虽然风格风格上可能并没有 bruno 统一,但是还是可以在一定程度提高开发的生产力。



事实上对于个人开发者来说,这种套件可以解决很多设计上的问题。



另外聊到 Flutter UI 套件就要一定要介绍国内的 fluttercandies 组织,fluttercandies 是由大佬们共同维护的一系列 Flutter 开源项目,记住,是大佬们,并且一直在持续更新:




举个例子,如果 flutter framework 短期不能解决的问题,那就大佬就会 cv 一份控件自己维护,这就是 fluttercandies 的节奏和优势。



PC


既然介绍 Flutter UI ,就不得不介绍 PC 相关的风格的 UI ,因为 Flutter 不只是 Android 和 iOS ,它还支持 Web 和 PC, 所以类似 PC 的 UI 风格也值得推荐,比如 ant_design_flutter 就是一个很有意思的项目

















fluent_uimacos_uiant_design_flutter

Responsive


那有的人可能就说,我想要一套代码适配多平台的屏幕尺寸行不行?答案肯定是可以的,下面这几个 package 就提供了不同屏幕尺寸下一套代码的动态适配方案,我个人可能会比较喜欢 ResponsiveFramework 。

















ResponsiveFrameworkresponsive_sizerflutter_adaptive_ui

Appbar


这类 Appbar 的实现其实是我被问过最多的,其实它的核心实现都是 Sliver ,严格意义上我觉得并不需要第三方库,自己用 Sliver 就可以实现,但是本着能不动手就不动手原则,也推荐几个库吧:

















draggable_homeextended_sliverscroll_app_bar


gsy_flutter_demo 里也提供了几种实现思路,其实并不复杂。



Drawer


可能有人会觉得,不会吧不会吧, Drawer 也需要第三方库?


还真有,因为有时候可能需要不一样的动画效果,另外这里的 sidebarx ,也和官方提供的 NavigationRail有异曲同工之妙,能在 UI 上适配多平台的操作习惯。

















sidebarxflutter_advanced_drawercurved_drawer

Tarbar


既然都说到 Drawer ,那 Tabbar 也提供几个花里胡哨的动画效果,主要是切换时的动画效果,另外 tab_container 可能算是比较有意思的库,用的 Path 来编绘背景动画效果。

















flutter-cupertino-tabbartab_indicator_stylertab_container

BottomBar


说到 Tabbar 相对应的还有 BottomBar 相关,这里也提供几个库,主要是动画效果很有趣,我个人还是挺喜欢这种曲线的动画效果。

















curved_navigation_barsalomon_bottom_barbubble_bottom_bar

指引


启动指引这个需求,正常情况下一个 PageView 就可以满足产品经理的场景,但是有时候可能会需要你来“亿”点点动画效果来增加 KPI,所示拿着也许就对你有用了,当然你也可以把它当作 PageView 动画来使用。

















concentric_transitionnice_introintro_views_flutter

角标


这个应该无需多言了,基本上 App 都会需要用到,这两个库基本覆盖了 90% 的场景















flutter_badgescorner_decoration

动画按键


这个可能一般情况下大家都不需要这么花里胡哨的效果,但是万一呢?Material 风格上这种交互还是挺多的,不过国内对 Material 确实不是很感冒。















flutter_animated_buttonprogress_state_button

头像


没想到吧?头像为什么还需要库?


其实就是下面的这个场景,相信这个场景可能大家都不会陌生,有社交需求的时候,经常会存在这样的 UI ,掘金沸点不也有类似 UI 么?

















avatar_stackoverflow_view

swipe 卡片


这个需求可能一般人不会需要,推荐它是因为我还记得几年的时候,收了 1000 给人做了这样的一个外包,就是做一个这样的控件。

















swipe_deckswipeable_card_stackappinio_swiper

bottom sheet


这其实更多是一个 Route 相关的动画效果,感觉好像国内也不常用到,但是之前确实有好几次咨询有没有类似的实现。















we_slidesliding_up_panel

时间轴 UI


这是我在群里被问过好多次的一个需求场景,我也不知道为什么那么多应用会需要用到这样的 UI ?不过这类需求自己从头实现确实会比较费事。















timeline_tiletimelines

好了,关于 Flutter UI 相关的内容推荐就到这里,本篇主要还是提供给大家如何理解 Flutter 的 UI 布局,并且尽可能去解决嵌套,同时提供一些有意思的第三方 package ,进一步提高大家开发 UI 的生产力


最后,如果你还有什么关于 Flutter 工程或者框架的疑问,欢迎留言评论,也许新的素材又有了~


作者:恋猫de小郭
链接:https://juejin.cn/post/7147437014026043399
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

图解常见排序算法

1. 冒泡排序 冒泡排序属于交换排序的一种,从数组的第一个角标开始逐个与后面的元素进行比较,如果小于就将其置换 首先取出第一个元素,与后面的元素挨个比较,如果大于后面的某个元素就将两个元素位置互换,然后继续比较直到最后一个。第二轮从第二个元素开始比较、第三轮...
继续阅读 »

1. 冒泡排序


冒泡排序属于交换排序的一种,从数组的第一个角标开始逐个与后面的元素进行比较,如果小于就将其置换


冒泡.webp


首先取出第一个元素,与后面的元素挨个比较,如果大于后面的某个元素就将两个元素位置互换,然后继续比较直到最后一个。第二轮从第二个元素开始比较、第三轮从第三个以此类推,最后一轮比较完毕就会形成一个有序数组


        int[] arr = {5,1,2,9,7};
//轮数
for (int i=0;i<arr.length-1;i++){
//从第i个元素开始比较
for (int j = i+1;j<arr.length;j++){
if(arr[i]>arr[j]){
//交换位置
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
System.out.println("第"+(i+1)+"轮:"+Arrays.toString(arr));
}
System.out.println(Arrays.toString(arr));

打印结果:


第1轮:[1, 5, 2, 9, 7]
第2轮:[1, 2, 5, 9, 7]
第3轮:[1, 2, 5, 9, 7]
第4轮:[1, 2, 5, 7, 9]
[1, 2, 5, 7, 9]

2. 快速排序


从数组中选取一个标准数,小于标准数的元素放在左边、大于标准数的元素放在右边,然后把分开的两个数组再进行以上操作,此步骤可通过递归来实现。


快速.webp


选择5为标准数进行分边,将1、2放在左边,7、9放在右边,通过递归将左右两个数组再次进行分边,当不能再分即数组块<2时跳出方法,以此类推最后会得出一个有序数组。代码部分:


public static void quickSort(int[] arr,int start,int end){
//递归结束条件
if(start>=end){
return;
}
int standard = arr[start];//选取第start个元素为标准数
int low = start;//低位指针
int high = end;//高位指针
//通过一个循环将小的数字分配到标准数左边、
//大的放在右边,标准数放在中间
//循环的结束条件为两个指针碰撞即low==high,
while (low<high){
//首先从高位开始找比标准数小的数字
while (low<high&&standard<=arr[high]){
//将高位角标+1继续比较
high--;
}
//找到比标准数小的数字放在low角标处,此时的low即start
arr[low] = arr[high];

//从低位开始找比标准数大的数字
while (low<high&&arr[low]<=standard){
//将低位指针+1继续比较
low++;
}
//找到比标准数大的数字放在high角标处
arr[high] = arr[low];
}
//能执行到这说明low==high,将标准值放在low/high角标中
arr[low] = standard;
System.out.println("start:"+start+"--end:"+end+"--"+Arrays.toString(arr));
//执行完以上步骤后就完成了第一轮数字分配,
//通过递归将分开的两个数组再次进行数组分配
quickSort(arr,start,low-1);//将左边数组进行数字分配
quickSort(arr,low+1,end);//将右边数组进行数字分配
}

执行如下代码:


 int[] arr = {5,1,7,9,2};
quickSort(arr,0,arr.length-1);
System.out.println("-------------");
System.out.println(Arrays.toString(arr));

打印结果


start:0--end:4--[2, 1, 5, 9, 7]
start:0--end:1--[1, 2, 5, 9, 7]
start:3--end:4--[1, 2, 5, 7, 9]
-------------
[1, 2, 5, 7, 9]

3. 直接插入排序


从第二个元素开始与第一个元素进行比较,如果小于第一个元素则交换位置。然后从第三个元素开始与第二个元素进行比较,如果小于第二个元素进行位置互换,再拿着第二个元素跟第一个元素比较,大于第一个元素就交换位置,以此类推


     int[] arr = {5,1,7,9,2};
for (int i=1;i<arr.length;i++){
//如果当前遍历数字小于前一个数字
if(arr[i]<arr[i-1]){
int temp = arr[i];//记录下来当前遍历的数字
int j;
//将temp与前面数字进行比较,
//如果小于前面数字将前一数字,
//就将当前数字设置成与前一数组相同
for (j = i-1;j>=0&&arr[j]>temp;j--){
arr[j+1] = arr[j];
}
//最后还要将temp放在j+1的位置
arr[j+1] = temp;
}
}
System.out.println(Arrays.toString(arr));

打印结果:


[1, 2, 5, 7, 9]

代码可能与上面的文字描述有些出入,但目的都是为了交换位置。


4. 希尔排序


首先获取到数组长度,将长度/2得到一个步长,举个例子来说明一下步长的作用,假如有一个长度为5的数组,那么步长就是2,将第4个元素和第2(4-步长)个元素进行比较,小于前面数字则交换位置,再将第2个元素与第0(2-步长)个元素比较,小于前面数字交换位置。然后再将第3个和第一个比较,以此类推,比较完一轮后将步长/2进行下一轮比较。


希尔.webp




  • 第一轮:将5、7、2和1、9进行比较得到第二轮

  • 第二轮:将2、1、5、9、7按步长为1进行比较



        int[] arr = {5,1,7,9,2};
//遍历步长,每遍历一轮步长除2,直到小于等于0跳出循环
for (int d = arr.length/2;d>0;d/=2){

//遍历每个元素
for (int i = d;i<arr.length;i++){
//遍历本步长组中的元素
for (int j=i-d;j>=0;j-=d ){
//将当前元素与加上步长的元素比对
//如果当前元素大就交换位置
if (arr[j]>arr[j+d]){
//交换位置
int temp = arr[j];
arr[j] = arr[j+d];
arr[j+d] = temp;
}
}
}
System.out.println("d:"+d+Arrays.toString(arr));
}
System.out.println(Arrays.toString(arr));

打印结果:


d:2[2, 1, 5, 9, 7]
d:1[1, 2, 5, 7, 9]
[1, 2, 5, 7, 9]

第一个for循环步长的轮数,第二个for循环从步长开始遍历后面每个元素,第三个for循环用于遍历一个步长组然后交换位置。


5. 简单选择排序


选择排序跟冒泡排序类似,从一个数字开始往后遍历,选出最小值并记录其角标然后第一位进行位置交换,再从第二个数字开始做以上操作以此类推


选择.webp


    int[] arr = {2,3,5,1};
for (int i=0;i<arr.length-1;i++){
int index = i;
for (int j=i+1;j<arr.length;j++){
//记录本轮最小值角标
if(arr[j]<arr[index]){
index = j;
}
}
//交换位置
if(i!=index) {
int temp = arr[i];
arr[i] = arr[index];
arr[index] = temp;
}
System.out.println("第"+(i+1)+"轮:"+Arrays.toString(arr));
}
System.out.println("----------------");
System.out.println(Arrays.toString(arr));

打印结果:


第1轮[1, 3, 5, 2]
第2轮[1, 2, 5, 3]
第3轮[1, 2, 3, 5]
----------------
[1, 2, 3, 5]

6. 归并排序


归并排序就是将一堆无序数字分成两部分,左右两边都保证有序,最后再将两边有序数字进行排序


归并.webp


通过递归的方式将数组拆分直到被拆分的数组长度<=1未知,原始数组可拆分为A和B,A数组又可拆分为C和D,D数组又可拆分为G和H,数组G和H长度都为1所以不能再往下进行拆分,因为数组G和H长度都为1所以可视为两个数组都是有序的,然后通过归并算法将G和H合并成一个有序数组,此时数组D就变成了[1,2],再通过归并算法将C和D合并成有序数组,此时A就变成了[1,2,5],依次类推最终就可以实现排序功能。


//归并算法就是将左右两边的两个有序数组归并成一个有序数组
public static void merge(int[] arr,int low,int middle,int high){
int[] temp = new int[high-low+1];//创建一个临时数组
int i = low;//第一个数组需要遍历的角标
int j = middle+1;//第二个数组需要遍历的角标
int index = 0;//记录临时数组的下表
//遍历两个数组,取出小的数字放入临时数组中
while (i<=middle&&j<=high){
//把小的数据放入临时数组中,小的一方角标+1
if(arr[i]<arr[j]){
temp[index] = arr[i];
i++;
}else {
temp[index] = arr[j];
j++;
}
//临时数组角标+1
index++;
}
//处理右边多余的数据
while (j<=high){
temp[index] = arr[j];
j++;
index++;
}
//处理左边多余的数据
while (i<=middle){
temp[index] = arr[i];
i++;
index++;
}

//将临时排好序的临时数组放入到原数组
for (int k=0;k<temp.length;k++){
arr[low+k] = temp[k];
}
}

重点说一下下面的两个取多余数据的代码,首先这两个while循环是互斥的,什么时候会执行这两个while循环呢?假如左边的所有数据都小于右边的第一个数据,此时会将左边数组全部放到临时数组中,当放入最后一个元素后左边数组的角标i已经>middle了,会跳出第一个while循环,但是右面的元素还没有放入到临时数组,所以要将右边多余的数字放入到临时数组。其他部分注释标的都很清楚就不一一叙述了,下面来看数组拆分的算法:


    public static void mergeSort(int[] arr,int low,int high){
//递归结束条件
if(high<=low){
return;
}
int middle = (high+low)/2;
//处理左边
mergeSort(arr,low,middle);
//处理右边
mergeSort(arr,middle+1,high);
//归并
merge(arr,low,middle,high);
}
...
//以下代码在main()方法中运行
int[] arr = {2,3,5,1,11,2,15};
mergeSort(arr,0,arr.length-1);
System.out.println(Arrays.toString(arr));

通过递归的方式拆分数组,然后结合上面写的归并算法进行排序。下面来看代印结果:


[1, 2, 2, 3, 5, 11, 15]

7. 基数排序


创建一个长度为10的二维数组,遍历无序数组,将个位数为0 - 9的放在二维数组的第0-9个位置,然后按顺序将元素取出,再将十位数为0 - 9的放在二维数组的第0-9个位置,然后再取出,以此类推最后会得到一个有序数组


基数.webp


首先创建一个二维数组也就是图中中间的那个数组,然后遍历需要排序的数组将个位数为0 - 9的元素放入到二维数组中0 - 9位置,然后再从前到后将元素逐个取出,这样第一轮就完成了,然后再进行下一轮,进行的轮数就是最大数的长度。


 //基数排序
public static void radixSort(int[] arr){
//存数组中最大的数,目的获取其位数
int max = Integer.MIN_VALUE;
for (int x:arr){
if(x>max){
max = x;
}
}
//最大值长度
int maxLength = (max+"").length();
//创建一个二维数组存储临时数据
int[][] temp = new int[10][arr.length ];
//创建一个数组,用来记录temp内层数组存储元素的个数
int[] count = new int[10];
//将数据放入二维数组中
for (int i =0,n=1;i<maxLength;i++,n*=10){
for (int j=0;j<arr.length;j++){
int number = arr[j]/n;
//将number放入指定数组的指定位置
temp[number][count[number]] = arr[j];
//将count数组中记录元素个数的元素+1
count[number]++;
}
//从二维数组中取数据
int index = 0;
for (int x=0;x<count.length;x++){
if(count[x]!=0){
for (int y=0;y<count[x];y++){
arr[index] = temp[x][y];
index++;
}
count[x] = 0;
}
}
}
}
...
//以下代码在main()方法中运行
int[] arr = {11,2,6,552,12,67,88,72,65,23,84,17};
radixSort(arr);
System.out.println(Arrays.toString(arr));



  • 首先遍历数组取出最大值,通过最大值确定轮数

  • 创建一个二维数组和一个存放二维数组每个角标中元素个数的数组

  • 将元素放入到二维数组中指定的位置

  • 从二维数组中逐个将元素取出



打印结果:


[2, 6, 11, 12, 17, 23, 65, 67, 72, 84, 88, 552]

作者:Bezier
链接:https://juejin.cn/post/7145772697367085069
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Java 泛型知多少

你可能遇到过以下困惑,为什么在 java 中我们不能 new 一个泛型,而 C++ 却可以,如下这样 此种方法在 java 中直接通不过编译,而如果我们要实现同样的功能,只能通过显示的传入 class,然后通过 RTTI 方式来在运行时动态创建 public...
继续阅读 »

你可能遇到过以下困惑,为什么在 java 中我们不能 new 一个泛型,而 C++ 却可以,如下这样


1362430-3d0473c0398ff1c1.webp
此种方法在 java 中直接通不过编译,而如果我们要实现同样的功能,只能通过显示的传入 class,然后通过 RTTI 方式来在运行时动态创建


public class AnimalPlayground<T>  {
public T create(Class<T> t) {
try {
return t.newInstance();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}

这个原因是因为 Java 的泛型其实是假泛型,他只在编译时进行了泛型校验,生成的字节码其实不存在泛型的概念,也就是所谓的泛型擦除。比如如下两段代码:


public class AnimalPlayground<T>  {
private T obj;
public void create(T t) {
this.obj = t;
}
}

public class AnimalPlayground {
private Object obj;
public void create(Object t) {
this.obj = t;
}
}

通过 javac 然后 javap 看他们的字节码,发现生成的字节码其实是一样的,没有包含特殊的泛型信息。


public void create(T);
descriptor: (Ljava/lang/Object;)V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: aload_1
2: putfield #7 // Field obj:Ljava/lang/Object;
5: return

这一点解释了为什么我们无法对泛型进行 new 的操作,因为泛型进行了擦除,所以我们无法验证这个泛型是否有默认的构造函数,所以 Java 编译器干脆进行了编译时报错处理。因为泛型擦除的原因,所以以下等式可以成立


List<Integer> list1 = new ArrayList<>();
List<String> list2 = new ArrayList<>();
System.out.println(list1.getClass() == list2.getClass()); // true

而且我们也留意到,我们可以声明 List.class 而无法使用 List<Integer>.class,因为第二种本身无意义。那如何得到包含泛型的列表的类型呢,此时我们可以使用 ParameterizedType。此篇文章对 ParameterizedType 的用法讲解的比较详细。


协变和逆变


个人理解泛型的本质是在尽可能编程灵活的情况下,做到逻辑自洽,此时一门语言是否好用就取决于它的编译器的聪明程度。


比如我们声明一个 Animal 类,然后再声明一个 Dog 类继承自 Animal。此时按照我们直觉的推断,如果一个 Dog 是 Animal 的子类,那么一个 Dog 的列表是否是一个 Animal 列表的子类呢?很可惜,如果我们直接这样做直接编译器就会不通过:


1362430-4e53c15e13034c5e.webp


此时我们就需要引入协变的概念,字面意思就是可以协同实际类型来变化。在 Java 中 List 是支持协变的,可以使用以下写法:


ArrayList<? extends Animal> animals = new ArrayList<Dog>();

任何 Animal 子类的列表都可以赋值给:ArrayList<? extends Animal> 。但要注意在 Java 中协变的 List 是没法使用 add 去增加元素的。这是为啥呢?


因为协变类型可以被任何子类数组赋值,而由于 Java 的泛型擦除机制,我们是没办法在编译时及时发现这个列表被传入了其他子类,比如上面的 animals 如果可以使用 add,那么我们执行 animals.add(new Cat()) 是没法在编译时发现问题的,那就有违 Java 是一门类型安全语言的设定,所以 Java 中直接去掉了协变增加元素的功能。


协变 我们知道是跟随实际类型的父子关系而来,那逆变呢?按照字面意思理解就是和实际类型的父子关系反过来,比如同样的 ArrayList 逆变则可以使用如下表达


ArrayList<? super Animal> animals = new ArrayList<Animal>();

ArrayList<? super Animal> 这种表达是 Animal 子类的一个列表,只能接收一个 Animal 的列表。此种方法我们知道列表一定是一个 Animal 类的列表,所以我们可以随意的向其中增加元素


ArrayList<? super Animal> animals = new ArrayList<>();
animals.add(new Cat());
animals.add(new Dog());
animals.add(new Animal());

写在最后


C++ 中使用泛型是通过 template 来实现,你可以理解它只是一个模板,是无法单独编译。C++ 在编译的过程中遇到 template 则会使用真实的类型来替换模板中的泛型,然后新生成一段机器码。而 Java 的泛型类是可以单独编译的,编译完成后的字节码就是一个普通的类,在运行层面的使用就是把它当作一个普通类来使用的。理解这一点,应该能帮助你理解 Java 中泛型种种限制的原因。


作者:CPPAlien
链接:https://juejin.cn/post/7146080877552861220
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

为什么你学不会递归?谈谈我的经验

前言 大家好,我是小彭。 今天分享到计算机科学中一个基础又非常重要的概念 —— 递归。递归是计算机中特有的概念,你很难在现实世界中找到一个恰当的例子与之关联起来。因此,对于很多初学编程的人,一开始会很难理解。 那么,究竟什么是递归,我们为什么要使用递归?我们今...
继续阅读 »

前言


大家好,我是小彭。


今天分享到计算机科学中一个基础又非常重要的概念 —— 递归。递归是计算机中特有的概念,你很难在现实世界中找到一个恰当的例子与之关联起来。因此,对于很多初学编程的人,一开始会很难理解。


那么,究竟什么是递归,我们为什么要使用递归?我们今天就围绕这两个问题展开。




学习路线图:





1. 什么是递归?


递归(Recursion)是一种通过 “函数自己调用自己” 的方式,将问题重复地分解为同类子问题,并最终解决问题的编程技巧。


举个例子,要求一个数 nn 的阶乘 n!=n(n1)(n2)21n! = n*(n-1)*(n-2)*…*2*1 ,有 2 种思考问题的思路:



  • 递推(一般思维): 我们从 11 开始,用 11 乘以 22 得到 2!2! 问题的解,用 33 乘以 2!2! 得到 3!3! 问题的解。依次类推,直到用 nn 乘以 (n1)!(n-1)! 得到原问题 n!n! 的解。这就是用递推解决问题,这是相对简单直接的思考方式;

  • 递归(计算机思维): 我们把 n!n! 的问题拆分为一个 (n1)!(n-1)! 的问题,如果我们知道 (n1)!(n-1)! 的解,那么将它乘以 nn 就可以得出 n!n! 的解。以此类推,我们将一个 (n1)!(n-1)! 的问题拆分为同类型的规模更小的 (n2)!(n-2)! 子问题,直到拆分到无法拆分,可以直接得出结果 1!1! 问题。此时,我们再沿着拆分问题的路径,反向地根据子问题的解求出原问题的解,最终得到原问题 n!n! 的结果。这就是用递归解决问题。


求 n!



从这个例子可以看出, 递归其实是在重复地做 2 件事:



  • 1、自顶向下拆分问题: 从一个很难直接求出结果的、规模较大的原问题开始,逐渐向下拆分为规模较小的子问题(从 n!n! 拆分到 (n1)!(n-1)!),直到拆分到问题边界时停止拆分,这个拆分的过程就是 “递”(问题边界也叫基准情况或终止条件);

  • 2、自底向上组合结果: 从问题边界开始,逐渐向上传递并组合子问题的解(从 (n1)!(n-1)! 得到 n!n!),直到最终回到原问题获得结果,这个组合的过程就是 “归”。


看到这里你会不会产生一个疑问: 我们直接从问题边界 1!1! 一层层自底向上组合结果也可以得到 n!n! 的解,自顶向下拆分问题的过程显得没有必要。确实,对于对于这种原问题与子问题只是 “线性” 地减少一个问题规模的情况,确实是这样。但是对于很多稍微复杂一些的问题,原问题与子问题会构成一个树型的 “非线性” 结构,这个时候就适合用递归解决,很难用递推解决。


举个例子, 求斐波那契数列,这个问题同时也是 LeetCode 上的一道典型例题:LeetCode · 509. 斐波那契数:该数列从 11 开始,每一项数字都是前面两项数字的和。


LeetCode 例题



虽然,我们可以利用递推的方式从 F(0)F(0)F(1)F(1) 自底向上推导出 F(n)F(n) 的解,但是这种非线性的方式在编程语言中很难实现,而使用递归的方式自顶向下地解决问题,在编码上是很容易实现的。


当然,这段代码中存在非常多的重复计算,最终使得整个算法的时间复杂度达到惊人的指数级 O(2n)O(2^n)。例如在计算 F(5)=F(3)+F(4)F(5)=F(3)+F(4)F(6)=F(4)+F(5)F(6)=F(4)+F(5) 的时候,F(4)F(4) 就被重复计算 2 次,这种重复计算完全相同的子问题的情况就叫 重叠子问题 ,以后我们再专门讨论。


用递归解决斐波那契数列



用递归解决(无优化)


class Solution {
fun fib(N: Int): Int {
if(N == 0){
return 0
}
if(N == 1){
return 1
}
// 拆分问题 + 组合结果
return fib(N - 1) + fib(N - 2)
}
}



2. 递归的解题模板



  • 1、判断当前状态是否异常,例如数组越界,n < 0 等;

  • 2、判断当前状态是否满足终止条件,即达到问题边界,可以直接求出结果;

  • 3、递归地拆分问题,缩小问题规模;

  • 4、组合子问题的解,结合当前状态得出最终解。


fun func(n){
// 1. 判断是否处于异常条件
if(/* 异常条件 */){
return
}
// 2. 判断是否满足终止条件(问题边界)
if(/* 终止条件 */){
return result
}
// 3. 拆分问题
result1 = func(n1)
result2 = func(n2)
...
// 4. 组合结果
return combine(result1, result2, ...)
}



3. 计算机如何实现递归?


递归程序在解决子问题之后,需要沿着拆分问题的路径一层层地原路返回结果,并且后拆分的子问题应该先解决。这个逻辑与栈 “后进先出” 的逻辑完全吻合:



  • 拆分问题: 就是一次子问题入栈的过程;

  • 组合结果: 就是一次子问题出栈的过程。


事实上,这种出栈和入栈的逻辑,在编程语言中是天然支持的,不需要程序员实现。程序员只需要维护拆分问题和组合问题的逻辑,一次函数自调用和返回的过程就是一次隐式的函数出栈入栈过程。在程序运行时,内存空间中会存在一块维护函数调用的区域,称为 函数调用栈 ,函数的调用与返回过程,就天然对应着一次子问题入栈和出栈的过程:



  • 调用函数: 程序会创建一个新的栈帧并压入调用栈的顶部;

  • 函数返回: 程序会将当前栈帧从调用栈栈顶弹出,并带着返回值回到上一层栈帧中调用函数的位置。


我们在分析递归算法的空间复杂度时,也必须将隐式的函数调用栈考虑在内。




4. 递归与迭代的区别


递归(Recursion)和迭代(Iteration)都是编程语言中重复执行某一段逻辑的语法。


语法上的区别在于:



  • 迭代: 通过迭代器(for/while)重复执行某一段逻辑;

  • 递归: 通过函数自调用重复执行函数中的一段逻辑。


核心区别在于解决问题的思路不同:



  • 迭代:迭代的思路认为只要从问题边界开始,在所有元素上重复执行相同的逻辑,就可以获得最终问题的解(迭代的思路与递推的思路类似);

  • 递归:递归的思路认为只要将原问题拆分为子问题,在每个子问题上重复执行相同的逻辑,最终组合所有子问题的结果就可以获得最终问题的解。


例如, 在计算 n! 的问题中,递推或迭代的思路是从 1! 开始重复乘以更大的数,最终获得原问题 n! 的解;而递归的思路是将 n! 问题拆分为 (n-1)! 的问题,最终通过 (n-1)! 问题获得原问题 n! 的解。


再举个例子,面试中出现频率非常高的反转链表问题,同时也是 LeetCode 上的一道典型例题:LeetCode 206 · 反转链表。假设链表为 1 → 2 → 3 → 4 → ∅,我们想要把链表反转为 ∅ ← 1 ← 2 ←3 ←4,用迭代和递归的思路是不同的:



  • 迭代: 迭代的思路认为,只要重复地在每个节点上处理同一个逻辑,最终就可以得到反转链表,这个逻辑是:“将当前节点的 next 指针指向前一个节点,再将游标指针移动到后一个节点”。

  • 递归: 递归的思路认为,只要将反转链表的问题拆分为 “让当前节点的 next 指针指向后面整段子链的反转链表”,在每个子链表上重复执行相同的逻辑,最终就能够获得整个链表反转的结果。


这两个思路用示意图表示如下:


示意图



迭代题解


class Solution {
fun reverseList(head: ListNode?): ListNode? {
var cur: ListNode? = head
var prev: ListNode? = null

while (null != cur) {
val tmp = cur.next
cur.next = prev
prev = cur
cur = tmp
}
return prev
}
}

迭代解法复杂度分析:



  • 时间复杂度:每个节点扫描一次,时间复杂度为 O(n)O(n)

  • 空间复杂度:使用了常量级别变量,空间复杂度为 O(1)O(1)


递归题解


class Solution {
fun reverseList(head: ListNode?): ListNode? {
if(null == head || null == head.next){
return head
}
val newHead = reverseList(head.next)
head.next.next = head
head.next = null
return newHead
}
}

递归解法复杂度分析:



  • 时间复杂度:每个节点扫描一次,时间复杂度为 O(n)O(n)

  • 空间复杂度:使用了函数调用栈,空间复杂度为 O(n)O(n)


理论上认为迭代程序的运行效率会比递归程序更好,并且任何递归程序(不止是尾递归,尾递归只是消除起来相对容易)都可以通过一个栈转化为迭代程序。但是,这种消除递归的做法实际上是以牺牲程序可读性为代价换取的,一般不会为了运行效率而刻意消除递归。


不过,有一种特殊的递归可以被轻松地消除,一些编译器或运行时会自动完成消除工作,不需要程序员手动消除,也不会破坏代码的可读性。




5. 尾递归


在编程语言中,尾调用是指在一个函数的最后返回另一个函数的调用结果。如果尾调用最后调用的是当前函数本身,就是尾递归。为什么我们要专门定义这种特殊的递归形式呢?因为尾递归也是尾调用,而在大多数编程语言中,尾调用可以被轻松地消除 ,这使得程序可以模拟递归的逻辑而又不损失性能,这叫 尾递归优化 / 尾递归消除 。例如,以下 2 段代码实现的功能是相同的,前者是尾递归,而后者是迭代。


尾递归


fun printList(itr : Iterator<*>){
if(!itr.hasNext()) {
return
}
println(itr.next())
// 尾递归
printList(itr)
}

迭代


fun printList(itr : Iterator<*>){
while(true) {
if(!itr.hasNext()) {
return
}
println(itr.next())
}
}

可以看到,使用一个 while 循环和若干变量消除就可以轻松消除尾递归。




6. 总结


到这里,相信你已经对递归的含义以及递归的强大之处有所了解。 递归是计算机科学中特有的解决问题的思路:先通过自顶向下拆分问题,再自底向上组合结果来解决问题。这个思路在编程语言中可以用函数自调用和返回实现,因此递归在编程实现中会显得非常简洁。 正如图灵奖获得者尼克劳斯·维尔特所说:“递归的强大之处在于它允许用户用有限的语句描述无限的对象。因此,在计算机科学中,递归可以被用来描述无限步的运算,尽管描述运算的程序是有限的。”


作者:彭旭锐
链接:https://juejin.cn/post/7145888770410151967
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

拒绝手动Notifydatasetchanged(),使用ListAdapter高效完成RecyclerView刷新

关于RecyclerView的更新  RecyclerView在显示静态的列表的数据的时候,我们用普通的Adapter,然后添加列表,调用notifyDataSetChanged()即可展示列表,但是对于动态变化的列表来说,全靠notifyDataSetCha...
继续阅读 »

关于RecyclerView的更新

  RecyclerView在显示静态的列表的数据的时候,我们用普通的Adapter,然后添加列表,调用notifyDataSetChanged()即可展示列表,但是对于动态变化的列表来说,全靠notifyDataSetChanged()来完成列表更新显得非常没有效率,因为有时候开发者只是想增删一个Item,而这却要付出刷新全部列表的代价。于是谷歌又给我们提供了多种api让我们完成局部Item的增删查改,如下:

  1. notifyItemRemoved()
  2. notifyItemInserted()
  3. notifyItemRangeChanged()
  4. ...

  这些api固然好用但是对于某些场景来说我们难以下手,例如后台返回的列表的全部数据,在获取新的列表之后,开发者也许想比较新旧列表的不同,然后更新发生变化的item,这又如何实现呢?

关于DiffUtil

  谷歌根据开发者需要比较新旧列表异同的痛点,推出了DiffUtil工具,它的核心算法是Myers差分算法,有兴趣可以自行学习,这篇文章不作深入探讨(其实笔者也不会)。

关于ListAdapter

  注:这个ListAdapter是需要额外引入的,给RecyclerView使用的一个Adapter,并非SDK里面的那个,因此需要区分开来。

  ListAdapter是谷歌基于上述的DiffUtil进行封装的一个Adapter,简单地继承重写即可达到DiffUtil的效果,高效完成RecyclerView的更新,这个也是本篇的重点。

实战

布局和对应的实体类

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">

<TextView
android:id="@+id/tv_name"
tools:text="名字"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>

<TextView
android:id="@+id/tv_age"
tools:text="18岁"
app:layout_constraintStart_toEndOf="@id/tv_name"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginStart="20dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>

<TextView
android:id="@+id/tv_tall"
tools:text="180cm"
app:layout_constraintStart_toEndOf="@id/tv_age"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginStart="20dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>

<TextView
android:id="@+id/tv_long"
tools:text="18cm"
app:layout_constraintStart_toEndOf="@id/tv_tall"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginStart="20dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>

</androidx.constraintlayout.widget.ConstraintLayout>

image.png

data class ItemTestBean(
val name:String,
val age:Int,
val tall:Int,
val long:Int
)

重写ListAdapter

ListAdapter的重写包含的关键点比较多,这里分步骤说明:

第一步:实现DiffUtil.ItemCallback

  这是整个ListAdapter中最最最关键的一个步骤,因为它是DiffUtil知道如何正确修改列表的核心,我们直接看代码。

object ItemTestCallback : DiffUtil.ItemCallback<ItemTestBean>() {
override fun areItemsTheSame(oldItem: ItemTestBean, newItem: ItemTestBean): Boolean {
return oldItem.name == newItem.name
}

override fun areContentsTheSame(oldItem: ItemTestBean, newItem: ItemTestBean): Boolean {
return oldItem.name == newItem.name
&& oldItem.age == newItem.age
&& oldItem.tall == newItem.tall
&& oldItem.long == newItem.long
}

}

  乍一看非常复杂,实际原理非常简单,areItemsTheSame()方法判断的是实体类的主键,areContentsTheSame()方法判断的是实体类中会导致UI变化的字段

第二步:实现viewHolder

这一步和其他的Adapter没什么区别,笔者用了viewBinding,你也可以根据自己项目实际情况改造。

inner class ItemTestViewHolder(private val binding: ItemTestBinding):RecyclerView.ViewHolder(binding.root){

fun bind(bean:ItemTestBean){
binding.run {
tvName.text=bean.name
tvAge.text=bean.age.toString()
tvTall.text=bean.tall.toString()
tvLong.text=bean.long.toString()
}
}

}

第三步:组合成完整的ListAdapter

在ListAdapter中填入相应的泛型(实体类和ViewHolder类型),然后在构造函数中传入我们刚才实现的DiffUtil.ItemCallback即可,实现的两个方法和其他Adapter大同小异,唯一需要注意的是ListAdapter为我们提供了一个getItem的快捷方法,因此在onBindViewHolder()时可以直接调用。

class ItemTestListAdapter : ListAdapter<ItemTestBean,ItemTestListAdapter.ItemTestViewHolder>(ItemTestCallback) {

inner class ItemTestViewHolder(private val binding: ItemTestBinding):RecyclerView.ViewHolder(binding.root){
//...省略
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemTestViewHolder {
return ItemTestViewHolder(ItemTestBinding.inflate(LayoutInflater.from(parent.context),parent,false))
}

override fun onBindViewHolder(holder: ItemTestViewHolder, position: Int) {
//通过ListAdapter内部实现的getItem方法找到对应的Bean
holder.bind(getItem(position))
}

}

使用ListAdapter完成列表的增删查改

为了方便演示,使用如下的List和初始化代码:

private val testList = listOf<ItemTestBean>(
ItemTestBean("小明",18,180,18),
ItemTestBean("小红",19,180,18),
ItemTestBean("小东",20,180,18),
ItemTestBean("小刘",18,180,18),
ItemTestBean("小德",15,180,18),
ItemTestBean("小豪",14,180,18),
ItemTestBean("小江",12,180,18),
)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding=ActivityMainBinding.inflate(LayoutInflater.from(this))
setContentView(binding.root)
val adapter=ItemTestListAdapter()
binding.rv.adapter=adapter
}

插入元素

插入全新的列表

adapter.submitList(testList)

image.png

完事了??

  是的,我们只需要调用submitList方法告诉Adatper我们要插入一个新的列表即可。

image.png

局部插入元素

  也许插入全新列表并不能让你感觉到ListAdapter的精妙之处,因为这和原来的Adapter差别并不大,我们再来试试往列表中插入局部的元素,例如我们要在小刘和小德之间插入一个新的Item。

  我们对列表转成可变列表(为什么使用不可变列表,原因后面会解释),然后插入元素,最后调用submitList把新的列表传入进去即可。

val newList=testList.toMutableList().apply {
add(3,ItemTestBean("坤坤鸡",21,150,4))
}
adapter.submitList(newList)

image.png

  列表更新了,由此可见,无论是增加一个元素还是多个元素,我们都只需要调submitList即可。

  这里说一下为什么要重新传入一个新的List而不是对原来的List进行修改,因为源码中有这样一段,笔者推测是因为这个校验差分的逻辑是异步的,如果外部对原列表进行修改会导致内部的逻辑异常(未验证只是猜测)。

  因此我们切记要传入新的List而不是对原List进行修改。

public void submitList(@Nullable final List<T> newList,
@Nullable final Runnable commitCallback) {

//...省略
//校验列表是否是同一个对象
if (newList == mList) {
// nothing to do (Note - still had to inc generation, since may have ongoing work)
if (commitCallback != null) {
commitCallback.run();
}
return;
}
//...省略
}

删除元素和修改元素

  聪明的读者估计也已经猜到了,无论是增加删除和修改,我们都只需要submitList即可,因为List中就已经包含了列表的更新信息,一切的更新ListAdapter已经自动替我们完成了。

val newList=testList.toMutableList().apply {
//删除
removeAt(2)
//修改
this[3]=this[3].copy(name = "改名后的小帅哥")
}

adapter.submitList(newList)

列表清空

  一切尽在submitList,如果我们要让列表清空,那我们就submit一个空对象就行了,非常简单!

adapter.submitList(null)

使用新的列表进行更新(项目中最常见的复杂场景)

val newList=listOf(
//修改
ItemTestBean("小明",18,20,18),
ItemTestBean("小红",19,180,18),
//插入
ItemTestBean("蔡徐鸡",20,180,18),
ItemTestBean("小刘",18,180,18),
ItemTestBean("我爱你",14,180,18),
ItemTestBean("小江",12,180,18),
)

adapter.submitList(newList)

image.png

我们可以看到,新的列表相对原列表而言,发生了修改、删除、插入等操作,如果这些由开发者自己来维护,是非常麻烦的,但是依靠ListAdapter内置的差异性算法,自动帮我们完成了这些工作。

总结

  笔者使用一个简单的案例演示了ListAdapter如何帮助开发者完成列表差异性更新的逻辑,非常适合那些返回整段列表然后更新局部元素的逻辑,例如后台返回的一整段列表,这些列表可能只有一两个元素发生了变化,如果按照传统的notifyDataSetChange()会严重浪费性能,而ListAdapter只会更新那些发生了变化的区域。

  如果你的项目不能直接使用ListAdapter,也希望使用这个差分算法,你可以直接使用DiffUtil去更新你项目的Adapter,关于这个DiffUtil的直接使用,网上有许多教程,用起来也并不难,这里不在赘述。


作者:晴天小庭
链接:https://juejin.cn/post/7125275626134585352
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

【Android爬坑日记四】组合替代继承,减少Base类滥用

背景 先说一下背景,当接触了比较多的项目之后,其实会发现每一个项目都会封装BaseActivity、BaseFragment等等。其实初衷其实是好的。每一个Activity和Fragment都是很多模板代码的,为了减少模板代码,封装进Base类其实是一种比较方...
继续阅读 »

背景


先说一下背景,当接触了比较多的项目之后,其实会发现每一个项目都会封装BaseActivity、BaseFragment等等。其实初衷其实是好的。每一个Activity和Fragment都是很多模板代码的,为了减少模板代码,封装进Base类其实是一种比较方便且可行的选择。


Base类涵盖了抽象、继承等面向对象特性,用得好会减少很多样板代码,但是一旦滥用,会对项目有很多弊端。


举个例子


当项目大了,需要封装进Base类的逻辑会非常多,比如说打印生命周期、ViewBinding 或者DataBinding封装、埋点、监听广播、监听EventBus、展示加载界面、弹Dialog等等其他业务逻辑,更有甚者把需要Context的函数都封装进Base类中。


以下举一个BaseActivity的例子,里面封装了上面所说的大部分情况,实际情况可能更多。


abstract class BaseActivity<T: ViewBinding, VM: ViewModel>: AppCompatActivity {

protected lateinit var viewBinding: T

protected lateinit var viewModel: VM

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 打印日志!!
ELog.debugLifeCycle("${this.localClassName} - onCreate")

// 初始化viewModel
viewModel = initViewModel()
// 初始化视图!!
initView()
// 初始化数据!!
initData()
// 注册广播监听!!
registerReceiver()
// 注册EventBus事件监听!!
registerEventBus()

// 省略一堆业务逻辑!

// 设置导航栏颜色!!
window.navigationBarColor = ContextCompat.getColor(this, R.color.primary_color)
}

protected fun initViewModel(): VM {
// 初始化viewModel
}

private fun initViewbinding() {
// 初始化viewBinding
}

// 让子类必须实现
abstract fun initView()

abstract fun initData()

private fun registerReceiver() {
// 注册广播监听
}

private fun unregisterReceiver() {
// 注销广播监听
}

private fun registerEventBus() {
// 注册EventBus事件监听
}

protected fun showDialog() {
// 需要用到Context,因此也封装进来了
}

override fun onResume() {
super.onResume()
ELog.debugLifeCycle("${this.localClassName} - onResume")
}

override fun onPause() {
super.onPause()
ELog.debugLifeCycle("${this.localClassName} - onPause")
}

override fun onDestroy() {
super.onDestroy()
ELog.debugLifeCycle("${this.localClassName} - onDestroy")
unregisterReceiver()
}
}

其实看起来还好,但是在使用的时候难免会遇到一些问题,对于中途接手项目的人来说问题更加明显。我们从中途接手项目的心路历程看看Base类的缺陷。


心路历程




  1. 当创建新的Activity或者Fragment的时候需要想想有没有逻辑可以复用,就去找Base类,或许写Base类的人不同,发现一个项目中可能会存在多个Base类,甚至Base类仍然有多个Base子类实现不同逻辑。这个时候就需要去查看分析每个Base类分别实现了什么功能,决定继承哪个。




  2. 如果一个项目中只有一个Base类的话,仍需要看看Base类实现了什么逻辑,没有实现什么逻辑,防止重复写样板代码。




  3. 当出现Base类实现了的,而自己本身并不想需要,例如不想监听广播或者不想用ViewModel,对于不想监听广播的情况就要特殊做适配,例如往Base类加标志位。对于不想用ViewModel但是由于泛型限制,还是只能传进去,不然没法继承。




  4. 当发现自己集成Base类出BUG了,就要考虑改子类还是改Base类,由于大量的类都集成了Base类,显然改Base类比较麻烦,于是改自己比较方便。




  5. 如果一个Activity中展示了多个Fragment,可能会有业务逻辑的重复,其实只需要一个就好了。




其实第一第二点还好,时间成本其实没有重复写样板代码那么高。但是第三点的话其实用标志位来决定Base类的功能哪个需要实现哪个不需要实现并不是一种优雅的方式,反而需要重写的东西多了几个。第四点归根到底就是Base类其实并不好维护。


爬坑


那么对于Base类怎样实践才比较优雅呢?在我看来组合替代继承其实是一种不错的思路。对于Kotlin first的Android项目来说,组合替代继承其实是比较容易的。以下仅代表个人想法,有不同意见可以交流一下。


成员变量委托


对于ViewModel、Handler、ViewBinding这些Base变量使用委托的方式是比较方便的。


对于ViewBinding委托可以看看我之前的文章,使用起来其实是非常简单的,只需要一行代码即可。


// Activity
private val binding by viewBinding(ActivityMainBinding::inflate)
// Fragment
private val binding by viewBinding(FragmentMainBinding::bind)

对于ViewModel委托,官方库则提供了一个viewBindings委托函数。


private val viewModel:HomeViewModel by viewModels()

需要在Gradle中引入ktx库


implementation 'androidx.fragment:fragment-ktx:1.5.1'
implementation 'androidx.activity:activity-ktx:1.5.1'

而对于Base变量则尽量少封装在Base类中,需要使用可以使用委托,因为如果实例了没有使用其实是比较浪费内存资源的,尽量按需实例。


扩展方法


对于需要用到Context上下文的逻辑封装到Base类中其实是没有必要的,在Kotlin还没有流行的时候,如果说需要使用到Context的工具方法,使用起来其实是不太优雅的。


例如展示一个Dialog:


class DialogUtils {
public static void showDialog(Activity activity, String title, String content) {
// 逻辑
}
}

使用起来就是这样:


class MyActivity : AppCompatActivity() {
...
fun initButton() {
button.setOnClickListener {
DialogUtils.showDialog(this, "title", "content")
}
}
}

使用起来可能就会有一些迷惑,第一个参数把自己传进去了,这对于展示Dialog的语义上是有些奇怪的。按理来说只需要传title和content就好了。


这个时候就会有人想着把这个封装到Base类中。


public abstract class BaseActivity extends AppCompatActivity {

protected void showDialog(String title, String content) {
// 这里就可以用Context了
}
}

使用起来就是这样:


class MyActivity : AppCompatActivity() {
...
fun initButton() {
button.setOnClickListener {
showDialog("title", "content")
}
}
}

是不是感觉好很多了。但是写在Base类中在Java中比较好用,对于Kotlin则完全可以使用扩展函数语法糖来替代了,在使用的时候和定义在Base类是一样的。


fun Activity.showDialog(title: String, content: String) {
// this就能获取到Activity实例
}

class MyActivity : AppCompatActivity() {
...
fun initButton() {
button.setOnClickListener {
// 使用起来和定义在Base类其实是一样的
showDialog("title", "content")
}
}
}

这也说明了,需要使用到Context上下文的函数其实不用在Base类中定义,直接定义在顶层就好了,可以减少Base类的逻辑。


注册监听器


对于注册监听器这种情况则需要分情况,监听器是需要根据生命周期来注册和取消注册的,防止内存泄漏。对于不是每个子类都需要的情况,有的人可能觉得提供一个标志位就好了,如果不需要的话让子类重写。如果定义成抽象方法则每个子类都要重写,如果不是抽象方法的话,子类可能就会忘记重写。在我看来获取生命周期其实是比较简单的事情。按需添加代码监听就好了。


那么什么情况需要封装在Base类中呢?




  • 怕之后接手项目的人忘记写这部分代码,则可以写到Base类中,例如打印日志或者埋点。




  • 而对于界面太多难以测试的功能,例如收到被服务器踢下线的消息跳到登录页面,这个可以写进Base类中,因为基本上每个类都需要监听这种消息。




总结


没有最优秀的架构,只有最适合的架构!对于Base类大家的看法都不一样,追求更少的工作量完成更多事情这个目的是统一的。而Base类一旦臃肿起来了会造成整个项目难以维护,因此对于Base类应该辩证看待,养成只有必要的逻辑才写在Base类中的习惯,feature类应该使用组合的方式来使用,这对于项目的可维护性和代码的可调试性是有好处的。


作者:米奇律师
链接:https://juejin.cn/post/7144671989159067656
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Compose挑灯夜看 - 照亮手机屏幕里面的书本内容

一、前言上一篇文章 Compose回忆童年 - 手拉灯绳-开灯/关灯里面82年钨丝灯,让我又有了新的想法,我们怎么照亮手机里面的文本内容呢?我们会在上一篇文章的基础上来实现“挑灯夜看”的功能,怎么下手呢?往下看👇二、文本着色器我们想要实现照亮功能,那...
继续阅读 »

一、前言

上一篇文章 Compose回忆童年 - 手拉灯绳-开灯/关灯里面82年钨丝灯,让我又有了新的想法,我们怎么照亮手机里面的文本内容呢?

我们会在上一篇文章的基础上来实现“挑灯夜看”的功能,怎么下手呢?往下看👇

二、文本着色器

我们想要实现照亮功能,那肯定需要有不亮的文本内容。

通过透明度来可以吗?肯定不行,文本内容是可以上下滑动的,是一个整体,我们不能通过透明度来做。

在看到小米手机的文本着色效果之后:

小米万象息屏.png

我知道如何下手了,我先来看看ComposeText如何做渐变着色?

1. 有些同学可能喜欢用Canvas去绘制:

Canvas(...) {
drawIntoCanvas { canvas ->
canvas.nativeCanvas.drawText(text, x, y, paint)
}
}

2. 我们可以使用ModifeirdrawWithCache修饰符,官方文档的链接里面也给我们了不少示例。

QQ20220830-203813@2x.png

Text(
text = "永远相信美好的事情即将发生❤️",
modifier = Modifier
.graphicsLayer(alpha = 0.99f)
.drawWithCache {
val brush = Brush.horizontalGradient(
listOf(
Color(0xFFE24CE2),
Color(0xFF73BB70),
Color(0xFFE24CE2)
)
)
onDrawWithContent {
drawContent()
drawRect(brush, blendMode = BlendMode.SrcAtop)
}
}
)

上面代码,我们使用到了BlendMode,我们这里用的是BlendMode#SrcAtop: 将源图像合成到目标图像上,仅限于与目标重叠的位置,确保只有文本可见并且矩形的其余部分被剪切。

3. Google在Compose1.2.0-beta01API变更里面,向TextStyleSpanStyle添加了 Brush API,以提供使用渐变颜色绘制文本的方法。

兄弟们支持了吗.png

private val GradientColors = listOf(
Color(0xFF00FFFF), Color(0xFF97E063),
Color(0xFFE24CE2), Color(0xFF97E063)
)
Text(
modifier = Modifier.align(Alignment.Center).requiredWidthIn(max = 250.dp),
text = "永远相信美好的事情即将发生❤️,我们不会期待米粉的期待!\n\n兄弟们支持了吗?",
style = TextStyle(
brush = Brush.linearGradient(
colors = GradientColors
)
)
)

我们可以看到Emoji表情没有被着色,非常Nice。

我们看一下linearGradient/verticalGradient/radialGradient/sweepGradient效果对比:

linearGradient.pngverticalGradient.png

左边的是linearGradient右边的是verticalGradient

4444.png5555.png

左边的是radialGradient右边的是sweepGradient

还有一种内置的BrushSolidColor,填充指定颜色。

查看Brush#LinearGradient源码发现它继承自ShaderBrush

// androidx.compose.ui.graphics.Brush
class LinearGradient internal constructor(
private val colors: List<Color>,
private val stops: List<Float>? = null,
private val start: Offset,
private val end: Offset,
private val tileMode: TileMode = TileMode.Clamp
) : ShaderBrush()

自定义ShaderBrush,可以修改画笔大小,那么我们也来整一个,用于下面的钨丝灯的照亮效果,刚刚上面还介绍了到一个gradient符合我们的要求,radialGradient,更多的源码细节,这里就不做深入介绍,夜深了哈哈哈。

我们接下来需要初始化一个ShaderBrush

object : ShaderBrush() {
override fun createShader(size: Size): Shader {
return RadialGradientShader(
center = ...,
radius = ...,
colors = ...
)
}
...
}

三、实现照亮文本

刚刚上面初始化了一个ShaderBrush,我们照亮文本内容,文本内容不可能只有一屏对吧,肯定需要支持滑动文本,那要怎么做呢?

我想肯定有掘友知道了,我们可以用ModifierverticalScroll修饰符,记录滚动状态ScrollState,然后设置到RadialGradientShadercenter里面。

我们这里的文本内容引用了:三国演义的第一章内容,我们同样需要上一篇文章RopHandleState

private fun ComposeText(state: RopeHandleState) {
Text(
text = sanguoString,
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(state.scrollState),
style = LocalTextStyle.current.merge(
TextStyle(
fontSize = 18.sp,
brush = state.lightContentBrush
)
)
)
}

这里我们用到了TextStyle#Brush的API,同时也添加了滚动修饰符,因为我们需要上下滑动文本,保证“钨丝灯”能照亮我们的文本内容。

我们在RopHandleState里面初始化ScrollState

val scrollState = ScrollState(0)

private val scrollOffset by derivedStateOf {
// 这里增加Y轴的距离
Offset(size.width / 2F, scrollState.value.toFloat() + size.width * 0.2F)
}

可以滚动,我们需要把滚动的距离同步给我们的ShaderBrush

// isOpen == true,钨丝灯亮了需要初始化ShaderBrush
object : ShaderBrush() {
override fun createShader(size: Size): Shader {
lastScrollOffset = Offset(size.width/2F, scrollOffset.y)
return RadialGradientShader(
center = lastScrollOffset!!,
radius = size.minDimension,
colors = listOf(Color.Yellow, Color(0xff85733a), Color.DarkGray)
)
}
override fun equals(other: Any?): Boolean {
return lastScrollOffset?.y == scrollOffset.y
}
}

// isOpen == false,钨丝灯灭了
SolidColor(Color.DarkGray)

根据“钨丝灯”的状态,返回不同的Brush:

val lightContentBrush by derivedStateOf {
if(isOpen) {
object : ShaderBrush() { ... }
} else {
SolidColor(Color.DarkGray)
}
}

这里需要注意一下,我们在打开和关闭钨丝灯的时候,需要把lastScrollOffset设置为初始状态值

fun toggle() {
isOpen = !isOpen
lastScrollOffset = Offset.Zero
}

其他相关的代码,请参考上一篇文章 Compose回忆童年 - 手拉灯绳-开灯/关灯

我们来看看最终效果吧

2022-08-30 22_18_31.gif

延伸:这里其实还可通过手指触摸指定范围区域内高亮哦,有兴趣的可以去试试!!


作者:Halifax
链接:https://juejin.cn/post/7137673203714899998
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

把数据库里的未付款订单改成已付款,会发生什么

导言 不知道大家在网上购物的时候,有没有这样的念头,如果能把未付款的订单偷偷用一条SQL改成已付款,该多么美好啊。那么在实际开发过程中,我们应当如何保证数据库里的数据在保存后不会被偷偷更改? 大家好我是日暮与星辰之间,创作不易,如果觉得有用,求点赞,求收藏,...
继续阅读 »

导言


不知道大家在网上购物的时候,有没有这样的念头,如果能把未付款的订单偷偷用一条SQL改成已付款,该多么美好啊。那么在实际开发过程中,我们应当如何保证数据库里的数据在保存后不会被偷偷更改?



大家好我是日暮与星辰之间,创作不易,如果觉得有用,求点赞,求收藏,求转发,谢谢。



理论


在介绍具体的内容之间,先介绍MD5算法,简单的来说,MD5能把任意大小、长度的数据转换成固定长度的一串字符,经常玩大型游戏的朋友应该都注意到过,各种补丁包、端游客户端之类的大型文件一般都附有一个MD5值,用于确保你下载文件的完整性。那么在这里,我们可以借鉴其思想,对订单的某些属性进行加密计算,得出来一个 MD5值一并保存在数据库当中。从数据库取出数据后第一时间进行校验,如果有异常更改,那么及时抛出异常进行人工处理。


实现


道理我都懂,但是我要如何做呢,别急,且听我一一道来。


这种需求听起来并不强绑定于某个具体的业务需求,这就要用到了我们熟悉的鼎鼎有名的AOP(面向切面编程)来实现。


首先定义四个类型的注解作为AOP的切入点。@Sign@Validate都是作用在方法层面的,分别用于对方法的入参进行加签和验证方法的返回值的签名。@SignField用于注解关键的不容篡改的字段。@ValidateField用于注解保存计算后得出的签名值。


@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Sign {
}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Validate {
}

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SignField {
}

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidField {
}

以订单的实体为例 sn,amt,status,userId就是关键字段,绝不能允许有人在落单到数据库后对这些字段偷偷篡改。


public class Order {
@SignField
private String sn;
@SignField
private String amt;
@SignField
private int status;
@SignField
private int userId;
@ValidField
private String sign;
}

下面就到了重头戏的部分,如何通过AOP来进行实现。


1. 定义切入点


@Pointcut("execution(@com.example.demo.annotations.Sign * *(..))")
public void signPointCut() {

}

@Pointcut("execution(@com.example.demo.annotations.Validate * *(..))")
public void validatePointCut() {

}

2.环绕切入点


@Around("signPointCut()")
public Object signAround(ProceedingJoinPoint pjp) throws Throwable {
Object[] args = pjp.getArgs();
for (Object o : args) {
System.out.println(o);
sign(o);
}
Object res = pjp.proceed(args);
return res;
}

@Around("validatePointCut()")
public Object validateAround(ProceedingJoinPoint pjp) throws Throwable {
Object[] args = pjp.getArgs();
Object res = pjp.proceed(args);
valid(res);
return res;
}

3. 签名的实现


1.获取需要签名字段


private Map<String, String> getSignMap(Object o) throws IllegalAccessException {
Map<String, String> fieldNameToValue = new HashMap<>();
for (Field f : o.getClass().getDeclaredFields()) {
System.out.println(f.getName());
for (Annotation annotation : f.getDeclaredAnnotations()) {
if (annotation.annotationType().equals(SignField.class)) {
String value = "";
f.setAccessible(true);
fieldNameToValue.put(f.getName(), f.get(o).toString());
}
}
}
return fieldNameToValue;
}

2.计算出签名值,这里在属性名和属性值以外加入了我的昵称以防止他人猜测,同时使用了自定义的分隔符来加强密码强度。


private String getSign(Map<String, String> fieldNameToValue) {
List<String> names = new ArrayList<>(fieldNameToValue.keySet());
StringBuilder sb = new StringBuilder();
for (String name : names)
sb.append(name).append("@").append(fieldNameToValue.get(name));
System.out.println(sb.append("日暮与星辰之间").toString());
String signValue = DigestUtils.md5DigestAsHex(sb.toString().getBytes(StandardCharsets.UTF_8));
return signValue;
}


  1. 找到保存签名的字段


private Field getValidateFiled(Object o) {
for (Field f : o.getClass().getDeclaredFields()) {
for (Annotation annotation : f.getDeclaredAnnotations()) {
if (annotation.annotationType().equals(ValidField.class)) {
return f;
}
}
}
return null;
}


  1. 对保存签名的字段进行赋值


public void sign(Object o) throws IllegalAccessException {
Map<String, String> fieldNameToValue = getSignMap(o);
if (fieldNameToValue.isEmpty()) {
return;
}
Field validateField = getValidateFiled(o);
if (validateField == null)
return;
String signValue = getSign(fieldNameToValue);
validateField.setAccessible(true);
validateField.set(o, signValue);
}


  1. 对从数据库中取出的对象进行验证


public void valid(Object o) throws IllegalAccessException {
Map<String, String> fieldNameToValue = getSignMap(o);
if (fieldNameToValue.isEmpty()) {
return;
}
Field validateField = getValidateFiled(o);
validateField.setAccessible(true);
String signValue = getSign(fieldNameToValue);
if (!Objects.equals(signValue, validateField.get(o))) {
throw new RuntimeException("数据非法");
}

}

使用示例


对将要保存到数据库的对象进行签名


@Sign
public Order save( Order order){
orderList.add(order);
return order;
}

验证从数据库中取出的对象是否合理


@Validate
public Order query(@ String sn){
return orderList.stream().filter(e -> e.getSn().equals(sn)).findFirst().orElse(null);
}

作者:日暮与星辰之间
链接:https://juejin.cn/post/7144378128914186270
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

超好用的官方core-ktx库,了解一下~

ktx
本篇文章主要是研究core-ktx库中graphics包下提供的关于View绘制、Bitmap、Rect、Color等操作的一系列扩展API,看看能为我们开发带来哪些便利。 Drawable与Bitmap相互间转换 Bitmap.toDrawable(Res...
继续阅读 »

本篇文章主要是研究core-ktx库中graphics包下提供的关于View绘制、BitmapRectColor等操作的一系列扩展API,看看能为我们开发带来哪些便利。



DrawableBitmap相互间转换


Bitmap.toDrawable(Resource)实现BitmapDrawable


image.png


Bitmap定义了一个快速转换成BitmapDrawable的扩展方法,还是少写了一些模板代码的。


Drawable.toBitmap()实现Drawable转换成Bitmap对象


image.png


Drawable转换成Bitmap应该是我们日常开发中常见的场景,这里官方库直接给我们提供了一个toBitmap()的API,非常的方便,下面我们来简单介绍下其中的原理:




  1. 首先判断当前Drawable的类型是否为BitmapDrawable,如果是直接调用其getBitmap()就能直接拿到Bitmap对象,然后根据传入的宽高进行一定比例的压缩转换后进行返回;




  2. 如果不是BitmapDrawable,就首先需要创建一个Bitmap对象,可以理解为一个"画布",然后接着创建一个Canvas对象并传入之前创建的Bitmap对象,这样我们就可以利用Canvas提供的绘制API在Bitmap这个"画布"上作画了,接下来直接调用Drawable的draw()方法并传入Canvas,就可以将Drawable中的显示内容绘制到我们一开始创建的Bitmap上了,这样就完成了DrawableBitmap的转换




Bitmap系列


简化对Bitmap的绘制操作


我们先看下日常开发中,我们怎么在Bitmap中绘制一点东西:


private fun test4(bitmap: Bitmap) {
val canvas = Canvas(bitmap)
canvas.apply {
//进行一些绘制操作
drawLine(0f, 0f, 100f, 100f, Paint())
}
}

有些繁琐,看下官方库给我们提供了什么便利的扩展实现:


image.png


帮助我们创建好Canvas对象,并且方法参数是一个接收者为Canvas的函数类型,这意味我们可以直接在外部传入的lambda中进行绘制操作:


private fun test4(bitmap: Bitmap) {
bitmap.applyCanvas {
//进行一些绘制操作
drawLine(0f, 0f, 100f, 100f, Paint())
}
}

简化Bitmap创建


1.createBitmap()创建指定大小和像素格式的Bitmap


image.png


还是简化了创建Bitmap的操作,虽然很小。


2.scale()缩放(压缩)Bitmap


image.png


这个也是我们常用的通过降低分辨率压缩Bitmap大小的一种方式。


操作Bitmap中的像素点


1.Bitmap.get(x: Int, y: Int)获取指定位置的像素点RGB值


image.png


经典的运算符重载函数,代码中可以直接val pointRGB = bitmap[100, 100]使用。


2.Bitmap.set()设置某个点的RGB像素值


image.png


同样也是个运算符重载方法,代码中直接bitmap[100, 100] = Color.RED使用。


3.Bitmap.contains()判断指定位置点是否落在Bitmap


image.png


运算符重载方法,直接Point(100, 100) in bitmap使用


color系列


普通扩展属性获取颜色的A、R、G、B


image.png


使用如下:


private fun test10(@ColorInt value: Int) {
val a = value.alpha
val r = value.red
val g = value.green
val b = value.blue
}

解构获取颜色的A、R、G、B


image.png


带有operator修饰componenX就是解构方法,X和参数声明的位置一一对应:


private fun test10(@ColorInt value: Int) {
val (a, r, g, b) = value
}

向我们常见的data classHashMap都实现了类似的解扩展。


转换颜色Color对象


1.Int.toColor()整形颜色转换Color对象


image.png


2.String.toColorInt()实现字符串转Color对象


image.png


这个应该比较常用,直接"#ffffff".toColorInt()即可


Rect系列


解构获取左、上、右、下的值


image.png


熟悉的解构,使用和上面一样(RectF也同样提供了相同的解构方法),如下:


private fun test10(rect: Rect) {
val (left, top, right, bottom) = rect
}

缩放Rect范围


下面是扩充Rect范围的API:


image.png
image.png


image.png


使用如下:


private fun test10(rect: Rect) {
val rect1 = rect + rect
val rect2 = rect + 10
val rect3 = rect + Point(100, 200)
}

同样也提供了minus()缩减Rect范围


Rect间取交集、并集等


image.png


image.png


判断某个点是否落在Rect


image.png


使用:Point(11, 11) in rect


Point、PointX系列



下面的扩展方法无非就是解构、通过运算符重载控制Point位置,上面已经讲了一大堆这样的使用,大家走马观花的看下就行,有个印象即可。



经典的解构取值方法


image.png


操作Point的位置


image.png


image.png


总结



上面的内容已经把graphics包下提供的扩展工具讲的七七八八了,大家主要是有个印象就行,使用的时候能想起来用更好,如果需要详细了解的请直接参考该包下的源码即可。




关于探索官方core-ktx库的还剩下大概最后一篇文章讲解了,希望可以在这个系列中带给大家一些帮助,提供大家的开发效率。并且通过学习官方库中的封装思路,也同样会给大家日常开发中小优化带来启发。


作者:长安皈故里
链接:https://juejin.cn/post/7121718556546482190
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Android 官方模块化方案解读

前言 前不久整理下 Now In Android 项目是如何做模块化的(Android 官方项目是怎么做模块化的?快来学习下),没想到官方不久前也在官方文档中更新了模块化相关的章节,下面就一起看一下官方文档中是如何描述 Android App 模块化的。 概述...
继续阅读 »

前言


前不久整理下 Now In Android 项目是如何做模块化的(Android 官方项目是怎么做模块化的?快来学习下),没想到官方不久前也在官方文档中更新了模块化相关的章节,下面就一起看一下官方文档中是如何描述 Android App 模块化的。


概述


首先思考下,为什么要做模块化或者说如果不做模块化会有什么问题?


模块化解决了什么问题?


随着项目以及业务的不断迭代,整个项目中代码数量会不断的增长,在这个过程中代码的可扩展性、可读性都会随着时间的推移而下降。


解决这个问题方式大致有两种,一种是定期 Review 代码架构并做一些防劣化措施,从而保证项目的质量不会随着项目的增长而下降。但是这种方式在需求快速迭代的团队中由于工期及人力投入的原因是很难被执行的。另外就是需要团队中有能够敏锐发现代码劣化倾向的人,从而发起 Review,这个角色通常由技术专家或者是架构师承担。这种方式可操作性并不高。


另外一种解决思路就是将复杂问题拆解成多个小的、简单问题,而对简单问题的处理通常并不需要特别依赖高级人才。这种方式就是分而治之,将大型、复杂问题拆解成一个个小的、简单问题,从而可以做到各个击破。这种方式对应的工程手段之一就是模块化


什么是模块化?


模块化简单讲就是把多功能、高耦合的代码逻辑拆散成多个功能单一、职责明确的模块(module)。一个项目模块化后的整体架构大致如下:


图片



注::app:phone 是模块名,app表示是子目录的形式,具体可以参考我给 Now In Android 提交的 PR 241



模块化有哪些好处?


模块化的好处有很多,主要集中表现在提高代码库的可维护性和整体质量上。下表总结了主要优势。























优点概括
多 App复用模块化是在多 App 开发中复用代码逻辑的基础。每个模块都是一个独立有效的构建单元。
严格的访问权限模块可以很好做控制代码的可访问性,模块内部私有的逻辑添加 internal 或者 private 修饰。防止代码被其他模块引用而导致的过度耦合。
可定制的交付可以使用动态下发( Play Feature Delivery )功能(注:国内应用商店基本不可用) 。

上述好处只能通过模块化才能实现。以下是不使用模块化也能实现的好处,但模块化可以更好地实现。































优点概括
可扩展性在代码紧密耦合的代码仓库中,一个微小的更改都有可能导致牵一发动全身。一个好的模块化的项目会做到代码解耦(关注点分离原则),从而规避了上述问题。
负责人一个模块可以有一个专门的负责人,负责维护代码、修复错误、添加测试和 CodeReview 。方便代码与人员的双重管理。
封装封装意味着代码的每一部分都应该对其他部分有尽可能少的了解(最少知道原则)。孤立的代码更容易阅读和理解。
可测试性可测试性描述了测试代码的难易程度。可测试代码是可以轻松独立测试组件的代码。小类总比大型类容易测试,依赖少的类总比依赖多的类容易测试。
构建时间模块化可以提升编译速度,例如增量构建、构建缓存或并行构建。

模块化常见的误区


任何一项技术都有好坏,模块化也是如此。对模块化使用不当,可能也会引入一些问题。一些常见的问题如下:




  • 太细粒度:太细粒度就意味着项目中会有很多模块,而多模块又会导致编译耗时以及多模块配置同步的问题;




  • 太粗粒度:太粗粒度就意味着项目中会有很少模块,基本不能完全发挥出模块化的好处;当做这是一个循序渐进的过程,太粗粒度可以是一个开始不应该是一个结束;




  • 太复杂:将项目模块化并不总是有意义的。如果在可预见的未来项目的增长并不明确,保持现状也是一种不错的选择。




高内聚低耦合原则


没有适合所有项目的模块化方案。下面讲下模块化开发过程中可以采用的一些一般规则和常见模式。


高内聚低耦合是模块化项目的一种属性。耦合衡量模块相互依赖的程度,内聚衡量单个模块的元素在功能上的相关性。应该争取低耦合和高内聚:




  • 低耦合模块不应该了解其他模块的内部工作原理,这意味着模块应该尽可能地相互独立,以便对一个模块的更改对其他模块的影响为零或最小。




  • 高内聚意味着模块应该仅包含相关性的代码。在一个示例电子书应用程序,将书籍和支付的代码混合在同一个模块中可能是不合适的,因为它们是两个不同的功能领域。




如果两个模块严重依赖彼此,那么它们实际上应该作为一个系统运行。相反,如果一个模块的两个部分不经常交互,它们可能应该是单独的模块。


小结


模块化就是一种将复杂问题拆解成多个简单问题的工程化方案。所以如果你觉得项目还没有那么复杂,引入模块化的收益将没有那么明显。这里的复杂性包括多 App 复用、严格的代码可见性以及 Google Paly 的动态下发(Play Feature Delivery)。当然,如果希望在可扩展性、所有权、封装或构建时间中受益,那么模块化是值得考虑的事情。


模块的类型


App 模块


应用程序模块是应用程序的入口点。它们依赖于特性(feature)模块,通常提供导航能力。使用多渠道打包方案,单个应用程序模块可以编译为许多不同的二进制文件。


如,根据使用用途可以分为正式版本 App、 测试 Demo App,其中正式版本 App 根据其发布平台又可以分为 智能手机、汽车、电视、可穿戴设备等,其依赖关系大致如下:


图片


特性(Feature)模块


特性是 App 中功能相对独立的部分,通常对包含一个页面或一系列密切相关的页面,例如注册或结帐流程。如果您的应用具有底部栏导航,则很可能每个目的地都是一项功能。


特性模块中一般会包含页面或路由(destinations)。因此,在模块内部需求处理 UI Layer 中相关的内容。特性模块中不必局限于单个页面或导航目的地,可以包含多个页面。特性模块依赖于数据模块。


图片


数据(Data)模块


数据模块通常包含 RepositoryDataSource 和实体类。数据模块主要有三个职责:



  1. 封装某个领域的所有数据和业务逻辑:每个数据模块应该负责处理代表某个领域的数据。它可以处理多种相关类型的数据。




  1. 将 Repository 公开为外部 API:数据模块的公共 API 应该是 Repository,因为它们负责将数据公开给 App 的其余部分。




  1. 对外隐藏所有实现细节和 DataSource:DataSource 只能由同一模块的 Repository 访问,对外是隐藏的状态。可以通过使用 Kotlin 的 private 或者 internal 关键字来强制执行此操作。


图片


公共(Common)模块


公共模块,也称为核心模块或者基础模块,包含其他模块经常使用的代码。以下是常用模块的示例:



  • 基础 UI 模块:如果 App 中使用自定义 View 和样式(style),应该考虑将他们统一封装到一个模块中,以便可以复用。也就是大家通常所说的 UI 规范库,这可以使 UI 在不同特性模块之间保持一致。

  • 打点统计模块:打点统计模块,一般是使用市面上现有的 SDK,当然也有自研的。取决于项目需要。

  • 网络模块:网络库模块,通常是对三方网络库(如 OhHttp)的封装,简化自定义配置时,减少不必要的重复代码。

  • 工具模块:工具类,也称为帮助类,通常是在应用程序中重用的小段代码。如文件读写、电子邮件验证器或自定义运算符等。


App 模块化整体汇总形式大致如下:


图片


\


模块间通信



注:此部分结合自身经验以及官方文档整合而得,请批判性观看。



项目虽然采用了模块化方式进行开发,减少了代码之间的耦合,但是模块间的通信仍是不可避免的事情。模块间相互依赖的方式在工程上并不可行,Android 项目并不允许模块间的相互依赖。通常的做法就是引入第三个中介模块,模块间通过中介模块来进行通信。


中介模块在依赖形式上有可以分为两种,一种是向下抽象,抽离出两个模块共有的数据层逻辑,模块通过回调或者是数据流的方式监听逻辑的变化;另一种形式是抽象,在宿主 App 模块中组合拼装两个模块的逻辑。前者是下沉逻辑,后者是控制反转。


下面我以一个简单的业务场景举例:在购书籍列表页面,选择特定的一本书并下单购买。


图片


抽离基础模块


大致流程如下:



  1. 分别在 :feature:home 与 :feature:checkout 设置对基础依赖模块的初始化操作,如接口实现、回调监听等;




  1. 在 :feature:home 模块内通过依赖的 :data:books模块,调用其 navigate() 方法跳转至 :feature:checkout 模块;




  1. 在 :feature:books 模块内将跳转事件通过 onNavigationBook 分发出去,由 :feature:checkout模块模块实现;

  2. :feature:home 模块通过 :data:books模块提供的 onPaymentCanceled() 回调来监听对应的结果;



这种通讯方式随着业务的迭代,底层通用的数据模块会不断膨胀,耦合也会越加严重,所以并不建议使用此方式。官方文档中示例方式则是交由调用者处理,各自模块也相对内聚。


依赖调用者模块


这种方式一般是依赖 app 模块来组装各个业务模块的业务逻辑,也就是一样意义上的胶水代码。大致方式如下:



  1. :app 模块调用 :feature:home提供的 navigate 函数跳转至 home 页面,并通过 onCheckout 函数将对应的结果回调出去;

  2. :app 模块监听到 onCheckout() 回调后,调用 :feature:checkout模块提供 navigate 函数进行跳转,并通过 onPaymentCanceled() 回调将结果抛出;



此种方式使得各业务模块的逻辑更加内聚,虽然这种方式的结果及事件也能很好的暴露出去。但是如果这种方式在大型项目中使用时会导致产生大量的胶水代码(频繁的初始化以及 Callback 设置),不利于项目中后续迭代。为了解决胶水代码问题,可以在项目中引入依赖管理的框架。


依赖管理框架


依赖管理不仅可以很好地解决对象繁琐的初始化逻辑,还可以很好的实施控制反转的编码思想。目前主流的依赖管理的方案有两种,分别为依赖注入与服务查找:




  1. 依赖注入:依赖注入使类能够定义其依赖项而不构造它们。在运行时,另一个类负责提供这些依赖项。一般是使用注解方式,适合大中型项目,如 Hilt




  2. 服务查找:服务查找的方式一般是维护一个注册表,所需的依赖都可以在这个注册表中查找;一般是使用相对简单,适合中小型项目,如 koin




官方推荐使用 Hilt 来进行依赖管理,如果你的项目中在使用其他的依赖管理方式,并且没有遇到问题的话,那么继续使用当前的框架即可。


依赖管理的方式不仅可以使用模块间通信,在模块内部通信也是一种很好的解耦与复用的手段,只是在模块间通信会流程变得更加复杂,也更能突出依赖管理的重要性。整个依赖管理在模块化整体架构大致如下图:


图片


以服务查找方式实现,其大致流程(忽略模块内通讯)如下:



  1. 数据层可以将对应的数据注入到 DI 容器中;

  2. 在特性模块中可以获取到数据层提供的数据,同时也可以将自身的数据注入到 DI 中;

  3. 在 app 模块获取所需的数据或特性功能;


小结


其实整个模块间的通信可以按照其行为方式分为两大类,一种是不同模块间页面直接的跳转,另一种则是不同模块间的数据交互。


对于前者有各种路由框架,Android 官方也提供了 Navigation 库;对于后者也有不少框架,如 Dagger2、Koin,Android 官方也提供了 hilt 库;当然社区中也有两者都能满足的库,如阿里的 ARouter


官方文档中只是提到了比较原始的方式,也是对初学者比较友好的方式。大家可以根据自己项目中的现状选择适合自己的即可。


最佳实践


虽然开发模块化 App 没有唯一正确的方式,但是以下的一些建议仍可以使代码更具可读性、可维护性和可测试性。


保持配置一致


每个模块都会引入配置开销。当模块数量达到某个阈值,则管理一致的配置将成为一项挑战。下面的配置可以减少这部分的工作量:





  • 使用 约定插件 在模块之间共享 build.gradle 中的构建逻辑。


尽量少暴露


模块的公共接口应该是最小的,并且仅仅只公开必需公开的。它不应该在外面暴露任何实现细节。尽可能的缩小外部调用者的可访问范围,使用 Kotlin 的privateinternal 可以很好的做到这一点。在模块中声明依赖项时推荐使用implementation,而非apiimplementation不会透传依赖,从而可以做到缩短构建时间。


尽量使用 Kotlin 和 Java 模块


Android Studio 支持三种基本类型的模块:



  • 应用程序模块AndroidManifest.xml是您的应用程序的入口点。它们可以包含源代码、资源、资产和. 应用模块的输出是一个 Android App Bundle (AAB) 或一个 Android 应用程序包 (APK)。




  • 库模块 依赖项与应用程序模块具有相同的内容。它们被其他 Android 模块用作依赖项。库模块的输出是一个 Android Archive (AAR),在结构上与应用程序模块相同,但它们被编译为一个 Android Archive (AAR) 文件,以后可以被其他模块用作。库模块可以在许多应用程序模块中封装和重用相同的逻辑和资源。




  • Kotlin 和 Java 库不包含任何 Android 资源、资产或清单文件。


由于 Android 模块会带来开销,因此您最好尽可能使用 Kotlin 或 Java 类型。


总结


以上内容是根据官方文档整理而得,对部分内容做了结构调整、重新绘制了 UML 图以及添加了些自己的经验感悟。对原文整理会存在疏忽遗漏的部分,请大家到官方文档中查看,做到“交叉验证”。


如果你想快速搭建一个全新的 Android 模块化项目,可以到 architecture-templates 仓库中 clone,可以说是非常便捷了。


虽然模块化技术在国内并算不上什么新技术了,但是我仍然看到了一些积极的影响:



  • 对于初学者而言,有一套相对详细指导文档并且完整示例的项目(Now in Android)可以参考,从而可以快速搭建模块化的项目;

  •  对于已经实践过模块化项目团队,我相信仍能从官方文章中学习到一些新思路及方法,以复盘的视角审视自己团队中的模块化方案的优劣;


当然,模块化本身并不是终点。模块化之后还有组件化,组件化之后还有壳工程和动态化。每个技术阶段对应到团队发展的阶段,那些适合目前团队现状的技术才是”好“技术。


作者:RethinkAndroid
链接:https://juejin.cn/post/7142884855091560479
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Android 三行代码实现高斯模糊

设计:有了毛玻璃效果,产品的逼格直接拉满了呀我:啊,对对对。我去 GayHub 上找找有没有好的解决方案吧设计:GayHub ???寻找可行的方案要实现高斯模糊的方式有很多,StackBlur、RenderScript、Glide 等等都是不错的方式,但最简单...
继续阅读 »

设计:有了毛玻璃效果,产品的逼格直接拉满了呀

我:啊,对对对。我去 GayHub 上找找有没有好的解决方案吧

设计:GayHub ???

寻找可行的方案

要实现高斯模糊的方式有很多,StackBlur、RenderScript、Glide 等等都是不错的方式,但最简单直接效率最高的方式,还得是上 Github。

搜索的关键词为 android blur,可以看到有两个库是比较合适的, Blurry 和 BlurView。 这两个库 Star 数比较高,并且也还在维护着。

于是,便尝试了一番,发现 BlurView 比 Blurry 更好用,十分推荐上手 BlurView

image-20220917223800119

Blurry

  • 优点:API 使用非常简洁,效果也不错,提供同步和异步加载的解决方案
  • 缺点:奇奇怪怪的 Bug 非常多,并且只能作用于 ImageView
    • 使用时,基本会遇到这两个 Bug:issue1 和 issue2 。
    • issue1(NullPointerException) 已经有现成的解决方案
    • issue2(Canvas: trying to use a recycled bitmap) 则从 17 年至今毫无进展,并且复现概率还比较高

BlurView(推荐)

  • 优点:使用的过程中几乎没有遇到 bug,实现时调用的代码较少。并且,可以实现复杂的模糊 View

  • 缺点:需要在 xml 中配置,并且需要花几秒钟的时间理解一下 rootView 的概念

  • 使用方式:

    XML:

    <androidx.constraintlayout.widget.ConstraintLayout
    ...
    android:id="@+id/rootView"
    android:background="@color/purple_200" >

    <ImageView
    ...
    android:id="@+id/imageView" />

    <eightbitlab.com.blurview.BlurView
    ...
    android:id="@+id/blurView" />

    </androidx.constraintlayout.widget.ConstraintLayout>

    MainActivity#onCreate:

    // 这里的 rootView,只要是 blurView 的任意一个父 View 即可
    val rootView = findViewById<ConstraintLayout>(R.id.rootView)
    val blurView = findViewById<BlurView>(R.id.blurView)
    blurView.setupWith(rootView, RenderScriptBlur(this))
  • 实现的效果:

    使用前:

    181663475092_.pic

    使用后:

    171663475091_.pic
  • Tips :

    • 在 BlurView 以下的 View 都会有高斯模糊的效果

    • rootView 可以选择离 BlurView 最近的 ViewGroup

    • .setBlurRadius() 可以用来设置卷积核的大小,默认是 16F

    • .setOverlayColor() 可以用来设置高斯模糊覆盖的颜色值

    • 例如如下参数配置时可以达到这样的效果:

      blurView.setupWith(rootView, RenderScriptBlur(this))
      .setBlurRadius(5F)
      .setOverlayColor(Color.parseColor("#77000000"))
      191663495988_.pic


作者:很好奇
链接:https://juejin.cn/post/7144663860027326494
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

开发这么久,gradle 和 gradlew 啥区别、怎么选?

使用 Gradle 的开发者最常问的问题之一便是: gradle 和 gradlew 的区别? 。 这两个都是应用在特定场景的 Gradle 命令。通过本篇文章你将了解到每个命令干了什么,以及如何在两个命令中做选择。 快速摘要 如果你正在开发的项目当中已经包...
继续阅读 »

使用 Gradle 的开发者最常问的问题之一便是: gradlegradlew 的区别?


这两个都是应用在特定场景的 Gradle 命令。通过本篇文章你将了解到每个命令干了什么,以及如何在两个命令中做选择。



快速摘要


如果你正在开发的项目当中已经包含 gradlew 脚本,安啦,可以一直使用它。没有包含的话,请使用 gradle 命令生成这个脚本。


想知道为什么吗,请继续阅读。



gradle 命令


如果你从 Gradle 官网(gradle.org/releases)下载和安装了 Gradle 的话,你便可以使用安装在 bin 路径下的 gradle 命令了。当然你记得将该 bin 路径添加到设备的 PATH 环境变量中。


此后,在终端上运行 gradle 的话,你会看到如下输出:



你会注意到输出里打印了 Gradle 的版本,它对应着你运行的 gradle 命令在设备中的 Gradle 安装包版本。这听起来有点废话,但在谈论 gradlew 的时候需要明确这点,这很重要。


通过这个本地安装的 Gradle,你可以使用 gradle 命令做很多事情,包括:



  • 使用 gradle init 命令创建一个新的 Gradle 项目或者使用 gradle wrapper 命令创建 gradle wrapper 目录及文件

  • 在一个 Gradle 项目内使用 gradle build 命令进行 Gradle 编译

  • 通过 gradle tasks 命令查看当前的 Gradle 项目中支持哪些 task


上述的命令均使用你本地安装的 Gradle 程序,无论你安装的是什么版本。


如果你使用的是 Windows 设备,那么 gradle 命令等同于 gradle.bat,gradlew 命令等同于 gradlew.bat,非常简单。


gradlew 命令


gradlew 命令,也被了解为 Gradle wrapper,与 gradle 命令相比它是略有不同的。它是一个打包在项目内的脚本,并且它参与版本控制,所以当年复制了某项目将自动获得这个 gradlew 脚本。


“可那又如何?”


好吧,如果你这么想。让我告诉你,它有很多重要的优势。


1. 无需本地安装 gradle


gradlew 脚本不依赖本地的 Gradle 安装。在设备上第一次运行的时候会从网络获取 Gradle 的安装包并缓存下来。这使得任何人、在任何设备上,只要拷贝了这个项目就可以非常简单地开始编译。


2. 配置固定的 gradle 版本


这个 gradlew 脚本和指定的 Gradle 版本进行绑定。这非常有用,因为这意味着项目的管理者可以强制要求该项目编译时应当使用的 Gradle 版本。


Gradle 特性并不总是互相兼容各版本的,所以使用 Gradle wrapper 可以确保项目每次编译都能获得一致性的结果。


当然这需要编译项目的人使用 gradlew 命令,如下是在项目内运行 ./gradlew 的示例:



输出和运行 gradle 命令的结果比较相似。但仔细查看你会发现版本不一样,不是上面的 6.8.2 而是 6.6.1


这个差异说重要也重要,说不重要也不重要。


但当使用 gradlew 的话可以免于担心由于 Gradle 版本导致的不一致性,缘自它可以保证所有的团队成员以及 CI 服务端都会使用相同的 Gradle 版本来构建这个项目。


另外,几乎所有使用 gradle 命令可以做的事情,你也可以使用 gradlew来完成。比如编译一个项目就是 ./gradlew build


如果你愿意的话,可以拷贝 示例项目 并来试一下gradlew


gradle 和 gradlew 对比


至此你应该能看到在项目内使用 gradlew 通常是最佳选择。确保 gradlew 脚本受到版本控制,这样的话你以及其他开发者都可以收获如上章节提到的好处。


但是,难道没有任何情况需要使用 gradle 命令了吗?当然有。如果你期望在一个空目录下搭建一个新的 Gradle 项目,你可以使用 gradle init 来完成。这个命令同样会生成 gradlew 脚本。


(如下的表格简单列出两者如何选)可以说,使用 gradlew 确实是 Gradle 项目的最佳实践。

你想做什么?gradle 还是 gradlew?
编译项目gradlew
测试项目gradlew
项目内执行其他 Gradle taskgradlew
初始化一个 Gradle 项目或者生成 Gradle wrappergradle

作者:TechMerger
链接:https://juejin.cn/post/7144558236643885092
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Android性能优化 - 捕获java crash的那些事

背景 crash一直是影响app稳定性的大头,同时在随着项目逐渐迭代,复杂性越来越提高的同时,由于主观或者客观的的原因,都会造成意想不到的crash出现。同样的,在android的历史化过程中,就算是android系统本身,在迭代中也会存在着隐含的crash。...
继续阅读 »

背景


crash一直是影响app稳定性的大头,同时在随着项目逐渐迭代,复杂性越来越提高的同时,由于主观或者客观的的原因,都会造成意想不到的crash出现。同样的,在android的历史化过程中,就算是android系统本身,在迭代中也会存在着隐含的crash。我们常说的crash包括java层(虚拟机层)crash与native层crash,本期我们着重讲一下java层的crash。


java层crash由来


虽然说我们在开发过程中会遇到各种各样的crash,但是这个crash是如果产生的呢?我们来探讨一下一个crash是如何诞生的!


我们很容易就知道,在java中main函数是程序的开始(其实还有前置步骤),我们开发中,虽然android系统把应用的主线程创建封装在了自己的系统中,但是无论怎么封装,一个java层的线程无论再怎么强大,背后肯定是绑定了一个操作系统级别的线程,才真正得与驱动,也就是说,我们平常说的java线程,它其实是被操作系统真正的Thread的一个使用体罢了,java层的多个thread,可能会只对应着native层的一个Thread(便于区分,这里thread统一只java层的线程,Thread指的是native层的Thread。其实native的Thread也不是真正的线程,只是操作系统提供的一个api罢了,但是我们这里先简单这样定义,假设了native的线程与操作系统线程为同一个东西)


每一个java层的thread调用start方法,就会来到native层Thread的世界


public synchronized void start() {
throw new IllegalThreadStateException();
group.add(this);

started = false;
try {
nativeCreate(this, stackSize, daemon);
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {
/* do nothing. If start0 threw a Throwable then
it will be passed up the call stack */
}
}
}

最终调用的是一个jni方法


private native static void nativeCreate(Thread t, long stackSize, boolean daemon);

而nativeCreate最终在native层的实现是


static void Thread_nativeCreate(JNIEnv* env, jclass, jobject java_thread, jlong stack_size,
jboolean daemon) {
// There are sections in the zygote that forbid thread creation.
Runtime* runtime = Runtime::Current();
if (runtime->IsZygote() && runtime->IsZygoteNoThreadSection()) {
jclass internal_error = env->FindClass("java/lang/InternalError");
CHECK(internal_error != nullptr);
env->ThrowNew(internal_error, "Cannot create threads in zygote");
return;
}
// 这里就是真正的创建线程方法
Thread::CreateNativeThread(env, java_thread, stack_size, daemon == JNI_TRUE);
}

CreateNativeThread 经过了一系列的校验动作,终于到了真正创建线程的地方了,最终在CreateNativeThread方法中,通过了pthread_create创建了一个真正的Thread


Thread::CreateNativeThread 方法中
...
pthread_create_result = pthread_create(&new_pthread,
&attr,
Thread::CreateCallback,
child_thread);
CHECK_PTHREAD_CALL(pthread_attr_destroy, (&attr), "new thread");

if (pthread_create_result == 0) {
// pthread_create started the new thread. The child is now responsible for managing the
// JNIEnvExt we created.
// Note: we can't check for tmp_jni_env == nullptr, as that would require synchronization
// between the threads.
child_jni_env_ext.release(); // NOLINT pthreads API.
return;
}
...

到这里我们就能够明白,一个java层的thread其实真正绑定的,是一个native层的Thread,有了这个知识,我们就可以回到我们的crash主题了,当发生异常的时候(即检测到一些操作不符合虚拟机规定时),注意,这个时候还是在虚拟机的控制范围之内,就可以直接调用


void Thread::ThrowNewException(const char* exception_class_descriptor,
const char* msg) {
// Callers should either clear or call ThrowNewWrappedException.
AssertNoPendingExceptionForNewException(msg);
ThrowNewWrappedException(exception_class_descriptor, msg);
}

进行对exception的抛出,我们目前所有的java层crash都是如此,因为对crash的识别还属于本虚拟机所在的进程的范畴(native crash 虚拟机就没办法直接识别),比如我们常见的各种crash


image.png


然后就会调用到Thread::ThrowNewWrappedException 方法,在这个方法里面再次调用到Thread::SetException方法,成功的把当次引发异常的信息记录下来


void Thread::SetException(ObjPtr<mirror::Throwable> new_exception) {
CHECK(new_exception != nullptr);
// TODO: DCHECK(!IsExceptionPending());
tlsPtr_.exception = new_exception.Ptr();
}

此时,此时就会调用Thread的Destroy方法,这个时候,线程就会在里面判断,本次的异常该怎么去处理


void Thread::Destroy() {
...

if (tlsPtr_.opeer != nullptr) {
ScopedObjectAccess soa(self);
// We may need to call user-supplied managed code, do this before final clean-up.
HandleUncaughtExceptions(soa);
RemoveFromThreadGroup(soa);
Runtime* runtime = Runtime::Current();
if (runtime != nullptr) {
runtime->GetRuntimeCallbacks()->ThreadDeath(self);
}

HandleUncaughtExceptions 这个方式就是处理的函数,我们继续看一下这个异常处理函数


void Thread::HandleUncaughtExceptions(ScopedObjectAccessAlreadyRunnable& soa) {
if (!IsExceptionPending()) {
return;
}
ScopedLocalRef<jobject> peer(tlsPtr_.jni_env, soa.AddLocalReference<jobject>(tlsPtr_.opeer));
ScopedThreadStateChange tsc(this, ThreadState::kNative);

// Get and clear the exception.
ScopedLocalRef<jthrowable> exception(tlsPtr_.jni_env, tlsPtr_.jni_env->ExceptionOccurred());
tlsPtr_.jni_env->ExceptionClear();

// Call the Thread instance's dispatchUncaughtException(Throwable)
// 关键点就在此,回到java层
tlsPtr_.jni_env->CallVoidMethod(peer.get(),
WellKnownClasses::java_lang_Thread_dispatchUncaughtException,
exception.get());

// If the dispatchUncaughtException threw, clear that exception too.
tlsPtr_.jni_env->ExceptionClear();
}

到这里,我们就接近尾声了,可以看到我们的处理函数最终通过jni,再次回到了java层的世界,而这个连接的java层函数就是dispatchUncaughtException(java_lang_Thread_dispatchUncaughtException)


public final void dispatchUncaughtException(Throwable e) {
// BEGIN Android-added: uncaughtExceptionPreHandler for use by platform.
Thread.UncaughtExceptionHandler initialUeh =
Thread.getUncaughtExceptionPreHandler();
if (initialUeh != null) {
try {
initialUeh.uncaughtException(this, e);
} catch (RuntimeException | Error ignored) {
// Throwables thrown by the initial handler are ignored
}
}
// END Android-added: uncaughtExceptionPreHandler for use by platform.
getUncaughtExceptionHandler().uncaughtException(this, e);
}

到这里,我们就彻底了解到了一个java层异常的产生过程!


为什么java层异常会导致crash


从上面我们文章我们能够看到,一个异常是怎么产生的,可能细心的读者会了解到,笔者一直在用异常这个词,而不是crash,因为异常发生了,crash是不一定产生的!我们可以看到dispatchUncaughtException方法最终会尝试着调用UncaughtExceptionHandler去处理本次异常,好家伙!那么UncaughtExceptionHandler是在什么时候设置的?其实就是在Init中,由系统提前设置好的!frameworks/base/core/java/com/android/internal/os/RuntimeInit.java


protected static final void commonInit() {
if (DEBUG) Slog.d(TAG, "Entered RuntimeInit!");

/*
* set handlers; these apply to all threads in the VM. Apps can replace
* the default handler, but not the pre handler.
*/
LoggingHandler loggingHandler = new LoggingHandler();
RuntimeHooks.setUncaughtExceptionPreHandler(loggingHandler);
Thread.setDefaultUncaughtExceptionHandler(new KillApplicationHandler(loggingHandler));

/*
* Install a time zone supplier that uses the Android persistent time zone system property.
*/
RuntimeHooks.setTimeZoneIdSupplier(() -> SystemProperties.get("persist.sys.timezone"));

LogManager.getLogManager().reset();
new AndroidConfig();

/*
* Sets the default HTTP User-Agent used by HttpURLConnection.
*/
String userAgent = getDefaultUserAgent();
System.setProperty("http.agent", userAgent);

/*
* Wire socket tagging to traffic stats.
*/
TrafficStats.attachSocketTagger();

initialized = true;
}

好家伙,原来是KillApplicationHandler“捣蛋”,在异常到来时,就会通过KillApplicationHandler去处理,而这里的处理就是,杀死app!!



private static class KillApplicationHandler implements Thread.UncaughtExceptionHandler {
private final LoggingHandler mLoggingHandler;
public KillApplicationHandler(LoggingHandler loggingHandler) {
this.mLoggingHandler = Objects.requireNonNull(loggingHandler);
}

@Override
public void uncaughtException(Thread t, Throwable e) {
try {
ensureLogging(t, e);

// Don't re-enter -- avoid infinite loops if crash-reporting crashes.
if (mCrashing) return;
mCrashing = true;

if (ActivityThread.currentActivityThread() != null) {
ActivityThread.currentActivityThread().stopProfiling();
}

// Bring up crash dialog, wait for it to be dismissed
ActivityManager.getService().handleApplicationCrash(
mApplicationObject, new ApplicationErrorReport.ParcelableCrashInfo(e));
} catch (Throwable t2) {
if (t2 instanceof DeadObjectException) {
// System process is dead; ignore
} else {
try {
Clog_e(TAG, "Error reporting crash", t2);
} catch (Throwable t3) {
// Even Clog_e() fails! Oh well.
}
}
} finally {
// Try everything to make sure this process goes away.
Process.killProcess(Process.myPid());
System.exit(10);
}
}


private void ensureLogging(Thread t, Throwable e) {
if (!mLoggingHandler.mTriggered) {
try {
mLoggingHandler.uncaughtException(t, e);
} catch (Throwable loggingThrowable) {
// Ignored.
}
}
}
}

看到了吗!异常的产生导致的crash,真正的源头就是在此了!


捕获crash


通过对前文的阅读,我们了解到了crash的源头就是KillApplicationHandler,因为它默认处理就是杀死app,此时我们也注意到,它是继承于UncaughtExceptionHandler的。当然,有异常及时抛出解决,是一件好事,但是我们也可能有一些异常,比如android系统sdk的问题,或者其他没那么重要的异常,直接崩溃app,这个处理就不是那么好了。但是不要紧,java虚拟机开发者也肯定注意到了这点,所以提供


Thread.java

public static void setDefaultUncaughtExceptionHandler(UncaughtExceptionHandler eh)

方式,导入一个我们自定义的实现了UncaughtExceptionHandler接口的类


public interface UncaughtExceptionHandler {
/**
* Method invoked when the given thread terminates due to the
* given uncaught exception.
* <p>Any exception thrown by this method will be ignored by the
* Java Virtual Machine.
* @param t the thread
* @param e the exception
*/
void uncaughtException(Thread t, Throwable e);
}

此时我们只需要写一个类,模仿KillApplicationHandler一样,就能写出一个自己的异常处理类,去处理我们程序中的异常(或者Android系统中特定版本的异常)。例子demo比如


class MyExceptionHandler:Thread.UncaughtExceptionHandler {
override fun uncaughtException(t: Thread, e: Throwable) {
// 做自己的逻辑
Log.i("hello",e.toString())
}
}

总结


到这里,我们能够了解到了一个java crash是怎么产生的了,同时我们也了解到了常用的UncaughtExceptionHandler为什么可以拦截一些我们不希望产生crash的异常,在接下来的android性能优化系列中,会持续带来相关的其他分享,感谢观看


作者:Pika
链接:https://juejin.cn/post/7144659801660719111
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

从《羊了个羊》看随机数的生成原理

你的《羊了个羊》第二关通关了吗? 作为一款三消类的休闲小游戏,《羊了个羊》虽然在玩法上并没有多大创新,但却以其相邻关卡间巨大的游戏难度落差成功出圈。讨论度提高的同时,也招致了一些批评的声音,主要是指责《羊了个羊》毫无游戏性可言,罪状无一例外都提到同一个词——随...
继续阅读 »

你的《羊了个羊》第二关通关了吗?


作为一款三消类的休闲小游戏,《羊了个羊》虽然在玩法上并没有多大创新,但却以其相邻关卡间巨大的游戏难度落差成功出圈。讨论度提高的同时,也招致了一些批评的声音,主要是指责《羊了个羊》毫无游戏性可言,罪状无一例外都提到同一个词——随机性


简单讲就是,三消类的游戏虽然看起来是一堆混乱无序的元素,但大体遵循一些默认的游戏规则,比如每种元素的数量以及所有元素的总数量一定数字3的倍数,以保证所有的元素最终都能配对消除并获胜,只是每种元素出现的时机是随机的而已。


但《羊了个羊》偏偏“不讲武德”,它让每种元素出现的概率都是随机的,也就是批评者口中的“真随机性”,这样导致的结果就是,你分配到的牌局可能从一开始就是个死局,等你玩到最后才发现根本无法完全消除。


最后的无解情况.png


今天我们不讨论《羊了个羊》“武德”问题,但既然提到了游戏中的随机性问题,那我们就想站在程序的角度来好好说道说道了~




大家好,我是玩羊玩到晚上要数羊睡觉的椎锋陷陈,今天我们要分享的主题是随机数的生成原理


可能有读者要产生疑惑了,我们讨论的不是游戏中的随机性问题吗,怎么变成了随机数的生成原理了?这是因为,随机数本身就是随机性所产生的结果,又是反过来指导游戏行为的依据,比如《羊了个羊》中每回合出现的元素种类,所以引申出来讨论随机数的生成原理并不生硬。


这里我们首先要探究的一个问题就是,游戏中产生的随机数,是真的随机数吗?很遗憾,并不是,这里面大部分产生的都是伪随机数


伪随机数是什么?


计算机是确定性的,这意味着其产生的所有行为,都是由其预先编写的所有指令所决定的,仅依赖一个确定性的事物,是无法产生一个随机性的结果的。


随机数.png


我们拿到的所谓随机数,只是看起来随机而已,也就是说,只是符合统计学上对于随机性认定的要求,但随机数的产生过程却是确定的


什么意思呢?


首先,伪随机数生成器内部制定了一个算法,本质上就是一个数学公式。公式所得到的随机数集是一个序列,序列中的每一个随机数,都是由前一个随机数代入相同的公式计算得出的


而序列的起始值,我们称之为种子数,决定了整个随机数序列的所有数值。


所以,理论上,我们只要知道伪随机数生成器的种子数和内部算法,就可以推演出整个随机数序列。因此,伪随机数生成器是不安全的,不能用于安全系数要求高的场合,比如登录时默认的随机密码生成,但对于《羊了个羊》这一类的休闲小游戏来讲还是没啥问题的。


那么,伪随机数生成器都有哪些算法呢?


伪随机数生成器算法


平方取中法


平方取中法是由冯·诺伊曼提出的一种产生均匀伪随机数的算法,算法的原理很简单,首先选定一个种子数,假设是一个四位数字,比如4321。


接着,正如算法的名字所表述,先对种子数进行平方4321^2=18671041,再取中间四位数字18[6710]41,从而得到序列下一项6710。


如果平方后不足八位,则在数字的前面填充0直至满八位,如241^2=58081=00[0580]81=0580。


随后重复这个过程,就能持续生成多个位于0到9999之间的随机数。


线性同余生成器


不过,这显然不能满足我们需要生成伪随机数的多数场景。目前生成伪随机数的最佳算法,是一种叫做马特赛特旋转演算法的现代算法,其基于更简单的线性同余生成器,一种常见的伪随机数生成器。


线性同余生成器要求有4个输入,除了固定要求的种子数之外,还有模数m、乘数a以及增量c


计算方式是种子数乘以a再加上c,然后把计算结果对m进行求模,也即除以m并取余数。


线性同余生成器.png


随后重复这个过程,就可以得到余下的随机数序列,得到的随机数将位于0到m-1之间。


算法过程我们了解了,但线性同余生成器又是凭借什么优势,在伪随机数生成这方面更受青睐的呢?


线性同余生成器的优势


其实,无论是哪一种伪随机数生成器算法,除了前面所提到的安全性问题之外,还有一个相同的天然缺陷,那就是其生成的随机数序列,无论长短,最终都会重复出现,即形成一个循环。


因此就有了周期的说法,所谓周期,指的就是在两次循环之间出现的不同随机数项的数目


原因我们前面已经讲了:恒定的计算公式,以及依赖于前一个随机数。


我贴一部知名恐怖电影的海报你们就懂了:


恐怖游轮.png


而线性同余生成器的优势在于,其周期的长度是m-1,即取决于模数m,只要保证m的取值尽量大,比如2的32次方,就能极大地延长随机数重复的周期,但也只是延长,本质上仍无法避免。


那么,真的就无解了吗?既然有伪随机数的说法,那有没有真随机数呢?


还是有的。


真随机数怎么得到?


既然从内部无法自我解决,那就寻求外部的帮助吧,也即接受一个我们认为是随机性的外部事物的输入作为种子数,从而使得经过计算机处理之后的结果也是随机的,这个外部事物就是——自然界的噪声。


这个噪声是物理学上的含义,指的是一切不规则的信号,而不一定是声音。


比如RANDOM.ORG这个网站,就是以大气噪声,也即自然界雷暴活动所产生的电磁辐射作为随机性的外部事物的输入,借此提供各项服务以满足各种各样需要生成真随机数的场景。


闪电击中广州塔.png


既然需要外部事物的输入,那也就意味着需要额外的硬件设备支持,以收集和测量随机的物理现象或普通事件。但也不用把它想象的过于高大上,诸如鼠标、键盘的点击都可以作为随机事件的种子数。


另一方面,由于搜集外部的数据需要时间,也导致了真随机数生成器的另外一个缺点——不够快。以及,由于随机性的外部事物的输入很难重现,也将导致我们无法复现随机数生成过程,测试流程常常无法正常进行。


不过,己之缺点即是彼之优点,对于伪随机数生成器来说,不需要外部设备支持、计算效率高、可复现则是其明显的优势


好了,这个就是今天要分享的内容。


总结一下,《羊了个羊》每种元素的随机生成使用的仍然是伪随机数生成器,因此说它“真随机性”其实并不太准确。


而其宣传所谓的通关率不到0.1%,与游戏难度本身关系不大,你无法通过大概率是你刚好被分配到的牌局没有达到三消类游戏通关的基本要求。


最后,祝你是那0.1%的幸运儿,游戏如是,生活也如是。


通关截图.png


作者:椎锋陷陈
链接:https://juejin.cn/post/7144657168891920391
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Android常用多线程解析(一)线程的使用

 上图是Android中多线程实现的主要方式,和线程的控制流程。1.最基础的方式就是在需要的时候new一个Thread,但是这种方式不利于线程的管理,容易引起内存泄漏。 试想一下,你在Activity中new一个Thread去处理耗时任务,并且在任务...
继续阅读 »
image.png 上图是Android中多线程实现的主要方式,和线程的控制流程。

1.最基础的方式就是在需要的时候new一个Thread,但是这种方式不利于线程的管理,容易引起内存泄漏。 试想一下,你在Activity中new一个Thread去处理耗时任务,并且在任务结束后通过Handler切换到UI线程上去操作UI。这时候你的Activity已经被销毁,因为Thread还在运行,所以他并不会被销毁,此外Thread中还持有Handler的引用,这时候必将会引发内存泄漏和crash。

newThread:可复写Thread#run方法,也可以传递Runnable对象 缺点:缺乏统一管理,线程无法复用,线程间会引起竞争,可能占用过多系统资源导致死机或oom。

Thread的两种写法
class ThreadRunable : Thread() {
override fun run() {
Thread.sleep(10000)
}
}

fun testThread(){
Thread{
Thread.sleep(10000)
}.start()
ThreadRunable().start()
}

2.在Android中我们也会使用AsyncTask来构建自己的异步任务。但是在Android中所有的AsyncTask都是共用一个核心线程数为1的线程池,也就是说如果你多次调用AsyncTask.execute方法后,你的任务需要等到前面的任务完成后才会执行。Android已经不建议使用AsyncTask了

@Deprecated
public static final Executor SERIAL_EXECUTOR = new SerialExecutor();

private static class SerialExecutor implements Executor {
final ArrayDeque<Runnable> mTasks = new ArrayDeque<Runnable>();
Runnable mActive;

public synchronized void execute(final Runnable r) {
mTasks.offer(new Runnable() {
public void run() {
try {
r.run();
} finally {
scheduleNext();
}
}
});
if (mActive == null) {
scheduleNext();
}
}

protected synchronized void scheduleNext() {
if ((mActive = mTasks.poll()) != null) {
THREAD_POOL_EXECUTOR.execute(mActive);
}
}
}


使用AsyncTask的方式有以下几种

2.1.继承AsyncTask<String, Int, String>()并且复写他的三个方法

class MyAsyncTask : AsyncTask<String, Int, String>(){
//线程池中调用该方法,异步任务的代码运行在这个方法中,参数代表运行异步任务传递的参数,通过AsyncTask.execute方法传递
override fun doInBackground(vararg params: String?): String {
Log.e(TAG, Thread.currentThread().name)
for(progress in 0..100){
//传递任务进度
publishProgress(progress)
}
return "success"
}
//运行在UI线程上 参数代表异步任务传递过来的进度
override fun onProgressUpdate(vararg values: Int?) {
Log.e(TAG, "progress ${values}, Thread ${Thread.currentThread().name}")
}

//异步任务结束,运行在UI线程上 参数代表异步任务运行的结果
override fun onPostExecute(result: String?) {
Log.e(TAG, "result ${result}, Thread ${Thread.currentThread().name}")
}

}
MyAsyncTask().execute("123")

2.2.直接调用execute方法传递Runable对象

for(i in 0..10){
AsyncTask.execute(Runnable {
Log.e(TAG, "for invoke: ${Thread.currentThread().name} time ${System.currentTimeMillis()}" )
Thread.sleep(10000)
})
}

2.3.直接向线程池添加任务

/**
* 并发执行任务
*/

for(i in 0..10){
AsyncTask.THREAD_POOL_EXECUTOR.execute( Runnable {
Log.e(TAG, "for invoke: ${Thread.currentThread().name} time ${System.currentTimeMillis()}" )
})
}

2.4.其中第三种是并行执行,使用是AsyncTask内部的线程池

@Deprecated
public static final Executor THREAD_POOL_EXECUTOR;

static {
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE_SECONDS, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>(), sThreadFactory);
threadPoolExecutor.setRejectedExecutionHandler(sRunOnSerialPolicy);
THREAD_POOL_EXECUTOR = threadPoolExecutor;
}

3.使用HandlerThread

HandlerThread在内部维护一个loop遍历子线程的消息,允许你向子线程中发送任务

使用方法如下,我们需要先构建HandlerThread实例,并调用start方法,启动内部的loop。

之后需要创建Handler并且将HandlerThread的loop传递进去,在内部实现handleMessage方法处理任务。

fun handlerThread(){
val handlerThread = HandlerThread("Handler__Thread")
handlerThread.start()
//传递的loop是ThreadHandler
val handler = object : Handler(handlerThread.looper){
override fun handleMessage(msg: Message) {
Log.e(TAG, "handlerThread ${Thread.currentThread().name}")
}
}
handler.sendEmptyMessage(1)
}

4.使用IntentService执行完任务后自动销毁,适用于一次性任务(已经被弃用)推荐使用workManager。


/**
* 任务执行完成后自动销毁,适用于一次性任务
*/

class MyIntentService : IntentService("MyIntentService"){
override fun onHandleIntent(intent: Intent?) {
Log.e("MyIntentService", "Thread ${Thread.currentThread().name}")
}

}

5.使用线程池。

线程池是我们最常用的控制线程的方式,他可以集中管理你的线程,复用线程,避免过多开辟新线程造成的内存不足。 线程池的详细解析将在下一章中描述

public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler
)

上面是线程池的构造函数

corePoolSize是线程池的核心线程数,这些线程不会被回收。

maximumPoolSize是线程池最大线程数,线程池分为核心线程和非核心线程,两者之和为maximumPoolSize。非核心线程将会在任务执行完后一段时间被释放。

keepAliveTime非核心线程存在的时间。

unit 非核心线程存在的时间单位

workQueue任务队列,在核心线程都在处理任务的时候会将任务存放在任务队列中。只有当任务队列存放满后,才会启动非核心线程。

threadFactory构建线程的工程,一般会在其中处理一些线程启动前的操作。

handler拒绝策略,线程池被关闭的时候(调用shutdonw),线程池线程数等于maximumPoolSize,任务队列已满的时候会被调用。

主要方法

void execute(Runnable run)//提交任务,交由线程池调度

void shutdown()//关闭线程池,等待任务执行完成

void shutdownNow()//关闭线程池,不等待任务执行完成

int getTaskCount()//返回线程池找中所有任务的数量  (已完成的任务+阻塞队列中的任务)

int getCompletedTaskCount()//返回线程池中已执行完成的任务数量  (已完成的任务)

int getPoolSize()//返回线程池中已创建线程数量

int getActiveCount()//返回当前正在运行的线程数量

void terminated() 线程池终止时执行的策略

线程池还有几种内置的方式。

5.1.newFixedThreadPool 创建固定线程数的线程池。核心线程数和最大核心线程数相等为入参。

这种方式创建的线程池,因为使用的是无界LinkedBlockingQueue队列,不加控制的话会引起内存溢出

创建固定线程数的线程池
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}

5.2.newSingleThreadExecutor 创建一个核心线程数和最大线程数为1的线程池,使用的也是LinkedBlockingQueue。 也会引发内存问题

public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}

5.3.newCachedThreadPool 创建一个无核心线程,最大线程数无限大的线程池。因为使用的是SynchronousQueue队列,不会存储任务,每提交一个任务就会创建一个新的线程使用。当任务足够多的情况下也会引起内存溢出。

public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}

上述三种方式,其实都不建议。使用线程池应该根据使用的场景,合理的安排核心线程和非核心线程。

收起阅读 »

Twitter 上有趣的代码

全文分为 视频版 和 文字版, 文字版: 文字侧重细节和深度,有些知识点,视频不好表达,文字描述的更加准确 视频版: 视频以动画的形式会更加的直观,看完文字版,在看视频,知识点会更加清楚,Twitter 上有趣的代码_哔哩哔哩_bilibili 这是海外一...
继续阅读 »

全文分为 视频版文字版



  • 文字版: 文字侧重细节和深度,有些知识点,视频不好表达,文字描述的更加准确

  • 视频版: 视频以动画的形式会更加的直观,看完文字版,在看视频,知识点会更加清楚,Twitter 上有趣的代码_哔哩哔哩_bilibili


这是海外一位 Kotlin GDE 大佬,在 Twitter 上分享的一段代码,我觉得非常的有意思,代码如下所示,我们花 10s 思考一下,输出结果是什么。


fun printE() = { println("E") }

fun main() {
if (true) println("A")
if (true) { println("B") }
if (true) {
{ println("C") }
}

{ println("D") }

printE()

when {
true -> { println("F") }
}
}

在 Twitter 评论区中也能看到很多不同的答案。


pic02


实际上最后输出结果如下所示。


A
B
F

不知道你第一次看到这么多混乱的花括是什么感觉,当我第一次看到这段代码的时候,我觉得非常的有意思。


如果在实际项目中有小伙伴这么嵌套花括号,我相信肯定会被拉出去暴晒。但是细心观察这段代码,我们能学习到很多 Kotlin 相关的知识点,我们先来说一下为什么最后输出的结果是 A B F


下面图中红色标注部分,if 表达式、 when ... case 表达,如果表达式内只有一行代码的话,花括号是可以省略的,程序执行到代码位置会输出对应的结果, 即 A B F



那为什么 C D E 没有打印,因为图中绿色部分是 lambda 表达式,在 Kotlin 中 lambda 表达式非常的自由,它可以出现在很多地方比如方法内、 if 表达式内、循环语句内、甚至赋值给一个变量、或者当做方法参数进行传递等等。


lambda 表达式用花括号包裹起来,用箭头把实参列表和 lambda 函数体分离开来,如下所示。


{ x: Int -> println("lambda 函数体") }

如果没有参数,上面的代码可以简写成下面这样。


{ println("lambda 函数体") }

C D E 的输出语句在 lambda 函数体内, lambda 表达式我们可以理解为高阶函数,在上面的代码中只是声明了这个函数,但是并没有调用它,因此不会执行,自然也就不会有任何输出。现在我将上面的代码做一点点修改,在花 10s 思考一下输出结果是什么。


fun printE() = { println("E") }

fun main() {
if (true) println("A")
if (true) { println("B") }
if (true) {
{ println("C") }()
}

{ println("D") }()

printE()()

when {
true -> { println("F") }
}
}

最后的输出结果是:


A
B
C
D
E
F

应该有小伙伴发现了我做了那些修改,我只是在 lambda 表达式后面加了一个 (),表示执行当前的 lambda 表达式,所以我们能看到对应的输出结果。如下图所示,



lambda 表达式最终会编译成 FunctionN 函数,如下图所示。



如果没有参数会编译成 Function0,一个参数编译成 Function1,以此类推。FunctionN 重载了操作符 invoke。如下图所示。



因此我们可以调用 invoke 方法来执行 lambda 表达式。


{ println("lambda 函数体") }.invoke()
复制代码

当然 Kotlin 也提供了更加简洁的方式,我们可以使用 () 来代替 invoke(),最后的代码如下所示。


{ println("lambda 函数体") }()
复制代码

到这里我相信小伙伴已经明白了上面代码输出的结果,但是这里隐藏了一个有性能损耗的风险点,分享一段我在实际项目中见到的代码,示例中的代码,我做了简化。


fun main() {

(1..10).forEach { value ->
calculate(value) { result ->
println(result)
}
}

}


fun calculate(x: Int, lambda: (result: Int) -> Unit) {
lambda(x + 10)
}

上面的代码其实存在一个比较严重的性能问题,我们看一下反编译后的代码。



每次在循环中都会创建一个 FunctionN 的对象,那么如何避免这个问题,我们可以将 lambda 表达式放在循环之外,这样就能保证只会创建一个 FunctionN 对象,我们来看一下修改后的代码。


fun main() {
val lambda: (result: Int) -> Unit = { result ->
println(result)
}

(1..10).forEach { value ->
calculate(value, lambda)
}

}


作者:程序员DHL
链接:https://juejin.cn/post/7145275953814437925
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Android登录拦截的场景-面向切面基于AOP实现

AOP
前言 场景如下:用户第一次下载App,点击进入首页列表,点击个人页面,需要校验登录,然后跳转到登录页面,注册/登录完成跳转到个人页面。 非常常见的场景,正常我们开发就只能判断是否已经登录,如果未登录就跳转到登录,然后登录完成之后怎么继续执行?如何封装?有哪些方...
继续阅读 »

前言


场景如下:用户第一次下载App,点击进入首页列表,点击个人页面,需要校验登录,然后跳转到登录页面,注册/登录完成跳转到个人页面。


非常常见的场景,正常我们开发就只能判断是否已经登录,如果未登录就跳转到登录,然后登录完成之后怎么继续执行?如何封装?有哪些方式?其实很多人并不清楚。


这里做一个系列总结一下,看看公共有多少种方式实现,你们使用的是哪一种方案,或者说你们觉得哪一种方案最好用。


这一次分享的是全网最多的方案 ,面向切面 AOP 的方式。你去某度一搜,Android拦截登录 最多的结果就是AOP实现登录拦截的功能,既然大家都推荐,我们就来看看它到底如何?


一、了解面向切面AOP


我们学习Java的开始,我们一直就知道 OOP 面向对象,其实 AOP 面向切面,是对OOP的一个补充,AOP采取横向收取机制,取代了传统纵向继承体系重复性代码,把某一类问题集中在一个地方进行处理,比如处理程序中的点击事件、打印日志等。


AOP是编程思想就是把业务逻辑和横切问题进行分离,从而达到解耦的目的,提高代码的重用性和开发效率。OOP的精髓是把功能或问题模块化,每个模块处理自己的家务事。但在现实世界中,并不是所有功能都能完美得划分到模块中。AOP的目标是把这些功能集中起来,放到一个统一的地方来控制和管理。


我记得我最开始接触 AOP 还是在JavaEE的框架SSH的学习中,AspectJ框架,开始流行于后端,现在在Android开发的应用中也越来越广泛了,Android中使用AspectJ框架的应用也有很多,比如点击事件防抖,埋点,权限申请等等不一而足,这里不展开说明,毕竟我们这一期不是专门讲AspectJ的应用的。


简单的说一下AOP的重点概念(摘抄):




  • 前置通知(Before):在目标方法被调用之前调用通知功能。




  • 后置通知(After):在目标方法完成之后调用通知,此时不会关心方法的输出是什么。




  • 返回通知(After-returning):在目标方法成功执行之后调用通知。




  • 异常通知(After-throwing):在目标方法抛出异常后调用通知。




  • 环绕通知(Around):通知包裹了被通知的方法,在被通知的方法调用之前和调用之后执行自定义的行为。




  • 连接点:是在应用执行过程中能够插入切面的一个点。




  • 切点: 切点定义了切面在何处要织入的一个或者多个连接点。




  • 切面:是通知和切点的结合。通知和切点共同定义了切面的全部内容。




  • 引入:引入允许我们向现有类添加新方法或属性。




  • 织入:是把切面应用到目标对象,并创建新的代理对象的过程。切面在指定的连接点被织入到目标对象中。在目标对象的生命周期中有多个点可以进行织入:




  • 编译期: 在目标类编译时,切面被织入。这种方式需要特殊的编译器。AspectJ的织入编译器就是以这种方式织入切面的。




  • 类加载期:切面在目标加载到JVM时被织入。这种方式需要特殊的类加载器(class loader)它可以在目标类被引入应用之前增强该目标类的字节码。




  • 运行期: 切面在应用运行到某个时刻时被织入。一般情况下,在织入切面时,AOP容器会为目标对象动态地创建一个代理对象。SpringAOP就是以这种方式织入切面的。




简单理解就是把一个方法拿出来,在这个方法执行前,执行后,做一些特别的操作。关于AOP的基本使用推荐大家看看大佬的教程:


深入理解Android之AOP


不多BB,我们直接看看Android中如何使用AspectJ实现AOP逻辑,实现拦截登录的功能。


二、集成AOP框架


Java项目集成


buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath 'org.aspectj:aspectjtools:1.8.9'
classpath 'org.aspectj:aspectjweaver:1.8.9'
}
}

组件build.gradle


dependencies {
implementation 'org.aspectj:aspectjrt:1.9.6'
}


import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main

// 获取log打印工具和构建配置
final def log = project.logger
final def variants = project.android.applicationVariants
variants.all { variant ->
if (!variant.buildType.isDebuggable()) {
// 判断是否debug,如果打release把return去掉就可以
log.debug("Skipping non-debuggable build type '${variant.buildType.name}'.")
// return;
}
// 使aspectj配置生效
JavaCompile javaCompile = variant.javaCompile
javaCompile.doLast {
String[] args = ["-showWeaveInfo",
"-1.8",
"-inpath", javaCompile.destinationDir.toString(),
"-aspectpath", javaCompile.classpath.asPath,
"-d", javaCompile.destinationDir.toString(),
"-classpath", javaCompile.classpath.asPath,
"-bootclasspath", project.android.bootClasspath.join(File.pathSeparator)]
log.debug "ajc args: " + Arrays.toString(args)

MessageHandler handler = new MessageHandler(true);
new Main().run(args, handler);
//在编译时打印信息如警告、error等等
for (IMessage message : handler.getMessages(null, true)) {
switch (message.getKind()) {
case IMessage.ABORT:
case IMessage.ERROR:
case IMessage.FAIL:
log.error message.message, message.thrown
break;
case IMessage.WARNING:
log.warn message.message, message.thrown
break;
case IMessage.INFO:
log.info message.message, message.thrown
break;
case IMessage.DEBUG:
log.debug message.message, message.thrown
break;
}
}
}
}

Kotlin项目集成


dependencies {
classpath 'com.android.tools.build:gradle:3.6.1'

classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"

classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.10'

项目build.gradle


apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'

apply plugin: 'android-aspectjx'

android {
...

// AOP 配置
aspectjx {
// 排除一些第三方库的包名(Gson、 LeakCanary 和 AOP 有冲突)
exclude 'androidx', 'com.google', 'com.squareup', 'com.alipay', 'com.taobao',
'org.apache',
'org.jetbrains.kotlin',
"module-info", 'versions.9'
}

}

ependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'org.aspectj:aspectjrt:1.9.5'
}

集成AOP踩坑:
zip file is empty



和第三方包有冲突,比如Gson,OkHttp等,需要配置排除一下第三方包,



gradle版本兼容问题



AGP版本4.0以上不支持 推荐使用3.6.1



kotlin兼容问题 :



基本都是推荐使用 com.hujiang.aspectjx



编译版本兼容问题:



4.0以上使用KT编译版本为Java11需要改为Java8



组件化兼容问题:



如果在library的moudle中自定义的注解, 想要通过AspectJ来拦截织入, 那么这个@Aspect类必须和自定义的注解在同一moudle中, 否则是没有效果的



等等...


难点就在集成,如何在指定版本的Gradle,Kotlin项目中集成成功。只要集成成功了,使用到是简单了。


三、定义注解实现功能


定义标记的注解


//不需要回调的处理
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}

定义处理类


@Aspect
public class LoginAspect {

@Pointcut("@annotation(com.guadou.kt_demo.demo.demo3_bottomtabbar_fragment.aop.Login)")
public void Login() {
}

//不带回调的注解处理
@Around("Login()")
public void loginJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
YYLogUtils.w("走进AOP方法-Login()");
Signature signature = joinPoint.getSignature();

if (!(signature instanceof MethodSignature)){
throw new RuntimeException("该注解只能用于方法上");
}

Login login = ((MethodSignature) signature).getMethod().getAnnotation(Login.class);
if (login == null) return;

//判断当前是否已经登录
if (LoginManager.isLogin()) {
joinPoint.proceed();
} else {
//如果未登录,去登录页面
LoginManager.gotoLoginPage();
}


object LoginManager {

@JvmStatic
fun isLogin(): Boolean {
val token = SP().getString(Constants.KEY_TOKEN, "")
YYLogUtils.w("LoginManager-token:$token")
val checkEmpty = token.checkEmpty()
return !checkEmpty
}

@JvmStatic
fun gotoLoginPage() {
commContext().gotoActivity<LoginDemoActivity>()
}
}

其实逻辑很简单,就是判断是否登录,看是放行还是跳转到登录页面


使用的逻辑也是很简单,把需要处理的逻辑使用方法抽取,并标记注解即可


    override fun init() {

mBtnCleanToken.click {
SP().remove(Constants.KEY_TOKEN)
toast("清除成功")
}

mBtnProfile.click {

//不带回调的登录方式
gotoProfilePage2()
}

}

@Login
private fun gotoProfilePage2() {
gotoActivity<ProfileDemoActivity>()
}

效果:



这..这和我使用Token自己手动判断有什么区别,完成登录之后还得我再点一次按钮,当然了这只是登录拦截,我想要的是登录成功之后继续之前的操作,怎么办?


其实使用AOP的方式的话,我们可以使用消息通知的方式,比如LiveBus FlowBus之类的间接实现这个效果。


我们先单独的定义一个注解


//需要回调的处理用来触发用户登录成功后的后续操作
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginCallback {
}

修改定义的切面类


@Aspect
public class LoginAspect {

@Pointcut("@annotation(com.guadou.kt_demo.demo.demo3_bottomtabbar_fragment.aop.Login)")
public void Login() {
}

@Pointcut("@annotation(com.guadou.kt_demo.demo.demo3_bottomtabbar_fragment.aop.LoginCallback)")
public void LoginCallback() {
}

//带回调的注解处理
@Around("LoginCallback()")
public void loginCallbackJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
YYLogUtils.w("走进AOP方法-LoginCallback()");
Signature signature = joinPoint.getSignature();

if (!(signature instanceof MethodSignature)){
throw new RuntimeException("该注解只能用于方法上");
}

LoginCallback loginCallback = ((MethodSignature) signature).getMethod().getAnnotation(LoginCallback.class);
if (loginCallback == null) return;

//判断当前是否已经登录
if (LoginManager.isLogin()) {
joinPoint.proceed();

} else {
LifecycleOwner lifecycleOwner = (LifecycleOwner) joinPoint.getTarget();

LiveEventBus.get("login").observe(lifecycleOwner, new Observer<Object>() {
@Override
public void onChanged(Object integer) {
try {
joinPoint.proceed();
LiveEventBus.get("login").removeObserver(this);

} catch (Throwable throwable) {
throwable.printStackTrace();
LiveEventBus.get("login").removeObserver(this);
}
}
});

LoginManager.gotoLoginPage();
}
}

//不带回调的注解处理
@Around("Login()")
public void loginJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
YYLogUtils.w("走进AOP方法-Login()");
Signature signature = joinPoint.getSignature();

if (!(signature instanceof MethodSignature)){
throw new RuntimeException("该注解只能用于方法上");
}

Login login = ((MethodSignature) signature).getMethod().getAnnotation(Login.class);
if (login == null) return;

//判断当前是否已经登录
if (LoginManager.isLogin()) {
joinPoint.proceed();
} else {
//如果未登录,去登录页面
LoginManager.gotoLoginPage();
}


}
}

在去登录页面之前注册一个LiveEventBus事件,当登录完成之后发出通知,这里就直接放行调用注解的方法。即可完成继续执行的操作。


使用:


    override fun init() {

mBtnCleanToken.click {
SP().remove(Constants.KEY_TOKEN)
toast("清除成功")
}

mBtnProfile.click {

//不带回调的登录方式
gotoProfilePage()
}

}

@LoginCallback
private fun gotoProfilePage() {
gotoActivity<ProfileDemoActivity>()
}

效果:



总结


从上面的代码我们就基于AOP思想实现了登录拦截功能,以后我们对于需要用户登录之后才能使用的功能只需要在对应的方法上添加指定的注解即可完成逻辑,彻底摆脱传统耗时耗力的开发方式。


需要注意的是AOP框架虽然使用起来很方便,能帮我们轻松完成函数插桩功能,但是它也有自己的缺点。AspectJ 在实现时会包装自己的一些类,不仅会影响切点方法的性能,还会导致安装包体积的增大。最关键的是对Kotlin不友好,对高版本AGP不友好,所以大家在使用时需要仔细权衡是否适合自己的项目。如有需求可以运行源码查看效果。源码在此


由于篇幅原因,后期会出单独出一些其他方式实现的登录拦截的实现,如果觉得这种方式不喜欢,大家可以对比一下哪一种方式比较你胃口。大家可以点下关注看看最新更新。


题外话:
我发现大家看我的文章都喜欢看一些总结性的,实战性的,直接告诉你怎么用的那种。所以我尽量不涉及到集成原理与基本使用,直接开箱即用。当然关于原理和基本的使用,我也不是什么大佬,我想大家也看不上我讲的。不过我也会给出推荐的文章。


作者:newki
链接:https://juejin.cn/post/7132643283083198501
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

定位都得集成第三方?Android原生定位服务LocationManager不行吗?

前言 现在的应用,几乎每一个 App 都存在定位的逻辑,方便更好的推荐产品或服务,获取当前设备的经纬度是必备的功能了。有些 App 还是以LBS(基于位置服务)为基础来实现的,比如美团,饿了吗,不获取到位置都无法使用的。 有些同学觉得不就是获取到经纬度么,An...
继续阅读 »

前言


现在的应用,几乎每一个 App 都存在定位的逻辑,方便更好的推荐产品或服务,获取当前设备的经纬度是必备的功能了。有些 App 还是以LBS(基于位置服务)为基础来实现的,比如美团,饿了吗,不获取到位置都无法使用的。


有些同学觉得不就是获取到经纬度么,Android 自带的就有位置服务 LocationManager ,我们无需引入第三方服务,就可以很方便的实现定位逻辑。


确实 LocationManager 的使用很简单,获取经纬度很方便,我们就无需第三方的服务了吗? 或者说 LocationManager 有没有坑呢?兼容性问题怎么样?获取不到位置有没有什么兜底策略?


一、LocationManager的使用


由于是Android的系统服务,直接 getSystemService 可以获取到


LocationManager locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
复制代码

一般获取位置有两种方式 NetWork 与 GPS 。我们可以指定方式,也可以让系统自动提供最好的方式。


// 获取所有可用的位置提供器
List<String> providerList = locationManager.getProviders(true);
// 可以指定优先GPS,再次网络定位
if (providerList.contains(LocationManager.GPS_PROVIDER)) {
provider = LocationManager.GPS_PROVIDER;
} else if (providerList.contains(LocationManager.NETWORK_PROVIDER)) {
provider = LocationManager.NETWORK_PROVIDER;
} else {
// 当没有可用的位置提供器时,弹出Toast提示用户
return;
}
复制代码

当然我更推荐由系统提供,当我的设备在室内的时候就会以网络的定位提供,当设备在室外的时候就可以提供GPS定位。


 String provider = locationManager.getBestProvider(criteria, true);
复制代码

我们可以实现一个定位的Service实现这个逻辑


/**
* 获取定位服务
*/
public class LocationService extends Service {

private LocationManager lm;
private MyLocationListener listener;

@Override
public IBinder onBind(Intent intent) {
return null;
}

@SuppressLint("MissingPermission")
@Override
public void onCreate() {
super.onCreate();

lm = (LocationManager) getSystemService(LOCATION_SERVICE);
listener = new MyLocationListener();

Criteria criteria = new Criteria();
criteria.setAccuracy(Criteria.ACCURACY_COARSE);
criteria.setAltitudeRequired(false);//不要求海拔
criteria.setBearingRequired(false);//不要求方位
criteria.setCostAllowed(true);//允许有花费
criteria.setPowerRequirement(Criteria.POWER_LOW);//低功耗

String provider = lm.getBestProvider(criteria, true);

YYLogUtils.w("定位的provider:" + provider);

Location location = lm.getLastKnownLocation(provider);

YYLogUtils.w("" + location);

if (location != null) {
//不为空,显示地理位置经纬度
String longitude = "Longitude:" + location.getLongitude();
String latitude = "Latitude:" + location.getLatitude();

YYLogUtils.w("getLastKnownLocation:" + longitude + "-" + latitude);

stopSelf();

}

//第二个参数是间隔时间 第三个参数是间隔多少距离,这里我试过了不同的各种组合,能获取到位置就是能,不能获取就是不能
lm.requestLocationUpdates(provider, 3000, 10, listener);
}

class MyLocationListener implements LocationListener {
// 位置改变时获取经纬度
@Override
public void onLocationChanged(Location location) {

String longitude = "Longitude:" + location.getLongitude();
String latitude = "Latitude:" + location.getLatitude();

YYLogUtils.w("onLocationChanged:" + longitude + "-" + latitude);


stopSelf(); // 获取到经纬度以后,停止该service
}

// 状态改变时
@Override
public void onStatusChanged(String provider, int status, Bundle extras) {
YYLogUtils.w("onStatusChanged - provider:"+provider +" status:"+status);
}

// 提供者可以使用时
@Override
public void onProviderEnabled(String provider) {
YYLogUtils.w("GPS开启了");
}

// 提供者不可以使用时
@Override
public void onProviderDisabled(String provider) {
YYLogUtils.w("GPS关闭了");
}

}

@Override
public void onDestroy() {
super.onDestroy();
lm.removeUpdates(listener); // 停止所有的定位服务
}

}
复制代码

使用:定义并动态申请权限之后即可开启服务



fun testLocation() {

extRequestPermission(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) {

startService(Intent(mActivity, LocationService::class.java))

}

}

复制代码

这样我们启动这个服务就可以获取到当前的经纬度,只是获取一次,大家如果想再后台持续定位,那么实现的方式就不同了,我们服务要设置为前台服务,并且需要额外申请后台定位权限。


话说回来,这么使用就一定能获取到经纬度吗?有没有兼容性问题


Android 5.0 Oppo



Android 6.0 Oppo海外版



Android 7.0 华为



Android 11 三星海外版



Android 12 vivo



目前测试不多,也能发现问题,特别是一些低版本,老系统的手机就可能无法获取位置,应该是系统的问题,这种服务跟网络没关系,开不开代理都是一样的。


并且随着测试系统的变高,越来越完善,提供的最好定位方式还出现混合定位 fused 的选项。


那是不是6.0的Oppo手机太老了,不支持定位了?并不是,百度定位可以获取到位置的。



既然只使用 LocationManager 有风险,有可能无法获取到位置,那怎么办?


二、混合定位


其实目前百度,高度的定位Api的服务SDK也不算大,相比地图导航等比较重的功能,定位的SDK很小了,并且目前都支持海外的定位服务。并且定位服务是免费的哦。


既然 LocationManager 有可能获取不到位置,那我们就加入第三方定位服务,比如百度定位。我们同时使用 LocationManager 和百度定位,哪个先成功就用哪一个。(如果LocationManager可用的话,它的定位比百度定位更快的)


完整代码如下:


@SuppressLint("MissingPermission")
public class LocationService extends Service {

private LocationManager lm;
private MyLocationListener listener;
private LocationClient mBDLocationClient = null;
private MyBDLocationListener mBDLocationListener;

@Override
public IBinder onBind(Intent intent) {
return null;
}

@Override
public void onCreate() {
super.onCreate();

createNativeLocation();

createBDLocation();
}

/**
* 第三方百度定位服务
*/
private void createBDLocation() {
mBDLocationClient = new LocationClient(UIUtils.getContext());
mBDLocationListener = new MyBDLocationListener();
//声明LocationClient类
mBDLocationClient.registerLocationListener(mBDLocationListener);
//配置百度定位的选项
LocationClientOption option = new LocationClientOption();
option.setLocationMode(LocationClientOption.LocationMode.Battery_Saving);
option.setCoorType("WGS84");
option.setScanSpan(10000);
option.setIsNeedAddress(true);
option.setOpenGps(true);
option.SetIgnoreCacheException(false);
option.setWifiCacheTimeOut(5 * 60 * 1000);
option.setEnableSimulateGps(false);
mBDLocationClient.setLocOption(option);
//开启百度定位
mBDLocationClient.start();
}

/**
* 原生的定位服务
*/
private void createNativeLocation() {

lm = (LocationManager) getSystemService(LOCATION_SERVICE);
listener = new MyLocationListener();

Criteria criteria = new Criteria();
criteria.setAccuracy(Criteria.ACCURACY_COARSE);
criteria.setAltitudeRequired(false);//不要求海拔
criteria.setBearingRequired(false);//不要求方位
criteria.setCostAllowed(true);//允许有花费
criteria.setPowerRequirement(Criteria.POWER_LOW);//低功耗

String provider = lm.getBestProvider(criteria, true);

YYLogUtils.w("定位的provider:" + provider);

Location location = lm.getLastKnownLocation(provider);

YYLogUtils.w("" + location);

if (location != null) {
//不为空,显示地理位置经纬度
String longitude = "Longitude:" + location.getLongitude();
String latitude = "Latitude:" + location.getLatitude();

YYLogUtils.w("getLastKnownLocation:" + longitude + "-" + latitude);

stopSelf();

}

lm.requestLocationUpdates(provider, 3000, 10, listener);
}

class MyLocationListener implements LocationListener {
// 位置改变时获取经纬度
@Override
public void onLocationChanged(Location location) {

String longitude = "Longitude:" + location.getLongitude();
String latitude = "Latitude:" + location.getLatitude();

YYLogUtils.w("onLocationChanged:" + longitude + "-" + latitude);


stopSelf(); // 获取到经纬度以后,停止该service
}

// 状态改变时
@Override
public void onStatusChanged(String provider, int status, Bundle extras) {
YYLogUtils.w("onStatusChanged - provider:" + provider + " status:" + status);
}

// 提供者可以使用时
@Override
public void onProviderEnabled(String provider) {
YYLogUtils.w("GPS开启了");
}

// 提供者不可以使用时
@Override
public void onProviderDisabled(String provider) {
YYLogUtils.w("GPS关闭了");
}

}


/**
* 百度定位的监听
*/
class MyBDLocationListener extends BDAbstractLocationListener {

@Override
public void onReceiveLocation(BDLocation location) {

double latitude = location.getLatitude(); //获取纬度信息
double longitude = location.getLongitude(); //获取经度信息


YYLogUtils.w("百度的监听 latitude:" + latitude);
YYLogUtils.w("百度的监听 longitude:" + longitude);

YYLogUtils.w("onBaiduLocationChanged:" + longitude + "-" + latitude);

stopSelf(); // 获取到经纬度以后,停止该service
}
}

@Override
public void onDestroy() {
super.onDestroy();
// 停止所有的定位服务
lm.removeUpdates(listener);

mBDLocationClient.stop();
mBDLocationClient.unregisterLocationListener(mBDLocationListener);
}

}
复制代码

其实逻辑都是很简单的,并且省略了不少回调通信的逻辑,这里只涉及到定位的逻辑,别的逻辑我就尽量不涉及到。


百度定位服务的API申请与初始化请自行完善,这里只是简单的使用。并且坐标系统一为国际坐标,如果需要转gcj02的坐标系,可以网上找个工具类,或者看我之前的文章


获取到位置之后,如何Service与Activity通信,就由大家自由发挥了,有兴趣的可以看我之前的文章


总结


所以说Android原生定位服务 LocationManager 还是有问题啊,低版本的设备可能不行,高版本的Android系统又很行,兼容性有问题!让人又爱又恨。


很羡慕iOS的定位服务,真的好用,我们 Android 的定位服务真是拉跨,居然还有兼容性问题。


我们使用第三方定位服务和自己的 LocationManager 并发获取位置,这样可以增加容错率。是比较好用的,为什么要加上 LocationManager 呢?我直接单独用第三方的定位服务不香吗?可以是可以,但是如果设备支持 LocationManager 的话,它会更快一点,体验更好。


作者:newki
链接:https://juejin.cn/post/7131910145574961182
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

【Flutter 异步编程 - 壹】 | 单线程下的异步模型

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究! 一、 本专栏图示概念规范 本专栏是对 异步编程 的系统探索,会通过各个方面去认知、思考 异步编程 的概念。期间会用到一些图片进行表达与示意,在一开始先对图中的元素和 ...
继续阅读 »

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!



一、 本专栏图示概念规范


本专栏是对 异步编程 的系统探索,会通过各个方面去认知、思考 异步编程 的概念。期间会用到一些图片进行表达与示意,在一开始先对图中的元素基本概念 进行规范和说明。




1. 任务概念规范


任务 : 完成一项需求的基本单位。

分发任务: 触发任务开始的动作。

任务结束: 任务完成的标识。

任务生命期: 任务从开始到完成的时间跨度。



如下所示,方块 表示任务;当 箭头指向一个任务时,表示对该任务进行分发;任何被分发的任务都会结束。在任务分发和结束之间,有一条虚线进行连接,表示 任务生命期





2. 任务的状态


未完成 : Uncompleted

成功完成 : Completed with Success

异常结束 : Completed with Error



一个任务生命期间有三种状态,如下通过三种颜色表示。在 任务结束 之前,该任务都是 未完成 态,通过 浅蓝色 表示;任何被分发的任务都是为了完成某项需求,任何任务都会结束,在结束时刻根据是否完成需求,可分为 成功完成异常结束 两种状态,如下分别用 绿色红色 表示。





3. 时刻与时间线


机体 : 任务分发者或处理者。

时刻: 机体运行中的某一瞬间。

时间线: 所有时刻构成的连续有向轴线。



在一个机体运行的过程中,时间线是绝对的,通过 紫色有向线段 表示时间的流逝的方向。时刻 是时间线上任意一点 ,通过 黑点 表示。





4.同步与异步


同步 : 机体在时间线上,将任务按顺序依次分发。



同步执行任务时,前一个任务完成后,才会分发下一任务。意思是说: 任意时刻只有一个任务在生命期中。




异步: 机体在时间线上,在一个任务未完成时,分发另一任务。



也就是说通过异步编程,允许某时刻有两个及以上的任务在生命期中。如下所示,在 任务1 完成后,分发 任务2; 在 任务2 未结束的情况下,可以分发 任务 3 。此时对于任务 3 来说,任务 2 就是异步执行的。


image.png




二、理解单线程中的异步任务


上面对基本概念进行了规范,看起来可能比较抽象,下面我们通过一个小场景来理解一下。妈妈早上出门散步,临走前嘱咐:



小捷,别睡了。快起床,把被子晒一下,地扫一下。还有,没开水了,记得烧。



当前场景下只有小捷 一个机体,需要完成的任务有四个:起床晒被拖地烧水


image.png




1. 任务的分配

当机体有多个任务需要分发时,需要对任务进行分配。认识任务之间的关系,是任务分配的第一步。只有理清关系,才能合理分配任务。分配过程中需要注意:


[1] 任务之间可能存在明确的先后顺序,比如起床 需要在 晒被 之前。

[2] 任务之间先后顺序也可能无所谓,比如先扫地还是先晒被,并没有太大区别。

[3] 某类任务只需要机体来分发,生命期中不需要机体处理,并且和后续的任务没有什么关联性,比如烧水任务。


image.png


像烧水这种任务,即耗时,又不需要机体在任务生命期中做什么事。如果这类任务使用同步处理,那么任务期间机体能做的事只有 等待 。对于一个机体来说,这种等待就会意味着阻塞,不能处理任何事。


结合日常生活,我们知道当前场景之中,想要发挥机体最大的效力,最好的方式是起床之后,先分发 烧水任务,不需要等待烧水任务完成,就去执行晒被、扫地任务。这样的任务分配就是将 烧水 作为一个异步任务来执行的。


但在如果在分配时,将烧水作为最后一个任务,那么异步执行的价值就会消失。所以对任务的合理分配,对机体的处理效率是非常重要的。




2.异步任务特点

从上面可以看出,异步任务 有很明显的特征,并不是任何任务都有必要异步执行。特别是对于单一机体来说,任务生命期间需要机体亲自参与,是无法异步处理的。 比如一个人不能一边晒被 ,一边 扫地 。所以对于单线程来说,像一些只需要 分发任务,任务的具体执行逻辑由其他机体完成的任务,适合使用 异步 处理,来避免不必要的等待。


这种任务,在应用程序中最常见的是网络 io磁盘 io 的任务。比如,从一个网络接口中获取数据,对于机体来说,只需要分发任务来发送请求,就像烧水时只需要装水按下启动键一样。而服务器如何根据请求,查询数据库来返回响应信息,数据如何在网络中传输的,和分发任务的机体没有关系。磁盘的访问也是一样,分发读写文件任务后,真正干活的是操作系统。


像这类任务通过异步处理,可以避免在分发任务后,机体因等待任务的结束而阻塞。在等待其他机体处理的过程中,去分发其他任务,可以更好地分配时间。比如下面所示,网络数据获取 的任务分发后,需要通过网络把请求传输给服务器,服务器进行处理,给出响应结果。



整个任务处理的过程,并不需要机体参与,所以分发 网络数据获取 任务后,无需等待任务完成,接着分发 构建加载中界面 的任务,来展示加载中的界面。从而给出用户交互的反馈,而不是阻塞在那里等待网络任务完成,这就是一个非常典型的异步任务使用场景。




3. 异步任务完成与回调

前面的介绍中可以看出,异步任务在分发之后,并不会等待任务完成,在任务生命期中,可以继续分发其他任务。但任何任务都会结束,很多时候我们需要知道异步任务何时完成,以及任务的完成情况、任务返回的结果,以便该任务后续的处理。比如,在烧水完成之后,我们需要处理 冲水 的任务。


image.png


这就要涉及到一个对异步而言非常重要的概念:



回调: 任务在生命期间向机体提供通知的方式。



比如 烧水 任务完成后,烧水壶 “叮” 的一声通知任务完成;或者烧水期间发生故障,发出报警提示。这种在任务生命期间向机体发送通知的方式称为回调 。在编程中,回调一般是通过 函数参数 来实现的,所以习惯称 回调函数 。 另外,函数可以传递数据,所以通过回调函数不仅可以知道任务结束的契机,还可以通过回调参数将任务的内部数据暴露给机体。


比如在实际开发中,分发 网络数据获取 的任务,其目的是为了通过网络接口获取数据。就像烧开水任务完成之后,需要把 开水 倒入瓶中一样。我们也需要知道 网络数据获取 的任务完成的时机,将获取的数据 "倒入" 界面中进行显示。



从发送异步任务,到异步任务结束的回调触发,就是一个异步任务完整的 生命期




三、 Dart 语言中的异步


上面只是介绍了 异步模型 中的概念,这些概念是共通的,无论什么编程语言都一样适用。就像现实中,无论使用哪国的语言表述,四则运算的概念都不会有任何区别。只是在表述过程中,表现形式会在语言的语法上有所差异。




1.编程语言中与异步模型的对应关系

每种语言的描述,都是对概念模型的具象化实现。这里既然是对 Flutter 中异步编程的介绍,自然要说一下 Dart 语言对异步模型的描述。


对于 任务 概念来说,在编程中和 函数 有着千丝万缕的联系:函数体 可以实现 任务处理的具体逻辑,也可以触发 任务分发的动作 。但我并不认为两者是等价的, 任务 有着明确的 目的性 ,而 函数 是实现这种 目的 的手段。在编程活动中,函数 作为 任务 在代码中的逻辑体现,任务 应先于 函数 存在。


如下代码所示,在 main 函数中,触发 calculate 任务,计算 0 ~ count 累加值和计算耗时,并返回。其中 calculate 函数就是对该任务的代码实现:


void main(){
TaskResult result = calculate();
}


TaskResult calculate({int count = 10000000}){
int startTime = DateTime.now().millisecondsSinceEpoch;
int result = loopAdd(count);
int cost = DateTime.now().millisecondsSinceEpoch-startTime;
return TaskResult(
cost:cost,
data:result,
taskName: "calculate"
);
}

int loopAdd(int count) {
int sum = 0;
for (int i = 0; i <= count; i++) {
sum+=i;
}
return sum;
}

这里 TaskResult 类用于记录任务完成的信息:


class TaskResult {
final int cost;
final String taskName;
final dynamic data;

TaskResult({
required this.cost,
required this.data,
required this.taskName,
});

Map<String,dynamic> toJson()=>{
"taskName":taskName,
"cost":cost,
"data": data
};
}



2.Dart 编程中的异步任务

如下在计算之后,还有两个任务:saveToFile 任务,将运算结果保存到文件中;以及 render 任务将运算结果渲染到界面上。


void main() {
TaskResult result = cacaulate();
saveToFile(result);
render(result);
}

这里 render 任务暂时通过在控制台打印显示作为渲染,逻辑如下:


void render(TaskResult result) {
print("结果渲染: ${result.toJson()}");
}

下面是将结果写入文件的任务实现逻辑。其中 File 对象的 writeAsString 是一个异步方法,可以将内容写入到文件中。通过 then 方法设置回调,监听任务完成的时机。



void saveToFile(TaskResult result) {
String filePath = path.join(Directory.current.path, "out.json");
File file = File(filePath);
String content = json.encode(result);
file.writeAsString(content).then((File value){
print("写入文件成功:!${value.path}");
});
}



3.当前任务分析

如下是这三个任务的执行示意,在 saveToFile 中使用 writeAsString 方法将异步处理写入逻辑。



这样就像在烧水任务分发后,可以执行晒被一样。saveToFile 任务分发之后,不需要等待文件写入完成,可以继续执行 render 方法。日志输出如下:渲染任务的执行并不会因写入文件任务而阻塞,这就是异步处理的价值。


image.png




四、异步模型的延伸


1. 单线程异步模型的局限性

本文主要介绍 异步模型 的概念,认识异步的作用,以及 Dart 编程语言中异步方法的基本使用。至于代码中更具体的异步使用方式,将在后期文章中结合详细介绍。另外,一般情况下,Dart 是以 单线程 运行的,所以本文中强调的是 单线程 下的异步模型。


仔细思考一下,可以看出,单线程中实现异步是有局限性的。比如说需要解析一个很大的 json ,或者进行复杂的逻辑运算等 耗时任务,这种必须由 本机体 处理的逻辑,而不是 等待结果 的场景,是无法在单线程中异步处理的。


就像是 扫地晒被 任务,对于单一机体来说,不可能同时参与到两个任务之中。在实际开发中这两个任务可类比为 解析超大 json显示解析中界面 两个任务。如果前者耗时三秒,由于单线程 中同步方法的阻塞,界面就会卡住三秒,这就是单线程异步模型的 局限性




2. 多线程与异步的关系

上面问题的本质矛盾是:一个机体无法 同时 参与到两件任务 具体执行过程中。解决方案也非常简单,一个人搞不定,就摇人呗。多个机体参与任务分配的场景,就是 多线程
很多人都会讨论 异步多线程 的关系,其实很简单:两个机体,一个 扫地,一个 晒被,同一时刻,存在两个及以上的任务在生命期中,一定是异步的。毫无疑问,多线程异步模型 的一种实现方式。





3. Dart 中如何解决单线程异步模型的局限性

C++Java 这些语言有 多线程 的支持,通过 “摇人” 可以充分调度 CPU 核心,来处理一些计算密集型的任务,实现任务在时间上的最合理分配。


绝大多数人可能觉得 Dart 是一个单线程的编程语言,其实不然。可能是很多人并没有在 Flutter 端做过计算密集型的任务,没有对多线程迫切的需要。毕竟 移动/桌面客户端 大多是网络、数据库访问等 io 密集型 的任务,人手一个终端,没有什么高并发的场景。不像后端那样需要保证一个终端被百万人同时访问。


或者计算密集型的任务都有由平台机体进行处理,将结果通知给 Flutter 端。这导致 Dart 看起来更像是一个 任务分发者,发号施令的人,绝大多数时候并不需要亲自参与任务的执行过程中。而这正是单线程下的异步模型所擅长的:借他人之力,监听回调信息


其实我们在日常开发中,使用的平台相关的插件,其中的方法基本上都是异步的,本质上就是这个原因。平台 是个烧水壶,烧水任务只需要分发监听回调。至于水怎么烧开,是 平台 需要关心的,这和 网络 io磁盘 io 是很类似的,都是 请求响应 的模式。这种任务,由单线程的异步模型进行处理,是最有效的,毕竟 “摇人” 还是要管饭的。


那如果非要在 Dart 中处理计算密集型的任务,该如何是好呢?不用担心,Dartisolate 机制可以完成这项需求。关于这点,在后面会进行详述。认识 异步 是什么,是本文的核心,那本文就到这里,谢谢观看 ~


作者:张风捷特烈
链接:https://juejin.cn/post/7144878072641585166
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

栈都知道,单调栈有了解吗?

前言 大家好,我是小彭。 今天分享到一种栈的衍生数据结构 —— 单调栈(Monotonic Stack)。栈(Stack)是一种满足后进先出(LIFO)逻辑的数据结构,而单调栈实际上就是在栈的基础上增加单调的性质(单调递增或单调递减)。那么,单调栈是用来解决什...
继续阅读 »

前言


大家好,我是小彭。


今天分享到一种栈的衍生数据结构 —— 单调栈(Monotonic Stack)。栈(Stack)是一种满足后进先出(LIFO)逻辑的数据结构,而单调栈实际上就是在栈的基础上增加单调的性质(单调递增或单调递减)。那么,单调栈是用来解决什么问题的呢?




学习路线图:





1. 单调栈的典型问题


单调栈是一种特别适合解决 “下一个更大元素” 问题的数据结构。


举个例子,给定一个整数数组,要求输出数组中元素 ii 后面下一个比它更大的元素,这就是下一个更大元素问题。这个问题也可以形象化地思考:站在墙上向后看,问视线范围内所能看到的下一个更高的墙。例如,站在墙 [3] 上看,下一个更高的墙就是墙 [4]


形象化思考



这个问题的暴力解法很容易想到:就是遍历元素 ii 后面的所有元素,直到找到下一个比 ii 更大的元素为止,时间复杂度是 O(n)O(n),空间复杂度是 O(1)O(1)。单次查询确实没有优化空间了,那多次查询呢?如果要求输出数组中每个元素的下一个更大元素,那么暴力解法需要的时间复杂度是 O(n2)O(n^2) 。有没有更高效的算法呢?




2. 解题思路


我们先转变一下思路:


在暴力解法中,我们每处理一个元素就要去求它的 “下一个更大元素”。现在我们不这么做,我们每处理一个元素时,由于不清楚它的解,所以先将它缓存到某种数据结构中。后续如果能确定它的解,再将其从缓存中取出来。 这个思路可以作为 “以空间换时间” 优化时间复杂度的通用思路。


回到这个例子上:



  • 在处理元素 [3] 时,由于不清楚它的解,只能先将 [3] 放到缓存中,继续处理下一个元素;

  • 在处理元素 [1] 时,我们观察缓存发现它比缓存中所有元素都小,只能先将它放到缓存中,继续处理下一个元素;

  • 在处理元素 [2] 时,我们观察缓存中的 [1] 比当前元素小,说明当前元素就是 [1] 的解。此时我们可以把 [1] 从缓存中弹出,记录结果。再将 [2] 放到缓存中,继续处理下一个元素;

  • 在处理元素 [1] 时,我们观察缓存发现它比缓存中所有元素都小,只能先将它放到缓存中,继续处理下一个元素;

  • 在处理元素 [4] 时,我们观察缓存中的 [3] [2] [1] 都比当前元素小,说明当前元素就是它们的解。此时我们可以把它们从缓存中弹出,记录结果。再将 [4] 放到缓存中,继续处理下一个元素;

  • 在处理元素 [1] 时,我们观察缓存发现它比缓存中所有元素都小,只能先将它放到缓存中,继续处理下一个元素;

  • 遍历结束,从缓存中弹出过的元素都是有解的,保留在缓存中的元素都是无解的。


分析到这里,我们发现问题已经发生转变,问题变成了:“如何寻找在缓存中小于当前元素的数”。 现在,我们把注意力集中在这个缓存上,思考一下用什么数据结构、用什么算法可以更高效地解决问题。由于这个缓存是我们额外增加的,所以我们有足够的操作空间。


先说结论:



  • 方法 1 - 暴力: 遍历整个缓存中所有元素,最坏情况(递减序列)下所有数据都进入缓存中,单次操作的时间复杂度是 O(N)O(N),整体时间复杂度是 O(N2)O(N^2)

  • 方法 2 - 二叉堆: 不需要遍历整个缓存,只需要对比缓存的最小值,直到缓存的最小值都大于当前元素。最坏情况(递减序列)下所有数据都进入堆中,单次操作的时间复杂度是 O(lgN)O(lgN),整体时间复杂度是 O(NlgN)O(N·lgN)

  • 方法 3 - 单调栈: 我们发现元素进入缓存的顺序正好是有序的,且后进入缓存的元素会先弹出做对比,符合 “后进先出” 逻辑,所以这个缓存数据结构用栈就可以实现。因为每个元素最多只会入栈和出栈一次,所以整体的计算规模还是与数据规模成正比的,整体时间复杂度是 O(n)O(n)


下面,我们先从优先队列说起。




3. 优先队列解法


寻找最值的问题第一反应要想到二叉堆。


我们可以维护一个小顶堆,每处理一个元素时,先观察堆顶的元素:



  • 如果堆顶元素小于当前元素,则说明已经确定了堆顶元素的解,我们将其弹出并记录结果;

  • 如果堆顶元素不小于当前元素,则说明小顶堆内所有元素都是不小于当前元素的,停止观察。


观察结束后,将当前元素加入小顶堆,堆会自动进行堆排序,堆顶就是整个缓存的最小值。此时,继续在后续元素上重复这个过程。


题解


fun nextGreaterElements(nums: IntArray): IntArray {
// 结果数组
val result = IntArray(nums.size) { -1 }
// 小顶堆
val heap = PriorityQueue<Int> { first, second ->
nums[first] - nums[second]
}
// 从前往后查询
for (index in 0 until nums.size) {
// while:当前元素比堆顶元素大,说明找到下一个更大元素
while (!heap.isEmpty() && nums[index] > nums[heap.peek()]) {
result[heap.poll()] = nums[index]
}
// 当前元素入堆
heap.offer(index)
}
return result
}

我们来分析优先队列解法的复杂度:



  • 时间复杂度: 最坏情况下(递减序列),所有元素都被添加到优先队列里,优先队列的单次操作时间复杂度是 O(lgN)O(lgN),所以整体时间复杂度是 O(NlgN)O(N·lgN)

  • 空间复杂度: 使用了额外的优先队列,所以整体的空间复杂度是 O(N)O(N)


优先队列解法的时间复杂度从 O(N2)O(N^2) 优化到 O(NlgN)O(N·lgN),还不错,那还有优化空间吗?




4. 单调栈解法


我们继续分析发现,元素进入缓存的顺序正好是逆序的,最后加入缓存的元素正好就是缓存的最小值。此时,我们不需要用二叉堆来寻找最小值,只需要获取最后一个进入缓存的元素就能轻松获得最小值。这符合 “后进先出” 逻辑,所以这个缓存数据结构用栈就可以实现。


这个问题也可以形象化地思考:把数字想象成有 “重量” 的杠铃片,每增加一个杠铃片,会把中间小的杠铃片压扁,当前的大杠铃片就是这些被压扁杠铃片的 “下一个更大元素”。


形象化思考



解题模板


// 从前往后遍历
fun nextGreaterElements(nums: IntArray): IntArray {
// 结果数组
val result = IntArray(nums.size) { -1 }
// 单调栈
val stack = ArrayDeque<Int>()
// 从前往后遍历
for (index in 0 until nums.size) {
// while:当前元素比栈顶元素大,说明找到下一个更大元素
while (!stack.isEmpty() && nums[index] > nums[stack.peek()]) {
result[stack.pop()] = nums[index]
}
// 当前元素入队
stack.push(index)
}
return result
}

理解了单点栈的解题模板后,我们来分析它的复杂度:



  • 时间复杂度: 虽然代码中有嵌套循环,但它的时间复杂度并不是 O(N2)O(N^2),而是 O(N)O(N)。因为每个元素最多只会入栈和出栈一次,所以整体的计算规模还是与数据规模成正比的,整体时间复杂度是 O(N)O(N)

  • 空间复杂度: 最坏情况下(递减序列)所有元素被添加到栈中,所以空间复杂度是 O(N)O(N)


这道题也可以用从后往前遍历的写法,也是参考资料中提到的解法。 但是,我觉得正向思维更容易理解,也更符合人脑的思考方式,所以还是比较推荐小彭的模板(王婆卖瓜)。


解题模板(从后往前遍历)


// 从后往前遍历
fun nextGreaterElement(nums: IntArray): IntArray {
// 结果数组
val result = IntArray(nums.size) { -1 }
// 单调栈
val stack = ArrayDeque<Int>()
// 从后往前查询
for (index in nums.size - 1 downTo 0) {
// while:栈顶元素比当前元素小,说明栈顶元素不再是下一个更大元素,后续不再考虑它
while (!stack.isEmpty() && stack.peek() <= nums[index]) {
stack.pop()
}
// 输出到结果数组
result[index] = stack.peek() ?: -1
// 当前元素入队
stack.push(nums[index])
}
return result
}



5. 典型例题 · 下一个更大元素 I


理解以上概念后,就已经具备解决单调栈常见问题的必要知识了。我们来看一道 LeetCode 上的典型例题:LeetCode 496.


LeetCode 例题



第一节的示例是求 “在当前数组中寻找下一个更大元素” ,而这道题里是求 “数组 1 元素在数组 2 中相同元素的下一个更大元素” ,还是同一个问题吗?其实啊,这是题目抛出的烟雾弹。注意看细节信息:



  • 两个没有重复元素的数组 nums1和 nums2

  • nums1nums2 的子集。


那么,我们完全可以先计算出 nums2 中每个元素的下一个更大元素,并把结果记录到一个散列表中,再让 nums1 中的每个元素去散列表查询结果即可。


题解


class Solution {
fun nextGreaterElement(nums1: IntArray, nums2: IntArray): IntArray {
// 临时记录
val map = HashMap<Int, Int>()
// 单调栈
val stack = ArrayDeque<Int>()
// 从前往后查询
for (index in 0 until nums2.size) {
// while:当前元素比栈顶元素大,说明找到下一个更大元素
while (!stack.isEmpty() && nums2[index] > stack.peek()) {
// 输出到临时记录中
map[stack.pop()] = nums2[index]
}
// 当前元素入队
stack.push(nums2[index])
}

return IntArray(nums1.size) {
map[nums1[it]] ?: -1
}
}
}



6. 典型例题 · 下一个更大元素 II(环形数组)


第一节的示例还有一道变型题,对应于 LeetCode 上的另一道典型题目:503. 下一个更大元素 II


LeetCode 例题



两道题的核心考点都是 “下一个更大元素”,区别只在于把 “普通数组” 变为 “环形数组 / 循环数组”,当元素遍历到数组末位后依然找不到目标元素,则会循环到数组首位继续寻找。这样的话,除了所有数据中最大的元素,其它每个元素都必然存在下一个更大元素。


其实,计算机中并不存在物理上的循环数组,在遇到类似的问题时都可以用假数据长度和取余的思路处理。如果你是前端工程师,那么你应该有印象:我们在实现无限循环轮播的控件时,有一个小技巧就是给控件 设置一个非常大的数据长度 ,长到永远不可能轮播结束,例如 Integer.MAX_VALUE。每次轮播后索引会加一,但在取数据时会对数据长度取余,这样就实现了循环轮播了。


无限轮播伪代码


class LooperView {

private val data = listOf("1", "2", "3")

// 假数据长度
fun getSize() = Integer.MAX_VALUE

// 使用取余转化为 data 上的下标
fun getItem(index : Int) = data[index % data.size]
}

回到这道题,我们的思路也更清晰了。我们不需要无限查询,所以自然不需要设置 Integer.MAX_VALUE 这么大的假数据,只需要 设置 2 倍的数据长度 ,就能实现循环查询(3 倍、4倍也可以,但没必要),例如:


题解


class Solution {
fun nextGreaterElements(nums: IntArray): IntArray {
// 结果数组
val result = IntArray(nums.size) { -1 }
// 单调栈
val stack = ArrayDeque<Int>()
// 数组长度
val size = nums.size
// 从前往后遍历
for (index in 0 until nums.size * 2) {
// while:当前元素比栈顶元素大,说明找到下一个更大元素
while (!stack.isEmpty() && nums[index % size] > nums[stack.peek() % size]) {
result[stack.pop() % size] = nums[index % size]
}
// 当前元素入队
stack.push(index)
}
return result
}
}



7. 总结


到这里,相信你已经掌握了 “下一个更大元素” 问题的解题模板了。除了典型例题之外,大部分题目会将 “下一个更大元素” 的语义隐藏在题目细节中,需要找出题目的抽象模型或转变思路才能找到,这是难的地方。


小彭在 20 年的文章里说过单调栈是一个相对冷门的数据结构,包括参考资料和网上的其他资料也普遍持有这个观点。 单调栈不能覆盖太大的问题域,应用价值不及其他数据结构。 —— 2 年前的文章


2 年后重新思考,我不再持有此观点。我现在认为:单调栈的关键是 “单调性”,而栈只是为了配合问题对操作顺序的要求而搭配的数据结构。 我们学习单调栈,应该当作学习单调性的思想在栈这种数据结构上的应用,而不是学习一种新的数据结构。对此,你怎么看?


下一篇文章,我们来学习单调性的思想在队列上数据结构上的应用 —— 单调队列。


更多同类型题目:

























































单调栈难度题解
496. 下一个更大元素 IEasy【题解】
1475. 商品折扣后的最终价格Easy【题解】
503. 下一个更大元素 IIMedium【题解】
739. 每日温度Medium【题解】
901. 股票价格跨度Medium【题解】
1019. 链表中的下一个更大节点Medium【题解】
402. 移掉 K 位数字Medium【题解】
42. 接雨水Hard【题解】
84. 柱状图中最大的矩形Hard【题解】

作者:彭旭锐
链接:https://juejin.cn/post/7144769422882766885
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

不用架构会怎么样?—— 在项目实战中探索架构演进(一)

复杂度 软件的首要技术使命是“管理复杂度” —— 《代码大全》 因为低复杂度才能降低理解成本和沟通难度,提升应对变更的灵活性,减少重复劳动,最终提高代码质量。 架构的目的在于“将复杂度分层” 复杂度为什么要被分层? 若不分层,复杂度会在同一层次展开,这...
继续阅读 »

复杂度



软件的首要技术使命是“管理复杂度” —— 《代码大全》



因为低复杂度才能降低理解成本和沟通难度,提升应对变更的灵活性,减少重复劳动,最终提高代码质量。



架构的目的在于“将复杂度分层”



复杂度为什么要被分层?


若不分层,复杂度会在同一层次展开,这样就太 ... 复杂了。


举一个复杂度不分层的例子:


小李:“你会做什么菜?”


小明:“我会做用土鸡生的土鸡蛋配上切片的番茄,放点油盐,开火翻炒的番茄炒蛋。”


听了小明的回答,你还会和他做朋友吗?


小明把不同层次的复杂度以不恰当的方式揉搓在一起,让人感觉是一种由“没有必要的具体”导致的“难以理解的复杂”。


小李其实并不关心土鸡蛋的来源、番茄的切法、添加的佐料、以及烹饪方式。


这样的回答除了难以理解之外,局限性也很大。因为它太具体了!只要把土鸡蛋换成洋鸡蛋、或是番茄片换成块、或是加点糖、或是换成电磁炉,其中任一因素发生变化,小明就不会做番茄炒蛋了。


再举个正面的例子,TCP/IP 协议分层模型自下到上定义了五层:



  1. 物理层

  2. 数据链路成

  3. 网络层

  4. 传输层

  5. 应用层


其中每一层的功能都独立且明确,这样设计的好处是缩小影响面,即单层的变动不会影响其他层。


这样设计的另一个好处是当专注于一层协议时,其余层的技术细节可以不予关注,同一时间只需要关注有限的复杂度,比如传输层不需要知道自己传输的是 HTTP 还是 FTP,传输层只需要专注于端到端的传输方式,是建立连接,还是无连接。


有限复杂度的另一面是“下层的可重用性”。当应用层的协议从 HTTP 换成 FTP 时,其下层的内容不需要做任何更改。


引子


为了降低客户端领域开发的复杂度,架构也在不断地演进。从 MVC 到 MVP,再到 MVVM,目前已经发展到 MVI。


MVVM 仍然是当下最常用的 Android 端架构,曾经的榜一大哥 MVP 已日落西山。


下图是 Google Trends 关于 “android mvvm” 和 “android mvp”的对比图,剪刀差发生在2018年:


微信图片_20220904192016.png


2018 年到底发生了什么使得架构改朝换代?


MVI 在架构设计上又做了哪些新的尝试?它是否能在将来取代 MVVM?


被如此多新名词弄得头晕脑胀的我,不由得倔强反问:“不是用架构又会怎么样?”


该系列以实战项目中的搜索场景为剧本,演绎了如何运用不同架构进行重构的过程,并逐个给出上述问题自己的理解。


搜索是 App 中常见的业务场景,该功能示意图如下:


1662106805162.gif


业务流程如下:在搜索条中输入关键词并同步展示联想词,点联想词跳转搜索结果页,若无匹配结果则展示推荐流,返回时搜索历史以标签形式横向铺开。点击历史直接发起搜索跳转到结果页。


技术选型


将搜索业务场景做了如下设计:


微信截图_20220902171024.png


搜索页用Activity来承载,它被分成两个部分,头部是常驻在 Activity 的搜索条。下面的“搜索体”用Fragment承载,它可能出现三种状态 1.搜索历史页 2.搜索联想页 3.搜索结果页。


Fragment 之间的切换采用 Jetpack 的Navigation


Navigation 封装了切换 Fragment 的细节,让开发者更轻松地实现界面切换。


它包含三个关键概念:



  1. Navigation graph:一个带标签的 xml 文件,用于配置页面及其包含的动作。

  2. NavHost:一个页面容器。

  3. NavController:页面跳转控制器,用于发起动作。


关于 Navigation 更详细的介绍可以点击Navigation 组件使用入门  |  Android 开发者  |  Android Developers


界面框架


class TemplateSearchActivity : AppCompatActivity() {
companion object {
const val NAV_HOST_ID = "searchFragmentContainer"
}
private lateinit var etSearch: EditText
private lateinit var tvSearch: TextView
private lateinit var ivClear: ImageView
private lateinit var ivBack: ImageView
private lateinit var vInputBg: View
private val contentView by lazy(LazyThreadSafetyMode.NONE) {
LinearLayout {
layout_width = match_parent
layout_height = match_parent
orientation = vertical
background_color = "#0C0D14"
fitsSystemWindows = true

// 搜索条
ConstraintLayout {
layout_width = match_parent
layout_height = wrap_content
// 返回按钮
ivBack = ImageView {
layout_id = "ivSearchBack"
layout_width = 7
layout_height = 14
scaleType = scale_fit_xy
start_toStartOf = parent_id
top_toTopOf = parent_id
margin_start = 22
margin_top = 11
src = R.drawable.search_back
onClick = { finish() }
}
// 搜索框背景
vInputBg = View {
layout_id = "vSearchBarBg"
layout_width = 0
layout_height = 36
start_toEndOf = "ivSearchBack"
align_vertical_to = "ivSearchBack"
end_toStartOf = ID_SEARCH
margin_start = 19.76
margin_end = 16
// 轻松定义圆角背景,省去新增一个 xml
shape = shape {
corner_radius = 54
solid_color = "#1AB8BCF1"
}
}
// 搜索框放大镜icon
ImageView {
layout_id = "ivSearchIcon"
layout_width = 16
layout_height = 16
scaleType = scale_fit_xy
start_toStartOf = "vSearchBarBg"
align_vertical_to = "vSearchBarBg"
margin_start = 16
src = R.drawable.template_search_icon
}
// 搜索框
etSearch = EditText {
layout_id = "etSearch"
layout_width = 0
layout_height = wrap_content
start_toEndOf = "ivSearchIcon"
end_toStartOf = ID_CLEAR_SEARCH
align_vertical_to = "vSearchBarBg"
margin_start = 7
margin_end = 12
textSize = 14f
textColor = "#F2F4FF"
imeOptions = EditorInfo.IME_ACTION_SEARCH
hint = "输入您想搜索的模板"
hint_color = "#686A72"
background = null
maxLines = 1
inputType = InputType.TYPE_CLASS_TEXT
}
// 搜索框尾部清空按钮
ivClear = ImageView {
layout_id = "ivClearSearch"
layout_width = 20
layout_height = 20
scaleType = scale_fit_xy
align_vertical_to = "vSearchBarBg"
end_toEndOf = "vSearchBarBg"
margin_end = 12
src = R.drawable.template_search_clear
// 搜索按钮
tvSearch = TextView {
layout_id = "tvSearch"
layout_width = wrap_content
layout_height = wrap_content
textSize = 14f
textColor = "#686A72"
text = "搜索"
gravity = gravity_center
align_vertical_to = "ivSearchBack"
end_toEndOf = parent_id
margin_end = 16
}
}
// 搜索体
FragmentContainerView {
layout_id = NAV_HOST_ID
layout_width = match_parent
layout_height = match_parent
NavHostFragment.create(R.navigation.search_navigation).also {
supportFragmentManager.beginTransaction()
.replace(NAV_HOST_ID.toLayoutId(), it)
.setPrimaryNavigationFragment(it)
.commit()
}
}
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(contentView)
initView()
}

private fun initView() {
// 绘制界面初始状态
tvSearch?.apply {
isEnabled = false
textColor = "#484951"
}
ivClear?.visibility = gone
KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
}
}

搜索页是一个 Activity,它的根布局是一个纵向的 LinearLayout,其中上部是一个搜索条,下部是搜索体。上述代码使用了 Kotlin 的 DSL 使得可以用声明式的语法动态的构建视图,避免了 XML 的解析并加载到内存,以及 findViewById() 遍历查找时间复杂度,性能略好,但缺点是无法预览。


关于 运用 Kotlin DSL 动态构建布局的详细讲解可以点击Android性能优化 | 把构建布局用时缩短 20 倍(下)


这套构建布局的 DSL 源码可以在这里找到wisdomtl/Layout_DSL: Build Android layout dynamically with kotlin, get rid of xml file, which has poor performance (github.com)


其中 FragmentContainerView 作为 Fragment 的容器,将其和 NavHostFragment 以及一个 navigation 资源文件绑定:


<?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"
android:id="@+id/search_navigation"
app:startDestination="@id/SearchHistoryFragment">
<!--联想页-->
<fragment
android:id="@+id/SearchHintFragment"
android:name="com.bilibili.studio.search.template.fragment.SearchHintFragment"
android:label="search_hint_fragment">
<!--跳转结果页-->
<action
android:id="@+id/action_to_result"
app:destination="@id/SearchResultFragment" />
<!--跳转历史页-->
<action
android:id="@+id/action_to_history"
app:popUpTo="@id/SearchHistoryFragment" />
</fragment>
<!--历史页-->
<fragment
android:id="@+id/SearchHistoryFragment"
android:name="com.bilibili.studio.search.template.fragment.SearchHistoryFragment"
android:label="search_history_fragment">
<!--跳转结果页-->
<action
android:id="@+id/action_to_result"
app:destination="@id/SearchResultFragment" />
<!--跳转联想页-->
<action
android:id="@+id/action_to_hint"
app:destination="@id/SearchHintFragment" />
</fragment>
<!--结果页-->
<fragment
android:id="@+id/SearchResultFragment"
android:name="com.bilibili.studio.search.template.fragment.SearchResultFragment"
android:label="search_result_fragment">
<!--跳转历史页-->
<action
android:id="@+id/action_to_history"
app:popUpTo="@id/SearchHistoryFragment" />
<!--跳转联想页-->
<action
android:id="@+id/action_to_hint"
app:destination="@id/SearchHintFragment" />
</fragment>
</navigation>

navigation 文件定义了 Fragment 实体以及对应的 跳转行为 action。然后就能用NavController方便地进行 Fragment 的切换:


findNavController(NAV_HOST_ID.toLayoutId()).navigate(
R.id.action_history_to_result, // 预定义在 xml 中的 action
bundleOf("keywords" to event.keyword) // 携带跳转参数
)

支离破碎的刷新


了解了整个界面框架和技术选型之后,在不使用任何架构的情况下实现第一个业务界面——搜索条,看看会遇到哪些意想不到的坑。


看上去简单的搜索框,其实包含不少交互逻辑。


交互逻辑:进入搜索页时,搜索按钮置灰,隐藏清空按钮,搜索框获取焦点并自动弹出输入法:


飞书20220903-130310.jpg

用代码表达如下:


class TemplateSearchActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(contentView)
initView()
}

private fun initView() {
// 绘制界面初始状态
tvSearch.apply {
isEnabled = false
textColor = "#484951"
}
ivClear.visibility = gone
KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
}
}

交互逻辑:当输入关键词时,显示清空按钮,并高亮搜索按钮:


飞书20220903-130555.gif


通过addTextChangedListener()监听输入框内容变化并做出视图调整:


class TemplateSearchActivity : AppCompatActivity() {
private fun initView() {
// 初始化时立刻被执行
tvSearch.apply {
isEnabled = false
textColor = "#484951"
}
ivClear.visibility = gone
// 监听输入框字符变化
etSearch.addTextChangedListener(object :TextWatcher{
override fun beforeTextChanged(s: CharSequence?,s: Int,c: Int,after: Int){}
// 将来的某个时间点被执行的逻辑
override fun onTextChanged(char: CharSequence?, s: Int, b: Int, c: Int) {
val input = char?.toString() ?: ""
// 显示 X,并高亮搜索
if(input.isNotEmpty()) {
ivClear.visibility = visible
tvSearch.apply {
textColor = "#F2F4FF"
isEnabled = true
}
}
// 隐藏 X,并置灰搜索
else {
ivClear.visibility = gone
tvSearch.apply {
textColor = "#484951"
isEnabled = false
}
}
}

override fun afterTextChanged(s: Editable?) {}
})

KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
}
}

为了让语义更加明确,抽象出initView()来表示初始化视图。其中包含了的确在初始化会被立刻执行的逻辑,以及在将来某个时间点会执行的逻辑。


如果按照这个趋势发展下去,界面越来越复杂时,initView() 会越来越长。


项目中,超 1000 行的initView()initConfig()就是这样练成的。如此庞大的初始化方法中,很难找到你想要的东西。


将来的逻辑和现在的逻辑最好不要待在一起,特别是当将来的逻辑很复杂时(嵌套回调)。


上述代码还有一个问题,更新搜索按钮tvSearch的逻辑有两个分身,分别处于现在和将来。这增加了理解界面状态的难度。当刷新同一控件的逻辑分处在各种各样的回调中时,如何轻松地回答“控件在某一时刻应该长什么样?”这个问题。当发生界面状态不一致的 Bug 时,又该从哪个地方下手排查问题?


名不副实的子程序


交互逻辑:点击键盘上的搜索或搜索条右侧的搜索进入结果页时,搜索框拉长并覆盖搜索按钮:


飞书20220903-130619.gif


用代码表达如下:


class TemplateSearchActivity : AppCompatActivity() {
private fun initView() {
tvSearch.apply {
isEnabled = false
textColor = "#484951"
}
ivClear.visibility = gone
etSearch.addTextChangedListener(object :TextWatcher{
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}

override fun onTextChanged(char: CharSequence?, start: Int, before: Int, count: Int) {
val input = char?.toString() ?: ""
if(input.isNotEmpty()) {
ivClear.visibility = visible
tvSearch.apply {
textColor = "#F2F4FF"
isEnabled = true
}
}else {
ivClear.visibility = gone
tvSearch.apply {
textColor = "#484951"
isEnabled = false
}
}
}

override fun afterTextChanged(s: Editable?) {
}
})
// 监听键盘搜索按钮
etSearch.setOnEditorActionListener { v, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
val input = etSearch.text.toString() ?: ""
if(input.isNotEmpty()) { searchAndHideKeyboard() }
true
} else false
}
// 监听搜索条搜索按钮
tvSearch.setOnClickListener {
searchAndHideKeyboard()
}
KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
}
// 跳转到搜索页 + 拉长搜索条 + 隐藏搜索按钮 + 隐藏键盘
private fun searchAndHideKeyboard() {
vInputBg.end_toEndOf = parent_id // 拉长搜索框(与父亲右边对齐)
runCatching {
findNavController(NAV_HOST_ID.toLayoutId()).navigate(
R.id.action_history_to_result,
bundleOf("keywords" to etSearch?.text.toString())
)
}
tvSearch.visibility = gone
KeyboardUtils.hideSoftInput(etSearch)
}
}

因为跳转到结果页有两个入口,为了复用代码,不得不抽象出一个方法叫searchAndHideKeyboard()


这个命名是糟糕的,因为它没有表达出方法内做的所有事情,或者说这个子程序的抽象是糟糕的,因为它包含了多个目的,不够单纯。



子程序应该有单一且明确的目的。—— 《代码大全》



单纯的子程序最大的好处是能提高复用度。


当需求稍加改动后,searchAndHideKeyboard()就无法被复用,比如另一个搜索场景中,点击搜索时不需要拉长搜索框,也不隐藏搜索按钮,而是将搜索按钮名称改为取消。


之所以该方法难以被复用,因为它的视角错了,它以当前业务为视角,抽象出当前业务下会发生的界面变化,遂该方法也只能被用于当前业务。


若以界面变化为视角,当搜索行为发生时,界面会发生三个维度的变化:1. 搜索框绘制效果变化 2. 输入法的显示状态变化 3. Fragment 的切换。以这样的视角做抽象就能提高代码的复用度,详细的实现细节会在后续篇章展开。


剪不断理还乱的耦合


交互逻辑:当从结果页返回时(系统返回键/搜索条清空键/点击搜索框),搜索条缩回原始长度,并展示搜索按钮:


飞书20220903-135702.gif


class TemplateSearchActivity : AppCompatActivity() {
private fun initView() {
tvSearch?.apply {
isEnabled = false
textColor = "#484951"
}
ivClear?.visibility = gone
etSearch?.addTextChangedListener(object :TextWatcher{
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}

override fun onTextChanged(char: CharSequence?, start: Int, before: Int, count: Int) {
val input = char?.toString() ?: ""
if(input.isNotEmpty()) {
ivClear?.visibility = visible
tvSearch?.apply {
textColor = "#F2F4FF"
isEnabled = true
}
}else {
ivClear?.visibility = gone
tvSearch?.apply {
textColor = "#484951"
isEnabled = false
}
}
}

override fun afterTextChanged(s: Editable?) {
}
})
etSearch?.setOnEditorActionListener { v, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
val input = etSearch?.text?.toString() ?: ""
if(input.isNotEmpty()) {
searchAndHideKeyboard()
}
true
} else false
}
tvSearch?.setOnClickListener {
searchAndHideKeyboard()
}
// 监听清空按钮
ivClear?.setOnClickListener {
etSearch?.text = null
etSearch?.requestFocus()
// 弹键盘
KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
// 回到历史页
backToHistory()
}
// 监听搜索框触摸事件并回到历史页
etSearch?.setOnTouchListener { v, event ->
if (event.action == MotionEvent.ACTION_DOWN) {
backToHistory()
}
false
}

KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
}
// 监听系统返回并回退到历史页
override fun onBackPressed() {
super.onBackPressed()
backToHistory()
}

private fun backToHistory() {
runCatching {
findNavController(NAV_HOST_ID.toLayoutId()).currentDestination?.takeIf { it.id == R.id.SearchResultFragment }?.let {
// 还原搜索框和搜索按钮
tvSearch?.visibility = visible
vInputBg?.end_toStartOf = "tvSearch"
}
// 弹出结果页回到历史页
findNavController(NAV_HOST_ID.toLayoutId()).navigate(R.id.action_to_history)
}
}
}

这段代码和上一小节有着同样的问题,即在 Activity 中面向业务抽象出各种名不副实且难以复用的子程序。


backToHistory()还加重了这个问题,因为它和searchAndHideKeyboard()是耦合在一起的,分别表示进入搜索结果页和从结果页返回时搜索条的界面交互逻辑。


交互逻辑是易变的,当它发生变化时,就得修改两个地方,漏掉一处,就会产生 Bug。


修改一个子程序后,另一个子程序出 Bug 了。你说恐怖不恐怖?


总结


整个过程没有 ViewModel 的影子,也没有 Model 的影子。


Model,模型(名词)。Trygve Reenskaug,MVC 概念的发明者,在 1979 年就对 MVC 中的 M 下过这样的结论:



The View observes the Model for changes. —— Trygve Reenskaug



上面的代码没有抽象出一个模型用来表达界面的状态变化,界面也没有发送任何指令给 ViewModel,而是在 Activity 中独自消化了所有的业务逻辑。


这是很多 MVVM 在项目中的现状:只要不牵涉到网络请求,则业务逻辑都写在 View 层。这样的代码好写,不好懂,也不好改。


显而易见的坏处是,Activity 变得复杂(复杂度在一个类中被铺开),代码量增多,随之而来的是理解成本增加。


除了业务逻辑和界面展示混合在一起的复杂度之外,上述代码还有另一个复杂度:


代码中对搜索按钮tvSearch的引用有将近10处,且散落在代码的各个角落,有些在初始化initView()中、有些在输入框回调onTextChange()中、有些在业务方法searchAndHideKeyboard()中、还有些在系统返回回调onBackPress()中。在不同的地方对同一个控件做出“是否显示”、“字体颜色”、“是否可点击”等状态的修改。



如果有人在阅读你的代码时不得不搜索整个应用程序以便找到所需的信息,那么就应该重新组织你的代码了。——《代码大全》



这样的写法无法简明扼要地回答“搜索按钮应该长什么样?”这个问题。你不得不搜索整段代码中所有对它的引用,才能拼凑出问题答案。这样是吃力的!


关于“界面该长什么样”这个问题的答案应该内聚在一个点,Flutter 以及 Compose 就是这样降低复杂度的,即 View 和 Model 强绑定,且内建了 View 随 Model 变化而变化的刷新机制。(也可以使用 MVI 实现类似的效果)


除此之外,这样写还有一个坏处是“容易改出 bug”。当需求变更,假设换了一种搜索按钮高亮颜色,可能会发生“没改全”的 bug,因为决定按钮颜色的代码不止一处。


用一张图来表达所有的复杂度在 Activity 层铺开:


微信截图_20220903170226.png


本篇用无架构的方式完成了搜索条的实现。搜索历史及结果的无架构实现会在后续篇章中展开。


作者:唐子玄
链接:https://juejin.cn/post/7144737325698777096
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

五子棋AI进阶:极大极小值搜索

AI
前言 上篇文章,介绍了一下五子棋 AI 的入门实现,学完之后能用,就是 AI 还太年轻,只能思考一步棋。 本文将介绍一种提高 AI 思考能力的算法:极大极小值算法。 Minimax算法 又名极小化极大算法,是一种找出失败的最大可能性中的最小值的算法(即最小...
继续阅读 »

前言


上篇文章,介绍了一下五子棋 AI 的入门实现,学完之后能用,就是 AI 还太年轻,只能思考一步棋。


image.png


本文将介绍一种提高 AI 思考能力的算法:极大极小值算法



Minimax算法 又名极小化极大算法,是一种找出失败的最大可能性中的最小值的算法(即最小化对手的最大得益)。通常以递归形式来实现。
Minimax算法常用于棋类等由两方较量的游戏和程序。该算法是一个零总和算法,即一方要在可选的选项中选择将其优势最大化的选择,另一方则选择令对手优势最小化的一个,其输赢的总和为0(有点像能量守恒,就像本身两个玩家都有1点,最后输家要将他的1点给赢家,但整体上还是总共有2点)。 —— 百度百科



极大极小值搜索算法


算法实现原理


对于五子棋游戏来说,如果 AI 执黑子先下,那么第一步 AI 共有 225 种落子方式,AI 落子到一个点后,表示 AI 回合结束,换到对手(白子)落子,这时对手共有 224 种落子方式。我们可以将 AI 和对手交替落子形成的所有情况穷举出来,这样就形成了一棵树,叫做 博弈树


但是,穷举出所有情况太不现实了,这颗 博弈树 最后一层节点数就有 225! ,这个数字是特别庞大的,数字10后边要加432个0!!!这程序运行起来,电脑还要不要了?


image.png


所以,我们只考虑2步棋或4步棋的情况。


image.png


如图所示,我只列举出了走4步棋所形成的部分情况。A0 是起点,AI 将在这个点中选择出最佳的落子点位。A0 下面有两个分支(实际有225个分支,这里放不下,就只演示2个)A1A2,这两个分支表示的就是 AI 第一步落子的两种情况。


A1 如果落子到 (0,0),则当前局面就如下图所示


image.png


A2 如果落子到 (0,1),则当前局面就如下图所示


image.png


AI 落子完后,就轮到对方落子了。在 A1 分支中,对方有 B1B2 两种落子情况(实际有224种)


B1 情况如图所示


image.png


B2 情况如图所示


image.png


一直到第4步落子完时,B5 的局面就会像下图这样


image.png


要知道,这颗 博弈树 是以 AI 的角度建立的,AI 为了赢,它需要从 A1A2 分支中,选择一个对自己最有利的落子点,而 A1A2 分支的好坏需要它们下面的 B1B2B3B4 决定,所以说,下层分支的局面会影响上层分支的选择。


要确定 A1A2 分支哪个好,我们必须从这个分支的最深层看起。


image.png


B5 ~ B12 节点的局面是由对方造成的,我们就假设对方很聪明,他一定能选择一个最有利于他自己的落子点。怎么知道哪个落子点好?还是和之前一样,用评估函数评估一下,分高的就好呗,但有一点不同的是,之前评估的是一个点,现在需要评估一个局面,怎么评估本文后面会提到。


假设 B5 ~ B12 中 各个节点的得分如下图最底部所示


image.png


A3 节点得分为 0A4 节点得分为 1A5 节点得分为 3A6 节点得分为 2。这就很奇怪了,不是说让选得分最大的吗?这怎么都选的最小的得分???


这其实还是要从评估函数说起,因为我们现在的评估函数都是从 AI 角度出发的,评估的得分越高,只会对 AI 有利,对对方来说是不利的。所以,当是对方的分支的时候,我们要选得分最低的节点,因为 AI 要站在对方的角度去做选择,换位思考。这里如果还是没有搞懂的话,我们可以这么理解:



假如张三遇到了抢劫犯,他认为他身上值钱的东西有:《Java从入门到入土》、1000元现金、某厂月薪3.5K包吃包住的Offer。现在抢劫犯要抢劫他身上的一样东西,如果站在张三的角度思考的话,那肯定是让抢《Java从入门到入土》这本破书了,但是站在抢劫犯的角度思考,1000元现金比什么都强!



image.png


这就是思考角度的问题,对方如果很聪明,那他肯定是选择让 AI 利益最低的一个节点,现在我们就认为对方是一个绝顶聪明的人,所以在对方选择的分支里都选择了分值最低的,好让 AI 的利益受损。


再接下去就是 AI 选择分支了,不用说,AI 肯定选分高的。AI 要从对方给的那些低分分支里选择分最高的,也就是差的里面选好的。所以 B1 得分为 1B2 得分为 3


image.png


后面也是一样的流程,又轮到对方选择了,对方肯定选择 B1 分支,B1 分支是得分最低的节点,所以到最后,A1 分支的最终得分为 1


image.png


我们对 A2 分支也做如上操作:AI 选高分,对方选低分。最后可以得出如下图所示的结果


image.png


现在我们知道 A1 最终得分为 1A2 最终得分为 2,因为 AI 会选择最大得分的分支 A2,所以最终 A0 得分为 2,也就是说,AI 下一步的最佳落子点为 (0,1)


image.png


image.png


AI 选择的分支一定是选最高分值的叫做 Max 分支,对方选择的分支一定是选最低分值的叫做 Min 分支,然后由低到高,倒推着求出起点的得分,这就是 极大极小值搜索 的实现原理。


image.png


代码实现


我们接着上次的代码来,在 ZhiZhangAIService 类中定义一个全局变量 bestPoint 用于存放 AI 当前最佳下棋点位,再定义一个全局变量 attack 用于设置 AI 的进攻能力。


    /**
* AI最佳下棋点位
*/
private Point bestPoint;
/**
* 进攻系数
*/
private int attack;

新增 minimax 方法,编写 极大极小值搜索 算法的实现代码。这里是使用递归的方式,深度优先遍历 博弈树,生成树和选择节点是同时进行的。type 表示当前走棋方,刚开始时,因为要从根节点开始生成树,所以要传入 0 ,并且 AI 最后选择高分节点的时候也是在根节点进行的。depth 表示搜索的深度,也就是 AI 思考的步数
,我这边传入的是 2,也就是只思考两步棋,思考4步或6步都行,只要你电脑吃得消(计算量很大的哦)。



/**
* 极大极小值搜索
*
* @param type 当前走棋方 0.根节点表示AI走棋 1.AI 2.玩家
* @param depth 搜索深度
* @return
*/
private int minimax(int type, int depth) {
// 是否是根节点
boolean isRoot = type == 0;
if (isRoot) {
// 根节点是AI走棋
type = this.ai;
}

// 当前是否是AI走棋
boolean isAI = type == this.ai;
// 当前分值,
int score;
if (isAI) {
// AI因为要选择最高分,所以初始化一个难以到达的低分
score = -INFINITY;
} else {
// 对手要选择最低分,所以初始化一个难以到达的高分
score = INFINITY;
}

// 到达叶子结点
if (depth == 0) {
/**
* 评估每棵博弈树的叶子结点的局势
* 比如:depth=2时,表示从AI开始走两步棋之后的局势评估,AI(走第一步) -> 玩家(走第二步),然后对局势进行评估
* 注意:局势评估是以AI角度进行的,分值越大对AI越有利,对玩家越不利
*/
return evaluateAll();
}

for (int i = 0; i < this.cols; i++) {
for (int j = 0; j < this.rows; j++) {
if (this.chessData[i][j] != 0) {
// 该处已有棋子,跳过
continue;
}

/* 模拟 AI -> 玩家 交替落子 */
Point p = new Point(i, j, type);
// 落子
putChess(p);
// 递归生成博弈树,并评估叶子结点的局势获取分值
int curScore = minimax(3 - type, depth - 1);
// 撤销落子
revokeChess(p);

if (isAI) {
// AI要选对自己最有利的节点(分最高的)
if (curScore > score) {
// 最高值被刷新
score = curScore;
if (isRoot) {
// 根节点处更新AI最好的棋位
this.bestPoint = p;
}
}
} else {
// 对手要选对AI最不利的节点(分最低的)
if (curScore < score) {
// 最低值被刷新
score = curScore;
}
}
}
}

return score;
}

新增模拟落子 putChess 和撤销落子 revokeChess 等方法。



/**
* 下棋子
*
* @param point 棋子
*/
private void putChess(Point point) {
this.chessData[point.x][point.y] = point.type;
}

/**
* 撤销下的棋子
*
* @param point 棋子
*/
private void revokeChess(Point point) {
this.chessData[point.x][point.y] = 0;
}

新增一个评估函数 evaluateAll ,用于评估一个局面。这个评估函数实现原理为:搜索棋盘上现在所有的已落子的点位,然后调用之前的评估函数 evaluate 对这个点进行评分,如果这个位置上是 AI 的棋子,则加上评估的分值,是对方的棋子就减去评估的分值。注意这里有个进攻系数 attack,这个值我现在设定的是 2,如果这个值太低或太高都会影响 AI 的判断,我这边经过测试,觉得设置为 2 会比较好点。最后就是将 AI 所有棋子的总得分乘以进攻系数,再减去对手所有棋子的总得分,作为本局面的得分。



/**
* 以AI角度对当前局势进行评估,分数越大对AI越有利
*
* @return
*/
private int evaluateAll() {
// AI得分
int aiScore = 0;
// 对手得分
int foeScore = 0;

for (int i = 0; i < this.cols; i++) {
for (int j = 0; j < this.rows; j++) {
int type = this.chessData[i][j];
if (type == 0) {
// 该点没有棋子,跳过
continue;
}

// 评估该棋位分值
int val = evaluate(new Point(i, j, type));
if (type == this.ai) {
// 累积AI得分
aiScore += val;
} else {
// 累积对手得分
foeScore += val;
}
}
}

// 该局AI最终得分 = AI得分 * 进攻系数 - 对手得分
return aiScore * this.attack - foeScore;
}

调整 AI 入口方法 getPoint,现在使用 minimax 方法获取 AI 的最佳落子点位。


    @Override
public Point getPoint(int[][] chessData, Point point, boolean started) {
initChessData(chessData);
this.ai = 3 - point.type;
this.bestPoint = null;
this.attack = 2;

if (started) {
// AI先下,首子天元
int centerX = this.cols / 2;
int centerY = this.rows / 2;
return new Point(centerX, centerY, this.ai);
}

// 基于极大极小值搜索获取最佳棋位
minimax(0, 2);

return this.bestPoint;
}

测试一下,因为现在的 AI 可以思考两步棋了,所以比之前厉害了许多。


image.png


但是,又因为要搜索很多个节点,所以响应耗时也变长了很多,思考两步的情况下,平均响应时间在 3s 左右。


image.png


再去和大佬的 AI 下一把(gobang.light7.cn/#/),思考两步棋的 AI 执黑子先下,已经可以很轻松的打败大佬的普通级别的 AI 了。


image.png


AI 执白后下的话,连萌新级别的都打不赢,这个应该是评估模型的问题,后续需要对评估模型做进一步的优化。


现在写的搜索算法,如果要让 AI 思考4步棋的话,我这普通电脑还是吃不消的,后续对搜索算法还有更多的优化空间。


作者:AnLingYi
链接:https://juejin.cn/post/7143550531420094477
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

教你写一个入门级别的五子棋AI

AI
前言 本文只是介绍五子棋AI的实现,最终的成品只是一个 AI 接口,并不包括 GUI,且不依赖 GUI。 五子棋 AI 的实现并不难,只需要解决一个问题就行: 怎么确定AI的最佳落子位置? 一般情况下,五子棋棋盘是由15条横线和15条纵线组合而成的,15...
继续阅读 »

前言



本文只是介绍五子棋AI的实现,最终的成品只是一个 AI 接口,并不包括 GUI,且不依赖 GUI



五子棋 AI 的实现并不难,只需要解决一个问题就行:


怎么确定AI的最佳落子位置?


image.png


一般情况下,五子棋棋盘是由15条横线和15条纵线组合而成的,15x15 的棋盘共有 225 个交叉点,也就是说共有 225 个落子点。


假如说,AI 是黑棋,先行落子,所以 AI 总共有 225 个落子点可以选择,我们可以对每个落子点进行评估打分,哪个分高下哪里,这样我们就能确定最佳落子点了。


但这样又引出了一个新的问题:


怎么对落子点进行评估打分呢?


这就是本文的重点了,请看后文!


image.png


实现过程


抽象



注:部分基础代码依赖于 lombok,请自行引入,或手写基础代码。



落子位置实体类,这里我们定义棋子类型字段:type1表示黑子,2表示白子。


/**
* 棋子点位
*
* @author anlingyi
* @date 2021/11/10
*/

@AllArgsConstructor
@NoArgsConstructor
@ToString
public class Point {
/**
* 横坐标
*/

int x;
/**
* 纵坐标
*/

int y;
/**
* 棋子类型 1.黑 2.白
*/

int type;
}

AI 对外提供的接口,不会依赖任何 GUI 代码,方便其他程序调用。


/**
* 五子棋AI接口
*
* @author anlingyi
* @date 2021/11/10
*/

public interface AIService {

/**
* 获取AI棋位
*
* @param chessData 已下棋子数据
* @param point 对手棋位
* @param started 是否刚开局
* @return
*/

Point getPoint(int[][] chessData, Point point, boolean started);

}

这个接口需要知道我们现在的棋盘落子数据 chessData,还有对手上一步的落子位置 pointstarted 参数表示是否是刚开局,后续可能对刚开局情况做单独的处理。


实现AI接口


我们创建一个类 ZhiZhangAIService,这个类实现 AIService 接口,来写我们的实现逻辑。


/**
*
* 五子棋AI实现
*
* @author anlingyi
* @date 2021/11/10
*/

public class ZhiZhangAIService implements AIService {

/**
* 已下棋子数据
*/

private int[][] chessData;
/**
* 棋盘行数
*/

private int rows;
/**
* 棋盘列数
*/

private int cols;
/**
* AI棋子类型
*/

private int ai;

/**
* 声明一个最大值
*/

private static final int INFINITY = 999999999;

@Override
public Point getPoint(int[][] chessData, Point point, boolean started) {
// 初始化棋盘数据
initChessData(chessData);
// 计算AI的棋子类型
this.ai = 3 - point.type;

if (started) {
// AI先下,首子天元
int centerX = this.cols / 2;
int centerY = this.rows / 2;
return new Point(centerX, centerY, this.ai);
}

// 获取最佳下棋点位
return getBestPoint();
}

/**
* 初始化棋盘数据
*
* @param chessData 当前棋盘数据
*/

private void initChessData(int[][] chessData) {
// 获取棋盘行数
this.rows = chessData.length;
// 获取棋盘列数
this.cols = chessData[0].length;
// 初始化棋盘数据
this.chessData = new int[this.cols][this.rows];
// 深拷贝
for (int i = 0; i < cols; i++) {
for (int j = 0; j < rows; j++) {
this.chessData[i][j] = chessData[i][j];
}
}
}

/**
* 获取最佳下棋点位
*
* @return
*/

private Point getBestPoint() {
Point best = null;
// 初始分值为最小
int score = -INFINITY;

/* 遍历所有能下棋的点位,评估各个点位的分值,选择分值最大的点位 */
for (int i = 0; i < this.cols; i++) {
for (int j = 0; j < this.rows; j++) {
if (this.chessData[i][j] != 0) {
// 该点已有棋子,跳过
continue;
}

Point p = new Point(i, j, this.ai);
// 评估该点AI得分
int val = evaluate(p);
// 选择得分最高的点位
if (val > score) {
// 最高分被刷新
score = val;
// 更新最佳点位
best = p;
}
}
}

return best;
}

/**
* 对当前棋位进行评估
*
* @param point 当前棋位
* @return
*/

private int evaluate(Point point) {
// 核心
}

}

首先看 getPoint 方法,这个是 AI 的出入口方法,我们要对传入的棋盘数据做一个初始化,调用 initChessData 方法,计算出当前游戏的棋盘行数、列数,并且拷贝了一份棋子数据到本地(深拷贝还是浅拷贝视情况而定)。


this.ai = 3 - point.type;

这行代码可以计算出AI是执黑子还是执白子,应该很好理解。


if (started) {
// AI先下,首子天元
int centerX = this.cols / 2;
int centerY = this.rows / 2;
return new Point(centerX, centerY, this.ai);
}

这段代码是处理刚开局时 AI 先行落子的情况,我们这边是简单的将落子点确定为棋盘中心位置(天元)。开局情况的落子我们可以自己定义,并不是固定的,只是说天元的位置比较好而已。


    private Point getBestPoint() {
Point best = null;
// 初始分值为最小
int score = -INFINITY;

/* 遍历所有能下棋的点位,评估各个点位的分值,选择分值最大的点位 */
for (int i = 0; i < this.cols; i++) {
for (int j = 0; j < this.rows; j++) {
if (this.chessData[i][j] != 0) {
// 该点已有棋子,跳过
continue;
}

Point p = new Point(i, j, this.ai);
// 评估该点AI得分
int val = evaluate(p);
// 选择得分最高的点位
if (val > score) {
// 最高分被刷新
score = val;
// 更新最佳点位
best = p;
}
}
}

return best;
}

然后就到了我们最主要的方法了 getBestPoint,这个方法用于选择出 AI 的最佳落子位置。这个方法的思路就是遍历棋盘上所有能下棋的点,然后对这个点进行评分,如果这个点的评分比之前点的评分高,就更新当前最佳落子点位,并更新最高分,所有的落子点都评估完成之后,我们就能确定最好的点位在哪了。


   /**
* 对当前棋位进行评估
*
* @param point 当前棋位
* @return
*/

private int evaluate(Point point) {
// 核心
}

最后就是评估函数的实现了。


评估函数


在写评估函数之前,我们要先了解一下五子棋的几种棋型。(还不熟的朋友,五子棋入门了解一下:和那威学五子棋)


在这里,我把五子棋棋型大致分为:连五活四冲四活三眠三活二眠二眠一 等共8种棋型。


0:空位 1:黑子 2:白子

连五:11111
活四:011110
冲四:21111
活三:001110
眠三:211100
活二:001100
眠二:001120
眠一:001200

冲四活三 如果形成,赢的可能性很大,活四 如果形成,棋局胜负基本确定,连五 形成就已经赢了。所以说,如果 AI 落的点能够形成这几种胜率很高的棋型的话,我们要给这个点评一个高分,这样对 AI 最有利。


我这边定义好了各个棋型的分数情况











































棋型分数
连五10000000
活四1000000
活三10000
冲四8000
眠三1000
活二800
眠二50
眠一10

评估模型的抽象


我们创建一个枚举内部类,然后定义这几种棋型和它的分数。


    @AllArgsConstructor
private enum ChessModel {
/**
* 连五
*/

LIANWU(10000000, new String[]{"11111"}),
/**
* 活四
*/

HUOSI(1000000, new String[]{"011110"}),
/**
* 活三
*/

HUOSAN(10000, new String[]{"001110", "011100", "010110", "011010"}),
/**
* 冲四
*/

CHONGSI(8000, new String[]{"11110", "01111", "10111", "11011", "11101"}),
/**
* 眠三
*/

MIANSAN(1000, new String[]{"001112", "010112", "011012", "211100", "211010"}),
/**
* 活二
*/

HUOER(800, new String[]{"001100", "011000", "000110"}),
/**
* 眠二
*/

MIANER(50, new String[]{"011200", "001120", "002110", "021100", "001010", "010100"}),
/**
* 眠一
*/

MIANYI(10, new String[]{"001200", "002100", "020100", "000210", "000120"});

/**
* 分数
*/

int score;
/**
* 局势数组
*/

String[] values;
}

为了评估方便,我们可以把所有定义好的棋型以及棋型对应的分数存入 Hash 表。


创建一个 LinkedHashMap 类型的类变量 SCORE,然后在静态代码块内进行初始化。


    /**
* 棋型分数表
*/

private static final Map SCORE = new LinkedHashMap<>();

static {
// 初始化棋型分数表
for (ChessModel chessScore : ChessModel.values()) {
for (String value : chessScore.values) {
SCORE.put(value, chessScore.score);
}
}
}

判断落子点位的棋型


棋型和分数都定义好了,现在我们要知道一个点位它的棋型的情况,这样才能评估这个点位的分数。


我们以落子点位为中心,分横、纵、左斜、右斜等4个大方向,分别取出各方向的9个点位的棋子,每个方向的9个棋子都组合成一个字符串,然后匹配现有的棋型数据,累积分值,这样就计算出了这个点位的分数了。


image.png


以上图为例,对横、纵、左斜、右斜做如上操作,可以得出:


横:000111000 -> 活三 +10000
纵:000210000 -> 眠一 +10
左斜:000210000 -> 眠一 +10
右斜:000010000 -> 未匹配到棋型 +0

所以这个点位总得分为:


10000 + 10 + 10 + 0 = 10020

代码实现:


    /**
* 获取局势分数
*
* @param situation 局势
* @return
*/

private int getScore(String situation) {
for (String key : SCORE.keySet()) {
if (situation.contains(key)) {
return SCORE.get(key);
}
}
return 0;
}

/**
* 获取棋位局势
*
* @param point 当前棋位
* @param direction 大方向 1.横 2.纵 3.左斜 4.右斜
* @return
*/

private String getSituation(Point point, int direction) {
// 下面用到了relativePoint函数,根据传入的四个大方向做转换
direction = direction * 2 - 1;
// 以下是将各个方向的棋子拼接成字符串返回
StringBuilder sb = new StringBuilder();
appendChess(sb, point, direction, 4);
appendChess(sb, point, direction, 3);
appendChess(sb, point, direction, 2);
appendChess(sb, point, direction, 1);
sb.append(1); // 当前棋子统一标记为1(黑)
appendChess(sb, point, direction + 1, 1);
appendChess(sb, point, direction + 1, 2);
appendChess(sb, point, direction + 1, 3);
appendChess(sb, point, direction + 1, 4);
return sb.toString();
}

/**
* 拼接各个方向的棋子
*


* 由于现有评估模型是对黑棋进行评估
* 所以,为了方便对局势进行评估,如果当前是白棋方,需要将扫描到的白棋转换为黑棋,黑棋转换为白棋
* 如:point(x=0,y=0,type=2) 即当前为白棋方
* 扫描到的某个方向局势为:20212 -> 转换后 -> 10121
*
* @param sb 字符串容器
* @param point 当前棋子
* @param direction 方向 1.左横 2.右横 3.上纵 4.下纵 5.左斜上 6.左斜下 7.右斜上 8.右斜下
* @param offset 偏移量
*/

private void appendChess(StringBuilder sb, Point point, int direction, int offset) {
int chess = relativePoint(point, direction, offset);
if (chess > -1) {
if (point.type == 2) {
// 对白棋进行转换
if (chess > 0) {
// 对棋子颜色进行转换,2->1,1->2
chess = 3 - chess;
}
}
sb.append(chess);
}
}

/**
* 获取相对点位棋子
*
* @param point 当前棋位
* @param direction 方向 1.左横 2.右横 3.上纵 4.下纵 5.左斜上 6.左斜下 7.右斜上 8.右斜下
* @param offset 偏移量
* @return -1:越界 0:空位 1:黑棋 2:白棋
*/

private int relativePoint(Point point, int direction, int offset) {
int x = point.x, y = point.y;
switch (direction) {
case 1:
x -= offset;
break;
case 2:
x += offset;
break;
case 3:
y -= offset;
break;
case 4:
y += offset;
break;
case 5:
x += offset;
y -= offset;
break;
case 6:
x -= offset;
y += offset;
break;
case 7:
x -= offset;
y -= offset;
break;
case 8:
x += offset;
y += offset;
break;
}

if (x < 0 || y < 0 || x >= this.cols || y >= this.rows) {
// 越界
return -1;
}

// 返回该位置的棋子
return this.chessData[x][y];
}


评估函数的实现


到这一步,我们已经能知道某个落子点位的各个方向的局势,又能通过局势获取到对应的分值,这样一来,评估函数就很好写了,评估函数要做的就是累积4个方向的分值,然后返回就行。


    /**
* 对当前棋位进行评估
*
* @param point 当前棋位
* @return
*/

private int evaluate(Point point) {
// 分值
int score = 0;

for (int i = 1; i < 5; i++) {
// 获取该方向的局势
String situation = getSituation(point, i);
// 下此步的得分
score += getScore(situation);
}

return score;
}

现在,已经可以将我们写的 AI 接入GUI 程序做测试了。如果还没有 GUI,也可以自己写个测试方法,只要按照方法的入参信息传入就行,方法输出的就是 AI 下一步的落子位置。


    /**
* 获取AI棋位
*
* @param chessData 已下棋子数据
* @param point 对手棋位
* @param started 是否刚开局
* @return
*/

Point getPoint(int[][] chessData, Point point, boolean started);

image.png


测试了一下,现在的 AI 只知道进攻,不知道防守,所以我们需要对 getBestPoint 方法进行优化。之前只对 AI 落子进行了评估,现在我们也要对敌方落子进行评估,然后累积分值,这样可以提高 AI 的防守力度。


    private Point getBestPoint() {
Point best = null;
// 初始分值为最小
int score = -INFINITY;

/* 遍历所有能下棋的点位,评估各个点位的分值,选择分值最大的点位 */
for (int i = 0; i < this.cols; i++) {
for (int j = 0; j < this.rows; j++) {
if (this.chessData[i][j] != 0) {
// 该点已有棋子,跳过
continue;
}

Point p = new Point(i, j, this.ai);
// 该点得分 = AI落子得分 + 对手落子得分
int val = evaluate(p) + evaluate(new Point(i, j, 3 - this.ai));
// 选择得分最高的点位
if (val > score) {
// 最高分被刷新
score = val;
// 更新最佳点位
best = p;
}
}
}

return best;
}

只有这行代码进行了改动,现在加上了对手落子到该点的得分。


// 该点得分 = AI落子得分 + 对手落子得分
int val = evaluate(p) + evaluate(new Point(i, j, 3 - this.ai));

再次测试,现在 AI 棋力还是太一般,防守能力是提高了,但还是输给了我这个“臭棋篓子”。


image.png


有一些局势的评分需要提高,例如:



  • 活三又活二

  • 冲四又活二

  • 两个或两个以上的活三

  • 冲四又活三


上面这些情况都得加一些分数,如果分数太普通,AI 棋力就会很普通甚至更弱,可以说目前的 AI 只能算是一个刚入门五子棋的新手。


我这边对这些情况的处理是这样的:



  • 活三又活二:总分x2

  • 冲四又活二:总分x4

  • 两个或两个以上的活三:总分x6

  • 冲四又活三:总分x8


新增一个方法,用于判断当前局势是属于什么棋型


    /**
* 检查当前局势是否处于某个局势
*
* @param situation 当前局势
* @param chessModel 检查的局势
* @return
*/

private boolean checkSituation(String situation, ChessModel chessModel) {
for (String value : chessModel.values) {
if (situation.contains(value)) {
return true;
}
}
return false;
}

修改评估方法 evaluate,对各种棋型做一个统计,最后按照我上面给出的处理规则进行加分处理。


    /**
* 对当前棋位进行评估
*
* @param point 当前棋位
* @return
*/

private int evaluate(Point point) {
// 分值
int score = 0;
// 活三数
int huosanTotal = 0;
// 冲四数
int chongsiTotal = 0;
// 活二数
int huoerTotal = 0;

for (int i = 1; i < 5; i++) {
String situation = getSituation(point, i);
if (checkSituation(situation, ChessModel.HUOSAN)) {
// 活三+1
huosanTotal++;
} else if (checkSituation(situation, ChessModel.CHONGSI)) {
// 冲四+1
chongsiTotal++;
} else if (checkSituation(situation, ChessModel.HUOER)) {
// 活二+1
huoerTotal++;
}

// 下此步的得分
score += getScore(situation);
}

if (huosanTotal > 0 && huoerTotal > 0) {
// 活三又活二
score *= 2;
}
if (chongsiTotal > 0 && huoerTotal > 0) {
// 冲四又活二
score *= 4;
}
if (huosanTotal > 1) {
// 活三数大于1
score *= 6;
}
if (chongsiTotal > 0 && huosanTotal > 0) {
// 冲四又活三
score *= 8;
}

return score;
}

再次进行测试,AI 棋力已经可以打败我这个菜鸡了,但由于我棋艺不精,打败我不具代表性。


image.png


在网上找了一个大佬写的五子棋 AIgobang.light7.cn/#/), 我用我写的 AI 去和大佬的 AI 下棋,我的 AI 执黑,只能打败大佬的萌新级别执白的 AI


AI 执黑的情况,赢


image.png


AI 执白的情况,输


image.png


由于目前的 AI 只能思考一步棋,所以棋力不强,对方稍微套路一下可能就输了,后续还有很大的优化空间。


作者:AnLingYi
链接:https://juejin.cn/post/7143227745164591118
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

灯泡开关 Ⅱ : 分情况讨论

题目描述 这是 LeetCode 上的 672. 灯泡开关 Ⅱ ,难度为 中等。 Tag : 「脑筋急转弯」、「找规律」 房间中有 n 只已经打开的灯泡,编号从 1 到 n 。墙上挂着 4 个开关 。 这 4 个开关各自都具有不同的功能,其中: 开...
继续阅读 »

题目描述


这是 LeetCode 上的 672. 灯泡开关 Ⅱ ,难度为 中等


Tag : 「脑筋急转弯」、「找规律」


房间中有 n 只已经打开的灯泡,编号从 1n 。墙上挂着 4 个开关 。


4 个开关各自都具有不同的功能,其中:



  • 开关 1 :反转当前所有灯的状态(即开变为关,关变为开)

  • 开关 2 :反转编号为偶数的灯的状态(即 2, 4, ...

  • 开关 3 :反转编号为奇数的灯的状态(即 1, 3, ...

  • 开关 4 :反转编号为 j = 3k + 1 的灯的状态,其中 k = 0, 1, 2, ...(即 1, 4, 7, 10, ...


你必须 恰好 按压开关 presses 次。每次按压,你都需要从 4 个开关中选出一个来执行按压操作。


给你两个整数 npresses,执行完所有按压之后,返回 不同可能状态 的数量。


示例 1:


输入:n = 1, presses = 1

输出:2

解释:状态可以是:
- 按压开关 1 ,[关]
- 按压开关 2 ,[开]

示例 2:


输入:n = 2, presses = 1

输出:3

解释:状态可以是:
- 按压开关 1 ,[关, 关]
- 按压开关 2 ,[开, 关]
- 按压开关 3 ,[关, 开]

示例 3:


输入:n = 3, presses = 1

输出:4

解释:状态可以是:
- 按压开关 1 ,[关, 关, 关]
- 按压开关 2 ,[关, 开, 关]
- 按压开关 3 ,[开, 关, 开]
- 按压开关 4 ,[关, 开, 开]

提示:



  • 1<=n<=10001 <= n <= 1000

  • 0<=presses<=10000 <= presses <= 1000


分情况讨论


记灯泡数量为 nn(至少为 11),翻转次数为 kk(至少为 00),使用 1 代表灯亮,使用 0 代表灯灭。


我们根据 nnkk 的数值分情况讨论:



  • k=0k = 0 时,无论 nn 为何值,都只有起始(全 1)一种状态;

  • k>0k > 0 时,根据 nn 进一步分情况讨论:

    • n=1n = 1 时,若 kk 为满足「k>0k > 0」的最小值 11 时,能够取满「1/0」两种情况,而其余更大 kk 值情况能够使用操作无效化(不影响灯的状态);

    • n=2n = 2 时,若 k=1k = 1,能够取得「11/10/01」三种状态,当 k=2k = 2 时,能够取满「11/10/01/00」四种状态,其余更大 kk 可以通过前 k1k - 1 步归结到任一状态,再通过最后一次的操作 11 归结到任意状态;

    • n=3n = 3 时,若 k=1k = 1 时,对应 44 种操作可取得 44 种方案;当 k=2k = 2 时,可取得 77 种状态;而当 k=3k = 3 时可取满 23=82^3 = 8 种状态,更大的 kk 值可通过同样的方式归结到取满的 88 种状态。

    • n>3n > 3 时,根据四类操作可知,灯泡每 66 组一循环(对应序列 k + 12k + 22k + 13k + 1),即只需考虑 n<=6n <= 6 的情况,而 n=4n = 4n=5n = 5n=6n = 6 时,后引入的灯泡状态均不会产生新的组合(即新引入的灯泡状态由前三个灯泡的状态所唯一确定),因此均可归纳到 n=3n = 3 的情况。




Java 代码:


class Solution {
public int flipLights(int n, int k) {
if (k == 0) return 1;
if (n == 1) return 2;
else if (n == 2) return k == 1 ? 3 : 4;
else return k == 1 ? 4 : k == 2 ? 7 : 8;
}
}

TypeScript 代码:


function flipLights(n: number, k: number): number {
if (k == 0) return 1
if (n == 1) return 2
else if (n == 2) return k == 1 ? 3 : 4;
else return k == 1 ? 4 : k == 2 ? 7 : 8;
};


  • 时间复杂度:O(1)O(1)

  • 空间复杂度:O(1)O(1)


最后


这是我们「刷穿 LeetCode」系列文章的第 No.672 篇,系列开始于 2021/01/01,截止于起始日 LeetCode 上共有 1916 道题目,部分是有锁题,我们将先把所有不带锁的题目刷完。


在这个系列文章里面,除了讲解解题思路以外,还会尽可能给出最为简洁的代码。如果涉及通解还会相应的代码模板。


为了方便各位同学能够电脑上进行调试和提交代码,我建立了相关的仓库:github.com/SharingSour…


在仓库地址里,你可以看到系列文章的题解链接、系列文章的相应代码、LeetCode 原题链接和其他优选题解。


作者:宫水三叶的刷题日记
链接:https://juejin.cn/post/7143438427050999838
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Android 闪屏页适配

遇到的坑 按官方文档设置完之后,debug运行,或者直接点击Run运行,闪屏页的logo不显示,清掉后台,从桌面点击启动logo才显示,不过设置的windowBackgroud 都是显示正常的,这个问题我调了一天,,,AndroidStudio版本4.2.2...
继续阅读 »

遇到的坑



按官方文档设置完之后,debug运行,或者直接点击Run运行,闪屏页的logo不显示,清掉后台,从桌面点击启动logo才显示,不过设置的windowBackgroud 都是显示正常的,这个问题我调了一天,,,AndroidStudio版本4.2.2




内容来自官方文档 文档地址:点我



如果您之前在 Android 11 或更低版本中实现了自定义初始屏幕,则需要将您的应用迁移到 SplashScreenAPI 以确保它在 Android 12 及更高版本中正确显示。


从 Android 12 开始,系统始终在所有应用的 启动和 热启动时应用新的Android 系统默认启动画面。默认情况下,此系统默认启动画面是使用您的应用程序的启动器图标元素和 您的主题(如果它是单色)构建的。windowBackground


如果您不迁移您的应用,您在 Android 12 及更高版本上的应用启动体验将会降级或可能出现意外结果:



  • 如果您现有的初始屏幕是使用覆盖 的自定义主题android:windowBackground实现的,则系统会将您的自定义初始屏幕替换为 Android 12 及更高版本上的默认 Android 系统初始屏幕(这可能不是您应用的预期体验)。

  • 如果您现有的初始屏幕是使用专用的 实现的,则Activity在运行 Android 12 或更高版本的设备上启动您的应用会导致重复的初始屏幕:显示新的系统初始屏幕 ,然后是您现有的初始屏幕活动。


您可以通过完成本指南中描述的迁移过程来防止这些降级或意外体验。迁移后,新 API 会缩短启动时间,让您完全控制初始屏幕体验,并确保与平台上其他应用程序的启动体验更加一致。


SplashScreen 兼容库


您可以SplashScreen直接使用 API,但我们强烈建议使用 AndroidxSplashScreen兼容库 。compat 库使用SplashScreenAPI,支持向后兼容,并为所有 Android 版本的初始屏幕显示创建一致的外观。本指南是使用 compat 库编写的。


如果您选择直接使用 SplashScreen API 进行迁移,在 Android 11 上并降低您的初始屏幕看起来与以前完全相同;从 Android 12 开始,初始屏幕将具有新的 Android 12 外观。


迁移您的启动画面实施


完成以下步骤,将您现有的初始屏幕实施迁移到适用于 Android 12 及更高版本的新体验。


此过程适用于您从中迁移的任何类型的实现。如果您是从专用迁移Activity,您还应该遵循本文档中描述的最佳实践来调整您的自定义启动屏幕Activity。新的SplashScreenAPI 还减少了由专用启动屏幕活动引入的启动延迟。


使用SplashScreencompat 库迁移后,系统会在所有版本的 Android 上显示相同的初始屏幕。


要迁移初始屏幕:




  1. build.gradle文件中,更改您的 compileSdkVersion并将 SplashScreencompat 库包含在依赖项中。


    build.gradle

    android {
       compileSdkVersion 31
       ...
    }
    dependencies {
       ...
       implementation 'androidx.core:core-splashscreen:1.0.0-beta02'
    }



  2. 使用 的父项创建一个主题Theme.SplashScreen,并将 的值设置为 应该使用 postSplashScreenTheme的主题以及可绘制或动画可绘制的主题。其他属性是可选的。Activity``windowSplashScreenAnimatedIcon


    <style name="Theme.App.Starting" parent="Theme.SplashScreen">
       <!-- Set the splash screen background, animated icon, and animation duration. -->
       <item name="windowSplashScreenBackground">@color/...</item>

       <!-- Use windowSplashScreenAnimatedIcon to add either a drawable or an
            animated drawable. One of these is required. -->
       <item name="windowSplashScreenAnimatedIcon">@drawable/...</item>
       <!-- Required for animated icons -->
       <item name="windowSplashScreenAnimationDuration">200</item>

       <!-- Set the theme of the Activity that directly follows your splash screen. -->
       <!-- Required -->
       <item name="postSplashScreenTheme">@style/Theme.App</item>
    </style>

    如果要在图标下方添加背景颜色,可以使用 Theme.SplashScreen.IconBackground主题并设置 windowSplashScreenIconBackground属性。




  3. 在清单中,将启动活动的主题替换为您在上一步中创建的主题。


    <manifest>
       <application android:theme="@style/Theme.App.Starting">
        <!-- or -->
            <activity android:theme="@style/Theme.App.Starting">
    ...



  4. installSplashScreen在调用之前调用启动 活动super.onCreate()


    class MainActivity : Activity() {

       override fun onCreate(savedInstanceState: Bundle?) {
           // Handle the splash screen transition.
           val splashScreen = installSplashScreen()

           super.onCreate(savedInstanceState)
           setContentView(R.layout.main_activity)
    ...



installSplashScreen返回初始屏幕对象,您可以选择使用它来自定义动画或将初始屏幕保持在屏幕上更长的时间。有关自定义动画的更多详细信息,请参阅 让初始屏幕在屏幕上停留更长时间 和自定义动画以关闭初始屏幕


使您的自定义启动屏幕活动适应新的启动屏幕体验


在您迁移到适用于 Android 12 及更高版本的新初始屏幕体验后,您的自定义初始屏幕Activity仍然存在,因此您需要选择如何处理它。您有以下选择:



  • 保留自定义活动,但阻止其显示

  • 出于品牌原因保留自定义活动

  • 删除自定义活动,并根据需要调整您的应用程序


阻止自定义 Activity 显示


如果您现有的初始屏幕Activity主要用于路由,请考虑删除它的方法;例如,您可以直接链接到实际活动或移动到带有子组件的单个活动。如果这不可行,您可以使用SplashScreen#setKeepOnScreenCondition 将路由活动保持在原位,但停止渲染。这样做会将初始屏幕转移到下一个活动,并允许平滑过渡。


  class RoutingActivity : Activity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        val splashScreen = installSplashScreen()
        super.onCreate(savedInstanceState)

        // Keep the splash screen visible for this Activity
        splashScreen.setKeepOnScreenCondition { true }
        startSomeNextActivity()
        finish()
     }
   ...
 

保留品牌化的自定义活动


如果您想使用后续启动画面Activity来获得品牌体验,您可以Activity通过自定义关闭启动画面的动画,从系统启动画面过渡到您的自定义启动画面。但是,如果可能的话,最好避免这种情况,并使用新的 SplashScreenAPI 来标记您的启动画面。


移除自定义闪屏Activity


一般来说,我们建议您Activity 完全删除您自定义的启动画面,以避免重复启动画面无法迁移,提高效率并减少启动画面加载时间。您可以使用不同的技术来避免显示多余的闪屏活动。




  • 延迟加载组件、模块或库:避免加载或初始化应用程序启动时不需要的组件或库,并在应用程序需要时加载它们。


    如果您的应用确实需要某个组件才能正常工作,请仅在真正需要时而不是在启动时加载它,或者在应用启动后使用后台线程加载它。尽量保持你Application onCreate()的轻盈。


    您还可以受益于使用App Startup 库在应用程序启动时初始化组件。这样做时,请确保仍然加载启动活动所需的所有模块,并且不要在延迟加载的模块变得可用时引入卡顿。




  • 在本地加载少量数据时创建占位符:使用推荐的主题化方法并保留渲染,直到应用程序准备好。要实现向后兼容的初始屏幕,请按照使初始屏幕在屏幕上停留更长时间中概述的步骤。




  • 显示占位符:对于持续时间不确定的基于网络的加载,关闭初始屏幕并显示占位符以进行异步加载。考虑将微妙的动画应用于反映加载状态的内容区域。确保加载的内容结构 尽可能匹配骨架结构,以便在加载内容后实现平滑过渡。




  • 使用缓存:当用户第一次打开您的应用程序时,您可以显示某些 UI 元素的加载指示符(如下例所示)。下次用户返回您的应用时,您可以在加载更新的内容时显示此缓存内容。


作者:一杯凉白开
链接:https://juejin.cn/post/7141215994197835789
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Android将倒计时做到极致

前言 倒计时的实现有很多方式,我觉得分享这个技术的关键在于有些官方的,甚至第三方的,也许能帮我实现99%的效果,但是当你从99%优化到100%,哪怕这1%微不足道,但你能从这个过程中得到的东西远远比你想象中的要多。 已有倒计时方案存在的问题 在开发倒计时功能时...
继续阅读 »

前言


倒计时的实现有很多方式,我觉得分享这个技术的关键在于有些官方的,甚至第三方的,也许能帮我实现99%的效果,但是当你从99%优化到100%,哪怕这1%微不足道,但你能从这个过程中得到的东西远远比你想象中的要多。


已有倒计时方案存在的问题


在开发倒计时功能时往往我们会为了方便直接使用CountDownTimer或者使用Handler做延时来实现,当然CountDownTimer内部封装也是使用的Handler。


如果只是做次数很少的倒计时或者不需要精确的倒计时逻辑那倒没关系,比如说我只要倒计时10秒,或者我大概5分钟请求某个接口


但是如果是需要做精确的倒计时操作,比如说手机发送验证码60秒,那使用现有的倒计时方案就会存在问题。可能有些朋友没有注意到这一点,下面我们就来简单分析一下现有倒计时的问题。


1. CountDownTimer


这个可能是用得最多的,因为方便嘛。但其实倒计时每一轮倒计时完之后都是存在误差的,如果看过CountDownTimer的源码你就会知道,他的内部是有做校准操作的。(源码很简单这里就不分析了)


但是如果你认真的测试过CountDownTimer,你就会发现,即便它内部有做校准操作,他的每一轮都是有偏差,只是他最后一次倒计时完之后的总共时间和开始倒计时的时间相比没偏差。

什么意思呢,意思就是1秒,2.050秒,3.1秒......,这样的每轮偏差,导致他会出现10.95秒,下一次12秒的情况,那它的回调中如果你直接做取整就会出现少一秒的情况,但实际是没少的。

这只是其中的一个问题,你可以不根据它的回调做展示,自己用一个整形累加做展示也能解决。但是他还有个问题,有概率直接出现跳秒,就是比如3秒,下次直接5秒,这是实际的跳秒,是少了一次回调的那种。


跳秒导致你如果直接使用它可能会大问题,你可能自测的时候没发现,到时一上线应用在用户那概率跳秒,那就蛋疼了。


2. Handler


不搞这么多花里胡哨的,直接使用Handler来实现,会有什么问题。

因为直接使用handler来实现,没有校准操作,每次循环会出现几毫秒的误差,虽然比CountDownTimer的十几毫秒的误差要好,但是在基数大的倒计时情况下误差会累计,导致最终结果和现实时间差几秒误差,时间越久,误差越大


3. Timer


直接使用Timer也一样,只不过他每轮的误差更小,几轮才有1毫秒的误差,但是没有校准还是会出现误差累计,时间越久误差越大。


自己封装倒计时


既然无法直接使用原生的,那我们就自己做一个。

我们基于Handler进行封装,从上面可以看出主要为了解决两个问题,时间校准和跳秒。自己写一个CountDownTimer


public class CountDownTimer {

private int mTimes;
private int allTimes;
private final long mCountDownInterval;
private final Handler mHandler;
private OnTimerCallBack mCallBack;
private boolean isStart;
private long startTime;

public CountDownTimer(int times, long countDownInterval){
this.mTimes = times;
this.mCountDownInterval = countDownInterval;
mHandler = new Handler();
}

public synchronized void start(OnTimerCallBack callBack){
this.mCallBack = callBack;
if (isStart || mCountDownInterval <= 0){
return;
}

isStart = true;
if (callBack != null){
callBack.onStart();
}
startTime = SystemClock.elapsedRealtime();

if (mTimes <= 0){
finishCountDown();
return;
}
allTimes = mTimes;

mHandler.postDelayed(runnable, mCountDownInterval);
}

private final Runnable runnable = new Runnable() {
@Override
public void run() {
mTimes--;
if (mTimes > 0){
if (mCallBack != null){
mCallBack.onTick(mTimes);
}

long nowTime = SystemClock.elapsedRealtime();
long delay = (nowTime - startTime) - (allTimes - mTimes) * mCountDownInterval;
// 处理跳秒
while (delay > mCountDownInterval){
mTimes --;
if (mCallBack != null){
mCallBack.onTick(mTimes);
}

delay -= mCountDownInterval;
if (mTimes <= 0){
finishCountDown();
return;
}
}

mHandler.postDelayed(this, 1000 - delay);
}else {
finishCountDown();
}
}
};

private void finishCountDown(){
if (mCallBack != null){
mCallBack.onFinish();
}
isStart = false;
}

public void cancel(){
mHandler.removeCallbacksAndMessages(null);
isStart = false;
}

public interface OnTimerCallBack{

void onStart();

void onTick(int times);

void onFinish();

}

}

思路就是在倒计时开始前获取一次SystemClock.elapsedRealtime(),每轮倒计时再获取一次SystemClock.elapsedRealtime()相减得到误差,根据delay校准。然后使用while循环来处理跳秒的操作,与原生的CountDownTimer不同,这里如果跳了多少秒,就会返回多少次回调。


作者:流浪汉kylin
链接:https://juejin.cn/post/7140652166980567076
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

白话ThreadLocal原理

ThreadLocal作用 对于Android程序员来说,很多人都是在学习消息机制时候了解到ThreadLocal这个东西的。那它有什么作用呢?官方文档大致是这么描述的: ThreadLocal提供了线程局部变量 每个线程都拥有自己的变量副本,可以通过Thr...
继续阅读 »

ThreadLocal作用


对于Android程序员来说,很多人都是在学习消息机制时候了解到ThreadLocal这个东西的。那它有什么作用呢?官方文档大致是这么描述的:



  • ThreadLocal提供了线程局部变量

  • 每个线程都拥有自己的变量副本,可以通过ThreadLocal的set或者get方法去设置或者获取当前线程的变量,变量的初始化也是线程独立的(需要实现initialValue方法)

  • 一般而言ThreadLocal实例在类中被private static修饰

  • 当线程活着并且ThreadLocal实例能够访问到时,每个线程都会持有一个到它的变量的引用

  • 当一个线程死亡后,所有ThreadLocal实例给它提供的变量都会被gc回收(除非有其它的引用指向这些变量)
    上述中“变量”是指ThreadLocal的get方法获取的值


简单例子


先来看一个简单的使用例子吧:


public class ThreadId {

private static final AtomicInteger nextId = new AtomicInteger(0);

private static final ThreadLocal<Integer> threadId = new ThreadLocal<Integer>() {
@Override
protected Integer initialValue() {
return nextId.get();
}
};

public static int get() {
return threadId.get();
}
}

这也是官方文档上的例子,非常简单,就是通过在不同线程调用ThredId.get()可以获取唯一的线程Id。如果在调用ThreadLocal的get方法之前没有主动调用过set方法设置值的话,就会返回initialValue方法的返回值,并把这个值存储为当前线程的变量。


ThreadLocal到底是用来解决什么问题,适用什么场景呢,例子是看懂了,但好像还是没什么体会?ThreadLocal既然是提供变量的,我们不妨把我们见过的变量类型拿出来,做个对比


局部变量、成员变量 、 ThreadLocal、静态变量










































变量类型作用域生命周期线程共享性作用
局部变量方法(代码块)内部,其他方法(代码块)不能访问方法(代码块)开始到结束只存在于每个线程的工作内存,不能在线程中共享解决变量在方法(代码块)内部的代码行之间的共享
成员变量实例内和实例相同可在线程间共享解决变量在实例方法之间的共享,否则方法之间只能靠参数传递变量
静态变量类内部和类的生命周期相同可在多个线程间共享解决变量在多个实例之间的共享
ThreadLocal存储的变量整个线程一般而言与线程的生命周期相同不再多线程间共享解决变量在单个线程中的共享问题,线程中处处可访问

ThreadLocal存储的变量本质上间接算是Thread的成员变量,ThreadLocal只是提供了一种对开发者透明的可以为每个线程存储同一维度成员变量的方式。


共享 or 隔离


网上有很多人持有如下的看法:
ThreadLocal为解决多线程程序的并发问题提供了一种新思路或者ThreadLocal是为了解决多线程访问资源时的共享问题。
个人认为这些都是错误的,ThreadLocal保存的变量是线程隔离的,与资源共享没有任何关系,也没有解决什么并发问题,这一点看了ThreadLocal的原理就会更加清楚。就好比上面的例子,每个线程应该有一个线程Id,这并不是什么并发问题啊。


同时他们会拿ThreadLocal与sychronized做对比,我们要清楚它们根本不是为了解决同一类问题设计的。sychronized是在牵涉到共享变量时候,要做到线程间的同步,保证并发中的原子性与内存可见性,典型的特征是多个线程会访问相同的变量。而ThreadLocal根本不是解决线程同步问题的,它的场景是A线程保存的变量只有A线程需要访问,而其它的线程并不需要访问,其他线程也只访问自己保存的变量。


原理


我们来一个开放性的问题,假如现在要给每个线程增加一个线程Id,并且Java的Thread类你能随便修改,你要怎么操作?非常简单吧,代码大概是这样


public class Thread{
private int id;

public void setId(int id){
this.id=id;
}
}

那好,现在题目变了,我们现在还得为每个线程保存一个Looper对象,那怎么办呢?再加一个Looper的字段不就好了,显然这种做法肯定是不具有扩展性的。那我们用一个容器类不就好了,很自然地就会想到Map,像下面这样


public class Thread{

private Map<String,Object> map;

public Map<String,Object> getMap(){
if(map==null)
map=new HashMap<>();
return map;
}

}

然后我们在代码里就可以通过如下代码来给Thread设置“成员变量”了


   Thread.currentThread().getMap().put("id",id);
Thread.currentThread().getMap().put("looper",looper);

然后可以在该线程执行的任意地方,这样访问:


  Looper looper=(Looper) Thread.currentThread().getMap().get("looper");

看上去还不错,但是还是有些问题:



  • 保存和获取变量都要用到字符换key

  • 因为map中要保存各种值,因此泛型只得用Object,这样获取时候就需要强制转换(可用泛型方法解)

  • 当该变量没有作用时候,此时线程还没有执行完,需要手动设置该变量为空,否则会造成内存泄漏


为了不通过字符串访问,同时省去强制转换,我们封装一个类,就叫ThreadLocal吧,伪代码如下:


  public class ThreadLocal<T> {

public void set(T value) {
Thread t = Thread.currentThread();
Map map = t.getMap();
if (map != null)
//以自己为键
map.put(this, value);
else
createMap(t, value);
}


public T get() {
Thread t = Thread.currentThread();
Map<ThreadLocal<?>,T> map = t.getMap();
if (map != null) {
T e = map.get(this);
return e;
}
return setInitialValue();
}
}

没错,以上基本上就是ThreadLocal的整体设计了,只是线程中存储数据的Map是特意实现的ThreadLocal.ThreadLocalMap。


ThreadLocal与线程的关系如下:
ThreadLocal与线程的关系.png


如上图如所示,ThredLocal本身并不存储变量,只是向每个线程的threadLocals中存储键值对。ThreadLocal横跨线程,提供一种类似切面的概念,这种切面是作用在线程上的。


我们对ThreadLocal已经有一个整体的认识了,接下来我们大致看一下源码


源码分析


TheadLocal


   public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

set方法通过Thread.currentThread方法获取当前线程,然后调用getMap方法获取线程的threadLocals字段,并往ThreadLocalMap中放入键值对,其中键为ThreadLocal实例自己。


 ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}

接着看get方法:


public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

很清晰,其中值得注意的是最后一行的setInitialValue方法,这个方法在我们没有调用过set方法时候调用。


private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}

setInitialValue方法会获取initialValue的返回值并把它放进当前线程的threadLocals中。默认情况下initialValue返回null,我们可以实现这个方法来对变量进行初始化,就像上面TheadId的例子一样。


remove方法,从当前线程的ThreadLocalMap中移除元素。


public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}

TheadLocalMap


看ThreadLocalMap的代码我们主要是关注以下两个方面:



  1. 散列表的一般设计问题。包括散列函数,散列冲突问题解决,负载因子,再散列等。

  2. 内存泄漏的相关处理。一般而言ThreadLocal 引用使用private static修饰,但是假设某种情况下我们真的不再需要使用它了,手动把引用置空。上面我们知道TreadLocal本身作为键存储在TheadLocalMap中,而ThreadLocalMap又被Thread引用,那线程没结束的情况下ThreadLocal能被回收吗?


散列函数
先来理一下散列函数吧,我们在之后的代码中会看到ThreadLocalMap通过 int i = key.threadLocalHashCode & (len-1);决定元素的位置,其中表大小len为2的幂,因此这里的&操作相当于取模。另外我们关注的是threadLocalHashCode的取值。


  private final int threadLocalHashCode = nextHashCode();
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
private static AtomicInteger nextHashCode =
new AtomicInteger();
private static final int HASH_INCREMENT = 0x61c88647;

这里很有意思,每个ThreadLocal实例的threadLocalHashCode是在之前ThreadLocal实例的threadLocalHashCode上加 0x61c88647,为什么偏偏要加这么个数呢?
这个魔数的选取与斐波那契散列有关以及黄金分割法有关,具体不是很清楚。它的作用是这样产生的值与2的幂取模后能在散列表中均匀分布,即便扩容也是如此。看下面一段代码:


  public class MagicHashCode {
//ThreadLocal中定义的魔数
private static final int HASH_INCREMENT = 0x61c88647;

public static void main(String[] args) {
hashCode(16);//初始化16
hashCode(32);//2倍扩容
hashCode(64);
}

private static void hashCode(int length){
int hashCode = 0;
for(int i=0;i<length;i++){
hashCode = i*HASH_INCREMENT+HASH_INCREMENT;
System.out.print(hashCode & (length-1));//求取模后的下标
System.out.print(" ");
}
System.out.println();
}
}

输出结果为:


7 14 5 12 3 10 1 8 15 6 13 4 11 2 9 0   //容量为16时
7 14 21 28 3 10 17 24 31 6 13 20 27 2 9 16 23 30 5 12 19 26 1 8 15 22 29 4 11 18 25 0 //容量为32时
7 14 21 28 35 42 49 56 63 6 13 20 27 34 41 48 55 62 5 12 19 26 33 40 47 54 61 4 11 18 25 32 39 46 53 60 3 10 17 24 31 38 45 52 59 2 9 16 23 30 37 44 51 58 1 8 15 22 29 36 43 50 57 0 //容量为64时

因为ThreadLocalMap使用线性探测法解决冲突(下文会看到),均匀分布的好处在于发生了冲突也能很快找到空的slot,提高效率。


瞄一眼成员变量:


       /**
* 初始容量,必须是2的幂。这样的话,方便把取模运算转化为与运算,
* 效率高
*/
private static final int INITIAL_CAPACITY = 16;

/**
* 容纳Entry元素,长度必须是2的幂
*/
private Entry[] table;

/**
* table中的元素个数.
*/
private int size = 0;

/**
* table里的元素达到这个值就需要扩容了
* 其实是有个装载因子的概念的
*/
private int threshold; // Default to 0

构造函数:


  ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY];
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}

firstKey和firstValue就是Map存放的第一个键值对喽。其中firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1)很关键,就是当容量为2的幂时候,这相当于一个取模操作。然后把Entry存储到数组的第i个位置,设置扩容的阈值。


private void setThreshold(int len) {
threshold = len * 2 / 3;
}

这说明当数组里的元素容量达到2/3时候就要扩容,也就是装载因子是2/3。
接下来我们来看下Entry


 static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}

就这么点东西,这个Entry只是与HashMap不同,只是个普通的键值对,没有链表结构相关的东西。另外Entry只持有对键,也就是ThreadLocal的弱引用,那么我们上面的第二个问题算是有答案了。当没有其他强引用指向ThreadLocal的时候,它其实是会被回收的。但是这有引出了另外一个问题,那Entry呢?当键都为空的时候这个Entry也是没有什么作用啊,也应该被回收啊。不慌,我们接着往下看。


set方法:


 private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
//如果冲突的话,进入该循环,向后探测
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
//判断键是否相等,相等的话只要更新值就好了
if (k == key) {
e.value = value;
return;
}

if (k == null) {
//该Entry对应的ThreadLocal已经被回收,执行replaceStaleEntry并返回
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
//进行启发式清理,如果没有清理任何元素并且表的大小超过了阈值,需要扩容并重哈希
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}

我们发现如果发生冲突的话,整体逻辑会一直调用nextIndex方法去探测下一个位置,直到找到没有元素的位置,逻辑上整个表是一个环形。下面是nextIndex的代码,就是加1而已。


private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}

线性探测的过程中,有一种情况是需要清理对应Entry的,也就是Entry的key为null,我们上面讨论过这种情况下的Entry是无意义的。因此调用
replaceStaleEntry(key, value, i);在看replaceStaleEntry(key, value, i)我们先明确几个问题。采用线性探测发解决冲突,在插入过程中产生冲突的元素之前一定是没有空的slot的。这样在也确保在查找过程,查找到空的slot就可以停止啦。但是假如我们删除了一个元素,就会破坏这种情况,这时需要对表中删除的元素后面的元素进行再散列,以便填上空隙。


空slot:即该位置没有元素
无效slot:该位置有元素,但key为null


replaceStaleEntry除了将value放入合适的位置之外,还会在前后连个空的slot之间做一次清理expungeStaleEntry,清理掉无效slot。


private void replaceStaleEntry(ThreadLocal<?> key, Object value,
int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;

// 向前扫描到一个空的slot为止,找到离这个空slot最近的无效slot,记录为slotToExpunge
int slotToExpunge = staleSlot;
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len)) {
if (e.get() == null) {
slotToExpunge = i;
}
}

// 向后遍历table
for (int i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();

// 找到了key,将其与无效slot交换
if (k == key) {
// 更新对应slot的value值
e.value = value;
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
//如果之前还没有探测到过其他无效的slot
if (slotToExpunge == staleSlot) {
slotToExpunge = i;
}
// 从slotToExpunge开始做一次连续段的清理,再做一次启发式清理
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}

// 如果当前的slot已经无效,并且向前扫描过程中没有无效slot,则更新slotToExpunge为当前位置
if (k == null && slotToExpunge == staleSlot) {
slotToExpunge = i;
}
}

// 如果key之前在table中不存在,则放在staleSlot位置
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);

// 在探测过程中如果发现任何其他无效slot,连续段清理后做启发式清理
if (slotToExpunge != staleSlot) {
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
}

expungeStaleEntry主要是清除连续段之前无效的slot,然后对元素进行再散列。返回下一个空的slot位置。


 private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;

// 删除 staleSlot
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;

Entry e;
int i;
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
//对元素进行再散列
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}

启发式地清理:
i对应是非无效slot(slot为空或者有效)
n是用于控制控制扫描次数
正常情况下如果log n次扫描没有发现无效slot,函数就结束了。
但是如果发现了无效的slot,将n置为table的长度len,做一次连续段的清理,再从下一个空的slot开始继续扫描。
这个函数有两处地方会被调用,一处是插入的时候可能会被调用,另外个是在替换无效slot的时候可能会被调用, 区别是前者传入的n为实际元素个数,后者为table的总容量。


private boolean cleanSomeSlots(int i, int n) {
boolean removed = false;
Entry[] tab = table;
int len = tab.length;
do {
// i在任何情况下自己都不会是一个无效slot,所以从下一个开始判断
i = nextIndex(i, len);
Entry e = tab[i];
if (e != null && e.get() == null) {
// 扩大扫描控制因子
n = len;
removed = true;
// 清理一个连续段
i = expungeStaleEntry(i);
}
} while ((n >>>= 1) != 0);
return removed;
}

接着看set函数,如果循环过程中没有返回,找到合适的位置,插入元素,表的size增加1。这个时候会做一次启发式清理,如果启发式清理没有清理掉任何无效元素,判断清理前表的大小大于阈值threshold的话,正常就要进行扩容了,但是表中可能存在无效元素,先把它们清除掉,然后再判断。


private void rehash() {
// 全量清理
expungeStaleEntries();
//因为做了一次清理,所以size可能会变小,这里的实现是调低阈值来判断是否需要扩容。 threshold默认为len*2/3,所以这里的threshold - threshold / 4相当于len/2。
if (size >= threshold - threshold / 4) {
resize();
}
}

作用即清除所有无效slot


private void expungeStaleEntries() {
Entry[] tab = table;
int len = tab.length;
for (int j = 0; j < len; j++) {
Entry e = tab[j];
if (e != null && e.get() == null) {
expungeStaleEntry(j);
}
}
}

保证table的容量len为2的幂,扩容时候要扩大2倍


private void resize() {
Entry[] oldTab = table;
int oldLen = oldTab.length;
int newLen = oldLen * 2;
Entry[] newTab = new Entry[newLen];
int count = 0;
for (int j = 0; j < oldLen; ++j) {
Entry e = oldTab[j];
if (e != null) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null;
} else {
// 扩容后要重新放置元素
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null) {
h = nextIndex(h, newLen);
}
newTab[h] = e;
count++;
}
}
}

setThreshold(newLen);
size = count;
table = newTab;
}

get方法:


private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
// 对应的entry存在且key未被回收
if (e != null && e.get() == key) {
return e;
} else {
// 继续往后查找
return getEntryAfterMiss(key, i, e);
}
}

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
// 不断向后探测直到遇到空entry
while (e != null) {
ThreadLocal<?> k = e.get();
// 找到
if (k == key) {
return e;
}
if (k == null) {
// 该entry对应的ThreadLocal实例已经被回收,调用expungeStaleEntry来清理无效的entry
expungeStaleEntry(i);
} else {
// 下一个位置
i = nextIndex(i, len);
}
e = tab[i];
}
return null;
}

remove方法,比较简单,在table中找key,如果找到了断开弱引用,做一次连续段清理。


private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len - 1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
//断开弱引用
e.clear();
// 连续段清理
expungeStaleEntry(i);
return;
}
}
}

ThreadLocal与内存泄漏


从上文我们知道当调用ThreadLocalMap的set或者getEntry方法时候,有很大概率会去自动清除掉key为null的Entry,这样就可以断开value的强引用,使对象被回收。但是如果如果我们之后再也没有在该线程操作过任何ThreadLocal实例的set或者get方法,那么就只能等线程死亡才能回收无效value。因此当我们不需要用ThreadLocal的变量时候,显示调用ThreadLocal的remove方法是一种好的习惯。


小结



  • ThredLocal为每个线程保存一个自己的变量,但其实ThreadLocal本身并不存储变量,变量存储在线程自己的实例变量ThreadLocal.ThreadLocalMap threadLocals

  • ThreadLocal的设计并不是为了解决并发问题,而是解决一个变量在线程内部的共享问题,在线程内部处处可以访问

  • 因为每个线程都只会访问自己ThreadLocalMap 保存的变量,所以不存在线程安全问题

作者:三雒
链接:https://juejin.cn/post/7138777386459332621
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

一类有趣的无限缓存OOM现象

OOM
首先想必大家都知道OOM是啥吧,我就不扯花里胡哨的了,直接进入正题。先说一个背景故事,我司app扫码框架用的zxing,在很长一段时间以前,做过一系列的扫码优化,稍微列一下跟今天主题相关的改动:串行处理改成并发处理,zxing的原生处理流程是通过CameraM...
继续阅读 »

首先

想必大家都知道OOM是啥吧,我就不扯花里胡哨的了,直接进入正题。先说一个背景故事,我司app扫码框架用的zxing,在很长一段时间以前,做过一系列的扫码优化,稍微列一下跟今天主题相关的改动:

  1. 串行处理改成并发处理,zxing的原生处理流程是通过CameraManager获取到一帧的数据之后,通过DecodeHandler去处理,处理完成之后再去获取下一帧,我们给改成了线程池去调度:
  • 单帧decode任务入队列之后立即获取下一帧数据
  • 二维码识别成功则停止其他解析任务
  1. 为了有更大的识别区域,选择对整张拍摄图片进行解码,保证中心框框没对准二维码也能识别到

现象

当时测试反馈,手上一个很古老的 Android 5.0 的机器,打开扫一扫必崩,一看错误栈,是个OOM

机器找不到了,我就不贴现象的堆栈了(埋在时光里了,懒得挖了)。

排查OOM三板斧

板斧一、 通过一定手段,抓取崩溃时的或者崩溃前的内存快照

咦,一年前的hprof文件还在?确实被我找到了。。。

从图中我们能获得哪些信息?

  1. 用户OOM时,byte数组的 java 堆占用是爆炸的

  2. 用户OOM时,byte数组里,有大量的 3M 的byte数组

  3. 3Mbyte 数组是被 zxing 的 DecodeHandler$2 引用的

板斧二、从内存对照出发,大胆猜测找到坏死根源

我们既然知道了 大对象 是被 DecodeHandler$2 引用的,那么 DecodeHandler$2 是个啥呀?

mDecodeExecutor.execute(new Runnable() {
@Override
public void run() {
for (Reader reader : mReaders) {
decodeInternal(data, width, height, reader, fullScreenFrame);
}
}
});

所以稍微转动一下脑瓜子就能知道,必然是堆积了太多的 Runnable,每个Runnable 持有了一个 data 大对象才导致了这个OOM问题。

但是为啥会堆积太多 Runnable 呢?结合一下只有 Android 5.0 机器会OOM,我们大胆猜测一下,就是因为这个机器消费(或者说解码)单张 Bitmap 太慢,同时像上面所说的,我们单帧decode任务入队列之后立即获取下一帧数据并入队下一帧decode 任务,这就导致大对象堆积在了LinkedBlockingDeque中。

OK,到这里原因也清楚了,改掉就完事了。

板斧三、 吃个口香糖舒缓一下心情

呵呵...

解决方案

解决方案其实很简单,从问题出发即可,问题是啥?我生产面包速度是一天10个,一个一斤,但是一天只能吃三斤,那岂不就一天就会多7斤囤货,假如囤货到了100斤地球会毁灭,怎么解决呢?

  1. 吃快点,一天吃10斤
  2. 少生产点,要么生产个数减少,要么生产单个重量减少,要么二者一起
  3. 生产前检查一下吃完没,吃完再生产都来得及,实在不行定个阈值觉得不够吃了再生产嘛。

那么自然而然的就大概知道有哪几种解决办法了:

  1. 生产的小点 - 隔几帧插一张全屏帧即可(如果要保留不在框框内也能解码的特性的话)
  2. 生产前检查一下吃完没 - 线程池的线程空闲时,才去 enqueue decode 任务
  3. 生产单个重量减少 - 限制队列大小
  4. blalala

总结

装模作样的总结一下。

这个例子是一年前遇到的,今天想水篇文章又突然想到了这个事就拿来写写,我总结为:线程池调度 + 进阻塞队列单任务数据过大 + 处理任务过慢

线程池调度任务是啥场景?

  • 有个 Queue,来了任务,先入队
  • 有个 ThreadPool ,空闲了,从 Queue 取任务。

那么,当入队的数据结构占内存太大,且 ThreadPool 处理速度小于 入队速度呢?就会造成 Queue 中数据越来越多,直到 OOM

扫一扫完美的满足了上面条件

  • 入队频率足够高

  • 入队对象足够大

  • 处理速度足够慢。

在这个例子中,做的不足的地方:

  1. 追求并发未考虑机器性能

  2. 大对象处理不够谨慎

当然,总结是为了避免未来同样的惨案发生,大家可以想想还会有什么类似的场景吧,转动一下聪明的小脑袋瓜~

未来展望

装模作样展望一下,未来展望就是,以后有空多水水贴子吧(不是多水水贴吧)。


作者:邹阿涛涛涛涛涛涛
链接:https://juejin.cn/post/7141301214523686926
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »