注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

环信FAQ

环信FAQ

集成常见问题及答案
RTE开发者社区

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

哭了,朋友当韭菜被割惨了

最近我的朋友,被某些知识付费坑得很惨。全程毫无干货可言。内容仅仅只适用于初级、或者说部分中级的程序员。为此,我的朋友交了大几千的学费,却收获甚微。 当然,你可能说,是你的朋友问题啊?你朋友烂泥扶不上墙,学习方法不对,别人都有很多成功的案例。什么offer收到...
继续阅读 »

最近我的朋友,被某些知识付费坑得很惨。全程毫无干货可言。内容仅仅只适用于初级、或者说部分中级的程序员。为此,我的朋友交了大几千的学费,却收获甚微。



当然,你可能说,是你的朋友问题啊?你朋友烂泥扶不上墙,学习方法不对,别人都有很多成功的案例。什么offer收到手酸,外包入大厂。




我买这些课就是为了学习,入门一些语言。知识付费很合理呀!!



于是我跟我朋友在微信彻夜长谈,有了如下分析


先说结论



请擦亮你的慧眼,你的一分一毫来之不易。不到迫不得已,才当学费



为什么这么说?


首先,不管你是想就业,还是想学习一些新的技术,网上都有例子,github上也会有前沿的项目提供学习。


类型结论
学习新技术某项技术开源出来,作为技术的布道者,恨不得你免费过去学习,然后你再发一篇文章,越来越多人学习你的技术。
就业简历包装无非就是抄抄抄,抄别人的优秀代码。github开源项目就非常合适

其次,你学费,一定要做到利益最大化。必须要有以下两点



  • 能学到大部分人都学不到的技术亮点。记住,是大部分人,一定要做到差异化

  • 能学到优秀的学习方法,push你前进。


开启慧眼


现在市面的学习机构,鱼龙混杂。,B站大学,某识xin球,某ke时jian 甚至,在某音上,都有那种连麦做模拟面试,然后引导你付费学习。


就业环境不好,买方市场竞争激烈,某些人就抓住你的焦虑心理,坑你一把。回想你的求学生涯,是否也有类似被坑经历?醒醒吧,少年。能救你的,只有你自己


当然,小海也会有潜龙。不可否认,知识付费为我们提供了便利性。



  • 原本散乱无章的知识点,人家给你整理好了,你尽管就是学习,实践

  • 面对焦虑,你觉得很迷茫,需要一个人指点你前进

  • 能认识更多同样诉求的人,为以后学习,就业,甚至做生意提供可能


但是,某些不法分子,就是抓住你的这个心理,疯狂ge你韭菜。什么10块钱知识手册,19.9面试题,100块钱的项目视频。天天一大早,就转发一些公众号到你群上,dddd。


这些内容,不是说没有用。我们讨论适合人群,这类东西不适合中高级程序员



说那么多,你得学会判断这个人是不是大佬




你都可以简历包装,为什么‘大佬’就不会是被包装的



那就稍微整理一下,哪些是真大佬,伪大佬


真伪大佬


某佬博客开源项目学习人群是否顺眼
伪大佬面试题居多,很多基础内容,没有干货无,或者很少。动不动就是商城,博客应届生占比较多可能顺眼
真大佬博客、论坛内容干货。整理分类完善,你能学到东西有,某些大项目的贡献,同时也有优秀开源项目应届生,中高级都有大多数不顺眼,因为实在优秀

就学习人群做一个说明



  • 在就业容易程度上,相对于初中高级别的程序员,应届生无论从考察的内容,招聘的人数。都会容易丢丢。

  • 他说跟着他学,offer赢麻了。但是其中,找到工作的大多数都是应届生


就这些点,我们其实可以能判断个大概了。


记住,你想知识付费。一定要摸清他的底细,不能认为他说得都是对的。人家也是会包装的


你的hello world


或许每个程序员的第一行代码,都是


    print("hello world")

我想说的是,请你记住你的初心。



  • 转行过来当程序员,就是为了狠狠赚他一笔

  • 喜欢写代码,苦中作乐


情况每个人都不太一样,这里不细说。明白你是谁,你还是否有动力能坚持下去。明白这一点,远比你在迷茫的时候病急乱投医更为重要,请勿过度焦虑


为此,后面会说一下如何学习,以及找工作如何不被骗


力量大会


事关钱包的问题,我们都得谨慎谨慎。就业市场那恶劣,朋友找不到工作还被坑了一把。骗子实在可恶。请你先自身强大,先自己找出问题,不花冤枉钱,避免传销式编程


如有雷同,纯属巧合,没有针对任何人,也没有动某些人的饭碗。


作者:Goland猫
来源:juejin.cn/post/7357231056288055336
收起阅读 »

面试官:假如有几十个请求,如何去控制并发?

web
面试官:看你简历上做过图片或文件批量下载,那么假如我一次性下载几十个,如何去控制并发请求的? 让我想想,额~, 选中ID,循环请求?,八嘎!肯定不是那么沙雕的做法,这样做服务器直接崩溃啦!突然灵光一现,请求池!!! 我:利用Promise模拟任务队列,从而实现...
继续阅读 »

面试官:看你简历上做过图片或文件批量下载,那么假如我一次性下载几十个,如何去控制并发请求的?

让我想想,额~, 选中ID,循环请求?,八嘎!肯定不是那么沙雕的做法,这样做服务器直接崩溃啦!突然灵光一现,请求池!!!

我:利用Promise模拟任务队列,从而实现请求池效果。

面试官:大佬!


废话不多说,正文开始:


众所周知,浏览器发起的请求最大并发数量一般都是6~8个,这是因为浏览器会限制同一域名下的并发请求数量,以避免对服务器造成过大的压力。


首先让我们来模拟大量请求的场景


const ids = new Array(100).fill('')

console.time()
for (let i = 0; i < ids.length; i++) {
console.log(i)
}
console.timeEnd()

image.png


一次性并发上百个请求,要是配置低一点,又或者带宽不够的服务器,直接宕机都有可能,所以我们前端这边是需要控制的并发数量去为服务器排忧解难。


什么是队列?


先进先出就是队列,push一个的同时就会有一个被shift。我们看下面的动图可能就会更加的理解:


e0a2696a2299a3692d030dc7b956089a.gif


我们接下来的操作就是要模拟上图的队列行为。


定义请求池主函数函数


export const handQueue = (  
reqs // 请求数量
) => {}

接受一个参数reqs,它是一个数组,包含需要发送的请求。函数的主要目的是对这些请求进行队列管理,确保并发请求的数量不会超过设定的上限。


定义dequeue函数


const dequeue = () => {  
while (current < concurrency && queue.length) {
current++;
const requestPromiseFactory = queue.shift() // 出列
requestPromiseFactory()
.then(() => { // 成功的请求逻辑
})
.catch(error => { // 失败
console.log(error)
})
.finally(() => {
current--
dequeue()
});
}
}

这个函数用于从请求池中取出请求并发送。它在一个循环中运行,直到当前并发请求数current达到最大并发数concurrency或请求池queue为空。对于每个出队的请求,它首先增加current的值,然后调用请求函数requestPromiseFactory来发送请求。当请求完成(无论成功还是失败)后,它会减少current的值并再次调用dequeue,以便处理下一个请求。


定义返回请求入队函数


return (requestPromiseFactory) => {  
queue.push(requestPromiseFactory) // 入队
dequeue()
}

函数返回一个函数,这个函数接受一个参数requestPromiseFactory,表示一个返回Promise的请求工厂函数。这个返回的函数将请求工厂函数加入请求池queue,并调用dequeue来尝试发送新的请求,当然也可以自定义axios,利用Promise.all统一处理返回后的结果。


实验


const enqueue = requestQueue(6) // 设置最大并发数
for (let i = 0; i < reqs.length; i++) { // 请求
enqueue(() => axios.get('/api/test' + i))
}

动画.gif


我们可以看到如上图所示,请求数确实被控制了,只有有请求响应成功的同时才会有新的请求进来,极大的降低里服务器的压力,后端的同学都只能喊6


整合代码


import axios from 'axios'

export const handQueue = (
reqs // 请求总数
) => {
reqs = reqs || []


const requestQueue = (concurrency) => {
concurrency = concurrency || 6 // 最大并发数
const queue = [] // 请求池
let current = 0

const dequeue = () => {
while (current < concurrency && queue.length) {
current++;
const requestPromiseFactory = queue.shift() // 出列
requestPromiseFactory()
.then(() => { // 成功的请求逻辑
})
.catch(error => { // 失败
console.log(error)
})
.finally(() => {
current--
dequeue()
});
}

}

return (requestPromiseFactory) => {
queue.push(requestPromiseFactory) // 入队
dequeue()
}

}

const enqueue = requestQueue(6)

for (let i = 0; i < reqs.length; i++) {

enqueue(() => axios.get('/api/test' + i))
}
}

作者:大码猴
来源:juejin.cn/post/7356534347509645375
收起阅读 »

别忘了前端是靠什么起家的😡😡😡

web
一、忘了最基础的东西 前端开发的核心构建在三大基石技术上:HTML、CSS和JavaScript。回想起多年前,前端开发者常被戏称为“切图仔”,但就是这样的角色,通过精湛的CSS技巧,能够实现各种复杂的交互和特效,展现出前所未有的网页魔法。这是那些专注于服务端...
继续阅读 »

一、忘了最基础的东西


前端开发的核心构建在三大基石技术上:HTML、CSS和JavaScript。回想起多年前,前端开发者常被戏称为“切图仔”,但就是这样的角色,通过精湛的CSS技巧,能够实现各种复杂的交互和特效,展现出前所未有的网页魔法。这是那些专注于服务端开发的工程师所难以企及的领域。因此,前端工程师这一职业逐渐崭露头角,早期的培训班甚至设立了专门的课程来传授这些技能。然而,随着时间的推移,UI组件库和框架变得越来越普及,HTML和JavaScript的重要性依旧被人们所认可,但CSS技能却逐渐被边缘化,甚至有所忽视。在一次代码走查中,发现一个拥有三四年前端开发经验的同事,连CSS最基本的类型选择器都掌握不熟练。这一现象令人感到忧虑。


二、令人无语的代码


在一次对 useState 的使用场景进行治理的过程中。发现了一段感觉很无语的代码。代码我简化一下如下所示:


import React, { useState } from 'react';
import { Input } from 'antd';
import type { FC } from 'react';
import styles from './index.less';

const Test: FC = () => {
const [isFocus, setIsFocus] = useState(false);

return (
<Input
className={isFocus ? styles['input-focus'] : styles.input}
onFocus={() =>
{
setIsFocus(true);
}}
onBlur={() => {
setIsFocus(false);
}}
/>

);
};

export default Test;

.input-focus{
background: #f2f3f;
}

三、询问缘由


这段代码的目的是根据输入框的焦点状态(聚焦或失去焦点)来改变其样式,逻辑上没有问题。


我找到编写这段代码的同事询问:“为什么需要定义一个isFocus状态呢?”


他看了代码良久,有些疑惑地解释说:“这是为了追踪输入框的聚焦状态,从而在聚焦时改变背景色。”


“这个状态还有其他用途吗?”我追问。


“没有,就这个作用。有问题吗?”他回答。


我继续探询:“不使用isFocus状态,我们还能达到同样的效果吗?”


他思考了一会儿:“如果不添加类名来标识输入框的聚焦状态,我们怎么区分呢?”


我提出了另一种方案:“我们能不能仅用CSS来实现这个效果?”


他迟疑了一下:“但是CSS怎么能识别输入框是否聚焦呢?”


我提醒他:“你有没有试过使用伪类选择器?”


“伪类?我通常只用类选择器。”他回答。


我解释道:“我们可以使用:focus伪类来实现这个效果。你可以先回去继续你的工作。”


四、审查他另外的代码


我继续审查了这位同事的其他代码,发现他对CSS的理解似乎并不深入。例如,为了实现列表的斑马纹效果,理应直接使用:nth-child(odd):nth-child(even)选择器,但他却通过在遍历过程中判断索引是奇数还是偶数来分别添加不同的类选择器实现这一效果。此外,他同时使用了float: leftposition: absolute,这在布局中是矛盾的组合。他还通过JavaScript动态添加类选择器来改变输入框提示文字的字体颜色,还一直重复定义colorfont-size而不懂这些可以继承。


我不确定这是否反映了他的态度问题或是能力问题,在现在只出不进,内部消化的环境下,我默默地记录下这些,以便将来作为评估的参考。


五、关键是理解而不是记忆


也许会有人觉得我要求的太苛刻,也许这位同事只是忘记了有这几个CSS选择器。的确,CSS选择器的种类众多,达到60多种,可能会让人难以记住每一个。然而,重点并不在于能否一一背诵每个选择器,而在于理解它们各自的功能和使用场景。这样,当面对特定的样式需求时,我们可以轻松地查找并应用最合适的选择器来实现目标效果。


最基本的元素选择器、类选择器、和ID选择器因其简洁直观而被频繁使用。但是,深入探索那些不那么显眼的选择器——如通配符选择器、组合选择器、属性选择器、伪类选择器、和伪元素选择器——同样至关重要。这些选择器赋予了我们更精细的控制权,使得我们能够创造出更加复杂和细腻的视觉效果。


总之,我们不必强迫自己记住所有CSS选择器。更为重要的是认识到CSS选择器的多样性和强大之处。这种认识使我们能够在遇到具体的样式挑战时,知道如何寻找解决方案,从而更高效地运用CSS优化我们的代码。


为了真正理解这些选择器,我们需要思考它们被设计出来的原因——它们是如何帮助我们更好地控制样式,应对各种布局和视觉挑战的。这种深入的理解方式,远比简单的记忆更为重要和有效。


六、为啥需要伪类选择器


伪类选择器在CSS中的存在有着重要的意义和作用。它们提供了一种方式来选择HTML文档中无法通过简单选择器(如元素选择器、类选择器或ID选择器)直接选择的元素。伪类选择器的设计初衷和主要用途包括以下几点:


1、表达元素的特定状态


伪类选择器允许开发者根据用户与页面的交互来改变元素的样式,而不需要改变HTML代码。例如,:hover伪类可以用来改变鼠标悬停在链接或按钮上时的样式,:focus伪类用于当元素获得焦点时(比如输入框被点击时),而:active伪类则用于元素被激活(通常是被点击)的瞬间。这些都是基于用户行为的动态变化,通过CSS直接实现,无需JavaScript介入,提高了网页的交互性和用户体验。


2、选择特定位置的元素


伪类选择器还可以用来选择处于特定位置的元素,例如第一个子元素、最后一个子元素或者是父元素的唯一子元素。这对于设计复杂的布局和样式非常有用,尤其是在处理列表、表格和导航菜单时。例如,:first-child:last-child:nth-child()等伪类选择器,它们提供了一种灵活的方式来选择和样式化这些特定位置的元素。


3、选择特定属性的元素


虽然属性选择器(如[attribute=value])可以用来基于元素的属性选择元素,但某些伪类选择器(如:checked)提供了更为简便的方式来选择具有特定属性的元素。例如,:checked伪类选择器可以选择所有选中的复选框和单选按钮,这对于创建自定义表单控件的样式非常有用。


4、增强可访问性


伪类选择器还可以增强网页的可访问性。例如,:focus伪类可以用来为获得焦点的元素定义明显的样式,这对于键盘导航用户来说非常重要。通过提供视觉反馈,用户可以更容易地识别当前交互的元素,从而提高网站的可访问性。


5、无需额外的HTML标记


使用伪类选择器,开发者可以在不增加额外HTML标记的情况下,实现复杂的样式和布局。这有助于保持HTML代码的简洁和语义化,同时还可以减少页面的大小和提高加载速度。


总之,伪类选择器为CSS提供了强大的功能,使得开发者能够以更细致和动态的方式控制网页的样式。它们是现代网页设计中不可或缺的工具,使得网页能够响应用户的交互,同时保持代码的整洁和高效。


七、为啥需要伪元素选择器


伪元素选择器在CSS中的引入,为网页设计和内容表现提供了更加丰富和灵活的手段。伪元素选择器允许开发者访问并样式化一个元素的特定部分,或者在文档树中虚拟地创建新的元素,而这些通常不能通过HTML直接实现。伪元素选择器的存在有几个重要的原因和用途:


1、访问和样式化文档的特定部分


伪元素选择器使得开发者能够访问并样式化元素的特定部分,比如第一行文本、第一个字母、或者元素之前和之后的内容。例如,::first-line::first-letter 伪元素分别允许开发者为元素的第一行文本和第一个字母设置特定的样式。这在打造具有吸引力的排版和阅读体验时非常有用。


2、在不改变HTML结构的情况下添加内容


通过使用 ::before::after 伪元素,开发者可以在元素的内容之前或之后插入新的内容或装饰,而不需要修改HTML代码。这种方法非常适合添加图标、装饰性元素或者是为元素添加特殊的前缀或后缀,同时保持HTML的清晰和语义化。


3、创建视觉效果


伪元素选择器也常被用于创建特殊的视觉效果,比如自定义的清除浮动方法(使用 ::after 清除浮动),或者是设计复杂的背景装饰和形状。这些都可以通过伪元素以及结合CSS的其他特性(如backgroundborderbox-shadow等)来实现。


4、提高网页性能


使用伪元素可以在不增加额外HTML元素的情况下实现复杂的设计,这有助于减少DOM的大小,从而提高网页的性能。通过减少页面加载时需要解析的HTML标签数量,可以加快页面的渲染速度。


5、保持HTML的语义化


通过使用伪元素来添加装饰性内容或样式,开发者可以避免在HTML中添加非语义化的标记。这有助于保持HTML文档的清晰和语义化,使得文档的结构更加明确,也更容易被搜索引擎优化(SEO)和屏幕阅读器理解。


总之,伪元素选择器为CSS提供了强大的功能,使得开发者能够以更细致和动态的方式控制网页的样式和内容。它们是现代网页设计中不可或缺的工具,允许开发者在不牺牲HTML语义化的前提下,实现复杂和创新的设计。


八、为啥需要属性选择器


属性选择器在CSS中的引入提供了一种强大的方式来根据元素的属性及其值来选择元素,从而应用特定的样式。这种选择器的存在和使用有几个关键的原因和优势:


1、精确选择和样式化元素


在复杂的网页设计中,开发者可能需要对具有特定属性或属性值的元素应用样式,而不是仅基于元素类型、类或ID。属性选择器使得这种精确选择成为可能。例如,可以选择所有设置了target="_blank"属性的<a>标签,并为它们应用特定的样式,以提示用户这些链接将在新窗口中打开。


2、提高CSS规则的灵活性


属性选择器增加了CSS规则的灵活性,允许开发者基于元素的属性和属性值来创建复杂的选择条件。这意味着开发者可以在不修改HTML结构的情况下,通过CSS实现更多的设计需求和响应式布局。


3、增强样式的可维护性


使用属性选择器,开发者可以避免在HTML中过度使用类或ID,从而简化HTML结构并提高样式的可维护性。当需要基于相同属性的元素应用统一的样式时,只需在CSS中定义一次相应的属性选择器规则,而不是在HTML中为每个元素重复添加类或ID。


4、促进更好的语义化和可访问性


属性选择器可以用来增强文档的语义化和可访问性。例如,通过选择具有特定role属性的元素并为它们应用样式,开发者可以帮助提高网页对于屏幕阅读器等辅助技术的可访问性。


5、实现条件样式


在某些情况下,开发者可能希望仅在元素具有特定属性或属性值时才应用样式。属性选择器使得这种条件样式化成为可能,无需额外的类或ID,也无需使用JavaScript。这种方式非常适合实现基于特定数据属性(data-*属性)的样式变化。


示例


假设我们想为所有含有特定属性data-tooltip的元素添加一个工具提示样式,我们可以使用如下CSS规则:


[data-tooltip] {
position: relative;
cursor: pointer;
}

[data-tooltip]:before {
content: attr(data-tooltip);
/* 更多的样式规则来定义工具提示的外观 */
}

这个示例展示了如何仅通过CSS和HTML属性来实现一个简单的工具提示功能,无需修改HTML结构或使用JavaScript。


总之,属性选择器为CSS提供了更多的选择和样式化能力,增加了样式表的灵活性和可维护性,同时促进了更好的文档结构和语义化。


九、为啥需要组合选择器


组合选择器在CSS中扮演着至关重要的角色,它们提供了一种强大的机制来选择具有特定关系的元素,从而允许开发者以更精细、更具体的方式应用样式。组合选择器的存在和使用主要基于以下几个原因:


1. 提高选择器的精确性


在复杂的网页布局中,仅使用简单选择器(如元素选择器、类选择器或ID选择器)往往难以精确地定位到特定的元素。组合选择器通过定义元素之间的关系(如父子关系、相邻关系等),使得开发者可以更精确地选择到目标元素。这种精确性对于实现特定的布局和样式效果至关重要。


2. 优化CSS的结构


使用组合选择器,可以避免在HTML中过度使用类或ID来达到样式目的,从而使得CSS的结构更加清晰和简洁。这种方法有助于提高代码的可维护性和可读性,同时减少了因重复定义样式而导致的冗余。


3. 实现更复杂的样式设计


组合选择器提供了一种方式来实现基于特定元素关系的复杂样式设计。例如,开发者可以使用子选择器(>)来仅为特定父元素的直接子元素应用样式,或使用相邻兄弟选择器(+)来为紧跟在特定元素后的兄弟元素应用样式。这种灵活性使得开发者能够创造出更加动态和富有层次感的页面布局和视觉效果。


4. 提升样式的可复用性


通过使用组合选择器,开发者可以为特定的元素关系定义样式,而不是针对特定的类或ID。这种做法增加了样式的可复用性,因为相同的组合选择器样式可以在不同的HTML结构中被复用,只要这些结构符合选择器定义的元素关系。


5. 保持HTML的语义化


组合选择器的使用有助于保持HTML代码的语义化,因为它们允许开发者基于元素之间的自然关系来应用样式,而不是强迫添加额外的类或ID。这样不仅使得HTML结构更加清晰,也有助于搜索引擎优化(SEO)和提高网站的可访问性。


示例


假设我们想为一个列表中的第一个项目添加特殊样式,我们可以使用子选择器和伪类选择器的组合来实现这一点:


ul > li:first-child {
color: red;
}

这个示例展示了如何使用组合选择器来精确选择并样式化特定的元素,而无需为该元素添加额外的类或ID。


总之,组合选择器是CSS中不可或缺的一部分,它们通过定义元素之间的关系增强了选择器的功能,使得开发者能够以更灵活、更高效的方式设计和实现网页样式。


作者:前端大骆
来源:juejin.cn/post/7357194991339143168
收起阅读 »

前端部署发布项目后,如何解决缓存的老版本文件问题

web
针对这个问题有两个思路 方式一:纯前端 每次打包发版时都使用webpack构建一个version.json文件,文件里的内容是一个随机的字符串(我用的是时间戳),每次打包都会自动更新这个文件。 项目中,通过监听点击事件来请求version.json文件。使用本...
继续阅读 »

针对这个问题有两个思路


方式一:纯前端


每次打包发版时都使用webpack构建一个version.json文件,文件里的内容是一个随机的字符串(我用的是时间戳),每次打包都会自动更新这个文件。


项目中,通过监听点击事件来请求version.json文件。使用本地缓存将上一次生成的字符串存储起来,和本次请求过来的字符串进行对比;若字符串不一样,则说明有项目有新内容更新,提供用户刷新或清除缓存(我使用的)


方式二:前后端配合


在每个请求头加上发版的版本号,和保留在客户端的上一次版本号进行对比,如果不一致则强制刷新,刷新后保存当前版本号


实现:


1、webpack构建生成一个json文件,在项目目录下新建一个plugins的文件夹,新建version-webpack-plugin.js文件


webpack4****等高版本构建方式


/** Customized plug-in: Generate version number json file */const fs = require("fs");class VersionPlugin {  apply(compiler) {    // emit is an asynchronous hook, use tapAsync to touch it, you can also use tapPromise/tap (synchronous)    compiler.hooks.emit.tap("Version Plugin", (compilation) => {      const outputPath = compiler.path || compilation.options.output.path;      const versionFile = outputPath + "/version.json";      const timestamp = Date.now(); // timestamp as version number      const content = `{"version": "${timestamp}"}`;      /** Returns true if the path exists, false otherwise */      if (!fs.existsSync(outputPath)) {        // Create directories synchronously. Returns undefined or the path to the first directory created if recursive is true. This is the synchronous version of fs.mkdir().        fs.mkdirSync(outputPath, { recursive: true });      }      // Generate json file      fs.writeFileSync(versionFile, content, {        encoding: "utf8",        flag: "w",      });    });  }}module.exports = { VersionPlugin };

webpack3


低版本构建方式


/** Customized plug-in: Generate version number json file */const fs = require('fs')class VersionPlugin {  apply(compiler) {    compiler.plugin('done', function () {      // Copy the logic of the file, and the file has been compiled.      const outputPath = compiler.outputPath      const versionFile = outputPath + '/version.json'      const timestamp = Date.now() // 时间戳作为版本号      const content = `{"version": "${timestamp}"}`      /** Returns true if the path exists, false otherwise. */      if (!fs.existsSync(outputPath)) {        // Create directories synchronously. Returns undefined or the path to the first directory created if recursive is true. This is the synchronous version of fs.mkdir().        fs.mkdirSync(outputPath, { recursive: true })      }      // Generate json file      fs.writeFileSync(versionFile, content, {        encoding: 'utf8',        flag: 'w'      })    })  }}module.exports = { VersionPlugin }

2、在vue.config.js中使用这个plugin


const { VersionPlugin } = require('./src/plugin/version-webpack-plugin')

config.plugins.push(new VersionPlugin())


3、在每次执行webpack构建命令,都会在dist目录下生成一个version.json文件,里面有一个字段叫version,值是构建时的时间戳,每次构建都会生成一个新的时间戳。




4、发起ajax请求,请求version.json文件获取version时间戳,和本地保存的上一次的时间戳做比较,如果不一样,则进行对应的操作。/business/version.json,business是我项目的前缀,改成你自己的项目地址,能请求到version.json文件就行。


import axios from 'axios'import i18n from '@/i18n'import UpdateMessage from '@/components/common/UpdateProject/index.js'export function reloadVersion() {  axios.get(window.location.origin + '/mobile/version.json?v=' + Date.now()).then(rsp => {    let mobileVersion = localStorage.getItem('mobileVersion')    let onlineVersion = rsp.data.version    if (!mobileVersion) {      localStorage.setItem('mobileVersion', onlineVersion)      return    }    if (onlineVersion) {      if (mobileVersion !== onlineVersion) {        UpdateMessage.success({          title: i18n.t('bulk.pleaseWait'),          msg: i18n.t('common.updateRemind')        })        setTimeout(() => {          UpdateMessage.close()          localStorage.setItem('mobileVersion', onlineVersion)          window.location.reload();        }, 2000);      }    }  })}

5、请求发起的时机,可以使用定时器或者在切换页面的时候进行校验版本。根据自己的实际情况选择合适的调用时机。


async mounted() {  process.env.NODE_ENV !== 'development' && window.addEventListener('mousedown', this.handleonmousedown);},beforeDestroy() {  window.removeEventListener('mousedown', this.handleonmousedown)},

handleonmousedown() { reloadVersion()}

作者:jskai
来源:juejin.cn/post/7356049143955390518
收起阅读 »

RecyclerView+多ItemType实现两级评论页面

多ItemType实现多级评论页面 前言 我的上一篇文章 Android简单的两级评论功能实现,得到了很多的评论(万万没想到),收获了jym们宝贵的建议和指导,在此特别感谢大家。 在上一篇文章中,对于‘两级评论功能’的实现,我采用的是Recycler嵌套的方法...
继续阅读 »

多ItemType实现多级评论页面


前言


我的上一篇文章 Android简单的两级评论功能实现,得到了很多的评论(万万没想到),收获了jym们宝贵的建议和指导,在此特别感谢大家。


在上一篇文章中,对于‘两级评论功能’的实现,我采用的是Recycler嵌套的方法,这种方法的实现不难,但是非常的麻烦,有很多不必要的操作,扩展性很差,维护起来也是十分复杂。虽然最后实现效果还可以,但是有更好更方便的方法,何乐而不为呢。所以这篇文章的内容就是对多ItemType实现评论功能的过程阐述,还有两种实现方式的区别和性能差异。


:文章要参加更文活动,只会粘贴关键的代码。如需详细代码,请私信。


一、适配器


重复的部分就不说了,数据库和布局部分基本和上一篇是一致的,只是把item布局中的RecyclerView和对应的适配器及相关代码去掉了。


1、创建两个ViewHolder


分别是TestOneViewHolder和TestTwoViewHolder,这里不贴代码只展示布局了


一级评论的布局:


image.png


二级评论的布局:


image.png


2、设置两个ItemType


LEVEL_ONE_VIEW一级评论的ViewType,LEVEL_TWO_VIEW二级评论的ViewType


private val LEVEL_ONE_VIEW = 0 // 一级布局的的ViewType
private val LEVEL_TWO_VIEW = 1 // 二级布局的的ViewType

3、方法重写



  • getItemViewType方法中返回ViewType


override fun getItemViewType(position: Int): Int {
val commentInfo = list.toList()[position].first
return if (commentInfo.level == 1) {
LEVEL_ONE_VIEW
} else {
LEVEL_TWO_VIEW
}
}


这里list的类型是‘Map<CommentInfo, User>’,是因为还需要User的数据,所以映射来的。
获取到评论信息后对level进行判断,返回相应的ViewType。




  • onCreateViewHolder中根据ViewType进行判断,根据TYPE返回相应的ViewHolder


override fun onCreateViewHolder(parent: ViewGr0up, viewType: Int): RecyclerView.ViewHolder {
return if (viewType == LEVEL_ONE_VIEW) {
TestOneViewHolder(parent)
} else {
TestTwoViewHolder(parent)
}
}


  • onBindViewHolder通过getItemViewType(position)来获取当前的ViewType,再进行数据绑定。如图:


image.png

4、数据绑定


在ViewHolder中将传入的数据对布局进行赋值就好了,最后实现的效果如下图。为了能够更加直观的看出一级评论与二级评论之间的关联,图片中的评论内容用数字进行标识。


微信图片_2.jpg

可以看到在设置完多ItemType后,显示的布局符合我们的预期了,可是一级评论和二级评论之间毫无关联,各过各的,那如何将评论布局展示出绑定的效果呢?主要还是对数据进行处理啦,如何处理呢,请看下一节。



二、绑定


这个绑定指的是将与一级评论相关联的二级评论和该一级评论展示在一起,有一种类似的绑定效果。大致思路如下:



  1. 获取该文章的所有评论

  2. 分别获取到level为1、2的评论列表

  3. 将level为2的列表按照回复评论的Id进行分组

  4. 创建空列表

  5. 遍历level为1的列表,获取到相应的level2的列表并依次添加进空列表


实现代码如下:


// 获取该文章的所有评论
val comments = commentStoreRepository.getCommentsByNewId(newsId)
// 获取level为1、2的评论、按时间进行排序
val level1 = comments.filter { it.level == 1 }.sortedBy { it.time }
val level2 = comments.filter { it.level == 2 }.sortedBy { it.time }
// 将level为2的列表按照回复评论的Id进行分组
val level2Gr0up = level2.groupBy { it.replyId }
// 创建空列表
val list = mutableListOf<CommentInfo>()
// 遍历level1的列表 获取到对应的level2列表 依次添加进空列表中
level1.forEach { level1Info ->
val newLevel2Gr0up = level2Gr0up[level1Info.id]
list.add(level1Info)
if (newLevel2Gr0up != null) {
list.addAll(newLevel2Gr0up)
}
}


这个空列表,即list就是我们需要的能展示强绑定关系的列表啦



最终呈现的效果如下图


微信图片_1.jpg

这样,一个多ItemType的二级评论展示就实现啦!!!


三、两种实现方式比对



  1. 实现1 - 嵌套RecyclerView的实现

  2. 实现2 - 多ItemView的实现


1、复杂程度:


主观方面来说,



  • 实现1 -- 首先是在数据及布局的处理方面,会显得非常杂乱。我在非常了解其数据结构的情况下,很多时候也摸不着头脑,而且代码不方便管理。再就是扩展性,如果在这个实现的基础上进行扩展会非常的复杂,想着要是做个更多层级的评论那得多麻烦。优点就是能够对二级评论进行单独的管理。

  • 实现2 -- 单独对布局进行管理,很方便,复杂程度低,扩展性也更好,用来做个多级评论不成问题。缺点:我想实现一个评论下的二级评论最多展示两条,可以展开,还可以显示回复条数的功能不知道怎么做,实现一因为可以对二级数据统一管理就会比较好实现。这一点,如果有大佬知道如何解决,请在评论下激情发表你的言论。


060c26572c4bf3f54107bd8b1d0e713.jpg


2、性能方面:


分别插入100100010000条数据,记录消耗时间。如图所示,统计的次数较少,但也可以看出二者在性能方面的差异不大。


结论两种实现性能差异较小。


image.png

四、结语


以上,就是多个ItemType实现二级评论的过程和结果以及两种实现方式的主观对比。文章若出现错误,欢迎各位批评指正,写文不易,转载请注明出处谢谢。


作者:遨游在代码海洋的鱼
来源:juejin.cn/post/7273685263841853496
收起阅读 »

24清明节咋过的?

头一天在朋友家呆了一天,下午又去武汉最负盛名的公园溜达了一会。 晚上回家,打算出去骑车,一直没想到目的地,想去大山里,但是总感觉自己的行李不顺心,帐篷太长,也没有炊具,没有视频里up主们那种风餐露宿,悠然自得的感觉。 这估计就是典型的i人了。只要有一点不顺遂...
继续阅读 »

头一天在朋友家呆了一天,下午又去武汉最负盛名的公园溜达了一会。


东湖.jpg
晚上回家,打算出去骑车,一直没想到目的地,想去大山里,但是总感觉自己的行李不顺心,帐篷太长,也没有炊具,没有视频里up主们那种风餐露宿,悠然自得的感觉。


这估计就是典型的i人了。只要有一点不顺遂,就会变的犹犹豫豫,然后大概率放弃。


然后我就放弃了原本打算去大山里待两天的计划。当然,我还是朝着大山所在的方向出发了。


打开地图,切换到地形模式,就可以看到藏在平面图里的山川河流了,我发现了一条沿着长江边伸展出去的小路,于是把这条路当做了我的目的地。


应该的假期的缘故,早上出发的时候难得的没有遇到堵车,很多路我都没有开导航,全凭感觉在走。有时候看到有趣的小路我还会直接拐进去。


有一回我拐到了一个小山下面,上山的小路就在眼前,但是因为前几天下雨的缘故,路上还有积水和泥泞的车辙印,休闲骑不越野,遂放弃上山。


后来又经过一个小镇,就跟着前面一辆小电动车一起穿街走巷,最后到路的尽头,发现正是长江,而我也终于拐到了那条沿江的小路上。这条路同时是长江的防洪堤,路边每隔几百米就有一块牌子,提醒过路的人们防洪的重要性。


我沿着江堤一直走啊走,左边是缓慢流淌的长江,右边是无边无际的田野。往左边看,是孤帆远影碧空尽,往右边看又是莽莽沃野,风吹草低。


微信图片_20240409134731.jpg
骑了大约半个小时左右,感觉有点尿急了,可是在江堤上,找个厕所是不可能了,但是也不能随地就解决啊,路上时常还有车辆经过。


又骑了一会看到了一个破房子,就在路边不远处,掩在杂草丛中,心想这里算是比较隐蔽的了,下车走过去。可是越靠近那破房子就越觉得恐惧,脑子里开始蹦出来一些恐怖片的情景。其实我是不怕鬼怪的,咱是一个坚定的唯物主义者,当然我也不怕活人。但是我怕尸体,动物的就算了,这万一。


这还要从大学那会说起,学校后面有座小山,山上有几座破房子,那时候大家经常爬山去上面看日落日出,我也去过几次,对那个房子也算记忆深刻。后来有一天学校突然开始统计人数,一个一个的查,态度之庄重坚决,行动之迅速果断,前所未见。最后才得知原来有学生在后山的破房子里发现有人上吊了,这事给我留下了极大的阴影。以至于后来我都不敢去那座山上了。


言归正传,我还是壮着胆子靠近了这座破房子,这是一座典型的南方小楼,好像是两层,门洞很窄,门窗都没有了,一楼很干净,有一条狭窄的楼梯可以通到二楼。我也就到此为止了。当然我也不敢在这上厕所了。


又骑了一会,油灯开始闪烁了,上周加了50块钱的油,当时就跑了大约180公里,今天又跑了大约60公里,一合计50块钱的油跑了240公里,心里又快乐了,比之前50块钱跑200公里的时候还快乐。经过半年的磨合,我和车子终于要人车合一了。


打开地图搜索了一个最近的加油站,十几公里,又加了50块钱的油。冬天的时候一个月就加一次油,春天了基本两周就要加一次,这么一想我又不快乐了。


加完油已经到饭点了,果然人车合一了,要饿一起饿。在小镇上点了一份炒菜,一个人吃就点了一份鱼香肉丝,分量还挺足的,竹笋很脆嫩,现在是吃春笋的时节。


微信图片_20240409134748.jpg
吃完饭,又开始漫无目的的骑车,没有开导航,只是这次长江在我的左手边,因为小镇所在的位置是长江转弯处突出的一块洼地上,所以我以为我正在沿小镇绕圈,也可能是一路上的风景同质化太严重了,以至于我一直没发现正在走回头路。


然后就在我又一次尿急,开始搜寻合适的放水地的时候,我又看到了那座老房子。


作者:渡人先渡己
来源:juejin.cn/post/7355433339547664403
收起阅读 »

怎么下载加密ts流的视频

web
以某网站如下的电影《2012》为例。 在这个网站上面,电影2012是以一系列几秒的ts格式来播放的,所以没办法直接复制视频地址来下载整部电影。看如下截图: 并且,每段ts还是加密的,单独下载ts文件是无法播放的,需要解密,如下图: 那要怎样才能下载完整的解...
继续阅读 »

以某网站如下的电影《2012》为例。


在这个网站上面,电影2012是以一系列几秒的ts格式来播放的,所以没办法直接复制视频地址来下载整部电影。看如下截图:


image.png


并且,每段ts还是加密的,单独下载ts文件是无法播放的,需要解密,如下图:


image.png


那要怎样才能下载完整的解密后的视频呢?下面分几步进行说明。


1、首先,获取该电影所有的ts列表,和加密方式及密钥:


要用chrome浏览器打开该网址,然后右击,点击检查,然后重新刷新页面,然后根据如下截图查看:


image.png


点击“index.m3u8”这个请求,然后根据如下截图:
image.png


能够得出该电影的所有ts列表,并且加密方式是“AES-128”,密钥是enc.key的请求中,iv是16字节长度的0 。 现查看enc.key请求如下:
image.png


发现是乱码(有些网站不是乱码,而是字符串)。乱码是因为该密钥是二进制的,需要用查看hex工具来获取16进制的密钥。


先下载该“enc.key”到本地,然后用hex工具查看16进制值。mac系统可以用如下查看:


image.png


可以得出该密钥的16进制为:7be5d74d56af87838c3b98f1a2febf8f


2、根据ts列表,用php来实现多进程快速下载


下载所有ts文件有很多方法,可以手动一个个下载,但是因为太多,所以这个方法会比较麻烦。可以用php脚本来快速下载。


创建个1.php文件,用来下载ts文件。写入如下内容:


<?php

function my_file_get_contents($url) {
$arrContextOptions = [
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
]
];
return file_get_contents($url, false, stream_context_create($arrContextOptions));
}

for ($i=$argv[1]; $i <= $argv[2]; $i++) {
echo $i.'...'.PHP_EOL;
$f = $i.'.ts';
if (file_exists($f)) {
continue;
}
// 下面的链接要改成“index.m3u8”这里面相对应的ts链接
$data = my_file_get_contents('https://hnts.ymuuy.com:65/hls/200/20240110/2077/plist'.$i.'.ts');
file_put_contents($f, $data);
}

然后,再创建个2.php文件,用来创建下载命令。写入如下内容:


<?php

// 882要改成改成“index.m3u8”这里面最大数字的ts链接后的数字
for ($i=1; $i<=882; $i+=20) {
$tmp = $i+20;
if ($tmp > 882) {
$tmp = 882;
}
echo 'php 1.php '.$i.' '.$tmp.' &'.PHP_EOL;
}

然后,运行如下命令:
image.png


生成了可以多进程下载ts文件的命令行,然后复制生成的命令,在终端运行如下:
image.png


可以看到,已经在快速下载了,分为了882/20=44个进程来同时快速下载。


可以用如下命令来查看下载进度:


while true
do
du -sh `pwd`; ls |wc -l;sleep 1;
done

显示如下:


image.png


会显示出当前下载的大小,和下载的总ts数。


注意,全部都下载完后,要查看下有没有大小为0的ts文件,这些是下载失败的文件,删除后,重新运行下下载命令即可。


3、所有文件都下载完后,要开始解密并合并了


同样也是用php脚本来解密,保存下面脚本为decrypt.php:


<?php

// 如果“enc.key”的密钥是二进制的话,就用下面这行
$key = hex2bin("7be5d74d56af87838c3b98f1a2febf8f");
// 如果“enc.key”的密钥是字符串的话,就用下面这行
// $key = 'Cibz2Dp3bCnzlmVx';

// 原样复制“index.m3u8”里面的IV的0x后面的部分
$iv = hex2bin("00000000000000000000000000000000");

$decrypted_file = 'output.ts'; // 最终要保存的文件

// 882改为ts总数
for ($i=1; $i<=882; $i++) {
echo $i,'...',PHP_EOL;
$encrypted_file = $i.'.ts';
$data = file_get_contents($encrypted_file);
$decrypted_data = openssl_decrypt($data, 'AES-128-CBC', $key, OPENSSL_RAW_DATA, $iv);
file_put_contents($decrypted_file, $decrypted_data, FILE_APPEND);
}

echo "解密成功,已保存为:".$decrypted_file;


运行如下命令:


image.png
image.png


这样,就成功的解密并合并为了output.ts文件,用支持ts的播放器就可以播放此电影了。


有问题这边留言探讨下~


作者:leptune
来源:juejin.cn/post/7356143704699519003
收起阅读 »

JAVA 一个简单查重的实现

JAVA 一个简单查重的实现 1. 前言 最近在做一个教育网站时,有一个考试的模块,其中学生编写的文章需要有一个查重的功能。到网上找了下,感觉这方面的资料还是比较少,大部分都需要收费,由于公司家境贫寒(不愿意花钱),且需求不是特别难,只需要一个建议版本的功能。...
继续阅读 »

JAVA 一个简单查重的实现


1. 前言


最近在做一个教育网站时,有一个考试的模块,其中学生编写的文章需要有一个查重的功能。到网上找了下,感觉这方面的资料还是比较少,大部分都需要收费,由于公司家境贫寒(不愿意花钱),且需求不是特别难,只需要一个建议版本的功能。于是只能亲自动手做一个 simple 版本的。


2. 实现思路


思路的话比较简单,想法是利用双指针的模式,找出两个文本中相似的文本。不过这样算法复杂度是 O(n2) ,不过由于我们是小网站,文章本来也不多。


image.png
image.png


核心就是有一个双层的循环做遍历,然后判断最小字符串是否相同,如果不相同,则指针B递增直到找到相同的文本


image.png


如果指针位置找到了相同文本,则增加最小字符串长度,直到找到最大的匹配的文本。


3. 代码实现



public static class SameResult {
// 存储相似文本关键词
private String keyword;
// 存储与关键词详细信息
private String detail;

public String getKeyword() {
return keyword;
}

public void setKeyword(String keyword) {
this.keyword = keyword;
}

public String getDetail() {
return detail;
}

public void setDetail(String detail) {
this.detail = detail;
}
}

/**
* 获取两个字符串中的相似文本片段
* @param a 文本a
* @param b 文本b
* @param minSize 最小相似字符数
* @return 返回相似文本片段的列表
*/

public static List<SameResult> getSameTextList(String a, String b, Integer minSize) {

List<SameResult> result = new ArrayList<>();
Map<String, String> stash = new HashMap<>();
if (a == null || b == null) {
return result;
}
if (a.length() < minSize || b.length() < minSize) {
return result;
}
int i = 0;
while (i <= a.length() - minSize) {
// 初始化窗口大小为最小相似字符数
int nowWindowSize = minSize;
// 遍历文本b,寻找与文本a当前片段相似的片段
int j = 0;
String nowMate = null; // 存储当前相似片段
String nowDetail = null; // 存储当前相似片段的详细信息
SameResult sameResult = new SameResult();
Boolean isMate = false; // 标记是否找到相似片段
while (j <= b.length() - minSize) {
// 如果文本a和文本b的当前片段相等
if (a.substring(i, nowWindowSize + i).equals(b.substring(j, nowWindowSize + j))) {
// 记录相似片段
nowMate = a.substring(i, nowWindowSize + i);
// 记录详细信息, 这里的5表示详细信息取前五个和后五个字符
nowDetail = b.substring(Math.max(j - 5, 0), Math.min(nowWindowSize + j + 5, b.length()));
sameResult.setKeyword(nowMate);
sameResult.setDetail(nowDetail);
// 设置找到相似片段的标记
isMate = true;
// 增加窗口大小
nowWindowSize++;
// 继续在文本b中寻找更长的相似片段
while (j <= b.length() - nowWindowSize) {
String ma1 = a.substring(i, nowWindowSize + i);
String ma2 = b.substring(j, nowWindowSize + j);
// 如果找到更长的相似片段
if (ma1.equals(ma2)) {
nowMate = a.substring(i, nowWindowSize + i);
nowDetail = b.substring(Math.max(j - 5, 0), Math.min(nowWindowSize + j + 5, b.length()));
sameResult.setKeyword(nowMate);
sameResult.setDetail(nowDetail);
nowWindowSize++;
} else {
// 如果不再相似,退出循环
break;
}
}
// 找到相似片段后,退出内部循环
break;
} else {
// 如果不相似,继续在文本b中寻找
j++;
}
}
// 如果找到相似片段,将其存储到映射中
if (isMate) {
// 移动文本a的索引
i += nowWindowSize - 1;
stash.put(sameResult.getKeyword(), sameResult.getDetail());
} else {
// 如果没有找到相似片段,移动文本a的索引
i++;
}
}
for (String key : stash.keySet()) {
SameResult sameResult = new SameResult();
sameResult.setKeyword(key);
sameResult.setDetail(stash.get(key));
result.add(sameResult);
}
return result;
}

public static void main(String[] args) {
// 调用getSameTextList方法,并打印结果
System.out.println(getSameTextList("test1", "test2", 10));
}

代码总体比较简单,就是获取到所有最小长度文本长度的所有相似文本,并放到一个 List 中,以便后续的业务处理。


最后可以整理为一个类似下面的表格


原文相似内容
脸哭声更为响亮。我问他是谁的悲他把他脸哭声更为响亮。我问他是谁使的打成这
情往往只是作为情的友爱和险情往往只是作为情可来及,正
着茂盛树叶的树下节了一棵已着茂盛树叶的树下,走的女棉花
再说我爹年轻时也我端人的子。再说我爹年轻时也好些一手,

4. 结尾


一般会用到查重的业务场景可能并不多,大部分都是学校、政府等才需要进行查重,本文算是抛砖引玉吧,只是为需要做查重内容展示时为大家提供一点点思路。


作者:码头的薯条
来源:juejin.cn/post/7355347789677035571
收起阅读 »

Android 图片裁剪

前言   图片裁剪是对图片进行区域选定,然后裁剪选定的区域,形成一个图片,然后再对这个图片进行压缩,最终返回结果图片。运行效果图 正文   从上面的描述来看貌似是挺简单的是吧,不过实际操作起来就没有那么简单了,下面先来看看简单的实现方式,就是Android自...
继续阅读 »

前言


  图片裁剪是对图片进行区域选定,然后裁剪选定的区域,形成一个图片,然后再对这个图片进行压缩,最终返回结果图片。运行效果图


在这里插入图片描述


正文


  从上面的描述来看貌似是挺简单的是吧,不过实际操作起来就没有那么简单了,下面先来看看简单的实现方式,就是Android自带的裁剪。


一、创建并配置项目


  我们依然从创建项目开始讲起,这虽然有一些繁琐,但无疑可以让每一个Android开发者看懂。创建一个名为PictureCroppingDemo的项目。


创建好之后,在app的build.gradle添加如下代码,有两处


	//JDK版本
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}

	//  google权限管理框架
implementation 'pub.devrel:easypermissions:3.0.0'
//热门强大的图片加载器
implementation 'com.github.bumptech.glide:glide:4.11.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0'

添加位置如下图所示:


在这里插入图片描述


然后打开AndroidManifest.xml,在里面添加两个权限


	<!--读写外部存储-->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

这两个权限在Android6.0及以上版本属于危险权限,需要动态申请,下面来写权限申请的代码吧。


二、权限申请


  首先在MainActivity中重写这个onRequestPermissionsResult方法。这个方法属于Android原生的权限请求返回,下面来看它的具体内容:


	/**
* 权限请求结果
* @param requestCode 请求码
* @param permissions 请求权限
* @param grantResults 授权结果
*/

@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
// 将结果转发给 EasyPermissions
EasyPermissions.onRequestPermissionsResult(requestCode, permissions, grantResults, this);
}

EasyPermissions就是刚才在build.gradle中添加的依赖库,然后写一个权限请求的方法。


	@AfterPermissionGranted(9527)
private void requestPermission(){
String[] param = {Manifest.permission.READ_EXTERNAL_STORAGE,Manifest.permission.WRITE_EXTERNAL_STORAGE};
if(EasyPermissions.hasPermissions(this,param)){
//已有权限
showMsg("已获得权限");
}else {
//无权限 则进行权限请求
EasyPermissions.requestPermissions(this,"请求权限",9527,param);
}
}

  这个requestPermission()方法上面有一个注解,这个注解是什么意思嗯呢,就是权限通过后再调用一次这个方法。然后看方法里面做了什么,定义了一个字符串数组,里面有两个权限,都是在AndroidManifest.xml中配置过的,实际上这两个权限在一个权限组里面,一个权限组只有有一个权限通过则表示整组权限通过,因此你只需要放置一个权限就好了,我这么写是为了让你更清楚一些。然后是一个判断,通过这框架去判断当前的权限是否以获取,是则进行后续操作,我这里是弹一个Toast,方法也很简单。


	/**
* Toast提示
* @param msg 内容
*/

private void showMsg(String msg){
Toast.makeText(this,msg,Toast.LENGTH_SHORT).show();
}

如果没有权限则通过下面这行代码去请求权限


EasyPermissions.requestPermissions(this,"请求权限",9527,param);

  这里的9527其实是一个请求码,它需要与注解中的对应,只有这样它在权限授予之后才会再次调用这个方法做检测。更规范的写法是定于一个全局变量,然后替换这个9527,比如这样


	/**
* 外部存储权限请求码
*/

public static final int REQUEST_EXTERNAL_STORAGE_CODE = 9527;

然后修改对应的地方即可,如下图所示:


在这里插入图片描述


最终记得在onCreate中调用这个requestPermission()方法。下面运行一下:


在这里插入图片描述


三、获取图片Uri


在上面我们已经获取到了权限,下面就来获取这个图片的Uri,然后通过图片Uri显示这个图片。


首先修改布局activity_main.xml


<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">

<ImageView
android:id="@+id/iv_picture"
android:layout_width="match_parent"
android:layout_height="match_parent" />

<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:layout_marginBottom="24dp"
android:onClick="openAlbum"
android:text="打开相册" />

</RelativeLayout>

  很简单的布局,这里唯一要说的就是这个onClick="openAlbum",如果你的按钮不需要进行设置的话,单个按钮的点击事件这样写更简洁一些,你会看到这个地方有一条红线,这需要到Activity中去写这个方法,你可以通过快捷键去生成这个方法。鼠标点击这个划红线的地方,然后Alt + Enter,下面会弹出一个窗口,第二项就是说在MainActivity中创建openAlbum方法。这种方式在Fragment中并不是适用,请注意。


在这里插入图片描述


然后你就会在MainActivity中看到这样的方法,请注意一点,这个方法名与你onClick中的值必须要一致。


	/**
* 打开相册
*/

public void openAlbum(View view) {

}

下面来写打开相册的方法。这里同样的需要一个请求码,去打开相册,然后通过返回的结果去读取图片的uri,定义一个请求码


	/**
* 打开相册请求码
*/

private static final int OPEN_ALBUM_CODE = 100;

然后在修改openAlbum方法,代码如下:


	/**
* 打开相册
*/

public void openAlbum(View view) {
Intent intent = new Intent();
intent.setAction(Intent.ACTION_PICK);
intent.setType("image/*");
startActivityForResult(intent, OPEN_ALBUM_CODE);
}

注意这里使用了startActivityForResult,则需要获取返回值。重写onActivityResult方法。


	/**
* 返回Activity结果
*
* @param requestCode 请求码
* @param resultCode 结果码
* @param data 数据
*/

@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);

}

这里先获取相册中的图片显示到Activity中,刚才在activity_main.xml中的ImageView控件就派上用场了。


	//图片
private ImageView ivPicture;

然后在onCreate中绑定xml的id。下面你再使用这个ivPicture就不会报空对象了。


	ivPicture = findViewById(R.id.iv_picture);

然后回到onActivityResult方法,修改代码如下:


	@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == OPEN_ALBUM_CODE && resultCode == RESULT_OK) {
final Uri imageUri = Objects.requireNonNull(data).getData();
//显示图片
Glide.with(this).load(imageUri).int0(ivPicture);
}
}

这里加了一个判断用于检测是否为打开相册之后的返回与返回是否成功。RESULT_OK是Activity中自带的。


  然后在获取数据时判空处理一下再赋值给一个Uri变量,然后通过Glide框架加载这个Url显示在刚才的ivPicture上。代码写好了,下面运行一下:


在这里插入图片描述


嗯,图片显示出来了,图片的url也拿到了,下面该做这个图片的剪裁了。


四、图片裁剪


既然是调用Android系统的图片裁剪,那么自然也和打开系统相册差不多,依然是先创建一个请求码:


	/**
* 图片剪裁请求码
*/

public static final int PICTURE_CROPPING_CODE = 200;

然后写一个裁剪的方法。


	/**
* 图片剪裁
*
* @param uri 图片uri
*/

private void pictureCropping(Uri uri) {
// 调用系统中自带的图片剪裁
Intent intent = new Intent("com.android.camera.action.CROP");
intent.setDataAndType(uri, "image/*");
// 下面这个crop=true是设置在开启的Intent中设置显示的VIEW可裁剪
intent.putExtra("crop", "true");
// aspectX aspectY 是宽高的比例
intent.putExtra("aspectX", 1);
intent.putExtra("aspectY", 1);
// outputX outputY 是裁剪图片宽高
intent.putExtra("outputX", 150);
intent.putExtra("outputY", 150);
// 返回裁剪后的数据
intent.putExtra("return-data", true);
startActivityForResult(intent, PICTURE_CROPPING_CODE);
}

图片裁剪需要用到uri,再上面打开相册返回时就已经拿到了uri,那么下面修改onActivityResult方法。


	@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == OPEN_ALBUM_CODE && resultCode == RESULT_OK) {
//打开相册返回
final Uri imageUri = Objects.requireNonNull(data).getData();
//图片剪裁
pictureCropping(imageUri);
} else if (requestCode == PICTURE_CROPPING_CODE && resultCode == RESULT_OK) {
//图片剪裁返回
Bundle bundle = data.getExtras();
if (bundle != null) {
//在这里获得了剪裁后的Bitmap对象,可以用于上传
Bitmap image = bundle.getParcelable("data");
//设置到ImageView上
ivPicture.setImageBitmap(image);
}
}
}

  在打开相册返回之后调用pictureCropping方法,传入图片url,然后会启动系统剪裁,剪裁后通过返回数据数据设置到ImageVIew控件上。注意剪裁后就不再是uri了,而是Bitmap。运行一下:


在这里插入图片描述


  可以看到系统的剪裁并不是很彻底,gif中虽然演示的剪裁时是一个圆形,但实际上剪裁的是一个正方形的,这其实和Android系统版本及设置的参数有关系。我在荣耀8和荣耀20i上运行都是这样的,对应的版本是8.0和10.0,效果基本一致。那么下面修改一下参数试试看,如下图我修改了宽高比例和剪裁后的宽高。


在这里插入图片描述


再运行一下:


在这里插入图片描述


可以看到通过该参数真的就不一样了不是吗?


  但是有一些朋友想要圆形的剪裁,那么这里有一个问题你要弄清楚,你要真的还是假的,真的圆形,那么肯定是需要剪裁后重新生成的,而假的圆形就很好办了,首先我们改回刚才的参数,那么在我的是手机上就还是这样的圆形剪裁框,而我只要让他显示出来是一个圆形,你就会以为你是剪裁成功了,当然这都是忽悠用户的好办法,下面来实践一下。这个可以通过外力来解决,圆形图片很多方式能做到,比如第三方框架、自定义View等。


还记得刚才用过的Glide吗?创建requestOptions对象


	/**
* Glide请求图片选项配置
*/

private RequestOptions requestOptions = RequestOptions
.circleCropTransform()//圆形剪裁
.diskCacheStrategy(DiskCacheStrategy.NONE)//不做磁盘缓存
.skipMemoryCache(true);//不做内存缓存

然后在剪裁图片的返回中设置图片


	Glide.with(this).load(image).apply(requestOptions).int0(ivPicture);

在这里插入图片描述


运行一下:


在这里插入图片描述


五、源码


源码地址:PictureCroppingDemo


尾声


  OK,就到这里了。我是初学者-Study,山高水长,后会有期。
此项目并不一定适配所有机型和Android版本,要根据实际情况就改动才行。


作者:初学者_Study
来源:juejin.cn/post/7226894630880460859
收起阅读 »

RecyclerView 实现Item倒计时效果

前言 平时我们做倒计时有很多种方法实现,也相对比较简单。但是最近碰到一个需求,需要在RecyclerView的item中实现倒计时,一般这种场景在电商的业务上应该也会有。那么我们就不能像开始单个倒计时一样简单做了,还需要考虑到viewholder的复用、性能等...
继续阅读 »

前言


平时我们做倒计时有很多种方法实现,也相对比较简单。但是最近碰到一个需求,需要在RecyclerView的item中实现倒计时,一般这种场景在电商的业务上应该也会有。那么我们就不能像开始单个倒计时一样简单做了,还需要考虑到viewholder的复用、性能等问题。


效果


这里可以简单先写个Demo看看效果


bd42682d-57b0-44e4-b569-1c0a1a62142f.gif


功能实现


1. 倒计时功能实现

核心就是开启一个计时器,每秒都更新时间到页面上,这个计时器的实现就很多了,比如直接handler,或者kotlin能用flow去做,或者TimerTask这些也能实现。打个广告,要做精准的倒计时可以看这篇文章juejin.cn/post/714065…


我这里是做Demo演示,为了代码整洁和方便,我就用TimerTask来做。


2. 设计思路

试着想想,你用RecyclerView做倒计时,每个ViewHolder的倒计时时间都不同,难道要在每个ViewHolder中开一个TimerTask来做吗?然后对viewholder的缓存再单独做处理?


我的想法是可以所有Item共用一个倒计时


这个系统有3个重要部分组成:


(1)倒计时的实现,只用一个TimerTask来做一个心跳的效果。每次心跳去更新正在显示的Item页面的倒计时 ,比如你有100个Item,但是显示在屏幕上的只有5个,那我只需要关心这5个Item的时间变动,其他95个没必要做处理。


(2)观察者队列。我的每次心跳都要通知正在显示的Item更新页面,那是不是很明显要通过观察者模式去做。


(3)倒计时时间列表。倒计时也需要用一个列表管理起来,recyclerview的页面显示是根据数据去显示,虽然比如说100个数组只需要5、6个viewholder来复用,但是你的差异数据还是100个数据。


3. 倒计时列表


倒计时列表的实现,我这里是用一个HashMap来实现,因为方便直接获取某个实际Item的当前倒计时的时间。


private val cdMap: HashMap<Long, Long> = HashMap()

我的key假设用一个id来做处理,因为我们的数据结构中基本都会存在id并且id是基础数据类型。


data class RcdItemData(  
var id : Long, // id
var cd : Long // 总倒计时时间
)

添加倒计时


fun addCountDown(id: Long, totalCd: Long, isCover: Boolean = false) {  
if (cdMap.containsKey(id)) {
if (isCover) {
cdMap[id] = totalCd
}
} else {
cdMap[id] = totalCd
}
}

这个isCover是一个策略,adapter数据刷新时是否更新倒计时,这里可以先不用管,可以简单看成


fun addCountDown(id: Long, totalCd: Long, isCover: Boolean = false) {  
if (!cdMap.containsKey(id)) {
cdMap[id] = totalCd
}
}

清除倒计时(比如页面退出时就需要做释放操作)


fun clearCountDown() {  
cdMap.clear()
}

获取某个Item当前倒计时的时间


fun getCountDownById(id: Long): Long? {  
if (cdMap.containsKey(id)) {
return cdMap[id]
}

return null
}

更新时间(随心跳更新所有数据)


private fun updateCdByMap() {  
cdMap.forEach { (t, u) ->
if (cdMap[t]!! > 0) {
cdMap[t] = u - 1
}

......
}

这些代码都不难理解,就不过多解释了


4. 观察者数组实现


先创建一个观察者数组


private var viewHolderObservables: ArrayList<OnItemSchedule?> = ArrayList()


然后就是最基础的添加观察者和移除观察者操作


fun addHolderObservable(onItemSchedule: OnItemSchedule?) {  
viewHolderObservables.add(onItemSchedule)
}

fun removeHolderObservable(onItemSchedule: OnItemSchedule?) {
viewHolderObservables.remove(onItemSchedule)
}

fun releaseHolderObservable() {
viewHolderObservables.clear()
}

通知观察者(通知Item倒计时1秒了,可以刷新页面了)


private fun notifyCdFinish() {  
viewHolderObservables.forEach {
it?.onCdSchedule()
}
}

5. 倒计时心跳实现


前面说了,我们让所有的Item共用一个倒计时,也是通过一个心跳去更新各自倒计时时间


private var task: TimerTask? = null  
private var timer: Timer? = null

开始倒计时


fun startHeartBeat() {  
if (task == null) {
timer = Timer()
task = object : TimerTask() {
override fun run() {
updateCdByMap()
}
}
timer?.schedule(task, 1000, 1000) // 每隔 1 秒钟执行一次
}
}

每一秒都会调用updateCdByMap()方法去刷新时间。


private fun updateCdByMap() {  
cdMap.forEach { (t, u) ->
if (cdMap[t]!! > 0) {
cdMap[t] = u - 1
}
}

// 更改完数据之后通知观察者
Handler(Looper.getMainLooper()).post {
notifyCdFinish()
}
}

TimerTask会在子线程中进行,所以最后通知观察者的操作需要切到主线程


最后关闭倒计时(页面关闭这些时机调用)


fun closeHeartBeat() {  
task?.cancel()
task = null
timer = null
}

6. 整体功能


因为上面是拆开来解释说明,这里再把整个工具的代码合起来可能会比较好管理。


object RecyclerCountDownManager {  

private var task: TimerTask? = null
private var timer: Timer? = null

// viewHolder观察者
private var viewHolderObservables: ArrayList<OnItemSchedule?> = ArrayList()

// 倒计时对象数组
private val cdMap: HashMap<Long, Long> = HashMap()

/**
* 添加viewHolder观察
*/

fun addHolderObservable(onItemSchedule: OnItemSchedule?) {
viewHolderObservables.add(onItemSchedule)
}

fun removeHolderObservable(onItemSchedule: OnItemSchedule?) {
viewHolderObservables.remove(onItemSchedule)
}

fun releaseHolderObservable() {
viewHolderObservables.clear()
}

/**
* 添加倒计时对象
* @param totalCd 总倒计时时间
* @param isCover 是否覆盖
*/

fun addCountDown(id: Long, totalCd: Long, isCover: Boolean = false) {
if (cdMap.containsKey(id)) {
if (isCover) {
cdMap[id] = totalCd
}
} else {
cdMap[id] = totalCd
}
}

/**
* 清除倒计时
*/

fun clearCountDown() {
cdMap.clear()
}

/**
* 根据id获取倒计时
*/

fun getCountDownById(id: Long): Long? {
if (cdMap.containsKey(id)) {
return cdMap[id]
}

return null
}

/**
* 开始心跳
*/

fun startHeartBeat() {
if (task == null) {
timer = Timer()
task = object : TimerTask() {
override fun run() {
updateCdByMap()
}
}
timer?.schedule(task, 1000, 1000) // 每隔 1 秒钟执行一次
}
}

/**
* 更新所有倒计时对象
*/

private fun updateCdByMap() {
cdMap.forEach { (t, u) ->
if (cdMap[t]!! > 0) {
cdMap[t] = u - 1
}
}
// 更改完数据之后通知观察者
Handler(Looper.getMainLooper()).post {
notifyCdFinish()
}
}

private fun notifyCdFinish() {
viewHolderObservables.forEach {
it?.onCdSchedule()
}
}

/**
* 关闭心跳
*/

fun closeHeartBeat() {
task?.cancel()
task = null
timer = null
}

/**
* 调度通知,一般由ViewHolder实现该接口
*/

interface OnItemSchedule {

fun onCdSchedule()

}


}

可以看到代码都整体比较简单,就不用过多说明,就是需要注意一下这个是用一个单例去实现的工具,在页面关闭之后需要手动调用closeHeartBeat()、clearCountDown()、releaseHolderObservable()去释放资源。


调用的地方,Demo的Adapter


class RcdAdapter(var context: Context, var list: List<RcdItemData>) :  
RecyclerView.Adapter<RcdAdapter.RcdViewHolder>() {

init {
// 因为模式默认选择不覆盖,需要每次添加前先清除
RecyclerCountDownManager.clearCountDown()
list.forEach {
RecyclerCountDownManager.addCountDown(it.id, it.cd)
}
}

override fun onCreateViewHolder(parent: ViewGr0up, viewType: Int): RcdViewHolder {
val text: TextView = TextView(context)
text.layoutParams = ViewGr0up.LayoutParams(ViewGr0up.LayoutParams.MATCH_PARENT, 64)
text.gravity = Gravity.CENTER
val holder = RcdViewHolder(text)
RecyclerCountDownManager.addHolderObservable(holder)
return holder
}

override fun getItemCount(): Int {
return list.size
}

override fun onBindViewHolder(holder: RcdViewHolder, position: Int) {
holder.setData(list[position])
}

class RcdViewHolder(var view: TextView) : RecyclerView.ViewHolder(view),
RecyclerCountDownManager.OnItemSchedule {

private var mData: RcdItemData? = null

fun setData(data: RcdItemData) {
mData = data
}

override fun onCdSchedule() {
val cd = mData?.id?.let { RecyclerCountDownManager.getCountDownById(it) }
if (cd != null) {
// 测试展示分秒
view.text = "${String.format("d", cd / 60)}:${String.format("d", cd % 60)}"
}
}

}

}

其他都比较基础的adapter的写法,就是viewholder要实现RecyclerCountDownManager.OnItemSchedule来充当观察者,然后拿到列表数据后调用RecyclerCountDownManager.addCountDown(it.id, it.cd)去创建倒计时列表。在onCreateViewHolder中调用RecyclerCountDownManager.addHolderObservable(holder)去添加观察者。最后在onCdSchedule()回调中做倒计时的更新


image.png


在页面销毁的时候主动释放内存


image.png


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

用Kotlin通杀“一切”单位换算

用Kotlin通杀“一切”单位换算之存储容量 前言 在之前的文章《用Kotlin Duration来优化时间单位换算》 中,用Duration可以很方便的进行时间的单位换算和运算。我忽然想到平时的工作中经常用到的换算和运算。(长度单位m,cm;质量单位 kg,...
继续阅读 »

用Kotlin通杀“一切”单位换算之存储容量


前言


在之前的文章《用Kotlin Duration来优化时间单位换算》
中,用Duration可以很方便的进行时间的单位换算和运算。我忽然想到平时的工作中经常用到的换算和运算。(长度单位m,cm;质量单位 kg,g,lb;存储容量单位 mb,gb,tb 等等)


//进率为1024
val tenMegabytes = 10 * 1024 * 1024 //10mb
val tenGigabytes = 10 * 1024 * 1024 * 1024 //10gb

加入这样的业务代码后阅读性就变差了,能否有像Duration一样的api实现下面这样的代码呢?


fun main() {
1.kg = 2.20462262.lb; 1.m = 100.cm

val fiftyMegabytes = 50.mb
val divValue = fiftyMegabytes - 30.mb
// 20mb
val timesValue = fiftyMegabytes * 2.4
// 120mb

// 1G文件 再增加2个50mb的数据空间
val fileSpace = fiftyMegabytes * 2 + 1.gb
RandomAccessFile("fileName","rw").use {
it.setLength(fileSpace.inWholeBytes)
it.write(...)
}
}

下面我们通过分析Duration源码了解原理,并且实现存储容量单位DataSize的换算和运算。


简单拆解Duration


kotlin没有提供,要做到上面的api那么我不会啊,但是我看到Duration可以做到,那我们来看看它的原理,进行仿写就行了。



  1. 枚举DurationUnit是用来定义时间不同单位,方便换算和转换的(详情看源码或上篇文)。

  2. Duration是如何做到不同单位的数据换算的,先看看Duration的创建函数和构造函数。toDuration把当前的值通过convertDurationUnit把时间换算成nanos或millis的值,再通过shl运算用来记录单位。
    //Long创建 Duration
    public fun Long.toDuration(unit: DurationUnit): Duration {
    //最大支持的 nanos值
    val maxNsInUnit = convertDurationUnitOverflow(MAX_NANOS, DurationUnit.NANOSECONDS, unit)
    //当前值如果在最大和最小值中间 表示不会溢出
    if (this in -maxNsInUnit..maxNsInUnit) {
    //创建 rawValue 是Nanos的 Duration
    return durationOfNanos(convertDurationUnitOverflow(this, unit, DurationUnit.NANOSECONDS))
    } else {
    //创建 rawValue 是millis的 Duration
    val millis = convertDurationUnit(this, unit, DurationUnit.MILLISECONDS)
    return durationOfMillis(millis.coerceIn(-MAX_MILLIS, MAX_MILLIS))
    }
    }
    // 用 nanos
    private fun durationOfNanos(normalNanos: Long) = Duration(normalNanos shl 1)
    // 用 millis
    private fun durationOfMillis(normalMillis: Long) = Duration((normalMillis shl 1) + 1)
    //不同os平台实现,肯定是 1小时60分 1分60秒那套算法
    internal expect fun convertDurationUnit(value: Long, sourceUnit: DurationUnit, targetUnit: DurationUnit): Long


  3. Duration是一个value class用来提升性能的,通过rawValue还原当前时间换算后的nanos或millis的数据value。为何不全部都用Nanos省去了这些计算呢,根据代码看应该是考虑了Nanos的计算会溢出。用一个long值可以还原构造对象前的所有参数,这代码设计真牛逼。
    @JvmInline
    public value class Duration internal constructor(private val rawValue: Long) : Comparable<Duration> {
    //原始最小单位数据
    private val value: Long get() = rawValue shr 1
    //单位鉴别器
    private inline val unitDiscriminator: Int get() = rawValue.toInt() and 1
    private fun isInNanos() = unitDiscriminator == 0
    private fun isInMillis() = unitDiscriminator == 1
    //还原的最小单位 DurationUnit对象
    private val storageUnit get() = if (isInNanos()) DurationUnit.NANOSECONDS else DurationUnit.MILLISECONDS


  4. Duration是如何做到算术运算的,是通过操作符重载实现的。不同单位Duration,持有的数据是同一个单位的那么是可以互相运算的,我们后面会着重介绍和仿写。

  5. Duration是如何做到逻辑运算的(>,<,>=,<=),构造函数实现了接口Comparable<Duration>重写了operator fun compareTo(other: Duration): Int,返回1,-1,0



Duration主要依靠对象内部持有的rawValue: Long,由于value的单位是“相同”的,就可以实现不同单位的换算和运算。



存储容量单位换算设计



  1. 存储容量的单位一般有比特(b),字节(B),千字节(KB),兆字节(MB),千兆字节(GB),太字节(TB),拍字节(PB),艾字节(EB),泽字节(ZB),尧字节(YB)
    ,考虑到实际应用和Long的取值范围我们最大支持PB即可。


    enum class DataUnit(val shortName: String) {
    BYTES("B"),
    KILOBYTES("KB"),
    MEGABYTES("MB"),
    GIGABYTES("GB"),
    TERABYTES("TB"),
    PETABYTES("PB")
    }


  2. 对于存储容量来说最小单位我们就定为Bytes,最大支持到PB,然后可以省去对数据过大的溢出的"单位鉴别器"设计。(注意使用pb时候,>= 8192.pb就会溢出)


    @JvmInline
    value class DataSize internal constructor(private val rawBytes: Long)


  3. 参照Duration在创建和最后单位换算时候都用到了convertDurationUnit函数,接受原始单位和目标单位。另外考虑到可能出现换算溢出使用Math.multiplyExact来抛出异常,防止数据计算异常无法追溯的问题。


    /** Bytes per Kilobyte.*/
    private const val BYTES_PER_KB: Long = 1024
    /** Bytes per Megabyte.*/
    private const val BYTES_PER_MB = BYTES_PER_KB * 1024
    /** Bytes per Gigabyte.*/
    private const val BYTES_PER_GB = BYTES_PER_MB * 1024
    /** Bytes per Terabyte.*/
    private const val BYTES_PER_TB = BYTES_PER_GB * 1024
    /** Bytes per PetaByte.*/
    private const val BYTES_PER_PB = BYTES_PER_TB * 1024

    internal fun convertDataUnit(value: Long, sourceUnit: DataUnit, targetUnit: DataUnit): Long {
    val valueInBytes = when (sourceUnit) {
    DataUnit.BYTES -> value
    DataUnit.KILOBYTES -> Math.multiplyExact(value, BYTES_PER_KB)
    DataUnit.MEGABYTES -> Math.multiplyExact(value, BYTES_PER_MB)
    DataUnit.GIGABYTES -> Math.multiplyExact(value, BYTES_PER_GB)
    DataUnit.TERABYTES -> Math.multiplyExact(value, BYTES_PER_TB)
    DataUnit.PETABYTES -> Math.multiplyExact(value, BYTES_PER_PB)
    }
    return when (targetUnit) {
    DataUnit.BYTES -> valueInBytes
    DataUnit.KILOBYTES -> valueInBytes / BYTES_PER_KB
    DataUnit.MEGABYTES -> valueInBytes / BYTES_PER_MB
    DataUnit.GIGABYTES -> valueInBytes / BYTES_PER_GB
    DataUnit.TERABYTES -> valueInBytes / BYTES_PER_TB
    DataUnit.PETABYTES -> valueInBytes / BYTES_PER_PB
    }
    }

    internal fun convertDataUnit(value: Double, sourceUnit: DataUnit, targetUnit: DataUnit): Double {
    val valueInBytes = when (sourceUnit) {
    DataUnit.BYTES -> value
    DataUnit.KILOBYTES -> value * BYTES_PER_KB
    DataUnit.MEGABYTES -> value * BYTES_PER_MB
    DataUnit.GIGABYTES -> value * BYTES_PER_GB
    DataUnit.TERABYTES -> value * BYTES_PER_TB
    DataUnit.PETABYTES -> value * BYTES_PER_PB
    }
    require(!valueInBytes.isNaN()) { "DataUnit value cannot be NaN." }
    return when (targetUnit) {
    DataUnit.BYTES -> valueInBytes
    DataUnit.KILOBYTES -> valueInBytes / BYTES_PER_KB
    DataUnit.MEGABYTES -> valueInBytes / BYTES_PER_MB
    DataUnit.GIGABYTES -> valueInBytes / BYTES_PER_GB
    DataUnit.TERABYTES -> valueInBytes / BYTES_PER_TB
    DataUnit.PETABYTES -> valueInBytes / BYTES_PER_PB
    }
    }


  4. 扩展属性和构造DataSize,rawBytes是Bytes因此所有的目标单位设置为DataUnit.BYTES,而原始单位就通过调用者告诉convertDataUnit


    fun Long.toDataSize(unit: DataUnit): DataSize {
    return DataSize(convertDataUnit(this, unit, DataUnit.BYTES))
    }
    fun Double.toDataSize(unit: DataUnit): DataSize {
    return DataSize(convertDataUnit(this, unit, DataUnit.BYTES).roundToLong())
    }
    inline val Long.bytes get() = this.toDataSize(DataUnit.BYTES)
    inline val Long.kb get() = this.toDataSize(DataUnit.KILOBYTES)
    inline val Long.mb get() = this.toDataSize(DataUnit.MEGABYTES)
    inline val Long.gb get() = this.toDataSize(DataUnit.GIGABYTES)
    inline val Long.tb get() = this.toDataSize(DataUnit.TERABYTES)
    inline val Long.pb get() = this.toDataSize(DataUnit.PETABYTES)

    inline val Int.bytes get() = this.toLong().toDataSize(DataUnit.BYTES)
    inline val Int.kb get() = this.toLong().toDataSize(DataUnit.KILOBYTES)
    inline val Int.mb get() = this.toLong().toDataSize(DataUnit.MEGABYTES)
    inline val Int.gb get() = this.toLong().toDataSize(DataUnit.GIGABYTES)
    inline val Int.tb get() = this.toLong().toDataSize(DataUnit.TERABYTES)
    inline val Int.pb get() = this.toLong().toDataSize(DataUnit.PETABYTES)

    inline val Double.bytes get() = this.toDataSize(DataUnit.BYTES)
    inline val Double.kb get() = this.toDataSize(DataUnit.KILOBYTES)
    inline val Double.mb get() = this.toDataSize(DataUnit.MEGABYTES)
    inline val Double.gb get() = this.toDataSize(DataUnit.GIGABYTES)
    inline val Double.tb get() = this.toDataSize(DataUnit.TERABYTES)
    inline val Double.pb get() = this.toDataSize(DataUnit.PETABYTES)


  5. 换算函数设计
    Duration用toLong(DurationUnit)或者toDouble(DurationUnit)来输出指定单位的数据,inWhole系列函数是对toLong(DurationUnit) 的封装。toLong和toDouble实现就比较简单了,把convertDataUnit传入输出单位,而原始单位就是rawValue的单位DataUnit.BYTES
    toDouble需要输出更加精细的数据,例如: 512mb = 0.5gb。


    val inWholeBytes: Long
    get() = toLong(DataUnit.BYTES)
    val inWholeKilobytes: Long
    get() = toLong(DataUnit.KILOBYTES)
    val inWholeMegabytes: Long
    get() = toLong(DataUnit.MEGABYTES)
    val inWholeGigabytes: Long
    get() = toLong(DataUnit.GIGABYTES)
    val inWholeTerabytes: Long
    get() = toLong(DataUnit.TERABYTES)
    val inWholePetabytes: Long
    get() = toLong(DataUnit.PETABYTES)

    fun toDouble(unit: DataUnit): Double = convertDataUnit(bytes.toDouble(), DataUnit.BYTES, unit)
    fun toLong(unit: DataUnit): Long = convertDataUnit(bytes, DataUnit.BYTES, unit)



操作符设计


在Kotlin 中可以为类型提供预定义的一组操作符的自定义实现,被称为操作符重载。这些操作符具有预定义的符号表示(如 + 或
*)与优先级。为了实现这样的操作符,需要为相应的类型提供一个指定名称的成员函数或扩展函数。这个类型会成为二元操作符左侧的类型及一元操作符的参数类型。


如果函数不存在或不明确,则导致编译错误(编译器会提示报错)。下面为常见操作符对照表:


操作符函数名说明
+aa.unaryPlus()一元操作 取正
-aa.unaryMinus()一元操作 取负
!aa.not()一元操作 取反
a + ba.plus(b)二元操作 加
a - ba.minus(b)二元操作 减
a * ba.times(b)二元操作 乘
a / ba.div(b)二元操作 除

算术运算支持



  1. 这里用算术运算符+实现来举例:假如DataSize对象需要重载操作符+
    val a = DataSize(); val c: DataSize = a + b


  2. 需要定义扩展函数1或者添加成员函数2
    1. operator fun DataSize.plus(other: T): DataSize {...}
    2. class DataSize { operator fun plus(other: T): DataSize {...} }


  3. 函数中的参数other: T表示b的对象类型,例如
    // val a: DataSize; val b: DataSize; a + DataSize()
    operator fun DataSize.plus(other: DataSize): DataSize {...}
    // val a: DataSize; val b: Int; a + 1
    operator fun DataSize.plus(other: Int): DataSize {...}


  4. 为了阅读性,Duration不会和同类型的对象乘除法运算,而使用了Int或Double,因此重载运算符用了operator fun times(scale: Int): Duration

  5. 那么在DataSize中我们也重载(+,-,*,/),并且(*,/)重载的参数只支持Int和Double即可
    operator fun unaryMinus(): DataSize {
    return DataSize(-this.bytes)
    }
    operator fun plus(other: DataSize): DataSize {
    return DataSize(Math.addExact(this.bytes, other.bytes))
    }

    operator fun minus(other: DataSize): DataSize {
    return this + (-other) // a - b = a + (-b)
    }

    operator fun times(scale: Int): DataSize {
    return DataSize(Math.multiplyExact(this.bytes, scale.toLong()))
    }

    operator fun div(scale: Int): DataSize {
    return DataSize(this.bytes / scale)
    }

    operator fun times(scale: Double): DataSize {
    return DataSize((this.bytes * scale).roundToLong())
    }

    operator fun div(scale: Double): DataSize {
    return DataSize((this.bytes / scale).roundToLong())
    }

    上面的操作符重载中minus(),我们使用了 plus()unaryMinus()重载组合a-b = a+(-b),这样我们可以多一个-DataSize的操作符


逻辑运算支持



  • (>,<,>=,<=)让DataSize构造函数实现了接口Comparable<DataSize>重写了operator fun compareTo(other: DataSize): Int,返回rawBytes对比值即可。

  • (==,!=)通过equals(other)函数实现的,value class默认为rawBytes的对比,可以通过java字节码看到。kotlin 1.9之前不支持重写value classs的equals和hashCode


    value class DataSize internal constructor(private val bytes: Long) : Comparable<DataSize> {
    override fun compareTo(other: DataSize): Int {
    return this.bytes.compareTo(other.bytes)
    }
    //示例
    600.mb > 0.5.gb //true
    512.mb == 0.5.gb




操作符重载的目的是为了提升阅读性,并不是所有对象为了炫酷都可以用操作符重载,滥用反而会增加代码的阅读难度。例如给DataSize添加*操作符,5mb * 2mb 就让人头大。



获取字符串形式


为了方便打印和UI展示,一般我们需要重写toSting。Duration的toSting不需要指定输出单位,可以详细的输出当前对象的字符串格式(1h 0m 45.677s)算法比较复杂。我不太会,就简单实现指定输出单位的toString(DataUnit)


 override fun toString(): String = String.format("%dB", rawBytes)

fun toString(unit: DataUnit, decimals: Int = 2): String {
require(decimals >= 0) { "decimals must be not negative, but was $decimals" }
val number = toDouble(unit)
if (number.isInfinite()) return number.toString()
val newDecimals = decimals.coerceAtMost(12)
return DecimalFormat("0").run {
if (newDecimals > 0) minimumFractionDigits = newDecimals
roundingMode = RoundingMode.HALF_UP
format(number) + unit.shortName
}
}

单元测试


功能都写好了需要验证期望的结果和实现的功能是否一直,那么这个时候就用单元测试最好来个100%覆盖。


class ExampleUnitTest {
@Test
fun data_size() {
val dataSize = 512.mb

println("format bytes:$dataSize")
// format bytes:536870912B
println("format kb:${dataSize.toString(DataUnit.KILOBYTES)}")
// format kb:524288.00KB
println("format gb:${dataSize.toString(DataUnit.GIGABYTES)}")
// format gb:0.50GB
// 单位换算
assertEquals(536870912, dataSize.inWholeBytes)
assertEquals(524288, dataSize.inWholeKilobytes)
assertEquals(512, dataSize.inWholeMegabytes)
assertEquals(0, dataSize.inWholeGigabytes)
assertEquals(0, dataSize.inWholeTerabytes)
assertEquals(0, dataSize.inWholePetabytes)
}

@Test
fun data_size_operator() {
val dataSize1 = 512.mb
val dataSize2 = 3.gb

val unaryMinusValue = -dataSize1 //取负数
println("unaryMinusValue :${unaryMinusValue.toString(DataUnit.MEGABYTES)}")
// unaryMinusValue :-512.00MB

val plusValue = dataSize1 + dataSize2 //+
println("plus :${plusValue.toString(DataUnit.GIGABYTES)}")
// plus :3.50GB

val minusValue = dataSize1 - dataSize2 // -
println("minus :${minusValue.toString(DataUnit.GIGABYTES)}")
// minus :-2.50GB

val timesValue = dataSize1 * 2 //乘法
println("times :${timesValue.toString(DataUnit.GIGABYTES)}")
// times :1.00GB

val divValue = dataSize2 / 2 //除法
println("div :${divValue.toString(DataUnit.GIGABYTES)}")
// div :1.50GB
}

@Test(expected = ArithmeticException::class)
fun data_size_overflow() {
8191.pb
8192.pb //溢出了不支持,如果要支持参考"单位鉴别器"设计
}

@Test
fun data_size_compare() {
assertTrue(600.mb > 0.5.gb)
assertTrue(512.mb == 0.5.gb)
}
}

总结


通过学习Kotlin Duration的源码,举一反三应用到储存容量单位转换和运算中。Duration中的拆解计算api,还有toSting算法实现就留给大家学习吧。当然了你也可以实现和Duration一样更加精细的"单位鉴别器"设计,支持ZB、YB等大单位。


另外类似的进率场景也可以实现,用Kotlin通杀“一切进率换算”。比如Degrees角度计算 -90.0.degrees == 270.0.degrees;质量计算kg和磅,两等等1.kg == 2.20462262.lb;甚至人民币汇率 (动态实现算法)8.dollar == 1.rmb 🐶


github 代码: github.com/forJrking/K…


操作符重载文档: book.kotlincn.net/text/operat…


作者:forJrking
来源:juejin.cn/post/7301145359852765218
收起阅读 »

Android自定义定时通知实现

Android自定义通知实现 前言 自定义通知就是使用自定义的布局,实现自定义的功能。 Notification的常规布局中可以设置标题、内容、大小图标,也可以实现点击跳转等。 常规的布局固然方便,可当需求变多就只能使用自定义布局了。 我想要实现的通知布局除了...
继续阅读 »

Android自定义通知实现


前言


自定义通知就是使用自定义的布局,实现自定义的功能。


Notification的常规布局中可以设置标题、内容、大小图标,也可以实现点击跳转等。


常规的布局固然方便,可当需求变多就只能使用自定义布局了。


我想要实现的通知布局除了时间、标题、图标、跳转外,还有“5分钟后提醒”、“已知悉”这两个功能需要实现。如下图所示:


nnotify.gif

正文


一、待办数据库


1、待办实体类


image.png

假设现在时间是12点,添加了一个14点的待办会议,并设置提前20分钟进行提醒


其中year、month、day、time构成会议的时间,也就是今天的14点content是待办的内容。remind是该待办提前提醒的时间,也就是20分钟type是待办类型,包括未完成,已完成和忽略


2、数据访问Dao


添加或更新一条待办事项


image.png



@Insert(onConflict = OnConflictStrategy.REPLACE) 注解: 如果指定 id 的对象没有保存在数据库中, 就会新增一条数据到数据库。如果指定 id 的对象数据已经保存到数据库中, 就会删除掉原来的数据, 然后新增一条数据。



3、数据库封装


在仓库层的TodoStoreRepository类中对待办数据库的操作进行封装。不赘述了。


二、添加定时器


每条待办都对应着一个定时任务,在用户添加一条待办数据的同时需要添加一条对应的定时任务。在这里使用映射的方式,将待办的Id和定时任务一一绑定。


1、思路:



  1. 首先要构造一个Map<Long, TimerTask>>类型的参数和一个定时器Timer。

  2. 在添加定时任务前,先对待办数据进行过滤。

  3. 计算出延迟的时间。

  4. 定时器调度,当触发时,消息弹窗提醒。


2、实现


image.png


说明



  • isOver() -- 判断该待办有没有完成,通过待办的type进行判断。



代码:fun isOver() = type == 1




  • delayTime -- 延迟时间。获取到当前时间的时间戳、将待办提醒的时间转换为时间戳,最后相减,得到一个Long类型的时间戳即延迟时间。

  • 当delayTime大于0时,进行定时器调度,将待办Id与定时任务绑定,当延迟时间到达时会触发定时器任务,定时器任务中包含了消息弹窗提醒。


3、封装


image.png



在 TodoScheduleUseCase 类中将待办数据插入和待办定时器创建封装在一起了,插入数据后获取到数据的Id,将id赋值传给待办定时器任务。



三、注册广播


1. 首先创建一个TodoNotifyReceiver广播


image.png



在TodoNotifyReceiver中,首先获取到待办数据,根据Action判断广播的类型并执行相应的回调。



2. 自定义Action


分别是“5分钟后提醒”、“已知悉”的Action


image.png


3. 广播注册方法


image.png

4.广播注册及回调实现


“5分钟后提醒”实现是调用delayTodoTask5min方法,原理就是将remind即提醒时间减5达到五分钟后提醒的效果。并取消该通知。再将修改过属性的待办重新添加到待办列表中。


“已知悉”实现是调用markTodoTaskDone方法,原理就是将type属性标记成1,代表已完成。并取消该通知。再将修改过属性的待办重新添加到待办列表中。


image.png


/**
* 延迟5分钟
*/

fun delayTodoTask5min(todoInfo: TodoInfo) {
useScope.launch {
todoInfo.remind -= 5
insertOrUpdateTodo(todoInfo)
}
}

/**
* 标记已完成
*/

fun markTodoTaskDone(todoInfo: TodoInfo) {
useScope.launch {
todoInfo.type = 1
insertOrUpdateTodo(todoInfo)
}
}



四、自定义通知构建


fun showNotify(todoInfo: TodoInfo) {
binding = LayoutTodoNotifyItemBinding.inflate(context.layoutInflater())

// 自定义通知布局
val notificationLayout =
RemoteViews(context.packageName, R.layout.layout_todo_notify_item)
// 设置自定义的Action
val notifyAfterI = Intent().setAction(TodoNotifyReceiver.TODO_CHANGE_ACTION)
val alreadyKnowI = Intent().setAction(TodoNotifyReceiver.TODO_ALREADY_KNOW_ACTION)
// 传入TodoInfo
notifyAfterI.putExtra("todoInfo", todoInfo)
alreadyKnowI.putExtra("todoInfo", todoInfo)
// 设置点击时跳转的界面
val intent = Intent(context, MarketActivity::class.java)
val pendingIntent = PendingIntent.getActivity(context, todoInfo.id.toInt(), intent, PendingIntent.FLAG_CANCEL_CURRENT)
val notifyAfterPI = PendingIntent.getBroadcast(context, todoInfo.id.toInt(), notifyAfterI, PendingIntent.FLAG_CANCEL_CURRENT)
val alreadyKnowPI = PendingIntent.getBroadcast(context, todoInfo.id.toInt(), alreadyKnowI, PendingIntent.FLAG_CANCEL_CURRENT)

//给通知布局中的组件设置点击事件
notificationLayout.setOnClickPendingIntent(R.id.notify_after, notifyAfterPI)
notificationLayout.setOnClickPendingIntent(R.id.already_know, alreadyKnowPI)

// 构建自定义通知布局
notificationLayout.setTextViewText(R.id.notify_content, todoInfo.content)
notificationLayout.setTextViewText(R.id.notify_date, "${todoInfo.year}-${todoInfo.month + 1}-${todoInfo.day} ${todoInfo.time}")

var notifyBuild: NotificationCompat.Builder? = null
// 构建NotificationChannel
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationChannel = NotificationChannel(context.packageName, "todoNotify", NotificationManager.IMPORTANCE_HIGH)
notificationChannel.lockscreenVisibility = Notification.VISIBILITY_SECRET
notificationChannel.enableLights(true) // 是否在桌面icon右上角展示小红点
notificationChannel.lightColor = Color.RED// 小红点颜色
notificationChannel.setShowBadge(true) // 是否在久按桌面图标时显示此渠道的通知
notificationManager.createNotificationChannel(notificationChannel)
notifyBuild = NotificationCompat.Builder(context, todoInfo.id.toString())
notifyBuild.setChannelId(context.packageName);
} else {
notifyBuild = NotificationCompat.Builder(context)
}
notifyBuild.setSmallIcon(R.mipmap.icon_todo_item_normal)
.setStyle(NotificationCompat.DecoratedCustomViewStyle())
.setCustomContentView(notificationLayout) //设置自定义通知布局
.setPriority(NotificationCompat.PRIORITY_MAX) //设置优先级
.setAutoCancel(true) //设置点击后取消Notification
.setContentIntent(pendingIntent) //设置跳转
.build()
notificationManager.notify(todoInfo.id.toInt(), notifyBuild.build())

// 取消指定id的通知
fun cancelNotifyById(id: Int) {
notificationManager.cancel(id)
}
}


步骤:



  1. 构建自定义通知布局

  2. 设置自定义的Action

  3. 传入待办数据

  4. 设置点击时跳转的界面

  5. 设置了两个BroadcastReceiver类型的点击回调

  6. 给通知布局中的组件设置点击事件

  7. 构建自定义通知布局

  8. 构建NotificationChannel

  9. 添加取消指定id的通知方法



总结


以上,Android自定义定时通知实现的过程和结果。文章若出现错误,欢迎各位批评指正,写文不易,转载请注明出处谢谢。


作者:遨游在代码海洋的鱼
来源:juejin.cn/post/7278566669282787383
收起阅读 »

Android 使用TextView实现验证码输入框

前言 网上开源的是构建同等数量的 EditText,这种存在很多缺陷,主要如下 1、数字 / 字符键盘切换后键盘状态无法保存 2、焦点切换无法判断 3、光标位置无法修正 4、切换过程需要做很多同步工作 5、需要处理聚焦选中区域问题 6、性能差 EditText...
继续阅读 »

前言


网上开源的是构建同等数量的 EditText,这种存在很多缺陷,主要如下


1、数字 / 字符键盘切换后键盘状态无法保存

2、焦点切换无法判断

3、光标位置无法修正

4、切换过程需要做很多同步工作

5、需要处理聚焦选中区域问题

6、性能差


EditText越多,造成的不确定性问题将越多,因此,在开发中,如果我们自行实现一个纯View的输入框有没有可能呢?比较遗憾的是,Android 层面android.widget.Editor是非公开的类,因此很难去实现一个想要的View。


另一种方案,我们继承TextView,改写TextView的绘制逻辑也是可以。


为什么TextView是可以的呢?



  • 第一:TextView 本身可以输入任何文本

  • 第二:TextView 绘制方法中使用android.widget.Editor可以辅助keycode->文本转换

  • 第三:TextView 提供了光标等各种组件


核心步骤


为了解决上述问题,使用 TextView 实现输入框,这里需要解决的问题是


1、允许 TextView 可编辑输入,这点可以参考EditText的实现

2、重写 onDraw 实现,不实用原有的绘制逻辑。

3、重写光标逻辑,默认的光标逻辑和Editor有很多关联逻辑,而Editor是@hide标注的,因此必须要重写

4、重写长按菜单逻辑,防止弹出剪切、复制、选中等PopWindow弹窗。
5、限制文本长度


fire_89.gif


代码实现


首先我们要继承TextView或者AppCompatTextView,然后实现下面的操作


变量定义


//边框颜色
private int boxColor = Color.BLACK;

//光标是否可见
private boolean isCursorVisible = true;
//光标
private Drawable textCursorDrawable;
//光标宽度
private float cursorWidth = dp2px(2);
//光标高度
private float cursorHeight = dp2px(36);
//是否展示光标
private boolean isShowCursor;
//字符数量控制
private int inputBoxNum = 5;
//间距
private int mBoxSpace = 10;

关键状态


禁止复制、粘贴、选中


mrb62ges5a.jpeg


super.setFocusable(true); //支持聚焦
super.setFocusableInTouchMode(true); //支持触屏模式聚焦
//可点击,因为聚焦的view必须是可以点击的,这里你也可以设置个clickListener,效果一样
super.setClickable(true);
super.setGravity(Gravity.CENTER_VERTICAL);
super.setMaxLines(1);
super.setSingleLine();
super.setFilters(inputFilters);
super.setLongClickable(false);// 禁止复制、剪切
super.setTextIsSelectable(false); // 禁止选中

绘制逻辑


我们重写onDraw方法,自行绘制View


TextPaint paint = getPaint();

float strokeWidth = paint.getStrokeWidth();
if(strokeWidth == 0){
//默认Text是没有strokeWidth的,为了防止绘制边缘存在问题,这里强制设置 1dp
paint.setStrokeWidth(dp2px(1));
strokeWidth = paint.getStrokeWidth();
}
paint.setTextSize(getTextSize());

float boxWidth = (getWidth() - strokeWidth * 2f - (inputBoxNum - 1) * mBoxSpace) / inputBoxNum;
float boxHeight = getHeight() - strokeWidth * 2f;
int saveCount = canvas.save();
//获取默认风格
Paint.Style style = paint.getStyle();
Paint.Align align = paint.getTextAlign();
paint.setTextAlign(Paint.Align.CENTER);

String text = getText().toString();
int length = text.length();

int color = paint.getColor();

for (int i = 0; i < inputBoxNum; i++) {

inputRect.set(i * (boxWidth + mBoxSpace) + strokeWidth,
strokeWidth,
strokeWidth + i * (boxWidth + mBoxSpace) + boxWidth,
strokeWidth + boxHeight);

paint.setStyle(Paint.Style.STROKE);
paint.setColor(boxColor);
//绘制边框
canvas.drawRoundRect(inputRect, boxRadius, boxRadius, paint);

//设置当前TextColor
int currentTextColor = getCurrentTextColor();
paint.setColor(currentTextColor);
paint.setStyle(Paint.Style.FILL);
if (text.length() > i) {
// 绘制文字,这里我们不过滤空格,当然你可以在InputFilter中处理
String CH = String.valueOf(text.charAt(i));
int baseLineY = (int) (inputRect.centerY() + getTextPaintBaseline(paint));//基线中间点的y轴计算公式
canvas.drawText(CH, inputRect.centerX(), baseLineY, paint);
}

//绘制光标
if(i == length && isCursorVisible && length < inputBoxNum){
Drawable textCursorDrawable = getTextCursorDrawable();
if(textCursorDrawable != null) {
if (!isShowCursor) {
textCursorDrawable.setBounds((int) (inputRect.centerX() - cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f), (int) (inputRect.centerX() + cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f + cursorHeight));
textCursorDrawable.draw(canvas);
isShowCursor = true; //控制光标闪烁 blinking
} else {
isShowCursor = false;//控制光标闪烁 no blink
}
removeCallbacks(invalidateCursor);
postDelayed(invalidateCursor,500);
}
}
}

paint.setColor(color);
paint.setStyle(style);
paint.setTextAlign(align);

canvas.restoreToCount(saveCount);

InsertionHandleView问题


image.png


我们上文处理了各种可能出现的选中区域弹窗,然而一个很难处理的弹窗双击后会展示,评论区有同学也贴出来了。主要原因是Editor为了方便EditText选中,在内部使用了InsertionHandleView去展示一个弹窗,但这个弹窗并不是直接addView的,而是通过PopWindow展示的,具体可以参考下面源码。


实际上,掘金Android 客户端也有类似的问题,不过掘金app的实现方式是使用多个EditText实现的,点击的时候就会明显看到这个小雨点,其次还有光标卡顿的问题。



android.widget.Editor.InsertionHandleView



解决方法其实有3种:


第一种是Hack Context,返回一个自定义的WindowManager给PopWindow,不过我们知道InputManagerService 作为 WindowManagerService中的子服务,如果处理不当,可能产生输入法无法输入的问题,另外要Hack WindowManager,显然工作量很大。


第二种是替换:修改InsertionHandleView的背景元素,具体可参考:blog.csdn.net/shi_xin/art… 一文


<item name="textSelectHandleLeft">@drawable/text_select_handle_left_material</item>
<item name="textSelectHandleRight">@drawable/text_select_handle_right_material</item>
<item name="textSelectHandle">@drawable/text_select_handle_middle_material</item>

这种方式增加了View的可扩展性,自定义View要尽可能避免和xml配置耦合,除非是自定义属性。


第三种是拦截hide方法,在popWindow展示之后,会立即设置一个定时消失的逻辑,这种相对简单,而且View的通用性不受影响,但是也有些不规范,不过目前这个调用还是相当稳定的。


综上,我们选择第三种方案,我这里直接拦截其内部调用postDelay的方法,如果是InsertionHandleView的内部类,且时间为4000秒,直接执行runnable


private void hideAfterDelay() {
if (mHider == null) {
mHider = new Runnable() {
public void run() {
hide();
}
};
} else {
removeHiderCallback();
}
mTextView.postDelayed(mHider, DELAY_BEFORE_HANDLE_FADES_OUT);
}

下面是解法:


@Override
public boolean postDelayed(Runnable action, long delayMillis) {
final long DELAY_BEFORE_HANDLE_FADES_OUT = 4000;
if(delayMillis == DELAY_BEFORE_HANDLE_FADES_OUT
&& action.getClass().getName().startsWith("android.widget.Editor$InsertionHandleView$")){
Log.d("TAG","delayMillis = " + delayMillis);
delayMillis = 0;
}
return super.postDelayed(action, delayMillis);
}

总结


上面就是本文的核心逻辑,实际上EditText、Button都继承自TextView,因此我们简单的修改就能让其支持输入,主要原因还是TextView复杂的设计和各种Layout的支持,但是这也给TextView带来了性能问题。


这里简单说下TextView性能优化,对于单行文本和非可编辑文本,最好是自行实现,单行文本直接用canvas.drawText绘制,当然多行也是可以的,不过鉴于要支持很多特性,多行文本可以使用StaticLayout去实现,但单行文本尽量自己绘制,也不要使用BoringLayout,因为其存在一些兼容性问题,另外自定义的单行文本不要和TextView同一行布局,因为TextView的计算相对较多,很可能产生对不齐的问题。


本篇全部代码


按照惯例,这里依然提供全部代码,仅供参考,当然,也可以直接使用到项目中,本篇代码在线上已经使用过。


public class EditableTextView extends TextView {

private RectF inputRect = new RectF();


//边框颜色
private int boxColor = Color.BLACK;

//光标是否可见
private boolean isCursorVisible = true;
//光标
private Drawable textCursorDrawable;
//光标宽度
private float cursorWidth = dp2px(2);
//光标高度
private float cursorHeight = dp2px(36);
//光标闪烁控制
private boolean isShowCursor;
//字符数量控制
private int inputBoxNum = 5;
//间距
private int mBoxSpace = 10;
// box radius
private float boxRadius = dp2px(0);

InputFilter[] inputFilters = new InputFilter[]{
new InputFilter.LengthFilter(inputBoxNum)
};


public EditableTextView(Context context) {
this(context, null);
}

public EditableTextView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public EditableTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
super.setFocusable(true); //支持聚焦
super.setFocusableInTouchMode(true); //支持触屏模式聚焦
//可点击,因为在触屏模式可聚焦的view一般是可以点击的,这里你也可以设置个clickListener,效果一样
super.setClickable(true);
super.setGravity(Gravity.CENTER_VERTICAL);
super.setMaxLines(1);
super.setSingleLine();
super.setFilters(inputFilters);
super.setLongClickable(false);// 禁止复制、剪切
super.setTextIsSelectable(false); // 禁止选中

Drawable cursorDrawable = getTextCursorDrawable();
if(cursorDrawable == null){
cursorDrawable = new PaintDrawable(Color.MAGENTA);
setTextCursorDrawable(cursorDrawable);
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
super.setPointerIcon(null);
}
super.setOnLongClickListener(new OnLongClickListener() {
@Override
public boolean onLongClick(View v) {
return true; //抑制长按出现弹窗的问题
}
});

//禁用ActonMode弹窗
super.setCustomSelectionActionModeCallback(null);

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setBreakStrategy(LineBreaker.BREAK_STRATEGY_SIMPLE);
}
mBoxSpace = (int) dp2px(10f);

}

@Override
public ActionMode startActionMode(ActionMode.Callback callback) {
return null;
}

@Override
public ActionMode startActionMode(ActionMode.Callback callback, int type) {
return null;
}

@Override
public boolean hasSelection() {
return false;
}

@Override
public boolean showContextMenu() {
return false;
}

@Override
public boolean showContextMenu(float x, float y) {
return false;
}

public void setBoxSpace(int mBoxSpace) {
this.mBoxSpace = mBoxSpace;
postInvalidate();
}

public void setInputBoxNum(int inputBoxNum) {
if (inputBoxNum <= 0) return;
this.inputBoxNum = inputBoxNum;
this.inputFilters[0] = new InputFilter.LengthFilter(inputBoxNum);
super.setFilters(inputFilters);
}

@Override
public void setClickable(boolean clickable) {

}

@Override
public void setLines(int lines) {

}
@Override
protected boolean getDefaultEditable() {
return true;
}


@Override
protected void onDraw(Canvas canvas) {

TextPaint paint = getPaint();

float strokeWidth = paint.getStrokeWidth();
if(strokeWidth == 0){
//默认Text是没有strokeWidth的,为了防止绘制边缘存在问题,这里强制设置 1dp
paint.setStrokeWidth(dp2px(1));
strokeWidth = paint.getStrokeWidth();
}
paint.setTextSize(getTextSize());

float boxWidth = (getWidth() - strokeWidth * 2f - (inputBoxNum - 1) * mBoxSpace) / inputBoxNum;
float boxHeight = getHeight() - strokeWidth * 2f;
int saveCount = canvas.save();

Paint.Style style = paint.getStyle();
Paint.Align align = paint.getTextAlign();
paint.setTextAlign(Paint.Align.CENTER);

String text = getText().toString();
int length = text.length();

int color = paint.getColor();

for (int i = 0; i < inputBoxNum; i++) {

inputRect.set(i * (boxWidth + mBoxSpace) + strokeWidth,
strokeWidth,
strokeWidth + i * (boxWidth + mBoxSpace) + boxWidth,
strokeWidth + boxHeight);

paint.setStyle(Paint.Style.STROKE);
paint.setColor(boxColor);
//绘制边框
canvas.drawRoundRect(inputRect, boxRadius, boxRadius, paint);

//设置当前TextColor
int currentTextColor = getCurrentTextColor();
paint.setColor(currentTextColor);
paint.setStyle(Paint.Style.FILL);
if (text.length() > i) {
// 绘制文字,这里我们不过滤空格,当然你可以在InputFilter中处理
String CH = String.valueOf(text.charAt(i));
int baseLineY = (int) (inputRect.centerY() + getTextPaintBaseline(paint));//基线中间点的y轴计算公式
canvas.drawText(CH, inputRect.centerX(), baseLineY, paint);
}

//绘制光标
if(i == length && isCursorVisible && length < inputBoxNum){
Drawable textCursorDrawable = getTextCursorDrawable();
if(textCursorDrawable != null) {
if (!isShowCursor) {
textCursorDrawable.setBounds((int) (inputRect.centerX() - cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f), (int) (inputRect.centerX() + cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f + cursorHeight));
textCursorDrawable.draw(canvas);
isShowCursor = true; //控制光标闪烁 blinking
} else {
isShowCursor = false;//控制光标闪烁 no blink
}
removeCallbacks(invalidateCursor);
postDelayed(invalidateCursor,500);
}
}
}

paint.setColor(color);
paint.setStyle(style);
paint.setTextAlign(align);

canvas.restoreToCount(saveCount);
}


private Runnable invalidateCursor = new Runnable() {
@Override
public void run() {
invalidate();
}
};


//避免paint.getFontMetrics内部频繁创建对象
Paint.FontMetrics fm = new Paint.FontMetrics();

/**
* 基线到中线的距离=(Descent+Ascent)/2-Descent
* 注意,实际获取到的Ascent是负数。公式推导过程如下:
* 中线到BOTTOM的距离是(Descent+Ascent)/2,这个距离又等于Descent+中线到基线的距离,即(Descent+Ascent)/2=基线到中线的距离+Descent。
*/


public float getTextPaintBaseline(Paint p) {
p.getFontMetrics(fm);
Paint.FontMetrics fontMetrics = fm;
return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent;
}

/**
* 控制是否保存完整文本
*
* @return
*/

@Override
public boolean getFreezesText() {
return true;
}

@Override
public Editable getText() {
return (Editable) super.getText();
}

@Override
public void setText(CharSequence text, BufferType type) {
super.setText(text, BufferType.EDITABLE);
}

/**
* 控制光标展示
*
* @return
*/

@Override
protected MovementMethod getDefaultMovementMethod() {
return ArrowKeyMovementMethod.getInstance();
}

@Override
public boolean isCursorVisible() {
return isCursorVisible;
}

@Override
public void setTextCursorDrawable(@Nullable Drawable textCursorDrawable) {
// super.setTextCursorDrawable(null);
this.textCursorDrawable = textCursorDrawable;
postInvalidate();
}

@Nullable
@Override
public Drawable getTextCursorDrawable() {
return textCursorDrawable; //支持android Q 之前的版本
}

@Override
public void setCursorVisible(boolean cursorVisible) {
isCursorVisible = cursorVisible;
}
public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
}

public void setBoxRadius(float boxRadius) {
this.boxRadius = boxRadius;
postInvalidate();
}

public void setBoxColor(int boxColor) {
this.boxColor = boxColor;
postInvalidate();
}

public void setCursorHeight(float cursorHeight) {
this.cursorHeight = cursorHeight;
postInvalidate();
}

public void setCursorWidth(float cursorWidth) {
this.cursorWidth = cursorWidth;
postInvalidate();
}

@Override
public boolean postDelayed(Runnable action, long delayMillis) {
final long DELAY_BEFORE_HANDLE_FADES_OUT = 4000;
if(delayMillis == DELAY_BEFORE_HANDLE_FADES_OUT
&& action.getClass().getName().startsWith("android.widget.Editor$InsertionHandleView$")){
delayMillis = 0;
}
return super.postDelayed(action, delayMillis);
}

}


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

生产环境中的console.log语句会导致内存泄漏,一定不要用!!!

web
前言 如果要在 JS 中找一个用的最多的函数,那一定就是console.log,在前端进行调试时,大家都屡试不爽,都喜欢用的函数。但是在生产环境中使用console.log之类的打印日志,这就会造成内存的泄漏了,这是我们不可以忽视的一个点。 为什么会造成内存泄...
继续阅读 »

前言


如果要在 JS 中找一个用的最多的函数,那一定就是console.log,在前端进行调试时,大家都屡试不爽,都喜欢用的函数。但是在生产环境中使用console.log之类的打印日志,这就会造成内存的泄漏了,这是我们不可以忽视的一个点。


为什么会造成内存泄漏呢?接下来我们来分析分析。


先来这样的一个场景


<body>
<h1 id="app" @click="handleClick"> Hello, console.log</h1>

<script>
const h1 = document.getElementById('app');

h1.addEventListener('click', () => {
const arr = new Array(100000).fill(0);
console.log(arr);
})
</script>
</body>

每当我们点击一次<h1>元素时,就会创建了一个包含 100000 个元素的数组,并将其输出到控制台中。


GIF 2024-4-9 18-20-27.gif


我们知道打印在控制台上的数组,我们是可以将它展开来看见更加详细的内容的,所以造成内存泄漏的原因是什么呢?


按照过程,点击一下,触发一个事件处理函数,待这个函数执行完之后,里面的生成的数组按道理是要销毁掉的,但是因为经过了打印,控制台里面需要保持对这个数组的引用, 不然的话我们就不能展开数组,查看里面的内容了,所以它会一直保存,随着我们点击次数的增多,这样的数组引用次数越来越多,于是就造成了内存泄漏。


接下来我们借助Performance来具体的展示一下是不是这样的情况。


在进行前我们先进行一下垃圾回收(图片中小扫把就是垃圾回收),释放一下内存以便为了更好的观察console.log带来的内存泄漏,然后点击几次h1元素,打印数组,最后再进行一次垃圾回收


GIF 2024-4-9 18-40-21.gif


我们就可以看到,即使我们最后点了垃圾回收,还是存在一部分东西没有被回收,也是占用着内存的,这里指的就是我们打印在控制台的数组了。


0c065197df9c917bb3f467cb7c1ee77.png


我们来个不打印数组的情况看看(操作过程和前面一样,这里只展示最后的结果)


12762d42e5c30b6d6690d79179a1ac9.png


这时我们就可以观察到,内存的增长和下降都是很正常的,每当我们点击一次h1元素,就执行一次事件处理函数,导致内存的占用,可是执行完之后,内存就立马释放出来了。最后点击一次垃圾回收,内存的占用也就和刚刚开始时一样了。


那么说,我们不打开控制台不就不会造成内容泄漏了?那确实,在谷歌浏览器中会进行特殊的处理,并不会造成内存泄漏,但是在别的浏览器中,情况就不一样了。


结尾 🌸🌸🌸


看完这篇文章,我们一定要注意不要在生产环境中使用console.log!不要在生产环境中使用console.log!不要在生产环境中使用console.log!重要的事情说三遍。


但是在开发环境中我们要使用console.log来调试代码怎么办呢?那就需要在打包到生产环境时,把这个console.log给去掉,手动删的话又太麻烦了,这时就可以借助terser工具来帮助我们了。


好的,今日分享到此结束,最后感谢小伙伴的阅读。


作者:Ywis
来源:juejin.cn/post/7355763456081313832
收起阅读 »

Android:监听滑动控件实现状态栏颜色切换

大家好,我是似曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap情有独钟。 今天给大家分享一个平时在滑动页面经常遇到的效果:滑动过程动态修改状态栏颜色。咱们废话不多说,有图有真相,直接上效果图: 看到效果是不是感觉很熟悉,相对而言如果页面顶部有背景色,而滑动...
继续阅读 »

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



今天给大家分享一个平时在滑动页面经常遇到的效果:滑动过程动态修改状态栏颜色。咱们废话不多说,有图有真相,直接上效果图:


1.gif


看到效果是不是感觉很熟悉,相对而言如果页面顶部有背景色,而滑动到底部的时候背景色变为白色或者其他颜色的时候,状态栏颜色不跟随切换颜色有可能会显得难看至极。因此有了上图的效果,其实就是简单的实现了状态栏颜色切换的功能,效果看起来不至于那么尴尬。


首先,我们需要分析,其中需要用到哪些东西呢?



  • 沉浸式状态栏

  • 滑动组件监听


关于沉浸式状态栏,这里推荐使用immersionbar,一款非常不错的轮子。我们只需要将mannifests中主体配置为NoActionBar类型,再根据文档配置好状态栏颜色等属性即可快速得到沉浸式效果:


<style name="Theme.MyApplication" parent="Theme.AppCompat.Light.NoActionBar">

//基础设置
ImmersionBar.with(this)
.navigationBarColor(R.color.color_bg)
.statusBarDarkFont(true, 0.2f)
.autoStatusBarDarkModeEnable(true, 0.2f)//启用自动根据StatusBar颜色调整深色模式与亮式
.autoNavigationBarDarkModeEnable(true, 0.2f)//启用自动根据NavigationBar颜色调整深色式
.init()

//状态栏view
status_bar_view?.let {
ImmersionBar.setStatusBarView(this, it)
}

//xml中状态栏
<View
android:id="@+id/status_bar_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="#b8bfff" />

关于滑动监听,我们都知道滑动控件有个监听滑动的方法OnScrollChangeListener,其中返回了Y轴滑动距离的参数。那么,我们可以根据这个参数进行对应的条件判断以达到动态修改状态栏的颜色。


scroll?.setOnScrollChangeListener { _, _, scrollY, _, _ ->
if (scrollY > linTop!!.height) {
if (!isChange) {
status_bar_view?.setBackgroundColor(
Color.parseColor("#ffffff")
)
isChange = true
}
} else {
if (isChange) {
status_bar_view?.setBackgroundColor(
Color.parseColor("#b8bfff")
)
isChange = false
}
}
}

这里判断滑动距离达到紫色视图末端时修改状态栏颜色。因为是在回调方法中,所以这里一旦滑动就在不停触发,所以给了一个私有属性进行不必要的操作,仅当状态改变时且滑动条件满足时才能修改状态栏。当然在这个方法内大家可以发挥自己的想象力做出更多的新花样来。


注意:



  • 滑动监听的这个方法只能在设备6.0及以上才能使用。

  • 需要初始化滑动控件的默认位置,xml中将焦点设置到其父容器中,防止滑动控件不再初始位置。


//初始化位置
scroll?.smoothScrollTo(0, 0)

//xml中设置父view焦点
android:focusable="true"
android:focusableInTouchMode="true"

好了,以上便是滑动控件中实现状态栏切换的简单实现,希望对大家有所帮助。


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

更适合年轻人体质的 git 工作流

关于如何使用 git,相信大家都见过下面这张图: 很多人都学习过这张图上的流程并应用在实际工作中,但是慢慢就发现,用起来好像有点不对劲:令人困惑的合并冲突,每次发版前都需要找哪些 commit 需要发布等等。然后突然发现,诶这套流程好像用起来也不太爽,不知道...
继续阅读 »

关于如何使用 git,相信大家都见过下面这张图:


image.png


很多人都学习过这张图上的流程并应用在实际工作中,但是慢慢就发现,用起来好像有点不对劲:令人困惑的合并冲突,每次发版前都需要找哪些 commit 需要发布等等。然后突然发现,诶这套流程好像用起来也不太爽,不知道有没有更好的流程可以用。本篇文章就来聊一聊这个问题。


现有 git flow 存在的问题


首先我们来分析一下上面这个流程中都存在哪些问题:


feature 分支要从 dev 分支创建,怎么保证代码是干净的?


举个例子,你要开发一个新功能,从 dev 切出一个新分支后之后发现怎么都跑不起来,群里问了一圈发现有人提交到 dev 的代码有问题,于是你就等到他重新提交了一个 commit 之后,你拉了下代码,这才开始正常开发。


发版时需要从 dev 分支创建 release 分支,怎么保证代码都是干净的?


再举个例子,本轮迭代共提交了 20 个 commit,其中 16 个 commit 需要发布,剩下 4 个 commit 因为还没测试完、bug 没改完不能发。这时候你能准确的把要发布的 commit 检出来么?


如果可以的话,咱们更进一步,本轮迭代由五位同事提交了 40 个 commit,在发版的时候其中两个请假了,这时候你能准确的知道哪些 commit 是要发布的,并准确将其检出来么?


如果还可以的话,那就更更进一步,你检出来之后,发布到 uat 环境,发现代码跑不起来了,结果发现,有个同事偷懒了,某个 commit 因为功能没开发完所以没有检进来,但是恰好这个 commit 里又包含了一些非常关键的代码,没有就跑不起来,这时候你会怎么做?


需要保持 dev 分支和 master 分支的同步,不同步的话可能导致合并冲突


回忆一下,你之前有没有处理过这种合并冲突:冲突的两方代码是完全一样的,但就是冲突了。


这种就是使用了 rebase(非 fast-forward)或者 cherry-pick 后导致的,因为这两种方法会产生代码完全一样,但是 id 不同的新 commit。就导致了 git 产生了混乱。


一个常见场景就是 hotfix 分支的 commit,合并到 master 之后又 cherry-pick 到了 dev 分支。这样下次再从 dev 往 master 合的时候就会出现这种问题。




不知道你什么感受,反正我是已经开始汗流浃背了,那么有没有更简单、更高效、心智负担更低的 git 工作流能解决这些问题呢?当然是有的。


正式介绍一下新的 git flow


首先我们还是以流程图的形式展示一下新的 flow:


image.png


和原本 git flow 的区别在于:



  • feature 分支不再从 dev 创建,而是从最稳定的 master 分支创建

  • dev 分支的代码不再向 release 分支合并,由 feature 直接发起到 release 的合并。

  • 当 release 分支测试完成要发版的时候,直接 fast-forward 到 master

  • 定期删除 dev 和 release,然后从 master 创建新的(例如每轮迭代结束之后)


那么这套工作流能解决刚才提到的问题么?答案是肯定的,老的工作流中存在的问题主要就是:


dev 分支过于重要


dev 需要接受来自多个 feat 以及 hotfix、master 的合并,并合并到 release 分支,这就会导致 dev 分支出现冲突的概率是成倍增加的。开发人员越多,其中存在的脏代码就越多,分支就越不稳定,冲突的情况就越多。


而这套新流程中 dev 的职责被弱化了,变得更加纯粹,即只对接测试环境的发布,其他的工作一概不管。也就是说 dev 本身就是合并路径的终点,从而消除了合并 commit 的回环,干掉了很多可能会产生迷惑冲突的场景。


从普通开发人员的视角看一下


现在我们从头开始,以普通开发的身份来走一遍这套流程,看会有什么效果:



  • 昨天版本发布了,master 代码上有了新的 commit,于是你执行了 git fetch会把远程的代码都同步到本地,比如远程的 master 分支同步到本地的 origin/master

  • 早上开会的时候给你安排了功能 a 和功能 b,你决定先做 a,于是你执行了 git checkout -b feat/a origin/master从刚才拉下来的 origin/master 分支创建了一个新分支

  • 你开始开发,随着开发进度的增加,中间可能执行了多次 git addgit commit

  • 几个小时后终于把功能做好了,自测也没问题,你决定发到测试环境让 QA 同事看一下,于是你执行了 git push 并且在远程仓库里提交了 feat/a 到 dev 分支的 pr,合并完成后流水线自动把代码发布到了测试环境。

  • 通知了 QA 之后,你决定开始开发 b 功能,于是你执行了 git checkout -b feat/b origin/master,然后开始开发。

  • 突然 QA 通知你功能 a 有 bug 需要修复,于是你执行了 git stash 把当前手头的工作暂存了起来,然后 git checkout feat/a 开始解决 bug。

  • 解决完了之后,你重新 git commitgit push 到了 dev 分支,QA 开始继续测试,你也切回了 feat/b 分支并 git stash pop 开始继续开发。

  • 过了一会,QA 通知你功能 a 测试没问题了,于是你在远程仓库里找到 feat/a 分支,并直接发起了一个到 release 分支的 pr。此时 release 分支触发了流水线,将功能 a 的代码更新到了预发环境。

  • 搞完之后,你切回 feat/b 分支继续开始功能 b 的开发...


故事到这里就结束了,你可能会好奇:版本发布的时候呢?不需要执行什么操作?


是的不需要。这套流程中发布生产环境极其简单。因为功能测试完成后会直接推到 release 分支。也就是说,只要和 release 分支绑定的环境(例如 uat)测试没问题,那么发布的时候只需要把 release 合并到 master 就行了。不会出现之前那种要在发版前检查很久要发布哪些 commit 的情况。


一些疑问解答


在实践过程中也有很多同事对这套流程产生了或多或少的疑问,这里就记录一下,希望对大家有帮助:


1、代码提交到 release 分支后出现 bug 怎么办?


切换到对应的分支(例如 feat/c),提交新的 commit 之后从 feat/c 合并到 dev,dev 测试没问题后从 feat/c 合并到 release 分支。


2、feat 分支合并到 dev 分支的时候代码冲突了怎么办?



首先,代码冲突很正常,没有任何一个工作流能完全避免代码冲突。我们应该尽力避免因工作流本身的问题产生的“令人困惑”的代码冲突。



比较正规的做法是:从最新的 dev 创建一个新分支,例如 dev-feat/a,然后把你的 feat/a 本地合并到 dev-feat/a 并解决冲突,然后 git push dev-feat/a 并在远程仓库发起 dev-feat/a 到 dev 的 pr。


比较随性的做法是:本地切到 dev 分支,git pull --rebase 拉取最新代码,然后直接 git rebase feat/a 解决冲突后直接 git push 到远程仓库的 dev 分支。


有些人可能会有疑问:"直接 push 到这种环境分支没问题么,之前我们这种分支都是写保护的,只能接受 pr"。


确实,老的工作流对环境分支的保护都是比较严格的,但是这一套工作流没有这些限制,因为最遭的情况也就是你把 dev 分支搞崩了。那直接把远程 dev 分支删掉再从 master 或者 release 分支拉一个就完事了嘛,反正大家的功能都在各自的 feat 分支上。再极端一点,只要你不搞坏其他人的代码,你就算直接 git push --force 强制推送到 dev 分支都没问题。


3、同事 A 和 B 的新功能要基于同事 C 的新代码,这时候怎么办?


假设同事 C 开发的功能在 feat/c,那么同事 A 和 B 的分支就应该从 feat/c 创建并继续开发。而不是等同事 C 合并到 dev 之后再从 dev 创建。


4、既然是 feat 直接合并到指定分支,那么为什么最后一步不是 feat 分支合并到 master 分支呢?


因为这套流程里,最重要的就是保证 master 分支的稳定性。所以 master 分支上的代码必须是经过严格验证的。


并且如果 feat 直接合到 master 的话还会导致一些其他的问题:



  • 有一个同事比较粗心,在提交 pr 的时候本来该合到 dev 分支,结果一不小心点到了 master,审核的人有不注意直接点了同意,这时候 master 就被污染了。

  • 合并到 dev 时如果出现合并冲突的话,那么合并到 release 分支大概率也会再出现一遍,你总不会想合到 master 的时候去解决第三遍吧,而且也无法保证冲突的解决一定是不会出问题的。


所以说,最稳妥,最省心的做法就是直接把 release 分支的代码合并到 master。


5、hotfix master 怎么办?


git flow 里 hotfix 分支中的 commit 一方面要合并到 master,另一方面要同步到 dev。但是由于后续 dev 也要再次更新到 master,这个 hotfix 的 commit 就可能会导致困惑冲突。


但是这套新流程里就不会出现冲突,因为 dev 分支自己就已经是终点了,不会合并到其他分支。所以 hotfix 里的提交无论怎么合并到 dev,不管是 merge、rebase 还是 cherry-pick,都是可以的。甚至不用管也没关系,因为只要是新 feat 合并到 dev,这个 hotfix commit 就被自动携带过来了。


总结


其实这一套工作流其实是 gitlab flow + git flow 的一个调优,使其在保证效率的同时更贴近 git 新手的心理认知。总结一下就是 dev 分支并不会“晋升”到 release 分支。而是由 feat 分支发起到 release 分支的合并,同时 master 只接受来自 release 的合并,由此减少了很多需要遵守的规则和发生冲突的情况。


参考



作者:HOHO
来源:juejin.cn/post/7355845860683202595
收起阅读 »

Android 切换主题时如何恢复 Dialog?

我们都知道,Android 在横竖屏切换、主题切换、语言等操作时,系统会 finish Activity ,然后重建,这样便可以重新加载配置变更后的资源。 如果你只有 Activity 的内容需要展示,那这样处理是没有问题的,但是如果界面在点击操作之后打开一个...
继续阅读 »

我们都知道,Android 在横竖屏切换、主题切换、语言等操作时,系统会 finish Activity ,然后重建,这样便可以重新加载配置变更后的资源。


如果你只有 Activity 的内容需要展示,那这样处理是没有问题的,但是如果界面在点击操作之后打开一个 Dialog,那在配置改变后这个 Dialog 还会在么?答案是不一定,我们来看看展示 Dialog 有几种方式。


Dilog#show()


这可能是大家比较常用的方法,创建一个 Dialog ,然后调用其 show 方法,就像这样。


class MainActivity : AppCompatActivity() {  
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

findViewById<View>(R.id.tvDialog).setOnClickListener {
AlertDialog.Builder(this)
.setView(R.layout.test_dialog)
.show()
}
}
}

每次点击按钮会创建一个新的 Dialog 对象,然后调用 show 方法展示。我们来看看配置改变后,Dialog 的表现是怎样的。


video2.gif


通过视频我们可以看到,在切换横竖屏或主题时,Dialog 都没有恢复。这是因为Dialog#show这种方式是开发者自己管理 Dialog,所以在恢复 Activity 时,Activity 是不知道需要恢复 Dialog 的。那怎么让 Activity 知道当前展示了 Dialog 呢?那就需要用到下面的方式。


Activity#showDialog()


先来看看此方法的注释



Show a dialog managed by this activity. A call to onCreateDialog(int, Bundle) will be made with the same id the first time this is called for a given id. From thereafter, the dialog will be automatically saved and restored. If you are targeting Build.VERSION_CODES.HONEYCOMB or later, consider instead using a DialogFragment instead.
Each time a dialog is shown, onPrepareDialog(int, Dialog, Bundle) will be made to provide an opportunity to do any timely preparation.



简单来说这个方法会让 Activity 来管理需要展示的 Dialog,会跟 onCreateDialog(int, Bundle)成对出现,并且会保存这个 Dialog,在重复调用Activity#showDialog()时不会重复创建 Dialog 对象。Activity 自己管理 Dialog?那就能恢复了吗?我们来试试。


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

findViewById<View>(R.id.tvDialog).setOnClickListener {
showDialog(100) //自定义 id
}
}

override fun onCreateDialog(id: Int): Dialog? {
if(id == 100){ // id 与 showDialog 匹配
return AlertDialog.Builder(this)
.setView(R.layout.test_dialog)
.create()
}
return super.onCreateDialog(id)
}

代码很简单,调用 Activity#showDialog(int id)方法,然后重写 Activity#onCreateDialog(id:Int),匹配两边的 id 就可以了。我们来看看效果。


video3.gif


我们可以看到,确实切换主题后 Dialog 是恢复了的,不过还有个问题,就是这个 ScrollView 的状态没有恢复,滑动的位置被还原了,难道我们需要手动记住滑动的 position 然后再恢复?是的,不过这个操作 Android 已经替我们做了,我们需要做的就是给需要恢复的组件指定一个 id 就行。


<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"  
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="200dp">


<ScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="300dp"
android:scrollbars="vertical"
android:scrollbarSize="10dp"
android:background="@color/primary_background">


<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">


<TextView
android:id="@+id/tvContent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:text="@string/test_content"
android:textAlignment="center"
android:textSize="30sp"
android:textColor="@color/primary_text"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent" />


</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</FrameLayout>

刚刚 ScrollView 标签是没有 id 的,现在我们加了一个 id 再看看效果。


video4.gif


是不是很方便?这是什么原理呢?主要是两个方法,如下:


public void saveHierarchyState(SparseArray<Parcelable> container) {  
dispatchSaveInstanceState(container);
}

protected void dispatchSaveInstanceState(SparseArray<Parcelable> container) {
if (mID != NO_ID && (mViewFlags & SAVE_DISABLED_MASK) == 0) {
mPrivateFlags &= ~PFLAG_SAVE_STATE_CALLED;
Parcelable state = onSaveInstanceState();
if ((mPrivateFlags & PFLAG_SAVE_STATE_CALLED) == 0) {
throw new IllegalStateException(
"Derived class did not call super.onSaveInstanceState()");
}
if (state != null) {
// Log.i("View", "Freezing #" + Integer.toHexString(mID)
// + ": " + state);
container.put(mID, state);
}
}
}

public void restoreHierarchyState(SparseArray<Parcelable> container) {
dispatchRestoreInstanceState(container);
}

protected void dispatchRestoreInstanceState(SparseArray<Parcelable> container) {
if (mID != NO_ID) {
Parcelable state = container.get(mID);
if (state != null) {
// Log.i("View", "Restoreing #" + Integer.toHexString(mID)
// + ": " + state);
mPrivateFlags &= ~PFLAG_SAVE_STATE_CALLED;
onRestoreInstanceState(state);
if ((mPrivateFlags & PFLAG_SAVE_STATE_CALLED) == 0) {
throw new IllegalStateException(
"Derived class did not call super.onRestoreInstanceState()");
}
}
}
}


在 Actvity 执行 onSaveInstance 时,会保存 View 的层级状态,View 的 id 为 key,状态为 value,这样的一个SparseArray,View 的状态是在 View 的 onSaveInstance 方法生成的,所以,如果 View 没有重写 onSaveInstance时,就算指定了 id 也不会被恢复。我们来看看 ScrollView#onSaveInstance做了什么工作。


protected Parcelable onSaveInstanceState() {  
if (mContext.getApplicationInfo().targetSdkVersion <= Build.VERSION_CODES.JELLY_BEAN_MR2) {
// Some old apps reused IDs in ways they shouldn't have.
// Don't break them, but they don't get scroll state restoration.
return super.onSaveInstanceState();
}
Parcelable superState = super.onSaveInstanceState();
SavedState ss = new SavedState(superState);
ss.scrollPosition = mScrollY;
return ss;
}

ss.scrollPosition = mScrollY关键代码就是这一句,保存了 scrollPosition,恢复的逻辑就是在onRestoreInstance大家可以自己看看,逻辑比较简单,我这边就不列了。


Activity 如何恢复 Dialog?


配置变化后的恢复都会依赖onSaveInstanceonRestoreInstance,Dialog 也不例外,不过 Dialog 这两个流程都依赖 Activity,我们来完整过一遍 onSaveInstance 的流程,saveInstanceActivity#performSaveInstanceState开始.


Activity.java


/**  
* The hook for {@link ActivityThread} to save the state of this activity.
*
* Calls {@link #onSaveInstanceState(android.os.Bundle)}
* and {@link #saveManagedDialogs(android.os.Bundle)}.
*
* @param outState The bundle to save the state to.
*/

final void performSaveInstanceState(@NonNull Bundle outState) {
dispatchActivityPreSaveInstanceState(outState);
onSaveInstanceState(outState);
saveManagedDialogs(outState);
mActivityTransitionState.saveState(outState);
storeHasCurrentPermissionRequest(outState);
if (DEBUG_LIFECYCLE) Slog.v(TAG, "onSaveInstanceState " + this + ": " + outState);
dispatchActivityPostSaveInstanceState(outState);
}

/**
* Save the state of any managed dialogs.
*
* @param outState place to store the saved state.
*/

@UnsupportedAppUsage
private void saveManagedDialogs(Bundle outState) {
if (mManagedDialogs == null) {
return;
}
final int numDialogs = mManagedDialogs.size();
if (numDialogs == 0) {
return;
}
Bundle dialogState = new Bundle();
int[] ids = new int[mManagedDialogs.size()];
// save each dialog's bundle, gather the ids
for (int i = 0; i < numDialogs; i++) {
final int key = mManagedDialogs.keyAt(i);
ids[i] = key;
final ManagedDialog md = mManagedDialogs.valueAt(i);
dialogState.putBundle(savedDialogKeyFor(key), md.mDialog.onSaveInstanceState());
if (md.mArgs != null) {
dialogState.putBundle(savedDialogArgsKeyFor(key), md.mArgs);
}
}
dialogState.putIntArray(SAVED_DIALOG_IDS_KEY, ids);
outState.putBundle(SAVED_DIALOGS_TAG, dialogState);
}

saveManagedDialogs这个方法就是处理 Dialog 的流程,我们可以看到它会调用 md.mDialog.onSaveInstanceState(),来保存 Dialog 的状态,而这个md.mDialog就是在showDialog时保存的


public final boolean showDialog(int id, Bundle args) {  
if (mManagedDialogs == null) {
mManagedDialogs = new SparseArray<ManagedDialog>();
}
ManagedDialog md = mManagedDialogs.get(id);
if (md == null) {
md = new ManagedDialog();
md.mDialog = createDialog(id, null, args);
if (md.mDialog == null) {
return false;
}
mManagedDialogs.put(id, md);
}
md.mArgs = args;
onPrepareDialog(id, md.mDialog, args);
md.mDialog.show();
return true;
}

这样流程就能串起来了吧,用Activity#showDialog关联 Activity 与 Dialog,在 Activity onSaveInstance 时会调用 Dialog#onSaveInstance保存状态,而不管在 Activity 或 Dialog 的 onSaveInstance 里都会执行View#saveHierarchyState来保存视图层级状态,这样不管是 Activity 还是 Dialog 亦或是 View 便都可以恢复啦。


不过以上描述的恢复,恢复的都是 Android 原生数据,如果你需要恢复业务数据,那就需要自己保存啦,不过 Google 也为我们提供了解决方案,就是 Jetpack ViewModel,对吧?


这样通过 ViewModel 和 SaveInstance 就可以恢复所有业务和视图状态了!


总结


到这边,关于如何恢复 Dialog 的主要内容就分享完了,需要多说一句的是,Activity#showDialog方法已被标记为废弃。



Use the new DialogFragment class with FragmentManager instead; this is also available on older platforms through the Android compatibility package.



原理都是一样,大家可以根据自己的需要选择。


作者:PuddingSama
来源:juejin.cn/post/7246293244636004409
收起阅读 »

RecyclerView刷新后定位问题

问题描述做需求开发时,遇到RecyclerView刷新时,通常会使用notifyItemXXX方法去做局部刷新。但是刷新后,有时会遇到RecyclerView定位到我们不希望的位置,这时候就会很头疼。这周有时间深入了解了下RecyclerView的源码,大致梳...
继续阅读 »

问题描述

做需求开发时,遇到RecyclerView刷新时,通常会使用notifyItemXXX方法去做局部刷新。但是刷新后,有时会遇到RecyclerView定位到我们不希望的位置,这时候就会很头疼。这周有时间深入了解了下RecyclerView的源码,大致梳理清楚刷新后位置跳动的原因了。

原因分析

先简单描述下RecyclerView在notify后的过程:

  1. 根据是否是全量刷新来选择触发RecyclerView.RecyclerViewDataObserver的onChanged方法或onItemRangeXXX方法

onChanged会直接调用requestlayout来重新layuout。 onItemRangeXXX会先把刷新数据保存到mAdapterHelper中,然后再调用requestlayout

  1. 进入dispatchLayout流程 这一步分为三个步骤:
  • dispatchLayoutStep1:处理adapter的更新、决定哪些view执行动画、保存view的信息
  • dispatchLayoutStep2:真正执行childView的layout操作
  • dispatchLayoutStep3:触发动画、保存状态、清理信息

需要注意的是,在onMeasure的过程中,如果传入的measureMode不是exactly,会去调用dispatchLayoutStep1和dispatchLayoutStep2从而取得真正需要的宽高。 所以在dispatchLayout会先判断是否需要重新执行dispatchLayoutStep1和dispatchLayoutStep2

重点分析dispatchLayoutStep2这一步: 核心操作在 mLayout.onLayoutChildren(mRecycler, mState)这一行。以LinearLayoutManager为例继续往下挖:

public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
...
final View focused = getFocusedChild();
if (!mAnchorInfo.mValid || mPendingScrollPosition != RecyclerView.NO_POSITION
|| mPendingSavedState != null) {
mAnchorInfo.reset();
mAnchorInfo.mLayoutFromEnd = mShouldReverseLayout ^ mStackFromEnd;
// 关键步骤1,寻找锚点View位置
updateAnchorInfoForLayout(recycler, state, mAnchorInfo);
mAnchorInfo.mValid = true;
} else if (focused != null && (mOrientationHelper.getDecoratedStart(focused)
>= mOrientationHelper.getEndAfterPadding()
|| mOrientationHelper.getDecoratedEnd(focused)
<= mOrientationHelper.getStartAfterPadding())) {
mAnchorInfo.assignFromViewAndKeepVisibleRect(focused, getPosition(focused));
}
...
// fill towards end
updateLayoutStateToFillEnd(mAnchorInfo);
mLayoutState.mExtraFillSpace = extraForEnd;
//关键步骤2,从锚点View位置往后填充
fill(recycler, mLayoutState, state, false);
endOffset = mLayoutState.mOffset;
final int lastElement = mLayoutState.mCurrentPosition;
if (mLayoutState.mAvailable > 0) {
//如果锚点位置后面数据不足,无法填满剩余的空间,那把剩余空间加到顶部
extraForStart += mLayoutState.mAvailable;
}
// fill towards start
updateLayoutStateToFillStart(mAnchorInfo);
mLayoutState.mExtraFillSpace = extraForStart;
mLayoutState.mCurrentPosition += mLayoutState.mItemDirection;
//关键步骤3,从锚点View位置向前填充
fill(recycler, mLayoutState, state, false);
startOffset = mLayoutState.mOffset;

if (mLayoutState.mAvailable > 0) {
//如果锚点View位置前面数据不足,那把剩余空间加到尾部再做一次尝试
extraForEnd = mLayoutState.mAvailable;
// start could not consume all it should. add more items towards end
updateLayoutStateToFillEnd(lastElement, endOffset);
mLayoutState.mExtraFillSpace = extraForEnd;
fill(recycler, mLayoutState, state, false);
endOffset = mLayoutState.mOffset;
}
}

先解释一下锚点View,锚点View在一次layout过程中的位置不会发生变化,即之前在哪里显示,这次layout完还在哪,从视觉上看没有位移。

总结一下,mLayout.onLayoutChildren主要做了以下几件事:

  1. 调用updateAnchorInfoForLayout方法确定锚点view位置
  2. 从锚点view后面的位置开始填充,直到后面空间被填满或者已经遍历到最后一个itemView
  3. 从锚点view前面的位置开始填充,直到空间被填满或者遍历到indexe为0的itemView
  4. 经过第三步后仍有剩余空间,则把剩余空间加到尾部再做一次尝试

所以回到一开始的问题,RecyclerView在notify之后位置跳跃的关键在于锚点View的确定,也就是updateAnchorInfoForLayout方法,所以下面重点看下这个方法:

private void updateAnchorInfoForLayout(RecyclerView.Recycler recycler, RecyclerView.State state,
AnchorInfo anchorInfo)
{
if (updateAnchorFromPendingData(state, anchorInfo)) {
if (DEBUG) {
Log.d(TAG, "updated anchor info from pending information");
}
return;
}

if (updateAnchorFromChildren(recycler, state, anchorInfo)) {
if (DEBUG) {
Log.d(TAG, "updated anchor info from existing children");
}
return;
}
if (DEBUG) {
Log.d(TAG, "deciding anchor info for fresh state");
}
anchorInfo.assignCoordinateFromPadding();
anchorInfo.mPosition = mStackFromEnd ? state.getItemCount() - 1 : 0;
}

这个方法比较短,所以代码全贴出来了。如果是调用了scrollToPosition后的刷新,会通过updateAnchorFromPendingData方法确定锚点View位置,否则调用updateAnchorFromChildren来计算:

private boolean updateAnchorFromChildren(RecyclerView.Recycler recycler,
RecyclerView.State state, AnchorInfo anchorInfo) {
if (getChildCount() == 0) {
return false;
}
final View focused = getFocusedChild();
if (focused != null && anchorInfo.isViewValidAsAnchor(focused, state)) {
anchorInfo.assignFromViewAndKeepVisibleRect(focused, getPosition(focused));
return true;
}
if (mLastStackFromEnd != mStackFromEnd) {
return false;
}
View referenceChild =
findReferenceChild(
recycler,
state,
anchorInfo.mLayoutFromEnd,
mStackFromEnd);
if (referenceChild != null) {
anchorInfo.assignFromView(referenceChild, getPosition(referenceChild));
...
return true;
}
return false;
}

代码比较简单,如果有焦点View,并且焦点View没被remove,则使用焦点View作为锚点。否则调用findReferenceChild来查找:

View findReferenceChild(RecyclerView.Recycler recycler, RecyclerView.State state,
boolean layoutFromEnd, boolean traverseChildrenInReverseOrder)
{
ensureLayoutState();

// Determine which direction through the view children we are going iterate.
int start = 0;
int end = getChildCount();
int diff = 1;
if (traverseChildrenInReverseOrder) {
start = getChildCount() - 1;
end = -1;
diff = -1;
}

int itemCount = state.getItemCount();

final int boundsStart = mOrientationHelper.getStartAfterPadding();
final int boundsEnd = mOrientationHelper.getEndAfterPadding();

View invalidMatch = null;
View bestFirstFind = null;
View bestSecondFind = null;

for (int i = start; i != end; i += diff) {
final View view = getChildAt(i);
final int position = getPosition(view);
final int childStart = mOrientationHelper.getDecoratedStart(view);
final int childEnd = mOrientationHelper.getDecoratedEnd(view);
if (position >= 0 && position < itemCount) {
if (((RecyclerView.LayoutParams) view.getLayoutParams()).isItemRemoved()) {
if (invalidMatch == null) {
invalidMatch = view; // removed item, least preferred
}
} else {
// b/148869110: usually if childStart >= boundsEnd the child is out of
// bounds, except if the child is 0 pixels!
boolean outOfBoundsBefore = childEnd <= boundsStart && childStart < boundsStart;
boolean outOfBoundsAfter = childStart >= boundsEnd && childEnd > boundsEnd;
if (outOfBoundsBefore || outOfBoundsAfter) {
// The item is out of bounds.
// We want to find the items closest to the in bounds items and because we
// are always going through the items linearly, the 2 items we want are the
// last out of bounds item on the side we start searching on, and the first
// out of bounds item on the side we are ending on. The side that we are
// ending on ultimately takes priority because we want items later in the
// layout to move forward if no in bounds anchors are found.
if (layoutFromEnd) {
if (outOfBoundsAfter) {
bestFirstFind = view;
} else if (bestSecondFind == null) {
bestSecondFind = view;
}
} else {
if (outOfBoundsBefore) {
bestFirstFind = view;
} else if (bestSecondFind == null) {
bestSecondFind = view;
}
}
} else {
// We found an in bounds item, greedily return it.
return view;
}
}
}
}
// We didn't find an in bounds item so we will settle for an item in this order:
// 1. bestSecondFind
// 2. bestFirstFind
// 3. invalidMatch
return bestSecondFind != null ? bestSecondFind :
(bestFirstFind != null ? bestFirstFind : invalidMatch);
}

解释一下,查找过程会遍历RecyclerView当前可见的所有childView,找到第一个没被notifyRemove的childView就停止查找,否则会把遍历过程中找到的第一个被notifyRemove的childView作为锚点View返回。

这里需要注意final int position = getPosition(view);这一行代码,getPosition返回的是经过校正的最终position,如果ViewHolder被notifyRemove了,这里的position会是0,所以如果可见的childView都被remove了,那最终定位的锚点View是第一个childView,锚点的position是0,偏移量offset是这个被删除的childView的top值,这就会导致后面fill操作时从位置0开始填充,先把position=0的view填充到偏移量offset的位置,再往后依次填满剩余空间,这也是导致画面上的跳动的根本原因。


作者:Ernest912
来源:juejin.cn/post/7259358063517515834

收起阅读 »

降本增笑,领导要求程序员半年做出一个金蝶

关于降本增效的事,今天在网上看到一个帖子,虽然有点搞笑,但是对于打工人(特别是技术同学)来说,可挖掘的东西也不少,特别分享给大家。 真的是好多年没听说这样的事了,记得以前总有老板让做个淘宝、做个京东,然后预算只有几千块,最多几万块。 这些故事程序员谈起来往往...
继续阅读 »

关于降本增效的事,今天在网上看到一个帖子,虽然有点搞笑,但是对于打工人(特别是技术同学)来说,可挖掘的东西也不少,特别分享给大家。



真的是好多年没听说这样的事了,记得以前总有老板让做个淘宝、做个京东,然后预算只有几千块,最多几万块。


这些故事程序员谈起来往往都是哈哈一笑,并疯狂吐槽一番。



不过笑过之后,大家是否想过如何去解决问题?或者真的去评估下可行性,探索一下可能的实现路径。


找到问题


首先我们看下老板的问题。老板的根本问题并不是想要做金蝶,为什么这么说呢?


我们看看网友的描述就知道了:经济下行,领导不想出金蝶系统的维护费,不想为新功能花大价钱。这才是根本问题,用四个字来说就是:降低成本。


然后才是老板想到能不能用更少的钱达到金蝶系统的使用效果,再之后才是自己能不能做一个类似金蝶的系统,并思考了自己可以承担的成本:一个前端、一个后端,半年时间。


最后问题被抛到了这位网友的手里。可以看得出来这位网友也不太懂,还去咨询了朋友。不知道它有没有向朋友说清楚问题,还是只说了老板想自己做一个金蝶系统,结果是朋友们都说不可行。


遇到问题时,我们得把这个问题完完整整的捋一遍,找到最根本的问题,然后再说怎么解决问题,否则只是停留在表面,就可能事倍功半。在这个上下文中,根本的问题就是:降低成本。



解决问题


明确了老板的根本问题,我们就可以琢磨方案了。既然是降低财务系统的成本,可行的方案应该还是有几个的。


使用替代产品


假如公司只使用产品的部分功能,是不是可以选择金蝶的低版本?是不是可以降低一些人头费?


金蝶的服务贵,是不是可以选择一些小厂的产品?国内做财务系统的应该挺多的,小厂也更容易妥协。


或者选择SaaS服务,虽然SaaS用久了成本也不低,但是可以先撑过这几年,降低当前的支出。


当然替换财务系统也是有成本的,需要仔细评估。不过既然都想自己做了,这个成本应该能hold住。


找第三方维护


金蝶的服务贵,是不是可以找其它三方或者个人来维护修改?根据我之前的了解,金蝶这种公司有很多的实施工作是外包出去的,或者通过代理商来为客户服务,能不能找这些服务商来代替金蝶呢?或者去某些副业平台上应该也能找到熟悉金蝶系统的人。


当然这个还要看系统能不能顺利交接,金蝶有没有什么软硬件限制,第三方能不能接过来。


另外最重要的必须考虑系统和数据的安全性,不能因小失大。


自己开发


虽然自己开发的困难和成本都很高,但我仍旧认为这可能也是一个合适的解决方案。理由有下面两点。



  • 功能简单:如果公司的业务比较简单,使用的流程也简单,比如不使用涉及复杂的财务处理,那么捋一捋,能给开发人员讲清楚,也是有可能在短时间内完成的。

  • 迭代渐进:长城不是一天建成的,系统也都是逐渐迭代完善的。自己开发可以先从部分模块或者功能开始,然后逐步替换,比如前边的流程先在新系统中做,最后再导入金蝶。即使不能做到逐步替换,也可以控制系统的风险,发现搞不定时,及时止损。相信老板也能明白这个道理,如果不明白或者不接受,那确实搞不了。



当然我们也肯定不能忽视这其中的困难。我之前做过和金蝶系统的对接,订单的收付款在业务系统完成,然后业务系统生成凭证导入到金蝶K3。依稀记得业务也不算复杂,但是需求分析做了好几遍,我的代码也是改了又改,上线之后遇到各种问题,继续改,最终花了几个月才稳定下来。


事后分析原因,大概有这么几点:



  • 产品或者需求分析人员没接触过类似的业务,即使他对财务系统有一些经验,也不能准确的将客户的业务处理方式转换到产品设计中;

  • 财务人员说不明白,虽然他会使用金蝶系统,但是他不能一次性的把所有规则都讲出来,讲出来也很难让程序员在短时间内理解;

  • 程序员没做过财务系统,没接触过类似的业务,系统的设计可能要反复调整,比如业务模块的划分逻辑,金额用Long还是用BigDecimal,数据保留几位小数,这都会大幅延长开发周期,如果不及时调整就可能写成一锅粥,后期维护更困难。


这还只是和金蝶系统做一个简单的对接,如果要替代它,还要实现更多的功能,总结下,企业可能会面对下面这些困难:


业务复杂:财务规则一般都比较复杂,涉及到各种运算,各种数字、报表能把人搞晕。如果公司的业务也很复杂,比如有很多分支或者特殊情况,软件开发的难度也会很大,这种难度的变化不是线性增加的,很可能是指数级增长的,一个工作流的设计可能就把人搞死了。


懂业务的人:系统过于复杂时,可能没有一个人能把系统前前后后、左左右右的整明白。而要完成这样一个复杂的系统,必须有人能从高层次的抽象,到具体数字运算的细枝末节,完完全全的整理出来,逻辑自洽,不重不漏,并形成文档,还要能把程序员讲明白。


懂架构的人:这里说的是要有一个经验丰富的程序员,不能是普通的码农,最好是有财务系统开发经验的架构师。没走过的路,总是要踩坑的。有经验的开发人员可以少走很多弯路,极大降低系统的风险。这样的人才如果公司没有,外招的难度比较大,即使能找到,成本也不低。


灵活性问题:开发固定业务流程的系统一般不会太考虑灵活性的问题,如果业务需要调整,可能需要对系统进行大幅修改,甚至推倒重来。如果要让系统灵活些,必然对业务和技术人员都提出了更高的要求,也代表着更强的工作能力和更多的工作量。


和其它系统的对接:要不要和税务系统对接?要不要和客户管理系统对接?要不要和公司的OA对接?每一次对接都要反复调试,工作量肯定下不来。


总之,稍微涉及到财务处理的系统,都不是一个前端和一个后端能在短时间内完全搞出来的。


对程序开发的启示


搞清楚需求


日常开发过程中,大家应该都遇到过不少此类问题。领导说这里要加个功能,然后产品和开发就去吭哧吭哧做了,做完了给领导一看,不是想要的,然后返工反复修改。或者说用户提了一个需求,产品感觉自己懂了,然后就让开发这么做那样改,最后给用户一看,什么破玩意。这都是没有搞清楚真正的需求,没有触达那个根本问题。


虽然开发人员可以把这些问题全部甩给产品,自己只管实现,但这毕竟实实在在的消耗了程序员的时间,大量的时间成本和机会成本,去干点有意义的事情不好吗?所以为了不浪费时间,开发也要完整的了解用户需求。在一个团队中,至少影响产品落地的关键开发人员要搞懂用户的需求。


那么遇到这种问题,程序员是不是可以直接跑路呢?


也是一个选择, 不过对于一个有追求的程序员,肯定也是想把程序设计好、架构好的,能解决实际问题的,这也需要对用户需求的良好把控能力,比如我们要识别出哪些是系统的核心模块,哪些是可扩展能力,就像设计冯诺依曼计算机,你设计的时候会怎么处理CPU和输入输出设备之间的关系呢?


对于用户需求,产品想的是怎么从流程设计上去解决,开发需要考虑的是怎么从技术实现上去满足,两者相向而行,才能把系统做好。


当然准确把握用户的需求,很多时候并不是我说的这么容易,因为用户可能也说不清楚,我们可能需要不断的追问才能得到一些关键信息。比如这位网友去咨询朋友时,可能需求就变成了:我们要做一个财务系统,朋友如果不多问,也只能拿到这个需求,说不定这位朋友也有二次开发的能力,错失了一次挣钱的好机会。还有这位老板上边可能还有更大的老板,这位老板降低成本的需求也可能是想在大老板面前表现一下,那是不是还有其它降本增效的方法呢?比如简化流程、裁掉几个不关键的岗位(这个要得罪人了)。


我们要让程序始终保持在良好的状态,就要准确的把握用户需求,要搞懂用户需求,就需要保持谦逊求知的心态,充分理解用户的问题,这种能力不是朝夕之间就可以掌握的,是需要修炼的。


动起来


任何没有被满足的需求都是一次机会。


我经常会在技术社区看到一些同学分享自己业余时间做的独立产品,有做进销存的、客户管理的、在线客服的,还有解决问题的各种小工具,而且有的同学还挣到了钱。


我并不是想说让大家都去搞钱,而是说要善于发现问题、找到机会,然后动起来、去实践,实践的过程中我们可以发现更多的问题,然后持续解决问题,必然能让自己变得越来越强。在经济不太好的情况下,我们才有更强的生存能力。




啰里八嗦一大堆,希望能对你有所启发。




作者:萤火架构
来源:juejin.cn/post/7317704464999235593
收起阅读 »

用 VitePress 搭建电子书,绝了!

web
大家好,我是杨成功。 自从《前端开发实战派》出版以后,好多买过的小伙伴都联系我,问我有没有电子书?纸质书在公司看不方便,一些现成的代码没办法复制。 确实没有电子版,我也听大家的建议上微信读书,结果那边审核没通过。我想不行我自己搞一个电子书呗,给买了纸书的朋友免...
继续阅读 »

大家好,我是杨成功。


自从《前端开发实战派》出版以后,好多买过的小伙伴都联系我,问我有没有电子书?纸质书在公司看不方便,一些现成的代码没办法复制。


确实没有电子版,我也听大家的建议上微信读书,结果那边审核没通过。我想不行我自己搞一个电子书呗,给买了纸书的朋友免费阅读,方便他们随时查阅。


经过一番调研,VitePress 的 UI 我最喜欢,扩展性也非常好,所以就用它来搭建。


新建项目


在一个空文件夹下,使用命令生成项目:


$ npx vitepress init

全部使用默认选项,生成结构如下:


2024-04-07-16-55-42.png


图中的 .vitepress/config.mts 就是 VitePress 的配置文件。另外三个 .md 文件是 Markdown 内容,VitePress 会根据文件名自动生成路由,并将文件内容转换为 HTML 页面。


为了代码更优雅,一般会把 Markdown 文件放在 docs 目录下。只需要添加一个配置:


// config.mts
export default defineConfig({
srcDir: 'docs',
});

改造后的目录结构是这样:


2024-04-07-17-27-23.png


安装依赖并运行项目:


$ yarn add vitepress vue
$ yarn run docs:dev

前期设计的难点


电子书的内容不完全对外开放,只有买过纸书的人才能阅读。和掘金小册差不多,只能看部分内容,登录或购买后才能解锁全部章节。


而 VitePress 是一个静态站点生成器,默认只解析 Markdown。要想实现上述的功能,必须用到纯 Vue 组件,这需要通过扩展默认主题来实现。


扩展默认主题,也就是扩展 VitePress 的原始 Vue 组件,达到自定义的效果。


遵循这个思路,我们需要扩展的内容如下:



  • 添加登录页面,允许用户登录。

  • 添加用户中心页面,展示用户信息、退出登录。

  • 修改头部组件,展示登录入口。

  • 页面根组件,获取当前用户状态。

  • 修改内容组件,无权限时不展示内容。


当然了还需要接入几个接口:



  • 登录/注册接口。

  • 获取当前用户信息接口。

  • 验证当前用户权限的接口。


扩展默认主题


扩展默认主题,首先要创建一个 .vitepress/theme 文件夹,用来存放主题的组件、样式等代码。该文件夹下新建 index.ts 表示主题入口文件。


入口文件导出主题配置:


// index.ts
import Layout from './Layout.vue';

export default {
Layout,
enhanceApp({ app, router, siteData }) {
// ...
},
};

上面代码导入了一个 Layout.vue,这个组件是自定义布局组件:


<!-- Layout.vue -->
<script setup>
import DefaultTheme from 'vitepress/theme';

const { Layout } = DefaultTheme;
</script>

<template>
<Layout>
<template #nav-bar-content-after>
<button>登录</button>
</template>
</Layout>
</template>

为啥需要这个组件呢?因为该组件是项目根组件,可以从两个方面扩展:


(1)使用自定义插槽。


Layout 组件提供了许多插槽,允许我们在页面的多处位置插入内容。比如上面代码中的 nav-bar-content-after 插槽,会在头部组件右侧插入登录按钮。


具体有哪些插槽,详见这里


(2)做全局初始化。


当刷新页面时,需要做一些初始化操作,比如调用接口、监听某些状态等。


这个时候可以使用 Vue 的各种钩子函数,比如 onMounted:


// Layout.vue
<script setup>
import { onMounted } from 'vue';
onMounted(() => {
console.log('初始化、请求接口');
});
</script>

如何定制内容组件?


VitePress 的内容组件,会把所有 Markdown 内容渲染出来。但是如果用户没有登录,我们不允许展示内容,而是提示用户登录,就像掘金小册这样:


2024-04-07-08-50-00.png


定制内容组件,核心是在内容渲染的区域加一个判断:如果用户登录且验证通过,渲染内容即可;否则,展示类似上图的提示登录界面。


接下来我翻了 VitePress 的源码,找到了这个名为 VPDoc.vue 的组件:



github.com/vuejs/vitep…



在上方组件大概 46 行,我找到了内容渲染区域:


2024-04-07-09-09-20.png


就在这个位置,添加一个判断,就达到我们想要的效果了:


<main class="main">
<Content
class="vp-doc"
v-if="isLogin"
:class="[
pageName,
theme.externalLinkIcon && 'external-link-icon-enabled'
]"

/>

<div v-else>
<h4>登录后阅读全文</h4>
<button>去登录</button>
</div>

</main>

那怎么让这个修改生效呢?


VitePress 提供了一个 重写内部组件 的方案。将 VPDoc.vue 组件拷贝到本地,按照上述方法修改,重命名为 CusVPDoc.vue


在配置文件 .vitepress/config.ts 中添加重写逻辑:


// config.ts
export default defineConfig({
vite: {
resolve: {
alias: [
{
find: /^.*\/VPDoc\.vue$/,
replacement: fileURLToPath(new URL('./components/CusVPDoc.vue', import.meta.url)),
},
],
},
},
});

这样便实现了自定义内容组件,电子书截图如下:


2024-04-10-09-28-42.png


添加自定义页面


添加自定义页面,首先要创建一个自定义组件。


以登录页面为例,创建一个自定义组件 CusLogin.vue,编写登录页面和逻辑,然后将其注册为一个全局组件。在 Markdown 页面文件中,直接使用这个组件。


注册全局组件的方法,是在主题入口文件中添加以下配置:


// .vitepress/theme/index.ts
import CusLogin from './components/CusLogin.vue'

export default {
...
enhanceApp({ app}) {
app.component("CusLogin", CusLogin); // 注册全局组件
// ...
},
} satisfies Theme;

最后,新建 Markdown 文件 login.md,写入内容如下:


---
layout: page
---


<CusLogin />

现在访问路由 “/login” 就可以看到自定义登录页面了。


2024-04-10-09-30-28.png


全局状态管理


涉及到用户登录,那么必然会涉及在多个组件中共享登录信息。


如果要做完全的状态管理,不用说,安装 Pinia 并经过一系列配置,可以实现。但是我们的需求只是共享登录信息,完全没必要再装一套 Pinia,使用 组合式函数 就可以了。


具体怎么实现,在另一篇文章 Vue3 新项目,没必要再用 Pinia 了! 中有详细介绍。


接入 Bootstrap


自定义页面,总是需要一个 UI 框架。上面的登录页面中,我使用了 Bootstrap。


Vitepress 使用 UI 框架有一个限制:必须兼容 SSR。因为 Vitepress 本质上使用了 Vue 的服务端渲染功能,在构建期间生成多个 HTML 页面,并不是常见的单页面应用。


这意味着,Vue 组件只有在 beforeMountmounted 钩子中才能访问 DOM API。


而 Bootstrap 不需要打包构建就可以使用 UI,非常适合 Vitepress。


首先安装 Bootstrap:


$ yarn add bootstrap

然后在主题入口文件中引入 Sass 和 JS 文件:


import 'bootstrap/scss/bootstrap-cus.scss';
import 'bootstrap/dist/js/bootstrap.bundle.min.js';

按常理说,这样就可以了,但是实际运行会报错:找不到某个 DOM API。


还记得那个限制吗?必须兼容 SSR!因此不能直接引入 JS 文件。


解决方法是在自定义布局组件 Layout.vue 中通过异步的方式引入:


// .vitepress/theme/Layout.vue
onMounted(() => {
import('bootstrap/dist/js/bootstrap.bundle.min.js');
});

这样就大功告成了,你可以使用 Bootstrap 中丰富的 UI。


最终的电子书效果:《前端开发实战派》,欢迎点评。


最后留一个思考题:Vitepress 支持主题切换,Bootstrap 也分浅色和深色主题;切换 Vitepress 主题时,如何同步更改 Bootstrap 的主题呢?



公众号:程序员成功

作者微信:杨成功



作者:杨成功
来源:juejin.cn/post/7355759709167910923
收起阅读 »

为了NullPointerException,你知道Java到底做了多少努力吗?

null 何错之有? 对于 Java 程序员而言,NullPointerException 是最令我们头疼的异常,没有之一 ,大明哥相信到这篇文章为止一定还有不少人在写下面这段代码: if (obj != null) { //... } NullPo...
继续阅读 »

null 何错之有?


对于 Java 程序员而言,NullPointerException 是最令我们头疼的异常,没有之一 ,大明哥相信到这篇文章为止一定还有不少人在写下面这段代码:


if (obj != null) {
//...
}


NullPointerException 是 Java 1.0 版本引入的,引入它的主要目的是为了提供一种机制来处理 Java 程序中的空引用错误。空引用(Null Reference)是一个与空指针类似的概念,是一个已宣告但其并未引用到一个有效对象的变量。它是伟大的计算机科学家Tony Hoare 早在1965年发明的,最初作为编程语言ALGOL W的一部分。嗯,就是这位老爷子




1965年,老爷子 Tony Hoare 在设计ALGOL W语言时,为了简化ALGOL W 的设计,引入空引用的概念,他认为空引用可以方便地表示“无值”或“未知值”,其设计初衷就是要“通过编译器的自动检测机制,确保所有使用引用的地方都是绝对安全的”。但是在2009年,很多年后,他开始为自己曾经做过这样的决定而后悔不已,把它称为“一个价值十亿美元的错误”。实际上,Hoare的这段话低估了过去五十年来数百万程序员为修复空引用所耗费的代价。因为在ALGOL W之后出现的大多数现代程序设计语言,包括Java,都采用了同样的设计方式,其原因是为了与更老的语言保持兼容,或者就像Hoare曾经陈述的那样,“仅仅是因为这样实现起来更加容易”。


在 Java 中,null 会带来各种问题(摘自:《Java 8 实战》):



  • 它是错误之源。 NullPointerException 是目前Java程序开发中最典型的异常。它会使你的代码膨胀。

  • 它让你的代码充斥着深度嵌套的null检查,代码的可读性糟糕透顶。

  • 它自身是毫无意义的。 null自身没有任何的语义,尤其是是它代表的是在静态类型语言中以一种错误的方式对缺失变量值的建模。

  • 它破坏了Java的哲学。 Java一直试图避免让程序员意识到指针的存在,唯一的例外是:null指针。

  • 它在Java的类型系统上开了个口子。 null并不属于任何类型,这意味着它可以被赋值给任意引用类型的变量。这会导致问题, 原因是当这个变量被传递到系统中的另一个部分后,你将无法获知这个null变量最初赋值到底是什么类型。


Java 做了哪些努力?


Java 为了处理 NullPointerException 一直在努力着。



  • Java 8 引入 Optional:减少 null而引发的NullPointerException异常

  • Java 14 引入 Helpful NullPointerExceptions:帮助我们更好地排查 NullPointerException


Java 8 的 Optional


Optional 是什么


Optional 是 Java 8 提供了一个类库。被设计出来的目的是为了减少因为null而引发的NullPointerException异常,并提供更安全和优雅的处理方式。


Java 中臭名昭著的 NullPointerException 是导致 Java 应用程序失败最常见的原因,没有之一,大明哥认为没有一个 Java 开发程序员没有遇到这个异常。为了解决 NullPointerException,Google Guava 引入了 Optional 类,它提供了一种在处理可能为null值时更灵活和优雅的方式,受 Google Guava 的影响,Java 8 引入 Optional 来处理 null 值。


在 Javadoc 中是这样描述它的:一个可以为 null 的容器对象。所以 java.util.Optional 是一个容器类,它可以保存类型为 T 的值,T 可以是实际 Java 对象,也可以是 null


Optional API 介绍


我们先看 Optional 的定义:


public final class Optional {

/**
* 如果非空,则为该值;如果为空,则表示没有值存在。
*/

private final T value;

//...
}

从这里可以看出,Optional 的本质就是内部存储了一个真实的值 T,如果 T 非空,就为该值,如果为空,则表示该值不存在。


构造 Optional 对象


Optional 的构造函数是 private 权限的,它对外提供了三个方法用于构造 Optional 对象。



Optional.of(T value)



    public static  Optional<T> of(T value) {
return new Optional<>(value);
}

private Optional(T value) {
this.value = Objects.requireNonNull(value);
}

所以 Optional.of(T value) 是创建一个包含非null值的 Optional 对象。如果传入的值为null,将抛出NullPointerException 异常信息。



Optional.ofNullable(T value)



    public static  Optional ofNullable(T value) {
return value == null ? empty() : of(value);
}

创建一个包含可能为null值的Optional对象。如果传入的值为null,则会创建一个空的Optional对象。



Optional.empty()



    public static<T> Optional<T> empty() {
@SuppressWarnings("unchecked")
Optional<T> t = (Optional<T>) EMPTY;
return t;
}

private static final Optional EMPTY = new Optional<>();

创建一个空的Optional对象,表示没有值。


检查是否有值


Optional 提供了两个方法用来检查是否有值。



isPresent()



isPresent() 用于检查Optional对象是否包含一个非null值,源码如下:


    public boolean isPresent() {
return value != null;
}

示例如下:


User user = null;
Optional optional = Optional.ofNullable(user);
System.out.println(optional.isPresent());
// 结果......
false


ifPresent(Consumer action)



该方法用来执行一个操作,该操作只有在 Optional 包含非null值时才会执行。源码如下:


    public void ifPresent(Consumersuper T> consumer) {
if (value != null)
consumer.accept(value);
}

需要注意的是,这是 Consumer,是没有返回值的。


示例如下:


User user = new User("xiaoming");
Optional.ofNullable(user).ifPresent(value-> System.out.println("名字是:" + value.getName()));

获取值


获取值是 Optional 中的核心 API,Optional 为该功能提供了四个方法。



get()



get() 用来获取 Optional 对象中的值。如果 Optional 对象的值为空,会抛出NoSuchElementException异常。源码如下:


    public T get() {
if (value == null) {
throw new NoSuchElementException("No value present");
}
return value;
}


orElse(T other)



orElse() 用来获取 Optional 对象中的值,如果值为空,则返回指定的默认值。源码如下:


    public T orElse(T other) {
return value != null ? value : other;
}

示例如下:


User user = null;
user = Optional.ofNullable(user).orElse(new User("xiaohong"));
System.out.println(user);
// 结果......
User(name=xiaohong, address=null)


orElseGet(Supplier other)



orElseGet()用来获取 Optional 对象中的值,如果值为空,则通过 Supplier 提供的逻辑来生成默认值。源码如下:


    public T orElseGet(Supplierextends T> other) {
return value != null ? value : other.get();
}

示例如下:


User user = null;
user = Optional.ofNullable(user).orElseGet(() -> {
Address address = new Address("湖南省","长沙市","岳麓区");
return new User("xiaohong",address);
});
System.out.println(user);
// 结果......
User(name=xiaohong, address=Address(province=湖南省, city=长沙市, area=岳麓区))

orElseGet()orElse()的区别是:当 T 不为 null 的时候,orElse() 依然执行 other 的部分代码,而 orElseGet() 不会,验证如下:


public class OptionalTest {

public static void main(String[] args) {
User user = new User("xiaoming");
User user1 = Optional.ofNullable(user).orElse(createUser());
System.out.println(user);

System.out.println("=========================");

User user2 = Optional.ofNullable(user).orElseGet(() -> createUser());
System.out.println(user2);
}

public static User createUser() {
System.out.println("执行了 createUser() 方法");
Address address = new Address("湖南省","长沙市","岳麓区");
return new User("xiaohong",address);
}
}

执行结果如下:



是不是 orElse() 执行了 createUser() ,而 orElseGet() 没有执行?一般而言,orElseGet()orElse() 会更加灵活些。



orElseThrow(Supplier exceptionSupplier)



orElseThrow() 用来获取 Optional 对象中的值,如果值为空,则通过 Supplier 提供的逻辑抛出异常。源码如下:


    public extends Throwable> T orElseThrow(Supplier exceptionSupplier) throws X {
if (value != null) {
return value;
} else {
throw exceptionSupplier.get();
}
}

示例如下:


User user = null;
user = Optional.ofNullable(user).orElseThrow(() -> new RuntimeException("用户不存在"));

类型转换


Optional 提供 map()flatMap() 用来进行类型转换。



map(Function mapper)



map() 允许我们对 Optional 对象中的值进行转换,并将结果包装在一个新的 Optional 对象中。该方法接受一个 Function 函数,该函数将当前 Optional 对象中的值映射成另一种类型的值,并返回一个新的 Optional 对应,这个新的 Optional 对象中的值就是映射后的值。如果当前 Optional 对象的值为空,则返回一个空的 Optional 对象,且 Function 不会执行,源码如下:


    public Optional map(Functionsuper T, ? extends U> mapper) {
Objects.requireNonNull(mapper);
if (!isPresent())
return empty();
else {
return Optional.ofNullable(mapper.apply(value));
}
}

比如我们要获取 User 对象中的 name,如下:


User user = new User("xiaolan");
String name = Optional.ofNullable(user).map(value -> value.getName()).get();
System.out.println(name);
// 结果......
xiaolan


Function> mapper



flatMap()map() 相似,不同之处在于 flatMap()的映射函数返回的是一个 Optional 对象而不是直接的值,它是将当前 Optional 对象映射为另外一个 Optional 对象。


    public<U> Optional<U> flatMap(Functionsuper T, Optional<U>> mapper) {
Objects.requireNonNull(mapper);
if (!isPresent())
return empty();
else {
return Objects.requireNonNull(mapper.apply(value));
}
}

上面获取 name 的代码如下:


String name = Optional.ofNullable(user).flatMap(value -> Optional.ofNullable(value.getName())).get();

flatMap() 内部需要再次封装一个 Optional 对象,所以 flatMap() 通常用于在一系列操作中处理嵌套的Optional对象,以避免层层嵌套的情况,使代码更加清晰和简洁。


过滤


Optional 提供了 filter() 用于在 Optional 对象中的值满足特定条件时进行过滤操作,源码如下:


    public Optional filter(Predicatesuper T> predicate) {
Objects.requireNonNull(predicate);
if (!isPresent())
return this;
else
return predicate.test(value) ? this : empty();
}

filter() 接受 一个Predicate 来对 Optional 中包含的值进行过滤,如果满足条件,那么还是返回这个 Optional;否则返回 Optional.empty


实战应用


这里大明哥利用 Optional 的 API 举几个例子。



  • 示例一


Java 8 以前:


    public static String getUserCity(User user) {
if (user != null) {
Address address = user.getAddress();
if (address != null) {
return address.getCity();
}
}
return null;
}

常规点的,笨点的方法:


    public static String getUserCity(User user) {
Optional userOptional = Optional.of(user);
return Optional.of(userOptional.get().getAddress()).get().getCity();
}

高级一点的:


    public static String getUserCity(User user) {
return Optional.ofNullable(user)
.map(User::getAddress)
.map(Address::getCity)
.orElseThrow(() -> new RuntimeException("值不存在"));
}

是不是比上面高级多了?



  • 示例二


比如我们要获取末尾为"ming"的用户的 city,不是的统一返回 "深圳市"。


Java 8 以前


    public static String getUserCity(User user) {
if (user != null && user.getName() != null) {
if (user.getName().endsWith("ming")) {
Address address = user.getAddress();
if (address != null) {
return address.getCity();
} else {
return "深圳市";
}
} else {
return "深圳市";
}
}

return "深圳市";
}

Java 8


    public static String getUserCity2(User user) {
return Optional.ofNullable(user)
.filter(u -> u.getName().endsWith("ming"))
.map(User::getAddress)
.map(Address::getCity)
.orElse("深圳市1");
}

这种写法确实是优雅了很多。其余的例子大明哥就不一一举例了,这个也没有其他技巧,唯手熟尔!!


Java 14 的 Helpful NullPointerExceptions


我们先看如下报错信息:


Exception in thread "main" java.lang.NullPointerException
at com.skjava.java.feature.Test.main(Test.java:6)

从这段报错信息中你能看出什么? Test.java 中的第 6 行产生了 NullPointerException。还能看出其他什么吗?如果这段报错的代码是这样的:


public class Test {
public static void main(String[] args) {
User user = new User();
System.out.println(user.getAddress().getProvince().length());
}
}

你知道是哪里报空指针吗? 是user.getAddress() 还是 user.getAddress().getProvince() ?看不出来吧?从这个报错信息中,我们确实很难搞清楚具体是谁导致的 NullPointerException


在 Java 14 之前,当发生 NullPointerException 时,错误信息通常很简单,仅仅只指出了出错的行号。这会导致我们在排查复杂表达式时显得比较困难,因为无法确定是表达式中的哪一部分导致了 NullPointerException,我们需要花费额外的时间进行调试,特别是在长链式调用或者包含多个可能为空的对象的情况下。


为了解决这个问题,Java 14 对 NullPointerException 的提示信息进行了改进,当发生 NullPointerException 时,异常信息会明确指出哪个具体的变量或者表达式部分是空的。例如,对于表达式 a.b().c().d(), 如果 b() 返回的对象是 null,异常信息将明确指出 b() 返回的对象为 null。例如上面的信息:


Exception in thread "main" java.lang.NullPointerException: Cannot invoke "*****.Address.getProvince()" because the return value of "*****.User.getAddress()" is null
at com.skjava.java.feature.Test.main(Test.java:6)

他会明确告诉你 User.getAddress() 返回的对象为 null


这样的提示信息将会让我们能够快速准确地定位导致 NullPointerException 的具体原因,无需逐步调试或猜测,有助于快速修复问题,减少维护时间和成本。


作者:大明哥_
来源:juejin.cn/post/7315080231627194387
收起阅读 »

HTML问题:如何实现分享URL预览?

web
前端功能问题系列文章,点击上方合集↑ 序言 大家好,我是大澈! 本文约2100+字,整篇阅读大约需要3分钟。 本文主要内容分三部分,如果您只需要解决问题,请阅读第一、二部分即可。如果您有更多时间,进一步学习问题相关知识点,请阅读至第三部分。 感谢关注微信公众号...
继续阅读 »

前端功能问题系列文章,点击上方合集↑


序言


大家好,我是大澈!


本文约2100+字,整篇阅读大约需要3分钟。


本文主要内容分三部分,如果您只需要解决问题,请阅读第一、二部分即可。如果您有更多时间,进一步学习问题相关知识点,请阅读至第三部分。


感谢关注微信公众号:“程序员大澈”,然后加入问答群,从此让解决问题的你不再孤单!


1. 需求分析


为了提高用户对页面链接分享的体验,需要对分享链接做一些处理。


以 Telegram(国外某一通讯软件) 为例,当在 Telegram 上分享已做过处理的链接时,它会自动尝试获取链接的预览信息,包括标题、描述和图片。


如此当接收者看到时,可以立即获取到分享链接的一些重要信息。这有助于接收者更好地了解链接的内容,决定是否点击查看详细内容。


图片


2. 实现步骤


2.1 实现前的说明


对于URL分享预览这个功能问题,在项目中挺常用的,只不过今天我们是以一些框架分享API的底层原理角度来讲的。


实现这种功能的关键,是在分享的链接中嵌入适当的元数据信息,应用软件会自动解析,请求分享链接的预览信息,并根据返回的元数据生成预览卡片。


对于国内的应用软件,目前我试过抖音,它可以实现分享和复制粘贴都自动解析,而微信、QQ等只能实现分享的自动解析。


对于国外的应用软件,我只实验过Telegram,它可以实现分享和复制粘贴都自动解析,但我想FacebookTwitterInstagram这些应用应该也都是可以的。


2.2 实现代码


实现URL链接的分享预览,你可以使用 Open Graph协议或 Twitter Cards,然后在 HTML 的 标签中,添加以下 meta 标签来定义链接预览的信息。


使用时,将所有meta全部复制过去,然后根据需求进行自定义即可。


还要注意两点,确保你页面的服务器正确配置了 SSL 证书,以及确保链接的URL有效(即:服务器没有做白名单限制)。


<head>
  
  <meta property="og:title" content="预览标题">
  <meta property="og:description" content="预览描述">
  <meta property="og:image:width" content="图片宽度">
  <meta property="og:image:height" content="图片高度">
  <meta property="og:image" content="预览图片的URL">
  <meta property="og:url" content="链接的URL">
  
  
  <meta name="twitter:card" content="summary">
  <meta name="twitter:title" content="预览标题">
  <meta name="twitter:description" content="预览描述">
  <meta property="twitter:image:width" content="图片宽度">
  <meta property="twitter:image:height" content="图片高度">
  <meta name="twitter:image" content="预览图片的URL">
  <meta name="twitter:url" content="链接的URL">
head>

下面我们做一些概念的整理、总结和学习。


3. 问题详解


3.1 什么是Open Graph协议?


Open Graph协议是一种用于在社交媒体平台上定义和传递网页元数据的协议。它由 Facebook 提出,并得到了其他社交媒体平台的支持和采纳。Open Graph 协议旨在标准化网页上的元数据,使网页在社交媒体上的分享和预览更加一致和可控。


通过在网页的 HTML  标签中添加特定的 meta 标签,使用 Open Graph 协议可以定义和传递与网页相关的元数据信息,如标题、描述、图片等。这些元数据信息可以被社交媒体平台解析和使用,用于生成链接预览、分享内容和提供更丰富的社交图谱。


使用 Open Graph 协议,网页的所有者可以控制链接在社交媒体上的预览内容,确保链接在分享时显示的标题、描述和图片等信息准确、有吸引力,并能够准确传达链接的主题和内容。这有助于提高链接的点击率、转化率和用户体验。


Open Graph 协议定义了一组标准的 meta 标签属性,如 og:titleog:descriptionog:image 等,用于提供链接预览所需的元数据信息。通过在网页中添加这些 meta 标签并设置相应的属性值,可以实现链接预览在社交媒体平台上的一致展示。


需要注意的是,Open Graph 协议是一种开放的标准,并不限于 Facebook 平台。其他社交媒体平台,如 Twitter、LinkedIn 等,也支持使用 Open Graph 协议定义和传递网页元数据,以实现链接预览的一致性。


图片


3.2 什么是Twitter Cards?


Twitter Cards 是一种由 Twitter 推出的功能,它允许网站所有者在他们的网页上定义和传递特定的元数据,以便在 Twitter 上分享链接时生成更丰富和吸引人的预览卡片。通过使用 Twitter Cards,网页链接在 Twitter 上的分享可以展示标题、描述、图片、链接和其他相关信息,以提供更具吸引力和信息丰富的链接预览。


Twitter Cards 提供了多种类型的卡片,以适应不同类型的内容和需求。以下是 Twitter Cards 的一些常见类型:



  • Summary CardSummary Card 类型的卡片包含一个标题、描述和可选的图片。它适用于分享文章、博客帖子等内容。

  • Summary Card with Large ImageSummary Card with Large Image 类型的卡片与 Summary Card 类型类似,但图片尺寸更大,更突出地展示在卡片上。

  • App CardApp Card 类型的卡片用于分享移动应用程序的信息。它包含应用的名称、图标、描述和下载按钮,以便用户可以直接从预览卡片中下载应用。

  • Player CardPlayer Card 类型的卡片用于分享包含媒体播放器的内容,如音频文件、视频等。它允许在预览卡片上直接播放媒体内容。


通过在网页的 HTML  标签中添加特定的 meta 标签,使用 Twitter Cards 可以定义和传递与链接预览相关的元数据信息,如标题、描述、图片、链接等。这些元数据信息将被 Twitter 解析和使用,用于生成链接预览卡片。


使用 Twitter Cards 可以使链接在 Twitter 上的分享更加吸引人和信息丰富,提高链接的点击率和用户参与度。它为网站所有者提供了更多控制链接在 Twitter 上展示的能力,并提供了一种更好的方式来呈现他们的内容。


图片


图片


结语


建立这个平台的初衷:



  • 打造一个仅包含前端问题的问答平台,让大家高效搜索处理同样问题。

  • 通过不断积累问题,一起练习逻辑思维,并顺便学习相关的知识点。

  • 遇到难题,遇到有共鸣的问题,一起讨论,一起沉淀,一起成长。

作者:程序员大澈
来源:juejin.cn/post/7310112330663231515
收起阅读 »

旋转、缩放、移动:掌握CSS Transform动画的终极指南!

在深入探讨CSS变形动画之前,让我们先探讨一下掌握它之后你可以实现哪些有趣的效果。学习了CSS变形动画之后,你将能够为你的网页添加引人注目的动态效果,例如创建一个立体的3D魔方,或者设计一个引人入胜的旋转菜单。这些仅仅是众多可能性中的一小部分,但或许可以勾起我...
继续阅读 »

在深入探讨CSS变形动画之前,让我们先探讨一下掌握它之后你可以实现哪些有趣的效果。

学习了CSS变形动画之后,你将能够为你的网页添加引人注目的动态效果,例如创建一个立体的3D魔方,或者设计一个引人入胜的旋转菜单。

Description
这些仅仅是众多可能性中的一小部分,但或许可以勾起我们的学习兴趣。

一、什么是CSS变形动画?

CSS变形动画是利用CSS3的transform属性创建的动画效果。它可以使元素旋转、缩放、倾斜甚至翻转,让静态的网页元素动起来,为用户带来更加丰富的交互体验。

坐标系统

首先我们要学习的变形动画,想达到在上图中出现的3D效果单纯的X与Y两个轴是实现不了的,还需要加入一条纵深轴,即Y轴的参与才有一个3D的视觉感受。

那么如何来理解X,Y,Z这三条轴的关系呢?可以看一下下面这张图。

Description

  • X轴代表水平轴

  • Y轴代表垂直轴

  • Z轴代表纵深轴

X和Y轴都非常好理解,怎么理解这个Z轴呢?

CSS的中文名称叫做层叠样式表,那么它肯定是一层一层的。之前学习过z-index就是用来设置层的优先级,优先级越高越在上面,也可以理解为离我们肉眼越近,它把优先级低的层给盖住了,所以Z轴可以理解为我们观察的视角与被观察物体之间的一条轴。

  • Z轴数值越大,说明观测距离越远。

  • Z轴的数值可以无限大,所以设置的时候一定要小心。

二、变形操作

使用 transform 来控制元素变形操作,包括控制移动、旋转、倾斜、3D转换等。

Description

下面我们通过一些例子来演示一下,比较常用的变形操作:

2.1 位移 translate()

translate()函数可以将元素向指定的方向移动,类似于position中的relative。或以简单的理解为,使用translate()函数,可以把元素从原来的位置移动,而不影响在X、Y轴上的任何Web组件。

想象一下,当你滚动页面时,一个元素平滑地从一个位置滑向另一个位置,这种流畅的过渡效果可以大大提升用户体验。

translate我们分为三种情况:

1)translate(x,y)水平方向和垂直方向同时移动(也就是X轴和Y轴同时移动)

2)translateX(x)仅水平方向移动(X轴移动)

3)translateY(Y)仅垂直方向移动(Y轴移动)

实例演示: 通过translate()函数将元素向Y轴下方移动50px,X轴右方移动100px。

HTML代码:

<div class="wrapper">
<div>我向右向下移动</div>
</div>

CSS代码:


.wrapper {
width: 200px;
height: 200px;
border: 2px dotted red;
margin: 20px auto;
}
.wrapper div {
width: 200px;
height: 200px;
line-height: 200px;
text-align: center;
background: orange;
color: #fff;
-webkit-transform: translate(50px,100px);
-moz-transform:translate(50px,100px);
transform: translate(50px,100px);
}

演示结果:

Description

2.2 旋转 rotate()

旋转rotate()函数通过指定的角度参数使元素相对原点进行旋转。旋转不仅可以是固定的度数,还可以是动态变化的,创造出无限的可能性。

它主要在二维空间内进行操作,设置一个角度值,用来指定旋转的幅度。如果这个值为正值,元素相对原点中心顺时针旋转;如果这个值为负值,元素相对原点中心逆时针旋转。如下图所示:

Description

HTML代码:

<div class="wrapper">
<div></div>
</div>

CSS代码:

.wrapper {
width: 200px;
height: 200px;
border: 1px dotted red;
margin: 100px auto;
}
.wrapper div {
width: 200px;
height: 200px;
background: orange;
-webkit-transform: rotate(45deg);
transform: rotate(45deg);
}

演示结果:
Description

2.3 扭曲 skew()

扭曲skew()函数能够让元素倾斜显示。这种效果常常用于模拟速度感或者倾斜的视觉效果。

它可以将一个对象以其中心位置围绕着X轴和Y轴按照一定的角度倾斜。这与rotate()函数的旋转不同,rotate()函数只是旋转,而不会改变元素的形状。skew()函数不会旋转,而只会改变元素的形状。

Skew()具有三种情况:
1)skew(x,y)使元素在水平和垂直方向同时扭曲(X轴和Y轴同时按一定的角度值进行扭曲变形);

Description

第一个参数对应X轴,第二个参数对应Y轴。如果第二个参数未提供,则值为0,也就是Y轴方向上无斜切。

2)skewX(x)仅使元素在水平方向扭曲变形(X轴扭曲变形);
Description

3)skewY(y)仅使元素在垂直方向扭曲变形(Y轴扭曲变形)。
Description

示例演示:通过skew()函数将长方形变成平行四边形。

HTML代码:

<div class="wrapper">
<div>我变成平形四边形</div>
</div>

CSS代码:

.wrapper {
width: 300px;
height: 100px;
border: 2px dotted red;
margin: 30px auto;
}
.wrapper div {
width: 300px;
height: 100px;
line-height: 100px;
text-align: center;
color: #fff;
background: orange;
-webkit-transform: skew(45deg);
-moz-transform:skew(45deg)
transform:skew(45deg);
}

演示结果:
Description

2.4 缩放 scale()

缩放 scale()函数 让元素根据中心原点对对象进行缩放。这不仅可以用来模拟放大镜效果,还可以创造出元素的进入和退出动画,比如一个图片慢慢缩小直至消失。

缩放 scale 具有三种情况:

1) scale(X,Y)使元素水平方向和垂直方向同时缩放(也就是X轴和Y轴同时缩放)。

Description
例如:

div:hover {
-webkit-transform: scale(1.5,0.5);
-moz-transform:scale(1.5,0.5)
transform: scale(1.5,0.5);
}

注意:Y是一个可选参数,如果没有设置Y值,则表示X,Y两个方向的缩放倍数是一样的。

2)scaleX(x)元素仅水平方向缩放(X轴缩放)
Description
3)scaleY(y)元素仅垂直方向缩放(Y轴缩放)
Description
HTML代码:

<div class="wrapper">
<div>我将放大1.5倍</div>
</div>

CSS代码:


.wrapper {
width: 200px;
height: 200px;
border:2px dashed red;
margin: 100px auto;
}
.wrapper div {
width: 200px;
height: 200px;
line-height: 200px;
background: orange;
text-align: center;
color: #fff;
}
.wrapper div:hover {
opacity: .5;
-webkit-transform: scale(1.5);
-moz-transform:scale(1.5)
transform: scale(1.5);
}

演示结果:
Description
注意: scale()的取值默认的值为1,当值设置为0.01到0.99之间的任何值,作用使一个元素缩小;而任何大于或等于1.01的值,作用是让元素放大。


想要快速入门前端开发吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!点这里前往学习哦!

2.5 矩阵 matrix()

matrix() 是一个含六个值的(a,b,c,d,e,f)变换矩阵,用来指定一个2D变换,相当于直接应用一个[a b c d e f]变换矩阵。就是基于水平方向(X轴)和垂直方向(Y轴)重新定位元素。

此属性值使用涉及到数学中的矩阵,我在这里只是简单的说一下CSS3中的transform有这么一个属性值,如果需要深入了解,需要对数学矩阵有一定的知识。

示例演示:通过matrix()函数来模拟transform中translate()位移的效果。
HTML代码:

<div class="wrapper">
<div></div>
</div>

CSS代码:

.wrapper {
width: 300px;
height: 200px;
border: 2px dotted red;
margin: 40px auto;
}
.wrapper div {
width:300px;
height: 200px;
background: orange;
-webkit-transform: matrix(1,0,0,1,50,50);
-moz-transform:matrix(1,0,0,1,50,50);
transform: matrix(1,0,0,1,50,50);
}

演示结果:

Description

2.6 原点 transform-origin

任何一个元素都有一个中心点,默认情况之下,其中心点是居于元素X轴和Y轴的50%处。如下图所示:

Description

在没有重置transform-origin改变元素原点位置的情况下,CSS变形进行的旋转、位移、缩放,扭曲等操作都是以元素自己中心位置进行变形。

但很多时候,我们可以通过transform-origin来对元素进行原点位置改变,使元素原点不在元素的中心位置,以达到需要的原点位置。

transform-origin取值和元素设置背景中的background-position取值类似,如下表所示:

Description

示例演示:

通过transform-origin改变元素原点到左上角,然后进行顺时旋转45度。

HTML代码:

<<div class="wrapper">
<div>原点在默认位置处</div>
</div>
<div class="wrapper transform-origin">
<div>原点重置到左上角</div>
</div>

CSS代码:

.wrapper {
width: 300px;
height: 300px;
float: left;
margin: 100px;
border: 2px dotted red;
line-height: 300px;
text-align: center;
}
.wrapper div {
background: orange;
-webkit-transform: rotate(45deg);
transform: rotate(45deg);
}
.transform-origin div {
-webkit-transform-origin: left top;
transform-origin: left top;
}

演示结果:
Description

以上就是css动画中几种基本的变形技巧了,掌握这些我们可以操控我们的网页元素实现我们想要的一些基本动画效果。

在这个充满创造力的时代,CSS变形动画是每个前端开发者必备的技能。它不仅能提升用户体验,更能激发设计师和开发者的创意火花。所以,不妨尝试一下,让你的网页动起来,给用户留下深刻的印象吧!

收起阅读 »

🎉🎉 环信 IM 客户端将适配鸿蒙 HarmonyOS

自华为推出了自主研发操作系统鸿蒙 HarmonyOS 后,国内许多应用软件开始陆续全面兼容和接入鸿蒙操作系统。环信 IM 客户端计划将全面适配统鸿蒙 HarmonyOS ,助力开发者快速实现社交娱乐、语聊房、在线教育、智能硬件、社交电商、在线金融、线上医疗等广...
继续阅读 »

自华为推出了自主研发操作系统鸿蒙 HarmonyOS 后,国内许多应用软件开始陆续全面兼容和接入鸿蒙操作系统。环信 IM 客户端计划将全面适配统鸿蒙 HarmonyOS ,助力开发者快速实现社交娱乐、语聊房、在线教育、智能硬件、社交电商、在线金融、线上医疗等广泛场景的即时消息互动。




环信 IM 客户端适配后,将为开发者提供鸿蒙 HarmonyOS 系统上单聊、群聊、会话等能力和服务,具体覆盖到以下:

  • 消息管理:收发消息、全消息类型支持、会话管理、消息回执、消息撤回等
  • 用户属性:用户头像、用户昵称、自定义属性等
  • 群组管理:群成员管理、群管理员、群文件、群公告等
  • 离线推送:主流推送厂商支持,推送模板设置、推送通知等
  • 多设备同步:多设备消息和事件同步,支持设备同步策略设置等

作为一直深耕在即时通信领域的老兵,在平台框架开发支持上,环信 IM 已经支持 Android、iOS、macOS、Windows、Linux、Web、Flutter、Unity、Electron、React Native、Uni-App、APICloud 等。尤其海外开发者重点关注的 React Native、Flutter 平台,游戏开发者关注的 Unity 平台,以及原生桌面应用 Windows 平台,环信保持了持续更新迭代。

此次将增加鸿蒙HarmonyOS 的 SDK 适配,后续会持续优化和适配鸿蒙系统特性,助力开发者在鸿蒙HarmonyOS 系统上实现更加稳定、优质的即时消息互动。

环信 IM 目前已支持的平台:


在移动端 SDK 性能上,环信能做到小包体 SDK,提升终端设备运行性能,并且 SDK 崩溃率低于 0.005%,远低于行业平均水平 0.01%。

iOS 引入 SDK 前后 App 大小对比


Android 引入 SDK 前后 App 大小对比



在消息传输的延时性上,环信拥有新加坡、美国、德国等几大数据中心,全球部署近千终端网络加速节点,覆盖全球230多个国家和地区,实现全球端到端时延<100毫秒,网络连通率>99.95%,并支持多链路智能路由,遇到运营商网络故障自动切换路由。

扩展:

收起阅读 »

Android应用内版本更新:使用BasicUI库的简单实现

在移动应用开发中,应用内版本更新是一项重要的功能。它允许开发者轻松地向用户提供新的应用版本,以修复错误、改进性能,或者引入新功能。这篇文章将介绍如何使用 BasicUI 库,一个Android库,来实现应用内版本更新的功能。我们将演示如何从远程服务器下载APK...
继续阅读 »

在移动应用开发中,应用内版本更新是一项重要的功能。它允许开发者轻松地向用户提供新的应用版本,以修复错误、改进性能,或者引入新功能。这篇文章将介绍如何使用 BasicUI 库,一个Android库,来实现应用内版本更新的功能。我们将演示如何从远程服务器下载APK文件并进行安装。


BasicUI库简介


BasicUI 是一个功能强大且易于使用的Android库,用于实现各种常见UI和网络操作,其中包括文件下载和更新功能。这个库提供了一些便捷的方法来简化Android应用开发中的一些常见任务,包括版本更新。


要开始使用BasicUI库,你需要在你的项目中添加相应的依赖,可以在官方GitHub仓库中找到详细的文档和示例。


GitHub库链接: BasicUI


应用内部升级弹窗的流程图


image.png


代码实现应用内版本更新


下面是一个简单的代码示例,演示了如何使用BasicUI库来实现应用内版本更新。这段代码将从远程服务器下载APK文件,并在下载完成后进行安装。请确保你已经添加了BasicUI库的依赖。


val file = File(cacheDir, "update.apk")
if (file.exists()) {
file.delete()
}
mDialog.apply {
setOnCancelListener {
HttpUtils.cancel()
}
}.show()
with(this@OkHttpActivity)
.url("http://example.com/your_update.apk") // 替换成实际的APK下载链接
.downloadSingle()
.file(file)
.exectureDownload(object : DownloadCallback {
override fun onFailure(e: Exception?) {
LogUtils.e(e!!.message)
mDialog.dismiss()
}

override fun onSucceed(file: File?) {
ToastUtils.showShort("文件下载完成")
LogUtils.e("文件保存的位置:" + file!!.absolutePath)
mProgressBar!!.visibility = View.GONE
mProgressBar!!.progress = 0
installApk(file)
mDialog.dismiss()
}

override fun onProgress(progress: Int) {
LogUtils.e("单线程下载APK的进度:$progress")
mProgressBar!!.progress = progress
mProgressBar!!.visibility = View.VISIBLE
}
})

上述代码的主要步骤包括:



  1. 创建一个用于保存下载APK文件的本地文件,要使用cacheDir目录,原因是可以不需要读写权限。

  2. 如果之前存在同名文件,先进行删除。

  3. 创建一个对话框,其中包括一个取消监听器,用于在用户取消下载时取消网络请求。

  4. 使用BasicUI库的网络操作类(HttpUtils)创建一个下载请求,指定下载地址、下载完成后保存的文件,以及下载回调接口。

  5. 在下载回调接口中处理下载成功、失败和进度更新的情况。


请注意,你需要将示例代码中的下载链接替换为实际的APK下载链接。这段代码提供了一个简单而有效的方式来执行应用内版本更新,但你还可以根据你的需求进行进一步的定制化。


结语


在本文中,我们演示了如何使用BasicUI库来实现Android应用内版本更新的功能。这是一个快速、方便的解决方案,可以帮助你轻松地向用户提供最新版本的应用程序。请记住,版本更新是确保用户始终使用最新、最稳定版本的应用的关键步骤。


为了更好地满足你的需求,你可以根据实际情况进一步定制版本更新流程,例如添加灰度发布、自动检测新版本等功能。希望这篇文章对你有所帮助,使你能够更好地满足用户的需求和提供卓越的应用体验。




这篇文章演示了如何使用 BasicUI 库来实现应用内版本更新的功能。你可以根据自己的需求进一步定制这个流程,以满足特定的应用程序要求。希望这篇文章对你有所帮助!


作者:peakmain9
来源:juejin.cn/post/7293401255053819941
收起阅读 »

个人或个体户,如何免费使用微信小程序授权登录

web
需求 个人或个体户,如何免费使用微信小程序授权,快速登录进系统内部? 微信授权登录好处: 不用自己开发一个登录模块,节省开发和维护成本 安全性得到了保障,安全验证完全交由腾讯验证,超级可靠哇 可能有的人会问,为何不用微信公众号授权登录?原因很简单,因为一年...
继续阅读 »

需求


个人或个体户,如何免费使用微信小程序授权,快速登录进系统内部?


微信授权登录好处:



  1. 不用自己开发一个登录模块,节省开发和维护成本

  2. 安全性得到了保障,安全验证完全交由腾讯验证,超级可靠哇


可能有的人会问,为何不用微信公众号授权登录?原因很简单,因为一年要300元,小公司得省钱啊!


实现步骤说明


所有的步骤里包含四个对象,分别是本地后台本地微信小程序本地网页、以及第三方微信后台



  1. 本地后台调用微信后台https://api.weixin.qq.com/cgi-bin/token接口,get请求,拿到返回的access_token

  2. 本地后台根据拿到的access_token,调用微信后台https://api.weixin.qq.com/wxa/getwxacodeunlimit接口,得到二维码图片文件,将其输出传递给本地网页显示

  3. 本地微信小程序本地网页的二维码图片,跳转至小程序登录页面,通过wx.login方法,在success回调函数内得到code值,并将该值传递给本地后台

  4. 本地后台拿到code值后,调用微信后台https://api.weixin.qq.com/sns/jscode2session接口,get请求,得到用户登录的openid即可。



注意点:



  1. 上面三个微信接口/cgi-bin/token/getwxacodeunlimit/jscode2session必须由本地后台调用,微信小程序那边做了前端限制;

  2. 本地网页如何得知本地微信小程序已扫码呢?


本地微信小程序code,通过A接口,将值传给后台,后台拿到openid后,再将成功结果返回给本地微信小程序;同时,本地网页不断地轮询A接口,等待后台拿到openid后,便显示登录成功页面。



微信小程序核心代码


Page({
data: {
theme: wx.getSystemInfoSync().theme,
scene: "",
jsCode: "",
isLogin: false,
loginSuccess: false,
isChecked: false,
},
onLoad(options) {
const that = this;
wx.onThemeChange((result) => {
that.setData({
theme: result.theme,
});
});
if (options !== undefined) {
if (options.scene) {
wx.login({
success(res) {
if (res.code) {
that.setData({
scene: decodeURIComponent(options.scene),
jsCode: res.code,
});
}
},
});
}
}

},
handleChange(e) {
this.setData({
isChecked: Boolean(e.detail.value[0]),
});
},
formitForm() {
const that = this;
if (!this.data.jsCode) {
wx.showToast({
icon: "none",
title: "尚未微信登录",
});
return;
}
if (!this.data.isChecked) {
wx.showToast({
icon: "none",
title: "请先勾选同意用户协议",
});
return;
}
wx.showLoading({
title: "正在加载",
});
let currentTimestamp = Date.now();
let nonce = randomString();
wx.request({
url: `A接口?scene=${that.data.scene}&js_code=${that.data.jsCode}`,
header: {},
method: "POST",
success(res) {
wx.hideLoading();
that.setData({
isLogin: true,
});
if (res.statusCode == 200) {
that.setData({
loginSuccess: true,
});
} else {
if (res.statusCode == 400) {
wx.showToast({
icon: "none",
title: "无效请求",
});
} else if (res.statusCode == 500) {
wx.showToast({
icon: "none",
title: "服务内部错误",
});
}
that.setData({
loginSuccess: false,
});
}
},
fail: function (e) {
wx.hideLoading();
wx.showToast({
icon: "none",
title: e,
});
},
});
},
});


scene为随机生成的8位数字


本地网页核心代码


    let isInit = true
function loginWx() {
isInit = false
refreshQrcode()
}
function refreshQrcode() {
showQrLoading = true
showInfo = false
api.get('/qrcode').then(qRes => {
if (qRes.status == 200) {
imgSrc = `${BASE_URL}${qRes.data}`
pollingCount = 0
startPolling()
} else {
showToast = true
toastMsg = '二维码获取失败,请点击刷新重试'
showInfo = true
}
}).finally(() => {
showQrLoading = false
})
}

// 开始轮询
// 1000毫秒轮询一次
function startPolling() {
pollingInterval = setInterval(function () {
pollDatabase()
}, 1000)
}
function pollDatabase() {
if (pollingCount >= maxPollingCount) {
clearInterval(pollingInterval)
showToast = true
toastMsg = '二维码已失效,请刷新'
showInfo = true
return
}
pollingCount++
api.get('/result').then(res => {
if (res.status == 200) {
clearInterval(pollingInterval)
navigate('/os', { replace: true })
} else if (res.status == 408) {
clearInterval(pollingInterval)
showToast = true
toastMsg = '二维码已失效,请刷新'
showInfo = true
}
})
}



html的部分代码如下所示


     <button class="btn" on:click={loginWx}>微信登录</button>
<div id="qrcode" class="relative mt-10">
{#if imgSrc}
<img src={imgSrc} alt="二维码图片"/>
{/if}
{#if showQrLoading}
<div class="mask absolute top-0 left-0 w-full h-full z-10">
<Loading height="12" width="12"/>
</div>
{/if}
</div>

尾声


若需要完整代码,或想知道如何申请微信小程序,欢迎大家关注或私信我哦~~


作者:zwf193071
来源:juejin.cn/post/7351649413401493556
收起阅读 »

🚫为了防止狗上沙发,写了一个浏览器实时识别目标功能📷

web
背景 家里有一条狗🐶,很喜欢乘人不备睡沙发🛋️,恰好最近刚搬家 + 狗迎来了掉毛期 不想让沙发上很多毛。所以希望能识别到狗,然后播放“gun 下去”的音频📣。 需求分析 需要一个摄像头📷 利用 chrome 浏览器可以调用手机摄像头,获取权限,然后利用 ...
继续阅读 »

背景



家里有一条狗🐶,很喜欢乘人不备睡沙发🛋️,恰好最近刚搬家 + 狗迎来了掉毛期 不想让沙发上很多毛。所以希望能识别到狗,然后播放“gun 下去”的音频📣。


需求分析



  • 需要一个摄像头📷

    • 利用 chrome 浏览器可以调用手机摄像头,获取权限,然后利用 video 将摄像头的内容绘制到 video 上。



  • 通过摄像头实时识别画面中的狗🐶

    • 利用 tensorflow 和预训练的 COCO-SSD MobileNet V2 模型进行对象检测。

    • 将摄像头的视频流转化成视频帧图像传给模型进行识别



  • 录制一个音频

    • 识别到目标(狗)后播放音频📣



  • 需要部署在一个设备上

    • 找一个不用的旧手机📱,Android 系统

    • 安装 termux 来实现开启本地 http 服务🌐




技术要点



  1. 利用浏览器 API 调用手机摄像头,将视频流推给 video


    const stream = await navigator.mediaDevices.getUserMedia({
    // video: { facingMode: "environment" }, // 摄像头后置
    video: { facingMode: "user" },
    });

    const videoElement = document.getElementById("camera-stream");
    videoElement.srcObject = stream;


  2. 加载模型,实现识别


    let dogDetector;

    async function loadDogDetector() {
    // 加载预训练的SSD MobileNet V2模型
    const model = await cocoSsd.load();
    dogDetector = model; // 将加载好的模型赋值给dogDetector变量
    }


  3. 监听 video 的播放,将视频流转换成图像传入模型检测


    videoElement.addEventListener("play", async () => {
    requestAnimationFrame(processVideoFrame);
    });

    async function processVideoFrame() {
    if (!videoElement.paused && !videoElement.ended) {
    canvas.width = videoElement.videoWidth;
    canvas.height = videoElement.videoHeight;
    ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height);

    // 获取当前帧图像数据
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

    // 对帧执行预测
    let predictionClasses = "";
    const predictions = await dogDetector.detect(imageData);
    // 处理预测结果,比如检查是否有狗被检测到
    for (const prediction of predictions) {
    predictionClasses += `${prediction.class}\n`; // 组装识别的物体名称
    if (prediction.class === "dog") {
    // 播放声音
    playDogBarkSound();
    }
    }
    nameContainer.innerText = predictionClasses.trim(); // 移除末尾的换行符

    requestAnimationFrame(processVideoFrame);
    }
    }


  4. 播放音频


    async function playDogBarkSound() {
    if (playing) return;
    playing = true;
    const audio = new Audio(dogBarkSound);
    audio.addEventListener("ended", () => {
    playing = false;
    });
    audio.volume = 0.5; // 调整音量大小
    await audio.play();
    }


  5. 手机开启本地 http 服务



    • 安装 termux

    • 安装 python3

    • 运行 python3 -m http.server 8000



  6. 将项目上传到 termux 的目录





项目代码(改为 html 文件后)


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mobile Dog Detector</title>
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.17.0/dist/tf.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/coco-ssd@2.2.3/dist/coco-ssd.min.js"></script>
<style>
#camera-stream {
width: 200px;
height: auto;
}
#name {
height: 200px;
overflow-y: auto;
font-family: Arial, sans-serif;
}
</style>
</head>
<body>
<video id="camera-stream" autoplay playsinline></video>
<div id="name" style="height: 200px"></div>

<script>
let playing = false;
let dogDetector;

async function loadDogDetector() {
// 加载预训练的SSD MobileNet V2模型
const model = await cocoSsd.load();
dogDetector = model; // 将加载好的模型赋值给dogDetector变量
console.log("dogDetector", dogDetector);
startCamera();
}
// 调用函数加载模型
loadDogDetector();

async function startCamera() {
const stream = await navigator.mediaDevices.getUserMedia({
// video: { facingMode: "environment" }, // 摄像头后置
video: { facingMode: "user" },
});
const nameContainer = document.getElementById("name");
const videoElement = document.getElementById("camera-stream");
videoElement.srcObject = stream;

const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");

videoElement.addEventListener("play", async () => {
requestAnimationFrame(processVideoFrame);
});
async function processVideoFrame() {
if (!videoElement.paused && !videoElement.ended) {
canvas.width = videoElement.videoWidth;
canvas.height = videoElement.videoHeight;
ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height);

const imageData = ctx.getImageData(
0,
0,
canvas.width,
canvas.height
);

let predictionClasses = "";
const predictions = await dogDetector.detect(imageData);
for (const prediction of predictions) {
predictionClasses += `${prediction.class}\n`;
if (prediction.class === "dog") {
// 修改为检测到狗时播放声音
playDogBarkSound();
}
}
nameContainer.innerText = predictionClasses.trim();

requestAnimationFrame(processVideoFrame);
}
}

async function playDogBarkSound() {
if (playing) return;
playing = true;
const audio = new Audio("./getout.mp3");
audio.addEventListener("ended", () => {
playing = false;
});
audio.volume = 0.5; // 调整音量大小
await audio.play();
}
}
</script>
</body>
</html>

实现效果


效果很好👍,用旧手机开启摄像头后,检测到狗就播放声音了。


但是,家里夫人直接做了一个围栏晚上给狗圈起来了🚫



实现总结


该方案通过以下步骤实现了一个基于网页的实时物体检测系统,专门用于识别画面中的狗并播放特定音频以驱赶它离开沙发。具体实现过程包括以下几个核心部分:



  • 调用摄像头:


使用浏览器提供的 navigator.mediaDevices.getUserMedia API 获取用户授权后调用手机摄像头,并将视频流设置给 video 元素展示。



  • 加载物体检测模型:


使用 TensorFlow.js 和预训练的 COCO-SSD MobileNet V2 模型进行对象检测,加载模型后赋值给 dogDetector 变量。
处理视频流与图像识别:


监听 video 元素的播放事件,通过 requestAnimationFrame 循环逐帧处理视频。
将当前视频帧绘制到 canvas 上,然后从 canvas 中提取图像数据传入模型进行预测。
在模型返回的预测结果中,如果检测到“dog”,则触发播放音频函数。



  • 播放音频反馈:


定义一个异步函数 playDogBarkSound 来播放指定的音频文件,确保音频只在前一次播放结束后才开始新的播放。



  • 部署环境准备:


使用旧 Android 手机安装 Termux,创建本地 HTTP 服务器运行项目代码。
上传项目文件至 Termux 目录下并通过访问 localhost:8000 启动应用。


通过以上技术整合,最终实现了在旧手机上部署一个能够实时检测画面中狗的网页应用,并在检测到狗时播放指定音频。


作者:前端小蜗
来源:juejin.cn/post/7345672631323394098
收起阅读 »

Androidmanifest文件加固和对抗

前言 恶意软件为了不让我们很容易反编译一个apk,会对androidmanifest文件进行魔改加固,本文探索androidmanifest加固的常见手法以及对抗方法。这里提供一个恶意样本的androidmanifest.xml文件,我们学完之后可以动手实践。...
继续阅读 »

前言


恶意软件为了不让我们很容易反编译一个apk,会对androidmanifest文件进行魔改加固,本文探索androidmanifest加固的常见手法以及对抗方法。这里提供一个恶意样本的androidmanifest.xml文件,我们学完之后可以动手实践。


1、Androidmanifest文件组成


这里贴一张经典图,主要描述了androidmanifest的组成


image


androidmanifest文件头部仅仅占了8个字节,紧跟其后的是StringPoolType字符串常量池


(为了方便我们观察分析,可以先安装一下010editor的模板,详细见2、010editor模板)


Magic Number


这个值作为头部,是经常会被魔改的,需要重点关注


image


StylesStart


该值一般为0,也是经常会发现魔改


image


StringPool


image


寻找一个字符串,如何计算?


1、获得字符串存放开放位置:0xac(172),此时的0xac是不带开头的8个字节


所以需要我们加上8,最终字符串在文件中的开始位置是:0xb4


2、获取第一个字符串的偏移,可以看到,偏移为0


image


3、计算字符串最终存储的地方: 0xb4 = 0xb4 + 0


读取字符串,以字节00结束


image


读取到的字符为:theme


帮助网安学习,全套资料S信领取:


① 网安学习成长路径思维导图

② 60+网安经典常用工具包

③ 100+SRC漏洞分析报告

④ 150+网安攻防实战技术电子书

⑤ 最权威CISSP 认证考试指南+题库

⑥ 超1800页CTF实战技巧手册

⑦ 最新网安大厂面试题合集(含答案)

⑧ APP客户端安全检测指南(安卓+IOS)


总结:


stringpool是紧跟在文件头后面的一块区域,用于存储文件所有用到的字符串


这个地方呢,也是经常发生魔改加固的,比如:将StringCount修改为0xFFFFFF无穷大


在经过我们的手动计算和分析后,我们对该区域有了更深的了解。


2、010editor模板


使用010editor工具打开,安装模板库


image


搜索:androidmanifest.bt


image


安装完成且运行之后:


image


会发现完整的结构,帮助我们分析


3、使用AXMLPrinter2进行的排错和修复


用法十分简单:


java -jar AXMLPrinter2.jar AndroidManifest_origin.xml

会有一系列的报错,但是不要慌张,根据这些报错来对原androidmanifest.xml进行修复


image​​


意思是:出乎意料的0x80003(正常读取的数据),此时却读取到:0x80000


按照小端序,正常的数据应该是: 03 00 08


使用 010editor 打开


image


将其修复


image


保存,再次尝试运行AXMLPrinter2


image


好家伙还有错误,这个-71304363,不方便我们分析,将其转换为python的hex数据


NegativeArraySizeException 表示在创建数组的时候,数组的大小出现了负数。


androidmanifest加固后文件与正常的androidmanifest文件对比之后就可以发现魔改的地方。


image


将其修改回去


image


运行仍然报错,是个新错误:


image


再次去分析:


image


stringoffsets如此离谱,并且数组的大小变为了0xff


image


image


根据报错的信息,尝试把FF修改为24


image


image


再次运行


image


成功拿到反编译后的androidmanifest.xml文件


总结:


这个例子有三个魔改点经常出现在androidmanifest.xml加固


恶意软件通过修改这些魔改点来对抗反编译


作者:合天网安实验室
来源:juejin.cn/post/7324011299272310811
收起阅读 »

前端在线预览播放视频方案,dpPlayer

web
华为云生成obs链接时,可以做配置。 视频是用来预览的 视频是用来下载的 一般我们播放本地视频都是使用vedio标签,但是vedio标签只支持三种视频格式:MP4、WebM、Ogg,对于在线视频直接使用vedio不支持播放。 故,上述 2 中的视频,在ve...
继续阅读 »

华为云生成obs链接时,可以做配置。



  1. 视频是用来预览

  2. 视频是用来下载


一般我们播放本地视频都是使用vedio标签,但是vedio标签只支持三种视频格式:MP4、WebM、Ogg,对于在线视频直接使用vedio不支持播放。
故,上述 2 中的视频,在vedio中不支持播放,浏览器访问链接,直接就下载了。


先介绍几个概念:


流协议: 流协议就是在两个通信系统之间传输多媒体文件的一套规则,它定义了视频文件将如何分解为小数据包以及它们在互联网上传输的顺序,RTMP与 RTSP 是比较常见的流媒体协议。


HLS: HLS (HTTP Live Streaming)是Apple的动态码率自适应技术。主要用于PC和Apple终端的音视频服务。包括一个m3u(8)的索引文件,TS媒体分片文件和key加密串文件。参考:HLS。简单来说,HLS是一种协议,如果你的视频源是http://xxxx.m3u8这种,就选择这种协议,.m3u8是个文本文件,直播时,他的内容实时变更,内部指向一个或多个.ts文件。


HTTP-FLV: HTTP-FLV 是将音视频数据以 FLV 文件格式进行封装,再将 FLV 格式数据封装在 HTTP 协议中进行传输的一种流媒体传输方式。HTTP-FLV 的实现原理: HTTP-FLV 利用 HTTP/1.1 分块传输机制发送 FLV 数据。虽然直播服务器无法知道直播流的长度,但是 HTTP/1.1 分块传输机制可以不填写 conten-length 字段而是携带 Transfer-Encoding: chunked 字段,这样客户端就会一直接受数据。参考:FLV 和 HTTP-FLV

简单来说就是你的视频源是直播且是xxxx.flv,就选择这种协议播放。还有个websocket-flv,是基于websocket的。


RTMP与RTSP: 什么是RTMP 和 RTSP?它们之间有什么区别?


H264(AVC)与H265(HEVC): 都是视频编码,是视频压缩格式,由于视频本身的码流太大,所以需要经过压缩然后再通过网络进行传输,其中H265是H264的升级版,很多播放器无法播放H265视频。




xgplayer


vue2的系统,本来用xgplayer 版本:2.32.5。无奈本地可以展示,测试环境不能用,报错不明显,粗略看了一下是插件底层,内部报错,故放弃xgpalyer插件。


ps.我在vue3的系统中,用过xgpalyer插件,挺好用的


优点如下:



  • 官网教程非常简单清晰,上手快

  • 使用起来体验感很好

  • 支持直播点播,支持hls、http+flv、dash、WebRTC直播,还有音乐播放器 。

  • 提供在线可调试demo


dpplayer


然后,我就换了 dppalyer插件来展示。点击查看中文文档


这个插件,我去github查了一下,15k星星,用的人还是挺多,但是,个人感觉不如 xgplayer好用。


安装npm install dplayer --save


在页面中引用


import DPlayer from 'dplayer';

const dp = new DPlayer(options);

dpplayer实现是通过生成iframe页面,将视频嵌套到其中。


刚开始给容器写了样式,宽100% 高100%,结果它不能自适应屏幕,很难受。后面我强行定宽420px。高度自动获取当前容器高度,定了一个最大高度。


但其实没有用,它会根据宽度,自己按比例缩放高度。
所以我在视频渲染出来后,自动调了一下全屏功能dpPlayer.fullScreen.request('web');
勉强解决了这个问题。


贴一下我的完整代码


<template>
<div class="vedio-wrapper" :style="{'max-height': winH}">
<el-empty v-if="!player" description="暂无数据"></el-empty>
<div :id="id" allowfullscreen="allowfullscreen" />
</div>

</template>


<script>
import DPlayer from 'dplayer';

import { getParam } from '@/utils/utils'
import {
getBucketObsFileUrl
} from '@/api/common'

export default {
name: 'previewMedia',
components:{},
data() {
return {
winH: '300px',
id: 'dpPlayerDom',
player: null
}

},
created() {
const winH = window.innerHeight
this.winH = winH + 'px'
},
mounted() {
this.getFileUrl()
},
methods: {

async getFileUrl() {
try {
const filePath = getParam('filePath')
const type = getParam('type') ? parseInt(getParam('type')) : 1
if (!filePath) return
const params = {
objectKey: filePath,
type
}
const data = await getBucketObsFileUrl(params);
this.setVedioplayerConfig(data)
} catch (e) {
console.error(e)
}
},

setVedioplayerConfig(url) {
if (!url) return

const tmpConfig = {
container: document.getElementById('dplayer'),
screenshot: false,
video: {
url: url,
thumbnails: 'thumbnails.jpg',
},
contextmenu: []

}

this.$nextTick(() => {
tmpConfig.container = document.getElementById(this.id)
const dpPlayer = new DPlayer(tmpConfig);
this.player = dpPlayer

dpPlayer.fullScreen.request('web');
})

}
}
}
</script>

<style scoped lang="scss">
.vedio-wrapper {
width: 400px;
height: 100%;
margin: 0 auto;
}
</style>



作者:山间板栗
来源:juejin.cn/post/7355456165244239912
收起阅读 »

使用RecyclerView实现三种阅读器翻页样式

一、整体逻辑 为何直接对RecyclerView进行扩展而不使用ViewPager/ViewPager2?原因如下: Scroll Model(垂直滑动)需要自定义自动滑动(对指定页进行吸附) Flip Mode(仿真翻页)需要获取各种情况下的方向信息,以...
继续阅读 »

一、整体逻辑


image.png


为何直接对RecyclerView进行扩展而不使用ViewPager/ViewPager2?原因如下:



  1. Scroll Model(垂直滑动)需要自定义自动滑动(对指定页进行吸附)

  2. Flip Mode(仿真翻页)需要获取各种情况下的方向信息,以实现更好的控制

  3. RecyclerView方便拓展,同时三种模式同时使用RecyclerView实现,便于复用


实现逻辑:三种滑动模式都在RecyclerView地基础上更改其滑动行为,横向滑动需要修改子View层级,仿真翻页需要再覆盖一层仿真动画


二、横向覆盖滑动(Slide Mode)


横向.gif


Slide Mode 最适合直接使用 ViewPager,不过我们还是以 RecyclerView 为基础来实现,让三种模式统一实现方式。实现思路:先实现跨页吸附,再实现覆盖翻页效果


1、跨页吸附


实现跨页吸附,需要在手指离开屏幕时对 RecyclerView 进行复位吸附操作,有两种情况:


(1)Scroll Idle


拖拽发生后,RecyclerView 滑动状态变为 SCROLL_STATE_IDLE 时,需要进行复位吸附操作


// OrientationHelper为系统提供的辅助类,LayoutManager的包装类
// 可以让我们方便的计算出RecyclerView相关的各种宽高,计算结果和LayoutManager方向相关
open fun snapToTargetExistingView(helper: OrientationHelper): Pair<Int, Int>? {
val lm = mRecyclerView.layoutManager ?: return null
val childCount = lm.childCount // 可见数量
if (childCount < 1) return null

var closestChild: View? = null
var absClosest = Int.MAX_VALUE
var scrollDistance = 0
// RecyclerView中心点,LayoutManager为竖向则是Y轴坐标,为横向则是X轴坐标
val containerCenter = helper.startAfterPadding + helper.totalSpace / 2

// 从可见Item中找到距RecyclerView离中心最近的View
for (i in 0 until childCount) {
val child = lm.getChildAt(i) ?: continue
if (consumeSnap(i, child)) return null // consumeSnap 默认返回false,竖直滑动模式才使用
val childCenter = (helper.getDecoratedStart(child)
+ helper.getDecoratedMeasurement(child) / 2)
val absDistance = abs(childCenter - containerCenter)
if (absDistance < absClosest) {
absClosest = absDistance
closestChild = child
scrollDistance = childCenter - containerCenter
}
}
closestChild ?: return null

// 滑动
when (orientation) {
VERTICAL -> mRecyclerView.smoothScrollBy(0, scrollDistance)
HORIZONTAL -> mRecyclerView.smoothScrollBy(scrollDistance, 0)
}
return Pair(scrollDistance, lm.getPosition(closestChild))
}


(2)Fling


可以通过 RecyclerView 提供的OnFlingListener消费掉Fling,将其转化为 SmoothScroll ,滑动到指定位置


①、找到吸附目标的位置(adapter position)


open fun findTargetSnapPosition(
lm: RecyclerView.LayoutManager,
velocity: Int,
helper: OrientationHelper
)
: Int {
val itemCount: Int = lm.itemCount
if (itemCount == 0) return RecyclerView.NO_POSITION

// 中心点以前距离最近的View
var closestChildBeforeCenter: View? = null
var distanceBefore = Int.MIN_VALUE // 中心点以前,距离为负数
// 中心点以后距离最近的View
var closestChildAfterCenter: View? = null
var distanceAfter = Int.MAX_VALUE // 中心点以后,距离为正数
val containerCenter = helper.startAfterPadding + helper.totalSpace / 2

val childCount: Int = lm.childCount
for (i in 0 until childCount) {
val child = lm.getChildAt(i) ?: continue
if (consumeSnap(i, child)) return RecyclerView.NO_POSITION // consumeSnap 默认返回false,竖直滑动模式才使用

val childCenter = (helper.getDecoratedStart(child)
+ helper.getDecoratedMeasurement(child) / 2)
val distance = childCenter - containerCenter

// Fling需要考虑方向,先获取两个方向最近的View
if (distance in (distanceBefore + 1)..0) {
distanceBefore = distance
closestChildBeforeCenter = child
}
if (distance in 0 until distanceAfter) {
distanceAfter = distance
closestChildAfterCenter = child
}
}

// 根据方向选择Fling到哪个View
val forwardDirection = velocity > 0
if (forwardDirection && closestChildAfterCenter != null) {
return lm.getPosition(closestChildAfterCenter)
} else if (!forwardDirection && closestChildBeforeCenter != null) {
return lm.getPosition(closestChildBeforeCenter)
}

// 边界情况处理
val visibleView =
(if (forwardDirection) closestChildBeforeCenter else closestChildAfterCenter)
?: return RecyclerView.NO_POSITION
val visiblePosition: Int = lm.getPosition(visibleView)
val snapToPosition = (visiblePosition - 1)

return if (snapToPosition < 0 || snapToPosition >= itemCount) {
RecyclerView.NO_POSITION
} else snapToPosition
}

②、使用RecyclerView的「LinearSmoothScroller」完成吸附动画


private fun createScroller(
oh: OrientationHelper
)
: LinearSmoothScroller {
return object : LinearSmoothScroller(mRecyclerView.context) {
override fun onTargetFound(
targetView: View,
state: RecyclerView.State,
action: Action
)
{
val d = distanceToCenter(targetView, oh)
val time = calculateTimeForDeceleration(abs(d))
if (time > 0) {
when (orientation) {
VERTICAL -> action.update(0, d, time, mDecelerateInterpolator)
HORIZONTAL -> action.update(d, 0, time, mDecelerateInterpolator)
}
}
}

override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics) =
100f / displayMetrics.densityDpi

override fun calculateTimeForScrolling(dx: Int) =
100.coerceAtMost(super.calculateTimeForScrolling(dx))
}
}

protected fun distanceToCenter(targetView: View, helper: OrientationHelper): Int {
val childCenter = (helper.getDecoratedStart(targetView)
+ helper.getDecoratedMeasurement(targetView) / 2)
val containerCenter = helper.startAfterPadding + helper.totalSpace / 2
return childCenter - containerCenter
}

完整操作:


protected fun snapFromFling(
lm: RecyclerView.LayoutManager,
velocity: Int,
helper: OrientationHelper
)
: Pair<Boolean, Int> {
val targetPosition = findTargetSnapPosition(lm, velocity, helper)
if (targetPosition == RecyclerView.NO_POSITION) return Pair(false, 0)
val smoothScroller = createScroller(helper)
smoothScroller.targetPosition = targetPosition
lm.startSmoothScroll(smoothScroller)
return Pair(true, targetPosition) // 消费fling
}

2、覆盖效果实现


(1)如果使用PageTransform实现


如果使用ViewPagerPageTransform,是可以实现覆盖动画的,实现思路:使可见View的第二个View跟随屏幕滑动


image.png


假设上图蓝色透明矩形为屏幕,其他为ItemView,图片上半部分正常滑动的状态,下半部分为 translate view 之后的状态。可以看到,在横向滑动过程中,最多可见2个View(蓝色透明方框最多覆盖2个View),此时将第二个View跟随屏幕,其他View保持跟随画布滑动,即可达到效果。在OnPageScroll回调中实现这个逻辑:


for (i in 0 until layoutManager.childCount) {
layoutManager.getChildAt(i)?.also { view ->
if (i == 1) {
// view.left是个负数,offsetPx(=-view.left)是个正数
view.translationX = offsetPx.toFloat() - view.width // 需要translate的距离(向前移需要负数)
} else {
// 恢复其余位置的translate
view.translationX = 0f
}
}
}

(2)扩展RecyclerView实现覆盖翻页


知道如何通过 PageTransfrom 实现后,我们来看看直接使用 RecyclerView 如何实现。观看ViewPager2源码可知PageTransfrom的实现方式


image.png


故我们直接copy代码,在OnScrollListener中自行实现onPageScrolled回调即可实现覆盖翻页效果。


但是此时还有一个问题,就是子View的层级问题,你会发现上面的滑动示意图中,绿色View会在黄色View之上,如何解决这个问题呢?我们需要控制View的绘制顺序,前面的View后绘制,保证前面地View在后面的View的绘制层级之上。


观看源码会发现,RecyclerView其实提供了一个回调ChildDrawingOrderCallback,可以很方便地实现这个效果:


override fun attach() {
super.attach()
mRecyclerView.setChildDrawingOrderCallback(this)
}

override fun onGetChildDrawingOrder(childCount: Int, i: Int) = childCount - i - 1 // 反向绘制

三、竖直滑动(Scroll Mode)


垂直.gif


竖直滑动需要滑动到跨章的位置时才吸附(自动回滚到指定位置),需要实现两个效果:跨章吸附、跨章Fling阻断。我们可以在横向覆盖滑动(Slide Mode)的基础上做一个减法,首先将LayoutManager改为竖向的,然后实现上述两个效果。


1、跨章吸附


实现跨章吸附,我们先在 RecyclerViewAdapter 中对每个View进行一个标记:


companion object {
const val TYPE_NONE = 100 // 其他
const val TYPE_FIRST_PAGE = 101 // 首页
const val TYPE_LAST_PAGE = 102 // 末页
}


fun bind() { // onBindViewHolder 时调用
itemView.tag = when {
textPage.isLastPage -> TYPE_LAST_PAGE
textPage.isFirstPage -> TYPE_FIRST_PAGE
else -> TYPE_NONE
}
......
}

其次我们实现横向覆盖滑动(Slide Mode)中的一段代码(做一个减法):


// 如果不是章节的最后一页,则消费Snap(不进行吸附操作)
override fun consumeSnap(index: Int, child: View) =
index == 0 && child.tag != ReadBookAdapter.TYPE_LAST_PAGE

这样就可以实现不是跨越章节的翻页不进行吸附,而跨越章节的滑动会自动吸附。


2、跨章Fling阻断


在滑动过程中,基于可见View只有两个的情况:



  • 如果向上滑动,判断第一个可见View是否「末页」,如果是,smoothScroll到第二个可见View

  • 如果向下滑动,判断第二个可见View是否「首页」,如果是,smoothScroll到第一个可见View


private var inFling = false     // 正在fling,在OnFlingListener中设置为true
private var inBlocking = false // 阻断fling


override val mScrollListener = object : RecyclerView.OnScrollListener() {
var mScrolled = false

override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
when (newState) {
RecyclerView.SCROLL_STATE_DRAGGING -> {
inFling = false // 重置inFling
}
RecyclerView.SCROLL_STATE_IDLE -> {
inFling = false // 重置inFling
if (inBlocking) {
inBlocking = false // 忽略阻断造成的IDLE
} else if (mScrolled) {
mScrolled = false
snapToTargetExistingView(orientationHelper.value)
}
}
}
}

override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (dy != 0) {
if (!mScrolled) {
this@VSnapHelper.mCallback.onScrollBegin()
mScrolled = true
}
val lm = mRecyclerView.layoutManager ?: return
// fling阻断
if (inFling && !inBlocking) {
val child: View?
val type: Int
if (dy > 0) { // 向上滑动
child = lm.getChildAt(0)
type = ReadBookAdapter.TYPE_LAST_PAGE
} else {
child = lm.getChildAt(lm.childCount - 1)
type = ReadBookAdapter.TYPE_FIRST_PAGE
}
child?.let {
if (it.tag == type) {
inBlocking = true
val d = distanceToCenter(it, orientationHelper.value)
mRecyclerView.smoothScrollBy(0, d)
}
}
}
}
}
}

四、仿真页(Flip Mode)


仿真.gif


仿真页在横向覆盖滑动(Slide Mode)基础之上实现,我们还需要实现:



  1. 确认手指滑动方向

  2. 所有可见View都跟随屏幕

  3. 绘制次序根据拖拽方向改变,保证目标页在当前页之上

  4. 绘制仿真页

  5. 手指抬起后的翻页动画(确认Fling、Scroll Idle产生的两种Snap的方向,因为手指会来回滑动导致方向判断错误)


1、确认手指滑动方向


滑动方向不能直接在 onTouchdispatchTouchEvent 这些方法中直接判断,
因为极微小的滑动都会决定方向,这样会造成轻微触碰就判定了方向,导致页面内容闪动、抖动等问题。
我们需要在滑动了一定距离后确定方向,最好的选择就是在 onPageScroll 中进行判断,系统为我们保证了ScrollState已变为DRAGGING,此时用户100%已经在滑动。可以看下源码真正触发「onPageScroll」的条件有哪些


image.png


我们实现的判断方向的代码:


// 在onScrolled中调用
// mCurrentItem:onPageSelected中赋值,代表当前Item
// position:第一个可见View的位置
// offsetPx:第一个可见View的left取负
// mForward:方向,true为画布向左滑动(向尾部滑动),false画布向右滑动(向头部滑动)
private fun dispatchScrolled(position: Int, offsetPx: Int) {
if (mScrollState == RecyclerView.SCROLL_STATE_DRAGGING) {
mForward = mCurrentItem == position
}
mCallback.onPageScrolled(position, mCurrentItem, offsetPx, mForward)
}

image.png


不过这个规则在超快速滑动时会判断错误,即settling直接变dragging的时候,所以会对滑动做一点限制


override fun dispatchTouchEvent(e: MotionEvent): Boolean {
if (snapHelper.mScrollState == RecyclerView.SCROLL_STATE_SETTLING) {
return true // sellting过程中禁止滑动
}
delegate.onTouch(e)
return super.dispatchTouchEvent(e)
}

2、遮盖效果


所有可见View都跟随屏幕,横向覆盖滑动(Slide Mode)的增强版,因为给 RecyclerView设置了 offScreenLimit=1 的效果,所以 LayoutManagerchild 数量最多会有4个
(参照 ViewPager2 # LinearLayoutManagerImpl 实现,这里设置是为了滑动时可以第一时间生成目标页的截图)


// onPageScrolled中调用
private fun transform(offsetPx: Int, firstVisible: Int) {
val count = layoutManager.childCount
if (count == 2 || (count == 3 && offsetPx == 0)) {
// 可见View只有一个的时候,全部复位
for (i in 0 until count) {
layoutManager.getChildAt(i)?.also { view ->
view.translationX = 0f
}
}
} else {
var target = 1
if (count == 3 && firstVisible == 0) target-- // 首位适配,currentItem=0且存在滑动的时候
for (i in 0 until layoutManager.childCount) {
layoutManager.getChildAt(i)?.also { view ->
when (i) {
target -> view.translationX = offsetPx.toFloat()
target + 1 -> view.translationX = offsetPx.toFloat() - view.width
else -> view.translationX = 0f
}
}
}
}
}

3、绘制次序根据拖拽方向改变


保证目标页在当前页之上,防止绘制的仿真页消失时出现闪屏(瞬间显示了不正确的页)


// 画布左移则反向绘制,右移则正想绘制
override fun getDrawingOrder(childCount: Int, i: Int) =
if (snapHelper.mForward) childCount - i - 1 else i

4、绘制仿真页


我们在 RecyclerView 的父View上直接覆盖绘制一层仿真页Bitmap


(1)生成截图


如上面所说,实现了 offScreenLimit=1 的效果,我们在首次获取到方向时生成截图:


// 生成截图方法
fun View.screenshot(): Bitmap? {
return runCatching {
val screenshot = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
val c = Canvas(screenshot)
c.translate(-scrollX.toFloat(), -scrollY.toFloat())
draw(c)
screenshot
}.getOrNull()
}
private var isBeginDrag = false

override fun onPageStateChange(state: Int) {
when (state) {
RecyclerView.SCROLL_STATE_DRAGGING -> {
isBeginDrag = true
}
}
}

override fun onPageScrolled(firstVisible: Int, current: Int, offsetPx: Int, forward: Boolean) {
if (isBeginDrag) {
isBeginDrag = false
delegate.apply {
if (forward) {
nextBitmap?.recycle()
nextBitmap = layoutManager.findViewByPosition(current + 1)?.screenshot()
curBitmap?.recycle()
curBitmap = layoutManager.findViewByPosition(current)?.screenshot()
} else {
prevBitmap?.recycle()
prevBitmap = layoutManager.findViewByPosition(current - 1)?.screenshot()
curBitmap?.recycle()
curBitmap = layoutManager.findViewByPosition(current)?.screenshot()
}
setDirection(if (forward) AnimDirection.NEXT else AnimDirection.PREV)
}
invalidate()
}
}

(2)绘制仿真页


绘制仿真页参考 gedoor/legadoSimulationPageDelegate



  • 基础知识:三角函数、Android的矩阵、贝塞尔曲线、canvas.clipPath的 XOR & INTERSECT 模式

  • 绘制方法:Android仿真翻页:cnblogs.com

  • 计算方法:使用手指触摸点和触摸点对应的角位置(比如触摸点靠近右下角,角位置就是右下角),这两个点可以算出所有参数


确认方向后,我们只用通过修改手指触碰点的参数即可控制整个动画(根据点击位置实时计算即可)


5、动画控制


手指抬起后的翻页动画通过 Scroller+invalidate实现


override fun computeScroll() {
if (scroller.computeScrollOffset()) {
setTouchPoint(scroller.currX.toFloat(), scroller.currY.toFloat())
} else if (isStarted) {
stopScroll()
}
}

对于FlingScroll Idle产生的吸附效果,我们需要各自回调方向:


// 选中时开始动画,此时position改变
override fun onPageSelected(position: Int) {
val page = adapter.data[position]
ReadBook.onPageChange(page)
if (canDraw) {
delegate.onAnimStart(300, false)
}
}

// position未改变的情况
override fun onSnap(isFling: Boolean, forward: Boolean, changePosition: Boolean) {
if (!changePosition) {
delegate.onAnimStart(
300,
true,
// 未改变方向,向前则播放向后动画
if (forward) AnimDirection.PREV else AnimDirection.NEXT
)
}
}

Scroll Idle通过 SmoothScroll 所需要滑动的距离正负判断方向:


// Scroll
override fun snapToTargetExistingView(helper: OrientationHelper): Pair<Int, Int>? {
mSnapping = true
super.snapToTargetExistingView(helper)?.also {
// first为滑动距离,second为目标Item的position
mCallback.onSnap(false, it.first > 0, mCurrentItem != it.second)
return it
}
return null
}

// Fling
override val mFlingListener = object : RecyclerView.OnFlingListener() {
override fun onFling(velocityX: Int, velocityY: Int): Boolean {
val lm = mRecyclerView.layoutManager ?: return false
mRecyclerView.adapter ?: return false
val minFlingVelocity = mRecyclerView.minFlingVelocity
val result = snapFromFling(
lm,
velocityX,
orientationHelper.value
)
val consume = abs(velocityX) > minFlingVelocity && result.first
if (consume) {
mSnapping = true
// second为目标Item的position,这里直接通过速度正负来判断方向
mCallback.onSnap(true, velocityX > 0, result.second != mCurrentItem)
}
return consume
}
}

(以上为所有关键点,只截取了部分代码,提供一个思路)


作者:尉迟涛
来源:juejin.cn/post/7244819106343829564
收起阅读 »

值得使用Lambda的8个场景,别再排斥它了!

前言 可能对不少人来说,Lambda显得陌生又复杂,觉得Lambda会导致代码可读性下降,诟病Lambda语法,甚至排斥。 其实所有的这些问题,在尝试并熟悉后,可能都不是问题。 对Lambda持怀疑态度的人,也许可以采取渐进式使用Lambda的策略。在一些简单...
继续阅读 »

前言


可能对不少人来说,Lambda显得陌生又复杂,觉得Lambda会导致代码可读性下降,诟病Lambda语法,甚至排斥。


其实所有的这些问题,在尝试并熟悉后,可能都不是问题。


对Lambda持怀疑态度的人,也许可以采取渐进式使用Lambda的策略。在一些简单和低风险的场景下先尝试使用Lambda,逐渐增加Lambda表达式的使用频率和范围。


毕竟2023年了,JDK都出了那么多新版本,是时候试试Lambda了!


耐心看完,你一定有所收获。


giphy.gif


正文


1. 对集合进行遍历和筛选:


使用Lambda表达式结合Stream API可以在更少的代码量下实现集合的遍历和筛选,更加简洁和易读。


原来的写法:


List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
for (Integer num : numbers) {
if (num % 2 == 0) {
System.out.println(num);
}
}

优化的Lambda写法:


List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
numbers.stream()
.filter(num -> num % 2 == 0)
.forEach(System.out::println);

2. 对集合元素进行排序:


使用Lambda表达式可以将排序逻辑以更紧凑的形式传递给sort方法,使代码更加简洁。


原来的写法:


List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");
Collections.sort(names, new Comparator<String>() {
public int compare(String name1, String name2) {
return name1.compareTo(name2);
}
});

优化的Lambda写法:


List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");
names.sort((name1, name2) -> name1.compareTo(name2));

3. 集合的聚合操作:


Lambda表达式结合Stream API可以更优雅地实现对集合元素的聚合操作,例如求和、求平均值等。


原来的写法:


List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = 0;
for (Integer num : numbers) {
sum += num;
}

优化的Lambda写法:


List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream()
.reduce(0, Integer::sum);

4. 条件过滤和默认值设置:


使用Lambda的Optional类可以更加优雅地处理条件过滤和默认值设置的逻辑。


原来的写法:


String name = "Alice";
if (name != null && name.length() > 0) {
System.out.println("Hello, " + name);
} else {
System.out.println("Hello, Stranger");
}

Lambda写法:


String name = "Alice";
name = Optional.ofNullable(name)
.filter(n -> n.length() > 0)
.orElse("Stranger");
System.out.println("Hello, " + name);

5. 简化匿名内部类:


可以简化代码,同时提高代码可读性。


举个创建Thread的例子,传统方式使用匿名内部类来实现线程,语法较为冗长,而Lambda表达式可以以更简洁的方式达到相同的效果。


原来的写法:


new Thread(new Runnable() {
public void run() {
System.out.println("Thread is running.");
}
}).start();

Lambda写法:


new Thread(() -> System.out.println("Thread is running.")).start();

new Thread(() -> {
// 做点什么
}).start();

这种写法也常用于简化回调函数,再举个例子:


假设我们有一个简单的接口叫做Calculator,它定义了一个单一的方法calculate(int a, int b)来执行数学运算:


// @FunctionalInterface: 标识接口是函数式接口,只包含一个抽象方法,从而能够使用Lambda表达式来实现接口的实例化
@FunctionalInterface
interface Calculator {
int calculate(int a, int b);
}

现在,让我们创建一个名为CallbackExample的类。该类有一个名为operate的方法,它接受两个整数和一个Calculator接口作为参数。该方法将使用提供的Calculator接口执行计算并返回结果:


public class CallbackExample {

public static int operate(int a, int b, Calculator calculator) {
return calculator.calculate(a, b);
}

public static void main(String[] args) {
int num1 = 10;
int num2 = 5;

// 使用Lambda作为回调
int sum = operate(num1, num2, (x, y) -> x + y);
int difference = operate(num1, num2, (x, y) -> x - y);
int product = operate(num1, num2, (x, y) -> x * y);
int division = operate(num1, num2, (x, y) -> x / y);

System.out.println("Sum: " + sum);
System.out.println("Difference: " + difference);
System.out.println("Product: " + product);
System.out.println("Division: " + division);
}
}

通过在方法调用中直接定义计算的行为,我们不再需要为每个运算创建多个实现Calculator接口的类,使得代码更加简洁和易读


giphy (1).gif


6. 集合元素的转换:


使用Lambda的map方法可以更优雅地对集合元素进行转换,提高代码的可读性


原来的写法:


List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
List<String> uppercaseNames = new ArrayList<>();
for (String name : names) {
uppercaseNames.add(name.toUpperCase());
}

Lambda写法:


List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
List<String> uppercaseNames = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());

7. 对集合进行分组和统计:


以更紧凑的形式传递分组和统计的逻辑,避免了繁琐的匿名内部类的声明和实现。


通过groupingBy、counting、summingInt等方法,使得代码更加流畅、直观且优雅。


传统写法:



List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Amy", "Diana");

// 对名字长度进行分组
Map<Integer, List<String>> namesByLength = new HashMap<>();
for (String name : names) {
int length = name.length();
if (!namesByLength.containsKey(length)) {
namesByLength.put(length, new ArrayList<>());
}
namesByLength.get(length).add(name);
}
System.out.println("Names grouped by length: " + namesByLength);

// 统计名字中包含字母'A'的个数
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Amy", "Diana");
int namesWithA = 0;
for (String name : names) {
if (name.contains("A")) {
namesWithA++;
}
}
System.out.println("Number of names containing 'A': " + namesWithA);

Lambda写法:


List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Amy", "Diana");

// 使用Lambda表达式对名字长度进行分组
Map<Integer, List<String>> namesByLength = names.stream()
.collect(Collectors.groupingBy(String::length));
System.out.println("Names grouped by length: " + namesByLength);

// 使用Lambda表达式统计名字中包含字母'A'的个数
long namesWithA = names.stream()
.filter(name -> name.contains("A"))
.count();
System.out.println("Number of names containing 'A': " + namesWithA);

8. 对大数据量集合的并行处理


当集合的数据量很大时,通过Lambda结合Stream API可以方便地进行并行处理,充分利用多核处理器的优势,提高程序的执行效率。


假设我们有一个包含一百万个整数的列表,我们想要计算这些整数的平均值:


import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;

public class ParallelStreamExample {
public static void main(String[] args) {
// 创建一个包含一百万个随机整数的列表
List<Integer> numbers = new ArrayList<>();
for (int i = 0; i < 1000000; i++) {
numbers.add(ThreadLocalRandom.current().nextInt(100));
}

// 顺序流的处理
long startTimeSeq = System.currentTimeMillis();
double averageSequential = numbers.stream()
.mapToInt(Integer::intValue)
.average()
.getAsDouble();
long endTimeSeq = System.currentTimeMillis();
System.out.println("Sequential Average: " + averageSequential);
System.out.println("Time taken (Sequential): " + (endTimeSeq - startTimeSeq) + "ms");

// 并行流的处理
long startTimePar = System.currentTimeMillis();
double averageParallel = numbers.parallelStream()
.mapToInt(Integer::intValue)
.average()
.getAsDouble();
long endTimePar = System.currentTimeMillis();
System.out.println("Parallel Average: " + averageParallel);
System.out.println("Time taken (Parallel): " + (endTimePar - startTimePar) + "ms");
}
}

分别使用顺序流和并行流来计算列表中整数的平均值:



  • 顺序流:通过stream()方法获取流,使用mapToInt将Integer转换为int,然后使用average()方法计算平均值

  • 并行流:使用parallelStream()方法获取并行流,其他步骤与顺序流相同


查看输出结果:


Sequential Average: 49.517461
Time taken (Sequential): 10ms
Parallel Average: 49.517461
Time taken (Parallel): 3ms

可以看出,顺序流和并行流得到了相同的平均值,但并行流的处理时间明显少于顺序流。因为并行流能够将任务拆分成多个小任务,并在多个处理器核心上同时执行这些任务。


当然并行流也有缺点:



  • 对于较小的数据集,可能并行流更慢

  • 数据处理本身的开销较大,比如复杂计算、大量IO操作、网络通信等,可能并行流更慢

  • 可能引发线程安全问题


收尾


Lambda的使用场景远不止这些,在多线程、文件操作等场景中也都能灵活运用,一旦熟悉后可以让代码更简洁,实现精准而优雅的编程。


写代码时,改变偏见需要我们勇于尝试和付诸行动。有时候,我们可能会对某种编程语言、框架或设计模式持有偏见,认为它们不适合或不好用。但是,只有尝试去了解和实践,我们才能真正知道它们的优点和缺点。


当我们愿意打破旧有的观念,敢于尝试新的技术和方法时,我们就有机会发现新的可能性和解决问题的新途径。不要害怕失败或犯错,因为每一次尝试都是我们成长和进步的机会。


只要我们保持开放的心态,不断学习和尝试,我们就能够超越偏见,创造出更优秀的代码和解决方案。


所以,让我们在编程的路上,积极地去挑战和改变偏见。用行动去证明,只有不断地尝试,我们才能取得更大的进步和成功。让我们敢于迈出第一步,勇往直前,一同创造出更美好的编程世界!


ab4cb34agy1g4sgjkrgxlj20j60ahgm2.jpg


作者:一只叫煤球的猫
来源:juejin.cn/post/7262737716852473914
收起阅读 »

Android - 你可能需要这样一个日志库

前言 目前大多数库api设计都是Log.d("tag", "msg")这种风格,而且支持自定义日志存储的比较少, 所以作者想自己造一个轮子。 这种api风格有什么不好呢? 首先,它的tag是一个字符串,需要开发人员严格管理tag,要不然可能各种硬编码的tag满...
继续阅读 »

前言


目前大多数库api设计都是Log.d("tag", "msg")这种风格,而且支持自定义日志存储的比较少,
所以作者想自己造一个轮子。


这种api风格有什么不好呢?


首先,它的tag是一个字符串,需要开发人员严格管理tag,要不然可能各种硬编码的tag满天飞。


另外,它也可能导致性能陷阱,假设有这么一段代码:


// 打印一个List
Log.d("tag", list.joinToString())

此处使用Debug打印日志,生产模式下调高日志等级,不打印这一行日志,但是list.joinToString()这一行代码仍然会被执行,有可能导致性能问题。


下文会分析作者期望的api是什么样的,本文演示代码都是用kotlin,库中好用的api也是基于kotlin特性来实现的。


作者写库有个习惯,对外开放的类或者全局方法都会加一个前缀f,一个是为了避免命名冲突,另一个是为了方便代码检索,以下文章中会出现,这里做一下解释。


期望


什么样的api才能解决上面的问题呢?我们看一下方法的签名和打印方式


inline fun <reified T : FLogger> flogD(block: () -> Any)

interface AppLogger : FLogger

flogD {
list.joinToString { it }
}

flogD方法打印Debug日志,传一个Flogger的子类AppLogger作为日志标识,同时传一个block来返回要打印的日志内容。


日志标识是一个类或者接口,所以管理方式比较简单不会造成tag混乱的问题,默认tag是日志标识类的短类名。生产模式下调高日志等级后,block就不会被执行了,避免了可能的性能问题。


实现分析


日志库的完整实现已经写好了,放在这里xlog



  • 支持限制日志大小,例如限制每天只能写入10MB的日志

  • 支持自定义日志格式

  • 支持自定义日志存储,即如何持久化日志


这一节主要分析一下实现过程中遇到的问题。


问题:如果App运行期间日志文件被意外删除了,怎么处理?


在Android中,用java.io的api对一个文件进行写入,如果文件被删除,继续写入的话不会抛异常,这样会导致日志丢失,该如何解决?


有同学说,在写入之前先检查文件是否存在,如果存在就继续写入,不存在就创建后写入。


检查一个文件是否存在通常是调用java.io.File.exist()方法,但是它比较耗性能,我们来做一个测试:


measureTime {
repeat(1_0000) {
file.exists()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

14:50:33.536 MainActivity            com.sd.demo.xlog                I  time:39
14:50:35.872 MainActivity com.sd.demo.xlog I time:54
14:50:38.200 MainActivity com.sd.demo.xlog I time:43
14:50:40.028 MainActivity com.sd.demo.xlog I time:53
14:50:41.693 MainActivity com.sd.demo.xlog I time:58

可以看到1万次调用的耗时在50毫秒左右。


我们再测试一下对文件写入的耗时:


val output = filesDir.resolve("log.txt").outputStream().buffered()
val log = "1".repeat(50).toByteArray()
measureTime {
repeat(1_0000) {
output.write(log)
output.flush()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

14:57:56.092 MainActivity            com.sd.demo.xlog                I  time:38
14:57:56.558 MainActivity com.sd.demo.xlog I time:57
14:57:57.129 MainActivity com.sd.demo.xlog I time:57
14:57:57.559 MainActivity com.sd.demo.xlog I time:46
14:57:58.054 MainActivity com.sd.demo.xlog I time:54

可以看到1万次调用,每次写入50个字符的耗时也在50毫秒左右。如果每次写入日志前都判断一下文件是否存在,那么实际上相当于2次写入的性能成本,这显然很不划算。


还有同学说,开一个线程,定时判断文件是否存在,这样子虽然不会损耗单次写入的性能,但是又多占用了一个线程资源,显然也不符合作者的需求。


其实Android已经给我们提供了这种场景的解决方案,那就是android.os.MessageQueue.IdleHandler,关于IdleHandler这里就不展开讨论了,简单来说就是当你在主线程注册一个IdleHandler后,它会在主线程空闲的时候被执行。


我们可以在每次写入日志之后注册IdleHandler,等IdleHandler被执行的时候检查一下日志文件是否存在,如果不存在就关闭输出流,这样子在下一次写入的时候就会重新创建文件写入了。


这里要注意每次写入日志之后注册IdleHandler,并不是每次都创建新对象,要判断一下如果原先的对象还未执行的话就不用注册一个新的IdleHandler,库中大概的代码如下:


private class LogFileChecker(private val block: () -> Unit) {
private var _idleHandler: IdleHandler? = null

fun register(): Boolean {
// 如果当前线程没有Looper则不注册,上层逻辑可以直接检查文件是否存在,因为是非主线程
Looper.myLooper() ?: return false

// 如果已经注册过了,直接返回
_idleHandler?.let { return true }

val idleHandler = IdleHandler {
// 执行block检查任务
libTryRun { block() }

// 重置变量,等待下次注册
_idleHandler = null
false
}

// 保存并注册idleHandler
_idleHandler = idleHandler
Looper.myQueue().addIdleHandler(idleHandler)
return true
}
}

这样子文件被意外删除之后,就可以重新创建写入了,避免丢失大量的日志。


问题:如何检测文件大小是否溢出


库支持对每天的日志大小做限制,例如限制每天最多只能写入10MB,每次写入日志之后都会检查日志大小是否超过限制,通常我们会调用java.io.File.length()方法获取文件的大小,但是它也比较耗性能,我们来做一个测试:


val file = filesDir.resolve("log.txt").apply {
this.writeText("hello")
}
measureTime {
repeat(1_0000) {
file.length()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

16:56:04.090 MainActivity            com.sd.demo.xlog                I  time:61
16:56:05.329 MainActivity com.sd.demo.xlog I time:80
16:56:06.382 MainActivity com.sd.demo.xlog I time:72
16:56:07.496 MainActivity com.sd.demo.xlog I time:79
16:56:08.591 MainActivity com.sd.demo.xlog I time:78

可以看到耗时在60毫秒左右,相当于上面测试中1次文件写入的耗时。


库中支持自定义日志存储,在日志存储接口中定义了size()方法,上层通过此方法来判断当前日志的大小。


如果自定义了日志存储,避免在此方法中每次调用java.io.File.length()来返回日志大小,应该维护一个表示日志大小的变量,变量初始化的时候获取一下java.io.File.length(),后续通过写入的数量来增加这个变量的值,并在size()方法中返回。库中默认的日志存储实现类就是这样实现的,有兴趣的可以看这里


问题:文件大小溢出后怎么处理?


假设限制每天最多只能写入10MB,那超过10MB后如何处理?有同学说直接删掉或者清空文件,重新写入,这也是一种策略,但是会丢失之前的所有日志。


例如白天写了9.9MB,到晚上的时候写满10MB,清空之后,白天的日志都没了,这时候用户反馈白天遇到的一个bug,需要上传日志,那就芭比Q了。


有没有办法少丢失一些呢?可以把日志分多个文件存储,为了便于理解假设分为2个文件存储,一天10MB,那1个文件最多只能写入5MB。具体步骤如下:



  1. 写入文件20231128.log

  2. 20231128.log写满5MB的时候关闭输出流,并把它重命名为20231128.log.1


这时候继续写日志的话,发现20231128.log文件不存在就会创建,又跳到了步骤1,就这样一直重复1和2两个步骤,到晚上写满10MB的时候,至少还有5MB的日志内容保存在20231128.log.1文件中避免丢失全部的日志。


分的文件数量越多,保留的日志就越多,实际上就是拿出一部分空间当作中转区,满了就向后递增数字重命名备份。目前库中只分为2个文件存储,暂时不开放自定义文件数量。


问题:打印日志的性能


性能,是这个库最关心的问题,通常来说文件写入操作是性能开销的大头,目前是用java.io相关的api来实现的,怎样提高写入性能作者也一直在探索,在demo中提供了一个基于内存映射的日志存储方案,但是稳定性未经测试,后续测试通过后可能会转正。有兴趣的读者可以看看这里


还有一个比较影响性能的就是日志的格式化,通常要把一个时间戳转为某个日期格式,大部分人都会用java.text.SimpleDateFormat来格式化,用它来格式化年:月:日的时候问题不大,但是如果要格式化时:分:秒.毫秒那它就比较耗性能,我们来做一个测试:


val format = SimpleDateFormat("HH:mm:ss.SSS")
val millis = System.currentTimeMillis()
measureTime {
repeat(1_0000) {
format.format(millis)
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

16:05:26.920 MainActivity            com.sd.demo.xlog                I  time:245
16:05:27.586 MainActivity com.sd.demo.xlog I time:227
16:05:28.324 MainActivity com.sd.demo.xlog I time:212
16:05:29.370 MainActivity com.sd.demo.xlog I time:217
16:05:30.157 MainActivity com.sd.demo.xlog I time:193

可以看到1万次格式化耗时大概在200毫秒左右。


我们再用java.util.Calendar测试一下:


val calendar = Calendar.getInstance()
// 时间戳1
val millis1 = System.currentTimeMillis()
// 时间戳2
val millis2 = millis1 + 1000
// 切换时间戳标志
var flag = true
measureTime {
repeat(1_0000) {
calendar.timeInMillis = if (flag) millis1 else millis2
calendar.run {
"${get(Calendar.HOUR_OF_DAY)}:${get(Calendar.MINUTE)}:${get(Calendar.SECOND)}.${get(Calendar.MILLISECOND)}"
}
flag = !flag
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

16:11:25.342 MainActivity            com.sd.demo.xlog                I  time:35
16:11:26.209 MainActivity com.sd.demo.xlog I time:35
16:11:27.316 MainActivity com.sd.demo.xlog I time:37
16:11:28.057 MainActivity com.sd.demo.xlog I time:25
16:11:28.825 MainActivity com.sd.demo.xlog I time:18


这里解释一下为什么要用两个时间戳,因为Calendar内部有缓存,如果用同一个时间戳测试的话,没办法评估它真正的性能,所以这里每次格式化之后就切换到另一个时间戳,避免缓存影响测试。


可以看到1万次的格式化耗时在30毫秒左右,差距很大。如果要自定义日志格式的话,建议用Calendar来格式化时间,有更好的方案欢迎和作者交流。


问题:日志的格式如何显示


手机的存储资源是宝贵的,如何定义日志格式也是一个比较重要的细节。



  • 优化时间显示


目前库内部是以天为单位来命名日志文件的,例如:20231128.log,所以在格式化时间戳的时候只保留了时:分:秒.毫秒,避免冗余显示当天的日期。



  • 优化日志等级显示


打印的时候提供了4个日志等级:Verbose, Debug, Info, Warning, Error,一般最常用的记录等级是Info,所以在格式化的时候如果等级是Info则不显示等级标志,规则如下:


private fun FLogLevel.displayName(): String {
return when (this) {
FLogLevel.Verbose -> "V"
FLogLevel.Debug -> "D"
FLogLevel.Warning -> "W"
FLogLevel.Error -> "E"
else -> ""
}
}


  • 优化日志标识显示


如果连续2条或多条日志都是同一个日志标识,那么就只有第1条日志会显示日志tag



  • 优化线程ID显示


如果是主线程的话,不显示线程ID,只有非主线程才显示线程ID


经过上面的优化之后,日志打印的格式是这样的:


flogI { "1" }
flogI { "2" }
flogW { "3" }
flogI { "user debug" }
thread {
flogI { "thread" }
}

19:19:43.961[AppLogger] 1
19:19:43.974 2
19:19:43.975[W] 3
19:19:43.976[UserLogger] user debug
19:19:43.977[12578] thread

API


这一节介绍一下库的API,调用FLog.init()方法初始化,初始化如果不想打印日志,可以调用FLog.setLevel(FLogLevel.Off)关闭日志


常用方法


// 初始化
FLog.init(
//(必传参数)日志文件目录
directory = filesDir.resolve("app_log"),

//(可选参数)自定义日志格式
formatter = AppLogFormatter(),

//(可选参数)自定义日志存储
storeFactory = AppLogStoreFactory(),

//(可选参数)是否异步发布日志,默认值false
async = false,
)

// 设置日志等级 All, Verbose, Debug, Info, Warning, Error, Off 默认日志等级:All
FLog.setLevel(FLogLevel.All)

// 限制每天日志文件大小(单位MB),小于等于0表示不限制大小,默认限制每天日志大小100MB
FLog.setLimitMBPerDay(100)

// 设置是否打打印控制台日志,默认打开
FLog.setConsoleLogEnabled(true)

/**
* 删除日志,参数saveDays表示要保留的日志天数,小于等于0表示删除全部日志,
* 此处saveDays=1表示保留1天的日志,即保留当天的日志
*/

FLog.deleteLog(1)

打印日志


interface AppLogger : FLogger

flogV { "Verbose" }
flogD { "Debug" }
flogI { "Info" }
flogW { "Warning" }
flogE { "Error" }

// 打印控制台日志,不会写入到文件中,不需要指定日志标识,tag:DebugLogger
fDebug { "console debug log" }

配置日志标识


可以通过FLog.config方法修改某个日志标识的配置信息,例如下面的代码:


FLog.config {
// 修改日志等级
this.level = FLogLevel.Debug

// 修改tag
this.tag = "AppLoggerAppLogger"
}

自定义日志格式


class AppLogFormatter : FLogFormatter {
override fun format(record: FLogRecord): String {
// 自定义日志格式
return record.msg
}
}

interface FLogRecord {
/** 日志标识 */
val logger: Class<out FLogger>

/** 日志tag */
val tag: String

/** 日志内容 */
val msg: String

/** 日志等级 */
val level: FLogLevel

/** 日志生成的时间戳 */
val millis: Long

/** 日志是否在主线程生成 */
val isMainThread: Boolean

/** 日志生成的线程ID */
val threadID: String
}

自定义日志存储


日志存储是通过FLogStore接口实现的,每一个FLogStore对象负责管理一个日志文件。
所以需要提供一个FLogStore.Factory工厂为每个日志文件提供FLogStore对象。


class AppLogStoreFactory : FLogStore.Factory {
override fun create(file: File): FLogStore {
return AppLogStore(file)
}
}

class AppLogStore(file: File) : FLogStore {
// 添加日志
override fun append(log: String) {}

// 返回当前日志的大小
override fun size(): Long = 0

// 关闭
override fun close() {}
}

结束


库目前还处于alpha阶段,如果有遇到问题可以及时反馈给作者,最后感谢大家的阅读。


作者:Sunday1990
来源:juejin.cn/post/7306423214493270050
收起阅读 »

布局升级秘籍:掌握CSS Grid网格布局,打造响应式网页设计

随着现代网页设计的不断演进,传统的布局方式已经逐渐不能满足设计师和开发者们对于高效、灵活且强大布局系统的追求。而CSS Grid网格布局,正是在这样的背景下应运而生的。今天,我们就来深入探讨CSS Grid布局的魅力所在,带你解锁这项强大的设计工具,让网页布局...
继续阅读 »

随着现代网页设计的不断演进,传统的布局方式已经逐渐不能满足设计师和开发者们对于高效、灵活且强大布局系统的追求。而CSS Grid网格布局,正是在这样的背景下应运而生的。

今天,我们就来深入探讨CSS Grid布局的魅力所在,带你解锁这项强大的设计工具,让网页布局变得更加简单和高效。

一、什么是CSS Grid布局?

CSS Grid布局,简称为Grid,是CSS的一个二维布局系统,它能够处理行和列,使得网页布局变得更加直观和强大。与传统的布局方式相比,Grid能够轻松实现复杂的页面结构,而无需繁琐的浮动、定位或是使用多个嵌套容器。

Grid网格布局是一种基于网格的布局系统,它允许我们通过定义行和列的大小、位置和排列方式来创建复杂的网页布局。

Description

这与之前讲到的flex一维布局不相同。

设置display:grid/inline-grid的元素就是网格布局容器,这样就能触发浏览器渲染引擎的网格布局算法。

<div>
<div class="item item-1">
<p></p >
</div>
<div class="item item-2"></div>
<div class="item item-3"></div>
</div>

上述代码实例中,.container元素就是网格布局容器,.item元素就是网格的项目,由于网格元素只能是容器的顶层子元素,所以p元素并不是网格元素。

二、Grid的基本概念

首先,我们来了解一下CSS Grid布局的核心概念:

容器(Container):

设置了display: grid;的元素成为容器。它是由一组水平线和垂直线交叉构成,就如同我们所在的地区是由小区和各个路构成。

项目(Item):

容器内的直接子元素,称为项目。

网格线(Grid Lines):

划分行和列的线条,可以想象成坐标轴。正常情况下n行会有n+1根横向网格线,m列有m+1根纵向网格线。比如田字就好像是一个三条水平线和三条垂直线构成的网格元素。

Description

上图是一个 2 x 3 的网格,共有3根水平网格线和4根垂直网格线。

行:

即两个水平网格线之间的空间,也就是水平轨道,就好比我们面朝北边东西方向横向排列的楼房称为行。

列:

即两个垂直网格线之间的空间,也就是垂直轨道,也就是南北方向排列的楼房。

单元格:

由水平线和垂直线交叉构成的每个区域称为单元格,网络单元格是CSS网格中的最小单元。也就是说东西和南北方向的路交叉后划分出来的土地区域。

网格轨道(Grid Tracks):

两条相邻网格线之间的空间。

网格区域(Grid Area):

四条网格线围成的空间,可以是行或列。本质上,网格区域一定是矩形的。例如,不可能创建T形或L形的网格区域。

三、Grid的主要属性

CSS Grid网格布局的主要属性包括:

  • display:设置元素为网格容器或网格项。

  • grid-template-columns 和 grid-template-rows:用于定义网格的列和行的大小。

  • grid-column-gap 和 grid-row-gap:用于定义网格的列和行的间距。

  • grid-template-areas:用于定义命名区域,以便在网格中引用。

  • grid-auto-flow:用于控制网格项的排列方式,可以是行(row)或列(column)。

  • grid-auto-columns 和 grid-auto-rows:用于定义自动生成的列和行的大小。

  • grid-column-start、grid-column-end、grid-row-start 和 grid-row-end:用于定义网格项的位置。

  • justify-items、align-items 和 place-items:用于对齐网格项。

  • grid-template:一个复合属性,用于一次性定义多个网格布局属性。

下面将详细介绍这些属性的概念及作用:

3.1 display

通过给元素设置:display:grid | inline-grid,可以让一个元素变成网格布局元素。

语法:

display: grid | inline-grid;

display: grid:表示把元素定义为块级网格元素,单独占一行;

display:inline-grid:表示把元素定义为行内块级网格元素,可以和其他块级元素在同一行。

3.2 grid-template-columns和grid-template-rows

grid-template-columns和grid-template-rows:用于定义网格的列和行的大小。

  • grid-template-columns 属性设置列宽

  • grid-template-rows 属性设置行高

.wrapper {
display: grid;
/* 声明了三列,宽度分别为 200px 200px 200px */
grid-template-columns: 200px 200px 200px;
grid-gap: 5px;
/* 声明了两行,行高分别为 50px 50px */
grid-template-rows: 50px 50px;
}

以上表示固定列宽为 200px 200px 200px,行高为 50px 50px。

上述代码可以看到重复写单元格宽高,我们也可以通过使用repeat()函数来简写重复的值。

  • 第一个参数是重复的次数

  • 第二个参数是重复的值

所以上述代码可以简写成:

.wrapper {
display: grid;
grid-template-columns: repeat(3,200px);
grid-gap: 5px;
grid-template-rows:repeat(2,50px);
}

除了上述的repeact关键字,还有:

auto-fill: 表示自动填充,让一行(或者一列)中尽可能的容纳更多的单元格。

grid-template-columns: repeat(auto-fill, 200px)

表示列宽是 200 px,但列的数量是不固定的,只要浏览器能够容纳得下,就可以放置元素。

fr: 片段,为了方便表示比例关系。

grid-template-columns: 200px 1fr 2fr

表示第一个列宽设置为 200px,后面剩余的宽度分为两部分,宽度分别为剩余宽度的 1/3 和 2/3。

minmax: 产生一个长度范围,表示长度就在这个范围之中都可以应用到网格项目中。第一个参数就是最小值,第二个参数就是最大值。

minmax(100px, 1fr)

表示列宽不小于100px,不大于1fr。

auto: 由浏览器自己决定长度。

grid-template-columns: 100px auto 100px

表示第一第三列为 100px,中间由浏览器决定长度。

3.3 grid-row-gap 属性, grid-column-gap 属性, grid-gap 属性

grid-column-gap和grid-row-gap,用于定义网格的列间距和行间距。grid-gap 属性是两者的简写形式。

  • grid-row-gap: 10px 表示行间距是 10px

  • grid-column-gap: 20px 表示列间距是 20px

  • grid-gap: 10px 20px 等同上述两个属性

3.4 grid-auto-flow 属性

grid-auto-flow,用于控制网格项的排列方式,可以是行(row)或列(column)。

  • 划分网格以后,容器的子元素会按照顺序,自动放置在每一个网格。

  • 顺序就是由grid-auto-flow决定,默认为行,代表"先行后列",即先填满第一行,再开始放入第二行。

Description

当修改成column后,放置变为如下:

Description

3.5 justify-items 属性, align-items 属性, place-items 属性

justify-items、align-items和place-items,用于定义网格项目的对齐方式。

  • justify-items 属性设置单元格内容的水平位置(左中右)

  • align-items 属性设置单元格的垂直位置(上中下)

.container {
justify-items: start | end | center | stretch;
align-items: start | end | center | stretch;
}

属性对应如下:

  • start:对齐单元格的起始边缘

  • end:对齐单元格的结束边缘

  • center:单元格内部居中

  • stretch:拉伸,占满单元格的整个宽度(默认值)

  • place-items属性是align-items属性和justify-items属性的合并简写形式。

3.6 justify-content 属性, align-content 属性, place-content 属性

  • justify-content属性是整个内容区域在容器里面的水平位置(左中右)

  • align-content属性是整个内容区域的垂直位置(上中下)

.container {
justify-content: start | end | center | stretch | space-around | space-between | space-evenly;
align-content: start | end | center | stretch | space-around | space-between | space-evenly;
}

两个属性的写法完全相同,都可以取下面这些值:

  • start - 对齐容器的起始边框

  • end - 对齐容器的结束边框

  • center - 容器内部居中

Description

  • space-around - 每个项目两侧的间隔相等。所以,项目之间的间隔比项目与容器边框的间隔大一倍。

  • space-between - 项目与项目的间隔相等,项目与容器边框之间没有间隔。

  • space-evenly - 项目与项目的间隔相等,项目与容器边框之间也是同样长度的间隔。

  • stretch - 项目大小没有指定时,拉伸占据整个网格容器。

Description

3.7 grid-auto-columns 属性和 grid-auto-rows 属性

有时候,一些项目的指定位置,在现有网格的外部,就会产生显示网格和隐式网格。

比如网格只有3列,但是某一个项目指定在第5行。这时,浏览器会自动生成多余的网格,以便放置项目。超出的部分就是隐式网格。

而grid-auto-rows与grid-auto-columns就是专门用于指定隐式网格的宽高。

3.8 grid-column-start 属性、grid-column-end 属性、grid-row-start 属性以及grid-row-end 属性

指定网格项目所在的四个边框,分别定位在哪根网格线,从而指定项目的位置。

  • grid-column-start 属性:左边框所在的垂直网格线

  • grid-column-end 属性:右边框所在的垂直网格线

  • grid-row-start 属性:上边框所在的水平网格线

  • grid-row-end 属性:下边框所在的水平网格线

<style>
#container{
display: grid;
grid-template-columns: 100px 100px 100px;
grid-template-rows: 100px 100px 100px;
}
.item-1 {
grid-column-start: 2;
grid-column-end: 4;
}
</style>

<div id="container">
<div class="item item-1">1</div>
<div class="item item-2">2</div>
<div class="item item-3">3</div>
</div>

通过设置grid-column属性,指定1号项目的左边框是第二根垂直网格线,右边框是第四根垂直网格线。

Description

3.9 grid-area 属性

grid-area 属性指定项目放在哪一个区域。

.item-1 {
grid-area: e;
}

意思为将1号项目位于e区域

grid-area属性一般与上述讲到的grid-template-areas搭配使用。

想要快速入门前端开发吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!

点这里前往学习哦!

3.10 justify-self 属性、align-self 属性以及 place-self 属性

  • justify-self属性设置单元格内容的水平位置(左中右),跟justify-items属性的用法完全一致,但只作用于单个项目。

  • align-self属性设置单元格内容的垂直位置(上中下),跟align-items属性的用法完全一致,也是只作用于单个项目。

.item {
justify-self: start | end | center | stretch;
align-self: start | end | center | stretch;
}

这两个属性都可以取下面四个值。

  • start:对齐单元格的起始边缘。

  • end:对齐单元格的结束边缘。

  • center:单元格内部居中。

  • stretch:拉伸,占满单元格的整个宽度(默认值)

四、Grid网格布局应用场景

CSS Grid网格布局的应用场景非常广泛,包括但不限于:

创建复杂的网页布局:

CSS Grid网格布局可以轻松创建出复杂的网页布局,如多列布局、不规则布局等。

创建响应式设计:

CSS Grid网格布局可以轻松实现响应式设计,通过调整网格的大小和间距,可以适应不同的屏幕尺寸。

创建复杂的组件布局:

CSS Grid网格布局也可以用于创建复杂的组件布局,如卡片布局、轮播图布局等。

总的来说,CSS Grid网格布局是一种强大的布局工具,可以帮助网页设计者轻松创建出各种复杂的网页布局。

CSS Grid布局为我们提供了一个全新的视角来思考页面布局,它让复杂布局的实现变得简单明了。随着浏览器支持度的提高,未来的网页设计将更加灵活和富有创意。

掌握了CSS Grid布局,你就已经迈出了成为前端设计高手的重要一步。不断实践,不断探索,你会发现更多Grid的神奇之处。

收起阅读 »

腾讯云:颜面尽失的草台班子

昨天下午,2024年04月08日,腾讯云出现了一场全球性的大故障,用腾讯云官方的说法,崩了 74 分钟(15:31 - 16:45),波及全球 17 个区域与数十款服务。事实影响是什么但这与我观察到的事实不符 —— 从故障范围上来说,这次的故障几乎是去年阿里云...
继续阅读 »

昨天下午,2024年04月08日,腾讯云出现了一场全球性的大故障,用腾讯云官方的说法,崩了 74 分钟(15:31 - 16:45),波及全球 17 个区域与数十款服务。

事实影响是什么

但这与我观察到的事实不符 —— 从故障范围上来说,这次的故障几乎是去年阿里云双十一史诗级大故障的翻版 —— 小道消息是整个管控面 GG,云 API 挂了,所以现象与去年阿里云如出一辙:依赖云 API 的云产品控制台不能用了。

被管控的纯资源,如云服务器 CVM,云数据库 RDS, 设置了公开读写访问对象存储 COS 不受影响可以继续使用。然而依赖认证与API 的各种云 PaaS 服务,例如标准的私有读写的对象存储 COS,就抓瞎了。

因为阿里云至今没有做一个像样的事后故障复盘,因此在《我们能从阿里云史诗级故障中学到什么》中,我为阿里云的这次故障做了非官方的技术复盘。同样的判断逻辑完全也适用于这次故障 —— 这样的爆炸半径,根因出在 Auth 上的概率很大。目前,腾讯云仍然没有给出官方的事后故障复盘报告,也可能不会有了。


忽悠人的状态页

我的朋友杨攀曾写过一篇《中国云服务走向全球?先把 Status Page 搞定》,讨论了 Status Page (服务健康状态页)对于公有云服务的重要性,各家本土云厂商也跟进了这一特性,包括腾讯云。—— 状态页能在服务宕机的情况下有效减少客户的焦虑,降低沟通成本,但它的核心价值在于 “建立与客户的信任关系”。

看上去,腾讯云与阿里云的 Status Page 反应都比较迟缓,在故障发生后三四十分钟才开始更新。而不是像Cloudflare 等产品一样及时更新故障,或采用自动化方式监测到故障后立即推送。但不同于阿里云 —— 虽慢却诚实地标记了所有服务受到影响,腾讯云的 Status Page 连基本的真实性与准确性都堪称稀烂。

例如,受到影响的对象存储 COS 服务,在有用户上报问题的几个可用区中,我并没有看到 Status 标红。而这样的例子还有更多。事实上如果问题真出在管控 API 上,那么影响的范围应该和阿里云一样 —— 所有服务的控制面。因此,这样鸡贼的做法只会给客户留下:“不透明、有猫腻“ 的负面印象。


撒谎的三无公告

在故障出现 40 ~ 50 分钟后,腾讯云终于发出了第一份故障公告,也是截止到目前 Status Page 上唯一一份公告。但其内容就一句话 —— 三无公告:无时间(故障时间),无地点(可用区/AZ),无范围(影响服务)。而且姗姗来迟,比我替它发的公告《【腾讯】云计算史诗级二翻车来了》还晚了十分钟。

但这份公告最致命的问题是真实性与准确性:首先,故障绝对不仅仅是“控制台”,而是整个控制面。作为一个专业的云计算服务供应商,一字之差天壤之别,混淆两者区别的原因,要么是蠢(缺乏专业素养,台面混为一谈)。要么是坏(避重就轻,推卸责任)。

请问,一个全身休克的人,说他 “面色异常”,这是一个真诚的回复吗?请问,一台被砸烂的笔记本电脑,说它“敲击键盘没有反应”是一个有意义的描述吗?同理,一个控制面爆炸的公有云,说自己“控制台异常”,是一个认真的回复吗?

其次,从事后官微的发布与用户群的反馈来看,在这个时间,“目前故障已恢复”  是在撒谎。至少相当一部分服务的可用性事件是在 16:45 标记恢复的,在17 点前后,腾讯云产品吐槽群中也仍然有一些问题上报。

我认为这份对腾讯云带来的伤害远比服务宕机要大的多 —— 首先,在及时性,准确性上体现出了极差的专业素养。其次,在真实性上有意做手脚,会伤及公有云,或者说一切生意的根本 —— 诚信这对品牌形象是一个摧毁性打击。


灾难级别的公关


按理说,出现了这么严重的故障,应当用诚恳认真的态度去处理,但腾讯云官方微博居然还在抖机灵 —— 堪称灾难级别的公关水平

这条微博也再次扇了腾讯云自己官网公告的大嘴巴子 —— 16:45 分发第一条帖子时,“工程师仍在紧急修复中”,17:16,距离第一次报告故障的 15:31已经过去近两个小时,“已经整体恢复”。然而,根据腾讯云官网 16:21 发布的公告[1]声称:“故障已恢复”。从实际情况来看,再次证明了官网公告在说谎

阿里云双十一大故障的时候,刚刚开完云栖大会,打脸了吹下的极致高可用的牛逼,但毕竟隔了一周了。而腾讯云这次大故障的同时还在开发布会吹牛逼,还找特大号发了一篇软文:太意外了!国内80%大模型都存在鹅厂!》,发布时间 16:19,2分钟后官网发出故障通告,堪称光速打脸二次方

与之形成鲜明对照的是,去年 11 月 Cloudflare 的故障,Cloudflare CEO Matthew 亲自出来对故障进行道歉与复盘,相比之下,国内云厂商的危机公关堪称灾难级别 —— 彻底做实了草台班子的称号。

实锤的草台班子

请允许我引用瑞典马工的一句名言 :“阿里云是个工程质量差劲的正经云,但腾讯云是一群业余销售加业务码农玩游戏”。所谓光鲜亮丽的大厂,在里面也不过是一个又一个的草台班子。

Reference

公告: https://cloud.tencent.com/announce/detail/1995

https://www.oschina.net/news/286685

https://www.v2ex.com/t/1030638

https://www.v2ex.com/t/103061


云计算泥石流
曾几何时,“上云“近乎成为技术圈的政治正确,整整一代应用开发者的视野被云遮蔽。就让我们用实打实的数据分析与亲身经历,讲清楚公有云租赁模式的价值与陷阱 —— 在这个降本增效的时代中,供您借鉴与参考。



作者:非法加冯
来源:mp.weixin.qq.com/s/PgduTGIvWSUgHZhVfnb7Bg
收起阅读 »

WiFi万能钥匙突然更新,网友炸了

时至今日,机哥已记不清,七八年前曾用过哪些家喻户晓的手机软件。若不是最近看到这样一篇新闻。我都差点忘了,有一个名为“WiFi万能钥匙”的App曾风靡全国。当时机哥身边的亲朋好友,只要是有智能手机的。基本都会安装上这个App。原因倒是不复杂,在那个流量资费偏高的...
继续阅读 »

时至今日,机哥已记不清,七八年前曾用过哪些家喻户晓的手机软件。


若不是最近看到这样一篇新闻。


我都差点忘了,有一个名为“WiFi万能钥匙”的App曾风靡全国。


当时机哥身边的亲朋好友,只要是有智能手机的。


基本都会安装上这个App。

原因倒是不复杂,在那个流量资费偏高的年代。


咱们上网冲浪,主打一个“能蹭WiFi,绝不用流量


恰好,WiFi万能钥匙对用户最大的贡献,也是帮忙蹭网。


不管是人流量爆满的商城,还是学校办公室。


WiFi万能钥匙,总是能如它的名字般神奇,帮用户成功连上WiFi。


关键是,这软件还免费使用。


多少给当时懵懂的机哥,来了点小小的互联网震撼。



也是靠着“随时随地,免费上网”的优势。


WiFi万能钥匙发布不到三年,就拥有超过5亿的激活用户。


公司发的年终奖更是重量级。


给所有入职超过4个月的员工,送一台价值近百万元的特斯拉跑车。


什么叫风头无两啊,就是。


可随着时间推移。


越来越多用户也发现了,所谓的“免费蹭网”,是需要付出代价的。


在这些年的发展中,WiFi万能钥匙翻车过好几次。


包括被官方点名批评。


被华为小米轮番整治。


当时两大手机厂商,标记它为恶意应用,还把它赶出了自家应用商店。


再加上App内部,出现了各种离谱的广告。


不仅在形式上,集百家之所长。


摇一摇跳转、伪装跳过按钮、多图层套娃全凑齐了。


具体到广告内容,更是大杂烩乱炖。


不知道的,还以为下了个病毒软件呢...


尽管官方最近宣布,给WiFi万能钥匙减少70%广告。


但它这些年,积攒起来的崩坏口碑。


可不是一两个优化减负,就能抹掉的。


当然啦,如果只是广告讨人嫌。


那WiFi万能钥匙,还不至于被喷成这程度。


整个App的争议点,就在于它那“共享热点”模式。


没错,虽然它大名叫WiFi万能钥匙。


但它能帮咱们连上各种场合的WiFi,原理并不是暴力破解。


而是从自家密码数据库中,找到与该WiFi相匹配的密码。


等配对成功后,我们就成功蹭上别人的网络了。


官方也很清晰明了地介绍过,该App的运行原理:


软件基于共享经济,利用热点主人分享的闲置WiFi资源,向所有用户提供免费上网服务。

听起来,似乎是个不错的模式对吧。


这就好比,我在某个餐厅输入密码连上了WiFi。


然后WiFi万能钥匙,又把我手机记录下来的WiFi密码,上传到云端数据库,下次别人再来这家餐厅,直接打开App就能连上。


你帮我,我帮你,天下就没有难办的事儿了。


但理想很丰满,现实很骨感。


这共享模式,实际是很难落实下来的。


机哥举个例子啊。


在知乎上,有一个问题写着:


“如果每个人都给我一块钱,那我不就有13亿了吗?”


而且每人只需掏一块钱,也不是啥很大的损失对吧。


可这事儿就和共享WiFi密码一样,有一个大前提:


凭什么?


我凭什么无缘无故,给一个陌生人一块钱?


我又凭什么无缘无故,给一个陌生人,提供自家的WiFi密码?


更何况,是密码上传到一个装机量8亿的App。


对于WiFi万能钥匙来说,运营初期就面对着这个问题。


不过出来混,总得有两把刷子。


很快啊,就有网友对Wi-Fi万能钥匙做出了分析。


他表示,App可以直接从用户手机拿到WiFi密码。


搞机佬都知道,安卓系统在获取Root权限后,可以通过使用Re管理器等App,直接查看存放WiFi密码的文件。


可谓是明文存放,点开就送。


当然,能访问到这个文件夹的前提是,手机得有Root权限。


可很凑巧的是,早期的安卓手机获取权限非常简单。


随便在网上下载个“一键Root”工具,重启手机就完事儿。


所以在那个时候,用户第一次安装打开WiFi万能钥匙,都会被这App索取Root权限。


紧接着,最关键的问题来了。


它到底有没有,通过申请Root权限,来查看用户手机里的WiFi密码保存文件呢?


当时有位知乎老哥,特意反编译了1.0版本的WiFi万能钥匙。


发现了以下这几行代码。


作为一个,只会输入“Hello World”的代码废柴。


机哥还是很自觉地,把代码交给了AI去分析。


结果AI给出的分析,和那位知乎老哥的结论,几乎一模一样。


WiFi万能钥匙1.0版本,会在获取Root权限后,把手机上的WiFi配置文件,复制到了自己的缓存文件夹中。

嗯?难道说...


不过在后续的版本更新中。


WiFi万能钥匙的玩法严谨得多,主打一手正儿八经的“共享”。


比如把用户主动输入的密码存到云端库。


或者和运营商合作,把一些公共区域的免费WiFi给收录进去。


如果实在遇到一些,数据库里配对不上的WiFi。


WiFi万能钥匙还会提示你,可以尝试一下【深度连接】。


而所谓的【深度连接】呢,是App用内置的几千个弱密码,逐个连接同一个WiFi。


机友们都懂的,其实很多家庭路由器,密码都设置得很简单。


诸如12345678、1122334等朗朗上口的密码,简直不要太常见。


所以在很多时候,【深度连接】还真能帮你连上WiFi。


但后来的事情,咱们都知道了。


流量资费便宜了,用户对蹭WiFi的需求日渐下降。


再加上手机厂商和路由器厂商,也开始注重隐私安全。


Wi-Fi万能钥匙作为一个工具类应用,也就失去了解决问题的场景。


用户量减少、入下滑,都是板上钉钉的事儿。


为了维持收支平衡,WiFi万能钥匙加大了软件招商的力度。


我们可是有9亿用户总量的,欢迎来合作啊喂。


具体到可以塞广告的位置。


不能说克制,只能说处处皆是广告位。


横幅、图文、弹窗,基本上能塞内容的位置,都有广告的一席之地。


而WiFi万能钥匙,对于广告的内容筛选,更是让人汗流浃背。


比如说,以美女为诱惑,吸引你点开广告。


早期还有用户吐槽,App内部的信息流推送,有很多擦边低俗资讯。


讲道理,以它如此庞大的用户总量。


这么多广告的接入,肯定能让它在短时间内,赚得盆满钵满。


但这操作,多少有点饮鸩止渴的味道。


更何况,现在早就不是,流氓App能随意践踏手机的时代了。


你看这两年,手机厂商都在集中整治,WiFI类和清理类App存在的问题:


包括违规收集个人信息、频繁弹窗自动下载第三方软件等。


可能是意识到,只靠广告营收走不通。


WiFi万能钥匙在宣布改版后,广告确实少了很多。


那它现在又能靠啥维持生计呢?


机哥带着好奇,安装了新版打开体验。


结果发现,它现在往App塞了个短剧板块。


emmm...机哥也没啥好说的,祝它一切顺利吧。



作者:好机友
来源:mp.weixin.qq.com/s/9IfrA6ilpOit4dVAjH6U4Q
收起阅读 »

Kotlin中 for in 是有序的吗?forEach呢?

我们要遍历一个数组、一个列表,经常会用到kotlin的 for in 语法,但是 for in 是不是有序的呢?forEach是不是有序的呢?这就需要看一下它们的本质了。 数组的 for in // 调用: val arr = arrayOf(1, 2, 3)...
继续阅读 »

我们要遍历一个数组、一个列表,经常会用到kotlin的 for in 语法,但是 for in 是不是有序的呢?forEach是不是有序的呢?这就需要看一下它们的本质了。


数组的 for in


// 调用:
val arr = arrayOf(1, 2, 3)
for (ele in arr) {
println(ele)
}

反编译成Java是个什么东西呢?


Integer[] arr = new Integer[]{1, 2, 3};
Integer[] var4 = arr;
int var5 = arr.length;

for(int var3 = 0; var3 < var5; ++var3) {
int ele = var4[var3];
System.out.println(ele);
}

总结:从Java代码可以看出,实际就是一个普通的for循环,是从下标0开始遍历到结束的,所以是有序的。


列表的 for in


// 调用:
val list = listOf(1, 2, 3)
for (ele in list) {
println(ele)
}

反编译成Java:


List list = CollectionsKt.listOf(new Integer[]{1, 2, 3});
Iterator var3 = list.iterator();

while(var3.hasNext()) {
int ele = ((Number)var3.next()).intValue();
System.out.println(ele);
}

可以看出列表的for in是通过iterator实现的,和数组不一样,那这个iterator遍历是否是有序的呢?首先我们要知道这个iterator怎么来的:


// iterator 是通过调用 list.iterator() 得到的,那么这个list又是什么呢?
Iterator var3 = list.iterator();

// list
List list = CollectionsKt.listOf(new Integer[]{1, 2, 3});

// list是通过数组elements.asList()得到的
public fun <T> listOf(vararg elements: T): List<T> = if (elements.size > 0) elements.asList() else emptyList()

// 这里有个expect,找到对应的actual
public expect fun <T> Array<out T>.asList(): List<T>

// 对应的actual
public actual fun <T> Array<out T>.asList(): List<T> {
return ArraysUtilJVM.asList(this)
}

// 最终调用了Arrays.asList(array)
class ArraysUtilJVM {
static <T> List<T> asList(T[] array) {
return Arrays.asList(array);
}
}

public class Arrays {

// 从这里看到最终拿到的list是 Arrays 类中的 ArrayList
// 然后我们找到里面的 iterator() 方法
public static <T> List<T> asList(T... a) {
return new ArrayList<>(a);
}

private static class ArrayList<E> extends AbstractList<E>
implements RandomAccess, java.io.Serializable
{
private final E[] a;

@Override
public Iterator<E> iterator() {
// 最终得到的iterator是ArrayItr
// 这里的a是一个数组,也就是我们一开始传进来的1,2,3
return new ArrayItr<>(a);
}
}

private static class ArrayItr<E> implements Iterator<E> {
private int cursor;
private final E[] a;

ArrayItr(E[] a) {
this.a = a;
}

@Override
public boolean hasNext() {
return cursor < a.length;
}

@Override
public E next() {
int i = cursor;
if (i >= a.length) {
throw new NoSuchElementException();
}
cursor = i + 1;
return a[i];
}
}
}

总结:列表的for in是通过iterator实现的,这个iterator是ArrayItr,从里面的next()方法可以看出,这也是有序的,从cursor开始,cursor默认是0,也就是从下标0开始遍历。
注:这里只是分析了Arrays.ArrayList的iterator,具体的集合类需要具体分析,比如ArrayList、LinkedList等,不过从正常思维来看,iterator是一个迭代器,就应该有序的把数据一个一个丢出来。


数组的 forEach


// 调用:
val arr = arrayOf(1, 2, 3)
arr.forEach {
println(it)
}

// 点进去forEach看:
// 其实也是调用了for in,所以也是有序的。
public inline fun <T> Array<out T>.forEach(action: (T) -> Unit): Unit {
for (element in this) action(element)
}

列表的 forEach


// 调用:
val list = listOf(1, 2, 3)
list.forEach {
println(it)
}

// 点进去forEach看:
// 其实也是调用了for in,所以也是有序的。
public inline fun <T> Iterable<T>.forEach(action: (T) -> Unit): Unit {
for (element in this) action(element)
}

作者:linq
来源:juejin.cn/post/7304562756429611046
收起阅读 »

一次接手外包公司前端代码运行踩坑过程

web
背景 外包项目结束后,代码交给我公司需要存起来,因为后期还会有迭代开发任务,以后的事情肯定是我们公司内部来维护了,那就需要把代码运行起来,这过程中运行前端项目遇到的几个问题和处理过程简单记录下。 主要问题是,外包公司建有自己UI组件库,所有里面很多包是他们np...
继续阅读 »

背景


外包项目结束后,代码交给我公司需要存起来,因为后期还会有迭代开发任务,以后的事情肯定是我们公司内部来维护了,那就需要把代码运行起来,这过程中运行前端项目遇到的几个问题和处理过程简单记录下。


主要问题是,外包公司建有自己UI组件库,所有里面很多包是他们npm私有仓库的托管,我们无法访问到他们的私服仓库,思路是从 node_modules中 把私有包迁移到我们公司自己内网仓库


代码


我拿到的两个项目代码,共有两个项目代码,下面这是web的代码,处理思路是一样的


image.png


第一步运行看是否正常


因为观察到项目中有 node_modules ,因为外包公司是把整个项目文件都拷贝过来了,里面还包括 .git 目录,如果能直接运行起来,那万事大吉。


显示看下图,是运行报错的,缺少包和相关命令,所以我们还是得自己来重新安装 node_modules ,但是问题是私有包如何解决?


image.png


第二次尝试重新安装包


我们尝试重新直接安装包,安装失败,因为访问不到私有仓库域名


image.png


正式迁移包


我们公司也是用verdaccio搭建过私有仓库的,所以要把外包项目的私有包上传到我们公司内部



  • package.json中找到私有包

  • 拷贝私有包成独立项目

  • 推送到我们公司内部verdaccio仓库(没有私有仓库就传到npm上也一样,但是外包公司自己的包还是别外传)

  • 项目中配置.npmrc锁定包来源

  • 锁定项目中版本号


package.json中找到私有包


通过判定看到下图的包在 http://www.npmjs.com/ 中查找不到,所以下面这些 @iios前缀的包是需要迁移到包


image.png


拷贝私有包成独立项目



我们从 node_modules 中拷贝出来这些文件夹



image.png



观察到所有包都是完整的,都有package.json文件



image.png


推送到我们公司内部verdaccio仓库


这里这么多包,如果简化可以使用lerna或者shell脚本来统一处理版本问题,但是我们简化就按个包执行推送命令即可


image.png


后续所有包同理操作即可


image.png


后面就不一一列举了,检查verdaccio是否推送成功


image.png


项目中配置.npmrc锁定包来源


现在私有包都上传完成了,所以需要回到主项目,安装包就行了,但是因为有私有包,于是需要执行 .npmrc 规定各种包的安装路径


image.png


锁定项目中版本号


这一步是我习惯,在package.json中,版本号固定写死,而不是 ^前缀开头自动更新此版本


而且更重要的是,外包项目已经在线上运行,万一以后要三方包变化导致一些莫名其妙问题就很麻烦,锁定版本号是非常有必要的,才能以后很久之后打开发布代码也是没有问题的


image.png


image.png


删除node_modules 和 yarn.lock ,重新安装包


image.png


image.png


重新运行


一切都搞完了,重新运行成功


image.png


image.png


作者:一个不会重复的id
来源:juejin.cn/post/7348090716578824230
收起阅读 »

Android文件存储

前言在Android中,对于持久化有如下4种:本篇文章主要介绍一些容易出错的地方,以及访问对应空间的API。正文先来看看内部存储空间。内部存储空间由上面图可以看出内部存储空间主要由3个部分组成,其中内部存储的目录就是/data/data/<包名>/...
继续阅读 »

前言

在Android中,对于持久化有如下4种:

持久化.jpg

本篇文章主要介绍一些容易出错的地方,以及访问对应空间的API。

正文

先来看看内部存储空间。

内部存储空间

由上面图可以看出内部存储空间主要由3个部分组成,其中内部存储的目录就是/data/data/<包名>/,对应的目录如下:

内部存储空间.jpg

内部存储空间有如下特点:

  • 每个应用独占一个以包名命名的私有文件夹。
  • 在应用卸载时被删除。
  • 对MediaScanner不可见。
  • 适用于私密数据。

对于内部存储空间,里面有一些默认的文件夹,而对于不同文件的访问,有着不同的API,如下:

  1. 对于data/data/<包名>/目录:
方法描述
Context#getDir(String name,int mode): File获取内部存储根目录下的文件夹,不存在则创建
  1. 对于data/data/<包名>/files/目录:
方法描述
Context#getFilesDir():File!返回files文件夹
Context#fileList(): Array!列举files目录下所有文件和文件夹,String类型为文件或者文件夹的名字
Context#openFileInput(String name):FileInputStream打开files文件下的某个文件的输入流,不存在则抛出异常:FileNotFoundException
Context#openFileOut(String name,int mode):FileOutputStream打开files文件下的某个文件的输入流,文件不存在则新建
Context#deleteFile(String name): Boolean删除文件或文件夹
  1. 对于data/data/<包名>/cache/目录:
方法描述
Context#getCacheDir():File返回cache文件夹
  1. 对于data/data/<包名>/code_cache目录:
方法描述
Context#getCodeCacheDir():File返回优化过的代码目录,如JIT优化

上述方法测试代码如下:

        val testDir = getDir("rootDir", MODE_PRIVATE)
//打印为:/data/user/0/com.wayeal.ocr/app_rootDir    
Logger.t("testFile").d("testDir = ${testDir.absolutePath}")
//打印为:/data/user/0/com.wayeal.ocr/files  
Logger.t("testFile").d("filesDir = ${filesDir.absolutePath}")
//在files目录下新建文件
val fileOutputStream = openFileOutput("filesTest", MODE_PRIVATE)
//打印为:[datastore, bugly_last_us_up_tm, local_crash_lock, filesTest]
Logger.t("testFile").d("fileList = ${fileList().toMutableList()}")
File(filesDir,"haha").createNewFile()
//打印为:[datastore, bugly_last_us_up_tm, haha, filesTest]
Logger.t("testFile").d("fileList = ${fileList().toMutableList()}")

外部存储空间

对于外部存储空间在使用前一般要判断是否挂载,因为早期的的Android手机是有SD卡的,是可以进行卸载SD卡的。

对于外部存储空间,也有严格的划分,如下:

外部存储空间划分.jpg

这里可以发现外部存储空间分为了公共目录和私有目录,对于公共目录特点:

  • 外部存储中除了私有目录外的其他空间。
  • 所有应用共享。
  • 在应用卸载时不会被卸载。
  • 对MediaScanner可见。
  • 适用于非私密数据,不需要随应用卸载删除。

对于私有目录,有如下特点:

  • 目录名为Android。
  • 在media和data等目录中,以包名区分各个应用。
  • 在应用卸载时被删除。
  • 对MediaScanner不可见。(对多媒体文件夹例外,要求API 21)
  • 适用于非私密数据,需要在应用卸载时删除。

这里对于公共目录storage/emulated/0/来说,其API主要是Environment类来完成,如下:

方法描述
Environment.getExternalStorageDirectory(): File获取外部存储目录
Environment.getExternalStoragePublicDirectory(name: String): File外部存储根目录下的某个文件夹
Environment.getExternalStorageState(): String外部存储的状态

对于外部空间的私有目录storage/emulated/0/Android/data/<包名>/来说,其API还是由Context,主要是方法名都携带external字样,如下:

方法描述
Context.getExternalCacheDir(): File获取cache文件夹
Context.getExternalCacheDirs(): Array多部分cache文件夹(API 18),因为外部存储空间可能有多个
Context.getExternalFilesDir(type: String): File获取files文件夹
Context.getExternalFilesDirs(type: String): Array获取多部分的files文件夹
Context.getExternalMediaDirs(): Array获取多部分多媒体文件(API 21)

上述方法测试代码和log如下:

        Logger.t("testFile")
          .d("外部公共存储根目录 = ${Environment.getExternalStorageDirectory().absolutePath}")
//外部公共存储根目录 = /storage/emulated/0
       Logger.t("testFile")
          .d("外部公共存储Pictures目录 = ${Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).absolutePath}")
//外部公共存储Pictures目录 = /storage/emulated/0/Pictures
       Logger.t("testFile")
          .d("外部公共存储状态 = ${Environment.getExternalStorageState()}")
//外部公共存储状态 = mounted
       Logger.t("testFile")
          .d("外部存储私有缓存目录 = ${externalCacheDir?.absolutePath}")
//外部存储私有缓存目录 = /storage/emulated/0/Android/data/com.wayeal.ocr/cache
       Logger.t("testFile")
          .d("外部存储私有多部分缓存目录 = ${externalCacheDirs?.toMutableList()}")
//外部存储私有多部分缓存目录 = [/storage/emulated/0/Android/data/com.wayeal.ocr/cache]
       Logger.t("testFile")
          .d("外部存储私有files的Pictures目录 = ${getExternalFilesDir(Environment.DIRECTORY_PICTURES)}")
//外部存储私有files的Pictures目录 = /storage/emulated/0/Android/data/com.wayeal.ocr/files/Pictures
       Logger.t("testFile")
          .d("外部存储私有媒体多部分目录 = ${externalMediaDirs.toMutableList()}")
//外部存储私有媒体目录 = [/storage/emulated/0/Android/media/com.wayeal.ocr]

总结

对于不同的存储空间的特点以及API要了解,在需要保存文件时选择适当的存储空间。


作者:yuanhao
来源:juejin.cn/post/7158365077488271367

收起阅读 »

借某次写需求谈Android文件存储

前言 某天,我导让我写一个“崩溃日志本地收集”的功能,可以方便测试和开发查看崩溃原因。然后故事就开始了。 Round 1 哥们一开始,用context.getFilesDir()获取存储目录,这个方法返回的文件夹路径是data/data/包名/files,也就...
继续阅读 »

前言


某天,我导让我写一个“崩溃日志本地收集”的功能,可以方便测试和开发查看崩溃原因。然后故事就开始了。


Round 1


哥们一开始,用context.getFilesDir()获取存储目录,这个方法返回的文件夹路径是data/data/包名/files,也就是Android的内部存储空间。


然后哥们很顺利的啊,把这个功能做出来了。


第二天开站会,测试提出了致命疑问:我们测试要怎么看到报错信息呢?


众所周知啊,这个路径手机不root是无法查看的。所以我看向我导:“手机root一下不就行了”


image.png


我导:改!


Round 2


哥们吸取教训啊,咱不存在内部,咱存外面还不行吗。这次我用context.getExternalFilesDir()获取存储目录,也就是外部存储的应用私有目录,路径是storage/emulated/0/Android/data/包名/files。


改个路径的事情,瞬间写好了。


我们这个日志搜集,一个是搜集Native层的报错,一个是搜集Jvm层的报错。然后经过测试,发现Jvm层的报错信息有权限取出来,而Native层的报错信息却没权限取出来


我们当时就震惊了:啊?同一个目录下存东西居然会出现两套权限?


image.png


然后另外一个Android开发的前辈就想通过adb强行把这个报错信息拿出来,但是问题是没有root过没法用su命令啊,所以这件事又绕回去了。


然后我导就让我改到根目录下。


行,哥们改!


Round 3


既然内部存储不行,存到外部存储的私有目录也不行,就只能存在公共目录了。也就是我们使用手机文件管理应用看,Music和Movie的那一层。


获取存储路径用Environment.getExternalStorageDirectory(),得到的路径是storage/emulated/0。


改完后我又发现,Native层的权限正常了,Jvm的报错信息写不进去了。


报错信息是:


java.io.FileNotFoundException:...(Opration not permitted)


我心想:啊?这个目录难道没有写权限?那Natvie的报错信息怎么写进去的?


当时复制粘贴进百度,看到了一名CSDN老哥的回答:


img_v3_027e_6922fad6-53b9-4b94-b35f-c5445a90a4eg.jpg


其实我当时就对这个回答存疑的,因为明显我能mkdir,但是.txt文本信息却写不进去。


终于,我在Stack Overflow看到了正解:


image.png


没错,真相只有一个,是文件名有问题。我将.txt改成了.log就能成功存储了。


至此,终于可以下班。


image.png


总结


Android的文件存储和权限管理是真的*蛋。


实习的每一天做需求,都像在拍走进科学,哎。


顺便复习一下Android文件存储吧:Android文件存储


作者:leiteorz
来源:juejin.cn/post/7327920541989781504
收起阅读 »

动态代理在Android中的运用

动态代理是一种在编程中非常有用的设计模式,它允许你在运行时创建一个代理对象来代替原始对象,以便在方法调用前后执行额外的逻辑。在Android开发中,动态代理可以用于各种用例,如性能监控、AOP(面向切面编程)和事件处理。本文将深入探讨Android动态代理的原...
继续阅读 »

动态代理是一种在编程中非常有用的设计模式,它允许你在运行时创建一个代理对象来代替原始对象,以便在方法调用前后执行额外的逻辑。在Android开发中,动态代理可以用于各种用例,如性能监控、AOP(面向切面编程)和事件处理。本文将深入探讨Android动态代理的原理、用途和实际示例。


什么是动态代理?


动态代理是一种通过创建代理对象来代替原始对象的技术,以便在方法调用前后执行额外的操作。代理对象通常实现与原始对象相同的接口,但可以添加自定义行为。动态代理是在运行时生成的,因此它不需要在编译时知道原始对象的类型。


动态代理的原理


动态代理的原理涉及两个关键部分:



  1. InvocationHandler(调用处理器):这是一个接口,通常由开发人员实现。它包含一个方法 invoke,在代理对象上的方法被调用时会被调用。在 invoke 方法内,你可以定义在方法调用前后执行的逻辑。

  2. Proxy(代理类):这是Java提供的类,用于创建代理对象。你需要传递一个 ClassLoader、一组接口以及一个 InvocationHandlerProxy.newProxyInstance 方法,然后它会生成代理对象。


下面是一个示例代码,演示了如何创建一个简单的动态代理:


import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
import java.lang.reflect.Proxy

// 接口
interface MyInterface {
fun doSomething()
}

// 实现类
class MyImplementation : MyInterface {
override fun doSomething() {
println("Original method is called.")
}
}

// 调用处理器
class MyInvocationHandler(private val realObject: MyInterface) : InvocationHandler {
override fun invoke(proxy: Any, method: Method, args: Array<Any?>?): Any? {
println("Before method is called.")
val result = method.invoke(realObject, *(args ?: emptyArray()))
println("After method is called.")
return result
}
}

fun main() {
val realObject = MyImplementation()
val proxyObject = Proxy.newProxyInstance(
MyInterface::class.java.classLoader,
arrayOf
(MyInterface::class.java),
MyInvocationHandler
(realObject)
) as MyInterface

proxyObject.doSomething()
}

运行上述代码会输出:


Before method is called.
Original method is called.
After method is called.

这里,MyInvocationHandler 拦截了 doSomething 方法的调用,在方法前后添加了额外的逻辑。


Android中的动态代理


在Android中,动态代理通常使用Java的java.lang.reflect.Proxy类来实现。该类允许你创建一个代理对象,该对象实现了指定接口,并且可以拦截接口方法的调用以执行额外的逻辑。在Android开发中,常见的用途包括性能监控、权限检查、日志记录和事件处理。


动态代理的用途


性能监控


你可以使用动态代理来监控方法的执行时间,以便分析应用程序的性能。例如,你可以创建一个性能监控代理,在每次方法调用前记录当前时间,然后在方法调用后计算执行时间。


import android.util.Log

class PerformanceMonitorProxy(private val target: Any) : InvocationHandler {
override fun invoke(proxy: Any, method: Method, args: Array<Any?>?): Any? {
val startTime = System.currentTimeMillis()
val result = method.invoke(target, *(args ?: emptyArray()))
val endTime = System.currentTimeMillis()
val duration = endTime - startTime
Log.d("Performance", "${method.name} took $duration ms to execute.")
return result
}
}

AOP(面向切面编程)


动态代理也是AOP的核心概念之一。AOP允许你将横切关注点(如日志记录、事务管理和安全性检查)从业务逻辑中分离出来,以便更好地维护和扩展代码。通过创建适当的代理,你可以将这些关注点应用到多个类和方法中。


事件处理


Android中常常需要处理用户界面上的各种事件,例如点击事件、滑动事件等。你可以使用动态代理来简化事件处理代码,将事件处理逻辑从Activity或Fragment中分离出来,使代码更加模块化和可维护。


实际示例


下面是一个简单的示例,演示了如何在Android中使用动态代理来处理点击事件:


import android.util.Log
import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
import java.lang.reflect.Proxy
import android.view.View

class ClickHandlerProxy(private val target: View.OnClickListener) : InvocationHandler {
override fun invoke(proxy: Any, method: Method, args: Array<Any?>?): Any? {
if (method.name == "onClick") {
Log.d("ClickHandler", "Click event intercepted.")
// 在事件处理前可以执行自定义逻辑
}
return method.invoke(target, *args.orEmpty())
}
}

// 使用示例
val originalClickListener = View.OnClickListener {
// 原始的点击事件处理逻辑
}

val proxyClickListener = Proxy.newProxyInstance(
originalClickListener::class.java.classLoader,
originalClickListener::
class.java.interfaces,
ClickHandlerProxy
(originalClickListener)
) as View.OnClickListener

button.setOnClickListener(proxyClickListener)

通过这种方式,你可以在原始的点击事件处理逻辑前后执行自定义逻辑,而无需修改原始的OnClickListener实现。


结论


动态代理是Android开发中强大的工具之一,它允许你在不修改原始对象的情况下添加额外的行为。在性能监控、AOP和事件处理等方面,动态代理都有广泛的应用。通过深入理解动态代理的原理和用途,你可以更好地设计和维护Android应用程序。




作者:午后一小憩
来源:juejin.cn/post/7275185537815183360
收起阅读 »

靠维护老项目度中年危机

最近靠维护老项目度过中年危机的话题挺火,刚好最近也在维护一个PHP开发的CRM的老项目,项目由于数据量比较大, 导致查询速度很慢, 经常出现超时的情况, 下面记录一下具体的优化过程。 优化老项目,老生常淡的几点: 1. 数据库优化 2. 代码结构优化 3. 缓...
继续阅读 »

最近靠维护老项目度过中年危机的话题挺火,刚好最近也在维护一个PHP开发的CRM的老项目,项目由于数据量比较大, 导致查询速度很慢, 经常出现超时的情况, 下面记录一下具体的优化过程。


优化老项目,老生常淡的几点:


1. 数据库优化
2. 代码结构优化
3. 缓存优化
4. 资源优化
...

数据库优化


众所周知, MySQL 优化第一步,就是建索引, 看了一下整个系统的表, 发现有大量的表都没有索引, 建了索引的表,索引名称有点花里胡哨, 如下:


contractId	`contacts_id`	NORMAL	BTREE	27599	A		0		
customer_id `customer_id` NORMAL BTREE 27599 A 0

--

index_group `role_id`, `callDate` NORMAL BTREE 4359069 A 0
business_id `business_id` NORMAL BTREE 518 A 0
status_id `status_id` NORMAL BTREE 43 A 0


于是,优化第一步,规范一下索引的命名,MySQL索引的命名虽然没有硬性的规范,但是修改一下自己看着舒服, 个人理解:


普通索引:idx_字段1_字段2
唯一索引:uk_字段1_字段2
主键索引:pk_字段1_字段2


于是 上面的索引改成了:


idx_contacts_id	`contacts_id`	NORMAL	BTREE	27599	A		0		
idx_customer_id `customer_id` NORMAL BTREE 27599 A 0

--

idx_role_id_callDate `role_id`, `callDate` NORMAL BTREE 4359069 A 0
idx_business_id `business_id` NORMAL BTREE 518 A 0
idx_status_id `status_id` NORMAL BTREE 43 A 0


一下看起来舒服多了, 于是, 优化第二步, 就是给没有索引的表加上索引, 这个工作量比较大, 先把几个 常用功能模块的 表给加上索引, 于是 吭哧吭哧的 分析了 2天的 慢日志, 给需要加索引的表加上索引,本以为 加完索引后, 查询速度会快很多,结果发现, 并没有什么卵用. 一个页面 虽然快了点, 但是 不是太明显.


本着能加 配置 绝不改代码的原则,先去问了一下运维 Mysql 运行的机器内存是多大 64G. 这么大,那好办,先分析一下 数据库中的表引擎. 上了一段代码:


/** * Author: PFinal南丞 * Date: 2023/12/28 * Email:  *//** 确保这个函数只能运行在 shell 中 **/if (!str_starts_with(php_sapi_name(), "cli")) {    die("此脚本只能在cli模式下运行.\n");}/** 关闭最大执行时间限制 */set_time_limit(0);error_reporting(E_ALL);ini_set('display_errors', 1);const MAX_SLEEP_TIME = 10;$hostname   = '';$username   = '';$password   = '';$connection = mysqli_connect($hostname, $username, $password);if (!$connection) {    die('Could not connect: ' . mysqli_error($connection));}$query  = "SELECT table_name,engine FROM informati0n—schema.tables WHERE table_schema = 'smm';";$result = mysqli_query($connection, $query);if (!$result) {    die("Query failed: " . mysqli_error($connection));}$InnoDB_num = 0;$MyISAM_num = 0;while ($process = mysqli_fetch_assoc($result)) {    echo $process['table_name'] . " " . $process['engine'] . PHP_EOL;    if ($process['engine'] == 'InnoDB') {        $InnoDB_num++;    }    if ($process['engine'] == 'MyISAM') {        $MyISAM_num++;    }}echo "InnoDB " . $InnoDB_num . " MyISAM " . $MyISAM_num . PHP_EOL;mysqli_close($connection);

得出结果:


表引擎 MyISAM 的表 176 张 InnoDB的表引擎 88张. 要了一份 线上MySql 的配置发现:


...

key_buffer_size = 512M
innodb_buffer_pool_size = 2048M

...


都知道 innodb_buffer_pool_size 针对的 是 InnoDB的表引擎,key_buffer_size 针对的 是 MyISAM的表引擎. 这配置不得修改一下. 果断打申请, 申请修改线上配置.


...

key_buffer_size = 2048M
innodb_buffer_pool_size = 2048M

...


重启服务后,果然比原来快了好多.能撑到 同事不在群里 打报告了.


艰巨的长征路迈出了第一步,接下来,本着 死道友不死贫道的原则, 厚着脸皮,让运维帮忙整了一台mysql 的机器,来做了个主从分离。 速度一下,不影响业务的正常使用了.


接着 开启漫长的 优化之路.


缓存优化



  1. 项目没有开启数据缓存, 只有 代码编译的缓存


所以这一块是一个大的工程, 所以先不动, 只是 给 几个 常用的功能加了一个 数据 的 缓存。后续的思路是:


  a. 加一个 redis, 使用 把代码中的统计数据 缓存到 redis 中

b. 把客户信息,客户的关联信息,组合到一起, 然后缓存到 redis中.
....


代码结构优化


开始挖开代码, 看看 查询慢的 功能 代码是咋写的,不看不知道,一看直接上头:



  1. 几乎全是 foreach 中 的 SQL 查询:


    foreach($customer_list as $key=>$value){        # ......        $customer_list[$key]['customer_name'] = $this->customer_model->get_customer_name($value['customer_id']);        $customer_list[$key]['customer_phone'] = $this->customer_model->get_customer_phone($value['customer_id']);        $customer_list[$key]['customer_address'] = $this->customer_model->get_customer_address($value['customer_id']);                # ......    }


  2. 由于 ORM 的方便复用, 大量的 表关联模型 复用,导致查询的 废字段特别多.比如:


    <?php    class CustomerViewModel extends ViewModel {        protected $viewFields;  public function _initialize(){   $main_must_field = array('customer_id','owner_role_id','is_locked','creator_role_id','contacts_id','delete_role_id','create_time','delete_time','update_time','last_relation_time','get_time','is_deleted','business_license');   $main_list = array_unique(array_merge(M('Fields')->where(array('model'=>'customer','is_main'=>1,'warehouse_id'=>0))->getField('field', true),$main_must_field));   $data_list = M('Fields')->where(array('model'=>'customer','is_main'=>0,'warehouse_id'=>0))->getField('field', true);   $data_list['_on'] = 'customer.customer_id = customer_data.customer_id';   $data_list['_type'] = "LEFT";   //置顶逻辑   $data_top = array('set_top','top_time');   $data_top['_on'] = "customer.customer_id = top.module_id and top.module = 'customer' and top.create_role_id = ".session('role_id');   $data_top['_type'] = "LEFT";   //首要联系人(姓名、电话)   $data_contacts = array('name'=>'contacts_name', 'telephone'=>'contacts_telephone');   $data_contacts['_on'] = "customer.contacts_id = contacts.contacts_id";   // 检查是否存在部门库字段            $warehouse_id = I('warehouse_id', '', 'intval');            if ($warehouse_id) {                $warehouse_id = D('Fields')->isExistsWarehouseTable(1, $warehouse_id);                if ($warehouse_id) {                    $customer_warehouse_data_table = customer_warehouse_table($warehouse_id);                    $warehouse_data_list = M('Fields')->where(array('model'=>'customer','is_main'=>0,'warehouse_id'=>$warehouse_id))->getField('field', true);                    $warehouse_data_list['_on'] = 'customer.customer_id = ' . $customer_warehouse_data_table .'.customer_id';                    $warehouse_data_list['_type'] = "LEFT";                    $this->viewFields = array('customer'=>$main_list,'customer_data'=>$data_list,$customer_warehouse_data_table=>$warehouse_data_list,'top'=>$data_top,'contacts'=>$data_contacts);                } else {                    $this->viewFields = array('customer'=>$main_list,'customer_data'=>$data_list,'top'=>$data_top,'contacts'=>$data_contacts);                }            } else {                $this->viewFields = array('customer'=>$main_list,'customer_data'=>$data_list,'top'=>$data_top,'contacts'=>$data_contacts);            }  }    ?>


  3. 代码中的业务逻辑一直再叠加,导致废代码量特别的大需要重新梳理逻辑


针对以上的代码做修改:


a. 第一点, 把所有foreach 中的 sql拆出来,先去查询到内存中,然后组合减少sql语句 

b. 第二点, 简化 ORM的乱用,比如只需要查询一个字段的 就直接用原生sql或者新的一个不关联的orm 来处理

资源优化



  1. 由于录音文件过大, 找运维 做了一个专门的文件服务器,移到了文件服务器上


最后


最后,给加了个定时任务告警的功能, 方便及时发现异常, 优化的 第一步 勉强交活。剩下的 优化 需要再花点时间了,慢慢来了.


作者:PFinal社区_南丞
来源:juejin.cn/post/7353475049418260517
收起阅读 »

弱智吧成最好中文AI训练数据:大模型变聪明,有我一份贡献

web
在中文网络上流传着这样一段话:弱智吧里没有弱智。百度「弱智吧」是个神奇的地方,在这里人人都说自己是弱智,但大多聪明得有点过了头。最近几年,弱智吧的年度总结文章都可以顺手喜提百度贴吧热度第一名。所谓总结,其实就是给当年吧里的弱智发言排个名。各种高质量的段子在这里...
继续阅读 »


在中文网络上流传着这样一段话:弱智吧里没有弱智。

百度「弱智吧」是个神奇的地方,在这里人人都说自己是弱智,但大多聪明得有点过了头。最近几年,弱智吧的年度总结文章都可以顺手喜提百度贴吧热度第一名。所谓总结,其实就是给当年吧里的弱智发言排个名。

各种高质量的段子在这里传入传出,吸引了无数人的围观和转载,这个贴吧的关注量如今已接近 300 万。你网络上看到的最新流行词汇,说不定就是弱智吧老哥的杰作。

随着十几年的发展,越来越多的弱智文学也有了奇怪的风格,有心灵鸡汤,有现代诗,甚至有一些出现了哲学意义。

最近几天,一篇人工智能领域论文再次把弱智吧推上了风口浪尖。

引发 AI 革命的大模型因为缺乏数据,终于盯上了弱智吧里无穷无尽的「数据集」。有人把这些内容拿出来训练了 AI,认真评测对比一番,还别说,效果极好。

接下来,我们看看论文讲了什么。
最近,大型语言模型(LLM)取得了重大进展,特别是在英语方面。然而,LLM 在中文指令调优方面仍然存在明显差距。现有的数据集要么以英语为中心,要么不适合与现实世界的中国用户交互模式保持一致。 
为了弥补这一差距,一项由 10 家机构联合发布的研究提出了 COIG-CQIA(全称 Chinese Open Instruction Generalist - Quality Is All You Need),这是一个高质量的中文指令调优数据集。数据来源包括问答社区、维基百科、考试题目和现有的 NLP 数据集,并且经过严格过滤和处理。
此外,该研究在 CQIA 的不同子集上训练了不同尺度的模型,并进行了深入的评估和分析。本文发现,在 CQIA 子集上训练的模型在人类评估以及知识和安全基准方面取得了具有竞争力的结果。
研究者表示,他们旨在为社区建立一个多样化、广泛的指令调优数据集,以更好地使模型行为与人类交互保持一致。
本文的贡献可以总结如下:

提出了一个高质量的中文指令调优数据集,专门用于与人类交互保持一致,并通过严格的过滤程序实现;

探讨了各种数据源(包括社交媒体、百科全书和传统 NLP 任务)对模型性能的影响。为从中国互联网中选择训练数据提供了重要见解;

各种基准测试和人工评估证实,在 CQIA 数据集上微调的模型表现出卓越的性能,从而使 CQIA 成为中国 NLP 社区的宝贵资源。


  • 论文地址:https://arxiv.org/pdf/2403.18058.pdf
  • 数据地址:https://huggingface.co/datasets/m-a-p/COIG-CQIA
  • 论文标题:COIG-CQIA: Quality is All You Need for Chinese Instruction Fine-tuning


COIG-CQIA 数据集介绍

为了保证数据质量以及多样性,本文从中国互联网内的优质网站和数据资源中手动选择了数据源。这些来源包括社区问答论坛、、内容创作平台、考试试题等。此外,该数据集还纳入了高质量的中文 NLP 数据集,以丰富任务的多样性。具体来说,本文将数据源分为四种类型:社交媒体和论坛、世界知识、NLP 任务和考试试题。


社交媒体和论坛:包括知乎、SegmentFault 、豆瓣、小红书、弱智吧。

世界知识:百科全书、四个特定领域的数据(医学、经济管理、电子学和农业)。

NLP 数据集:COIG-PC 、COIG Human Value 等。

考试试题:中学和大学入学考试、研究生入学考试、逻辑推理测试、中国传统文化。
表 1 为数据集来源统计。研究者从中国互联网和社区的 22 个来源总共收集了 48,375 个实例,涵盖从常识、STEM 到人文等领域。

图 2 说明了各种任务类型,包括信息提取、问答、代码生成等。

图 3 演示了指令和响应的长度分布。

为了分析 COIG-CQIA 数据集的多样性,本文遵循先前的工作,使用 Hanlp 工具来解析指令。

实验结果

该研究在不同数据源的数据集上对 Yi 系列模型(Young et al., 2024)和 Qwen-72B(Bai et al., 2023)模型进行了微调,以分析数据源对模型跨领域知识能力的影响,并使用 Belle-Eval 上基于模型(即 GPT-4)的自动评估来评估每个模型在各种任务上的性能。
表 2、表 3 分别显示了基于 Yi-6B、Yi-34B 在不同数据集上进行微调得到的不同模型的性能。模型在头脑风暴、生成和总结等生成任务中表现出色,在数学和编码方面表现不佳。


下图 4 显示了 CQIA 和其他 5 个基线(即 Yi-6B-Chat、Baichuan2-7B-Chat、ChatGLM2-6B、Qwen-7B-Chat 和 InternLM-7B-Chat)的逐对比较人类评估结果。结果表明,与强基线相比,CQIA-Subset 实现了更高的人类偏好,至少超过 60% 的响应优于或与基线模型相当。这不仅归因于 CQIA 能够对人类问题或指令生成高质量的响应,还归因于其响应更符合现实世界的人类沟通模式,从而导致更高的人类偏好。

该研究还在 SafetyBench 上评估了模型的安全性,结果如下表 4 所示:

在 COIG Subset 数据上训练的模型性能如下表 5 所示:





作者:APPSO
来源:mp.weixin.qq.com/s/BN52IrDg-xNosxkJ6MbNvA
收起阅读 »

Geoserver:小程序巨丝滑渲染海量点位

web
文章最后有效果图 需求 在小程序上绘制 40000+ 的点位。 难点 众所周知小程序的 map 组件性能低下,同时渲染几百个 marker 就会卡顿,一旦加上 callout 弹窗,这个数量可能会降到几十个,如果使用了 自定义弹窗(custom-callou...
继续阅读 »

文章最后有效果图



需求


在小程序上绘制 40000+ 的点位。


难点


众所周知小程序的 map 组件性能低下,同时渲染几百个 marker 就会卡顿,一旦加上 callout 弹窗,这个数量可能会降到几十个,如果使用了 自定义弹窗(custom-callout) 会更卡,所以渲染 4w+ 的点,用常规方法是不可能实现的。


方案


按需加载


按需加载即按屏幕坐标加载,只显示视野范围内的点位,需要后端配合在接口中新增 bbox(Bounding box) 参数,再从数据库中查出范围内的点。


小程序端需要使用视野变化监听方法实时更新,虽然请求和渲染频繁,但是在缩放等级较大时,有很高的性能:


<map bindregionchange="regionChanged" markers="{{markers}}">

regionChanged(e){
this.data.bbox = [     [e.detail.region.southwest.longitude, e.detail.region.southwest.latitude],
    [e.detail.region.northeast.longitude, e.detail.region.northeast.latitude],
  ]
   // 执行获取点、渲染点的操作
}

需要注意的是,目前的微信版本(8.0.47),基础库3.3.4该方法不可用,见 微信开放社区


如果遇到 bindregionchange 不可用时,可以用 bind:touchend 方法代替,手动获取范围


    setBbox() {
     mapCtx = wx.createMapContext('map', this)
     mapCtx.getRegion({
       success: (res) => {
         let bbox = [
          [res.southwest.longitude, res.southwest.latitude],
          [res.northeast.longitude, res.northeast.latitude],
        ]
         // 执行获取点、渲染点的操作
        })
    })
  }

使用了按需渲染后,在缩放等级较大时,已经可以有很好的效果,移动屏幕时基本可以秒加载出新的点,同时清除掉屏幕范围外的点。


然而,在点位多的时候,我们收到了 setData 长度超出的报错,页面也异常卡顿。


优化渲染方式


小程序的 setData 方法最多只能更新 1M 的数据,超过这个数据会报错,并严重卡顿,即使不超过,在数据量较大时,也会非常卡顿,为了解决这个问题,我们不能再使用 setData 去渲染数据。


小程序提供了专门渲染点的方法: addMarkers


// 执行获取点、渲染点的操作处,使用该方法,并设置 clear: true 。这样就达到了上面说的,更新点时,旧的点会被清除。


然而,这并没有解决根本问题,我们现在可以做到渲染远远大于1M的数据,并渲染时不会报错,但是由于小程序 map 组件的渲染策略,我们的点会一个一个渲染上去,我们知道更新 canvas 代价是很大的,尤其是像 marker 这种携带很多必要信息的东西。


这里我们尝试将 marker 携带的参数压缩到极致,仅保留经纬度、颜色状态信息、id、callout,效果依然差强人意。


并且,由于小程序 marker 的 callout 不是互斥的,且没有给我们预留参数去设置这一点,所以在我们切换 marker 选中状态时,需要把 marker 数组完全遍历一遍,移除其他的 callout , 并添加新的 callout,这个开销也是巨大不可接受的。


优化选中策略


为了解决切换 marker 选中状态时的开销问题,我们想了一个绝妙的主意,就是将 marker 数组中的 callout 完全移除,只保留 id 等必要字段,在点击时,添加一个新的带 callout 点上去,盖住原来的点,这样看起来就是原有的点被选中了,这样既压缩了 marker 携带参数,又解决了切换选中时必须遍历 marker 数组的问题。


height: 20,
width: 17,
iconPath: this.data.markerIcons[this.getMarkerType(item)],
latitude: item.point[1],
longitude: item.point[0],
id: this.getUniqueNumber(item.uid), //id 必须是数字
storeCode: item.uid,
//callout:{...} // 不要此项
customCallout: {} //必须加,不然会有一个没有内容的弹窗,这个可以阻止默认弹窗弹出

优化海量点渲染策略


经过上面的优化,我们的小程序已经可以高性能的显示点位了,但是当缩放等级低时(12以下),点位多起来了,我们目前的方法就显得力不从心了。


如果点位无限多,我们又该如何优化呢?


聚合


聚合指的是将临近的点位聚合成一个大点,从而达到渲染点数变少、提高性能的方法。


此方法经过实测,发现当点达到一定量级的时候,用了反而比不用还卡,因为每当你缩放地图时,都需要计算聚合,当计算压力大于渲染压力时,聚合反而成了一种负担,而不是优化了。


所以我们不用聚合。


小程序个性化图层


小程序提供了付费功能:个性化图层,可以上传海量数据并生成一个小程序支持加载的图层。遗憾的是这种方法只适合静态数据,对于经常需要变动的数据,这种方法的实时性得不到保证,只能通过手动在后台更新数据。


所以此方案也不可用。


瓦片


小程序 map 是不支持瓦片(个性化图层除外)加载的,但是我们知道,瓦片就是一张图片而已,那么小程序可以在地图上放图片吗,答案是可以:addGroundOverlay


我们决定朝着此方案努力,请看下文。


搭建 geoserver


首先到 geoserver官网 下载geoserver本体,geoserver是为数不多几个推荐 windows 平台的大型工具软件,下载前注意,geoserver对 jdk 版本有要求,版本不一致会导致 geoserver 启动失败等问题。


image.png
我们的服务器是 linux ,所以下载了linux版本,到服务器找个位置 直接 unzip 就可以了。


安装完之后,需要先编辑 start.ini 调整一个合适的空闲端口,作为后面web端管理页面的地址端口。别忘了在防火墙开启此端口。


最后在 bin 中有一个 startup.sh , 使用 nohup 命令设置后台运行。


此时在浏览器输入服务器地址和你刚刚设置的端口号,最后加上 /geoserver,即可看到geoserver的管理页面。


image.png
初始用户名密码:admin geoserver


登录完成后可以看到全部功能


image.png
点击数据存储 -> 添加新的数据存储,即可添加数据并发布图层。


可以看到支持 PostGis,使用 PostGis 作为数据源,图层会实时更新,也就是说,当数据变化时,无需任何代码和人工干预。


当数据源添加完成后,需要新建一个图层,并指定为刚刚新建的数据源。


此时,在图层预览页面即可看到刚刚创建的图层了,当然此时的图层使用的是默认样式,需要编写SLD(xml格式)的样式文件去指定样式,这对于我们来说无疑是一种负担。


好在 geoserver 有 css 插件,安装此插件并重启geoserver,即可使用 css 编写图层样式。


* {
 mark-size:8px;
}
[control_sts == 1] {
 mark:url("https://entropy.xxx.cn/xx/dotgreen.png");
}
[control_sts == 0] {
 mark:url("https://entropy.xxx.cn/xx/dotgray.png");
}

可以看到,它与标准css还是有一些差异的,像mark、mark-size在标准css中是不存在的。


指定样式后,在图层预览页面,可以看到效果


image.png


打开控制台,可以看到网络请求中的地址长这样:


http://xxx:8089/geoserver/cite/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&FORMAT=image%2Fpng&TRANSPARENT=true&STYLES&LAYERS=cite%3Axc_store_geo&exceptions=application%2Fvnd.ogc.se_inimage&SRS=EPSG%3A4326&WIDTH=670&HEIGHT=768&BBOX=114.4720458984375%2C37.7874755859375%2C118.1524658203125%2C42.0062255859375

放到浏览器窗口打开,发现是一张png图片,那么我们刚好可以使用小程序的 addGroundOverlay 添加到地图上。


SERVICE: WMS
VERSION: 1.1.1
REQUEST: GetMap
FORMAT: image/png
TRANSPARENT: true
STYLES:
LAYERS: xx:xxxx
exceptions: application/vnd.ogc.se_inimage
SRS: EPSG:4326
WIDTH: 670
HEIGHT: 768
BBOX: 114.4720458984375,37.7874755859375,118.1524658203125,42.0062255859375

看一下这些参数,出了 BBOX ,其他的写固定值就可以了。


这里注意,宽高值,需要设置为小程序中地图元素的大小,单位是 px。


在小程序中拼装WMS地址


比较简单,直接看代码:


    setTileImage(params: { LAYERS: string[], BBOX: string, SCREEN_WIDTH: number, SCREEN_HEIGHT: number, CQL_FILTER: string }) {
     mapCtx = wx.createMapContext('map', this)
     this.removeTileImage().then(() => {
       for (let index in params.LAYERS) {
         let id = +(9999 + index)
         !this.data.groundOverlayIds.includes(id) && this.data.groundOverlayIds.push(id)
         let data: any = {
           id: +(9999 + index),
           zIndex: 999,
           src: `http://xxx:8089/geoserver/cite/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=${params.LAYERS[index]}&STYLES=&exceptions=application/vnd.ogc.se_inimage&FORMAT=image/png&TRANSPARENT=true&FORMAT_OPTIONS=antialias:full&SRS=EPSG:4326&BBOX=${params.BBOX}&WIDTH=${params.SCREEN_WIDTH * 2}&HEIGHT=${params.SCREEN_HEIGHT * 2}&CQL_FILTER=${params.CQL_FILTER}`,
           bounds: {
             southwest: {
               latitude: +params.BBOX.split(',')[1],
               longitude: +params.BBOX.split(',')[0]
            },
             northeast: {
               latitude: +params.BBOX.split(',')[3],
               longitude: +params.BBOX.split(',')[2]
            }
          }
        }
         mapCtx.addGroundOverlay({
           ...data,
        })
      }
    })
  },

我这里封装了一个可以接受多个图层的方法,这里值得注意的是,我没有使用 updateGroundOverlay 方法去更新图层,而是先使用 removeGroundOverlay 移除,再重新添加的,这是因为updateGroundOverlay有一个bug,我不说,你可以自己试试。


完成


f42f89c10e3c044f3b8e0200d7dfa52a.webp
至此已经完全实现了小程序的海量点的渲染,无论点有多少,我们都只需要渲染一张图片而已,性能好的一批。


作者:德莱厄斯
来源:juejin.cn/post/7348363874965028864
收起阅读 »

错过Android主线程空闲期,你可能损失的不仅仅是性能

在Android应用程序的开发过程中,性能优化一直是开发者关注的焦点之一。在这个背景下,Android系统提供了一项强大的工具——IdleHandler,它能够帮助开发者在应用程序的空闲时段执行任务,从而提高应用的整体性能。IdleHandler的机制基于An...
继续阅读 »

在Android应用程序的开发过程中,性能优化一直是开发者关注的焦点之一。在这个背景下,Android系统提供了一项强大的工具——IdleHandler,它能够帮助开发者在应用程序的空闲时段执行任务,从而提高应用的整体性能。IdleHandler的机制基于Android主线程的空闲状态,使得开发者能够巧妙地利用这些空闲时间执行一些耗时的操作,而不影响用户界面的流畅性。


在深入研究IdleHandler之前,让我们先了解一下它的基本原理,以及为何它成为Android性能优化的重要组成部分。


IdleHandler的基本原理


Android应用的主线程通过一个消息循环(Message Loop)来处理各种事件和任务。当主线程没有新的消息需要处理时,它就处于空闲状态。这就是IdleHandler发挥作用的时机。


通过注册IdleHandler来告诉系统在主线程空闲时执行特定的任务。当主线程进入空闲状态时,系统会依次调用注册的IdleHandler,执行相应的任务。


IdleHandler与Handler和MessageQueue密切相关。它通过MessageQueue的空闲时间来执行任务。每当主线程处理完一个消息后,系统会检查是否有注册的IdleHandler需要执行。


空闲状态的定义


了解什么时候主线程被认为是空闲的至关重要。一般情况下,Android系统认为主线程在处理完所有消息后即处于空闲状态。IdleHandler通过这个定义,能够在保证不影响用户体验的前提下执行一些耗时的操作。


	// 没有消息,判断是否有IdleHandler
if (pendingIdleHandlerCount < 0
&& (mMessages == null || now < mMessages.when)) {
pendingIdleHandlerCount = mIdleHandlers.size();
}
if (pendingIdleHandlerCount <= 0) {
// No idle handlers to run. Loop and wait some more.
mBlocked
= true;
continue;
}

if (mPendingIdleHandlers == null) {
mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)];
}
mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);

....

// 执行IdleHandler
for (int i = 0; i < pendingIdleHandlerCount; i++) {
final IdleHandler idler = mPendingIdleHandlers[i];
mPendingIdleHandlers[i] = null; // release the reference to the handler

boolean keep = false;
try {
keep = idler.queueIdle();
} catch (Throwable t) {
Log.wtf(TAG, "IdleHandler threw exception", t);
}

if (!keep) {
synchronized (this) {
mIdleHandlers.remove(idler);
}
}
}

如何使用IdleHandler


使用IdleHandler可以执行一些轻量级的任务,例如加载数据、更新UI等。以下是使用IdleHandler的几个使用技巧:



  1. 注册IdleHandler:


Looper.myQueue().addIdleHandler(MyIdleHandler())

class MyIdleHandler : MessageQueue.IdleHandler {
override fun queueIdle(): Boolean {
// 在主线程空闲时执行的任务逻辑
performIdleTask()
// 返回 false,表示任务处理完毕,不再执行
return false
}

private fun performIdleTask() {
// 具体的任务逻辑
// ...
}
}


  1. 取消注册


当不需要继续执行任务时,可以通过removeIdleHandler方法取消注册


Looper.myQueue().removeIdleHandler(idleHandler);

IdleHandler的适用场景



  • 轻量级任务:IdleHandler主要用于执行轻量级的任务。由于它是在主线程空闲时执行,所以不适合执行耗时的任务。

  • 主线程空闲时执行:IdleHandler通过在主线程空闲时被调用,避免了主线程的阻塞。因此,适用于需要在主线程执行的任务,并且这些任务对于用户体验的影响较小。

  • 优先级较低的任务:如果有多个任务注册了IdleHandler,系统会按照注册的顺序调用它们的queueIdle方法。因此,适用于需要在较低优先级下执行的任务。


总的来说IdleHandler适用于需要在主线程空闲时执行的轻量级任务,以提升应用的性能和用户体验。


高级应用



  1. 性能监控与优化
    利用 IdleHandler 可以实现性能监控和优化,例如统计每次空闲时的内存占用情况,或者执行一些内存释放操作。

  2. 预加载数据
    在用户操作前,通过 IdleHandler 提前加载一些可能会用到的数据,提高用户体验。

  3. 动态资源加载
    利用空闲时间预加载和解析资源,减轻在用户操作时的资源加载压力。


性能优化技巧


虽然IdleHandler提供了一个方便的机制来在主线程空闲时执行任务,但在使用过程中仍需注意一些性能方面的问题。



  1. 任务的轻量级处理: 确保注册的IdleHandler中的任务是轻量级的,不要在空闲时执行过于复杂或耗时的操作,以免影响主线程的响应性能。

  2. **避免频繁注册和取消IdleHandler: **频繁注册和取消IdleHandler可能会引起性能问题,因此建议在应用的生命周期内尽量减少注册和取消的操作。可以在应用启动时注册IdleHandler,在应用退出时取消注册。

  3. **合理设置任务执行频率: **根据任务的性质和执行需求,合理设置任务的执行频率。不同的任务可能需要在不同的时间间隔内执行,这样可以更好地平衡性能和功能需求。


结语


通过深度解析 IdleHandler 的原理和高级应用,让我们更好地利用这一工具进行性能优化。在实际项目中,灵活运用 IdleHandler 可以有效提升应用的响应速度和用户体验。希望本文能够激发大家对于Android性能优化的更多思考和实践。




作者:午后一小憩
来源:juejin.cn/post/7307471896693522471
收起阅读 »

永不生锈的螺丝钉!一款简洁好用的数据库表结构文档生成器

大家好,我是 Java陈序员。 在企业级开发中,我们经常会有编写数据库表结构文档的需求,常常需要手写维护文档,很是繁琐。 今天,给大家介绍一款数据库表结构文档生成工具。 关注微信公众号:【Java陈序员】,获取开源项目分享、AI副业分享、超200本经典计算机...
继续阅读 »

大家好,我是 Java陈序员


在企业级开发中,我们经常会有编写数据库表结构文档的需求,常常需要手写维护文档,很是繁琐。


今天,给大家介绍一款数据库表结构文档生成工具。



关注微信公众号:【Java陈序员】,获取开源项目分享、AI副业分享、超200本经典计算机电子书籍等。



项目介绍


screw —— 螺丝钉(代表企业级开发中一颗永不生锈的螺丝钉),是一款简洁好用的数据库表结构文档生成工具。



screw 主打简洁、轻量,支持多种数据库、多种格式文档,可自定义模板进行灵活拓展。



  • 支持 MySQL、MariaDB、TIDB、Oracle 多种数据库




  • 支持生成 HTML、Word、MarkDown 三种格式的文档



快速上手


screw 普通方式Maven 插件的两种方式来生成文档。


普通方式


1、引入依赖


<!-- 引入数据库驱动,这里以 MySQL 为例 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.29</version>
</dependency>

<!-- 引入 screw -->
<dependency>
<groupId>cn.smallbun.screw</groupId>
<artifactId>screw-core</artifactId>
<version>1.0.5</version>
</dependency>

2、编写代码


public class DocumentGeneration {

/**
* 文档生成
*/

@Test
public void documentGeneration() {

// 文档生成路径
String fileOutputPath = "D:\\database";

// 数据源
HikariConfig hikariConfig = new HikariConfig();
// 指定数据库驱动
hikariConfig.setDriverClassName("com.mysql.cj.jdbc.Driver");
// 设置数据库连接地址
hikariConfig.setJdbcUrl("jdbc:mysql://localhost:3306/database");
// 设置数据库用户
hikariConfig.setUsername("root");
// 设置数据库密码
hikariConfig.setPassword("root");
// 设置可以获取 tables remarks 信息
hikariConfig.addDataSourceProperty("useInformationSchema", "true");
hikariConfig.setMinimumIdle(2);
hikariConfig.setMaximumPoolSize(5);

DataSource dataSource = new HikariDataSource(hikariConfig);
// 生成配置
EngineConfig engineConfig = EngineConfig.builder()
// 生成文件路径
.fileOutputDir(fileOutputPath)
// 打开目录
.openOutputDir(true)
// 文件类型 HTML、WORD、MD 三种类型
.fileType(EngineFileType.HTML)
// 生成模板实现
.produceType(EngineTemplateType.freemarker)
// 自定义文件名称
.fileName("Document")
.build();

// 忽略表
ArrayList<String> ignoreTableName = new ArrayList<>();
ignoreTableName.add("test_user");
ignoreTableName.add("test_group");

//忽略表前缀
ArrayList<String> ignorePrefix = new ArrayList<>();
ignorePrefix.add("test_");

//忽略表后缀
ArrayList<String> ignoreSuffix = new ArrayList<>();
ignoreSuffix.add("_test");

ProcessConfig processConfig = ProcessConfig.builder()
// 指定生成逻辑、当存在指定表、指定表前缀、指定表后缀时,将生成指定表,其余表不生成、并跳过忽略表配置
// 根据名称指定表生成
.designatedTableName(new ArrayList<>())
// 根据表前缀生成
.designatedTablePrefix(new ArrayList<>())
// 根据表后缀生成
.designatedTableSuffix(new ArrayList<>())
// 忽略表名
.ignoreTableName(ignoreTableName)
// 忽略表前缀
.ignoreTablePrefix(ignorePrefix)
// 忽略表后缀
.ignoreTableSuffix(ignoreSuffix)
.build();
//配置
Configuration config = Configuration.builder()
// 版本
.version("1.0.0")
// 描述
.description("数据库设计文档生成")
// 数据源
.dataSource(dataSource)
// 生成配置
.engineConfig(engineConfig)
// 生成配置
.produceConfig(processConfig)
.build();

//执行生成
new DocumentationExecute(config).execute();
}
}

3、执行代码输出文档



Maven 插件


1、引入依赖


<build>
<plugins>
<plugin>
<groupId>cn.smallbun.screw</groupId>
<artifactId>screw-maven-plugin</artifactId>
<version>1.0.5</version>
<dependencies>
<!-- HikariCP -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>3.4.5</version>
</dependency>
<!--mysql driver-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.20</version>
</dependency>
</dependencies>
<configuration>
<!-- 数据库用户名 -->
<username>root</username>
<!-- 数据库密码 -->
<password>password</password>
<!-- 数据库驱动 -->
<driverClassName>com.mysql.cj.jdbc.Driver</driverClassName>
<!-- 数据库连接地址 -->
<jdbcUrl>jdbc:mysql://127.0.0.1:3306/xxxx</jdbcUrl>
<!-- 生成的文件类型 HTML、WORD、MD 三种类型 -->
<fileType>HTML</fileType>
<!-- 打开文件输出目录 -->
<openOutputDir>false</openOutputDir>
<!-- 生成模板 -->
<produceType>freemarker</produceType>
<!-- 文档名称 为空时:将采用[数据库名称-描述-版本号]作为文档名称 -->
<fileName>数据库文档</fileName>
<!-- 描述 -->
<description>数据库文档生成</description>
<!-- 版本 -->
<version>${project.version}</version>
<!-- 标题 -->
<title>数据库文档</title>
</configuration>
<executions>
<execution>
<phase>compile</phase>
<goals>
<goal>run</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

2、执行插件



3、使用 Maven 插件执行的方式会将文档输出到项目根目录的 doc 目录下



文档截图


HTML 类型文档



Word 类型文档



MarkDown 类型文档



自从用了 screw 后,编写数据库文档信息就很方便了,一键生成,剩下的时间就可以用来摸鱼了~


大家如果下次有需要编写数据库文档,可以考虑使用 screw ,建议先把本文收藏起来,下次就不会找不到了~


最后,贴上项目地址:


https://github.com/pingfangushi/screw

最后


推荐的开源项目已经收录到 GitHub 项目,欢迎 Star


https://github.com/chenyl8848/great-open-source-project

或者访问网站,进行在线浏览:


https://chencoding.top:8090/#/


大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!



作者:Java陈序员
来源:juejin.cn/post/7354922285093683252
收起阅读 »

统一公司的项目规范

web
初始化项目 vscode 里下好插件:eslint,prettier,stylelint 官网模版创建项目:pnpm create vite react-starter --template react-swc-ts 安装依赖:pnpm i 后面有可能遇到 ...
继续阅读 »

初始化项目



  • vscode 里下好插件:eslint,prettier,stylelint

  • 官网模版创建项目:pnpm create vite react-starter --template react-swc-ts

  • 安装依赖:pnpm i

  • 后面有可能遇到 ts 类型错误,可以提前安装一个pnpm i @types/node -D


配置 npm 使用淘宝镜像



  • 配置npmrc


    registry = "https://registry.npmmirror.com/"



配置 node 版本限制提示



  • package.json 中配置


    "engines": {
    "node": ">=16.0.0"
    },



配置 eslint 检查代码规范



eslint 处理代码规范,prettier 处理代码风格
eslint 选择只检查错误不处理风格,这样 eslint 就不会和 prettier 冲突
react 官网有提供一个 hook 的 eslint (eslint-plugin-react-hooks),用处不大就不使用了




  • 安装:pnpm i eslint -D

  • 生成配置文件:eslint --init(如果没eslint,可以全局安装一个,然后使用npx eslint --init)


    - To check syntax and find problems  //这个选项是eslint默认选项,这样就不会和pretter起风格冲突
    - JavaScript modules (import/export)
    - React
    - YES
    - Browser
    - JSON
    - Yes
    - pnpm


  • 配置eslintrc.json->rules里配置不用手动引入 react,和配置不可以使用 any

  • 注意使用 React.FC 的时候如果报错说没有定义 props 类型,那需要引入一下 react


    "rules": {
    //不用手动引入react
    "react/react-in-jsx-scope": "off",
    //使用any报错
    "@typescript-eslint/no-explicit-any": "error",
    }


  • 工作区配置.vscode>settings.json,配置后 vscode 保存时自动格式化代码风格


    比如写了一个 var a = 100,会被自动格式化为 const a = 100


    {
    "editor.codeActionsOnSave": {
    // 每次保存的时候将代码按照 eslint 格式进行修复
    "source.fixAll.eslint": true,
    //自动格式化
    "editor.formatOnSave": true
    }
    }


  • 配置.eslintignore,eslint 会自动过滤 node_modules


    dist


  • 掌握eslint格式化命令,后面使用 lint-staged 提交代码的时候需要配置


    为什么上面有 vscode 自动 eslint 格式化,还需要命令行: 因为命令行能一次性爆出所有警告问题,便于找到位置修复


    npx eslint . --fix//用npx使用项目里的eslint,没有的话也会去使用全局的eslint
    eslint . --fix //全部类型文件
    eslint . --ext .ts,.tsx --fix //--ext可以指定文件后缀名s

    eslintrc.json 里配置



  • "env": {
    "browser": true,
    "es2021": true,
    "node": true // 因为比如配置vite的时候会使用到
    },



配置 prettier 检查代码风格



prettier 格式化风格,因为使用 tailwind,使用 tailwind 官方插件




  • 安装:pnpm i prettier prettier-plugin-tailwindcss -D

  • 配置.prettierrc.json


    注释要删掉,prettier 的配置文件 json 不支持注释


    {
    "singleQuote": true, // 单引号
    "semi": false, // 分号
    "trailingComma": "none", // 尾随逗号
    "tabWidth": 2, // 两个空格缩进
    "plugins": ["prettier-plugin-tailwindcss"] //tailwind插件
    }


  • 配置.prettierignore


    dist
    pnpm-lock.yaml


  • 配置.vscode>settings.json,配置后 vscode 保存时自动格式化代码风格


    {
    "editor.codeActionsOnSave": {
    // 每次保存的时候将代码按照 eslint 格式进行修复
    "source.fixAll.eslint": true
    },
    //自动格式化
    "editor.formatOnSave": true,
    //风格用prettier
    "editor.defaultFormatter": "esbenp.prettier-vscode"
    }


  • 掌握prettier命令行


    可以让之前没有格式化的错误一次性暴露出来


    npx prettier --write .//使用Prettier格式化所有文件



配置 husky 使用 git hook



记得要初始化一个 git 仓库,husky 能执行 git hook,在 commit 的时候对文件进行操作




  • 安装


    sudo pnpm dlx husky-init


    pnpm install


    npx husky add .husky/commit-msg 'npx --no -- commitlint --edit "$1"',commit-msg 使用 commitlint


    npx husky add .husky/pre-commit "npm run lint-staged",pre-commit 使用 lint-staged



配置 commitlint 检查提交信息



提交规范参考:http://www.conventionalcommits.org/en/v1.0.0/




  • 安装pnpm i @commitlint/cli @commitlint/config-conventional -D

  • 配置.commitlintrc.json


    { extends: ['@commitlint/config-conventional'] }



配置 lint-staged 增量式检查



  • 安装pnpm i -D lint-staged

  • 配置package.json


    "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "prepare": "husky install",
    "lint-staged": "npx lint-staged"//新增,对应上面的husky命令
    },


  • 配置.lintstagedrc.json


    {
    "*.{ts,tsx,json}": ["prettier --write", "eslint --fix"],
    "*.css": ["stylelint --fix", "prettier --write"]
    }



配置 vite(代理/别名/drop console 等)



如果有兼容性考虑,需要使用 legacy 插件,vite 也有 vscode 插件,也可以下载使用




  • 一些方便开发的配置


    import { defineConfig } from 'vite'
    import react from '@vitejs/plugin-react-swc'
    import path from 'path'

    // https://vitejs.dev/config/
    export default defineConfig({
    esbuild: {
    drop: ['console', 'debugger']
    },
    css: {
    // 开css sourcemap方便找css
    devSourcemap: true
    },
    plugins: [react()],
    server: {
    // 自动打开浏览器
    open: true
    proxy: {
    '/api': {
    target: 'https://xxxxxx',
    changeOrigin: true,
    rewrite: (path) => path.replace(/^\/api/, '')
    }
    }
    },
    resolve: {
    // 配置别名
    alias: { '@': path.resolve(__dirname, './src') }
    },
    //打包路径变为相对路径,用liveServer打开,便于本地测试打包后的文件
    base: './'
    })


  • 配置打包分析,用 legacy 处理兼容性


    pnpm i rollup-plugin-visualizer -D


    pnpm i @vitejs/plugin-legacy -D,实际遇到了再看官网用


    import { defineConfig } from 'vite'
    import react from '@vitejs/plugin-react-swc'
    import { visualizer } from 'rollup-plugin-visualizer'
    import legacy from '@vitejs/plugin-legacy'
    import path from 'path'
    // https://vitejs.dev/config/
    export default defineConfig({
    css: {
    // 开css sourcemap方便找css
    devSourcemap: true
    },
    plugins: [
    react(),
    visualizer({
    open: false // 打包完成后自动打开浏览器,显示产物体积报告
    }),
    //考虑兼容性,实际遇到了再看官网用
    legacy({
    targets: ['ie >= 11'],
    additionalLegacyPolyfills: ['regenerator-runtime/runtime']
    })
    ],
    server: {
    // 自动打开浏览器
    open: true
    },
    resolve: {
    // 配置别名
    alias: { '@': path.resolve(__dirname, './src') }
    },
    //打包路径变为相对路径,用liveServer打开,便于本地测试打包后的文件
    base: './'
    })


  • 如果想手机上看网页,可以pnpm dev --host

  • 如果想删除 console,可以按h去 help 帮助,再按c就可以 clear console


配置 tsconfig



  • tsconfig.json 需要支持别名


    {
    "compilerOptions": {
    "target": "ESNext",
    "useDefineForClassFields": true,
    "lib": ["DOM", "DOM.Iterable", "ESNext"],
    "allowJs": false,
    "skipLibCheck": true,
    "esModuleInterop": false,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "module": "ESNext",
    "moduleResolution": "Node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "baseUrl": "./",
    "paths": {
    "@/*": ["src/*"]
    }
    },
    "include": ["src"],
    "references": [{ "path": "./tsconfig.node.json" }]
    }



配置 router



  • 安装:pnpm i react-router-dom

  • 配置router->index.ts


    import { lazy } from 'react'
    import { createBrowserRouter } from 'react-router-dom'
    const Home = lazy(() => import('@/pages/home'))
    const router = createBrowserRouter([
    {
    path: '/',
    element: <Home></Home>
    }
    ])
    export default router


  • 配置main.tsx


    import { RouterProvider } from 'react-router-dom'
    import ReactDOM from 'react-dom/client'
    import './global.css'
    import router from './router'

    ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
    <RouterProvider router={router} />
    )



配置 zustand 状态管理



  • 安装pnpm i zustand

  • store->index.ts


    import { create } from 'zustand'

    interface appsState {
    nums: number
    setNumber: (nums: number) => void
    }

    const useAppsStore = create<appsState>((set) => ({
    nums: 0,
    setNumber: (num) => {
    return set(() => ({
    nums: num
    }))
    }
    }))

    export default useAppsStore


  • 使用方法


    import Button from '@/comps/custom-button'
    import useAppsStore from '@/store/app'
    const ZustandDemo: React.FC = () => {
    const { nums, setNumber } = useAppsStore()
    const handleNum = () => {
    setNumber(nums + 1)
    }
    return (
    <div className="p-10">
    <h1 className="my-10">数据/更新</h1>
    <Button click={handleNum}>点击事件</Button>
    <h1 className="py-10">{nums}</h1>
    </div>

    )
    }

    export default ZustandDemo



配置 antd



  • 新版本的 antd,直接下载就可以用,如果用到它的图片再单独下载pnpm i antd

  • 注意 antd5 版本的 css 兼容性不好,如果项目有兼容性要求,需要去单独配置


配置 Tailwind css


pnpm i tailwindcss autoprefixer postcss


tailwind.config.cjs


// 打包后会有1kb的css用不到的,没有影响
// 用了antd组件关系也不大,antd5的样式是按需的
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
// colors: {
// themeColor: '#ff4132',
// textColor: '#1a1a1a'
// },
// 如果写自适应布局,可以指定设计稿为1000px,然后只需要写/10的数值
// fontSize: {
// xs: '3.3vw',
// sm: '3.9vw'
// }
}
},
plugins: []
}

postcss.config.cjs


module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

我喜欢新建一个 apply.css 引入到全局


@tailwind base;
@tailwind components;
@tailwind utilities;

.margin-center {
@apply mx-auto my-0;
}

.flex-center {
@apply flex justify-center items-center;
}

.absolute-center {
@apply absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2;
}

封装 fetch 请求



这个封装仅供参考,TS 类型有点小问题



// 可以传入这些配置
interface BaseOptions {
method?: string
credentials?: RequestCredentials
headers?: HeadersInit
body?: string | null
}

// 请求方式
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD'

// 第一层出参
interface ResponseObject {
ok: boolean
error: boolean
status: number
contentType: string | null
bodyText: string
response: Response
}

// 请求头类型
type JSONHeader = {
Accept: string
'Content-Type': string
}

// 创建类
class Request {
private baseOptions: BaseOptions = {}

// 根据传入的 baseOptions 做为初始化参数
constructor(options?: BaseOptions) {
this.setBaseOptions(options || {})
}

public setBaseOptions(options: BaseOptions): BaseOptions {
this.baseOptions = options
return this.baseOptions
}

// 也提供获取 baseOption 的方法
public getBaseOptions(): BaseOptions {
return this.baseOptions
}

// 核心请求 T 为入参类型,ResponseObject 为出参类型
public request<T>(
method: HttpMethod,
url: string,
data?: T, //支持使用get的时候配置{key,value}的query参数
options?: BaseOptions //这里也有个 base 的 method
): Promise<ResponseObject> {
// 默认 baseOptions
const defaults: BaseOptions = {
method
// credentials: 'same-origin'
}

// 收集最后要传入的配置
const settings: BaseOptions = Object.assign(
{},
defaults,
this.baseOptions,
options
)

// 如果 method 格式错误
if (!settings.method || typeof settings.method !== 'string')
throw Error('[fetch-json] HTTP method missing or invalid.')

// 如果 url 格式错误
if (typeof url !== 'string')
throw Error('[fetch-json] URL must be a string.')

// 支持大小写
const httpMethod = settings.method.trim().toUpperCase()

// 如果是GET
const isGetRequest = httpMethod === 'GET'

// 请求头
const jsonHeaders: Partial<JSONHeader> = { Accept: 'application/json' }

// 如果不是 get 设置请求头
if (!isGetRequest && data) jsonHeaders['Content-Type'] = 'application/json'

// 收集最后的headers配置
settings.headers = Object.assign({}, jsonHeaders, settings.headers)

// 获取query参数的key
const paramKeys = isGetRequest && data ? Object.keys(data) : []

// 获取query参数的值
const getValue = (key: keyof T) => (data ? data[key] : '')

// 获取query key=value
const toPair = (key: string) =>
key + '=' + encodeURIComponent(getValue(key as keyof T) as string)

// 生成 key=value&key=value 的query参数
const params = () => paramKeys.map(toPair).join('&')

// 收集最后的 url 配置
const requestUrl = !paramKeys.length
? url
: url + (url.includes('?') ? '&' : '?') + params()

// get没有body
settings.body = !isGetRequest && data ? JSON.stringify(data) : null

// 做一层res.json()
const toJson = (value: Response): Promise<ResponseObject> => {
// 拿到第一次请求的值
const response = value

const contentType = response.headers.get('content-type')
const isJson = !!contentType && /json|javascript/.test(contentType)

const textToObj = (httpBody: string): ResponseObject => ({
ok: response.ok,
error: !response.ok,
status: response.status,
contentType: contentType,
bodyText: httpBody,
response: response
})

const errToObj = (error: Error): ResponseObject => ({
ok: false,
error: true,
status: 500,
contentType: contentType,
bodyText: 'Invalid JSON [' + error.toString() + ']',
response: response
})

return isJson
? // 如果是json,用json()
response.json().catch(errToObj)
: response.text().then(textToObj)
}

// settings做一下序列化
const settingsRequestInit: RequestInit = JSON.parse(
JSON.stringify(settings)
)

// 最终请求fetch,再通过then就能取到第二层res
return fetch(requestUrl, settingsRequestInit).then(toJson)
}

public get<T>(
url: string,
params?: T,
options?: BaseOptions
): Promise<ResponseObject> {
return this.request<T>('GET', url, params, options)
}

public post<T>(
url: string,
resource: T,
options?: BaseOptions
): Promise<ResponseObject> {
return this.request<T>('POST', url, resource, options)
}

public put<T>(
url: string,
resource: T,
options?: BaseOptions
): Promise<ResponseObject> {
return this.request<T>('PUT', url, resource, options)
}

public patch<T>(
url: string,
resource: T,
options?: BaseOptions
): Promise<ResponseObject> {
return this.request<T>('PATCH', url, resource, options)
}

public delete<T>(
url: string,
resource: T,
options?: BaseOptions
): Promise<ResponseObject> {
return this.request<T>('DELETE', url, resource, options)
}
}

const request = new Request()

export { request, Request }


如果用 axios 请求


request.ts


import axios from 'axios'
import { AxiosInstance } from 'axios'
import { errorHandle, processData, successHandle } from './resInterceptions'
import { defaultRequestInterception } from './reqInterceptions'
const TIMEOUT = 5 * 1000

class Request {
instance: AxiosInstance
constructor() {
this.instance = axios.create()
this.init()
}

private init() {
this.setDefaultConfig()
this.reqInterceptions()
this.resInterceptions()
}
private setDefaultConfig() {
this.instance.defaults.baseURL = import.meta.env.VITE_BASE_URL
this.instance.defaults.timeout = TIMEOUT
}
private reqInterceptions() {
this.instance.interceptors.request.use(defaultRequestInterception)
}
private resInterceptions() {
this.instance.interceptors.response.use(processData)
this.instance.interceptors.response.use(successHandle, errorHandle)
}
}

export default new Request().instance

reqInterceptions.ts


import type { InternalAxiosRequestConfig } from 'axios'

const defaultRequestInterception = (config: InternalAxiosRequestConfig) => {
// TODO: 全局请求拦截器: 添加token
return config
}

export { defaultRequestInterception }

resInterceptions.ts


import { AxiosError, AxiosResponse } from 'axios'
import { checkStatus } from './checkStatus'

const processData = (res: AxiosResponse) => {
// TODO:统一处理数据结构
return res.data
}

const successHandle = (res: AxiosResponse) => {
// TODO:处理一些成功回调,例如请求进度条
return res.data
}

const errorHandle = (err: AxiosError) => {
if (err.status) checkStatus(err.status)
else return Promise.reject(err)
}

export { processData, successHandle, errorHandle }

checkStatus.ts


export function checkStatus(status: number, msg?: string): void {
let errMessage = ''

switch (status) {
case 400:
errMessage = `${msg}`
break
case 401:
break
case 403:
errMessage = ''
break
// 404请求不存在
case 404:
errMessage = ''
break
case 405:
errMessage = ''
break
case 408:
errMessage = ''
break
case 500:
errMessage = ''
break
case 501:
errMessage = ''
break
case 502:
errMessage = ''
break
case 503:
errMessage = ''
break
case 504:
errMessage = ''
break
case 505:
errMessage = ''
break
default:
}
if (errMessage) {
// TODO:错误提示
// createErrorModal({title: errMessage})
}
}

api.ts


import request from '@/services/axios/request'
import { ReqTitle } from './type'

export const requestTitle = (): Promise<ReqTitle> => {
return request.get('/api/一个获取title的接口')
}

type.ts


export type ReqTitle = {
title: string
}

配置 mobx(可不用)



  • 安装pnpm i mobx mobx-react-lite

  • 配置model->index.ts


    import { makeAutoObservable } from 'mobx'

    const store = makeAutoObservable({
    count: 1,
    setCount: (count: number) => {
    store.count = count
    }
    })

    export default store


  • 使用方法举个 🌰


    import store from '@/model'
    import { Button } from 'antd'
    import { observer, useLocalObservable } from 'mobx-react-lite'
    const Home: React.FC = () => {
    const localStore = useLocalObservable(() => store)
    return (
    <div>
    <Button>Antd</Button>
    <h1>{localStore.count}</h1>
    </div>

    )
    }

    export default observer(Home)



配置 changelog(可不用)


pnpm i conventional-changelog-cli -D


第一次先执行conventional-changelog -**p** angular -**i** CHANGELOG.md -s -r 0全部生成之前的提交信息


配置个脚本,版本变化打 tag 的时候可以使用


"scripts": {
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s"
}

配置 editorConfig 统一编辑器(可不用)



editorConfig,可以同步编辑器差异,其实大部分工作 prettier 做了,需要下载 editorConfig vscode 插件
有编辑器差异的才配置一下,如果团队都是 vscode 就没必要了




  • 配置editorconfig


    #不再向上查找.editorconfig
    root = true
    # *表示全部文件
    [*]
    #编码
    charset = utf-8
    #缩进方式
    indent_style = space
    #缩进空格数
    indent_size = 2
    #换行符lf
    end_of_line = lf



配置 stylelint 检查 CSS 规范(可不用)



stylelint 处理 css 更专业,但是用了 tailwind 之后用处不大了




  • 安装:pnpm i -D stylelint stylelint-config-standard

  • 配置.stylelintrc.json


    {
    "extends": "stylelint-config-standard"
    }


  • 配置.vscode>settings.json,配置后 vscode 保存时自动格式化 css


    {
    "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true, // 每次保存的时候将代码按照 eslint 格式进行修复
    "source.fixAll.stylelint": true //自动格式化stylelint
    },
    "editor.formatOnSave": true, //自动格式化
    "editor.defaultFormatter": "esbenp.prettier-vscode" //风格用prettier
    }


  • 掌握stylelint命令行


    npx stylelint "**/*.css" --fix//格式化所有css,自动修复css



下面是 h5 项目(可不用)


配置vconsole(h5)



  • 安装pnpm i vconsole -D

  • main.tsx里新增


    import VConsole from 'vconsole'
    new VConsole({ theme: 'dark' })



antd 换成 mobile antd(h5)



  • pnpm remove antd

  • pnpm add antd-mobile


配置 postcss-px-to-viewport(废弃)



  • 把蓝湖设计稿尺寸固定为 1000px(100px我试过蓝湖直接白屏了),然后你点出来的值比如是 77px,那你只需要写 7.7vw 就实现了自适应布局,就不再需要这个插件了

  • 安装:pnpm i postcss-px-to-viewport -D

  • 配置postcss.config.cjs


    module.exports = {
    plugins: {
    'postcss-px-to-viewport': {
    landscape: false, // 是否添加根据 landscapeWidth 生成的媒体查询条件 @media (orientation: landscape)
    landscapeUnit: 'vw', // 横屏时使用的单位
    landscapeWidth: 568, // 横屏时使用的视口宽度
    unitToConvert: 'px', // 要转化的单位
    viewportWidth: 750, // UI设计稿的宽度
    unitPrecision: 5, // 转换后的精度,即小数点位数
    propList: ['*'], // 指定转换的css属性的单位,*代表全部css属性的单位都进行转换
    viewportUnit: 'vw', // 指定需要转换成的视窗单位,默认vw
    fontViewportUnit: 'vw', // 指定字体需要转换成的视窗单位,默认vw
    selectorBlackList: ['special'], // 指定不转换为视窗单位的类名,
    minPixelValue: 1, // 默认值1,小于或等于1px则不进行转换
    mediaQuery: true, // 是否在媒体查询的css代码中也进行转换,默认false
    replace: true, // 是否转换后直接更换属性值
    exclude: [/node_modules/] // 设置忽略文件,用正则做目录名匹配
    }
    }
    }



作者:imber
来源:juejin.cn/post/7241875166887444541
收起阅读 »

技术总监写的十个方法,让我精通了lambda表达式

前公司的技术总监写了工具类,对Java Stream 进行二次封装,使用起来非常爽,全公司都在用。 我自己照着写了一遍,改了名字,分享给大家。 一共整理了10个工具方法,可以满足 Collection、List、Set、Map 之间各种类型转化。例如 将 C...
继续阅读 »

前公司的技术总监写了工具类,对Java Stream 进行二次封装,使用起来非常爽,全公司都在用。


我自己照着写了一遍,改了名字,分享给大家。


一共整理了10个工具方法,可以满足 Collection、List、Set、Map 之间各种类型转化。例如



  1. Collection<OrderItem> 转化为 List<OrderItem>

  2. Collection<OrderItem> 转化为 Set<OrderItem>

  3. List<OrderItem> 转化为 List<Long>

  4. Set<OrderItem> 转化为 Set<Long>

  5. Collection<OrderItem> 转化为 List<Long>

  6. Collection<OrderItem> 转化为 Set<Long>

  7. Collection<OrderItem>中提取 Key, Map 的 Value 就是类型 OrderItem

  8. Collection<OrderItem>中提取 Key, Map 的 Value 根据 OrderItem 类型进行转化。

  9. Map<Long, OrderItem> 中的value 转化为 Map<Long, Double>

  10. value 转化时,lamada表达式可以使用(v)->{}, 也可以使用 (k,v)->{ }


Collection 集合类型到 Map类型的转化。


Collection 转化为 Map


由于 List 和 Set 是 Collection 类型的子类,所以只需要实现Collection 类型转化为 Map 类型即可。
Collection转化为 Map 共分两个方法



  1. Collection<OrderItem> Map<Key, OrderItem>,提取 Key, Map 的 Value 就是类型 OrderItem

  2. Collection<OrderItem>Map<Key,Value> ,提取 Key, Map 的 Value 根据 OrderItem 类型进行转化。


使用样例


代码示例中把Set<OrderItem> 转化为 Map<Long, OrderItem>Map<Long ,Double>


@Test
public void testToMap() {
Collection<OrderItem> collection = coll;
Set<OrderItem> set = toSet(collection);

Map<Long, OrderItem> map = toMap(set, OrderItem::getOrderId);
}

@Test
public void testToMapV2() {
Collection<OrderItem> collection = coll;
Set<OrderItem> set = toSet(collection);

Map<Long, Double> map = toMap(set, OrderItem::getOrderId, OrderItem::getActPrice);
}

代码展示


public static <T, K> Map<K, T> toMap(Collection<T> collection, Function<? super T, ? extends K> keyMapper) {
return toMap(collection, keyMapper, Function.identity());
}

public static <T, K, V> Map<K, V> toMap(Collection<T> collection,
Function<? super T, ? extends K> keyFunction,
Function<? super T, ? extends V> valueFunction)
{
return toMap(collection, keyFunction, valueFunction, pickSecond());
}

public static <T, K, V> Map<K, V> toMap(Collection<T> collection,
Function<? super T, ? extends K> keyFunction,
Function<? super T, ? extends V> valueFunction,
BinaryOperator<V> mergeFunction)
{
if (CollectionUtils.isEmpty(collection)) {
return new HashMap<>(0);
}

return collection.stream().collect(Collectors.toMap(keyFunction, valueFunction, mergeFunction));
}

public static <T> BinaryOperator<T> pickFirst() {
return (k1, k2) -> k1;
}
public static <T> BinaryOperator<T> pickSecond() {
return (k1, k2) -> k2;
}

Map格式转换


转换 Map 的 Value



  1. 将 Map<Long, OrderItem> 中的value 转化为 Map<Long, Double>

  2. value 转化时,lamada表达式可以使用(v)->{}, 也可以使用 (k,v)->{ }。


测试样例


@Test
public void testConvertValue() {
Collection<OrderItem> collection = coll;
Set<OrderItem> set = toSet(collection);

Map<Long, OrderItem> map = toMap(set, OrderItem::getOrderId);

Map<Long, Double> orderId2Price = convertMapValue(map, item -> item.getActPrice());
Map<Long, String> orderId2Token = convertMapValue(map, (id, item) -> id + item.getName());

}

代码展示


public static <K, V, C> Map<K, C> convertMapValue(Map<K, V> map, 
BiFunction<K, V, C> valueFunction,
BinaryOperator<C> mergeFunction)
{
if (isEmpty(map)) {
return new HashMap<>();
}
return map.entrySet().stream().collect(Collectors.toMap(
e -> e.getKey(),
e -> valueFunction.apply(e.getKey(), e.getValue()),
mergeFunction
));
}

public static <K, V, C> Map<K, C> convertMapValue(Map<K, V> originMap, BiFunction<K, V, C> valueConverter) {
return convertMapValue(originMap, valueConverter, Lambdas.pickSecond());
}

public static <T> BinaryOperator<T> pickFirst() {
return (k1, k2) -> k1;
}
public static <T> BinaryOperator<T> pickSecond() {
return (k1, k2) -> k2;
}

集合类型转化


Collection 和 List、Set 的转化



  1. Collection<OrderItem> 转化为 List<OrderItem>

  2. Collection<OrderItem> 转化为 Set<OrderItem>


public static <T> List<T> toList(Collection<T> collection) {
if (collection == null) {
return new ArrayList<>();
}
if (collection instanceof List) {
return (List<T>) collection;
}
return collection.stream().collect(Collectors.toList());
}

public static <T> Set<T> toSet(Collection<T> collection) {
if (collection == null) {
return new HashSet<>();
}
if (collection instanceof Set) {
return (Set<T>) collection;
}
return collection.stream().collect(Collectors.toSet());
}

测试样例


@Test//将集合 Collection 转化为 List
public void testToList() {
Collection<OrderItem> collection = coll;
List<OrderItem> list = toList(coll);
}

@Test//将集合 Collection 转化为 Set
public void testToSet() {
Collection<OrderItem> collection = coll;
Set<OrderItem> set = toSet(collection);
}

List和 Set 是 Collection 集合类型的子类,所以无需再转化。


List、Set 类型之间的转换


业务中有时候需要将 List<A> 转化为 List<B>。如何实现工具类呢?


public static <T, R> List<R> map(List<T> collection, Function<T, R> mapper) {
return collection.stream().map(mapper).collect(Collectors.toList());
}

public static <T, R> Set<R> map(Set<T> collection, Function<T, R> mapper) {
return collection.stream().map(mapper).collect(Collectors.toSet());
}

public static <T, R> List<R> mapToList(Collection<T> collection, Function<T, R> mapper) {
return collection.stream().map(mapper).collect(Collectors.toList());
}

public static <T, R> Set<R> mapToSet(Collection<T> collection, Function<T, R> mapper) {
return collection.stream().map(mapper).collect(Collectors.toSet());
}

测试样例



  1. List<OrderItem> 转化为 List<Long>

  2. Set<OrderItem> 转化为 Set<Long>

  3. Collection<OrderItem> 转化为 List<Long>

  4. Collection<OrderItem> 转化为 Set<Long>


@Test
public void testMapToList() {
Collection<OrderItem> collection = coll;
List<OrderItem> list = toList(coll);

List<Long> orderIdList = map(list, (item) -> item.getOrderId());
}

@Test
public void testMapToSet() {
Collection<OrderItem> collection = coll;
Set<OrderItem> set = toSet(coll);

Set<Long> orderIdSet = map(set, (item) -> item.getOrderId());
}

@Test
public void testMapToList2() {
Collection<OrderItem> collection = coll;

List<Long> orderIdList = mapToList(collection, (item) -> item.getOrderId());
}

@Test
public void testMapToSetV2() {
Collection<OrderItem> collection = coll;

Set<Long> orderIdSet = mapToSet(collection, (item) -> item.getOrderId());

}

总结一下 以上样例包含了如下的映射场景



  1. Collection<OrderItem> 转化为 List<OrderItem>

  2. Collection<OrderItem> 转化为 Set<OrderItem>

  3. List<OrderItem> 转化为 List<Long>

  4. Set<OrderItem> 转化为 Set<Long>

  5. Collection<OrderItem> 转化为 List<Long>

  6. Collection<OrderItem> 转化为 Set<Long>

  7. Collection<OrderItem>中提取 Key, Map 的 Value 就是类型 OrderItem

  8. Collection<OrderItem>中提取 Key, Map 的 Value 根据 OrderItem 类型进行转化。

  9. Map<Long, OrderItem> 中的value 转化为 Map<Long, Double>

  10. value 转化时,lamada表达式可以使用(v)->{}, 也可以使用 (k,v)->{ }


作者:五阳
来源:juejin.cn/post/7305572311812587531
收起阅读 »

如何及时发现网页的隐形错误

web
在上一篇文章前端监控究竟有多重要?大家了解了前端监控系统的重要性以及前端监控的组成部分、常见的监控指标、埋点方式。 接下来这篇文章我们就来详细学习一下前端监控系统中的,异常监控。 想要进行异常监控之前,肯定先要了解有哪些异常才能进行监控。 异常的类型 一般来说...
继续阅读 »

在上一篇文章前端监控究竟有多重要?大家了解了前端监控系统的重要性以及前端监控的组成部分、常见的监控指标、埋点方式。


接下来这篇文章我们就来详细学习一下前端监控系统中的,异常监控


想要进行异常监控之前,肯定先要了解有哪些异常才能进行监控。


异常的类型


一般来说,浏览器端的异常分为两种类型:



  • JavaScript 错误,一般都是来自代码的原因。

  • 静态资源错误,一般都是来着资源加载的原因


而这里面我们又有各自的差异


JavaScript 错误


先来说说JavaScript的错误类型,ECMA-262 定义了 7 种错误类型,说明如下:



  • EvalError :eval() 函数的相关的错误

  • RangeError :使用了超出了 JavaScript 的限制或范围的值。

  • ReferenceError: 引用了未定义的变量或对象

  • TypeError: 类型错误

  • URIError: URI操作错误

  • SyntaxError: 语法错误 (这个错误WebIDL中故意省略,保留给ES解析器使用)

  • Error: 普通异常,通常与 throw 语句和 try/catch 语句一起使用,利用属性 name 可以声明或了解异常的类型,利用message 属性可以设置和读取异常的详细信息。


如果想更详细了解可以看详细错误罗列这篇文章


静态资源错误



  • 通过 XMLHttpRequest、Fetch() 的方式来请求的 http 资源时。

  • 利用
收起阅读 »

Java程序员快速提高代码质量建议

1、概述 相同的业务需求不同层级的程序员实现方式不一样,经验稍微欠缺一点的新手程序员,可能单纯的实现功能,经验丰富的程序员,开发的代码可能会具有很好的扩展性、易读性、健壮性。相信很多小伙伴在工作团队中,有时候会一起code review,互相review代码,...
继续阅读 »
1、概述

相同的业务需求不同层级的程序员实现方式不一样,经验稍微欠缺一点的新手程序员,可能单纯的实现功能,经验丰富的程序员,开发的代码可能会具有很好的扩展性、易读性、健壮性。相信很多小伙伴在工作团队中,有时候会一起code review,互相review代码,其实review代码时大家保持开放包容心态,是一种团队进度的方式。
今天分享的内容主要帮助大家从代码规范的角度,梳理出快速提升代码质量的建议,学完之后可以帮助大家在团队code review时,提供建议,帮大家写出高质量代码。


2、什么样的代码是高质量代码

如何评价一段代码的好与坏,其实是有一定主观性的,不同人有不同的标准和看法,但是总的概括下来优秀的代码一般具有如下特点:


高质量代码特点.png


3、如何提高代码质量

这里主要代码规范角度,小伙伴们可以快速理解掌握,并快速使用。


3.1 代码命名

项目名、模块名、包名、类名、接口名、变量名、参数名等,都会涉及命名,良好的代码命名是程序员的基本素养,对代码可读性非常重要。



  • 命名原则
    1、Java采用驼峰命名,代码命名要使用通俗易懂的词汇,不要采用生僻单词;
    2、团队内部或者项目中风格要统一,例如查询类方法,要么都使用findByXXX方式,或者queryByXXX、getByXXX等,不要几种混用,风格保持一致;
    3、命名长度:个人建议有时候为了易于理解,可以将命名适当长一些,例如:如下方法,一看就知道是上传照片到阿里云服务器,


public void uploadPhotoImageToAliyun(String userPhotoImageUri){}

可以利用上下文语义简化变量命名长度,如下用户实体类变量命名可以简化,更简洁


public class User {
private String userName;
private String userPassword;
private String userGender;
}

public class User {
private String name;
private String password;
private String gender;
}

4、抽象类通常带有Abstract前缀,接口命名和实现类命名,通常类似这样RoleService,实现类跟一个Impl,如RoleServiceImpl



  • 注释
    1、良好的代码注释对于可读性很重要,虽然有小伙伴可能会觉得好的命名可以替代注释;
    2、个人觉得注释很重要,注释可以起到代码分隔作用,代码块总结作用,文档作用;
    3、部分程序设计核心关键点,可以通过注释帮助其他研发人员理解;
    4、注释是否越多越好呢,然而并不是这样,太多注释反而让人迷惑,增加维护成本,代码变动之后也需要对注释进行修改。


3.2 代码风格

良好的代码风格,可以提升代码可读性,主要梳理以下几点:


良好的代码风格.png


3.3 实用代码技巧


  • 将代码分隔成多个单元
    代码逻辑太长不易阅读,将代码分隔成多个小的方法单元,更好理解和复用,如下所示,用户注册接口,包含账号、手机号校验及用户保存操作


public Long registerUser(String Account, String mobile, String password){
// 校验账号是否重复
if(StringUtils.isNotBlank(Account)){
User user = userService.getUserByName(Account);
AssertUtils.isNull(user, "用户账号已存在,不能重复");
}
// 校验手机号是否重复
if(StringUtils.isNotBlank(mobile)){
User user = userService.getUserByMobile(mobile);
AssertUtils.isNull(user, "手机号已存在,不能重复");
}
// 保存用户到DB
return userService.insert(Account, mobile, password);
}

重构之后的代码如下:


public Long registerUser(String Account, String mobile, String password){
// 校验账号是否重复
checkAccountIsExists(Account);
// 校验手机号是否重复
checkMobileIsExists(mobile);
// 保存用户到DB
return userService.insert(Account, mobile, password);
}

private void checkAccountIsExists(String Account){
if(StringUtils.isNotBlank(Account)){
User user = userService.getUserByName(Account);
AssertUtils.isNull(user, "用户账号已存在,不能重复");
}
}
private void checkMobileIsExists(String mobile){
if(StringUtils.isNotBlank(mobile)){
User user = userService.getUserByMobile(mobile);
AssertUtils.isNull(user, "手机号已存在,不能重复");
}
}



  • 避免方法太多参数
    方法太多参数影响代码可读性,当方法参数太多时可以采取将方法抽取为几个私有方法,如下所示:


public User getUser(String username, String telephone, String email);

// 拆分成多个函数
public User getUserByUsername(String username);
public User getUserByTelephone(String telephone);
public User getUserByEmail(String email);

也可以将参数封装为对象,通过抽取为对象对于C端项目还能更好兼容,如果是对外暴露的接口,可以避免新老接口兼容问题


public User getUser(String username, String telephone, String email);

// 重构后将方法入参封装为对象
public class SearchUserRequest{
private String username;
private String telephone;
private String email;
}
public User getUser(SearchUserRequest searchUserReq重构后将方法入参封装为对象


  • 不要使用参数null及boolean来判断
    使用参数非空和为空作为代码的if、else分支,以及boolean参数作为代码分支,这些都不建议,如果可以尽量拆分为多个细小的私有方法;当然也不是绝对的,实际情况具体分析;

  • ** 方法设计遵守单一职责**
    方法设计不要追求大而全,尽量做到职责单一,粒度细,更易理解和复用,如下所示:


public boolean checkUserIfExisting(String telephone, String username, String email)  { 
if (!StringUtils.isBlank(telephone)) {
User user = userRepo.selectUserByTelephone(telephone);
return user != null;
}

if (!StringUtils.isBlank(username)) {
User user = userRepo.selectUserByUsername(username);
return user != null;
}

if (!StringUtils.isBlank(email)) {
User user = userRepo.selectUserByEmail(email);
return user != null;
}

return false;
}

// 拆分成三个函数
public boolean checkUserIfExistingByTelephone(String telephone);
public boolean checkUserIfExistingByUsername(String username);
public boolean checkUserIfExistingByEmail(String email);


  • 避免嵌套逻辑太深
    避免if else太多的方法,可以使用卫语句,将满足条件的结果提前返回,或者使用枚举、策略模式、switch case等;
    对于for循环太深嵌套,可以使用continue、break、return等提前结束循环,或者优化代码逻辑。

  • 使用解释性变量
    尽量不要使用魔法值,要使用常量来管理,代码中复杂的判断逻辑可以使用解释性变量,如下所示:


public double CalculateCircularArea(double radius) {
return (3.1415) * radius * radius;
}

// 常量替代魔法数字
public static final Double PI = 3.1415;
public double CalculateCircularArea(double radius) {
return PI * radius * radius;
}

if (date.after(SPRING_START) && date.before(SPRING_END)) {
// ...
} else {
// ...
}

// 引入解释性变量后逻辑更加清晰
boolean isSpring = date.after(SPRING_START)&&date.before(SPRING_END);
if (isSpring) {
// ...
} else {
// ...
}



作者:美丽的程序人生
来源:juejin.cn/post/7352079427863920651
收起阅读 »

身份认证的尽头竟然是无密码 ?

概述 几乎所有的系统都会面临安全认证相关的问题,但是安全相关的问题是一个很麻烦的事情。因为它不产生直接的业务价值,而且处理起来复杂繁琐,所以很多时都容易被忽视。很多后期造成重大的安全隐患,往往都是前期的不重视造成的。但庆幸的是安全问题是普遍存在的,而且大家面临...
继续阅读 »

概述


几乎所有的系统都会面临安全认证相关的问题,但是安全相关的问题是一个很麻烦的事情。因为它不产生直接的业务价值,而且处理起来复杂繁琐,所以很多时都容易被忽视。很多后期造成重大的安全隐患,往往都是前期的不重视造成的。但庆幸的是安全问题是普遍存在的,而且大家面临的问题几乎相同,所以可以制定行业标准来规范处理,甚至是可以抽出专门的基础设施(例如:AD、LDAP 等)来专门解决这类共性的问题。总之,关于安全问题非常复杂而且麻烦,对于大多数 99% 的系统来说,不要想着在安全问题领域上搞发明和创新,容易踩坑。而且行业的标准解决方案已经非常成熟了。经过长时间的检验。所以在安全领域,踏踏实实的遵循规范和标准就是最好的安全设计。


HTTP 认证


HTTP 认证协议的最初是在 HTTP/1.1标准中定义的,后续由 IETF 在 RFC 7235 中进行完善。HTTP 协议的主要涉及两种的认证机制。


HTTP 认证的对话框


基本认证


常见的叫法是 HTTP Basic,是一种对于安全性不高,以演示为目的的简单的认证机制(例如你家路由器的登录界面),客户端用户名和密码进行 Base64 编码(注意是编码,不是加密)后,放入 HTTP 请求的头中。服务器在接收到请求后,解码这个字段来验证用户的身份。示例:


GET /some-protected-resource HTTP/1.1
Host: example.com
Authorization: Basic dXNlcjpwYXNzd29yZA==

虽然这种方式简单,但并不安全,因为 base64 编码很容易被解码。建议仅在 HTTPS 协议下使用,以确保安全性。


摘要认证


主要是为了解决 HTTP Basic 的安全问题,但是相对也更复杂一些,摘要认证使用 MD5 哈希函数对用户的密码进行加密,并结合一些盐值(可选)生成一个摘要值,然后将这个值放入请求头中。即使在传输过程中被截获,攻击者也无法直接从摘要中还原出用户的密码。示例:


GET /dir/index.html HTTP/1.1
Host: example.com
Authorization: Digest username="user", realm="example.com", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", uri="/dir/index.html", qop=auth, nc=00000001, cnonce="0a4f113b", response="6629fae49393a05397450978507c4ef1", opaque="5ccc069c403ebaf9f0171e9517f40e41"

**补充:**另在 RFC 7235 规范中还定义当用户没有认证访问服务资源时应返回 401 Unauthorized 状态码,示例:


HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="Restricted Area"

这一规范目前应用在所有的身份认证流程中,并且沿用至今。


Web 认证


表单认证


虽然 HTTP 有标准的认证协议,但目前实际场景中大多应用都还是基于表单认证实现,具体步骤是:



  1. 前端通过表单收集用户的账号和密码

  2. 通过协商的方式发送服务端进行验证的方式。


常见的表单认证页面通常如下:


html>
<html>
<head>
    <title>Login Pagetitle>
head>
<body>
    <h2>Login Formh2>
    <form action="/perform_login" method="post">
        <div class="container">
            <label for="username"><b>Usernameb>label>
            <input type="text" placeholder="Enter Username" name="username" required>
            
            <label for="password"><b>Passwordb>label>
            <input type="password" placeholder="Enter Password" name="password" required>
            
            <button type="submit">Loginbutton>
        div>
    form>
body>
html>

为什么表单认证会成为主流 ?主要有以下几点原因:



  • 界面美化:开发者可以创建定制化的登录界面,可以与应用的整体设计风格保持一致。而 HTTP 认证通常会弹出一个很丑的模态对话框让用户输入凭证。

  • 灵活性:可以在表单里面自定义更多的逻辑和流程,比如多因素认证、密码重置、记住我功能等。这些功能对于提高应用的安全性和便利性非常重要。

  • 安全性:表单认证可以更容易地结合现代的安全实践,背后也有 OAuth 2 、Spring Security 等框架的主持。


表单认证传输内容和格式基本都是自定义本没啥规范可言。但是在 2019 年之后 web 认证开始发布标准的认证协议。


WebAuthn


WebAuthn 是一种彻底抛弃传统密码的认证,完全基于生物识别技术和实体密钥作为身份识别的凭证(有兴趣的小伙伴可以在 github 开启 Webauhtn 的 2FA 认证体验一下)。在 2019 年 3 月,W3C 正式发布了 WebAuthn 的第一版规范。


webauthn registration


相比于传统的密码,WebAuthn 具有以下优势:



  1. 减少密码泄露:传统的用户名和密码登录容易受到钓鱼攻击和数据泄露的影响。WebAuthn,不依赖于密码,不存在密码丢失风险。

  2. 提高用户体验:用户不需要记住复杂的密码,通过使用生物识别等方式可以更快捷、更方便地登录。

  3. 多因素认证:WebAuthn 可以作为多因素认证过程中的一部分,进一步增强安全性。使用生物识别加上硬件密钥的方式进行认证,比短信验证码更安全。


总的来说,WebAuthn 是未来的身份认证方式,通过提供一个更安全、更方便的认证方式,目的是替代传统的基于密码的登录方法,从而解决了网络安全中的一些长期问题。WebAuthn 目前已经得到流程的浏览器厂商(Chrome、Firefox、Edge、Safari)、操作系统(WIndows、macOS、Linux)的广泛支持。


实现效果


当你的应用接入 WebAuthn 后,用户便可以通过生物识别设备进行认证,效果如下:


WebAuthn login


实现原理


WebAuthn 实现较为复杂,这里不做详细描述,具体可参看权威的官方文档,大概交互过程可以参考以下时序图:


webauthn 交互时序图


登录流程大致可以分为以下步骤:



  1. 用户访问登录页面,填入用户名后即可点击登录按钮。

  2. 服务器返回随机字符串 Challenge、用户 UserID。

  3. 浏览器将 Challenge 和 UserID 转发给验证器。

  4. 验证器提示用户进行认证操作。

  5. 服务端接收到浏览器转发来的被私钥加密的 Challenge,以此前注册时存储的公钥进行解密,如果解密成功则宣告登录成功。


WebAuthn 采用非对称加密的公钥、私钥替代传统的密码,这是非常理想的认证方案,私钥是保密的,只有验证器需要知道它,连用户本人都不需要知道,也就没有人为泄漏的可能;



备注:你可以通过访问 webauthn.me 了解到更多消息的信息



文章不适合加入过多的演示代码,想要手上体验的可以参考 okta 官方给出基于 Java 17 和 Maven 构建的 webauthn 示例程序,如下:



作者:小二十七
来源:juejin.cn/post/7354632375446061083
收起阅读 »