注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

Android TextView的颜色和字体自适应

前言 最近比较忙,没有时间去梳理一些硬核的东西,今天就来分享一些简单有意思的技巧。拿TextView来说,平时我们都会在特定的场景去设置它的字体颜色和字体大小。那么有没有一种办法能够一劳永逸不用每次都去设置,能让TextView自己去根据自身的控件属性去自适应...
继续阅读 »

前言


最近比较忙,没有时间去梳理一些硬核的东西,今天就来分享一些简单有意思的技巧。拿TextView来说,平时我们都会在特定的场景去设置它的字体颜色和字体大小。那么有没有一种办法能够一劳永逸不用每次都去设置,能让TextView自己去根据自身的控件属性去自适应颜色和大小呢?当然是有的,这里可以简单的分享一些思路。


1. 字体大小自适应


TextView可以根据让字体的大小随着宽高进行自适应。


设置大小自适应的方式很简单,只需要添加这3行代码即可


android:autoSizeMaxTextSize="22dp"  
android:autoSizeMinTextSize="8dp"
android:autoSizeTextType="uniform"

我们可以来看看效果,我给宽高都设置不同的值,能看到字体大小变化的效果


android:layout_width="50dp"  
android:layout_height="20dp"

image.png


android:layout_width="50dp"  
android:layout_height="30dp"

image.png


android:layout_width="50dp"  
android:layout_height="50dp"

image.png


android:layout_width="80dp"  
android:layout_height="80dp"

image.png


最后这里可以看到autoSizeMaxTextSize的效果


这里可以多提一句,一般这种字体随宽高自适应的场景在正常开发中比较少见。如果你的项目合理的话,一般字体的大小都是固定那几套,所以把字体大小定义到资源文件中,甚至通过style的方式去设置,才是最节省时间的方式。


2. 字体颜色自适应


关于字体的颜色自适应,如果你真想把这套东西搞起来,你就需要对“颜色”这个概念有一定的深层次的了解。我这里就只简单做一些效果来举例。


我这里演示Textview根据背景颜色来自动设置字体颜色是白色还是黑色,当背景颜色是暗色时(比如黑色),字体颜色变成白色,当背景颜色是亮色时(比如白色),字体颜色变成黑色。


那么首先需要有个概念:我怎么判断背景是亮色还是暗色?


这就需要对颜色有一定的理解。要判断一个颜色是暗色还是亮色,可以通过计算颜色的亮度来实现。一种常见的方法是将RGB颜色值转换为灰度值,然后根据灰度值来判断颜色的深浅程度。

灰度值的计算公式 灰度值 = 0.2126 * R + 0.7152 * G + 0.0722 * B


根据这个公式,我们能封装一个判断颜色是否是亮色的方法


private fun isLightColor(color: Int): Boolean {  
val r = color shr 16 and 0xFF
val g = color shr 8 and 0xFF
val b = color and 0xFF
val luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
return luminance > 0.5
}

如果觉得这个判断不太符合你心里的预期,可以修改最后一行的luminance > 0.5值


下一步,我们需要获取控件的背景,然后从背景中获取颜色值。


获取背景直接调用


val d = textView?.background

根据Drawable去获取颜色


private fun getColorByDrawable(d : Drawable) : Int{  
val bitmap = Bitmap.createBitmap(
textView?.width ?: 0,
textView?.height ?: 0,
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bitmap)
d.setBounds(0, 0, canvas.width, canvas.height)
d.draw(canvas)
return bitmap.getPixel(0, 0)
}

注意,我这里不考虑渐变色的情况,只是考虑单色的情况,所以x和y是传0,一般对于复杂的渐变色也不好做适配,但是对于background分边框和填充两种颜色的情况,一般文字都是显示在填充区域,这时候的x和y可以去根据边框宽度去加个偏移量(总之可以灵活应变)


还有一种场景,对于TextView没背景颜色,是它的父布局有背景颜色的情况,可以循环去调用父布局的view.background判断是否为空,为空就循环一次,不为空直接获取颜色。我这里就不演示代码了。


这里先把全部代码贴出来(都是用了最简单的方式)


override fun onCreate(savedInstanceState: Bundle?) {  
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_demo_text)

textView = findViewById(R.id.tv)
val d = textView?.background
textView?.post {
if (d != null){
if (isLightColor(getColorByDrawable(d))){
textView?.setTextColor(resources.getColor(R.color.black))
}else{
textView?.setTextColor(resources.getColor(R.color.white))
}
}
}
}

private fun getColorByDrawable(d : Drawable) : Int{
val bitmap = Bitmap.createBitmap(
textView?.width ?: 0,
textView?.height ?: 0,
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bitmap)
d.setBounds(0, 0, canvas.width, canvas.height)
d.draw(canvas)
return bitmap.getPixel(0, 0)
}

private fun isLightColor(color: Int): Boolean {
val r = color shr 16 and 0xFF
val g = color shr 8 and 0xFF
val b = color and 0xFF
val luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
return luminance > 0.5
}

然后改几个背景色来看看效果


android:background="#000000"

image.png


android:background="#ffffff"

image.png


android:background="#3377ff"

image.png


android:background="#ee7700"

image.png


作者:流浪汉kylin
来源:juejin.cn/post/7361998447908208651
收起阅读 »

实现抖音 “视频无限滑动“效果

web
前言 在家没事的时候刷抖音玩,抖音首页的视频怎么刷也刷不完,经常不知不觉的一刷就到半夜了😅 不禁感叹道 "垃圾抖音,费我时间,毁我青春😅" 这是我的 模仿抖音 系列文章的第二篇,本文将一步步实现抖音首页 视频无限滑动 的效果,干货满满。 如果您对滑动原理不太熟...
继续阅读 »

前言


在家没事的时候刷抖音玩,抖音首页的视频怎么刷也刷不完,经常不知不觉的一刷就到半夜了😅

不禁感叹道 "垃圾抖音,费我时间,毁我青春😅"


这是我的 模仿抖音 系列文章的第二篇,本文将一步步实现抖音首页 视频无限滑动 的效果,干货满满。


如果您对滑动原理不太熟悉,推荐先看我的这篇文章:200行代码实现类似Swiper.js的轮播组件


最终效果


在线预览:zyronon.gitee.io/douyin/


Github地址:github.com/zyronon/dou…


源码:SlideVerticalInfinite.vue


实现原理


无限滑动的原理和虚拟滚动的原理差不多,要保持 SlideList 里面永远只有 NSlideItem,就要在滑动时不断的删除和增加 SlideItem

滑动时调整 SlideList 的偏移量 translateY 的值,以及列表里那几个 SlideItemtop 值,就可以了


为什么要调整 SlideList 的偏移量 translateY 的值同时还要调整 SlideItemtop 值呢?

因为 translateY 只是将整个列表移动,如果我们列表里面的元素是固定的,不会变多和减少,那么没关系,只调整 translateY 值就可以了,上滑了几页就减几页的高度,下滑同理


但是如果整个列表向前移动了一页,同时前面的 SlideItem 也少了一个,,那么最终效果就是移动了两页...因为 塌陷 了一页

这显然不是我们想要的,所以我们还需要同时调整 SlideItemtop 值,加上前面少的 SlideItem 的高度,这样才能显示出正常的内容


步骤


定义




virtualTotal:页面中同时存在多少个 SlideItem,默认为 5


//页面中同时存在多少个SlideItem
virtualTotal: {
type: Number,
default: () => 5
},

设置这个值可以让外部组件使用时传入,毕竟每个人的需求不同,有的要求同时存在 10 条,有的要求同时存在 5 条即可。

不过同时存在的数量越大,使用体验就越好,即使用户快速滑动,我们依然有时间处理。

如果只同时存在 5 条,用户只需要快速滑动两次就到底了(因为屏幕中显示第 3 条,刚开始除外),我们可能来不及添加新的视频到最后




render:渲染函数,SlideItem内显示什么由render返回值决定


render: {
type: Function,
default: () => {
return null
}
},

之所以要设定这个值,是因为抖音首页可不只有视频,还有图集、推荐用户、广告等内容,所以我们不能写死显示视频。

最好是定义一个方法,外部去实现,我们内部去调用,拿到返回值,添加到 SlideList




list:数据列表,外部传入


list: {
type: Array,
default: () => {
return []
}
},

我们从 list 中取出数据,然后调用并传给 render 函数,将其返回值插入到 SlideList中


初始化



watch(
() => props.list,
(newVal, oldVal) => {
//新数据长度比老数据长度小,说明是刷新
if (newVal.length < oldVal.length) {
//从list中取出数据,然后调用并传给render函数,将其返回值插入到SlideList中
insertContent()
} else {
//没数据就直接插入
if (oldVal.length === 0) {
insertContent()
} else {
// 走到这里,说明是通过接口加载了下一页的数据,
// 为了在用户快速滑动时,无需频繁等待请求接口加载数据,给用户更好的使用体验
// 这里额外加载3条数据。所以此刻,html里面有原本的5个加新增的3个,一共8个dom
// 用户往下滑动时只删除前面多余的dom,等滑动到临界值(virtualTotal/2+1)时,再去执行新增逻辑
}
}
}
)

watch 监听 list 是因为它一开始不一定有值,通过接口请求之后才有值

同时当我们下滑 加载更多 时,也会触发接口请求新的数据,用 watch 可以在有新数据时,多添加几条到 SlideList 的最后面,这样用户快速滑动也不怕了


如何滑动


这里就不再赘述,参考我的这篇文章:200行代码实现类似Swiper.js的轮播组件


滑动结束


判断滑动的方向


当我们向上滑动时,需要删除最前面的 dom ,然后在最后面添加一个 dom

下滑时反之


slideTouchEnd(e, state, canNext, (isNext) => {
if (props.list.length > props.virtualTotal) {
//手指往上滑(即列表展示下一条视频)
if (isNext) {
//删除最前面的 `dom` ,然后在最后面添加一个 `dom`
} else {
//删除最后面的 `dom` ,然后在最前面添加一个 `dom`
}
}
})

手指往上滑(即列表展示下一条视频)



  • 首先判断是否要加载更多,快到列表末尾时就要加载更多数据了

  • 再判断是否符合 腾挪 的条件,即当前位置要大于 half,且小于列表长度减 half

  • 在最后面添加一个 dom

  • 删除最前面的 dom

  • 将所有 dom 设置为最新的 top 值(原因前面有讲,因为删除了最前面的 dom,导致塌陷一页,所以要加上删除 dom 的高度)


let half = (props.virtualTotal - 1) / 2

//删除最前面的 `dom` ,然后在最后面添加一个 `dom`
if (state.localIndex > props.list.length - props.virtualTotal && state.localIndex > half) {
emit('loadMore')
}

//是否符合 `腾挪` 的条件
if (state.localIndex > half && state.localIndex < props.list.length - half) {
//在最后面添加一个 `dom`
let addItemIndex = state.localIndex + half
let res = slideListEl.value.querySelector(`.${itemClassName}[data-index='${addItemIndex}']`)
if (!res) {
slideListEl.value.appendChild(getInsEl(props.list[addItemIndex], addItemIndex))
}

//删除最前面的 `dom`
let index = slideListEl.value
.querySelector(`.${itemClassName}:first-child`)
.getAttribute('data-index')
appInsMap.get(Number(index)).unmount()

slideListEl.value.querySelectorAll(`.${itemClassName}`).forEach((item) => {
_css(item, 'top', (state.localIndex - half) * state.wrapper.height)
})
}

手指往下滑(即列表展示上一条视频)


逻辑和上滑都差不多,不过是反着来而已



  • 再判断是否符合 腾挪 的条件,和上面反着

  • 在最前面添加一个 dom

  • 删除最后面的 dom

  • 将所有 dom 设置为最新的 top


//删除最后面的 `dom` ,然后在最前面添加一个 `dom`
if (state.localIndex >= half && state.localIndex < props.list.length - (half + 1)) {
let addIndex = state.localIndex - half
if (addIndex >= 0) {
let res = slideListEl.value.querySelector(`.${itemClassName}[data-index='${addIndex}']`)
if (!res) {
slideListEl.value.prepend(getInsEl(props.list[addIndex], addIndex))
}
}
let index = slideListEl.value
.querySelector(`.${itemClassName}:last-child`)
.getAttribute('data-index')
appInsMap.get(Number(index)).unmount()

slideListEl.value.querySelectorAll(`.${itemClassName}`).forEach((item) => {
_css(item, 'top', (state.localIndex - half) * state.wrapper.height)
})
}

其他问题


为什么不直接用 v-for直接生成 SlideItem 呢?


如果内容不是视频就可以。要删除或者新增时,直接操作 list 数据源,这样省事多了


如果内容是视频,修改 list 时,Vue 会快速的替换 dom,正在播放的视频,突然一下从头开始播放了😅😅😅


如何获取 Vue 组件的最终 dom


有两种方式,各有利弊



  • Vuerender 方法

    • 优点:只是渲染一个 VNode 而已,理论上讲内存消耗更少。

    • 缺点:但我在开发中,用了这个方法,任何修改都会刷新页面,有点难蚌😅



  • VuecreateApp 方法再创建一个 Vue 的实例

    • 和上面相反😅




import { createApp, onMounted, reactive, ref, render as vueRender, watch } from 'vue'

/**
* 获取Vue组件渲染之后的dom元素
* @param item
* @param index
* @param play
*/

function getInsEl(item, index, play = false) {
// console.log('index', cloneDeep(item), index, play)
let slideVNode = props.render(item, index, play, props.uniqueId)
const parent = document.createElement('div')
//TODO 打包到线上时用这个,这个在开发时任何修改都会刷新页面
if (import.meta.env.PROD) {
parent.classList.add('slide-item')
parent.setAttribute('data-index', index)
//将Vue组件渲染到一个div上
vueRender(slideVNode, parent)
appInsMap.set(index, {
unmount: () => {
vueRender(null, parent)
parent.remove()
}
})
return parent
} else {
//创建一个新的Vue实例,并挂载到一个div上
const app = createApp({
render() {
return <SlideItem data-index={index}>{slideVNode}</SlideItem>
}
})
const ins = app.mount(parent)
appInsMap.set(index, app)
return ins.$el
}
}

总结


原理其实并不难。主要是一开始可能会用 v-for 去弄,折腾半天发现不行。v-for 不行,就只能想想怎么把 Vue 组件搞到 html 里面去,又去研究如何获取 Vue 组件的最终 dom,又查了半天资料,Vue 官方文档也不写,还得去翻 api ,麻了


结束



以上就是文章的全部内容,感谢看到这里,希望对你有所帮助或启发!创作不易,如果觉得文章写得不错,可以点赞收藏支持一下,也欢迎关注我的公众号 前端张余让,我会更新更多实用的前端知识与技巧,期待与你共同成长~



作者:前端张余让
来源:juejin.cn/post/7361614921519054883
收起阅读 »

Android实战 -> 使用Interceptor+Lock实现无缝刷新Token

前言 哈喽各位我又来了,相信大家在做APP的时候肯定会遇到用户Token即将过期或者已经过期的情况,那么这个时候后端都返回相应的Code提示我们要求刷新Token处理。 那么今天这篇文章就给大家提供一个思路。 开工 技术点 Interceptor ->...
继续阅读 »

前言


哈喽各位我又来了,相信大家在做APP的时候肯定会遇到用户Token即将过期或者已经过期的情况,那么这个时候后端都返回相应的Code提示我们要求刷新Token处理。


那么今天这篇文章就给大家提供一个思路。


开工


技术点


  • Interceptor -> 拦截器

  • ReentrantLock -> 重入锁


实现思路


  • 通过TokenInterceptor获取Response解析请求结果验证是否Token过期

  • 监控到Token已过期后阻塞当前线程,调用刷新Token接口并使用Lock锁

  • 并发的请求也监控到了Token过期后,先校验Lock是否已锁,已锁等待,未锁步骤2

  • Token刷新成功后各线程携带新的Token创建Request重新请求


总结:4个并发线程接口,谁抢到了Lock锁谁去刷新Token,其他三个线程阻塞等待


实现代码

private fun handle(name: String) {
Log.d(TAG, "handle 【Start】 called with: name = $name")
try {
if (!mLock.isLocked) {
this.mLock.lock() // 加锁
Log.d(TAG, "handle 【Start Refresh】 called with: name = $name")
Thread.sleep(5000) // 此处应为刷新Token请求
Log.d(TAG, "handle 【End Refresh】 called with: name = $name")
this.mLock.unlock() // 释放锁
} else {
Log.d(TAG, "handle 【Wait Refresh】 called with: name = $name")
while (true) { // 阻塞等待
if (!mLock.isLocked) { // 查询锁状态
Log.d(TAG, "handle 【OK Refresh】 called with: name = $name")
break
}
}
}
} finally {
if (mLock.isLocked) {
this.mLock.unlock()
}
}
Log.d(TAG, "handle 【End】 called with: name = $name")
}

如上述代码,抢到Lock锁的线程去刷新Token,其余线程等待结果。


模拟测试

// 此处模拟并发请求
this.findViewById<View>(R.id.btnGo).setOnClickListener {
thread {
handle("线程1")
}
thread {
handle("线程2")
}
thread {
handle("线程3")
}
}

输出日志

image.png


如图,线程2抢到了Lock锁,线程1、3则进入了等待状态


image.png


如图,线程2刷新Token成功后释放了锁,线程1、3监听到了锁被释放则进入重新请求逻辑


实践代码

class TokenInterceptor : Interceptor {

@Volatile
private var mRefreshInvalidTime = 0L

@Volatile
private var isRefreshToken = false

private val mRefreshTokenLock by lazy { ReentrantLock() }

private val mAccountRep by lazy { .... }

override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
// 接口过滤Token校验
val ignoreToken = request.headers[HeaderConstant.IGNORE_TOKEN_NAME]
if (ignoreToken == HeaderConstant.IGNORE_TOKEN_VALUE) {
return chain.proceed(request)
}
val response = chain.proceed(request)
if (HttpFactory.bodyEncoded(response.headers)) {
return response
}
// 解析反参Json
val result = HttpFactory.bodyToString(response) ?: ""
if (!TextUtils.isEmpty(result)) {
val resp = result.convert<BaseResp<Any>>()
// 校验Token是否过期
if (ResponseConstant.isTokenExpire(resp.code)) {
return onTokenRefresh(chain, response) ?: kotlin.run { response }
}
// 校验Token是否失效
if (ResponseConstant.isTokenInvalid(resp.code)) {
this.onTokenInvalid(response)
}
}
return response
}

/**
* Token 刷新
*/

private fun onTokenRefresh(chain: Interceptor.Chain, response: Response): Response? {
var newResponse: Response? = response
try {
if (!mRefreshTokenLock.isLocked) {
this.mRefreshTokenLock.lock()
this.isRefreshToken = true
runBlocking {
launch(Dispatchers.Default) {
newResponse = requestAuthToken(chain, response)
}
}

this.mRefreshTokenLock.unlock()
this.isRefreshToken = false
} else {
while (true){
if (!isRefreshToken){
newResponse = doRequest(chain)
break
}
}
}
} catch (e: Exception) {
// do something
} finally {
if (mRefreshTokenLock.isLocked) {
this.mRefreshTokenLock.unlock()
this.isRefreshToken = false
}
}
return newResponse
}


/**
* Token 失效
*/

private fun onTokenInvalid(response: Response) {
response.close()
// 防抖
val currentTime = System.currentTimeMillis()
if ((currentTime - mRefreshInvalidTime) > KET_TOKEN_INVALID_ANTI_SHAKE) {
this.mRefreshInvalidTime = currentTime
// 跳转登录页 or 自行逻辑
...
}
}

/**
* 请求 刷新Token
*/

private suspend fun requestAuthToken(chain: Interceptor.Chain, response: Response): Response? {
var newResponse: Response? = response
val resp = .... // 请求代码
if (resp.isSuccess()) {
response.close()
resp.data?.let { data -> .... //更新本地Token }
newResponse = doRequest(chain)
}
return newResponse
}

private fun doRequest(chain: Interceptor.Chain): Response? {
var response: Response? = null
try {
val newRequest = HttpFactory.newRequest(chain.request()).build()
response = chain.proceed(newRequest)
} catch (e: Exception) {
// do something
}
return response
}

companion object {
const val KET_TOKEN_INVALID_ANTI_SHAKE = 2000
}
}

End

到这里就结束了,简单吧,希望可以帮到在座的小伙伴们。当然如果有更好的实现方式或方案也希望各位在评论区留言讨论,我秒回复哦~ Bye



作者:新啊新之助
来源:juejin.cn/post/7306018966920970274
收起阅读 »

Android适配:判断机型和系统

在Android开发中,我们总是会碰到各种各样的适配问题。如果要解决适配问题,我们必须就要解决,出现问题的是什么机型?出现问题的是什么系统?怎么判断当前机型是不是出问题的机型?这几个问题。这篇文章,就将介绍如何判断机型和系统,介绍目前应该如何解决这些问题。 判...
继续阅读 »

在Android开发中,我们总是会碰到各种各样的适配问题。如果要解决适配问题,我们必须就要解决,出现问题的是什么机型?出现问题的是什么系统?怎么判断当前机型是不是出问题的机型?这几个问题。这篇文章,就将介绍如何判断机型和系统,介绍目前应该如何解决这些问题。


判断指定的机型


在Android里面可以通过 android.os.Build这个类获取相关的机型信息,它的参数如下:(这里以一加的手机为例)


Build.BOARD = lahaina
Build.BOOTLOADER = unknown
Build.BRAND = OnePlus //品牌名
Build.CPU_ABI = arm64-v8a
Build.CPU_ABI2 =
Build.DEVICE = OP5154L1
Build.DISPLAY = MT2110_13.1.0.100(CN01) //设备版本号
Build.FINGERPRINT = OnePlus/MT2110_CH/OP5154L1:13/TP1A.220905.001/R.1038728_2_1:user/release-keys
Build.HARDWARE = qcom
Build.HOST = dg02-pool03-kvm97
Build.ID = TP1A.220905.001
Build.IS_DEBUGGABLE = false
Build.IS_EMULATOR = false
Build.MANUFACTURER = OnePlus //手机制造商
Build.MODEL = MT2110 //手机型号
Build.ODM_SKU = unknown
Build.PERMISSIONS_REVIEW_REQUIRED = true
Build.PRODUCT = MT2110_CH //产品名称
Build.RADIO = unknown
Build.SERIAL = unknown
Build.SKU = unknown
Build.SOC_MANUFACTURER = Qualcomm
Build.SOC_MODEL = SM8350
Build.SUPPORTED_32_BIT_ABIS = [Ljava.lang.String;@fea6460
Build.SUPPORTED_64_BIT_ABIS = [Ljava.lang.String;@3a22d19
Build.SUPPORTED_ABIS = [Ljava.lang.String;@2101de
Build.TAGS = release-keys
Build.TIME = 1683196675000
Build.TYPE = user
Build.UNKNOWN = unknown
                                                                            Build.USER = root

其中重要的属性已经设置了注释,所有的属性可以看官方文档。在这些属性中,我们一般使用 Build.MANUFACTURER 来判断手机厂商,使用 Build.MODEL 来判断手机的型号。



tips: 如果你是使用kotlin开发,可以使用 android.os.Build::class.java.fields.map { "Build.${it.name} = ${it.get(it.name)}"}.joinToString("\n") 方便的获取所有的属性



上面的获取机型的代码在鸿蒙系统(HarmonyOS)上也同样适用,下面是在华为P50 Pro的机型上测试打印的日志信息:



Build.BOARD = JAD
Build.BOOTLOADER = unknown
Build.BRAND = HUAWEI
Build.CPU_ABI = arm64-v8a
Build.CPU_ABI2 = 
Build.DEVICE = HWJAD
Build.DISPLAY = JAD-AL50 2.0.0.225(C00E220R3P4)
Build.FINGERPRINT = HUAWEI/JAD-AL50/HWJAD:10/HUAWEIJAD-AL50/102.0.0.225C00:user/release-keys
Build.FINGERPRINTEX = HUAWEI/JAD-AL50/HWJAD:10/HUAWEIJAD-AL50/102.0.0.225C00:user/release-keys
Build.HARDWARE = kirin9000
Build.HIDE_PRODUCT_INFO = false
Build.HOST = cn-east-hcd-4a-d3a4cb6341634865598924-6cc66dddcd-dcg9d
Build.HWFINGERPRINT = ///JAD-LGRP5-CHN 2.0.0.225/JAD-AL50-CUST 2.0.0.220(C00)/JAD-AL50-PRELOAD 2.0.0.4(C00R3)//
Build.ID = HUAWEIJAD-AL50
Build.IS_CONTAINER = false
Build.IS_DEBUGGABLE = false
Build.IS_EMULATOR = false
Build.IS_ENG = false
Build.IS_TREBLE_ENABLED = true
Build.IS_USER = true
Build.IS_USERDEBUG = false
Build.MANUFACTURER = HUAWEI
Build.MODEL = JAD-AL50
Build.NO_HOTA = false
Build.PERMISSIONS_REVIEW_REQUIRED = true
Build.PRODUCT = JAD-AL50
Build.RADIO = unknown
Build.SERIAL = unknown
Build.SUPPORTED_32_BIT_ABIS = [Ljava.lang.String;@a90e093
Build.SUPPORTED_64_BIT_ABIS = [Ljava.lang.String;@8ce98d0
Build.SUPPORTED_ABIS = [Ljava.lang.String;@366a0c9
Build.TAGS = release-keys
Build.TIME = 1634865882000
Build.TYPE = user
Build.UNKNOWN = unknown

综上,判断手机厂商的代码如下:


//是否是荣耀设备
fun isHonorDevice() = Build.MANUFACTURER.equals("HONOR", ignoreCase = true)
//是否是小米设备
fun isXiaomiDevice() = Build.MANUFACTURER.equals("Xiaomi", ignoreCase = true)
//是否是oppo设备
//realme 是oppo的海外品牌后面脱离了;一加是oppo的独立运营品牌。因此判断
//它们是需要单独判断
fun isOppoDevice() = Build.MANUFACTURER.equals("OPPO", ignoreCase = true)
//是否是一加手机
fun isOnePlusDevice() = Build.MANUFACTURER.equals("OnePlus", ignoreCase = true)
//是否是realme手机
fun isRealmeDevice() = Build.MANUFACTURER.equals("realme", ignoreCase = true)
//是否是vivo设备
fun isVivoDevice() = Build.MANUFACTURER.equals("vivo", ignoreCase = true)
//是否是华为设备
fun isHuaweiDevice() = Build.MANUFACTURER.equals("HUAWEI", ignoreCase = true)

需要判断指定的型号的代码则为:


//判断是否是小米12s的机型
fun isXiaomi12S() = isXiaomiDevice() && Build.MODEL.contains("2206123SC"//xiaomi 12s

如果你不知道对应机型的型号,可以看基于谷歌维护的表格,支持超过27,000台设备。如下图所示:



判断手机的系统


除了机型外,适配过程中我们还需要考虑手机的系统。但是相比于手机机型,手机的系统的判断就没有统一的方式。下面介绍几个常用的os的判断


● 鸿蒙


private static final String HARMONY_OS = "harmony";
/**
* check the system is harmony os
*
* @return true if it is harmony os
*/
public static boolean isHarmonyOS() {
    try {
        Class clz = Class.forName("com.huawei.system.BuildEx");
        Method method = clz.getMethod("getOsBrand");
        return HARMONY_OS.equals(method.invoke(clz));
    } catch (ClassNotFoundException e) {
        Log.e(TAG, "occured ClassNotFoundException");
    } catch (NoSuchMethodException e) {
        Log.e(TAG, "occured NoSuchMethodException");
    } catch (Exception e) {
        Log.e(TAG, "occur other problem");
    }
    return false;
}

● Miui


fun checkIsMiui() = !TextUtils.isEmpty(getSystemProperty("ro.miui.ui.version.name"))

private fun getSystemProperty(propName: String): String? {
    val line: String
    var input: BufferedReader? = null
    try {
        val p = Runtime.getRuntime().exec("getprop $propName")
        input = BufferedReader(InputStreamReader(p.inputStream), 1024)
        line = input.readLine()
        input.close()
    } catch (ex: IOException) {
        Log.i(TAG, "Unable to read sysprop $propName", ex)
        return null
    } finally {
        if (input != null) {
            try {
                input.close()
            } catch (e: IOException) {
                Log.i(TAG, "Exception while closing InputStream", e)
            }
        }
    }
    return line
}

● Emui 或者 Magic UI


Emui是2018之前的荣耀机型的os,18年之后是 Magic UI。历史关系如下图所示:



判断的代码如下。需要注意是对于Android 12以下的机型,官方文档并没有给出对于的方案,下面代码的方式是从网上找的,目前测试了4台不同的机型,均可正在判断。


fun checkIsEmuiOrMagicUI()Boolean {
    return if (Build.VERSION.SDK_INT >= 31) {
//官方方案,但是只适用于api31以上(Android 12)
        try {
            val clazz = Class.forName("com.hihonor.android.os.Build")
            Log.d(TAG, "clazz = " + clazz)
            true
        }catch (e: ClassNotFoundException) {
            Log.d(TAG, "no find class")
            e.printStackTrace()
            false
        }
    } else {
//网上方案,测试了 荣耀畅玩8C
// 荣耀20s、荣耀x40 、荣耀v30 pro 四台机型,均可正常判断
        !TextUtils.isEmpty(getSystemProperty("ro.build.version.emui"))
    }
}

● Color Os


下面是网上判断是否是Oppo的ColorOs的代码。经测试,在 OPPO k10 、 oppo findx5 pro、 oneplus 9RT 手机上都是返回 false,只有在 realme Q3 pro 机型上才返回了 true。


//这段代码是错的
fun checkIsColorOs() = !TextUtils.isEmpty(getSystemProperty("ro.build.version.opporom"))

从测试结果可以看出上面这段代码是错的,但是在 ColorOs 的官网上,没有找到如何判断ColorOs的代码。这种情况下,有几种方案:


1.  判断手机制造商,即 Build.MANUFACTURER 如果为 oneplus、oppo、realme就认为它是ColorOs


2.  根据系统应用的包名判断,即判断是否带有 com.coloros.* 的系统应用,如果有,就认为它是ColorOs


这几种方案都有很多问题,暂时没有找到更好的解决方法。


● Origin Os


//网上代码,在 IQOQ Neo5、vivo Y50、 vivo x70三种机型上
//都可以正常判断
fun checkIsOriginOs() = !TextUtils.isEmpty(getSystemProperty("ro.vivo.os.version"))

总结


对于手机厂商和机型,我们可以通过Android原生的 android.os.Build  类来判断。使用 Build.MANUFACTURER 来判断手机厂商,使用 Build.MODEL 来判断手机的型号。如果是不知道的型号,还可以尝试在谷歌维护的手机机型表格中查询。


但是对于厂商的系统,就没有统一的判断方法了。部分厂商有官方提供的判断方式,如Miui、Magic UI;部分厂商暂时没有找到相关的内容。这种情况下,只能通过网上的方式判断,但是部分内容也不靠谱,如判断Oppo的ColorOs。如果你有靠谱的方式,欢迎补充。


参考



作者:小墙程序员
来源:juejin.cn/post/7241056943388983356
收起阅读 »

自己没有价值之前,少去谈人情世故

昨天和几个网友在群里聊天,一个网友说最近公司辞退了一个人,原因就是太菜了,有一个功能是让从数据库随机查一条数据,他硬是把整个数据表的数据都查出来,然后从里面随机选一条数据。 另外的群友说,这人应该在公司的人情世故做得不咋滴,要是和自己组长,领导搞好关系,不至于...
继续阅读 »

昨天和几个网友在群里聊天,一个网友说最近公司辞退了一个人,原因就是太菜了,有一个功能是让从数据库随机查一条数据,他硬是把整个数据表的数据都查出来,然后从里面随机选一条数据。


另外的群友说,这人应该在公司的人情世故做得不咋滴,要是和自己组长,领导搞好关系,不至于被辞退。


发言人说:相反,这人的人情世故做得很到位,和别人相处得也挺好,说话又好听,大家都觉得他很不错!


但是这有用吗?


和自己的组长关系搞好了,难道他就能给你的愚蠢兜底?


这未免太天真,首先组长也是打工的,你以为和他关系好,他就能包庇你,容忍你不断犯错?


没有人会愿意冒着被举报的风险去帮助一个非亲非故的人,因为自己还要生活,老婆孩子还要等着用钱,包庇你,那么担风险的人就是他自己,他为何要这样做?


我们许多人总是觉得人情世故太重要了,甚至觉得比自己的能力重要,这其实是一个侮误区。


有这种想法的大多是刷垃圾短视频刷多了,没经历过社会的毒打,专门去学酒满敬人,茶满欺人。给领导敬酒杯子不能高过对方,最好直接跪下来……


那么人情世故重要吗?


重要,但是得分阶层,你一个打工的,领导连你名字都叫不出来,你见到他打声招呼,他都是用鼻子答应,你觉得你所谓的人情世故有意义吗?


你以为团建的时候跑上去敬酒,杯子直接低到他脚下,他就会看中你,为他挡酒他就觉得你这人可扶?未免电视看得太多。


人情世故有用的前提一定是建立在你有被利用的价值之上,你能漂漂亮亮做完一件事,问题又少,创造的价值又多,那么别人就会觉得你行,就会记住你,重视你,至于敬酒这些,不过是走个过场而已。


所以在自己没有价值之前,别去谈什么人情世故,安安心心提升自己。


前段时间一个大二的小妹妹叫我帮她运行一个项目,她也是为了课程蒙混过关,后面和她聊了几句,她叫我给她一点建议。


我直接给她说,你真正的去写了几行代码?看了几本书?做了多少笔记?你真正的写了代码,看了书,有啥疑问你再找我,而不是从我这里找简便方法,因为我也没有!


她说最烦学习了,完全不想学,自己还是去学人情世故了。


我瞬间破放了,对她说你才20岁不到,专业知识不好好学,就要去学人情世故了?你能用到人情世故吗?


你是怕以后去进厂自己人情世故不到位别人不要你?还是以后去ktv陪酒或者当营销学不会?这么早就做准备了?


她后面反驳我说:你看那些职场里面的女生不也是很懂人情世故吗,你为啥说没用,这些东西迟早都是要学的,我先做准备啊!


我当时就不想和她聊下去了,我知道又是垃圾短视频看多了,所以才会去想这些!以为自己不好好学习,毕业后只要人情世故做到位,就能像那些女职场秘书一样,陪着领导出去谈生意。


想啥呢!


当然,并不存在歧视别人的想法,因为我没有资格,只不过是觉得该学习的时间别去想一些没啥用的事情!


我们所能看到的那些把人情世故运用得炉火纯青,让人感觉很自然的人,别人肯定已经到了一定的段位,这是TA的职业需要。


而大多数人都是在底层干着街边老太太老大爷都能干的活,领导连你名字都叫不出来,可以用空气人来形容,你说人情世故有什么卵用吗?


这不就等于把自己弄得四不像吗?


当你真的有利用价值,能够给别人提供解决方案的时候,再来谈人情世故,那时候你不学,生活都会逼着你去学。


最后说一句,当你有价值的时候,人情世故是你别人学来用在你身上的,不信你回头去看一下自己的身边的人,哪怕是一个小学教师,都有人提着东西来找他办事,但是如果没有任何利用价值,哪怕TA把酒场上面的套路都运用得炉火纯青,也会成为别人的笑柄!


作者:苏格拉的底牌
来源:juejin.cn/post/7352799449456738319
收起阅读 »

三个开发者,支撑一万亿的活跃使用量

对于很多开发者来说,SQLite 一定不陌生。 也知道它很强,但是没想到居然这么强。 SQlite 目前超一万亿(1e121e121e12)的活跃使用量。 它主要用于: 平台包含SQLite移动设备每一台安卓设备,每一台 iPhone 和 iOS 设备计算机每...
继续阅读 »

对于很多开发者来说,SQLite 一定不陌生。


也知道它很强,但是没想到居然这么强。


SQlite 目前超一万亿(1e121e12)的活跃使用量。


它主要用于:


平台包含SQLite
移动设备每一台安卓设备,每一台 iPhone 和 iOS 设备
计算机每一台 Mac,每一台 Windows10 机器
网络浏览器每一款 Firefox、Chrome 和 Safari网络浏览器
通讯应用每一个 Skype 实例
媒体应用每一个 iTunes 实例,每一个 Dropbox 客户端
财务软件每一款 TurboTax 和 QuickBooks
编程语言PHP 和 Python
家庭娱乐大多数电视机和机顶盒
汽车大多数汽车多媒体系统
其他无数百万其他应用程序


👉 表格来源于:http://www.sqlite.org/mostdeploye…



而 SQLite 的全部开发者,也就三个人:




👉 图片来源于:http://www.sqlite.org/crew.html




  • D. Richard Hipp2000 年 5 月 29 日开始 SQLite 项目,并继续担任项目架构师。理查德在北卡罗来纳州夏洛特出生、生活和工作。他拥有佐治亚理工学院(电子工程硕士学位,1984 年)和杜克大学(博士学位,1992 年)学位,并且是咨询公司 Hwaci 的创始人。

  • Dan Kennedy :澳大利亚人,目前居住在东南亚。他拥有昆士兰大学计算机系统工程学位,曾在多个领域工作过,包括工业自动化、计算机图形和嵌入式软件开发。Dan 是主要贡献者自 2002 年起使用 SQLite。

  • Joe Mistachkin(发音为“miss-tash-kin”):软件工程师,也是 Tcl/Tk 的维护者之一。他也是 TclBridge 组件和 Eagle 脚本语言的作者。他自 1994 年以来一直在软件行业工作。


另外一件有趣的事情是,SQLite 不接受任何外来的代码贡献。


也就是说,SQLite 开源,但是并不开放代码贡献。


在 SQLite 的版权声明有提到:




👉 图片来源于:http://www.sqlite.org/copyright.h…



很多时候,都不得不感慨软件的边际成本


一份代码,可以分发给十个人用,也可以给十亿个人使用


三个开发者,就支撑一万亿的活跃使用量。


SQLite 创造的价值,无与伦比,科技改变世界。


REFERENCES



作者:吴楷鹏
来源:juejin.cn/post/7352877037125894180
收起阅读 »

我们开源啦!一键部署免费使用!Kubernetes上直接运行大数据平台!

导语:市场上首个 K8s 上的大数据平台,开源了!智领云自主研发的首个完全基于Kubernetes的容器化大数据平台Kubernetes Data Platform (简称KDP)开源啦!开发者只要准备好命令行工具,一键部署Hadoop,Hive,Spark,...
继续阅读 »

导语:市场上首个 K8s 上的大数据平台,开源了!

智领云自主研发的首个完全基于Kubernetes的容器化大数据平台

Kubernetes Data Platform (简称KDP)

开源啦!

开发者只要准备好命令行工具,一键部署

Hadoop,Hive,Spark,Kafka, Flink, MinIO ...

就可以创建以前要花几十万甚至几百万才可以买到的大数据平台

无需再花大量的时间和经费去做重复的研发

高度集成,单机即可体验大数据平台

在高级安装模式下

用户可在现有的K8s集群上集成运行大数据组件

不用额外单独建设大数据集群

项目地址:

https://github.com/linktimecloud/kubernetes-data-platform

辛辛苦苦研究出来的成果,为什么要开源?

这波格局开大,老板有话说

问题1:我们为什么要开源?

我们的产品一直是基于大数据开源生态体系建设的。之前就一直有开源回馈社区的计划,但是因为之前Kubernetes对于大数据组件的支持还不够成熟,我们也一直在迭代与Kubernetes的适配。现在我们的企业版已经在很多头部客户落地并且在生产环境下高效运行,觉得这个版本已经可以达到大部分生产级项目的需求,集成度以及可用性是能够帮到有类似需求的用户的,希望这次开源能够降低在Kubernetes上集成大数据组件的门槛,让更多Kuberenetes和big data社区的同行们可以使用。

问题2:开源版本的KDP,能干啥?

KDP可以很方便的在Kubenetes上安装和管理常用的大数据组件,Hadoop,Hive,Spark,Kafka, Flink, MinIO 等等,不需要自己一个一个去适配,可以直接开始使用。然后KDP也提供集成的运维管理界面,用户可以从界面管理所有组件的安装配置,运行状况,资源使用情况,修改配置。而且KDP会将一个大数据组件的所有负载(容器,pod)作为一个整体管理,用户不需要在Kubernetes的控制平面上去管理单独的负载。

问题3:最大的亮点是?

只要你已经在使用Kubernetes,那么在现有集群上十几分钟就可以启动一个完整的大数据集群,马上开始使用,极大的降低了大数据平台的使用门槛。因为我们这个流程是高度集成的,整个安装过程在一个单机环境下也都能启动(例如使用单机kind虚拟集群都可以),所以在测试和实验环境下都可以高效使用。当然,启动之后Day 2的很多好处,例如资源的高效利用和集成的运维管理,也是KDP提供的重要功能。

KDP,即在Kubernetes上使用原生的分布式功能搭建及管理大数据平台。

将多套大数据组件集成在Kubernetes之上,同时提供一个整体的管理及运维工具体系,形成一个完全基于Kubernetes的大数据平台。企业级KDP更是支持在同一个Kubernetes集群中同时运行多个大数据平台以及多租户管理的能力,充分发挥Kubernetes云原生体系的优势。

KDP,通过对开源大数据组件的扩展和集成,实现了传统大数据平台到K8s大数据平台的平稳迁移。

作为市场上首个可完全在Kubernetes上部署的容器化云原生大数据平台,智领云自主研发的KDP,深度整合云原生架构优势,将大数据组件、数据应用及资源调度混排,纳入Kubernetes管理体系,从而带你真正玩转云原生!

总体框架

简单来讲,KDP可以允许客户在Kubernetes上运行它所有的大数据组件,并把它们作为一个整体管理起来。

在Kubernetes上运行大数据平台有三个好处:

第一,更高效的大数据组件集成:KDP提供标准化自动化的大数据组件部署和配置,极大地缩短了大数据项目开发和上线时间;

第二,更高效的大数据集群运管:KDP通过大数据组件与K8s的集成,在K8s之上搭建了一个大数据组件管理抽象层,标准化大数据组件生命周期管理,并提供UI界面进一步提升了部署、升级等操作的效率;

第三,更高的集群资源利用率:利用K8s的资源管理和配额机制,与其它系统共享K8s资源池,精细化资源管理,对比传统大数据平台约30%左右的资源利用率,KDP可大幅提升至60%以上。

社区

我们期待您的贡献和建议!最简单的贡献方式是参与Github议题/讨论的讨论。 如果您有任何问题,请与我们联系,我们将确保尽快为您解答。

微信群:添加小助手微信拉您进入交流群

钉钉群:搜索公开群组号 82250000662

贡献

参考开发者指南,了解如何开发及贡献 KDP。

https://linktimecloud.github.io/kubernetes-data-platform/docs/zh/developer-guide/developer-guide.html

收起阅读 »

宫崎骏系列电影:《你想活出怎样的人生》观后感

1 回想宫崎骏上一部上映的电影,已经是2013年,距今已有十一年之久,那时在唐山的网吧里,在微博偶然刷到了《起风了》的预告片,一开始就被主题曲《ひこうき雲》轻快动听的旋律吸引住了,加上当时的说法是,这部《起风了》应该是收官之作了,所以当时情绪很复杂,既有期待又...
继续阅读 »


1


回想宫崎骏上一部上映的电影,已经是2013年,距今已有十一年之久,那时在唐山的网吧里,在微博偶然刷到了《起风了》的预告片,一开始就被主题曲《ひこうき雲》轻快动听的旋律吸引住了,加上当时的说法是,这部《起风了》应该是收官之作了,所以当时情绪很复杂,既有期待又有不舍,不像《你想活出怎样的人生》,只是隐约看到一两则新闻,其他没有过多关注,虽然也有说是告别之作,但是竟没觉得有何特别之处,心态还是很坦然的,不过想到宫崎骏老爷子都八十四高龄了,这次应该真的是最后一部了。


2


你想活出怎样的人生?


通过电影名字,就可以知道这部电影的主旨,剩下的就是我们怎么能在这场奇幻旅途之中,寻找到想要的答案。


3


整部影片的剧情用一句话概括就是,二战之后,一个丧母的少年和一只会说话的苍鹭去寻找继母的奇幻旅程。


我本来想着,要把电影剧情一五一十地表述完整,但是,我感觉这个东西很容易变成流水账,完全是为了充字数,并不会有什么价值可言,是个人只要想写,都可以写出来,所以就此作罢。


我觉得观后感,是很自我的东西,不用非常刻意去挖掘多高深的内容,也不需要每看一部电影,好像带着写观后感的任务一样,那就违背了初衷,我不是为了写观后感才去看电影的,我是喜欢电影,喜欢这部电影才有感而发,是很纯粹的一些感受。


4


在电影院观看时,我一开始并不觉得音乐有多出色,至少相比之前那些作品而言,没有觉得很惊艳。但是现在在敲这些文字时,QQ音乐正好放着配乐《Ask me why》,居然觉得很好听,仿佛有一股暖流,慢慢流淌遍及我的全身,而《青サギ》则是清冽的感觉,像在深山的竹林,偶然听见风铃一样冰凉的声音。


5


宫崎骏的电影,没有绝对的善与恶,不同的立场,不同的选择,大家都只不过是为了自己坚持的正义而战罢了。


我很感恩,在我很小的时候,遇到了《千与千寻》和《哈尔的移动城堡》这两部电影,不管岁月如何变迁,不管社会如何险恶,不管人心如何复杂多变,我始终相信,会有那么一样东西,是如此纯粹,如此干净而美好的。


哪怕做不到具体的某个事物,只是某个瞬间,让我们回归到最初的自己,就好像从未改变过一样。


想到《火影忍者》里大蛇丸,找到君麻吕的时候,说的那一段话,抛开PUA洗脑这些因素不谈,整段话还是很有哲理的,带着对美好事物的向往。


“并没有所谓的活着一定就有意义这种事情,但是活下去,说不定能找到有趣的事情,就像你找到了那朵花一样,就像我找到了你一样。”


我又想起来2015年加入的QQ群,叫作06年神雕侠侣,里面就有位大兄弟说的一段话,我很喜欢,所以截图一直保存至今,我想这就是宫崎骏的电影,之所以如此迷人的原因。


“无论这个人走过怎么样的一条路,经历过什么样的沧桑浮华,对美好事物的爱永远都不会消失。”



6


这部电影中,让我印象比较深刻的是苍鹭,太好玩了,他的造型让我眼前一亮,本体是一个留着大鼻子的老头,披着苍鹭的外形,出场的画面都很滑稽,补鸟喙窟窿那段笑死我了哈哈哈,还有就是戴眼罩的样子,太可爱了。然后苍鹭露出原型振翅滑稽的飞行样子,让我想到了《千与千寻》里的苍蝇带着坊宝宝变的小白鼠起飞的样子。除此之外,从影片中还是能看出,有之前很多作品的影子,比如任意门就是对应的《哈尔的移动城堡》,进入异世界的入口就对应了《千与千寻》等等,想必宫崎骏老爷子也是想在告别之作,缅怀自己已逝去的岁月吧。



7


“我不怕火,能生下你,是我的荣幸。”


当时看到这一幕的时候,想到了丹尼斯·维伦纽瓦的电影《降临》里的剧情,对,就是《沙丘》的导演,女主因为接触到外星人,所以有了预知未来的能力,当她知道和男主在一起,会面临未来女儿死亡的结果时,当她只能眼睁睁看着结果发生时,拥有的同时就正在失去。


“如果你已经能一览自己的人生,从始到终,你会想做改变吗?”


“就算知晓整段旅程,知晓它如何终结,我也选择接受。选择去拥抱其中每个瞬间。”


有异曲同工之处,这一切都源自于爱。我还想到韩寒《乘风波浪》里最后那一声“妈咪”,也是很感人的。



8


要说影片觉得不好之处,应该是结局突然就结束了,感觉太匆忙了些,然后就是火美在看到真人老爸,也就是自己未来老公的时候,稍微多一些表情变化就好了,表现出一丝细腻的联系,电影中整得好像陌生人一样,缺少了一点温情。其他的都还好。


9


最后那段塔主人对真人说的那些话,其实也是本片的高潮之处。


“我穿越时间和空间,旅行了很远,才找到它们,你可以建造自己的塔,创造一个没有邪恶的国度,一个富裕和平而美好的世界。”


“你想回到那个充满了凶杀和盗窃的疯狂世界吗?它很快就会变成一片火海。”


“随你交朋友,回你的世界去,但你必须堆积这些石头。”


“我的塔撑不了多久时间了。”


有人说,真人其实是宫崎骏自己,但我觉得塔主人才是宫崎骏自己,在自己即将告别之际,让屏幕前的你我,勇敢地做出一个选择,你能活出怎样的人生?你敢活出怎样的人生?你想活出怎样的人生?


想到的是东野圭吾的《解忧杂货店》里的结尾,那个一张白纸的回信,十年前就很受感动,所以一直保留这个照片,而以这个作为结尾,同样很适用于《你想活出怎样的人生》这部电影想要传达的美好祝愿。



作者:AR7
来源:juejin.cn/post/7355692365330153512
收起阅读 »

Android后台驻留:保活和回收的机制

简介 众所周知,Android平台的管理机制下,App进入后台后,为了提供持续的及时服务(如推送、音乐),或进行驻留获取收益(跟踪、信息收集、广告)等,会利用一些方法来让自身保持活跃,躲过被Android系统或用户发觉、清理,实现后台驻留。 其中,后台驻留的广...
继续阅读 »

简介


众所周知,Android平台的管理机制下,App进入后台后,为了提供持续的及时服务(如推送、音乐),或进行驻留获取收益(跟踪、信息收集、广告)等,会利用一些方法来让自身保持活跃,躲过被Android系统或用户发觉、清理,实现后台驻留。


其中,后台驻留的广义概念,除了保持在后台运行外,被其他组件拉起也属于驻留(唤醒)。


由于驻留会对系统的性能、响应延迟、续航、发热等带来负面影响,令系统的资源管理效果降低,属于违背用户意愿和知情的恶劣行为,因此将这些App称为顽固(Diehard)应用,其利用的方法称为顽固方法。


除了App利用的一些黑科技(甚至是在违法边缘的擦边手段)以外,Android系统本身自带的机制也可以实现保活和拉起。这些保活、拉起机制,粗略划分为两类:



  1. 保持活跃,在后台运行不被清理、回收

  2. 被其他组件唤醒,包括被其他App唤醒、被系统提供的功能唤醒


本文总结上述这两类会被顽固App利用的机制。


进程和Task管理


首先简单梳理一下Android Framework层基本的进程管理。


Android平台基于Linux,除了基于Linux的“进程”维度来进行管理外,还按照Task的概念来管理应用进程,分别为ProcessRecord和TaskRecord。系统可以按Task也可以按Process来管理进程。


Android提供接口直接杀死Linux进程:1. ProcessRecord的kill()方法,其实现是向对应的进程发送SIGNAL_KILL信号;2. libc的kill()函数,也是发送信号


OOM终止进程(LMK)


App进程在系统中根据OOM(Out of Memory)ADJ(Adjustment)级别和进程状态来确定优先级,当系统需要杀死进程来释放内存时,优先级越低的会优先终止。OOM ADJ分数越小优先级越高。


由于顽固App进程后台驻留时可能会被系统回收,因此顽固App通常通过一些手段(services、弹窗)等来降低OOM(提高优先级),减少自身被系统回收的几率。


最近任务列表结束Task


用户在多任务界面(Recents)移除应用,系统会结束应用对应的Task:Removing Recent Task Item(RRT)。


该操作会结束掉与Task关联的进程,但在一些场景下仍然会有对应App的进程没有被杀死。



  1. 当App通过"Exclude from recents"功能(不在最近任务列表显示自己)时,没有提供给用户结束的机会,就没有手动结束掉Task的入口

  2. 当一个进程属于多个Task时(该进程还需要为其他Task服务)


这类终止机制由用户操作触发,当顽固应用借助多进程、多任务、唤醒拉起、互拉等操作,被终止后仍在后台运行(或后续又被唤醒),给用户感受为“杀不干净”。


强制结束App


强制结束(Force-Stop)时Android内建的功能,由ActivityManagerService提供接口,可以在设置-应用程序界面由用户手动调用。


强制结束的范畴是App对应的所有Task(即可以杀死一般App所有进程)。FSA还额外会将App设置为“STOPPED“状态,禁止应用在下一次被用户手动启用或应用跳转前被广播、服务等唤醒。强制结束对顽固App的效果不佳,许多顽固App具备Native保活能力、互拉保活、唤醒拉起等对抗措施。


此外,Android提供KILL_BACKGROUND_PROCESSES权限,允许具备权限的App调用API杀死ADJ大于SERVICE_ADJ的后台进程(即没有Service的后台进程可以被杀掉)。


保持活跃或唤醒


从最近任务隐藏或多个最近任务


Android平台提供的excludeFromRecents功能可以让App的Task在多任务中隐藏。此外一个进程可以属于不同的Task,产生多个Task并隐藏其中几个Task可以实现”杀不干净“的效果。


提升App进程优先级、阻止部分回收场景


LMK和OOM ADJ会受到进程状态和优先级的影响,提高优先级可以降低被系统回收的几率,阻止部分会杀进程的场景。


其中,将借助前台进程绑定后台服务进程保活的手段,是较常见的“杀不死、杀不干净”的情况(最近任务移除后仍有进程)。



  1. 接收广播,启动Receiver,具有Receiver的后台进程优先级高于无Receiver的后台进程

  2. 创建前台Service(高版本Android前台service需要带有通知),OOM ADJ更低(SERVICE_ADJ),杀死概率更低,此时进程不会被“杀死后台进程”杀掉(会跳过ADJ小于等于SERVICE_ADJ的进程)

  3. 保持前台Activity,OOM ADJ更低(用户可见的Task)

  4. 创建前台窗口(悬浮窗)或覆盖窗口(将窗口盖在前台App上面)

  5. 将后台服务绑定到前台进程,赋予后台服务在的进程更低的OOM,提升该进程的优先级,减少被杀的几率;同时对应进程不再属于后台进程,不会被“杀死后台进程”杀死,且该进程转为“需要为其他Task服务”,同样不会被最近任务移除时杀死

  6. 对于涉及Service的场景,ContentProvider也适用


借助Sticky Service唤醒


黏性Service是系统提供的机制,被杀死后会由系统调度进行重启。前述的force-stop杀死的进程,由于设置的“STOPPED”状态是会被跳过的,因此这种情况杀死的进程不会再自动重启。大多数ROM对此都有限制(次数、频率)。


借助广播唤醒


通过系统或其他App、组件发出的广播可以唤醒应用,顽固应用可以借助广播来完成唤醒自启。同样的,force-stop设置的“STOPPED”状态也会让广播跳过这些App,不会唤醒这些App来传递广播。但广播带有一个特例功能,带有FLAG_INCLUDE_STOPPED_PACKAGES的广播可以无视“STOPPED状态”,仍会唤醒force-stop的App。通常系统广播没有这个FLAG,基本上是其他应用发出的广播带有。


高版本的Android已经不再触发静态广播和隐式广播,这种唤醒方式少了很多。(但有FLAG_RECEIVER_INCLUDE_BACKGROUND和FLAG_INCLUDE_STOPPED_PACKAGES规避)


借助Alarm Service定时器唤醒


Alarm是Android提供的定时器功能,定时器timeout时会唤醒App。被force-stop的应用会自动移除掉注册的定时器,因此不会被唤醒。


借助Job Scheduling Service任务调度唤醒


与Alarm类似,定时唤醒App。但是受到电源管理策略、功耗管理策略、系统休眠状态、WorkManager等的影响,唤醒的定时精度较低,且不同ROM可能表现一致性较差。同样的,会跳过被force-stop的App。


借助其他App拉起唤醒


这是国内互联网App最恶心的一种机制,一群App(或集成的SDK)互相拉起对方、互相绑定提高优先级、互相拉起唤醒。其中,唤醒方式除了常规的四大组件外,还有一些黑科技、Native的方法。其中,App发出的广播带上FLAG_RECEIVER_INCLUDE_BACKGROUND和FLAG_INCLUDE_STOPPED_PACKAGES完全可以规避force-stop后"STOPPED"的应用,实现唤醒。


总结


可以说,Android本身的管理机、提供的组件间通信功能,叠加App们的流氓行为,可以说后台驻留、拉起唤醒是防不胜防的,实现较好的后台驻留管理需要较高的投入,且对系统稳定性、App基本功能的影响较大,是高投入高难度的研究方向。其中,App互拉唤醒和保活的机制,让force-stop机制做不到太好的效果,其"STOPPED"实现的类似的轻度冻结状态几乎报废,也是各大ROM厂商在后台管理部分大展身手的重要因素。


为了实现好的功耗、续航、性能,就需要在应用唤醒、冻结、暂停执行等方面下功夫了。


作者:飞起来_飞过来
来源:juejin.cn/post/7240251159763648573
收起阅读 »

微信小程序用户授权获取手机号流程

web
在做小程序开发的过程中,经常会涉及到用户身份的问题,最普遍的就是要获取用户的手机号码,通过微信获取手机号码后可以减少很多操作,比如用户手机号码验证等,以及给用户发送提示短信等等。 ※ 正常情况下,小程序可获取的公开信息有:昵称,城市,ip等公开信息,如果想要手...
继续阅读 »

在做小程序开发的过程中,经常会涉及到用户身份的问题,最普遍的就是要获取用户的手机号码,通过微信获取手机号码后可以减少很多操作,比如用户手机号码验证等,以及给用户发送提示短信等等。


※ 正常情况下,小程序可获取的公开信息有:昵称,城市,ip等公开信息,如果想要手机号等非公开信息,前提是需要已认证的非个人小程序账号。


小程序具有非常简洁的api,通过小程序内部封装,只要通过一个类型 调取他们的api方法,便可直接拉起授权。


接下来和大家聊聊,获取用户手机号码的api:getPhoneNumber


官方文档:developers.weixin.qq.com/miniprogram…


大致实现思路:


无标题.png


1687328383569.png


获取用户手机号码 分为以下几步:


第一步,点击页面获取授权按钮


第二步,获取用户授权参数


第三步,根据加解密算法解密手机号码


接下来我们来实现以上三步(包含前后端)


前端


代码:


<button open-type="getPhoneNumber" bindgetphonenumber="getPhoneNumber"></button>


只要在你的view里将此button放上,便可拉起授权。


分析:
open-type=“getPhoneNumber” 这个是官方给出的api。


bindgetphonenumber=“getPhoneNumber”,是调起授权框之后用户操作的回调 用户点击了拒绝还是接受,由此方法接收通知。


bindgetphonenumber:官方指定点击事件。 如果使用uniapp则需要改为@getphonenumber,并遵循uniapp开发规范。


至于getPhoneNumber


下面看代码


getPhoneNumber (e) {
var that = this;
if (e.detail.errMsg == 'getPhoneNumber:fail user deny') { //用户点击拒绝
wx.showToast({
title: '请绑定手机号',
duration: 5000,
icon: 'none',
});
} else {
}
}

getPhoneNumber:fail user deny 拒绝与否,这个是依据
※ 很多博客上写着 getPhoneNumber:user deny 缺少了fail 。


以下为获取手机号封装方法:


getPhoneNumber(e) {
var that = this;
wx.login({
success (res) {
if (res.code) {
console.log('步骤2获检查用户登录状态,获取用户电话号码!', res)
wx.request({
url: '这里写自己的获取授权的服务器地址',
data: {code: res.code},
header: {'content-type': 'application/json'},
success: function(res) {
console.log("步骤三获取授权码,获取授权openid,session_key",res);
var userphone=res.data.data;
wx.setStorageSync('userphoneKey',userphone);
//解密手机号
var msg = e.detail.errMsg;
var sessionID=wx.getStorageSync("userphoneKey").session_key;
var encryptedData=e.detail.encryptedData;
var iv=e.detail.iv;
if (msg == 'getPhoneNumber:ok') {//这里表示获取授权成功
wx.checkSession({
success:function(){
//这里进行请求服务端解密手机号
that.deciyption(sessionID,encryptedData,iv);
},
fail:function(){
// that.userlogin()
}
})
}

},fail:function(res){
console.log("fail",res);
}
})
} else {
console.log('登录失败!' + res.errMsg)
}
}
})

以上


e.detail.iv
e.detail.encryptedData


两个必传 传到后台 换取mobile


后端


不管是spring还是servlet只要请求能进到该方法即可,所以重点关注中间部分,把参数值传正确即可
1687329461422.png


工具类方法 WxUtil.doGetStr(url)


1687329536242.png


以上值可以返回给前端,前端可以收到三个参数:openid,session_key,expires_in。


接着我们通过授权之后,获取第三个参数iv,调用下面方法进行服务端解密


1687329672055.png


服务端解密代码参考:


1687329836867.png


deciphering解密方法参考:


1687329894328.png


以上


作者:SC前端开发
来源:juejin.cn/post/7246997498571554871
收起阅读 »

我二姨卖猪为什么不能自己决定价格

我二姨既养猪也养牛,收益是赔的时候更多。 如果你有投资猪肉股经验,一定知道猪周期。要是在猪肉下行周期中囤猪,那就等着赔吧,赔多少而已。 我年轻的时候就想,为啥二姨自己养的猪,自己却不能决定卖多少价格?多年过去这个问题总算是有点眉目。 本质是二姨在利用市场销售自...
继续阅读 »

我二姨既养猪也养牛,收益是赔的时候更多。


如果你有投资猪肉股经验,一定知道猪周期。要是在猪肉下行周期中囤猪,那就等着赔吧,赔多少而已。


我年轻的时候就想,为啥二姨自己养的猪,自己却不能决定卖多少价格?多年过去这个问题总算是有点眉目。


本质是二姨在利用市场销售自己养的猪,而市场有其自身的规则,单一家庭养猪户是没有办法决定市场猪价的。


一 市场


市场准确的说是市场经济,自我国宋代就已诞生。


然而现代市场经济理论的奠基人是一位西方经济学家——亚当·斯密,就是写了《国富论》的作者。


在《国富论》中其详细阐述了自由市场经济的原理。他提出了“看不见的手”理论,认为在自由竞争的市场中,每个人都在追求自己的利益,这种追求会像一只“看不见的手”一样,引导市场资源向最有利于社会的方向分配。


在姚洋的《经济学的意义》中提到福利经济学第一定律:如果由市场自己去生产和交换,最后经济总会达到帕累托最优。提到福利经济学第二定律:任何的帕累托最优状态,通过调整初始的禀赋分配,最后都能在市场机制下实现。帕累托最优指的是不可能在不牺牲任何人利益的情况下改善其他人的福利的状态。


所以市场经济被认为是配置资源最好的方式,至少目前还没找到比她更好的方式。


曾经一位伟大的国人说,他所做的事情不过是对我们的国家做了一次市场化改革。


现在我们建设的是具有中国特色的市场经济。


二 边际与均衡价格


边际是一种思维方式,就是永远看市场中最后一个人的行为或者最后一个产品的情况。比如在劳动力市场上,工资不是市场中的平均水平的劳动者决定的,而是最后一个参加劳动的人决定的,要看给他多高的工资他才愿意去做这份工作,同时也要看他有多大的贡献工厂才雇用他,两者相等的时候才是市场里的均衡工资。


边际能够解释一些实际问题。比如高速费的收取,如果不收取高速费,会导致高速拥堵,收取高速费导致对高速使用价格敏感者退出,所以说高速价格不是由第一个人决定的,而是最后一些人决定的。


边际也是新商品上市后价格的演化,直到形成均衡价格。就生猪市场而言,其是成熟市场,均衡价格已经形成。在均衡价格下,价格决定于供求关系,决定于价值链,决定于生猪出厂价格和猪肉消费价格。


我的老家在河北,我从我妈那里了解到我们老家农村的猪肉价格是10/斤元上下;而我在北京小区超市看到的是13/斤元上下。这个价格我认为肯定不是大家口袋里没钱造成的。


我又看了下A股几家上规模的生猪养殖集团:牧原股份、温氏股份、正邦科技。三者在2023年都是大幅亏损,其中正邦科技更是st了。而2020年牧原股份大幅度盈利200多个亿,我还查到2020年河北9月份的平均生猪价格,为33.73元/公斤,这都赶上今年的猪肉价了。


这样的数据结果表明今年的猪肉或者生猪价格,主要是供给导致,是生猪太多,生猪养殖太卷,不得不低价销售导致。


总结一下,生猪养殖市场均衡价格由供求关系决定,供求关系就像是天平,只有其上的砝码发生较大变化时才会影响平衡。就生猪市场来说,供求关系可以被牧原股份这种千万生猪体量的养殖集团影响,可以被一场范围特别大的猪瘟影响。


单一家庭养猪户因为生猪体量非常非常小,影响力微乎其微,不可能影响供求关系,也就不可能决定生猪价格,这也就是我二姨不能决定卖猪价格的原因。


三 周期


这部分属于题外话。不仅猪市场存在周期;文明也有周期,表现为王朝的兴衰更替;经济本身也存在周期,比如加息周期和降息周期;现在更有万物皆周期一说。


一种解释是,周期的产生源于人的贪婪。有一句话著名的话:人们从历史中吸取的教训就是从不吸取教训


文明周期源于王朝的后期统治者普遍开始奢侈,导致统治力衰弱,最终王朝灭亡,比如烽火戏诸侯。


经济周期源于债务,也是贪婪。债务越借越大,越借越不想还,就比如现在的美利坚,你看他的国债多大了,一年利息都1w多亿。


或许周期本源于人性,源于这个世界本身,且看那天地有四季,有日月更替。


尾声


现在二姨已经不养猪了。如果还养猪,我会建议要当有大猪场倒闭时再进入,这个时候市场上能卖猪的少了,而想买猪的没变,均衡价格该起来了。


作者:通往自由之路pro
来源:juejin.cn/post/7352100456334639114
收起阅读 »

如何快速实现一个无缝轮播效果

web
需求简介 轮播图是我们前端开发中的一个常见需求,在项目开发中,我们可以使用element、ant等UI库实现。某些场景,为了一个简单的功能安装一个库是没必要的,我们最好的选择就是手搓。 我们来看一个需求 上述需求核心就是实现一个无缝轮播的切换效果。以这个需求...
继续阅读 »

需求简介


轮播图是我们前端开发中的一个常见需求,在项目开发中,我们可以使用element、ant等UI库实现。某些场景,为了一个简单的功能安装一个库是没必要的,我们最好的选择就是手搓。


我们来看一个需求



上述需求核心就是实现一个无缝轮播的切换效果。以这个需求为例,我们看看最终实现效果:



实现思路


要想实现一个无缝的轮播效果,其实非常简单,核心思想就是动态改变显示的列表而已。比如我们有这样一个数组


const list = ref([
{ name: 1, id: 1 },
{ name: 2, id: 2 },
{ name: 3, id: 3 }
])

如果我们想无缝切换的展示这个数据,最简单的代码就是动态的改变下面的代码的index


<template>
<div>
{{ list[index] }}
</div>

</template>
<script setup>
const index = ref(0)
const list = ref([{ name: 1, id: 1 }, { name: 2, id: 2 }, { name: 2, id: 2 }])
<scriptp>

那如何实现切换的样式呢?也非常简单,我们只要给元素添加一个出现样式和离开样式即可。现在,我们来具体实现这样一个需求。


技术方案


数据的动态切换


要想实现一个数据的动态循环切换效果,是非常容易的:


<template>
<div v-for="(build, index) in list" :key="index">
<div v-show="index === selectIndex">
卡片自定义内容
</div>
</div>

</template>
<script setup>
const selectIndex = ref(0)
const list = ref(
[{ name: "卡片1", id: 1 }, { name: "卡片1", id: 2 }, { name: "卡片1", id: 2 }]
)

// #计时器实例
let timer: any = null

// >计时器逻辑
const timeFuc = () => {
timer = setInterval(() => {
// 更改选中的index
if (selectIndex.value >= list.value.length - 1) {
selectIndex.value = 0
} else {
selectIndex.value++
}
}, 5000)
}

timeFuc()
<scriptp>

上述代码中,我们设置了一个定时器,定时器每5s执行一次,每次执行都会动态更改当前要显示的数据索引值,当索引值达到最大实,在将其重置。通过上述的简单代码,我们就实现了一个可以自动切换的循环渲染的卡片。


动画添加


要想实现最终效果的动态效果也非常容易,我们只需要给每个元素出现时设置一些样式,离开时设置一些样式即可。借助vue的Transition组件,我们能很容易实现这样一个效果。



如果你不了解vue的Transition组件,请去官网补充下知识:cn.vuejs.org/guide/built…



<template>
<div class="main-content">
<Transition v-for="(build, index) in list" :key="selectIndex">
<div class="banner-scroll-wrap" v-show="index === selectIndex">
卡片自定义内容
</div>
</Transition>
</div>

</template>
<script setup>
const selectIndex = ref(0)
const list = ref(
[{ name: "卡片1", id: 1 }, { name: "卡片1", id: 2 }, { name: "卡片1", id: 2 }]
)

// #计时器实例
let timer: any = null

// >计时器逻辑
const timeFuc = () => {
timer = setInterval(() => {
// 更改选中的index
if (selectIndex.value >= list.value.length - 1) {
selectIndex.value = 0
} else {
selectIndex.value++
}
}, 5000)
}

timeFuc()
<scriptp>
<style lang="less" scoped>
.main-content {
position: relative;
height: 100%;
.banner-scroll-wrap {
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
}
}

.v-enter-from {
transform: translateX(100%);
opacity: 0;
}

.v-enter-active,
.v-leave-active {
transition: transform 600ms ease-in-out, opacity 600ms ease-in-out;
}

.v-enter-to {
transform: translateX(0);
opacity: 1;
}

.v-leave-from {
transform: translateX(0);
opacity: 1;
}

.v-leave-to {
transform: translateX(-100%);
opacity: 0;
}
<style/>

上述代码中,由于 selectIndex是动态的,元素不断地在显示与隐藏。因此,Transition标签的进入样式和离开样式会动态触发,从而形成切换效果。



v-enter是元素的进入样式,进入时,我们从最右边偏移100%的距离到正常位置,透明度从0到1,这个过程持续0.6s,实现了元素左移淡入的效果。




v-leave是元素的离开样式,离开时,我们从正常位置偏移到100%的左侧位置,透明度从1到0,这个过程持续0.6s,实现了元素左移淡出的效果。



在这些类的共同作用下,我们实现了元素的动态切换。


你可能注意到了我给元素设置了一个banner-scroll-wrap类名,并使用了position: absolute,这样设置的注意目的是保证切换离开元素的淡出效果和进入元素的淡入效果是连贯的。如果你不这样写,可能会出现样式问题。


此外,注意我给Transition设置了key="Transition",这样些会保证每次数据在切换时,transition能够重新渲染,触发元素离开和进入的样式。


至此,我们就完成了基本功能样式



轮播的停止与恢复


很常见的一种情况就是我们需要鼠标放在卡片上时停止轮播,离开卡片的时候恢复轮播,这非常容易。


<template>
<div class="main-content" @mouseenter="stop()" @mouseleave="start()">
<Transition v-for="(build, index) in list" :key="selectIndex">
<div class="banner-scroll-wrap" v-show="index === selectIndex">
卡片自定义内容
</div>
</Transition>
</div>

</template>
<script setup>
const selectIndex = ref(0)
const list = ref(
[{ name: "卡片1", id: 1 }, { name: "卡片1", id: 2 }, { name: "卡片1", id: 2 }]
)

// #计时器实例
let timer: any = null

// >计时器逻辑
const timeFuc = () => {
timer = setInterval(() => {
// 更改选中的index
if (selectIndex.value >= list.value.length - 1) {
selectIndex.value = 0
} else {
selectIndex.value++
}
}, 5000)
}

// >开启轮播
const start = () => {
if (timer) return
timeFuc()
}

// >关闭轮播
const stop = () => {
clearInterval(timer)
timer = null
}

timeFuc()
<scriptp>
<style lang="less" scoped>
<style/>

解决重影问题


在某些情况下,我们离开这个页面很久后(浏览器切换到其他选项卡),然后在切回来的时候,可能会出现短暂的画面重影问题,这个问题也很好解决,加上下面的代码即可


<script setup>

//...

// 解决切屏后重影的问题
onMounted(() => {
document.addEventListener('visibilitychange', () => {
// 用户息屏、或者切到后台运行 (离开页面)
if (document.visibilityState === 'hidden') {
stop()
}
// 用户打开或回到页面
if (document.visibilityState === 'visible') {
start()
}
})
})

onBeforeUnmount(() => stop())

<scriptp>

visibilitychange 事件:当其选项卡的内容变得可见或被隐藏时,会在 document 上触发 visibilitychange 事件。该事件不可取消。


总结


在本教程中,我们通过简单代码实现了无缝轮播效果,样式是左右切换,我们也可以通过样式控制实现上下切换的效果,比如将translateX设置为translateY即可。


 .v-enter-from {
transform: translateY(100%);
opacity: 0;
}

时间原因,本教程也没有对技术上做深究,也希望各位大佬能提供自己的思路与建议,感谢大家分享!


作者:石小石Orz
来源:juejin.cn/post/7351790785743978537
收起阅读 »

打造聊天框丝滑滚动体验:AI 聊天框的翻转之道 ——— 聊天框发送消息后自动滚动到底部(前端框架通用)

web
逐字渲染的挑战 最近在开发AI聊天助手的时候,遇到了一个很有趣的滚动问题。我们需要开发一个类似微信聊天框的交互体验: 每当聊天框中展示新消息时,需要将聊天框滚动到底部,展示最新消息。 如果在 web 什么也不做,聊天体验可能是这样的,需要用户手动滚动到最新消...
继续阅读 »

逐字渲染的挑战


最近在开发AI聊天助手的时候,遇到了一个很有趣的滚动问题。我们需要开发一个类似微信聊天框的交互体验:


每当聊天框中展示新消息时,需要将聊天框滚动到底部,展示最新消息。


请在此添加图片描述


如果在 web 什么也不做,聊天体验可能是这样的,需要用户手动滚动到最新消息:


请在此添加图片描述


试想一下如何在 web 中实现微信的效果。每当聊天框中接收到新消息时,都需要调用滚动方法滚动到消息底部。


element.scrollIntoView({ behavior: "smooth", block: "end");

对于普通的聊天工具来说,这样实现没有什么大问题,因为聊天框接收到每条消息的长度都是确定的。但是 AI 大模型一般都是逐字渲染的,AI 助手聊天框接受的消息体大小不是固定的,而是会随着 AI 大模型的输出不断变大。如果仍使用 scrollIntoView 来滚动到底部,就需要监听消息体的变化,每次消息更新时都要通过 JavaScript 调用一次滚动方法,会造成一些问题:



  1. 频繁的 JavaScript 滚动调用。每输出一个文字要滚动一次,听起来就会性能焦虑。

  2. AI 正在输出内容时,用户无法滚动查看历史消息。用户向上滚动查看历史消息,会被 Javascript 不断执行的 scrollIntoView 打断。需要写特殊逻辑才能避免这个情况。

  3. 通过监听数据变化频繁的执行滚动,基于浏览器单线程的设计,不可避免的会造成滚动行为的滞后,导致聊天体验不够丝滑。


自然列表:灵感来源


聊天框接收到新消息时滚动到最新位置,总感觉这应该是一个很自然的行为,不需要这么多 Javascript 代码去实现滚动行为。


于是联想到了 Excel 表格,当我们在表格中第一行插入一行,这一行后边的内容会被很自然的挤下去。并不需要做什么滚动,这一行就会出现在最顶部的位置。


请在此添加图片描述


想到这里惊讶的发现,聊天框实际上不就是一个倒过来的列表吗? 列表最上边新增的行会把后边的行往下挤,而聊天框最下边新增消息需要把上边的消息往上挤。那假如我们将聊天框旋转 180° 呢...?


聊天框的翻转实现


翻转聊天框


请在此添加图片描述


利用 CSS transform: rotate(180deg) 将整个聊天框倒转,并且把接收到最新的消息插入到消息列表的头部。发现我们的设想确实是行得通的,新增的消息很自然的把历史消息顶了上去,消息卡片内容增加也能很自然的撑开。并且在消息输出时,也可以随意滚动查看历史记录。


滚动条调整与滚动行为反转


最核心的问题已经解决了,但总觉得哪里看起来怪怪的。滚动条怎么跑到左边,并且滚动行为和鼠标滚轮的方向反了,滚轮向上滚,聊天框却向下滚。(让人想起了 MacOS 连鼠标滚轮的反人类体验)


查阅文档发现 CSS 有个 direction: rtl; 属性可以改变内容的排布的方向。这样我们就可以把滚动条放回右边了。然后在通过监听滚动事件,改变滚动方向就可以恢复鼠标滚轮的滚动行为。


element.addEventListener('wheel', event => {
event.preventDefault(); // 阻止默认滚动行为
const { deltaY } = event; // 获取滚动方向和速度
chatContent.current.scrollTop -= deltaY; // 反转方向
});

请在此添加图片描述


消息卡片翻转恢复


可以看到目前就只剩下聊天框中的消息卡片是反的,接下来把聊天框中的消息卡片转正就大功告成了。我们在聊天框中,给每个消息卡片都添加 transform: rotate(180deg);direction: ltr; 样式,把消息重新转正。


这样就把翻转的行为全部隔离在了聊天框组件中。消息卡片组件完全感知不到自己其实已经被旋转了 180° 后又旋转了 180° 了。聊天框的父组件也完全不知道自己的子节点被转了又转。


742ea972f92d4e7abc7344e75c331467.avif


总结


最后总结一下,我们通过两行 CSS 代码 + 反转滚动行为,利用浏览器的默认行为完美的实现了 AI 聊天框中的滚动体验。


transform: rotate(180deg);
direction: rtl;

element.addEventListener('wheel', event => {
event.preventDefault(); // 阻止默认滚动行为
const { deltaY } = event; // 获取滚动方向和速度
chatContent.current.scrollTop -= deltaY; // 反转方向
});

DEMO 仓库:github.com/lrwlf/messa…




更新:


想到一个更简洁的办法可以达到相同的效果,只用把聊天框 CSS 设置为:


display: flex;
flex-direction: column-reverse;

让列表倒序渲染,并且像原来的方法一样,在消息列表的头部插入消息,就可以实现一样的效果。不需要对聊天框和消息体再进行旋转操作,也不需要反转滚动条的行为。


以上两种方法都存在一个相同的问题,当一开始聊天消息还很少时,聊天消息也会紧贴着底部,顶部会留出一片空白。


请在此添加图片描述


这时只需要在聊天列表的最开始设置一个空白的占位元素,把它的 CSS 设置为:


flex-grow: 1;
flex-shrink: 1;

就可以实现消息少的时候自动撑开,把消息撑到顶部。消息列表开始滚动时,占位元素又会被挤压消失,不影响列表滚动效果。


(为了演示,把占位元素设置为了黑色)


请在此添加图片描述


更新部分代码见: github.com/lrwlf/messa…


将 App.js 的 chat 组件,替换为 src/components/chat-flex


作者:lrwlf
来源:juejin.cn/post/7306693980959588379
收起阅读 »

实现一个支持@的输入框

web
近期产品期望在后台发布帖子或视频时,需要添加 @用户 的功能,以便用户收到通知,例如“xxx在xxx提及了您!”。然而,现有的开源库未能满足我们的需求,例如 ant-design 的 Mentions 组件: 但是不难发现跟微信飞书对比下,有两个细节没有处...
继续阅读 »

近期产品期望在后台发布帖子或视频时,需要添加 @用户 的功能,以便用户收到通知,例如“xxx在xxx提及了您!”。然而,现有的开源库未能满足我们的需求,例如 ant-design 的 Mentions 组件:



20240415161851.gif


但是不难发现跟微信飞书对比下,有两个细节没有处理。



  1. @用户没有高亮

  2. 在删除时没有当做一个整体去删除,而是单个字母删除,首先不谈用户是否想要整体删除,在这块有个模糊查询的功能,如果每删一个字母之后去调接口查询数据库造成一些不必要的性能开销,哪怕加上防抖。


然后也是找了其他的库都没达到产品的期望效果,那么好,自己实现一个,先看看最终实现的效果


6ND88RssMr.gif


封装之后使用:


<AtInput
height={150}
onRequest={async (searchStr) => {
const { data } = await UserFindAll({ nickname: searchStr });
return data?.list?.map((v) => ({
id: v.uid,
name: v.nickname,
wechatAvatarUrl: v.wechatAvatarUrl,
}));
}}
onChange={(content, selected) => {
setAtUsers(selected);
}}
/>

那么实现这么一个输入框大概有以下几个点:



  1. 高亮效果

  2. 删除/选中用户时需要整体删除

  3. 监听@的位置,复制给弹框的坐标,联动效果

  4. 最后我需要拿到文本内容,并且需要拿到@那些用户,去做表单提交


大多数文本输入框我们会使用input,或者textarea,很明显以上1,2两点实现不了,antd也是使用的textarea,所以也是没有实现这两个效果。所以这块使用富文本编辑,设置contentEditable,将其变为可编辑去做。输入框以及选择器的dom就如下:


 <div style={{ height, position: 'relative' }}>
{/* 编辑器 */}
<div
id="atInput"
ref={atRef}
className={'editorDiv'}
contentEditable
onInput={editorChange}
onClick={editorClick}
/>

{/* 选择用户框 */}
<SelectUser
options={options}
visible={visible}
cursorPosition={cursorPosition}
onSelect={onSelect}
/>

</div>

实现思路:



  1. 监听输入@,唤起选择框。

  2. 截取@xxx的xxx作为搜素的关键字去查询接口

  3. 选择用户后需要将原先输入的 @xxx 替换成 @姓名,并且将@的用户缓存起来

  4. 选择文本框中的姓名时需要变为整体选中状态,这块依然可以给标签设置为不可编辑状态就可实现,contentEditable=false,即可实现整体删除,在删除的同时需要将当前用户从之前缓存的@过的用户数组删除

  5. 那么可以拿到输入框的文本,@的用户, 最后将数据抛给父组件就完成了


以上提到了监听@文本变化,通常绑定onChange事件就行,但是还有一种用户通过点击移动光标,这块需要绑定change,click两个时间,他们里边的逻辑基本一样,只需要额外处理点击选中输入框中用户时,整体选中g功能,那么代码如下:


    const onObserveInput = () => {
let cursorBeforeStr = '';
const selection: any = window.getSelection();
if (selection?.focusNode?.data) {
cursorBeforeStr = selection.focusNode?.data.slice(0, selection.focusOffset);
}
setFocusNode(selection.focusNode);
const lastAtIndex = cursorBeforeStr?.lastIndexOf('@');
setCurrentAtIdx(lastAtIndex);
if (lastAtIndex !== -1) {
getCursorPosition();
const searchStr = cursorBeforeStr.slice(lastAtIndex + 1);
if (!StringTools.isIncludeSpacesOrLineBreak(searchStr)) {
setSearchStr(searchStr);
fetchOptions(searchStr);
setVisible(true);
} else {
setVisible(false);
setSearchStr('');
}
} else {
setVisible(false);
}
};

const selectAtSpanTag = (target: Node) => {
window.getSelection()?.getRangeAt(0).selectNode(target);
};

const editorClick = async (event) => {
onObserveInput();
// 判断当前标签名是否为span 是的话选中当做一个整体
if (e.target.localName === 'span') {
selectAtSpanTag(e.target);
}
};

const editorChange = (event) => {
const { innerText } = event.target;
setContent(innerText);
onObserveInput();
};

每次点击或者文本改变时都会去调用onObserveInput,以上onObserveInput该方法中主要做了以下逻辑:




  1. 通过getSelection方法可以获取光标的偏移位置,那么可以截取光标之前的字符串,并且使用lastIndexOf从后向前查找最后一个“@”符号,并记录他的下标,那么有了【光标之前的字符串】,【@的下标】就可以拿到到@之后用于过滤用户的关键字,并将其缓存起来。

  2. 唤起选择器,并通过关键字去过滤用户。这块涉及到一个选择器的位置,直接使用window.getSelection()?.getRangeAt(0).getBoundingClientRect()去获取光标的位置拿到的是光标相对于窗口的坐标,直接用这个坐标会有问题,比如滚动条滚动时,这个选择器发生位置错乱,所以这块同时去拿输入框的坐标,去做一个相减,这样就可以实现选择器跟着@符号联动的效果。


 const getCursorPosition = () => {
// 坐标相对浏览器的坐标
const { x, y } = window.getSelection()?.getRangeAt(0).getBoundingClientRect() as any;
// 获取编辑器的坐标
const editorDom = window.document.querySelector('#atInput');
const { x: eX, y: eY } = editorDom?.getBoundingClientRect() as any;
// 光标所在位置
setCursorPosition({ x: x - eX, y: y - eY });
};

选择器弹出后,那么下面就到了选择用户之后的流程了,


 /**
* @param id 唯一的id 可以uid
* @param name 用户姓名
* @param color 回显颜色
* @returns
*/

const createAtSpanTag = (id: number | string, name: string, color = 'blue') => {
const ele = document.createElement('span');
ele.className = 'at-span';
ele.style.color = color;
ele.id = id.toString();
ele.contentEditable = 'false';
ele.innerText = `@${name}`;
return ele;
};

/**
* 选择用户时回调
*/

const onSelect = (item: Options) => {
const selection = window.getSelection();
const range = selection?.getRangeAt(0) as Range;
// 选中输入的 @关键字 -> @郑
range.setStart(focusNode as Node, currentAtIdx!);
range.setEnd(focusNode as Node, currentAtIdx! + 1 + searchStr.length);
// 删除输入的 @关键字
range.deleteContents();
// 创建元素节点
const atEle = createAtSpanTag(item.id, item.name);
// 插入元素节点
range.insertNode(atEle);
// 光标移动到末尾
range.collapse();
// 缓存已选中的用户
setSelected([...selected, item]);
// 选择用户后重新计算content
setContent(document.getElementById('atInput')?.innerText as string);
// 关闭弹框
setVisible(false);
// 输入框聚焦
atRef.current.focus();
};

选择用户的时候需要做的以下以下几点:



  1. 删除之前的@xxx字符

  2. 插入不可编辑的span标签

  3. 将当前选择的用户缓存起来

  4. 重新获取输入框的内容

  5. 关闭选择器

  6. 将输入框重新聚焦


最后


在选择的用户或者内容发生改变时将数据抛给父组件


 const getAttrIds = () => {
const spans = document.querySelectorAll('.at-span');
let ids = new Set();
spans.forEach((span) => ids.add(span.id));
return selected.filter((s) => ids.has(s.id));
};

/** @的用户列表发生改变时,将最新值暴露给父组件 */
useEffect(() => {
const selectUsers = getAttrIds();
onChange(content, selectUsers);
}, [selected, content]);

完整组件代码


输入框主要逻辑代码:


let timer: NodeJS.Timeout | null = null;

const AtInput = (props: AtInputProps) => {
const { height = 300, onRequest, onChange, value, onBlur } = props;
// 输入框的内容=innerText
const [content, setContent] = useState<string>('');
// 选择用户弹框
const [visible, setVisible] = useState<boolean>(false);
// 用户数据
const [options, setOptions] = useState<Options[]>([]);
// @的索引
const [currentAtIdx, setCurrentAtIdx] = useState<number>();
// 输入@之前的字符串
const [focusNode, setFocusNode] = useState<Node | string>();
// @后关键字 @郑 = 郑
const [searchStr, setSearchStr] = useState<string>('');
// 弹框的x,y轴的坐标
const [cursorPosition, setCursorPosition] = useState<Position>({
x: 0,
y: 0,
});
// 选择的用户
const [selected, setSelected] = useState<Options[]>([]);
const atRef = useRef<any>();

/** 获取选择器弹框坐标 */
const getCursorPosition = () => {
// 坐标相对浏览器的坐标
const { x, y } = window.getSelection()?.getRangeAt(0).getBoundingClientRect() as any;
// 获取编辑器的坐标
const editorDom = window.document.querySelector('#atInput');
const { x: eX, y: eY } = editorDom?.getBoundingClientRect() as any;
// 光标所在位置
setCursorPosition({ x: x - eX, y: y - eY });
};

/**获取用户下拉列表 */
const fetchOptions = (key?: string) => {
if (timer) {
clearTimeout(timer);
timer = null;
}
timer = setTimeout(async () => {
const _options = await onRequest(key);
setOptions(_options);
}, 500);
};

useEffect(() => {
fetchOptions();
// if (value) {
// /** 判断value中是否有at用户 */
// const atUsers: any = StringTools.filterUsers(value);
// setSelected(atUsers);
// atRef.current.innerHTML = value;
// setContent(value.replace(/<\/?.+?\/?>/g, '')); //全局匹配内html标签)
// }
}, []);

const onObserveInput = () => {
let cursorBeforeStr = '';
const selection: any = window.getSelection();
if (selection?.focusNode?.data) {
cursorBeforeStr = selection.focusNode?.data.slice(0, selection.focusOffset);
}
setFocusNode(selection.focusNode);
const lastAtIndex = cursorBeforeStr?.lastIndexOf('@');
setCurrentAtIdx(lastAtIndex);
if (lastAtIndex !== -1) {
getCursorPosition();
const searchStr = cursorBeforeStr.slice(lastAtIndex + 1);
if (!StringTools.isIncludeSpacesOrLineBreak(searchStr)) {
setSearchStr(searchStr);
fetchOptions(searchStr);
setVisible(true);
} else {
setVisible(false);
setSearchStr('');
}
} else {
setVisible(false);
}
};

const selectAtSpanTag = (target: Node) => {
window.getSelection()?.getRangeAt(0).selectNode(target);
};

const editorClick = async (e?: any) => {
onObserveInput();
// 判断当前标签名是否为span 是的话选中当做一个整体
if (e.target.localName === 'span') {
selectAtSpanTag(e.target);
}
};

const editorChange = (event: any) => {
const { innerText } = event.target;
setContent(innerText);
onObserveInput();
};

/**
* @param id 唯一的id 可以uid
* @param name 用户姓名
* @param color 回显颜色
* @returns
*/

const createAtSpanTag = (id: number | string, name: string, color = 'blue') => {
const ele = document.createElement('span');
ele.className = 'at-span';
ele.style.color = color;
ele.id = id.toString();
ele.contentEditable = 'false';
ele.innerText = `@${name}`;
return ele;
};

/**
* 选择用户时回调
*/

const onSelect = (item: Options) => {
const selection = window.getSelection();
const range = selection?.getRangeAt(0) as Range;
// 选中输入的 @关键字 -> @郑
range.setStart(focusNode as Node, currentAtIdx!);
range.setEnd(focusNode as Node, currentAtIdx! + 1 + searchStr.length);
// 删除输入的 @关键字
range.deleteContents();
// 创建元素节点
const atEle = createAtSpanTag(item.id, item.name);
// 插入元素节点
range.insertNode(atEle);
// 光标移动到末尾
range.collapse();
// 缓存已选中的用户
setSelected([...selected, item]);
// 选择用户后重新计算content
setContent(document.getElementById('atInput')?.innerText as string);
// 关闭弹框
setVisible(false);
// 输入框聚焦
atRef.current.focus();
};

const getAttrIds = () => {
const spans = document.querySelectorAll('.at-span');
let ids = new Set();
spans.forEach((span) => ids.add(span.id));
return selected.filter((s) => ids.has(s.id));
};

/** @的用户列表发生改变时,将最新值暴露给父组件 */
useEffect(() => {
const selectUsers = getAttrIds();
onChange(content, selectUsers);
}, [selected, content]);

return (
<div style={{ height, position: 'relative' }}>
{/* 编辑器 */}
<div id="atInput" ref={atRef} className={'editorDiv'} contentEditable onInput={editorChange} onClick={editorClick} />
{/* 选择用户框 */}
<SelectUser options={options} visible={visible} cursorPosition={cursorPosition} onSelect={onSelect} />
</div>

);
};

选择器代码


const SelectUser = React.memo((props: SelectComProps) => {
const { options, visible, cursorPosition, onSelect } = props;

const { x, y } = cursorPosition;

return (
<div
className={'selectWrap'}
style={{
display: `${visible ? 'block' : 'none'}`,
position: 'absolute',
left: x,
top: y + 20,
}}
>

<ul>
{options.map((item) => {
return (
<li
key={item.id}
onClick={() =>
{
onSelect(item);
}}
>
<img src={item.wechatAvatarUrl} alt="" />
<span>{item.name}</span>
</li>
);
})}
</ul>
</div>

);
});
export default SelectUser;

以上就是实现一个支持@用户的输入框功能,就目前而言,比较死板,不支持自定义颜色,自定义选择器等等,未来,可以进一步扩展功能,例如添加@用户的高亮样式定制、支持键盘快捷键操作等,从而提升用户体验和功能性。


作者:tech_zjf
来源:juejin.cn/post/7357917741909819407
收起阅读 »

Android 双屏异显自适应Dialog

一、前言 Android 多屏互联的时代,必然会出现多屏连接的问题,通常意义上的多屏连接包括HDMI/USB、WifiDisplay,除此之外Android 还有OverlayDisplay和VirtualDisplay,其中VirtualDisplay相比不...
继续阅读 »

一、前言


Android 多屏互联的时代,必然会出现多屏连接的问题,通常意义上的多屏连接包括HDMI/USB、WifiDisplay,除此之外Android 还有OverlayDisplay和VirtualDisplay,其中VirtualDisplay相比不少人录屏的时候都会用到,在Android中他们都是Display,除了物理屏幕,你在OverlayDisplay和VirtualDisplay同样也可以展示弹窗或者展示Activity,所有的Display的差异化通过DisplayManagerService 进行了兼容,同样任意一种Display都拥有自己的密度和大小以及display Id,对于测试双屏应用,一般也可以通过VirtualDisplay进行模拟操作。


企业微信20231224-132106@2x.png


需求


本篇主要解决副屏Dialog 组建展示问题。存在任意类型的副屏时,让 Dialog 展示在副屏上,如果不存在,就需要让它自动展示在主屏上。


为什么会有这种需求呢?默认情况下,实现双屏异显的时候, 通常不是使用Presentation就是Activity,然而,Dialog只能展示在主屏上,而Presentation只能展示的副屏上。想象一下这种双屏场景,在切换视频的时候,Loading展示应该是在主屏还是副屏呢 ?毫无疑问,答案当然是副屏。


问题


我们要解决的问题当然是随着场景的切换,Dialog展示在不同的屏幕上。同样,内容也可以这样展示,当存在副屏的时候在副屏上展示内容,当只有主屏的时候在主屏上展示内容。


二、方案


我们这里梳理一下两种方案。


方案:自定义Presentation


作为Presentation的核心点有两个,其中一个是displayId,另一个是WindowType,第一个是通常意义上指定Display Id,第二个是窗口类型。如果是副屏,那么displayId是必须的参数,且不能和DefaultDisplay的id一样,除此之外WindowType是一个需要重点关注的东西。


早期的 TYPE_PRESENTATION 存在指纹信息 “被借用” 而造成用户资产损失的风险,即便外部无法获取,但是早期的Android 8.0版本利用 (TYPE_PRESENTATION=TYPE_APPLICATION_OVERLAY-1)可以实现屏幕外弹框,在之后的版本做了修复,同时对 TYPE_PRESENTATION 展示必须有 Token 等校验,但是在这种过程中,Presentation的WindowType 变了又变,因此,我们如何获取到兼容每个版本的WindowType呢?


原理


Display Id的问题我们不需要重点处理,从display 获取即可。WindowType才是重点,方法当然是有的,我们不继承Presentation,而是继承Dialog因此自行实现可以参考 Presentation 中的代码,当然难点是 WindowManagerImpl 和WindowType类获取,前者 @hide 标注的,而后者不固定。


早期我们可以利用 compileOnly layoutlib.jar 的方式导入 WindowManagerImpl,但是新版本中 layoutlib.jar 中的类已经几乎被删,另外如果要使用 layoutlib.jar,那么你的项目中的 kotlin 版本就会和 layoutlib.jar 产生冲突,虽然可以删除相关的类,但是这种维护方式非常繁琐,因此我们这里借助反射实现。当然除了反射也可以利用Dexmaker或者xposed Hook方式,只是复杂性会很多。


WindowType问题解决

我们知道,创建Presentation的时候,framework源码是设置了WindowType的,我们完全在我们自己的Dialog创建Presentation对象,读取出来设置上到我们自己的Dialog上即可。


不过,我们先要对Display进行隔离,避免主屏走这段逻辑


WindowManager wm = (WindowManager) outerContext.getSystemService(WINDOW_SERVICE); 
if(display==null || wm.getDefaultDisplay().getDisplayId()==display.getDisplayId()){
return;
}

//注意,这里需要借助Presentation的一些属性,否则无法正常弹出弹框,要么有权限问题、要么有token问题


Presentation presentation = new Presentation(outerContext, display, theme);  
WindowManager.LayoutParams standardAttributes =presentation.getWindow().getAttributes();
final Window w = getWindow();
final WindowManager.LayoutParams attr = w.getAttributes();
attr.token = standardAttributes.token; w.setAttributes(attr);
//type 源码中是TYPE_PRESENTATION,事实上每个版本是不一样的,因此这里动态获取 w.setGravity(Gravity.FILL);
w.setType(standardAttributes.type);

WindowManagerImpl 问题

其实我们知道,Presentation的WindowManagerImpl并不是给自己用的,而是给Dialog上的其他组件(如Menu、PopWindow等),将其他组件加到Dialog的 Window上,因为在Android系统中,WindowManager都是parent Window所具备的能力,所以创建这个不是为了把Dialog加进去,而是为了把基于Dialog的Window组件加到Dialog上,这和Activity是一样的。那么,其实如果我们没有Menu、PopWindow,这里实际上是可以不处理的,但是作为一个完整的类,我们这里使用反射处理一下。


怎么处理呢?


我们知道,异显屏的Context是通过createDisplayContext创建的,但是我们这里并不是Hook这个方法,只是在创建这个Display Context之后,再通过ContextThemeWrapper,设置进去即可。


private static Context createPresentationContext(
Context outerContext, Display display, int theme)
{
if (outerContext == null) {
throw new IllegalArgumentException("outerContext must not be null");
}
WindowManager outerWindowManager = (WindowManager) outerContext.getSystemService(WINDOW_SERVICE);
if (display == null || display.getDisplayId()==outerWindowManager.getDefaultDisplay().getDisplayId()) {
return outerContext;
}
Context displayContext = outerContext.createDisplayContext(display);
if (theme == 0) {
TypedValue outValue = new TypedValue();
displayContext.getTheme().resolveAttribute(
android.R.attr.presentationTheme, outValue, true);
theme = outValue.resourceId;
}

// Derive the display's window manager from the outer window manager.
// We do this because the outer window manager have some extra information
// such as the parent window, which is important if the presentation uses
// an application window type.
// final WindowManager outerWindowManager =
// (WindowManager) outerContext.getSystemService(WINDOW_SERVICE);
// final WindowManagerImpl displayWindowManager =
// outerWindowManager.createPresentationWindowManager(displayContext);

WindowManager displayWindowManager = null;
try {
ClassLoader classLoader = ComplexPresentationV1.class.getClassLoader();
Class<?> loadClass = classLoader.loadClass("android.view.WindowManagerImpl");
Method createPresentationWindowManager = loadClass.getDeclaredMethod("createPresentationWindowManager", Context.class);
displayWindowManager = (WindowManager) loadClass.cast(createPresentationWindowManager.invoke(outerWindowManager,displayContext));
} catch (ClassNotFoundException | NoSuchMethodException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
final WindowManager windowManager = displayWindowManager;
return new ContextThemeWrapper(displayContext, theme) {
@Override
public Object getSystemService(String name) {
if (WINDOW_SERVICE.equals(name)) {
return windowManager;
}
return super.getSystemService(name);
}
};
}

全部源码


public class ComplexPresentationV1 extends Dialog  {

private static final String TAG = "ComplexPresentationV1";
private static final int MSG_CANCEL = 1;

private Display mPresentationDisplay;
private DisplayManager mDisplayManager;
/**
* Creates a new presentation that is attached to the specified display
* using the default theme.
*
* @param outerContext The context of the application that is showing the presentation.
* The presentation will create its own context (see {@link #getContext()}) based
* on this context and information about the associated display.
* @param display The display to which the presentation should be attached.
*/

public ComplexPresentationV1(Context outerContext, Display display) {
this(outerContext, display, 0);
}

/**
* Creates a new presentation that is attached to the specified display
* using the optionally specified theme.
*
* @param outerContext The context of the application that is showing the presentation.
* The presentation will create its own context (see {@link #getContext()}) based
* on this context and information about the associated display.
* @param display The display to which the presentation should be attached.
* @param theme A style resource describing the theme to use for the window.
* See <a href="{@docRoot}guide/topics/resources/available-resources.html#stylesandthemes">
* Style and Theme Resources</a> for more information about defining and using
* styles. This theme is applied on top of the current theme in
* <var>outerContext</var>. If 0, the default presentation theme will be used.
*/

public ComplexPresentationV1(Context outerContext, Display display, int theme) {
super(createPresentationContext(outerContext, display, theme), theme);
WindowManager wm = (WindowManager) outerContext.getSystemService(WINDOW_SERVICE);
if(display==null || wm.getDefaultDisplay().getDisplayId()==display.getDisplayId()){
return;
}
mPresentationDisplay = display;
mDisplayManager = (DisplayManager)getContext().getSystemService(DISPLAY_SERVICE);

//注意,这里需要借助Presentation的一些属性,否则无法正常弹出弹框,要么有权限问题、要么有token问题
Presentation presentation = new Presentation(outerContext, display, theme);
WindowManager.LayoutParams standardAttributes = presentation.getWindow().getAttributes();

final Window w = getWindow();
final WindowManager.LayoutParams attr = w.getAttributes();
attr.token = standardAttributes.token;
w.setAttributes(attr);
w.setType(standardAttributes.type);
//type 源码中是TYPE_PRESENTATION,事实上每个版本是不一样的,因此这里动态获取
w.setGravity(Gravity.FILL);
setCanceledOnTouchOutside(false);
}

/**
* Gets the {@link Display} that this presentation appears on.
*
* @return The display.
*/

public Display getDisplay() {
return mPresentationDisplay;
}

/**
* Gets the {@link Resources} that should be used to inflate the layout of this presentation.
* This resources object has been configured according to the metrics of the
* display that the presentation appears on.
*
* @return The presentation resources object.
*/

public Resources getResources() {
return getContext().getResources();
}

@Override
protected void onStart() {
super.onStart();

if(mPresentationDisplay ==null){
return;
}
mDisplayManager.registerDisplayListener(mDisplayListener, mHandler);

// Since we were not watching for display changes until just now, there is a
// chance that the display metrics have changed. If so, we will need to
// dismiss the presentation immediately. This case is expected
// to be rare but surprising, so we'll write a log message about it.
if (!isConfigurationStillValid()) {
Log.i(TAG, "Presentation is being dismissed because the "
+ "display metrics have changed since it was created.");
mHandler.sendEmptyMessage(MSG_CANCEL);
}
}

@Override
protected void onStop() {
if(mPresentationDisplay ==null){
return;
}
mDisplayManager.unregisterDisplayListener(mDisplayListener);
super.onStop();
}

/**
* Inherited from {@link Dialog#show}. Will throw
* {@link android.view.WindowManager.InvalidDisplayException} if the specified secondary
* {@link Display} can't be found.
*/

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

/**
* Called by the system when the {@link Display} to which the presentation
* is attached has been removed.
*
* The system automatically calls {@link #cancel} to dismiss the presentation
* after sending this event.
*
* @see #getDisplay
*/

public void onDisplayRemoved() {
}

/**
* Called by the system when the properties of the {@link Display} to which
* the presentation is attached have changed.
*
* If the display metrics have changed (for example, if the display has been
* resized or rotated), then the system automatically calls
* {@link #cancel} to dismiss the presentation.
*
* @see #getDisplay
*/

public void onDisplayChanged() {
}

private void handleDisplayRemoved() {
onDisplayRemoved();
cancel();
}

private void handleDisplayChanged() {
onDisplayChanged();

// We currently do not support configuration changes for presentations
// (although we could add that feature with a bit more work).
// If the display metrics have changed in any way then the current configuration
// is invalid and the application must recreate the presentation to get
// a new context.
if (!isConfigurationStillValid()) {
Log.i(TAG, "Presentation is being dismissed because the "
+ "display metrics have changed since it was created.");
cancel();
}
}

private boolean isConfigurationStillValid() {
if(mPresentationDisplay ==null){
return true;
}
DisplayMetrics dm = new DisplayMetrics();
mPresentationDisplay.getMetrics(dm);
try {
Method equalsPhysical = DisplayMetrics.class.getDeclaredMethod("equalsPhysical", DisplayMetrics.class);
return (boolean) equalsPhysical.invoke(dm,getResources().getDisplayMetrics());
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
return false;
}

private static Context createPresentationContext(
Context outerContext, Display display, int theme)
{
if (outerContext == null) {
throw new IllegalArgumentException("outerContext must not be null");
}
WindowManager outerWindowManager = (WindowManager) outerContext.getSystemService(WINDOW_SERVICE);
if (display == null || display.getDisplayId()==outerWindowManager.getDefaultDisplay().getDisplayId()) {
return outerContext;
}
Context displayContext = outerContext.createDisplayContext(display);
if (theme == 0) {
TypedValue outValue = new TypedValue();
displayContext.getTheme().resolveAttribute(
android.R.attr.presentationTheme, outValue, true);
theme = outValue.resourceId;
}

// Derive the display's window manager from the outer window manager.
// We do this because the outer window manager have some extra information
// such as the parent window, which is important if the presentation uses
// an application window type.
// final WindowManager outerWindowManager =
// (WindowManager) outerContext.getSystemService(WINDOW_SERVICE);
// final WindowManagerImpl displayWindowManager =
// outerWindowManager.createPresentationWindowManager(displayContext);

WindowManager displayWindowManager = null;
try {
ClassLoader classLoader = ComplexPresentationV1.class.getClassLoader();
Class<?> loadClass = classLoader.loadClass("android.view.WindowManagerImpl");
Method createPresentationWindowManager = loadClass.getDeclaredMethod("createPresentationWindowManager", Context.class);
displayWindowManager = (WindowManager) loadClass.cast(createPresentationWindowManager.invoke(outerWindowManager,displayContext));
} catch (ClassNotFoundException | NoSuchMethodException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
final WindowManager windowManager = displayWindowManager;
return new ContextThemeWrapper(displayContext, theme) {
@Override
public Object getSystemService(String name) {
if (WINDOW_SERVICE.equals(name)) {
return windowManager;
}
return super.getSystemService(name);
}
};
}

private final DisplayManager.DisplayListener mDisplayListener = new DisplayManager.DisplayListener() {
@Override
public void onDisplayAdded(int displayId) {
}

@Override
public void onDisplayRemoved(int displayId) {
if (displayId == mPresentationDisplay.getDisplayId()) {
handleDisplayRemoved();
}
}

@Override
public void onDisplayChanged(int displayId) {
if (displayId == mPresentationDisplay.getDisplayId()) {
handleDisplayChanged();
}
}
};

private final Handler mHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case MSG_CANCEL:
cancel();
break;
}
}
};
}

方案:Delegate方式:


第一种方案利用反射,但是android 9 开始,很多 @hide 反射不被允许,但是办法也是很多的,比如 freeflection 开源项目,不过对于开发者,能减少对@hide的使用也是为了后续的维护。此外还有一个需要注意的是 Presentation 继承的是 Dialog 构造方法是无法被包外的子类使用,但是影响不大,我们在和Presentation的包名下创建我们的自己的Dialog依然可以解决。不过,对于反射天然厌恶的人来说,可以使用代理。


这种方式借壳 Dialog,套用 Dialog 一层,以代理方式实现,不过相比前一种方案来说,这种方案也有很多缺陷,比如他的onCreate\onShow\onStop\onAttachToWindow\onDetatchFromWindow等方法并没有完全和Dialog同步,需要做下兼容。


兼容


onAttachToWindow\onDetatchFromWindow


WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
if (display != null && display.getDisplayId() != wm.getDefaultDisplay().getDisplayId()) {
dialog = new Presentation(context, display, themeResId);
} else {
dialog = new Dialog(context, themeResId);
}
//下面兼容attach和detatch问题
mDecorView = dialog.getWindow().getDecorView();
mDecorView.addOnAttachStateChangeListener(this);

onShow和\onStop


@Override
public void show() {
if (!isCreate) {
onCreate(null);
isCreate = true;
}
dialog.show();
if (!isStart) {
onStart();
isStart = true;
}
}


@Override
public void dismiss() {
dialog.dismiss();
if (isStart) {
onStop();
isStart = false;
}
}

从兼容代码上来看,显然没有做到Dialog那种同步,因此只适合在单一线程中使用。


总结


本篇总结了2种异显屏弹窗,总体上来说,都有一定的瑕疵,但是第一种方案显然要好的多,主要是View更新上和可扩展上,当然第二种对于非多线程且不关注严格回调的需求,也是足以应付,在实际情况中,合适的才是最重要的。


作者:时光少年
来源:juejin.cn/post/7315846805920972809
收起阅读 »

HarmonyOS 鸿蒙下载三方依赖 ohpm环境搭建

前言ohpm(One Hundred Percent Mermaid )是一个集成了Mermaid的命令工具,可以用于生成关系图、序列图、等各种图表。我们可以使用ohpm来生成漂亮且可读性强的图表。本期教大家如何搭建ophm环境:一、在DevEco Studi...
继续阅读 »

前言

ohpm(One Hundred Percent Mermaid )是一个集成了Mermaid的命令工具,可以用于生成关系图、序列图、等各种图表。我们可以使用ohpm来生成漂亮且可读性强的图表。

本期教大家如何搭建ophm环境:

一、在DevEco Studio中,将依赖放到指定的 oh-package-json5 的 dependencies 内


二、打开 Terminal 执行 :ohpm install

(1)成功会提示

(2)失败会提示
ohpm not found ! 大概意思就是找不到这个ohpm

三、解决小标题(2)的失败提示:

1、查阅我们的ohpm地址
一定要记住这个地址
Mac端找到该位置路径 点击DevEco Studio ->Preferences

2、打开终端命令行 输入:echo $SHELL 输入后 单击回车

注:如果不需要以下图文引导 请向下拉 下方有无图文引导方式


3、提示 /bin/zsh

(1)执行: vi ~/.zshrc 后点击回车


(2)进入到该页面 输入 i

(3)拷贝:export OHPM_HOME=/Users/xxx/Library/Huawei/ohpm export PATH=${PATH}:${OHPM_HOME}/bin

中间的 xxx 输入 在标题三 1 图中 /Users 每个用户名都不一样 不要直接填xxx 按照路径给的填写即可。


(4)编辑完成后,单击ESC ,即可退出编辑模式,然后输入:wq!单击回车保存


(5)输入: source ~/.zshrc;


以下是无图文引导方式:

(1)执行: vi ~/.zshrc
(2)输入 i
(3)export OHPM_HOME=/Users/xxx/Library/Huawei/ohpm export PATH=${PATH}:${OHPM_HOME}/bin

中间的 xxx 输入 在 标题三的(1)图中 /Users 每个用户名都不一样 不要直接填xxx 按照路径给的填写即可。

(4)编辑完成后,单击ESC ,即可退出编辑模式,然后输入:wq!,单击回车保存
(5)输入: source ~/.zshrc;

4、提示/bin/base

(1)执行: vi ~/.bash_profile
(2)输入 i
(3)export OHPM_HOME=/Users/xxx/Library/Huawei/ohpm export PATH=${PATH}:${OHPM_HOME}/bin

中间的 xxx 输入 在 标题3的(1)图中 /Users 每个用户名都不一样 不要直接填xxx 按照路径给的填写即可(4)编辑完成后,单击ESC ,即可退出编辑模式,然后输入“:wq!”,单击回车保存
(5)输入: source ~/.bash_profile

四、检验 ohpm环境是否配置成功

命令行输入 export 查验是否有 ohpm

第二种检验方式 输入 ohpm -v 会显示你的版本


相关文档:

收起阅读 »

Android:实现带边框的输入框

如今市面上APP的输入框可以说是千奇百怪,不搞点花样出来貌似代表格局没打开。还在使用系统自带的输入框的兄弟可以停下脚步,哥带你实现一个简易的带边框输入框。 话不多说,直接上图: 要实现这个效果,不得不再回顾下自定义View的流程,感兴趣的童鞋可以自行网上搜...
继续阅读 »

如今市面上APP的输入框可以说是千奇百怪,不搞点花样出来貌似代表格局没打开。还在使用系统自带的输入框的兄弟可以停下脚步,哥带你实现一个简易的带边框输入框。



话不多说,直接上图:
1.gif


要实现这个效果,不得不再回顾下自定义View的流程,感兴趣的童鞋可以自行网上搜索,这里只提及该效果涉及到的内容。总体实现大致流程:



  • 继承AppCompatEditText

  • 配置可定义的资源属性

  • onDraw() 方法的重写


首先还得分析:效果图中最多只能输入6个数字,需要计算出每个文字的宽高和间隙,再分别绘制文字背景和文字本身。从中我们需要提取背景颜色、高度、边距等私有属性,通过新建attrs.xml文件进行配置:


<declare-styleable name="RoundRectEditText">
<attr name="count" format="integer"/>
<attr name="itemPading" format="dimension"/>
<attr name="strokeHight" format="dimension"/>
<attr name="strokeColor" format="color"/>/>
</declare-styleable>

这样在初始化的时候即可给你默认值:


val typedArray =context.obtainStyledAttributes(it, R.styleable.RoundRectEditText)
count = typedArray.getInt(R.styleable.RoundRectEditText_count, count)
itemPading = typedArray.getDimension(R.styleable.RoundRectEditText_itemPading,0f)
strokeHight = typedArray.getDimension(R.styleable.RoundRectEditText_strokeHight,0f)
strokeColor = typedArray.getColor(R.styleable.RoundRectEditText_strokeColor,strokeColor)
typedArray.recycle()

接下来便是重头戏,如何绘制文字和背景色。思路其实很简单,通过for循环去遍历绘制每一个数字。关键点还在于去计算每个文字的位置及宽高,只要得到了位置和宽高,绘制背景和绘制文字易如反掌。


获取每个文字宽度:


strokeWith =(width.toFloat() - paddingLeft.toFloat() - paddingRight.toFloat() - (count - 1) * itemPading) / count

文字居中需要计算出对应Y值:


val fontMetrics = paint.fontMetrics
val textHeight = fontMetrics.bottom - fontMetrics.top
val distance = textHeight / 2 - fontMetrics.bottom
val baseline = height / 2f + distance

文字的X值则根据当前index和文字宽度以及各边距得出:


private fun getIndexOfX(index: Int): Float {
return paddingLeft.toFloat() + index * (itemPading + strokeWith) + 0.5f * strokeWith
}

得到了位置,宽高接下来的步骤再简单不过了。使用drawText 绘制文字,使用drawRoundRect 绘制背景。这里有一个细节一定要注意,绘制背景一定要在绘制文字之前,否则背景会把文字给覆盖。


另外,还需要注意一点。如果onDraw方法中不注释掉超类方法,底部会多出一段输入的数字。其实很好理解,这是AppCompatEditText 自身绘制的数字,所以我们把它注释即可,包括光标也是一样。如果想要光标则需要自己在onDraw方法中绘制即可。


//隐藏自带光标
super.setCursorVisible(false)

override fun onDraw(canvas: Canvas) {
//不注释掉会显示在最底部
// super.onDraw(canvas)
......
}

以上便是实现带边框的输入框的全部类型,希望对大家有所帮助!


作者:似曾相识2022
来源:juejin.cn/post/7271056651129995322
收起阅读 »

状态🐔到底如何优雅的实现

状态机的组成 状态机是一种抽象的数学模型,描述了对象或系统在特定时间点可能处于的各种状态以及状态之间的转换规则。它由一组状态、事件、转移和动作组成,用于模拟对象在不同条件下的行为和状态变化。 状态机包括以下基本组成部分: 状态(State):表示对象或系统...
继续阅读 »

状态机的组成


状态机是一种抽象的数学模型,描述了对象或系统在特定时间点可能处于的各种状态以及状态之间的转换规则。它由一组状态、事件、转移和动作组成,用于模拟对象在不同条件下的行为和状态变化。


image-20240423094124100

状态机包括以下基本组成部分:



  • 状态(State):表示对象或系统当前的状态,例如开、关、就绪等。

  • 事件(Event):触发状态转换的动作或条件,例如按钮点击、消息到达等。

  • 转移(Transition):定义了从一个状态到另一个状态的转换规则,通常与特定事件相关联。

  • 动作(Action):在状态转换过程中执行的操作或行为,例如更新状态、记录日志等。


状态机,也就是 State Machine ,不是指一台实际机器,而是指一个数学模型。说白了,一般就是指一张状态转换图。例如,根据自动门的运行规则,我们可以抽象出下面这么一个图。


image-20240423095540911


简单实现


在计算机中,状态机通常用编程语言来实现。在 C、C++、Java、Python 等编程语言中,可以通过使用 switch-case 语句、if-else 语句、状态转移表等来实现状态机。在下面还有更加优雅的方式,使用 Spring 状态机 来实现。


if-else 实现状态机


在上面的示例中,我们使用 if-else 结构根据当前活动来控制音乐的播放状态,并执行相应的行为。代码如下:


public class BasketballMusicStateMachineUsingIfElse {
private boolean isPlayingMusic;

public BasketballMusicStateMachineUsingIfElse() {
this.isPlayingMusic = false; // 初始状态为音乐未播放
}

public void playMusic() {
if (!isPlayingMusic) {
System.out.println("Music starts playing...");
isPlayingMusic = true;
}
}

public void stopMusic() {
if (isPlayingMusic) {
System.out.println("Music stops playing...");
isPlayingMusic = false;
}
}

public void performActivity(String activity) {
if ("basketball".equals(activity)) {
System.out.println("Music~");
playMusic(); // 打篮球时播放音乐
} else if ("sing_rap".equals(activity)) {
System.out.println("哎哟你干嘛!");
stopMusic(); // 唱跳Rap时停止音乐
} else {
System.out.println("Invalid activity!");
}
}

public static void main(String[] args) {
BasketballMusicStateMachineUsingIfElse stateMachine = new BasketballMusicStateMachineUsingIfElse();

// 测试状态机
stateMachine.performActivity("basketball"); // 打篮球,音乐开始播放
stateMachine.performActivity("sing_rap"); // 唱跳Rap,音乐停止播放
stateMachine.performActivity("basketball"); // 再次打篮球,音乐重新开始播放
}
}

switch-case 实现状态机


在这个示例中,我们使用 switch-case 结构根据不同的活动来控制音乐的播放状态,并执行相应的行为。代码如下:


public class BasketballMusicStateMachineUsingSwitchCase {
private boolean isPlayingMusic;

public BasketballMusicStateMachineUsingSwitchCase() {
this.isPlayingMusic = false; // 初始状态为音乐未播放
}

public void playMusic() {
if (!isPlayingMusic) {
System.out.println("Music starts playing...");
isPlayingMusic = true;
}
}

public void stopMusic() {
if (isPlayingMusic) {
System.out.println("Music stops playing...");
isPlayingMusic = false;
}
}

public void performActivity(String activity) {
switch (activity) {
case "basketball":
System.out.println("Music ~");
playMusic(); // 打篮球时播放音乐
break;
case "sing_rap":
System.out.println("哎哟 你干嘛 ~");
stopMusic(); // 唱跳Rap时停止音乐
break;
default:
System.out.println("Invalid activity!");
}
}

public static void main(String[] args) {
BasketballMusicStateMachineUsingSwitchCase stateMachine = new BasketballMusicStateMachineUsingSwitchCase();

// 测试状态机
stateMachine.performActivity("basketball"); // 打篮球,音乐开始播放
stateMachine.performActivity("sing_rap"); // 唱跳Rap,音乐停止播放
stateMachine.performActivity("basketball"); // 再次打篮球,音乐重新开始播放
}
}


是不是感觉状态机其实经常在我们的日常使用中捏~,接下来带大家使用更优雅的状态机 Spring 状态机。


image-20240423100302874

使用 Spring 状态机


1)引入依赖


<dependency>
<groupId>org.springframework.statemachine</groupId>
<artifactId>spring-statemachine-core</artifactId>
<version>2.0.1.RELEASE</version>
</dependency>

2)定义状态和事件的枚举


代码如下:


public enum States {
IDLE, // 空闲状态
PLAYING_BB, // 打篮球状态
SINGING // 唱跳Rap状态
}
public enum Event {
START_BB_MUSIC, // 开始播放篮球音乐事件
STOP_BB_MUSIC // 停止篮球音乐事件
}

3)配置状态机


代码如下:


@Configuration
@EnableStateMachine
public class BasketballMusicStateMachineConfig extends EnumStateMachineConfigurerAdapter<States, Event> {

@Autowired
private BasketballMusicStateMachineEventListener eventListener;

@Override
public void configure(StateMachineConfigurationConfigurer<States, Event> config) throws Exception {
config
.withConfiguration()
.autoStartup(true)
.listener(eventListener); // 设置状态机事件监听器
}

@Override
public void configure(StateMachineStateConfigurer<States, Event> states) throws Exception {
states
.withStates()
.initial(States.IDLE)
.states(EnumSet.allOf(States.class));
}

@Override
public void configure(StateMachineTransitionConfigurer<States, Event> transitions) throws Exception {
transitions
.withExternal()
.source(States.IDLE).target(States.PLAYING_BB).event(Event.START_BB_MUSIC)
.and()
.withExternal()
.source(States.PLAYING_BB).target(States.SINGING).event(Event.STOP_BB_MUSIC)
.and()
.withExternal()
.source(States.SINGING).target(States.PLAYING_BB).event(Event.START_BB_MUSIC);
}
}

4)定义状态机事件监听器


代码如下:


@Component
public class BasketballMusicStateMachineEventListener extends StateMachineListenerAdapter<States, Event> {

@Override
public void stateChanged(State<States, Event> from, State<States, Event> to) {
if (from.getId() == States.IDLE && to.getId() == States.PLAYING_BB) {
System.out.println("开始打篮球,music 起");
} else if (from.getId() == States.PLAYING_BB && to.getId() == States.SINGING) {
System.out.println("唱跳,你干嘛");
} else if (from.getId() == States.SINGING && to.getId() == States.PLAYING_BB) {
System.out.println("继续打篮球,music 继续");
}
}
}

5)编写单元测试


@SpringBootTest
class ChatApplicationTests {
@Resource
private StateMachine<States, Event> stateMachine;

@Test
void contextLoads() {
//开始打球,music 起
stateMachine.sendEvent(Event.START_BB_MUSIC);
//开始唱跳,你干嘛
stateMachine.sendEvent(Event.STOP_BB_MUSIC);
//继续打球,music 继续
stateMachine.sendEvent(Event.START_BB_MUSIC);

}
}

效果如下:


image-20240423103523546


在上面的示例中,我们定义了一个状态机,用于控制在打篮球时音乐的播放和唱跳 Rap 的行为。通过触发事件来执行状态转移,并通过事件监听器监听状态变化并执行相应的操作。


image-20240423103604502


作者:cong_
来源:juejin.cn/post/7360647839448088613
收起阅读 »

数据连接已满,导致新连接无法成功

个人项目中经常会把每个项目的平台部署成开发、测试环境,而数据库就有可能是多个平台共用一个了,现在基本上都是用的微服务架构,那么数据库连接就不够用了。 我们用的是MySQL数据库,最近遇到了这个尴尬的问题,本地修改了代码启动的时候经常会连不上数据库既然连接太多,...
继续阅读 »

个人项目中经常会把每个项目的平台部署成开发、测试环境,而数据库就有可能是多个平台共用一个了,现在基本上都是用的微服务架构,那么数据库连接就不够用了。


我们用的是MySQL数据库,最近遇到了这个尴尬的问题,本地修改了代码启动的时候经常会连不上数据库既然连接太多,要么减少连接,要么扩大最大可连接数,


经过查询,MySQL 数据库 的默认最大连接数是经常设置在151的默认值,而最大连接数可以达到16384个


对应报错信息一般为:


com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure


too many connections



  1. 查看数据库当前连接信息,可以看到连接数据库的进程id,ip,用户名,连接的数据库,连接状态,连接时长等如果发现有大量的sleep状态的连接进程,则说明该参数设置的过大,可以进行适当的调整小些。




    1. SHOW FULL processlist;




    2. 在MySQL中,使用 SHOW FULL PROCESSLIST; 命令可以显示系统中所有当前运行的线程,包括每个线程的状态、是否锁定等信息。这个命令输出的表格中包含的字段具有以下含义:



      1. Id: 线程的唯一标识符。这个ID可以用来引用特定的线程,例如,在需要终止一个特定的线程时可以使用 KILL 命令。

      2. User: 启动线程的MySQL用户。

      3. Host: 用户连接到MySQL服务器的主机名和端口号,显示格式通常是 host_name:port

      4. db: 当前线程所在的数据库。如果线程没有使用任何数据库,这一列可能显示为NULL。

      5. Command: 线程正在执行的命令类型,例如 QuerySleepConnect 等。

      6. Time: 命令执行的时间,以秒为单位。对于 Sleep 状态,这表示线程处于空闲状态的时长。

      7. State: 线程的当前状态,提供了正在执行的命令的额外信息。这可以是 Sending datasorting resultLocked 等。

      8. Info: 如果线程正在执行查询,则这一列显示具体的SQL语句。对于其他类型的命令,这一列可能为空或显示为NULL。


      Command 列显示为 Sleep 时,这意味着该线程当前没有执行任何查询,只是在连接池中等待下一个查询命令。通常,应用程序执行完一个查询后,连接可能会保持打开状态而不是立即关闭,以便可以重用该连接执行后续的查询。在这种状态下,线程不会使用服务器资源来处理任何数据,但仍占用一个连接槽。如果看到很多线程处于 Sleep 状态且持续时间较长,这可能是一个优化点,例如,通过调整应用逻辑或连接池设置来减少空闲连接的数量。






  2. 查询当前最大连接数和超时时间




    1. # 查看最大连接数
      show variables like '%max_connections%';
      # 查看非交互式超时时间 单位秒
      show variables like 'wait_timeout';
      # 查看交互式叫号时间 单位秒
      show variables like 'interactive_timeout';





      1. max_connections:



        1. max_connections 参数定义了数据库服务器能够同时接受的最大客户端连接数。当达到这个限制时,任何新的尝试连接的客户端将会收到一个错误,通常是“Too many connections”。

        2. 默认值通常基于系统的能力和配置,但经常设置在151的默认值。这个值可以根据服务器的硬件资源(如CPU和内存)和负载要求进行调整。



      2. mysqlx_max_connections:



        1. mysqlx_max_connections 参数是专门为MySQL的X协议(一种扩展的协议,支持更复杂的操作,如CRUD操作和实时通知)设定的最大连接数。X协议使得开发者能够使用NoSQL风格的接口与数据库交互。

        2. 默认值通常较小,因为X协议的使用还不如传统SQL协议普遍。这个参数允许你独立于max_connections控制通过X协议可能的连接数。



      3. wait_timeout:



        1. wait_timeout 设置的是非交互式(非控制台)客户端连接在变成非活动状态后,在被自动关闭之前等待的秒数。非交互式连接通常指的是通过网络或API等进行的数据库连接,如应用程序服务器到数据库的连接。

        2. 默认值通常较长,如8小时(28800秒),但这可以根据需要进行调整,特别是在连接数资源受限的环境中。



      4. interactive_timeout:



        1. interactive_timeout 适用于MySQL服务器与客户端进行交互式会话时的连接超时设置。交互式会话通常是指用户通过MySQL命令行客户端或类似工具直接连接并操作数据库。

        2. 这个超时值只会在MySQL服务器识别连接为交互式时应用。它的默认值也通常是8小时。








  3. 修改最大连接数和超时时间



    1. SQL直接改




      1. # 重启后失效 这里直接设置1000
        SET GLOBAL max_connections = 1000;
        # 设置全局 非交互连接 超时时间 单位秒
        SET GLOBAL wait_timeout = 300;




    2. 配置文件改



      1.     MySQL的配置文件通常是 my.cnf(在Linux系统中)或 my.ini(在Windows系统中)。你应该在 [mysqld] 部分中设置这些参数

      2.     在Linux系统上,MySQL的配置文件一般位于以下几个路径之一:

      3. /etc/my.cnf

      4. /etc/mysql/my.cnf

      5. /var/lib/mysql/my.cnf

      6.     具体位置可能会根据不同的Linux发行版和MySQL安装方式有所不同。你可以使用 find 命令来搜索这个文件,例如:


      7. sudo find / -name my.cnf




    3.   找到到文件后,将这些值修改为下列的值 这里直接设置1000


    4. [mysqld]
      max_connections = 1000

      wait_timeout = 300


    5. docker情况



      1. 使用Docker命令行参数

      2.     你可以在运行MySQL容器时通过Docker命令行直接设置配置参数,例如:


      3. docker run -d \
        -p 3306:3306 \
        --name mysql \
        -e MYSQL_ROOT_PASSWORD=my-secret-pw \
        -e MYSQL_DATABASE=mydatabase \
        mysql:tag --max-connections=1000 --wait-timeout=300


      4.     在这个例子中,--max-connections=1000 是作为命令行参数传递给MySQL服务器的。

      5. 修改配置文件并挂载

      6.     如果你需要修改多个配置项或者希望使用配置文件来管理设置,可以创建一个自定义的 my.cnf 文件,然后在启动容器时将它挂载到容器中适当的位置。例如:


      7. docker run -d \
        -p 3306:3306 \
        --name mysql \
        -e MYSQL_ROOT_PASSWORD=my-secret-pw \
        -v /path/to/your/custom/my.cnf:/etc/mysql/my.cnf \
        mysql:tag






  4. 修改完后再查一遍看看有没有改成功




    1. # 查看最大连接数
      show variables like '%max_connections%';
      # 查看非交互式超时时间
      SHOW GLOBAL VARIABLES LIKE 'wait_timeout';




  5. 拓展



    1. wait_timeout 变量分为全局级别和会话级别



      •     执行 SET GLOBAL wait_timeout = 300;后,

      •     使用执行 show variables like 'wait_timeout'; 发现并没有改变

      •     是因为在MySQL中,当你执行 SET GLOBAL wait_timeout = 300; 这条命令时,理论上应该是会设置全局的 wait_timeout 值为300秒。在查询 wait_timeout 时,没有指定是查询全局变量,可能会返回会话级的值。会话级的 wait_timeout 并没有被改变。尝试使用以下命令来查看全局设置:


      • # 查看全局变量
        SHOW GLOBAL VARIABLES LIKE 'wait_timeout';
        # 与之对应的,查看会话级别的变量可以使用:
        SHOW SESSION VARIABLES LIKE 'wait_timeout';




    2. 全局变量和会话变量的区别



      • 全局变量:



        1. 全局变量对服务器上所有当前会话和未来会话都有效。

        2. 当你设置一个全局变量时,它的值会影响所有新建的连接。然而,对于已经存在的连接,全局变量的更改通常不会影响它们,除非这些连接被重新启动或者明确地重新读取全局设置。

        3. 通过 SET GLOBAL 命令修改全局变量,或者在服务器的配置文件中设置并重新启动服务器。



      • 会话变量:



        1. 会话变量只对当前连接的会话有效,并且当会话结束时,会话变量的设置就会失效。

        2. 修改会话变量的命令是 SET SESSION 或者简单的 SET,它不会影响其他会话或连接。

        3. 每个新的会话都会从当前的全局设置中继承变量的值,但在会话中对这些变量的修改不会影响到其他会话。



      • 关于是否改变全局变量,这取决于你试图解决的具体问题:



        • 如果你需要修改的设置应该对所有新的连接生效,例如,修改 wait_timeout 来减少空闲连接超时,那么修改全局变量是合适的。这样,所有新建立的连接都会采用新的超时设置。

        • 然而,如果你需要立即影响当前活动的会话,你必须在每个会话中单独设置会话变量。这在某些操作中可能是必需的,比如调整当前事务的隔离级别或者调试中动态改变某些性能调优参数。

        • 因此,如果改变是为了长期或持久的配置调整,修改全局变量通常是正确的做法。但如果需要对当前会话立即生效的改变,应该使用会话变量。








作者:不惊夜
来源:juejin.cn/post/7361056871673446437
收起阅读 »

基于EdgeEffect实现RecyclerView列表阻尼滑动效果

探索EdgeEffect的花样玩法 1、EdgeEffect是什么 当用户在一个可滑动的控件内(如RecyclerView),滑动内容已经超过了内容边界时,RecyclerView通过EdgeEffect绘制一个边界图形来提醒用户,滑动已经到边界了,不要再滑动...
继续阅读 »

探索EdgeEffect的花样玩法


1、EdgeEffect是什么


当用户在一个可滑动的控件内(如RecyclerView),滑动内容已经超过了内容边界时,RecyclerView通过EdgeEffect绘制一个边界图形来提醒用户,滑动已经到边界了,不要再滑动啦。


简言之:就是通过边界图形来提醒用户,没啥内容了,别滑了。


2、EdgeEffect在RecyclerView的现象是什么


1、到达边界后的阴影效果


在RecyclerView列表中,滑动到边界还继续滑动或者快速滑动到边界,则现象如下图中的到达边界后产生的阴影效果。


滑动到边界阴影效果

2、如何去掉阴影效果


在布局中,可以设置overScrollMode的属性值为never即可。


或者在代码中设置,即可取消


recyclerView?.overScrollMode = View.OVER_SCROLL_NEVER

3、EdgeEffect在RecyclerView的实现原理是什么


1、onMove事件对应EdgeEffect的onPull


EdgeEffect在RecyclerView中大致流程可以参考下面这个图,以onMove事件举例


EdgeEffect与RecyclerView交互图

通过上面这个图,并结合下面的源码,就能对这个流程有个大致的理解。


@Override
public boolean onTouchEvent(MotionEvent e) {
...
switch (action) {
...
case MotionEvent.ACTION_MOVE: {
...
// (1) move事件
if (scrollByInternal(
canScrollHorizontally ? dx : 0,
canScrollVertically ? dy : 0,
e, TYPE_TOUCH)) {
getParent().requestDisallowInterceptTouchEvent(true);
}
...
}
}
break;
}
}


boolean scrollByInternal(int x, int y, MotionEvent ev, int type) {
...
// (2)判断是否设置了过度滑动,所以通过布局设置overScrollMode的属性值为never就走不进了分支逻辑中了
if (getOverScrollMode() != View.OVER_SCROLL_NEVER) {
if (ev != null && !MotionEventCompat.isFromSource(ev, InputDevice.SOURCE_MOUSE)) {
pullGlows(ev.getX(), unconsumedX, ev.getY(), unconsumedY);
}
considerReleasingGlowsOnScroll(x, y);
}
...

if (!awakenScrollBars()) {
// 刷新当前界面
invalidate();
}
return consumedNestedScroll || consumedX != 0 || consumedY != 0;
}

private void pullGlows(float x, float overscrollX, float y, float overscrollY) {
boolean invalidate = false;
...
// 顶部边界
if (overscrollY < 0) {
// 构建顶部边界的EdgeEffect对象
ensureTopGlow();
// 调用EdgeEffect的onPull方法 设置些属性
EdgeEffectCompat.onPull(mTopGlow, -overscrollY / getHeight(), x / getWidth());
invalidate = true;
}
...

if (invalidate || overscrollX != 0 || overscrollY != 0) {
// 刷新界面
ViewCompat.postInvalidateOnAnimation(this);
}
}

void ensureTopGlow() {
...
mTopGlow = mEdgeEffectFactory.createEdgeEffect(this, EdgeEffectFactory.DIRECTION_TOP);
// 设置边界图形的大小
if (mClipToPadding) {
mTopGlow.setSize(getMeasuredWidth() - getPaddingLeft() - getPaddingRight(),
getMeasuredHeight() - getPaddingTop() - getPaddingBottom());
} else {
mTopGlow.setSize(getMeasuredWidth(), getMeasuredHeight());
}

}

// RecyclerView的绘制
@Override
public void draw(Canvas c) {
super.draw(c);
...
if (mTopGlow != null && !mTopGlow.isFinished()) {
final int restore = c.save();
if (mClipToPadding) {
c.translate(getPaddingLeft(), getPaddingTop());
}
// 调用 EdgeEffect的draw方法
needsInvalidate |= mTopGlow != null && mTopGlow.draw(c);
c.restoreToCount(restore);
}
...
}

// EdgeEffect的draw方法
public boolean draw(Canvas canvas) {
...
update();
final int count = canvas.save();
final float centerX = mBounds.centerX();
final float centerY = mBounds.height() - mRadius;

canvas.scale(1.f, Math.min(mGlowScaleY, 1.f) * mBaseGlowScale, centerX, 0);

final float displacement = Math.max(0, Math.min(mDisplacement, 1.f)) - 0.5f;
float translateX = mBounds.width() * displacement / 2;

canvas.clipRect(mBounds);
canvas.translate(translateX, 0);
mPaint.setAlpha((int) (0xff * mGlowAlpha));
// 绘制扇弧
canvas.drawCircle(centerX, centerY, mRadius, mPaint);
canvas.restoreToCount(count);
...

同理:RecyclerView的 up 及Cancel事件对应调用EdgeEffect的onRelease;fling过度滑动对应EdgeEffect的onAbsorb方法


2、EdgeEffect的onPull、onRelease、onAbsorb方法


(1)onPull


对于RecyclerView列表而言,内容已经在顶部到达边界了,此时用户仍向下滑动时,会调用onPull方法及后续流畅,来更新当前视图,提示用户已经到边界了。


(2)onRelease


对于(1)的情况,用户松开了,不向下滑动了,此时释放拉动的距离,并刷新界面消失当前的图形界面。


(3)onAbsorb


用户过度滑动时,RecyclerView调用Fling方法,把内容到达边界后消耗不掉的距离传递给onAbsorb方法,让其显示图形界面提示用户已到达内容边界。


4、使用EdgeEffect在RecyclerView中实现列表阻尼滑动等效果


(1)先看下效果


EdgeEffect的录屏

上述gif图中展示了两个效果:RecyclerView的阻尼下拉 及 复位,这就是使用上面的EdgeEffect的三个方法可以实现。


上述的gif图中,使用MultiTypeAdapter实现RecyclerView的多类型页面(ViewModel、json数据源),可以参考这篇文章快速写个RecyclerView的多类型页面


下面主要展示如何构建一个EdgeEffect,充分地使用onPull、onRelease及onAbsorb能力


(2)代码示意


// 构建一个自定义的EdgeEffectFactory 并设置给RecyclerView
recyclerView?.edgeEffectFactory = SpringEdgeEffect()

// SpringEdgeEffect
class SpringEdgeEffect : RecyclerView\.EdgeEffectFactory() {

override fun createEdgeEffect(recyclerView: RecyclerView, direction: Int): EdgeEffect {

return object : EdgeEffect(recyclerView.context) {

override fun onPull(deltaDistance: Float) {
super.onPull(deltaDistance)
handlePull(deltaDistance)
}

override fun onPull(deltaDistance: Float, displacement: Float) {
super.onPull(deltaDistance, displacement)
handlePull(deltaDistance)
}

private fun handlePull(deltaDistance: Float) {
val sign = if (direction == DIRECTION_BOTTOM) -1 else 1
val translationYDelta =
sign * recyclerView.width * deltaDistance * 0.8f
Log.d("qlli1234-pull", "deltDistance: " + translationYDelta)
recyclerView.forEach {
if (it.isVisible) {
// 设置每个RecyclerView的子item的translationY属性
recyclerView.getChildViewHolder(it).itemView.translationY += translationYDelta
}
}
}

override fun onRelease() {
super.onRelease()
Log.d("qlli1234-onRelease", "onRelease")
recyclerView.forEach {
//复位
val animator = ValueAnimator.ofFloat(recyclerView.getChildViewHolder(it).itemView.translationY, 0f).setDuration(500)
animator.interpolator = DecelerateInterpolator(2.0f)
animator.addUpdateListener { valueAnimator ->
recyclerView.getChildViewHolder(it).itemView.translationY = valueAnimator.animatedValue as Float
}
animator.start()
}
}

override fun onAbsorb(velocity: Int) {
super.onAbsorb(velocity)
val sign = if (direction == DIRECTION_BOTTOM) -1 else 1
Log.d("qlli1234-onAbsorb", "onAbsorb")
val translationVelocity = sign * velocity * FLING_TRANSLATION_MAGNITUDE
recyclerView.forEach {
if (it.isVisible) {
// 在这个可以做动画
}
}
}

override fun draw(canvas: Canvas?): Boolean {
// 设置大小之后,就不会有绘画阴影效果
setSize(0, 0)
val result = super.draw(canvas)
return result
}
}
}

这里有一个小细节,如何在使用onPull等方法时,去掉绘制的阴影部分:其实,可以重写draw方法,重置大小为0即可,如上述代码中的这一小块内容:


override fun draw(canvas: Canvas?): Boolean {
// 设置大小之后,就不会有绘画阴影效果
setSize(0, 0)
val result = super.draw(canvas)
return result
}

5、参考


1、google的motion示例中的ChessAdapter内容


2、仿QQ的recyclerview效果实现


作者:李暖光
来源:juejin.cn/post/7235463575300046903
收起阅读 »

底部弹出菜单原来这么简单

底部弹出菜单是什么 底部弹出菜单,即从app界面底部弹出的一个菜单列表,这种UI形式被众多app所采用,是一种主流的布局方式。 思路分析 我们先分析一下,这样一种UI应该由哪些布局组成?首先在原界面上以一小块区域显示界面的这种形式,很明显就是对话框Dial...
继续阅读 »

底部弹出菜单是什么


底部弹出菜单,即从app界面底部弹出的一个菜单列表,这种UI形式被众多app所采用,是一种主流的布局方式。


截屏2023-09-28 14.36.51.png


截屏2023-09-28 14.37.29.png


思路分析


我们先分析一下,这样一种UI应该由哪些布局组成?首先在原界面上以一小块区域显示界面的这种形式,很明显就是对话框Dialog做的事情吧!最底部是一个取消菜单,上面的功能菜单可以是一个,也可以是两个、三个甚至更多。所以,我们可以使用RecyclerView实现。需要注意一点的是,最上面那个菜单的样式稍微有点不一样,因为它上面是圆滑的,有圆角,这样的界面显示更加和谐。我们主要考虑的就是弹出对话框的动画样式,另外注意一点就是可以多支持几个语种,让框架更加专业,这里只需要翻译“取消”文字。


开始看代码


package dora.widget

import android.app.Activity
import android.app.Dialog
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.widget.TextView
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
import com.chad.library.adapter.base.BaseQuickAdapter
import com.chad.library.adapter.base.listener.OnItemChildClickListener
import dora.widget.bean.BottomMenu
import dora.widget.bottomdialog.R

class DoraBottomMenuDialog : View.OnClickListener, OnItemChildClickListener {

private var bottomDialog: Dialog? = null
private var listener: OnMenuClickListener? = null

interface OnMenuClickListener {
fun onMenuClick(position: Int, menu: String)
}

fun setOnMenuClickListener(listener: OnMenuClickListener) : DoraBottomMenuDialog {
this.listener = listener
return this
}

fun show(activity: Activity, menus: Array<String>): DoraBottomMenuDialog {
if (bottomDialog == null && !activity.isFinishing) {
bottomDialog = Dialog(activity, R.style.DoraView_AlertDialog)
val contentView =
LayoutInflater.from(activity).inflate(R.layout.dview_dialog_content, null)
initView(contentView, menus)
bottomDialog!!.setContentView(contentView)
bottomDialog!!.setCanceledOnTouchOutside(true)
bottomDialog!!.setCancelable(true)
bottomDialog!!.window!!.setGravity(Gravity.BOTTOM)
bottomDialog!!.window!!.setWindowAnimations(R.style.DoraView_BottomDialog_Animation)
bottomDialog!!.show()
val window = bottomDialog!!.window
window!!.decorView.setPadding(0, 0, 0, 0)
val lp = window.attributes
lp.width = WindowManager.LayoutParams.MATCH_PARENT
lp.height = WindowManager.LayoutParams.WRAP_CONTENT
window.attributes = lp
} else {
bottomDialog!!.show()
}
return this
}

private fun initView(contentView: View, menus: Array<String>) {
val recyclerView = contentView.findViewById<RecyclerView>(R.id.dview_recycler_view)
val adapter = MenuAdapter()
val list = mutableListOf<BottomMenu>()
menus.forEachIndexed { index, s ->
when (index) {
0 -> {
list.add(BottomMenu(s, BottomMenu.TOP_MENU))
}
else -> {
list.add(BottomMenu(s, BottomMenu.NORMAL_MENU))
}
}
}
adapter.setList(list)
recyclerView.adapter = adapter
val decoration = DividerItemDecoration(contentView.context, DividerItemDecoration.VERTICAL)
recyclerView.addItemDecoration(decoration)
adapter.addChildClickViewIds(R.id.tv_menu)
adapter.setOnItemChildClickListener(this)
val tvCancel = contentView.findViewById<TextView>(R.id.tv_cancel)
tvCancel.setOnClickListener(this)
}

private fun dismiss() {
bottomDialog?.dismiss()
bottomDialog = null
}

override fun onClick(v: View) {
when (v.id) {
R.id.tv_cancel -> dismiss()
}
}

override fun onItemChildClick(adapter: BaseQuickAdapter<*, *>, view: View, position: Int) {
listener?.onMenuClick(position, adapter.getItem(position) as String)
dismiss()
}
}

类的结构不仅可以继承,还可以使用聚合和组合的方式,我们这里就不直接继承Dialog了,使用一种更接近代理的一种方式。条条大路通罗马,能抓到老鼠的就是好猫。这里的设计是通过调用show方法,传入一个菜单列表的数组来显示菜单,调用dismiss方法来关闭菜单。最后添加一个菜单点击的事件,把点击item的内容和位置暴露给调用方。


package dora.widget

import com.chad.library.adapter.base.BaseMultiItemQuickAdapter
import com.chad.library.adapter.base.viewholder.BaseViewHolder
import dora.widget.bean.BottomMenu
import dora.widget.bottomdialog.R

class MenuAdapter : BaseMultiItemQuickAdapter<BottomMenu, BaseViewHolder>() {

init {
addItemType(BottomMenu.NORMAL_MENU, R.layout.dview_item_menu)
addItemType(BottomMenu.TOP_MENU, R.layout.dview_item_menu_top)
}

override fun convert(holder: BaseViewHolder, item: BottomMenu) {
holder.setText(R.id.tv_menu, item.menu)
}
}

多类型的列表布局我们采用BRVAH,


implementation("io.github.cymchad:BaseRecyclerViewAdapterHelper:3.0.10")

来区分有圆角和没圆角的item条目。


<?xml version="1.0" encoding="utf-8"?>

<resources>
<style name="DoraView.AlertDialog" parent="@android:style/Theme.Dialog">
<!-- 是否启用标题栏 -->
<item name="android:windowIsFloating">true</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowNoTitle">true</item>

<!-- 是否使用背景半透明 -->
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:background">@android:color/transparent</item>
<item name="android:backgroundDimEnabled">true</item>
</style>

<style name="DoraView.BottomDialog.Animation" parent="Animation.AppCompat.Dialog">
<item name="android:windowEnterAnimation">@anim/translate_dialog_in</item>
<item name="android:windowExitAnimation">@anim/translate_dialog_out</item>
</style>
</resources>

以上是对话框的样式。我们再来看一下进入和退出对话框的动画。


translate_dialog_in.xml


<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:duration="300"
android:fromXDelta="0"
android:fromYDelta="100%"
android:toXDelta="0"
android:toYDelta="0">

</translate>

translate_dialog_out.xml


<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:duration="300"
android:fromXDelta="0"
android:fromYDelta="0"
android:toXDelta="0"
android:toYDelta="100%">

</translate>

最后给你们证明一下我是做了语言国际化的。
截屏2023-09-28 15.08.20.png


使用方式


// 打开底部弹窗
val dialog = DoraBottomMenuDialog()
dialog.setOnMenuClickListener(object : DoraBottomMenuDialog.OnMenuClickListener {
override fun onMenuClick(position: Int, menu: String) {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(url)
startActivity(intent)
}
})
dialog.show(this, arrayOf("外部浏览器打开"))

开源项目


github.com/dora4/dview…


作者:dora
来源:juejin.cn/post/7283516197487214611
收起阅读 »

Android:LayoutAnimation的神奇效果

大家好,我是时曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap却情有独钟。 今天给大家讲讲酷炫的动画成员——LayoutAnimation。话不多说,直接上一个简单的效果图: 怎么样,和往常自己写的没有动画效果的页面比起来是不是更加酷炫。效果图只展示了从右到...
继续阅读 »

大家好,我是时曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap却情有独钟。


今天给大家讲讲酷炫的动画成员——LayoutAnimation。话不多说,直接上一个简单的效果图:


Screenrecorder-2023-09-10-10-29-52-627.gif


怎么样,和往常自己写的没有动画效果的页面比起来是不是更加酷炫。效果图只展示了从右到左叠加渐变的效果,只要脑洞够大,LayoutAnimation是可以帮你实现各类动画的。接下来就让我们看看LayoutAnimation如何实现这样的效果。


首先,新建一个XML动画文件slide_from_right.xml:


<set xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="600">
<translate
android:fromXDelta="100%p"
android:interpolator="@android:anim/decelerate_interpolator"
android:toXDelta="0" />

<alpha
android:fromAlpha="0.5"
android:interpolator="@android:anim/accelerate_decelerate_interpolator"
android:toAlpha="1" />
</set>

set标签下由translate(移动)和alpha(渐变)动画组成。


其中translate(移动)动画由100%p移动到0。这里需要注意使用的是100%p,其中加这个p是指按父容器的宽度进行百分比计算。插值器就根据自己想要的效果设置,这里使用了一个decelerate_interpolator(减速)插值器。


第二个动画是alpha(渐变)动画,由半透明到不透明,其中插值器是先加速后减速的效果。


接着我们还需要创建一个layoutAnimation,其实也是一个XML文件layout_slid_from_right.xml:


<layoutAnimation xmlns:android="http://schemas.android.com/apk/res/android"
android:animation="@anim/slide_from_right"
android:animationOrder="normal"
android:delay="15%"/>

其中animation指定的就是我们创建的第一个xml文件。animationOrder是指动画执行的顺序模式,包含normal, reverse 和random。normal就是从上到下依次进行,reverse根据名字就知道是反序,random那当然是随机了,我们就使用mormal即可。delay则是每个子视图执行动画的延迟比例,这里需要注意的是这是相对于上个子视图执行动画延时比例。


最后我们只需要在咱们的ViewGr0up中设置layoutAnimation属性即可:


android:layoutAnimation="@anim/layout_slid_from_right"

当然也可在代码中手动设置:


val lin = findViewById<LinearLayout>(R.id.linParent)
val resId = R.anim.layout_slid_from_right
lin.layoutAnimation = AnimationUtils.loadLayoutAnimation(lin.context, resId)

总结:



  • layoutAnimation可以使用在任何一个ViewGr0up上

  • 在使用set标签做动画叠加的时候一定要注意,set标签内需要添加duration属性,也就是动画时间。如果不加动画是没有效果的。

  • 使用移动动画时,在百分比后面添加p的意思是基于父容器宽度进行百分比计算


以上便是LayoutAnimation的简单使用,只要你脑洞大开,各种各样的效果都能玩出来。实现起来也很简单,赶紧在项目中使用起来吧。


作者:似曾相识2022
来源:juejin.cn/post/7276630249547513895
收起阅读 »

和后端吵架后,我写了个库,让整个前端团队更加规范!

web
前言 大家好,我是林三心,用最通俗易懂的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心~ 本文源码地址:github.com/sanxin-lin/… 背景 在平时的开发中,表格数据->(增加/编辑/查看)行->(增加/编辑)提交,这...
继续阅读 »

前言


大家好,我是林三心,用最通俗易懂的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心~



本文源码地址:github.com/sanxin-lin/…



背景


在平时的开发中,表格数据->(增加/编辑/查看)行->(增加/编辑)提交,这是很常见且简单的业务,但是就是这些业务,我也发现一些问题



首先我们来理性一下这些业务的逻辑



  • 第一步:请求回表格的数据

  • 第二步:点开(增加/编辑/查看)弹窗,如果是(编辑/查看),则需要将表格行的数据传到弹窗中回显

  • 第三部:如果是(编辑)弹窗,则需要把表单数据提交请求接口


我用一个图来概括大概就是:



问题所在


我不知道其他公司怎么样,但是就拿我自身来举例子,公司的后端跟前端的命名规则是不同的



  • 后端命名: 请求方法+字段类型+字段含义+下划线命名(比如 in_name、os_user_id)

  • 前端命名: 字段含义+驼峰命名(比如 name、userId)


回到刚刚的业务逻辑,还是那张图,假如我们前端不去管命名的话,那么数据的传输是这样的,发现了很多人都懒得去转换后端返回的字段名,直接拿着后端的字段名去当做前端的表单字段名,但这是不符合前端规范的



理想应该是表单要用前端的命名,比如这样



但是很多前端就是懒得去转换,原因有多个:



  • 开发者自身比较懒,或者没有规范意识

  • 回显时要转一次,提交时还要再转一次,每次总是得写一遍


解决方案


所以能不能写一个工具,解放开发者的压力又能达到期望的效果呢?比如我开发一个工具,然后像下面这样在弹窗里用



  • state: 响应式表单数据,可以用在弹窗表单中

  • resetState: 重置表单

  • inputState: 将表格行数据转成表单数据

  • outputState: 将表单数据转成提交请求的数据


配置的含义如下:



  • default: 表单字段默认值

  • input: 转入的字段名

  • output: 转出的字段名

  • inputStrategy: 转入的转换策略,可以选择内置的,也可以自定义策略函数

  • outputStrategy: 转出的转换策略,可以选择内置的,也可以自定义策略函数



转入和转出策略,内置了一些,你也可以自定义,内置的有如下



下面是自定义策略函数的例子,必须要在策略函数中返回一个转换值



这样的话,当我们执行对应的转换函数之后,会得到我们想要的结果



use-dsp


所以我开发了一个工具



源码地址:github.com/sanxin-lin/…



其实 dsp 意思就是



  • data

  • state

  • parameter


npm i use-dsp
yarn i use-dsp
pnpm i use-dsp

import useDSP from 'use-dsp'

为啥不从一开始就转?


有人会问,为啥不从一开始请求表格数据回来的时候,就把数据转成前端的命名规范?


其实这个问题我也想过,但是设想一下,有一些表格如果只是单纯做展示作用,那么就没必要去转字段名了,毕竟不涉及任何的数据传递。


但是需要编辑或者查看弹窗的表格,就涉及到了行数据的传递,那么就需要转字段名




作者:Sunshine_Lin
来源:juejin.cn/post/7360892717545799689
收起阅读 »

特斯拉违约24届全部应届生,如何评价?

4月23日有消息称:特斯拉(上海)撤回了所有24应届生的offer,统一给了一个月底薪作为补偿。 目前这个话题的讨论热度在脉脉上排第二。 先不管是真是假,至少看到有一部分同学确实是被裁了,这个时间点被裁,基本是废了。金三银四都差不多过完了,虽说还是应届生身...
继续阅读 »

4月23日有消息称:特斯拉(上海)撤回了所有24应届生的offer,统一给了一个月底薪作为补偿。



目前这个话题的讨论热度在脉脉上排第二。


脉脉热榜


先不管是真是假,至少看到有一部分同学确实是被裁了,这个时间点被裁,基本是废了。金三银四都差不多过完了,虽说还是应届生身份,但马上就要毕业了,这个时间点工作确实不好找了。


4月15日马斯克宣布特斯拉裁员10%,按照特斯拉去年公布的数据显示,特斯拉在全球共有14万名员工,算下来得裁1.4万人呐。之前出裁员消息的时候就上过一波热搜,没想到这事还没结束,现在又把所有应届生都毁约了,特斯拉这波是有点狠的。




注:信息来源于网络,官方目前并未公开发出毁约所有24应届生的消息。



看看网友怎么说


网友一


思路打开点,反正工作找不到了,考个研缓三年,还是应届生[狗头]。



网友二


能进特斯拉的肯定都是大佬级别,趁机宣传一波公司招人,这确实是个好时机。



网友三


至少赔钱了,一般的企业你看理你吗?(确实)



网友四


看了下特斯拉股票,“广进计划” 诚不欺我😅。



我怎么看


裁员我是可以理解的,哪家公司都会有亏损的时候,进行一波裁员也很正常,谁又能保证公司能一帆风顺向上发展?


但是集体毁约应届生这个事,我是不太能理解的。每年亏损的公司这么多,集体毁约应届生的一般也就两三家(老员工也可以裁呀)。既然集体毁约,是否能够根据当前的就业环境,拿出足够的诚意?如果你是十月十一月集体毁约,那我觉得赔一个月工资差不多了。但是在四月底进行毁约,我觉得得赔3个月才够诚意。


当然啦,这些只是我站在道德制高点的一些看法,真要说起来,特斯拉这个毁约条件,比起大多数企业确实还是算好的了,就像网友说的 “赔钱了良心,国内都不理你”。


最终倒霉的还是这些拿了特斯拉offer开开心心准备去上班的同学了,用“天崩开局”来形容都不为过。



你怎么看


欢迎在评论区留言,发表的你看法。


作者:阿杆
来源:juejin.cn/post/7361023886852849718
收起阅读 »

JavaScript精粹:26个关键字深度解析,编写高质量代码的秘诀!

JavaScript关键字是一种特殊的标识符,它们在语言中有固定的含义,不能用作变量名或函数名。这些关键字是JavaScript的基础,理解它们是掌握JavaScript的关键。今天,我们将一起探索JavaScript中的26个关键字,了解这些关键字各自独特的...
继续阅读 »

JavaScript关键字是一种特殊的标识符,它们在语言中有固定的含义,不能用作变量名或函数名。这些关键字是JavaScript的基础,理解它们是掌握JavaScript的关键。

今天,我们将一起探索JavaScript中的26个关键字,了解这些关键字各自独特的含义、特性和使用方法。

一、JavaScript关键字是什么

Javascript关键字(Keyword)是指在Javascript语言中有特定含义,成为Javascript语法中一部分的那些字,是 JavaScript 语言内部使用的一组名字(或称为命令)。

Description


Javascript关键字是不能作为变量名和函数名使用的。使用Javascript关键字作为变量名或函数名,会使Javascript在载入过程中出现编译错误。

Java中的关键字可用于表示控制语句的开始或结束,或者用于执行特定操作等。按照规则,关键字也是语言保留的,不能用作标识符。

下面我们来详细介绍一下JavaScript关键字的作用和使用方法。

二、JavaScript的26个关键字

JavaScript是一种广泛使用的编程语言,它具有丰富的关键字,这些关键字在JavaScript语言中发挥着重要的作用,JavaScript一共提供了26个关键字:


break, case, catch, continue, debugger, default, delete, do, else, finally, for, function, if, in, instanceof, new, return, switch, this, throw, try, typeof, var, void, while, with
其中,debugger在ECMAScript 5 新增的。

1、break:跳出 循环

break用于跳出循环结构。循环结构是一种重复执行某个代码块的结构,break关键字可以用于循环结构中的条件语句中,用于跳出循环。例如:

for (var i = 0; i < 10; i++) {
if (i == 5) {
break; // 当i等于5时跳出循环
}
console.log(i);
}

2、case:捕捉

它用于在switch语句中定义一个分支。switch语句是一种根据表达式的值执行不同代码块的结构,case关键字可以用于switch语句中,用于定义不同的分支。例如:

switch (n) {

case 1:
console.log('n等于1');
break;
case 2:
console.log('n等于2');
break;
default:
console.log('n不等于1或2');
break;
}

3、catch:配合try进行错误判断

catch用于捕获异常。异常是一种程序运行时出现的错误,catch关键字可以用于try-catch语句中,用于捕获并处理异常。例如:

try {
// 代码
} catch (e) {
console.log('发生异常:' + e.message);
}

4、continue:继续

continue用于跳过当前循环中的某个迭代。循环结构是一种重复执行某个代码块的结构,continue关键字可以用于循环结构中的条件语句中,用于跳过当前迭代。例如:

for (var i = 0; i < 10; i++) {
if (i == 5) {
continue; // 当i等于5时跳过当前迭代
}
console.log(i);
}

5、debugger:设置断点

它用于在代码中设置断点,方便调试代码。调试是一种在代码运行时发现和解决问题的过程,debugger关键字可以用于代码中,用于设置断点。例如:

function foo() {

var x = 10;

debugger; // 在这里设置断点
console.log(x);

}

6、default:配合switch,当条件不存在时使用该项

default用于在switch语句中定义一个默认分支。switch语句是一种根据表达式的值执行不同代码块的结构,default关键字可以用于switch语句中,用于定义默认分支。例如:


switch (n) {
case 1:
console.log('n等于1');
break;
case 2:
console.log('n等于2');
break;
default:
console.log('n不等于1或2');
break;
}

7、delete:删除了一个属性

delete用于删除对象的属性或数组中的元素。对象是JavaScript中的一种数据类型,它由一组属性组成,delete关键字可以用于对象的属性中,用于删除属性。例如:

var obj = {a: 1, b: 2, c: 3};
delete obj.b; // 删除对象obj的属性b
console.log(obj); // 输出{a: 1, c: 3}

8、do:声明一个循环

do用于定义一个do-while循环结构。循环结构是一种重复执行某个代码块的结构,do关键字可以用于do-while循环中,用于定义循环体。例如:

var i = 0;
do {
console.log(i);
i++;
} while (i < 10);

9、else:否则//配合if条件判断,用于条件选择的跳转

else用于在if语句中定义一个分支。if语句是一种根据条件执行不同代码块的结构,else关键字可以用于if语句中,用于定义另一个分支。例如:

if (n == 1) {
console.log('n等于1');
} else {
console.log('n不等于1');
}

10、finally:预防出现异常时用的

finally用于定义一个try-catch-finally语句中的finally块。try-catch-finally语句是一种用于处理异常的结构,finally关键字可以用于finally块中,用于定义一些必须执行的代码。例如:

try {
// 可能会抛出异常的代码
} catch (e) {
// 处理异常的代码
} finally {
// 必须执行的代码
}

11、for:循环语句

for用于定义一个for循环结构。循环结构是一种重复执行某个代码块的结构,for关键字可以用于for循环中,用于定义循环条件。例如:

for (var i = 0; i < 10; i++) {
console.log(i);
}

12、function:定义函数的关键字

function用于定义一个函数。函数是一种封装了一段代码的结构,它可以接受参数并返回结果。function关键字可以用于函数定义中,用于定义函数名和参数列表。例如:

function add(a, b) {
return a + b;
}
console.log(add(1, 2)); // 输出3

13、if:定义一个if语句

if用于定义一个if语句。if语句是一种根据条件执行不同代码块的结构,if关键字可以用于if语句中,用于定义条件。例如:


if (n == 1) {
console.log('n等于1');
} else {
console.log('n不等于1');
}

14、in:判断某个属性属于某个对象

in用于判断一个对象是否包含某个属性。对象是JavaScript中的一种数据类型,它由一组属性组成,in关键字可以用于对象中,用于判断对象是否包含某个属性。例如:


var obj = {a: 1, b: 2, c: 3};
if ('a' in obj) {
console.log('obj包含属性a');
} else {
console.log('obj不包含属性a');
}

15、instanceof:某个对象是不是另一个对象的实例

instanceof用于判断一个对象是否是某个类的实例。类是JavaScript中的一种数据类型,它由一组属性和方法组成,instanceof关键字可以用于类中,用于判断对象是否是该类的实例。例如:

function Person(name) {
this.name = name;
}
var p = new Person('张三');
if (p instanceof Person) {
console.log('p是Person类的实例');
} else {
console.log('p不是Person类的实例');
}

16、new:创建一个新对象

new用于创建一个对象。对象是JavaScript中的一种数据类型,它由一组属性和方法组成,new关键字可以用于类中,用于创建该类的实例。例如:

function Person(name) {
this.name = name;
}
var p = new Person('张三');
console.log(p.name); // 输出张三

17、return:返回

return用于从函数中返回一个值。函数是JavaScript中的一种数据类型,它由一段代码块组成,return关键字可以用于函数中,用于返回函数的执行结果。例如:

function add(a, b) {
return a + b;
}

console.log(add(1, 2)); // 输出3

18、switch:弥补if的多重判断语句

switch用于根据不同的条件执行不同的代码块。switch语句是一种根据条件执行不同代码块的结构,switch关键字可以用于switch语句中,用于定义条件。例如:

var day = 3;
switch (day) {
case 1:
console.log('星期一');
break;
case 2:
console.log('星期二');
break;
case 3:
console.log('星期三');
break;
default:
console.log('不是星期一、二、三');
}

19、this:总是指向调用该方法的对象

this用于引用当前对象。对象是JavaScript中的一种数据类型,它由一组属性和方法组成,this关键字可以用于对象中,用于引用当前对象的属性和方法。例如:

var obj = {
name: '张三',
sayName: function() {
console.log(this.name);
}
};
obj.sayName(); // 输出张三

20、throw:抛出异常

throw用于抛出一个异常。异常是JavaScript中的一种错误类型,它可以用于在程序运行过程中发现错误并停止程序的执行。throw关键字可以用于函数中,用于抛出异常。例如:

function divide(a, b) {
if (b === 0) {
throw new Error('除数不能为0');

}
return a / b;
}
console.log(divide(10, 0)); // 抛出异常

21、try:接受异常并做出判断

try用于捕获异常。异常是JavaScript中的一种错误类型,它可以用于在程序运行过程中发现错误并停止程序的执行。try语句是一种捕获异常的结构,try关键字可以用于try语句中,用于捕获异常。例如:

function divide(a, b) {
if (b === 0) {
throw new Error('除数不能为0');
}
return a / b;
}
try {
console.log(divide(10, 0)); // 抛出异常
} catch (e) {
console.log(e.message); // 输出除数不能为0
}

22、typeof:检测变量的数据类型

typeof用于获取变量的类型。变量是JavaScript中的一种数据类型,它可以是数字、字符串、布尔值等。typeof关键字可以用于变量中,用于获取变量的类型。例如:

var a = 10;
console.log(typeof a); // 输出number

23、var:声明变量

var用于声明变量。变量是JavaScript中的一种数据类型,它可以用于存储数据。var关键字可以用于变量中,用于声明变量。例如:

var a = 10;
console.log(a); // 输出10

24、void:空/ 声明没有返回值

void它用于执行一个表达式并返回undefined。undefined是JavaScript中的一种特殊值,它表示一个未定义的值。void关键字可以用于表达式中,用于执行表达式并返回undefined。例如:

function doSomething() {
console.log('执行了doSomething函数');
}
var result = void doSomething();
console.log(result); // 输出undefined

25、while

while用于创建一个循环结构。循环是JavaScript中的一种控制结构,它可以用于重复执行一段代码。while关键字可以用于循环中,用于创建一个基于条件的循环。例如:

var i = 0;
while (i < 10) {
console.log(i);
i++;
}

26、with

with用于创建一个作用域。作用域是JavaScript中的一种机制,它可以用于控制变量的作用范围。with关键字可以用于代码块中,用于创建一个作用域。例如:


var obj = {
name: '张三',
age: 20
};
with (obj) {
console.log(name); // 输出张三
console.log(age); // 输出20
}


三、JS关键字注意事项

在开发过程中使用关键字我们需要注意以下几点:

  • 区分大小写: JavaScript是区分大小写的,因此关键字的大小写必须正确。

  • 不能用作变量名: 关键字不能被用作变量名,函数名等等,会出现问题

  • 不需要硬记关键字: 关键字不用去硬记,我们在编写代码时根据系统的提示去规避就可以了

  • 保留字: JavaScript有一些保留字,不能用作变量名、函数名或属性名。

  • 不要使用全局变量: 尽量避免使用全局变量,以免与其他脚本或库发生冲突。可以使用立即执行函数表达式(IIFE)或模块模式来避免全局变量污染。

  • 使用严格模式: 在代码中添加"use strict"指令,以启用严格模式。这将有助于避免一些常见的错误,例如未声明的变量、隐式类型转换等。

  • 避免使用eval()函数: eval()函数用于动态执行字符串中的JavaScript代码,但可能导致性能问题和安全风险。尽量避免使用eval(),寻找其他替代方案。

  • 不要使用with语句: with语句会改变代码的作用域链,可能导致意外的错误。尽量避免使用with语句,改用局部变量或对象属性访问。

  • 避免使用重复的标识符: 确保变量名、函数名和属性名在同一作用域内是唯一的,以避免命名冲突。

  • 遵循编码规范: 遵循一致的命名约定、缩进风格和代码结构,以提高代码的可读性和可维护性。

想要快速入门前端开发吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!

点这里前往学习哦!


四、关于保留字的了解

除了关键字还有个“保留字”的概念,所谓保留字,实际上就是预留的“关键字”。意思是现在虽然还不是关键字,但是未来可能会成为关键字,同样不能使用它们当充当变量名、函数名等标识符。

下面是JavaScript中保留字的含义,大家同样不用记,了解一下就行了。

Description

以上就是关于JavaScript关键字的相关内容了,通过了解这26个JavaScript关键字的含义、特性和使用方法,你已经迈出了成为编程高手的重要一步。

记住,实践是学习的关键,不断地编写代码并应用这些关键字,你将逐渐掌握JavaScript的精髓。

如果觉得本文对你有所帮助,别忘了点赞和分享哦!

收起阅读 »

Android自定义一个省份简称键盘

hello啊各位老铁,这篇文章我们重新回到Android当中的自定义View,其实最近一直在搞Flutter,初步想法是,把Flutter当中的基础组件先封装一遍,然后接着各个工具类,列表,网络,统统由浅入深的搞一遍,弄完Flutter之后,再逐步的更新And...
继续阅读 »

hello啊各位老铁,这篇文章我们重新回到Android当中的自定义View,其实最近一直在搞Flutter,初步想法是,把Flutter当中的基础组件先封装一遍,然后接着各个工具类,列表,网络,统统由浅入深的搞一遍,弄完Flutter之后,再逐步的更新Android当中的技术点,回头一想,还是穿插着来吧,再系统的规划,难免也有变化,想到啥就写啥吧,能够坚持输出就行。


今天的这个知识点,是一个自定义View,一个省份的简称键盘,主要用到的地方,比如车牌输入等地方,相对来说还是比较的简单,我们先看下最终的实现效果:



实现方式呢有很多种,我相信大家也有自己的一套实现机制,这里,我采用的是组合View,用的是LinearLayout的方式。


今天的内容大致如下:


1、分析UI,如何布局


2、设置属性和方法,制定可扩展效果


3、部分源码剖析


4、开源地址及实用总结


一、分析UI,如何布局


拿到UI效果图后,其实也没什么好分析的,无非就是两块,顶部的完成按钮和底部的省份简称格子,一开始,打算用RecyclerView网格布局来实现,但是最后的删除按钮如何摆放就成了问题,直接悬浮在网格上边,动态计算位置,显然不太合适,也没有这样去搞的,索性直接抛弃这个方案,多布局的想法也实验过,但最终还是选择了最简单的LinearLayout组合View形式。


所谓简单,就是在省份简称数组的遍历中,不断的给LinearLayout进行追加子View,需要注意的是,本身的View,也就是我们自定义View,继承LinearLayout后,默认的是垂直方向的,往本身View追加的是横向属性的LinearLayout,这也是换行的效果,也就是,一行一个横向的LinearLayout,记住,横向属性的LinearLayout,才是最终添加View的直接父类。



换行的条件就是基于UI效果,当模于设置length等于0时,我们就重新创建一个水平的LinearLayout,这就可以了,是不是非常的简单。


至于最后的删除按钮,使其靠右,占据两个格子的权重设置即可。


二、设置属性和方法,制定可扩展效果


当我们绘制完这个身份简称键盘后,肯定是要给他人用的,基于灵活多变的需求,那么相对应的我们也需要动态的进行配置,比如背景颜色,文字的颜色,大小,还有边距,以及点击效果等等,这些都是需要外露,让使用者选择性使用的,目前所有的属性如下,大家在使用的时候,也可以对照设置。


设置属性


属性类型概述
lp_backgroundcolor整体的背景颜色
lp_rect_spacingdimension格子的边距
lp_rect_heightdimension格子的高度
lp_rect_margin_topdimension格子的距离上边
lp_margin_left_rightdimension左右距离
lp_margin_topdimension上边距离
lp_margin_bottomdimension下边距离
lp_rect_backgroundreference格子的背景
lp_rect_select_backgroundreference格子选择后的背景
lp_rect_text_sizedimension格子的文字大小
lp_rect_text_colorcolor格子的文字颜色
lp_rect_select_text_colorcolor格子的文字选中颜色
lp_is_show_completeboolean是否显示完成按钮
lp_complete_text_sizedimension完成按钮文字大小
lp_complete_text_colorcolor完成按钮文字颜色
lp_complete_textstring完成按钮文字内容
lp_complete_margin_topdimension完成按钮距离上边
lp_complete_margin_bottomdimension完成按钮距离下边
lp_complete_margin_rightdimension完成按钮距离右边
lp_text_click_effectboolean是否触发点击效果,true点击后背景消失,false不消失

定义方法


方法参数概述
keyboardContent回调函数获取点击的省份简称简称信息
keyboardDelete函数删除省份简称简称信息
keyboardComplete回调函数键盘点击完成
openProhibit函数打开禁止(使领学港澳),使其可以点击

三、关键源码剖析


这里只贴出部分的关键性代码,整体的代码,大家滑到底部查看源码地址即可。


定义身份简称数组


    //省份简称数据
private val mLicensePlateList = arrayListOf(
"京", "津", "渝", "沪", "冀", "晋", "辽", "吉", "黑", "苏",
"浙", "皖", "闽", "赣", "鲁", "豫", "鄂", "湘", "粤", "琼",
"川", "贵", "云", "陕", "甘", "青", "蒙", "桂", "宁", "新",
"藏", "使", "领", "学", "港", "澳",
)

遍历省份简称


mLength为一行展示多少个,当取模为0时,就需要换行,也就是再次创建一个水平的LinearLayout,添加至外层的垂直LinearLayout中,每个水平的LinearLayout中,则是一个一个的TextView。


  //每行对应的省份简称
var layout: LinearLayout? = null
//遍历车牌号
mLicensePlateList.forEachIndexed { index, s ->
if (index % mLength == 0) {
//重新创建,并添加View
layout = createLinearLayout()
layout?.weightSum = 1f
addView(layout)
val params = layout?.layoutParams as LayoutParams
params.apply {
topMargin = mRectMarginTop.toInt()
height = mRectHeight.toInt()
leftMargin = mMarginLeftRight.toInt()
rightMargin = mMarginLeftRight.toInt() - mSpacing.toInt()
layout?.layoutParams = this
}
}

//创建文字视图
val textView = TextView(context).apply {
text = s
//设置文字的属性
textSize = px2sp(mRectTextSize)
//最后五个是否禁止
if (mNumProhibit && index > (mLicensePlateList.size - 6)) {
setTextColor(mNumProhibitColor)
mTempTextViewList.add(this)
} else {
setTextColor(mRectTextColor)
}

setBackgroundResource(mRectBackGround)
gravity = Gravity.CENTER
setOnClickListener {
if (mNumProhibit && index > (mLicensePlateList.size - 6)) {
return@setOnClickListener
}
//每个格子的点击事件
changeTextViewState(this)
}
}

addRectView(textView, layout, 0.1f)
}

追加最后一个View


由于最后一个视图是一个图片,占据了两个格子的大小,所以需要特殊处理,需要做的就是,单独设置权重weight和单独设置宽度width,如下所示:


  /**
* AUTHOR:AbnerMing
* INTRODUCE:追加最后一个View
*/

private fun addEndView(layout: LinearLayout?) {
val endViewLayout = LinearLayout(context)
endViewLayout.gravity = Gravity.RIGHT
//删除按钮
val endView = RelativeLayout(context)
//添加删除按钮
val deleteImage = ImageView(context)
deleteImage.setImageResource(R.drawable.view_ic_key_delete)
endView.addView(deleteImage)

val imageParams = deleteImage.layoutParams as RelativeLayout.LayoutParams
imageParams.addRule(RelativeLayout.CENTER_IN_PARENT)
deleteImage.layoutParams = imageParams
endView.setOnClickListener {
//删除
mKeyboardDelete?.invoke()
invalidate()
}
endView.setBackgroundResource(mRectBackGround)
endViewLayout.addView(endView)
val params = endView.layoutParams as LayoutParams
params.width = (getScreenWidth() / mLength) * 2 - mMarginLeftRight.toInt()
params.height = LayoutParams.MATCH_PARENT

endView.layoutParams = params

layout?.addView(endViewLayout)
val endParams = endViewLayout.layoutParams as LayoutParams
endParams.apply {
width = (mSpacing * 3).toInt()
height = LayoutParams.MATCH_PARENT
weight = 0.4f
rightMargin = mSpacing.toInt()
endViewLayout.layoutParams = this
}


}

四、开源地址及使用总结


开源地址:github.com/AbnerMing88…


关于使用,其实就是一个类,大家可以下载源码,直接复制即可使用,还可以进行修改里面的代码,非常的方便,如果懒得下载源码,没关系,我也上传到了远程Maven,大家可以按照下面的方式进行使用。


Maven具体调用


1、在你的根项目下的build.gradle文件下,引入maven。


 allprojects {
repositories {
maven { url "https://gitee.com/AbnerAndroid/almighty/raw/master" }
}
}

2、在你需要使用的Module中build.gradle文件下,引入依赖。


 dependencies {
implementation 'com.vip:plate:1.0.0'
}

代码使用


   <com.vip.plate.LicensePlateView
android:id="@+id/lp_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:lp_complete_text_size="14sp"
app:lp_margin_left_right="10dp"
app:lp_rect_spacing="6dp"
app:lp_rect_text_size="19sp"
app:lp_text_click_effect="false" />


总结


大家在使用的时候,一定对照属性表进行选择性使用;关于这个省份简称自定义View,实现方式有很多种,我目前的这种也不是最优的实现方式,只是自己的一个实现方案,给大家一个作为参考的依据,好了,铁子们,本篇文章就先到这里,希望可以帮助到大家。


作者:程序员一鸣
来源:juejin.cn/post/7235484890019659834
收起阅读 »

文本美学:text-image打造视觉吸引力

web
当我最近浏览 GitHub 时,偶然发现了一个项目,它能够将文字、图片和视频转化为文本,我觉得非常有趣。于是我就花了一些时间了解了一下,发现它的使用也非常简单方便。今天我打算和家人们分享这个发现。 项目介绍 话不多说,我们先看下作者的demo效果: _202...
继续阅读 »

当我最近浏览 GitHub 时,偶然发现了一个项目,它能够将文字、图片和视频转化为文本,我觉得非常有趣。于是我就花了一些时间了解了一下,发现它的使用也非常简单方便。今天我打算和家人们分享这个发现。


项目介绍


话不多说,我们先看下作者的demo效果:


微信截图_20240420194201.png


_20240420194201.jpg


text-image可以将文字、图片、视频进行「文本化」


只需要通过简单的配置即可使用。


虽然这个项目star数很少,但确实是一个很有意思的项目,使用起来很简单的项目。


_20240420194537.jpg


_20240420194537.jpg


github地址:https://github.com/Sunny-117/text-image


我也是使用这个项目做了一个简单的web页面,感兴趣的家人可以使用看下效果:


web地址:http://h5.xiuji.mynatapp.cc/text-image/


_20240420211509.jpg


_20240420211509.jpg


项目使用


这个项目使用起来相对简单,只需按作者的文档使用即可,虽然我前端属于小白的水平,但还是在ai的帮助下做了一个简单的html页面,如果有家人需要的话可以私信我,我发下文件。下边我们就介绍下:



  • 文字「文本化」


先看效果:


_20240420195701.jpg


_20240420195701.jpg


我们在这儿是将配置的一些参数在页面上做了一个可配置的表单,方便我们配置。


家人们想自己尝试的话可以试下以下这个demo。


demo.html


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Document</title>
</head>

<body>
<canvas id="demo"></canvas>
<script src="http://h5.xiuji.mynatapp.cc/text-image/text-image.iife.js"></script>
<script>
textImage.createTextImage({
canvas: document.getElementById('demo'),
replaceText: '123',
source: {
text: '修己xj',
},
});
</script>
</body>
</html>


  • 图片「文本化」


_20240420200651.jpg


_20240420200651.jpg


demo.html


<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8" />
<title>Document</title>
</head>

<body>
<canvas id="demo"></canvas>
<script src="http://h5.xiuji.mynatapp.cc/text-image/text-image.iife.js"></script>
<script>
textImage.createTextImage({
canvas: document.getElementById('demo'),
raduis: 7,
isGray: true,
source: {
img: './assets/1.png',
},
});
</script>
</body>

</html>


  • 视频「文本化」


动画1.gif


1.gif


demo.html


<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8" />
<title>Document</title>
</head>

<body>
<canvas id="demo"></canvas>
<script src="http://h5.xiuji.mynatapp.cc/text-image/text-image.iife.js"></script>

<script>
textImage.createTextImage({
canvas: document.getElementById('demo'),
raduis: 8,
isGray: true,
source: {
video: './assets/1.mp4',
height: 700,
},
});
</script>
</body>

</html>

需要注意的是:作者在项目中提供的视频的demo这个属性值有错误,我们需要改正后方可正常显示:


_20240420211124.jpg


_20240420211124.jpg


总结


text-image 是一个强大的前端工具,可以帮助用户快速、轻松地将文本、图片、视频转换成文本化的图片,增强文本内容的表现力和吸引力。


作者:修己xj
来源:juejin.cn/post/7359510120248786971
收起阅读 »

安卓开发中如何实现一个定时任务

定时任务方式优点缺点使用场景所用的API普通线程sleep的方式简单易用,可用于一般的轮询Polling不精确,不可靠,容易被系统杀死或者休眠需要在App内部执行短时间的定时任务Thread.sleep(long)Timer定时器简单易用,可以设置固定周期或者...
继续阅读 »

定时任务方式优点缺点使用场景所用的API
普通线程sleep的方式简单易用,可用于一般的轮询Polling不精确,不可靠,容易被系统杀死或者休眠需要在App内部执行短时间的定时任务Thread.sleep(long)
Timer定时器简单易用,可以设置固定周期或者延迟执行的任务不精确,不可靠,容易被系统杀死或者休眠需要在App内部执行短时间的定时任务Timer.schedule(TimerTask,long)
ScheduledExecutorService灵活强大,可以设置固定周期或者延迟执行的任务,并支持多线程并发不精确,不可靠,容易被系统杀死或者休眠需要在App内部执行短时间且需要多线程并发的定时任务Executors.newScheduledThreadPool(int).schedule(Runnable,long,TimeUnit)
Handler中的postDelayed方法简单易用,可以设置延迟执行的任务,并与UI线程交互不精确,不可靠,容易被系统杀死或者休眠需要在App内部执行短时间且需要与UI线程交互的定时任务Handler.postDelayed(Runnable,long)
Service + AlarmManger + BroadcastReceiver可靠稳定,可以设置精确或者不精确的闹钟,并在后台长期运行需要声明相关权限,并受系统时间影响需要在App外部执行长期且对时间敏感的定时任务AlarmManager.set(int,PendingIntent), BroadcastReceiver.onReceive(Context,Intent), Service.onStartCommand(Intent,int,int)
WorkManager可靠稳定,不受系统时间影响,并可以设置多种约束条件来执行任务需要添加依赖,并不能保证准时执行需要在App外部执行长期且对时间不敏感且需要满足特定条件才能执行的定时任务WorkManager.enqueue(WorkRequest), Worker.doWork()
RxJava简洁、灵活、支持多线程、支持背压、支持链式操作学习曲线较高、内存占用较大需要处理复杂的异步逻辑或数据流io.reactivex:rxjava:2.2.21
CountDownTimer简单易用、不需要额外的线程或handler不支持取消或重置倒计时、精度受系统时间影响需要实现简单的倒计时功能android.os.CountDownTimer
协程+Flow语法简洁、支持协程作用域管理生命周期、支持流式操作和背压需要引入额外的依赖库、需要熟悉协程和Flow的概念和用法需要处理异步数据流或响应式编程kotlinx-coroutines-core:1.5.0
使用downTo关键字和Flow实现一个定时任务1、可以使用简洁的语法创建一个倒数的范围 2 、可以使用Flow异步地发射和收集倒数的值3、可以使用onEach等操作符对倒数的值进行处理或转换1、需要注意倒数的范围是否包含0,否则可能会出现偏差 2、需要注意倒数的间隔是否与delay函数的参数一致,否则可能会出现不准确 3、需要注意取消或停止Flow的时机,否则可能会出现内存泄漏或资源浪费1、适合于需要实现简单的倒计时功能,例如显示剩余时间或进度 2、适合于需要在倒计时过程中执行一些额外的操作,例如播放声音或更新UI 3、适合于需要在倒计时结束后执行一些额外的操作,例如跳转页面或弹出对话框implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0"
Kotlin 内联函数的协程和 Flow 实现很容易离开主线程,样板代码最少,协程完全活用了 Kotlin 语言的能力,包括 suspend 方法。可以处理大量的异步数据,而不会阻塞主线程。可能会导致内存泄漏和性能问题。处理 I/O 阻塞型操作,而不是计算密集型操作。kotlinx.coroutines 和 kotlinx.coroutines.flow

安卓开发中如何实现一个定时任务


在安卓开发中,我们经常会遇到需要定时执行某些任务的需求,比如轮询服务器数据、更新UI界面、发送通知等等。那么,我们该如何实现一个定时任务呢?本文将介绍安卓开发中实现定时任务的五种方式,并比较它们的优缺点,以及适用场景。


1. 普通线程sleep的方式


这种方式是最简单也最直观的一种实现方法,就是在一个普通线程中使用sleep方法来延迟执行某个任务。例如:


// 创建一个普通线程
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
// 循环执行
while (true) {
// 执行某个任务
doSomething();
// 延迟10秒
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
// 启动线程
thread.start();


这种方式的优点是简单易懂,不需要借助其他类或组件。但是它也有很多缺点:



  • sleep方法会阻塞当前线程,导致资源浪费和性能下降。

  • sleep方法不准确,它只能保证在指定时间后醒来,但不能保证立即执行。

  • sleep方法受系统时间影响,如果用户修改了系统时间,会导致计时错误。

  • sleep方法不可靠,如果线程被异常终止或者进入休眠状态,会导致计时中断。


因此,这种方式只适合一般的轮询Polling场景。


2. Timer定时器


这种方式是使用Java API里提供的Timer类来实现定时任务。Timer类可以创建一个后台线程,在指定的时间或者周期性地执行某个任务。例如:


// 创建一个Timer对象
Timer timer = new Timer();
// 创建一个TimerTask对象
TimerTask task = new TimerTask() {
@Override
public void run() {
// 执行某个任务
doSomething();
}
};
// 设置在5秒后开始执行,并且每隔10秒重复执行一次
timer.schedule(task, 5000, 10000);


这种方式相比第一种方式有以下优点:



  • Timer类内部使用wait和notify方法来控制线程的执行和休眠,不会浪费资源和性能。

  • Timer类可以设置固定频率或者固定延迟来执行任务,更加灵活和准确。

  • Timer类可以取消或者重新安排任务,更加方便和可控。


但是这种方式也有以下缺点:



  • Timer类只创建了一个后台线程来执行所有的任务,如果其中一个任务耗时过长或者出现异常,则会影响其他任务的执行。

  • Timer类受系统时间影响,如果用户修改了系统时间,会导致计时错误。

  • Timer类不可靠,如果进程被杀死或者进入休眠状态,会导致计时中断。


因此,这种方式适合一些不太重要的定时任务。


3. ScheduledExecutorService


这种方式是使用Java并发包里提供的ScheduledExecutorService接口来实现定时任务。ScheduledExecutorService接口可以创建一个线程池,在指定的时间或者周期性地执行某个任务。例如:


// 创建一个ScheduledExecutorService对象
ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor();
// 创建一个Runnable对象
Runnable task = new Runnable() {
@Override
public void run() {
// 执行某个任务
doSomething();
}
};
// 设置在5秒后开始执行,并且每隔10秒重复执行一次
service.scheduleAtFixedRate(task, 5, 10, TimeUnit.SECONDS);


这种方式相比第二种方式有以下优点:



  • ScheduledExecutorService接口可以创建多个线程来执行多个任务,避免了单线程的弊端。

  • ScheduledExecutorService接口可以设置固定频率或者固定延迟来执行任务,更加灵活和准确。

  • ScheduledExecutorService接口可以取消或者重新安排任务,更加方便和可控。


但是这种方式也有以下缺点:



  • ScheduledExecutorService接口受系统时间影响,如果用户修改了系统时间,会导致计时错误。

  • ScheduledExecutorService接口不可靠,如果进程被杀死或者进入休眠状态,会导致计时中断。


因此,这种方式适合一些需要多线程并发执行的定时任务。


4. Handler中的postDelayed方法


这种方式是使用Android API里提供的Handler类来实现定时任务。Handler类可以在主线程或者子线程中发送和处理消息,在指定的时间或者周期性地执行某个任务。例如:


// 创建一个Handler对象
Handler handler = new Handler();
// 创建一个Runnable对象
Runnable task = new Runnable() {
@Override
public void run() {
// 执行某个任务
doSomething();
// 延迟10秒后再次执行该任务
handler.postDelayed(this, 10000);
}
};
// 延迟5秒后开始执行该任务
handler.postDelayed(task, 5000);


这种方式相比第三种方式有以下优点:



  • Handler类不受系统时间影响,它使用系统启动时间作为参考。

  • Handler类可以在主线程中更新UI界面,避免了线程间通信的问题。


但是这种方式也有以下缺点:



  • Handler类只能在当前进程中使用,如果进程被杀死或者进入休眠状态,会导致计时中断。

  • Handler类需要手动循环调用postDelayed方法来实现周期性地执行任务。


因此,这种方式适合一些需要在主线程中更新UI界面的定时任务.


5. Service + AlarmManager + BroadcastReceiver


这种方式是使用Android API里提供的三个组件来实现定时任务. Service组件可以在后台运行某个长期的服务;AlarmManager组件可以设置一个闹钟,在指定的时间发送一个



  • Intent,用于指定要启动的Service组件和传递一些参数。

  • AlarmManager组件可以设置一个闹钟,在指定的时间发送一个Intent给BroadcastReceiver组件。

  • BroadcastReceiver组件可以接收AlarmManager发送的Intent,并启动Service组件来执行任务。


这种方式相比第四种方式有以下优点:



  • Service组件可以在后台运行,即使进程被杀死或者进入休眠状态,也不会影响计时。

  • AlarmManager组件可以设置精确或者不精确的闹钟,根据不同的需求节省电量。

  • BroadcastReceiver组件可以在系统开机后自动注册,实现开机自启动。


但是这种方式也有以下缺点:



  • Service组件需要在AndroidManifest.xml文件中声明,并申请相关的权限。

  • AlarmManager组件受系统时间影响,如果用户修改了系统时间,会导致计时错误。

  • BroadcastReceiver组件需要在代码中动态注册和注销,避免内存泄漏。


因此,这种方式适合一些需要长期在后台执行的定时任务。


6. WorkManager


这种方式是使用Android Jetpack里提供的WorkManager库来实现定时任务. WorkManager库是一个用于管理后台任务的框架,它可以在满足一定条件下执行某个任务,并保证任务一定会被执行。例如:


// 创建一个PeriodicWorkRequest对象
PeriodicWorkRequest request = new PeriodicWorkRequest.Builder(MyWorker.class, 15, TimeUnit.MINUTES)
.setConstraints(new Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build())
.build();
// 获取一个WorkManager对象
WorkManager workManager = WorkManager.getInstance(this);
// 将PeriodicWorkRequest对象加入到队列中
workManager.enqueue(request);


这种方式相比第五种方式有以下优点:



  • WorkManager库不受系统时间影响,它使用系统启动时间作为参考。

  • WorkManager库可以设置多种约束条件来执行任务,例如网络状态、电量状态、设备空闲状态等。

  • WorkManager库可以取消或者重新安排任务,更加方便和可控。


但是这种方式也有以下缺点:



  • WorkManager库需要添加依赖并配置相关的权限。

  • WorkManager库不能保证任务准时执行,它会根据系统资源和约束条件来调度任务。


因此,这种方式适合一些对时间不敏感且需要满足特定条件才能执行的定时任务


您好,这是必应。我根据您的要求,从网络上搜集了一些关于如何使用RXJAVA、CountDownTimer、协程安卓实现一个定时任务的资料,并整理成了一篇文章。文章的结构如下:


7.如何使用RXJAVA实现定时任务


RXJAVA是一个基于观察者模式和函数式编程的库,它可以用来处理异步事件和数据流。RXJAVA提供了一些操作符,可以用来创建和处理定时任务。其中,interval操作符可以用来创建一个按照固定时间间隔发射整数序列的Observable,可以用来实现周期性的定时任务。例如,以下代码可以实现每隔1秒发射一个整数,从0开始:


//创建一个Observable,每隔1秒发射一个整数,从0开始
Observable timer = Observable.interval(1, TimeUnit.SECONDS);
//订阅这个Observable,打印发射的数据
timer.subscribe(new Observer() {
@Override
public void onSubscribe(Disposable d) {
//订阅时的操作,可以用来取消订阅
}

@Override
public void onNext(Long aLong) {
//收到数据时的操作,可以用来更新UI或者执行其他逻辑
Log.d("RXJAVA", "onNext: " + aLong);
}

@Override
public void onError(Throwable e) {
//发生错误时的操作,可以用来处理异常
}

@Override
public void onComplete() {
//完成时的操作,可以用来释放资源
}
});


如果要实现倒计时的效果,可以使用map操作符,将发射的整数转换为倒计时的秒数。例如,以下代码可以实现一个10秒的倒计时:


//创建一个Observable,每隔1秒发射一个整数,从0开始,共发射10个
Observable timer = Observable.interval(1, TimeUnit.SECONDS).take(10);
//使用map操作符,将发射的整数转换为倒计时的秒数
timer = timer.map(new Function() {
@Override
public Long apply(Long aLong) throws Exception {
//将发射的整数转换为倒计时的秒数,例如0转换为10,1转换为9,以此类推
return 10 - aLong;
}
});
//订阅这个Observable,打印发射的数据
timer.subscribe(new Observer() {
@Override
public void onSubscribe(Disposable d) {
//订阅时的操作,可以用来取消订阅
}

@Override
public void onNext(Long aLong) {
//收到数据时的操作,可以用来更新UI或者执行其他逻辑
Log.d("RXJAVA", "onNext: " + aLong);
}

@Override
public void onError(Throwable e) {
//发生错误时的操作,可以用来处理异常
}

@Override
public void onComplete() {
//完成时的操作,可以用来释放资源
}
});


RXJAVA的优点是可以方便地处理异步事件和数据流,可以使用各种操作符来实现复杂的逻辑,可以避免内存泄漏和线程安全


8.如何使用CountDownTimer实现定时任务


CountDownTimer是Android中提供的一个倒计时器类,它可以用来实现一个在一定时间内递减的倒计时。CountDownTimer的构造方法接受两个参数:总时间和间隔时间。例如,以下代码可以创建一个10秒的倒计时,每隔1秒更新一次:


//创建一个10秒的倒计时,每隔1秒更新一次
CountDownTimer timer = new CountDownTimer(10000, 1000) {
@Override
public void onTick(long millisUntilFinished) {
//每隔一秒调用一次,可以用来更新UI或者执行其他逻辑
Log.d("CountDownTimer", "onTick: " + millisUntilFinished / 1000);
}

@Override
public void onFinish() {
//倒计时结束时调用,可以用来释放资源或者执行其他逻辑
Log.d("CountDownTimer", "onFinish");
}
};
//开始倒计时
timer.start();
//取消倒计时
timer.cancel();


CountDownTimer的优点是使用简单,可以直接在UI线程中更新UI,不需要额外的线程或者Handler。CountDownTimer的缺点是只能实现倒计时的效果,不能实现周期性的定时任务,而且精度受系统时间的影响,可能不够准确。


9.如何使用协程实现定时任务


协程可以用来简化异步编程和线程管理。协程是一种轻量级的线程,它可以在不阻塞线程的情况下挂起和恢复执行。协程安卓提供了一些扩展函数,可以用来创建和处理定时任务。其中,delay函数可以用来暂停协程的执行一段时间,可以用来实现倒计时或者周期性的定时任务。例如,以下代码可以实现一个10秒的倒计时,每隔1秒更新一次:


//创建一个协程作用域,可以用来管理协程的生命周期
val scope = CoroutineScope(Dispatchers.Main)
//在协程作用域中启动一个协程,可以用来执行异步任务
scope.launch {
//创建一个变量,表示倒计时的秒数
var seconds = 10
//循环执行,直到秒数为0
while (seconds > 0) {
//打印秒数,可以用来更新UI或者执行其他逻辑
Log.d("Coroutine", "seconds: $seconds")
//暂停协程的执行1秒,不阻塞线程
delay(1000)
//秒数减一
seconds--
}
//倒计时结束,打印日志,可以用来释放资源或者执行其他逻辑
Log.d("Coroutine", "finish")
}
//取消协程作用域,可以用来取消所有的协程
scope.cancel()


协程安卓的优点是可以方便地处理异步任务和线程切换,可以使用简洁的语法来实现复杂的逻辑,可以避免内存泄漏和回调。协程的缺点是需要引入额外的依赖,而且需要一定的学习成本,不太适合初学者。


10.使用kotlin关键字 ‘downTo’ 搭配Flow


// 创建一个倒计时器,从10秒开始,每秒减一
val timer = object: CountDownTimer(10000, 1000) {
override fun onTick(millisUntilFinished: Long) {
// 在每个间隔,发射剩余的秒数
emitSeconds(millisUntilFinished / 1000)
}

override fun onFinish() {
// 在倒计时结束时,发射0
emitSeconds(0)
}
}

// 创建一个Flow,用于发射倒数的秒数
fun emitSeconds(seconds: Long): Flow = flow {
// 使用downTo关键字创建一个倒数的范围
for (i in seconds downTo 0) {
// 发射当前的秒数
emit(i.toInt())
}
}


11.kotlin内联函数的协程和 Flow 实现


fun FragmentActivity.timerFlow(
time: Int = 60,
onStart: (suspend () -> Unit)? = null,
onEach: (suspend (Int) -> Unit)? =
null,
onCompletion: (suspend () -> Unit)? =
null
): Job {
return (time downTo 0)
.asFlow()
.cancellable()
.flowOn(Dispatchers.Default)
.onStart { onStart?.invoke() }
.onEach {
onEach?.invoke(it)
delay(
1000L)
}.onCompletion { onCompletion?.invoke() }
.launchIn(lifecycleScope)
}


//在activity中使用
val job = timerFlow(
time = 60,
onStart = { Log.d("Timer", "Starting timer...") },
onEach = { Log.d("Timer", "Seconds remaining: $it") },
onCompletion = { Log.d("Timer", "Timer completed.") }
)

//取消计时
job.cancel()

作者:淘淘养乐多
来源:juejin.cn/post/7270173192789737487
收起阅读 »

各平台移动开发技术对比

针对原生开发面临的问题,业界一直都在努力寻找好的解决方案,而时至今日,已经有很多跨平台框架(注意,本书中所指的“跨平台”若无特殊说明,即特指 Android 和 iOS 两个平台),根据其原理,主要分为三类: hybrid :H5 + 原生(Cordova、I...
继续阅读 »

针对原生开发面临的问题,业界一直都在努力寻找好的解决方案,而时至今日,已经有很多跨平台框架(注意,本书中所指的“跨平台”若无特殊说明,即特指 Android 和 iOS 两个平台),根据其原理,主要分为三类:


hybrid :H5 + 原生(Cordova、Ionic、微信小程序)

JavaScript 开发 + 原生渲染 (React Native、Weex)

自绘UI + 原生 (Qt for mobile、Flutter)


1、Hybrid :H5 + 原生


主要原理:
将 App 中需要动态变动的内容通过HTML5(简称 H5)来实现,
通过原生的网页加载控件WebView (Android)或 WKWebView(iOS)来加载。
WebView 中 JavaScript 与原生 API 之间就需要一个通信的桥梁,JsBridge。


**优点是:**动态内容可以用 H5开发,而H5是Web 技术栈,Web技术栈生态开放且社区资源丰富,整体开发效率高。


缺点是:

1.性能体验不佳,对于复杂用户界面或动画,WebView 有时会不堪重任。

2.其 JavaScript 依然运行在一个权限受限的沙箱中,所以对于大多数系统能力都没有访问权限,如无法访问文件系统、不能使用蓝牙等。所以,对于 H5 不能实现的功能,就需要原生去做了。


2、JavaScript开发 + 原生渲染 (React Native、Weex)


2.1、React Native


1.React Native (简称 RN )是 Facebook 开源的跨平台移动应用开发框架。
目前支持 iOS 和 Android 两个平台。
2.React Native 基于 JavaScript,开发者可以利用已有的前端开发经验快速上手
3.开发者编写的js代码,通过 react native 的中间层转化为原生控件和操作
4.react native 运行在JavaCore中,所以不存在浏览器兼容的问题
最终,JS代码会被打包成一个 bundle 文件,自动添加到 App 的资源目录下。



JavaScriptCore 是一个JavaScript解释器,它在React Native中主要有两个作用:



  1. 为 JavaScript 提供运行环境。

  2. 是 JavaScript 与原生应用之间通信的桥梁,作用和 JsBridge 一样,事实上,在 iOS 中,很多 JsBridge 的实现都是基于 JavaScriptCore 。




而 RN 中将虚拟 DOM 映射为原生控件的过程主要分两步:



  1. 布局消息传递; 将虚拟 DOM 布局信息传递给原生;

  2. 原生根据布局信息通过对应的原生控件渲染;



RN 和 React 原理相通,React 是一个响应式的 Web 框架。



  • 开发者只需关注状态转移(数据),当状态发生变化,React 框架会自动根据新的状态重新构建UI。

  • React 框架在接收到用户状态改变通知后,会根据当前渲染树,结合最新的状态改变,通过 Diff 算法,计算出树中变化的部分,然后只更新变化的部分(DOM操作),从而避免整棵树重构,提高性能


2.2、Weex


1.Weex 是阿里的跨平台移动端开发框架,思想及原理和 React Native 类似
底层都是通过原生渲染的
2.不同是应用层开发语法 (即 DSL,Domain Specific Language):Weex 支持 Vue 语法和 Rax 语法
3.Rax 的 DSL(Domain Specific Language) 语法是基于 React JSX 语法而创造
4.但相对于 React Native,它对前端开发者的要求较低
5、一定程度减少了JS Bundle的体积,使得 bundle 里面只保留业务代码。


JavaScript 开发 + 原生渲染 的方式主要优点如下



  1. 采用 Web 开发技术栈,社区庞大、有前端基础的话上手快、开发成本相对较低。

  2. 原生渲染,性能相比 H5 提高很多。

  3. 动态化较好,支持热更新。


不足:



  1. 渲染时需要 JavaScript 和原生之间通信,在有些场景如拖动可能会因为通信频繁导致卡顿。

  2. JavaScript 为脚本语言,执行时需要解释执行 (这种执行方式通常称为 JIT,即 Just In Time,指在执行时实时生成机器码),执行效率和编译类语言(编译类语言的执行方式为 AOT ,即 Ahead Of Time,指在代码执行前已经将源码进行了预处理,这种预处理通常情况下是将源码编译为机器码或某种中间码)仍有差距。

  3. 由于渲染依赖原生控件,不同平台的控件需要单独维护,并且当系统更新时,社区控件可能会滞后;

    除此之外,其控件系统也会受到原生UI系统限制,例如,在 Android 中,手势冲突消歧规则是固定的,这在使用不同人写的控件嵌套时,手势冲突问题将会变得非常棘手。这就会导致,如果需要自定义原生渲染组件时,开发和维护成本过高。


3、自绘UI + 原生


自绘UI + 原生这种技术的思路是:
通过在不同平台实现一个统一接口的渲染引擎来绘制UI,而不依赖系统原生控件,
所以可以做到不同平台UI的一致性


注意,自绘引擎解决的是 UI 的跨平台问题,如果涉及其他系统能力调用,依然要涉及原生开发。这种平台技术的优点如下:



  1. 性能高;由于自绘引擎是直接调用系统API来绘制UI,所以性能和原生控件接近。

  2. 灵活、组件库易维护、UI外观保真度和一致性高;由于UI渲染不依赖原生控件,也就不需要根据不同平台的控件单独维护一套组件库,所以代码容易维护。由于组件库是同一套代码、同一个渲染引擎,所以在不同平台,组件显示外观可以做到高保真和高一致性;另外,由于不依赖原生控件,也就不会受原生布局系统的限制,这样布局系统会非常灵活。


不足:



  1. 动态性不足;为了保证UI绘制性能,自绘UI系统一般都会采用 AOT 模式编译其发布包,所以应用发布后,不能像 Hybrid 和 RN 那些使用 JavaScript(JIT)作为开发语言的框架那样动态下发代码。

  2. 应用开发效率低:Qt 使用 C++ 作为其开发语言,而编程效率是直接会影响 App 开发效率的,C++ 作为一门静态语言,在 UI 开发方面灵活性不及 JavaScript 这样的动态语言,另外,C++需要开发者手动去管理内存分配,没有 JavaScript 及Java中垃圾回收(GC)的机制。


Flutter 就属于这一类跨平台技术,没错,Flutter 正是实现一套自绘引擎,并拥有一套自己的 UI 布局系统,且同时在开发效率上有了很大突破。


3.1、Qt


Qt 是一个1991年由 Qt Company 开发的跨平台 C++ 图形用户界面应用程序开发框架。


在近几年,虽然偶尔能听到 Qt 的声音,但一直很弱,无论 Qt 本身技术如何、设计思想如何,但事实上终究是败了,究其原因,笔者认为主要有四:


第一:Qt 移动开发社区太小,学习资料不足,生态不好。

第二:官方推广不利,支持不够。

第三:移动端发力较晚,市场已被其他动态化框架占领( Hybrid 和 RN )。

第四:在移动开发中,C++ 开发和Web开发栈相比有着先天的劣势,直接结果就是 Qt 开发效率太低。


3.2、Flutter


Flutter 是 Google 发布的一个用于创建跨平台、高性能移动应用的框架。
Flutter 实现了一个自绘引擎,使用自身的布局、绘制系统。


2021年8月底,已经有 127K  Star,Star 数量 Github 上排名前 20 
Flutter 生态系统得以快速增长,国内外有非常多基于 Flutter 的成功案例。



1.Flutter 采用自己的渲染引擎 Skia,将 UI 渲染到画布上,具有良好的性能表现

2.如果对性能要求较高,特别是需要处理复杂动画和大量图形渲染的场景,建议选择 Flutter。

3.Flutter 则采用 Dart 语言,需要开发人员掌握新的语法和概念。

4.支持iOS、Android、Windows/MAC/Linux等多个平台,且能达到原生性能。(移动端、Web端和PC端)



Flutter和Gt对比:



  1. 生态:Flutter 生态系统发展迅速,社区非常活跃,无论是开发者数量还是第三方组件都已经非常可观。

  2. 技术支持:现在 Google 正在大力推广Flutter,Flutter 的作者中很多人都是来自Chromium团队,并且 Github上活跃度很高。另一个角度,从 Flutter 诞生到现在,频繁的版本发布也可以看出 Google 对 Flutter的投入的资源不小,所以在官方技术支持这方面,大可不必担心。

  3. 开发效率:一套代码,多端运行;并且在开发过程中 Flutter 的热重载可帮助开发者快速地进行测试、构建UI、添加功能并更快地修复错误。在 iOS 和 Android 模拟器或真机上可以实现毫秒级热重载,并且不会丢失状态。这真的很棒,相信我,如果你是一名原生开发者,体验了Flutter开发流后,很可能就不想重新回去做原生了,毕竟很少有人不吐槽原生开发的编译速度。


4、react-native、weex、flutter对比:



三种跨平台技术



react-native、weex、flutter对比


React Native:宣布放弃使用 React Native,回归使用原生技术。主要还是集中于项目庞大之后的维护困难,第三方库的良莠不齐,兼容上需要耗费更多的精力导致放弃。


hybrid:


大家都知道hybrid即为web+native的混合开发模式



优点:就是拥有了web开发的服务端发布即可更新的便捷性,Android和iOS两端可以共用代码,并且web技术已经非常成熟,开发效率也会很高。




缺点:就是众所周知的性能相比native有很大的不足,且不同机型和系统版本下的兼容性较差。



React Native、Weex 和 Flutter 是目前最为热门的混合开发框架,它们各自有着优势和特点:


1、React Native



1.React Native 是由 Facebook 推出的开源框架,拥有庞大而活跃的社区,有大量的第三方组件和库可供使用。

2.React Native 基于 JavaScript,开发者可以利用已有的前端开发经验快速上手

3.开发者编写的js代码,通过 react native 的中间层转化为原生控件和操作

4.react native 运行在JavaCore中,所以不存在浏览器兼容的问题



  1. 最终,JS代码会被打包成一个 bundle 文件,自动添加到 App 的资源目录下。



2、Weex



  • Weex 是阿里巴巴推出的开源项目,也有一个较为活跃的社区,但相对于 React Native 来说,生态系统规模稍小。



1.React Native 和 Weex 使用了 WebView 或类似的机制来渲染应用界面,性能相对较低。

2.Weex 同样基于 JavaScript,但相对于 React Native,它对前端开发者的要求较低

3.开发者可以使用Vue.js和Rax两个前端框架来进行WEEX页面开发

4.和 react native一样,weex 所有的标签也不是真实控件,JS 代码中所生成存的 dom,最后都是由 Native 端解析,再得到对应的Native控件渲染



  1. weex:一定程度减少了JS Bundle的体积,使得 bundle 里面只保留业务代码。



3、Flutter



  • Flutter 是由 Google 开发的开源框架,虽然相对较新,但也有一个迅速增长的社区和生态系统。



1.Flutter 采用自己的渲染引擎 Skia,将 UI 渲染到画布上,具有良好的性能表现

2.如果对性能要求较高,特别是需要处理复杂动画和大量图形渲染的场景,建议选择 Flutter。

3.Flutter 则采用 Dart 语言,需要开发人员掌握新的语法和概念。

4.支持iOS、Android、Windows/MAC/Linux等多个平台,且能达到原生性能。(移动端、Web端和PC端)



4、react-native、weex、flutter对比:



react-native、weex、flutter对比


React Native:宣布放弃使用 React Native,回归使用原生技术。主要还是集中于项目庞大之后的维护困难,第三方库的良莠不齐,兼容上需要耗费更多的精力导致放弃。


作者:码农君
来源:juejin.cn/post/7360586351816638501
收起阅读 »

vue反编译dist包到源码

web
最近由于公司老项目上的问题,由于项目很老,之前交接的源码包中缺少了很大一部分模块,但是现在线上的环境和dist包是正常运行的,领导希望能够手动将这部分补全,由于前期项目的不规范,缺少接口文档以及原型图,因此无法知道到底该如何补全,因此,我想着能不能通过dist...
继续阅读 »

最近由于公司老项目上的问题,由于项目很老,之前交接的源码包中缺少了很大一部分模块,但是现在线上的环境和dist包是正常运行的,领导希望能够手动将这部分补全,由于前期项目的不规范,缺少接口文档以及原型图,因此无法知道到底该如何补全,因此,我想着能不能通过dist包去反编译源码包呢,经过多方面探索发现是可行的,但是只能编译出vue文件,但是也满足基本需要了。


1.如何反编译


1.首先需要在管理员模式下打开cmd


2.找到需要编译的dist/static/js的目录下
执行完成后在该目录会看到目录下存在下面的文件名:0.7ab7d1434ffcc747c1ca.js.map,这里以0.7ab7d1434ffcc747c1ca.js.map为例,如下图:


image.png


3.全局安装reverse-sourcemap资源



npm install --global reverse-sourcemap



4.反编译
执行:reverse-sourcemap --output-dir source 0.7ab7d1434ffcc747c1ca.js.map


2.脚本反编译


上面的方式执行完毕,确实在source中会出现源码,那么有没有可能用脚本去执行呢,通过node的child_process模块中的exec方式便可以执行reverse-sourcemap --output-dir source这个命令,那么只需要拿到当前文件夹中包含.map文件即可,那么可以借助node中fs模块,递归读取文件名,并使用正则将所有.map的文件提取出来放在一个集合或数组中,在对数组进行递归循环执行reverse-sourcemap --output-dir source这个命令


2.1根据child_process模块编写执行函数



function executeReverseSourceMap(outputDir) {
// 构建 reverse-sourcemap 命令
const command = `reverse-sourcemap --output-dir source ${outputDir}`;

// 执行命令
exec(command, (error, stdout, stderr) => {
if (error) {
console.error(`执行命令时出错:${error.message}`);
return;
}
if (stderr) {
console.error(`命令输出错误:${stderr}`);
return;
}
console.log(`命令输出结果:${stdout}`);
});
}

2.2读取文件并匹配文件


// // 读取文件夹中的文件
fs.readdir(folderPath, (err, files) => {
if (err) {
console.error('读取文件夹时出错:', err);
return;
}
// 遍历文件
files.forEach(file => {
// 使用正则表达式匹配特定格式的文件名
const match = /^(\d+)\..+\.js\.map$/.exec(file);
if (match) {
// 如果匹配成功,将文件名存入数组
targetFiles.push(match[0]);
}
});

// 输出目标文件名数组
targetFiles.forEach(file=>{
executeReverseSourceMap(file)
})
});

2.3完整的执行代码


const fs = require('fs');
const path = require('path');
const { exec } = require('child_process');
// 文件夹路径
const folderPath = '../js';

// 存放目标文件名的数组
const targetFiles = [];
function executeReverseSourceMap(outputDir) {
// 构建 reverse-sourcemap 命令
const command = `reverse-sourcemap --output-dir source ${outputDir}`;

// 执行命令
exec(command, (error, stdout, stderr) => {
if (error) {
console.error(`执行命令时出错:${error.message}`);
return;
}
if (stderr) {
console.error(`命令输出错误:${stderr}`);
return;
}
console.log(`命令输出结果:${stdout}`);
});
}
// // 读取文件夹中的文件
fs.readdir(folderPath, (err, files) => {
if (err) {
console.error('读取文件夹时出错:', err);
return;
}
// 遍历文件
files.forEach(file => {
// 使用正则表达式匹配特定格式的文件名
const match = /^(\d+)\..+\.js\.map$/.exec(file);
if (match) {
// 如果匹配成功,将文件名存入数组
targetFiles.push(match[0]);
}
});

// 输出目标文件名数组
targetFiles.forEach(file=>{
executeReverseSourceMap(file)
})
});

image.png


3最终结果展示图


image.png


作者:ws_qy
来源:juejin.cn/post/7359893196439207972
收起阅读 »

Zed,有望打败 VS Code 吗?

大家好,我是楷鹏。 先说结论,不行。 Zed,又一款新起的文本代码编辑器 👉 zed.dev 今年一月二十四号正式开源,短短不到三个月,GitHub 上已经冲上 3 万 star 正如 Zed 的口号所说「Code at the speed of th...
继续阅读 »

大家好,我是楷鹏。


先说结论,不行


Zed,又一款新起的文本代码编辑器




👉 zed.dev



今年一月二十四号正式开源,短短不到三个月,GitHub 上已经冲上 3 万 star



正如 Zed 的口号所说「Code at the speed of thought 以思考的速度编码


实际体验下来,Zed 确实会比 VS Code 丝滑


⬇️ Zed



⬇️ VS Code



官网也给出了打字输入性能对比:



输入字母 z 并显示到屏幕,Zed 仅需 58 毫秒,而 VS Code 需要 97 毫秒


Zed 比 VS Code 快了 1.4 倍


在输入性能方面,Zed 胜出


其次就是 Zed 主打的另一个核心功能,多用户协同编程



额说实话,这个功能暂时想不到很好的落地使用场景。




到目前为止,Zed 仅仅是一个不错的文本编辑器。


甚至可以说,Zed 实质上并没有重大的突破,属于自嗨产品。


Zed 宣传的高性能,并没有质的飞跃,很难打到用户的马屁上。



「58毫秒」和「97毫秒」两个差距并不大


实际开发都知道,编程的瓶颈并不在于输入速度


另外是多用户协同,目前看这个场景不友好



如果是文档协同,国内的飞书文档、腾讯文档等哪一个不是佼佼者,按着 Zed 锤。


如果是代码协同,显然 Git 才是主流。




Zed 太年轻,目前很基础的 markdown 预览都没有实现


VS Code 珠玉在前,用开源、插件化形成的护城河,一开放拥有大批拥趸


而 Zed 虽然同样有插件机制,但是能指望多少人贡献呢?



《重来》一书讲到,第一次创业失败的人,第二次创业失败概率一样大


Zed 的团队原先做过 Atom 编辑器,而现在 Atom 名存实亡



团队做 Atom 失败过,而卷土重来的 Zed,还不行


Zed 大概率能够圈住一部分用户,但不会成为领域的成功。




Zed 如何能破局呢?最重要的还是要顺势而为


想想 VS Code 当时,互联网的繁荣,带动开源领域的发展,Eclipses 老旧、Jetbrains 高昂收费,前端分工细化,急需轻量的编辑器,这些都是 VS Code 的势头。


而目前 Zed 最好的势头,显而易见,就是 AI 方向


而 Zed 目前显然支持不足,仅有 Copilot 代码不足和 Chat 能力



而这些 VS Code 不仅有,而且功能更加完善。


Zed 团队应该思考下了,要做一款怎么样的编辑器,适应目前的 AI 潮流,开创新的赛道。


如果继续安于微不足道的性能提升、垂直的协同,继续在垂直赛道内卷,那我祝你成功。



作者:吴楷鹏
来源:juejin.cn/post/7359469421742473225
收起阅读 »

为什么不建议在 Vue <style> 中使用 scoped?

web
前言 标签下编写样式。不知你是否留意,在 标签下有一个属性经常出现 - scoped。你知道它起到什么作用吗?原理是怎样的?有没有什么弊端呢?今天我们就来聊聊它。 1. 什么是 scoped? scoped 顾名思义,与作用域有关,因为是设计组件样式的,所以...
继续阅读 »

前言

亲爱的小伙伴,你好!我是 嘟老板。我们使用 Vue 开发页面时,经常需要在 

收起阅读 »

是的,失业快一个月了

离职快一个月了,在这一个月里,也会在闲暇的时间记录一下思考。突然对于工作、对于求职、对于健康、对于生活有了新的认识。 3月30日 是的,我离职了,离开了我工作接近两年的公司。没有拍很多的照片,也没有习惯性的剪辑一个vlog。走的很平静,甚至工位上的东西,用一个...
继续阅读 »

离职快一个月了,在这一个月里,也会在闲暇的时间记录一下思考。突然对于工作、对于求职、对于健康、对于生活有了新的认识。


3月30日


是的,我离职了,离开了我工作接近两年的公司。没有拍很多的照片,也没有习惯性的剪辑一个vlog。走的很平静,甚至工位上的东西,用一个装零食的塑料袋就可以全部的拿走。


那个下午,我回忆了这两年的很多的事情。从一线回来,先租房子,然后等着毕业后的7月办理入职;随后研究起AI,做了很多的demo。后来搬到了楼上,写着用古老技术写的项目,楼下部分伙伴也相继离开了。勉强熬过了23年的冬天,年后我也得走了。


经过了漫长的离职流程,一切就绪,就差一个离职证明。


正好在那天晚上回来刷着抖音的时候,听到了飞书要裁员的消息,大约1000多人要面临着失业。


在还没毕业的大学时光里,我就满怀着焦虑。考研爹妈不支持,生活费没有着落,就业压力大。实在是没了办法,四处的海投,后来去了上海,再后来去了一线。再后来,在一线面临着裁员,又一下子回到了武汉。


回武汉-到现在的住所路上


想着过去的三年,真的像是一场梦。熬过了疫情,熬过了“二阳”,任何时候,互联网上都充斥着“互联网寒冬“的声音。而我,也在将近两年的摆烂之后,不得不面对这个残酷、血淋淋的现实。


人很容易陷入自我怀疑和沮丧的境地,在激情和麻木中徘徊。我常常在一些加班到很晚的晚上,看着车窗外的街道问自己:这一切的努力为的是什么?为了梦想、为了走出农村,为了体面的活着,仿佛内心燃起了激情。然而,第二天又得从床上爬起来,继续按部就班的上班,挤着电梯,做着乏味的工作。晚上下班了,又回来坐在电脑桌前。周而复始。


年与时驰,意与日去,遂成枯落,多不接世,悲守穷庐,将复何及!终究将逃不过30岁!我也时常遥望自己的30岁,30岁是什么样子的?是有一个爱的人,有了自己的孩子,有了自己幸福的家;还是一个人在异乡狭小的出租屋等下,听着伤感的音乐,喝着咖啡抽着烟。


这次的离职给了我很多的思考时间,对于这个行业、对于接下来的选择。


四月上旬


正式开始了失业,一直呆在家里,偶尔会在晚上出去跑步。互联网行情依旧是差得很,很多的招聘平台都是已读不回,当然还有很多奇葩的HR。加上AI大模型越来越多,甚至说阿里都要用AI来代替20%的人工工作,需要传统的程序员的岗位将越来越少。于是,我开始分析各种调查报告、行业报告。最后锁定在了这几个行业:新能源领域 新材料 AIGC 化工 电驱动工艺。虽然这段时间我也没有找到合适的行业的工作,但是我总结了一个找工作的铁律:跟着资本选行业


2023Q3国内小赛道股权投资势头:来源IT桔子


投资机构基本上都是嗅觉到最新的商机的,他们的资本注入将会给新行业、新领域或者企业带来新的活力。


四月中旬


在武汉跑了马拉松之后,我想回到家乡去也跑一下。这次我全程vlog记录了:2024十堰马拉松健康跑纪实。来回两天,时间的安排上也妥妥的。


回来之后的第二天,就要去体检了。不幸的是:我的胆囊出现了息肉,而且以不可控的速度在增长。因为这几年我对于我的身体都密切的关切,每年都会体检,息肉出现在我回武汉的这两年里。


拿着最近三年的检查结果,就往我最近的军医医院去。门诊的医生建议我直接做手术将它切除了。一下子慌了神,但是又在担心它的癌变,担心每次饭后的胸闷胸胀。一番纠结,最终我入院接受手术了。


我的病房和病床


第一次手术室和重症监护室,你是什么感觉?如果问我,我会说我很害怕,害怕到浑身颤抖,害怕到忍不住哭了。


换上病号才穿的衣服,我进入了手术室,躺在了手术台上,随着手术室的大门关上,看着手术室穿着手术服的医生,我整个身体忍不住的颤抖。我不能想象锋利的手术刀割开我的肚皮,然后找到我的胆,割下来,再缝合好。上高中的时候,我就经历了不打麻药直接用针线缝补伤口(当然,那是医疗条件不是很方便的时候)。解开衣服,闭上眼睛,不知道麻药怎么注入我的身体的。我在迷迷糊糊中没了知觉。


当我再次被叫醒的时候,是我的手术已经完成了。医生告诉我:“xxx,手术做完了,手术做的很顺利。”我被几个人抱上了监护室的病床上,我的伙伴也在我身边,我才知道手术了两个多小时。


在监护室哭了两小时,这是这么多年来第一次哭,而且哭的这么久。还好诺大的监护室只有我和一个监护的护士,不至于丢掉我男子汉大丈夫的面子。她耐心的帮我多次擦干泪水,说我的眼睛都红了。


后来的两天就都是输液了。不敢咳嗽,还好也有雾化的设备。没有了痛觉,因为脖子上的止痛泵帮我麻痹了神经。


四月下旬


最后也如期的出院了,每天除了按时吃清水面条+喝药+换药外,好像也没什么事情可以做的。屏蔽了朋友圈,很少看消息,坐累了就躺着休息。一次手术,几乎花光了我三年来的积蓄,再一次是那么的怕死惜命。想着被资本家誉为996福报、单休、PUA,觉得人命在资本家面前看起来是那么的卑贱。如果资本比不上别人,那就请你一定要健康。


闲暇的时间也会去看看纪录片,楼下散步走走。


《大秦赋》-始皇嬴政


看了《大秦赋》,当然我追剧是倒着看,想起了贾谊的《六国论》,有时间的话,会出一期文章。


还有大美的天山雪山、有趣的万物生灵。


雪山


黑颈鹤



觉得地球是那么的奇妙,生命是那么的奇妙,这么多的奇妙才是神秘、才是美好、才是丰富的存在。



这个月的经历,算得上是跌宕起伏。总之:希望每一个职场人或者是同行,一定要爱惜自己的身体,按时吃饭、早睡早起。不要畏惧失业,不为各种被贩卖的焦虑而焦虑,更多的是爱护自己,珍惜眼前,享受生活。


作者:shigen01
来源:juejin.cn/post/7360711693359988786
收起阅读 »

完了,安卓项目代码被误删了......

写在前面 这是一个朋友的经历记录了下来。朋友开发完了一个公司的app,过了一段时间,在清理电脑空间的时候把该app的项目目录给删了,突然公司针对该app提出了新的需求,这不完了?幸好有之前打包好的apk,所以可以通过逆向去弥补..... Apk文件结构 apk...
继续阅读 »

写在前面


这是一个朋友的经历记录了下来。朋友开发完了一个公司的app,过了一段时间,在清理电脑空间的时候把该app的项目目录给删了,突然公司针对该app提出了新的需求,这不完了?幸好有之前打包好的apk,所以可以通过逆向去弥补.....


Apk文件结构


apk的本质是压缩包,apk解压后会生成下列所示文件夹




  • Assets:存放的是不会被编译处理的文件。

  • Lib:存放的是一些so库,native库文件

  • META-INF:存放的是签名信息,用来保证apk的完整性和系统安全。防止被重新修改打包。

  • res:存放的资源文件,图片、字符串、颜色信息等

  • AndroidManifest.xml:是Android程序的配置文件,权限和配置信息

  • Classes.dex:Android平台下的字节码文件。

  • Resources.arcs:编译后的二进制资源文件,用来记录资源文件和资源ID的关系


逆向


这里用了逆向神器——jdax。支持命令行和图形化界面,地址如下:


github.com/skylot/jadx…


下载好之后,直接解压后打开exe,将apk文件拖入进去就可以,图形化界面,更方便搜索查看,可以看到下列文件夹



先看资源文件,asset存放的是静态资源文件,一般不会被压缩,但是会占用更多的安装包空间,res文件是由Android目录下的res进行压缩得到的,所以里面的文件直接解压打开会乱码,在这个工具里打开是正常的。



话不多说直接找回我的代码,找到我写的一个类,拷贝回去,补齐里面缺失的资源文件和一些新增的接口,跟着自己之前开发的流程,一步一步的找回去,发现其中局部变量在编译的时候都被进行了优化,以便缩小体积



找到我写的最核心的代码,发现被混淆了,我在代码里没有进行代码混淆配置,还是被一些工具给我进行了混淆,只能凭借着记忆去还原了。



终于进行了不到一天多的时间,把所有的代码还原了,然后自测通过。


代码混淆


现在其实也可以看到自己的程序是非常危险的,任何人拿到我的apk进行一个逆向就可以看到大概的逻辑。所以要在Android中进行代码混淆的配置。


项目中如果含有多个module时,在主app中设置了混淆其他module都会混淆,在build.gradle中配置下列代码 proguardFiles getDefaultProguardFile


android {
...
buildTypes {
release {
minifyEnabled true // 开启代码混淆
zipAlignEnabled true // 开启Zip压缩优化
shrinkResources true // 移除未被使用的资源
//混淆文件列表,混淆规则配置
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
...
}

这里代表的是混淆文件,我们在项目里找到proguard-rules.pro,这里就是混淆规则,规定了哪些代码进行混淆,哪些不进行混淆。混淆规则一般有以下几点:



  • 混淆规则,等级、预校验、混淆算法等

  • 第三方库

  • 自定义类、控件

  • 本地的R类

  • 泛型 注解 枚举类等


示例配置如下:



#压缩等级,一般选择中间级别5
-optimizationpasses 5
#包名不混合大小写
-dontusemixedcaseclassnames
#不去忽略非公共的库类
-dontskipnonpubliclibraryclasses
#优化 不优化输入的类文件
-dontoptimize
#预校验
-dontpreverify
#混淆时采用的算法
-optimizations !code/simplification/arithmetic,!field/*,!class/merging/*
#保护注解
-keepattributes *Annotation*
#保持下面的类不被混淆(没有用到的可以删除掉,比如没有用到service则可以把service行删除)
-keep public class * extends android.app.Fragment
-keep public class * extends android.app.Activity
-keep public class * extends android.app.Application
-keep public class * extends android.app.Service
-keep public class * extends android.content.BroadcastReceiver
-keep public class * extends android.content.ContentProvider
-keep public class * extends android.preference.Preference
-keep public class * extends android.support.v4.app.FragmentActivity
-keep public class * extends android.support.** { *;}
#如果引用了v4或者v7包
-dontwarn android.support.*
#忽略警告(开始应该注释掉,让他报错误解决,最后再打开,警告要尽量少)
-ignorewarnings
#####################记录生成的日志数据,gradle build时在本项目根目录输出################
#混淆时是否记录日志
-verbose
#apk 包内所有class 的内部结构
-dump class_files.txt
#为混淆的类和成员
-printseeds seeds.txt
#列粗从 apk 中删除的代码
-printusage unused.txt
#混淆前后的映射
-printmapping mapping.txt
#####################记录生成的日志数据,gradle build时在本项目根目录输出结束################

#本地的R类不要被混淆,不然就找不到相应的资源
-keep class **.R$*{ public static final int *; }

#保持内部类,异常类
-keepattributes Exceptions, InnerClasses
#保持泛型、注解、源代码之类的不被混淆
-keepattributes Signature, Deprecated, SourceFile
-keepattributes LineNumberTable, *Annotation*, EnclosingMethod

#保持自定义控件不被混淆(没有就不需要)
-keepclasseswithmembers class * extends android.app.Activity{
public void *(android.view.View);
}
-keepclasseswithmembers class * extends android.supprot.v4.app.Fragment{
public void *(android.view.View);
}
#保持 Parcelable 不被混淆(没有就不需要)
-keep class * implements android.os.Parcelable {
public static final android.os.Parcelable$Creator *;
}
#保持 Serializable 不被混淆(没有就不需要)
-keepnames class * implements java.io.Serializable

-keepclassmembers class * {
public void *ButtonClicked(android.view.View);
}
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}

再次打包,然后打开apk后就会发现包名类名变量名都变得很奇怪。




这样代码混淆就完成了。


作者:银空飞羽
来源:juejin.cn/post/7360903734853730356
收起阅读 »

JavaScript注释:单行注释和多行注释详解

为了提高代码的可读性,JS与CSS一样,也提供了注释功能。JS中的注释主要有两种,分别是单行注释和多行注释。在编程的世界里,注释是那些默默无闻的英雄,它们静静地站在代码的背后,为后来的维护者、为未来的自己,甚至是为那些偶然间翻阅你代码的开发者提供着不可或缺的信...
继续阅读 »

为了提高代码的可读性,JS与CSS一样,也提供了注释功能。JS中的注释主要有两种,分别是单行注释和多行注释。

在编程的世界里,注释是那些默默无闻的英雄,它们静静地站在代码的背后,为后来的维护者、为未来的自己,甚至是为那些偶然间翻阅你代码的开发者提供着不可或缺的信息。

Description


今天,我们就来深入探讨JavaScript中的注释,让我们的代码不仅能够运行,还能够“说话”。

一、什么是JavaScript注释

JavaScript注释是用来解释代码的,不会被浏览器执行。它们可以帮助其他开发者理解代码的功能和目的。

注释就像是给代码穿上了一件华丽的外衣,让我们的代码更加优雅、易读。如下图中的例子所示:


Description


在JavaScript中,有两种类型的注释:单行注释和多行注释。下面分别讲解这两种注释的含义和使用。


二、JavaScript注释的种类

1、单行注释

单行注释: 使用两个斜杠(//)开头,后面的内容直到该行结束都被视为注释。例如:

// 这是一个单行注释
console.log("Hello, World!"); // 这也是一个单行注释

它适用于简短的注释,比如对某一行代码的快速说明。

2、多行注释

多行注释: 使用斜杠星号(/)开头,星号斜杠(/)结尾,中间的内容都被视为注释。

例如:

/*
这是一个多行注释
可以跨越多行
*/

console.log("Hello, World!");

这种注释可以跨越多行,适合用于函数描述、复杂的算法解释或者临时屏蔽代码块。

注意: 在HTML文件中,如果需要将JavaScript代码嵌入到<script>标签中,可以使用以下方法来添加多行注释:

<script>
<!--
这是一个多行注释
可以跨越多行
-->

console.log("Hello, World!");
</script>


三、JavaScript注释的作用

1、解释代码功能:

通过注释,我们可以解释代码的功能和作用,让其他程序员更容易理解我们的代码。

// 这是一个求和函数
function sum(a, b) {
return a + b;
}

2、 标记代码状态:

我们可以使用注释来标记代码的状态,例如TODO、FIXME等,提醒自己或其他程序员注意这些问题。

// TODO: 优化这个函数的性能
function slowFunction() {
// ...
}

3、临时禁用代码:

当我们需要暂时禁用某段代码时,可以使用注释将其包裹起来,而不是直接删除。

// function oldFunction() {
// // ...
// }


想要快速入门前端开发吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!

点这里前往学习哦!


四、如何写好注释

注释虽好,但过多或不当的注释反而会成为阅读代码的障碍。我们在写注释时也要注意以下几点:

  • 简洁明了: 注释应该简单明了,能够快速传达信息。

  • 适当使用: 不要过度使用注释,只有在必要的时候才添加。

  • 保持一致: 在团队开发中,要遵循统一的注释规范,以便于团队成员之间的沟通。

  • 适时更新: 随着代码的变更,记得更新相关的注释。

JavaScript注释是我们编程过程中的得力助手,它们不仅能够帮助我们更好地理解代码,还能提高代码的可读性和可维护性。让我们一起学会使用注释,让我们的代码更加精彩!

收起阅读 »

记一次划线需求的实现方式

web
1 背景 1.1 需求背景 前半年接了一个划线需求,一直没空总结下。微信公众号和微信读书等读书类应用都有此功能,但前者只对部分用户开放了,并且没有长期运营。 这次只谈下划线技术实现本身。 1.2 功能详叙 用户可以对文章句子进行长按选区,过程中弹出面板,且面...
继续阅读 »

1 背景


1.1 需求背景


前半年接了一个划线需求,一直没空总结下。微信公众号和微信读书等读书类应用都有此功能,但前者只对部分用户开放了,并且没有长期运营。


这次只谈下划线技术实现本身。


1.2 功能详叙



  1. 用户可以对文章句子进行长按选区,过程中弹出面板,且面板位置动态变化,点击点赞按钮后生成划线;

  2. 点击划线句子默认选中,并弹出面板,显示所点句子点赞量;

  3. 划线句子可以合并,规则是选取句子和已赞过的句子有交叉时合并为一条新的首尾更长的句子,选取的句子被包含在已赞过的句子中时显示点赞量,选取句子包含了已赞句子则删掉已赞句子并对新句子点赞量加一;

  4. 点赞量超过3的句子才外显;

  5. 他人的划线句子用虚线展示,自己的划线用实线展示;

  6. 小流量,用户量由小至大,过程中可以对外显策略微调;


1.3 竞品


可以看到,微信公众号划线过程会弹出一个灰色面板,面板上有划线(这次需求改为了点赞)按钮:


image.png
image.png


2 关键逻辑


这个需求乍看可能会觉得没那么复杂,但细细分析后会发现有较长的交互流程和逻辑链:


image.png


其中有几个会影响整体逻辑的关键点需要关注:



  1. 渲染划线的方式:插入 dom 标签还是绝对定位或其他方式;

  2. 监听划线选取的交互事件选择 selectionchange 还是 touchend;

  3. 整个交互过程分为哪些部分;

  4. 怎么判断新划线和其他划线的位置关系,怎么合并或删除;

  5. 数据结构怎么设计;

  6. 怎么将划线序列化;

  7. 怎么将数据反序列化成划线;


3 详细设计


3.1获取划线


window 上提供了 Selection 对象,它代表页面中的文本选区,可能横跨多个元素。文本选区由用户拖拽鼠标经过文字而产生。调用 Selection.toString() 方法会返回被选中区域中的纯文本。


var selObj = window.getSelection();
var range = selObj.getRangeAt(0);

Selection 对象所对应的是用户所选择的 ranges(区域)


var selObj = window.getSelection();
var range = selObj.getRangeAt(0);

3.2 划线渲染方式


渲染划线有两种方式:


1 在划线range对象的首尾dom的位置,插入线段的dom标签;
优点:划线的点击不需要计算点击位置,直接在插入dom上绑定事件即可;
缺点:对原页面结构有入侵,改变了dom结构,可能引发其他问题;


2 绝对定位,相对于整篇文章;
优点:完全增量,对原页面没有入侵;
缺点:需要计算点击位置;


我选择的第二种,原因是为了不影响原有页面逻辑,这样项目风险也是最小的。那么具体怎么实现呢?


range对象提供了一个 getClientRects 方法,表示 range 在屏幕上所占的区域。这个列表相当于汇集了范围中所有元素调用 Element.getClientRects() 方法所得的结果。用拿到的位置信息进行绝对定位即可。


rectList = range.getClientRects()

我们把用户所有划线range对象和其产生的位置信息都存入到一个list中。


pageRangeList.push({
range,
rectInfo
})

3.3 交互过程


我们分析下整个交互过程:
有两个主要的交互事件,一是点击划线,二是滑动选区。


3.3.1 点击事件


处理点击事件,我们拿到点击事件的位置,和存放的 pageRangeList 进行位置比较,得出用户点击的是哪个range对象。


// 点击事件
const {pageX, pageY} = event;
const lineHeight = 23;

const {range} = rectInfo.some(rect => {
const {left, right, realY} = rect;
return pageX < left && pageX > left && pageY > realY
})

this.selection.removeAllRanges();
this.selection.addRange();

3.3.2 选区事件


选区事件我选择的是 selectionchange,需要加防抖和节流处理。


如果你选的是 touchend 安卓系统会点问题。


3.3.3 比较位置关系


如第2点核心逻辑中所说,在滑词过程中,需要比较位置关系,我们直接使用Range.compareBoundaryPoints方法即可。返回值 0 、-1 、1 分别代表不同的位置关系。


const compare = range.compareBoundaryPoints(Range.START_TO_END, sourceRange);

3.4 序列化与反序列化


序列化是整个需求的重点,序列化是指将交互产生的划线转化成某种数据结构能存储在服务器上,反序列化是指如何将server下发的序列化数据转化成非序列化的划线。


两者是两个相反的过程,当我们确定了序列化方案,其实也就知道了反序列化了。


3.4.1 序列化


方案一,识别段落


刚开始我观察文章都会拆分段落,如按P标签或某一个class类名来划分段落,于是计划用段落信息,告诉 server 划线在第几段的第几个字。


interface data {
startParagraph: 1,
startIndex: 22,
endParagraph: 2,
endIndex: 15
}

但后来发现有一些抓取的文章根本内容很混乱,且没有特定的段落,强行识别复杂度极高。(如下图)所以此方案不可行。


image.png


方案二,全文第几个字


前面的方案不可能的原因是,识别段落信息复杂度不可控,那么我们可以绕过段落信息,去识别全文第几个字。


interface data {
startCharacters: 122,
endCharacters: 166
}

具体方式是用Range,圈选文章开头到当前dom,形成一个新Range,再调用range.toString查看字数即可。


const range = new Range();
range.setStart(pageContainer, 0);
range.setEnd(curEndContainer, endOffset);
const str = range.toString();

3.4.2 反序列化


这里注意,由于 Javascript 在大多宿主环境下没有递归的尾调用优化,所以我采用了手动创建栈来进行 dfs:


    dfs({
node = this.content,
}) {
const stack = [];
if (!node) {
return;
}

stack.push(node);

while (stack.length) {
const item = stack.pop();

const children = item.childNodes;
for (let i = children.length - 1; i >= 0; i--) {
stack.push( [i]);
}
}
}

作者:雨默默下了一整夜
来源:juejin.cn/post/7344993022075813938
收起阅读 »

⚡聊天框 - 微信加载历史数据的效果原来这样实现的

web
前言 我记得2021年的时候做过聊天功能,那时业务也只限微信小程序 那时候的心路历程是: 卧槽,让我写一个聊天功能这么高大上?? 嗯?这么简单,不就画画页面来个轮询吗,加个websocket也还行吧 然后,卧槽?这查看历史聊天记录什么鬼,页面闪一下不太好啊,...
继续阅读 »

前言


我记得2021年的时候做过聊天功能,那时业务也只限微信小程序


那时候的心路历程是:



卧槽,让我写一个聊天功能这么高大上??


嗯?这么简单,不就画画页面来个轮询吗,加个websocket也还行吧


然后,卧槽?这查看历史聊天记录什么鬼,页面闪一下不太好啊,真的能做到微信的那种效果吗



然后一堆调研加测试,总算在小程序中查看历史记录没那么鬼畜了,但是总是感觉不是最佳解决方案。



那时打出的子弹,一直等到现在击中了我



最近又回想到了这个痛点,于是网上想看看有没有大佬发解决方案,结果还真被我找到了。


image.png


正文开始


1,效果展示


上才艺~~~


222.gif


2,聊天页面


2.1,查看历史聊天记录的坑


常规写法加载历史记录拼接到聊天主体的顶部后,滚动条会回到顶部、不在原聊天页面


直接上图


111.gif


而我们以往的解决方案也只是各种利用缓存scroll的滚动定位把回到顶部的滚动条重新拉回加载历史记录前的位置,好让我们可以继续在原聊天页面。


但即使我们做了很多优化,也会有安卓和苹果部分机型适配问题,还是不自然,可能会出现页面闪动


其实吧,解决方案只有两行css代码~~~


2.2,解决方案:flex神功


想优雅顺滑的在聊天框里查看历史记录,这两行css代码就是flex的这个翻转属性


dispaly:flex;
flex-direction: column-reverse

灵感来源~~~


333.gif


小伙伴可以看到,在加载更多数据时



滚动条位置没变、加载数据后还是原聊天页面的位置



这不就是我们之前的痛点吗~~~


所以,我们只需要翻转位置,用这个就可以优雅流畅的实现微信的加载历史记录啦


flex-direction: column-reverse


官方的意思:指定Flex容器中子元素的排列方向为列(从上到下),并且将其顺序反转(从底部到顶部)


如果感觉还是抽象,不好理解的话,那就直接上图,不加column-reverse的样子


image.png


加了column-reverse的样子


image.png


至此,我们用column-reverse再搭配data数据的位置处理就完美解决加载历史记录的历史性问题啦


代码放最后啦~~~


2.3,其他问题


2.3.1,数据过少时第一屏展示


因为用了翻转,数据少的时候会出现上图的问题


只需要.mainArea加上height:100%


然后额外写个适配盒子就行


flex-grow: 1; 
flex-shrink: 1;

image.png


2.3.2,用了scroll-view导致的问题


这一part是因为我用了uniappscroll-view组件导致的坑以及解决方案,小伙伴们没用这个组件的可忽略~~~


如下图,.mainArea使用了height:100%后,继承了父级高度后scroll-view滚动条消失了。


image.png


.mainArea去掉height:100%后scroll-view滚动条出现,但是第一屏数据过多时不会滚动到底部展示最新信息


image.png


解决方案:第一屏手动进行滚动条置顶


scrollBottom() {
if (this.firstLoad) return;
// 第一屏后不触发
this.$nextTick(() => {
const query = uni.createSelectorQuery().in(this);
query
.select("#mainArea")
.boundingClientRect((data) => {
console.log(data);
if (data.height > +this.chatHeight) {
this.scrollTop = data.height; // 填写个较大的数
this.firstLoad = true;
}
})
.exec();
});
},

3,服务端


使用koa自己搭一个websocket服务端


3.1 服务端项目目录


image.png


package.json


{
"name": "websocketapi",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"koa": "^2.14.2",
"koa-router": "^12.0.1",
"koa-websocket": "^7.0.0"
}
}


koa-tcp.js


const koa = require('koa')
const Router = require('koa-router')
const ws = require('koa-websocket')

const app = ws(new koa())
const router = new Router()

/**
* 服务端给客户端的聊天信息格式
* {
id: lastid,
showTime: 是否展示时间,
time: nowDate,
type: type,
userinfo: {
uid: this.myuid,
username: this.username,
face: this.avatar,
},
content: {
url:'',
text:'',
w:'',
h:''
},
}
消息数据队列的队头为最新消息,以次往下为老消息
客户端展示需要reverse(): 客户端聊天窗口最下面需要为最新消息,所以队列尾部为最新消息,以此往上为老消息
*/



router.all('/websocket/:id', async (ctx) => {
// const query = ctx.query
console.log(JSON.stringify(ctx.params))
ctx.websocket.send('我是小服,告诉你连接成功啦')
ctx.websocket.on('message', (res) => {
console.log(`服务端收到消息, ${res}`)
let data = JSON.parse(res)
if (data.type === 'chat') {
ctx.websocket.send(`我也会说${data.text}`)
}
})
ctx.websocket.on('close', () => {
console.log('服务端关闭')
})
})

// 将路由中间件添加到Koa应用中
app.ws.use(router.routes()).use(router.allowedMethods())

app.listen(9001, () => {
console.log('socket is connect')
})



切到server目录yarn


然后执行nodemon koa-tcp.js


没有nodemon的小伙伴要装一下


image.png


代码区


完整项目Github传送门


聊天页面的核心代码如下(包含data数据的位置处理和与服务端联动)



完结


这篇文章我尽力把我的笔记和想法放到这了,希望对小伙伴有帮助。


到这里,想给小伙伴分享两句话



现在搞不清楚的事,不妨可以先慢下来,不要让自己钻到牛角尖了


一些你现在觉得解决不了的事,可能需要换个角度



欢迎转载,但请注明来源。


最后,希望小伙伴们给我个免费的点赞,祝大家心想事成,平安喜乐。


image.png


作者:尘落笔记
来源:juejin.cn/post/7337114587123335180
收起阅读 »

基于装饰器——我劝你不要在业务代码上装逼!!!

web
基于装饰器——我劝你不要在业务代码上装逼!!! 装饰器模式的定义 在传统的面向对象语言中,给对象添加功能常使用继承的方式,但继承的方式并不灵活,会带来一些许多问题,如:超类和子类存在强耦合性,也就是说当改变超类时,子类也需要改变。 而装饰器模式的出现改变的这...
继续阅读 »

基于装饰器——我劝你不要在业务代码上装逼!!!


装饰器模式的定义



  • 在传统的面向对象语言中,给对象添加功能常使用继承的方式,但继承的方式并不灵活,会带来一些许多问题,如:超类和子类存在强耦合性,也就是说当改变超类时,子类也需要改变。

  • 而装饰器模式的出现改变的这种方式,装饰器模式可在不改变现有对象解构的基础上,动态地为对象添加功能


传统的 JavaScript 装饰器


var plane = {
fire: function () {
console.log("普通子弹");
},
};

var missleDecorator = function () {
console.log("发射导弹");
};

var atomDecorator = function () {
console.log("发射原子弹");
};

var fire1 = plane.fire;
plane.fire = function () {
fire1();
missleDecorator();
};

var fire2 = plane.fire;
plane.fire = function () {
fire2();
atomDecorator();
};

plane.fire();
/**
普通子弹
发射导弹
发射原子弹
*/



装饰函数



  • 在 JavaScript 中,几乎一切都是对象,其中函数也被成为对象,在平时的开发中,我们都在和函数打交道。在给对象扩展属性和方法时,很难在不改动原功能函数的情况下,给函数添加一些额外的功能,最直接的粗暴方式就是直接改写函数,但这是最差的方式,这违反了开放——封闭原则。

  • 如下:


function a(){
console.log(1);
}

// 改写:
function a(){
console.log(1);

// 新功能
console.log(2);
}



  • 很多时候,我们都不想去触碰之前的一些代码,但需要添加功能,所以如果需要在不改变原功能函数的情况下,给函数添加功能。可使用以下方式:

  • 要想完美的给函数添加功能,可使用 AOP 来装饰函数

    • AOP:一种编程规范,通过将关注点从主业务逻辑中剥离出来并单独处理,以此来提高代码的可读性和重用性。



  • 如下:


Function.prototype.before = function (beforeFn) {
var _self = this;
return function () {
beforeFn.apply(this, arguments);
return _self.apply(this, arguments);
};
};

Function.prototype.after = function (afterFn) {
var _self = this;
return function () {
var ret = _self.apply(this, arguments);
afterFn.apply(this, arguments);
return ret;
}
}

// before 和 after 函数都接收一个函数作为参数,这个函数也就是新添加的函数(里面也就是要添加的新功能逻辑)。
// 而before 和 after 函数区别在于在是原函数之前执行还是之后执行。



  • AOP 函数的使用


Function.prototype.before = function (beforeFn) {
var _self = this;
return function () {
beforeFn.apply(this, arguments);
return _self.apply(this, arguments);
};
};

Function.prototype.after = function (afterFn) {
var _self = this;
return function () {
var ret = _self.apply(this, arguments);
afterFn.apply(this, arguments);
return ret;
}
}

var o1 = function(){
console.log('1');
}
var o2 = function(){
console.log('2');
}
var o3 = function(){
console.log('3');
}

var desctor = o1.after(o2);
desctor = desctor.after(o3);
desctor(); // 1 2 3
/**
var desctor = o1.after(o2);
desctor = desctor.after(o3);
desctor();
1
2
3

var desctor = o1.before(o2);
desctor = desctor.before(o3);
desctor();
3
2
1

var desctor = o1.after(o2);
desctor = desctor.before(o3);
desctor();
3
1
2


var desctor = o1.before(o2);
desctor = desctor.after(o3);
desctor();
2
1
3
*/



AOP的应用


1.数据上报



  • 在程序开发中,当业务代码开发完后,在结尾时需要加很多的日志上报的代码,普遍我们会去改已经之前封装好的功能函数。其实这并不是一个好的方式,那如何在不直接修改之前函数的基础上添加日志上报功能呢?

  • 如下:


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>AOP日志上报</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://unpkg.com/vue@3.2.20/dist/vue.global.js"></script>
</head>
<body>
<div id="app">
<button class="btn" @click="handler">Button</button>
<p id="tt">{{message}}</p>
</div>
</body>
</html>
<script type="text/javascript">
// log report
const { reactive, ref, createApp } = Vue;
const app = createApp({
setup() {
const message = ref("未点击");
const count = ref(0);

Function.prototype.before = function (beforeFn) {
var _self = this;
return function () {
beforeFn.apply(this, arguments);
return _self.apply(this, arguments);
};
};

Function.prototype.after = function (afterFn) {
var _self = this;
return function () {
var ret = _self.apply(this, arguments);
afterFn.apply(this, arguments);
return ret;
};
};

function handler() {
message.value = `已点击${++count.value}`;
}

handler = handler.after(log);

function log() {
message.value = message.value + "-----> log reported";
console.log("log report");
}

return {
message,
handler,
};
},
});
app.mount("#app");
</script>


2.动态参数



  • 在日常开发中,我们需要向后台接口发送请求来获取信息,例如传参如下。业务在后续时需要添加新参数,每个接口需要把 token 值也一并传过去, 普遍我们会去改封装的请求方法,把 token 参数添加进去。但我们直接修改封装好的请求方法不是好的行为,那我们可使用上面说过的 AOP 方式来改进。


{
name: 'xxxx',
password: 'xxxx',
}



  • 如下:


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>AOP动态参数</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://unpkg.com/vue@3.2.20/dist/vue.global.js"></script>
</head>
<body>
<div id="app">{{message}}</div>
</body>
</html>

<script type="text/javascript">
const { reactive, ref, createApp } = Vue;
const app = createApp({
setup() {
const message = ref("empty params");
Function.prototype.before = function (beforeFn) {
var _self = this;
return function () {
beforeFn.apply(this, arguments);
return _self.apply(this, arguments);
};
};

Function.prototype.after = function (afterFn) {
var _self = this;
return function () {
var ret = _self.apply(this, arguments);
afterFn.apply(this, arguments);
return ret;
};
};

function ajax(type, url, params){
message.value = `${type} ----> ${url} -----> ${JSON.stringify(params)}`;
}

function getToken(){
// do something
return 'token';
}

ajax = ajax.before(function(type, url, params){
params.token = getToken();
})

ajax('get', 'https://www.baidu.com/userinfo', {name: 'se', password: 'xsdsd'});
return {
message,
};
},
});
app.mount("#app");
</script>


3.表单校验



  • 在日常开发中,我们经常要去做校验表单数据,通常的方式是在功能函数中进行判断处理或将判断逻辑提取为一个函数的方式。但这种方式其实是与功能性函数相混合,且校验逻辑与功能性函数有耦合关系。那我们可使用 AOP 方式来改进。

  • 如下:


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>AOP表单验证</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://unpkg.com/vue@3.2.20/dist/vue.global.js"></script>
</head>
<body>
<div id="app">
<label>
姓名:
<input
type="text"
v-model="data.name"
placeholder="请输入姓名"
/>

</label>
<label>
密码:
<input
type="text"
v-model="data.pass"
placeholder="请输入密码"
/>

</label>
<p v-if="data.name || data.pass">{{data.name + '/' + data.pass}} ----after-----> {{data.message}}</p>
<hr>
<button @click="submitBtn">submit</button>
</div>
</body>
</html>

<script type="text/javascript">
const { reactive, ref, createApp, watchEffect } = Vue;
const app = createApp({
setup() {
const data = reactive({
name: "",
pass: "",
message: "",
});

Function.prototype.before = function (beforeFn) {
var _self = this;
return function () {
if (beforeFn.apply(this, arguments) === false) return;
return _self.apply(this, arguments);
};
};

function valid() {
if (!data.name || !data.pass) {
alert("用户名或密码不能为空");
return false;
}
}

function formSubmit() {
console.log("data ------>", data);
data.message = `${data.name} ------- ${data.pass}`;
}

formSubmit = formSubmit.before(valid);

function submitBtn() {
formSubmit();
}
return {
data,
submitBtn,
};
},
});
app.mount("#app");
</script>


装饰器模式的优缺点



  • 优点:

    1. 扩展性强:装饰器模式允许在不修改现有代码的情况下,动态地添加新功能或修改现有功能。通过使用装饰器,可以在运行时按需组合和堆叠装饰器对象,实现各种组合方式,从而实现更多的功能扩展。

    2. 遵循开闭原则:装饰器模式通过添加装饰器类来扩展功能,而不是修改现有的代码。这样可以保持原有代码的稳定性,符合开闭原则,即对扩展开放,对修改关闭。

    3. 分离关注点:装饰器模式将功能的扩展和核心功能分离开来,每个装饰器类只关注单一的额外功能。这样可以使代码更加清晰、可读性更高,并且容易维护和测试。



  • 缺点:

    1. 增加复杂性:使用装饰器模式会增加额外的类和对象,引入了更多的复杂性和层次结构。这可能使代码变得更加复杂,理解和调试起来可能更加困难。

    2. 潜在的性能影响:由于装饰器模式涉及多个对象的组合和堆叠,可能会引入额外的运行时开销,对性能产生一定的影响。尤其是当装饰器链较长时,可能会导致性能下降。




装饰器模式的适用场景



  1. 动态地扩展对象功能:当需要在运行时动态地为对象添加额外的功能或责任时,装饰器模式是一个很好的选择

  2. 遵循开闭原则:如果你希望在不修改现有代码的情况下扩展功能,而且要保持代码的稳定性,装饰器模式是一个合适的解决方案。

  3. 分离关注点:当你希望将不同的功能分离开来,使每个功能都有自己独立的装饰器类时,装饰器模式是有用的。每个装饰器只关注单一的额外功能,这样可以使代码更加清晰、可读性更高,并且容易维护和测试。

  4. 多层次的功能组合:如果你需要实现多个功能的组合,而且每个功能都可以灵活选择是否添加,装饰器模式可以很好地满足这个需求。通过堆叠多个装饰器对象,可以按照特定的顺序组合功能,实现各种组合方式。

  5. 继承关系的替代方案:当你面临类似于创建大量子类的情况时,装饰器模式可以作为继承关系的替代方案。通过使用装饰器模式,可以避免创建过多的子类,而是通过组合不同的装饰器来实现不同的功能组合。


Tip: 文章部分内容参考于曾探大佬的《JavaScript 设计模式与开发实践》。文章仅做个人学习总结和知识汇总

作者:南囝coding
来源:juejin.cn/post/7272869799960559679
收起阅读 »

还在封装 xxxForm,xxxTable 残害你的同事?试试这个工具

web
之前写过一篇文章 我理想中的低代码开发工具的形态,已经吐槽了各种封装 xxxForm,xxxTable 的行为,这里就不啰嗦了。今天再来看看我的工具达到了什么程度。 多图预警。。。 以管理后台一个列表页为例 选择对应的模板 截图查询区域,使用 OCR 初始...
继续阅读 »

之前写过一篇文章 我理想中的低代码开发工具的形态,已经吐槽了各种封装 xxxForm,xxxTable 的行为,这里就不啰嗦了。今天再来看看我的工具达到了什么程度。


多图预警。。。


以管理后台一个列表页为例



选择对应的模板



截图查询区域,使用 OCR 初始化查询表单的配置



截图表头,使用 OCR 初始化 table 的配置



使用 ChatGPT 翻译中文字段



生成代码



效果


目前我们没有写一行代码,就已经达到了如下的效果



下面是一部分生成的代码


import { reactive, ref } from 'vue'

import { IFetchTableListResult } from './api'

interface ITableListItem {
/**
* 决算单状态
*/

settlementStatus: string
/**
* 主合同编号
*/

mainContractNumber: string
/**
* 客户名称
*/

customerName: string
/**
* 客户手机号
*/

customerPhone: string
/**
* 房屋地址
*/

houseAddress: string
/**
* 工程管理
*/

projectManagement: string
/**
* 接口返回的数据,新增字段不需要改 ITableListItem 直接从这里取
*/

apiResult: IFetchTableListResult['result']['records'][0]
}

interface IFormData {
/**
* 决算单状态
*/

settlementStatus?: string
/**
* 主合同编号
*/

mainContractNumber?: string
/**
* 客户名称
*/

customerName?: string
/**
* 客户手机号
*/

customerPhone?: string
/**
* 工程管理
*/

projectManagement?: string
}

interface IOptionItem {
label: string
value: string
}

interface IOptions {
settlementStatus: IOptionItem[]
}

const defaultOptions: IOptions = {
settlementStatus: [],
}

export const defaultFormData: IFormData = {
settlementStatus: undefined,
mainContractNumber: undefined,
customerName: undefined,
customerPhone: undefined,
projectManagement: undefined,
}

export const useModel = () => {
const filterForm = reactive<IFormData>({ ...defaultFormData })

const options = reactive<IOptions>({ ...defaultOptions })

const tableList = ref<(ITableListItem & { _?: unknown })[]>([])

const pagination = reactive<{
page: number
pageSize: number
total: number
}>({
page: 1,
pageSize: 10,
total: 0,
})

const loading = reactive<{ list: boolean }>({
list: false,
})

return {
filterForm,
options,
tableList,
pagination,
loading,
}
}

export type Model = ReturnType<typeof useModel>


这就是用模板生成的好处,有规范,随时可以改,而封装 xxxForm,xxxTable 就是一个黑盒。


原理


下面大致说一下原理



首先是写好一个个模版,vscode 插件读取指定目录下模版显示到界面上



每个模版下可能包含如下内容:



选择模版后,进入动态表单配置界面



动态表单是读取 config/schema.json 里的内容进行动态渲染的,目前支持 amis、form-render、formily



配置表单是为了生成 JSON 数据,然后根据 JSON 数据生成代码。所以最终还是无法避免的使用私有的 DSL ,但是生成后的代码是没有私有 DSL 的痕迹的。生成代码本质是 JSON + EJS 模版引擎编译 src 目录下的 ejs 文件。


为了加快表单的配置,可以自定义脚本进行操作



这部分内容是读取 config/preview.json 内容进行显示的



选择对应的脚本方法后,插件会动态加载 script/index.js 脚本,并执行里面对应的方法



以 initColumnsFromImage 方法为例,这个方法是读取剪贴板里的图片,然后使用百度 OCR 解析出文本,再使用文本初始化表单


initColumnsFromImage: async (lowcodeContext) => {
context.lowcodeContext = lowcodeContext;
const res = await main.handleInitColumnsFromImage();
return res;
},

export async function handleInitColumnsFromImage() {
const { lowcodeContext } = context;
if (!lowcodeContext?.clipboardImage) {
window.showInformationMessage('剪贴板里没有截图');
return lowcodeContext?.model;
}
const ocrRes = await generalBasic({ image: lowcodeContext!.clipboardImage! });
env.clipboard.writeText(ocrRes.words_result.map((s) => s.words).join('\r\n'));
window.showInformationMessage('内容已经复制到剪贴板');
const columns = ocrRes.words_result.map((s) => ({
slot: false,
title: s.words,
dataIndex: s.words,
key: s.words,
}));
return { ...lowcodeContext.model, columns };
}

反正就是可以根据自己的需求定义各种各样的脚本。比如使用 ChatGPT 翻译 JSON 里的指定字段,可以看我的上一篇文章 TypeChat、JSONSchemaChat实战 - 让ChatGPT更听你的话


再比如要实现把中文翻译成英文,然后英文使用驼峰语法,这样就可以将中文转成英文代码变量,下面是实现的效果



选择对应的命令菜单后 vscode 插件会加载对应模版里的脚本,然后执行里面的 onSelect 方法。



main.ts 代码如下


import { env, window, Range } from 'vscode';
import { context } from './context';

export async function bootstrap() {
const clipboardText = await env.clipboard.readText();
const { selection, document } = window.activeTextEditor!;
const selectText = document.getText(selection).trim();
let content = await context.lowcodeContext!.createChatCompletion({
messages: [
{
role: 'system',
content: `你是一个翻译家,你的目标是把中文翻译成英文单词,请翻译时使用驼峰格式,小写字母开头,不要带翻译腔,而是要翻译得自然、流畅和地道,使用优美和高雅的表达方式。请翻译下面用户输入的内容`,
},
{
role: 'user',
content: selectText || clipboardText,
},
],
});
content = content.charAt(0).toLowerCase() + content.slice(1);
window.activeTextEditor?.edit((editBuilder) => {
if (window.activeTextEditor?.selection.isEmpty) {
editBuilder.insert(window.activeTextEditor.selection.start, content);
} else {
editBuilder.replace(
new Range(
window.activeTextEditor!.selection.start,
window.activeTextEditor!.selection.end,
),
content,
);
}
});
}


使用了 ChatGPT。


再来看看,之前生成管理后台 CURD 页面的时候,连 mock 也一起生成了,主要逻辑放在了 complete 方法里,这是插件的一个生命周期函数。



因为 mock 服务在另一个项目里,所以需要跨目录去生成代码,这里我在 mock 服务里加了个接口返回 mock 项目所在的目录


.get(`/mockProjectPath`, async (ctx, next) => {
ctx.body = {
status: 200,
msg: '',
result: __dirname,
};
})

生成代码的时候请求这个接口,就知道往哪个目录生成代码了


const mockProjectPathRes = await axios
.get('http://localhost:3001/mockProjectPath', { timeout: 1000 })
.catch(() => {
window.showInformationMessage(
'获取 mock 项目路径失败,跳过更新 mock 服务',
);
});
if (mockProjectPathRes?.data.result) {
const projectName = workspace.rootPath
?.replace(/\\/g, '/')
.split('/')
.pop();
const mockRouteFile = path.join(
mockProjectPathRes.data.result,
`${projectName}.js`,
);
let mockFileContent = `
import KoaRouter from 'koa-router';
import proxy from '../middleware/Proxy';
import { delay } from '../lib/util';

const Mock = require('mockjs');

const { Random } = Mock;

const router = new KoaRouter();
router{{mockScript}}
module.exports = router;
`
;

if (fs.existsSync(mockRouteFile)) {
mockFileContent = fs.readFileSync(mockRouteFile).toString().toString();
const index = mockFileContent.lastIndexOf(')') + 1;
mockFileContent = `${mockFileContent.substring(
0,
index,
)}
{{mockScript}}\n${mockFileContent.substring(index)}`
;
}
mockFileContent = mockFileContent.replace(/{{mockScript}}/g, mockScript);
fs.writeFileSync(mockRouteFile, mockFileContent);
try {
execa.sync('node', [
path.join(
mockProjectPathRes.data.result
.replace(/\\/g, '/')
.replace('/src/routes', ''),
'/node_modules/eslint/bin/eslint.js',
),
mockRouteFile,
'--resolve-plugins-relative-to',
mockProjectPathRes.data.result
.replace(/\\/g, '/')
.replace('/src/routes', ''),
'--fix',
]);
} catch (err) {
console.log(err);
}

mock 项目也可以通过 vscode 插件快速创建和使用



上面展示的模版都放在了 github.com/lowcode-sca… 仓库里,照着 README 步骤做就可以使用了。


作者:若邪
来源:juejin.cn/post/7315242945454735414
收起阅读 »

精美绝伦:小程序日历组件推荐

web
前言众所周知,小程序的渲染性能一直被广大开发者诟病,2023年中旬,小程序团队正式发布了 skyline 渲染引擎,Skyline,旨在替代 WebView 作为小程序的渲染层,以提供更优秀的渲染性能和诸多增强特性,让小程序能达到原生的体验...
继续阅读 »

前言

众所周知,小程序的渲染性能一直被广大开发者诟病,2023年中旬,小程序团队正式发布了 skyline 渲染引擎,Skyline,旨在替代 WebView 作为小程序的渲染层,以提供更优秀的渲染性能和诸多增强特性,让小程序能达到原生的体验。

非常好,那么就是说我们可以在小程序上体验类原生的特性啦!这下谁敢再说小程序是屎?

尝试

在用了一段时间,主要尝试了canvas、手势组件动画等功能,惊奇的发现,小程序做的这个 skyline 渲染引擎,是一陀超大的屎。

噢!腾讯,你小子好啊,研究了这么长时间,跑我这排宿便了是吧?

image.png

image.png

自己写的样式和iconfont样式给我报了很多坨警告,能用吗?能用,但是我是屎我需要恶心你,我必须得给你点警告。

除了控制台脏了之外,还有各种各样数不清的 bug。比如,地图的bindregionchange失效,而你去论坛发,他们只会说:"未复现"、"写片段"、"你试试",发文时实测依然没有修复😅。

爱莲说

铺垫了这么多,实属无奈,我也不想说这么多,只是这口屎憋在嘴里,臭的难受。我本以为出淤泥而不染已经很难得了,没想到在这屎坑里还有大佬栽培了一朵精美绝伦的白莲花,它就是 lspriv/wx-calendar ,github链接:github.com/lspriv/wx-c…

看到这么牛逼的组件,只有区区一百来个 star。

牛逼不牛逼,直接看效果:

QQ2024422-123019.webp

它还同时支持 skyline 和 webview 下渲染。

image.png

每个场景都是丝滑过渡的,元素到元素的联合动画。看的出来,这个日历是有很重的 MIUI 风格的,如果不是右上角的小程序胶囊,我甚至以为是某手机的自带日历。

QQ2024422-14944.webp

依赖 skyline 的 worklet 动画,组件做到了跟手、丝滑,且符合直觉的动画。

lspriv/wx-calendar 使用

lspriv/wx-calendar 需要使用npm下载并构建,然后引入组件使用。

npm i @lspriv/wx-calendar

然后需要使用微信开发者工具构建 npm

{
   "usingComponents": {
       "calendar": "@lspriv/wx-calendar"
  }
}
id="calendar" bindload="handleLoad" />

生态

作者十分聪明,给 lspriv/wx-calendar 预留了插件接口,开发者可以根据自身需求,写扩展功能。

源码中 src>plugins>lunar.ts 是一个内置插件,实现了农历、节气、闰年等功能。

目前为止,还没有看到有第二个人为作者贡献插件。

展望

目前还有很多基础功能还没有开发,比如

  1. 日期标注,日期标注有是有,但是作者将几种标记方式写死了,只能用内置的日程、角标和节假日标记,开发中肯定是期望可以传入组件或自定义样式的。
  2. 选择区间,一个很常见的场景,需要选择日期区间,跨月、跨年选择,这些在不了解源码的情况下,去手写插件也是比较困难的。
  3. 自定义样式,作者将自己的样式隔离了,开发者只能通过传入指定的style字符串修改样式,这个用起来不是很方便。

结语

总的来说,这是一款不可多得的组件,即使在PC端,也是不常见的。在小程序的层层阻挠下能开发出如此的组件,实属不易。

ce2898a24a9846c59a058e07eaeea24c_tplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif


作者:德莱厄斯
来源:juejin.cn/post/7360237771637489679

收起阅读 »

一个小小的批量插入,被面试官追问了6次

嗨,你好呀,我是哪吒。 面试经常被问到“MyBatis批量入库时,xml的foreach和java的foreach,性能上有什么区别?”。 首先需要明确一点,优先使用批量插入,而不是在Java中通过循环单条插入。 很多小伙伴都知道这个结论,但是,为啥?很少有人...
继续阅读 »

嗨,你好呀,我是哪吒。


面试经常被问到“MyBatis批量入库时,xml的foreach和java的foreach,性能上有什么区别?”。


首先需要明确一点,优先使用批量插入,而不是在Java中通过循环单条插入。


很多小伙伴都知道这个结论,但是,为啥?很少有人能说出个所以然来。


就算我不知道,你也不能反反复复问我“同一个问题”吧?


1、MyBatis批量入库时,xml的foreach和java的foreach,性能上有什么区别?


批量入库时,如果通过Java循环语句一条一条入库,每一条SQL都需要涉及到一次数据库的操作,包括网络IO以及磁盘IO,可想而知,这个效率是非常低下的。


xml中使用foreach的方式会一次性发送给数据库执行,只需要进行一次网络IO,提高了效率。


但是,xml中的foreach可能会导致内存溢出OOM问题,因为它会一次性将所有数据加载到内存中。而java中的foreach可以有效避免这个问题,因为它会分批次处理数据,每次只处理一部分数据,从而减少内存的使用。


如果操作比较复杂,例如需要进行复杂的计算或者转换,那么使用java中的foreach可能会更快,因为它可以直接利用java的强大功能,而不需要通过xml进行转换。


孰重孰轻,就需要面试官自己拿捏了~


2、在MyBatis中,对于<foreach>标签的使用,通常有几种常见的优化方法?


比如避免一次性传递过大的数据集合到foreach中,可以通过分批次处理数据或者在业务层先进行数据过滤和筛选。


预编译SQL语句、优化SQL语句,减少foreach编译的工作量。


对于重复执行的SQL语句,可以利用mybatis的缓存机制来减少数据库的访问次数。


对于关联查询,可以考虑使用mybatis的懒加载特性,延迟加载关联数据,减少一次性加载的数据量。


3、MyBatis foreach批量插入会有什么问题?


foreach在处理大量数据时会消耗大量内存。因为foreach需要将所有要插入的数据加载到内存中,如果数据量过大,可能会导致内存溢出。


有些数据库对单条SQL语句中可以插入的数据量有限制。如果超过这个限制,foreach生成的批量插入语句将无法执行。


使用foreach进行批量插入时,需要注意事务的管理。如果部分插入失败,可能需要进行回滚操作。


foreach会使SQL语句变得复杂,可能影响代码的可读性和可维护性。


4、当使用foreach进行批量插入时,如何处理可能出现的事务问题?内存不足怎么办?


本质上这两个是一个问题,就是SQL执行慢,一次性执行SQL数量大的问题。


大多数数据库都提供了事务管理功能,可以确保一组操作要么全部成功,要么全部失败。在执行批量插入操作前,开始一个数据库事务,如果所有插入操作都成功,则提交事务;如果有任何一条插入操作失败,则回滚事务。


如果一次插入大量数据,可以考虑分批插入。这样,即使某一批插入失败,也不会影响到其他批次的插入。


优化foreach生成的SQL语句,避免因SQL语句过长或过于复杂而导致的问题。


比如MySQL的INSERT INTO ... VALUES语法 通常比使用foreach进行批量插入更高效,也更可靠。


5、MyBati foreach批量插入时如何处理死锁问题?


当使用MyBatis的foreach进行批量插入时,可能会遇到死锁问题。这主要是因为多个事务同时尝试获取相同的资源(如数据库的行或表),并且每个事务都在等待其他事务释放资源,从而导致了死锁。


(1)优化SQL语句


确保SQL语句尽可能高效,避免不必要的全表扫描或复杂的联接操作,这可以减少事务持有锁的时间,从而降低死锁的可能性。


不管遇到什么问题,你就回答优化SQL,基本上都没毛病。


(2)设置锁超时


为事务设置一个合理的锁超时时间,这样即使发生死锁,也不会导致系统长时间无响应。


(3)使用乐观锁


乐观锁是一种非阻塞性锁,它假设多个事务在同一时间不会冲突,因此不会像悲观锁那样在每次访问数据时都加锁。乐观锁通常用于读取频繁、写入较少的场景。


(4)分批插入


如果一次插入大量数据,可以考虑分批插入。这样,即使某一批插入失败,也不会影响到其他批次的插入。


(5)调整事务隔离级别


较低的隔离级别(如READ UNCOMMITTED)可能会减少死锁的发生,但可能会导致其他问题,如脏读或不可重复读。


6、mybatis foreach批量插入时如果数据库连接池耗尽,如何处理?


(1)增加最大连接数


数据库连接池耗尽了,增加最大连接数,这个回答,没毛病。


(2)优化SQL语句


减少每个连接的使用时间,从而减少连接池耗尽的可能性。


万变不离其宗,优化SQL,没毛病。


(3)分批插入


避免一次性占用过多的连接,从而减少连接池耗尽的可能性。


(4)调整事务隔离级别


降低事务隔离级别可以减少每个事务持有连接的时间,从而减少连接池耗尽的可能性。但需要注意,较低的事务隔离级别可能会导致其他问题,如脏读或不可重复读。


(5)使用更高效的批量插入方法


比如MySQL的INSERT INTO ... VALUES语法。这些方法通常比使用foreach进行批量插入更高效,也更节省连接资源。


感觉每道题的答案都是一样呢?这就对喽,数据库连接池耗尽,本质问题不就是入库的速度太慢了嘛。


(6)定期检查并关闭空闲时间过长的连接,以释放连接资源。


就前面的几个问题,做一个小总结,你会发现,它们的回答大差不差。


通过现象看本质,批量插入会有什么问题?事务问题?内存不足怎么办?如何处理死锁问题?数据库连接池耗尽,如何处理?


这些问题的本质都是因为SQL执行慢,一次性SQL数据量太大,事务提交太慢导致的。


回答的核心都是:如何降低单次事务时间?



  1. 优化SQL语句

  2. 分批插入

  3. 调整事务隔离级别

  4. 使用更高效的批量插入方法


作者:哪吒编程
来源:juejin.cn/post/7359900973991362597
收起阅读 »

领导让我加水印

web
tips: 文末有完整示例代码。 领导:『小S啊,我们有个新需求🥸,需要在预览的资源上添加水印,让服务端来加水印成本太高了,在前端渲染的时候把水印加上可以吗🤨?』 小S:『加水印啊,简简单单🤏。我们项目使用的是 Vue3,使用自定义指令一下就可以加好了。领导...
继续阅读 »

tips: 文末有完整示例代码。



领导:『小S啊,我们有个新需求🥸,需要在预览的资源上添加水印,让服务端来加水印成本太高了,在前端渲染的时候把水印加上可以吗🤨?』


小S:『加水印啊,简简单单🤏。我们项目使用的是 Vue3,使用自定义指令一下就可以加好了。领导你看我操作!』


小S说着,就把生产力工具打开了。手速熟练🤠的启动了项目。


小S:『领导你看😈,我先在项目自定义指令的文件夹下新建一个自定义水印指令文件 - watermark.ts。在需要添加水印的目标 Dom 挂载时,创建一个 canvas 节点,canvas 的宽高自然要跟 Dom 的大小一样啦,层级也必须是最高的。然后我再给 canvas 里画上水印内容,最后再给 canvas 挂载到目标节点。当然啦,目标节点销毁时也要把 canvas 销毁掉。』


小S一边讲,一边就在生产力工具中敲🫳出了代码。


import type { Directive, App } from 'vue';
import { nextTick } from 'vue';

const watermarkDirective: Directive = {
// 在绑定元素的父组件
// 及他自己的所有子节点都挂载完成后调用
async mounted(el, binding) {
await createWatermark(el, binding.value.text);
},
// 绑定元素的父组件卸载后调用
unmounted(el) {
removeWatermark(el);
},
};

/** 创建水印 */
async function createWatermark(el, text: string) {
const canvasEl = document.createElement('canvas');
const newCanvas = !el.querySelector('canvas');

canvasEl.id = 'watermark-canvas';
canvasEl.style.position = 'absolute';
canvasEl.style.top = '0';
canvasEl.style.left = '0';
canvasEl.style.zIndex = '99';
canvasEl.style.pointerEvents = 'none';
el.appendChild(canvasEl);
canvasEl.width = window.screen.width;
canvasEl.height = window.screen.height;
const ctx = canvasEl.getContext('2d');
ctx.rotate((-20 * Math.PI) / 180); //旋转角度
ctx.font = '24px serif';
ctx.fillStyle = 'rgba(180, 180, 180, 0.3)';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let i = -canvasEl.width / 100; i < canvasEl.width / 100; i++) {
for (let j = -canvasEl.height / 200; j < canvasEl.height / 200; j++) {
ctx.fillText(text, i * 300, j * 300);
}
}
}

async function removeWatermark(el) {
await nextTick();
const canvasEl = el.querySelector('#watermark-canvas');
if (canvasEl) {
canvasEl.remove();
}
}

export default watermarkDirective;


小S得意😏的抖着腿,侧身向领导讲到:『这样就可以生成水印啦! 撒花🥳🥳🥳』。


领导🫲🫱:『你这样是可以实现了,但是也仅仅可以防一下小白,稍微懂点前端知识的人,都可以 F12 把控制台打开,选中水印节点,给它哐哐哐删掉。』


小S听了,一拍脑门:『是哦,我怎么没想到呢!嗯……』小S陷入了沉思,如何防止被删掉呢?小S脑子转了3圈后:『领导,我知道怎么做了!DOM3 Event 规范中有一个 MutationObserver,这个接口可以监视 DOM 进行监视,只要我的水印被删掉了,我就赶紧再生成一个水印!』


小S立刻转身,一边思索🤔着逻辑,一边在生产力工具中继续完善:


小S心里想到:『在目标节点挂载,首次添加 canvas 时,我给目标节点添加 MutationObserver 监听,并把实例化的监视器放在目标节点的自定义属性上,监听它的子节点,如果监听到子节点水印被删除,我就再新建一个水印 canvas,插入到目标节点中,对了,还要考虑到我主动删除水印的操作。水印节点也要加监视,不然手动改一下水印的CSS样式,就可以把水印给隐藏掉了。emmm……最后在 目标节点卸载时把监听移除掉。』


小S搞好了,转身给领导讲道:『领导,搞定了!使用的时候只需要引入自定义指令,在需要加水印的节点添加参数就可以啦』


<template>
<div v-watermark="watermarkOption">
<img src="xxxx">
</div>
</template>

<script setup lang="ts">
// @ts-ignore
import vWatermark from '/@/directives/watermark';

const watermarkOption = {
text: '小S水印'
}
</script>

领导看着小S加好水印,笑😼着说:『针不错,这就去给你涨工资!』


小S听了,连忙摇头🙀道:『领导,不用,不用,这都是前端切图仔的基本功!』




END




完整示例代码


import type { Directive, App } from 'vue';
import { nextTick } from 'vue';

const watermarkDirective: Directive = {
// 在绑定元素的父组件
// 及他自己的所有子节点都挂载完成后调用
async mounted(el, binding) {
await createWatermark(el, binding.value.text);
},
// 绑定元素的父组件卸载后调用
unmounted(el) {
removeWatermark(el);
},
};

async function createWatermark(el, text: string) {
const canvasEl = el.querySelector('canvas') || document.createElement('canvas');
const newCanvas = !el.querySelector('canvas');

if (!el.dataset.mutationObserverParent) {
const mutationObserver = new MutationObserver((records) =>
parentCheckWatermark(records, el, text),
);
mutationObserver.observe(el, {
childList: true,
});
el.dataset.mutationObserverParent = mutationObserver;
}
canvasEl.id = 'watermark-canvas';
canvasEl.style.position = 'absolute';
canvasEl.style.top = '0';
canvasEl.style.left = '0';
canvasEl.style.zIndex = '99';
canvasEl.style.pointerEvents = 'none';
newCanvas && el.appendChild(canvasEl);
canvasEl.width = window.screen.width * 3;
canvasEl.height = window.screen.height * 3;
const ctx = canvasEl.getContext('2d');
if (!ctx) return;
ctx.rotate((-20 * Math.PI) / 180); //旋转角度
ctx.font = '24px serif';
ctx.fillStyle = 'rgba(180, 180, 180, 0.3)';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let i = -canvasEl.width / 100; i < canvasEl.width / 100; i++) {
for (let j = -canvasEl.height / 200; j < canvasEl.height / 200; j++) {
ctx.fillText(text, i * 300, j * 300);
}
}

if (newCanvas) {
// 水印属性监听
const mutationObserver = new MutationObserver(() => canvasCheckWatermark(el, text));
mutationObserver.observe(canvasEl, {
attributes: true,
});
el.dataset.mutationObserverCanvas = mutationObserver;
}
}

/** 检查水印是否被删除 */
async function parentCheckWatermark(records, el, text) {
// 主动删除水印不处理
if (el.dataset.focusRemove) return;
const removedNodes = records[0].removedNodes;
let hasDelWatermark = false;
removedNodes.forEach((el) => {
if (el.id === 'watermark-canvas') {
hasDelWatermark = true;
}
});
// 水印被删除了
hasDelWatermark && createWatermark(el, text);
}

/** 检查水印属性是否变化了 */
async function canvasCheckWatermark(el, text) {
// 防止多次触发
if (el.dataset.canvasRending) return;
el.dataset.canvasRending = 'rending';

// 水印canvas属性变化了,重新创建
await createWatermark(el, text);
el.dataset.canvasRending = '';
}

async function removeWatermark(el) {
el.dataset.focusRemove = true;
el.dataset.mutationObserverParent?.disconnect?.();
await nextTick();
const canvasEl = el.querySelector('#watermark-canvas');
if (canvasEl) {
canvasEl.dataset.mutationObserverCanvas?.disconnect?.();
canvasEl.remove();
}
}
export default watermarkDirective;

作者:sofor
来源:juejin.cn/post/7360269869399392310
收起阅读 »

偷偷分享下我们公司的研发规范~

有程序员朋友问:啥是研发规范?还有朋友表示:鱼皮别拿咱当外人,把你们公司的研发规范发来看看?可以,必须安排!这篇文章就给大家简单分享下我们公司的研发规范,不过在开始前必须要明确 2 点:每个团队都应该根据情况定制自己的研发规范,别人的规范仅供参考,未必最适合你...
继续阅读 »

有程序员朋友问:啥是研发规范?

还有朋友表示:鱼皮别拿咱当外人,把你们公司的研发规范发来看看?

可以,必须安排!

这篇文章就给大家简单分享下我们公司的研发规范,不过在开始前必须要明确 2 点:

  1. 每个团队都应该根据情况定制自己的研发规范,别人的规范仅供参考,未必最适合你们团队。
  2. 篇幅有限,本文仅分享一些我认为很重要的规范,并且移除了我们自己的敏感信息。

一、项目整体研发流程

1)团队共同确认目标和规划

开会讨论,产出目标和规划文档

2)产品调研和需求分析

产出调研报告和需求分析文档

3)需求评审

开需求评审会,明确要做的需求和工作,评估工作量并明确工作时间节点。

4)方案设计

产出方案设计文档,比如数据库表设计、页面设计、接口设计等。

5)研发

包括各自开发、单元测试、前后端联调等

6)测试和验收

包括研发自测、产品验收、组内验收等

7)代码提交

提交可上线的代码,需要由负责人审查,通过后可合并

8)部署上线

将代码发布到服务器上,组内进行上线通知并更新上线文档,上线后需要自行验证

9)产品迭代

持续收集用户对新功能的反馈、并进行数据分析,从而验证改动效果,便于下一轮的更新迭代。

二、开发规范

开发前注意事项

1)确保自己充分理解了业务和需求,需要先进行整体的方案设计;尤其是对于重要需求和核心业务,必须先跟组内同学核对方案并通过后,才能下手开发,避免重复工作。

2)先熟悉项目再开发,建议阅读项目文档、项目代码、接口文档、前端组件文档等。

3)慎重引入新的依赖或类库、或者升级版本,重大依赖变更需要和组内其他成员确认。

4)熟悉团队已实现的功能和代码,尽量复用,避免重复开发。

5)熟悉团队内部的研发规范,并在 IDE 中进行相应的配置,比如前端配置 ESLint、Prettier 等代码规范插件。

开发中注意事项

1)开发新功能时,确保从项目仓库拉取 最新主分支 的代码。

2)每个功能都要新建自己的分支进行开发,千万不要直接修改主分支的代码!注意分支名称要使用英文、足够语义化,不要和其他人的混淆。

3)开发时,尽量复用现有的功能、模块、类、方法、对象代码。有现成的代码,就不要再重复编写。如无法复用,可以适当通过注释说明。

4)开发时,遵循团队内部的研发规范,尽量参考现有项目代码的写法,尤其是不要使用和原项目不一致的格式、命名、写法,避免特立独行。

5)开发过程中,有任何不明确的地方,不要凭空猜测,及时去联系项目的其他成员或负责人确认。

6)开发过程中,每隔一段时间(比如 1 - 3 天)可以使用 git pull 同步一下最新的主分支代码,防止合并代码冲突。

7)开发过程中,注意整体时间进度的把控,先完成再完美,有风险时及时反馈。

8)开发时,需要格外注意对异常情况的捕获和处理。

9)每个分支尽量保证纯净,尽量减少每次开发和提交时改动的代码量。建议每次开分支只改一个功能、Bug 或模块,不要把多个不相关的功能写在一起,并且非必要不修改。

10)完成部分功能开发后,一定要自测!自测时,可以 Mock 假数据。注意一定不要在线上测试、一定不要影响线上数据!

三、代码提交规范

1)只有通过测试和产品验收的代码,才能够发起合并到主分支的 PR 请求。在这之前可以提交到自己的分支。

2)发起合并到主分支的 PR 前,一定要完整阅读 3 遍自己的代码,避免不规范的写法和无意义的改动。

3)每次合并尽量只专注于一个功能或改动,避免多个功能耦合在一起合并,提高审查效率并降低改动风险。

4)每次提交时,需要在 commit 信息中提供代码改动说明,还可以通过关联需求文档、测试用例、方案文档、效果截图等方式进行补充说明。

commit 信息可参考《约定式提交》文档,但不做强制要求。

5)除非特殊情况,否则所有的代码必须经过至少一位项目负责人 Code Review 审核通过后,才能合并;并且只有合并到主分支的代码才允许发布上线。

上线规范

上线前注意事项

1)上线前,除了严格验证功能特性能否正常运行、并符合需求外,还要格外关注程序的:

  • 健壮性。比如给用户友好的错误提示、输入校验。
  • 安全性。防止越权操作、输入校验。
  • 稳定性。尽量保证调用 100% 成功,如果有几率失败,要考虑重试或容错策略。

2)除非特殊情况,只有经过产品验证的功能、通过代码审核的主分支代码才允许发布上线。

3)除非特殊情况,尽量在工作日上线(建议周二 ~ 周四),保证上线后出了问题时能够及时修复。

上线后注意事项

1)上线后,一定要再次进行完整流程的测试,尤其要重点关注权限相关的功能测试。

2)上线后,一定要在群内及时同步上线信息,周知相关的成员,如果遇到问题第一时间反馈。

3)首次上线后,需要即时配置监控告警。

4)上线验证通过、并经过内部群成员确认后,可以在外部用户群发布版本更新公告。

5)上线后,即时更新项目的更新记录文档。

6)注意,上线不是终点。上线后的一段时间(至少一周内),一定要持续观察自己负责的功能是否正常运行、持续接受用户反馈、通过数据分析来观察新功能的效果,期间有任何问题都需要即时修复处理,并且准备好下一期的改进迭代。


作者:程序员鱼皮
来源:juejin.cn/post/7360486735798927396
收起阅读 »

奇技淫巧:如何修改第三方npm包?

web
如何修改第三方npm包? 有这样一种场景:如果你在开发过程中,发现某个npm包有Bug,应该怎么办? 第一选择当然是向原作者提issue,或者Fork该仓库代码,修改以后,提交合并请求。 但这种情况有个严重的问题,就是耗时,如果遇到严格的作者,或者不活跃的作...
继续阅读 »

如何修改第三方npm包?



有这样一种场景:如果你在开发过程中,发现某个npm包有Bug,应该怎么办?


第一选择当然是向原作者提issue,或者Fork该仓库代码,修改以后,提交合并请求。


但这种情况有个严重的问题,就是耗时,如果遇到严格的作者,或者不活跃的作者,时间线可能会拉得很长。你的项目未必能等这么长时间。


还有一种可能是,你修改的代码并不具备普适性,只有你的业务场景能用到,合并被拒的概率会大大增加。


总而言之,如果能修改npm包的源包,再好不过,如果不行,则需要有个临时方案,或者替代方案。


这时,又有下面两种情况:



  1. 代码量少,可以直接修改npm包代码的,考虑补丁方案。

  2. 代码量多,或者npm包代码是压缩混淆过的,不具备修改条件。修改源码后,再修改包名,重新发布,在应用代码中更换引用。为叙文方便,我将这种方案命名为换日方案(偷天换日,李代桃僵)。


下面,详细介绍下这两种不同方案。


补丁方案


patch-package


patch-package是一个用于修复第三方依赖包的工具,使用方式非常简单。



它支持npm和yarn v1,如果是yarn v2+或者pnpm,则使用自带的patch方案(下文会介绍pnpm方案)。



安装:


$ npm i patch-package
$ yarn add patch-package postinstall-postinstall


如果只是前端使用,可以添加--dev或-D参数。如果是后端使用,为保障生产模式(会去除devDendencies依赖)也能正常使用,就不要加了。



在node_modules中找到你要修改的npm包,修改内容后,就可以运行patch-package创建patch文件了。


$ npx patch-package package-name   # 使用npm
$ yarn patch-package package-name # 使用yarn

运行后会在项目根目录下创建一个patches文件夹,并生成一个名为package-name+version.patch的文件。将该patch文件提交至版本控制中,即可在之后应用该补丁了。


以我修改的verdaccio为例,会生成一个verdaccio+4.4.0.patch的文件,内容大致如下:


diff --git a/node_modules/verdaccio/build/index.js b/node_modules/verdaccio/build/index.js
index 3a79eaa..d00974b 100644
--- a/node_modules/verdaccio/build/index.js
+++ b/node_modules/verdaccio/build/index.js
@@ -5,6 +5,8 @@ Object.defineProperty(exports, "__esModule", {
});
exports.default = void 0;

+console.log('---------------')
+
var _bootstrap = require("./lib/bootstrap");

完成上述操作后,最后在package.jsonscripts中加入"postinstall": "patch-package"


"scripts": {
"postinstall": "patch-package"
}

这样当其他同事拉下代码,运行npm install或是yarn install命令时,便会自动为依赖包打上我们的补丁了。



简单来说,这个方案的原理就是记录补丁的代码与位置,利用npm的hook(postinstall会在npm install后触发),在安装完依赖以后,触发相应的脚本,将补丁覆盖到node_modules对应的包里。


当然,补丁是对应具体版本的,需要锁定版本号。这样的缺点是如果要升级的话,还得重新来一遍,不过不是有Bug或性能问题,通常不必追求新的版本。



pnpm patch


pnpm的patch自称灵感来自yarn的类似命令。由于yarn v2可能走了邪路,我们就不介绍了。


首先,执行pnpm patch @。该命令会将指定的软件包提取到一个可以随意编辑的临时目录中。


完成修改后, 运行pnpm patch-commit ( 是之前提取的临时目录,这个临时目录会长到你根本记不住,不过不用担心,命令行里会有完备的提示) 以生成一个补丁文件,并提供patchedDependencies 字段注册到你的项目中。


比如,我想修改一个is-even的包:


pnpm patch is-even  
You can now edit the following folder: /private/var/folders/sq/0jfgh1js6cs8_31df82hx3jw0000gn/T/29ba74c7c7ffd7aa157831c6436d3738

Once you're done with your changes, run "pnpm patch-commit /private/var/folders/sq/0jfgh1js6cs8_31df82hx3jw0000gn/T/29ba74c7c7ffd7aa157831c6436d3738"

按照提示,打开这个文件夹,加一行代码:
image.png


执行上面控制台的提示:


pnpm patch-commit /private/var/folders/sq/0jfgh1js6cs8_31df82hx3jw0000gn/T/e103de90617a18eee7942d1df35a2c48
Packages: -1
-
Progress: resolved 5, reused 6, downloaded 0, added 1, done

这时你会发现package.json中多了一段内容:


"pnpm": {
"patchedDependencies": {
"is-even@1.0.0": "patches/is-even@1.0.0.patch"
}
}

根目录下,也多了个文件夹patches,打开以后,你就能找到添加的代码:
image.png


打开node_modules/is-even/index.js,可以看到已经多了我们添加的代码:
image.png


删除node_modules,重新pnpm i安装依赖,仍然与现在一样,这就代表成功了。


整个流程下来,我们看得出来相比于patch-package,要稍微复杂点儿,但也是可以接受的。



注意:patches目录是一定得提交到git的。



换日方案


上面说过,如果要修改的代码较多,或者不具备修改条件,这时就需要修改源码。
到GitHub上找到npm包的源码,Fork该项目,修改代码后,再修改包名,重新发布,比如你要修改的包是lodash,可以修改为awesome-lodash,在应用代码中更换引用。


本来这个方案没什么好说的,但有一种情况,如果你修改的是个底层包,也就是说并不是你的应用代码中直接引用的,而是你引用的npm包A所依赖的,甚至可能同时被包B依赖的,这时就比较尴尬了,你不可能再去修改A和B的源码,那就太不值当了。


pnpm提供了一种别名(Aliases)的能力。


假设你发布了一个名为awesome-lodash的新包,并使用lodash作为别名来安装它:


$ pnpm add lodash@npm:awesome-lodash

不需要更改代码,所有的lodash引用都被解析到了awesome-lodash
就这么简单,上面说的问题就解决了。


再说点儿题外话,有时你会想要在项目中使用一个包的两个不同版本,很简单:


$ pnpm add lodash1@npm:lodash@1
$ pnpm add lodash2@npm:lodash@2

现在,您可以通过 require('lodash1') 引入第一个版本的 lodash 并通过 require('lodash2') 引入第二个。


与pnpm的钩子结合使用功能会更加强大,比如你想将node_modules里所有的lodash引用也替换为awesome-lodash,你可以用下面的.pnpmfile.cjs 轻松实现:


function readPackage(pkg) {
if (pkg.dependencies && pkg.dependencies.lodash) {
pkg.dependencies.lodash = 'npm:awesome-lodash@^1.0.0'
}
return pkg
}

module.exports = {
hooks: {
readPackage
}
}

pnpm功能非常强大,后面我会再详细写篇文章介绍下。


总结


在开发过程中发现npm包的Bug,首先向原作者提交issue或Fork代码修改后提交合并请求。但遇到不活跃或拒绝修改的情况,项目等待时间会很长。这时可以使用补丁方案或换日方案进行解决。


补丁方案中,如果是npm或yarn v1,可以使用patch-package工具包处理;如果是yarn v2或pnpm,可以使用各自的patch命令。


换日方案,则是修改源码,发布新的npm包后,利用pnpm的别名功能,将所有依赖原npm包的地方,全部替换为新的包。


这种场景在日常开发中还是比较常见的,这里为大家提供一种思路。当然,如果真是个Bug,别忘了提issue或PR,为开源贡献自己的一份力量,在与作者的沟通交流中,相信你也能受益匪浅。


作者:纪轻昀
来源:juejin.cn/post/7356534347509497919
收起阅读 »

【前端缓存】localStorage是同步还是异步的?为什么?

web
🧑‍💻 写在开头 点赞 + 收藏 === 学会🤣🤣🤣 首先明确一点,localStorage是同步的 🥝 一、首先为什么会有这样的问题 localStorage 是 Web Storage API 的一部分,它提供了一种存储键值对的机制。loca...
继续阅读 »

🧑‍💻 写在开头


点赞 + 收藏 === 学会🤣🤣🤣



首先明确一点,localStorage是同步的



🥝 一、首先为什么会有这样的问题


localStorage 是 Web Storage API 的一部分,它提供了一种存储键值对的机制。localStorage 的数据是持久存储在用户的硬盘上的,而不是内存。这意味着即使用户关闭浏览器或电脑,localStorage 中的数据也不会丢失,除非主动清除浏览器缓存或者使用代码删除。


当你通过 JavaScript 访问 localStorage 时,浏览器会从硬盘中读取数据或向硬盘写入数据。然而,在读写操作期间,数据可能会被暂时存放在内存中,以提高处理速度。但主要的特点是它的持久性,以及它不依赖于会话的持续性。


🍉 二、硬盘不是io设备吗?io读取不都是异步的吗?


是的,硬盘确实是一个 IO 设备,而大部分与硬盘相关的操作系统级IO操作确实是异步进行的,以避免阻塞进程。不过,在 Web 浏览器环境中,localStorage 的API是设计为同步的,即使底层的硬盘读写操作有着IO的特性。


js代码在访问 localStorage 时,浏览器提供的API接口通常会处于js执行线程上下文中直接调用。这意味着尽管硬盘是IO设备,当一个js执行流程访问 localStorage 时,它将同步地等待数据读取或写入完成,该过程中js执行线程会阻塞。


这种同步API设计意味着开发者在操作 localStorage 时不需要考虑回调函数或者Promise等异步处理模式,可以按照同步代码的方式来编写。不过,这也意味着如果涉及较多数据的读写操作时,可能对性能产生负面影响,特别是在主线程上,因为它会阻塞UI的更新和其他js的执行。


🍑 三、完整操作流程


localStorage 实现同步存储的方式就是阻塞 JavaScript 的执行,直到数据的读取或者写入操作完成。这种同步操作的实现可以简单概述如下:



  1. js线程调用: 当 JavaScript 代码执行一个 localStorage 的操作,比如 localStorage.getItem('key') 或 localStorage.setItem('key', 'value'),这个调用发生在 js 的单个线程上。

  2. 浏览器引擎处理: 浏览器的 js 引擎接收到调用请求后,会向浏览器的存储子系统发出同步IO请求。此时 js 引擎等待IO操作的完成。

  3. 文件系统的同步IO: 浏览器存储子系统对硬盘执行实际的存储或检索操作。尽管操作系统层面可能对文件访问进行缓存或优化,但从浏览器的角度看,它会进行一个同步的文件系统操作,直到这个操作返回结果。

  4. 操作完成返回: 一旦IO操作完成,数据要么被写入硬盘,要么被从硬盘读取出来,浏览器存储子系统会将结果返回给 js 引擎。

  5. JavaScript线程继续执行: js 引擎在接收到操作完成的信号后,才会继续执行下一条 js 代码。


在同步的 localStorage 操作期间,由于 js 的单线程性质,整个线程会阻塞,即不会执行其他任何js代码,也不会进行任何渲染操作,直到 localStorage 调用返回。


🍒 四、localStorage限制容量都是因为同步会阻塞的原因吗?



  1. 资源公平分享:同一用户可能会访问大量不同的网站,如果没有限制,随着时间的积累,每个网站可能会消耗大量的本地存储资源。这样会导致本地存储空间被少数几个站点占用,影响到用户访问其他网页的体验。限制大小可以确保所有网站都有公平的存储机会。

  2. 防止滥用:如果没有存储限制,网站可能会滥用 localStorage,存储大量数据在用户的设备上,这可能导致设备存储空间迅速耗尽,也可能侵犯用户的隐私。

  3. 性能限制:如之前提到的,localStorage 的操作是阻塞的。如果网站能够存储大量数据,就会加剧读写操作对页面性能的影响。

  4. 存储效率localStorage 存储的是字符串形式的数据,不是为存储大量或结构化数据设计的。当尝试存储过多数据时,效率会降低。

  5. 历史和兼容性:5MB 的限制很早就已经被大多数浏览器实现,并被作为一个非正式的标准被采纳。尽管现在有些浏览器支持更大的 localStorage,但出于跨浏览器兼容性的考虑,开发者通常会假设这个限制。

  6. 浏览器政策:浏览器厂商可能会依据自己的政策来设定限制,可能是出于提供用户更一致体验的角度,或者是出于管理用户数据的方便。


🍐 五、那indexDB会造成滥用吗?


虽然它们提供了更大的存储空间和更丰富的功能,但确实潜在地也可能被滥用。但是与相比 localStorage 增加了一些特性用来降低被滥用的风险:



  1. 异步操作IndexedDB 是一个异步API,即使它被用来处理更大量的数据,也不会像 localStorage 那样阻塞主线程,从而避免了对页面响应性的直接影响。

  2. 用户提示和权限:对于某些浏览器,当网站尝试存储大量数据时,浏览器可能会弹出提示,要求用户授权。这意味着用户有机会拒绝超出合理范围的存储请求。

  3. 存储配额和限制:尽管 IndexedDB 提供的存储容量比 localStorage 大得多,但它也不是无限的。浏览器会为 IndexedDB 设定一定的存储配额,这个配额可能基于可用磁盘空间的一个百分比或者是一个事先设定的限额。配额超出时,浏览器会拒绝更多的存储请求。

  4. 更清晰的存储管理IndexedDB 的数据库形式允许有组织的存储和更容易的数据管理。用户或开发者可以更容易地查看和清理占用的数据。

  5. 逐渐增加的存储:某些浏览器实现 IndexedDB 存储时,可能会在数据库大小增长到一定阈值时,提示用户是否允许继续存储,而不是一开始就分配一个很大的空间。




作者:ObjectX不知名程序员
来源:juejin.cn/post/7359405716090011659
收起阅读 »

为 App 增加清理缓存功能

为 App 增加清理缓存功能 不废话,直接上干货 功能预期 评估缓存情况,估算出缓存大小; 一键清除所有缓存 评估缓存大小 已知 app 的缓存目录可通过 context.getCacheDir() 获取,那么评估其内容文件的大小即可,若有其他缓存路径也可...
继续阅读 »

为 App 增加清理缓存功能


不废话,直接上干货


功能预期



  1. 评估缓存情况,估算出缓存大小;

  2. 一键清除所有缓存


评估缓存大小


已知 app 的缓存目录可通过 context.getCacheDir() 获取,那么评估其内容文件的大小即可,若有其他缓存路径也可通过此方法合并计算:


public long getFolderSize(File folder) {
   long length = 0;
   File[] files = folder.listFiles();
   
   if (files != null) {
       for (File file : files) {
           if (file.isFile()) {
               length += file.length();
          } else {
               length += getFolderSize(file);
          }
      }
  }
   return length;
}

执行方法:


// 新建异步线程防止卡顿
new Thread() {
   @Override
   public void run() {
       super.run();
long cacheSize = getFolderSize(getCacheDir());
  }
}.start();

接下来需要将缓存大小按照合理的格式显示到界面上,我按照自己的需求小于 1MB 时显示 KB 单位,小于 1KB 时显示 0 KB,使用以下方法即可完成缓存大小的文本组织:


public String formatSize(long size) {
   if (size >= 1024 * 1024) {
       return (size / (1024 * 1024)) + " MB";
  } else if (size >= 1024) {
       return (size / 1024) + " KB";
  } else {
       return "0 KB";
  }
}

清理各单位缓存


WebView 的缓存清理


对于 WebView 可以直接使用 webView.clearCache(true) 方法来进行清理,但清除缓存的界面没有 WebView 实例,因此需要现场先建立一个来执行,注意 WebView 的创建不可以在异步线程进行:


WebView webView = new WebView(me);
webView.clearCache(true);

Glide 的缓存清理


只需要注意 Glide 的缓存清理必须在异步线程执行


try {
   // Glide: You must call this method on a background thread
   Glide.get(me).clearDiskCache();
} catch (Exception e) {
   e.printStackTrace();
}

其他组件请自行按照对应技术文档进行清理


综合缓存文件清理


所有缓存文件删除即可彻底完成清理步骤


File cacheDir = context.getCacheDir();
deleteDirectory(cacheDir);

删除目录方法:


private static void deleteDirectory(File dir) {
   if (dir != null && dir.isDirectory()) {
       for (File child : dir.listFiles()) {
           // 递归删除目录中的内容
           deleteDirectory(child);
      }
  }
   if (dir != null) {
       dir.delete();
  }
}

总结


其实清理缓存是个挺没必要的工作,Glide 等组件进行缓存的主要目的也在于避免重复资源的加载加快 app 的界面呈现速度,但不可避免的可能因为用户需要或者出现缓存 bug 导致界面无法正常显示等情况需要清理 app 缓存,即便系统本身自带了缓存清理功能(应用设置- app - 存储和缓存 - 清除缓存)但毕竟有些上手门槛且各家厂商操作系统操作逻辑各异不如自己做一个清除功能在 app 内了,通过上述代码即可完成缓存大小估算和清理流程,如有其他常用组件的清理操作方法也欢迎在评论区补充。


作者:Kongzue
来源:juejin.cn/post/7304932252826288180
收起阅读 »

Android串口通信蓝牙通信中数据格式转换整理

Android 定制开发版上应用开发,免不了使用一些串口通信、蓝牙通信,考虑到每次发送的数据包需要尽可能的小,约定的协议中基本上都是一些字节流表示,因此特地将之前搜集到的一些数据格式转换的方法整理出来。在此感谢将这些code发布出来的博主们。一、Byte相关的...
继续阅读 »

Android 定制开发版上应用开发,免不了使用一些串口通信、蓝牙通信,考虑到每次发送的数据包需要尽可能的小,约定的协议中基本上都是一些字节流表示,因此特地将之前搜集到的一些数据格式转换的方法整理出来。在此感谢将这些code发布出来的博主们。

一、Byte相关的数据转换

  1. 获取Byte指定下标[0 - 7]的Bit的值,和获取Byte的所有Bit的值
/**
* 获取第i位的bit值
*/

fun Byte.getPointedBit(position: Int): Int {
return (this.toInt() shr position) and 0x1
}

/**
* 通过byte获取int类型的字节list
* IntelMode 低字节在前,如 0x55-> 0101 0101
*/

fun Byte.getBitList(intelMode: Boolean = true): List<Int> {
val list = arrayListOf<Int>()
val input = this
for (i in 0 until 8) {
val index = if (intelMode) (7 - i) else i
list.add(input.getPointedBit(index))
}
return list
}
  1. Byte转16进制字符串
/**
* 十六进制字节转字符串,不足2位的字符串则在前补0
* 其实质是Byte->Int->String
*/

fun Byte.toHexString(): String {
var hexStr = Integer.toHexString(this.toInt() and 0xFF)
if (hexStr.length == 1) {
hexStr = "0$hexStr"
}
return hexStr.uppercase(Locale.getDefault())
}
  1. Byte 中修改指定位置的Bit,这个需要绕一下,先将Byte转成一个长度为8的数组,然后修改指定下标的值,然后再将这个数组转换成一个Int,最后Int可以直接转成Byte。目前还未发现其他更好的方法,如有后续补充上。
/**
* 将byte转换成bit组成的数组
*/

fun Byte.toByteArray(): ByteArray {
val bytes = ByteArray(8)
for (i in 0 until 8) {
bytes[i] = this.getPointedBit(i).toByte()
}
return bytes
}

/**
* 一个byte所代表的int值
*/

fun ByteArray.oneByteToIntSum(): Int {
var sum = 0
for (i in this.indices) {
val tmp = this[i]
// 2 的 n 次方
sum += (tmp * 2.0.pow(i.toDouble())).toInt()
}

return sum
}

二、ByteArray相关的数据转换

  1. ByteArray转Int
/**
* 有符号,int 占 2 个字节
*/

fun ByteArray.toIntWithTwo(): Int {
return (this[0].toInt() shl 8) or (this[1].toInt() and 0xFF)
}

/**
* 无符号,int 占 2 个字节
*/

fun ByteArray.toUnSignIntWithTwo(): Int {
return (this[0].toInt() and 0xFF) shl 8 or
(this[1].toInt() and 0xFF)
}

/**
* 有符号, int 占 4 个字节
*/

fun ByteArray.toIntWithFour(): Int {
return (this[0].toInt() shl 24) or
(this[1].toInt() and 0xFF) or
(this[2].toInt() shl 8) or
(this[3].toInt() and 0xFF)
}

/**
* 无符号, int 占 4 个字节
*/

fun ByteArray.toUnSignIntWithFour(): Long {
return ((this[0].toInt() and 0xFF) shl 24 or
(this[1].toInt() and 0xFF) shl 16 or
(this[2].toInt() and 0xFF) shl 8 or
(this[3].toInt() and 0xFF)).toLong()
}

/**
* 一个Int转成2个字节的byte数组
*/

fun Int.toIntArrayFor2(): List<Int> {
val list = arrayListOf<Int>()
val lowH = (this shr 8) and 0xff
val lowL = this and 0xff
list.add(lowH)
list.add(lowL)
return list
}

/**
* 一个Int转成4个字节的byte数组
*/

fun Int.toByteArray4(): ByteArray {
val byteArray = ByteArray(4)
val highH = ((this shr 24) and 0xff).toByte()
val highL = ((this shr 16) and 0xff).toByte()
val lowH = ((this shr 8) and 0xff).toByte()
val lowL = (this and 0xff).toByte()
byteArray[0] = highH
byteArray[1] = highL
byteArray[2] = lowH
byteArray[3] = lowL
return byteArray
}
  1. ByteArray转字符串
/**
* 字节数组转字符串
*/

fun ByteArray.toSimpleString(format: Charset = Charsets.UTF_8): String {
return String(this, format)
}

/**
* 字节数组转换成16进制字符串
*/

fun ByteArray.toHexString(): String {
var result = ""
for (element in this) {
var hexStr = Integer.toHexString(element.toInt() and 0xFF)
if (hexStr.length == 1) {
hexStr = "0$hexStr"
}
result += hexStr.uppercase(Locale.getDefault())
}
return result
}
  1. ByteArray转Long
/**
* 字节数组转换为long 8个byte
*/

fun ByteArray.convertToLong(): Long {
val bais = ByteArrayInputStream(this)
val dis = DataInputStream(bais)
return dis.readLong()
}

/**
* long转换为字节数组 8个byte
*/

fun Long.convertToBytes(): ByteArray {
val baos = ByteArrayOutputStream()
val dos = DataOutputStream(baos)
dos.writeLong(this)
return baos.toByteArray()
}

/**
* Long 类型转成4个字节数组
* 时间只能精确到秒
*/

fun Long.convertToBytes4(): ByteArray {
var tmp = this
val bytes = ByteArray(4)
for (i in bytes.size - 1 downTo 0) {
bytes[i] = (tmp and 0xFF).toByte()
tmp = tmp shr 8
}
return bytes
}

/**
* 4个字节数组转成Long 类型
* 时间只能精确到秒
*/

fun ByteArray.convertToLong4(): Long {
var num: Long = 0
for (i in 0 until 4) {
num = num shl 8
num = num or ((this[i].toInt() and 0xFF).toLong())
}
return num
}
  1. 两个ByteArray拼接
/**
* byte数组拼接一个byte数组
*/

fun ByteArray.appendByteArray(extraBytes: ByteArray): ByteArray {
val inputSize = this.size
val extraSize = extraBytes.size
val totalSize = inputSize + extraSize
val combineBytes = ByteArray(totalSize)
System.arraycopy(this, 0, combineBytes, 0, inputSize)
System.arraycopy(extraBytes, 0, combineBytes, inputSize, extraSize)
return combineBytes
}
  1. ByteArray转Double,此种转换较为复杂,目前未找到稳定可用的代码
none
  1. ByteArray 和 BCD 格式的时间相互转换
/**
* BCD字节数组转为字符串
*/

fun ByteArray.bcdToString(): String {
val sb = StringBuilder(this.size / 2)
for (i in 0 until this.size) {
// 高四位
sb.append((this[i].toInt() and 0xF0) ushr 4)
// 低四位
sb.append(this[i].toInt() and 0x0F)
}
val retStr = sb.toString()
return if (retStr.substring(0, 1).equals("0", ignoreCase = true)) {
retStr.substring(1)
} else {
retStr
}
}

/**
* 字符串转BCD字节数组
*/

fun String.bcdToByteArray(): ByteArray {
var len = this.length
val mod = len % 2
val srcStr = if (0 != mod) {
len += 1
"0$this"
} else this
val bytes = srcStr.toByteArray()
len = if (len >= 2) len / 2 else len
val secondBytes = ByteArray(len)
var j: Int
var k: Int
for (p in 0 until srcStr.length / 2) {
val jIndex = 2 * p
j = if (bytes[jIndex].toInt().toChar() in '0'..'9') {
bytes[jIndex].toInt().toChar() - '0'
} else if (bytes[jIndex].toInt().toChar() in 'a'..'z') {
bytes[jIndex].toInt().toChar() - 'a' + 0x0a
} else {
bytes[jIndex].toInt().toChar() - 'A' + 0x0a
}
val kIndex = 2 * p + 1
k = if (bytes[kIndex].toInt().toChar() in '0'..'9') {
bytes[kIndex].toInt().toChar() - '0'
} else if (bytes[kIndex].toInt().toChar() in 'a'..'z') {
bytes[kIndex].toInt().toChar() - 'a' + 0x0a
} else {
bytes[kIndex].toInt().toChar() - 'A' + 0x0a
}
val a = (j shl 4) + k
val b = a.toByte()
secondBytes[p] = b
}
return secondBytes
}

三、String相关的类型转换,主要是方便把二进制字节流转换成易于查看的字符串

  1. 16进制字符串转ByteArray
private fun char2Byte(input: Char): Byte {
return "0123456789ABCDEF".indexOf(input).toByte()
}

/**
* 16进制字符串转字节数组,提供3种转换方式
*/

fun String.hexStringToBytes(type: Int = 0): ByteArray {
if (this.isEmpty()) {
return ByteArray(0)
}
val hexStr = this.uppercase(Locale.getDefault())
val length = hexStr.length / 2
val outBytes = ByteArray(length)
when (type) {
0 -> {
val hexCharArr = this.toCharArray()
for (i in 0 until length) {
val p = 2 * i
val p1 = char2Byte(hexCharArr[p]).toInt() shl 4
val p2 = char2Byte(hexCharArr[p + 1])
outBytes[i] = p1.toByte() or p2
}
}
1 -> {
for (i in 0 until length step 2) {
val v1 = (this[i].digitToIntOrNull(16) ?: -1) shl 4
val v2 = this[i + 1].digitToIntOrNull(16) ?: -1
outBytes[i / 2] = (v1 + v2).toByte()
}
}
else -> {
for (i in outBytes.indices) {
val subStr = this.substring(2 * i, 2 * i + 2)
outBytes[i] = subStr.toInt(16).toByte()
}
}
}
return outBytes
}
  1. 字符串Json格式转Map
/**
* json 字符串转 Map
*/

fun String.jsonStringToMap(): HashMap? {
val jsonObject: JSONObject
try {
jsonObject = JSONObject(this)
val keyIter: Iterator = jsonObject.keys()
var key: String
var value: Any
val valueMap = HashMap()
while (keyIter.hasNext()) {
key = keyIter.next()
value = jsonObject[key] as Any
valueMap[key] = value
}
return valueMap
} catch (e: JSONException) {
e.printStackTrace()
}
return null
}


作者:pursuit_hu
来源:juejin.cn/post/7226629911350542391
收起阅读 »

我早就看现在的工作流不爽了!- 前端使用 Jenkins

背景目前笔者所在的小公司的前端项目还是推送到git仓库后由另一名后端拉取代码到他电脑上再build,然后再手动同步到服务器上,比较麻烦,而且出现一个bug就要立即修复,笔者一天要说100次“哥,代码更新了,打包上传下吧,球球了”,终于我实在受不了了(上传代码的...
继续阅读 »


背景

目前笔者所在的小公司的前端项目还是推送到git仓库后由另一名后端拉取代码到他电脑上再build,然后再手动同步到服务器上,比较麻烦,而且出现一个bug就要立即修复,笔者一天要说100次“哥,代码更新了,打包上传下吧,球球了”,终于我实在受不了了(上传代码的这位哥也受不了了),于是想通过 Jenkins 实现简单的前端项目自动打包部署。

通过 docker 安装 Jenkins

通过 ssh 连接上局域网服务器 192.168.36.2,在 home 目录下新建了一个 Jenkins 文件夹,后续我们的配置文件就放在其中。

 cd
 # 将 Jenkins 相关的文件都放在这里
 mkdir jenkins
 cd jenkins
 
 # 创建 Jenkins 配置文件存放的地址,并赋予权限
 mkdir jenkins_home
 chmod -R 777 jenkins_home
 
 pwd
 # /root/jenkins

创建docker-compose.yml

 touch docker-compose.yml
 vim docker-compose.yml
 version: '3'
 services:
  jenkins:
    image: jenkins/jenkins:latest
    container_name: 'jenkins'
    restart: always
    ports:
      - "8999:8080"
    volumes:
      - /root/jenkins/jenkins_home:/var/jenkins_home

Jenkins 启动后会挂在8080端口上,本文笔者将其映射到8999端口,读者可以自行更改。

关键在于将容器中的/var/jenkins_home目录映射到宿主机的/root/jenkins/jenkins_home目录,这一步相当于将 Jenkins 的所有配置都存放在宿主机而不是容器中,这样做的好处在于,后续容器升级、删除、崩溃等情况下,不需要再重新配置 Jenkins。

使用:wq保存后可以开始构建了:

 docker compose up -d

这一步会构建容器并启动,看到如下信息就说明成功了:

 [+] Running 1/1
  Container Jenkins   Started           1.3s

查看一下容器是否在运行:

 docker ps

image-20240403133238265

这个时候通过http://192.168.36.2:8999就可以访问 Jenkins 了。

Jenkins 初次配置向导

解锁

image-20240403133538015

第一次打开会出现向导,需要填入管理员密码,获取密码有三种方式:

  1. 通过宿主机

     cat /root/jenkins/jenkins_home/secrets/initialAdminPassword
     
     # 2bf4ca040f624716befd5ea137b70560
  2. 通过 docker 进入容器

     docker exec -it jenkins /bin/bash
     
     #进入了docker
     jenkins@1c151dfc2482:/$ cat /var/jenkins_home/secrets/initialAdminPassword
     
     # 2bf4ca040f624716befd5ea137b70560

    与方法一类似,因为目录映射,这两个目录其实是同一个。

  3. 通过查看 docker log

     docker logs jenkins

    会出现一大串,最后能找到密码:

    image-20240403134001532

填入密码,点击继续。

安装插件

image-20240403134122512

选择安装推荐插件即可。

安装插件可能会非常慢,可以选择换源。

更换 Jenkins 插件源(可选)

有两种方法:

  1. 直接输入地址:

    http://192.168.36.2:8999/manage/pluginManager/advanced,在Update Site中填入清华源地址:

     https://mirrors.tuna.tsinghua.edu.cn/jenkins/updates/update-center.json

    点击Submit提交保存,并重启容器。

  2. 直接更改配置文件:

    宿主机中操作:

     cd /root/jenkins/jenkins_home
     vim hudson.model.UpdateCenter.xml

    替换其中的地址,然后重启容器即可。

    image-20240403135010339

创建用户

这一步建议用户名不为 admin ,不然会出现奇怪的问题,比如密码登录不上,需要用上一部的初始密码(2bf4ca040f624716befd5ea137b70560)才能登录。

我这里创建了一个 root 用户(只是名字叫 root,防止用户名太多记不住而已)。

image-20240403135909136

点击保存并完成。

实例配置按需调整即可,直接下一步,Jenkins 就准备就绪了。

image-20240403140101678

至此 Jenkins 安装就算完成了。

安装插件

笔者是一名前端,因此以前端项目为例。

前端项目的打包需要 node 环境,打包完成后通过 ssh 部署到服务器上,并且构建结果通过钉钉机器人推送到群里,因此需要三个插件。

  1. NodeJS
  2. Publish Over SSH
  3. DingTalk(可选)

在 系统管理 -> 插件管理 -> Available plugins 中搜索并安装。

image-20240403140852383

image-20240403140907525

勾选安装后重启,让插件生效。

插件配置

我们安装了三个插件,分别进行配置。

NodeJS

这个插件可以在不同的项目中使用不同的 node 环境,例如 A项目 使用 node14,B项目 使用 node20 这样。

进入 系统管理 -> 全局工具配置 -> NodeJS 安装 (在最下面)

点击新增:

image-20240403142305544

默认的这个使用的是 nodejs.org 的官方源,虽然现在 nodejs.org 的官方源国内访问也还可以,但为了保险起见,笔者还是换成阿里巴巴源。

image-20240403142424488

点击红框里的 X 删除当前安装,在点击新增安装,选择 Install from nodejs.org mirror

image-20240403142605321

镜像地址填入https://mirrors.aliyun.com/nodejs-release/,版本按需选择,笔者这里选择的是 node20-lts,并且安装了包管理工具 pnpm,如果读者的项目需要别的全局安装的包,也可以写在 Global npm packages to install ,比如 yarncnpm 之类的。

记得起一个别名:

image-20240403153355639

配置好后点击保存。

一般来说,在使用 npm 时,需要更改 npm 的源,同样在 Jenkins 中也是可以的。

安装完 NodeJS 插件后,系统设置中会多一项 Managed files

image-20240403143048480

进入后选择左侧的Add a new Config,然后选择 Npm config file,然后点击 Next

image-20240403143327739

image-20240403143449389

新增一个 NPM Registry,填入阿里巴巴镜像源:http://registry.npmmirror.com

至此 NodeJS 相关的配置就完成了。

SSH Server

打包后需要通过 SSH 部署到服务器上,因此需要先配置好 SSH 服务器。

打开 系统管理 -> 系统配置 -> Publish over SSH (在最下面):

image-20240403143805027

然后根据实际情况进行填写:

image-20240403144201158

字段解释
Name显示在 Jenkins 中的名称,可随意填写
Hostname服务器地址,ip 或 域名
UsernameSSH 登录的用户名
Remote DirectorySSH 登录后进入的目录,必须是服务器中已经存在的目录,设置好之后所有通过 SSH 上传的文件只能放在这个目录下

这里笔者使用用户名-密码的方式登录 SSH,如果要通过 SSH Key 的方式的话,需要在字段 Path to key 填入 key 文件的地址,或者直接将 key 的内容填入 Key 字段中:

image-20240403144737759

设置好可以通过Test Configuration,测试 SSH 连通性:

image-20240403144822057

出现 Success 代表 SSH 配置成功。

钉钉通知(可选)

如果不需要通过钉钉通知,可以不装 DingTalk 插件,并跳过本节内容。

钉钉部分设置

该功能需要一个钉钉群,并打开钉钉群机器人:

image-20240403145500789

点击添加机器人,选择自定义:

image-20240403145604092

这里笔者的安全设置选择了加签:

image-20240403145717147

将签名保存下来备用。

点击完成后,出现了钉钉机器人的 Webhook 地址。

image-20240403145823192

将地址保存下来备用。

至此钉钉部分的设置就结束了。

Jenkins 部分

打开 系统设置 -> 钉钉 (在最下面的未分类中):

image-20240403145150439

根据需要配置通知时机:

image-20240403145231249

然后点击机器人-新增:

image-20240403145303034

将刚刚的钉钉机器人的签名和 Webhook 地址填入对应的地方,并点击测试:

image-20240403150049799

此时钉钉机器人也在群中发了消息:

image-20240403150138516

至此钉钉机器人配置完毕。

创建任务(job)

本文中,笔者将以存储在 Git 仓库中的项目为例。

Github 项目

注意,如果想让 Github 项目全自动构建的话,需要你的 Jenkins 能被公网访问到,例如部署在云服务器上,像笔者这样部署在本地局域网中,是无法实现“提交代码 -> 自动构建 -> 自动部署”的,只能实现“提交代码 -> 手动点击开始构建 -> 自动部署”

如果在 Jenkins 新手向导里选择了 安装推荐插件,那么现在就不需要额外安装 Github 相关的插件了,否则的话需要手动安装 Github 相关的插件:

image-20240403151242880

创建项目

选择 Dashboard -> 新建任务:

image-20240403151424735

选择构建一个自由风格的软件项目,点击确定。

General

这部分可以添加钉钉机器人:

image-20240403151545166

源码管理

这里选择 Git:

输入仓库地址:https://github.com/baIder/homepage.git

image-20240403151822101

由于笔者这是一个私有仓库,因此会报错。

在下面的Credentials中,添加一个。

image-20240403151941812

image-20240403152135370

注意,这里的用户名是 Github 用户名,但是密码不是你的 Github 密码,而是你的 Github Access Token!!!

image-20240403152324115

image-20240403152429183

可以在这里创建 Token,需要勾选 admin:repo_hook 、repo 权限。

image-20240403152535951

image-20240403152729685

这里的报错是网络问题,连接 Github 懂得都懂。

image-20240403152824725

分支可以根据实际情况选择。

构建触发器

勾选GitHub hook trigger for GITScm polling,这样在 Git 仓库产生提交时,就会触发构建,属于是真正的核心。

image-20240403153134664

构建环境

勾选 Provide Node & npm bin/ folder to Path

image-20240403153444910

Build Steps

到这里,可以理解为 Jenkins 已经将仓库克隆到本地,并且已经安装好了nodenpmpnpm,接下来就是执行命令:

image-20240403153625159

我们需要执行命令:

 node -v
 pnpm -v
 
 rm -rf node_modules
 rm -rf dist
 
 pnpm install
 pnpm build

这里的pnpm build需要按情况更换为package.json中设定的命令。

image-20240403153850007

image-20240403153750787

构建后操作

经过所有的流程到这里,项目应该已经打包在dist目录下了。现在可以通过 SSH 将打包好的产物上传到服务器上了:

image-20240403154044658

image-20240403155757484

这里的 Source files 字段一定要写成dist/**/**,如果写成dist/*,则只会将第一层的文件上传。

Remove prefix 需要填写,否则会将dist这个目录也上传到服务器上。

Remote directory 是相对于配置 SSH Server 时的 Remote directory 的,本例中就是 /data/sites/homepage 。

Exec command 是文件上传后执行的命令,可以是任何命令,可以是让nginx有权限访问这些数据,重启nginx等等,根据服务器实际情况更改。

当然也可以在 Build Steps 中 build 完成后将 dist 目录打包,然后在通过 SSH 将压缩包上传到服务器,然后在 Exec command 中解压。

至此所有的配置已经完成,保存。

测试

点击左侧的 立即构建:

image-20240403154858929

image-20240403154950197

第一次构建会比较慢,因为需要下载node,安装依赖等等,可以从控制台看到,命令都如期执行了:

image-20240403155359524

构建成功,钉钉机器人也提示了(因为 Github 访问失败的原因,多试了几次):

image-20240403155855959

笔者已经配置好了nginx,因此可以直接访问网页,查看效果:

image-20240403160008179

通过 Git 提交触发构建

目前虽然构建成功了,但是需要手动点击构建,接下来实现如何将代码提交 Git 后自动触发构建。

打开仓库设置 -> Webhooks 添加一个:

image-20240403160353025

这里的 Payload URL 就是 Jenkins 地址 + /github-webhook,例如笔者的就如图所示。

但是由于笔者的 Jenkins 部署在本地局域网,因此是不行的,Github 肯定是无法访问到笔者的局域网的,有公网地址的读者可以试试,在笔者的阿里云服务器上是没有问题的。所以目前如果是 Github 项目的话,笔者需要提交代码后手动点击 立即构建:

image-20240403161026497

Gitlab 项目

实际上笔者所在公司是在局域网中部署了 Gitlab 的,因此针对 Gitlab 项目的自动化才是核心。

安装 Gitlab 插件:

image-20240403161442736

安装完毕后重启 Jenkins。

获取 Gitlab token

与 Github 的流程类似,也需要在 Gitlab 中创建一个 token:

image-20240403161807711

创建好之后保存 token 备用。

在 Jenkins 中配置 Gitlab

打开 Jenkins -> 系统管理 -> 系统配置 -> Gitlab

image-20240403162301361

这里需要新建一个Credentials,点击下方的添加:

类型选择GitLab API token,将刚刚保存的 token 填入到 API token 字段中。

image-20240403162144399

点击Test Connection

image-20240403162651637

出现Success说明配置成功。

创建项目

大多数过程与 Github 项目雷同。

General

会多出一个选项,选择刚刚添加的:

image-20240403163406501

源码管理

Git 仓库地址填 Gitlab 仓库地址,同样会报错,添加一个Credentials便可解决:

image-20240403163538105

用户名密码填登录 Gitlab 的用户名密码即可。

构建触发器

按需选择触发条件,这里笔者仅选择了提交代码:

这里红框中的 url 需要记下,后面要用。

image-20240403164252265

其他配置

与 Github 项目相同。

测试构建

点击立即构建,查看是否能构建成功:

image-20240403163945821

构建成功:

image-20240403164002293

提交代码自动构建

进入 Gitlab 仓库 -> 设置 -> 集成:

这里的 url 填入刚刚 Jenkins 构建触发器 中红框内的 url 地址。

image-20240403164203308

看情况是否开启 SSL verification。

点击 Add webhook:

image-20240403164449921

测试一下:

image-20240403164513668

可以看到 Jenkins 那边已经开始构建了:

image-20240403164551282

构建成功:

image-20240403164606737

测试 Git 提交触发构建

目前页面:

image-20240403164712339

我们将v2.0-f改成v2.0-g

image-20240403164817371

提交代码,Jenkins 开始了自动构建:

image-20240403164852625

构建成功,页面也发生了变化:

image-20240403164912343

至此,Gitlab 提交代码后自动打包并部署至服务器的流水线就完成了。

后记

本文实现了从提交代码到部署上线的自动化工作流,适合小公司的小型项目或自己的演示项目,大公司一定会有更规范更细节的流程,笔者也是从实际需求出发,希望本文能帮助到各位,由于笔者也是第一次使用 Jenkins,如有不足或错误之处,请读者批评指正。


作者:bald3r
来源:juejin.cn/post/7354406980784504870

收起阅读 »

Android 将json数据显示在RecyclerView

json数据要通过Get请求获取,这里有个重要的知识点,get请求需要拼接url的 本次拼接url的参数为phone,由于登录的时候已经填了手机号,如果这里再收集手机号就会让客户体验变差,于是我采用了SharedPreferences进行记录并调出 Share...
继续阅读 »

json数据要通过Get请求获取,这里有个重要的知识点,get请求需要拼接url的
本次拼接url的参数为phone,由于登录的时候已经填了手机号,如果这里再收集手机号就会让客户体验变差,于是我采用了SharedPreferences进行记录并调出


SharedPreferences pref=getSharedPreferences("data",MODE_PRIVATE);
String phone=pref.getString("phone","");

得到了phone之后,我采用了okhttp请求返回json,注意:进行网络请求都需要开启线程以及一些必要操作
例如


<uses-permission android:name="android.permission.INTERNET" /> 

url为你申请的网络url


 new Thread(new Runnable() {
@Override
public void run() {
OkHttpClient client=new OkHttpClient().newBuilder()
.connectTimeout(60000, TimeUnit.MILLISECONDS)
.readTimeout(60000,TimeUnit.MILLISECONDS).build();
//url/phone
Request request=new Request.Builder().url("url/phone"+phone).build();
try {
Response sponse=client.newCall(request).execute();
String string = sponse.body().string();
Log.d("list",string);
jsonJXDate(string);
}catch (IOException | JSONException e){
e.printStackTrace();
}
}
}).start();

由上可知,string即为所需的json


展示大概长这样


{
"code": 200,
"message": "成功",
"data": [
{
"id": "string",
"createTime": "2023-04-18T05:50:08.905+00:00",
"updateTime": "2023-04-18T05:50:08.905+00:00",
"isDeleted": 0,
"param": {},
"phone": "15019649343",
"commercialTenant": "string",
"payTime": "2023-04-18T05:50:08.905+00:00",
"type": "string",
"paymentType": "string",
"bills": [
{
"product": "烧烤",
"amount": "4",
"price": "60",
"subtotal": "240"
}
],
"total": "string"
},
{
"id": "643e9efb09ecf071b0fd2df0",
"createTime": "2023-04-18T13:28:35.889+00:00",
"updateTime": "2023-04-18T13:28:35.889+00:00",
"isDeleted": 0,
"param": {},
"phone": "15019649343",
"commercialTenant": "string",
"payTime": "2023-04-18T13:28:35.889+00:00",
"type": "string",
"paymentType": "string",
"bills": [
{
"product": "兰州拉面",
"amount": "5",
"price": "40",
"subtotal": "200"
}
],
"total": "string"
}
],
"ok": true
}

我所需要的是payTime,product,subtotal


有{}用JSONObject,有[]用JSONArray,一步步来靠近你的需要


JSONObject j1 = new JSONObject(data);
try {
JSONArray array = j1.getJSONArray("data");
for (int i=0;i<array.length();i++){
j1=array.getJSONObject(i);
Map<String,Object>map=new HashMap<>();
String payTime = j1.getString("payTime");
JSONObject bills = j1.getJSONArray("bills").getJSONObject(0);
String product = bills.getString("product");
String subtotal = bills.getString("subtotal");
map.put("payTime",payTime);
map.put("product",product);
map.put("subtotal",subtotal);
list.add(map);
}
Message msg=new Message();
msg.what=1;
handler.sendMessage(msg);

}catch (JSONException e){
e.printStackTrace();
}

}
public Handler handler=new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case 1:
//添加分割线
rv.addItemDecoration(new androidx.recyclerview.widget.DividerItemDecoration(
MeActivity.this, androidx.recyclerview.widget.DividerItemDecoration.VERTICAL));
MyAdapter recy = new MyAdapter(MeActivity.this, list);
//设置布局显示格式
rv.setLayoutManager(new LinearLayoutManager(MeActivity.this));
rv.setAdapter(recy);
break;
}
}
};

在adapter处通过常规layout显示后填入数据


 //定义时间展现格式
Map<String, Object> map = list.get(position);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
LocalDateTime dateTime = LocalDateTime.parse(map.get("payTime").toString(), formatter);
String strDate = dateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));

holder.produce.setText(map.get("product").toString());
holder.payTime.setText(strDate);
holder.price.setText(map.get("subtotal").toString());

就大功告成啦,由于后台那边还没把base64图片传上来,导致少了个图片,大致就是这样的


6a2d483f93267f3cd09f25576c1f29c.jpg


作者:m924
来源:juejin.cn/post/7224841852305588280
收起阅读 »

Android 开发中是否应该使用枚举?

前言在Android官方文档推出性能优化的时候,从一开始有这样一段说明:Enums often require more than twice as much memory as static constants. You should strictly av...
继续阅读 »

前言

Android官方文档推出性能优化的时候,从一开始有这样一段说明:

Enums often require more than twice as much memory as static constants. You should strictly avoid using enums on Android.

意思是说在 Android 平台上 avoid 使用枚举,因为枚举类比一般的静态常量多占用两倍的空间。

由于枚举最终的实现原理还是类,在编译完成后,最终为每一种类型生成一个静态对象,而在内存申请方面,对象需要的内存空间远大于普通的静态常量,而且分析枚举对象的成员变量可知,每一个对象中默认都会有一个字符数组空间的申请,计算下来,枚举需要的空间远大于普通的静态变量。

如果只是使用枚举来标记类型,那使用静态常量确实更优,但是现在翻看官方文档发现,这个建议已经被删除了,这是为什么那 ? 具体看 JakeWharton 在 reddit 上的一个评论

The fact that enums are full classes often gets overlooked. They can implement interfaces. They can have methods in the enum class and/or in each constant. And in the cases where you aren't doing that, ProGuard turns them back int0 ints anyway.
The advice was wrong for application developers then. It's remains wrong now.

最重要的一句是

ProGuard turns them back int0 ints anyway.

在开启 ProGuard 优化的情况下,枚举会被转为int类型,所以内存占用问题是可以忽略的。具体可参看 ProGuard 的优化列表页面 Optimizations Page,其中就列举了 enum 被优化的项,如下所示:

class/unboxing/enum

Simplifies enum types to integer constants, whenever possible.

ProGuard官方出了一篇文章 ProGuard and R8: Comparing Optimizers(大致意思就是自己比R8强 ),既ProGuard会把枚举优化为整形.但是安卓抛弃了了ProGuard,而是使用了R8作为混淆优化工具。我们重点看下R8对枚举优化的效果如何 ?

R8对枚举优化

下面通过以下例子验证一下在真实的开发环境中R8对枚举优化的支持效果。 代码如下:

  1. 定义一个简答枚举类Language
package com.example.enum_test;

public enum Language {

English("en", "英文"), Chinese("zh", "中文");

String webName;
String zhName;

Language(String webName, String zhName) {
this.webName = webName;
this.zhName = zhName;
}
}
  1. MainActivity主要代码
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Language language = null;
if (Math.random() < 0.5) {
doEnumAction(Language.English);
} else {
doEnumAction(Language.Chinese);
}
// doNumberAction(CHINESE);
}


private void doEnumAction(Language language) {
switch (language) {
case English:
System.out.println("english ");
break;
case Chinese:
System.out.println("chinese");
break;
}
System.out.println(language.name());
}

3.build.gradle.kts文件内开启混淆

buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("debug")
}
}
  1. 将编译后的apk反编译结果如下(枚举类被优化):

截屏2023-11-07 20.55.14.png

以上结果可以看出,如果是一个简单的枚举类,那么枚举类将会被优化为一个整形数字。既然ProGuard/R8会把枚举优化为整形,那是不是在Android中,就可以继续无所顾忌的使用枚举了呢? 我没有找到官方对R8枚举具体的优化场景说明 ,只找了ProGuard对枚举的优化有一定的限制条件,如果枚举类存在如下的情况,将不会有优化为整形,如下所示:

  1. 枚举实现了自定义接口。并且被调用。
  2. 代码中使用了不同签名来存储枚举。
  3. 使用instanceof指令判断。
  4. 使用枚举加锁操作。
  5. 对枚举强转。
  6. 在代码中调用静态方法valueOf方法
  7. 定义可以外部访问的方法。

参考自:ProGuard 初探 · dim's blog,另外,上面的这七种情况,我并没有找到官方的说明,如果有哪位读者知道,请在评论区里留下链接,谢谢啦~

下面我们对以上的情况进行追一验证,看下这些条件是否也会对R8编译优化产生限制 , 如下 :

  1. 枚举实现了自定义接口,并且被调用。
public interface ILanguage {
int getIndex();
}


public enum Language implements ILanguage{
English("en", "英文"), Chinese("zh", "中文");

String webName;
String zhName;

Language(String webName, String zhName) {
this.webName = webName;
this.zhName = zhName;
}

@Override
public int getIndex() {
return this.ordinal();
}
}

// 调用如下
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
ILanguage iLanguage = Language.Chinese;
System.out.println(iLanguage.getIndex());
}

反编译结果如下(枚举类被优化):

截屏2023-11-07 18.37.01.png

  1. 代码中使用了不同签名来存储枚举。

在对以下代码调用的时候,使用一个变量保存枚举值,由于currColor变量声明类型的不同, 导致枚举的优化结果也不同

// 枚举会被优化
Signal currColor = Signal.RED;

//发生了类型转换,变量签名不一致,枚举不会被优化
Object currColor = Signal.RED;

public void change(Signal color) {
switch (color) {
case RED:
currColor = Signal.GREEN;
break;
case GREEN:
currColor = Signal.YELLOW;
break;
case YELLOW:
currColor = Signal.RED;
break;
}
}


protected void onCreate(Bundle savedInstanceState) {

double random = Math.random();
if (random > 0.5f) {
change(GREEN);
} else if (random > 0.5 && random < 0.7) {
change(RED);
} else {
change(Signal.YELLOW);
}
// 最终也是被优化为if语句
//switch (currColor) {
// case RED:
// System.out.println("红灯");
// break;
// case GREEN:
// System.out.println("绿灯");
// break;
// case YELLOW:
// System.out.println("黄灯");
// break;
//}

if (currColor == RED) {
System.out.println("红灯");
} else if (currColor == GREEN) {
System.out.println("绿灯");
} else if (currColor == YELLOW) {
System.out.println("黄灯");
}
}

Signal currColor = Signal.RED; 时 ,枚举被优化整数

截屏2023-11-10 11.41.41.png

Object currColor = Signal.RED;时 ,枚举未被优化

截屏2023-11-10 11.34.39.png

  1. 使用instanceof指令判断。 (发生了类型转换)
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
boolean result = getObj() instanceof Language;
System.out.println(result);
}

Language getObj() {
return Math.random() > 0.5 ? Language.Chinese : null;
}

反编译结果如下(枚举类未被优化):

截屏2023-11-10 12.13.48.png

  1. 使用枚举加锁操作。
synchronized (Language.Chinese) {
System.out.println("synchronized");
}

从反编译结果如下(枚举类未被优化):

截屏2023-11-07 18.23.22.png 可以看到在该场景下枚举类没有被优化。

  1. 不要作为一个输出或打印对象
System.out.println(RED);

从反编译结果如下(枚举类未被优化):

截屏2023-11-10 12.17.11.png

  1. 对枚举强转。 比如下代码不会出现枚举优化
  @Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
boolean result = (getObj()) != null;
System.out.println(result);
}

// 如果返回值类型和返回的枚举类型不一致时,也不会优化枚举。
@Nullable
Object getObj() {
return Math.random() > 0.5 ? Language.Chinese : null;
}

反编译结果如下:(枚举类未被优化): 截屏2023-11-07 20.07.43.png

如果把返回值修改为Language则会发生优化

@Nullable
Language getObj() {
return Math.random() > 0.5 ? Language.Chinese : null;
}

反编译结果如下:(枚举类被优化):

截屏2023-11-07 20.15.21.png

以下代码也会出现枚举被优化,把方法的返回值类型修改为 Language ,接收变量类型改为 Object

 @Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Object language = getObj();
boolean result= language != null;
System.out.println(result);
}


@Nullable
Language getObj() {
return Math.random() > 0.5 ? Language.Chinese : null;
}

截屏2023-11-07 20.34.54.png

  1. 定义可以外部访问的方法。 R8对枚举的优化并不受定义外部方法的影响,如下在枚举内定义getLanguage方法后,枚举仍被优化
package com.example.enum_test;

import androidx.annotation.Nullable;

public enum Language {
English("en", "英文"), Chinese("zh", "中文");

String webName;
String zhName;

Language(String webName, String zhName) {
this.webName = webName;
this.zhName = zhName;
}

@Nullable
public Language getLanguage(String name) {
if (English.webName.equals(name)) {
return Language.English;
} else {
return null;
}
}
}

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Language language = Language.getLanguage(Math.random() > 0.5f ? "en" : "zh");
boolean result= language != null;
System.out.println(result);
}

apk反编译结果如下(枚举被优化):

截屏2023-11-07 20.45.17.png

复杂多变的枚举优化

在测试中发现一个问题 ,同样的代码,放在不同的文件内,优化效果竟然也不同。

  1. 在 MainActivity定义如下方法
public void change(Signal color) {
switch (color) {
case RED:
currColor = GREEN;
break;
case GREEN:
currColor = YELLOW;
break;
case YELLOW:
currColor = RED;
break;
}
}

public void test() {
double random = Math.random();
if (random > 0.5f) {
change(GREEN);
} else if (random > 0.5 && random < 0.7) {
change(RED);
} else {
change(YELLOW);
}

if (currColor == RED) {
System.out.println("红灯");
} else if (currColor == GREEN) {
System.out.println("绿灯");
} else {
System.out.println("黄灯");
}
}

并在onCreate方法内执行, 从下面的反编译结果中可以看到枚举被优化了。

截屏2023-11-10 14.29.32.png

  1. 相同的代码如果定义在 TrafficLight类中, 并在MainActivityonCreate方法中运行 ,如下:
package com.example.enum_test;
import static com.example.enum_test.TrafficLight.Signal.GREEN;
import static com.example.enum_test.TrafficLight.Signal.RED;
import static com.example.enum_test.TrafficLight.Signal.YELLOW;

public class TrafficLight {

enum Signal {
GREEN, YELLOW, RED;
}

private Signal currColor = RED;

public void change(Signal color) {
switch (color) {
case RED:
currColor = GREEN;
break;
case GREEN:
currColor = YELLOW;
break;
case YELLOW:
currColor = RED;
}

}


public void test() {
double random = Math.random();
if (random > 0.5f) {
change(GREEN);
} else if (random > 0.5 && random < 0.7) {
change(RED);
} else {
change(YELLOW);
}

if (currColor == RED) {
System.out.println("红灯");
} else if (currColor == GREEN) {
System.out.println("绿灯");
} else {
System.out.println("黄灯");
}
}
}
// onCreate 内执行
TrafficLight trafficLight = new TrafficLight();
trafficLight.test();

截屏2023-11-10 14.43.23.png

从上面的对比中发现,相同的枚举代码操作放在Activity和 放在普通类中 ,编译结果是不同的。导致这种问题的原因还是因为Activity默认是配置了防混淆的,如果对一个类成员添加了防混淆配置,编译会尽可能的对类里面相关的枚举使用优化为一个常量,但这不是一定的,枚举的优化会受到其他因素影响,例如 锁对象、类型转换等其它条件限制。而TrafficLight默认是没有被配置防混淆的,如果类内定义了枚举变量,编译器会对类进行一系列的编译优化和函数内联等处理,枚举变量被抽取到一个工厂公共类里内部,枚举变量对象被指向一个Object类型引用,编译器不会对枚举进行优化。如果对类进行防混淆配置后,该类内部枚举代码会被优化为一个整数常量,结果如下:

配置 -keep class com.example.enum_test.TrafficLight效果

截屏2023-11-10 15.16.59.png

配置#-keepclassmembernames class com.example.enum_test.TrafficLight效果

截屏2023-11-10 15.31.18.png

引用代码

截屏2023-11-10 15.17.25.png

如果对具有引用枚举类型变量的类进行了防混淆配置处理TrafficLight内的枚举引用也全部被优化为了整数类型。

如果未对TrafficLight类进行防混淆配置,这个类的相关成员可能会被抽取到一个公共类里。 currColor 就是m0f1749b属性, 该属性是一个Object类型,这也是可能是导致枚举未完全优化为整数的原因, 从 m0 的代码中可以看到编译器将多个实例的构造统一只使用了一个Object作为引用, 方法也被编译到m0类内部,可以看到m0类不是一个TrafficLight,猜测这也是编译器在对枚举进行整型优化和枚举持有类优化一种权衡和选择吧 。

截屏2023-11-10 16.05.23.png

截屏2023-11-10 16.05.54.png

枚举 、常量

从编译结果来看,枚举由于会构建多个静态对象ordinal()values()等函数和变量的存在,确实会比普通的静态对象或常量更加占用空间和内存。但是从上面的测试结果中可以看到 ,枚举在最佳情况下可以被优化为整型,达到和常量一样的效果。

截屏2023-11-10 16.19.04.png

总结

以下场景都会阻止枚举优化 :

  1. 使用instanceof指令判断。
  2. 使用枚举作为锁对象操作
  3. System.out.println(enum) 输出
  4. 枚举作为返回值返回时,返回参数的声明类型与枚举不一致,请参考 例6
  5. 混淆优化配置影响枚举优化, 如果一个类中有变量是一个枚举类型,如果该类未在proguard-rules.pro配置混淆优化处理,该类则可能会被编译器优化掉,其变量和方法会被抽取到一个公共类或者内敛到引用类里, 且枚举类不会被优化,因为枚举变量公共类被一个Object类型变量引用持有。
  6. 常规的枚举使用,R8都会对枚举进行一定程度的优化,最好的情况下会优化成一个整数常量,性能几乎不会有任何影响。

我的结论是如果我们可以通过定义普通常量的方式代替枚举,则优先通过使用定义常量解决。因为枚举本身确实会带来导致包体积和内存的增长, 而枚举被优化的环境和条件实在是过于苛刻,例如可能在输出语造成打印了一下枚举System.out.println(enum),一不小心可能就会造成举优化失败。也不是不能使用枚举,权衡易用性和性能以及使用场景,可以考虑继续使用枚举,因为枚举在有些时候确实让代码更简洁,更容易维护,牺牲点内存也无妨。况且Android官方自己也在许多地方应用了枚举,例如Lifecycle.StateLifecycle.Event等 。

小彩蛋

前几天群里在讨论 京东金融Android瘦身探索与实践 文章,内容中一点优化是关于枚举的 。

截屏2023-11-07 21.14.13.png

我感觉他们以这个例子没有很强的说服力,原因如下 :

  1. 如果对持有枚举变量的类或者变量进行混淆配置后 ,编译器会对枚举进行优化 ,TrafficLight 内枚举的引用被替换为整数,从反编译结果可以看到优化后的代码就是普通的if语句,并不会出现所谓的占用大量体积的情况。

image.png

  1. 如果枚举相关类未进行完全优化,但是例子中的change()方法并不会导致大量增加包体 ,只是增加了4行字节码指令。但是枚举的定义的确会占用一定的包体积大小,这个毋庸置疑。

使用枚举实现以及编译后字节码如下 :

public class TrafficLight {

enum Signal {
GREEN, YELLOW, RED;
}

private Signal currColor = RED;

public void change(Signal color) {
switch (color) {
case RED:
currColor = GREEN;
break;
case GREEN:
currColor = YELLOW;
break;
case YELLOW:
currColor = RED;
}

}
}
// 22行字节码指令
.method public change(Lb1/a;)V
.registers 3
invoke-virtual {p1}, Ljava/lang/Enum;->ordinal()I
move-result p1
if-eqz p1, :cond_15
const/4 v0, 0x1
if-eq p1, v0, :cond_12
const/4 v0, 0x2
if-eq p1, v0, :cond_d
goto :goto_18
:cond_d
sget-object p1, Lb1/a;->a:Lb1/a;
:goto_f
iput-object p1, p0, Lcom/example/enum_test/TrafficLight;->currColor:Lb1/a;
goto :goto_18
:cond_12
sget-object p1, Lb1/a;->c:Lb1/a;
goto :goto_f
:cond_15
sget-object p1, Lb1/a;->b:Lb1/a;
goto :goto_f
:goto_18
return-void
.end method

使用常量实现相同功能编译后字节码如下 :

package com.example.enum_test;


public class TrafficLightConst {

public static final int GREEN = 0;
public static final int YELLOW = 1;
public static final int RED = 2;

private int currColor = RED;

public void change(int color) {
switch (color) {
case RED:
currColor = GREEN;
break;
case GREEN:
currColor = YELLOW;
break;
case YELLOW:
currColor = RED;
}

}
}
// 18行字节码指令
.method public change(I)V
.registers 4
const/4 v0, 0x1
if-eqz p1, :cond_10
const/4 v1, 0x2
if-eq p1, v0, :cond_d
if-eq p1, v1, :cond_9
goto :goto_12
:cond_9
const/4 p1, 0x0
iput p1, p0, Lcom/example/enum_test/TrafficLightConst;->currColor:I
goto :goto_12
:cond_d
iput v1, p0, Lcom/example/enum_test/TrafficLightConst;->currColor:I
goto :goto_12
:cond_10
iput v0, p0, Lcom/example/enum_test/TrafficLightConst;->currColor:I
:goto_12
return-void
.end method

参考

zhuanlan.zhihu.com/p/91459700

jakewharton.com/r8-optimiza…


作者:Lstone
来源:juejin.cn/post/7299666003364249650

收起阅读 »