注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

【Ktor挖坑日记】还在用Retrofit网络请求吗?试试Ktor吧!

Ktor官方对Ktor的描述是: Create asynchronous client and server applications. Anything from microservices to multiplatform HTTP client app...
继续阅读 »

Ktor官方对Ktor的描述是:



Create asynchronous client and server applications. Anything from microservices to multiplatform HTTP client apps in a simple way. Open Source, free, and fun!



创建异步客户端和和服务器应用,从微服务到多平台HTTP客户端应用程序都可以用一种简单的方式完成。开源、免费、有趣!


它具有轻量级+可扩展性强+多平台+异步的特性。




  • 轻量级和可扩展性是因为它的内核比较简单,并且当需要一些功能的时候可以加入别的插件到项目中,并不会造成功能冗余。并且Ktor的扩展是使用插拔的方式,使用起来非常简单!




  • 异步,Ktor内部是使用Kotlin协程来实现异步,这对于熟悉Kotlin的Android开发非常友好。




看到这里可能一头雾水,下面将用一个比较简单的例子来带大家入坑Ktor!等看完这篇文章之后就会对Ktor的这些特性有进一步的了解。


小例子 —— 看猫咪


序列 01.gif


引入依赖


在app模块的gradle中引入依赖


plugins { 
...
id 'org.jetbrains.kotlin.plugin.serialization' version "1.7.10" // 跟Kotlin版本一致
}

dependencies {
...
// Ktor
def ktor_version = "2.1.0"
implementation "io.ktor:ktor-client-core:$ktor_version"
implementation "io.ktor:ktor-client-android:$ktor_version"
implementation "io.ktor:ktor-client-content-negotiation:$ktor_version"
implementation "io.ktor:ktor-serialization-kotlinx-json:$ktor_version"
}

稍微解释一下这两个依赖




  1. Ktor的客户端内核




  2. 由于本APP是部署在Android上的,因此需要引入一个Android依赖,Android平台和其他平台的不同点在于Android具有主线程的概念,Android不允许在主线程发送网络请求,而在Kotlin协程中就是主调度器的概念,其内部是post任务到主线程Handler中,这里就不展开太多。当然如果要使用OkHttp也是可以的!


    implementation "io.ktor:ktor-client-okhttp:$ktor_version"

    如果想应用到其他客户端平台可以使用CIO




  3. 第三个简单来说就是数据转换的插件,例如将远端发送来的数据(可以是CBOR、Json、Protobuf)转换成一个个数据类。




  4. 而第四个就是第三个的衍生插件,相信用过kotlin-serialization的人会比较熟悉,是Kotlin序列化插件,本次引用的是json,类似于Gson,可以将json字符串转换成数据类。




当然,如果需要其他插件可以到官网上看看,例如打印日志Logging


implementation "ch.qos.logback:logback-classic:$logback_version"
implementation "io.ktor:ktor-client-logging:$ktor_version"

创建HttpClient


首先创建一个HttpClient实例


val httpClient = HttpClient(Android) {
defaultRequest {
url {
protocol = URLProtocol.HTTP
host = 你的host
port = 你的端口
}
}
install(ContentNegotiation) {
json()
}
}

创建的时候是使用DSL语法的,这里解释一下其中使用的两个配置




  • defaultRequest:给每个HTTP请求加上BaseUrl


    例如请求"/get-cat"就会向"http://${你的host}:${你的端口}/get-cat"发起HTTP请求。




  • ContentNegotiation:引入数据转换插件。




  • json:引入自动将json转换数据类的插件。




定义数据类


@Serializable
data class Cat(
val name: String,
val description: String,
val imageUrl: String
)

此处给猫咪定义名字、描述和图片url,需要注意的是需要加上@Serializable注解,这是使用kotlin-serialization的前提条件,而需要正常使用kotlin-serialization,需要在app模块的build.gradle加上以下plugin


plugins {
...
id 'org.jetbrains.kotlin.plugin.serialization' version "1.7.10" // 跟Kotlin版本一致
}

创建API


interface CatSource {

suspend fun getRandomCat(): Result<Cat>

companion object {
val instance = CatSourceImpl(httpClient)
}
}

class CatSourceImpl(
private val client: HttpClient
) : CatSource {

override suspend fun getRandomCat(): Result<Cat> = runCatching {
client.get("random-cat").body()
}

}

此处声明一个CatSource接口,接口中声明一个获取随机小猫咪的函数,并且对该接口进行实现。




  • suspend:HttpClient的方法大多数为suspend函数,例如例子中的get为suspend函数,因此接口也要定义成suspend函数。




  • Result:Result为Kotlin官方包装类,具有successfailure两个方法,可以包装成功和失败两种数据,可以简单使用runCatching来返回Result


    @InlineOnly
    @SinceKotlin("1.3")
    public inline fun <T, R> T.runCatching(block: T.() -> R): Result<R> {
    return try {
    Result.success(block())
    } catch (e: Throwable) {
    Result.failure(e)
    }
    }



  • body:获取返回结果,由于内部协程实现,因此不用担心阻塞主线程的问题,由于引入了ContentNegotiation,因此获取到结果之后可以对其进行转换,转换成实际数据类。




展示


ViewModel


class MainViewModel : ViewModel() {

private val catSource = CatSource.instance

private val _catState = MutableStateFlow<UiState<Cat>>(UiState.Loading)
val catState = _catState.asStateFlow()

init {
getRandomCat()
}

fun getRandomCat() {
viewModelScope.launch {
_catState.value = UiState.Loading
// fold 方法可以用来对 Result 的结果分情况处理
catSource.getRandomCat().fold(
onSuccess = {
_catState.value = UiState.Success(it)
}, onFailure = {
_catState.value = UiState.Failure(it)
}
)
}
}
}

sealed class UiState<out T> {
object Loading: UiState<Nothing>()
data class Success<T>(val value: T): UiState<T>()
data class Failure(val exc: Throwable): UiState<Nothing>()
}

inline fun <T> UiState<T>.onState(
onSuccess: (T) -> Unit,
onFailure: (Throwable) -> Unit = {},
onLoading: () -> Unit = {}
) {
when(this) {
is UiState.Failure -> onFailure(this.exc)
UiState.Loading -> onLoading()
is UiState.Success -> onSuccess(this.value)
}
}

Activity


界面比较简单,因此用Compose实现


class MainActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
KittyTheme {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp)
) {
val viewModel: MainViewModel = viewModel()
val catState by viewModel.catState.collectAsState()
catState.onState(
onSuccess = { cat ->
AsyncImage(model = cat.imageUrl, contentDescription = cat.name)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = cat.name,
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp
)
Spacer(modifier = Modifier.height(8.dp))
Text(text = cat.description)
},
onFailure = {
Text(text = "Loading Failure!")
},
onLoading = {
CircularProgressIndicator()
}
)

Button(
onClick = viewModel::getRandomCat,
modifier = Modifier.align(Alignment.End)
) { Text(text = "Next Cat!") }

Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
}



  • 对state分情况展示




    • 加载中就展示转圈圈。




    • 成功就展示猫咪图片、猫咪名字、猫咪描述。




    • 失败就展示加载失败。






  • 展示图片的AsyncImage来自于Coil展示库,传入imageUrl就好啦,使用Kotlin编写,内部使用协程实现异步。




我们运行一下吧!


image.png


总结一下


是不是很简单捏!看起来好像很多,其实核心用法就三个




  • 实例HttpClient




  • 在HttpClient中配置插件




  • 调用get或者post方法




由于内部使用了协程来进行异步,因此不用担心主线程阻塞!令我觉得比较香的是数据转换插件,可以再也不用担心数据转换了。并且支持例如XML、CBOR、Json等等,也不会担心后端会给我们发来什么数据格式了。


还有一个文中没有用到的是Logging插件,可以在logcat打印给服务端发了什么,服务端给客户端发了什么,调试API起来也很方便,跟后端拉扯起来也很有底气!


另外,Android插件不支持WebSocket,但是Okhttp和CIO支持!实际使用中可以用后者创建httpClient!


服务端


创建项目


服务端不是重点就简单提一下,贴一下代码,使用IntelliJ IDEA Ultimate可以直接创建Ktor工程,要是用社区版就去ktor.io/create/创建。



  1. 工程名字。


image2.png


2. 配置插件,官方很多插件,不用想着一下子就添加完,需要用的时候再像客户端一样引入依赖就好。


image3.png


3. 创建项目,下载打开。


编写代码


到Application.kt看一下主函数


fun main() {
embeddedServer(Netty, port = 你的端口, host = "0.0.0.0") {
configureRouting()
configureSerialization()
}.start(wait = true)
}



  • 配置Routing插件


    fun Application.configureRouting() {
    routing {
    randomCat()
    static {
    resources("static")
    }
    }
    }

    fun Route.randomCat() {
    get("/random-cat") {
    // 随便回一直猫咪给客户端
    call.respond(cats.random())
    }
    }

    //本地IPV4地址
    private const val BASE_URL = "http://${你的host}:${你的端口}"

    private val cats = listOf(
    Cat("夺宝1号", "这是一只可爱的小猫咪", "$BASE_URL/cats/cat1.jpg"),
    Cat("夺宝2号", "这是一只可爱的小猫咪", "$BASE_URL/cats/cat2.jpg"),
    Cat("夺宝3号", "这是一只可爱的小猫咪", "$BASE_URL/cats/cat3.jpg"),
    Cat("夺宝4号", "这是一只可爱的小猫咪", "$BASE_URL/cats/cat4.jpg"),
    Cat("夺宝5号", "这是一只可爱的小猫咪", "$BASE_URL/cats/cat5.jpg"),
    Cat("夺宝6号", "这是一只可爱的小猫咪", "$BASE_URL/cats/cat6.jpg"),
    Cat("夺宝7号", "这是一只可爱的小猫咪", "$BASE_URL/cats/cat7.jpg"),
    )

    @Serializable
    data class Cat(
    val name: String,
    val description: String,
    val imageUrl: String
    )



  • 配置Serialization插件


    fun Application.configureSerialization() {
    install(ContentNegotiation) {
    json()
    }
    }



  • 放入图片资源,我放了七只猫咪图片。




image4.png


然后跑起来就好啦!去手机上看看效果吧!


又总结一次


客户端和服务端使用方式是比较相似的,这也非常友好,由于也是使用Kotlin作为后端,那很多代码都可以拷贝了,例如文中的数据类Cat甚至可以直接拷贝过来。Ktor用起来非常方便,由于其Okhttp插件的存在,在全Kotlin的Android项目中甚至可以考虑Ktor而不是Retrofit(当然Retrofit也是非常优秀的网络请求库)。关于Ktor的坑先开到这啦!


参考


ktor.io/


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

六年安卓开发的技术回顾和展望

本文字数:7190 字,阅读完需:约 5 分钟大家好,我是 shixin。一转眼,我从事安卓开发工作已经六年有余,对安卓开发甚至软件开发的价值,每年都有更进一步的认识。对未来的方向,也从刚入行的迷茫到现在逐渐清晰。我想是时候做一个回顾和展望了。这篇文章会先回顾...
继续阅读 »

本文字数:7190 字,阅读完需:约 5 分钟

大家好,我是 shixin。

一转眼,我从事安卓开发工作已经六年有余,对安卓开发甚至软件开发的价值,每年都有更进一步的认识。对未来的方向,也从刚入行的迷茫到现在逐渐清晰。我想是时候做一个回顾和展望了。

这篇文章会先回顾我从入行至今的一些关键点,然后讲一下经过这些年,我对软件开发的认知变化,最后分享一下后面的规划。

回顾

人太容易在琐碎生活中迷失,我们总是需要记住自己从哪里来,才能清楚要到哪里去。

入行至今的一些关键节点

2014~2015:开始安卓开发之旅

说起为什么做安卓开发,我很有感慨,差一点就“误入歧途”😄。

当初在大学时,加入了西电金山俱乐部,俱乐部里有很多方向:后端、前端、安卓、Windows Phone 等。


由于我当时使用的是三星 i917,WindowsPhone,所以就选了 WinPhone 方向。

当时还是 iOS、安卓、WinPhone、塞班四足鼎立的时代,WinPhone 的磁贴式设计我非常喜欢,加上设备的流畅性、像素高,一度让我觉得它可能会统治移动市场。

结果在学习不到 2 个月以后,我的 WinPhone 意外进水了!我当时非常难过,一方面是对手机坏了的伤痛,另一方面也是对无法继续做 WinPhone 开发很遗憾。对于当时的我来说,再换一台 WinPhone 过于昂贵,只好换一台更加便宜的安卓机,因此也就转向学习安卓开发。

后面的故事大家都知道了,因为 WindowsPhone 缺乏良好的开发生态,支持应用很少,所以用户也少,用户少导致开发者更少,恶性循环,如今市场份额已经少的可怜。

现在回想起来,对于这件事还很有感慨,有些事当时觉得是坏事,拉长时间线去看,未必是这样。

当时还有一件目前看来非常重要的决定:开始写博客,记录自己的所学所得。

在开发项目时,我经常需要去网上搜索解决方案,后来搜索的多了,觉得总不能一直都是索取,我也可以尝试去写一下。于是在 CSDN 注册了账号,并于 2014 年 10 月发布了我的第一篇原创文章

后来工作学习里新学到什么知识,我都会尽可能地把它转换成别人看得懂的方式,写到播客里。这个不起眼的开始,让我逐渐有了解决问题后及时沉淀、分享的习惯,受益匪浅。

2015~2017:明白项目迭代的全流程

在学习安卓开发时,我先看了一本明日科技的《Android 从入门到精通》,然后看了些校内网的视频,逐渐可以做一些简单的应用。安卓开发所见即所得的特点,让我很快就可以得到正反馈。后来又去参加一些地方性的比赛,获得一些名次,让我逐渐加强了从事这个行业的信心。


在 2015 年时,偶然参加了一家公司的招聘会,在面试时,面试官问了一些简单的 Java 、安卓和算法问题。其中印象最深的就是会不会使用四大组件和 ListView。在当时移动互联网市场飞速发展时,招聘要求就是这么低。以至于现在很多老安卓回忆起当初,都很有感慨:“当初会个 ListView 就能找工作了,现在都是八股文” 哈哈。

到公司实习后,我感触很多,之前都是自己拍脑袋写一些简单的功能,没有开发规范、发布规范,也没有工程结构设计、系统设计,更没有考虑性能是否有问题。真正的去开发一个商业项目,让我发现自己不足的太多了。


因此在完成工作的同时,我观察并记录了项目迭代的各个流程,同时对自己的技术点做查漏补缺,输出了一些 Java 源码分析、Android 进阶、设计模式文章,也是从那个时候开始,养成了定期复盘的习惯,每次我想回顾下过去,都会看看我的成长专栏

2017~2020:提升复杂项目的架构能力和做事意识

第一个项目中我基本掌握了从 0 到 1 开发一个安卓应用的流程,但对安卓项目架构还只停留在表面,没有足够实践。

在 2017 年,我开始做喜马拉雅直播项目,由于喜马拉雅在当时已经有比较多年的技术积累,加上业务比较复杂,在架构设计、编译加速、快速迭代相关都做了比较多的工作,让我大饱眼福。

同时直播业务本身也是比较复杂的,在一个页面里会集成 IM、推拉流等功能,同时还有大量的消息驱动 UI 刷新操作,要保证业务快速迭代,同时用户体验较好,需要下不少功夫。

为了能够提升自己的技术,在这期间我学习了公司内外很多框架的源码,通过分析这些框架的优缺点、核心机制、架构层级、设计模式,对如何开发一个框架算是有了基本的认识,也输出了一些文章,比如 《Android 进阶之路:深入理解常用框架实现原理》


有了这些知识,再去做复杂业务需求、基础框架抽取、内部 SDK 和优化,就容易多了。

在开发一些需求或者遇到复杂的问题时,我会先想想,之前看的这些三方框架或者系统源码里有没有类似的问题,它们是怎么解决的? 比如开发 PK 功能,这个需求的复杂性在于业务流程很多,分很多状态,咋一看好像很复杂,但如果了解了状态机模式,就会发现很简单。借用其他库的设计思路帮我解决了很多问题,这让我确信了学习优秀框架源码的价值

除了技术上的提升,在这几年里,我的项目全局思考能力也提升很多。

由于我性格外向,和各个职能的同学沟通交流比较顺畅,领导让我去做一个十人小组的敏捷组长,负责跟进需求的提出、开发、测试、上线、运营各个环节,保证项目及时交付并快速迭代。

一开始我还有些不习惯,写代码时总是被不同的人打断,比如产品需求评审、测试 bug 反馈、运营反馈线上数据有问题等等,经常刚想清楚代码怎么写,正准备动手,就被叫去开会,回来后重新寻找思路。

后来在和领导沟通、看一些书和分享后,逐渐对写代码和做事,有了不同的认识。代码只是中间产物,最终我们还是要拿到对用户有价值、给公司能带来收入的产品,要做到这个,眼里除了代码,还需要关注很多。

2020~至今:深入底层技术

在进入字节做基础技术后,我的眼界再一次被打开。

字节有多款亿级用户的产品,复杂的业务常常会遇到各种意想不到的问题,这些问题需要深入底层,对安卓系统的整个架构都比较熟悉,才能够解决。


上图是安卓系统架构图,之前我始终停留在一二层,在这一时期,终于有了纵深的实践经验。

比如帮业务方解决一个内存问题,除了要了解内存指标监控方式,还要知道分析不同类型内存使用的工具及基本原理,最后知道是哪里出了问题后,还要想如何进行体系化的工具,降低学习成本,提升排查效率。

问题驱动是非常好的学习方式。每次帮助业务解决一个新问题,我的知识库都会多一个点,这让我非常兴奋。之前不知道学来干什么的 Linux 编程、Android 虚拟机,终于在实际问题中明白了使用场景,学起来效率也高了很多。

对软件开发的认识

前面讲了个人的一些经历,包括我怎么入的行,做了什么项目,过程中有什么比较好的实践。下面讲一下我从这些具体的事里面,沉淀出哪些东西有价值的结论。

主要聊下对这两点的认识:

  • 职业发展的不同阶段

  • 技术的价值

职业发展的不同阶段

第一点是对职业发展的认识。我们在工作时,要对自己做的事有一个清晰的认识,它大概属于哪一个阶段,怎样做可以更好。

结合我这些年的工作内容、业内大佬所做的事情,我把软件开发者的职业发展分这几个阶段:

  1. 使用某个技术方向的一个点开发简单项目

  2. 使用某个技术方向的多个点及某条线,开发一个较为复杂的业务或系统

  3. 掌握某个方向的通用知识,有多个线的实践,可以从整体上认识和规划

  4. 不限于该方向,能从产品指标方面出发,提供全方位的技术支持业务角度,端到端关注指标

第一个阶段就是使用某个技术方向的一个点完成业务需求。拿安卓开发者来说,比如使用 Android SDK 自定义布局,完成产品要求的界面功能。这个阶段比较简单,只要能够仔细学习官方文档或者看一些书即可胜任。拿后端来说,比如刚接手一个小项目,日常工作就是使用 Spring 等库开发简单的接口,不涉及到上下游通信、数据库优化等。

第二个阶段,你做的项目更加复杂了,会涉及到一个技术方向的多个点,这时你需要能把这些点连起来,给出一个更体系化的解决方案。

拿安卓开发者来说,比如在自定义布局时,发现界面很卡顿,要解决这个问题的话,你就要去了解这个自定义 View 的哪些代码流程影响了这个页面的刷新速度。这就相当于是从一个点到另一个点。怎么连起来呢?你需要去研究渲染的基本原理,分析卡顿的工具,找到导致卡顿的原因,进行优化。这个过程会对流畅性有整体的认识,能够对相关问题有比较全面的分析思路、解决手段,从而可以开发相关的分析工具或优化库。 如果能达到这个程度,基本就算是一个高级工程师了,不只是做一个模块,还能够负责一个具体细分方向的工作。

第三个阶段,掌握某个技术方向的通用知识,有多个线的实践,能够连线为面,同时给工作做中长期的技术规划。

拿安卓开发来说,刚才提到你通过解决卡顿问题,在流畅性这方面有了比较多的实践;然后你又发现内存有问题,去了解了内存分配、回收原理,做出内存分析优化工具,这样就也有了内存的一个体系化的实践。再加一些其他的优化经验,比如启动速度、包大小等。把这些线连起来,就得到了一个性能监控平台,这就是有把多条线连成一个面。

还有比如说你发现项目打包和发布过程中的一些痛点,并且能够做一些实践解决,最后如果能够把这些优化项连起来做一个统一的系统,给出完整的 DevOps 方案,提升开发、发布、运维的效率。能够把这个系统搭建起来,有比较深入的经验,那就可以成为“技术专家”了。

再往上走就不只是做技术,而要更多思考业务。技术最终都是要为业务服务。职业发展的第四个阶段,就是不局限于某个技术方向,能够从产品的业务规划、业务指标出发,给产品提供技术支持。

你首先要明白公司业务的核心指标是什么,比如说拿一个短视频应用来说,它核心指标除了常规的日活、用户量,还更关注视频的播放率、停留时长、页面渗透率等。了解这些指标以后,你要思考做什么可以有助于公司提升这些指标。结合业务指标反思当前的项目哪里存在优化空间。

有了这个思路并且知道可以做什么以后,你可以做一个较为全面的规划,然后拉领导去讨论可行性。这时你不能再局限于某一端,不能说我只是个安卓开发,其他部分都找别人做。一般在项目的价值没有得到验证之前,领导不会轻易给你资源,因此第一个版本迭代肯定是要靠你自己,从前到后独立完成,做一个 MVP 版本,然后让领导认可了这个系统的价值,才有可能会分给你更多的资源做这件事。

总结一下对职业发展的认识:第一阶段只做一些具体的点;第二阶段做多个点,需要能够连点成线;第三个阶段需要围绕这些线提炼出通用的知识,然后做到对业务/技术项目有整体的认识;第四阶段能够从业务指标出发,做出有价值的系统/平台。

技术的价值

说完职业发展的不同阶段,接下来聊下技术对业务的价值。

技术是为业务服务的。根据业务的不同阶段,技术的价值也有所不同:

  1. 业务从 0 到 1 时,帮助业务快速确定模式

  2. 业务从 1 到 100 时,帮助业务快速扩大规模

  3. 最卓越的,用技术创新带动业务有新的发展 (Google、AWS、阿里云)

业务从 0 到 1 时

我一开始做的工作,业务就是处于确定模式期间。业务上反复试错,项目常常推倒重来,会让程序员觉得很有挫败感。

这个阶段很多程序员都会发挥复制粘贴大法,产品经理说要新增一个功能,就复制一份代码稍微改一改。

如果说目前就是在这种业务中,该怎么做呢?如果我回到当时那个情景,我可以做什么让公司业务变得更好呢?

我总结了两点:在高效高质量完成业务的同时,思考如何让业务试错成本更低。

如何让业务试错成本更低呢?大概可以有这些方式:

  • 提供可复用的框架

  • 提供便捷的数据反馈机制

  • 多了解一些竞品业务,在产品不确定的时候,给一些建议

第一点:尽可能的抽象相似点,减少重复成本。

如果产品每次都给你类似的需求,你可以考虑如何把这些重复需求抽象成一些可以复用的逻辑,做一个基本的框架,然后在下次开发的时候能够去直接用框架,而不是每次都从头开始。我平时工作也常常问自己“我现在做的事有哪些是重复的,哪些是可以下沉的”。

就安卓开发来说,这个阶段,可以做好基础建设,提供插件化、热修复、动态化框架,帮助业务快速发版,自研还是第三方看公司财力。

如果你说这些太复杂了我做不来,那就从更小的层面做起,比如某个功能原本需要多个接口多个界面,看能不能改成接口参数可配置,界面根据参数动态生成(也就是 DSL)。

第二点:提供便捷的数据反馈机制

在产品提需求时,你可以问问产品这个需求出于什么考虑,有没有数据支撑?比如说产品需求是某个按钮换个位置,那你要搞清楚,为什么要换,换完之后会导致页面打开率提升吗?要有这种数据驱动的理念。

如果公司做决策时缺乏相应的数据,你可以主动地去提供这种数据反馈机制。比如说开发一个埋点平台、数据监控平台。尽可能地让业务有数据可看,能够数据驱动,而不是像无头苍蝇一样盲目尝试。

如果无法做一个这么大的系统,那可以先从力所能及的做起,比如说战略上重视数据;做好数据埋点;思考做的功能,目前有哪些数据是核心的,这些数据有没有上报,不同版本的数据是升还是降等。

好,这是第一个阶段,技术对业务价值就是帮助业务快速确定模式。第二个阶段就是业务快速扩大规模时,技术的核心价值是什么呢?

业务从 1 到 100 时

业务正在快速扩大规模时,需要把当前跑通的业务模式复制到更多的地方,同时能够服务更多的用户。这个阶段,技术能够提供的价值主要是两点。

  1. 快速迭代(这一点其实无论什么阶段)

  2. 提升质量(用户规模日活上亿和日活一万,需要面对的挑战差异也是这个数量级)

第一点:快速迭代

虽然快速迭代是业务各个阶段都需要做到,但和从 0 到 1 相比,从 1 到 100 的阶段会有更多的挑战,除了个人速度,更要关注团队的速度。

团队的速度如何提升?可以参考后端的单体到微服务、前端的单仓到多仓的演变过程及原因。

这个阶段主要有这几点问题:

  1. 多人协作代码冲突

  2. 发布速度慢

  3. 出问题影响大,不好定位

具体到安卓项目,几百人开发和三两个人开发的,复杂度也是几百倍。我们可以做的是:

  1. 下沉基础组件,定义组件规范,收敛核心流程

  2. 拆分业务模块,设计业务模板,单独维护迭代

  3. 探索适合业务的新方式:跨端(RN Flutter KotlinMultiplatform)、动态化、多端逻辑一致(C/C++ Rust)

第二点:提升质量

和日活几万的项目相比,日活千万甚至上亿的产品,需要应对的质量问题更加显著。在这个阶段,我们不仅要满足于实现功能,还要能够写的好,更要能够了解底层原理,才能应对这样大的业务量。

有了大规模的用户后,你会遇到很多奇怪的问题,不能疲于每天去解决一样重复的问题,那你就需要从这些问题中找到一些共通的点,然后提炼出来,输出工具、解决方案甚至平台。

这就需要你从问题中磨练本领,站在更高的层面思考自己该具体的能力、思路和工具。

在解决问题的时候,除了当下这个问题,更需要做的是把这个问题解构、归类,抽象出不同问题的相似和差异,得出问题分析流程图。

同样是分析内存泄漏,有的人可能只知道使用 Leakcanary,但你还可以思考的更深入,比如:

  • 先定义问题。什么是泄露?

  • 泄露是申请了没有释放或者创建了没有回收

  • 内存泄露怎么分析?

  • 找到创建和销毁的点

  • 在创建的时候保存记录,销毁的时候删除这个记录,最终剩下来的就是泄露的

有了基础的逻辑,就可以把它套用到各种问题上:

  • Native 内存泄漏:在 Native 内存分配和释放 API,做记录

  • 图片使用不当:在图片创建、释放的 API 里做记录

  • 线程过多:在线程创建、释放的 API 里做记录

在遇到一个新问题时,发现和之前解决过的有点像,但又不知道哪里像。怎么办?回头去思考新旧的两个问题,它们的本质是什么?有什么相似的分析思路?

这个思考训练的目的,就是提升举一反三的能力。大规模应用可能各种问题,需要你一方面提升技术,另一方面分析问题的思路和能力上也要提升,不能看着一个问题就是一个问题,要做到看到一个问题,想到一类问题。

展望(后面的规划)

技术上达到一专多能,软实力上持续提升。

硬实力

专业

如果你是安卓开发,最好在某个有细分领域很擅长,比如音视频、跨端、动态化、性能优化。

我目前主要是做优化,后面需要继续补充的知识:

  • Linux 内核原理

  • Android 虚拟机原理

  • 项目从开发、编译、发布、数据分析各个流程的效率提升方式

多能

前面提到职业发展的第四个阶段:

不限于该方向,能从产品指标方面出发,提供全方位的技术支持

我希望可以具备独立完成一个系统从前到后的能力。

目前已有的经验:

  • 使用 TypeScript + React + Electron 开发桌面端软件

  • 使用 SpringMVC 开发简单的内部系统

后面需要加强的点:

  • 熟练掌握前端的 JS、打包、优化等知识

  • 后端技术达到中级

还有这些点需要长期关注:

  • Flutter 更新频繁,有一些尝试效果还不错,一套代码多端运行,节省开发成本

  • 掌握 DevOps 理念及实践

最终目的:

  • 具备独立完成一个有价值的系统的能力

  • 具备对研发整个流程的完善、优化能力

软实力

除了技术规划,我也有很多软实力需要继续提升,今年主要想提升的就是同频对话的能力。

什么是同频对话?

同频对话就是根据听众的角色和他的思考角度去转换你的表达内容。

比如说我们在和领导汇报的时候,你要去讲你做的一个系统,你就要从他角度去表达。他可能关注的是整体流程、系统的难点、瓶颈在哪里,带来的收益是什么。那你就不能只讲某个模块的细节,而要从更高的层面去思考和表达。

为什么要提升呢?

随着工作年限的增加,市场对我们的要求是越来越高的,除了写代码,对表达能力的要求也是越来越高的。

一开始刚入行,你就是做一个执行者,只要多动耳朵、眼睛、手,实现别人要求你做的功能。

后来你的能力逐渐提升以后,有机会设计一个模块的时候,你就需要多动脑力去思考,去复设计这个系统的输入输出、内部数据流转等。

再往后走的话,你可能会有一些资源,那就需要能把你的想法完整地表达出来,让别人帮你去贯彻落地。这其实是一种比较难得的能力。我今年计划通过多分享、多与不同的人交流等方式,提升自己的这种能力,争取做到满意的程度。

结束语

好了,这篇文章就到这里了,这就是我这六年的技术回顾和展望,感谢你的阅读❤️。

人生的多重境界:看山是山、看水是水;看山不是山、看水不是水;看山还是山、看水还是水。

我想,我对软件开发,还没有达到第三层,相信用不了多久,就会有不同的观点冒出来。

但,怕什么真理无穷,进一寸有一寸的欢喜!

作者:张拭心
来源:juejin.cn/post/7064960413280141348

收起阅读 »

我说MySQL每张表最好不超过2000万数据,面试官让我回去等通知?

事情是这样的下面是我朋友的面试记录:面试官:讲一下你实习做了什么。朋友:我在实习期间做了一个存储用户操作记录的功能,主要是从MQ获取上游服务发送过来的用户操作信息,然后把这些信息存到MySQL里面,提供给数仓的同事使用。由于数据量比较大,每天大概有四五千多万条...
继续阅读 »

事情是这样的

下面是我朋友的面试记录:

面试官:讲一下你实习做了什么。

朋友:我在实习期间做了一个存储用户操作记录的功能,主要是从MQ获取上游服务发送过来的用户操作信息,然后把这些信息存到MySQL里面,提供给数仓的同事使用。由于数据量比较大,每天大概有四五千多万条,所以我还给它做了分表的操作。每天定时生成3张表,然后将数据取模分别存到这三张表里,防止表内数据过多导致查询速度降低。

这表述,好像没什么问题是吧,别急,接着看:

面试官:那你为什么要分三张表呢,两张表不行吗?四张表不行吗?

朋友:因为MySQL每张表最好不超过2000万条数据,否则会导致查询速度降低,影响性能。我们每天的数据大概是在五千万条左右,所以分成三张表比较稳妥。

面试官:还有吗?

朋友: 没有了…… 你干嘛,哎呦

面试官:那你先回去等通知吧。

🤣🤣🤣讲完了,看出什么了吗,你们觉得我这位朋友回答的有什么问题吗?

前言

一般来说,MySQL每张表最好不要超过2000万条数据,否则就会导致性能下降。阿里的Java开发手册上也提出:单表行数超过 500 万行或者单表容量超过 2GB,才推荐进行分库分表。

但实际上,这个2000万或者500万都只是一个大概的数字,并不适用于所有场景,如果盲目的以为表数据只要不超过2000万条就没问题了,很可能会导致系统的性能大幅下降。

实际情况下,每张表由于自身的字段不同、字段所占用的空间不同等原因,它们在最佳性能下可以存放的数据量也就不同。

那么,该如何计算出每张表适合的数据量呢?别急,慢慢往下看。

本文适合的读者

阅读本文你需要有一定的MySQL基础,最好对InnoDB和B+树都有一定的了解,可能需要有一年以上的MySQL学习经验(大概一年?),知道 “InnoDB中B+树的高度一般保持在三层以内会比较好”。

本文主要是针对 “InnoDB中高度为3的B+树最多可以存多少数据” 这一话题进行讲解的。且本文对数据的计算比较严格(至少比网上95%以上的相关博文都要严格),如果你比较在意这些细节并且目前不太清楚的话,请继续往下阅读。

阅读本文你大概需要花费10-20分钟的时间,如果你在阅读的过程中对数据进行验算的话,可能要花费30分钟左右。


基础知识快速回顾

众所周知,MySQL中InnoDB的存储结构是B+树,B+树大家都熟悉吧?特性大概有以下几点,一起快速回顾一下吧!

注:下面这这些内容都是精华,看不懂或者不理解的同学建议先收藏本文,之后有知识基础了再回来看 。🤣🤣

  1. 一张数据表一般对应一颗或多颗树的存储,树的数量与建索引的数量有关,每个索引都会有一颗单独的树。

  2. 聚簇索引和非聚簇索引:

    主键索引也是聚簇索引,非主键索引都是非聚簇索引,两种索引的非叶子节点都是只存索引数据的,比如索引为id,那非叶子节点就只存id的数据。

    叶子节点的区别如下:

    • 聚簇索引的叶子节点存的是这条数据的所有字段信息。所以我们 select * from table where id = 1 的时候,都是要去叶子节点拿数据的。

    • 非聚簇索引的叶子节点存的是这条数据所对应的主键信息。比如这条非聚簇索引是username,然后表的主键是id,那该非聚簇索引的叶子节点存的就是id,而不存其他字段。 相当于是先从非聚簇索引查到主键的值,再根据主键索引去查数据内容,一般情况下要查两次(除非索引覆盖),这也称之为 回表 ,就有点类似于存了个指针,指向了数据存放的真实地址。

  3. B+树的查询是从上往下一层层查询的,一般情况下我们认为B+树的高度保持在3层是比较好的,也就是上两层是索引,最后一层存数据,这样查表的时候只需要进行3次磁盘IO就可以了(实际上会少一次,因为根节点会常驻内存)。

    如果数据量过大,导致B+数变成4层了,则每次查询就需要进行4次磁盘IO了,从而使性能下降。所以我们才会去计算InnoDB的3层B+树最多可以存多少条数据。

  4. MySQL每个节点大小默认为16KB,也就是每个节点最多存16KB的数据,可以修改,最大64KB,最小4KB。

    扩展:那如果某一行的数据特别大,超过了节点的大小怎么办?

    MySQL5.7文档的解释是:

    • 对于 4KB、8KB、16KB 和 32KB设置 ,最大行长度略小于数据库页面的一半 ,例如:对于默认的 16KB页大小,最大行长度略小于 8KB 。

    • 而对于 64KB 页面,最大行则长度略小于 16KB。

    • 如果行超过最大行长度, 则将可变长度列用外部页存储,直到该行符合最大行长度限制。 就是说把varchar、text这种长度可变的存到外部页中,来减小这一行的数据长度。

    image-20221108112456250

    文档地址:MySQL :: MySQL 5.7 Reference Manual :: 14.12.2 File Space Management

  5. MySQL查询速度主要取决于磁盘的读写速度,因为MySQL查询的时候每次只读取一个节点到内存中,通过这个节点的数据找到下一个要读取的节点位置,再读取下一个节点的数据,直到查询到需要的数据或者发现数据不存在。

    肯定有人要问了,每个节点内的数据难道不用查询吗?这里的耗时怎么不计算?

    这是因为读取完整个节点的数据后,会存到内存当中,在内存中查询节点数据的耗时其实是很短的,再配合MySQL的查询方式,时间复杂度差不多为 O(log2N)O(log_2N)O(log2N) ,相比磁盘IO来说,可以忽略不计。


MySQL B+树每个节点都存里些什么?

在Innodb的B+树中,我们常说的节点被称之为 页(page),每个页当中存储了用户数据,所有的页合在一起组成了一颗B+树(当然实际会复杂很多,但我们只是要计算可以存多少条数据,所以姑且可以这么理解😅)。

是InnoDB存储引擎管理数据库的最小磁盘单位,我们常说每个节点16KB,其实就是指每页的大小为16KB。

这16KB的空间,里面需要存储 页格式 信息和 行格式 信息,其中行格式信息当中又包含一些元数据和用户数据。所以我们在计算的时候,要把这些数据的都计算在内。

页格式

每一页的基本格式,也就是每一页都会包含的一些信息,总结表格如下:

名称空间含义和作用等
File Header38字节文件头,用来记录页的一些头信息。 包括校验和、页号、前后节点的两个指针、 页的类型、表空间等。
Page Header56字节页头,用来记录页的状态信息。 包括页目录的槽数、空闲空间的地址、本页的记录数、 已删除的记录所占用的字节数等。
Infimum & supremum26字节用来限定当前页记录的边界值,包含一个最小值和一个最大值。
User Records不固定用户记录,我们插入的数据就存储在这里。
Free Space不固定空闲空间,用户记录增加的时候从这里取空间。
Page Directort不固定页目录,用来存储页当中用户数据的位置信息。 每个槽会放4-8条用户数据的位置,一个槽占用1-2个字节, 当一个槽位超过8条数据的时候会自动分成两个槽。
File Trailer8字节文件结尾信息,主要是用来校验页面完整性的。

示意图:


页格式这块的内容,我在官网翻了好久,硬是没找到🤧。。。。不知道是没写还是我眼瞎,有找到的朋友希望可以在评论区帮我挂出来😋。

所以上面页格式的表格内容主要是基于一些博客中学习总结的。

另外,当新记录插入到 InnoDB 聚集索引中时,InnoDB 会尝试留出 1/16 的页面空闲以供将来插入和更新索引记录。如果按顺序(升序或降序)插入索引记录,则生成的页大约可用 15/16 的空间。如果以随机顺序插入记录,则页大约可用 1/2 到 15/16 的空间。参考文档:MySQL :: MySQL 5.7 Reference Manual :: 14.6.2.2 The Physical Structure of an InnoDB Index

除了 User RecordsFree Space 以外所占用的内存是 38+56+26+8=12838 + 56 + 26 + 8 = 12838+56+26+8=128 字节,每一页留给用户数据的空间就还剩 16×1516×1024−128=1523216 \times \frac{15}{16} \times 1024 - 128 = 1523216×1615×1024−128=15232 字节(保留了1/16)。

当然,这是最小值,因为我们没有考虑页目录。页目录留在后面根据再去考虑,这个得根据表字段来计算。

行格式

首先,我觉得有必要提一嘴,MySQL5.6的默认行格式为COMPACT(紧凑),5.7及以后的默认行格式为DYNAMIC(动态),不同的行格式存储的方式也是有区别的,还有其他的两种行格式,本文后续的内容主要是基于DYNAMIC(动态)进行讲解的。

官方文档链接:MySQL :: MySQL 5.7 参考手册 :: 14.11 InnoDB 行格式(包括下面的行格式内容大都可以在里面找到)



每行记录都包含以下这些信息,其中大都是可以从官方文档当中找到的。我这里写的不是特别详细,仅写了一些能够我们计算空间的知识,更详细内容可以去网上搜索 “MySQL 行格式”。

名称空间含义和作用等
行记录头信息5字节行记录的标头信息 包含了一些标志位、数据类型等信息 如:删除标志、最小记录标志、排序记录、数据类型、 页中下一条记录的位置等
可变长度字段列表不固定来保存那些可变长度的字段占用的字节数,比如varchar、text、blob等。 若变长字段的长度小于 255字节,就用1字节表示; 若大于 255字节,用2字节表示。 表字段中有几个可变长字段该列表中就有几个值,如果没有就不存。
null值列表不固定用来存储可以为null的字段是否为null。 每个可为null的字段在这里占用一个bit,就是bitmap的思想。 该列表占用的空间是以字节为单位增长的,例如,如果有 9 到 16 个 可以为null的列,则使用两个字节,没有占用1.5字节这种情况。
事务ID和指针字段6+7字节了解MVCC的朋友应该都知道,数据行中包含了一个6字节的事务ID和 一个7字节的指针字段。 如果没有定义主键,则还会多一个6字节的行ID字段 当然我们都有主键,所以这个行ID我们不计算。
实际数据不固定这部分就是我们真实的数据了。

示意图:


另外还有几点需要注意:

溢出页(外部页)的存储

注意:这一点是DYNAMIC的特性。

当使用 DYNAMIC 创建表时,InnoDB 会将较长的可变长度列(比如 VARCHAR、VARBINARY、BLOB 和 TEXT 类型)的值剥离出来,存储到一个溢出页上,只在该列上保留一个 20 字节的指针指向溢出页。

而 COMPACT 行格式(MySQL5.6默认格式)则是将前 768 个字节和 20 字节的指针存储在 B+ 树节点的记录中,其余部分存储在溢出页上。

列是否存储在页外取决于页大小和行的总大小。当一行太长时,选择最长的列进行页外存储,直到聚集索引记录适合 B+ 树页(文档里没说具体是多少😅)。小于或等于 40 字节的 TEXT 和 BLOB 直接存储在行内,不会分页。

优点

DYNAMIC 行格式避免了用大量数据填充 B+ 树节点从而导致长列的问题。

DYNAMIC 行格式的想法是,如果长数据值的一部分存储在页外,则通常将整个值存储在页外是最有效的。

使用 DYNAMIC 格式,较短的列会尽可能保留在 B+ 树节点中,从而最大限度地减少给定行所需的溢出页数。

字符编码不同情况下的存储

char 、varchar、text 等需要设置字符编码的类型,在计算所占用空间时,需要考虑不同编码所占用的空间。

varchar、text等类型会有长度字段列表来记录他们所占用的长度,但char是固定长度的类型,情况比较特殊,假设字段 name 的类型为 char(10) ,则有以下情况:

  • 对于长度固定的字符编码(比如ASCII码),字段 name 将以固定长度格式存储,ASCII码每个字符占一个字节,那 name 就是占用 10 个字节。

  • 对于长度不固定的字符编码(比如utf8mb4),至少将为 name 保留 10 个字节。如果可以,InnoDB会通过修剪尾部空格空间的方式来将其存到 10 个字节中。

    如果空格剪完了还存不下,则将尾随空格修剪为 列值字节长度的最小值(一般是 1 字节)。

    列的最大长度为: 字符编码的最大字符长度×N字符编码的最大字符长度 \times N字符编码的最大字符长度×N,比如 name 字段的编码为 utf8mb4,那就是 4×104 \times 104×10。

  • 大于或等于 768 字节的 char 列会被看成是可变长度字段(就像varchar一样),可以跨页存储。例如,utf8mb4 字符集的最大字节长度为 4,则 char(255) 列将可能会超过 768 个字节,进行跨页存储。

说实话对char的这个设计我是不太理解的,尽管看了很久,包括官方文档和一些博客🤧,希望懂的同学可以在评论区解惑:

对于长度不固定的字符编码这块,char是不是有点像是一个长度可变的类型了?我们常用的 utf8mb4,占用为 1 ~ 4 字节,那么 char(10) 所占用的空间就是 10 ~ 40 字节,这个变化还是挺大的啊,但是它并没有留足够的空间给它,也没有使用可变长度字段列表去记录char字段的空间占用情况,就很特殊?


开始计算

好了,我们已经知道每一页当中具体存储的东西了,现在我们已经具备计算能力了。

由于页的剩余空间我已经在上面页格式的地方计算过了,每页会剩余 15232 字节可用,下面我们直接计算行。

非叶子节点计算

单个节点计算

索引页就是存索引的节点,也就是非叶子节点。

每一条索引记录当中都包含了当前索引的值一个 6字节 的指针信息一个 5 字节的行标头,用来指向下一层数据页的指针。

索引记录当中的指针占用空间我没在官方文档里找到😭,这个 6 字节是我参考其他博文的,他们说源码里写的是6字节,但具体在哪一段源码我也不知道😭。

希望知道的同学可以在评论区解惑。

假设我们的主键id为 bigint 型,也就是8个字节,那索引页中每行数据占用的空间就等于 8+6+5=198 + 6 + 5 = 198+6+5=19 字节。每页可以存 15232÷19≈80115232 \div 19 \approx 80115232÷19≈801 条索引数据。

那算上页目录的话,按每个槽平均6条数据计算的话,至少有 801÷6≈134801 \div 6 \approx 134801÷6≈134 个槽,需要占用 268 字节的空间。

把存数据的空间分一点给槽的话,我算出来大约可以存 787 条索引数据。

如果是主键是 int 型的话,那可以存更多,大约有 993 条索引数据。

前两层非叶子节点计算

在 B+ 树当中,当一个节点索引记录为 NNN 条时,它就会有 NNN 个子节点。由于我们 3 层B+树的前两层都是索引记录,第一层根节点有 NNN 条索引记录,那第二层就会有 NNN 个节点,每个节点数据类型与根节点一致,仍然可以再存 NNN 条记录,第三层的节点个数就会等于 N2N^2N2。

则有:

  • 主键为 bigint 的表可以存放 7872=619369787 ^ 2 = 6193697872=619369 个叶子节点

  • 主键为 int 的表可以存放 9932=986049993 ^ 2 = 9860499932=986049 个叶子节点

OK计算完毕。

数据条数计算

最少存放记录数

前面我们提到,最大行长度略小于数据库页面的一半,之所以是略小于一半,是由于每个页面还留了点空间给页格式 的其他内容,所以我们可以认为每个页面最少能放两条数据,每条数据略小于8KB。如果某行的数据长度超过这个值,那InnoDB肯定会分一些数据到 溢出页 当中去了,所以我们不考虑。

那每条数据8KB的话,每个叶子节点就只能存放 2 条数据,这样的一张表,在主键为 bigint 的情况下,只能存放 2×619369=12387382 \times 619369 = 12387382×619369=1238738 条数据,也就是一百二十多万条,这个数据量,没想到吧🤣🤣。

较多的存放记录数

假设我们的表是这样的:

-- 这是一张非常普通的课程安排表,除id外,仅包含了课程id和老师id两个字段
-- 且这几个字段均为 int 型(当然实际生产中不会这么设计表,这里只是举例)。

CREATE TABLE `course_schedule` (
`id` int NOT NULL,
`teacher_id` int NOT NULL,
`course_id` int NOT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

先来分析一下这张表的行数据:无null值列表,无可变长字段列表,需要算上事务ID和指针字段,需要算上行记录头,那么每行数据所占用的空间就是 4+4+4+6+7+5=304 + 4 + 4 + 6 + 7 + 5 = 304+4+4+6+7+5=30 字节,每个叶子节点可以存放 15232÷30≈50715232 \div 30 \approx 50715232÷30≈507 条数据。

算上页目录的槽位所占空间,每个叶子节点可以存放 502 条数据,那么三层B+树可以存放的最大数据量就是 502×986049=494,996,598502 \times 986049 = 494,996,598502×986049=494,996,598,将近5亿条数据!没想到吧🤡😏。

常规表的存放记录数

大部分情况下我们的表字段都不是上面那样的,所以我选择了一场比较常规的表来进行分析,看看能存放多少数据。表情况如下:

CREATE TABLE `blog` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '博客id',
`author_id` bigint unsigned NOT NULL COMMENT '作者id',
`title` varchar(50) CHARACTER SET utf8mb4 NOT NULL COMMENT '标题',
`description` varchar(250) CHARACTER SET utf8mb4 NOT NULL COMMENT '描述',
`school_code` bigint unsigned DEFAULT NULL COMMENT '院校代码',
`cover_image` char(32) DEFAULT NULL COMMENT '封面图',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`release_time` datetime DEFAULT NULL COMMENT '首次发表时间',
`modified_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`status` tinyint unsigned NOT NULL COMMENT '发表状态',
`is_delete` tinyint unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `author_id` (`author_id`),
KEY `school_code` (`school_code`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COLLATE=utf8_general_mysql500_ci ROW_FORMAT=DYNAMIC;

这是我的开源项目“校园博客”(GitHub地址:github.com/stick-i/scb…) 中的博客表,用于存放博客的基本数据。

分析一下这张表的行记录:

  1. 行记录头信息:肯定得有,占用5字节。

  2. 可变长度字段列表:表中 title占用1字节,description占用2字节,共3字节。

  3. null值列表:表中仅school_codecover_imagerelease_time3个字段可为null,故仅占用1字节。

  4. 事务ID和指针字段:两个都得有,占用13字节。

  5. 字段内容信息:

    1. id、author_id、school_code 均为bigint型,各占用8字节,共24字节。

    2. create_time、release_time、modified_time 均为datetime类型,各占8字节,共24字节。

    3. status、is_delete 为tinyint类型,各占用1字节,共2字节。

    4. cover_image 为char(32),字符编码为表默认值utf8,由于该字段实际存的内容仅为英文字母(存url的),结合前面讲的字符编码不同情况下的存储 ,故仅占用32字节。

    5. title、description 分别为varchar(50)、varchar(250),这两个应该都不会产生溢出页(不太确定),字符编码均为utf8mb4,实际生产中70%以上都是存的中文(3字节),25%为英文(1字节),还有5%为4字节的表情😁,则存满的情况下将占用 (50+250)×(0.7×3+0.25×1+0.05×4)=765(50 + 250) \times (0.7 \times 3 + 0.25 \times 1 + 0.05 \times 4 ) = 765(50+250)×(0.7×3+0.25×1+0.05×4)=765 字节。

统计上面的所有分析,共占用 869 字节,则每个叶子节点可以存放 15232÷869≈1715232 \div 869 \approx 1715232÷869≈17 条,算上页目录,仍然能放 17 条。

则三层B+树可以存放的最大数据量就是 17×619369=10,529,27317 \times 619369 = 10,529,27317×619369=10,529,273,约一千万条数据,再次没想到吧👴。

数据计算总结

根据上面三种不同情况下的计算,可以看出,InnoDB三层B+树情况下的数据存储量范围为 一百二十多万条将近5亿条,这个跨度还是非常大的,同时我们也计算了一张博客信息表,可以存储 约一千万条 数据。

所以啊,我们在做项目考虑分表的时候还是得多关注一下表的实际情况,而不是盲目的认为两千万数据都是那个临界点。

面试时如果谈到这块的问题,我想面试官也并不是想知道这个数字到底是多少,而是想看你如何分析这个问题,如何得出这个数字的过程。

如果本文中有任何写的不对的地方,欢迎各位朋友在评论区指正🥰。

写在后面的一些话

这篇文章写了整整两周😭😭(虽然第一周在划水),真的超级干货了,前前后后查了好多资料,也看了好多博文,官方文档有些地方写的确实含糊,我看了好久都没看懂😂😂。

作者:阿杆
来源:juejin.cn/post/7165689453124517896

收起阅读 »

View工作原理 | 理解MeasureSpec和LayoutParams

前言本篇文章是理解View的测量原理的前置知识,在说View的测量时,我相信很多开发者都会说出重写onMeasure方法,比如下面方法:override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpe...
继续阅读 »

前言

本篇文章是理解View的测量原理的前置知识,在说View的测量时,我相信很多开发者都会说出重写onMeasure方法,比如下面方法:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}

但是这时你有没有想过这个方法中的参数即2个Int值是什么意思,以及是谁调用该方法的。

正文

本篇文章就从这个MeasureSpec值的意义以及如何得到MeasureSpec这2个角度来分析。

MeasureSpec

直接翻译就是测量规格,虽然我们在开发中会自己使用Java代码写布局或者在XML中直接进行布局,但是系统在真正测量以及确定其View大小的函数onMeasue中,参数却是MeasureSpec类型,那么它和普通的Int类型有什么区别呢?

其实在测量过程中,系统会将View的布局参数LayoutParams根据父View容器所施加的规则转换为对应的MeasureSpec,然后根据这个MeasureSpec便可以测量出View的宽高。注意一点,测量宽高不一定等于View的最终宽高。

其实这里就可以想一下为什么要如此设计,我们在XML中写布局的时候,在设置View的大小时就是通过下面2个属性:

android:layout_width="match_parent"
android:layout_height="wrap_content"

然后再加上padding、margin等共同确定该View的大小;这里虽然没啥问题,但是这个中间转换模式太麻烦了,需要开发者手动读取属性,而且读取各种padding、margin值等,不免会引起错误。

所以Android系统就把这个复杂个转换自己给做了,留给开发者的只有一个宽度MeasureSpec和高度MeasureSpec,可以方便开发者。

MeasureSpec是一个32位Int的值,高2位代表SpecMode,低30位代表SpecSize,SpecMode是指测量模式,而SpecSize是指在某种测量模式下的规格大小。下面是MeasureSpec的源码,比较简单:

//静态类
public static class MeasureSpec {
//移位 30位
private static final int MODE_SHIFT = 30;
//MODE_MASK的值也就是110000...000即11后面跟30个0
private static final int MODE_MASK = 0x3 << MODE_SHIFT;
//UNSPECIFED模式的值也就是00...000即32个0
public static final int UNSPECIFIED = 0 << MODE_SHIFT;
//EXACTLY模式的值也就是01000...000即01后面跟30个0
public static final int EXACTLY = 1 << MODE_SHIFT;
//AT_MOST模式的值也就是10000...000即10后面跟30个0
public static final int AT_MOST = 2 << MODE_SHIFT;

//根据最多30位二进制大小的值以及3个MODE创建出一个32位的MeasureSpec的值
//32位中高2位00 01 10分别表示模式,低30位代表大小
public static int makeMeasureSpec( int size,
@MeasureSpecMode int mode) {
//不考虑这种情况
if (sUseBrokenMakeMeasureSpec) {
return size + mode;
} else {
return (size & ~MODE_MASK) | (mode & MODE_MASK);
}
}

//获取模式
public static int getMode(int measureSpec) {
//noinspection ResourceType
return (measureSpec & MODE_MASK);
}

//获取大小
public static int getSize(int measureSpec) {
return (measureSpec & ~MODE_MASK);
}
}

MeasureSpec通过将SpecMode和SpecSize打包成一个int值来避免过多的对象内存分配,为了方便操作,其提供了打包和解包方法。

其中SpecMode有3类,每一类都表示特殊的含义,如下所示:

SpecMode含义
UNSPECIFIED表示父容器不对View做任何限制,要多大给多大,这种情况一般用于系统内部,表示一种测量的状态
EXACTLY表示父容器已经监测出View所需要的精确大小,这个时候View的最终大小就是SpecSize所指的值。它对于于LayoutParams中的match_parent和具体的数值这俩种模式
AT_MOST表示父容器指定了一个可用大小即SpecSize,View的大小不能大于这个值,具体是什么值,需要看View的具体实现。对应于LayoutParams中的wrap_content。

从上表格可用发现,一个View的宽高MeasureSpec由它父View和自己的LayoutParams共同决定。

MeasureSpec和LayoutParams的对应关系

上面提到在系统中是以MeasureSpec来确定View测量后的宽高,而正常情况下我们会使用LayoutParams来约束View的大小,所以中间这个转换过程也就是将View的LayoutParams在父容器的MeasureSpec作用下,共同产生View的MeasureSpec

LayoutParams

这个类在我们平时用代码来设置布局的时候非常常见,其实它就是用来解析XML中一些属性的,我们来看一下源码:

//这个是ViewGroup中的LayoutParams
public static class LayoutParams {
//对应于XML中的match_parent、wrap_parent
public static final int FILL_PARENT = -1;
public static final int MATCH_PARENT = -1;
public static final int WRAP_CONTENT = -2;
//宽度
public int width;
//高度
public int height;
//构造函数
public LayoutParams(Context c, AttributeSet attrs) {
TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.ViewGroup_Layout);
//解析出XML定义的属性,赋值到宽和高2个属性上
setBaseAttributes(a,
R.styleable.ViewGroup_Layout_layout_width,
R.styleable.ViewGroup_Layout_layout_height);
a.recycle();
}
//构造函数,用于代码创建实例
public LayoutParams(int width, int height) {
this.width = width;
this.height = height;
}
//读取XML中的对应属性
protected void setBaseAttributes(TypedArray a, int widthAttr, int heightAttr) {
width = a.getLayoutDimension(widthAttr, "layout_width");
height = a.getLayoutDimension(heightAttr, "layout_height");
}
}

这里我们会发现我们在XML中设置的宽高属性就会在这个ViewGroup的LayoutParams给记录起来

既然说起来LayoutParams,我们就来扩展一下子,因为我们平时在代码中设置这个LayoutParams经常会犯的一个错误就是获取到这个View的LayoutParams,它通常不是ViewGroup.LayoutParams,而是其他的,如果不注意就会强转失败,这里多看2个常见子类。

MarginLayoutParams

第一个就是MarginLayoutParams,一般具体具体View的XXX.LayoutParams都是继承这个父类,代码如下:

public static class MarginLayoutParams extends ViewGroup.LayoutParams {
//4个方向间距的大小
public int leftMargin;
public int topMargin;
public int rightMargin;
public int bottomMargin;

//分别解析XML中的margin、topMargin、leftMargin、bottomMargin和rightMargin属性
public MarginLayoutParams(Context c, AttributeSet attrs) {
super();

TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.ViewGroup_MarginLayout);
setBaseAttributes(a,
R.styleable.ViewGroup_MarginLayout_layout_width,
R.styleable.ViewGroup_MarginLayout_layout_height);

//省略

a.recycle();
}

//省略
}

这个不论我们View在啥ViewGroup的里面,在XML中都可以设置其margin,而这些margin的值都会被保存起来。

具体的LayoutParams

第二个就是具体的LayoutParams,比如这里举例LinearLayout.LayoutParams

首先回顾一下,线性布局的布局参数有什么特点,在XML中在线性布局里写新的View,这时你可以设置宽或者高为0dp,然后设置权重,以及设置layout_gravity这些属性,所以这些属性在解析XML时就会保存到相应的布局参数LayoutParams中,线性布局的布局参数代码如下:

//线性布局的LayoutParams
public static class LayoutParams extends ViewGroup.MarginLayoutParams {
//权重属性
@InspectableProperty(name = "layout_weight")
public float weight;
//layout_gravity属性
@ViewDebug.ExportedProperty(category = "layout", mapping = {
@ViewDebug.IntToString(from = -1, to = "NONE"),
@ViewDebug.IntToString(from = Gravity.NO_GRAVITY, to = "NONE"),
@ViewDebug.IntToString(from = Gravity.TOP, to = "TOP"),
@ViewDebug.IntToString(from = Gravity.BOTTOM, to = "BOTTOM"),
@ViewDebug.IntToString(from = Gravity.LEFT, to = "LEFT"),
@ViewDebug.IntToString(from = Gravity.RIGHT, to = "RIGHT"),
@ViewDebug.IntToString(from = Gravity.START, to = "START"),
@ViewDebug.IntToString(from = Gravity.END, to = "END"),
@ViewDebug.IntToString(from = Gravity.CENTER_VERTICAL, to = "CENTER_VERTICAL"),
@ViewDebug.IntToString(from = Gravity.FILL_VERTICAL, to = "FILL_VERTICAL"),
@ViewDebug.IntToString(from = Gravity.CENTER_HORIZONTAL, to = "CENTER_HORIZONTAL"),
@ViewDebug.IntToString(from = Gravity.FILL_HORIZONTAL, to = "FILL_HORIZONTAL"),
@ViewDebug.IntToString(from = Gravity.CENTER, to = "CENTER"),
@ViewDebug.IntToString(from = Gravity.FILL, to = "FILL")
})
@InspectableProperty(
name = "layout_gravity",
valueType = InspectableProperty.ValueType.GRAVITY)
public int gravity = -1;
//一样从构造函数中获取对应的属性
public LayoutParams(Context c, AttributeSet attrs) {
super(c, attrs);
TypedArray a =
c.obtainStyledAttributes(attrs, com.android.internal.R.styleable.LinearLayout_Layout);

weight = a.getFloat(com.android.internal.R.styleable.LinearLayout_Layout_layout_weight, 0);
gravity = a.getInt(com.android.internal.R.styleable.LinearLayout_Layout_layout_gravity, -1);

a.recycle();
}
}

到这里我们就知道了,其实我们在XML布局中的写的各种大小属性,都会被解析为各种LayoutParams实例给保存起来。

转换关系

前面我们知道既然测量的过程需要这个MeasureSpec,而我们平时在开发中在XML里都是使用View的属性,而上面我们可知不论是XML还是代码最终View的宽高等属性都是赋值到了LayoutParams这个类实例中,所以搞清楚MeasureSpec和LayoutParams的转换关系非常重要

正常来说,View的MeasureSpec由它父View的MeasureSpec和自己的LayoutParams来共同得到,但是对于不同的View,其转换关系是有一点差别的,我们挨个来说一下。

DecorView的MeasureSpec

因为DecorView作为顶级View,它没有父View,所以我们来看一下它的MeasureSpec是如何生成的,在ViewRootImpl的measureHierarchy方法中有,代码如下:

//获取decorView的宽高的MeasureSpec
childWidthMeasureSpec = getRootMeasureSpec(baseSize, lp.width);
childHeightMeasureSpec = getRootMeasureSpec(desiredWindowHeight, lp.height);
//开始对DecorView进行测量
performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);

看一下这个getRootMeasureSpec方法:

//windowSize就是当前Window的大小
private static int getRootMeasureSpec(int windowSize, int rootDimension) {
int measureSpec;
switch (rootDimension) {
case ViewGroup.LayoutParams.MATCH_PARENT:
//当布局参数是match_parent时,测量模式是EXACTLY
measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.EXACTLY);
break;
case ViewGroup.LayoutParams.WRAP_CONTENT:
/当布局参数是wrap_content时,测量模式是AT_MOST
measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.AT_MOST);
break;
default:
//具体宽高时,对应也就是EXACTLY
measureSpec = MeasureSpec.makeMeasureSpec(rootDimension, MeasureSpec.EXACTLY);
break;
}
return measureSpec;
}

这里我们就会发现DecorView的Measure的获取非常简单,当DecorView的LayoutParams是match_parent时,测量模式是EXACTLY,值是Window大小;当DecorView的LayoutParams是wrap_content时,测量模式是AT_MOST,值是window大小

View的MeasureSpec

对于View的MeasureSpec的获取稍微不一样,因为它肯定有父View,所以它的MeasureSpec的创造不仅和自己的LayoutParam有关,还和父View的MeasureSpec有关。

在这里我们先不讨论ViewGroup以及View是如何分发这个测量流程的,后面再说,这里有个我们在自定义ViewGroup时常用的方法,它用来测量它下面的子View,代码如下:

//ViewGroup中的代码,用来自定义ViewGroup时遍历子view,然后挨个进行测量
protected void measureChildWithMargins(
//子View
View child,
//ViewGroup的MeasureSpec,即父View的MeasureSpec
int parentWidthMeasureSpec, int widthUsed,
int parentHeightMeasureSpec, int heightUsed) {
//子View的LayoutParams
final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
//获取子View的MeasureSpec
final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin
+ widthUsed, lp.width);
final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin
+ heightUsed, lp.height);
//子View进行测量
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}

这里先不讨论子View如何去测量,只关注在有父View的MeasureSpec和自己的LayoutParams时,它是如何得到自己的MeasureSpec的,代码如下:

//调用的代码
final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin
+ widthUsed, lp.width);

这里注意一下参数,第一个参数是父View的MeasureSpec,第三个参数是当前View的宽度,而这里的宽度有3种:wrap_content为-2,match_parent为-1,具体值大于等于0,虽然说是宽度,也包含了View的LayoutParams信息。

第二个参数表示间距,其中mPaddingLeft和mPaddingRight很重要,因为这个属性是不会记录在LayoutParams中的,而且它的涵义是内间距,这里它是写在父ViewGroup中的属性值,比如加了这个paddingLeft属性后,其子View不会从原点开始绘制,它所可用的宽度就会变小,所以View在测量其大小时要把padding排除在外。

然后看一下源码实现:

public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
//父View的测量模式
int specMode = MeasureSpec.getMode(spec);
//父View的大小
int specSize = MeasureSpec.getSize(spec);
//padding是否大于父View的大小了
int size = Math.max(0, specSize - padding);

//子View的大小
int resultSize = 0;
//子View的测量模式
int resultMode = 0;

//这里要明白layoutParams中的wrap_content是-2,match_parent是-1,具体值才大于0
switch (specMode) {
//父View的测量模式是精确模式
case MeasureSpec.EXACTLY:
if (childDimension >= 0) {
//子View当前写死了大小,所以测量模式必是精确模式
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
//子View和父View一样大,所以测量模式肯定是精确模式
resultSize = size;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
//子View是包裹内容,其最大值是父View的大小
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
}
break;

//父View的测量模式是至多模式
case MeasureSpec.AT_MOST:
if (childDimension >= 0) {
//子View大小写死,测量模式必须是精确模式
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
//和父类一样,也是父类的至多模式
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
//这里要稍微注意一下,由于父类最大多少,所以这个View也是至多模式
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
}
break;

// 不分析
case MeasureSpec.UNSPECIFIED:
if (childDimension >= 0) {
// Child wants a specific size... let them have it
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
// Child wants to be our size... find out how big it should
// be
resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
resultMode = MeasureSpec.UNSPECIFIED;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
// Child wants to determine its own size.... find out how
// big it should be
resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
resultMode = MeasureSpec.UNSPECIFIED;
}
break;
}
//noinspection ResourceType
return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}

由这里代码可以看出,和DecorView不同的是当前View的MeasureSpec的创建和父View的MeasureSpec和自己的LayoutParams有关

普通View的MeasureSpec创建规则

对于DecorView的转换我们一般不会干涉,这里有一个普通View的MeasureSpce创建规则总结:

子View布局\父View ModeEXACTLYAT_MOSTUNSPECIFIED
dp/dx具体值EXACTLY+childSizeEXACTLY+childSizeEXACTLY+childSize
match_parentEXACTLY+parentSizeAT_MOST+parentSizeUNSPECIFIIED+0
wrap_conentAT_MOST+parentSizeAT_MOST+parentSizeUNSPECIFIIED+0

这个规则必须牢记,在后面View的绘制中我们将具体解析。

总结

本篇文章主要是理解MeasureSpec的设计初衷以及其含义,然后就是一个View的MeasureSpec是通过什么规则转换而来。后面文章我们将具体分析如何利用MeasureSpce来进行测量,最终确定View的大小。

笔者水平有限,有错误希望大家评论、指正。


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

Flow是如何解决背压问题的

前言 随着时间的推移,越来越多的主流应用已经开始全面拥抱Kotlin,协程的引入,Flow的诞生,给予了开发很多便捷,作为协程与响应式编程结合的流式处理框架,一方面它简单的数据转换与操作符,没有繁琐的操作符处理,广受大部分开发的青睐,另一方面它并没有响应式编...
继续阅读 »

前言



随着时间的推移,越来越多的主流应用已经开始全面拥抱Kotlin,协程的引入,Flow的诞生,给予了开发很多便捷,作为协程与响应式编程结合的流式处理框架,一方面它简单的数据转换与操作符,没有繁琐的操作符处理,广受大部分开发的青睐,另一方面它并没有响应式编程带来的背压问题(BackPressure)的困扰;接下来,本文将会就Flow如何解决背压问题进行探讨



关于背压(BackPressure


背压问题是什么


首先我们要明确背压问题是什么,它是如何产生的?简单来说,在一般的流处理框架中,消息的接收处理速度跟不上消息的发送速度,从而导致数据不匹配,造成积压。如果不及时正确处理背压问题,会导致一些严重的问题



  • 比如说,消息拥堵了,系统运行不畅从而导致崩溃

  • 比如说,资源被消耗殆尽,甚至会发生数据丢失的情况


如下图所示,可以直观了解背压问题的产生,它在生产者的生产速率高于消费者的处理速率的情况下出现


背压@2x

定义背压策略


既然我们已经知道背压问题是如何产生的,就要去尝试正确地处理它,大致解决方案策略在于,如果你有一个流,你需要一个缓冲区,以防数据产生的速度快于消耗的速度,所以往往就会针对这个背压策略进行些讨论



  • 定义的中间缓冲区需要多大才比较合适?

  • 如果缓冲区数据已满了,我们怎么样处理新的事件?


对于以上问题,通过学习Flow里的背压策略,相信可以很快就知道答案了


Flow的背压机制


由于Flow是基于协程中使用的,它不需要一些巧妙设计的解决方案来明确处理背压,在Flow中,不同于一些传统的响应式框架,它的背压管理是使用Kotlin挂起函数suspend实现的,看下源码你会发现,它里面所有的函数方法都是使用suspend修饰符标记,这个修饰符就是为了暂停调度者的执行不阻塞线程。因此,Flow<T>在同一个协程中发射和收集时,如果收集器跟不上数据流,它可以简单地暂停元素的发射,直到它准备好接收更多。看到这,是不是觉得有点难懂.......


简单举个例子,假设我们拥有一个烤箱,可以用来烤面包,由于烤箱容量的限制,一次只能烤4个面包,如果你试着一次烤8个面包,会大大加大烤箱的承载负荷,这已经远远超过了它的内存使用量,很有可能会因此烧掉你的面包。


模拟背压问题


回顾下之前所说的,当我们消耗的速度比生产的速度慢的时候,就会产生背压,下面用代码来模拟下这个过程




  • 首先先创建一个方法,用来每秒发送元素


    fun currentTime() = System.currentTimeMillis()
    fun threadName() = Thread.currentThread().name
    var start: Long = 0

    fun createEmitter(): Flow<Int> =
      (1..5)
          .asFlow()
          .onStart { start = currentTime() }
          .onEach {
               delay(1000L)
               print("Emit $it (${currentTime() - start}ms) ")
          }



  • 接着需要收集元素,这里我们延迟3秒再接收元素, 延迟是为了夸大缓慢的消费者并创建一个超级慢的收集器。


    fun main() {
       runBlocking {
           val time = measureTimeMillis {
               createEmitter().collect {
                   print("\nCollect $it starts ${start - currentTime()}ms")
                   delay(3000L)
                   println("   Collect $it ends ${currentTime() - start}ms")
              }
          }
           print("\nCollected in $time ms")
      }
    }

    看下输出结果,如下图所示




输出结果

这样整个过程下来,大概需要20多秒才能结束,这里我们模拟了接收元素比发送元素慢的情况,因此就需要一个背压机制,而这正是Flow本质中的,它并不需要另外的设计来解决背压


背压处理方式


使用buffer进行缓存收集

为了使缓冲和背压处理正常工作,我们需要在单独的协程中运行收集器。这就是.buffer()操作符进来的地方,它是将所有发出的项目发送Channel到在单独的协程中运行的收集器


public fun <T> Flow<T>.buffer(
   capacity: Int = BUFFERED,
   onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND
): Flow<T>

它还为我们提供了缓冲功能,我们可以指定capacity我们的缓冲区和处理策略onBufferOverflow,所以当Buffer溢出的时候,它为我们提供了三个选项


enum BufferOverflow { 
  SUSPEND,
  DROP_OLDEST,
  DROP_LATEST
}


  • 默认使用SUSPEND:会将当前协程挂起,直到缓冲区中的数据被消费了

  • DROP_OLDEST:它会丢弃最老的数据

  • DROP_LATEST: 它会丢弃最新的数据


好的,我们回到上文所展示的模拟示例,这时候我们可以加入缓冲收集buffer,不指定任何参数,这样默认就是使用SUSPEND,它会将当前协程进行挂起


2e237e1156e29a2b6dc3fc7e16f33d4.png


此时当收集器繁忙的时候,程序就开始缓冲,并在第一次收集方法调用结束的时候,两次发射后再次开始收集,此时流程的耗时时长缩短到大约16秒就可以执行完毕,如下图所示输出结果


0e3d1180f304af00553ac592d9c2987.png


使用conflate解决

conflate操作符于Channel中的Conflate模式是一直的,新数据会直接覆盖掉旧数据,它不设缓冲区,也就是缓冲区大小为 0,丢弃旧数据,也就是采取 DROP_OLDEST 策略,那么不就等于buffer(0,BufferOverflow.DROP_OLDEST),可以看下它的源码可以佐证我们的判断


public fun <T> Flow<T>.conflate(): Flow<T> = buffer(CONFLATED)

在某些情况下,由于根本原因是解决生产消费速率不匹配的问题,我们需要做一些取舍的操作,conflate将丢弃掉旧数据,只有在收集器空闲之前发出的最后一个元素才被收集,将上文的模拟实例改为conflate执行,你会发现我们直接丢弃掉了2和4,或者说新的数据直接覆盖掉了它们,整个流程只需要10秒左右就执行完成了


2ad6092e759f600f0db93397b7b0266.png


使用collectLatest解决

通过官方介绍,我们知道collectLatest作用在于当原始流发出一个新的值的时候,前一个值的处理将被取消,也就是不会被接收, 和conflate的区别在于它不会用新的数据覆盖,而是每一个都会被处理,只不过如果前一个还没被处理完后一个就来了的话,处理前一个数据的逻辑就会被取消


suspend fun <T> Flow<T>.collectLatest(action: suspend (T) -> Unit)

还是上文的模拟实例,这里我们使用collectLatest看下输出结果:


d2555f7e584655901b3ebc6dd086b5c.png


这样也是有副作用的,如果每个更新都非常重要,例如一些视图,状态刷新,这个时候就不必要用collectLatest ; 当然如果有些更新可以无损失的覆盖,例如数据库刷新,就可以使用到collectLatest,具体详细的使用场景,还需要靠开发者自己去衡量选择使用


小结


对于Flow可以说不需要额外提供什么巧妙的方式解决背压问题,Flow的本质,亦或者说Kotlin协程本身就已经提供了相应的解决方案;开发者只需要在不同的场景中选择正确的背压策略即可。总的来说,它们都是通过使用Kotlin挂起函数suspend,当流的收集器不堪重负时,它可以简单地暂停发射器,然后在准备好接受更多元素时恢复它。


关于挂起函数suspend这里就不过多赘述了,只需要明白的一点是它与传统的基于线程的同步数据管道中背压管理非常相似,无非就是,缓慢的消费者通过阻塞生产者的线程自动向生产者施加背压,简单来说,suspend通过透明地管理跨线程的背压而不阻塞它们,将其超越单个线程并进入异步编程领域。


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

从 internal 修饰符一探 kotlin 的可见性控制

前言 之前探讨过的 sealed class 和 sealed interface 存在 module 的限制,但其主要用于密封 class 的扩展和 interface 的实现。 如果没有这个需求只需要限制 module 的话,使用 Kotlin 中独特的 ...
继续阅读 »

前言


之前探讨过的 sealed classsealed interface 存在 module 的限制,但其主要用于密封 class 的扩展和 interface 的实现。


如果没有这个需求只需要限制 module 的话,使用 Kotlin 中独特的 internal 修饰符即可。


本文将详细阐述 internal 修饰符的特点、原理以及 Java 调用的失效问题,并以此为切入点网罗 Kotlin 中所有修饰符,同时与 Java 修饰符进行对比以加深理解。



  • internal 修饰符

  • open 修饰符

  • default、private 等修饰符

  • 针对扩展函数的访问控制

  • Kotlin 各修饰符的总结


internal 修饰符


修饰符,modifier,用作修饰如下对象。以展示其在 module 间package 间file 间class 间的可见性。



  • 顶层 class、interface

  • sub class、interface

  • 成员:属性 + 函数


特点


internal 修饰符是 Kotlin 独有的,其在具备了 Java 中 public 修饰符特性的同时,还能做到类似包可见(package private)的限制。只不过范围更大,变成了模块可见(module private)。


首先简单看下其一些基本特点:




  • 上面的特性可以看出来,其不能和 private 共存



    Modifier 'internal' is incompatible with 'private'





  • 可以和 open 共存,但 internal 修饰符优先级更高,需要靠前书写。如果 open 在前的话会收到如下提醒:



    Non-canonical modifiers order





  • 其子类只可等同或收紧级别、但不可放宽级别,否则



    'public' subclass exposes its 'internal' supertype XXX





说回其最重要的特性:模块可见,指的是 internal 修饰的对象只在相同模块内可见、其他 module 无法访问。而 module 指的是编译在一起的一套 Kotlin 文件,比如:



  • 一个 IntelliJ IDEA 模块;

  • 一个 Maven 项目;

  • 一个 Gradle 源集(例外是 test 源集可以访问 main 的 internal 声明);

  • 一次 <kotlinc> Ant 任务执行所编译的一套文件。


而且,在其他 module 内调用被 internal 修饰对象的话,根据修饰对象的不同类型、调用语言的不同,编译的结果或 IDE 提示亦有差异:




  • 比如修饰对象为 class 的话,其他 module 调用时会遇到如下错误/提示


    Kotlin 中调用:



    Cannot access 'xxx': it is internal in 'yyy.ZZZ'



    Java 中调用:



    Usage of Kotlin internal declaration from different module





  • 修饰对象为成员,比如函数的话,其他 module 调用时会遇到如下错误/提示


    Kotlin 中调用:



    Cannot access 'xxx': it is internal in 'yyy.ZZZ'(和修饰 class 的错误一样)



    Java 中调用:



    Cannot resolve method 'xxx'in 'ZZZ'



    你可能会发现其他 module 的 Kotlin 语言调用 internal 修饰的函数发生的错误,和修饰 class 一样。而 Java 调用的话,则是直接报找不到,没有 internal 相关的说明。


    这是因为 Kotlin 针对 internal 函数名称做了优化,导致 Java 中根本找不到对方,而 Kotlin 还能找到是因为编译器做了优化。




  • 假使将函数名称稍加修改,改为 fun$moduleName 的话,Java 中错误/提示会发生变化,和修饰 class 时一样了:


    Kotlin 中调用:



    Cannot access 'xxx': it is internal in 'yyy.ZZZ'(仍然一样)



    Java 中调用:



    Usage of Kotlin internal declaration from different module





优化


前面提到了 Kotlin 会针对 internal 函数名称做优化,原因在于:



internal 声明最终会编译成 public 修饰符,如果针对其成员名称做错乱重构,可以确保其更难被 Java 语言错误调用、重载。



比如 NonInternalClass 中使用 internal 修饰的 internalFun() 在编译成 class 之后会被编译成 internalFun$test_debug()


 class NonInternalClass {
     internal fun internalFun() = Unit
     fun publicFun() = Unit
 }
 
 public final class NonInternalClass {
    public final void internalFun$test_debug() {
    }
 
    public final void publicFun() {
    }
 }

Java 调用的失效


前面提到 Java 中调用 internal 声明的 class 或成员时,IDE 会提示不应当调用跨 module 调用的 IDE 提示,但事实上编译是可以通过的


这自然是因为编译到字节码里的是 public 修饰符,造成被 Java 调用的话,模块可见的限制会失效。这时候我们可以利用 Kotlin 的其他两个特性进行限制的补充:




  • 使用 @JvmName ,给它一个 Java 写不出来的函数名


     @JvmName(" zython")
     internal fun zython() {
     }



  • Kotlin 允许使用 ` 把一个不合法的标识符强行合法化,而 Java 无法识别这种名称


     internal fun ` zython`() { }



open 修饰符


除了 internal,Kotlin 还拥有特殊的 open 修饰符。首先默认情况下 class 和成员都是具备 final 修饰符的,即无法被继承和复写。


如果显式写了 final 则会被提示没有必要:



Redundant visibility modifier



如果可以被继承或复写,需要添加 open 修饰。(当然有了 open 自然不能再写 final,两者互斥)


open 修饰符的原理也很简单,添加了则编译到 class 里即不存在 final 修饰符。


下面抛开 open、final 修饰符的这层影响,着重讲讲 Kotlin 中 default、public、protected、private 的具体细节以及和 Java 的差异。


default、private 等修饰符


除了 internal,open 和 final,Kotlin 还拥有和 Java 一样命名的 defaultpublicprotectedprivate修饰符。虽然叫法相同,但在可见性限制的具体细节上存在这样那样的区别。


default


和 Java default visibility 是包可见(package private)不同的是,Kotlin 中对象的 default visibility 是随处可见(visible everywhere)。


public


就 public 修饰符的特性而言,Kotlin 和 Java 是相同的,都是随处可见。只不过 public 在 Kotlin 中是 default visibility,Java 则不是。


正因为此 Kotlin 中无需显示声明 public,否则会提示:Redundant visibility modifier


protected


Kotlin 中 protected 修饰符和 Java 有相似的地方是可以被子类访问。但也有不同的地方,前者只能在当前 class 内访问,而 Java 则是包可见。


如下在同一个 package 并且是同一个源文件内调用 protected 成员会发生编译错误。



Cannot access 'i': it is protected in 'ProtectedMemberClass'



 // TestProtected.kt
 open class ProtectedMemberClass {
     protected var i = 1
 }
 
 class TestProtectedOneFile {
     fun test() {
         ProtectedMemberClass().run {
             i = 2
        }
    }
 }

private


Kotlin 中使用 private 修饰顶级类、成员、内部类的不同,visibility 的表现也不同。


当修饰成员的时候,其只在当前 class 内可见。否则提示:



"Cannot access 'xxx': it is private in 'XXX'"



当修饰顶级类的时候,本 class 能看到它,当前文件也能看到,即文件可见(file private)的访问级别。事实上,private 修饰顶级对象的时候,会被编译成 package private,即和 Java 的 default 一样


但因为 Kotlin 编译器的作用,同 package 但不同 file 是无法访问 private class 的。



Cannot access 'XXX': it is private in file



当修饰的非顶级类,即内部类的话,即便是同文件也无法被访问。比如下面的 test 函数可以访问 TestPrivate,但无法访问 InnerClass



Cannot access 'InnerClass': it is private in 'TestPrivate'



 // TestPrivate.kt
 private class TestPrivate {
     private inner class InnerClass {
         private var name1 = "test"
    }
 }
 
 class TestPrivateInOneFile: TestGrammar {
     override fun test() {
         TestPrivate()
         TestPrivate().InnerClass() // error
    }
 }

另外一个区别是,Kotlin 中外部类无法访问内部类的 private 成员,但 Java 可以。



Cannot access 'xxx': it is private in 'InnerClass'



针对扩展函数的访问控制


private 等修饰符在扩展函数上也有些需要留意的地方。




  1. 扩展函数无法访问被扩展对象的 private / protected 成员,这是可以理解的。毕竟其本质上是静态方法,其内部需要调用实例的成员,而该静态方法是脱离定义 class 的,自然不允许访问访问仅类可见的、子类可见的对象



    Cannot access 'xxx': it is private in 'XXX'


    Cannot access 'yyy': it is protected in 'XXX'







  1. 只可以针对 public 修饰的类添加 public 级别的扩展函数,否则会收到如下的错误



'public' member exposes its 'private-in-file' receiver type TestPrivate



扩展函数的原理使得其可以针对目标 class 做些处理,但变相地将文件可见、模块可见的 class 放宽了可见性是不被允许的。但如果将扩展函数定义成 private / internal 是可以通过编译的,但这个扩展函数的可用性会受到限制,需要留意。


Kotlin 各修饰符的总结


对 Kotlin 中各修饰符进行简单的总结:




  • default 情况下:



    • 等同于 final,需要声明 open 才可扩展,这是和 Java 相反的扩展约束策略

    • 等同于 public 访问级别,和 Java 默认的包可见不同

    • 正因为此,Kotlin 中 finalpublic 无需显示声明




  • protected 是类可见外加子类可见,而 Java 则是包可见外加子类可见




  • private 修饰的内部类成员无法被外部类访问,和 Java 不同




  • internal 修饰符是模块可见,和 Java 默认的包可见有相似之处,也有区别




下面用表格将各修饰符和 Java 进行对比,便于直观了解。























































修饰符Kotlin 中适用场景KotlinJava
(default)随处可见的类、成员= public + final对象包可见
public同上= (default) ; 对象随处可见; 无需显示声明对象随处可见
protected自己和子类可见对象类可见 + 子类可见对象包可见 + 子类可见
private自己和当前文件可见修饰成员:对象类可见; 修饰顶级类:对象源文件可见; 外部类无法访问内部类的 private 成员对象类可见; 外部类可以访问内部类的 private 成员
internalmodule 内使用的类、成员对象模块可见; 子类只可等同或收紧级别、但不可放宽级别-
open可扩展对象可扩展; 和 final 互斥; 优先级低于 internal、protected 等修饰符-
final不可扩展= (default) ; 对象不可扩展、复写; 无需显示声明对象不可扩展、复写

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

页面曝光难点分析及应对方案

曝光 曝光埋点分为两种: PV show 它俩都表示“展示”,但有如下不同: 概念不同:PV = Page View,它特指页面维度的展示。对于 Android 平台来说,可以是一个 Activity 或 Fragment。而 show 可以是任何东西...
继续阅读 »

曝光


曝光埋点分为两种:



  1. PV

  2. show


它俩都表示“展示”,但有如下不同:




  • 概念不同:PV = Page View,它特指页面维度的展示。对于 Android 平台来说,可以是一个 Activity 或 Fragment。而 show 可以是任何东西的展示,可以是页面,也可以是一个控件的展示。




  • 上报时机不同:PV 是在离开页面的时候上报,show 是在控件展示的时候上报。




  • 上报参数不同:PV 通常会上报页面停留时长。




  • 消费场景不同:在消费侧,“展示”通常用于形成页面转化率漏斗,PV 和 show 都可用于形成这样的漏斗。但 show 比 PV 更精细,因为可能 A 页面中有 N 个入口可以通往 B页面。




由于产品希望知道更精确的入口信息,遂新增埋点全都是 show。


现有 PV 上报组件


Activity PV


项目中引入了一个第三方库实现了 Activity PV 半自动化上报:


public interface PvTracker {
String getPvEventId();// 生成事件ID
Bundle getPvExtra();// 生成额外参数
default boolean shouldReport() {return true;}
default String getUniqueKey() {return null;}
}

该接口定义了如何生成曝光埋点的事件ID和额外参数。


当某 Activity 需要 PV 埋点时实现该接口:


class AvatarActivity : BaseActivity, PvTracker{
override fun getPvEventId() = "avatar.pv"
override fun getPvExtra() = Bundle()
}

然后该 pvtracker 库就会自动实现 Activity 的 PV 上报。


它通过如下方式对全局 Activity 生命周期做了监听:


class PvLifeCycleCallback implements Application.ActivityLifecycleCallbacks {
@Override
public void onActivityResumed(Activity activity) {
String eventId = getEventId(activity);
if (!TextUtils.isEmpty(eventId)) {
onActivityVisibleChanged(activity, true); // activity 可见
}
}

@Override
public void onActivityPaused(Activity activity) {
String eventId = getEventId(activity);
if (!TextUtils.isEmpty(eventId)) {
onActivityVisibleChanged(activity, false);// activity 不可见
}
}

// 当 Activity 可见性发生变化
private void onActivityVisibleChanged(Activity activity, boolean isVisible) {
if (activity instanceof PvTracker) {
PvTracker tracker = (PvTracker) activity;
if (!tracker.shouldReport()) {
return;
}
String eventId = tracker.getPvEventId();
Bundle bundle = tracker.getPvExtra();

if (TextUtils.isEmpty(eventId) || mActivityLoadType == null) {
return;
}
String uniqueEventId = PageViewTracker.getUniqueId(activity, eventId);
if (isVisible) {
// 标记曝光开始
PvManager.getInstance().triggerVisible(uniqueEventId, eventId, bundle, loadType);
} else {
// 标记曝光结束,统计曝光时间并上报PV
PvManager.getInstance().triggerInvisible(uniqueEventId);
}
}
}
}

PvLifeCycleCallback 是一个全局性的 Activity 生命周期监听器,它会在 Application 初始化的时候注册:


class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
// 在 Application 中初始化
registerActivityLifecycleCallbacks(PvLifeCycleCallback)
}
}

这套方案实现了 Activity 层面半自动声明式埋点,即只需要编码埋点数据,不需要手动触发埋点。


Fragment PV


Fragment 生命周期是件非常头痛的事情。


FragmentManager.FragmentLifecycleCallbacks出现之前没有一个官方的解决方案,Fragment 生命周期处于一片混沌之中。


FragmentManager.FragmentLifecycleCallbacks 为开发者开了一扇窗(但这是一扇破窗):


public abstract static class FragmentLifecycleCallbacks {
public void onFragmentPreAttached(@NonNull FragmentManager fm, @NonNull Fragment f,@NonNull Context context) {}
public void onFragmentAttached(@NonNull FragmentManager fm, @NonNull Fragment f,@NonNull Context context) {}
public void onFragmentPreCreated(@NonNull FragmentManager fm, @NonNull Fragment f,@Nullable Bundle savedInstanceState) {}
public void onFragmentCreated(@NonNull FragmentManager fm, @NonNull Fragment f,@Nullable Bundle savedInstanceState) {}
public void onFragmentActivityCreated(@NonNull FragmentManager fm, @NonNull Fragment f,@Nullable Bundle savedInstanceState) {}
public void onFragmentViewCreated(@NonNull FragmentManager fm, @NonNull Fragment f,@NonNull View v, @Nullable Bundle savedInstanceState) {}
public void onFragmentStarted(@NonNull FragmentManager fm, @NonNull Fragment f) {}
public void onFragmentResumed(@NonNull FragmentManager fm, @NonNull Fragment f) {}
public void onFragmentPaused(@NonNull FragmentManager fm, @NonNull Fragment f) {}
public void onFragmentStopped(@NonNull FragmentManager fm, @NonNull Fragment f) {}
public void onFragmentSaveInstanceState(@NonNull FragmentManager fm, @NonNull Fragment f,@NonNull Bundle outState) {}
public void onFragmentViewDestroyed(@NonNull FragmentManager fm, @NonNull Fragment f) {}
public void onFragmentDestroyed(@NonNull FragmentManager fm, @NonNull Fragment f) {}
public void onFragmentDetached(@NonNull FragmentManager fm, @NonNull Fragment f) {}
}

可以通过观察者模式在 Fragment 实例以外的地方全局性地监听所有 Fragment 的生命周期。当其中的 onFragmentResumed() 回调时,意味着 Fragment 可见,而当 onFragmentPaused() 回调时,意味着 Fragment 不可见。


但有如下例外情况:



  1. 调用 FragmentTransaction的 show()/hide() 方法时,不会走对应的 resume/pause 生命周期回调。(因为它只是隐藏了 Fragment 对应的 View,但 Fragment 还处于 resume 状态,详见FragmentTransaction.hide()- findings | by Nav Singh 🇨🇦 | Nerd For Tech | Medium

  2. 当 Fragment 和 ViewPager/ViewPager2 共用时,resume/pause 生命周期回调失效。表现为没有展示的 Fragment 会回调 resume,而不可见的 Fragment 不会回调 pause。


pvTracker 的这个库在检测 Fragment 生命周期时也有上述问题。不过它也给出了解决方案:



  • 通过监听 ViewPager 页面切换来实现 Fragment + ViewPager 的可见性判断:在 ViewPager 初始化完毕后调用 PageViewTracker.getInstance().observePageChange(viewpager)

  • 如果 ViewPager + Fragment 嵌套在一个父 Fragment 还需在父 Fragment.onHiddenChanged() 方法里监听父 Fragment 的显示隐藏状态。


pvTracker 的解决方案是“把皮球踢给上层”,即上层手动调用一个方法来告知库当前 Fragment 的可见性。


全声明式 show 上报


pvtracker 是“半声明式 PV 上报”(Fragment 的可见性需要上层调方法)。


缺少一种“全声明式 show 上报”,即上层无需关注任何上报时机,只需生成埋点参数,就能自动实现 show 的上报。


Fragment 之所以会出现上述例外的情况,是因为 Fragment 的生命周期和其根视图的生命周期不同步。



是不是可以忘掉 Fragment,通过判定其根视图的可见性来表达 Fragment 的可见性?



所以需要一个控件维度全局可见性监听器,引用全网最优雅安卓控件可见性检测 中提供的解决方案:


fun View.onVisibilityChange(
viewGroups: List<ViewGroup> = emptyList(), // 会被插入 Fragment 的容器集合
needScrollListener: Boolean = true,
block: (view: View, isVisible: Boolean) -> Unit
) {
val KEY_VISIBILITY = "KEY_VISIBILITY".hashCode()
val KEY_HAS_LISTENER = "KEY_HAS_LISTENER".hashCode()
// 若当前控件已监听可见性,则返回
if (getTag(KEY_HAS_LISTENER) == true) return

// 检测可见性
val checkVisibility = {
// 获取上一次可见性
val lastVisibility = getTag(KEY_VISIBILITY) as? Boolean
// 判断控件是否出现在屏幕中
val isInScreen = this.isInScreen
// 首次可见性变更
if (lastVisibility == null) {
if (isInScreen) {
block(this, true)
setTag(KEY_VISIBILITY, true)
}
}
// 非首次可见性变更
else if (lastVisibility != isInScreen) {
block(this, isInScreen)
setTag(KEY_VISIBILITY, isInScreen)
}
}

// 全局重绘监听器
class LayoutListener : ViewTreeObserver.OnGlobalLayoutListener {
// 标记位用于区别是否是遮挡case
var addedView: View? = null
override fun onGlobalLayout() {
// 遮挡 case
if (addedView != null) {
// 插入视图矩形区域
val addedRect = Rect().also { addedView?.getGlobalVisibleRect(it) }
// 当前视图矩形区域
val rect = Rect().also { this@onVisibilityChange.getGlobalVisibleRect(it) }
// 如果插入视图矩形区域包含当前视图矩形区域,则视为当前控件不可见
if (addedRect.contains(rect)) {
block(this@onVisibilityChange, false)
setTag(KEY_VISIBILITY, false)
} else {
block(this@onVisibilityChange, true)
setTag(KEY_VISIBILITY, true)
}
}
// 非遮挡 case
else {
checkVisibility()
}
}
}

val layoutListener = LayoutListener()
// 编辑容器监听其插入视图时机
viewGroups.forEachIndexed { index, viewGroup ->
viewGroup.setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {
override fun onChildViewAdded(parent: View?, child: View?) {
// 当控件插入,则置标记位
layoutListener.addedView = child
}

override fun onChildViewRemoved(parent: View?, child: View?) {
// 当控件移除,则置标记位
layoutListener.addedView = null
}
})
}
viewTreeObserver.addOnGlobalLayoutListener(layoutListener)
// 全局滚动监听器
var scrollListener:ViewTreeObserver.OnScrollChangedListener? = null
if (needScrollListener) {
scrollListener = ViewTreeObserver.OnScrollChangedListener { checkVisibility() }
viewTreeObserver.addOnScrollChangedListener(scrollListener)
}
// 全局焦点变化监听器
val focusChangeListener = ViewTreeObserver.OnWindowFocusChangeListener { hasFocus ->
val lastVisibility = getTag(KEY_VISIBILITY) as? Boolean
val isInScreen = this.isInScreen
if (hasFocus) {
if (lastVisibility != isInScreen) {
block(this, isInScreen)
setTag(KEY_VISIBILITY, isInScreen)
}
} else {
if (lastVisibility == true) {
block(this, false)
setTag(KEY_VISIBILITY, false)
}
}
}
viewTreeObserver.addOnWindowFocusChangeListener(focusChangeListener)
// 为避免内存泄漏,当视图被移出的同时反注册监听器
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View?) {
}

override fun onViewDetachedFromWindow(v: View?) {
v ?: return
// 有时候 View detach 后,还会执行全局重绘,为此退后反注册
post {
try {
v.viewTreeObserver.removeOnGlobalLayoutListener(layoutListener)
} catch (_: java.lang.Exception) {
v.viewTreeObserver.removeGlobalOnLayoutListener(layoutListener)
}
v.viewTreeObserver.removeOnWindowFocusChangeListener(focusChangeListener)
if(scrollListener !=null) v.viewTreeObserver.removeOnScrollChangedListener(scrollListener)
viewGroups.forEach { it.setOnHierarchyChangeListener(null) }
}
removeOnAttachStateChangeListener(this)
}
})
// 标记已设置监听器
setTag(KEY_HAS_LISTENER, true)
}

有了这个扩展方法,就可以在在项目中的 BaseFragment 中进行全局 Fragment 的可见性监听了:


// 抽象 Fragment
abstract class BaseFragment:Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if(detectVisibility){
view.onVisibilityChange { view, isVisible ->
onFragmentVisibilityChange(isVisible)
}
}
}
// 抽象属性:是否检测当前 fragment 的可见性
abstract val detectVisibility: Boolean
open fun onFragmentVisibilityChange(show: Boolean) {}
}

其子类必须实现抽象属性detectVisibility,表示是否监听当前Fragment的可见性:


class FragmentA: BaseFragment() {
override val detectVisibility: Boolean
get() = true
override fun onFragmentVisibilityChange(show: Boolean) {
if(show) ... else ...
}
}

为了让 show 上报不入侵基类,选择了一种可拔插的方案,先定义一个接口:


interface ExposureParam {
val eventId: String
fun getExtra(): Map<String, String?> = emptyMap()
fun isForce():Boolean = false
}

该接口用于生成 show 上报的参数。任何需要上报 show 的页面都可以实现该接口:


class MaterialFragment : BaseFragment(), ExposureParam {
abstract val tabName: String
abstract val type: Int

override val eventId: String
get() = "material.show"

override fun getExtra(): Map<String, String?> {
return mapOf(
"tab_name" to tabName,
"type" to type.toString()
)
}
}

再自定义一个 Activity 生命周期监听器:


class PageVisibilityListener : Application.ActivityLifecycleCallbacks {
// 页面可见性变化回调
var onPageVisibilityChange: ((page: Any, isVisible: Boolean) -> Unit)? = null

private val fragmentLifecycleCallbacks by lazy(LazyThreadSafetyMode.NONE) {
object : FragmentManager.FragmentLifecycleCallbacks() {
override fun onFragmentViewCreated(fm: FragmentManager, f: Fragment, v: View, savedInstanceState: Bundle?) {
// 注册 Fragment 根视图可见性监听器
if (f is ExposureParam) {
v.onVisibilityChange { view, isVisible ->
onPageVisibilityChange?.invoke(f, isVisible)
}
}
}
}
}

override fun onActivityCreated(activity: Activity, p1: Bundle?) {
// 注册 Fragment 生命周期监听器
(activity as? FragmentActivity)?.supportFragmentManager?.registerFragmentLifecycleCallbacks(fragmentLifecycleCallbacks, true)
}

override fun onActivityDestroyed(activity: Activity) {
// 注销 Fragment 生命周期监听器
(activity as? FragmentActivity)?.supportFragmentManager?.unregisterFragmentLifecycleCallbacks(fragmentLifecycleCallbacks)
}

override fun onActivityStarted(p0: Activity) {
}

override fun onActivityResumed(activity: Activity) {
// activity 可见
if (activity is ExposureParam) onPageVisibilityChange?.invoke(activity, true)
}

override fun onActivityPaused(activity: Activity) {
// activity 不可见
if (activity is ExposureParam) onPageVisibilityChange?.invoke(activity, false)
}

override fun onActivityStopped(p0: Activity) {
}

override fun onActivitySaveInstanceState(p0: Activity, p1: Bundle) {
}

}

该监听器同时监听了 Activity 和 Fragment 的可见性变化。其中 Activity 的可见性变化是借助于 ActivityLifecycleCallbacks,而 Fragment 的可见性变化是借助于其视图的可见性。


Activity 和 Fragment 的可见性监听使用同一个onPageVisibilityChange进行回调。


然后在 Application 中页面可见性监听器:


open class MyApplication : Application(){
private val fragmentVisibilityListener by lazy(LazyThreadSafetyMode.NONE) {
PageVisibilityListener().apply {
onPageVisibilityChange = { page, isVisible ->
// 当页面可见时,上报 show
if (isVisible) {
(page as? ExposureParam)?.also { param ->
ReportUtil.reportShow(param.isForce(), param.eventId, param.getExtra())
}
}
}
}
}

override fun onCreate() {
super.onCreate()
registerActivityLifecycleCallbacks(fragmentVisibilityListener)
}

这样一来,上报时机已经完全自动化,只需要在上报的页面通过 ExposureParam 声明上报参数即可。


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

Kotlin 默认可见性为 public,是不是一个好的设计?

前言 众所周知,Kotlin 的默认可见性为 public,而这会带来一定的问题。比如最常见的,library 中的代码被无意中声明为 public 的了,导致用户使用者可以用到我们不想暴露的 API ,这样违背了最小知识原则,也不利于我们后续的变更 那么既然...
继续阅读 »

前言


众所周知,Kotlin 的默认可见性为 public,而这会带来一定的问题。比如最常见的,library 中的代码被无意中声明为 public 的了,导致用户使用者可以用到我们不想暴露的 API ,这样违背了最小知识原则,也不利于我们后续的变更


那么既然有这些问题,为什么 Kotlin 的默认可见性还被设计成这样呢?又该怎么解决这些问题?


为什么默认为 public


其实在 Kotlin M13 版本之前,Kotlin 的默认可见性是 internal 的,在 M13 版本之后才改成了 public


那么为什么会做这个修改呢?官方是这样说的



In real Java code bases (where public/private decisions are taken explicitly), public occurs a lot more often than private (2.5 to 5 times more often in the code bases that we examined, including Kotlin compiler and IntelliJ IDEA). This means that we’d make people write public all over the place to implement their designs, that would make Kotlin a lot more ceremonial, and we’d lose some of the precious ground won from Java in terms of brevity. In our experience explicit public breaks the flow of many DSLs and very often — of primary constructors. So we decided to use it by default to keep our code clean.



总得来说,官方认为在实际的生产环境中,public 发生的频率要比 private 要高的多,比如在 Kotlin 编译器和 InterlliJ 中是 2.5 倍到 5 倍的差距


这意味着如果默认的不是 public 的话,用户需要到处手动添加 public,会增加不少模板代码,并且会失去简洁性


但是官方这个回答似乎有点问题,我们要对比的是 internal 与 public,而不是 private 与 public


因此也有不少人提出了质疑


反方观点


包括 JakeWharton 在内的很多人对这一改变了提出了质疑,下面我们一起来看下loganj的观点


internal 是安全的默认值


如果一个类或成员最初具有错误的可见性,那么提高可见性要比降低可见性容易得多。也就是说,将 internal 类或成员更改为 public 不需要做什么额外的工作,因为没有外部调用者


在执行相反的操作的成本则很高,如果初始时是 public 的,你要将它修改为 internal 的,就要做很多的兼容工作。


因此,将 internal 设为默认值可以随着代码库的发展而节省大量工作。


分析使用的数据存在缺陷


官方提到 public 发生的频率是 private 的 2.5 倍到 5 倍,但这是建立在有瑕疵的数据上的


由于 Java 提供的可见性选项不足,开发人员被迫两害相权取其轻。更有经验的开发人员倾向于通过命名约定和文档来解决这个问题。经验不足的开发人员往往会直接将可见性设置为 public。


因此,大多数 Java 代码库的 public 类和成员比其作者需要或想要的要多得多。我们不能简单地查看 Java 可见性修饰符在普通代码库中的使用并假设它反映了作者的意愿


例如,我们常用的 Okhttp ,由经验丰富的 Java 开发人员编写的代码库,尽管 Java 存在限制,但他们仍努力将可见性降至最低。


下面是 Okhttp 的 public 包,它们旨在构成 Okhttp 的 API



这里是它的 internal 包,理想情况下只能在模块中被看到。



简单计算可以看到大根有 46% 的公共方法和 71% 的公共类。这已经比一般的代码库好很多,这是我们应该鼓励的方向。


但是 internal 包内部的类根本不应该被公开!而这是因为 Java 的可见性限制引起的(没有模块内可见)


如果 Java 有 Kotlin 的可见性修饰符,我们应该期望接近 24% 的公共方法和 35% 的 public 类。此外,48% 的方法和 65% 的类将是 internal 的!


internal 的潜力被浪费了


在 Java 中,别无选择,只能通过 public 来实现模块内可见,并使用约定和文档来阻止它们的使用。Kotlin 的 internal 可见性修复了 Java 中的这个缺陷,但是选择 public 作为默认可见性忽略了这个重要的修正。


默认 public 会浪费 Kotlin 内部可见性的潜力。它一反常态地鼓励了 Java 实际上不鼓励的不良做法,当 Kotlin 有办法向前迈出一大步时,这样做是从 Java 倒退了一大步。


正方观点


对于一些质疑的观点,官方也做了一些回应



我们曾经将 internal 设置为默认可见性,只是它没有被编译器检查,所以它被像 public 一样被使用。然后我们尝试打开检查,并意识到我们需要在代码中添加很多 public。在应用(Application)代码,而不是库(library)代码中,常常包括很多 public。我们分析了很多 case,结果发现并不是模块边界布局边界不清晰造成的。模块的划分是完全合乎逻辑的,但仍然有很多类由于到处都是 public 关键字而变得非常丑陋。




在主构造函数和基于委托属性的 DSL 中这个情况尤其严重:每个属性都承受着 public 一遍又一遍地重复的视觉负担




因此,我们意识到类的成员在默认情况下必须与类本身一样可见。请注意,如果一个类是内部的,那么它的公共成员实际上也是内部的。所以,我们有两个选择:




默认可见性是公开的
或者类具有与其成员不同的默认可见性。




在后一种情况下,函数的默认值会根据它是在顶层还是在类中声明而改变。我们决定保持一致,因此将默认可见性设置为了 public.




对于库作者,可以通过 lint 规则和 IDE 检查,以确保所有 public 的声明在代码中都是显式的。这会给库代码开发者带来一定的成本,但比起不一致的默认可见性,或者在应用代码中添加大量 public,这似乎并不是一个问题,总得来说优点大于缺点。



如何解决默认可见性的问题


总得来说,双方的观点各有各的道理,不过从 M13 到现在已经很多年了,Kotlin 的可见性一直默认是 public,看样子 Kotlin 官方已经下了结论


那么我们该如何解决库代码默认可见性为 public,导致用户使用者可以用到我们不想暴露的 API 的问题呢?


Kotlin 官方也提供了一个插件供我们使用:binary-compatibility-validator


这个插件可以 dump 出所有的 public API,将代码与 dump 出来的 api 进行对比,可以避免暴露不必要的 api


应用插件


plugins {
id("org.jetbrains.kotlinx.binary-compatibility-validator") version "0.12.1"
}

应用插件很简单,只要在 build.gradle 中添加以上代码就好了


插件任务


该插件包括两个任务



  • apiDump: 构建项目并将其公共 API 转储到项目 api 子文件夹中。API 以人类可读的格式转储。如果 API 转储文件已经存在,它将会被覆盖。

  • apiCheck: 构建项目并检查项目的公共 API 是否与项目 api 子文件夹中的声明相同。如果不同则抛出异常


工作流


我们可以通过以下工作流,确保 library 模块不会无意中暴露 public api


准备阶段(一次性工作):



  • 应用插件,配置它并执行 apiDump ,导出项目 public api

  • 手动验证您的公共 API (即执行 apiCheck 任务)。

  • 提交项目的 api (即 .api 文件) 到您的 VCS。


常规工作流程



  • 后续提交代码时,都会构建项目,并将项目的 API 与 .api 文件声明的 api 进行对比,如果两者不同,则 check 任务会失败

  • 如果是代码问题,则将可见性修改为 internal 或者 private,再重新提交代码

  • 如果的确应该添加新的 public api,则通过 apiDump 更新 .api 文件,并重新提交


与 CI 集成


常规工作流程中,每次提交代码都应该检查 api 是否发生变化,这主要是通过 CI 实现的


以 Github Action 为例,每次提交代码时都会触发检查,如果检查不通过会抛出以下异常



总结


本文主要介绍了为什么 Kotlin 的默认可见性是 public,及其优缺点。同时在这种情况下,我们该如何解决 library 代码容易无意中被声明为 public ,导致用户使用者可以用到我们不想暴露的 API 的问题


如果本文对你有所帮助,欢迎点赞~


示例项目


github.com/RicardoJian…


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

鹅厂组长,北漂 10 年,有房有车,做了一个违背祖宗的决定

前几天是 10 月 24 日,有关注股票的同学,相信大家都过了一个非常难忘的程序员节吧。在此,先祝各位朋友们身体健康,股票基金少亏点,最重要的是不被毕业。抱歉,当了回标题党,不过在做这个决定之前确实纠结了很久,权衡了各种利弊,就我个人而言,不比「3Q 大战」时...
继续阅读 »


前几天是 10 月 24 日,有关注股票的同学,相信大家都过了一个非常难忘的程序员节吧。在此,先祝各位朋友们身体健康,股票基金少亏点,最重要的是不被毕业。

抱歉,当了回标题党,不过在做这个决定之前确实纠结了很久,权衡了各种利弊,就我个人而言,不比「3Q 大战」时腾讯做的「艰难的决定」来的轻松。如今距离这个决定过去了快 3 个月,我也还在适应着这个决定带来的变化。

按照工作汇报的习惯,先说结论:

在北漂整整 10 年后,我回老家合肥上班了

做出这个决定的唯一原因:

没有北京户口,积分落户陪跑了三年,目测 45 岁之前落不上

户口搞不定,意味着孩子将来在北京只能考高职,这断然是不能接受的;所以一开始是打算在北京读几年小学后再回老家,我也能多赚点钱,两全其美。

因为我是一个人在北京,如果在北京上小学,就得让我老婆或者让我父母过来。可是我老婆的职业在北京很难就业,我父母年龄大了,北京人生地不熟的,而且那 P 点大的房子,住的也憋屈。而将来一定是要回去读书的,这相当于他们陪着我在北京折腾了。

或者我继续在北京打工赚钱,老婆孩子仍然在老家?之前的 6 年基本都是我老婆在教育和陪伴孩子,我除了逢年过节,每个月回去一到两趟。孩子天生过敏体质,经常要往医院跑,生病时我也帮不上忙,所以时常被抱怨”丧偶式育儿“,我也只能跟渣男一样说些”多喝热水“之类的废话。今年由于那啥,有整整 4 个多月没回家了,孩子都差点”笑问客从何处来“了。。。

5月中旬,积分落户截止,看到贴吧上网友晒出的分数和排名,预计今年的分数线是 105.4,而实际分数线是 105.42,比去年的 100.88 多了 4.54 分。而一般人的年自然增长分数是 4 分,这意味着如果没有特殊加分,永远赶不上分数线的增长。我今年的分数是 90.8,排名 60000 左右,每年 6000 个名额,即使没有人弯道超车,落户也得 10 年后了,孩子都上高一了,不能在初二之前搞到户口,就表示和大学说拜拜了。

经过我的一番仔细的测算,甚至用了杠杆原理和人品守恒定理等复杂公式,最终得到了如下结论:

我这辈子与北京户口无缘了

所以,思前想后,在没有户口的前提下,无论是老婆孩子来北京,还是继续之前的异地,都不是好的解决方案。既然将来孩子一定是在合肥高考,为了减少不必要的折腾,那就只剩唯一的选择了,我回合肥上班,兼顾下家里。

看上去是个挺自然的选择,但是:

我在腾讯是组长,团队 20 余人;回去是普通工程师,工资比腾讯打骨折

不得不说,合肥真的是互联网洼地,就没几个公司招人,更别说薪资匹配和管理岗位了。因此,回合肥意味着我要放弃”高薪“和来之不易的”管理“职位,从头开始,加上合肥这互联网环境,基本是给我的职业生涯判了死刑。所以在 5 月底之前就没考虑过这个选项,甚至 3 月份时还买了个显示器和 1.6m * 0.8m 的大桌子,在北京继续大干一场,而在之前的 10 年里,我都是用笔记本干活的,从未用过外接显示器。

5 月初,脉脉开始频繁传出毕业的事,我所在的部门因为是盈利的,没有毕业的风险。但是营收压力巨大,作为底层的管理者,每天需要处理非常非常多的来自上级、下级以及甲方的繁杂事务,上半年几乎都是凌晨 1 点之后才能睡觉。所以,回去当个普通工程师,每天干完手里的活就跑路,貌似也不是那么不能接受。毕竟自己也当过几年 leader 了,leader 对自己而言也没那么神秘,况且我这还是主动激流勇退,又不是被撸下来的。好吧,也只能这样安慰自己了,中年人,要学会跟自己和解。后面有空时,我分享下作为 leader 和普通工程师所看到的不一样的东西。

在艰难地说服自己接受之后,剩下的就是走各种流程了:

1. 5月底,联系在合肥工作的同学帮忙内推;6月初,通过面试。我就找了一家,其他家估计性价比不行,也不想继续面了
2. 6月底告诉总监,7月中旬告诉团队,陆续约或被约吃散伙饭
3. 7月29日,下午办完离职手续,晚上坐卧铺离开北京
4. 8月1日,到新公司报道

7 月份时,我还干了一件大事,耗时两整天,历经 1200 公里,不惧烈日与暴雨,把我的本田 125 踏板摩托车从北京骑到了合肥,没有拍视频,只能用高德的导航记录作为证据了:


这是导航中断的地方,晚上能见度不行,在山东花了 70 大洋,随便找了个宾馆住下了,第二天早上出发时拍的,发现居然是水泊梁山附近,差点落草为寇:


骑车这两天,路上发生了挺多有意思的事,以后有时间再分享。到家那天,是我的结婚 10 周年纪念日,我没有提前说我要回来,更没说骑着摩托车回来,当我告诉孩子他妈时,问她我是不是很牛逼,得到的答复是:

我觉得你是傻逼

言归正传,在离开北京前几天,我找团队里的同学都聊了聊,对我的选择,非常鲜明的形成了两个派系:

1. 未婚 || 工作 5 年以内的:不理解,为啥放弃管理岗位,未来本可以有更好的发展的,太可惜了,打骨折的降薪更不能接受

2. 已婚 || 工作 5 年以上的:理解,支持,甚至羡慕;既然迟早都要回去,那就早点回,多陪陪家人,年龄大了更不好回;降薪很正常,跟房价也同步,不能既要又要

确实,不同的人生阶段有着不同的想法,我现在是第 2 阶段,需要兼顾家庭和工作了,不能像之前那样把工作当成唯一爱好了。

在家上班的日子挺好的,现在加班不多,就是稍微有点远,单趟得 1 个小时左右。晚上和周末可以陪孩子玩玩,虽然他不喜欢跟我玩🐶。哦,对了,我还有个重要任务 - 做饭和洗碗。真的是悔不当初啊,我就不应该说会做饭的,更不应该把饭做的那么好吃,现在变成我工作以外的最重要的业务了。。。

比较难受的是,现在公司的机器配置一般,M1 的 MBP,16G 内存,512G 硬盘,2K 显示器。除了 CPU 还行,内存和硬盘,都是快 10 年前的配置了,就这还得用上 3 年,想想就头疼,省钱省在刀刃上了,属于是。作为对比,腾讯的机器配置是:

M1 Pro MBP,32G 内存 + 1T SSD + 4K 显示器

客户端开发,再额外配置一台 27寸的 iMac(i9 + 32G内存 + 1T SSD)

由奢入俭难,在习惯了高配置机器后,现在的机器总觉得速度不行,即使很多时候,它和高配机没有区别。作为开发,尤其是客户端开发,AndroidStudio/Xcode 都是内存大户,16G 实在是捉襟见肘,非常影响搬砖效率。公司不允许用自己的电脑,否则我就自己买台 64G 内存的 MBP 干活用了。不过,换个角度,编译时间变长,公司提供了带薪摸鱼的机会,也可以算是个福利🐶

另外,比较失落的就是每个月发工资的日子了,比之前少了太多了,说没感觉是不可能的,还在努力适应中。不过这都是小事,毕竟年底发年终奖时,会更加失落,hhhh😭😭😭😭

先写这么多吧,后面有时间的话,再分享一些有意思的事吧,工作上的或生活上的。

遥想去年码农节时,我还在考虑把房子从昌平换到海淀,好让孩子能有个“海淀学籍”,当时还做了点笔记:


没想到,一年后的我回合肥了,更想不到一年后的腾讯,股价竟然从 500 跌到 206 了(10月28日,200.8 了)。真的是世事难料,大家保重身体,好好活着,多陪陪家人,一起静待春暖花开💪🏻💪🏻

作者:野生的码农

来源:juejin.cn/post/7159837250585362469

收起阅读 »

Android 无所不能的 hook,让应用不再崩溃

之前推送了很多大厂分享,很多同学看完就觉得,大厂输出的理论知识居多,缺乏实践。那这篇文章,我们将介绍一个大厂的库,这个库能够实打实的帮助大家解决一些问题。今天的主角:初学者小张,资深研发老羊。三方库中的 bug这天 QA 上线前给小张反馈了一个 bug,应用启...
继续阅读 »

之前推送了很多大厂分享,很多同学看完就觉得,大厂输出的理论知识居多,缺乏实践。

那这篇文章,我们将介绍一个大厂的库,这个库能够实打实的帮助大家解决一些问题。

今天的主角:初学者小张,资深研发老羊。

三方库中的 bug

这天 QA 上线前给小张反馈了一个 bug,应用启动就崩溃,小张一点不慌,插入 USB,触发,一看日志,原来是个空指针。

想了想,空指针比较好修复,大不了判空防御一下,于是回答:这个问题交给我,马上修复。

根据堆栈,找到了空指针的元凶。

忽然间,小张愣住了,这个空指针是个三方库在初始化的时候获取用户剪切板出错了。

这可怎么解决呢?

本来以为判个空防御一下完事,这会遇到硬茬了。

毕竟是自己装的逼,含着泪也要修复了,我们模拟下现场。

/**
* 这是三方库中的调用
*/
public class Tools {
   
  public static String getClipBoardStr(Context context) {
      ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
      ClipData primaryClip = clipboardManager.getPrimaryClip();
      // NPE
      ClipData.Item itemAt = primaryClip.getItemAt(0);
      if (itemAt == null) {
          return "";
      }
      CharSequence text = itemAt.getText();
      if (text == null) {
          return "";
      }
      return text.toString();
  }
}

我们写个按钮来触发一下:


果然发生了崩溃,空指针发生在clipboardManager.getPrimaryClip(),当手机上没有过复制内容时,getPrimaryClip返回的就是 null。

马上就要上线了,但是这个问题,也不是修复不了,根据自己的经验,大多数系统服务都可以被 hook,hook 掉 ClipboradManager 的相关方法,保证返回的 getPrimaryClip 的不为 null 即可。

于是看了几个点:

public @Nullable ClipData getPrimaryClip() {
   try {
       return mService.getPrimaryClip(mContext.getOpPackageName(), mContext.getUserId());
  } catch (RemoteException e) {
       throw e.rethrowFromSystemServer();
  }
}

这个 mService 的初始化为:

mService = IClipboard.Stub.asInterface(
              ServiceManager.getServiceOrThrow(Context.CLIPBOARD_SERVICE));

这么看,已经八成可以 hook了,再看下我们自己能构造 ClipData 吗?

public ClipData(CharSequence label, String[] mimeTypes, Item item) {}

恩,hook 的思路基本可行。

小张内心暗喜,多亏是遇到了我呀,还好我实力扎实。

这时候,资深研发老羊过来问了句,马上就要上线了,你这干啥呢?

小张滔滔不绝的描述了一下当前遇到了问题,和自己的解决思路,本以为老羊这次会拍拍自己的肩膀「还好是你遇到了呀」来表示对自己的认可。

老羊开口说道:

getPrimaryClip返回 null 造成的空指针,那你在之前调用一个setPrimaryClip不就行了?

恩?卧槽...看一眼源码:

#ClipboardManager
public void setPrimaryClip(@NonNull ClipData clip) {
   try {
       Preconditions.checkNotNull(clip);
       clip.prepareToLeaveProcess(true);
       mService.setPrimaryClip(clip, mContext.getOpPackageName(), mContext.getUserId());
  } catch (RemoteException e) {
       throw e.rethrowFromSystemServer();
  }
}

还真有这个方法...

那试试吧。

添加了一行:

ClipboardManager clipboardManager = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE);
clipboardManager.setPrimaryClip(new ClipData("bugfix", new String[]{"text/plain"}, new ClipData.Item("")));

果然不在崩溃了。

这时候老羊说了句:

你也想想,假设三方库里面真有个致命的 bug,然后你没找到合适的 hook 点你怎么处理?想好了过来告诉我。

致命 bug,没找到合适的 hook 点?

模拟下代码:

public class Tools {

  public static void evilCode() {
      int a = 1 / 0;
  }

  public static String getClipBoardStr(Context context) {
      evilCode();
      ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
      ClipData primaryClip = clipboardManager.getPrimaryClip();
      ClipData.Item itemAt = primaryClip.getItemAt(0);
      if (itemAt == null) {
          return "";
      }
      CharSequence text = itemAt.getText();
      if (text == null) {
          return "";
      }
      return text.toString();
  }


}

假设 getClipBoardStr 内部调用了一行 evilCode,执行到就crash。

一眼望去这个 evilCode 方法,简单是简单,但是在三方库里面怎么解决呢?

小张百思不得其解,忽然灵光一闪:

是不是老羊想考察我的推动能力,让我没事别瞎 hook 人家代码,这种问题当然找三方库那边修复,然后给个新版本咯。

于是跑过去,告诉老羊,我想到了,这种问题,我们应该及时推动三方库那边解决,然后我们升级版本即可。

老羊听了后,恩,确实要找他们,但是如果是上线前遇到,推动肯定是来不及了,就是人家立马给你个新版本,直接升级风险也是比较大的。

然后老羊说道:

我看你对于反射找 hook 点已经比较熟悉了,其实还有一类 hook 更加好用,也更加稳定。

叫做字节码 hook。

怎么说?

我们的代码在打包过程中,会经过如下步骤:

.java -> .class -> dex -> apk

上面那个类的 evil 方法,从 class 文件的角度来看,其实都是字节码。

假设我们在编译过程中,这么做:

.java -> .class -> 拿到 Tools.class,修正里面的方法 evil 方法 -> dex -> apk

这个时机,其实构建过程中也给我们提供了,也就是传说的 Transform 阶段(这里不讨论 AGP 7 之后的变化,还是有对应时机的)。

小张又问,这个时机我知道,Tools.class 文件怎么修改呢?

老羊说,这个你去看看我的博客:

Android 进阶之路:ASM 修改字节码,这样学就对了!

不过话说回来,既然你会遇到这样的痛点,那么别的开发者肯定也会遇到。

这个时候应该怎么想?

小张:肯定有人造了好用的轮子。

老羊:恩,99%的情况,轮子肯定都造好了,剩下 1%,那就是你的机会了。

轻量级 aop 框架 lancet 出现

饿了么,很早的时候就开源了一个框架,叫 lancet。

github.com/eleme/lance…

这个框架可以支持你,在不懂字节码的情况下,也能够完成对对应方法字节码的修改。

代入到我们刚才的思路:

.java -> .class -> lancet 拿到 Tools.class,修正里面的方法 evilCode 方法 -> dex -> apk

小张:怎么使用 lancet 来修改我们的 evilCode 方法呢?

引入框架

在项目的根目录添加:

classpath 'me.ele:lancet-plugin:1.0.6'

在 module 的build.gradle 添加依赖和 apply plugin:

apply plugin: 'me.ele.lancet'

dependencies {
  implementation 'me.ele:lancet-base:1.0.6' // 最好查一下,用最新版本
}

开始使用

然后,我们做一件事情,把Tools 里面的 evilCode方法:

public static void evilCode() {
   int a = 1 / 0;
}

里面的这个代码给去掉,让它变成空方法。

我们编写代码:

package com.imooc.blogdemo.blog04;

import me.ele.lancet.base.annotations.Insert;
import me.ele.lancet.base.annotations.TargetClass;

public class ToolsLancet {

   @TargetClass("com.imooc.blogdemo.blog04.Tools")
   @Insert("evilCode")
   public static void evilCode() {

  }

}

我们编写一个新的方法,保证其是个空方法,这样就完成让原有的 evilCode 中调用没有了。

其中:

  • TargetClass 注解:标识你要修改的类名;

  • Insert注解:表示你要往 evilCode 这个方法里面注入下面的代码

  • 下面的方法声明需要和原方法保持一致,如果有参数,参数也要保持一致(方法名、参数名不需要一致)

然后我们打包,看看背后发生了什么神奇的事情。

在打包完成后,我们反编译,看看 Tools.class

public class Tools {    
  //...
   public static void evilCode() {
       Tools._lancet.com_imooc_blogdemo_blog04_ToolsLancet_evilCode();
  }

   private static void evilCode$___twin___() {
       int a = 1 / 0;
  }

   private static class _lancet {
       private _lancet() {
      }

       @TargetClass("com.imooc.blogdemo.blog04.Tools")
       @Insert("evilCode")
       static void com_imooc_blogdemo_blog04_ToolsLancet_evilCode() {
      }
  }
}

可以看到,原本的evilCode方法中的校验,被换成了一个生成的方法调用,而这个生成的方法和我们编写的非常类似,并且其为空方法。

而原来的 evilCode 逻辑,放在一个evilCode$___twin___()方法中,可惜这个方法没地方调用。

这样原有的 evilCode 逻辑就变成了一个空方法了。

我们可以大致梳理下原理:

lancet 会将我们注明需要修改的方法调用中转到一个临时方法中,这个临时方法你可以理解为和我们编写的方法逻辑基本保持一致。

然后将该方法的原逻辑也提取到一个新方法中,以备使用。

小张:确实很神奇,那这个原方法我们什么时候会使用呢?

老羊:很多时候,可能原有逻辑只是个概率很低的问题,比如发送请求,只有在超时等情况才发生错误,你不能粗暴的把人家逻辑移除了,你可能更想加个 try-catch 然后给个提示什么的。

这个时候你可以这么改:

package com.imooc.blogdemo.blog04;

import me.ele.lancet.base.Origin;
import me.ele.lancet.base.annotations.Insert;
import me.ele.lancet.base.annotations.TargetClass;

public class ToolsLancet {

   @TargetClass("com.imooc.blogdemo.blog04.Tools")
   @Insert("evilCode")
   public static void evilCode() {
       try {
           Origin.callVoid();
      } catch (Exception e) {
           e.printStackTrace();
      }
  }

}

我们再来看下反编译代码:

public class Tools {

   public static void evilCode() {
       Tools._lancet.com_imooc_blogdemo_blog04_ToolsLancet_evilCode();
  }

   private static void evilCode$___twin___() {
       int a = 1 / 0;
  }

   private static class _lancet {
       @TargetClass("com.imooc.blogdemo.blog04.Tools")
       @Insert("evilCode")
       static void com_imooc_blogdemo_blog04_ToolsLancet_evilCode() {
           try {
               Tools.evilCode$___twin___();
          } catch (Exception var1) {
               var1.printStackTrace();
          }

      }
  }
}

看到没,不出所料中转方法内部调用了原有方法,然后外层包了个 try-catch。

是不是很强大,而且相对于运行时反射相关的 hook 更加稳定,其实他就像你写的代码,只不过是直接改的 class。

小张:所以我早上遇到的剪切板崩溃问题,其实也可以利用 lancet 加一个 try-catch。

老羊:是的,挺会举一反三的,当然也从侧面反映出来字节码 hook 的强大之处,几乎不需要找什么 hook 点,只要你有方法,就能干涉。

另外,我给你介绍的都是最基础的 api,你下去好好看看 lancet 的其他用法。

小张:好嘞,又学到了。

新的问题又来了

过了几日,忽然项目又遇到一个问题:

用户未授权读取剪切板之前,不允许有读取剪切板的行为,否则认定为不合规

小张听到这个任务,大脑快速运转:

这个读取剪切板行为的 API 是:

clipboardManager.getPrimaryClip();

搜索下项目中的调用,然后逐一修改。

先不说能不能搜索完整,这三方库里面肯定有,此外后续新增的代码如何控制呢?

另外之前学习 lancet,可以修改三方库代码,但是我也不能把包含clipboardManager.getPrimaryClip的方法全部列出来,一个个字节码修改?

还是解决不了后续新增,已经能保证全部搜出来呀。

最终心里嘀咕:别让我干,别让我干,八成是个坑。

这时候老羊来了句:这个简单,小张熟悉,他搞就行了。

小张:我...

重新思考一下,反正搜索出来,一一修改是不可能了。

那就从源头上解决:

系统肯定是通过framework,system 进程那边去判断是否读取剪切板的。

那么我们只要把:

clipboardManager.getPrimaryClip
IClipboard.getPrimaryClip(mContext.getOpPackageName(), mContext.getUserId());

内部的逻辑hook 掉,换掉IClipBoard 的实现,然后切到我们自己的逻辑即可。

懂了,这就是我之前想的系统服务的 hook 而已,难怪老羊安排给我,我给他说过这个。

于是乎...我开启了一顿写模式...

此处代码略。(确实可以,不过非本文主要内容)

正完成了 Android 10.0的测试,准备翻翻各个版本有没有源码修改,好适配适配,老羊走了过来。

说了句:这都两个小时过去了,你还没搞完?

小张:两个小时搞完?你来。

老羊:我让你自己看看 lancet 其他 api你没看?

这个用 lancet 就是送分题你知道吗?看好:

public class ToolsLancet {

   // 模拟用户同意后的状态
   public static boolean isAuth = true;

   @TargetClass("android.content.ClipboardManager")
   @Proxy("getPrimaryClip")
   public ClipData getPrimaryClip() {
       if (isAuth) {
           return (ClipData) Origin.call();
      }
       // 这里也可以 return null,毕竟系统也 return null
       return new ClipData("未授权呢", new String[]{"text/plain"}, new ClipData.Item(""));
  }
}

小张:这个不行呀,android.content.ClipboardManager类是系统的,不是我们写的,在打包阶段没有这个 class。

老羊:我当然知道,你仔细看,这次用的注解和上次有什么不同。

这次用的是:

  • @Proxy:意思就是代理,会代理ClipboardManager. getPrimaryClip到我们这个方法中来。

我们反编译看看:

原来的调用:

public static String getClipBoardStr(Context context) {
  ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
  ClipData primaryClip = clipboardManager.getPrimaryClip();
  ClipData.Item itemAt = primaryClip.getItemAt(0);
  if (itemAt == null) {
      return "";
  }
  CharSequence text = itemAt.getText();
  if (text == null) {
      return "";
  }
  return text.toString();
}

反编译的调用:

public class Tools {

   public static String getClipBoardStr(Context context) {
       ClipboardManager clipboardManager = (ClipboardManager)context.getSystemService("clipboard");
       ClipData primaryClip = Tools._lancet.com_imooc_blogdemo_blog04_ToolsLancet_getPrimaryClip(clipboardManager);
       Item itemAt = primaryClip.getItemAt(0);
       if (itemAt == null) {
           return "";
      } else {
           CharSequence text = itemAt.getText();
           return text == null ? "" : text.toString();
      }
  }

   private static class _lancet {
   
       @TargetClass("android.content.ClipboardManager")
       @Proxy("getPrimaryClip")
       static ClipData com_imooc_blogdemo_blog04_ToolsLancet_getPrimaryClip(ClipboardManager var0) {
           return ToolsLancet.isAuth ? var0.getPrimaryClip() : new ClipData("未授权呢", new String[]{"text/plain"}, new Item(""));
      }
  }
}

看到没有,clipboardManager.getPrimaryClip()方法变成了Tools._lancet.com_imooc_blogdemo_blog04_ToolsLancet_getPrimaryClip,中转到了我们的hook 实现。

这次明白了吧:

  1. lancet 对于我们自己的类中方法,可以使用@Insert 指令;

  2. 遇到系统的调用,我们可以针对调用函数使用@Proxy 指令将其中转到中转函数;

好了,lancet 还有一些 api,你再下去好好看看。

完结

终于结束了,大家退出小张和老羊的对话场景。

其实字节码 hook 在 Android 开发过程中更为强大,比我们传统的找 Hook 点(单例,静态变量),然后反射的方式方便太多了,还有个最大的优势就是稳定。

当然lancet hook 有个前提就是要明确知道方法调用,如果你想 hook 一个类的所有调用,那么写起来就有点费劲了,可能并不如动态代理那么方便。

好了,话说回来:

之前有个小伙去面试,被问到:

如何收敛三方库里面线程池的创建?

你有想法了吗?

作者:鸿洋
来源:juejin.cn/post/7034178205728636941

收起阅读 »

localStorage容量太小?试试它们

web
localStorage 是前端本地存储的一种,其容量一般在 5M-10M 左右,用来缓存一些简单的数据基本够用,毕竟定位也不是大数据量的存储。在某些场景下 localStorage 的容量就会有点捉襟见肘,其实浏览器是有提供大数据量的本地存储的如 Index...
继续阅读 »

localStorage 是前端本地存储的一种,其容量一般在 5M-10M 左右,用来缓存一些简单的数据基本够用,毕竟定位也不是大数据量的存储。

在某些场景下 localStorage 的容量就会有点捉襟见肘,其实浏览器是有提供大数据量的本地存储的如 IndexedDB 存储数据大小一般在 250M 以上。

弥补了localStorage容量的缺陷,但是使用要比localStorage复杂一些 mdn IndexedDB

不过已经有大佬造了轮子封装了一些调用过程使其使用相对简单,下面我们一起来看一下

localforage

localforage 拥有类似 localStorage API,它能存储多种类型的数据如 Array ArrayBuffer Blob Number Object String,而不仅仅是字符串。

这意味着我们可以直接存 对象、数组类型的数据避免了 JSON.stringify 转换数据的一些问题。

存储其他数据类型时需要转换成上边对应的类型,比如vue3中使用 reactive 定义的数据需要使用toRaw 转换成原始数据进行保存, ref 则直接保存 xxx.value 数据即可。

安装

下载最新版本 或使用 npm bower 进行安装使用。

# 引入下载的 localforage 即可使用
<script src="localforage.js"></script>
<script>console.log('localforage is: ', localforage);</script>

# 通过 npm 安装:
npm install localforage

# 或通过 bower:
bower install localforage

使用

提供了与 localStorage 相同的api,不同的是它是异步的调用返回一个 Promise 对象

localforage.getItem('somekey').then(function(value) {
   // 当离线仓库中的值被载入时,此处代码运行
   console.log(value);
}).catch(function(err) {
   // 当出错时,此处代码运行
   console.log(err);
});

// 回调版本:
localforage.getItem('somekey', function(err, value) {
   // 当离线仓库中的值被载入时,此处代码运行
   console.log(value);
});

提供的方法有

  • getItem 根据数据的 key 获取数据 差不多返回 null

  • setItem 根据数据的 key 设置数据(存储undefined时getItem获取会返回 null

  • removeItem 根据key删除数据

  • length 获取key的数量

  • key 根据 key 的索引获取其名

  • keys 获取数据仓库中所有的 key。

  • iterate 迭代数据仓库中的所有 value/key 键值对。

配置

完整配置可查看文档 这里说个作者觉得有用的

localforage.config({ name: 'My-localStorage' }); 设置仓库的名字,不同的名字代表不同的仓库,当一个应用需要多个本地仓库隔离数据的时候就很有用。

const store = localforage.createInstance({
   name: "nameHere"
});

const otherStore = localforage.createInstance({
   name: "otherName"
});

// 设置某个数据仓库 key 的值不会影响到另一个数据仓库
store.setItem("key", "value");
otherStore.setItem("key", "value2");

同时也支持删除仓库

// 调用时,若不传参,将删除当前实例的 “数据仓库” 。
localforage.dropInstance().then(function() {
 console.log('Dropped the store of the current instance').
});

// 调用时,若参数为一个指定了 name 和 storeName 属性的对象,会删除指定的 “数据仓库”。
localforage.dropInstance({
 name: "otherName",
 storeName: "otherStore"
}).then(function() {
 console.log('Dropped otherStore').
});

// 调用时,若参数为一个仅指定了 name 属性的对象,将删除指定的 “数据库”(及其所有数据仓库)。
localforage.dropInstance({
 name: "otherName"
}).then(function() {
 console.log('Dropped otherName database').
});

idb-keyval

idb-keyval是用IndexedDB实现的一个超级简单的基于 promise 的键值存储。

安装

npm npm install idb-keyval

// 全部引入
import idbKeyval from 'idb-keyval';

idbKeyval.set('hello', 'world')
.then(() => console.log('It worked!'))
.catch((err) => console.log('It failed!', err));
 
// 按需引入会摇树
import { get, set } from 'idb-keyval';

set('hello', 'world')
.then(() => console.log('It worked!'))
.catch((err) => console.log('It failed!', err));
 
get('hello').then((val) => console.log(val));

浏览器直接引入 <script src="https://cdn.jsdelivr.net/npm/idb-keyval@6/dist/umd.js"></script>

暴露的全局变量是 idbKeyval 直接使用即可。

提供的方法

由于其没有中文的官网,会把例子及自己的理解附上

set 设置数据

值可以是 数字、数组、对象、日期、Blobs等,尽管老Edge不支持null。

键可以是数字、字符串、日期,(IDB也允许这些值的数组,但IE不支持)。

import { set } from 'idb-keyval';

set('hello', 'world')
.then(() => console.log('It worked!'))
.catch((err) => console.log('It failed!', err));

setMany 设置多个数据

一个设置多个值,比一个一个的设置更快

import { set, setMany } from 'idb-keyval';

// 不应该:
Promise.all([set(123, 456), set('hello', 'world')])
.then(() => console.log('It worked!'))
.catch((err) => console.log('It failed!', err));

// 这样做更快:
setMany([
[123, 456],
['hello', 'world'],
])
.then(() => console.log('It worked!'))
.catch((err) => console.log('It failed!', err));

get 获取数据

如果没有键,那么val将返回undefined的。

import { get } from 'idb-keyval';

// logs: "world"
get('hello').then((val) => console.log(val));

getMany 获取多个数据

一次获取多个数据,比一个一个获取数据更快

import { get, getMany } from 'idb-keyval';

// 不应该:
Promise.all([get(123), get('hello')]).then(([firstVal, secondVal]) =>
console.log(firstVal, secondVal),
);

// 这样做更快:
getMany([123, 'hello']).then(([firstVal, secondVal]) =>
console.log(firstVal, secondVal),
);

del 删除数据

根据 key 删除数据

import { del } from 'idb-keyval';

del('hello');

delMany 删除多个数据

一次删除多个键,比一个一个删除要快

import { del, delMany } from 'idb-keyval';

// 不应该:
Promise.all([del(123), del('hello')])
.then(() => console.log('It worked!'))
.catch((err) => console.log('It failed!', err));

// 这样做更快:
delMany([123, 'hello'])
.then(() => console.log('It worked!'))
.catch((err) => console.log('It failed!', err));

update 排队更新数据,防止由于异步导致数据更新问题

因为 getset 都是异步的使用他们来更新数据可能会存在问题如:

// Don't do this:
import { get, set } from 'idb-keyval';

get('counter').then((val) =>
set('counter', (val || 0) + 1);
);

get('counter').then((val) =>
set('counter', (val || 0) + 1);
);

上述代码我们期望的是 2 但实际结果是 1,我们可以在第一个回调执行第二次操作。

更好的方法是使用 update 来更新数据

// Instead:
import { update } from 'idb-keyval';

update('counter', (val) => (val || 0) + 1);
update('counter', (val) => (val || 0) + 1);

将自动排队更新,所以第一次更新将计数器设置为1,第二次更新将其设置为2

clear 清除所有数据

import { clear } from 'idb-keyval';

clear();

entries 返回 [key, value] 形式的数据

import { entries } from 'idb-keyval';

// logs: [[123, 456], ['hello', 'world']]
entries().then((entries) => console.log(entries));

keys 获取所有数据的 key

import { keys } from 'idb-keyval';

// logs: [123, 'hello']
keys().then((keys) => console.log(keys));

values 获取所有数据 value

import { values } from 'idb-keyval';

// logs: [456, 'world']
values().then((values) => console.log(values));

createStore 自定义仓库

文字解释:表 === store === 商店 一个意思

// 自定义数据库名称及表名称
// 创建一个数据库: 数据库名称为 tang_shi, 表名为 table1
const tang_shi_table1 = idbKeyval.createStore('tang_shi', 'table1')

// 向对应仓库添加数据
idbKeyval.set('add', 'table1 的数据', tang_shi_table1)

// 默认创建的仓库名称为 keyval-store 表名为 keyval
idbKeyval.set('add', '默认的数据')


使用 createStore 创建的数据库一个库只会创建一个表即:

// 同一个库有不可以有两个表,custom-store-2 不会创建成功:
const customStore = createStore('custom-db-name', 'custom-store-name');
const customStore2 = createStore('custom-db-name', 'custom-store-2');

// 不同的库 有相同的表名 这是可以的:
const customStore3 = createStore('db3', 'keyval');
const customStore4 = createStore('db4', 'keyval');

promisifyRequest

自己管理定制商店,这个没搞太明白,看文档中说既然都用到这个了不如直接使用idb 这个库

总结

本文介绍了两个 IndexedDB 的库,用来解决 localStorage 存储容量太小的问题

localforageidb-keyval 之间我更喜欢 localforage 因为其与 localStorage 相似的api几乎没有上手成本。

如果需要更加灵活的库可以看一下 dexie.jsPouchDBidbJsStore 或者 lovefield 之类的库

感谢观看!

作者:唐诗
来源:juejin.cn/post/7163075131261059086

收起阅读 »

Android 混淆规则是如何生效的?

前言 记录一下关于 Android 中关于混淆配置文件的生效规则、混淆规则的细节、build 产物中和混淆相关的内容及其作用。 混淆配置生效规则 现在的 Android 项目一般由一个主 app module,n 个子 lib module 共同组成。 ap...
继续阅读 »

前言


记录一下关于 Android 中关于混淆配置文件的生效规则、混淆规则的细节、build 产物中和混淆相关的内容及其作用。



混淆配置生效规则


现在的 Android 项目一般由一个主 app module,n 个子 lib module 共同组成。 app module 通过 dependencies 闭包依赖这些子 module ,或者是将这些子 module 上传到中央仓库之后进行依赖。


    if (source_code.toBoolean()) {
implementation project(path: ':thirdlib')
} else {
implementation 'com.engineer.third:thirdlib:1.0.0'
}
implementation project(path: ':compose')
implementation project(path: ':common')
implementation 'androidx.navigation:navigation-fragment-ktx:2.5.2'
...

比如对于下图中的几个子 module 可以通过 project(path: 'xxx') 的方式依赖,也可以将这个本地 module 上传到中央仓库之后通过 group_id:artifact_id:version 的方式依赖。


那么这两种方式依赖由哪些差异呢?



  • 远程依赖会比直接在本地依赖节省一些编译时间 (当然这不包括下载依赖本身耗费的时间),毕竟可以省去编译源码及资源的时间。

  • 对于混淆来说,这两种依赖方式混淆配置规则的生效是有些差异的。这里的差异是说混淆配置文件的差异,而不是说具体的一条混淆配置语法会有差异


下面具体来说一下这个差异。关于混淆配置,除了各个 moudle 下我们非常熟悉的肉眼可见 proguard-rules.pro 之外,其实还有别的混淆配置,最终会合并在一起生效。


说到各个 module 的配置文件合并,大家一定会想到 AndroidManifest.xml 。最终打包使用的 AndroidManifest.xml 的内容,就是各个子 module 和主 module merge 后的结果。


需要注意的是,Android 打包过程并不会主动合并本地 module 中的 proguard-rules.pro 文件 。注意,这里说的是本地 module .


module_structure.png


也就是说像 common/thirdlib/compose 这类直接在本地依赖的 module, 其内部的 proguard-rules.pro 并不会直接生效。 而通过 implementation group_id:artifact_id:version 依赖的远程 module ,如果其内部有配置 proguard 规则,就会 merge 到最终的混淆配置中。上一篇 发布 Android Lib 到 Maven 解惑 中我们提到, library 通过 gradle 任务发布到中央仓库的时候,会基于本地 consumer-rules.pro 生成最终的 proguard.txt 文件一并打包到 aar 文件中;这里 merge 的就是这个自动生成的 proguard.txt。而最终的混淆配置规则叠加到一起之后,在 app/build/outputs/mapping/huaweiLocalRelease/configuration.txt 这个文件里。


这个文件是有规则的,会按照段落列出编译过招中所涉及的模块。


001:# The proguard configuration file for the following section is D:\workspace\MinApp\app\build\intermediates\default_proguard_files\global\proguard-android-optimize.txt-7.2.1
121:# The proguard configuration file for the following section is D:\workspace\MinApp\app\proguard-rules.pro
182:# The proguard configuration file for the following section is D:\workspace\MinApp\app\build\intermediates\aapt_proguard_file\huaweiLocalRelease\aapt_rules.txt
392:# The proguard configuration file for the following section is D:\workspace\MinApp\common\build\intermediates\consumer_proguard_dir\release\lib0\proguard.txt
395:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\89e35bb901a511dc73379ee56d9a96fb\transformed\navigation-ui-2.3.5\proguard.txt
416:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\ed14b9608e236c3cb341584bd1991f2a\transformed\material-1.5.0\proguard.txt
465:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\38b91e3dad918eabe8ced61c0f881bef\transformed\jetified-stetho-1.6.0\proguard.txt
470:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\380c0daab5f38fa92451c63d6b7f2468\transformed\preference-1.1.1\proguard.txt
494:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\a00816a85507c4640738406281464e4f\transformed\appcompat-1.4.1\proguard.txt
519:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\19a9b30d1e238c7cf954868475b2d87a\transformed\navigation-common-ktx-2.3.5\proguard.txt
541:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\bc1e654ac594a8eec67d83a310d595cd\transformed\rules\lib\META-INF\com.android.tools\r8-from-1.6.0\kotlin-reflect.pro
559:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\822d22c7ed69ccdf4d90c18a483e72c5\transformed\rules\lib\META-INF\com.android.tools\r8-from-1.6.0\coroutines.pro
585:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\555542c94d89a20ac01618f64dfcfed2\transformed\rules\lib\META-INF\proguard\coroutines.pro
608:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\a830f069563364388aaf53b586352be8\transformed\jetified-glide-4.13.1\proguard.txt
625:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\9ce146a7d8708a759f2821d06606c176\transformed\jetified-flexbox-1.0.0\proguard.txt
647:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\003c1e88ccf7eabdb17daba177d5544b\transformed\jetified-hilt-lifecycle-viewmodel-1.0.0-alpha03\proguard.txt
654:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\2675f8213875fddbbb3d30c803c00c9c\transformed\jetified-hilt-android-2.40.1\proguard.txt
665:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\3368f9a73434dea0d4e52626ffd9a8c9\transformed\fragment-1.3.6\proguard.txt
687:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\58278e1b3a97715913034b7b81fae8cb\transformed\jetified-lifecycle-viewmodel-savedstate-2.3.1\proguard.txt
697:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\cb4d77137f22248d78cd200f94d17fc4\transformed\jetified-savedstate-1.1.0\proguard.txt
717:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\6a6fcb77b4395418002e332cd9738bfb\transformed\work-runtime-2.7.0\proguard.txt
728:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\4ab9d68c51a5e06d113a80174817d2cc\transformed\media-1.0.0\proguard.txt
753:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\5599788d3c018cf9be3c21d9a4ff4718\transformed\coordinatorlayout-1.1.0\proguard.txt
778:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\0e108ece111c1c104d1543d98f952017\transformed\vectordrawable-animated-1.1.0\proguard.txt
800:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\19c137c4f40e8110221a03964c21b354\transformed\recyclerview-1.1.0\proguard.txt
827:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\9eb9006bf5796c20208d89f414c860f8\transformed\transition-1.3.0\proguard.txt
848:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\848cc86aa556453b7ae2d77cf1ed69f7\transformed\core-1.7.0\proguard.txt
867:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\c269ff43c6850351c92a4f3de7a5d26d\transformed\jetified-lifecycle-process-2.4.0\proguard.txt
871:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\f082dcda3ea45d057bb4fd056c4b3864\transformed\lifecycle-runtime-2.4.0\proguard.txt
896:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\c78621e75bc17f9e3a8dc4279fe51aed\transformed\rules\lib\META-INF\proguard\retrofit2.pro
928:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\9dd79968324ef9619ccee991ab21aa68\transformed\rules\lib\META-INF\proguard\rxjava2.pro
931:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\9eef9e5128bbcd6232ee9a89f4c5bf00\transformed\lifecycle-viewmodel-2.3.1\proguard.txt
941:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\5875adda5cf6fa792736faf48738cf7c\transformed\jetified-startup-runtime-1.0.0\proguard.txt
952:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\e06b693ee9109a0e8f8d0949e74720e0\transformed\room-runtime-2.4.0\proguard.txt
957:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\6008298b41480f69c56a08890c83e302\transformed\versionedparcelable-1.1.1\proguard.txt
964:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\2810bf2a83f304a3ff02e4019efe065f\transformed\rules\lib\META-INF\proguard\androidx-annotations.pro
985:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\6dc53357fb30238e16dbc902967a8aab\transformed\jetified-annotation-experimental-1.1.0\proguard.txt
1011:# The proguard configuration file for the following section is D:\Android\.gradle\caches\transforms-3\4233f8c9725e3a6760c0e0e606e43b29\transformed\rules\lib\META-INF\proguard\okhttp3.pro
1025:# The proguard configuration file for the following section is <unknown>

可以看到,除了我们熟悉的 app/proguard-rules.pro 之外,其实还使用了其他 module 的 xxx.pro 文件。当然,这里有些文件,可能没有配置任何内容,只是一个默认的配置,就像 app/proguard-rules.pro 刚创建时候的样子,有兴趣的话可以打开文件查看。


在这个最终的混淆配置规则里还有一些值得我们注意的地方。



  • proguard-android-optimize.txt-7.2.1


# This is a configuration file for ProGuard.
# http://proguard.sourceforge.net/index.html#manual/usage.html
#
# Starting with version 2.2 of the Android plugin for Gradle, this file is distributed together with
# the plugin and unpacked at build-time. The files in $ANDROID_HOME are no longer maintained and
# will be ignored by new version of the Android plugin for Gradle.

也就是说从 AGP 2.2 开始,在编译阶段会使用当前 AGP 插件所携带的混淆规则,而不在使用本地 Android SDK/tools/proguard/ 目录下的混淆配置了。
这个混淆配置文件里规则都非常通用的,比如对于 enum , Keep 注解,webview Interface 等等之类的规则。 这就意味着 AGP 插件的升级有可能会影响到混淆,如果某个 AGP 版本所携带的混淆规则发了一些变化的话。



  • aapt_rules.txt


aapt 顾名思义,就是在执行 AAPT 阶段生成的混淆规则,可以看到里面都是基于 Android 应用层源码的一些配置。会根据代码中的资源文件、布局文件等内容生成相应的规则。比如会基于 AndroidManifest.xml 中声明的四大组件,保留相应的 Activity、Service 等类的构造函数,一些自定义 View 的构造函数等。



  • META-INF\proguard\okhttp3.pro


这类混淆规则其实是 xxx.jar 文件内部的混淆规则。Android 开发中非常实用的 okhttp、RxJava、Retrofit 等这些纯 Java/Kotlin 代码的 module 打包之后上传到中央仓库的就是 jar 文件,而不是 aar (毕竟不涉及到 UI,因此也不会有资源文件了)。


lib_jar.png


对于 java-library 类型的 module, 通过上述配置,最终打包的 jar 文件中将包含这个 thirdlib.pro 混淆配置文件。


剩下的就是一些我们常用的类库自身携带的混淆规则了,可以看到这些 aar 类型的库其混淆配置文件都是 proguard.txt 。


从这里我们可以看到,AGP 已经非常完善了,在打包过程中会在基于实际代码自动生成相应的混淆规则,尤其是关于 Android 自身类及组件的配置。平时在网上看到的各种混淆配置,没必要非得对着里面的内容一条一条的进行配置,一些非常基础且共用的混淆规则都是默认的。我们实际需要关心的还是自身业务相关的混淆规则,比如涉及 Json 序列化操作的 Model 类的,以及自己写的涉及反射操作的类。


那么子 moudle 直接在本地依赖的情况下,混淆配置是如何生效的呢?


子 module 的生效规则


这里我们可以重点关注一下 392:# The proguard configuration file for the following section is D:\workspace\MinApp\common\build\intermediates\consumer_proguard_dir\release\lib0\proguard.txt


从路径就可以猜出来了,这里的 lib0 就是本地依赖的 common module 。


这部分在 configuration.txt 中是这样的。


# The proguard configuration file for the following section is D:\workspace\MinApp\common\build\intermediates\consumer_proguard_dir\release\lib0\proguard.txt
-keep class com.engineer.common.utils.AndroidFileUtils {*;}
# End of content from D:\workspace\MinApp\common\build\intermediates\consumer_proguard_dir\release\lib0\proguard.txt

这部分就是子 module 的混淆配置。子 module 混淆配置生效有两种方式,而这两种方式都依赖 consumerProguardFiles 这个属性。


直接使用 consumer-rules.pro


直接在子 module 的 consumer-rules.pro 中配置要混淆的规则。然后在 build.gradle 中通过默认的配置生效


    defaultConfig {
minSdk ext.minSdkVersion
targetSdk ext.targetSdkVersion

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
}

子 module 创建的时候,会默认在 defaultConfig 闭包中添加 consumerProguardFiles 这个配置,因此只要在 consumer-rules.pro 中配置了混淆规则,就会生效。


使用 proguard-rules.pro


如果你不习惯使用 consumer-rules.pro 的话,也可以使用 proguard-rules.pro ,直接配置一下就可以了。


    buildTypes {
release {
minifyEnabled false
consumerProguardFiles "proguard-rules.pro"
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}

这两种方式配置的内容,最终都会生效。再次谨记,混淆规则是叠加生效的,并不存在什么先后顺序。打包过程中只要找到了可用的配置文件,就会照单全收


混淆产物


说完了混淆配置生效的规则,可以一并再看一下混淆的产物。打包完成后,会在 app/build/outputs/mapping/{flavor}/ 目录下生成一些混淆相关的文件。































文件名作用
configuration.txt所有混淆配置的汇总
mapping.txt原始与混淆过的类、方法、字段名称间的转换
resources.txt资源优化记录文件,哪些资源引用了其他资源,哪些资源在使用,哪些资源被移除
seeds.txt未进行混淆的类与成员
usage.txtAPK中移除的代码

通过这些文件,我们就可以看到一次打包过程中,混淆具体做了哪些事情。比较常用的是 mapping.txt 文件,当混淆过后的包出现问题时,通过 stacktrace 定位问题的时候,由于代码被混淆会无法识别,这时候就是通过 mappting.txt 文件解混淆。这里使用过 bugly 的同学应该很熟悉了。线上代码出现问题,上传 mapping 文件就可以快速定位到出现问题的具体代码了。


通过 seeds.txt 也可以看到哪些文件没有被混淆,内容是否符合预期。


上面混淆配置生效规则里提到了,打包过程中会综合各个 module 的混淆配置文件。因此,有时候我们会发现,自己明明没有配置某些类的 keep ,但是这些类依然没有被混淆,这时候可能就是由于项目本身依赖的 module 的混淆规则生效了。 比如 configuration.txt 中 Android fragment 包的这条规则


# The proguard configuration file for the following section is /Users/rookie/.gradle/caches/transforms-3/3368f9a73434dea0d4e52626ffd9a8c9/transformed/fragment-1.3.6/proguard.txt

# The default FragmentFactory creates Fragment instances using reflection
-if public class ** extends androidx.fragment.app.Fragment
-keepclasseswithmembers,allowobfuscation public class <1> {
public <init>();
}

# End of content from /Users/rookie/.gradle/caches/transforms-3/3368f9a73434dea0d4e52626ffd9a8c9/transformed/fragment-1.3.6/proguard.txt

所有继承自 androidx.fragment.app.Fragment 的类都会随着其构造方法的一起被 keep 。这样最终混淆结果中就会有很多的业务相关的 XXXFragment 类无法被混淆。至于原因,上面的注释解释的很清楚了,需要通过反射创建 Fragment 的实例。


所以,在混淆过程中,如果发现一些没有类没有被混淆,不妨在 configuration.txt 中找找原因。


严格来说,resources.txt 是由于配置了 shrinkResources true 对无效资源文件进行移除操作后产生的结果,不算是混淆,但是这里可以理解为混淆过程


混淆规则


混淆规则本质上非常灵活,很难用一句话概括清楚。这里引用郭神的Android安全攻防战,反编译与混淆技术完全解析(下) 中的表述 ,感觉比较清晰。


keep 关键字规则



































关键字描述
keep保留类和类中的成员,防止它们被混淆或移除。
keepnames保留类和类中的成员,防止它们被混淆,但当成员没有被引用时会被移除。
keepclassmembers只保留类中的成员,防止它们被混淆或移除。
keepclassmembernames只保留类中的成员,防止它们被混淆,但当成员没有被引用时会被移除。
keepclasseswithmembers保留类和类中的成员,防止它们被混淆或移除,前提是指名的类中的成员必须存在,如果不存在则还是会混淆。
keepclasseswithmembernames保留类和类中的成员,防止它们被混淆,但当成员没有被引用时会被移除,前提是指名的类中的成员必须存在,如果不存在则还是会混淆。

通配符规则







































通配符描述
<field>匹配类中的所有字段
<method>匹配类中的所有方法
<init>匹配类中的所有构造函数
*匹配任意长度字符,但不含包名分隔符(.)。比如说我们的完整类名是com.example.test.MyActivity,使用com.,或者com.exmaple.都是无法匹配的,因为无法匹配包名中的分隔符,正确的匹配方式是com.exmaple..,或者com.exmaple.test.,这些都是可以的。但如果你不写任何其它内容,只有一个*,那就表示匹配所有的东西。
**匹配任意长度字符,并且包含包名分隔符(.)。比如proguard-android.txt中使用的-dontwarn android.support.**就可以匹配android.support包下的所有内容,包括任意长度的子包。
***匹配任意参数类型。比如void set*()就能匹配任意传入的参数类型, get*()就能匹配任意返回值的类型。
匹配任意长度的任意类型参数。比如void test(…)就能匹配任意void test(String a)或者是void test(int a, String b)这些方法。

网上大部分文章提到的混淆配置语法都大同小异,都是从正面出发,这样有时候其实是不太灵活的。比如在某些场景下我们需要保留所有实现了 Serializable 接口的类,因为这些类涉及到序列化操作。


-keep class * implements java.io.Serializable {*;}

这条规则本身没问题,但是其实这个规则的范围是很大的。因为我们常用的 enum 的具体实现 Enum 类


public abstract class Enum<E extends Enum<E>>
implements Comparable<E>, Serializable { ....}

也是实现了 Serializable 接口的。因此会导致所有的 enum 类型无法被混淆。实际上 Java 集合框架也有很多类实现了这个接口。虽然 Android 官方不建议使用枚举,但是现实中使用的还是挺多的,比如 glide 。这样就会导致原本可以被混淆的类受到牵连。


那么可以避免这种情况吗?其实是有办法的,细心的话你也许已经发现了,在上面 FragmentFactory 的混淆配置语法里有条件判断的逻辑。


# The default FragmentFactory creates Fragment instances using reflection
-if public class ** extends androidx.fragment.app.Fragment
-keepclasseswithmembers,allowobfuscation public class <1> {
public <init>();
}

看到这里的 if 你是不是有点想法了呢?强烈建议在需要配置混淆规则的时候多参考一下 configuration.txt 中一些官方库的配置规则,也许会让你打开一扇新的打门。


混淆认知


混淆配置规则看起来简单,但其实结合实际场景会变得有些复杂,尤其是代码包含内部类,匿名内部,静态内部类等等不同场景下。这些具体的规律还是需要结合实际场景通过不断的验证。
关于代码混淆,最好的学习方法就是自己通过写代码,组合各类配置不断验证。打包后可以用 jadx-gui 查看混淆的 apk 文件。


最后再补充一个进行混淆配置验证时的小技巧。


android {
//...
lint {
checkReleaseBuilds false
}
}

直接在 app/build.gradle android 闭包下配置关闭 releaseBuild 时的 lint 检查。毕竟混淆规则的修改不会影响代码本身,因此可以通过跳过检测,节省编译时间。毕竟这个 lint 检查的耗时还是很可观的。这样就可以避免每次打包时的等待了。
有些时候临时打 release 包验证一些问题的时候,也可以临时加上这个配置关闭检测。


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

以为很熟悉CountDownLatch的使用了,没想到在生产环境翻车了

前言 大家好,我是小郭,之前分享了CountDownLatch的使用,我们知道用来控制并发流程的同步工具,主要的作用是为了等待多个线程同时完成任务后,在进行主线程任务。 万万没想到,在生产环境中竟然翻车了,因为没有考虑到一些场景,导致了CountDownLat...
继续阅读 »

前言


大家好,我是小郭,之前分享了CountDownLatch的使用,我们知道用来控制并发流程的同步工具,主要的作用是为了等待多个线程同时完成任务后,在进行主线程任务。


万万没想到,在生产环境中竟然翻车了,因为没有考虑到一些场景,导致了CountDownLatch出现了问题,接下来来分享一下由于CountDownLatch导致的问题。


# 【线程】并发流程控制的同步工具-CountDownLatch


需求背景


先简单介绍下业务场景,针对用户批量下载的文件进行修改上传


未命名文件.png


为了提高执行的速度,所以在采用线程池去执行 下载-修改-上传 的操作,并在全部执行完之后统一提交保存文件地址到数据库,于是加入了CountDownLatch来进行控制。


具体实现


根据服务本身情况,自定义一个线程池


public static ExecutorService testExtcutor() {
       return new ThreadPoolExecutor(
               2,
               2,
               0L,
               TimeUnit.SECONDS,
               new LinkedBlockingQueue<>(1));
  }

模拟执行


public static void main(String[] args) {
      // 下载文件总数
      List<Integer> resultList = new ArrayList<>(100);
      IntStream.range(0,100).forEach(resultList::add);
      // 下载文件分段
      List<List<Integer>> split = CollUtil.split(resultList, 10);

      ExecutorService executorService = BaseThreadPoolExector.testExtcutor();
      CountDownLatch countDownLatch = new CountDownLatch(100);
      for (List<Integer> list : split) {
          executorService.execute(() -> {
              list.forEach(i ->{
                  try {
                      // 模拟业务操作
                      Thread.sleep(500);
                      System.out.println("任务进入");
                  } catch (InterruptedException e) {
                      e.printStackTrace();
                      System.out.println(e.getMessage());
                  } finally {
                      System.out.println(countDownLatch.getCount());
                      countDownLatch.countDown();
                  }
              });
          });
      }
      try {
          countDownLatch.await();
          System.out.println("countDownLatch.await()");
      } catch (InterruptedException e) {
          e.printStackTrace();
      }
  }

一开始我个人感觉没有什么问题,反正finally都能够做减一的操作,到最后调用await方法,进行主线程任务


Exception in thread "main" java.util.concurrent.RejectedExecutionException: Task java.util.concurrent.FutureTask@300ffa5d rejected from java.util.concurrent.ThreadPoolExecutor@1f17ae12[Running, pool size = 2, active threads = 2, queued tasks = 1, completed tasks = 0]
at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2063)
at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:830)
at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1379)
at java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:112)
at Thread.executor.executorTestBlock.main(executorTestBlock.java:28)
任务进入
countDownLatch.countDown
任务进入
countDownLatch.countDown
任务进入
countDownLatch.countDown
任务进入
countDownLatch.countDown
任务进入
countDownLatch.countDown
任务进入
countDownLatch.countDown
任务进入
countDownLatch.countDown
任务进入
countDownLatch.countDown
任务进入
countDownLatch.countDown
任务进入
countDownLatch.countDown
任务进入
countDownLatch.countDown
任务进入
countDownLatch.countDown
任务进入
countDownLatch.countDown
任务进入
countDownLatch.countDown
任务进入
countDownLatch.countDown

由于任务数量较多,阻塞队列中已经塞满了,所以默认的拒绝策略,当队列满时,处理策略报错异常,


要注意这个异常是线程池,自己抛出的,不是我们循环里面打印出来的,


这也造成了,线上这个线程池被阻塞了,他永远也调用不到await方法,


利用jstack,我们就能够看到有问题


"pool-1-thread-2" #12 prio=5 os_prio=31 tid=0x00007ff6198b7000 nid=0xa903 waiting on condition [0x0000700001c64000]
java.lang.Thread.State: WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x000000076b2283f8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039)
at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442)
at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1074)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)

"pool-1-thread-1" #11 prio=5 os_prio=31 tid=0x00007ff6198b6800 nid=0x5903 waiting on condition [0x0000700001b61000]
java.lang.Thread.State: WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x000000076b2283f8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039)
at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442)
at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1074)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)

解决方案




  1. 调大阻塞队列,但是问题来了,到底多少阻塞队列才是大呢,如果太大了会不由又造成内存溢出等其他的问题




  2. 在第一个的基础上,我们修改了拒绝策略,当触发拒绝策略的时候,用调用者所在的线程来执行任务


     public static ThreadPoolExecutor queueExecutor(BlockingQueue<Runnable> workQueue){
          return new ThreadPoolExecutor(
                  size,
                  size,
                  0L,
                  TimeUnit.SECONDS,
                  workQueue,
                  new ThreadPoolExecutor.CallerRunsPolicy());
      }



  3. 你可能又会想说,会不会任务数量太多,导致调用者所在的线程执行不过来,任务提交的性能急剧下降


    那我们就应该自定义拒绝策略,将这下排队的消息记录下来,采用补偿机制的方式去执行




  4. 同时也要注意上面的那个异常是线程池抛出来的,我们自己也需要将线程池进行try catch,记录问题数据,并且在finally中执行countDownLatch.countDown来避免,线程池的使用




总结


目前根据业务部门的反馈,业务实际中任务数不很特别多的情况,所以暂时先采用了第二种方式去解决这个线上问题


在这里我们也可以看到,如果没有正确的关闭countDownLatch,可能会导致一直等待,这也是我们需要注意的。


工具虽然好,但是依然要注意他带来的问题,没有正确的去处理好,引发的一系列连锁反应。


告辞.jpg


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

面试必备:ThreadLocal详解

前言 大家好,我是捡田螺的小男孩。 无论是工作还是面试,我们都会跟ThreadLocal打交道,今天就跟大家聊聊ThreadLocal哈~ ThreadLocal是什么?为什么要使用ThreadLocal 一个ThreadLocal的使用案例 ThreadL...
继续阅读 »

前言


大家好,我是捡田螺的小男孩


无论是工作还是面试,我们都会跟ThreadLocal打交道,今天就跟大家聊聊ThreadLocal哈~



  1. ThreadLocal是什么?为什么要使用ThreadLocal

  2. 一个ThreadLocal的使用案例

  3. ThreadLocal的原理

  4. 为什么不直接用线程id作为ThreadLocalMap的key

  5. 为什么会导致内存泄漏呢?是因为弱引用吗?

  6. Key为什么要设计成弱引用呢?强引用不行?

  7. InheritableThreadLocal保证父子线程间的共享数据

  8. ThreadLocal的应用场景和使用注意点



  • github地址,麻烦给个star鼓励一下,感谢感谢

  • 公众号:捡田螺的小男孩(欢迎关注,干货多多)


1. ThreadLocal是什么?为什么要使用ThreadLocal?


ThreadLocal是什么?


ThreadLocal,即线程本地变量。如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个本地拷贝,多个线程操作这个变量的时候,实际是在操作自己本地内存里面的变量,从而起到线程隔离的作用,避免了并发场景下的线程安全问题。


//创建一个ThreadLocal变量
static ThreadLocal localVariable = new ThreadLocal<>();

为什么要使用ThreadLocal


并发场景下,会存在多个线程同时修改一个共享变量的场景。这就可能会出现线性安全问题


为了解决线性安全问题,可以用加锁的方式,比如使用synchronized 或者Lock。但是加锁的方式,可能会导致系统变慢。加锁示意图如下:



还有另外一种方案,就是使用空间换时间的方式,即使用ThreadLocal。使用ThreadLocal类访问共享变量时,会在每个线程的本地,都保存一份共享变量的拷贝副本。多线程对共享变量修改时,实际上操作的是这个变量副本,从而保证线性安全。



2. 一个ThreadLocal的使用案例


日常开发中,ThreadLocal经常在日期转换工具类中出现,我们先来看个反例



/**
* 日期工具类
*/

public class DateUtil {

private static final SimpleDateFormat simpleDateFormat =
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

public static Date parse(String dateString) {
Date date = null;
try {
date = simpleDateFormat.parse(dateString);
} catch (ParseException e) {
e.printStackTrace();
}
return date;
}
}

我们在多线程环境跑DateUtil这个工具类:


public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(10);

for (int i = 0; i < 10; i++) {
executorService.execute(()->{
System.out.println(DateUtil.parse("2022-07-24 16:34:30"));
});
}
executorService.shutdown();
}

运行后,发现报错了:



如果在DateUtil工具类,加上ThreadLocal,运行则不会有这个问题:


/**
* 日期工具类
*/

public class DateUtil {

private static ThreadLocal dateFormatThreadLocal =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

public static Date parse(String dateString) {
Date date = null;
try {
date = dateFormatThreadLocal.get().parse(dateString);
} catch (ParseException e) {
e.printStackTrace();
}
return date;
}

public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(10);

for (int i = 0; i < 10; i++) {
executorService.execute(()->{
System.out.println(DateUtil.parse("2022-07-24 16:34:30"));
});
}
executorService.shutdown();
}
}

运行结果:


Sun Jul 24 16:34:30 GMT+08:00 2022
Sun Jul 24 16:34:30 GMT+08:00 2022
Sun Jul 24 16:34:30 GMT+08:00 2022
Sun Jul 24 16:34:30 GMT+08:00 2022
Sun Jul 24 16:34:30 GMT+08:00 2022
Sun Jul 24 16:34:30 GMT+08:00 2022
Sun Jul 24 16:34:30 GMT+08:00 2022
Sun Jul 24 16:34:30 GMT+08:00 2022
Sun Jul 24 16:34:30 GMT+08:00 2022
Sun Jul 24 16:34:30 GMT+08:00 2022

刚刚反例中,为什么会报错呢?这是因为SimpleDateFormat不是线性安全的,它以共享变量出现时,并发多线程场景下即会报错。


为什么加了ThreadLocal就不会有问题呢?并发场景下,ThreadLocal是如何保证的呢?我们接下来看看ThreadLocal的核心原理。


3. ThreadLocal的原理


3.1 ThreadLocal的内存结构图


为了有个宏观的认识,我们先来看下ThreadLocal的内存结构图



从内存结构图,我们可以看到:



  • Thread类中,有个ThreadLocal.ThreadLocalMap 的成员变量。

  • ThreadLocalMap内部维护了Entry数组,每个Entry代表一个完整的对象,keyThreadLocal本身,valueThreadLocal的泛型对象值。


3.2 关键源码分析


对照着几段关键源码来看,更容易理解一点哈~我们回到Thread类源码,可以看到成员变量ThreadLocalMap的初始值是为null


public class Thread implements Runnable {
//ThreadLocal.ThreadLocalMap是Thread的属性
ThreadLocal.ThreadLocalMap threadLocals = null;
}

ThreadLocalMap的关键源码如下:


static class ThreadLocalMap {

static class Entry extends WeakReference> {
/** The value associated with this ThreadLocal. */
Object value;

Entry(ThreadLocal k, Object v) {
super(k);
value = v;
}
}
//Entry数组
private Entry[] table;

// ThreadLocalMap的构造器,ThreadLocal作为key
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);
}
}

ThreadLocal类中的关键set()方法:


 public void set(T value) {
Thread t = Thread.currentThread(); //获取当前线程t
ThreadLocalMap map = getMap(t); //根据当前线程获取到ThreadLocalMap
if (map != null) //如果获取的ThreadLocalMap对象不为空
map.set(this, value); //K,V设置到ThreadLocalMap中
else
createMap(t, value); //创建一个新的ThreadLocalMap
}

ThreadLocalMap getMap(Thread t) {
return t.threadLocals; //返回Thread对象的ThreadLocalMap属性
}

void createMap(Thread t, T firstValue) { //调用ThreadLocalMap的构造函数
t.threadLocals = new ThreadLocalMap(this, firstValue); this表示当前类ThreadLocal
}

ThreadLocal类中的关键get()方法


    public T get() {
Thread t = Thread.currentThread();//获取当前线程t
ThreadLocalMap map = getMap(t);//根据当前线程获取到ThreadLocalMap
if (map != null) { //如果获取的ThreadLocalMap对象不为空
//由this(即ThreadLoca对象)得到对应的Value,即ThreadLocal的泛型值
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue(); //初始化threadLocals成员变量的值
}

private T setInitialValue() {
T value = initialValue(); //初始化value的值
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t); //以当前线程为key,获取threadLocals成员变量,它是一个ThreadLocalMap
if (map != null)
map.set(this, value); //K,V设置到ThreadLocalMap中
else
createMap(t, value); //实例化threadLocals成员变量
return value;
}

所以怎么回答ThreadLocal的实现原理?如下,最好是能结合以上结构图一起说明哈~



  • Thread线程类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals,即每个线程都有一个属于自己的ThreadLocalMap

  • ThreadLocalMap内部维护着Entry数组,每个Entry代表一个完整的对象,keyThreadLocal本身,valueThreadLocal的泛型值。

  • 并发多线程场景下,每个线程Thread,在往ThreadLocal里设置值的时候,都是往自己的ThreadLocalMap里存,读也是以某个ThreadLocal作为引用,在自己的map里找对应的key,从而可以实现了线程隔离


了解完这几个核心方法后,有些小伙伴可能会有疑惑,ThreadLocalMap为什么要用ThreadLocal作为key呢?直接用线程Id不一样嘛?


4. 为什么不直接用线程id作为ThreadLocalMap的key呢?


举个代码例子,如下:


public class TianLuoThreadLocalTest {

private static final ThreadLocal threadLocal1 = new ThreadLocal<>();
private static final ThreadLocal threadLocal2 = new ThreadLocal<>();

}

这种场景:一个使用类,有两个共享变量,也就是说用了两个ThreadLocal成员变量的话。如果用线程id作为ThreadLocalMapkey,怎么区分哪个ThreadLocal成员变量呢?因此还是需要使用ThreadLocal作为Key来使用。每个ThreadLocal对象,都可以由threadLocalHashCode属性唯一区分的,每一个ThreadLocal对象都可以由这个对象的名字唯一区分(下面的例子)。看下ThreadLocal代码:


public class ThreadLocal {
private final int threadLocalHashCode = nextHashCode();

private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
}

然后我们再来看下一个代码例子:


public class TianLuoThreadLocalTest {

public static void main(String[] args) {
Thread t = new Thread(new Runnable(){
public void run(){
ThreadLocal threadLocal1 = new ThreadLocal<>();
threadLocal1.set(new TianLuoDTO("公众号:捡田螺的小男孩"));
System.out.println(threadLocal1.get());
ThreadLocal threadLocal2 = new ThreadLocal<>();
threadLocal2.set(new TianLuoDTO("公众号:程序员田螺"));
System.out.println(threadLocal2.get());
}});
t.start();
}

}
//运行结果
TianLuoDTO{name='公众号:捡田螺的小男孩'}
TianLuoDTO{name='公众号:程序员田螺'}

再对比下这个图,可能就更清晰一点啦:



5. TreadLocal为什么会导致内存泄漏呢?


5.1 弱引用导致的内存泄漏呢?


我们先来看看TreadLocal的引用示意图哈:



关于ThreadLocal内存泄漏,网上比较流行的说法是这样的:



ThreadLocalMap使用ThreadLocal弱引用作为key,当ThreadLocal变量被手动设置为null,即一个ThreadLocal没有外部强引用来引用它,当系统GC时,ThreadLocal一定会被回收。这样的话,ThreadLocalMap中就会出现keynullEntry,就没有办法访问这些keynullEntryvalue,如果当前线程再迟迟不结束的话(比如线程池的核心线程),这些keynullEntryvalue就会一直存在一条强引用链:Thread变量 -> Thread对象 -> ThreaLocalMap -> Entry -> value -> Object 永远无法回收,造成内存泄漏。



当ThreadLocal变量被手动设置为null后的引用链图:



实际上,ThreadLocalMap的设计中已经考虑到这种情况。所以也加上了一些防护措施:即在ThreadLocalget,set,remove方法,都会清除线程ThreadLocalMap里所有keynullvalue


源代码中,是有体现的,如ThreadLocalMapset方法:


  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;
}

//如果k等于null,则说明该索引位之前放的key(threadLocal对象)被回收了,这通常是因为外部将threadLocal变量置为null,
//又因为entry对threadLocal持有的是弱引用,一轮GC过后,对象被回收。
//这种情况下,既然用户代码都已经将threadLocal置为null,那么也就没打算再通过该对象作为key去取到之前放入threadLocalMap的value, 因此ThreadLocalMap中会直接替换调这种不新鲜的entry。
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}

tab[i] = new Entry(key, value);
int sz = ++size;
//触发一次Log2(N)复杂度的扫描,目的是清除过期Entry
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}

如ThreadLocal的get方法:


  public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
//去ThreadLocalMap获取Entry,方法里面有key==null的清除逻辑
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

private Entry getEntry(ThreadLocal key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
//里面有key==null的清除逻辑
return getEntryAfterMiss(key, i, e);
}

private Entry getEntryAfterMiss(ThreadLocal key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;

while (e != null) {
ThreadLocal k = e.get();
if (k == key)
return e;
// Entry的key为null,则表明没有外部引用,且被GC回收,是一个过期Entry
if (k == null)
expungeStaleEntry(i); //删除过期的Entry
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}

5.2 key是弱引用,GC回收会影响ThreadLocal的正常工作嘛?


到这里,有些小伙伴可能有疑问,ThreadLocalkey既然是弱引用.会不会GC贸然把key回收掉,进而影响ThreadLocal的正常使用?




  • 弱引用:具有弱引用的对象拥有更短暂的生命周期。如果一个对象只有弱引用存在了,则下次GC将会回收掉该对象(不管当前内存空间足够与否)



其实不会的,因为有ThreadLocal变量引用着它,是不会被GC回收的,除非手动把ThreadLocal变量设置为null,我们可以跑个demo来验证一下:


  public class WeakReferenceTest {
public static void main(String[] args) {
Object object = new Object();
WeakReference testWeakReference = new WeakReference<>(object);
System.out.println("GC回收之前,弱引用:"+testWeakReference.get());
//触发系统垃圾回收
System.gc();
System.out.println("GC回收之后,弱引用:"+testWeakReference.get());
//手动设置为object对象为null
object=null;
System.gc();
System.out.println("对象object设置为null,GC回收之后,弱引用:"+testWeakReference.get());
}
}
运行结果:
GC回收之前,弱引用:java.lang.Object@7b23ec81
GC回收之后,弱引用:java.lang.Object@7b23ec81
对象object设置为null,GC回收之后,弱引用:null

结论就是,小伙伴放下这个疑惑了,哈哈~


5.3 ThreadLocal内存泄漏的demo


给大家来看下一个内存泄漏的例子,其实就是用线程池,一直往里面放对象


public class ThreadLocalTestDemo {

private static ThreadLocal tianLuoThreadLocal = new ThreadLocal<>();


public static void main(String[] args) throws InterruptedException {

ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(5, 5, 1, TimeUnit.MINUTES, new LinkedBlockingQueue<>());

for (int i = 0; i < 10; ++i) {
threadPoolExecutor.execute(new Runnable() {
@Override
public void run()
{
System.out.println("创建对象:");
TianLuoClass tianLuoClass = new TianLuoClass();
tianLuoThreadLocal.set(tianLuoClass);
tianLuoClass = null; //将对象设置为 null,表示此对象不在使用了
// tianLuoThreadLocal.remove();
}
});
Thread.sleep(1000);
}
}

static class TianLuoClass {
// 100M
private byte[] bytes = new byte[100 * 1024 * 1024];
}
}


创建对象:
创建对象:
创建对象:
创建对象:
Exception in thread "pool-1-thread-4" java.lang.OutOfMemoryError: Java heap space
at com.example.dto.ThreadLocalTestDemo$TianLuoClass.(ThreadLocalTestDemo.java:33)
at com.example.dto.ThreadLocalTestDemo$1.run(ThreadLocalTestDemo.java:21)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)

运行结果出现了OOM,tianLuoThreadLocal.remove();加上后,则不会OOM


创建对象:
创建对象:
创建对象:
创建对象:
创建对象:
创建对象:
创建对象:
创建对象:
......

我们这里没有手动设置tianLuoThreadLocal变量为null,但是还是会内存泄漏。因为我们使用了线程池,线程池有很长的生命周期,因此线程池会一直持有tianLuoClass对象的value值,即使设置tianLuoClass = null;引用还是存在的。这就好像,你把一个个对象object放到一个list列表里,然后再单独把object设置为null的道理是一样的,列表的对象还是存在的。


    public static void main(String[] args) {
List list = new ArrayList<>();
Object object = new Object();
list.add(object);
object = null;
System.out.println(list.size());
}
//运行结果
1

所以内存泄漏就这样发生啦,最后内存是有限的,就抛出了OOM了。如果我们加上threadLocal.remove();,则不会内存泄漏。为什么呢?因为threadLocal.remove();会清除Entry,源码如下:


    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) {
//清除entry
e.clear();
expungeStaleEntry(i);
return;
}
}
}

有些小伙伴说,既然内存泄漏不一定是因为弱引用,那为什么需要设计为弱引用呢?我们来探讨下:


6. Entry的Key为什么要设计成弱引用呢?


通过源码,我们是可以看到EntryKey是设计为弱引用的(ThreadLocalMap使用ThreadLocal的弱引用作为Key的)。为什么要设计为弱引用呢?



我们先来回忆一下四种引用:



  • 强引用:我们平时new了一个对象就是强引用,例如 Object obj = new Object();即使在内存不足的情况下,JVM宁愿抛出OutOfMemory错误也不会回收这种对象。

  • 软引用:如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。

  • 弱引用:具有弱引用的对象拥有更短暂的生命周期。如果一个对象只有弱引用存在了,则下次GC将会回收掉该对象(不管当前内存空间足够与否)。

  • 虚引用:如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收器回收的活动。


我们先来看看官方文档,为什么要设计为弱引用:


To help deal with very large and long-lived usages, the hash table entries use WeakReferences for keys.
为了应对非常大和长时间的用途,哈希表使用弱引用的 key。

我再把ThreadLocal的引用示意图搬过来:



下面我们分情况讨论:



  • 如果Key使用强引用:当ThreadLocal的对象被回收了,但是ThreadLocalMap还持有ThreadLocal的强引用的话,如果没有手动删除,ThreadLocal就不会被回收,会出现Entry的内存泄漏问题。

  • 如果Key使用弱引用:当ThreadLocal的对象被回收了,因为ThreadLocalMap持有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收。value则在下一次ThreadLocalMap调用set,getremove的时候会被清除。


因此可以发现,使用弱引用作为EntryKey,可以多一层保障:弱引用ThreadLocal不会轻易内存泄漏,对应的value在下一次ThreadLocalMap调用set,get,remove的时候会被清除。


实际上,我们的内存泄漏的根本原因是,不再被使用的Entry,没有从线程的ThreadLocalMap中删除。一般删除不再使用的Entry有这两种方式:



  • 一种就是,使用完ThreadLocal,手动调用remove(),把Entry从ThreadLocalMap中删除

  • 另外一种方式就是:ThreadLocalMap的自动清除机制去清除过期Entry.(ThreadLocalMapget(),set()时都会触发对过期Entry的清除)


7. InheritableThreadLocal保证父子线程间的共享数据


我们知道ThreadLocal是线程隔离的,如果我们希望父子线程共享数据,如何做到呢?可以使用InheritableThreadLocal。先来看看demo


public class InheritableThreadLocalTest {

public static void main(String[] args) {
ThreadLocal threadLocal = new ThreadLocal<>();
InheritableThreadLocal inheritableThreadLocal = new InheritableThreadLocal<>();

threadLocal.set("关注公众号:捡田螺的小男孩");
inheritableThreadLocal.set("关注公众号:程序员田螺");

Thread thread = new Thread(()->{
System.out.println("ThreadLocal value " + threadLocal.get());
System.out.println("InheritableThreadLocal value " + inheritableThreadLocal.get());
});
thread.start();

}
}
//运行结果
ThreadLocal value null
InheritableThreadLocal value 关注公众号:程序员田螺

可以发现,在子线程中,是可以获取到父线程的 InheritableThreadLocal 类型变量的值,但是不能获取到 ThreadLocal 类型变量的值。


获取不到ThreadLocal 类型的值,我们可以好理解,因为它是线程隔离的嘛。InheritableThreadLocal 是如何做到的呢?原理是什么呢?


Thread类中,除了成员变量threadLocals之外,还有另一个成员变量:inheritableThreadLocals。它们两类型是一样的:


public class Thread implements Runnable {
ThreadLocalMap threadLocals = null;
ThreadLocalMap inheritableThreadLocals = null;
}

Thread类的init方法中,有一段初始化设置:


 private void init(ThreadGroup g, Runnable target, String name,
long stackSize, AccessControlContext acc,
boolean inheritThreadLocals
)
{

......
if (inheritThreadLocals && parent.inheritableThreadLocals != null)
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
/* Stash the specified stack size in case the VM cares */
this.stackSize = stackSize;

/* Set thread ID */
tid = nextThreadID();
}
static ThreadLocalMap createInheritedMap(ThreadLocalMap parentMap) {
return new ThreadLocalMap(parentMap);
}

可以发现,当parent的inheritableThreadLocals不为null时,就会将parentinheritableThreadLocals,赋值给前线程的inheritableThreadLocals。说白了,就是如果当前线程的inheritableThreadLocals不为null,就从父线程哪里拷贝过来一个过来,类似于另外一个ThreadLocal,但是数据从父线程那里来的。有兴趣的小伙伴们可以在去研究研究源码~


8. ThreadLocal的应用场景和使用注意点


ThreadLocal很重要一个注意点,就是使用完,要手动调用remove()


ThreadLocal的应用场景主要有以下这几种:



  • 使用日期工具类,当用到SimpleDateFormat,使用ThreadLocal保证线性安全

  • 全局存储用户信息(用户信息存入ThreadLocal,那么当前线程在任何地方需要时,都可以使用)

  • 保证同一个线程,获取的数据库连接Connection是同一个,使用ThreadLocal来解决线程安全的问题

  • 使用MDC保存日志信息。

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

Flutter 工程化框架选择 — 状态管理何去何从

这是 《Flutter 工程化框架选择》 系列的第六篇 ,就像之前说的,这个系列只是单纯告诉你,创建一个 Flutter 工程,或者说搭建一个 Flutter 工程脚手架,应该如何快速选择适合自己的功能模块,或者说这是一个指引系列,所以比较适合新手同学。 其...
继续阅读 »

这是 《Flutter 工程化框架选择》 系列的第六篇 ,就像之前说的,这个系列只是单纯告诉你,创建一个 Flutter 工程,或者说搭建一个 Flutter 工程脚手架,应该如何快速选择适合自己的功能模块,或者说这是一个指引系列,所以比较适合新手同学。



其实这是我最不想写的一个篇



状态管理是 Flutter 里 ♾️ 的话题,本质上 Flutter 里的状态管理就是传递状态和基于 setState 的封装,状态管理框架解决的是如何更优雅地共享状态和调用 setState


那为什么我不是很想写状态管理的对比内容


首先因为它很繁,繁体的煩,从 Flutter 发布到现在,scoped_modelBLoCProviderflutter_reduxMobXfish_reduxRiverpodGetX 等各类框架“百花齐放”,虽然这对于社区来说是这是好事,但是对于普通开发者来说很容易造成过度选择困难症,特别早期不少人被各种框架“伤害过”。



其次,状体管理在 Flutter 里一直是一个“敏感”话题,每次聊到状态管理就绕不开 GetX ,但是一旦聊 GetX 又会变成“立场”问题,所以一直以来我都不是很喜欢写状态管理的内容。















image-20221109095950845

所以本来应该在第一篇就出现的内容,一直被拖到现在才放出来,这里提前声明一些,本篇不会像之前一样从大小和性能等方面去做对比,因为对于状态管理框架来说这没什么意义:



  • 集成后对大小的影响可能还不如一张图片

  • 性能主要取决于开发者的习惯,在状态管理框架上对比性能其实很主观


当然,如果你对集成后对大小的影响真的很在意,那可以在打包时通过 --analyze-size 来生成 analysis.json 文件用于对比分析:


flutter build apk --target-platform android-arm64 --analyze-size

上诉命令在执行之后,会在 /Users/你的用户名/.flutter-devtools/ 目录下生成一个 apk-code-size-analysis_01.json 文件,之后我们只需要打开 Flutter 的 DevTools 下的 App Size Tooling 就可以进行分析。










例如这里是将 RiverpodGetX 在同一项项目集成后导出不同 json 在 Diff 进行对比,可以看到此时差异也就在 78.5kb ,这个差异大小还不如一张 png 资源图片的影响大。



所以本次主要是从这些状体管理框架自身的特点出发,简单列举它们的优劣,至于最后你觉得哪个适合你,那就见仁见智了



本篇只是告诉你它们的特点和如何去选择,并不会深入详细讲解,如果对实现感兴趣的可以看以前分享过的文章:






Provider


2019 年的 Google I/O 大会 Provider 成了 Flutter 官方新推荐的状态管理方式之一,它的特点就是: 不复杂,好理解,代码量不大的情况下,可以方便组合和控制刷新颗粒度 , 其实一开始官方也有一个 flutter-provide ,不过后来宣告GG , Provider 成了它的替代品。



⚠️注意,providerflutter-provide 多了个 r,所以不要再看着 provide 说 Provider 被弃坑了。



简单来说,Provider 就是针对 InheritedWidget 的一个包装工具,他让 InheritedWidget 的使用变得更简单,在往下共享状态的同时,可以通过 ChangeNotifierStreamFuture 配合 Consumer* 组合出多样的更新模式。


所以使用 Provider 的好处之一就是简单,同时你可以通过 Consumer* 等来决定刷新的颗粒度,其实也就是 BuildContextof(context) 时的颗粒度控制。



登记到 InheritedWidget 里的 context 决定了更新是 rebuild 哪个 ComponentElement ,感兴趣的可以看 全面理解 State 与 Provider



当然,虽然一直说 Provider 简单,但是其实还是有一些稍微“复杂”的地方,例如 select


Provider 里 select 是对 BuildContext 做了 “二次登记” 的行为,就是以前你用 context 是 watch 的时候 ,是直接把这个 Widget 登记到 Element 里,有更新就通知。



但是 select 做了二次处理,就是用 dependOnInheritedElement 做了颗粒化的判断,如果是不等于了才更新,所以它对 context 有要求,如下图对就是对 context 类型进行了判断。










所以 select 算是 Provider 里的“小魔法“之一,总的来说 Provider 是一个符合 Flutter 的行为习惯,但是不大符合前端和原生的开发习惯的优秀状态管理框架


优点:



  • 简单好维护

  • read、watch、select 提供更简洁的颗粒度管理

  • 官方推荐


缺点:



  • 相对依赖 Flutter 和 Widget

  • 需要依赖 Context


最后顺带辟个谣,之前有 “传闻” Provider 要被弃坑的说法,作者针对这个也有相应对澄清,所以你还是可以继续安心使用 Provider。










Riverpod


Riverpod 和 Provider 是同个作者,因为 Provider 存在某些局限性,所以作者根据 Provider 这个单词重新排列组合成 Riverpod



如果说 Provider 是 InheritedWidget 的封装,那 Riverpod 就是在 Provider 的基础上重构出更灵活的操作能力,最直观的就是 Riverpod 中的 Provider 可以随意写成全局,并且不依赖 BuildContext 来编写我们需要的业务逻



注意: Riverpod 中的 Provider 和前面的 Provider 没有关系。




在 Riverpod 里基本是每一个 “Provider” 都会有一个自己的 “Element” ,然后通过 WidgetRef 去 Hook 后成为 BuildContext 的替代,所以这就是 Riverpod 不依赖 Context 的 “魔法” 之一



⚠️这里的 “Element” 不是 Flutter 概念里三棵树的 Element,它是 Riverpod 里 Ref 对象的子类Ref 主要提供 Riverpod 内的 “Provider” 之间交互的接口,并且提供一些抽象的生命周期方法,所以它是 Riverpod 里的独有的 “Element” 单位。



另外对比 Provider ,Riverpod 不需要依赖 Flutter ,所以也不需要依赖 Widget,也就是不依赖 BuildContext ,所以可以支持全局变量定义 “Provider” 对象。


优点:



  • 在 Provider 的基础上更加灵活的实现,

  • 不依赖 BuildContext ,所以业务逻辑也无需注入 BuildContext

  • Riverpod 会尽可能通过编译时安全来解决存在运行时异常问题

  • 支持全局定义

  • ProviderReference 能更好解决嵌套代码


缺点:



  • 实现更加复杂

  • 学习成本提高


目前从我个人角度看,我觉得 Riverpod 时当前之下状态管理的最佳选择,它灵活且专注,体验上也更符合 Flutter 的开发习惯



注意,很多人一开始只依赖 riverpod 然后发现一些封装对象不存在,因为 riverpod 是不依赖 flutter 的实现,所以在 flutter 里使用时不要忘记要依赖 flutter_riverpod



BLoC


BLoC 算是 Flutter 早期比较知名的状态管理框架,它同样是存在 blocflutter_bloc 这样的依赖关系,它是基于事件驱动来实现的状态管理



flutter_bloc 基于事件驱动的核心就是 Stream 和 Provider , 是的, flutter_bloc 依赖于 Provider,然后在其基础上设计了基于 Stream 的事件响应机制。


所以严格意义上 BLoC 其实是 Provider + Stream ,如果你一直很习惯基于事件流开发模式,那么 BLoC 就很适合你,但是其实从我个人体验上看,BLoC 在开发节奏上并不是快,相反还有点麻烦,不过优势也很明显,基于 Stream 的封装可以更方便做一些事件状态的监听和转换。


BlocSelector(
selector: (state) {
// return selected state based on the provided state.
},
builder: (context, state) {
// return widget here based on the selected state.
},
)

MultiBlocListener(
listeners: [
BlocListener(
listener: (context, state) {},
),
BlocListener(
listener: (context, state) {},
),
BlocListener(
listener: (context, state) {},
),
],
child: ChildA(),
)

优点:



  • 代码更加解耦,这是事件驱动的特性

  • 把状态更新和事件绑定,可以灵活得实现状态拦截,重试甚至撤回


缺点:



  • 需要写更多的代码,开发节奏会有点影响

  • 接收代码的新维护人员,缺乏有效文档时容易陷入对着事件和业务蒙圈

  • 项目后期事件容易混乱交织



类似的库还有 rx_bloc ,同样是基于 Stream 和 Provider , 不过它采用了 rxdart 的 Stream 封装。



flutter_redux


flutter_redux 虽然也是 pub 上的 Flutter Favorite 的项目,但是现在的 Flutter 开发者应该都不怎么使用它,而恰好我在刚使用 Flutter 时使用的状态管理框架就是它。




其实前端开始者对 redux 可能会更熟悉一些,当时我恰好用 RN 项目切换到 Flutter 项目,在 RN 时代我就一直在使用 redux,flutter_redux 自然就成了我首选的状态管理框架。



其实这也是 Flutter 最有意思的,很多前端的状态管理框架都可以迁移到 Flutter ,例如 flutter_redux 里就是利用了 Stream特性,通过 redux 单向事件流的设计模式来完成解耦和拓展。



在 flutter_redux 中,开发者的每个操作都只是一个 Action ,而这个行为所触发的逻辑完全由 middlewarereducer 决定,这样的设计在一定程度上将业务与UI隔离,同时也统一了状态的管理。


当然缺陷也很明显,你要写一堆代码,开发逻辑一定程度上也不大符合 Flutter 的开发习惯。


优点:



  • 解耦

  • 对 redux 开发友好

  • 适合中大型项目里协作开发


缺点:



  • 影响开发速度,要写一堆模版

  • 不是很贴合 Flutter 开发思路


说到 redux 就不得不说 fish_redux ,如果说 redux 是搭积木,那闲鱼最早开源的 fish_redux 可以说是积木界的乐高,闲鱼在 redux 的基础上提出了 Comoponent 的概念,这个概念下 fish_redux 是从 ContextWidget 等地方就开始全面“入侵”你的代码,从而带来“超级赛亚人”版的 redux



所以不管是 flutter_redux 还是 fish_redux 都是很适合团队协作的开发框架,但是它的开发体验和开发过程,注定不是很友好


GetX


GetX 可以说是 Flutter 界内大名鼎鼎,Flutter 不能没有 GetX 就像程序员不能没有 PHP ,GetX 很好用,很具备话题,很全面同时也很 GetX



严格意义上说现在 GetX 已经不是一个简单的状态管理框架,它是一个统一的 Flutter 开发脚手架,在 GetX 内你可以找到:



  • 状态管理

  • 路由管理

  • 多语言支持

  • 页面托管

  • Http GetConnect

  • Rx GetStream

  • 各式各样的 extension


可以说大部分你想到的 GetX 里都有,甚至还有基于 GetX 的 get_storage 实现纯 Dart 文件级 key-value 存储支持。


所以很多时候使用 GetX 开发甚至不需要关心 Flutter ,当然这也导致经常遇到的奇怪情况:大家的问题集中在 GetX 里如何 xxxx,而不是 Flutter 如何 xxxx所以 GetX 更像是依附在 Flutter 上的解决方案










当然,使用 GetX 最直观的就是不需要 BuildContext ,甚至是你在路由跳转时都不需要关心 Context ,这就让你的代码看起来很“干净”,把整个开发过程做到“面向 GetX 开发”的效果


另外 GetX 和 Provider 等相比还具备的特色是:



  • Get.putGet.findGet.to 等操作完全无需 Widget 介入

  • 内置的 extension 如各类基础类似的 *.obs 通过 GetStream 实现了如 var count = 0.obs;Obx(() => Text("${controller.name}")); 这样的简化绑定操作


那 GetX 是如何脱离 Context 的依赖?说起来也不复杂,例如 :




  • GetMaterialApp 内通过一个会有一个 GlobalKey 用于配置 MaterialAppnavigatorKey ,这样就可以通过全局的 navigatorKey 获取到 NavigatorState ,从而调用 push API 打开路由




  • Get.putGet.find 是通过一个内部全局的静态 Map 来管理,所以在传递和存放时就脱离了 InheritedWidget ,结合 Obx ,在对获取到的 GetxController 的 value 时会有个 addListener 的操作,从而实现 Stream 的绑定和更新












可以说 GetX 内部有很多“魔法”,这些魔法或者是对 Flutter API 的 Hook、或者是直接脱离 Flutter 设计的自定义实现,总的来说 GetX “有自己的想法”


这也就带来一个了个问题,很多人新手一上手就是 GetX ,然后对 Flutter 一知半解,特别是深度解绑了 Context 之后,很多 Flutter 问题就变成了 GetX 上如何 xxxx,例如前面的: Flutter GetX 如何调用谷歌地图这种问题




如果使用 GetX 而不去思考和理解 GetX 的实现,就很容易在 Flutter 的路上走歪,比如上面各种很基础的问题。



这其实也是 GetX 的最大问题:GetX 做的很多,它入侵到很多领域,而且它拥有很多“魔法”,这些“魔法”让 Flutter 开发者不知布局的脱离了本来应有的轨迹。



当然,你说我就是想完成需求,好用就行,何必关心它们的实现呢?从这个角度看 GetX 无疑是非常不错的选择,只要 GetX 能继续维护下去并把“魔法”继续兼容。



大概就是:GetX “王国” 对初级开发者友好,但是“魔法全家桶”其实对社区的健康发展很致命



优点:



  • 瑞士军刀式护航

  • 对新人友好

  • 可以减少很多代码


缺点:



  • 全家桶,做的太多对于一些使用者来说是致命缺点,需要解决的 Bug 也多

  • “魔法”使用较多,脱离 Flutter 原本轨迹

  • 入侵性极强


总的来说,GetX 很优秀,他帮你都写好了很多东西,省去了开发者还要考虑如何去组合和思考的过程,从我个人的角度我不喜欢这种风格,但是它总归是可以帮助你提高开发效率。


另外还有一个状态管理库 Mobx ,它库采用了和 GetX 类似的风格,虽然 Mobx 的知名度和关注度不像 GetX 那么高,但是它同样采用了隐式依赖的模式,某种意义上可以把 Mobx 看成是只有状态管理版本的 GetX



最后


通过上面分享的内容,相信大家对于选哪个状态管理框架应该有自己的理解了,还是那句废话,采用什么方案和框架具体还是取决于你的需求场景,不管是哪个框架目前都有坑和局限,重点还是在于它未来是否持续维护,或者不维护了你自己能否继续维护下去


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

Android仿淘宝、京东Banner滑动查看图文详情

效果图 原理分析 Banner与右侧的查看更多View都是子View,被父View包裹,默认Banner的宽度是match_parent,而查看更多则是在屏幕的右侧,处于不可见状态; 当Banner进行左右滑动时,当前的滑动事件是在Banner中消费的,...
继续阅读 »

效果图


Banner滑动查看图文详情


原理分析


滑动查看更多



  • Banner与右侧的查看更多View都是子View,被父View包裹,默认Banner的宽度是match_parent,而查看更多则是在屏幕的右侧,处于不可见状态;

  • Banner进行左右滑动时,当前的滑动事件是在Banner中消费的,即父View不会进行拦截。

  • Banner滑动到最右侧且要继续滑动时,此时父View会进行事件的拦截,从而事件由父View接管,并在父ViewonTouchEvent()中消费事件,此时就可以滑动父View中的内容了。怎么滑动呢?在MOVE事件时通过scrollTo()/scrollBy()滑动,而在UP/CANCEL事件时,需要通过ScrollerstartScroll()自动滑动到查看更多子View的左侧或右侧,从而完成一次事件的消费;

  • UP/CANCEL事件触发时,查看更多子View滑动的距离超过一半,认为需要触发查看更多操作了,当然这里的值都可以自行设置。


核心代码



  • TJBannerFragment.kt


/**
* 仿淘宝京东宝贝详情Fragment
*/
class TJBannerFragment : BaseFragment() {
private val mModels: MutableList<Any> = mutableListOf()
private val mContainer: VpLoadMoreView by id(R.id.vp2_load_more)

override fun getLayoutId(): Int {
return R.layout.fragment_tx_news_n
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
initVerticalTxScroll()
}

private fun initVerticalTxScroll() {
mModels.add(TxNewsModel(MConstant.IMG_4, "美轮美奂节目", "奥运五环缓缓升起"))
mModels.add(TxNewsModel(MConstant.IMG_1, "精美商品", "9块9包邮"))
mContainer.setData(mModels) {
showToast("打开更多页面")
}
}
}


  • VpLoadMoreView.kt(父View)


class VpLoadMoreView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0,
) : LinearLayout(context, attrs, defStyle) {

private val mMVPager2: MVPager2 by id(R.id.mvp_pager2)
private var mNeedIntercept: Boolean = false //是否需要拦截VP2事件
private val mLoadMoreContainer: LinearLayout by id(R.id.load_more_container)
private val mIvArrow: ImageView by id(R.id.iv_pull)
private val mTvTips: TextView by id(R.id.tv_tips)

private var mCurPos: Int = 0 //Banner当前滑动的位置
private var mLastX = 0f
private var mLastDownX = 0f //用于判断滑动方向
private var mMenuWidth = 0 //加载更多View的宽度
private var mShowMoreMenuWidth = 0 //加载更多发生变化时的宽度
private var mLastStatus = false // 默认箭头样式
private var mAction: (() -> Unit)? = null
private var mScroller: OverScroller
private var isTouchLeft = false //是否是向左滑动
private var animRightStart = RotateAnimation(0f, -180f,
Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f).apply {
duration = 300
fillAfter = true
}

private var animRightEnd = RotateAnimation(-180f, 0f,
Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f).apply {
duration = 300
fillAfter = true
}

init {
orientation = HORIZONTAL
View.inflate(context, R.layout.fragment_tx_news, this)
mScroller = OverScroller(context)
}

/**
* @param mModels 要加载的数据
* @param action 回调Action
*/
fun setData(mModels: MutableList<Any>, action: () -> Unit) {
this.mAction = action
mMVPager2.setModels(mModels)
.setLoop(false) //非循环模式
.setIndicatorShow(false)
.setLoader(TxNewsLoader(mModels))
.setPageTransformer(CompositePageTransformer().apply {
addTransformer(MarginPageTransformer(15))
})
.setOrientation(ViewPager2.ORIENTATION_HORIZONTAL)
.setAutoPlay(false)
.setOnBannerClickListener(object : OnBannerClickListener {
override fun onItemClick(position: Int) {
showToast(mModels[position].toString())
}
})
.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageScrollStateChanged(state: Int) {
if (mCurPos == mModels.lastIndex && isTouchLeft && state == ViewPager2.SCROLL_STATE_DRAGGING) {
//Banner在最后一页 & 手势往左滑动 & 当前是滑动状态
mNeedIntercept = true //父View可以拦截
mMVPager2.setUserInputEnabled(false) //VP2设置为不可滑动
}
}

override fun onPageSelected(position: Int) {
mCurPos = position
}
})
.start()
}

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
mMenuWidth = mLoadMoreContainer.measuredWidth
mShowMoreMenuWidth = mMenuWidth / 3 * 2
super.onLayout(changed, l, t, r, b)
}

override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
when (ev?.action) {
MotionEvent.ACTION_DOWN -> {
mLastX = ev.x
mLastDownX = ev.x
}
MotionEvent.ACTION_MOVE -> {
isTouchLeft = mLastDownX - ev.x > 0 //判断滑动方向
}
}
return super.dispatchTouchEvent(ev)
}

override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
var isIntercept = false
when (ev?.action) {
MotionEvent.ACTION_MOVE -> isIntercept = mNeedIntercept //是否拦截Move事件
}
//log("ev?.action: ${ev?.action},isIntercept: $isIntercept")
return isIntercept
}

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(ev: MotionEvent?): Boolean {
when (ev?.action) {
MotionEvent.ACTION_MOVE -> {
val mDeltaX = mLastX - ev.x
if (mDeltaX > 0) {
//向左滑动
if (mDeltaX >= mMenuWidth || scrollX + mDeltaX >= mMenuWidth) {
//右边缘检测
scrollTo(mMenuWidth, 0)
return super.onTouchEvent(ev)
}
} else if (mDeltaX < 0) {
//向右滑动
if (scrollX + mDeltaX <= 0) {
//左边缘检测
scrollTo(0, 0)
return super.onTouchEvent(ev)
}
}
showLoadMoreAnim(scrollX + mDeltaX)
scrollBy(mDeltaX.toInt(), 0)
mLastX = ev.x
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
smoothCloseMenu()
mNeedIntercept = false
mMVPager2.setUserInputEnabled(true)
//执行回调
val mDeltaX = mLastX - ev.x
if (scrollX + mDeltaX >= mShowMoreMenuWidth) {
mAction?.invoke()
}
}
}
return super.onTouchEvent(ev)
}

private fun smoothCloseMenu() {
mScroller.forceFinished(true)
/**
* 左上为正,右下为负
* startX:X轴开始位置
* startY: Y轴结束位置
* dx:X轴滑动距离
* dy:Y轴滑动距离
* duration:滑动时间
*/
mScroller.startScroll(scrollX, 0, -scrollX, 0, 300)
invalidate()
}

override fun computeScroll() {
if (mScroller.computeScrollOffset()) {
showLoadMoreAnim(0f) //动画还原
scrollTo(mScroller.currX, mScroller.currY)
invalidate()
}
}

private fun showLoadMoreAnim(dx: Float) {
val showLoadMore = dx >= mShowMoreMenuWidth
if (mLastStatus == showLoadMore) return
if (showLoadMore) {
mIvArrow.startAnimation(animRightStart)
mTvTips.text = "释放查看图文详情"
mLastStatus = true
} else {
mIvArrow.startAnimation(animRightEnd)
mTvTips.text = "滑动查看图文详情"
mLastStatus = false
}
}
}

父View的注释很清晰,不用过多解释了,这里需要注意一点,已知在Banner的最后一页滑动时需要判断滑动方向:继续向左滑动,需要父View拦截滑动事件并自己进行消费;向右滑动时,父View不需要处理滑动事件,仍由Banner进行事件消费。


滑动方向需要起始位置(DOWN事件)的X坐标 - 滑动时的X坐标(MOVE事件) 的差值进行判断,那问题在哪里取起始位置的X坐标呢?在父ViewonInterceptTouchEvent()->DOWN事件里吗?这里是不行的,因为滑动方向是在MOVE事件里判断的,在父ViewonInterceptTouchEvent()->DOWN事件里拦截的话,后续事件不会往Banner里传递了。这里可以选择在父ViewdispatchTouchEvent()->DOWN事件里即可解决。


VpLoadMoreView对应的XML布局


<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white"
android:orientation="horizontal"
tools:parentTag="android.widget.LinearLayout">

<!--ViewPager2-->
<org.ninetripods.lib_viewpager2.MVPager2
android:id="@+id/mvp_pager2"
android:layout_width="match_parent"
android:layout_height="200dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<!--加载更多View-->
<LinearLayout
android:id="@+id/load_more_container"
android:layout_width="100dp"
android:layout_height="200dp"
android:gravity="center_vertical"
android:orientation="horizontal">

<ImageView
android:id="@+id/iv_pull"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_gravity="center_vertical"
android:layout_marginStart="10dp"
android:src="@drawable/icon_arrow_pull" />

<TextView
android:id="@+id/tv_tips"
android:layout_width="16dp"
android:layout_height="match_parent"
android:layout_marginStart="10dp"
android:gravity="center_vertical"
android:text="滑动查看图文详情"
android:textColor="#333333"
android:textSize="14sp"
android:textStyle="bold" />
</LinearLayout>
</merge>

这里的父View(VpLoadMoreView)LinearLayout,且必须是横向布局,XML的顶层布局使用的merge标签,这样既可以优化一层布局,又可以在父View中直接操作加载图文详情的子View


源码地址


完整代码地址参见:Android仿淘宝、京东Banner滑动至最后查看图文详情


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

MD5 到底算不算一种加密算法?

MD5
本文正在参加「金石计划 . 瓜分6万现金大奖」 hello,大家好,我是张张,「架构精进之路」公号作者。 一旦提到加密算法,经常有人会有这样的疑问:MD5 到底算不算一种加密算法呢? 在回答这个问题之前,我们需要先弄清楚两点: 什么是加密算法? 什么是...
继续阅读 »

本文正在参加「金石计划 . 瓜分6万现金大奖」


hello,大家好,我是张张,「架构精进之路」公号作者。


一旦提到加密算法,经常有人会有这样的疑问:MD5 到底算不算一种加密算法呢?


在回答这个问题之前,我们需要先弄清楚两点:




  • 什么是加密算法?




  • 什么是 MD5?




1、什么是加密算法?



数据加密的基本过程就是对原来为明文的文件或数据按某种算法进行处理,使其成为不可读的一段代码为“密文”,使其只能在输入相应的密钥之后才能显示出原容,通过这样的途径来达到保护数据不被非法人窃取、阅读的目的。 该过程的逆过程为解密,即将该编码信息转化为其原来数据的过程。


-- 来自《百度百科》



使用密码学可以达到以下三个目的:




  • 数据保密性:防止用户的数据被窃取或泄露;




  • 数据完整性:防止用户传输的数据被篡改;




  • 身份验证:确保数据来源与合法的用户。




加密算法分类


常见的加密算法大体可以分为两大类:对称加密和非对称加密。



  • 对称加密


对称加密算法就是用一个秘钥进行加密和解密。




  • 非对称加密


与对称加密算法不同的是,进行加密与解密使用的是不同的秘钥,有一个公钥-私钥对,秘钥正确才可以正常的进行加解密。



2、什么是MD5?


MD5算法:MD5全称Message Digest Algorithm 5,即消息摘要算法第5版。



MD5 以 512位分组来处理输入的信息,且每一分组又被划分为16个32位子分组,经过了一系列的处理后,算法的输出由四个32位分组组成,将这四个32位分组级联后将生成一个128位散列值。



MD5算法的主要特点:



  • 长度固定


MD5加密后值固定长度是128位,使用32个16进制数字进行表示。



  • 单向性


如果告诉原始消息,算法是MD5,迭代次数=1的情况下,我们一样可以得到一摸一样的消息摘要,但是反过来却不行。



  • 不可逆


在不知道原始消息的前提下,是无法凭借16个字节的消息摘要(Message Digest),还原出原始的消息的。


下面这个消息摘要,你知道他的原始信息是什么吗?


Message Digest = '454e2624461c206380f9f088b1e55fae'

其实,原始信息是以下长长的字符串:


93eyHv2Iw5kbn1dqfBw1BuTE29V2FJKicJSu8iEOpfoafwJISXmz1wnnWL3V/0NxTulfWsXug
OoLfv0ZIBP1xH9kmf22jjQ2JiHhQZP7ZDsreRrOeIQ/c4yR8IQvMLfC0WKQqrHu5ZzXTH4NO3
CwGWSlTY74kE91zXB5mwWAx1jig+UXYc2w4RkVhy0//lOmVya/PEepuuTTI4+UJwC7qbVlh5z
fhj8oTNUXgN0AOc+Q0/WFPl1aw5VV/VrO8FCoB15lFVlpKaQ1Yh+DVU8ke+rt9Th0BCHXe0uZ
OEmH0nOnH/0onD


  • 恒定性


如果按照以上示例的原始信息,大家与我计算出来的消息摘要不一样,那肯定你是使用了一个假的 MD5 工具,哈哈哈。


当原始消息恒定时,每次运行MD5产生的消息摘要都是恒定不变的,无论是谁来计算,结果都应该是一样的。



  • 不可预测性


让我们再来尝试一次,「不可逆」中应用到的原始消息的最后一个字母'D',修改成'E',如下所示:


93eyHv2Iw5kbn1dqfBw1BuTE29V2FJKicJSu8iEOpfoafwJISXmz1wnnWL3V/0NxTulfWsXug
OoLfv0ZIBP1xH9kmf22jjQ2JiHhQZP7ZDsreRrOeIQ/c4yR8IQvMLfC0WKQqrHu5ZzXTH4NO3
CwGWSlTY74kE91zXB5mwWAx1jig+UXYc2w4RkVhy0//lOmVya/PEepuuTTI4+UJwC7qbVlh5z
fhj8oTNUXgN0AOc+Q0/WFPl1aw5VV/VrO8FCoB15lFVlpKaQ1Yh+DVU8ke+rt9Th0BCHXe0uZ
OEmH0nOnH/0onE

那经 MD5 后产生的消息摘要,是不是和 '454e2624461c206380f9f088b1e55fae' 很相似呢?


让大家失望了,产生的消息摘要没有一丝一毫的关联性,新的消息摘要如下所示:


Message Digest = '8796ed5412b84ff5c4769d080b4a89a2'

聊到这里,突然想到一个有意思的问题:



MD5是32位的,理论上是有限的,而世界上的数据是无限的,那会不会生成重复的MD5值?


是不是也有同学产生相似的疑问呢?



理论上来讲,当然会生成重复的MD5值。


分享一个经典的例子:




  • 数据源1:


    d131dd02c5e6eec4693d9a0698aff95c2fcab58712467eab4004583eb8fb7f89
    55ad340609f4b30283e488832571415a085125e8f7cdc99fd91dbdf280373c5b
    d8823e3156348f5bae6dacd436c919c6dd53e2b487da03fd02396306d248cda0
    e99f33420f577ee8ce54b67080a80d1ec69821bcb6a8839396f9652b6ff72a70




  • 数据源2:


    d131dd02c5e6eec4693d9a0698aff95c2fcab50712467eab4004583eb8fb7f89
    55ad340609f4b30283e4888325f1415a085125e8f7cdc99fd91dbd7280373c5b
    d8823e3156348f5bae6dacd436c919c6dd53e23487da03fd02396306d248cda0
    e99f33420f577ee8ce54b67080280d1ec69821bcb6a8839396f965ab6ff72a70




它们竟然有着共同的MD5值(☞ 注意看,数据源1、2是存在很多细节不同的):


79054025255fb1a26e4bc422aef54eb4

3、MD5是加密算法吗?


MD5计算,对原始消息(Message)做有损的压缩计算,无论消息(输入值)的长度字节是多少,是1亿字节还是1个字节,都会生成一个固定长度(128位/16字节)的消息摘要(输出值)。


也就是说,MD5 算法和加密算法都可以将信息转换为另外一种内容,但是,MD5 算法对比 加密算法 缺少了解密过程。



好比一头山羊,被层层加工制作成一包包风干羊肉,这个就是一次MD5操作。这种加工过程,势必将羊身体N多部位有损失,故无法通过羊肉干再复原出一头山羊...




使用 加密算法 加密后的消息是完整的,并且基于解密算法后,可以恢复原始数据。而 MD5 算法 得到的消息是不完整的,并且通过摘要的数据也无法得到原始数据。


所以严格意义上来讲,MD5 称为摘要/散列算法更合适,而不是加密算法


那现实的问题来了,MD5究竟有什么用?


欢迎各位留言补充~


·················· END ··················


希望今天的讲解对大家有所帮助,谢谢!


Thanks for reading!


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

简简单单搞一个实用的Android端搜索框

Hello啊老铁们,今天带来一个非常实用的自定义搜索框,包含了搜索框、热门搜索列表、最近搜索列表等常见的功能,有类似的,大家可以直接复用,将会大大节约您的开发时间,有一点,很负责任的告诉大家,实现这个没什么技术含量,就是很简单的自定义组合View,本文除了使用...
继续阅读 »

Hello啊老铁们,今天带来一个非常实用的自定义搜索框,包含了搜索框、热门搜索列表、最近搜索列表等常见的功能,有类似的,大家可以直接复用,将会大大节约您的开发时间,有一点,很负责任的告诉大家,实现这个没什么技术含量,就是很简单的自定义组合View,本文除了使用介绍,我也会把具体的实现过程分享给大家。


今天的内容大概如下:


1、效果展示


2、快速使用及属性介绍


3、具体代码实现


4、开源地址及总结


一、效果展示


效果很常见,就是平常需求中的效果,上面是搜索框,下面是最近和热门搜索列表,为了方便大家在实际需求中使用,配置了很多属性,也进行了上下控件的拆分,也就是上边搜索框和下面的搜索列表的拆分,可以按需进行使用。


image.png


二、快速使用及属性介绍


快速使用


目前已经发布至远程Maven,大家可以进行远程依赖使用。


1、在你的根项目下的build.gradle文件下,引入maven。


allprojects {
repositories {
maven { url "https://gitee.com/AbnerAndroid/almighty/raw/master" }
}
}

2、在你需要使用的Module中build.gradle文件下,引入依赖。


dependencies {
implementation 'com.vip:search:1.0.0'
}

具体代码


1、xml中引入SearchLayout(搜索框)和SearchList(搜索列表),在实际开发中,根据需求可选择使用,二者是互不关联的。


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingLeft="10dp"
android:paddingRight="10dp"
tools:context=".MainActivity">

<com.vip.search.SearchLayout
android:id="@+id/search_layout"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="10dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:search_bg="@drawable/shape_stroke_10" />

<com.vip.search.SearchList
android:id="@+id/search_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
app:is_hot_flex_box_or_grid="true"
app:is_visibility_history_clear="true"
app:layout_constraintTop_toBottomOf="@id/search_layout" />

</androidx.constraintlayout.widget.ConstraintLayout>

2、代码逻辑,以下是测试代码,如用到实际项目,请以实际项目获取控件为主。


class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

val searchLayout = findViewById<SearchLayout>(R.id.search_layout)
val searchList = findViewById<SearchList>(R.id.search_list)

searchLayout.setOnTextSearchListener({
//搜索内容改变

}, {
//软键盘点击了搜索
searchList.doSearchContent(it)
})

//设置用于测试的热门搜索列表
searchList.setHotList(getHotList())
//热门搜索条目点击事件
searchList.setOnHotItemClickListener { s, i ->
Toast.makeText(this, s, Toast.LENGTH_SHORT).show()
}
//历史搜索条目点击事件
searchList.setOnHistoryItemClickListener { s, i ->
Toast.makeText(this, s, Toast.LENGTH_SHORT).show()
}

}

/**
* AUTHOR:AbnerMing
* INTRODUCE:模拟热门搜索列表
*/
private val mTestHotList = arrayListOf(
"二流小码农", "三流小可爱", "Android",
"Kotlin", "iOS", "Java", "Python", "Php是世界上最好的语言"
)

private fun getHotList(): ArrayList<SearchBean> {
return ArrayList<SearchBean>().apply {
mTestHotList.forEachIndexed { index, s ->
val bean = SearchBean()
bean.content = s
bean.isShowLeftIcon = true

val drawable: Drawable? = if (index < 2) {
ContextCompat.getDrawable(this@MainActivity, R.drawable.shape_circle_select)
} else if (index == 2) {
ContextCompat.getDrawable(this@MainActivity, R.drawable.shape_circle_ordinary)
} else {
ContextCompat.getDrawable(this@MainActivity, R.drawable.shape_circle_normal)
}
drawable?.setBounds(0, 0, drawable.minimumWidth, drawable.minimumHeight)
bean.leftIcon = drawable

add(bean)
}
}
}
}

主要方法介绍


1、搜索框监听


拿到searchLayout控件之后,调用setOnTextSearchListener方法即可,第一个方法是搜索内容发生变化会回调,第二个方法是,点击了软键盘的搜索按钮会回调,如果要在最近搜索里展示,直接调用doSearchContent方法即可。


 searchLayout.setOnTextSearchListener({
//搜索内容改变

}, {
//软键盘点击了搜索
searchList.doSearchContent(it)
})

2、搜索列表点击事件


热门搜索调用setOnHotItemClickListener方法,历史搜索也就是最近搜索调用setOnHistoryItemClickListener方法,都是两个参数,第一个是文本内容,第二个是索引,也就是点的是哪一个。


    	//热门搜索条目点击事件
searchList.setOnHotItemClickListener { s, i ->
Toast.makeText(this, s, Toast.LENGTH_SHORT).show()
}
//历史搜索条目点击事件
searchList.setOnHistoryItemClickListener { s, i ->
Toast.makeText(this, s, Toast.LENGTH_SHORT).show()
}

3、改变最近(历史)搜索item背景


有的老铁说了,默认的背景我不喜欢,能否可以动态设置,必须能!


默认背景


image.png


设置背景,通过setHistoryItemBg方法。


 searchList.setHistoryItemBg(R.drawable.shape_solid_d43c3c_10)

效果展示


image.png


4、动态设置热门搜索热度


可能在很多需求中,需要展示几个热度,有的是按照颜色区分,如下图:


image.png


实现起来很简单,在设置热门列表(setHotList)的时候,针对传递的对象设置leftIcon即可。测试代码如下:



private fun getHotList(): ArrayList<SearchBean> {
return ArrayList<SearchBean>().apply {
mTestHotList.forEachIndexed { index, s ->
val bean = SearchBean()
bean.content = s
bean.isShowLeftIcon = true
val drawable: Drawable? = if (index < 2) {
ContextCompat.getDrawable(this@MainActivity, R.drawable.shape_circle_select)
} else if (index == 2) {
ContextCompat.getDrawable(this@MainActivity, R.drawable.shape_circle_ordinary)
} else {
ContextCompat.getDrawable(this@MainActivity, R.drawable.shape_circle_normal)
}
drawable?.setBounds(0, 0, drawable.minimumWidth, drawable.minimumHeight)
bean.leftIcon = drawable

add(bean)
}
}
}

具体的哪个数据展示什么颜色,直接设置即可,想怎么展示就怎么展示。当然了除了展示不同的热度之外,还有一些其他的变量,isShowLeftIcon为是否展示文字左边的icon,textColor为当前文字的颜色,根据不同的颜色,我们也可以实现下面的效果。


image.png


除了常见的方法之外,还提供了很多的属性操作,具体的大家可以看下面,按需使用即可。


属性介绍


为了让功能灵活多变,也为了满足更多的需求样式,目前自定义了很多属性,大家可以按自己的需要进行设置,或者直接去GitHub中下载源码更改也可以。


SearchLayout(搜索框属性)


















































































属性类型概述
search_iconreference搜索图标,可直接从drawable或者mipmap中设置
search_icon_widthdimension搜索图标的宽
search_icon_heightdimension搜索图标的高
search_icon_leftdimension搜索图标距离左边的距离
search_icon_deletereference搜索删除图标,右侧的删除
search_icon_delete_widthdimension搜索删除图标的宽
search_icon_delete_heightdimension搜索删除图标的高
search_icon_delete_rightdimension搜索删除图标距离右边的距离
search_hintstring搜索框占位字符
search_hint_colorcolor搜索框占位字符颜色
search_colorcolor搜索框文字颜色
search_sizedimension搜索框文字大小
search_text_cursorreference搜索框光标
search_bgreference整个搜索框背景

SearchList(搜索列表属性)


































































































































































属性类型概述
is_hot_flex_box_or_gridboolean热门搜索列表样式,是网格还是流式布局
is_hot_centerboolean热门搜索列表样式,内容是否居中
hot_grid_span_countinteger热门搜索列表样式,如果是网格布局,条目列数,默认2
hot_item_top_margininteger热门搜索列表 item距离上边的距离
hot_item_colorcolor热门搜索列表 item 文字颜色
hot_item_sizedimension热门搜索列表 item 文字大小
hot_item_lineinteger热门搜索列表 item 文字展示几行
hot_item_bgreference热门搜索列表 item 背景
hot_item_margin_topreference热门搜索列表 item 距离上边的距离
hot_padding_leftdimension热门搜索列表 内边距,左
hot_padding_topdimension热门搜索列表 内边距,上
hot_padding_rightdimension热门搜索列表 内边距,右
hot_padding_bottomdimension热门搜索列表 内边距,下
is_history_flex_box_or_gridboolean历史搜索列表样式,是网格还是流式布局
history_flex_box_countinteger历史搜索列表,最多展示几个item,默认10
is_history_centerboolean历史搜索列表样式,内容是否居中
history_grid_span_countinteger历史搜索列表样式,如果是网格布局,条目列数,默认2
history_item_top_margininteger历史搜索列表 item距离上边的距离
history_item_colorcolor历史搜索列表 item 文字颜色
history_item_sizedimension历史搜索列表 item 文字大小
history_item_margin_topdimension历史搜索列表 item 距离上边的距离
is_visibility_history_clearboolean历史搜索右边是否展示清除小按钮
history_clear_iconreference历史搜索右边的清除小按钮
history_clear_textstring历史搜索右边的清除文字
history_clear_sizedimension历史搜索右边的清除文字大小
history_clear_colorcolor历史搜索右边的清除文字颜色
history_padding_leftdimension历史搜索列表 内边距,左
history_padding_topdimension历史搜索列表 内边距,上
history_padding_rightdimension历史搜索列表 内边距,右
history_padding_bottomdimension历史搜索列表 内边距,下

三、具体代码实现


关于这个组合View的实现方式,我是分为了两个View,大家在上边的使用中应该也看到了,一个是搜索框SearchLayout,一个是搜索框下面的搜索列表展示SearchList,开头就阐述了,没啥技术含量,简单的罗列下代码实现吧。


SearchLayout是一个组合View,中间是一个EditText,左右两边是一个ImageView,也就是搜索图标和删除图标,如下图:


image.png


SearchLayout本身没有啥要说的,无非就是把View组合到了一起,在开发的时候,既然要给别人使用,那么就要拓展出很多的动态属性或者方法出来,这是很重要的,所以,在封装的时候,自定义属性无比的重要,需要精确和认真,这一块没啥好说的,有一点需要注意,也就是EditText绑定软键盘搜索,除了设置属性android:imeOptions="actionSearch",也要设置,android:singleLine="true",方可生效。


SearchList其实也没啥好说的,也是一个组合View,使用的是上下两个RecyclerView来实现的,至于流失布局,采用的是google提供的flexbox,设置布局管理器即可。


recyclerView.layoutManager = FlexboxLayoutManager(mContext)

除了这个之外,可能需要阐述的也就是最近搜索的存储机制了,存储呢,Android中提供了很多的存储方式,比如数据库,SharedPreferences,SD卡,还有DataStore,MMKV等,无论哪一种吧,选择适合的即可,这个开源中,不想引入其他的三方了,直接使用的是SharedPreferences。


具体的实现方式,把搜索的内容,转成json串,以json串的形式进行存储,这里借助了原生的JSONArray和JSONObject。流程就是,触发搜索内容后,先从SharedPreferences取出之前存储的内容,放到JSONArray中,当前搜索内容如果存在JSONArray中,那边就要执行删除原来的,再把新的内容插入到第一个的位置,如果不存在JSONArray中,直接添加即可,随后再转成字符串存储即可。


当然了,一般在正常的需求开发中,最近搜索列表肯定不是无限展示的,都有固定的展示个数,比如10个,比如15个,所以,当超过指定的个数,也就是指定的阀门后,就要执行删除的操作。


    	val searchHistory = getSearchHistory()

if (!TextUtils.isEmpty(it)) {
val jsonArray: JSONArray = if (TextUtils.isEmpty(searchHistory)) {
JSONArray()
} else {
JSONArray(searchHistory)
}

val json = JSONObject()
json.put("content", it)

//如果出现了一样的,删除后,加到第一个
var isEqual = false
var equalPosition = 0
for (i in 0 until jsonArray.length()) {
val item = jsonArray.getJSONObject(i)
val content = item.getString("content")
if (it == content) {
isEqual = true
equalPosition = i
break
}
}
//有一样的
if (isEqual) {
jsonArray.remove(equalPosition)
} else {
//超过了指定的阀门之后,就不在扩充
if (jsonArray.length() >= mHistoryListSize) {
jsonArray.remove(0)
}
}

jsonArray.put(json)

SearchSharedPreUtils.put(mContext!!, "search_history", jsonArray.toString())
}

getSearchHistory()?.let {
eachSearchHistory(it)
}
//两个有一个不为空,展示
if (!TextUtils.isEmpty(it) || !TextUtils.isEmpty(searchHistory)) {
showOrHideHistoryLayout(View.VISIBLE)
}

当然了,存储的逻辑,有很多的实现的方式,这里并不是最优的,只是提供了一种思路,大家可以按照自己的方式来操作。


四、开源地址及总结


开源地址:github.com/AbnerMing88…


搜索列表,无论是热门还是最近的搜索列表,均支持网格和流失布局形式展示,大家看属性相关介绍中即可。这个搜索框本身就是很简单的效果还有代码,大家直接看源码或文中介绍即可,就不多赘述了。


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

浅谈RecyclerView的性能优化

RecyclerView的性能优化 在我们谈RecyclerView的性能优化之前,先让我们回顾一下RecyclerView的缓存机制。 RecyclerView缓存机制 众所周知,RecyclerView拥有四级缓存,它们分别是: Scrap缓存:包括...
继续阅读 »

RecyclerView的性能优化



在我们谈RecyclerView的性能优化之前,先让我们回顾一下RecyclerView的缓存机制。



RecyclerView缓存机制


众所周知,RecyclerView拥有四级缓存,它们分别是:



  • Scrap缓存:包括mAttachedScrap和mChangedScrap,又称屏内缓存,不参与滑动时的回收复用,只是用作临时保存的变量。

    • mAttachedScrap:只保存重新布局时从RecyclerView分离的item的无效、未移除、未更新的holder。

    • mChangedScrap:只会负责保存重新布局时发生变化的item的无效、未移除的holder。



  • CacheView缓存:mCachedViews又称离屏缓存,用于保存最新被移除(remove)的ViewHolder,已经和RecyclerView分离的视图,这一级的缓存是有容量限制的,默认最大数量为2。

  • ViewCacheExtension:mViewCacheExtension又称拓展缓存,为开发者预留的缓存池,开发者可以自己拓展回收池,一般不会用到。

  • RecycledViewPool:终极的回收缓存池,真正存放着被标识废弃(其他池都不愿意回收)的ViewHolder的缓存池。这里的ViewHolder是已经被抹除数据的,没有任何绑定的痕迹,需要重新绑定数据。


RecyclerView的回收原理


(1)如果是RecyclerView不滚动情况下缓存(比如删除item)、重新布局时。



  • 把屏幕上的ViewHolder与屏幕分离下来,存放到Scrap中,即发生改变的ViewHolder缓存到mChangedScrap中,不发生改变的ViewHolder存放到mAttachedScrap中。

  • 剩下ViewHolder会按照mCachedViews > RecycledViewPool的优先级缓存到mCachedViews或者RecycledViewPool中。


(2)如果是RecyclerView滚动情况下缓存(比如滑动列表),在滑动时填充布局。



  • 先移除滑出屏幕的item,第一级缓存mCachedViews优先缓存这些ViewHolder。

  • 由于mCachedViews最大容量为2,当mCachedViews满了以后,会利用先进先出原则,把旧的ViewHolder存放到RecycledViewPool中后移除掉,腾出空间,再将新的ViewHolder添加到mCachedViews中。

  • 最后剩下的ViewHolder都会缓存到终极回收池RecycledViewPool中,它是根据itemType来缓存不同类型的ArrayList,最大容量为5。


RecyclerView的复用原理


当RecyclerView要拿一个复用的ViewHolder时:



  • 如果是预加载,则会先去mChangedScrap中精准查找(分别根据position和id)对应的ViewHolder。

  • 如果没有就再去mAttachedScrap和mCachedViews中精确查找(先position后id)是不是原来的ViewHolder。

  • 如果还没有,则最终去mRecyclerPool找,如果itemType类型匹配对应的ViewHolder,那么返回实例,让它重新绑定数据

  • 如果mRecyclerPool也没有返回ViewHolder才会调用createViewHolder()重新去创建一个。


这里有几点需要注意:



  • 在mChangedScrap、mAttachedScrap、mCachedViews中拿到的ViewHolder都是精准匹配。

  • mAttachedScrap和mCachedViews没有发生变化,是直接使用的。

  • mChangedScrap由于发生了变化,mRecyclerPool由于数据已被抹去,所以都需要调用onBindViewHolder()重新绑定数据才能使用。


缓存机制总结



  • RecyclerView最多可以缓存 N(屏幕最多可显示的item数【Scrap缓存】) + 2 (屏幕外的缓存【CacheView缓存】) + 5*M (M代表M个ViewType,缓存池的缓存【RecycledViewPool】)。

  • RecyclerView实际只有两层缓存可供使用和优化。因为Scrap缓存池不参与滚动的回收复用,所以CacheView缓存池被称为一级缓存,又因为ViewCacheExtension缓存池是给开发者定义的缓存池,一般不用到,所以RecycledViewPool缓存池被称为二级缓存。


如果想深入了解RecyclerView缓存机制的同学,可以参考《RecyclerView的回收复用缓存机制详解》 这篇文章。


性能优化方案


根据上面我们对缓存机制的了解,我们可以简单得到以下几个大方向:



  • 1.提高ViewHolder的复用,减少ViewHolder的创建和数据绑定工作。【最重要】

  • 2.优化onBindViewHolder方法,减少ViewHolder绑定的时间。由于ViewHolder可能会进行多次绑定,所以在onBindViewHolder()尽量只做简单的工作。

  • 3.优化onCreateViewHolder方法,减少ViewHolder创建的时间。


提高ViewHolder的复用


1.多使用Scrap进行局部更新。



  • (1) 使用notifyItemChangenotifyItemInsertednotifyItemMovednotifyItemRemoved等方法替代notifyDataSetChanged方法。

  • (2) 使用notifyItemChanged(int position, @Nullable Object payload)方法,传入需要刷新的内容进行局部增量刷新。这个方法一般很少有人知道,具体做法如下:

    • 首先在notify的时候,在payload中传入需要刷新的数据,一般使用Bundle作为数据的载体。

    • 然后重写RecyclerView.AdapteronBindViewHolder(@NonNull RecyclerViewHolder holder, int position, @NonNull List<Object> payloads)方法
      @Override
      public void onBindViewHolder(@NonNull RecyclerViewHolder holder, int position, @NonNull List<Object> payloads) {
      if (CollectionUtils.isEmpty(payloads)) {
      Logger.e("正在进行全量刷新:" + position);
      onBindViewHolder(holder, position);
      return;
      }
      // payloads为非空的情况,进行局部刷新
      //取出我们在getChangePayload()方法返回的bundle
      Bundle payload = WidgetUtils.getChangePayload(payloads);
      if (payload == null) {
      return;
      }
      Logger.e("正在进行增量刷新:" + position);
      for (String key : payload.keySet()) {
      if (KEY_SELECT_STATUS.equals(key)) {
      holder.checked(R.id.scb_select, payload.getBoolean(key));
      }
      }
      }





详细使用方法可参考XUI中的RecyclerView局部增量刷新 中的代码。



  • (3) 使用DiffUtilSortedList进行局部增量刷新,提高刷新效率。和上面讲的传入payload原理一样,这两个是Android默认提供给我们使用的两个封装类。这里我以DiffUtil举例说明该如何使用。

    • 首先需要实现DiffUtil.Callback的5个抽象方法,具体可参考DiffUtilCallback.java

    • 然后调用DiffUtil.calculateDiff方法返回比较的结果DiffUtil.DiffResult

    • 最后调用DiffUtil.DiffResultdispatchUpdatesTo方法,传入RecyclerView.Adapter进行数据刷新。




详细使用方法可参考XUI中的DiffUtil局部刷新XUI中的SortedList自动数据排序刷新 中的代码。


2.合理设置RecyclerViewPool的大小。如果一屏的item较多,那么RecyclerViewPool的大小就不能再使用默认的5,可适度增大Pool池的大小。如果存在RecyclerView中嵌套RecyclerView的情况,可以考虑复用RecyclerViewPool缓存池,减少开销。


3.为RecyclerView设置setHasStableIds为true,并同时重写RecyclerView.Adapter的getItemId方法来给每个Item一个唯一的ID,提高缓存的复用率。


4.视情况使用setItemViewCacheSize(size)来加大CacheView缓存数目,用空间换取时间提高流畅度。对于可能来回滑动的RecyclerView,把CacheViews的缓存数量设置大一些,可以省去ViewHolder绑定的时间,加快布局显示。


5.当两个数据源大部分相似时,使用swapAdapter代替setAdapter。这是因为setAdapter会直接清空RecyclerView上的所有缓存,但是swapAdapter会将RecyclerView上的ViewHolder保存到pool中,这样当数据源相似时,就可以提高缓存的复用率。


优化onBindViewHolder方法


1.在onBindViewHolder方法中,去除冗余的setOnItemClick等事件。因为直接在onBindViewHolder方法中创建匿名内部类的方式来实现setOnItemClick,会导致在RecyclerView快速滑动时创建很多对象。应当把事件的绑定在ViewHolder创建的时候和对应的rootView进行绑定。


2.数据处理与视图绑定分离,去除onBindViewHolder方法里面的耗时操作,只做纯粹的数据绑定操作。当程序走到onBindViewHolder方法时,数据应当是准备完备的,禁止在onBindViewHolder方法里面进行数据获取的操作。


3.有大量图片时,滚动时停止加载图片,停止后再去加载图片。


4.对于固定尺寸的item,可以使用setHasFixedSize避免requestLayout


优化onCreateViewHolder方法


1.降低item的布局层级,可以减少界面创建的渲染时间。


2.Prefetch预取。如果你使用的是嵌套的RecyclerView,或者你自己写LayoutManager,则需要自己实现Prefetch,重写collectAdjacentPrefetchPositions方法。


其他


以上都是针对RecyclerView的缓存机制展开的优化方案,其实还有几种方案可供参考。


1.取消不需要的item动画。具体的做法是:


((SimpleItemAnimator) recyclerView.getItemAnimator()).setSupportsChangeAnimations(false);

2.使用getExtraLayoutSpace为LayoutManager设置更多的预留空间。当RecyclerView的元素比较高,一屏只能显示一个元素的时候,第一次滑动到第二个元素会卡顿,这个时候就需要预留的额外空间,让RecyclerView预加载可重用的缓存。


最后


以上就是RecyclerView性能优化的全部内容,俗话说:百闻不如一见,百见不如一干,大家还是赶紧动手尝试着开始进行优化吧!


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

计算耗时? Isolate 来帮忙

一、问题引入 - 计算密集型任务 假如现在有个需求,我想要计算 1 亿 个 1~10000 间随机数的平均值,在界面上显示结果,该怎么办? 可能有小伙伴踊跃发言:这还不简单,生成 1 亿 个随机数,算呗。 1. 搭建测试场景 如下,写个简单的测试界面,界面中...
继续阅读 »

一、问题引入 - 计算密集型任务


假如现在有个需求,我想要计算 1 亿1~10000 间随机数的平均值,在界面上显示结果,该怎么办?

可能有小伙伴踊跃发言:这还不简单,生成 1 亿 个随机数,算呗。




1. 搭建测试场景

如下,写个简单的测试界面,界面中有计算结果和耗时的信息。点击运行按钮,触发 _doTask 方法进行运算。计算完后将结果展示出来:



代码详见: 【async/isolate/01】



1667781807811.png


void _doTask() {
int sum = 0;
int startTime = DateTime.now().millisecondsSinceEpoch;
for(int i = 0;i



可以看到,这样是可以实现需求的,总耗时在 8.5 秒左右。细心的朋友可能会发现,在点击按键触发 _doTask 时,FloatingActionButton 的水波纹并没有出现,仿佛是卡死一般。为了应证这点,我们再进行一个对比实验。















请点击前请点击后



2. 计算耗时阻塞

如下所示,我们让 CupertinoActivityIndicator 一直处于运动状态,作为界面 未被卡死 的标志。当点击运行时,可以看出指示器被卡住了, 再点击按钮也没有任何的水波纹反映,这说明:



计算的耗时任务会阻塞 Dart 的线程,界面因此无法有任何响应。
















未执行前执行前后
37.gif35.gif

Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [Text("动画指示器示意: "), CupertinoActivityIndicator()],
),



3. 计算耗时阻塞的解决方案

有人说,用异步的方式触发 _doTask 呗,比如用 FuturescheduleMicrotask 包一下,或 Stream 异步处理。有这个想法的人可以试一试,如果你看懂前面几篇看到了原理,就知道是不可行的,这些工具只不过是回调包装而已。只要计算的任务仍是 Dart 在单线程中处理的,就无法避免阻塞。现在的问题相当于:



一个人无法同时做 洗漱扫地 的任务。



一旦阻塞,界面就无法有任何响应,自然也无法展示加载中的动画,这对于用户体验来说是极其糟糕的。那如何让计算密集型的耗时任务,在处理时不阻塞呢? 我们可以好好品味一下这句话:


image.png


这句话言外之意给出了两种解决方案:



【1】. 将计算密集型的耗时任务,从 Dart 端剥离,交由 其他机体 来处理。

【2】. 在 Dart 中通过 多线程 的方式处理,从而不阻塞主线程。



方式一其实很好理解,比如耗时的任务交由服务端来完成,客户端通过 接口请求 ,获取响应结果。这样计算型的密集任务,对于 Flutter 而言,就转换成了一个网络的 IO 任务。或者通过 插件 的方式,将计算的耗时任务交由平台来通过多线程处理,而 Dart 端只需要通过回调处理即可,也不会阻塞。


方式一处理的本质上都是将计算密集型的任务转移到其他机体中,从而让 Dart 避免处理计算密集型的耗时任务。这种方式需要其他语言或后端的支持,想要实现是有一定门槛的。那如何直接在 Flutter 中,通过 Dart 语言处理计算密集型的任务呢?


这就是我们今天的主角: Isolate 。 可能很多人潜意识里 Dart 是单线程模型,无法通过多线程的处理任务,这种认知就狭隘了。其实 Dart 提供了 Isolate, 本质上是通过 C++ 创建线程,隔离出另一份区间来通过 Dart 处理任务。它相当于线程的一种上层封装,屏蔽了很多内部细节,可以通过 Dart 语言直接操作。




二、从 compute 函数认识 Isolate


首先,我们通过 compute 函数认识一下计算密集型的耗时任务该如何处理。 compute 函数字如其名,用于处理计算。只要简单看一下,就知道它本身是 Isolate 的一个简单的封装使用方式。它作为全局函数被定义在 foundation/isolates.dart 中:


image.png




1. 认识 compute 函数

既然是函数,那使用时就非常简单,调用就行了。关于函数的调用,比较重要的是 入参返回值泛型。从上面函数定义中可以看出,它就是 isolate 包中的 compute 函数, 其中泛型有两个 QR ,返回值是 R 泛型的 Future 对象,很明显该泛型表示结果 Result;第二入参是 Q 泛型的 message ,表示消息类型;第三入参是可选参数,用于调试时的标签。


---->[_isolates_io.dart#compute]----
/// The dart:io implementation of [isolate.compute].
Future compute(
isolates.ComputeCallback callback,
Q message,
{ String? debugLabel })
async {

看到这里,很自然地就可以想到,这里第一参中传入的 callback 就是计算任务,它将被在其他的 isolate 中被执行,然后返回计算结果。下面我们来看一下在当前场景下的使用方式。在此之前,先封装一下返回的结果。通过 TaskResult 记录结果,作为 compute 的返回值:



代码详见: 【async/isolate/02_compute】



class TaskResult {
final int cost;
final double result;

TaskResult({required this.cost, required this.result});
}



2. compute 函数的使用

compute 方法在传入两个参数,其一是 _doTaskInCompute ,也就是计算的耗时任务,其二是传递的信息,这里不需要,传空值字符串。虽然方法的泛型可以不传,但严谨一些的话,可也以把泛型加上,这样可读性更好一些:


void _doTask() async {
TaskResult taskResult = await compute(
_doTaskInCompute, '',
debugLabel: "task1");
setState(() {
result = taskResult.result;
cost = taskResult.cost;
});
}



对于 compute 而言,传入的回调有一个非常重要的注意点:



函数必须是 静态函数 或者 全局函数



static Random random = Random();

static Future _doTaskInCompute(String arg) async {
int count = 100000000;
double result = 0;
int cost = 0;
int sum = 0;
int startTime = DateTime.now().millisecondsSinceEpoch;
for (int i = 0; i < count; i++) {
sum += random.nextInt(10000);
}
int endTime = DateTime.now().millisecondsSinceEpoch;
result = sum / count;
cost = endTime - startTime;
return TaskResult(
result: result,
cost: cost,
);
}



下面看一下用和不用 compute 处理的效果差异,如下左图是使用 compute 的效果,在进行计算的同时指示器的动画仍在运动,桌面计算操作并未影响主线程,界面仍可以触发响应,这就和前面产生了鲜明的对比。















用 compute不用 compute
38.gif35.gif



3. 理解 compute 的作用

如下,在 _doTaskInCompute 中打断点调试一下,可以看出此时除了 main 还有一个 task1 的栈帧。此时断点停留在新帧中, main 仍处于运行状态:


image.png


image.png


这就相当于计算任务不想自己处理,找另外一个人来做。每块处理任务的单元,就可以视为一个 isolate。它们之间的信息数据在内存中是不互通的,这也是为什么起名为 隔离 isolate 的原因。 这种特性能非常有效地避免多线程中操作同一内存数据的风险。 但同时也需要引入一个 通信机制 来处理两个 isolate 间的通信。


image.png


其实这和 客户端 - 服务端 的模型非常相似,通过 发送端 SendPort 发送消息,通过接收端 RawReceivePort 接收消息。从 compute 方法的源码中可以简单地看出,其本质是通过 Isolate.spawn 实现的 Isolate 创建。


image.png


这里有个小细节要注意,通过多次测试发现 compute 中的计算耗时要普遍高于主线程中的耗时。这并不是说新建的 isolate 在计算能力上远小于 主 isolate, 毕竟这里是 1 亿 次的计算,任何微小的细节都将被放大 1 亿 倍。这里的关注点应在于 新 isolate 可以独立于 主 isolate 运行,并且可以通过通信机制将结果返回给 主 isolate




4. compute 参数传递与多个 isolate

如果是大量的相互独立的计算耗时任务,可以开启多个 isolate 共同处理,最后进行结果汇总。比如这里 1 亿 次的计算,我们可以开 2isolate , 分别处理 5000 万 个计算任务。如下所示,总耗时就是 6 秒左右。当然创建 isolate 也是有资源消耗的,并不是说创建 100 个就能把耗时降低 100 倍。


39.gif


关于传参非常简单,compute 第一泛型是参数类型,这里可以指定 int类型作为 _doTaskInCompute 任务的入参,指定计算的次数。这里通过两个 compute 创建两个 isolate 同时处理 5000 万 个随机数的的平均值,来模拟那些相互独立的任务:



代码详见: 【async/isolate/03_compute】



image.png


最后通过 Future.await 对多个异步任务进行结果汇总,示意图如下,这样就相当于又开了一个 isolate 进行处理计算任务:


image.png


对于 isolate 千万不要盲目使用,一定要认清当前任务是否真有必要使用。比如几百微秒就能处理完成的任务,用 isolate 就是拿导弹打蚊子。或者那些并非由 Dart 端处理的 IO 密集型 任务,用 isolate 就相当于你打开了烧水按钮,又找来一个人专门看着烧水的过程。这种多此一举的行为,都是对于异步不理解的表现。


一般而言,客户端中并没有太多需要处理复杂计算的场景,只有一些特定场景的软件,比如需要进行大量的文字解析、复杂的图片处理等。




三、分析 compute 函数的源码实现


到这可能有人觉得,新开一个 isolate好简单啊,compute 函数处理一下就好啦。但是,简单必然有简单的 局限性,仔细思考一下,会发现 compute 函数有个缺陷:它只会 "闷头干活",只有任务完成才会通过 Future 通知 main isolate


也就是说,对于 UI 界面来说无法无法感知到 任务执行进度 信息,处理展示 计算中... 之外没什么能干的。这在某些特别耗时的场景中会造成用户的等待焦虑,我们需要让干活的 isolate 抽空通知一下 main isolate,所以对 isolate 之间的通信方式,是有必要了解的。


image.png


既然 compute 在完成任务时可以进行一次通信,那么就可以从 compute 函数的源码中去分析这种通信的方式。




1. 接收端口的创建与处理器设置

如下所示,在一开始会创建一个 Flow 对象,从该对象的成员中可以看出,它只负责维护两个整型 id_type 的数值信息。接下来会创建 RawReceivePort 对象,是不是有点眼熟?


image.png




还记得那个经常在面前晃的 _RawRecivePortImpl类吗? RawReceivePort 的默认工厂构造方法创建的就是 _RawReceivePortImpl 对象,如下代码所示:


image.png


---->[isolate_patch.dart/RawReceivePort]----
@patch
class RawReceivePort {
@patch
factory RawReceivePort([Function? handler, String debugName = '']) {
_RawReceivePortImpl result = new _RawReceivePortImpl(debugName);
result.handler = handler;
return result;
}
}



接下来,会创建一个 Completer 对象,并在为 port 设置信息的 handler 处理器,在处理回调中触发 completer#complete 方法,表示异步任务完成。也就是说处理器接收信息之时,就是 completer 中异步任务完成之日。


如果不知道 Completer 和接收端口设置 handler 是干嘛的,可以分别到 【第五篇·第二节】【第六篇·第一节】 温故,这里就不赘述了。


---->[_isolates_io.dart#compute]----
final Completer completer = Completer();
port.handler = (dynamic msg) {
timeEndAndCleanup();
completer.complete(msg);
};



2. 认识 Isolate.spawn 方法

接下来会触发 Isolate.spawn 方法,该方法是生成 isolate 的核心。其中传入的 回调 callback消息 message 以及发送的端口 SendPort 会组合成 _IsolateConfiguration 作为第二参数:


image.png


通过 Isolate.spawn 方法的定义可以看出,第一参是一个入口函数,第二参是函数入参。所以上面红框中的对象将作为 _spawn 函数的入参。从这里可以看出第一参 _spawn 函数应该是在新 isolate 中执行的。


external static Future spawn(
void entryPoint(T message), T message,
{bool paused = false,
bool errorsAreFatal = true,
SendPort? onExit,
SendPort? onError,
@Since("2.3") String? debugName})
;



下面是在耗时任务中打断点的效果,其中很清晰地展现出 _spawn 方法到 _doTaskInCompute 的过程。


image.png


如下,是 _spawn 的处理流程,上面的调试发生在 127 行,此时触发回调方法,获取结果。然后在关闭 isolate 时,将结果发送出去,流程其实并不复杂。


image.png


有一个小细节,结果通过 _buildSuccessResponse 方法处理了一下,关闭时发送的消息是列表,后期会根据列表的长度判断任务处理的正确性。


List _buildSuccessResponse(R result) {
return List.filled(1, result);
}



3. 异步任务的结束

从前面测试中可以知道 compute 函数返回值是一个泛型为结果的 Future 对象,那这个返回值是什么呢?如下可以看出当结果列表长度为 1 表示任务成功完成,返回 completer 任务结果的首元素:


image.png


再结合 completer 触发 complete 完成的时机,就不难知道。最终的结果是由接收端接收到的信息,调试如下:


image.png


也就是说,isolate 关闭时发送的信息,将会被 接收端的处理器 监听到。这就是 compute 函数源码的全部处理逻辑,总的来看还是非常简单的。就是,使用 Completer ,基于 Isolate.spawn 的简单封装,屏蔽了用户对 RawReceivePort 的感知,从而简化使用。




四、Isolate 发送和接收消息的使用


通过 compute 函数我们知道 isoalte 之间有着一套消息 发送 - 监听 的机制。我们可以利用这个机制在某些时刻发送进度消息传给 main isolate,这样 UI 界面中就可以展示出 耗时任务 的进度。如下所示,每当 100 万次 计算时,发送消息通知 main isolate :


40.gif




1. 使用 Isolate.spawn

compute 函数为了简化使用,将 发送 - 监听 的处理封装在了内部,用户无法操作。使用为了能使用该功能,我们可以主动来使用 Isolate.spawn 。如下所示,创建 RawReceivePort,并设置 handler 处理器器,这里通过 handleMessage 函数来单独处理。



代码详见: 【async/isolate/04_spawn】



然后调用 Isolate.spawn 来开启新 isolate,其中第一参是在新 isolate 中处理的耗时任务,第二参是任务的入参。这里将发送端口传入 _doTaskInCompute 方法,以便发送消息:


void _doTask() async {
final receivePort = RawReceivePort();
receivePort.handler = handleMessage;
await Isolate.spawn(
_doTaskInCompute,
receivePort.sendPort,
onError: receivePort.sendPort,
onExit: receivePort.sendPort,
);
}



2. 通过端口发送消息

SendPort 传入 _doTaskInCompute 中,如下 tag1 处,可以每隔 1000000 次发送一次进度通知。在任务完成后,使用 Isolate.exit 方法关闭当前 isolate 并发送结果数据。


static void _doTaskInCompute(SendPort port) async {
int count = 100000000;
double result = 0;
int cost = 0;
int sum = 0;
int startTime = DateTime.now().millisecondsSinceEpoch;
for (int i = 0; i < count; i++) {
sum += random.nextInt(10000);
if (i % 1000000 == 0) { // tag1
port.send(i / count);
}
}
int endTime = DateTime.now().millisecondsSinceEpoch;
result = sum / count;
cost = endTime - startTime;
Isolate.exit(port, TaskResult(result: result, cost: cost));
}



3. 通过接收端处理消息

接下来只要在 handleMessage 方法中处理发送端传递的消息即可,可以根据消息的类型判断是什么消息,比如这里如果是 double 表示是进度,通知 UI 更新进度值。另外,如果不同类型的消息非常多,也可以自己定义一套发送结果的规范方便处理。


void handleMessage(dynamic msg) {
print("=========$msg===============");
if (msg is TaskResult) {
progress = 1;
setState(() {
result = msg.result;
cost = msg.cost;
});
}
if (msg is double) {
setState(() {
progress = msg;
});
}
}



其实学会了如何通过 Isolate.spawn 处理计算耗时任务,以及通过 SendPort-RawReceivePort 处理 发送 - 监听 消息,就能满足绝大多数对 Isolate 的使用场景。如果不需要在任务执行过程中发送通知,使用 compute 函数会方便一些。最后还是要强调一点,不要滥用 Isolate ,使用前动动脑子,思考一下是否真的是计算耗时任务,是否真的需要在 Dart 端来完成。开一个 isolate 至少要消耗 30 kb


image.png


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

为什么会发生 Fragment not attached to Activity 异常?

事情是这样的,前两天有位大佬在群里提了个问题,原文如下 一个 Fragment 在点击按钮跳转一个新的 Activity 的时候,报崩溃异常:Fragment not attached to Activity 问:复现路径可能是什么样的呢? 一、回答问题前先...
继续阅读 »

事情是这样的,前两天有位大佬在群里提了个问题,原文如下


聊天截图.jpg


一个 Fragment 在点击按钮跳转一个新的 Activity 的时候,报崩溃异常:Fragment not attached to Activity


问:复现路径可能是什么样的呢?


一、回答问题前先审题


我们把这个问题的几个关键词圈出来


首先,可以点击 Fragment 上的按钮,证明这个 Fragment 是可以被看到的,那肯定是处于存活的状态的


其次,在跳转到新的 Activity 的时候发生崩溃,证明 Fragment 调用的是 startActivity() 方法


最后,来看异常信息:”Fragment not attached to Activity“


这个报错我们都已经很熟悉了,在 onAttach() 之前,或者 onDetach() 之后,调用任何和 Context 相关的方法,都会抛出 " not attached to Activity " 异常


发生的原因往往是因为异步任务导致的,比如一个网络请求回来以后,再调用了 startActivity() 进行页面跳转,或者调用 getResources() 获取资源文件等等


解决方案也非常简单:在 Fragment 调用了 Context 相关方法前,先通过 isAdded() 方法检查 Fragment 的存活状态就完事了


到这里,崩溃产生的原因找到了,解决方案也有了,似乎整篇文章就可以结束了


但是,楼主问的是:复现路径可能是什么样的呢?


这勾起了我的好奇心,我也想知道可能的路径是怎样的


于是,在接下来的两个晚上,笔者开始了一场源码之旅..


二、大胆假设,小心求证


审题结束我们就可以开始动手解答了,以下是群里的完整对话


大佬:一个 Fragment 在点击按钮跳转一个新的 Activity 的时候,报崩溃异常:Fragment not attached to Activity 。复现路径可能是什么样的呢?


我:这个问题之前在项目中也有碰到过,当时的解决方案是,通过调用 isAdded() 来检查 Fragment 是否还活着,来避免因为上下文为空导致的崩溃


当时忙于做业务没有深入研究,现在趁着晚上有时间来研究一下下


首先,打开 Fragment 源码,路径在:frameworks/base/core/java/android/app/Fragment.java


用 “not attached to Activity” 作为关键字搜索,可以发现 getResources()getLoaderManager()startActivity() 等等共计 6 处地方,都可能抛出这个异常


题目明确提到,是跳转 Activity 时发生的错误,那我们直接来看 startActivity() 方法


class Fragment {
void startActivity(){
if (mHost == null)
throw new IllegalStateException("Fragment " + this + " not attached to Activity");
}
}

从上面代码可以看出,当 mHost 对象为空时,程序抛出 Fragment not attached to Activity 异常


好,现在我们的问题转变为:



  1. mHost 对象什么时候会被赋值?


很显然,如果在赋值前调用了 startActivity() 方法,那程序必然会崩溃



  1. mHost 对象赋值以后,可能会被置空吗?如果会,什么时候发生?


我们都知道,Fragment 依赖 Activity 才能生存,那我们有理由怀疑:


当 Activity 执行 stop / destroy ,或者,配置发生变化(比如屏幕旋转)导致 Activity 重建,会不会将 mHost 对象也置空呢?


mHost 对象什么时候会被赋值?


先来看第一个问题,mHost 对象什么时候会被赋值?


平时我们使用 Fragment 开发时,通常都是直接 new 一个对象出来,然后再提交给 FragmentManager 去显示


创建 Fragment 对象的时候,不要求传入 mHost 参数,那 mHost 对象只能是 Android 系统帮我们赋值的了


得,又得去翻源码


打开 FragmentManager.java ,路径在:/frameworks/base/core/java/android/app/FragmentManager.java


class FragmentManager {

FragmentHostCallback mHost; // 内部持有 Context 对象,其本质是宿主 Activity

void moveToState(f,newState){
switch(f.mState){
case Fragment.INITIALIZING:
f.mHost = mHost; // 赋值 Fragment 的 mHost 对象
f.onAttach(mHost.getContext());
}
f.mState = newState;
}
}

我们发现源码里只有一个地方会给 mHost 对象赋值,在 FragmetnManager#moveToState() 方法中


如果当前 Fragment 的状态是 INITIALIZING ,那么就把 FragmentManager 自身的 mHost 对象,赋值给 Fragment 的 mHost 对象


这里多说一句,在 Android 系统中,一个 Activity 只会对应一个 FragmentManager 管理者。而 FragmentManager 中的 mHost ,其本质上就是 Activity 宿主。


所以,这里把 FragmentManager 的 mHost 对象,赋值给了 Fragment ,就相当于 Fragment 也持有了宿主 Activity


这也解释了我们之所以能在 Fragment 中调用 getResource()startActivity() 等需要 context 的才能访问方法,实际使用的就是 Activity 的上下文


废话说完了,我们来聊正事


FragmentManager#moveToState() 方法会先去判断 Fragment 的状态,那我们首先得知道 Fragment 有哪几种状态


class Fragment {

int INITIALIZING = 0; // Not yet created.
int CREATED = 1; // Created.
int ACTIVITY_CREATED = 2; // The activity has finished its creation.
... // 共6种标识

int mState = INITIALIZING; // 默认为 INITIALIZING
}

Google 为 Fragment 共声明了6个状态标识符,各个标识符的含义看注释即可


这里重点关注标识符下面的 mState 变量,它表示的是 Fragment 当前的状态,默认为 INITIALIZING


了解完 Fragment 的状态标识,我们回过头继续来看 FragmentManager#moveToState() 方法


class FragmentManager {
void moveToState(f,newState){
switch(f.mState){
case Fragment.INITIALIZING: // 必走逻辑
f.mHost = mHost; // 赋值 Fragment 的 mHost 对象
f.onAttach(mHost.getContext());
}
f.mState = newState;
}
}

moveToState() 方法中,只要当前 Fragment 状态为 INITIALIZING ,即执行 mHost 的赋值操作


巧了不是,前面刚说完,mState 默认值就是 INITIALIZING


也就是说,在第一次调用 moveToState() 方法时,不管接下来 Fragment 要转变成什么状态(根据 newState 的值来判断


首先,它都得从 INITIALIZING 状态变过去!那么,case = Fragment.INITIALIZING 这个分支必然会被执行!!这时候,mHost 也必然会被赋值!!!


再然后,才会有 onAttach() / onCreate() / onStart() 等等这些生命周期的回调!


因此,我们的第一个猜想:mHost 对象赋值前,有没有可能调用 startActivity() 方法?


答案显然是否定的


因为,根据楼主描述,点击按钮以后才发生的崩溃,视图能显示出来,说明 mHost 已经赋值过并且生命周期都正常走


那就只可能是点击按钮后,发生了什么事情,将 mHost 又置为 null


mHost 对象什么时候会被置空?


继续,来看第二个问题:mHost 对象赋值以后,可能会被置空吗?如果会,什么时候发生?


我们就不绕弯了,直接说答案,会!


置空 mHost 的逻辑,同样藏在 FragmentManager 的源码里:


class FragmentManager {

void moveToState(f,newState){
if (f.mState < newState) {
switch(f.mState){
case Fragment.INITIALIZING:
f.mHost = mHost; // mHost 对象赋值
}
} else if (f.mState > newState) {
switch (f.mState) {
case Fragment.CREATED:
if (newState < Fragment.CREATED) {
f.performDetach(); // 调用 Fragment 的 onDetach()
if (!f.mRetaining) {
makeInactive(f); // 重点1号,这里会清空 mHost
} else {
f.mHost = null; // 重点2号,这里也会清空 mHost 对象
}
}
}
}
f.mState = newState;
}

void makeInactive(f) {
f.initState(); // 此调用会清空 Fragment 全部状态,包括 mHost
}
}

看上面的代码,分发 Fragment 的 performDetach() 方法后,紧接着就会把 mHost 对象置空!


标记为 "重点1号" 和 "重点2号" 的代码都会执行了置空 mHost 对象的逻辑,两者的区别是:


Fragment 有一个保留实例的接口 setRetainInstance(bool) ,如果设置为 true ,那么在销毁重建 Activity 时,不会销毁该 Fragment 的实例对象


当然这不是本节的重点,我们只需要知道:执行完 performDetach() 方法后,无论如何,mHost 也都活不了了


那,什么动作会触发 performDetach() 方法?


1、Activity 销毁重建


不管因为什么原因,只要 Activity 被销毁,Fragment 也不能独善其身,所有的 Fragment 都会被一起销毁,对应的生命周期如下:



Activity#onDestroy() -> Fragment#onDestroyView() - > Fragment#onDestroy() - >Fragment#onDetach()



2、调用 FragmentTransaction#remove() 方法移除 Fragment


remove() 方法会移除当前的 Fragment 实例,如果这个 Fragment 正在屏幕上显示,那么 Android 会先移除视图,对应的生命周期如下:



Fragment#onPause() -> onStop() -> onDestroyView() - > onDestroy() - >onDetach()



3、调用 FragmentTransaction#replace() 方法显示新的 Fragment


replace() 方法会将所有的 Fragment 实例对象都移除掉,只会保留当前提交的 Fragment 对象,生命周期参考 remove() 方法


以上三种场景,是我自己做测试得出来的结果,应该还有其他没测出来的场景,欢迎大佬补充


另外,FragmentTransaction 中还有两个常用的 detach() / hide() 方法,它俩只会将视图移除或隐藏,而不会触发 performDetach() 方法


真相永远只有一个


好了,现在我们知道了 mHost 对象置空的时机,答案已经越来越近了


我们先来汇总下已有的线索


从 FragmentManager 源码来看,只要我们的 startActivity() 页面跳转逻辑写在:


onAttach() 方法执行之后 ,onDetach() 方法执行之前


那结果一定总是能够跳转成功,不会报错!


那么问题就来了


onAttach() 之前,视图不存在,onDetach() 之后,视图都已经销毁了,还点击哪门子按钮?


这句话翻译一下就是:


视图在,Activity 在,点击事件正常响应


视图不在,按钮也不在了呀,也就不存在页面跳转了


这样看起来,似乎永远不会出现楼主说的错误嘛


除非。。。


执行 startActivity() 方法的时候,视图已经不在了!!!


这听起来很熟悉,ummmmmm。。这不就是异步调用吗?


class Fragment {
void onClick(){
//do something
Handler().postDelayed(startActivity(),1000);
}
}

上面是一段异步调用的演示代码,为了省事我直接用 Handler 提交了延迟消息


当用户点击跳转按钮后,一旦发生 Activity 销毁重建,或者 Fragment 被移除的情况


等待 1s 执行 startActivity() 方法时,程序就会发生崩溃,这时候终于可以看到我们期待已久的异常:Fragment not attached to Activity


为什么会这样?熟悉 Java 的小伙伴这里肯定要说了,因为提交到 Handler 的 Runnable 会持有外部类呀,也就是宿主 Fragment 的引用。如果在执行 Runnable#run() 方法之前, Fragment 的 mHost 被清空,那程序肯定会发生崩溃的


那我们怎么样才能防止程序崩溃呢?




  • 要么,同步执行 Context 相关方法




  • 要么,异步判空,用到 Context 前调用 isAdded() 方法检查 Fragment 存活状态




三、结语


呼~ 这下总算是理清了,我们来尝试回答楼主的问题:发生 not attached to Activity,可能路径是怎样的?


首先,必然存在一个异步任务持有 Fragment 引用,并且内部调用了 startActivity() 方法。


在这个异步任务提交之后,执行之前,一旦发生了下面列表中,一个或多个的情况时,程序就会抛出 not attached to Activity 异常:



  • 调用 finishXXX() 结束了 Activity,导致 Activity 为空

  • 手动调用 Activity#recreate() 方法,导致 Activity 重建

  • 旋转屏幕、键盘可用性改变、更改语言等配置更改,导致 Activity 重建

  • 向 FragmentManager 提交 remove() / replace() 请求,导致 Fragment 实例被销毁

  • ...


最后,发生这个错误信息的本质,是在 Activity 、Fragment 销毁时,没有同步取消异步任务,这是内存泄漏啊


所以,除了使用 isAdded() 方法判空,避免程序崩溃外,更应该排查哪里可能会长时间引用该 Fragment


如果可能,在 Fragment 的 onDestroy() 方法中,取消异步任务,或者,把 Fragment 改为弱引用


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

Next.js 和 React 到底该选哪一个?

web
这篇文章将从流行度、性能、文档生态等方面对next.js 和 react 做一个简单的比较。我们那可以根据正在构建的应用的规模和预期用途,选择相应开发框架。web技术在不断发展变化,js的生态系统也在不断的更新迭代,相应的React和Next也不断变化。作为前...
继续阅读 »

这篇文章将从流行度、性能、文档生态等方面对next.js 和 react 做一个简单的比较。我们那可以根据正在构建的应用的规模和预期用途,选择相应开发框架。

web技术在不断发展变化,js的生态系统也在不断的更新迭代,相应的React和Next也不断变化。

作为前端开发人员,可能我们的项目中已经使用了react, 或者我们可能考虑在下一个项目中使用next.js。理解这两个东西之间的关系或者异同点,可以帮助我们作出更好的选择。

React

按照官方文档的解释:

React是一个声明性、高效且灵活的JavaScript库,用于构建用户界面。它允许我们从称为“组件”的代码片段组成复杂的UI。

React的主要概念是虚拟DOM,虚拟的dom对象保存在内存中,并通过ReactDOM等js库与真实DOM同步。

使用React我们可以进行单页程序、移动端程序和服务器渲染等应用程序的开发。

但是,React通常只关心状态管理以及如何将状态呈现到DOM,因此创建React应用程序时通常需要使用额外的库进行路由,以及某些客户端功能。

Next.js

维基百科对Next.js的解释:

Next.js是一个由Vercel创建的开源web开发框架,支持基于React的web应用程序进行服务器端渲染并生成静态网站。

Next.js提供了一个生产环境需要的所有特性的最佳开发体验:前端静态模版、服务器渲染、支持TypeScript、智能绑定、预获取路由等,同时也不需要进行配置。

React 的文档中将Next.js列为推荐的工具,建议用Next.js+Node.js 进行服务端渲染的开发。

Next.js的主要特性是:使用服务器端渲染来减轻web浏览器的负担,同时一定程度上增强了客户端的安全性。它使用基于页面的路由以方便开发人员,并支持动态路由。

其他功能包括:模块热更新、代码自动拆分,仅加载页面所需的代码、页面预获取,以减少加载时间。

Next.js还支持增量静态再生和静态站点生成。网站的编译版本通常在构建期间构建,并保存为.next文件夹。当用户发出请求时,预构建版本(静态HTML页面)将被缓存并发送给他们。这使得加载时间非常快,但这并不适用于所有的网站,比如经常更改内容且使用有大量用户输入交互的网站。

Next.js vs React

我们可以简单做个比较:

Next.jsReact
Next 是 React 的一个框架React 是一个库
可以配置需要的所有内容不可配置
客户端渲染 & 服务端渲染 而为人们所知-
构件web应用速度非常快构建速度相对较慢
会react上手非常快上手稍显困难
社区小而精非常庞大的社区生态
对SEO 优化较好需要做些支持SEO 优化的配置
不支持离线应用支持离线应用

利弊分析

在看了上面的比较之后,我们可能对应该选择哪个框架有一些自己的想法。

React的优势:

  • 易学易用

  • 使用虚拟DOM

  • 可复用组件

  • 可以做SEO优化

  • 提供了扩展能力

  • 需要较好的抽象能力

  • 强有力的社区

  • 丰富的插件资源

  • 提供了debug工具

React的劣势:

  • 发展速度快

  • 缺少较好的文档

  • sdk更新滞后

Next.js的优势:

  • 提供了图片优化功能

  • 支持国际化

  • 0配置

  • 编译速度快

  • 即支持静态站也可以进行服务端渲染

  • API 路由

  • 内置CSS

  • 支持TypeScript

  • seo友好

Next.js的劣势:

  • 缺少插件生态

  • 缺少状态管理

  • 相对来说是一个比较固定的框架

选 Next.js 还是 React ?

这个不太好直接下结论,因为React是一个用于构建UI的库,而Next是一个基于React构建整个应用程序的框架。

React有时比Next更合适,但是有时候Next比React更合适。

当我们需要很多动态路由,或者需要支持离线应用,或者我们对jsx非常熟悉的时候,我们就可以选择React进行开发。

当我们需要一个各方面功能都很全面的框架时,或者需要进行服务端渲染时,我们就可以使用next.js进行开发。

最后

虽然React很受欢迎,但是Nextjs提供了服务器端渲染、非常快的页面加载速度、SEO功能、基于文件的路由、API路由,以及许多独特的现成特性,使其在许多情况下都是一种非常方便的选择。

虽然我们可以使用React达到同样的目的,但是需要自己去熟悉各种配置,配置的过程有时候也是一件非常繁琐的事情。

作者:前端那些年
来源:juejin.cn/post/7163660046734196744

收起阅读 »

程序员真的需要“程序员鼓励师”吗?

没错,你没看错,今天要谈的话题是程序员鼓励师,程序员需要有程序员鼓励师么?你心里的回答当然是需要的,如果你是男程序员的话。女程序员我就不清楚了,因为没做过女人,这里就不研究女人的心里的,这里只讨论男程序员。程序员的价值观 网上充斥着各种段子,什么程序员不懂浪漫...
继续阅读 »

没错,你没看错,今天要谈的话题是程序员鼓励师,程序员需要有程序员鼓励师么?你心里的回答当然是需要的,如果你是男程序员的话。女程序员我就不清楚了,因为没做过女人,这里就不研究女人的心里的,这里只讨论男程序员。


程序员的价值观 

网上充斥着各种段子,什么程序员不懂浪漫,程序员对女人不感兴趣,程序员和电脑谈恋爱。

这些不尽是正确,程序员不懂浪漫这倒是真的,如果说对女人不感兴趣打死我也不认,据我多年与程序员共事,观察的情况来看,程序员也有非常细腻的内心世界, 准确的来说,他们应该是一群闷骚类型的群体。

你只要提到苍老师,志玲什么的,他们都是知道的。他们对事物的观察也是比较敏锐,比较到位,但是又不太善于表达的一个群体。同时他们也有这样几个标签,“屌丝”,“单身狗”(当然有的是有女朋友的哈),“宅男”,“钱多,话少,死的早”等等。不论是哪些标签,但是大多数人都不太会照料自己的生活。

对衣服不太讲究,对吃穿也不太讲究,有的更过分的就是经常去公司有眼屎,有头皮屑,有的还有脚臭味,哇哇,不说了,脑海里的画面相当凌乱了。


程序员们需要鼓励师么?

需要,站在程序员这个角度,当然是需要的了啊,这个问题我还就身边的男程序员做过了一些调查,100%都是认为需要鼓励师的。

他们可是巴不得有一堆鼓励师围着自己,享受帝王般的感觉,可是理想很丰满,现实很骨干。好多公司没有这样的条件。只能听说有一个叫 “别人的公司”有美女程序员鼓励师,自己也只能望洋兴叹罢了。


有了鼓励师之后能提高工作效率吗?

这个我的回答是能,只是个人感觉,回答不算权威,从直观上感觉出来的,鼓励师是IT行业近几年出现的一个新兴职位,算是公司给程序员们的一个福利,一个人文关怀。

一向照顾不好自己的程序员,有了鼓励师之后,生活会被调理的相对有序的,心情上相对舒畅不少,工作效率也是相对能提高不少,公司的产出也会增加不少,总体来说公司还是会赚的,要不这个职业不会存在下来,存在即道理嘛。


程序员鼓励师日常工作是啥?

\1. 程序员之间的润滑剂,可以这么说,程序员都是性格比较耿直那种,说话也是开门见山,口无遮拦那种,有时不免会产生一些意见分歧什么的,甚至还有可能打声争吵什么的,拍桌子,摔鼠标什么的,有时还可能大打出手。在这种情况下有一个美女鼓励师从中调停,能使沟通的气氛优化不少。用另一种话说,程序员鼓励师就是来打圆场的。

\2. 程序员鼓励师另一项工作就是照理程序员的日常生活,比如早晨起来帮程序员带份早餐,因为好多程序员比较喜欢睡懒觉,然后掐点上班,匆匆忙忙去上班,就没有吃早餐的习惯,程序员鼓励师会每天早起一段时间,帮程序员带份早餐,也帮他们去整理一下工位什么的,使他们的工作环境相对整洁一点,这样工作起来也相对十分舒心。另外还会帮他们收个快递,打点热水,困了就帮他们捏捏肩等这些杂事。



内容来源网络,版权归原创者所有。除非无法确认,我们都会标明作者及出处,如有侵权烦请告知,我们会立即删除并表示歉意。谢谢!

收起阅读 »

用了这个设计模式,我优化了50%表单校验代码

web
表单校验背景假设我们正在编写一个注册页面,在点击注册按钮之时,有如下几条校验逻辑:用户名不能为空密码长度不能少于6位手机号码必须符合格式常规写法:const form = document.getElementById('registerForm');form...
继续阅读 »

表单校验

背景

假设我们正在编写一个注册页面,在点击注册按钮之时,有如下几条校验逻辑:

  • 用户名不能为空

  • 密码长度不能少于6位

  • 手机号码必须符合格式

常规写法:

const form = document.getElementById('registerForm');

form.onsubmit = function () {
 if (form.userName.value === '') {
   alert('用户名不能为空');
   return false;
}

 if (form.password.value.length < 6) {
   alert('密码长度不能少于6位');
   return false;
}

 if (!/^1[3|5|8][0-9]{9}$/.test(form.phoneNumber.value)) {
   alert('手机号码格式不正确');
   return false;
}

 ...
}

这是一种很常见的代码编写方式,但它有许多缺点:

  • onsubmit 函数比较庞大,包含了很多 if-else 语句,这些语句需要覆盖所有的校验规则。

  • onsubmit 函数缺乏弹性,如果增加了一种新的校验规则,或者想把密码的长度从6改成8,我们都必须深入 obsubmit 函数的内部实现,这是违反开放-封闭原则的。

  • 算法的复用性差,如果在项目中增加了另外一个表单,这个表单也需要进行一些类似的校验,我们很可能将这些校验逻辑复制得漫天遍野。

如何避免上述缺陷,更优雅地实现表单校验呢?

策略模式介绍

💡 策略模式是一种行为设计模式, 它能让你定义一系列算法, 把它们一个个封装起来, 并使它们可以相互替换。

真实世界类比


此图源自 refactoringguru.cn/design-patt…

假如你需要前往机场。 你可以选择骑自行车、乘坐大巴或搭出租车。这三种出行策略就是广义上的“算法”,它们都能让你从家里出发到机场。你无需深入它们的内部实现细节,如怎么开大巴、公路系统如何确保你家到机场有通路等。你只需要了解这些策略的各自特点:所需要花费的时间与金钱,你就可以根据预算和时间等因素来选择其中一种策略。

更广义的“算法”

在实际开发中,我们通常会把算法的含义扩散开来,使策略模式也可以用来封装一系列的“业务规则”。只要这些业务规则指向的目标一致,并且可以被替换使用,我们就可以用策略模式来封装它们。

策略模式的组成

一个策略模式至少由两部分组成。

第一个部分是一组策略类,策略类封装了具体的算法,并负责具体的计算过程。

第二个部分是环境类 Context,Context 接受客户的请求,随后把请求委托给某一个策略类。

利用策略模式改写

定义规则(策略),封装表单校验逻辑:

const strategies = {
 isNonEmpty: function (value, errMsg) {
   if (value === '') {
     return errMsg;
  }
},
 minLenth: function (value, length, errMsg) {
   if (value.length < length) {
     return errMsg;
  }
},
 isMobile: function (value, errMsg) {
   if (!/^1[3|5|8][0-9]{9}$/.test(value)) {
     return errMsg;
  }
}
}

定义环境类 Context,进行表单校验,调用策略:

form.onsubmit = function () {
const validator = new Validator();
validator.add(form.userName, 'isNonEmpty', '用户名不能为空');
validator.add(form.password, 'minLength:6', '密码长度不能少于6位');
validator.add(form.phoneNumber, 'isMobile', '手机号码格式不正确');
const errMsg = validator.start();
if (errMsg) {
alert(errMsg);
return false;
}
}

Validator 类代码如下:

class Validator {
constructor() {
this.cache = [];
}

add(dom, rule, errMsg) {
const arr = rule.split(':');
this.cache.push(() => {
const strategy = arr.shift();
arr.unshift(dom.value);
arr.push(errMsg);
return strategies[strategy].apply(dom, arr);
})
}

start() {
for (let i = 0; i < this.cache.length; i++) {
const msg = this.cache[i]();
if (msg) return msg;
}
}
}

使用策略模式重构代码之后,我们消除了原程序中大片的条件分支语句。我们仅仅通过“配置”的方式就可以完成一个表单校验,这些校验规则也能在程序中任何地方复用,还能作为插件的形式,方便地移植到其他项目中。

策略模式优缺点

优点:

  1. 可以有效地避免多重条件选择语句。

  2. 开放-封闭原则完美支持,将算法封装在独立的 strategy 中,使得它们易于切换,易于理解,易于扩展。

  3. 可以使算法复用在系统的其他地方,避免许多重复的复制粘贴工作。

缺点:

  1. 使用策略模式会在程序中增加许多策略类或策略对象

  2. 要使用策略模式,必须了解所有的 strategy,了解它们的不同点,我们才能选择一个合适的 strategy。这是违反最少知识原则的。

策略模式适合应用场景

💡 当你想使用对象中各种不同的算法变体, 并希望能在运行时切换算法时, 可使用策略模式。

策略模式让你能够将对象关联至可以不同方式执行特定子任务的不同子对象, 从而以间接方式在运行时更改对象行为。

💡 当你有许多仅在执行某些行为时略有不同的相似类时, 可使用策略模式。

策略模式让你能将不同行为抽取到一个独立类层次结构中, 并将原始类组合成同一个, 从而减少重复代码。

💡 如果算法在上下文的逻辑中不是特别重要, 使用该模式能将类的业务逻辑与其算法实现细节隔离开来。

策略模式让你能将各种算法的代码、 内部数据和依赖关系与其他代码隔离开来。 不同客户端可通过一个简单接口执行算法, 并能在运行时进行切换。

💡 当类中使用了复杂条件运算符以在同一算法的不同变体中切换时, 可使用该模式。

策略模式将所有继承自同样接口的算法抽取到独立类中, 因此不再需要条件语句。 原始对象并不实现所有算法的变体, 而是将执行工作委派给其中的一个独立算法对象。

总结

在上述例子中,使用策略模式虽然使得程序中多了许多策略对象和执行策略的代码。但这些代码可以在应用中任意位置的表单复用,使得整个程序代码量大幅减少,且易维护。下次面对多表单校验的需求时,别再傻傻写一堆 if-else 逻辑啦,快试试策略模式!

引用资料

作者:前端唯一深情
来源:juejin.cn/post/7069395092036911140

收起阅读 »

史上最污技术解读,我竟然秒懂了!

假设你是个妹子,你有一位男朋友,于此同时你和另外一位男生暧昧不清,比朋友好,又不是恋人。你随时可以甩了现任男友,另外一位马上就能补上。这是冷备份。假设你是个妹子,同时和两位男性在交往,两位都是你男朋友。并且他们还互不干涉,独立运行。这就是双机热备份。假设你是个...
继续阅读 »

假设你是个妹子,你有一位男朋友,于此同时你和另外一位男生暧昧不清,比朋友好,又不是恋人。你随时可以甩了现任男友,另外一位马上就能补上。这是冷备份


假设你是个妹子,同时和两位男性在交往,两位都是你男朋友。并且他们还互不干涉,独立运行。这就是双机热备份

假设你是个妹子,不安于男朋友给你的安全感。在遥远的男友未知的地方,和一位男生保持着联系,你告诉他你没有男朋友,你现在处于纠结期,一旦你和你男朋友分开了,你马上可以把自己感情转移到异地男人那里去。这是异地容灾备份

假设你是个妹子,有一位男朋友,你又付了钱给一家婚姻介绍所,让他帮你留意好的资源,一旦你和你这位男朋友分开,婚姻介绍所马上给你安排资源,你感情不间断运行,这是云备份。。。。

假设你是个妹子,你怀疑男朋友对你的忠诚,在某宝购买了一个测试忠诚度的服务。这是灾难演练。友情提醒,在没有备份的情况下,切忌进行灾难演练,说不好会让你数据血本无归。

假设你是个妹子,你和男友异地恋,你每天晚上都打电话查岗,问他还爱不爱你了,这叫ping

假设你是个妹子,你的男友经常玩失踪,所以你希望时刻掌握他的行踪,你先打电话给他的好基友A,A说好基友B知道,B说好基友C知道,C说好基友D知道,D说你男朋友正在网吧打游戏,你终于知道了男友在哪儿,这叫TraceRoute

假设你是个妹子,你的男友沉迷游戏经常不接电话无故宕机,所以当你们约好下午逛街以后你要时不时的打个电话询问,看看他是不是还能正常提供服务,这叫心跳监测

假设你是个妹子,你想去逛街而你的男友A在打游戏不接电话,于是乎你把逛街的请求发给了替补男友B,从而保障服务不间断运行,这叫故障切换

假设你是个妹子,你有很多需要男朋友完成的事情,于是乎你跟A逛街旅游吃饭不可描述,而B只能陪你逛街,不能拥有全部男朋友的权利,这叫主从配置 master-slave

假设你是个妹子,你败家太厉害,以至于你的男友根本吃不消,于是呼你找了两个男朋友,一三五单号,二四六双号限行,从而减少一个男朋友所面临的压力,这叫负载均衡

假设你是个妹子并且有多个男朋友,配合心跳检测与故障切换和负载均衡将会达到极致的体验,这叫集群LVS,注意,当需求单机可以处理的情况下不建议启用集群,会造成大量资源闲置,提高维护成本。

假设你是个妹子,你的需求越来越高导致一个男朋友集群已经处理不了了,于是乎你又新增了另外几个,这叫多集群横向扩容,简称multi-cluster grid

假设你是个妹子,你的男朋友身体瘦弱从而无法满足需求,于是乎你买了很多大补产品帮你男朋友升级,从而提高单机容量,这叫纵向扩容,Scale up。切记,纵向扩容的成本会越来越高而效果越来越不明显。

假设你是个妹子,你跟男友经常出去游玩,情到深处想做点什么的时候却苦于没有tt,要去超市购买,于是乎你在你们经常去的地方都放置了tt,从而大幅度降低等待时间,这叫CDN

假设你是个妹子,你的男朋友英俊潇洒风流倜傥财大气粗对你唯一,于是乎你遭到了女性B的敌视,B会以朋友名义在周末请求你男朋友修电脑,修冰箱,占用男朋友大量时间,造成男朋友无法为你服务,这叫拒绝服务攻击,简称DOS

假设你是个妹子,你因男朋友被一位女性敌视,但是你男朋友的处理能力十分强大,处理速度已经高于她的请求速度,于是她雇佣了一票女性来轮流麻烦你的男朋友,这叫分布式拒绝服务攻击,简称DDOS

假设你是个妹子,你发现男朋友总是在处理一些无关紧要的其它请求,于是乎你给男朋友了一个白名单,要求他只处理白名单内的请求,而拒绝其它身份不明的人的要求,这叫访问控制

假设你是个妹子,你男朋友风流倜傥,你总担心他出轨,于是你在他身上安装了一个窃听器,里面内置了一些可疑女生勾搭行为的特征库,只要出现疑似被勾搭的情况,就会立刻向你报警,这叫入侵检测系统(IDS)

假设你是个妹子,你改良了上面的窃听器,当可疑女性对你男朋友做出勾搭行为的时候,立刻释放1万伏电压,把可疑人击昏,终止这次勾搭。这叫入侵防御系统(IPS)

假设你是个妹子,虽然你装了各种窃听器、报警器,可是你蓝朋友处处留情,报警器响个不停,让你应接不暇,疲于奔命,于是你搞了个装置集中收集这些出轨告警,进行综合分析,生成你男朋友的出轨报告。这叫SIEM或者SOC

假设你是个妹子,你把男朋友的出轨报告提交给他父母,得到了他们的大力支持,男友父母开始对他严加管教、限期整改,为你们的爱情保驾护航,做到合情合理、合法合规,这叫等级保护

假设你是个妹子,你离男朋友家有点远,你开车去,这叫自建专线,你打车过去,这叫租用专线,你骑摩拜单车过去,这叫SDWAN

假设你是个妹子,你和男朋友的恋爱遭到了双方家长的反对,不准双方往来,你们偷偷挖了一条隧道,便于进行幽会,这叫VPN

假设你是个妹子,你的男朋友太优秀而造人窥视,于是乎它们研究了一下你的男朋友,稍微修改了一点点生产出一个男朋友B,与你的男朋友百分制99相似,这不叫剽窃,这叫逆向工程,比如男朋友外挂。

假设你是个妹子,你要求你的男朋友坚持十分钟,然后十五分钟继而二十分钟,以测试你男朋友的极限在哪里,这叫压力测试

假设你是个妹子,为了保证你男朋友的正常运行,于是乎你每天查看他的微信微博等社交资料来寻找可能产生问题的线索,这叫数据分析

假设你是个妹子,你的男朋友属于社交活跃选手,每天的微博知乎微信生产了大量信息,你发现自己的分析速度远远低于他生的速度,于是乎你找来你的闺蜜一起分析,这叫并行计算

假设你是个妹子,你的男朋友太能折腾处处留情产生了天量的待处理信息,你和你的闺蜜们已经累趴也没赶上他创造的速度,于是你付费在知乎上找了20个小伙伴帮你一起分析,这叫云计算

假设你是个妹子,你在得到男朋友经常出没的地点后,根据酒店,敏感时间段等信息确定男朋友因该是出轨了,这叫数据挖掘

假设你是个妹子,在分析男友的数据后,得知他下午又要出去开房,于是乎你在他准备出门前给他发了个短信,问他有没有带tt,没有的话可以在我这里买,这叫精准推送,需要配合数据挖掘。另外,搜索公众号顶级科技后台回复“物联网平台”,获取一份惊喜礼包。

假如你是个妹子,你的男朋友总该出去浪而各种出问题,于是乎你租了间屋子并准备好了所有需要的东西并告诉他,以后不用找酒店了,直接来我这屋子吧,什么都准备好了,这叫容器

假如你是个妹子,你每天都要和男朋友打通一次接口,采集数据。你一天24小时不停地采,这叫实时数据采集。你决定开发新的接口来和男朋友交流,这叫虚拟化。你决定从不同的男友身上采集数据,你就是大数据中心。有一天你决定生一个宝宝,这叫大数据应用。宝宝生下来不知道是谁的,这叫大数据脱敏。但是从宝宝外观来看,黑色皮肤金色头发,这叫数据融合跨域建模。你决定把这个宝宝拿来展览收点门票,这叫大数据变现

你还有什么想要补充的吗?

版权申明:内容来源网络,版权归原创者所有。除非无法确认,我们都会标明作者及出处,如有侵权烦请告知,我们会立即删除并表示歉意。谢谢!
收起阅读 »

Android 实现卡片堆叠,钱包管理效果(带动画)

先上效果图源码 github.com/woshiwzy/Ca…实现原理:1.继承LinearLayout 2.重写onLayout,onMeasure 方法 3.利用ValueAnimator 实施动画 4.在动画回调中requestLayout 实现动画效果...
继续阅读 »


先上效果图


源码 github.com/woshiwzy/Ca…

实现原理:

1.继承LinearLayout
2.重写onLayout,onMeasure 方法
3.利用ValueAnimator 实施动画
4.在动画回调中requestLayout 实现动画效果

思路:

1.用Bounds 对象记录每一个CardView 对象的初始位置,当前位置,运动目标位置

2.点击时计算出对应的view以及可能会产生关联运动的View的运动的目标位置,从当前位置运动到目标位置,然后以这2个位置作为动画参数实施ValueAnimator动画,在动画回调中触发onLayout,达到动画的效果。

重写adView 方法,确保新添加的在这里确保所有的子view 都有一个初始化的bounds位置

   @Override
   public void addView(View child, ViewGroup.LayoutParams params) {
       super.addView(child, params);
       Bounds bounds = getBunds(getChildCount());
  }

确保每个子View的测量属性宽度填满父组件

    boolean mesured = false;
   @Override
   protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
       super.onMeasure(widthMeasureSpec, heightMeasureSpec);
       if (mesured == true) {//只需要测量一次
           return;
      }
       mesured = true;
       int childCount = getChildCount();
       int rootWidth = getWidth();
       int rootHeight = getHeight();
       if (childCount > 0) {
           View child0 = getChildAt(0);
           int modeWidth = MeasureSpec.getMode(child0.getMeasuredWidth());
           int sizeWidth = MeasureSpec.getSize(child0.getMeasuredWidth());

           int modeHeight = MeasureSpec.getMode(child0.getMeasuredHeight());
           int sizeHeight = MeasureSpec.getSize(child0.getMeasuredHeight());

           if (childCount > 0) {
               for (int i = 0; i < childCount; i++) {
                   View childView = getChildAt(i);
                   childView.measure(MeasureSpec.makeMeasureSpec(sizeWidth, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(sizeHeight, MeasureSpec.EXACTLY));
                   int top = (int) (i * (sizeHeight * carEvPercnet));
                   getBunds(i).setTop(top);
                   getBunds(i).setCurrentTop(top);
                   getBunds(i).setLastCurrentTop(top);
                   getBunds(i).setHeight(sizeHeight);
              }

          }

      }
  }

重写onLayout 方法是关键,是动画触发的主要目的,这里layout参数并不是写死的,而是计算出来的(通过ValueAnimator 计算出来的)

@Override
   protected void onLayout(boolean changed, int sl, int st, int sr, int sb) {
       int childCount = getChildCount();
       if (childCount > 0) {
           for (int i = 0; i < childCount; i++) {
               View view = getChildAt(i);
               int mWidth = view.getMeasuredWidth();
               int mw = MeasureSpec.getSize(mWidth);
               int l = 0, r = l + mw;
               view.layout(l, getBunds(i).getCurrentTop(), r, getBunds(i).getCurrentTop() + getBunds(i).getHeight());
          }
      }
  }

源码

github: github.com/woshiwzy/Ca…

作者:Sand
来源:juejin.cn/post/7073371960150851615

收起阅读 »

程序员的双十一是怎样的?

说起双十一,大部分人的印象就是买买买买买买。大家肯定认为,作为程序员的我们,肯定如你们一样过双十一很轻松惬意吧,事实上,真实情况却远非如此,换句话说,真实情况比你想象的要差很多。与你们而言是轻松惬意,对我们来说更像是一场苦战。下面呢我们来说说程序员过双十一,那...
继续阅读 »

说起双十一,大部分人的印象就是买买买买买买。大家肯定认为,作为程序员的我们,肯定如你们一样过双十一很轻松惬意吧,事实上,真实情况却远非如此,换句话说,真实情况比你想象的要差很多。与你们而言是轻松惬意,对我们来说更像是一场苦战。下面呢我们来说说程序员过双十一,那些不为人知的心酸故事。


正常人过双十一呢,都是在互相比价,互相看折扣,等待满减,然后期待快递,一切看起来稀松平常。

但是程序员所面临的状况却是完全相反,当双十一开始的那一刻,整个服务器就进入了高度运行的状态,而这背后的程序员就要解决各种各样的bug,以防止服务器的崩溃。


举一个通俗的例子,正常来说,一个房间在一定的时间内,所通行的人数和数量基本都是一定的,而且数量在平时相对较小。这时候服务器的上限如果够大的话,在不需要很多程序的情况下是可以正常运行的。


但是到了双十一这一天就不一样了,这个房间不仅人数爆满,而且出入口都异常拥挤,这就是高并发,而程序员一个秩序管理员把出现的各种问题bug都要一一解决,保持人流的正常疏散,以及整个房间内秩序的稳定。

除此之外还有各种新品的上线以及所不为人知的变动,都需要程序员加以维护以防止出现问题。

然后就有人会问,如果没有程序员做这些工作会出现什么样的状况呢?

一个明显的结果就是这个页面崩溃,你就再也不能通过这个页面购买你所心爱的东西了。这种情况就是这个需求量还没加到服务器的上限,但是因为商家的某些改动比如说满减等不及时,或者改多了改错了,不能及时的进行撤销,在这期间产生的交易将会给商家或者是购买者带来极大的损失。

双十一期间程序员生存现状。


双十一期间基本上就是进入了高度加班状态,*吃饭睡觉什么都通通在公司进行,要知道在我们这个行业本来就是一个高强高压的状态,在双十一期间更甚,真的是让我们*苦不堪言。每天都在祈祷自己的维修的页面能够正常运行 少给自己添点bug,多给自己添点头发。


但每一次购物都可能是一个程序员,用自己的头发换来的,大家一定要抓住双***十一***,珍惜这次机会啊!


作者:HelloWorld先生
来源:juejin.cn/post/7160986859512791071

收起阅读 »

B+树的生成过程 怎么去看懂B+树

前提看B+树 看不懂树结构什么意思 这一篇可以帮你理解树结构生成的过程在说B+ 树之前 需要知道 一页的大小是多少show global status like 'innodb_page_size'这个是看出 一页是 16384 也就是 16384/102...
继续阅读 »

前提

看B+树 看不懂树结构什么意思 这一篇可以帮你理解树结构生成的过程

在说B+ 树之前 需要知道 一页的大小是多少

show global status like 'innodb_page_size'


这个是看出 一页是 16384 也就是 16384/1024 = 16kb innodb中一页的大小默认是 16kb

正文

创建表结构 指定引擎为 Innodb

CREATE TABLE tree(
id int PRIMARY key auto_increment,
t_name VARCHAR(20),
t_code int
) ENGINE=INNODB

查看一下当前表的索引情况

show index from tree 

B树和B+树的显示都是BTREE 但是实际使用的B+树 但是B+树也是B树的升级版 称之为B树也是没有问题的


创建数据 这里会有一个小知识点 如果看过上一篇文章的朋友可以明白是为什么

INSERT into tree VALUES(3,"变成派大星",3);
INSERT into tree VALUES(1,"变成派大星",1);
INSERT into tree VALUES(2,"变成派大星",2);
INSERT into tree VALUES(4,"变成派大星",4);
INSERT into tree VALUES(7,"变成派大星",7);
INSERT into tree VALUES(5,"变成派大星",5);
INSERT into tree VALUES(6,"变成派大星",6);
INSERT into tree VALUES(8,"变成派大星",8);


疑问

为什么创建数据的时候数据是乱的但是在创建好数据 被拍好顺序了

基础知识

我们在寻找答案之前 想明白一些基础知识

细心的朋友可以看出来 我们插入Id 时候数据是乱的 插入进去之后 数据就自动帮我通过Id 进行排序了 这是为什么呢?接着往下看

我们如果对于B+树有点了解的话就知道B+树是每页16KB 进行数据储存 在进行数据查询的时候也是一页一页的去查询

相当于下面的数据

首先每一页都有很多数据 就像 我们平常去写分页的时候我们返回给前端的数据也会有很多属性


这个可能比较抽象 我是把他当成平常 分页查询的思想代入进去

我们可以把一页想成是一个对象

@Data
public class page {

List<UserRecords> data;

....省略其余属性
}

我们先看一下 一页数据的图是什么样子 仅仅是进行逻辑思考画的图

这里的Data 就相当于 一页中的数据区域


但是这里是有限制的 上面我们说到 一页的数据只能是16Kb 也就是 一个Page 里面的data只能16Kb 数据 当超过16Kb 就会新开一个对象相当于在进行创建树的时候增加了判断

Java代码思路模拟:


当 Page 对象的大小已经达到16Kb 就算完成这一页 把这一页放到 磁盘中等待使用就行了 到时候进行查询数据的时候会直接返回这一页 里面包含这些数据

我们回到最初的问题 为什么我们在进行插入的时候明明Id 是乱的 等到插入到数据的时候 数据就变成有序的了 我们知道 同时这个数据是根据主键进行排序的 也就Innobd 的数据储存一定是要依赖主键的 有些人会想 我就是不创建主键 他还能排序吗?

疑问二

我们在疑问一的基础上 产生出的疑问 不设置主键 Mysql怎么办

解答

InnoDB对聚簇索引处理如下:

  • 如果定义了主键,那么InnoDB会使用主键作为聚簇索引

  • 如果没有定义主键,那么会使用第一非空的唯一索引(NOT NULL and UNIQUE INDEX)作为聚簇索引

  • 如果既没有主键也找不到合适的非空索引,InnoDB会自动帮你创建一个不可见的、长度为6字节的row_id,而且InnoDB 维护了一个全局的 dictsys.row_id,所以未定义主键的表都共享该row_id,每次插入一条数据,都把全局row_id当成主键id,然后全局row_id加1

很明显,缺少主键的表,InnoDB会内置一列用于聚簇索引来组织数据。而没有建立主键的话就没法通过主键来进行索引,查询的时候都是全表扫描,小数据量没问题,大数据量就会出现性能问题。

但是,问题真的只是查询影响吗?不是的,对于生成的ROW_ID,其自增的实现来源于一个全局的序列,而所以有ROW_ID的表共享该序列,这也意味着插入的时候生成需要共享一个序列,那么高并发插入的时候为了保持唯一性就避免不了锁的竞争,进而影响性能

解答

我们看完疑问二的解答就知道 即便我们不设置主键 数据也会帮我们去生成一个默认的主键 有点像 类默认生成构造器的思想

有了主键之后呢?


为什么会自动排序 大家都知道了 其实在文章之初就会有很多人明白是为什么 大概脑子里会有答案

疑问三

为什么要进行排序

解答

我们都知道 在进行数据查找的时候 比如几个基础的查找算法的 前提都是 先进行排序 再者 List 和 Map 的一些区别肯定都很熟悉了 当然是为了更快 所以无需的Id 会对插入效率造成影响 也就是为什么很多文章说使用自增Id比UUID 或者雪花算效率高的原因 第一个是UUID他们是随机的 每次都要重新排序 甚至可能会因为排序的原因造成页数据的更换 还有就是 UUID 一般都比较长 一页是16Kb 数据越短 一页的数据就会越多 查询的速度也就比较快

这里说完为什么排序 还有一个点就是上面的页目录

疑问三

页目录的作用是什么?

通过上一篇文章可以明白 页目录的作用是减少范围


这里的第三层是数据 上面都是目录 可以增加数据的检索效率


如果没有目录我们需要去直接遍历数据区域 会降低效率 目录能帮我们缩小范围 这里 我们查询 ID = 3 我们可以通过目录知道 1 < 3 < 4 如果 在1 中没有找到对应数据 但是 因为 3 < 4 就不会接着往下查询了 直接返回空结果

当第一页没有的时候去第二页查询 不会直接跳到第二页查询


为了提高效率 当目录数据数量过多时 就会网上延伸一层树 同时可以减少磁盘的IO次数


关于所有叶子节点都处于同一深度是如何实现的?这与B+树具体的插入和删除算法有关。简单解释一下插入时的情况,根据插入值的大小,逐步向下直到对应的叶子节点。如果叶子节点关键字个数小于2t,则直接插入值或者更新卫星数据;如果插入之前叶子节点已经满了,则分裂该叶子节点成两半,并把中间值提上到父节点的关键字中,如果这导致父节点满了的话,则把该父节点分裂,如此递归向上。所以树高是一层层的增加的,叶子节点永远都在同一深度。

小总结

  • 内部节点并不存储真正的信息,而是保存其叶子节点的最小值作为索引。

  • 每次插入删除都进行更新(此时用到parent指针),保持最新状态。

  • B+ 树非叶子节点上是不存储数据的,仅存储键值

  • B+只在底层树储存数据,上层就会存储更多的键值,相应的树的阶数(节点的子节点树)就会更大,树就会更矮更胖,如此一来我们查找数据进行磁盘的 IO 次数又会再次减少,数据查询的效率也会更快。

  • B+ 树的阶数是等于键值的数量的,如果我们的 B+ 树一个节点可以存储 1000 个键值,那么 3 层 B+ 树可以存储 1000×1000×1000=10 亿个数据。

  • 一般根节点是常驻内存的,所以一般我们查找 10 亿数据,只需要 2 次磁盘 IO。

  • 因为 B+ 树索引的所有数据均存储在叶子节点,而且数据是按照顺序排列的。

  • 那么 B+ 树使得范围查找,排序查找,分组查找以及去重查找变得异常简单

  • 有心的读者可能还发现上图 B+ 树中各个页之间是通过双向链表连接的,叶子节点中的数据是通过单向链表连接的。

  • 其实上面的 B 树我们也可以对各个节点加上链表。这些不是它们之前的区别,是因为在 MySQL 的 InnoDB 存储引擎中,索引就是这样存储的。

  • 我们通过数据页之间通过双向链表连接以及叶子节点中数据之间通过单向链表连接的方式可以找到表中所有的数据。

作者:变成派大星
来源:juejin.cn/post/7162856738692005918

收起阅读 »

一个 MySQL 隐式转换的坑,差点把服务器整崩溃了

本来是一个平静而美好的下午,其他部门的同事要一份数据报表临时汇报使用,因为系统目前没有这个维度的功能,所以需要写个SQL马上出一下,一个同事接到这个任务,于是开始在测试环境拼装这条 SQL,刚过了几分钟,同事已经自信的写好了这条SQL,于是拿给DBA,到线上跑...
继续阅读 »

本来是一个平静而美好的下午,其他部门的同事要一份数据报表临时汇报使用,因为系统目前没有这个维度的功能,所以需要写个SQL马上出一下,一个同事接到这个任务,于是开始在测试环境拼装这条 SQL,刚过了几分钟,同事已经自信的写好了这条SQL,于是拿给DBA,到线上跑一下,用客户端工具导出Excel 就好了,毕竟是临时方案嘛。

就在SQL执行了之后,意外发生了,先是等了一下,发现还没执行成功,猜测可能是数据量大的原因,但是随着时间滴滴答答流逝,逐渐意识到情况不对了,一看监控,CPU已经上去了,但是线上数据量虽然不小,也不至于跑成这样吧,眼看着要跑死了,赶紧把这个事务结束掉了。

什么原因呢?查询的条件和 join 连接的字段基本都有索引,按道理不应该这样啊,于是赶紧把SQL拿下来,也没看出什么问题,于是限制查询条数再跑了一次,很快出结果了,但是结果却大跌眼镜,出来的查询结果并不是预期的。


经过一番检查之后,最终发现了问题所在,是 join 连接中有一个字段写错了,因为这两个字段有一部分名称是相同的,于是智能的 SQL 客户端给出了提示,顺手就给敲上去了。但是接下来,更让人迷惑了,因为要连接的字段是 int 类型,而写错的这个字段是 varchar 类型,难道不应该报错吗?怎么还能正常执行,并且还有预期外的查询结果?

难道是 MySQL 有 bug 了,必须要研究一下了。

复现当时的情景

假设有两张表,这两张表的结构和数据是下面这样的。

第一张 user表。

CREATE TABLE `user` (
 `id` int(11NOT NULL AUTO_INCREMENT,
 `name` varchar(50COLLATE utf8_bin DEFAULT NULL,
 `age` int(3DEFAULT NULL,
 `create_time` datetime DEFAULT NULL,
 `update_time` datetime DEFAULT NULL,
 PRIMARY KEY (`id`)
ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8 COLLATE=utf8_bin;


INSERT INTO `user` VALUES (1'张三'28'2022-09-06 07:40:56''2022-09-06 07:40:59');


第二张 order

CREATE TABLE `order` (
 `id` int(11NOT NULL AUTO_INCREMENT,
 `user_id` int(11DEFAULT NULL,
 `order_code` varchar(64COLLATE utf8_bin DEFAULT NULL,
 `money` decimal(20,0DEFAULT NULL,
 `title` varchar(255COLLATE utf8_bin DEFAULT NULL,
 `create_time` datetime DEFAULT NULL,
 `update_time` datetime DEFAULT NULL,
 PRIMARY KEY (`id`)
ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8 COLLATE=utf8_bin;


INSERT INTO `order` VALUES (12'1d90530e-6ada-47c1-b2fa-adba4545aabd'100'xxx购买两件商品''2022-09-06 07:42:25''2022-09-06 07:42:27');


目的是查看所有用户的 order 记录,假设数据量比较少,可以直接查,不考虑性能问题。

本来的 SQL 语句应该是这样子的,查询 order表中用户iduser_iduser表的记录。

select o.* from `user` u 
left JOIN `order` o on u.id = o.user_id;

但是呢,因为手抖,将 on 后面的条件写成了 u.id = o.order_code,完全关联错误,这两个字段完全没有联系,而且u.id是 int 类型,o.order_codevarchar类型。

select o.* from `user` u 
left JOIN `order` o on u.id = o.order_code;

这样的话, 当我们执行这条语句的时候,会不会查出数据来呢?

我的第一感觉是,不仅不会查出数据,而且还会报错,因为连接的这两个字段类型都不一样,值更不一样。

结果却被啪啪打脸,不仅没有报错,而且还查出了数据。


可以把这个问题简化一下,简化成下面这条语句,同样也会出现问题。

select * from `order` where order_code = 1;


明明这条记录的 order_code 字段的值是 1d90530e-6ada-47c1-b2fa-adba4545aabd,怎么用 order_code=1的条件就把它给查出来了。

根源所在

相信有的同学已经猜出来了,这里是 MySQL 进行了隐式转换,由于查询条件后面跟的查询值是整型的,所以 MySQL 将 order_code字段进行了字符串到整数类型的转换,而转换后的结果正好是 1

通过 cast函数转换验证一下结果。

select cast('1d90530e-6ada-47c1-b2fa-adba4545aabd' as unsigned);


再用两条 SQL 看一下字符串到整数类型转换的规则。

select cast('223kkk' as unsigned);
select cast('k223kkk' as unsigned);


223kkk转换后的结果是 223,而k223kkk转换后的结果是0。总结一下,转换的规则是:

1、从字符串的左侧开始向右转换,遇到非数字就停止;

2、如果第一个就是非数字,最后的结果就是0;

隐式转换的规则

当操作符与不同类型的操作数一起使用的时候,就会发生隐式转换。

例如算数运算符的前后是不同类型时,会将非数字类型转换为数字,比如 '5a'+2,就会将5a转换为数字类型,然后和2相加,最后的结果就是 7 。


再比如 concat函数是连接两个字符串的,当此函数的参数出现非字符串类型时,就会将其转换为字符串,例如concat(88,'就是发'),最后的结果就是 88就是发


MySQL 官方文档有以下几条关于隐式转换的规则:

1、两个参数至少有一个是 NULL 时,比较的结果也是 NULL,例外是使用 <=> 对两个 NULL 做比较时会返回 1,这两种情况都不需要做类型转换;

也就是两个参数中如果只有一个是NULL,则不管怎么比较结果都是 NULL,而两个 NULL 的值不管是判断大于、小于或等于,其结果都是1。

2、两个参数都是字符串,会按照字符串来比较,不做类型转换;

3、两个参数都是整数,按照整数来比较,不做类型转换;

4、十六进制的值和非数字做比较时,会被当做二进制字符串;

例如下面这条语句,查询 user 表中name字段是 0x61 的记录,0x是16进制写法,其对应的字符串是英文的 'a',也就是它对应的 ASCII 码。

select * from user where name = 0x61;

所以,上面这条语句其实等同于下面这条

select * from user where name = 'a';

可以用 select 0x61;验证一下。

5、有一个参数是 TIMESTAMP 或 DATETIME,并且另外一个参数是常量,常量会被转换为 时间戳;

例如下面这两条SQL,都是将条件后面的值转换为时间戳再比较了,只不过


6、有一个参数是 decimal 类型,如果另外一个参数是 decimal 或者整数,会将整数转换为 decimal 后进行比较,如果另外一个参数是浮点数(一般默认是 double),则会把 decimal 转换为浮点数进行比较;

在不同的数值类型之间,总是会向精度要求更高的那一个类型转换,但是有一点要注意,在MySQL 中浮点数的精度只有53 bit,超过53bit之后的话,如果后面1位是1就进位,如果是0就直接舍弃。所以超大浮点数在比较的时候其实只是取的近似值。

7、所有其他情况下,两个参数都会被转换为浮点数再进行比较;

如果不符合上面6点规则,则统一转成浮点数再进行运算

避免进行隐式转换

我们在平时的开发过程中,尽量要避免隐式转换,因为一旦发生隐式转换除了会降低性能外, 还有很大可能会出现不期望的结果,就像我最开始遇到的那个问题一样。

之所以性能会降低,还有一个原因就是让本来有的索引失效。

select * from `order` where order_code = 1;

order_code 是 varchar 类型,假设我已经在 order_code 上建立了索引,如果是用“=”做查询条件的话,应该直接命中索引才对,查询速度会很快。但是,当查询条件后面的值类型不是 varchar,而是数值类型的话,MySQL 首先要对 order_code 字段做类型转换,转换为数值类型,这时候,之前建的索引也就不会命中,只能走全表扫描,查询性能指数级下降,搞不好,数据库直接查崩了。

作者:古时的风筝
来源:juejin.cn/post/7161991470268825631

收起阅读 »

马斯克裁员过猛,推特又恳求数十名员工回公司上班

  马斯克在斥资440亿美元收购推特后,为了改变该平台持续亏损的现状便于11月4日开始了“大裁员计划”,裁掉了近3700人,约一半的员工。然而,据知情人士透露,推特发现自己可能裁错了一些人,目前他们正在联系数十名被裁的员工,请他们重返工作岗位。  彭博社报道截...
继续阅读 »

  马斯克在斥资440亿美元收购推特后,为了改变该平台持续亏损的现状便于11月4日开始了“大裁员计划”,裁掉了近3700人,约一半的员工。然而,据知情人士透露,推特发现自己可能裁错了一些人,目前他们正在联系数十名被裁的员工,请他们重返工作岗位。


  彭博社报道截图

  据彭博社11月7日报道,据两名知情人士透露,这些被要求返回公司上班的员工是被错误地开除了。这些员工工作领域和经验其实是“推特”仍然需要的,但管理层在裁掉了他们后才意识到这一点。

  彭博社称,邀请部分员工返岗的情况表明,推特的裁员显得仓促而混乱。

  目前,“推特”方面尚未就彭博社披露的这一情况做出回应。

  10月27日,马斯克完成了价值440亿美元的推特收购交易,并裁掉了推特领导层的大部分人员,包括首席执行官(CEO)、首席财务官(CFO)和两名高级法务人员,马斯克亲自担任CEO。11月4日,推特通过电子邮件宣布裁员近3700人,以此来削减成本。许多员工在访问电子邮件和Slack等公司系统遭拒后,才意识到失去了工作。

  推特安全与诚信主管约尔·罗斯(Yoel Roth)稍早前在推特上表示,该公司解雇了50%的员工,其中包括信任与安全团队成员。同时,负责沟通、内容策划、机器学习伦理的团队,以及部分产品和工程团队人员都在被解雇之列。

  在宣布大裁员当天(11月4日),马斯克在推特上写道:“关于推特裁员,不幸的是,当公司每天亏损超过400万美元时,我们别无选择。”

  今年二季度,推特实现营收11.8亿美元,同比下降1%。季度净亏损2.7亿美元,上年同期净利润为0.66亿美元。广告业务营收为10.8亿美元,同比增长2%;订阅和其他收入总计1.01亿美元,同比下降27%。

  据知情人士透露,推特裁员后仍有近3700名员工。马斯克正在敦促留在公司的员工迅速推出新功能。在某些情况下,员工甚至会在办公室留宿,以便在最后期限前完成任务。

  不过,在推特正式宣布大裁员的前一天(11月3日),Twitter员工已向旧金山联邦法院提出一起集体诉讼。员工们表示,Twitter在没有提前通知的情况下进行裁员,违反了联邦和加州法律。根据美国联邦《工人调整和再培训通知法》(WARN)的要求,大型公司在进行大规模裁员之前,要至少提前60天发出解雇通知。

  11月5日,联合国人权事务高级专员沃尔克·蒂尔克(Volker Türk)发布了一封写给推特的新老板马斯克的公开信,敦促在马斯克的带领下,“人权成为推特管理的核心”。同一天(11月5日),推特联合创始人、前首席执行官杰克·多西针对推特裁员事件在社交媒体发文称,他对所有员工所面临的艰难处境负有责任,“我把公司的规模扩张得太快了,我为此道歉”。

来源:观察者网

收起阅读 »

假ArrayList导致的线上事故......

线上事故回顾 晚饭时,当我正沉迷于排骨煲肉质鲜嫩,汤汁浓郁时,产研沟通群内发出一条消息,显示用户存在可用劵,但进去劵列表却什么也没有,并附含了一个视频。于是我一边吃了排骨,一边查看消息点开了视频,en~,视频跟描述一样。但没有系统告警,用户界面也没有明显的报错...
继续阅读 »

线上事故回顾


晚饭时,当我正沉迷于排骨煲肉质鲜嫩,汤汁浓郁时,产研沟通群内发出一条消息,显示用户存在可用劵,但进去劵列表却什么也没有,并附含了一个视频。于是我一边吃了排骨,一边查看消息点开了视频,en~,视频跟描述一样。但没有系统告警,用户界面也没有明显的报错提示,怀疑是小部分特殊情况导致的,查看消息后几秒,我直接被@来处理问题,擦,只好把外卖盒重新盖好,先去处理问题。




处理经过


通过群内产品发的用户邮箱查到了用户id,再根据接口的相关日志结合uid在日志平台进行关联查询,查到日志后,再拿到traceId进行链路查询,果不其然,发现了异常日志,如下部分日志所示


java.lang.UnsupportedOperationException: null
at java.util.AbstractList.add(AbstractList.java:148) ~[na:1.8.0_151]
at java.util.AbstractList.add(AbstractList.java:108) ~[na:1.8.0_151]

乍一看,这不是空指针嘛,so easy啊


image-20221029144806796


仔细一瞧,这UnsupportedOperationException是个什么玩意


于是,根据日志找到代码中报错的那一行,下面给大家简单模拟下


@Slf4j
@SpringBootTest
public class Demo {

public void test(Context context) {
context.getList().add("Code皮皮虾");
}

}

@Data
class Context {

private List<String> list;

}

基本操作就是拿到上下文中的List,然后再add一个元素


image-20221029145443989


讲道理,add操作是不会有问题的,有问题的还得是List,追根溯源,让我康康这个List是怎么来的


于是我一顿狂点,来到了set这个list的位置


@Slf4j
@SpringBootTest
public class Demo {

public void test(Context context) {
context.setList(Arrays.asList("Code皮皮虾"));
}

}

@Data
class Context {

private List<String> list;

}

context.setList(Arrays.asList("Code皮皮虾")); 这行看起来好像没问题啊


Arrays.asList(T... a)我们平时也会用,传入一个数组,返回出一个List没啥问题呀


image-20221029151336521


那我再试试add方法


image-20221029151419519


擦,问题复现了,还真是Arrays.asList(T... a)生成的List的add方法报错


由于线上存在问题,则先修改为以下代码上线,也就是修改为我们平时正常的写法


image-20221029151638593


上线后,观察了下日志,群里回复已解决问题,也让用户重试,发现没问题,自此问题解决。


接下来,咱们来看看为啥Arrays.asList(T... a)add方法会报错




追根溯源


进入asList方法,发现底层new了一个ArrayList,并将数组传入作为List的元素


@SafeVarargs
@SuppressWarnings("varargs")
public static <T> List<T> asList(T... a) {
return new ArrayList<>(a);
}

emm,看起来很简单啊,没问题啊,咋会报错呢


别着急,咱们在点开这个ArrayList瞅瞅


private static class ArrayList<E> extends AbstractList<E>
implements RandomAccess, java.io.Serializable
{
private static final long serialVersionUID = -2764017481108945198L;
private final E[] a;

ArrayList(E[] array) {
a = Objects.requireNonNull(array);
}
// ... 省略
}

擦,这ArrayListArrays类的一个静态内部类,不是我们经常用的java.util.ArrayList


image-20221029152405421


真是离谱他妈给离谱开门,离谱大家了,还是我源码看得太少了,呜呜呜~


继续看,这个静态内部类ArrayList继承了AbstractList,而且默认是没有实现add方法的


image-20221029152630097


也就是说调用add方法会直接调用父类,也就是AbstractListadd方法,源码点开一看,真相大白了


AbstractListadd方法直接抛出UnsupportedOperationException异常,跟线上报错一模一样!!!


public boolean add(E e) {
add(size(), e);
return true;
}

public void add(int index, E element) {
throw new UnsupportedOperationException();
}

至此,排查结束,继续吃饭去排骨去咯~~~


image-20221029153737494




小彩蛋


在使用Arrays.asList(T... a)方法时,如果只是单个元素的话,Idea会提示我们更建议Collections.singletonList


image-20221029153915201


别用!!!真的别用!!!


因为Collections.singletonList底层跟Arrays.asList(T... a)差不多


SingletonList也是继承了AbstractList的一个内部类,调用add一样会报UnsupportedOperationException异常


public static <T> List<T> singletonList(T o) {
return new SingletonList<>(o);
}

private static class SingletonList<E>
extends AbstractList<E>
implements RandomAccess, Serializable {

private static final long serialVersionUID = 3093736618740652951L;

private final E element;

SingletonList(E obj) {
element = obj;
}
}



结尾


当然咯,也不是禁止使用Collections.singletonListArrays.asList(T... a),只是我们在使用的时候一定要区分一下场景,如果创建的是一个不会再添加元素的List,那么则可以使用


但我们平时不想写那么麻烦,想要在创建的时候就把元素塞到List中,那咋办呢?


我们其实能使用google的工具类


<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>23.0</version>
</dependency>

如下写法


@Slf4j
@SpringBootTest
public class Demo {

public static void main(String[] args) {
List<String> strings = Lists.newArrayList("Code皮皮虾", "哈哈哈");
strings.add("憨憨熊");
System.out.println(strings);
}
}

其内部已经为我们封装好了,拿来即用即可,哈哈


@SafeVarargs
@CanIgnoreReturnValue // TODO(kak): Remove this
@GwtCompatible(serializable = true)
public static <E> ArrayList<E> newArrayList(E... elements) {
checkNotNull(elements); // for GWT
// Avoid integer overflow when a large array is passed in
int capacity = computeArrayListCapacity(elements.length);
ArrayList<E> list = new ArrayList<>(capacity);
Collections.addAll(list, elements);
return list;
}

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

韩国程序员面试考什么?

大家好,我是老三,在G站闲逛的时候,从每日热门上,看到一个韩国的技术面试项目,感觉有点好奇,忍不住点进去看看。 韩国的面试都考什么?有没有国内的卷呢? 可以看到,有8.k star,2.2k forks,在韩国应该算是顶流的开源项目了。 再看看贡献者,嗯,...
继续阅读 »

大家好,我是老三,在G站闲逛的时候,从每日热门上,看到一个韩国的技术面试项目,感觉有点好奇,忍不住点进去看看。


韩国的面试都考什么?有没有国内的卷呢?
瘦巴巴的老爷们


可以看到,有8.k star,2.2k forks,在韩国应该算是顶流的开源项目了。


star


再看看贡献者,嗯,明显看出来是韩国人。
贡献者


整体看一下内容。


第一大部分是计算机科学,有这些小类:



  • 计算机组成


计算机组成原理



  • 数据结构


数据结构




  • 数据库
    数据库




  • 网络




网络



  • 操作系统


操作系统


软件工程


先不说内容,韩文看起来也够呛,但是基础这一块,内容结构还是比较完整的。


第二大部分是算法:
算法


十大排序、二分查找、DFS\BFS…… 大概也是那些东西。


第三大部分是设计模式,内容不多。
设计模式


第四大部分是面试题:
面试题


终于到了比较感兴趣的部分了,点进语言部分,进去看看韩国人面试都问什么,随便抽几道看看:
面试题



  • Vector和ArrayList的区别?

  • 值传递 vs 引用传递?

  • 进程和线程的区别?

  • 死锁的四个条件是什么?

  • 页面置换算法?

  • 数据库是无状态的吗?

  • oracle和mysql的区别?

  • 说说数据库的索引?

  • OSI7层体系结构?

  • http和https的区别是?

  • DI(Dependency Injection)?

  • AOP(Aspect Oriented Programming)?

  • ……


定睛一看,有种熟悉的感觉,天下八股都一样么?


第五大部分是编程语言:
编程语言


包含了C、C++、Java、JavaScript、Python。


稍微看看Java部分,也很熟悉的感觉:



  • Java编译过程

  • 值传递 vs 引用传递

  • String & StringBuffer & StringBuilder

  • Thread使用


还有其它的Web、Linux、新技术部分就懒得再一一列出了,大家可以自己去看。


这个仓库,让我来评价评价,好,但不是特别好,为什么呢?大家可以看看国内类似的知识仓库,比如JavaGuide,那家伙,内容丰富的!和国内的相比,这个仓库还是单薄了一些——当然也可能是韩国的IT环境没那么卷,这些就够用了。


再扯点有点没的,我对韩国的IT稍微有一点点了解,通过Kakao。之前对接过Kakao的支付——Kakao是什么呢?大家可以理解为韩国的微信就行了,怎么说呢,有点离谱,他们的支付每天大概九点多到十点多要停服维护,你能想象微信支付每天有一个小时不可用吗?


也有同事对接过Kakao的登录,很简单的一个Oauth2,预估两三天搞定,结果也是各种状况,搞了差不多两周。


可能韩国的IT环境真的没有那么卷吧!


有没有对韩国IT行业、IT面试有更多了解的读者朋友呢?欢迎和老三交流。


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

封装一个有趣的 Loading 组件

组件定义 loading组件共定义4个入口参数: 前景色:绘制图形的前景色; 背景色:绘制图形的背景色; 图形尺寸:绘制图形的尺寸; 加载文字:可选,如果有文字就显示,没有就不显示。 得到的Loading组件类如下所示: class LoadingAnim...
继续阅读 »



组件定义


loading组件共定义4个入口参数:



  • 前景色:绘制图形的前景色;

  • 背景色:绘制图形的背景色;

  • 图形尺寸:绘制图形的尺寸;

  • 加载文字:可选,如果有文字就显示,没有就不显示。


得到的Loading组件类如下所示:


class LoadingAnimations extends StatefulWidget {
final Color bgColor;
final Color foregroundColor;
String? loadingText;
final double size;
LoadingAnimations(
{required this.foregroundColor,
required this.bgColor,
this.loadingText,
this.size = 100.0,
Key? key})
: super(key: key);

@override
_LoadingAnimationsState createState() => _LoadingAnimationsState();
}

圆形Loading


我们先来实现一个圆形的loading,效果如下所示。
circle_loading.gif
这里绘制了两组沿着一个大圆运动的轴对称的实心圆,半径依次减小,圆心间距随着动画时间逐步拉大。实际上实现的核心还是基于PathPathMetrics。具体实现代码如下:


_drawCircleLoadingAnimaion(
Canvas canvas, Size size, Offset center, Paint paint) {
final radius = boxSize / 2;
final ballCount = 6;
final ballRadius = boxSize / 15;

var circlePath = Path()
..addOval(Rect.fromCircle(center: center, radius: radius));

var circleMetrics = circlePath.computeMetrics();
for (var pathMetric in circleMetrics) {
for (var i = 0; i < ballCount; ++i) {
var lengthRatio = animationValue * (1 - i / ballCount);
var tangent =
pathMetric.getTangentForOffset(pathMetric.length * lengthRatio);

var ballPosition = tangent!.position;
canvas.drawCircle(ballPosition, ballRadius / (1 + i), paint);
canvas.drawCircle(
Offset(size.width - tangent.position.dx,
size.height - tangent.position.dy),
ballRadius / (1 + i),
paint);
}
}
}

其中路径比例为lengthRatio,通过animationValue乘以一个系数使得实心圆的间距越来越大 ,同时通过Offset(size.width - tangent.position.dx, size.height - tangent.position.dy)绘制了一组对对称的实心圆,这样整体就有一个圆形的效果了,动起来也会更有趣一点。


椭圆运动Loading


椭圆和圆形没什么区别,这里我们搞个渐变的效果看看,利用之前介绍过的Paintshader可以实现渐变色绘制效果。


oval_loading.gif


实现代码如下所示。


final ballCount = 6;
final ballRadius = boxSize / 15;

var ovalPath = Path()
..addOval(Rect.fromCenter(
center: center, width: boxSize, height: boxSize / 1.5));
paint.shader = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [this.foregroundColor, this.bgColor],
).createShader(Offset.zero & size);
var ovalMetrics = ovalPath.computeMetrics();
for (var pathMetric in ovalMetrics) {
for (var i = 0; i < ballCount; ++i) {
var lengthRatio = animationValue * (1 - i / ballCount);
var tangent =
pathMetric.getTangentForOffset(pathMetric.length * lengthRatio);

var ballPosition = tangent!.position;
canvas.drawCircle(ballPosition, ballRadius / (1 + i), paint);
canvas.drawCircle(
Offset(size.width - tangent.position.dx,
size.height - tangent.position.dy),
ballRadius / (1 + i),
paint);
}
}

当然,如果渐变色的颜色更丰富一点会更有趣些。


colorful_loading.gif


贝塞尔曲线Loading


通过贝塞尔曲线构建一条Path,让一组圆形沿着贝塞尔曲线运动的Loading效果也很有趣。


bezier_loading.gif


原理和圆形的一样,首先是构建贝塞尔曲线Path,代码如下。


var bezierPath = Path()
..moveTo(size.width / 2 - boxSize / 2, center.dy)
..quadraticBezierTo(size.width / 2 - boxSize / 4, center.dy - boxSize / 4,
size.width / 2, center.dy)
..quadraticBezierTo(size.width / 2 + boxSize / 4, center.dy + boxSize / 4,
size.width / 2 + boxSize / 2, center.dy)
..quadraticBezierTo(size.width / 2 + boxSize / 4, center.dy - boxSize / 4,
size.width / 2, center.dy)
..quadraticBezierTo(size.width / 2 - boxSize / 4, center.dy + boxSize / 4,
size.width / 2 - boxSize / 2, center.dy);

这里实际是构建了两条贝塞尔曲线,先从左边到右边,然后再折回来。之后就是运动的实心圆了,这个只是数量上多了,ballCount30,这样效果看着就有一种拖影的效果。


var ovalMetrics = bezierPath.computeMetrics();
for (var pathMetric in ovalMetrics) {
for (var i = 0; i < ballCount; ++i) {
var lengthRatio = animationValue * (1 - i / ballCount);
var tangent =
pathMetric.getTangentForOffset(pathMetric.length * lengthRatio);

var ballPosition = tangent!.position;
canvas.drawCircle(ballPosition, ballRadius / (1 + i), paint);
canvas.drawCircle(
Offset(size.width - tangent.position.dx,
size.height - tangent.position.dy),
ballRadius / (1 + i),
paint);
}
}

这里还可以改变运动方向,实现一些其他的效果,例如下面的效果,第二组圆球的绘制位置实际上是第一组圆球的x、y坐标的互换。


bezier_loading_transform.gif


var lengthRatio = animationValue * (1 - i / ballCount);
var tangent =
pathMetric.getTangentForOffset(pathMetric.length * lengthRatio);

var ballPosition = tangent!.position;
canvas.drawCircle(ballPosition, ballRadius / (1 + i), paint);
canvas.drawCircle(Offset(tangent.position.dy, tangent.position.dx),
ballRadius / (1 + i), paint);

组件使用


我们来看如何使用我们定义的这个组件,使用代码如下,我们用Future延迟模拟了一个加载效果,在加载过程中使用loading指示加载过程,加载完成后显示图片。


class _LoadingDemoState extends State<LoadingDemo> {
var loaded = false;

@override
void initState() {
super.initState();
Future.delayed(Duration(seconds: 5), () {
setState(() {
loaded = true;
});
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: Text('Loading 使用'),
),
body: Center(
child: loaded
? Image.asset(
'images/beauty.jpeg',
width: 100.0,
)
: LoadingAnimations(
foregroundColor: Colors.blue,
bgColor: Colors.white,
size: 100.0,
),
),
);
}

最终运行的效果如下,源码已提交至:绘图相关源码,文件名为loading_animations.dart


loading_usage.gif


总结


本篇介绍了Loading组件的封装方法,核心要点还是利用Path和动画控制绘制元素的运动轨迹来实现更有趣的效果。在实际应用过程中,也可以根据交互设计的需要,做一些其他有趣的加载动效,提高等待过程的趣味性。


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

忙里偷闲IdleHandler

在Android中,Handler是一个使用的非常频繁的东西,输入事件机制和系统状态,都通过Handler来进行流转,而在Handler中,有一个很少被人提起但是却很有用的东西,那就是IdleHandler,它的源码如下。 /** * Callback in...
继续阅读 »

在Android中,Handler是一个使用的非常频繁的东西,输入事件机制和系统状态,都通过Handler来进行流转,而在Handler中,有一个很少被人提起但是却很有用的东西,那就是IdleHandler,它的源码如下。


/**
* Callback interface for discovering when a thread is going to block
* waiting for more messages.
*/
public static interface IdleHandler {
/**
* Called when the message queue has run out of messages and will now
* wait for more. Return true to keep your idle handler active, false
* to have it removed. This may be called if there are still messages
* pending in the queue, but they are all scheduled to be dispatched
* after the current time.
*/
boolean queueIdle();
}

从注释我们就能发现,这是一个IdleHandler的静态接口,可以在消息队列没有消息时或是队列中的消息还没有到执行时间时才会执行的一个回调。


这个功能在某些重要但不紧急的场景下就非常有用了,比如我们要在主页上做一些处理,但是又不想影响原有的初始化逻辑,避免卡顿,那么我们就需要等系统闲下来的时候再来执行我们的操作,这个时候,我们就可以通过IdleHandler来进行回调。


它的使用也非常简单,代码示例如下。


Looper.myQueue().addIdleHandler {
// Do something
false
}

在Handler的消息循环中,一旦队列里面没有需要处理的消息,该接口就会回调,也就是Handler空闲的时候。


这个接口有返回值,代表是否需要持续执行,如果返回true,那么一旦Handler空闲,就会执行IdleHandler中的回调,而如果返回false,那么就只会执行一次。



当返回true时,可以通过removeIdleHandler的方式来移除循环的处理,如果是false,那么在处理完后,它自己会移除。



综上,IdleHandler的使用主要有下面这些场景。



  • 低优先级的任务处理:替换之前为了不在初始化的时候影响性能而使用的Handler.postDelayed方法,通过IdleHandler来自动获取空闲的时机。

  • Idle时循环处理任务:通过控制返回值,在系统空闲时,不断重复某个操作。


但是要注意的是,如果Handler过于繁忙,那么IdleHandler的执行时机是有可能被延迟很久的,所以,要注意一些比较重要的处理逻辑的处理时机。


在很多第三方库里面,都有IdleHandler的使用,例如LeakCanary,它对内存的dump分析过程,就是在IdleHandler中处理的,从而避免对主线程的影响。


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

项目维护几年了,为啥还这么卡?

浅谈 前段时间有个客户问我,为啥你们项目都搞了好几年了,为啥线上还会经常反馈卡顿,呃呃呃。。 于是根据自己的理解以及网上大佬们的思路总结了一篇关于卡顿优化这块的文章。 卡顿问题是一个老生常谈的话题了,一个App的好坏,卡顿也许会占一半,它直接决定了用户的留存...
继续阅读 »

浅谈


前段时间有个客户问我,为啥你们项目都搞了好几年了,为啥线上还会经常反馈卡顿,呃呃呃。。


无言以对.webp


于是根据自己的理解以及网上大佬们的思路总结了一篇关于卡顿优化这块的文章。


卡顿问题是一个老生常谈的话题了,一个App的好坏,卡顿也许会占一半,它直接决定了用户的留存问题,各大app排行版上,那些知名度较高,但是排行较低的,可能就要思考思考是不是和你app本身有关系了。


卡顿一直是性能优化中相对重要的一个点,因为其涉及了UI绘制垃圾回收(GC)、线程调度以及BinderCPU,GPU方面等JVM以及FrameWork相关知识


如果能做好卡顿优化,那么也就间接证明你对Android FrameWork的理解之深。


下面我们就来讲解下卡顿方面的知识。


什么是卡顿:


对用户来讲就是界面不流畅,滞顿。
场景如下



  • 1.视频加载慢,画面卡顿,卡死,黑屏

  • 2.声音卡顿,音画不同步。

  • 3.动画帧卡顿,交互响应慢

  • 4.滑动不跟手,列表自动更新,滚动不流畅

  • 5.网络响应慢,数据和画面展示慢、

  • 6.过渡动画生硬。

  • 7.界面不可交互,卡死,等等现象。


卡顿是如何发生的


卡顿产生的原因一般都比较复杂,如CPU内存大小,IO操作,锁操作,低效的算法等都会引起卡顿


站在开发的角度看:
通常我们讲,屏幕刷新率是60fps,需要在16ms内完成所有的工作才不会造成卡顿


为什么是16ms,不是17,18呢?


下面我们先来理清在UI绘制中的几个概念:


SurfaceFlinger:


SurfaceFlinger作用是接受多个来源的图形显示数据Surface,合成后发送到显示设备,比如我们的主界面中:可能会有statusBar,侧滑菜单,主界面,这些View都是独立Surface渲染和更新,最后提交给SF后,SF根据Zorder,透明度,大小,位置等参数,合成为一个数据buffer,传递HWComposer或者OpenGL处理,最终给显示器


SurfaceFlinger.image


在显示过程中使用到了bufferqueue,surfaceflinger作为consumer方,比如windowmanager管理的surface作为生产方产生页面,交由surfaceflinger进行合成。
sf2.image


VSYNC


Android系统每隔16ms发出VSYNC信号,触发对UI进行渲染,VSYNC是一种在PC上很早就有应用,可以理解为一种定时中断技术。


tearing 问题:


早期的 Android 是没有 vsync 机制的,CPU 和 GPU 的配合也比较混乱,这也造成著名的 tearing 问题,即 CPU/GPU 直接更新正在显示的屏幕 buffer 造成画面撕裂
后续 Android 引入了双缓冲机制,但是 buffer 的切换也需要一个比较合适的时机,也就是屏幕扫描完上一帧后的时机,这也就是引入 vsync 的原因。


早先一般的屏幕刷新率是 60fps,所以每个 vsync 信号的间隔也是 16ms,不过随着技术的更迭以及厂商对于流畅性的追求,越来越多 90fps 和 120fps 的手机面世,相对应的间隔也就变成了 11ms 和 8ms。


VSYNC信号种类:



  • 1.屏幕产生的硬件VSYNC:硬件VSYNC是一种脉冲信号,起到开关和触发某种操作的作用。

  • 2.由SurfaceFlinger将其转成的软件VSYNC信号,经由Binder传递给Choreographer


Choreographer:


编舞者用于注册VSYNC信号并接收VSYNC信号回调,当内部接收到这个信号时最终会调用到doFrame进行帧的绘制操作


Choreographer在系统中流程


Choreographer.png


如何通过Choreographer计算掉帧情况:原理就是:



通过给Choreographer设置FrameCallback,在每次绘制前后看时间差是16.6ms的多少倍,即为前后掉帧率。



使用方式如下:


//Application.java
public void onCreate() {
super.onCreate();
//在Application中使用postFrameCallback
Choreographer.getInstance().postFrameCallback(new FPSFrameCallback(System.nanoTime()));
}
public class FPSFrameCallback implements Choreographer.FrameCallback {

private static final String TAG = "FPS_TEST";
private long mLastFrameTimeNanos = 0;
private long mFrameIntervalNanos;

public FPSFrameCallback(long lastFrameTimeNanos) {
mLastFrameTimeNanos = lastFrameTimeNanos;
mFrameIntervalNanos = (long)(1000000000 / 60.0);
}

@Override
public void doFrame(long frameTimeNanos) {

//初始化时间
if (mLastFrameTimeNanos == 0) {
mLastFrameTimeNanos = frameTimeNanos;
}
final long jitterNanos = frameTimeNanos - mLastFrameTimeNanos;
if (jitterNanos >= mFrameIntervalNanos) {
final long skippedFrames = jitterNanos / mFrameIntervalNanos;
if(skippedFrames>30){
//丢帧30以上打印日志
Log.i(TAG, "Skipped " + skippedFrames + " frames! "
+ "The application may be doing too much work on its main thread.");
}
}
mLastFrameTimeNanos=frameTimeNanos;
//注册下一帧回调
Choreographer.getInstance().postFrameCallback(this);
}
}

UI绘制全路径分析:


有了前面几个概念,这里我们让SurfaceFlinger结合View的绘制流程用一张图来表达整个绘制流程:
生产者消费2.awebp



  • 生产者:APP方构建Surface的过程。

  • 消费者:SurfaceFlinger


UI绘制全路径分析卡顿原因:


接下来,我们逐个分析,看看都会有哪些原因可能造成卡顿:


1.渲染流程




  • 1.Vsync 调度:这个是起始点,但是调度的过程会经过线程切换以及一些委派的逻辑,有可能造成卡顿,但是一般可能性比较小,我们也基本无法介入;




  • 2.消息调度:主要是 doframe Message 的调度,这就是一个普通的 Handler 调度,如果这个调度被其他的 Message 阻塞产生了时延,会直接导致后续的所有流程不会被触发




  • 3.input 处理:input 是一次 Vsync 调度最先执行的逻辑,主要处理 input 事件。如果有大量的事件堆积或者在事件分发逻辑中加入大量耗时业务逻辑,会造成当前帧的时长被拉大,造成卡顿,可以尝试通过事件采样的方案,减少 event 的处理




  • 4.动画处理:主要是 animator 动画的更新,同理,动画数量过多,或者动画的更新中有比较耗时的逻辑,也会造成当前帧的渲染卡顿。对动画的降帧和降复杂度其实解决的就是这个问题;




  • 5.view 处理:主要是接下来的三大流程,过度绘制、频繁刷新、复杂的视图效果都是此处造成卡顿的主要原因。比如我们平时所说的降低页面层级,主要解决的就是这个问题;




  • 6.measure/layout/draw:view 渲染的三大流程,因为涉及到遍历和高频执行,所以这里涉及到的耗时问题均会被放大,比如我们会降不能在 draw 里面调用耗时函数,不能 new 对象等等;




  • 7.DisplayList 的更新:这里主要是 canvas 和 displaylist 的映射,一般不会存在卡顿问题,反而可能存在映射失败导致的显示问题;




  • 8.OpenGL 指令转换:这里主要是将 canvas 的命令转换为 OpenGL 的指令,一般不存在问题




  • 9.buffer 交换:这里主要指 OpenGL 指令集交换给 GPU,这个一般和指令的复杂度有关




  • 10.GPU 处理:顾名思义,这里是 GPU 对数据的处理,耗时主要和任务量和纹理复杂度有关。这也就是我们降低 GPU 负载有助于降低卡顿的原因;




  • 11.layer 合成:Android P 修改了 Layer 的计算方法 , 把这部分放到了 SurfaceFlinger 主线程去执行, 如果后台 Layer 过多, 就会导致 SurfaceFlinger 在执行 rebuildLayerStacks 的时候耗时 , 导致 SurfaceFlinger 主线程执行时间过长。
    可以选择降低Surface层级来优化卡顿。




Layer过多.image



  • 12.光栅化/Display:这里暂时忽略,底层系统行为;
    Buffer 切换:主要是屏幕的显示,这里 buffer 的数量也会影响帧的整体延迟,不过是系统行为,不能干预。


2.系统负载



  • 内存:内存的吃紧会直接导致 GC 的增加甚至 ANR,是造成卡顿的一个不可忽视的因素;

  • CPU:CPU 对卡顿的影响主要在于线程调度慢、任务执行的慢和资源竞争,比如


    • 1.降频会直接导致应用卡顿




    • 2.后台活动进程太多导致系统繁忙,cpu \ io \ memory 等资源都会被占用, 这时候很容易出现卡顿问题 ,这种情况比较常见,可以使用dumpsys cpuinfo查看当前设备的cpu使用情况:




    • 3.主线程调度不到 , 处于 Runnable 状态,这种情况比较少见




    • 4.System 锁:system_server 的 AMS 锁和 WMS 锁 , 在系统异常的情况下 , 会变得非常严重 , 如下图所示 , 许多系统的关键任务都被阻塞 , 等待锁的释放 , 这时候如果有 App 发来的 Binder 请求带锁 , 那么也会进入等待状态 , 这时候 App 就会产生性能问题 ; 如果此时做 Window 动画 , 那么 system_server 的这些锁也会导致窗口动画卡顿






锁.image



  • GPU:GPU 的影响见渲染流程,但是其实还会间接影响到功耗和发热;

  • 功耗/发热:功耗和发热一般是不分家的,高功耗会引起高发热,进而会引起系统保护,比如降频、热缓解等,间接的导致卡顿


如何监控卡顿


线下监控:


我们知道卡顿问题的原因错综复杂,但最终都可以反馈到CPU使用率上来


1.使用dumpsys cpuinfo命令


这个命令可以获取当时设备cpu使用情况,我们可以在线下通过重度使用应用来检测可能存在的卡顿点


A8S:/ $ dumpsys cpuinfo
Load: 1.12 / 1.12 / 1.09
CPU usage from 484321ms to 184247ms ago (2022-11-02 14:48:30.793 to 2022-11-02 1
4:53:30.866):
2% 1053/scanserver: 0.2% user + 1.7% kernel
0.6% 934/system_server: 0.4% user + 0.1% kernel / faults: 563 minor
0.4% 564/signserver: 0% user + 0.4% kernel
0.2% 256/ueventd: 0.1% user + 0% kernel / faults: 320 minor
0.2% 474/surfaceflinger: 0.1% user + 0.1% kernel
0.1% 576/vendor.sprd.hardware.gnss@2.0-service: 0.1% user + 0% kernel / faults
: 54 minor
0.1% 286/logd: 0% user + 0% kernel / faults: 10 minor
0.1% 2821/com.allinpay.appstore: 0.1% user + 0% kernel / faults: 1312 minor
0.1% 447/android.hardware.health@2.0-service: 0% user + 0% kernel / faults: 11
75 minor
0% 1855/com.smartpos.dataacqservice: 0% user + 0% kernel / faults: 755 minor
0% 2875/com.allinpay.appstore:pushcore: 0% user + 0% kernel / faults: 744 mino
r
0% 1191/com.android.systemui: 0% user + 0% kernel / faults: 70 minor
0% 1774/com.android.nfc: 0% user + 0% kernel
0% 172/kworker/1:2: 0% user + 0% kernel
0% 145/irq/24-70900000: 0% user + 0% kernel
0% 575/thermald: 0% user + 0% kernel / faults: 300 minor
...

2.CPU Profiler


这个工具是AS自带的CPU性能检测工具,可以在PC上实时查看我们CPU使用情况。
AS提供了四种Profiling Model配置:



  • 1.Sample Java Methods:在应用程序基于Java的代码执行过程中,频繁捕获应用程序的调用堆栈
    获取有关应用程序基于Java的代码执行的时间和资源使用情况信息。

  • 2.Trace java methods:在运行时对应用程序进行检测,以在每个方法调用的开始和结束时记录时间戳。收集时间戳并进行比较以生成方法跟踪数据,包括时序信息和CPU使用率。


请注意与检测每种方法相关的开销会影响运行时性能,并可能影响性能分析数据。对于生命周期相对较短的方法,这一点甚至更为明显。此外,如果您的应用在短时间内执行大量方法,则探查器可能会很快超过其文件大小限制,并且可能无法记录任何进一步的跟踪数据。



  • 3.Sample C/C++ Functions:捕获应用程序本机线程的示例跟踪。要使用此配置,您必须将应用程序部署到运行Android 8.0(API级别26)或更高版本的设备。

  • 4.Trace System Calls:捕获细粒度的详细信息,使您可以检查应用程序与系统资源的交互方式
    您可以检查线程状态的确切时间和持续时间,可视化CPU瓶颈在所有内核中的位置,并添加自定义跟踪事件进行分析。在对性能问题进行故障排除时,此类信息可能至关重要。要使用此配置,您必须将应用程序部署到运行Android 7.0(API级别24)或更高版本的设备。


使用方式


Debug.startMethodTracing("");
// 需要检测的代码片段
...
Debug.stopMethodTracing();

优点:**有比较全面的调用栈以及图像化方法时间显示,包含所有线程的情况


缺点:本身也会带来一点的性能开销,可能会带偏优化方向**


火焰图:可以显示当前应用的方法堆栈:


cpuprofiler.png


3.Systrace


Systrace在前面一篇分析启动优化的文章讲解过


这里我们简单来复习下:


Systrace用来记录当前应用的系统以及应用(使用Trace类打点)的各阶段耗时信息包括绘制信息以及CPU信息等


使用方式


Trace.beginSection("MyApp.onCreate_1");
alt(200);
Trace.endSection();

在命令行中:


python systrace.py -t 5 sched gfx view wm am app webview -a "com.chinaebipay.thirdcall" -o D:\trac1.html

记录的方法以及CPU中的耗时情况:


systrace1.png
优点



  • 1.轻量级,开销小,CPU使用率可以直观反映

  • 2.右侧的Alerts能够根据我们应用的问题给出具体的建议,比如说,它会告诉我们App界面的绘制比较慢或者GC比较频繁。


4.StrictModel


StrictModel是Android提供的一种运行时检测机制,用来帮助开发者自动检测代码中不规范的地方。
主要和两部分相关:
1.线程相关
2.虚拟机相关


基础代码:


private void initStrictMode() {
// 1、设置Debug标志位,仅仅在线下环境才使用StrictMode
if (DEV_MODE) {
// 2、设置线程策略
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
.detectCustomSlowCalls() //API等级11,使用StrictMode.noteSlowCode
.detectDiskReads()
.detectDiskWrites()
.detectNetwork() // or .detectAll() for all detectable problems
.penaltyLog() //在Logcat 中打印违规异常信息
// .penaltyDialog() //也可以直接跳出警报dialog
// .penaltyDeath() //或者直接崩溃
.build());
// 3、设置虚拟机策略
StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
.detectLeakedSqlLiteObjects()
// 给NewsItem对象的实例数量限制为1
.setClassInstanceLimit(NewsItem.class, 1)
.detectLeakedClosableObjects() //API等级11
.penaltyLog()
.build())
;
}
}


线上监控:


线上需要自动化的卡顿检测方案来定位卡顿,它能记录卡顿发生时的场景。


自动化监控原理


blockCan原理.png



采用拦截消息调度流程,在消息执行前埋点计时,当耗时超过阈值时,则认为是一次卡顿,会进行堆栈抓取和上报工作



首先,我们看下Looper用于执行消息循环的loop()方法,关键代码如下所示:


/**
* Run the message queue in this thread. Be sure to call
* {@link #quit()} to end the loop.
*/

public static void loop() {

...

for (;;) {
Message msg = queue.next(); // might block
if (msg == null) {
// No message indicates that the message queue is quitting.
return;

// This must be in a local variable, in case a UI event sets the logger
final Printer logging = me.mLogging;
if (logging != null) {
// 1
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ": " + msg.what);
}

...

try {
// 2
msg.target.dispatchMessage(msg);
dispatchEnd = needEndTime ? SystemClock.uptimeMillis() : 0;
} finally {
if (traceTag != 0) {
Trace.traceEnd(traceTag);
}
}

...

if (logging != null) {
// 3
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}

在Looper的loop()方法中,在其执行每一个消息(注释2处)的前后都由logging进行了一次打印输出。可以看到,在执行消息前是输出的">>>>> Dispatching to ",在执行消息后是输出的"<<<<< Finished to ",它们打印的日志是不一样的,我们就可以由此来判断消息执行的前后时间点。


具体的实现可以归纳为如下步骤



  • 1、首先,我们需要使用Looper.getMainLooper().setMessageLogging()去设置我们自己的Printer实现类去打印输出logging。这样,在每个message执行的之前和之后都会调用我们设置的这个Printer实现类。

  • 2、如果我们匹配到">>>>> Dispatching to "之后,我们就可以执行一行代码:也就是在指定的时间阈值之后,我们在子线程去执行一个任务,这个任务就是去获取当前主线程的堆栈信息以及当前的一些场景信息,比如:内存大小、电脑、网络状态等。

  • 3、如果在指定的阈值之内匹配到了"<<<<< Finished to ",那么说明message就被执行完成了,则表明此时没有产生我们认为的卡顿效果,那我们就可以将这个子线程任务取消掉。


这里我们使用blockcanary来做测试:


BlockCanary


APM是一个非侵入式的性能监控组件,可以通过通知的形式弹出卡顿信息。它的原理就是我们刚刚讲述到的卡顿监控的实现原理。
使用方式



  • 1.导入依赖


implementation 'com.github.markzhai:blockcanary-android:1.5.0'


  • Application的onCreate方法中开启卡顿监控


// 注意在主进程初始化调用
BlockCanary.install(this, new AppBlockCanaryContext()).start();


  • 3.继承BlockCanaryContext类去实现自己的监控配置上下文类


public class AppBlockCanaryContext extends BlockCanaryContext {
...
...
/**
* 指定判定为卡顿的阈值threshold (in millis),
* 你可以根据不同设备的性能去指定不同的阈值
*
* @return threshold in mills
*/

public int provideBlockThreshold() {
return 1000;
}
....
}


  • 4.在Activity的onCreate方法中执行一个耗时操作


try {
Thread.sleep(4000);
} catch (InterruptedException e) {
e.printStackTrace();
}


  • 5.结果:


可以看到一个和LeakCanary一样效果的阻塞可视化堆栈图


BlockCan效果.png


那有了BlockCanary的方法耗时监控方式是不是就可以解百愁了呢,呵呵。有那么容易就好了


根据原理:我们拿到的是msg执行前后的时间和堆栈信息,如果msg中有几百上千个方法,就无法确认到底是哪个方法导致的耗时,也有可能是多个方法堆积导致


这就导致我们无法准确定位哪个方法是最耗时的。如图中:堆栈信息是T2的,而发生耗时的方法可能是T1到T2中任何一个方法甚至是堆积导致。


apm原理2过程.image


那如何优化这块


这里我们采用字节跳动给我们提供的一个方案:基于 Sliver trace 的卡顿监控体系


Sliver trace


整体流程图


sliver2.awebp
主要包含两个方面:



  • 检测方案
    在监控卡顿时,首先需要打开 Sliver 的 trace 记录能力,Sliver 采样记录 trace 执行信息,对抓取到的堆栈进行 diff 聚合和缓存。


同时基于我们的需要设置相应的卡顿阈值,以 Message 的执行耗时为衡量。对主线程消息调度流程进行拦截,在消息开始分发执行时埋点,在消息执行结束时计算消息执行耗时,当消息执行耗时超过阈值,则认为产生了一次卡顿。



  • 堆栈聚合策略
    当卡顿发生时,我们需要为此次卡顿准备数据,这部分工作是在端上子线程中完成的,主要是 dump trace 到文件以及过滤聚合要上报的堆栈。分为以下几步:

    • 1.拿到缓存的主线程 trace 信息并 dump 到文件中。

    • 2.然后从文件中读取 trace 信息,按照数据格式,从最近的方法栈向上追溯,找到当前 Message 包含的全部 trace 信息,并将当前 Message 的完整 trace 写入到待上传的 trace 文件中,删除其余 trace 信息。

    • 3.遍历当前 Message trace,按照(Method 执行耗时 > Method 耗时阈值 & Method 耗时为该层堆栈中最耗时)为条件过滤出每一层函数调用堆栈的最长耗时函数,构成最后要上报的堆栈链路,这样特征堆栈中的每一步都是最耗时的,且最底层 Method 为最后的耗时大于阈值的 Method。




之后,将 trace 文件和堆栈一同上报,这样的特征堆栈提取策略保证了堆栈聚合的可靠性和准确性,保证了上报到平台后堆栈的正确合理聚合,同时提供了进一步分析问题的 trace 文件。


可以看到字节给的是一整套监控方案,和前面BlockCanary不同之处就在于,其是定时存储堆栈,缓存,然后使用diff去重的方式,并上传到服务器,可以最大限度的监控到可能发生比较耗时的方法。


开发中哪些习惯会影响卡顿的发生


1.布局太乱,层级太深。



  • 1.1:通过减少冗余或者嵌套布局来降低视图层次结构。比如使用约束布局代替线性布局和相对布局。

  • 1.2:用 ViewStub 替代在启动过程中不需要显示的 UI 控件。

  • 1.3:使用自定义 View 替代复杂的 View 叠加。


2.主线程耗时操作



  • 2.1:主线程中不要直接操作数据库,数据库的操作应该放在数据库线程中完成。

  • 2.2:sharepreference尽量使用apply,少使用commit,可以使用MMKV框架来代替sharepreference。

  • 2.3:网络请求回来的数据解析尽量放在子线程中,不要在主线程中进行复制的数据解析操作。

  • 2.4:不要在activity的onResume和onCreate中进行耗时操作,比如大量的计算等。

  • 2.5:不要在 draw 里面调用耗时函数,不能 new 对象


3.过度绘制


过度绘制是同一个像素点上被多次绘制,减少过度绘制一般减少布局背景叠加等方式,如下图所示右边是过度绘制的图片。


过度绘制.image


4.列表


RecyclerView使用优化,使用DiffUtil和notifyItemDataSetChanged进行局部更新等。


5.对象分配和回收优化


自从Android引入 ART 并且在Android 5.0上成为默认的运行时之后,对象分配和垃圾回收(GC)造成的卡顿已经显著降低了,但是由于对象分配和GC有额外的开销,它依然又可能使线程负载过重。 在一个调用不频繁的地方(比如按钮点击)分配对象是没有问题的,但如果在在一个被频繁调用的紧密的循环里,就需要避免对象分配来降低GC的压力。


减少小对象的频繁分配和回收操作。


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

Android 13这些权限废弃,你的应用受影响了吗?

无论是更改个人头像、分享照片、还是在电子邮件中添加附件,选择和分享媒体文件是用户最常见的操作之一。在听取了 Android 用户反馈之后,我们对应用程序访问媒体文件的方式做了一些改变。Android 13 已被废弃的权限许多用户告诉我们,文件和媒体权限让他们很...
继续阅读 »

无论是更改个人头像、分享照片、还是在电子邮件中添加附件,选择和分享媒体文件是用户最常见的操作之一。在听取了 Android 用户反馈之后,我们对应用程序访问媒体文件的方式做了一些改变。

Android 13 已被废弃的权限

许多用户告诉我们,文件和媒体权限让他们很困扰,因为他们不知道应用程序想要访问哪些文件。

在 Android 13 上废弃了 READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE 权限,用更好的文件访问方式代替这些废弃的 API。

从 Android 10 开始向共享存储中添加文件不需要任何权限。因此,如果你的 App 只在共享存储中添加文件,你可以停止在 Android 10+ 上申请任何权限。

在之前的系统版本中 App 需要申请 READ_EXTERNAL_STORAGE 权限访问设备的文件和媒体,然后选择自己的媒体选择器,这为开发者增加了开发和维护成本,另外 App 依赖于通过 ACTION_GET_CONTENT 或者 ACTION_OPEN_CONTENT 的系统文件选择器,但是我们从开发者那里了解到,它感觉没有很好地集成到他们的 App 中。


图片选择器

在 Android 13 中,我们引入了一个新的媒体工具 Android 照片选择器。该工具为用户提供了一种选择媒体文件的方法,而不需要授予对其整个媒体库的访问权限。

它提供了一个简洁界面,展示照片和视频,按照日期排序。另外在 "Albums" 页面,用户可以按照屏幕截图或下载等等分类浏览,通过指定一些用户是否仅看到照片或视频,也可以设置选择最大文件数量,也可以根据自己的需求定制照片选择器。简而言之,这个照片选择器是为私人设计的,具有干净和简洁的 UI 易于实现。


我们还通过谷歌 Play 系统更新 (2022 年 5 月 1 日发布),将照片选择器反向移植到 Android 11 和 12 上,以将其带给更多的 Android 用户。

开发一个照片选择器是一个复杂的项目,新的照片选择器不需要团队进行任何维护。我们已经在 ActivityX 1.6.0 版本中为它创建了一个 ActivityResultContract。如果照片选择器在你的系统上可用,将会优先使用照片选择器。

// Registering Photo Picker activity launcher with a max limit of 5 items
val pickMultipleVisualMedia = registerForActivityResult(PickMultipleVisualMedia(5)) { uris ->
   // TODO: process URIs
}
// Launching the photo picker (photos & video included)
pickMultipleVisualMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageAndVideo))
复制代码

如果希望添加类型进行筛选,可以采用这种方式。

// Launching the photo picker (photos only)
pickMultipleVisualMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly))
// Launching the photo picker (video only)
pickMultipleVisualMedia.launch(PickVisualMediaRequest(PickVisualMedia.VideoOnly))
// Launching the photo picker (GIF only)
pickMultipleVisualMedia.launch(PickVisualMediaRequest(PickVisualMedia.SingleMimeType("image/gif")))
复制代码

可以调用 isPhotoPickerAvailable 方法来验证在当前设备上照片选择器是否可用。

ACTION_GET_CONTENT 将会发生改变

正如你所见,使用新的照片选择器只需要几行代码。虽然我们希望所有的 Apps 都使用它,但在 App 中迁移可能需要一些时间。

这就是为什么我们使用 ACTION_GET_CONTENT 将系统文件选择器转换为照片选择器,而不需要进行任何代码更改,从而将新的照片选择器引入到现有的 App 中。


针对特定场景的新权限

虽然我们强烈建议您使用新的照片选择器,而不是访问所有媒体文件,但是您的 App 可能有一个场景,需要访问所有媒体文件(例如图库照片备份)。对于这些特定的场景,我们将引入新的权限,以提供对特定类型的媒体文件的访问,包括图像、视频或音频。您可以在文档中阅读更多关于它们的内容。

如果用户之前授予你的应用程序 READ_EXTERNAL_STORAGE 权限,系统会自动授予你的 App 访问权限。否则,当你的 App 请求任何新的权限时,系统会显示一个面向用户的对话框。

所以您必须始终检查是否仍然授予了权限,而不是存储它们的授予状态。


下面的决策树可以帮助您更好的浏览这些更改。


我们承诺在保护用户隐私的同时,继续改进照片选择器和整体存储开发者体验,以创建一个安全透明的 Android 生态系统。

新的照片选择器被反向移植到所有 Android 11 和 12 设备,不包括 Android Go 和非 gms 设备。


原文: medium.com/androiddeve…
译者:程序员 DHL
来源:
juejin.cn/post/7161230716838084616



收起阅读 »

程序员被开除,老板:“有你参与的项目全黄了!”

而我发现,之前跟我对接算法的同事好像很久都没有来了,因为我们公司程序员经常有出差的安排,所以我也没当回事。直到有一次,我在跟经理汇报工作的时候,因为算法部分还有部分功能有待完善,我就提了下之前跟我对接的算法同事。听到这话我着实有些惊讶,因为这位被开除了的算法同...
继续阅读 »

我现在所在的公司是一家专注于做机器视觉的公司,简单点解释就是像车牌号识别、工厂工件自动质检、摄像头抓拍盯梢等等这一类的功能,我们公司都是可以做的。而我在公司内是负责ERP模块的,大多数还是负责流程模块的开发。对图像进行识别处理的功能,则需要公司另外一批专攻算法、图像识别的同事去解决,然后我直接拿他们给的结果就行,这样的同事我们称之为“算法同事”。

而我发现,之前跟我对接算法的同事好像很久都没有来了,因为我们公司程序员经常有出差的安排,所以我也没当回事。直到有一次,我在跟经理汇报工作的时候,因为算法部分还有部分功能有待完善,我就提了下之前跟我对接的算法同事。

结果我经理跟我说:“他已经被老板开除了!”。

听到这话我着实有些惊讶,因为这位被开除了的算法同事我之前一直以为他在公司的地位是很高的,想不到他竟然被开除了,于是我就问经理他被开除的原因。

经理回答说:“基本上有他参与的项目都黄了,所以老板找他谈了下,后来就直接把他开除了!”。

事情经过简单地讲就是:

这个同事来了公司快三年了,也算公司的老员工了,大大小小的项目也做了不少,但是只要是他参与的项目,算法部分总是会出现各种问题。基本上解决一个旧的问题,新的问题就又来了。因为我们的项目大多数都涉及到自动化,所以,项目总是出问题的话,甲方就不会放心项目在无人值守的情况下运行,就得安排人手。这样一来,自动化的意义何在?所以很多甲方就不愿意去验收项目。

而为了项目能够顺利验收,我们公司就得派专人去客户现场调试,然后对项目中存在的问题进行反馈。这样一来,如果问题始终得不到解决,公司派去的调试人员就无法回来。用我经理的话讲:“曾经有个同事在客户那待了三个多月,硬是没解决问题,最后项目黄了才不得不回来!”。

我听我们经理这么一说,还蛮惊讶,因为我觉得之前跟那个算法同事对接的时候,感觉到他身上有一股子“大牛”程序员的气质,想不到他竟然是个“马大哈”!

老板觉得是这个算法同事的问题,白白搭进去近三年的工资,还有那么多人力物力,这都不算什么,主要是大多数的项目黄了都是因为验收不过关导致的。公司不光损失了客户,还损失了大把的时间。于是,最后决定把这个算法同事开除!

其实看到这里,也许有一部分人会觉得老板的做法有点草率,感觉是这个算法同事“背锅”了。其实我开始也这么觉得,但是最后了解了事情的原委以后,我觉得老板的想法并不是毫无根据,反而我觉得这个算法同事被开除,其实一点也不冤!

我们公司的研发部门结构还算完整,从产品设计到项目研发,然后到测试到实施其实都是有专人负责的。只不过在测试和实施阶段需要在客户现场进行,所以如果一个研发人员做事不细心的话,就会拖累测试和实施的同时。

而其他管项目中算法部分的同事在以往的项目当中,均没有出现太大纰漏,大部分项目都能够正常验收。而我们公司的项目中,算法和图像处理看似占比不多,但却是软件当中一个重要的环节,其他环节只是为了辅助算法和图像处理而已。所以,如果算法和图像处理部分没做好,即使其他部分做得再好也是徒劳的。

最后老板将问题定位在这个算法同事身上,其实也是比较合理的。

其实对于这个同事被开除,我觉得可能存在两方面的原因:

第一点就是可能这个算法同事的能力有问题导致的!简单地打个比方,比如一个摄像头在抓拍到车牌号以后,能够正确识别,那就没什么问题。但是,如果这个算法同事在分析摄像头抓拍到的图片的时候,在相机和其他设备都没有问题的情况下,始终无法稳定的获得图像识别结果,那可能就是这个同事的能力问题了。

第二点可能就是这个算法同事的做事态度问题!因为常见的图像识别目前都有公开的解决方案,在此基础上做一些个性化的修改,无可厚非。但是,当一些常见的解决方案放在他手上的时候,他如果做事不细心,总是在小的地方出纰漏,那么即使把代码照搬给他,在他手上都可能会出问题。其实这个也是很多程序员虽然能力看似很强,但对于代码总是不去自查,不先过自己那关,给人的感觉就很不靠谱,他们写的代码让人很不放心的原因。

但不管是哪种原因,被开除总是一个不太好的经历。希望这个算法同事经过这个事情也能弥补自己能力上的不足,端正自己的工作态度。

而且,我觉得公司也存在一些问题。因为近三年在一个人的手上的项目总是无法验收的时候,公司应该要做一些事情来避免这种事情的发生,并不是说把一个人开除了就能够解决根本问题了。虽然我对公司没有什么太好的建议,但是我还是希望公司在未来能够避免这种情况发生,毕竟少一单生意,对于公司和员工都是损失!

来源:http://www.163.com/dy/article/HJBBCM5R0552RA7X.html

收起阅读 »

一台服务器最大能支持多少条TCP连接

一、一台服务器最大能打开的文件数1、限制参数我们知道在Linux中一切皆文件,那么一台服务器最大能打开多少个文件呢?Linux上能打开的最大文件数量受三个参数影响,分别是:fs.file-max (系统级别参数):该参数描述了整个系统可以打开的最大文件数量。但...
继续阅读 »

一、一台服务器最大能打开的文件数

1、限制参数

我们知道在Linux中一切皆文件,那么一台服务器最大能打开多少个文件呢?Linux上能打开的最大文件数量受三个参数影响,分别是:

  • fs.file-max (系统级别参数):该参数描述了整个系统可以打开的最大文件数量。但是root用户不会受该参数限制(比如:现在整个系统打开的文件描述符数量已达到fs.file-max ,此时root用户仍然可以使用ps、kill等命令或打开其他文件描述符)

  • soft nofile(进程级别参数):限制单个进程上可以打开的最大文件数。只能在Linux上配置一次,不能针对不同用户配置不同的值

  • fs.nr_open(进程级别参数):限制单个进程上可以打开的最大文件数。可以针对不同用户配置不同的值

这三个参数之间还有耦合关系,所以配置值的时候还需要注意以下三点:

  1. 如果想加大soft nofile,那么hard nofile参数值也需要一起调整。如果因为hard nofile参数值设置的低,那么soft nofile参数的值设置的再高也没有用,实际生效的值会按照二者最低的来。

  2. 如果增大了hard nofile,那么fs.nr_open也都需要跟着一起调整(fs.nr_open参数值一定要大于hard nofile参数值)。如果不小心把hard nofile的值设置的比fs.nr_open还大,那么后果比较严重。会导致该用户无法登录,如果设置的是*,那么所有用户都无法登录

  3. 如果加大了fs.nr_open,但是是用的echo "xxx" > ../fs/nr_open命令来修改的fs.nr_open的值,那么刚改完可能不会有问题,但是只要机器一重启,那么之前通过echo命令设置的fs.nr_open值便会失效,用户还是无法登录。所以非常不建议使用echo的方式修改内核参数!!!

2、调整服务器能打开的最大文件数示例

假设想让进程可以打开100万个文件描述符,这里用修改conf文件的方式给出一个建议。如果日后工作里有类似的需求可以作为参考。

  • vim /etc/sysctl.conf

fs.file-max=1100000 // 系统级别设置成110万,多留点buffer
fs.nr_open=1100000 // 进程级别也设置成110万,因为要保证比 hard nofile大
  • 使上面的配置生效sysctl -p

  • vim /etc/security/limits.conf

// 用户进程级别都设置成100完
soft nofile 1000000
hard nofile 1000000

二、一台服务器最大能支持多少连接

我们知道TCP连接,从根本上看其实就是client和server端在内存中维护的一组【socket内核对象】(这里也对应着TCP四元组:源IP、源端口、目标IP、目标端口),他们只要能够找到对方,那么就算是一条连接。那么一台服务器最大能建立多少条连接呢?

  • 由于TCP连接本质上可以理解为是client-server端的一对socket内核对象,那么从理论上将应该是【2^32 (ip数) * 2^16 (端口数)】条连接(约等于两百多万亿)

  • 但是实际上由于受其他软硬件的影响,我们一台服务器不可能能建立这么多连接(主要是受CPU和内存限制)。

如果只以ESTABLISH状态的连接来算(这些连接只是建立,但是不收发数据也不处理相关的业务逻辑)那么一台服务器最大能建立多少连接呢?以一台4GB内存的服务器为例!

  • 这种情况下,那么能建立的连接数量主要取决于【内存的大小】(因为如果是)ESTABLISH状态的空闲连接,不会消耗CPU(虽然有TCP保活包传输,但这个影响非常小,可以忽略不计)

  • 我们知道一条ESTABLISH状态的连接大约消耗【3.3KB内存】,那么通过计算得知一台4GB内存的服务器,【可以建立100w+的TCP连接】(当然这里只是计算所有的连接都只建立连接但不发送和处理数据的情况,如果真实场景中有数据往来和处理(数据接收和发送都需要申请内存,数据处理便需要CPU),那便会消耗更高的内存以及占用更多的CPU,并发不可能达到100w+)

上面讨论的都是进建立连接的理想情况,在现实中如果有频繁的数据收发和处理(比如:压缩、加密等),那么一台服务器能支撑1000连接都算好的了,所以一台服务器能支撑多少连接还要结合具体的场景去分析,不能光靠理论值去算。抛开业务逻辑单纯的谈并发没有太大的实际意义

服务器的开销大头往往并不是连接本身,而是每条连接上的数据收发,以及请求业务逻辑处理!!!

三、一台客户端机器最多能发起多少条连接

我们知道客户端每和服务端建立一个连接便会消耗掉client端一个端口。一台机器的端口范围是【0 ~ 65535】,那么是不是说一台client机器最多和一台服务端机器建立65535个连接呢(这65535个端口里还有很多保留端口,可用端口可能只有64000个左右)?

由TCP连接的四元组特性可知,只要四元组里某一个元素不同,那么就认为这是不同的TCP连接。所以需要分情况讨论:

  • 【情况一】、如果一台client仅有一个IP,server端也仅有一个IP并且仅启动一个程序,监听一个端口的情况下,client端和这台server端最大可建立的连接条数就是 65535 个。

    • 因为源IP固定,目标IP和端口固定,四元组中唯一可变化的就是【源端口】,【源端口】的可用范围又是【0 ~ 65535】,所以一台client机器最大能建立65535个连接

  • 【情况二】、如果一台client有多个IP(假设客户端有 n 个IP),server端仅有一个IP并且仅启动一个程序,监听一个端口的情况下,一台client机器最大能建立的连接条数是:n * 65535

    • 因为目标IP和端口固定,有 n 个源IP,四元组中可变化的就是【源端口】+ 【源IP】,【源端口】的可用范围又是【0 ~ 65535】,所以一个IP最大能建立65535个连接,那么n个IP最大就能建立n * 65535个连接了

    • 以现在的技术,给一个client分配多个IP是非常容易的事情,只需要去联系你们网管就可以做到。

  • 【情况三】、如果一台client仅有一个IP,server端也仅有一个IP但是server端启动多个程序,每个程序监听一个端口的情况下(比如server端启动了m个程序,监听了m个不同端口),一台client机器最大能建立的连接数量为:65535 * m

    • 源IP固定,目标IP固定,目标端口数量为m个,可变化的是源端口,而源端口变化范围是【0 ~ 65535】,所以一台client机器最大能建立的TCP连接数量是 65535 * m

  • 其余情况类推,但是客户端的可用端口范围一般达不到65535个,受内核参数net.ipv4.ip_local_port_range限制,如果要修改client所能使用的端口范围,可以修改这个内核参数的值。

  • 所以,不光是一台server端可以接收100w+个TCP连接,一台client照样能发出100w+个连接

四、其他

  • 三次握手里socket的全连接队列长度由参数net.core.somaxconn来控制,默认大小是128,当两台机器离的非常近,但是建立连接的并发又非常高时,可能会导致半连接队列或全连接队列溢出,进而导致server端丢弃握手包。然后造成client超时重传握手包(至少1s以后才会重传),导致三次握手连接建立耗时过长。我们可以调整参数net.core.somaxconn来增加去按连接队列的长度,进而减小丢包的影响

  • 有时候我们通过 ctrl + c方式来终止了某个进程,但是当重启该进程的时候发现报错端口被占用,这种问题是因为【操作系统还没有来得及回收该端口,等一会儿重启应用就好了】

  • client程序在和server端建立连接时,如果client没有调用bind方法传入指定的端口,那么client在和server端建立连接的时候便会自己随机选择一个端口来建立连接。一旦我们client程序调用了bind方法传入了指定的端口,那么client将会使用我们bind里指定的端口来和server建立连接。所以不建议client调用bind方法,bind函数会改变内核选择端口的策略

    public static void main(String[] args) throws IOException {
       SocketChannel sc = SocketChannel.open();
    // 客户端还可以调用bind方法
       sc.bind(new InetSocketAddress("localhost", 9999));
       sc.connect(new InetSocketAddress("localhost", 8080));
       System.out.println("waiting..........");
    }
  • 在Linux一切皆文件,当然也包括之前TCP连接中说的socket。进程打开一个socket的时候需要创建好几个内核对象,换一句直白的话说就是打开文件对象吃内存,所以Linux系统基于安全角度考虑(比如:有用户进程恶意的打开无数的文件描述符,那不得把系统搞奔溃了),在多个位置都限制了可打开的文件描述符的数量

  • 内核是通过【hash表】的方式来管理所有已经建立好连接的socket,以便于有请求到达时快速的通过【TCP四元组】查找到内核中对应的socket对象

    • 在epoll模型中,通过红黑树来管理epoll对象所管理的所有socket,用红黑树结构来平衡快速删除、插入、查找socket的效率

五、相关实际问题

在网络开发中,很多人对一个基础问题始终没有彻底搞明白,那就是一台机器最多能支撑多少条TCP连接。不过由于客户端和服务端对端口使用方式不同,这个问题拆开来理解要容易一些。

注意,这里说的是客户端和服务端都只是角色,并不是指某一台具体的机器。例如对于我们自己开发的应用程序来说,当他响应客户端请求的时候,他就是服务端。当他向MySQL请求数据的时候,他又变成了客户端。

1、"too many open files" 报错是怎么回事,该如何解决

你在线上可能遇到过too many open files这个错误,那么你理解这个报错发生的原理吗?如果让你修复这个错误,应该如何处理呢?

  • 因为每打开一个文件(包括socket),都需要消耗一定的内存资源。为了避免个别进程不受控制的打开了过多文件而让整个服务器奔溃,Linux对打开的文件描述符数量有限制。如果你的进程触发到内核的限制,那么"too many open files" 报错就产生了

  • 可以通过修改fs.file-maxsoft nofilefs.nr_open这三个参数的值来修改进程能打开的最大文件描述符数量

    • 需要注意这三个参数之间的耦合关系!

2、一台服务端机器最大究竟能支持多少条连接

因为这里要考虑的是最大数,因此先不考虑连接上的数据收发和处理,仅考虑ESTABLISH状态的空连接。那么一台服务端机器上最大可以支持多少条TCP连接?这个连接数会受哪些因素的影响?

  • 在不考虑连接上数据的收发和处理的情况下,仅考虑ESTABLISH状态下的空连接情况下,一台服务器上最大可支持的TCP连接数量基本上可以说是由内存大小来决定的。

  • 四元组唯一确定一条连接,但服务端可以接收来自任意客户端的请求,所以根据这个理论计算出来的数字太大,没有实际意义。另外文件描述符限制其实也是内核为了防止某些应用程序不受限制的打开【文件句柄】而添加的限制。这个限制只要修改几个内核参数就可以加大。

  • 一个socket大约消耗3kb左右的内存,这样真正制约服务端机器最大并发数的就是内存,拿一台4GB内存的服务器来说,可以支持的TCP连接数量大约是100w+

3、一条客户端机器最大究竟能支持多少条连接

和服务端不同的是,客户端每次建立一条连接都需要消耗一个端口。在TCP协议中,端口是一个2字节的整数,因此范围只能是0~65535。那么客户单最大只能支持65535条连接吗?有没有办法突破这个限制,有的话有哪些办法?

  • 客户度每次建立一条连接都需要消耗一个端口。从数字上来看,似乎最多只能建立65535条连接。但实际上我们有两种办法破除65535这个限制

    • 方式一,为客户端配置多IP

    • 方式二,分别连接不同的服务端

  • 所以一台client发起百万条连接是没有任何问题的

4、做一个长连接推送产品,支持1亿用户需要多少台机器

假设你是系统架构师,现在老板给你一个需求,让你做一个类似友盟upush这样的产品。要在服务端机器上保持一个和客户端的长连接,绝大部分情况下连接都是空闲的,每天也就顶多推送两三次左右。总用户规模预计是1亿。那么现在请你来评估一下需要多少台服务器可以支撑这1亿条长连接。

  • 对于长连接推送模块这种服务来说,给客户端发送数据只是偶尔的,一般一天也就顶多一两次。绝大部分情况下TCP连接都是空闲的,CPU开销可以忽略

  • 再基于内存来考虑,加色服务器内存是128G的,那么一台服务器可以考虑支持500w条并发。这样会消耗掉大约不到20GB内存用来保存这500w条连接对应的socket。还剩下100GB以上的内存来应对接收、发送缓冲区等其他的开销足够了。所以,一亿用户,仅仅需要20台服务器就差不多够用了!

参考:《深入理解Linux网络》

作者:文攀
来源:juejin.cn/post/7162824884597293086

收起阅读 »

环信Discord场景创意编程大赛开启招募啦!

这一届年轻人最喜欢用的社交软件有什么?很多人会想到TikTok、Snapchat、Instagram,但很多人却忽略了一个隐藏王者“Discord”。2021年微软以120亿美元提出收购Discord惨遭拒绝引爆了行业讨论,相较于TikTok、Instagra...
继续阅读 »


这一届年轻人最喜欢用的社交软件有什么?很多人会想到TikTok、Snapchat、Instagram,但很多人却忽略了一个隐藏王者“Discord”。2021年微软以120亿美元提出收购Discord惨遭拒绝引爆了行业讨论,相较于TikTok、Instagram这种大众开放社交平台,Discord似乎更为私密和闭环,但相对于Messenger、WhatsApp这种点对点的IM工具,Discord又更加开放且丰富。就是这样一款富有魔力的社交产品,以1.6亿月活、2亿多美元收入、150亿美元估值,一时风头无二,同时也成为了国内和出海企业竞相模仿的对方!




国内首届Discord场景创意编程大赛由环信联合华为主办,将以 环信超级社区SDK 应用场景创意为方向,号召开发者聚焦「类 Discord」热门场景,发挥天马行空的创意,探索超大型万人社区更多行业实践,根据大赛提供的创意channel、场景功能、UI素材包等,设计和开发出创意兼具实用性的场景应用。

环信超级社区(Circle)是一款基于环信 IM 打造的类 Discord 实时社区应用场景方案,支持社区(Server)、频道(Channel) 和子区(Thread) 三层结构。一个 App 下可以有多个社区,同时支持陌生人/好友单聊。用户可创建和管理自己的社区,在社区中设置和管理频道将一个话题下的子话题进行分区,在频道中根据感兴趣的某条消息发起子区讨论,实现万人实时群聊,满足超大规模用户的顺畅沟通需求。旨在一站式帮助客户快速开发和构建稳定超大规模用户即时通讯的"类Discord超级社区",作为构建实时交互社区的第一选择,环信超级社区自发布以来很好地满足了类 Discord 实时社区业务场景的客户需求,并支持开发者结合业务需要灵活自定义产品形态,目前已经广泛服务于国内头部出海企业以及海外东南亚和印度企业。











参赛作品需基于超级社区接口或超级社区Demo进行开发,使用至少一项基于超级社区的功能,参考下方功能建议,作品至少包含2个与场景相关的新功能。
以下场景及功能供参考:


  • 参赛作品需提供图片、代码、文字说明等,参赛作品的 Readme 文档中应包含详细的项目介绍、使用说明;
  • 参赛作品须保证原创性,不违反相关法律法规,不存在任何法律或合规风险,作品中使用的素材(包括但不限于开源代码、图片、视频等)不存在版权问题;
  • 参赛作品的源代码均以 MIT 协议对外进行开源;

报名须知:
  • 超级社区功能需联系平台进行开通,请入群联系活动负责人或环信商务进行开通;
  • 参赛者需在规定时间内报名,不限个人或团队参赛,团队参赛请指定一名联系人,活动期间可随时报名和提交作品;
  • 请注意作品截止提交时间,将作品内容在规定时间内推送至官方仓库,否则将不参与作品评选







  • Fork 本次创意赛官方作品提交仓库至你的个人 GitHub 仓库;
  • Clone 你的 GitHub 仓库代码「https://github.com/你的GitHub名/EasemobCircle-2022-Innovation-Challenge」;
  • 在本地的 「Innovation-Challenge」文件夹下新创建个人项目文件夹,命名格式为“项目序号-队伍名-作品名”,将参赛作品的相关文件与代码放置在该文件夹内,勿要“感染”其他文件或者文件夹;
  • 最后通过 Pull Request 将作品内容推送至官方仓库;
  • Review 通过,合入本次比赛的主分支。



编程赛交流群:




- 注册环信:

https://console.easemob.com/user/register


- 超级社区介绍:

https://www.easemob.com/product/im/circle


- 超级社区SDK 集成文档:

https://docs-im.easemob.com/ccim/circle/overview


-超级社区Demo体验

https://www.easemob.com/download/demo#discord


- 技术支持社区:

https://www.imgeek.org/topic/27685


-大赛报名:

https://www.wjx.top/vm/eGIlI4V.aspx


-下载设计资源:

https://www.figma.com/community/file/1169587212861993948


-作品提交:

https://github.com/easemob/EasemobCircle-2022-Innovation-Challenge




欢迎访问大赛官网:https://www.easemob.com/event/discord/

收起阅读 »

因面试提到 Handler 机制后,引发连环炮轰(我已承受不来~)

因业务繁忙,有段时间没有和大家进行技术分享了,今日特此抽出时间先来分享!!!今日头条面试题:讲讲ThreadLocal底层原理和Handler的关系竟然提到了Handler机制就不得不提到这几大将了:Handler,Looper,MessageQueue,Me...
继续阅读 »

因业务繁忙,有段时间没有和大家进行技术分享了,今日特此抽出时间先来分享!!!

今日头条面试题:讲讲ThreadLocal底层原理和Handler的关系

竟然提到了Handler机制就不得不提到这几大将了:Handler,Looper,MessageQueue,Message。延伸重点ThreadLocal!!!

当UI的主线程在初始化第一个 Handler时,就会通过ThreadLocal创建一个Looper,该Looper与UI主线程一一对应。而使用ThreadLocal的目的是保证每一个线程只创建唯一一个Looper。

Looper初始化的时候会创建一个 消息队列MessageQueue。至此,主线程、消息循环、消息队列之间的关系是1:1:1。

Handler、Looper、MessageQueue的初始化流程如下图所示:Hander持有对UI主线程消息队列MessageQueue和消息循环Looper的引用,子线程可以通过 Handler 将消息发送到UI线程的消息队列MessageQueue中。

二问:主线程为啥不用初始化looper呢?

那是因为looper早在ActivityThread初始化的时候就声明好了,可以直接拿来用的。通过分析源码我们知道MessageQueue在Looper中,Looper初始化后作为对象丢给了Handler,并且又存在了 ThreadLocal 里面,ThreadLocal 对象的话作为Key又存在了ThreadLocalMap,ThreadLocalMap对象是Thread里面的一个属性值,也就是说Looper作为桥梁连接了Handler与Looper所在的线程。

三问:Handler机制有了解过没?跟我说说?

在理解Handler 机制前,我们需要先搞懂ThreadLocal。

ThreadLocal叫做线程变量,意思是ThreadLocal中填充的变量属于当前线程,该变量对其他线程而言是隔离的,也就是说该变量是当前线程独有的变量。ThreadLocal为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量。

ThreadLocal的原理

想搞懂原理那就得先从源码入手开始分析。

我们先从set方法看起:

从上面的代码不难看出,ThreadLocal  set赋值的时候首先会获取当前线程thread,并获取thread线程中的ThreadLocalMap属性。如果map中属性不为空,则直接更新value值,如果map中找不到此ThreadLocal对象,则在threadLocalMap创建一个,并将value值初始化。显然ThreadLocal对象存的值是根据线程走的!

那么ThreadLocalMap又是什么呢,还有createMap又是怎么做的,我们继续往下看。

首先第一步我们得要知道这个东西

每个Thread有一个属性,类型是ThreadLocalMap,从代码不难看出ThreadLocalMap是ThreadLocal的内部静态类。它是与线程所绑定联系在一起的,可以看成一个线程只有一个ThreadLocalMap,知道这一点我们再往下看。

ThreadLocalMap的构成主要是用Entry来保存数据 ,而且还是继承的弱引用。在Entry内部使用ThreadLocal对象作为key,使用我们设置的value作为value。

get的话就比较简单了就是获取当前线程的ThreadLocalMap属性值,在获取Map中对应ThreadLocal对象的value值并返回。

 ThreadLocal总结一下就是:每个线程Thread自身有一个属性ThreadLocalMap,然后ThreadLocalMap是一个键值对,它的key值是ThreadLocal对象,它的value值则是我们想要保存处理的数据值。getMap是找到对应线程的ThreadLocalMap属性值,然后通过判断可以初始化或者更新数值。

ThreadLocal分析完了我们接着来看Handler吧。

因为主线程在ActivityThread的main方法中已经创建了Looper,所以主线程使用Handler时可以直接new;子线程使用Handler时需要调用Looper的prepare和loop方法才能进行使用,否则会抛出异常。所以我们从Looper的prepare来分析。

Looper 提供了 Looper.prepare() 方法来创建 Looper ,并且会借助 ThreadLocal 来实现与当前线程的绑定功能。Looper.loop() 则会开始不断尝试从 MessageQueue 中获取 Message , 并分发给对应的 Handler,也就是说 Handler 跟线程的关联是靠 Looper 来实现的。

Looper.loop() 负责对消息的分发,也是和prepare配套使用的方法,两者缺一不可。

msg.target是个啥呢,我们追到Message里面不难发现其实它就是我们发送消息的Handler,这写法是不是很聪明,当从MessageQueen中捞出Message后,我们就能直接调用Handler的dispatchMessage,然后就会走到我们的Handler的handleMessage了。直接上源码:

处理分析完了,我们看个简单点的,消息发送吧。Handler 提供了一些列的方法让我们来发送消息,如 send()系列 post()系列 。不过不管我们调用什么方法,最终都会走到 MessageQueue的enqueueMessage(Message,long) 方法。也就是实现了消息的发送,将Message插入到我们的MessageQueue中。

注意:dispatchMessage() 方法针对 Runnable 的方法做了特殊处理,如果是 ,则会直接执行 Runnable.run() 。(判断依据是上述msg.callback !=null这句)

MessageQueue是个单链表。

MessageQueue里消息按时间排序

MessageQueue的next()是个堵塞方法

总结分析:

Looper.loop() 是个死循环,会不断调用 MessageQueue.next() 获取 Message ,并调用 msg.target.dispatchMessage(msg) 回到了 Handler 来分发消息,以此来完成消息的回调。

四问:Handler什么会出现内存泄漏问题呢?

Handler使用是用来进行线程间通信的,所以新开启的线程是会持有Handler引用的,如果在Activity等中创建Handler,并且是非静态内部类的形式,就有可能造成内存泄漏。

非静态内部类是会隐式持有外部类的引用,所以当其他线程持有了该Handler,线程没有被销毁,则意味着Activity会一直被Handler持有引用而无法导致回收。

MessageQueue中如果存在未处理完的Message,Message的target也是对Activity等的持有引用,也会造成内存泄漏。

解决的办法:

使用静态内部类 + 弱引用的方式: 静态内部类不会持有外部类的的引用,当需要引用外部类相关操作时,可以通过弱引用还获取到外部类相关操作,弱引用是不会造成对象该回收回收不掉的问题,不清楚的可以查阅JAVA的几种引用方式的详细说明。

在外部类对象被销毁时,将MessageQueue中的消息清空。

五问:Looper死循环为什么不会导致应用卡死?

对于线程即是一段可执行的代码,当可执行代码执行完成后,线程生命周期便该终止了,线程退出。而对于主线程,我们是绝不希望会被运行一段时间,自己就退出,那么如何保证能一直存活呢?简单做法就是可执行代码是能一直执行下去的,死循环便能保证不会被退出,例如,binder线程也是采用死循环的方法,通过循环方式不同与Binder驱动进行读写操作,当然并非简单地死循环,无消息时会休眠。但这里可能又引发了另一个问题,既然是死循环又如何去处理其他事务呢?通过创建新线程的方式。真正会卡死主线程的操作是在回调方法onCreate/onStart/onResume等操作时间过长,会导致掉帧,甚至发生ANR,looper.loop本身不会导致应用卡死。

六问:主线程的死循环一直运行是不是特别消耗CPU资源呢?

其实不然,这里就涉及到Linux pipe/epoll机制,简单说就是在主线程的MessageQueue没有消息时,便阻塞在Loop的queue.next()中的nativePollOnce()方法里,此时主线程会释放CPU资源进入休眠状态,直到下个消息到达或者有事务发生,通过往pipe管道写端写入数据来唤醒主线程工作。这里采用的epoll机制,是一种IO多路复用机制,可以同时监控多个描述符,当某个描述符就绪(读或写就绪),则立刻通知相应程序进行读或写操作,本质同步I/O,即读写是阻塞的。 所以说,主线程大多数时候都是处于休眠状态,并不会消耗大量CPU资源。

好了,这轮面试中问道的Handler 就问了这么多了,大家可以好好的吸收一下,如有什么疑议欢迎在评论区进行讨论!!!


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

Flutter桌面开发-项目工程化框架搭建

前言 在本专栏前面的几篇文章中,我们对桌面应用实现了可定制的窗口化,适配了多种分辨率的屏幕,并且实现了小组件 “灵动岛”。前面的文章算是一些基础建设的搭建,这篇文章我将基于状态管理库GetX,搭建一个成熟完善的,可投入生产开发的项目架构。这也是我们后面继续开发...
继续阅读 »

前言


在本专栏前面的几篇文章中,我们对桌面应用实现了可定制的窗口化适配了多种分辨率的屏幕,并且实现了小组件 “灵动岛”。前面的文章算是一些基础建设的搭建,这篇文章我将基于状态管理库GetX,搭建一个成熟完善的,可投入生产开发的项目架构。这也是我们后面继续开发桌面应用的基础。


搭建原则


此次项目框架的搭建,完全基于GetX库。虽说之前我也分析过GetX的优势和弊端,但对于我们一个开源的项目,GetX这种“全家桶”库再适合不过啦。同时还会提供在Windows开发过程中一些区别于移动端开发的小技巧。


GetX全家桶的搭建


为啥说GetX是全家桶,因为它不仅可以满足MVVM的状态管理,还能满足:国际化、路由配置、网络请求等等,着实方便,而且亲测可靠!GetX


1. 国际化


GetX提供了应用的顶层入口GetMaterialApp,这个控件封装了Flutter的MaterialApp,我们只需要按照GetX给定的规则传入多语言的配置即可。配置也是非常简单的,只需要在类中提供get声明的map对象即可。Map的key由语言的代码和国家地区组成无需处理系统语言环境变化等事件。


import 'package:get/get.dart';

class Internationalization extends Translations {
@override
Map<String, Map<String, String>> get keys => {
'en_US': {
'appName': 'Flutter Windows',
'hello':'Hello World!'
},
'zh_CN': {
'appName': 'Flutter桌面应用',
'hello':'你好,世界!'
},
'zh_HK': {
'appName': 'Flutter桌面應用',
'hello':'你好,世界!'
},
};
}

2. 路由配置


如果没有使用GetX,路由管理很大情况是使用Fluro,大量的define、setting、handle真的配置的很枯燥。在GetX中,你只需要配置路由名称和对应的Widget即可。


class RouteConfig {
/// home模块
static const String home = "/home/homePage";

/// 我的模块
static const String mine = "/mine/myPage";

static final List<GetPage> getPages = [
GetPage(name: home, page: () => HomePage()),
GetPage(name: mine, page: () => MinePage()),
];
}

至于参数,可以直接像web端的url一样,使用?、&传递。

同时GetX也提供了路由跳转的方式,相比Flutter Navigator2提供的api,GetX的路由跳转明显更加方便,可以脱离context进行跳转,我们可以在VM层随意处理路由,这点真的很爽。


// 跳转到我的页面
Get.toNamed('${RouteConfig.mine}?userId=123&userName=karl');

// 我的页面接收参数
String? userName = Get.parameters['userName'];

3. GetX状态管理


状态管理才是GetX的重头戏,GetX中实现的Obx机制,能非常轻量级的帮我们定点刷新。Obx是通过创建定向的Stream,来局部setState的。而且作者还提供了ide的插件,我们来创建一个GetX的页面。

image.png
通过插件快捷创建之后我们可以得到:logic、state、view的分层结构,通过logic绑定数据和视图,并且实现数据驱动UI刷新。
image.png
当然,通过Obx的方式会触发创建较多的Stream,有时使用update()来主动刷新也是可以的。

关于GetX的状态管理,有个细节要提示下:



  • 如果listview.build下的item都有自己的状态管理,那么每个item需要向logic传递自己的tag才能产生各自的Obx stream;


Get.put(SwiperItemLogic(), tag: model.key);

GetX相对其他的状态管理,最重点是基于Stream实现了真正的跨组件通信,包括兄弟组件;只需要保证logic层Put一次,其余组件去Find即可直接更新logic的值,实现视图刷新。


4. 网络请求


在网络请求上,GetX的封装其实并没有dio来的好,Get_connect插件集成了REST API请求和GraphQL规范,我们开发过程中其实不会两者都用。虽然GraphQL提高了健壮性,但在定义请求对象的时候,往往会增加一些工作量,特别是对于小项目。



  1. 我们可以先创建一个基础内容提供,完成通用配置;


/// 网络请求基类,配置公共属性
class BaseProvider extends GetConnect {
@override
void onInit() {
super.onInit();
httpClient.baseUrl = Api.baseUrl;
// 请求拦截
httpClient.addRequestModifier<void>((request) {
request.headers['accept'] = 'application/json';
request.headers['content-type'] = 'application/json';
return request;
});

// 响应拦截;甚至已经把http status都帮我们区分好了
httpClient.addResponseModifier((request, response) {
if (response.isOk) {
return response;
} else if (response.unauthorized) {
// 账户权限失效
}
return response;
});
}
}


  1. 然后按照模块化去配置请求,提高可维护性。


import 'package:get/get.dart';

import 'base_provider.dart';

/// 按照模块去制定网络请求,数据源模块化
class HomeProvider extends BaseProvider {
// get会带上baseUrl
Future<Response> getHomeSwiper(int id) => get('home/swiper');
}

日志记录


日志我们采用Logger进行记录,桌面端一般使用txt文件格式。以时间命名,天为单位建立日志文件即可。如果有需要,也可以加一些定时清理的逻辑。

我们需要重写下LogOutput的方法,把颜色和表情都去掉,避免编码错误,然后实现下单例。


Logger? logger;

Logger get appLogger => logger ??= Logger(
filter: CustomerFilter(),
printer: PrettyPrinter(
printEmojis: false,
colors: false,
methodCount: 0,
noBoxingByDefault: true),
output: LogStorage(),
);

class LogStorage extends LogOutput {
// 默认的日志文件过期时间,以小时为单位
static const _logExpiredTime = 72;

/// 日志文件操作对象
File? _file;

/// 日志目录
String? logDir;

/// 日志名称
String? logName;

LogStorage({this.logDir, this.logName});

@override
void destroy() {
deleteExpiredLogs(_logExpiredTime);
}

@override
void init() async {
deleteExpiredLogs(_logExpiredTime);
}

@override
void output(OutputEvent event) async {
_file ??= await createFile(logDir, logName);
String now = CommonUtils.formatDateTime(DateTime.now());
String version = packageInfo.version;
_file!.writeAsStringSync('>>>> $version $now [${event.level.name}]\n',
mode: FileMode.writeOnlyAppend);

for (var line in event.lines) {
_file!.writeAsStringSync('${line.toString()}\n',
mode: FileMode.writeOnlyAppend);
debugPrint(line);
}
}

Future<File> createFile(String? logDir, String? logName) async {
logDir = logDir;
logName = logName;
if (logDir == null) {
Directory documentsDirectory = await getApplicationSupportDirectory();
logDir =
"${documentsDirectory.path}${Platform.pathSeparator}${Constants.logDir}";
}
logName ??=
"${CommonUtils.formatDateTime(DateTime.now(), format: 'yyyy-MM-dd')}.txt";

String path = '$logDir${Platform.pathSeparator}$logName';
debugPrint('>>>>日志存储路径:$path');
File file = File(path);
if (!file.existsSync()) {
file = await File(path).create(recursive: true);
}
return file;
}

吐司提示


吐司用的还是fluttertoast的方式。但是windows的实现比较不一样,在windows上的实现toast提示只能显示在应用窗体内。


static FToast fToast = FToast().init(Get.overlayContext!);

static void showToast(String text, {int? timeInSeconds}) {
// 桌面版必须使用带context的FToast
if (Platform.isWindows || Platform.isMacOS) {
cancelToastForDesktop();
fToast.showToast(
toastDuration: Duration(seconds: timeInSeconds ?? 3),
gravity: ToastGravity.BOTTOM,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(25.0),
color: const Color(0xff323334),
),
child: Text(
text,
style: const TextStyle(
fontSize: 16,
color: Colors.white,
),
),
),
);
} else {
cancelToast();
Fluttertoast.showToast(
msg: text,
gravity: ToastGravity.BOTTOM,
timeInSecForIosWeb: timeInSeconds ?? 3,
backgroundColor: const Color(0xff323334),
textColor: Colors.white,
fontSize: 16,
);
}
}

一些的小技巧


代码注入,更简洁的实现单例和构造引用


在开发过程中,我还会使用get_itinjectable来生成自动单例、工厂构造函数等类。好处是让代码更为简洁可靠,便于维护。下面举个萌友上报的例子,初始配置只需要在create中写入即可,然后业务方调用只需要使用GetIt.get<YouMengReport>().report()上报就行了。这就是一个非常完整的单例,使用维护都很方便。


/// 声明单例,并且自动初始化
@singleton(signalsReady: true)
class YouMengReport {
/// 声明工厂构造函数,自动初始化的时候会自动自行create方法
@factoryMethod
create() {
// 这里可以做一些初始化工作
}
report() {}
}

json生成器


由于不支持反射,导致Flutter的json解析一直为人诟病。因此使用json_serializable会是一个不错的选择,其原理是通过AOP注解,帮我们生成json编码和解析。通过插件Json2json_serializable可以帮我们自动生成dart文件,如下图:
image.png


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

Dagger2四种使用方式

1. 什么是Dagger2 Dagger2是用来解决对象之间的高度耦合的框架。介绍Dagger2四种方式实现。 具体的四种使用场景 0. 配置 app模块下的build.gradle dependencies { //...其他依赖信息 im...
继续阅读 »

1. 什么是Dagger2


Dagger2是用来解决对象之间的高度耦合的框架。介绍Dagger2四种方式实现。


image.png


具体的四种使用场景


0. 配置


app模块下的build.gradle
dependencies {
//...其他依赖信息
implementation 'com.google.dagger:dagger:2.44'
annotationProcessor 'com.google.dagger:dagger-compiler:2.44'
}

1. 第一种实现方式


自己实现的代码可以在代码的构造函数上通过@Inject修饰,实现代码注入,如下:


image.png


1. 实现细节


public class SingleInstance {

@Inject
User user;

@Inject
public SingleInstance() {
DaggerApplicationComponent.create().inject(this);
Log.i("TAG", "SingleInstance === " + user);
}
}


public class User {
//自定义的类通过@Inject注解修饰,在使用的地方使用@Inject初始化时,dagger会去找被@Inject修饰的类进行初始化。
@Inject
public User() {

}
}


@Component
public interface ApplicationComponent {
//指的将对象注入到那个地方,这里指的注入到MainActivity
void inject(MainActivity activity);
//指定将对象注入到什么位置,这里值注入到SingleInstance中
void inject(SingleInstance activity);
}


//TODO 被注入的类
public class MainActivity extends AppCompatActivity {
@Inject
SingleInstance instance;
@Inject
User user;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
DaggerApplicationComponent.create().inject(this);
Log.i("TAG", "instance == " + instance);
Log.i("TAG", "user == " + user);
}
}

2. 小结


  1. 在需要注入的地方通过@Inject注解进行标记。

  2. @Component修饰的是一个接口,并在该接口中提供一个将对象注入到哪里的方法。示例中为注入MainActivitySingleInstance两个类中。

  3. 需要在自定义类的构造函数通过@Inject注解进行修饰。

  4. 关键一步是将Component注入到目标类中,在需要实现注入的地方调用由@Component修饰的接口生成的对应的类。名字规则为Dagger+被@Component修饰的接口名代码为DaggerApplicationComponent.create().inject(this);


2. 第二种实现方式


第三方库可以Module+主Component的方式实现。该种方式解决对第三方库的初始化。


image.png


@Module
public class HttpModule {

//TODO 在module提供实例
@NetScope
@Provides
String providerUrl() {
return "http://www.baidu.com";
}

@NetScope
@Provides
GsonConverterFactory providerGsonConverterFactory() {
return GsonConverterFactory.create();
}

@NetScope
@Provides
RxJava2CallAdapterFactory providerRxjava2CallAdapterFactory() {
return RxJava2CallAdapterFactory.create();
}

/**
* 1. 这里可以通过作用域限制实例使用的范围,这里的作用域必须和自己的Component使用的一样。
* 2. 一个Component只能有一个作用域修饰符。
*
* @return
*/
@NetScope
@Provides
OkHttpClient providerOkHttpClient() {
return new OkHttpClient.Builder()
//TODO 这里可以添加各种拦截器
.build();
}

@NetScope
@Provides
Retrofit providerRetrofit(String url,
OkHttpClient okHttpClient,
GsonConverterFactory gsonConverterFactory,
RxJava2CallAdapterFactory rxJava2CallAdapterFactory) {
return new Retrofit.Builder()
.baseUrl(url)
.client(okHttpClient)
.addConverterFactory(gsonConverterFactory)
.addCallAdapterFactory(rxJava2CallAdapterFactory)
.build();
}

@NetScope
@Provides
ApiService providerApiService(Retrofit retrofit) {
return retrofit.create(ApiService.class);
}
}



@Module
public class DaoModule {
@Name01
@Provides
Student providerStudent01() {
return new Student("tom01");
}

@Name02
@Provides
Student providerStudent02() {
return new Student("tom02", 20);
}

@Named("threeParam")
@Provides
Student providerStudent03() {
return new Student("tom03", 20, 1);
}

}


//NetScope这里的直接是为了限制其作用域
@NetScope
@Component(modules = {HttpModule.class,DaoModule.class})//装载多个Module
public interface HttpComponent {
void inject(MainActivity activity);
}

//被注入的类
public class MainActivity extends AppCompatActivity {


@Inject
ApiService apiService;

@Inject
ApiService apiService2;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
DaggerHttpComponent.builder()
.build()
.inject(this);
Log.i(" TAG", "apiService === " + apiService);
Log.i(" TAG", "apiService2 === " + apiService2);

}
}


小结


  1. 通过Module提供了创建实例的方法。这里的@NetScope用于限制了创建实例的作用域。

  2. providerRetrofit方法中的参数是由其他几个方法提供的,如果没有提供@Providers修饰的方法提供实例外,Dagger2会去找被@Inject修饰的构造方法创建实例,如果都没有提供方法参数的实例则会报错。

  3. 如果相同的类型创建不同的对象可以使用@Named注解解决.

  4. @NetScope注解用来使用限定作用域。


3. 第三种实现方式


通过多组件实现相互依赖并提供实例。


image.png


//提供了全局的组件
@Component(modules = UserModule.class)
public interface ApplicationComponent {
User createUser();//通过这种方式需要将UserModule中的参数暴露出来,需要提供要暴露出来的相关方法。
// HttpComponent createHttpComponent();
}

//提供基础的实例
@BaseHttpScope
@Component(modules = BaseHttpModule.class, dependencies = ApplicationComponent.class)
public interface BaseHttpComponent {

//TODO 在其他子组件需要依赖时,需要将对应的方法暴露出来
Retrofit providerRetrofit();
}



//这里依赖了BaseHttpComponent组件中提供的实例
@NetScope
@Component(modules = ApiServiceModule.class, dependencies = BaseHttpComponent.class)
public interface ApiServiceComponent {
void inject(MainActivity activity);
}


//具体的实现注入的地方的初始化
public class MainActivity extends AppCompatActivity {

@Inject
ApiService apiService;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

DaggerApiServiceComponent.builder()
.baseHttpComponent(DaggerBaseHttpComponent.builder()
.applicationComponent(DaggerApplicationComponent.create())
.build()).build()
.inject(this);
Log.i("TAG", "apiService == " + apiService);

}
}

小结



  1. 这里需要注意的是ApiServiceComponent组件依赖BaseHttpComponent组件,BaseHttpComponent组件以来的是ApplicationComponent组件,结果就是在注入的地方如


 DaggerApiServiceComponent.builder()
.baseHttpComponent(DaggerBaseHttpComponent.builder()
.applicationComponent(DaggerApplicationComponent.create())
.build()).build()
.inject(this);


  1. 需要被子组件使用的实例需要在XXComponent中暴露出来,如果没有暴露出来会去找被@Inject修饰的构造方法创建实例,如果没有找到则会报错。不能提供对应的实例。


4. 第四种实现方式


跟上面多个Component提供创建实例时,如果在子组件中需要使用父组件中提供的实例,父组件需要手动暴露出提供对应实例的方法。


image.png



@Module
public class ApiServiceModule {
//TODO 在module提供实例
@NetScope
@Provides
String providerUrl() {
return "http://www.baidu.com";
}

@NetScope
@Provides
GsonConverterFactory providerGsonConverterFactory() {
return GsonConverterFactory.create();
}

@NetScope
@Provides
RxJava2CallAdapterFactory providerRxjava2CallAdapterFactory() {
return RxJava2CallAdapterFactory.create();
}

/**
* 1. 这里可以通过作用域限制实例使用的范围,这里的作用域必须和自己的Component使用的一样。
* 2. 一个Component只能有一个作用域修饰符。
*
* @return
*/
@NetScope
@Provides
OkHttpClient providerOkHttpClient() {
return new OkHttpClient.Builder()
//TODO 这里可以添加各种拦截器
.build();
}

@NetScope
@Provides
Retrofit providerRetrofit(String url,
OkHttpClient okHttpClient,
GsonConverterFactory gsonConverterFactory,
RxJava2CallAdapterFactory rxJava2CallAdapterFactory) {
return new Retrofit.Builder()
.baseUrl(url)
.client(okHttpClient)
.addConverterFactory(gsonConverterFactory)
.addCallAdapterFactory(rxJava2CallAdapterFactory)
.build();
}

@NetScope
@Provides
ApiService providerApiService(Retrofit retrofit) {
return retrofit.create(ApiService.class);
}
}

@NetScope
@Subcomponent(modules = ApiServiceModule.class)
public interface ApiServiceComponent {
@Subcomponent.Factory
interface Factory {
ApiServiceComponent create();
}

void inject(MainActivity activity);
}

@Module(subcomponents = ApiServiceComponent.class)
public class ApiServiceComponentModule {

}


@Component(modules = {ApiServiceComponentModule.class})
public interface ApplicationComponent {
//这里在主组件中需要把子组件暴露出来
ApiServiceComponent.Factory createApiServiceComponent();
}


public class MainActivity extends AppCompatActivity {

@Inject
ApiService apiService;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

DaggerApplicationComponent.create()
.createApiServiceComponent()
.create()
.inject(this);
Log.i("TAG", "apiService == " + apiService);
}
}

小结



  1. 这里需要注意的一点是SubComponent装载到主组件中时需要使用一个Module链接。


其他几个使用方法Binds和Lazy 参考demo app05



@Module
public abstract class DaoModule {
/**
* 这里的参数类型决定了调用哪一个实现方法
*
* @param impl01
* @return
*/
@Binds
public abstract BInterface bindBInterface01(Impl01 impl01);


@Provides
static Impl01 providerBInterface01() {
return new Impl01();
}

@Provides
static Impl02 providerBInterface02() {
return new Impl02();
}

}

public class MainActivity extends AppCompatActivity {

@Inject
BInterface impl01;

@Inject
Lazy<BInterface> impl02;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
DaggerApplicationComponent.create()
.inject(this);

Log.i("TAG", "impl01 === " + impl01.getClass().getSimpleName());

Log.i("TAG", "impl02 === " + impl02.getClass().getSimpleName());

Log.i("TAG", "impl02 impl02.get()=== " + impl02.get().getClass().getSimpleName());
}
}


参考资料


demo地址


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

Android 13这些权限废弃,你的应用受影响了吗?

无论是更改个人头像、分享照片、还是在电子邮件中添加附件,选择和分享媒体文件是用户最常见的操作之一。在听取了 Android 用户反馈之后,我们对应用程序访问媒体文件的方式做了一些改变。 Android 13 已被废弃的权限 许多用户告诉我们,文件和媒体权限让他...
继续阅读 »

无论是更改个人头像、分享照片、还是在电子邮件中添加附件,选择和分享媒体文件是用户最常见的操作之一。在听取了 Android 用户反馈之后,我们对应用程序访问媒体文件的方式做了一些改变。


Android 13 已被废弃的权限


许多用户告诉我们,文件和媒体权限让他们很困扰,因为他们不知道应用程序想要访问哪些文件。


在 Android 13 上废弃了 READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE 权限,用更好的文件访问方式代替这些废弃的 API。


从 Android 10 开始向共享存储中添加文件不需要任何权限。因此,如果你的 App 只在共享存储中添加文件,你可以停止在 Android 10+ 上申请任何权限。


在之前的系统版本中 App 需要申请 READ_EXTERNAL_STORAGE 权限访问设备的文件和媒体,然后选择自己的媒体选择器,这为开发者增加了开发和维护成本,另外 App 依赖于通过 ACTION_GET_CONTENT 或者 ACTION_OPEN_CONTENT 的系统文件选择器,但是我们从开发者那里了解到,它感觉没有很好地集成到他们的 App 中。


System File Picker using ACTION_OPEN_CONTENT


图片选择器


在 Android 13 中,我们引入了一个新的媒体工具 Android 照片选择器。该工具为用户提供了一种选择媒体文件的方法,而不需要授予对其整个媒体库的访问权限。


它提供了一个简洁界面,展示照片和视频,按照日期排序。另外在 "Albums" 页面,用户可以按照屏幕截图或下载等等分类浏览,通过指定一些用户是否仅看到照片或视频,也可以设置选择最大文件数量,也可以根据自己的需求定制照片选择器。简而言之,这个照片选择器是为私人设计的,具有干净和简洁的 UI 易于实现。



我们还通过谷歌 Play 系统更新 (2022 年 5 月 1 日发布),将照片选择器反向移植到 Android 11 和 12 上,以将其带给更多的 Android 用户。


开发一个照片选择器是一个复杂的项目,新的照片选择器不需要团队进行任何维护。我们已经在 ActivityX 1.6.0 版本中为它创建了一个 ActivityResultContract。如果照片选择器在你的系统上可用,将会优先使用照片选择器。


// Registering Photo Picker activity launcher with a max limit of 5 items
val pickMultipleVisualMedia = registerForActivityResult(PickMultipleVisualMedia(5)) { uris ->
// TODO: process URIs
}
// Launching the photo picker (photos & video included)
pickMultipleVisualMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageAndVideo))

如果希望添加类型进行筛选,可以采用这种方式。


// Launching the photo picker (photos only)
pickMultipleVisualMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly))
// Launching the photo picker (video only)
pickMultipleVisualMedia.launch(PickVisualMediaRequest(PickVisualMedia.VideoOnly))
// Launching the photo picker (GIF only)
pickMultipleVisualMedia.launch(PickVisualMediaRequest(PickVisualMedia.SingleMimeType("image/gif")))

可以调用 isPhotoPickerAvailable 方法来验证在当前设备上照片选择器是否可用。


ACTION_GET_CONTENT 将会发生改变


正如你所见,使用新的照片选择器只需要几行代码。虽然我们希望所有的 Apps 都使用它,但在 App 中迁移可能需要一些时间。


这就是为什么我们使用 ACTION_GET_CONTENT 将系统文件选择器转换为照片选择器,而不需要进行任何代码更改,从而将新的照片选择器引入到现有的 App 中。



针对特定场景的新权限


虽然我们强烈建议您使用新的照片选择器,而不是访问所有媒体文件,但是您的 App 可能有一个场景,需要访问所有媒体文件(例如图库照片备份)。对于这些特定的场景,我们将引入新的权限,以提供对特定类型的媒体文件的访问,包括图像、视频或音频。您可以在文档中阅读更多关于它们的内容。


如果用户之前授予你的应用程序 READ_EXTERNAL_STORAGE 权限,系统会自动授予你的 App 访问权限。否则,当你的 App 请求任何新的权限时,系统会显示一个面向用户的对话框。


所以您必须始终检查是否仍然授予了权限,而不是存储它们的授予状态。



下面的决策树可以帮助您更好的浏览这些更改。



我们承诺在保护用户隐私的同时,继续改进照片选择器和整体存储开发者体验,以创建一个安全透明的 Android 生态系统。


新的照片选择器被反向移植到所有 Android 11 和 12 设备,不包括 Android Go 和非 gms 设备。



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

一些有用的技巧帮助你开发 flutter

前言 你好今天给你带来了些有用的建议,让我们开始吧。 正文 1. ElevatedButton.styleFrom 快速样式 你是否厌倦了 container 里那些乏味的 decorations ,想要轻松实现这个美丽的按钮别担心,我给你准备了一些魔法密码...
继续阅读 »

前言


你好今天给你带来了些有用的建议,让我们开始吧。



正文


1. ElevatedButton.styleFrom 快速样式


你是否厌倦了 container 里那些乏味的 decorations ,想要轻松实现这个美丽的按钮别担心,我给你准备了一些魔法密码。



示例代码


SizedBox(
height: 45,
width: 200,
child: ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
elevation: 10, shape: const StadiumBorder()),
child: const Center(child: Text('Elevated Button')),
),
),


示例代码


SizedBox(
height: 45,
width: 60,
child: ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
elevation: 10, shape: const CircleBorder()),
child: const Center(child: Icon(Icons.add)),
),
),

2. TextInputAction.next 焦点切换


你们都知道“焦点节点”。这基本上是用来识别 Flutter 的“焦点树”中特定的 TextField 这允许您在接下来的步骤中将焦点放在 TextField 上。但你知道 Flutter 提供了一个神奇的一行代码同样..。


示例代码


Padding(
padding: const EdgeInsets.all(30.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextFormField(
textInputAction: TextInputAction.next,
),
const SizedBox(
height: 50,
),
TextFormField(
textInputAction: TextInputAction.next,
),
const SizedBox(
height: 50,
),
TextFormField(
textInputAction: TextInputAction.done,
),
const SizedBox(
height: 50,
),
],
),
);

3. 设置 status Bar 状态栏颜色


你的状态栏颜色破坏了你的页面外观吗? 让我们改变它..。



示例代码


void main() {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.transparent, // transparent status bar
));
runApp(const MyApp());
}

4.设置 TextStyle.height 段落间距


如果您在页面上显示了一个段落(例如: 产品描述、关于我们的内容等) ,并且它看起来不如 xd 设计那么好!使用这个神奇的代码,使它有吸引力和顺利。



示例代码


 Text(
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
style: TextStyle(
fontSize: 17.0,
height: 1.8,
),
)

5. 设置文字 3D


想让你的“标题”文字更有吸引力吗? 给它一个有阴影的 3D 效果..。



示例代码


Center(
child: Text(
'Hello, world!',
style: TextStyle(
fontSize: 50,
color: Colors.pink,
fontWeight: FontWeight.w900,
shadows: <Shadow>[
const Shadow(
offset: Offset(4.0, 4.0),
blurRadius: 3.0,
color: Color.fromARGB(99, 64, 64, 64),
),
Shadow(
offset: const Offset(1.0, 1.0),
blurRadius: 8.0,
color: Colors.grey.shade100),
],
),
),
)

6. vscode 插件 Pubspec Assist


你知道 Flutter extensions 吗?你当然是! !我正在分享我最喜欢的 Flutter extensions..。



Pubspec Assist 是一个 VisualStudio 代码扩展,它允许您轻松地向 Dart 和 Flutter 项目的 Pubspec 添加依赖项。Yaml 不需要你编辑。你必须试试。


7. 应用 app 尺寸控制


Application 大小很重要!应用程序大小的 Flutter 应用程序是非常重要的。当它是一个更大的应用程序时,尺寸变得更加重要,因为你需要在设备上有更多的空间。更大的应用程序下载时间也更长。它扩大了 Flutter 应用程序,可以是两个、三个或更多的安装尺寸。因此,在 Android 平台上减小 Flutter 应用程序的大小是非常重要的。


这里有一些减小 Flutter 应用程序大小的技巧 ~ ~ ~



  1. 减小应用程序元素的大小

  2. 压缩所有 JPEG 和 PNG 文件

  3. 使用谷歌字体

  4. 在 Android Studio 中使用 Analyzer

  5. 使用命令行中的分析 Analyzer

  6. 减少资源数量和规模

  7. 使用特定的 Libraries


谢谢你的阅读...


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

哈啰 Quark Design 正式开源,下一代跨技术栈前端组件库

官网:quark-design.hellobike.comQuark(夸克) Design 是由哈啰平台 UED 和增长&电商前端团队联合打造的一套面向移动端的跨框架 UI 组件库。与业界第三方组件库不一样,Quark Design 底层基于 Web ...
继续阅读 »

Quark Design 是什么?

官网:quark-design.hellobike.com

github:github.com/hellof2e/qu…

Quark(夸克) Design 是由哈啰平台 UED 和增长&电商前端团队联合打造的一套面向移动端的跨框架 UI 组件库。与业界第三方组件库不一样,Quark Design 底层基于 Web Components 实现,它能做到一套代码,同时运行在各类前端框架中。

Quark Design 历经一年多的开发时间,已在集团内部大量业务中得到验证,本着“共创、共建、共享”的开源精神,我们于即日起将 Quark 正式对外开源!Github地址:github.com/hellof2e/qu… (求star、求关注~😁)


注:文档表现/样式参考了HeadlessUI/nutui/vant等。

Quark Design 与现有主流组件库的区别是什么?

Quark(夸克)有别于业界主流的移动端组件库,Quark 能同时运行在业界所有前端框架/无框架工程中,做到真正的技术栈无关 !我们不一样,:)

  • 不依赖技术栈(eg. Vue、React、Angular等)

  • 不依赖技术栈版本(eg. Vue2.x、Vue3.x)

  • 全新的Api设计(eg. 弹窗的打开属性由传统的 Visible 调整为符合浏览器原生弹窗的 open等)

  • 公司前端技术生态项目技术栈多时,保持视觉/交互统一

  • 完全覆盖您所需要的各类通用组件

  • 支持按需引用

  • 详尽的文档和示例

  • 支持定制主题

性能优势-优先逻辑无阻塞

我们以对 React 组件的 Web Components 化为例,一个普通的 React 组件在初次执行时需要一次性走完所有必须的节点逻辑,而这些逻辑的执行都同步占用在 js 的主线程上,那么当你的页面足够复杂时,一些非核心逻辑就将会阻塞后面的核心逻辑的执行。

比如首次加载时,你的页面中有一个复杂的交互组件,交互组件中又包含 N多逻辑和按钮等小组件,此时页面的首次加载不应该优先去执行这些细节逻辑,而首要任务应当是优先渲染出整体框架或核心要素,而后再次去完善那些不必要第一时间完成的细节功能。 例如一些图像处理非常复杂,但你完全没必要在第一时间就去加载它们。

当我们使用 Web Components 来优化 React的时候,这个执行过程将会变得简洁的多,比如我们注册了一个复杂的逻辑组件,在 React 执行时只是执行了一个 createElement 语句,创建它只需要 1-2 微秒即可完成,而真正的逻辑并不在同时执行,而是等到“核心任务”执行完再去执行,甚至你可以允许它在合适的时机再去执行。

我们也可以简单的理解为,部分逻辑在之后进行执行然后被 render 到指定 id 的 Div 中的,那么为什么传统的组件为什么不能这么做呢?而非得 Web Components 呢?那就不得不提到它所包含的另一个技术特性:Shadow DOM


组件隔离(Shadow Dom)

Shadow DOM 为自定义的组件提供了包括 CSS、事件的有效隔离,不再担心不同的组件之间的样式、事件污染了。 这相当于为自定义组件提供了一个天然有效的保护伞。

Shadow DOM 实际上是一个独立的子 DOM Tree,通过有限的接口和外部发生作用。 我们都知道页面中的 DOM 节点数越多,运行时性能将会越差,这是因为 DOM 节点的相互作用会时常在触发重绘(Repaint)和重排(reflow)时会关联计算大量 Frame 关系。


而对 CSS 的隔离也将加快选择器的匹配速度,即便可能是微秒级的提升,但是在极端的性能情况下,依然是有效的手段。

Quark 能为你带来什么?

提效降本几乎是所有企业的主旋律,Quark 本身除了提供了通用组件之外,我们还为大家提供了开箱即用的 CLI,可以让大家在直接在日常开发中开发横跨多个技术栈/框架的业务组件。比如一个相同样式的营销弹窗,可以做到:

  • 同时运行在不同技术栈(Angular、Vue、React等)的前端工程中

  • 同时运行在不同版本的技术栈中,比如能同时运行在 Vue2.x、Vue3.x 中

CLI 内部 Beta 版本目前初版已完成,github 地址:github.com/hellof2e/qu…

适合场景:前端团队想发布一个独立的组件或npm包,让其他各类技术栈的工程使用,从而达到提效降本的目的。

npm i -g @quarkd/quark-cli
npx create-quark


相关链接

作者:Allan91
来源:juejin.cn/post/7160483409691672606

收起阅读 »

一个瞬间让你的代码量暴增的脚本

web
在某些特殊情况下,需要凑齐一定的代码量,或者一定的提交次数,为了应急不得不采用一些非常规的手段来保证达标。本文分享的是一段自动提交代码的脚本,用于凑齐code review流程数量,将单次code review代码修改行数拉下来(备注:如果git开启自动生成c...
继续阅读 »

1 功能概述

在某些特殊情况下,需要凑齐一定的代码量,或者一定的提交次数,为了应急不得不采用一些非常规的手段来保证达标。本文分享的是一段自动提交代码的脚本,用于凑齐code review流程数量,将单次code review代码修改行数拉下来(备注:如果git开启自动生成code review流程,则每次push操作就会自动生成一次code review流程)。

2 友情提示

本脚本仅用于特殊应急场景下,平时开发中还是老老实实敲代码。

重要的事情说三遍:

千万不要在工作中使用、千万不要在工作中使用、千万不要在工作中使用

3 实现思路

3.1 准备示例代码

可以多准备一些样例代码,然后随机取用, 效果会更好。例如:

需要确保示例代码是有效的代码,有些项目可能有eslint检查,如果格式不对可能导致无法自动提交

function huisu(value, index, len, arr, current) {
 if (index >= len) {
     if (value === 8) {
         console.log('suu', current)
    }
     console.log('suu', current)
     return
}
 for (let i = index; i < len; i++) {
     current.push(arr[i])
     console.log('suu', current)
     if (value + arr[i] === 8) {
         console.log('结果', current)
         return
    }
     huisu(value + arr[i], i + 1, len, arr, [...current])
     console.log('suu', value)
     current.pop()
     onsole.log('suu', current)
}
}

3.2、准备一堆文件名

准备一堆文件名,用于生成新的问题,如果想偷懒,直接随机生成问题也不大。例如:

// 实现准备好的文件名称,随机也可以
const JS_NAMES = ['index.js', 'main.js', 'code.js', 'app.js', 'visitor.js', 'detail.js', 'warning.js', 'product.js', 'comment.js', 'awenk.js', 'test.js'];

3.3 生成待提交的文件

这一步策略也很简单,就是根据指定代码输出文件夹内已有的文件数量,来决定是要执行新增文件还是删除文件

if (codeFiles.length > MIN_COUNT) {
 rmFile(codeFiles);
} else {
 createFile(codeDir);
}

【新增文件】

根据前面两步准备的示例代码和文件命名,随机获取文件名和代码段,然后创建新文件

// 创建新的代码文件
function createFile(codeDir) {
 const ran = Math.floor(Math.random() * JS_NAMES.length);
 const name = JS_NAMES[ran];
 const filePath = `${codeDir}/${name}`;
 const content = getCode();
 writeFile(filePath, content);
}

【删除文件】

这一步比较简单,直接随机删除一个就行了

// 随机删除一个文件
function rmFile(codeFiles) {
 const ran = Math.floor(Math.random() * codeFiles.length);
 const filePath = codeFiles[ran];
 try {
   if (fs.existsSync(filePath)) {
     fs.unlinkSync(filePath);
  }
} catch (e) {
   console.error('removeFile', e);
}
}

3.4 准备commit信息

这一步怎么简单怎么来,直接准备一堆,然后随机取一个就可以了

const msgs = ['feat:消息处理', 'feat:详情修改', 'fix: 交互优化', 'feat:新增渠道', 'config修改'];
const ran = Math.floor(Math.random() * msgs.length);
console.log(`${msgs[ran]}--测试提交,请直接通过`);

3.5 扩大增幅

上述步骤执行一次可能不太够,咱们可以循环多来几次。随机生成一个数字,用来控制循环的次数

const ran = Math.max(3, parseInt(Math.random() * 10, 10));
console.log(ran);

3.6 组合脚本

组合上述步骤,利用shell脚本执行git提交,详细代码如下:

#! /bin/bash

git pull

cd $(dirname $0)

# 执行次数
count=$(node ./commit/ran.js)
echo $count

# 循环执行
for((i=0;i<$count;i++))
do
node ./commit/code.js
git add .

msg=$(node ./commit/msg.js)
git commit -m "$msg"

git push
done

总结

总的来就就是利用shell脚本执行git命令,随机生成代码或者删除代码之后执行commit提交,最后push推送到远程服务器。

源码

欢迎有需要的朋友取用,《源码传送门》

作者:先秦剑仙
来源:juejin.cn/post/7160649931928109092

收起阅读 »

程序员转行做运营,降薪降得心甘情愿

自2019年末新冠疫情爆发以来,近三年的就业形势一直不太乐观,大厂裁员的消息接踵而至。身边的朋友都在感慨:现阶段能保住工作就不错了,新工作就算了。但,就是在这样严峻的大环境下,我的前同事不三不仅跳槽还转岗,1年的转行之路,经受了各种磨难。通过小摹的热情邀请,和...
继续阅读 »

自2019年末新冠疫情爆发以来,近三年的就业形势一直不太乐观,大厂裁员的消息接踵而至。身边的朋友都在感慨:现阶段能保住工作就不错了,新工作就算了。

但,就是在这样严峻的大环境下,我的前同事不三不仅跳槽还转岗,1年的转行之路,经受了各种磨难。通过小摹的热情邀请,和不三聊了聊程序员转运营过程中的经验与心得。

小摹把这份干货分享出来,希望能为每一位即将转行的伙伴提供动力支撑,也能给其他岗位的朋友新增一些不同视角的思考。

试用期差点被劝退

小摹:从事前端四年,是什么让你下定决心转行?

不三:后续有创业的打算,所以希望自己在了解产品研发的基础上,也多了解一下市场,为自己创业做准备吧。

小摹:你做的是哪方面的运营呢?这一年的感触如何?

不三:运营岗位细分很多:新媒体运营、产品运营、用户运营、活动运营、市场推广等,我所从事的是内容运营和用户运营。

公司是SaaS通信云服务提供商,对于之前从未接触过这方面工作的我而言,门槛比较高。为了能尽快熟悉产品业务,也能让我更了解用户,为后续用户运营和内容运营打基础,领导安排我前期先接触和客户相关的工作。

我试用期大部分的工作都涉及到和用户打交道,他们总会反馈给我们各种产品的需求和bug,我基本都冲在第一线安抚用户。Bug提交给开发后或许还能尽快修复,而需求反馈过去后,只能等到那句再熟悉不过的话“等排期吧”。


刚做运营的前三个月,提给开发的需求大多都被驳回了,要么做出来的东西无法达到预期。那段时间,每天上班心态濒临崩溃,颇有打道回府之意。

转正之前,领导找我谈了一次话,让我醍醐灌顶:

运营身为提需求大户,你连需求都没规划好,想一出是一出,产品开发为啥会帮你做?

你之前是前端,设身处地的想,是不是非常反感产品或运营给你提莫名其妙的需求?不注重用户体验、忽略了产品的长远发展,即便当下你的KPI完成了,你有获得真正的成长,产品有迭代得更好吗?

在和领导沟通的过程中慢慢意识到,我把自己的位置摆错了,即使运营是结果驱动,但我直面用户,所以我必须要学会洞察用户的心理,重视产品的长远发展,这样才能让我有所进度。

跟领导聊完之后,我便开始调整了工作状态和节奏,明白了自己的不足,接下来就是有目标、有计划的解决问题。

回到岗位后,我梳理了公司的业务方向,写好MRD(市场需求报告),重新制定了我的运营策略,提交给了领导。

三天后,人事找到我:我通过了试用期,成功转正了。


我很感谢我的领导,尽管试用期我做得很烂,但他仍然愿意给我机会,让我转正,继续工作。现在回过头看这一年,试用期阶段很痛苦,找不到工作的方向,但后来越来越熟悉了解后,也能更快上手了。

小摹:你认为一名优秀的运营要具备什么样的特质?

不三:现在的我只能说刚刚入门,我发现身边的运营大佬身上有以下特点,我希望自己能尽快向他靠拢。

  • 用户体感:所有的产品研发出来后,面向对象一定是用户,那么产品的使用体验、页面设计、活动机制、规则设定是否都能满足用户的胃口。

如果只是冲着所谓的KPI目标,而忽略了用户体验,或许你会收获万人骂的情况。

例如,随时随地朋友圈砍一刀的拼夕夕。

  • 把控热点能力:无论做什么方向的运营,都逃不了蹭热点,你可以说蹭热点low,但不可否认它会给自己和产品带来新机遇。

例如,写一篇文章蹭了热点之后,爆的几率更大;疫情刚出现时,异地办公、社区团购也随之应运而生。

  • 产品思维:互联网运营和产品经理的联系是非常紧密的,所以在推广的过程中,需要和产品部门多多碰撞。这样不仅能收获创意灵感,还能学到不少的产品思维。

在需求迭代时,应该站在更高的层次思考问题,一味给产品做加法,根本行不通。

  • 数据思维:运营以结果为导向,从数据中发现不足,从数据中发现增长点,弥补缺陷,让增长幅度更大。程序员比较有优势,可以写SQL导数据,但拿到数据只是第一步,还要懂得分析才行。

  • 抵御公关风险:例如我们在做活动时,我们要提前考虑活动的风险有哪些,如何积极应对,当有别有用心的人利用规则薅羊毛时,也应该有相应的解决方案。


这段简单且干货的采访随着烧烤啤酒的上桌步入了尾声。最后不三给我说到:

一年前我调整了自己的职业方向,从前端步入运营,苦涩欢笑并存,有时看着达到目标很是激动,有时苦于KPI的折磨。一年间,我经历了人生的成长,思想也更加成熟。但我还没有达到最终目的地,现在的一切只是为了以后的创业蓄力。我不想一辈子为别人打工,也想为自己活一次。


===

后记

小摹见过太多转行失败的案例,所以很为不三感到高兴,不仅仅是为他的转行成功,更多的是他坚定人生的方向,并为之做出了各种努力而高兴。

给大家分享这段采访经历,是希望大家能尽早对自己的职业生涯有所规划,有了目标后,再细分到某一阶段,这样工作起来积极性也会更高。停止摆烂,对自己负责!

人生之难,一山又一山,愿你我共赴远山。

作者:摹客
来源:juejin.cn/post/7158734145575714853

收起阅读 »

入坑两个月自研创业公司

一、拿 offer 其实入职前,我就感觉到有点不对劲,居然要自带电脑。而且人事是周六打电话发的 offer!自己多年的工作经验,讲道理不应该入这种坑,还是因为手里没粮心中慌,工作时间长的社会人,还是不要脱产考研、考公,疫情期间更是如此,本来预定 2 月公务员面...
继续阅读 »

一、拿 offer


其实入职前,我就感觉到有点不对劲,居然要自带电脑。而且人事是周六打电话发的 offer!自己多年的工作经验,讲道理不应该入这种坑,还是因为手里没粮心中慌,工作时间长的社会人,还是不要脱产考研、考公,疫情期间更是如此,本来预定 2 月公务员面试,结果一直拖到 7 月。


二、入职工作


刚入职工作时,一是有些抗拒,二呢是有些欣喜。抗拒是因为长时间呆家的惯性,以及人的惰性,我这只是呆家五个月,那些呆家一年两年的,再进入社会,真的很难,首先心理上他们就要克服自己的惰性和惯性,平时生活习惯也要发生改变


三、人言可畏


刚入职工作时,有工作几个月的老员工和我说,前公司的种种恶心人的操作,后面呢我也确实见识到了:无故扣绩效,让员工重新签署劳动协议,但是,也有很多不符实的,比如公司在搞幺蛾子的时候,居然传出来我被劝退了……


四、为什么离开


最主要的原因肯定还是因为发不出工资,打工是为了赚钱,你想白嫖我?现在公司规模也不算小了,想要缓过来,很难。即便缓过来,以后就不会出现这样的状况了?公司之前也出现过类似的状况,挺过来的老员工们我也没看到有什么优待,所以这家公司不值得我去熬。技术方面我也基本掌握了微信和支付宝小程序开发,后面不过是需求迭代。个人成长方面,虽然我现在是前端部门经理,但前端组跑的最快,可以预料后面我将面临无人可用的局面,我离职的第二天,又一名前端离职了,约等于光杆司令,没意义。


五、收获


1. 不要脱产,不要脱产
2. 使用 uniapp 进行微信和支付宝小程序开发
3. 工作离家近真的很爽
4. 作为技术人员,只要你的上司技术还行,你的工期他是能正常估算,有什么难点说出来,只要不是借口,他也能理解,同时,是借口他也能一下识别出来,比如,一个前端和我说:“后端需求不停调整,所以没做好。” 问他具体哪些调整要两个星期?他又说不出来。这个借口就不要用了,但是我也要走了,我也没必要去得罪他。
5. 进公司前,搞清楚公司目前是盈利还是靠融资活,靠融资活的创业公司有风险…


六、未来规划


关于下一份工作:
南京真是外包之城,找了两周只有外包能满足我目前 18k 的薪资,还有一家还降价了 500…
目前 offer 有
vivo 外包,20k
美的外包,17.5k
自研中小企业,18.5k


虽然美的外包薪资最低,但我可能还是偏向于美的外包。原因有以下几点:
1. 全球手机出货量下降,南京的华为外包被裁了不少,很难说以后 vivo 会不会也裁。
2. 美的目前是中国家电行业的龙头老大,遥遥领先第二名,目前在大力发展 b2c 业务,我进去做的也是和商场相关。
3. 美的的办公地点离我家更近些
4. 自研中小企业有上网限制,有过类似经验的开发人,懂得都懂,很难受。


关于考公:
每年 10 月到 12 月准备下,能进就进,不能再在考公上花费太多时间了。


作者:哇哦谢谢你
链接:https://juejin.cn/post/7160138475688165389

收起阅读 »

Android性能优化 -- 内存优化

内存,是Android应用的生命线,一旦在内存上出现问题,轻者内存泄漏,重者直接crash,因此一个应用保持健壮,内存这块的工作是持久战,而且从写代码这块就需要注意合理性,所以想要了解内存优化如何去做,要先从基础知识开始。 1 JVM内存原理 这一部分确实很枯...
继续阅读 »

内存,是Android应用的生命线,一旦在内存上出现问题,轻者内存泄漏,重者直接crash,因此一个应用保持健壮,内存这块的工作是持久战,而且从写代码这块就需要注意合理性,所以想要了解内存优化如何去做,要先从基础知识开始。


1 JVM内存原理


这一部分确实很枯燥,但是对于我们理解内存模型非常重要,这一块也是面试的常客


image.png


从上图中,我将JVM的内存模块分成了左右两大部分,左边属于共享区域(方法区、堆区),所有的线程都能够访问,但也会带来同步问题,这里就不细说了;右边属于私有区域,每个线程都有自己独立的区域。


1.1 方法执行流程


class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
execute()
}

private fun execute(){

val a = 2.5f
val b = 2.5f
val c = a + b

val method = Method()

val d = getD()
}

private fun getD(): Int {
return 0
}

}

class Method{
private var a:Int = 0
}

我们看到在MainActivity的onCreate方法中,执行了execute方法,因为当前是UI线程,每个线程都有一个Java虚拟机栈,从上图中可以看到,那么每执行一个方法,在Java虚拟机栈中都对应一个栈帧。


image.png


每次调用一个方法,都代表一个栈帧入栈,当onCreate方法执行完成之后,会执行execute方法,那么我们看下execute方法。


execute方法在Java虚拟机栈中代表一个栈帧,栈帧是由四部分组成:


(1)局部变量表:局部变量是声明在方法体内的,例如a,b,c,在方法执行完成之后,也会被回收;
(2)操作数栈:在任意方法中,涉及到变量之间运算等操作都是在操作数栈中进行;例如execute方法中:


val a = 2.5f

当执行这句代码时,首先会将 2.5f压入操作数栈,然后给a赋值,依次类推

(3)返回地址:例如在execute调用了getD方法,那么这个方法在执行到到return的时候就结束了,当一个方法结束之后,就要返回到该方法的被调用处,那么该方法就携带一个返回地址,告诉JVM给谁赋值,然后通过操作数栈给d赋值

(4)动态链接:在execute方法中,实例化了Method类,在这里,首先会给Method中的一些静态变量或者方法进行内存分配,这个过程可以理解为动态链接。


1.2 从单例模式了解对象生命周期


单例模式,可能是众多设计模式中,我们使用最频繁的一个,但是单例真是就这么简单吗,使用不慎就会造成内存泄漏!


interface IObserver {

fun send(msg:String)

}

class Observable : IObserver {

private val observers: MutableList<IObserver> by lazy {
mutableListOf()
}

fun register(observer: IObserver) {
observers.add(observer)
}

fun unregister(observer: IObserver) {
observers.remove(observer)
}

override fun send(msg: String) {
observers.forEach {
it.send(msg)
}
}

companion object {
val instance: Observable by lazy {
Observable()
}
}
}

这里是写了一个观察者,这个被观察者是一个单例,instance是存放在方法区中,而创建的Observable对象则是存在堆区,看下图


image.png


因为方法区属于常驻内存,那么其中的instance引用会一直跟堆区的Observable连接,导致这个单例对象会存在很长的时间


btnRegister.setOnClickListener {
Observable.instance.register(this)
}
btnSend.setOnClickListener {
Observable.instance.send("发送消息")
}

在MainActivity中,点击注册按钮,注意这里传入的值,是当前Activity,那么这个时候退出,会发生什么?我们先从profile工具里看一下,退出之后,有2个内存泄漏的地方,如果使用的leakcannary(后面会介绍)就应该会明白


image.png


那么在MainActivity中,哪个地方发生的了内存泄漏呢?我们紧跟一下看看GcRoot的引用,发现有这样一条引用链,MainActivity在一个list数组中,而且这个数组是Observable中的observers,而且是被instance持有,前面我们说到,instance的生命周期很长,所以当Activity准备被销毁时,发现被instance持有导致回收失败,发生了内存泄漏。


image.png


那么这种情况,我们该怎么处理呢?一般来说,有注册就有解注册,所以我们在封装的时候一定要注意单例中传入的参数


override fun onDestroy() {
super.onDestroy()
Observable.instance.unregister(this)
}

再次运行我们发现,已经不存在内存泄漏了


image.png


1.3 GcRoot


前面我们提到了,因为instance是Gcroot,导致其引用了observers,observers引用了MainActivity,MainActivity退出的时候没有被回收,那么什么样的对象能被看做是GcRoot呢?


(1)静态变量、常量:例如instance,其内存是在方法区的,在方法区一般存储的都是静态的常量或者变量,其生命周期非常长;

(2)局部变量表:在Java虚拟机栈的栈帧中,存在局部变量表,为什么局部变量表能作为gcroot,原因很简单,我们看下面这个方法


private fun execute() {

val a = 2.5f
val method = Method()
val d = getD()
}

a变量就是一个局部变量表中的成员,我们想一下,如果a不是gcroot,那么垃圾回收时就有可能被回收,那么这个方法还有什么意义呢?所以当这个方法执行完成之后,gcroot被回收,其引用也会被回收。


2 OOM


在之前我们简单介绍了内存泄漏的场景,那么内存泄漏一旦发生,就会导致OOM吗?其实并不是,内存泄漏一开始并不会导致OOM,而是逐渐累计的,当内存空间不足时,会造成卡顿、耗电等不良体验,最终就会导致OOM,app崩溃


那么什么情况下会导致OOM呢?

(1)Java堆内存不足

(2)没有连续的内存空间

(3)线程数超出限制


其实以上3种状况,前两种都有可能是内存泄漏导致的,所以如何避免内存泄漏,是我们内存优化的重点


2.1 leakcanary使用


首先在module中引入leakcanary的依赖,关于leakcanary的原理,之后会单独写一篇博客介绍,这里我们的主要工作是分析内存泄漏


debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7'

配置依赖之后,重新运行项目,会看到一个leaks app,这个app就是用来监控内存泄漏的工具


image.png
那我们执行之前的应用,打开leaks看一下gcroot的引用,是不是跟我们在as的profiler中看到的是一样的


image.png


如果使用过leakcanary的伙伴们应该知道,leakcanary会生成一个hprof文件,那么通过MAT工具,可以分析这个hprof文件,查找内存泄漏的位置,下面的链接能够下载MAT工具
http://www.eclipse.org/mat/downloa…


2.2 内存泄漏的场景


1. 资源性的对象没有关闭


例如,我们在做一个相机模块,通过camera拿到了一帧图片,通常我们会将其转换为bitmap,在使用完成之后,如果没有将其回收,那么就会造成内存泄漏,具体使用完该怎么办呢?


if(bitmap != null){
bitmap?.recycle()
bitmap = null
}

调用bitmap的recycle方法,然后将bitmap置为null


2. 注册的对象没有注销


这种场景其实我们已经很常见了,在之前也提到过,就是注册跟反注册要成对出现,例如我们在注册广播接收器的时候,一定要记得,在Activity销毁的时候去解注册,具体使用方式就不做过多的赘述。


3. 类的静态变量持有大数据量对象


因为我们知道,类的静态变量是存储在方法区的,方法区空间有限而且生命周期长,如果持有大数据量对象,那么很难被gc回收,如果再次向方法区分配内存,会导致没有足够的空间分配,从而导致OOM


4. 单例造成的内存泄漏


这个我们在前面已经有一个详细的介绍,因为我们在使用单例的时候,经常会传入context或者activity对象,因为有上下文的存在,导致单例持有不能被销毁;


因此在传入context的时候,可以传入Application的context,那么单例就不会持有activity的上下文可以正常被回收;


如果不能传入Application的context,那么可以通过弱引用包装context,使用的时候从弱引用中取出,但这样会存在风险,因为弱引用可能随时被系统回收,如果在某个时刻必须要使用context,可能会带来额外的问题,因此根据不同的场景谨慎使用。


object ToastUtils {

private var context:Context? = null

fun setText(context: Context) {
this.context = context
Toast.makeText(context, "1111", Toast.LENGTH_SHORT).show()
}

}

我们看下上面的代码,ToastUtils是一个单例,我们在外边写了一个context:Context? 的引用,这种写法是非常危险的,因为ToastUtils会持有context的引用导致内存泄漏


object ToastUtils {

fun setText(context: Context) {
Toast.makeText(context, "1111", Toast.LENGTH_SHORT).show()
}

}

5. 非静态内部类的静态实例


我们先了解下什么是静态内部类和非静态内部类,首先只有内部类才能设置为静态类,例如


class MainActivity : AppCompatActivity() {

private var a = 10

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main2)
}

inner class InnerClass {
fun setA(code: Int) {
a = code
}
}
}

InnerClass是一个非静态内部类,那么在MainActivity声明了一个变量a,其实InnerClass是能够拿到这个变量,也就是说,非静态内部类其实是对外部类有一个隐式持有,那么它的静态实例对象是存储在方法区,而且该对象持有MainActivity的引用,导致退出时无法被释放。


解决方式就是:将InnerClass设置为静态类


class InnerClass {

fun setA(code: Int) {
a = code //这里就无法使用外部类的对象或者方法
}
}

大家如果对于kotlin不熟悉的话,就简单介绍一下,inner class在java中就是非静态的内部类;而直接用class修饰,那么就相当于Java中的 public static 静态内部类。


6. Handler


这个可就是老生常谈了,如果使用过Handler的话都知道,它非常容易产生内存泄漏,具体的原理就不说了,感觉现在用Handler真的越来越少了


其实说了这么多,真正在写代码的时候,不能真正的避免,接下来我就使用leakcanary来检测某个项目中存在的内存泄漏问题,并解决


3 从实际项目出发,根除内存泄漏


1. 单例引发的内存泄漏


image.png


我们从gcroot中可以看到,在TeachAidsCaptureImpl中传入了LifeCycleOwner,LifeCycleOwner大家应该熟悉,能够监听Activity或者Fragment的生命周期,然后CaptureModeManager是一个单例,传入的mode就是TeachAidsCaptureImpl,这样就会导致一个问题,单例的生命周期很长,Fragment被销毁的时候因为TeachAidsCaptureImpl持有了Fragment的引用,导致无法销毁


fun clear() {
if (mode != null) {
mode = null
}
}

所以,在Activity或者Fragment销毁前,将model置为空,那么内存泄漏就会解决了,直到看到这个界面,那么我们的应用就是安全的了


image.png


2.使用Toast引发的内存泄漏


image.png


在我们使用Toast的时候,需要传入一个上下文,我们通常会传入Activity,那么这个上下文给谁用的呢,在Toast中也有View,如果我们自定过Toast应该知道,那么如果Toast中的View持有了Activity的引用,那么就会导致内存泄漏


Toast.makeText(this,"Toast内存泄漏",Toast.LENGTH_SHORT).show()

那么怎样避免呢?传入Application的上下文,就不会导致Activity不被回收。


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

线上kafka消息堆积,consumer掉线,怎么办?

线上kafka消息堆积,所有consumer全部掉线,到底怎么回事? 最近处理了一次线上故障,具体故障表现就是kafka某个topic消息堆积,这个topic的相关consumer全部掉线。 整体排查过程和事后的复盘都很有意思,并且结合本次故障,对kafka使...
继续阅读 »

线上kafka消息堆积,所有consumer全部掉线,到底怎么回事?


最近处理了一次线上故障,具体故障表现就是kafka某个topic消息堆积,这个topic的相关consumer全部掉线。


整体排查过程和事后的复盘都很有意思,并且结合本次故障,对kafka使用的最佳实践有了更深刻的理解。


好了,一起来回顾下这次线上故障吧,最佳实践总结放在最后,千万不要错过。


1、现象



  • 线上kafka消息突然开始堆积

  • 消费者应用反馈没有收到消息(没有处理消息的日志)

  • kafka的consumer group上看没有消费者注册

  • 消费者应用和kafka集群最近一周内没有代码、配置相关变更


2、排查过程


服务端、客户端都没有特别的异常日志,kafka其他topic的生产和消费都是正常,所以基本可以判断是客户端消费存在问题。


所以我们重点放在客户端排查上。


1)arthas在线修改日志等级,输出debug


由于客户端并没有明显异常日志,因此只能通过arthas修改应用日志等级,来寻找线索。


果然有比较重要的发现:


2022-10-25 17:36:17,774 DEBUG [org.apache.kafka.clients.consumer.internals.AbstractCoordinator] - [Consumer clientId=consumer-1, groupId=xxxx] Disabling heartbeat thread

2022-10-25 17:36:17,773 DEBUG [org.apache.kafka.clients.consumer.internals.AbstractCoordinator] - [Consumer clientId=consumer-1, groupId=xxxx] Sending LeaveGroup request to coordinator xxxxxx (id: 2147483644 rack: null)

看起来是kafka-client自己主动发送消息给kafka集群,进行自我驱逐了。因此consumer都掉线了。


2)arthas查看相关线程状态变量

用arthas vmtool命令进一步看下kafka-client相关线程的状态。



可以看到 HeartbeatThread线程状态是WAITING,Cordinator状态是UNJOINED。


此时,结合源码看,大概推断是由于消费时间过长,导致客户端自我驱逐了。


于是立刻尝试修改max.poll.records,减少一批拉取的消息数量,同时增大max.poll.interval.ms参数,避免由于拉取间隔时间过长导致自我驱逐。


参数修改上线后,发现consumer确实不掉线了,但是消费一段时间后,还是就停止消费了。


3、最终原因


相关同学去查看了消费逻辑,发现了业务代码中的死循环,确认了最终原因。



消息内容中的一个字段有新的值,触发了消费者消费逻辑的死循环,导致后续消息无法消费。

消费阻塞导致消费者自我驱逐,partition重新reblance,所有消费者逐个自我驱逐。



这里核心涉及到kafka的消费者和kafka之间的保活机制,可以简单了解一下。



kafka-client会有一个独立线程HeartbeatThread跟kafka集群进行定时心跳,这个线程跟lisenter无关,完全独立。


根据debug日志显示的“Sending LeaveGroup request”信息,我们可以很容易定位到自我驱逐的逻辑。



HeartbeatThread线程在发送心跳前,会比较一下当前时间跟上次poll时间,一旦大于max.poll.interval.ms 参数,就会发起自我驱逐了。


4、进一步思考


虽然最后原因找到了,但是回顾下整个排查过程,其实并不顺利,主要有两点:



  • kafka-client对某个消息消费超时能否有明确异常?而不是只看到自我驱逐和rebalance

  • 有没有办法通过什么手段发现 消费死循环?


4.1 kafka-client对某个消息消费超时能否有明确异常?


4.1.1 kafka似乎没有类似机制


我们对消费逻辑进行断点,可以很容易看到整个调用链路。



对消费者来说,主要采用一个线程池来处理每个kafkaListener,一个listener就是一个独立线程。


这个线程会同步处理 poll消息,然后动态代理回调用户自定义的消息消费逻辑,也就是我们在@KafkaListener中写的业务。



所以,从这里可以知道两件事情。


第一点,如果业务消费逻辑很慢或者卡住了,会影响poll。


第二点,这里没有看到直接设置消费超时的参数,其实也不太好做。


因为这里做了超时中断,那么poll也会被中断,是在同一个线程中。所以要么poll和消费逻辑在两个工作线程,要么中断掉当前线程后,重新起一个线程poll。


所以从业务使用角度来说,可能的实现,还是自己设置业务超时。比较通用的实现,可以是在消费逻辑中,用线程池处理消费逻辑,同时用Future get阻塞超时中断。


google了一下,发现kafka 0.8 曾经有consumer.timeout.ms这个参数,但是现在的版本没有这个参数了,不知道是不是类似的作用。


4.1.2 RocketMQ有点相关机制


然后去看了下RocketMQ是否有相关实现,果然有发现。


在RocketMQ中,可以对consumer设置consumeTimeout,这个超时就跟我们的设想有一点像了。


consumer会启动一个异步线程池对正在消费的消息做定时做 cleanExpiredMsg() 处理。



注意,如果消息类型是顺序消费(orderly),这个机制就不生效。


如果是并发消费,那么就会进行超时判断,如果超时了,就会将这条消息的信息通过sendMessageBack() 方法发回给broker进行重试。



如果消息重试超过一定次数,就会进入RocketMQ的死信队列。



spring-kafka其实也有做类似的封装,可以自定义一个死信topic,做异常处理



4.2 有没有办法通过什么手段快速发现死循环?


一般来说,死循环的线程会导致CPU飙高、OOM等现象,在本次故障中,并没有相关异常表现,所以并没有联系到死循环的问题。


那通过这次故障后,对kafka相关机制有了更深刻了解,poll间隔超时很有可能就是消费阻塞甚至死循环导致。


所以,如果下次出现类似问题,消费者停止消费,但是kafkaListener线程还在,可以直接通过arthas的 thread id 命令查看对应线程的调用栈,看看是否有异常方法死循环调用。


5、最佳实践


通过此次故障,我们也可以总结几点kafka使用的最佳实践:




  • 使用消息队列进行消费时,一定需要多考虑异常情况,包括幂等、耗时处理(甚至死循环)的情况。




  • 尽量提高客户端的消费速度,消费逻辑另起线程进行处理,并最好做超时控制。




  • 减少Group订阅Topic的数量,一个Group订阅的Topic最好不要超过5个,建议一个Group只订阅一个Topic。




  • 参考以下说明调整参数值:max.poll.records:降低该参数值,建议远远小于<单个线程每秒消费的条数> * <消费线程的个数> * <max.poll.interval.ms>的积。max.poll.interval.ms: 该值要大于<max.poll.records> / (<单个线程每秒消费的条数> * <消费线程的个数>)的值。


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