注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

android之阿拉伯语适配及注意细节

1.  AndroidManifest.xml配置文件中的 标签下,配置元素 android:supportsRtl="true"。此时当系统语言切换的时候,你的 App 也会跟着切换 UI 布局为镜像后的效果。若未增加该元素,在xml中切换语言时,...
继续阅读 »

1.  AndroidManifest.xml配置文件中的 标签下,配置元素 android:supportsRtl="true"。此时当系统语言切换的时候,你的 App 也会跟着切换 UI 布局为镜像后的效果。

若未增加该元素,在xml中切换语言时,会提示 image.png 增加后,可在xml文件中查看反转后的效果 2.  新增value-ar文件夹

image.png

image.png

image.png 把values/strings.xml文件复制到values-ar文件中,逐条翻译即可。

  1. layout中的Left/Right修改为Start/End

可使用Android Studio中自带的工具:“工具栏”-“Refactor”-“Add right-to-Left(RTL)Support” image.png

注意事项:

  • 1).此时会把所依赖gradle里的xml文件列出,记得删除,不要转换。

image.png

  • 2). 该工具只适用于项目的app模块,无法直接应用于依赖模块。如果需要在依赖模块中进行RTL转换,要逐个打开并手动进行相应的修改。
  • 3). Start属性在LTR中对应Left,End属性在LTR中对应Right,在API 17开始支持,为了兼容低版本,可以同时有Left和Start。

    即在“Add right-to-Left(RTL)Support”工具中,不勾选“Replace Left/Right Properties with Start/End Properties”

image.png

  1. 返回icon、下一个icon等,要针对阿拉伯语新建一个文件夹,放镜像后的图片,规则如下:

mipmap-xhdpi->mipmap-ldrtl-xhdpi

drawable->drawable-ldrtl

最终镜像的图片要UI同事提供,临时修改看效果可以使用镜像图片的网站:http://www.lddgo.net/image/flip

  1. TextView、EditText:利用全局样式,在style.xml中定义,在xml里使用style=”@style/xxx”即可
  • 1). TextView
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
       ...
       <item name="android:textViewStyle">@style/TextViewStyle.TextDirectionitem>
       ...
style>
<style name="TextViewStyle.TextDirection" parent="android:Widget.TextView">
        <item name="android:textDirection">localeitem>
style>
  • 2). EditText
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
       ...
       <item name="editTextStyle">@style/EditTextStyle.Alignmentitem>
       ...
style>
<style name="EditTextStyle.Alignment" parent="@android:style/Widget.EditText">
        <item name="android:textAlignment">viewStartitem>
        <item name="android:gravity">startitem>
        <item name="android:textDirection">localeitem>
style>
  1. 其他细节
  • 1).固定ltr,如阿拉伯语下的“99%”要从左到右展示,可在xml中使用
android:layoutDirection ="ltr"
  • 2).获取当前系统语言Locale.getDefault().getLanguage()

判断是否为阿拉伯语:"ar".equals(Locale.getDefault().getLanguage())

判断是否为英语:"en".equals(Locale.getDefault().getLanguage())

  • 3). drawable/xxx_selector.xml中item里有android:drawable,如勾选框。

drawable有android:autoMirrored属性,将selector的该属性设置为true,就可以让drawable在RTL布局下进行反转

image.png

  • 4).进度条的默认进度指示是从左到右,使用leftMargin;在阿拉伯语下,进度指示从右到左,使用rightMargin属性
  • 5).阿拉伯语环境下,使用SimpleDateFormat格式化时间字符串的时候,会显示为:٢٠١٥-٠٩-١٨ ٠٧:٠٣:٤٩。若要展示:2023-09-067:10:45,可以使用Locale.ENGLISH参数
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH);
Date now=new Date();
System.out.println(sdf .format(now));
  • 6). 加载html可用 tv.setText(Html.fromHtml(getResources().getString(R.String.xxx));
  • 7). 开机导航中设置了阿拉伯语,当前页面布局要刷新,可以重写activity的onConfigurationChanged()方法,如在该方法里重置下一步箭头、指示器样式等

image.png

  • 8).ViewPager

若是ViewPager,可使用第三方控件RtlViewPager替换: 521github.com/diego-gomez…,添加依赖,单纯替换原ViewPager即可

implementation 'com.booking:rtlviewpager:1.0.1' 

类似三方控件: 521github.com/duolingo/rt…

或者使用androidx的ViewPager2替换: developer.android.google.cn/jetpack/and…,支持RTL布局

image.png

image.png

  • 9). 固定RTL字符串的顺序

问题现象:EditText带hint,密码可见、不可见时,会调用如下方法进行设置

image.png 此时会影响hint的展示:在勾选时,hint的结束字符在右侧;不勾选时,hint的结束字符在左侧。

image.png

image.png

解决方法:此时要使用Unicode控制字符来限制整个字符串的显示方向:\u202B 和 \u202C。

image.png

有以下两种方法

a.  java代码

image.png

b.  strings.xml

image.png

最终效果:

image.png

image.png

10). Blankj的toast展示异常

android工具类Blankj的toast工具类在展示阿拉伯语时为空或者部分展示,建议使用1.30.6 及以上版本

image.png

github.com/Blankj/Andr…

11). RTL布局中出现双光标/光标截断的情形

image.png

在布局文件内加上如下两个属性即可:

android:textDirection="anyRtl"
android:textAlignment="viewStart"

若还未解决

1.可查看是否使用了android:textCursorDrawable=“@null”,若有,可尝试去掉该句。

2.在AndroidManifest.xml中查看当前App/Activity的主题,比较老的项目可能使用了android:Theme.NotitleBar/android:Theme.Light等轻量级主题,如下所示:




收起阅读 »

微信小程序dom节点最高限制16000?能忍?盘它!!

web
起因 最近参与了个微信小程序的项目,有个长列表模块 我在自测的时候(测试人员居然没发现,可能懒得加载那么多数据去验证),发现当列表加载加载500条以上的时候,会有一个 Dom limit exceeded, please check if there's an...
继续阅读 »

起因


最近参与了个微信小程序的项目,有个长列表模块 我在自测的时候(测试人员居然没发现,可能懒得加载那么多数据去验证),发现当列表加载加载500条以上的时候,会有一个 Dom limit exceeded, please check if there's any mistake you've made.(超过了Dom限制,请检查你是否犯了任何错误) 渲染报错,页面会白屏,所以猜测小程序的Dom节点应该是有最高限制的。


分析问题原因


网上查了很多资料,查到了Dom节点确实有渲染限制,有位大神还晒出了源码 如下图,限制最高16000个节点


image.png


之前只知道 包大小 限制、域名需要配置不然限制、各种授权api限制,没想到 dom节点数量还要限制 居然还有这种骚操作


图片.png


不过想想也算合理,因为小程序 小程序,小才行,哈哈,资源也不可能无限大,为的就是让你在有限的资源内完成强大的功能。


实验是检验真理的唯一标准


上文图中 代码 左上角显示版本是2019年的,那现在都2023年了,会不会有所改进呢,为了搞清楚现在到底限制多少节点,实验一波。


c5d0486839eb2ee8d6711d4985df6b4.png


136f39b2c09b1ff611f4bcb15157f65.png


测试结果如上面两张图 Dom限制数量没变还是 16000个节点,但实际可新增的 渲染节点为 15999个 ,你要是问为啥,那我告诉你,因为小程序默认根标签 page 也占用一个,一个字 牛!!


图片.png


解决方案


既然找了原因,就要想办法解决,经过一番深思熟虑(面向百度思考),总结了以下几个解决方案。


方案一 人在屋檐下不得不低头


毕竟平台是人家的,规则都是人家定的,那么就得按照规则去开发,下图是官方推荐说明(也是评分标准),单页面节点尽量不超过1000个节点,嵌套不超过30层,子节点不超过60个


image.png
动动你灵活嘴皮子,看看能不能说服 产品 说服老板,就这标准 爱做不做,爱谁谁


方案二 就是不服 就是干


长列表 数据过多、dom节点过多 确实有性能问题,但谁让需求是刚需呢,看了下网上的方案 五花八门,有利用swiper 始终只展示固定数量的 swiper-item的 根据当前的 index 显示第几页数据。还有有自己写算法动态显示的。也有很多现成的组件


核心原理 我总结了下就是,只渲染显示在屏幕的数据(为了减少白页面和滑动更丝滑,要再当前屏幕的前后再分别多渲染一屏幕),实现就是监听 scroll 事件,并且重新计算需要渲染的数据,不需要渲染的数据留一个空的 view 空白占位元素。


具体实现方案就不在这里展开了,留下几个已经实现的链接地址


可以扫码体验下腾讯官方新闻小程序的 渲染效率 感觉很哇塞




小程序长列表组件


文章1


文章2


懒人方案 官方 [Skyline 渲染引擎] list-view


注意点: 列表布局容器,仅支持作为 scroll-view 自定义模式下的直接子节点或组件直接子节点


解释下,就是要注意2点


1 scroll-view要设置自定义模式 type="custom"


2 list-view要作为 scroll-view 直接子节点(不然会失去效果)


示例代码如下


    <scroll-view type="custom">
<list-view>
...
循环列表逻辑
...
</list-view>
</scroll-view>

作者:iwhao
来源:juejin.cn/post/7245223225575374905
收起阅读 »

JavaScript变量的奥秘:从声明到使用,一文掌握!

在编程的世界里,数据是构建一切的基础。而在JavaScript中,变量就是存储数据的容器。它们就像是我们生活中的盒子,可以装下各种物品,让我们在需要的时候随时取用。今天,就让我们一起揭开变量的神秘面纱,探索它们的概念、使用规则,以及那些令人头疼的错误。一、变量...
继续阅读 »

在编程的世界里,数据是构建一切的基础。而在JavaScript中,变量就是存储数据的容器。它们就像是我们生活中的盒子,可以装下各种物品,让我们在需要的时候随时取用。

今天,就让我们一起揭开变量的神秘面纱,探索它们的概念、使用规则,以及那些令人头疼的错误。


一、变量的概念和作用

变量,顾名思义,是可以变化的量。在JavaScript中,变量是用来存储数据的,这些数据可以是数字、字符串、对象等等。想象一下,如果没有变量,我们的程序就会变得非常死板,无法灵活地处理和交换信息。

Description

注意: 变量不是数据本身,它们仅仅是一个用于存储数值的容器。可以理解为是一个个用来装东西的纸箱子。


二、变量的基本使用

1)声明变量

要想使用变量,首先需要创建变量(也称为声明变量或者定义变量),JavaScript中通常使用var关键字或者let关键字进行变量的声明操作。

语法:

var age;       //声明一个名为age的变量
let name; //声明一个名为name的变量
  • 声明变量有两部分构成:声明关键字、变量名(标识符)
  • let 即声明关键字,所谓关键字是在JavaScript中有特殊意义的词汇,比如let、var、function、if、else、switch、case、break等。

举例:

let age
  • 我们声明了一个age变量
  • age 即变量的名称,也叫标识符

2) 变量赋值

声明出来后的变量是没有值的,我们需要对声明出来的变量进行赋值操作。

变量赋值的语法为:

var age;       //声明一个名为age的变量
age = 18; //为该个age变量赋值为18

定义了一个变量后,你就能够初始化它(赋值)。在变量名之后跟上一个“=”,然后是数值。

Description

注意: 是通过变量名来获得变量里面的数据。

3)变量初始化

变量初始化就相当于声明变量和变量赋值操作的结合,声明变量并为其初始化。

变量初始化语法为:

var age = 18;   //声明变量age并赋值为18

案例如下:


<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>变量的使用</title>
</head>

<body>
<script>
// 1. 声明一个年龄变量
let age
// 2. 赋值
age = 18
console.log(age)
// 3. 声明的同时直接赋值 变量的初始化
let age2 = 18
// 小案例
let num = 20
let uname = 'pink老师'
console.log(num)
console.log(uname)
</script>
</body>

</html>

4)更新变量

变量赋值后,还可以通过简单地给它一个不同的值来更新它。

Description

注意: let 不允许多次声明一个变量。
案例如下:

<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>变量的使用更新</title>
</head>

<body>
<script>
// 1 声明的同时直接赋值 变量的初始化
// let age = 18
// age = 19
// // let age = 19
// console.log(age)
// 2. 声明多个变量
// let age = 18, uname = '迪丽热巴'
// console.log(age, uname)
</script>
</body>

</html>

5)声明多个变量

语法:多个变量中间用逗号隔开

let age=18,uname='pink'

**说明:**看上去代码长度更短,但并不推荐这样。为了更好的可读性,请一行只声明一个变量。

Description

输入用户名案例:

<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>输入用户名案例</title>
</head>

<body>
<script>
// 输出用户名案例
// 1. 用户输入
// prompt('请输入姓名')
// 2. 内部处理保存数据
let uname = prompt('请输入姓名')
// 3. 打印输出
document.write(uname)
</script>
</body>

</html>

Description


三、let 和var区别

1、var声明的特点:

  • 变量可以先使用再声明(不合理)。

  • var声明过的变量可以重复声明(不合理)。

  • 比如变量提升、全局变量、没有块级作用域等等

2、let 声明的特点:

  • let声明的变量不会被提升,即在声明之前引用let声明的变量系统会直接报错,直接阻断程序的运行。

  • let不可以在同一个作用域下重复声明同一个变量,如果用let重复声明同一个变量,那么这时候就会报错。

  • 用let声明的变量支持块级作用域,在es6提出块级作用域的概念之前,作用域只存在函数里面,或者全局。而es6提出的块级作用域则是一个大括号就是一个块级作用域,该变量只能在块级作用域里使用,否则就会报错。

注意:

var 在现代开发中一般不再使用它,只是我们可能在老版程序中看到它。

let 是为了解决 var 的一些问题而出现的,以后声明变量我们统一使用 let。
案例如下:


<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>let和var的区别</title>
</head>

<body>
<script>
// var可以重复声明,后面声明的覆盖前面的
var num1
var num1 = 10
var num1= 20
console.log(num1)

// let不能重复声明,直接编译不通过
// let num
// let num = 20
// let num = 10
// console.log(num)
</script>
</body>

</html>


四、变量命名规则与规范

规则: 必须遵守,不遵守报错 (法律层面)

  • 不能用关键字(有特殊含义的字符,JavaScript 内置的一些英语词汇,例如:let、var、if、for等)

  • 只能用下划线、字母、数字、$组成,且数字不能开头

  • 字母严格区分大小写,如 Age 和 age 是不同的变量
    **规范:**建议,不遵守不会报错,但不符合业内通识 (道德层面)

  • 起名要有意义

  • 遵守小驼峰命名法:第一个单词首字母小写,后面每个单词首字母大写。例:userName。

Description

案例如下:

<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>变量的命名规范</title>
</head>

<body>
<script>
// let if = 10
let num1$_ = 11
// let nav-bar = 11
// let 1num = 10
//严格区分大小写
let pink = '老师'
let Pink = '演员'
console.log(pink, Pink)
</script>
</body>

</html>


五、Strict(严格)模式

严格模式是一种限制性更强的JavaScript运行环境。在严格模式下,一些不安全或容易出错的行为会被禁止。

  • JavaScript在设计之初,并不强制要求申明变量,如果一个变量没有申明就被使用,那么该变量就自动被声明为全局变量。

  • 在同一个页面的不同的JavaScript文件中,如果都不声明,将造成变量污染。

  • ECMA在后续规范中推出了strict模式,在strict模式下运行的JavaScript代码,强制要求申明变量,否则报错。启用strict模式的方法是在JavaScript代码的第一行写上:

'use strict';

这是一个字符串,不支持strict模式的浏览器会把它当做一个字符串语句执行,支持strict模式的浏览器将开启strict模式运行JavaScript。

想要快速入门前端开发吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!点这里前往学习哦!

如果浏览器不报错,说明你的浏览器太古老了,需要尽快升级。

'use strict';
// 如果浏览器支持strict模式,下面的代码将报ReferenceError错误:
abc = 'Hello, world';
console.log(abc);


六、常量const的概念和使用

有时候,我们希望某些变量的值在程序运行过程中保持不变。这时,可以使用const关键字来声明一个常量。

const是ES6引入的一个新特性,用于声明常量。常量一旦被声明并赋值后,其值就不能被改变。这为我们提供了一种保护机制,确保某些值不会被意外修改。

  • 使用场景:当某个变量永远不会改变的时候,就可以使用 const 来声明,而不是let。

  • 命名规范:和变量一致

  • 注意: 常量不允许重新赋值,声明的时候必须赋值(初始化)

案例如下:

<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>常量</title>
</head>

<body>
<script>
// 1.声明常量,使用常量
const PI = 3.14
console.log(PI)
//不允许更改值
//PI = 3.15
// 2. 常量声明的时候必须赋值
//const G
</script>
</body>

</html>


七、常见错误

1、常量必须要赋值

Description

2、常量被重新赋值

Description

3、变量未定义

Description

分析:

  • 提示 age变量没有定义过。

  • 很可能 age 变量没有声明和赋值。

  • 或者我们输出变量名和声明的变量不一致引起的(简单说写错变量名了)。

4、重复声明变量

Description

分析:

  • 提示 “age”已经声明。

  • 很大概率是因为重复声明了一个变量。

  • 注意let 或者const 不允许多次声明同一个变量。

变量是JavaScript编程的基础,掌握了变量的声明和使用,就能更好地理解和编写代码。希望这篇文章能帮助你更好地理解和使用变量,让你的编程之路更加顺畅。

记住,实践是最好的老师,多写代码,多尝试,你会发现,原来变量的世界,也可以如此精彩!

如果觉得本文对你有所帮助,别忘了点赞和分享哦!

收起阅读 »

润和软件成功举办2023-2024年openEuler技术委员会会议

4月19日,由openEuler社区发起,江苏润和软件股份有限公司(以下简称“润和软件”)承办的2023-2024年openEuler技术委员会会议在南京圆满召开。润和软件董事长兼总裁周红卫代表承办方致辞,openEuler委员会执行总监、开放原子开源基金会T...
继续阅读 »

4月19日,由openEuler社区发起,江苏润和软件股份有限公司(以下简称“润和软件”)承办的2023-2024年openEuler技术委员会会议在南京圆满召开。润和软件董事长兼总裁周红卫代表承办方致辞,openEuler委员会执行总监、开放原子开源基金会TOC副主席熊伟,openEuler技术委员会主席胡欣蔚,润和软件高级副总裁钟毅,润和软件副总裁于萍及多位openEuler技术委员会委员齐聚一堂,围绕openEuler社区技术规划建设及openEuler未来发展方向开展深入研讨。

2023-2024年openEuler技术委员会会议合影

润和软件董事长兼总裁周红卫在致辞中表示,润和软件作为openEuler黄金捐赠人,坚持创新开源技术、推动开源落地、繁荣开源生态,在openEuler社区的贡献一直位于前列,并一直以openEuler的创新价值为全产业赋能。润和软件始终致力于基于openEuler的创新解决方案的产品开发与市场推广,2023年交付重大行业项目的核心业务系统过万套,实现高可靠、高性能运转。2024年,又在操作系统优势基础上提出“All in AI”战略,为客户提供智能化边端的大模型解决方案服务。未来,润和软件将一如既往地深入参与openEuler生态,提供更优的操作系统产品与服务;并与社区协同推动openEuler成为面向全球化的、最具创新力的操作系统开源社区。

润和软件董事长兼总裁周红卫发表致辞

润和软件openEuler业务研发总监张剑就“润和软件openEuler产研进展”做汇报演讲,他分享了润和软件基于openEuler打造的企业级操作系统(HopeOS)。HopeOS聚焦大数据、智能计算场景的服务器操作系统及面向边缘智能场景的嵌入式操作系统提供产品与服务,产品发面,提供包括大数据平台一体化交付、大数据平台原地迁移、基于分布式软总线的openEuler及OpenHarmony协同、基于openEuler的行业发行版定制等综合解决方案;技术服务方面,润和软件为客户提供包括操作系统定制化开发、测试、技术咨询和技术支持等全栈服务,保障关键技术可控、企业级安全可信。

润和软件openEuler业务研发总监张剑进行分享

各参会人员畅所欲言、各抒己见,针对社区重要的版本计划、关键核心特性等内容进行了深入沟通与讨论,群策群力推动openEuler技术创新发展。

2023-2024年openEuler技术委员会会议现场

润和软件凭借在操作系统、大数据、AI等领域的核心技术能力,以应急安全、电网、金融、通信、教育为核心业务场景打造包括基础架构创新方案、基于“openEuler+OpenHarmony”的云边端一体化创新方案等多场景、多维度、一体化、智能化的解决方案。

核心设备适配方面:润和软件先后与宝德、超聚变、浪潮电子等国内最具影响力的服务器生产厂商开展合作。HopeOS也已经取得了包括上述国内顶级服务器提供商在内的数十个服务器厂商的兼容认证,为openEuler在国内各核心行业的业务拓展发挥了重要作用。

核心软件适配方面:润和软件与以达梦数据库、南大通用、人大进仓、瀚高、海量为代表的主流数据库提供商,以及以东方通、保兰德为代表的主流中间件提供商展开合作,并通过产品双向适配及双向产品认证。

操作系统基础架构替换改造及技术创新优化方面:润和软件先后与各主要行业头部核心客户展开合作,在多个垂直赛道取得丰硕成果。并已完成操作系统替换部署数万套,系统运行稳定可靠。

作为openEuler社区核心共建单位和头部企业,润和软件积极参与、加速构建全球开源新生态、开创中国开源新模式。2024润和软件携手openEuler社区拓展海外业务,共同推动openEuler走向全球化。润和软件openEuler业务亮相海外,参与近日在越南河内举行的国际开源盛会FOSSASIASummit 2024,发表精彩演讲并展示创新技术成果,为来自东南亚的金融、互联网等行业用户分享openEuler关于开源、创新和协作的最新动态。

润和软件亮相FOSSASIA Summit 2024展示openEuler创新方案

未来,润和软件将持续深耕优势行业,结合自身在数据要素、人工智能等技术领域的核心优势,打造深度融合行业属性的应用级解决方案,释放科技开源所带来的澎湃创新力量,助力千行百业发展新质生产力。

收起阅读 »

如何仿一个抖音极速版领现金的进度条动画?

效果演示 不仅仅是实现效果,要封装,就封装好 看完了演示的效果,你是否在思考,代码应该怎么实现?先不着急写代码,先想想哪些地方是要可以动态配置的。首先第一个,进度条的形状是不是要可以换?然后进度条的背景色和填充的颜色,以及动画的时长是不是也要可以配置?没错,...
继续阅读 »

效果演示


20230617_064552_edit.gif


不仅仅是实现效果,要封装,就封装好


看完了演示的效果,你是否在思考,代码应该怎么实现?先不着急写代码,先想想哪些地方是要可以动态配置的。首先第一个,进度条的形状是不是要可以换?然后进度条的背景色和填充的颜色,以及动画的时长是不是也要可以配置?没错,起始位置是不是也要可以换?最好还要让速度可以一会快一会慢对吧,画笔的笔帽是不是还可以选择平的或圆的?带着这些问题,我们再开始写代码。


代码实现


我们写一个自定义View,把可以动态配置的地方想好后,就可以定义自定义属性了。


<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="DoraProgressView">
<attr name="dview_progressType">
<enum name="line" value="0"/>
<enum name="semicircle" value="1"/>
<enum name="semicircleReverse" value="2"/>
<enum name="circle" value="3"/>
<enum name="circleReverse" value="4"/>
</attr>
<attr name="dview_progressOrigin">
<enum name="left" value="0"/>
<enum name="top" value="1"/>
<enum name="right" value="2"/>
<enum name="bottom" value="3"/>
</attr>
<attr format="dimension|reference" name="dview_progressWidth"/>
<attr format="color|reference" name="dview_progressBgColor"/>
<attr format="color|reference" name="dview_progressHoverColor"/>
<attr format="integer" name="dview_animationTime"/>
<attr name="dview_paintCap">
<enum name="flat" value="0"/>
<enum name="round" value="1"/>
</attr>
</declare-styleable>
</resources>

然后我们不管三七二十一,先把自定义属性解析出来。


private fun initAttrs(context: Context, attrs: AttributeSet?, defStyleAttr: Int) {
val a = context.obtainStyledAttributes(
attrs,
R.styleable.DoraProgressView,
defStyleAttr,
0
)
when (a.getInt(R.styleable.DoraProgressView_dview_progressType, PROGRESS_TYPE_LINE)) {
0 -> progressType = PROGRESS_TYPE_LINE
1 -> progressType = PROGRESS_TYPE_SEMICIRCLE
2 -> progressType = PROGRESS_TYPE_SEMICIRCLE_REVERSE
3 -> progressType = PROGRESS_TYPE_CIRCLE
4 -> progressType = PROGRESS_TYPE_CIRCLE_REVERSE
}
when (a.getInt(R.styleable.DoraProgressView_dview_progressOrigin, PROGRESS_ORIGIN_LEFT)) {
0 -> progressOrigin = PROGRESS_ORIGIN_LEFT
1 -> progressOrigin = PROGRESS_ORIGIN_TOP
2 -> progressOrigin = PROGRESS_ORIGIN_RIGHT
3 -> progressOrigin = PROGRESS_ORIGIN_BOTTOM
}
when(a.getInt(R.styleable.DoraProgressView_dview_paintCap, 0)) {
0 -> paintCap = Paint.Cap.SQUARE
1 -> paintCap = Paint.Cap.ROUND
}
progressWidth = a.getDimension(R.styleable.DoraProgressView_dview_progressWidth, 30f)
progressBgColor =
a.getColor(R.styleable.DoraProgressView_dview_progressBgColor, Color.GRAY)
progressHoverColor =
a.getColor(R.styleable.DoraProgressView_dview_progressHoverColor, Color.BLUE)
animationTime = a.getInt(R.styleable.DoraProgressView_dview_animationTime, 1000)
a.recycle()
}

解析完自定义属性,切勿忘了释放TypedArray。接下来我们考虑下一步,测量。半圆是不是不要那么大的画板对吧,我们在测量的时候就要充分考虑进去。


override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
progressBgPaint.strokeWidth = progressWidth
progressHoverPaint.strokeWidth = progressWidth
if (progressType == PROGRESS_TYPE_LINE) {
// 线
var left = 0f
var top = 0f
var right = measuredWidth.toFloat()
var bottom = measuredHeight.toFloat()
val isHorizontal = when(progressOrigin) {
PROGRESS_ORIGIN_LEFT, PROGRESS_ORIGIN_RIGHT -> true
else -> false
}
if (isHorizontal) {
top = (measuredHeight - progressWidth) / 2
bottom = (measuredHeight + progressWidth) / 2
progressBgRect[left + progressWidth / 2, top, right - progressWidth / 2] = bottom
} else {
left = (measuredWidth - progressWidth) / 2
right = (measuredWidth + progressWidth) / 2
progressBgRect[left, top + progressWidth / 2, right] = bottom - progressWidth / 2
}
} else if (progressType == PROGRESS_TYPE_CIRCLE || progressType == PROGRESS_TYPE_CIRCLE_REVERSE) {
// 圆
var left = 0f
val top = 0f
var right = measuredWidth
var bottom = measuredHeight
progressBgRect[left + progressWidth / 2, top + progressWidth / 2, right - progressWidth / 2] =
bottom - progressWidth / 2
} else {
// 半圆
val isHorizontal = when(progressOrigin) {
PROGRESS_ORIGIN_LEFT, PROGRESS_ORIGIN_RIGHT -> true
else -> false
}
val min = measuredWidth.coerceAtMost(measuredHeight)
var left = 0f
var top = 0f
var right = 0f
var bottom = 0f
if (isHorizontal) {
if (measuredWidth >= min) {
left = ((measuredWidth - min) / 2).toFloat()
right = left + min
}
if (measuredHeight >= min) {
bottom = top + min
}
progressBgRect[left + progressWidth / 2, top + progressWidth / 2, right - progressWidth / 2] =
bottom - progressWidth / 2
setMeasuredDimension(
MeasureSpec.makeMeasureSpec(
(right - left).toInt(),
MeasureSpec.EXACTLY
),
MeasureSpec.makeMeasureSpec(
(bottom - top + progressWidth).toInt() / 2,
MeasureSpec.EXACTLY
)
)
} else {
if (measuredWidth >= min) {
right = left + min
}
if (measuredHeight >= min) {
top = ((measuredHeight - min) / 2).toFloat()
bottom = top + min
}
progressBgRect[left + progressWidth / 2, top + progressWidth / 2, right - progressWidth / 2] =
bottom - progressWidth / 2
setMeasuredDimension(
MeasureSpec.makeMeasureSpec(
(right - left + progressWidth).toInt() / 2,
MeasureSpec.EXACTLY
),
MeasureSpec.makeMeasureSpec(
(bottom - top).toInt(),
MeasureSpec.EXACTLY
)
)
}
}
}

View的onMeasure()方法是不是默认调用了一个


super.onMeasure(widthMeasureSpec, heightMeasureSpec)

它最终会调用setMeasuredDimension()方法来确定最终测量的结果吧。如果我们对默认的测量不满意,我们可以自己改,最后也调用setMeasuredDimension()方法把测量结果确认。半圆,如果是水平的情况下,我们的宽度就只要一半,相反如果是垂直的半圆,我们高度就只要一半。最后我们画还是照常画,只不过在最后把画到外面的部分移动到画板上显示出来。接下来就是我们最重要的绘图环节了。


override fun onDraw(canvas: Canvas) {
if (progressType == PROGRESS_TYPE_LINE) {
val isHorizontal = when(progressOrigin) {
PROGRESS_ORIGIN_LEFT, PROGRESS_ORIGIN_RIGHT -> true
else -> false
}
if (isHorizontal) {
canvas.drawLine(
progressBgRect.left,
measuredHeight / 2f,
progressBgRect.right,
measuredHeight / 2f,
progressBgPaint)
} else {
canvas.drawLine(measuredWidth / 2f,
progressBgRect.top,
measuredWidth / 2f,
progressBgRect.bottom, progressBgPaint)
}
if (percentRate > 0) {
when (progressOrigin) {
PROGRESS_ORIGIN_LEFT -> {
canvas.drawLine(
progressBgRect.left,
measuredHeight / 2f,
(progressBgRect.right) * percentRate,
measuredHeight / 2f,
progressHoverPaint
)
}
PROGRESS_ORIGIN_TOP -> {
canvas.drawLine(measuredWidth / 2f,
progressBgRect.top,
measuredWidth / 2f,
(progressBgRect.bottom) * percentRate,
progressHoverPaint)
}
PROGRESS_ORIGIN_RIGHT -> {
canvas.drawLine(
progressWidth / 2 + (progressBgRect.right) * (1 - percentRate),
measuredHeight / 2f,
progressBgRect.right,
measuredHeight / 2f,
progressHoverPaint
)
}
PROGRESS_ORIGIN_BOTTOM -> {
canvas.drawLine(measuredWidth / 2f,
progressWidth / 2 + (progressBgRect.bottom) * (1 - percentRate),
measuredWidth / 2f,
progressBgRect.bottom,
progressHoverPaint)
}
}
}
} else if (progressType == PROGRESS_TYPE_SEMICIRCLE) {
if (progressOrigin == PROGRESS_ORIGIN_LEFT) {
// PI ~ 2PI
canvas.drawArc(progressBgRect, 180f, 180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
180f,
angle.toFloat(),
false,
progressHoverPaint
)
} else if (progressOrigin == PROGRESS_ORIGIN_TOP) {
canvas.translate(-progressBgRect.width() / 2, 0f)
// 3/2PI ~ 2PI, 0 ~ PI/2
canvas.drawArc(progressBgRect, 270f, 180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
270f,
angle.toFloat(),
false,
progressHoverPaint
)
} else if (progressOrigin == PROGRESS_ORIGIN_RIGHT) {
canvas.translate(0f, -progressBgRect.height() / 2)
// 2PI ~ PI
canvas.drawArc(progressBgRect, 0f, 180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
0f,
angle.toFloat(),
false,
progressHoverPaint
)
} else if (progressOrigin == PROGRESS_ORIGIN_BOTTOM) {
// PI/2 ~ 3/2PI
canvas.drawArc(progressBgRect, 90f, 180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
90f,
angle.toFloat(),
false,
progressHoverPaint
)
}
} else if (progressType == PROGRESS_TYPE_SEMICIRCLE_REVERSE) {
if (progressOrigin == PROGRESS_ORIGIN_LEFT) {
canvas.translate(0f, -progressBgRect.height() / 2)
// PI ~ 2PI
canvas.drawArc(progressBgRect, 180f, -180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
180f,
-angle.toFloat(),
false,
progressHoverPaint
)
} else if (progressOrigin == PROGRESS_ORIGIN_TOP) {
// 3/2PI ~ PI/2
canvas.drawArc(progressBgRect, 270f, -180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
270f,
-angle.toFloat(),
false,
progressHoverPaint
)
} else if (progressOrigin == PROGRESS_ORIGIN_RIGHT) {
// 2PI ~ PI
canvas.drawArc(progressBgRect, 0f, -180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
0f,
-angle.toFloat(),
false,
progressHoverPaint
)
} else if (progressOrigin == PROGRESS_ORIGIN_BOTTOM) {
canvas.translate(-progressBgRect.width() / 2, 0f)
// PI/2 ~ 2PI, 2PI ~ 3/2PI
canvas.drawArc(progressBgRect, 90f, -180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
90f,
-angle.toFloat(),
false,
progressHoverPaint
)
}
} else if (progressType == PROGRESS_TYPE_CIRCLE) {
val deltaAngle = if (progressOrigin == PROGRESS_ORIGIN_TOP) {
90f
} else if (progressOrigin == PROGRESS_ORIGIN_RIGHT) {
180f
} else if (progressOrigin == PROGRESS_ORIGIN_BOTTOM) {
270f
} else {
0f
}
canvas.drawArc(progressBgRect, 0f, 360f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
180f + deltaAngle,
angle.toFloat(),
false,
progressHoverPaint
)
} else if (progressType == PROGRESS_TYPE_CIRCLE_REVERSE) {
val deltaAngle = if (progressOrigin == PROGRESS_ORIGIN_TOP) {
90f
} else if (progressOrigin == PROGRESS_ORIGIN_RIGHT) {
180f
} else if (progressOrigin == PROGRESS_ORIGIN_BOTTOM) {
270f
} else {
0f
}
canvas.drawArc(progressBgRect, 0f, 360f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
180f + deltaAngle,
-angle.toFloat(),
false,
progressHoverPaint
)
}
}

绘图除了需要Android的基础绘图知识外,还需要一定的数学计算的功底,比如基本的几何图形的点的计算你要清楚。怎么让绘制的角度变化起来呢?这个问题问的好。这个就牵扯出我们动画的一个关键类,TypeEvaluator,这个接口可以让我们只需要指定边界值,就可以根据动画执行的时长,来动态计算出当前的渐变值。


private inner class AnimationEvaluator : TypeEvaluator<Float> {
override fun evaluate(fraction: Float, startValue: Float, endValue: Float): Float {
return if (endValue > startValue) {
startValue + fraction * (endValue - startValue)
} else {
startValue - fraction * (startValue - endValue)
}
}
}

百分比渐变的固定写法,是不是应该记个笔记,方便以后CP?那么现在我们条件都成熟了,只需要将初始角度的百分比改变一下,我们写一个改变角度百分比的方法。


fun setPercentRate(rate: Float) {
if (animator == null) {
animator = ValueAnimator.ofObject(
AnimationEvaluator(),
percentRate,
rate
)
}
animator?.addUpdateListener { animation: ValueAnimator ->
val value = animation.animatedValue as Float
angle =
if (progressType == PROGRESS_TYPE_CIRCLE || progressType == PROGRESS_TYPE_CIRCLE_REVERSE) {
(value * 360).toInt()
} else if (progressType == PROGRESS_TYPE_SEMICIRCLE || progressType == PROGRESS_TYPE_SEMICIRCLE_REVERSE) {
(value * 180).toInt()
} else {
0 // 线不需要求角度
}
percentRate = value
invalidate()
}
animator?.interpolator = LinearInterpolator()
animator?.setDuration(animationTime.toLong())?.start()
animator?.addListener(object : Animator.AnimatorListener {
override fun onAnimationStart(animation: Animator) {}
override fun onAnimationEnd(animation: Animator) {
percentRate = rate
listener?.onComplete()
}

override fun onAnimationCancel(animation: Animator) {}
override fun onAnimationRepeat(animation: Animator) {}
})
}

这里牵扯到了Animator。有start就一定不要忘了异常中断的情况,我们可以写一个reset的方法来中断动画执行,恢复到初始状态。


fun reset() {
percentRate = 0f
animator?.cancel()
}

如果你不reset,想连续执行动画,则两次调用的时间间隔一定要大于动画时长,否则就应该先取消动画。


涉及到的Android绘图知识点


我们归纳一下完成这个自定义View需要具备的知识点。



  1. 基本图形的绘制,这里主要是扇形

  2. 测量和画板的平移变换

  3. 自定义属性的定义和解析

  4. Animator和动画估值器TypeEvaluator的使用


思路和灵感来自于系统化的基础知识


这个控件其实并不难,主要就是动态配置一些参数,然后在计算上稍微复杂一些,需要一些数学的功底。那么你为什么没有思路呢?你没有思路最可能的原因主要有以下几个可能。



  1. 自定义View的基础绘图API不熟悉

  2. 动画估值器使用不熟悉

  3. 对自定义View的基本流程不熟悉

  4. 看的自定义View的源码不够多

  5. 自定义View基础知识没有系统学习,导致是一些零零碎碎的知识片段

  6. 数学功底不扎实


我觉得往往不是你不会,这些基础知识点你可能都看到过很多次,但是一到自己写就没有思路了。思路和灵感来自于大量源码的阅读和大量的实践。大前提就是你得先把自定义View的这些知识点系统学习一下,先保证都见过,然后才是将它们融会贯通,用的时候信手拈来。


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

按Home键时SingleInstance Activity销毁了???

前段时间,突然有朋友询问,自己写的SingleInstance Activity在按home键的时候被销毁了,刚听到这个问题的时候,我直觉怀疑是Activity在onPause或者onStop中发生了Crash导致闪退了,但是安装apk查看现象,没有发现异常日...
继续阅读 »

前段时间,突然有朋友询问,自己写的SingleInstance Activity在按home键的时候被销毁了,刚听到这个问题的时候,我直觉怀疑是Activity在onPause或者onStop中发生了Crash导致闪退了,但是安装apk查看现象,没有发现异常日志,这究竟是怎么回事呢?编写测试Demo来详细探索下


Demo代码说明


Demo日志很简单,包含MainActivity和SingleInstanceActivity两个页面,在MainActivity中的TextView点击事件中启动SingleInstanceActivity,在SingleInstanceActivity中的TextView点击事件中调用moveTaskToBack(true)切回后台,随后在MainActivity界面按Home键返回桌面,就可以看到SingleInstanceActivity被销毁了,示例代码如下所示:


 // MainActivity.kt
 class MainActivity : ComponentActivity() {
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         setContent {
             MyApplicationTheme {
                 // A surface container using the 'background' color from the theme
                 Surface(
                     modifier = Modifier
                        .fillMaxSize()
                        .clickable { onBtnClick() },
                     color = MaterialTheme.colorScheme.background
                ) {
                     Greeting("Android")
                }
            }
        }
    }
     fun onBtnClick() {
          startActivity(Intent(this,                                  SingleInstanceActivity::class.java))
    }
 }
 ​
 @Composable
 fun Greeting(name: String, modifier: Modifier = Modifier) {
     Text(
         text = "Hello $name!",
         modifier = modifier
    )
 }
 ​
 @Preview(showBackground = true)
 @Composable
 fun GreetingPreview() {
     MyApplicationTheme {
         Greeting("Android")
    }
 }

 // SingleInstanceActivity.java
 public class SingleInstanceActivity extends ComponentActivity {
     private static final String TAG = "SingleInstanceActivity";
   @Override
   protected void onCreate(Bundle savedInstanceState) {
     Log.d(TAG,"SingleInstanceActivity onCreate method called",new Exception());
     super.onCreate(savedInstanceState);
     setContentView(R.layout.activity_single_instance);
     findViewById(R.id.move_back).setOnClickListener(new View.OnClickListener() {
         @Override
         public void onClick(View v) {
             moveTaskToBack(true);
        }
    });
  }
 ​
   @Override
   protected void onDestroy() {
     Log.d(TAG,"SingleInstanceActivity onDestroy method called",new Exception());
     super.onDestroy();
  }
 }

 <!-- AndroidManifest.xml文件中application节点的内容-->
 <application
     android:allowBackup="true"
     android:dataExtractionRules="@xml/data_extraction_rules"
     android:fullBackupContent="@xml/backup_rules"
     android:icon="@mipmap/ic_launcher"
     android:label="@string/app_name"
     android:roundIcon="@mipmap/ic_launcher_round"
     android:supportsRtl="true"
     android:theme="@style/Theme.MyApplication"
     tools:targetApi="31">
     <activity
         android:name=".SingleInstanceActivity"
         android:launchMode="singleInstance"
         android:exported="true" />
     <activity
         android:name=".MainActivity"
         android:exported="true"
         android:label="@string/app_name"
         android:theme="@style/Theme.MyApplication">
         <intent-filter>
             <action android:name="android.intent.action.MAIN" />
 ​
             <category android:name="android.intent.category.LAUNCHER" />
         </intent-filter>
     </activity>
 </application>

调用栈回溯


即然SingleInstanceActivity被销毁了,那么我们只需要在Activity生命周期中添加日志,来看下onDestroyed函数是怎么驱动调用的即可,从Activity生命周期可知,在Framework中框架通过ClientLifecycleManager类来管理Activity的生命周期变化,在该类的scheduleTransaction函数中,Activity的每一种生命周期类型均被包装成一个ClientTransaction来处理,在该函数中添加日志,打印调用栈,即可确定是那个地方销毁了SingleInstanceActivity,添加日志的代码如下:


24-2-3


随后编译framework.jar并push到设备上,查看日志,可以看到SingleInstanceActivity是在Task类的removeActivities方法中被销毁的,日志如下:


24-2-4


按照如上的思路,逐步类推,添加日志,查看调用栈,我们最终追溯到ActivityThread的handleResumeActivity,在该函数的最后,添加的IdlerHandler里面会执行RecentTasks的onActivityIdle方法,在该函数的调用流程里,会判断当前resume的Activity是不是桌面,是的话在HiddenTask不为空的情况下,就会执行removeUnreachableHiddenTasks的逻辑,销毁SingleInstanceActivity(这里的代码分支为android-13.0.0_r31)。


完整的正向调用流程如下图所示:


SingleInstance Task release process 1


remove-hidden-task机制


前文中我们已经跟踪到Activity销毁的调用流程,那么为什么要销毁SingleInstanceActivity呢?我们继续看前文中的日志,可以看出Activity销毁的原因是:remove-hidden-task。


24-2-6


那么这个remove-hidden-task到底是用来干嘛的呢?我们来看下代码提交信息:


24-2-1


从代码提交说明不难看出,这里的意思是:当我们向最近任务列表中添加一个任务时,会移除已不可达/未激活的Task,这里我们的SingleInstanceActivity所在的Task被判定为不可达/未激活状态,所以被这套机制移除了。


不可达/未激活的Task


那么为什么SingleInstanceActivity被认为是不可达的呢?我们进一步追踪代码,可以看到RencentTasks.removeUnreachableHiddenTasks移除的是mHiddenTasks中的任务,代码如下:


24-2-7


这样我们就只需要搞清楚什么样的Task会被加入mHiddenTasks中即可,mHiddenTasks.add的调用代码如下所示:


24-2-8


24-2-9


从上述代码可知,在removeForAddTask中通过findRemoveIndexForAddTask来查找当给定Task添加到最近任务列表时,需要被移除的Task,在findRemoveIndexForAddTask中最典型的一种场景就是当两个Task的TaskAffinity相同时,当后来的Task被添加到最近任务列表时,前一个Task会被销毁,这也就意味着在SingleInstanceActivity按Home键,MainActivity也会被销毁,经过实践,确实是这样。


解决方案


前文中已探讨了remove-hidden-task的运行机制,那么解决方案也就很简单了,给SingleInstanceActivity添加独立的TaskAffinity即可(注意:此时SingleInstanceActivity会显示在最近任务中,如果不想显示,请指定android:excludeFromRecents="true")。


影响范围


经排查,Google Pixel记性从Android 12开始支持该特性,针对国内定制厂商而言,大多数应该是在Android 13跟进的,大家可以测试看看。


作者:小海编码日记
来源:juejin.cn/post/7259311837463724069
收起阅读 »

Redis stream 用做消息队列完美吗 ?

Redis Stream 是 Redis 5.0 版本中引入的一种新的数据结构,它用于实现简单但功能强大的消息传递模式。 这篇文章,我们聊聊 Redis Stream 基本用法 ,以及如何在 SpringBoot 项目中应用 Redis Stream 。 1...
继续阅读 »

Redis Stream 是 Redis 5.0 版本中引入的一种新的数据结构,它用于实现简单但功能强大的消息传递模式。


这篇文章,我们聊聊 Redis Stream 基本用法 ,以及如何在 SpringBoot 项目中应用 Redis Stream 。



1 基础知识


Redis Stream 的结构如下图所示,它是一个消息链表,将所有加入的消息都串起来,每个消息都有一个唯一的 ID 和对应的内容。



每个 Redis Stream 都有唯一的名称 ,对应唯一的 Redis Key 。


同一个 Stream 可以挂载多个消费组 ConsumerGr0up , 消费组不能自动创建,需要使用 XGR0UP CREATE 命令创建


每个消费组会有个游标 last_delivered_id,任意一个消费者读取了消息都会使游标 last_delivered_id 往前移动 ,标识当前消费组消费到哪条消息了。


消费组 ConsumerGr0up 同样可以挂载多个消费者 Consumer , 每个 Consumer 并行的读取消息,任意一个消费者读取了消息都会使游标 last_delivered_id 往前移动。


消费者内部有一个属性 pending_ids , 记录了当前消费者读取但没有回复 ACK 的消息 ID 列表 。


2 核心命令


01 XADD 向 Stream 末尾添加消息


使用 XADD 向队列添加消息,如果指定的队列不存在,则创建一个队列。基础语法格式:


XADD key ID field value [field value ...]


  • key :队列名称,如果不存在就创建

  • ID :消息 id,我们使用 * 表示由 redis 生成,可以自定义,但是要自己保证递增性。

  • field value : 记录。


127.0.0.1:6379> XADD mystream * name1 value1 name2 value2
"1712473185388-0"
127.0.0.1:6379> XLEN mystream
(integer) 1
127.0.0.1:6379> XADD mystream * name2 value2 name3 value3
"1712473231761-0"

消息 ID 使用 * 表示由 redis 生成,同时也可以自定义,但是自定义时要保证递增性。



消息 ID 的格式: 毫秒级时间戳 + 序号 , 例如:1712473185388-5 , 它表示当前消息在毫秒时间戳 1712473185388 产生 ,并且该毫秒内产生到了第5条消息。



在添加队列消息时,也可以指定队列的长度


127.0.0.1:6379> XADD mystream MAXLEN 100 * name value1 age 30
"1713082205042-0"

使用 XADD 命令向 mystream 的 stream 中添加了一条消息,并且指定了最大长度为 100。消息的 ID 由 Redis 自动生成,消息包含两个字段 nameage,分别对应的值是 value130


02 XRANGE 获取消息列表


使用 XRANGE 获取消息列表,会自动过滤已经删除的消息。语法格式:


XRANGE key start end [COUNT count]


  • key :队列名

  • start :开始值, - 表示最小值

  • end :结束值, + 表示最大值

  • count :数量


127.0.0.1:6379> XRANGE mystream - + COUNT 2
1) 1) "1712473185388-0"
  2) 1) "name1"
     2) "value1"
     3) "name2"
     4) "value2"
2) 1) "1712473231761-0"
  2) 1) "name2"
     2) "value2"
     3) "name3"
     4) "value3"

我们得到两条消息,第一层是消息 ID ,第二层是消息内容 ,消息内容是 Hash 数据结构 。


03 XREAD 以阻塞/非阻塞方式获取消息列表


使用 XREAD 以阻塞或非阻塞方式获取消息列表 ,语法格式:


XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] id [id ...]


  • count :数量

  • milliseconds :可选,阻塞毫秒数,没有设置就是非阻塞模式

  • key :队列名

  • id :消息 ID


127.0.0.1:6379> XREAD streams mystream 0-0
1) 1) "mystream"
  2) 1) 1) "1712473185388-0"
        2) 1) "name1"
           2) "value1"
           3) "name2"
           4) "value2"
     2) 1) "1712473231761-0"
        2) 1) "name2"
           2) "value2"
           3) "name3"
           4) "value3"

XRED 读消息时分为阻塞非阻塞模式,使用 BLOCK 选项可以表示阻塞模式,需要设置阻塞时长。非阻塞模式下,读取完毕(即使没有任何消息)立即返回,而在阻塞模式下,若读取不到内容,则阻塞等待。


127.0.0.1:6379> XREAD block 1000 streams mystream $
(nil)
(1.07s)

使用 Block 模式,配合 $ 作为 ID ,表示读取最新的消息,若没有消息,命令阻塞!等待过程中,其他客户端向队列追加消息,则会立即读取到。


因此,典型的队列就是 XADD 配合 XREAD Block 完成。XADD 负责生成消息,XREAD 负责消费消息。


04 XGR0UP CREATE 创建消费者组


使用 XGR0UP CREATE 创建消费者组,分两种情况:



  • 从头开始消费:


XGR0UP CREATE mystream consumer-group-name 0-0  


  • 从尾部开始消费:


XGR0UP CREATE mystream consumer-group-name $

执行效果如下:


127.0.0.1:6379> XGR0UP CREATE mystream mygroup 0-0
OK

05 XREADGR0UP GR0UP 读取消费组中的消息


使用 XREADGR0UP GR0UP 读取消费组中的消息,语法格式:


XREADGR0UP GR0UP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...]


  • group :消费组名

  • consumer :消费者名。

  • count : 读取数量。

  • milliseconds : 阻塞毫秒数。

  • key : 队列名。

  • ID : 消息 ID。


示例:


127.0.0.1:6379>  XREADGR0UP group mygroup consumerA count 1 streams mystream >
1) 1) "mystream"
  2) 1) 1) "1712473185388-0"
        2) 1) "name1"
           2) "value1"
           3) "name2"
           4) "value2"

消费者组 mygroup 中的消费者 consumerA ,从 名为 mystream 的 Stream 中读取消息。



  • COUNT 1 表示一次最多读取一条消息

  • > 表示消息的起始位置是当前可用消息的 ID,即从当前未读取的最早消息开始读取。


06 XACK 消息消费确认


接收到消息之后,我们要手动确认一下(ack),语法格式:


xack key group-key ID [ID ...]

示例:


127.0.0.1:6379> XACK mystream mygroup 1713089061658-0
(integer) 1

消费确认增加了消息的可靠性,一般在业务处理完成之后,需要执行 ack 确认消息已经被消费完成,整个流程的执行如下图所示:



我们可以使用 xpending 命令查看消费者未确认的消息ID


127.0.0.1:6379> xpending mystream mygroup
1) (integer) 1
2) "1713091227595-0"
3) "1713091227595-0"
4) 1) 1) "consumerA"
     2) "1"

07 XTRIM 限制 Stream 长度


我们使用 XTRIM 对流进行修剪,限制长度, 语法格式:


127.0.0.1:6379> XADD mystream * field1 A field2 B field3 C field4 D
"1712535017402-0"
127.0.0.1:6379> XTRIM mystream MAXLEN 2
(integer) 4
127.0.0.1:6379> XRANGE mystream - +
1) 1) "1712498239430-0"
  2) 1) "name"
     2) "zhangyogn"
2) 1) "1712535017402-0"
  2) 1) "field1"
     2) "A"
     3) "field2"
     4) "B"
     5) "field3"
     6) "C"
     7) "field4"
     8) "D"

3 SpringBoot Redis Stream 实战


1、添加 SpringBoot Redis 依赖


<dependency>
   <groupId>org.springframework.bootgroupId>
   <artifactId>spring-boot-starter-data-redisartifactId>
dependency>

2、yaml 文件配置



3、RedisTemplate 配置



4、定义stream监听器



5、定义streamcontainer 并启动



6、发送消息



执行完成之后,消费者就可以打印如下日志:



演示代码地址:



github.com/makemyownli…



4 Redis stream 用做消息队列完美吗


笔者认为 Redis stream 用于消息队列最大的进步在于:实现了发布订阅模型


发布订阅模型具有如下特点:



  • 消费独立


    相比队列模型的匿名消费方式,发布订阅模型中消费方都会具备的身份,一般叫做订阅组(订阅关系),不同订阅组之间相互独立不会相互影响。


  • 一对多通信


    基于独立身份的设计,同一个主题内的消息可以被多个订阅组处理,每个订阅组都可以拿到全量消息。因此发布订阅模型可以实现一对多通信。



细品 Redis stream 的设计,我们发现它和 Kafka 非常相似,比如说消费者组,消费进度偏移量等。


我们曾经诟病 Redis List 数据结构用做队列时,因为消费时没有 Ack 机制,应用异常挂掉导致消息偶发丢失的情况,Redis Stream 已经完美的解决了。


因为消费者内部有一个属性 pending_ids , 记录了当前消费者读取但没有回复 ACK 的消息 ID 列表 。当消费者重新上线,这些消息可以重新被消费。


但 Redis stream 用做消息队列完美吗 ?


这个真没有!


1、Redis 本身定位是内存数据库,它的设计之初都是为缓存准备的,并不具备消息堆积的能力。而专业消息队列一个非常重要的功能是数据中转枢纽,Redis 的定位很难满足,所以使用起来要非常小心。


2、Redis 的高可用方案可能丢失消息(AOF 持久化 和 主从复制都是异步 ),而专业消息队列可以针对不同的场景选择不同的高可用策略。


所以,笔者认为 Redis 非常适合轻量级消息队列解决方案,轻量级意味着:数据量可控 + 业务模型简单 。




参考文章:



redis.io/docs/data-t…


http://www.runoob.com/redis/redis…


pdai.tech/md/db/nosql…





作者:勇哥Java实战
来源:juejin.cn/post/7357301805569687563
收起阅读 »

争论不休的一个话题:金额到底是用Long还是BigDecimal?

在网上一直流传着一个争论不休的话题:金额到底是用Long还是用BigDecimal?这个话题一出在哪都会引起异常无比激烈的讨论。。。。 比如说这个观点:算钱用BigDecimal是常识 有支持用Long的,将金额的单位设计为分,然后乘以100,使用Long...
继续阅读 »

在网上一直流传着一个争论不休的话题:金额到底是用Long还是用BigDecimal?这个话题一出在哪都会引起异常无比激烈的讨论。。。。 比如说这个观点:算钱用BigDecimal是常识


image.png


image.png


有支持用Long的,将金额的单位设计为分,然后乘以100,使用Long进行存储以及计算,这样不用担心小数点问题。


image.png


并且一些银行系统就会选择用Long


image.png


还有,最最最牛逼的万能大法:用String


image.png


成年人不做选择题,Long跟BigDecimal都用。。。


image.png


image.png


还有一种就是封装一个金额的基类,对金额进行统一处理。


image.png


排除float和double


当然,对于金额,首先我们要排除的就是float和double。它们不适合用于精确的金融计算,因为floatdouble是基于IEEE 754标准的浮点数表示,它们无法精确地表示所有的十进制小数。这会导致在进行财务计算时出现舍入误差,这些误差可能会累积并导致不可预测的结果。



关于带精度的计算,我们不推荐使用float以及double,推荐使用BigDecimal,具体原因请参考:聊一聊BigDecimal使用时的陷阱



选择Long


Long类型在Java中用于存储64位整数。它的主要优点是速度快,因为整数运算在CPU层面是非常高效的。另外,Long类型也占用较少的内存,并且整数类型(BIGINT)在数据库中占用较少的存储空间。


但是Long类型在处理金额时有几个明显的缺点:



  1. 1. 精度问题Long只能存储整数,无法直接表示小数。使用Long来表示以分为单位的金额(例如,100表示1元),此时就会失去小数的精度。即使使用某种方式来表示小数(例如,乘以100或10000),也会遇到舍入误差的问题。并且这种计算方式也会增加计算的复杂度。

  2. 2. 浮点数问题:虽然这不是直接使用Long的问题,但如果你尝试将Long与浮点数(如doublefloat)进行转换以进行计算(比如汇率计算等),还是会遇到浮点数精度问题,这可能导致在财务计算中出现不可接受的误差。


在阿里巴巴的开发手册中建议使用Long。


image.png


但是在一些金融系统当中,对小数位要求比较高的,比如精确到小数点后6位,那么我们使用Long进行存储,每次在计算时都要除以或者乘以1000000,那么计算的开销就很大了。


并且,如果在需求确认时,我们无法知道金额要求的小数位,那我们使用Long也是不行的,我们并不知道需要乘以或者除以多少个0。


选择BigDecimal


BigDecimal是Java提供的一个类,用于任意精度的算术运算。它的主要优点是提供了高精度的计算,这对于金融和货币计算来说是非常重要的。BigDecimal可以表示任意大小的正数、负数或零,并可以精确控制舍入行为。并且在数据库中存储时也有对应的类型进行匹配,比如MySQL的DECIMAL类型提供了精确的数值存储,可以匹配BigDecimal的精度。


但是BigDecimal也有一些缺点:



  1. 1. 性能:与Long相比,BigDecimal的性能较差。因为它的运算需要更多的内存和CPU时间。

  2. 2. 复杂性:使用BigDecimal进行运算比使用Long或基本数据类型更复杂。你需要考虑舍入模式、精度等因素。

  3. 3. 在数据库中需要更多的存储空间来存储小数部分。


而在Mysql的开发手册中,建议金额需要进行小数位计算时,存储要使用Decimal,否则我们要将金额乘以对应小数位的倍数变成BIGINT进行存储。


image.png


总结


基于上述对LongBigDecimal的优缺点分析,我们可以得出以下结论:


在金额计算层面,即代码实现中,推荐使用BigDecimal进行所有与金额相关的计算。BigDecimal提供了高精度的数值运算,能够确保金额计算的精确性,避免了因浮点数精度问题导致的财务误差。使用BigDecimal可以简化代码逻辑,减少因处理精度问题而引入的复杂性。


而在数据库存储方面,我们需要根据具体需求进行权衡。如果业务需求已经明确金额只需精确到分(如某些国家/地区的货币最小单位为分),并且我们确信不会涉及到需要更高精度的小数计算,那么可以使用Long类型进行存储,将金额转换为最小货币单位(如分)进行存储。这样可以节省存储空间并提高查询性能。


但是如果业务需求中金额的小数位数不确定,或者可能涉及多位小数的计算(如国际货币交易等),那么最好使用DECIMALNUMERIC类型进行存储。这些类型提供了精确的数值存储,可以确保数据库中的数据与应用程序中的BigDecimal对象保持一致,避免数据转换过程中可能引入的精度损失。




作者:码农Academy
来源:juejin.cn/post/7358670107902984229
收起阅读 »

一行代码引发的离奇问题,众多大佬纷纷参与点评,最终Typescript之父出手解决

web
故事的起因是这样的, 一个前端开发人员(也算是挺有名的,ariakit.org的作者, wrodPress的前维护者)在社交媒体上发了这么一条帖子。 短短几天就有了51.8万次的view。 简单的文案:又是使用 TypeScript 的一天.。表达了对Typ...
继续阅读 »

故事的起因是这样的, 一个前端开发人员(也算是挺有名的,ariakit.org的作者, wrodPress的前维护者)在社交媒体上发了这么一条帖子。


image-20240317131231595.png


短短几天就有了51.8万次的view。 简单的文案:又是使用 TypeScript 的一天.。表达了对Typescript的又爱又恨😂。在目前的前端市场上,Typescript已经成为标配,ts强大的类型检查机制给我们带来了非常多的好处(代码质量,强大的可维护性,代码即注释),但是其槽点也很多, 很多奇奇怪怪的问题(相信不仅是我一个人这么觉得),繁多的配置项组合,稍不注意就会引起页面爆红,代码量增多和代码组织也会引起一定的负担。但在这些并不能撼动Typescript 在目前前端社区中的地位,在开发项目中一般还是会选择typescript。


反应


话说回到这个帖子上,这个帖子发出来之后迅速引起发酵,被很多大佬转发和引用,下面的评论很多都是wait, what, why happen?类似的语气😂,有很多给出建议,比如换种写法, 重启下Typescript server试试,也有很多开发爱好者希望作者能提供一个例子来复现,他们也想看看是什么问题,看能不能尝试解决这个有趣的例子。


(ps: 在ts中有很多奇怪的东西,特别是在和编辑器配合的时候,有些时候不能判断出来是否是个bug?还是我们代码写的有问题?还是设计如此?还是编辑器的问题?还是版本兼容问题?仅代表个人看法)


复现例子


后来有大佬根据作者提供的信息复现出来了样板例子


declare function hasOwnPropertyextends AnyObject>(
object: T,
prop: keyof any,
): prop is keyof T
;

type EffectCallback = () => void;

declare const useSafeLayoutEffect: (effect: EffectCallback) => void;

type AnyObject = Record<string, any>;
export type State = AnyObject;

type AnyFunction = (...args: any) => any;
type BivariantCallbackextends AnyFunction> = {
bivarianceHack(...args: Parameters): ReturnType;
}["bivarianceHack"];
type SetStateAction = T | BivariantCallback<(prevState: T) => T>;

interface StoreState> {
getState(): S;
setStateextends keyof S>(key: K, value: SetStateAction): void;
}

export function useStoreProps<
S
extends State,
P
extends Partial,
K
extends keyof S,
>(
store: Store, props: P, key: K) {
const value = hasOwnProperty(props, key) ? props[key] : undefined;

useSafeLayoutEffect(() => {
if (value === undefined) return;
value;
// ^?
if (value === undefined) return; // toggle this to see the magic
value;
// ^?
store.setState(key, value);
});
}

将鼠标放到倒数第八行上显示value的类型:


const value: P[K] & ({} | null)

但是将鼠标放到倒数第五行时显示的value类型:


const value: P[K] & {}

真是见了鬼了。同样的操作复制了一遍,显示的类型却不一样?是的,这很Typescript😏。


提出issue


issue的地址在这


image-20240317141601821.png


这个提出issue的哥们就是复现样板例子的人,看的出来他应该是个狂热的技术爱好者,执行力也很强,从问作者要出现这种情况的代码仓库可以是否可以公开 ==> 复现样板例子 ==> 给Typescript提出issue(还尝试了自己能不能解决),执行力power👍。


Typescript之父出手解决


在提出issue之后立即就被官方定位是一个bug, 而且Typescript之父还给出了一个简化版可复现的例子:


function f1extends Record<string, any>, K extends keyof T>(x: T[K] | undefined) {
if (x === undefined) return;
x; // T[K] & ({} | null)
if (x === undefined) return;
x; // T[K] & {}
}

通过上面的例子发现null被意外的消除了。


ahejlsberg(ts之父) 写了一个规范化nullundefined在类型系统中的表现的函数解决了这个问题。


image-20240317152740323.png


至此issue被关闭。


我们打开palyground的nightly版本,可以发现这个问题被解决, 错误不在显示了。


总结


这是无意间从网上看到,然后从问题追溯到问题被一步步的解决。从帖子中可以看出来现在大部分用Typescript写项目的人又爱又恨的普遍状态。不管你是多菜的菜鸟也能感受到ts给日益庞大的前端项目带来的好处,不管你是多厉害的大牛也是会遇到一些奇怪的错误。随着Typescript的普及,社区中有很多不同的声音,有热爱者,有反对者,也有随波逐流者,但这也代表Typescript在社区中展现的旺盛生命力。质疑也好,热爱也罢,我觉得ts会越来越好。


作者:xinling_any
来源:juejin.cn/post/7347210988260147210
收起阅读 »

一行代码搞定禁用web开发者工具

web
在如今的互联网时代,网页源码的保护显得尤为重要,特别是前端代码,几乎就是明文展示,很容易造成源码泄露,黑客和恶意用户往往会利用浏览器的开发者工具来窃取网站的敏感信息。为了有效防止用户打开浏览器的Web开发者工具面板,今天推荐一个不错的npm库,可以帮助开发者更...
继续阅读 »

在如今的互联网时代,网页源码的保护显得尤为重要,特别是前端代码,几乎就是明文展示,很容易造成源码泄露,黑客和恶意用户往往会利用浏览器的开发者工具来窃取网站的敏感信息。为了有效防止用户打开浏览器的Web开发者工具面板,今天推荐一个不错的npm库,可以帮助开发者更好地保护自己的网站源码,本文将介绍该库的功能和使用方法。


功能介绍


npm库名称:disable-devtool,github地址:github.com/theajack/disable-devtool。从f12按钮,右键单击和浏览器菜单都可以禁用Web开发工具。



🚀 一行代码搞定禁用web开发者工具



该库有以下特性:



  • 支持可配置是否禁用右键菜单

  • 禁用 f12 和 ctrl+shift+i 等快捷键

  • 支持识别从浏览器菜单栏打开开发者工具并关闭当前页面

  • 开发者可以绕过禁用 (url参数使用tk配合md5加密)

  • 多种监测模式,支持几乎所有浏览器(IE,360,qq浏览器,FireFox,Chrome,Edge...)

  • 高度可配置、使用极简、体积小巧

  • 支持npm引用和script标签引用(属性配置)

  • 识别真移动端与浏览器开发者工具设置插件伪造的移动端,为移动端节省性能

  • 支持识别开发者工具关闭事件

  • 支持可配置是否禁用选择、复制、剪切、粘贴功能

  • 支持识别 eruda 和 vconsole 调试工具

  • 支持挂起和恢复探测器工作

  • 支持配置ignore属性,用以自定义控制是否启用探测器

  • 支持配置iframe中所有父页面的开发者工具禁用


使用方法


使用该库非常简单,只需按照以下步骤进行操作:


1.1 npm 引用


推荐使用这种方式安装使用,使用script脚本可以被代理单独拦截掉从而无法执行。


npm i disable-devtool

import DisableDevtool from 'disable-devtool';

DisableDevtool(options);

1.2 script方式使用


<script disable-devtool-auto src='https://cdn.jsdelivr.net/npm/disable-devtool'>script>

或者通过版本引用:



<script disable-devtool-auto src='https://cdn.jsdelivr.net/npm/disable-devtool@x.x.x'>script>

<script disable-devtool-auto src='https://cdn.jsdelivr.net/npm/disable-devtool@latest'>script>

1.3 npm 方式 options参数说明


options中的参数与说明如下,各方面的配置相当完善。


interface IConfig {
md5?: string; // 绕过禁用的md5值,默认不启用绕过禁用
url?: string; // 关闭页面失败时的跳转页面,默认值为localhost
tkName?: string; // 绕过禁用时的url参数名称,默认为 ddtk
ondevtoolopen?(type: DetectorType, next: Function): void; // 开发者面板打开的回调,启用时url参数无效,type 为监测模式, next函数是关闭当前窗口
ondevtoolclose?(): void; // 开发者面板关闭的回调
interval?: number; // 定时器的时间间隔 默认200ms
disableMenu?: boolean; // 是否禁用右键菜单 默认为true
stopIntervalTime?: number; // 在移动端时取消监视的等待时长
clearIntervalWhenDevOpenTrigger?: boolean; // 是否在触发之后停止监控 默认为false, 在使用ondevtoolclose时该参数无效
detectors?: Array<DetectorType>; // 启用的检测器 检测器详情
clearLog?: boolean; // 是否每次都清除log
disableSelect?: boolean; // 是否禁用选择文本 默认为false
disableCopy?: boolean; // 是否禁用复制 默认为false
disableCut?: boolean; // 是否禁用剪切 默认为false
disablePaste: boolean; // 是否禁用粘贴 默认为false
ignore?: (string|RegExp)[] | null | (()=>boolean); // 某些情况忽略禁用
disableIframeParents?:
boolean; // iframe中是否禁用所有父窗口
timeOutUrl?:
// 关闭页面超时跳转的url;
}

enum DetectorType {
Unknown = -1,
RegToString = 0, // 根据正则检测
DefineId, // 根据dom id检测
Size, // 根据窗口尺寸检测
DateToString, // 根据Date.toString 检测
FuncToString, // 根据Function.toString 检测
Debugger, // 根据断点检测,仅在ios chrome 真机情况下有效
Performance, // 根据log大数据性能检测
DebugLib, // 检测第三方调试工具 erudavconsole
};

1.4 script 方式使用属性配置


<script 
disable-devtool-auto
src='https://cdn.jsdelivr.net/npm/disable-devtool'
md5='xxx'
url='xxx'
tk-name='xxx'
interval='xxx'
disable-menu='xxx'
detectors='xxx'
clear-log='true'
disable-select='true'
disable-copy='true'
disable-cut='true'
disable-paste='true'
>
script>

1.5 事件监听


ondevtoolopen 事件的回调参数就是被触发的监测模式。可以在 ondevtoolopen 里执行业务逻辑,比如做数据上报、用户行为分析等。


DisableDevtool({
ondevtoolopen(type, next){
alert('Devtool opened with type:' + type);
next();
}
});

1.6 md5 与 tk 绕过禁用


该库中使用 key 与 md5 配合的方式使得开发者可以在线上绕过禁用。


流程如下:


先指定一个 key a(该值不要记录在代码中),使用 md5 加密得到一个值 b,将b作为 md5 参数传入,开发者在访问 url 的时候只需要带上url参数 ddtk=a,便可以绕过禁用。


disableDevtool对象暴露了 md5 方法,可供开发者加密时使用:


DisableDevtool.md5('xxx');

更多细节可查阅官方文档,中文文档地址:https://github.com/theajack/disable-devtool/blob/master/README.cn.md


最后


尽管该库可以有效地禁用浏览器的开发者工具面板,但仍然需要注意以下几点:



  • 该库只能禁用开发者工具的面板,无法阻止用户通过其他途径访问网页源码。因此,建议结合其他安全措施来保护网站。

  • 禁用开发者工具可能会对网站的调试和维护造成一定的困扰。需要调试线上代码的时候可以使用上述1.6绕过禁用进行调试。

  • 该库仅适用于现代浏览器,对于一些较旧的浏览器可能存在兼容性问题。在使用前请确保测试过兼容性。


为了进一步加强网页源码的安全性,我们可以采取以下额外措施:



  • 加密敏感代码,使用加密算法对关键代码进行加密,以防止非授权访问和修改。

  • 使用服务器端渲染,将网页的渲染过程放在服务器端,只返回最终渲染结果给客户端,隐藏源代码和逻辑。

  • 定期更新代码,定期更新代码库以充分利用新的安全特性和修复已知漏洞。


保护网页源码的安全性对于Web开发至关重要。通过使用npm库disable-devtool,并结合其他安全措施,我们可以有效地降低用户访问和修改源代码的风险。但是绝对的安全是不存在的,因此定期更新和加强安全性措施也是必要的。




作者:南城FE
来源:juejin.cn/post/7296089060833148943
收起阅读 »

一款好用到爆的可视化拖拽库

web
嗨,大家好,我是徐小夕,之前一直在研究可视化零代码相关的技术实践,也做了很多可视化搭建的产品,比如: H5-Dooring(页面可视化搭建平台) V6.Dooring(数据大屏可视化平台) formManager(表单搭建引擎) Next-Admin(基于n...
继续阅读 »

嗨,大家好,我是徐小夕,之前一直在研究可视化零代码相关的技术实践,也做了很多可视化搭建的产品,比如:



  • H5-Dooring(页面可视化搭建平台)

  • V6.Dooring(数据大屏可视化平台)

  • formManager(表单搭建引擎)

  • Next-Admin(基于nextjs和antd5.0的中后台管理系统)


最近在研发智能搭建系统(WEP)的时候发现一款非常好用的可视化拖拽插件——draggable。它在 github 上有17.4k star,提供了很多非常精美的拖拽案例, 我们使用它可以轻松实现可视化拖拽,组件排序,网格拖拽等效果,而且浏览器兼容性也非常不错,原生 javascript 开发, 可以轻松集成到 reactvue 等主流框架中。


接下来我就和大家一起介绍一下这款开源插件。



安装与使用


我们可以使用如下方式安装:


# yarn add shopify/draggable
pnpm add shopify/draggable

在项目里使用:


import {
Draggable,
Sortable,
Droppable,
Swappable,
} from 'shopify/draggable'

github地址: https://github.com/Shopify/draggable


接下来我就来和大家分享几个非常有价值的使用案例。


1. 3D效果拖拽



代码实现:


// eslint-disable-next-line import/no-unresolved
import {Draggable} from '@shopify/draggable';

// eslint-disable-next-line shopify/strict-component-boundaries
import Plate from '../../components/Plate';

export default function Home() {
const containerSelector = '#Home .PlateWrapper';
const container = document.querySelector(containerSelector);

if (!container) {
return false;
}

const draggable = new Draggable(container, {
draggable: '.Plate',
});
const plates = new Plate(container);

// --- Draggable events --- //
draggable.on('drag:start', (evt) => {
plates.setThreshold();
plates.setInitialMousePosition(evt.sensorEvent);
});

draggable.on('drag:move', (evt) => {
// rAF seems to cause the animation to get stuck?
// requestAnimationFrame(() => {});
plates.dragWarp(evt.source, evt.sensorEvent);
});

draggable.on('drag:stop', () => {
plates.resetWarp();
});

return draggable;
}

2. 可拖拽的开关效果


2.gif


代码如下:


// eslint-disable-next-line import/no-unresolved
import {Draggable} from '@shopify/draggable';

function translateMirror(mirror, mirrorCoords, containerRect) {
if (mirrorCoords.top < containerRect.top || mirrorCoords.left < containerRect.left) {
return;
}

requestAnimationFrame(() => {
mirror.style.transform = `translate3d(${mirrorCoords.left}px, ${mirrorCoords.top}px, 0)`;
});
}

function calcOffset(offset) {
return offset * 2 * 0.5;
}

export default function DragEvents() {
const toggleClass = 'PillSwitch--isOn';
const containers = document.querySelectorAll('#DragEvents .PillSwitch');

if (containers.length === 0) {
return false;
}

const draggable = new Draggable(containers, {
draggable: '.PillSwitchControl',
delay: 0,
});

let isToggled = false;
let initialMousePosition;
let containerRect;
let dragRect;
let dragThreshold;
let headings;
let headingText;

// --- Draggable events --- //
draggable.on('drag:start', (evt) => {
initialMousePosition = {
x: evt.sensorEvent.clientX,
y: evt.sensorEvent.clientY,
};
});

draggable.on('mirror:created', (evt) => {
containerRect = evt.sourceContainer.getBoundingClientRect();
dragRect = evt.source.getBoundingClientRect();

const containerRectQuarter = containerRect.width / 4;
dragThreshold = isToggled ? containerRectQuarter * -1 : containerRectQuarter;
headings = {
source: evt.originalSource.querySelector('[data-switch-on]'),
mirror: evt.mirror.querySelector('[data-switch-on]'),
};
headingText = {
on: headings.source.dataset.switchOn,
off: headings.source.dataset.switchOff,
};
});

draggable.on('mirror:move', (evt) => {
evt.cancel();
const offsetX = calcOffset(evt.sensorEvent.clientX - initialMousePosition.x);
const offsetY = calcOffset(initialMousePosition.y - evt.sensorEvent.clientY);
const offsetValue = offsetX > offsetY ? offsetX : offsetY;
const mirrorCoords = {
top: dragRect.top - offsetValue,
left: dragRect.left + offsetValue,
};

translateMirror(evt.mirror, mirrorCoords, containerRect);

if (isToggled && offsetValue < dragThreshold) {
evt.sourceContainer.classList.remove(toggleClass);
headings.source.textContent = headingText.off;
headings.mirror.textContent = headingText.off;
isToggled = false;
} else if (!isToggled && offsetValue > dragThreshold) {
evt.sourceContainer.classList.add(toggleClass);
headings.source.textContent = headingText.on;
headings.mirror.textContent = headingText.on;
isToggled = true;
}
});

const triggerMouseUpOnESC = (evt) => {
if (evt.key === 'Escape') {
draggable.cancel();
}
};

draggable.on('drag:start', () => {
document.addEventListener('keyup', triggerMouseUpOnESC);
});

return draggable;
}

3.可拖拽的网格元素


3.gif


源码地址: https://github.com/Shopify/draggable/tree/master/examples/src/content/Droppable/UniqueDropzone


4. 可拖拽的列表


4.gif


源码地址: https://github.com/Shopify/draggable/tree/master/examples/src/content/Sortable/SimpleList


5. 卡牌拖拽效果


5.gif


源码地址: https://github.com/Shopify/draggable/tree/master/examples/src/content/Sortable/Transformed


6. 多容器拖拽效果


6.gif


源码地址: https://github.com/Shopify/draggable/tree/master/examples/src/content/Sortable/MultipleContainers


7. 不规则网格拖拽


7.gif


源码地址:https://github.com/Shopify/draggable/tree/master/examples/src/content/Swappable/Floated


8. 拖拽排序动画


8.gif


源码地址: https://github.com/Shopify/draggable/tree/master/examples/src/content/Plugins/SortAnimation


当然还有很多有意思的拖拽案例, 大家也可以去体验一下。


今天就分享到这啦,祝大家节日快乐, 博学!


如果有收获,记得点赞 + 再看哦, 欢迎在评论区评论, 分享你的收藏干货~




作者:徐小夕
来源:juejin.cn/post/7353877562303021093
收起阅读 »

这两年,我把28年以来欠的亏都吃完了...

前言 很长一段时间没有总结一下过去几个月的状态了,今天思绪万千,脑海中浮现了很多经历和生活片段,我把它记录下来。顺便今天聊一聊认知突破,分享我在买房这段时间吃过的亏,也希望作为你的前车之鉴。 买房 21年底的时候,那时刚好毕业三年,也正是互联网公司996最流行...
继续阅读 »

前言


很长一段时间没有总结一下过去几个月的状态了,今天思绪万千,脑海中浮现了很多经历和生活片段,我把它记录下来。顺便今天聊一聊认知突破,分享我在买房这段时间吃过的亏,也希望作为你的前车之鉴。


买房


21年底的时候,那时刚好毕业三年,也正是互联网公司996最流行的阶段,由于平时我不怎么花钱,也很少买衣服,上网买东西是个矛盾体,需要花很多时间对比,经常看了一件东西很久,最后又不买。加上比较高强度的工作状态,两点一线,可以说是没时间花钱,再加上自己把钱都拿去理财了,也赚了几万块,最后一共攒了几十万下来。我从小就立志要走出农村,而且认为以后有女朋友结婚也要房子,加上当时花比较多时间在理财上面,那时候其实行情已经不好了,工作上没什么突破,比较迷茫,于是想着干脆就把钱花出去了,自己也就有动力去搞各种路子尝试赚钱。在没有经过任何对比之后就在佛山买了一套房子,房价正是高峰的时候,于是我成功站岗!因为这个契机,躲过了持续了2年多的低迷股市,却没躲过低迷的房地产。


while(true) { 坑++ }


我买的是期房,当时不知道期房会有这么多坑,比如期间不确定开发商会不会破产,我这个开发商(龙光)就差点破产了,房产证无着落,相当于花了200w买了一个无证的房子,这辈子就算是搭进去了。


对于整个购房过程也是很懵逼,对流程完全不熟悉,当时去翻了政府规划文件,看那个地段后续有没有涨价空间,然后跟着亲戚介绍的销售转圈圈,当时说给我免3年物业费,合计也有几万块。在签合同之前销售都有说可以给到,但由于第一次没有录音,导致在签合同的时候销售反口,不承认,我们也没有证据,最后吃了哑巴亏。


开始的时候谈好了一个价格167w,然后销售私下打电话给我洗脑说我给点辛苦费1.5w,他可以向领导申请多几万块优惠。我知道这是他们的销售套路,但是架不住给我优惠5w啊,中间反复拉扯最后说给他8k,采用线下现金交易的方式。这一次我有录音了,因为私底下交易没有任何痕迹,也不合法,所以留了一手,也成为我后面维权时争取话语权的基础。


中介佣金是很乐观的,当时由于我亲戚推荐我去,销售承诺税前有4w,当时看中这个返佣也促使我火急火燎的交了定金。现在3年过去了,这个佣金依旧没有到账,我一度怀疑是中介搞ABC套路把我这个钱💰吃了,其他邻居的推荐佣金都到了账,加上现在地产商没钱了,同时跟那个亲戚有些过节,这个返佣更是遥遥无期。最后通过上面的录音获得了一丝话语权,知道了这个钱还在开发商手上,一直没有拨款下来到中介公司。下面是部分聊天记录:


image.png


不接受微信语音沟通,文字可以留给自己思考的时间,同时也更好收集证据。


image.png


然后去找相关人员把信息拉出来给我看,显示开发商未付款状态,这个状态维持2年了,目前看来只能再等下去。


image.png


签合同的时候,有个律师所说是协助我们签合同、备案、办房产证等各种边缘工作,糊里糊涂交了700元律师费,不交不行,甚至律师所连发票都没有给,而我都没有意识到这个最基本的法律法规问题。现在交房了可以办理房产证了,拿证下来也就80块登记费,居然收我700,其他业主有些是600多,400多,顿时觉得智商受到了侮辱,看了网上铁头各种打假的视频,我觉得自己也应该勇敢发声。现在也在收集商家各种违规证据,提交给相关部门解决。


image.png


image.png


image.png


后面市场监督管理局收到投诉,应该是有协商,意识到没有给我们发票,过来几天之后才把发票补过来,开票日期不是付款时候的2022年,而是2024年,明显属于偷税了。目前跟他要发票的应该只有我,估算2300多户业主都没有开发票的。


当时我首付需要50w,自己手上不够,我爸干建筑一辈子,辛苦供我们两个孩子上了大学,山上建了两层楼,手里没钱。我妈是一辈子没打过工,消极派,说出来没几句好话,家里不和睦的始作俑者,更不可能有钱支持。所以我还有20w是首付贷,也就是跟开发商借的,利率10%,这个利息很高了。销售当时说可以优惠到5%,但是优惠金额是补贴到总房价里面去,其实这也是他们的一种销售套路,这亏我也吃了,2年之后我连本带息还24w。当时认为自己应该一年左右能还完,但是实际远远高估自己的能力,买完房子接着我爸又生病在医院待了几个月,前后花了十几万,人生一下子跌入了谷底。


从头再来


后面2023一年,我出去创业,模式很新,很多人不赞同,期间遇到了不少小人诋毁我们两夫妻,当时我老婆还在怀孕,但我们最后都熬过来了,还生了一个儿子,6斤多。期间一年赚了十几万,但是开支也大,加上父母要养,我爸还要吃药,房子要供,最后还是选择了先稳定下来,我重新回到了职场,空窗一年后在这个环境下拿了一个还不错的offer,同时也想自己沉淀一下。


自从有了宝宝之后,生活似乎都往好的方面发展,出版社找我出书,为了契合自己的职业发展,我选择了写书《NestJS全栈开发秘籍》,从2023年11月份开始,迄今快半年了,在收尾阶段,希望尽快与各位读者们见面。同时,等了3年的房子也收房了,由于是高层,质量相对其他邻居好,没有出现成片天花掉下来或者漏水的情况。我们经常都说他是天使宝宝,是来报恩的。


由于我们公司技术部门是属于后勤支持性质的,技术变化不大,Vue2+微前端和React管理系统那一套,没有太多的新技术扩展,意味着不确定也大。业务发展不好考虑的是减少这些部门的开支,所以不出意外最近也迎来了降薪。这不是最可怕的,对于我们技术人来讲,最可怕的是我认为在业务中成长停滞了,或者没有业务来锻炼技术,所以在业余时间也选择了参与一些开源项目,如hello-alog开源算法书的代码贡献,并且这也是选择写书的原因。很简单地说,当下一个面试官问到我的时候,我不可能什么都讲不出来,最经典的问题就是:在这个公司期间你做过最有成就感的事情是什么?现在,我有了答案!


哲学


我的人生哲学是不断改变,拥抱不确定性!这么看来,我的确在这些年上了不少当,吃了不少亏,把自己搞的很累,甚至连累到家里人。但,用我老婆经常说的一句话:人生这么长,总是要经历点什么,再说现在也没有很差。的确,不断将自己处于变化之中,当不确定性降临到普罗大众时,我们唯一的优势,就是更加从容


总结


人们还在行走,我们的故事还在继续~


WechatIMG154.jpg


作者:寻找奶酪的mouse
来源:juejin.cn/post/7349136892333981711
收起阅读 »

普通Android应用的系统签名

一、前言 对于常见的Android开发来说,普通级别的app已经满足不了需求。对系统的要求能力越来越定制化,所以针对系统权限的需求也迫在眉睫。 那怎么通过aosp的系统签名,将普通app升级为系统权限的app,使app能访问系统资源的权限呢? 二、流程 1. ...
继续阅读 »

一、前言


对于常见的Android开发来说,普通级别的app已经满足不了需求。对系统的要求能力越来越定制化,所以针对系统权限的需求也迫在眉睫。
那怎么通过aosp的系统签名,将普通app升级为系统权限的app,使app能访问系统资源的权限呢?


二、流程


1. 手动签名apk文件


a. app设置系统权限


在app项目的AndroidManifest文件的节点新增


<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:sharedUserId="android.uid.system">


b. 编译打包apk


生成对应apk


c. 准备签名文件及工具


需要准备:



  • java环境命令

  • 系统签名文件:platform.pk8、platform.x509.pem




  • signapk.jar:



    • 进入/build/tools/signapk/文件夹

    • 执行命令: mm

    • 在out/host/linux-x86/framework/目录找到signapk.jar




d. 签名打包好的apk



tips: 最好将工具等文件复制到统一文件中,比较好操作,中途会遇到各式各样的问题,操作篇尾



java -jar signapk.jar platform.x509.pem platform.pk8 app-debug.apk new.apk 

e. 安装新包测试


卸载旧包,安装新包,即可完成系统权限


2. 自动签名apk文件


每次开发时,总是要手动签名新打出的安装包,很不方便,直接在打包时完成系统签名更高效


a. pk8 私钥解密pem格式


此时会生成platform.priv.pem文件



  • [platform.priv.pem]为生成文件名称


openssl pkcs8 -in platform.pk8 -inform DER --outform PEM -out platform.priv.pem -nocrypt

b. 私钥通过公钥pem加密pk12


此时会生成platform.pk12文件



  • [platform.priv.pem]为上一步生成的文件

  • [zxxkey]为AliasName


openssl pkcs12 -export -in platform.x509.pem -inkey platform.priv.pem -out platform.pk12 -name zxxkey

需要输入两次密码:(实测store和key密码需要一致)


c. 通过java的keytool 工具生成 keystore



  • [12345678]为store密码

  • [zxxkey]为上一步设置的别名,需要与上面保持一致


jks:


keytool -importkeystore -destkeystore platform.jks -srckeystore platform.pk12 -srcstoretype PKCS12 -srcstorepass 12345678 -alias zxxkey

keystore:


keytool -importkeystore -destkeystore platform.keystore -srckeystore platform.pk12 -srcstoretype PKCS12 -srcstorepass 12345678 -alias zxxkey

d. 项目中使用签名


1)引入签名文件:


将keystore或者jks文件引入项目


2)创建keystore.properties:


keyAlias=zxxkey
keyPassword=12345678
storeFile=../key/platform.jks
storePassword=12345678

3)在app/build.gradle.kts引入signConfig:


import java.io.FileInputStream
import java.util.Properties

...

val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
keystoreProperties.load(FileInputStream(keystorePropertiesFile))

...

android {

...

signingConfigs {
create("release") {
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
storeFile = file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
}
}

buildTypes {
debug {
signingConfig = signingConfigs.getByName("release")
}
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}

...
}


三、问题


1. java版本问题


Q:版本异常?


Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main" java.lang.UnsupportedClassVersionError: com/android/signapk/SignApk has been compiled by a more recent version of the Java Runtime (class file version 53.0), this version of the Java Runtime only recognizes class file versions up to 52.0

A:解决方案:


升级jdk版本,52.0版本为java8,选用更高版本即可。


/home/zengxiangxi/Developer/JDK/jdk-9.0.4/bin/java


/home/zengxiangxi/Developer/JDK/jdk-9.0.4/bin/java -jar signapk.jar platform.x509.pem platform.pk8 app-debug.apk new.apk 

2. 签名问题报错


Q:找不到依赖库?


Exception in thread "main" java.lang.UnsatisfiedLinkError: no conscrypt_openjdk_jni-linux-x86_64 in java.library.path
at java.base/java.lang.ClassLoader.loadLibrary(ClassLoader.java:2541)
at java.base/java.lang.Runtime.loadLibrary0(Runtime.java:873)
at java.base/java.lang.System.loadLibrary(System.java:1857)
at org.conscrypt.NativeLibraryUtil.loadLibrary(NativeLibraryUtil.java:54)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)


A:附带以来库路径。


-Djava.library.path="/home/zengxiangxi/Project/aosp/out/soong/host/linux-x86/lib64"


stackoverflow.com/questions/4…


~/Developer/JDK/jdk-9.0.4/bin/java -Djava.library.path="/home/zengxiangxi/Project/aosp/out/soong/host/linux-x86/lib64" -jar signapk.jar platform.x509.pem platform.pk8 app-debug.apk new.apk

四、文档链接



作者:会飞de小牛人
来源:juejin.cn/post/7299991094627500072
收起阅读 »

突破自定义View性能瓶颈

在Android应用程序中,自定义View是一个非常常见的需求。自定义View可以帮助您创建独特的UI元素,以满足您的应用程序的特定需求。然而,自定义View也可能会导致性能问题,特别是在您的应用程序需要处理大量自定义View的情况下。 在本篇文章中,我们将探...
继续阅读 »

在Android应用程序中,自定义View是一个非常常见的需求。自定义View可以帮助您创建独特的UI元素,以满足您的应用程序的特定需求。然而,自定义View也可能会导致性能问题,特别是在您的应用程序需要处理大量自定义View的情况下。


在本篇文章中,我们将探讨一些Android自定义View性能优化的技巧,以确保您的应用程序在处理自定义View时保持高效和稳定。我们将从以下几个方面进行讨论:


1. 使用正确的布局


在创建自定义View时,正确的布局是至关重要的。使用正确的布局可以帮助您最大限度地减少布局层次结构,从而提高您的应用程序的性能。


例如,如果您需要创建一个具有多个子视图的自定义View,使用ConstraintLayout代替RelativeLayout和LinearLayout可以简化布局并减少嵌套。


下面是一个示例代码:


<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">




androidx.constraintlayout.widget.ConstraintLayout>

另一个重要的布局技巧是使用ViewStub。ViewStub是一个轻量级的视图,它可以用作占位符,直到需要真正的视图时才充气。这可以大大减少布局层次结构并提高性能。


2. 缓存视图


缓存视图是另一个重要的性能优化技巧。当您使用自定义View时,通常需要创建多个实例。如果您没有正确地缓存这些实例,那么您的应用程序可能会变得非常慢。


为了缓存视图,您可以使用Android的ViewHolder模式或使用自定义缓存对象。ViewHolder模式是Android开发者广泛使用的一种技术,可以在列表或网格视图中提高性能。使用自定义缓存对象可以更好地控制视图的生命周期,并减少视图的创建和销毁。


以下是ViewHolder模式的示例代码:


class CustomView(context: Context) : View(context) {
private class ViewHolder {
// 缓存视图
var textView: TextView? = null
var imageView: ImageView? = null
// 添加其他视图组件
}

private var viewHolder: ViewHolder? = null

init {
// 初始化ViewHolder
viewHolder = ViewHolder()
// 查找视图并关联到ViewHolder
viewHolder?.textView = findViewById(R.id.text_view)
viewHolder?.imageView = findViewById(R.id.image_view)
// 添加其他视图组件的查找和关联
}

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 绘制视图
}
}

3. 避免过多的绘制操作


绘制操作是自定义View中最重要的性能问题之一。如果您的自定义View需要大量的绘制操作,那么您的应用程序可能会变得非常慢。


为了避免过多的绘制操作,您可以使用View的setWillNotDraw方法来禁用不必要的绘制。您还可以使用Canvas的clipRect方法来限制绘制操作的区域。此外,您还可以使用硬件加速来加速绘制操作。


以下是一个示例代码:


class CustomView(context: Context) : View(context) {
init {
setWillNotDraw(true) // 禁用绘制
}

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 绘制操作
canvas.clipRect(0, 0, width, height) // 限制绘制区域
// 添加其他绘制操作
}
}

4. 使用异步任务


如果您的自定义View需要执行耗时的操作,例如从网络加载图像或处理大量数据,那么您应该使用异步任务来执行这些操作。这可以确保您的应用程序在执行这些操作时保持响应,并且不会阻塞用户界面。


以下是一个使用异步任务加载图像的示例代码:


class CustomView(context: Context) : View(context) {
private var image: Bitmap? = null

fun loadImageAsync(imageUrl: String) {
val asyncTask = object : AsyncTask<Void, Void, Bitmap>() {
override fun doInBackground(vararg params: Void?): Bitmap {
// 执行耗时操作,如从网络加载图像
return loadImageFromUrl(imageUrl)
}

override fun onPostExecute(result: Bitmap) {
super.onPostExecute(result)
// 在主线程更新UI
image = result
invalidate() // 刷新视图
}
}

asyncTask.execute()
}

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 绘制图像
image?.let {
canvas.drawBitmap(it, 0f, 0f, null)
}
// 添加其他绘制操作
}
}

5. 使用适当的数据结构


在自定义View中,使用适当的数据结构可以大大提高性能。例如,如果您需要绘制大量的点或线,那么使用FloatBuffer或ByteBuffer可以提高性能。如果您需要处理大量的图像数据,那么使用BitmapFactory.Options可以减少内存使用量。


以下是一个使用FloatBuffer绘制点的示例代码:


class CustomView(context: Context) : View(context) {
private var pointBuffer: FloatBuffer? = null

init {
// 初始化点的数据
val points = floatArrayOf(0f, 0f, 100f, 100f, 200f, 200f)
pointBuffer = ByteBuffer.allocateDirect(points.size * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
pointBuffer?.put(points)
pointBuffer?.position(0)
}

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 绘制点
pointBuffer?.let {
canvas.drawPoints(it, paint)
}
// 添加其他绘制操作
}
}

结论


在本篇文章中,我们探讨了一些Android自定义View性能优化的技巧。通过使用正确的布局,缓存视图,避免过多的绘制操作,使用异步任务和适当的数据结构,您可以确保您的应用程序在处理自定义View时保持高效和稳定。


请记住,优化自定义View的性能是一个持续的过程。您应该经常检查您的应用程序,并使用最新的技术和最佳实践来提高性能。




作者:午后一小憩
来源:juejin.cn/post/7238491755448369211
收起阅读 »

关于页面适配的一些方案

web
早期的页面使用了左右布局。左侧宽度固定,右侧宽度自适应。未使用vm、em、百分比等进行屏幕适配。所有的尺寸(宽度、高度、边框宽度、字体大小等)全部使用的px进行开发。导致只有常用的显示屏尺寸显示较为正常,但是小屏幕显示不正常。 媒体查询屏幕适配 正常显示屏的...
继续阅读 »

早期的页面使用了左右布局。左侧宽度固定,右侧宽度自适应。未使用vm、em、百分比等进行屏幕适配。所有的尺寸(宽度、高度、边框宽度、字体大小等)全部使用的px进行开发。导致只有常用的显示屏尺寸显示较为正常,但是小屏幕显示不正常。



媒体查询屏幕适配


正常显示屏的分辨率是1920 * 1080【假如缩放比例为100%】。在此尺寸下显示正常的布局和展示,如果修改分辨率为1360 * 768。则正常显示的字体等有一种放大的效果。


image.png
如果想要同1920的显示屏同样的显示效果,则需要在index.html中设置:



@media(max-width: 1440px) {
html {
zoom: 90%;
}
}

image.png


但是有一个弊端,字体会变模糊。


根据dpr适配


很多小屏幕推荐的缩放比例是150%。


此时根据dpr进行适配


    @media (-webkit-min-device-pixel-ratio: 1.5) {
html {
zoom: 0.67
}
}

注意,在此设置下,如果系统中有根据pageX, pageY进行定位时,需要额外处理。


        if (window.devicePixelRatio == 1.5) {
x = x/0.67;
y = y/0.67;
}

作者:一涯
来源:juejin.cn/post/7306749023473451045
收起阅读 »

如何秒开WebView?Android性能优化全攻略!

在Android应用开发中,WebView是一个常用的组件,用于在应用中展示网页内容。然而,WebView的启动速度和性能可能会影响用户体验,特别是在一些性能较低的设备上。本文将介绍一些优化WebView启动的技巧,以提高应用的响应速度和用户体验。 在优化We...
继续阅读 »

在Android应用开发中,WebView是一个常用的组件,用于在应用中展示网页内容。然而,WebView的启动速度和性能可能会影响用户体验,特别是在一些性能较低的设备上。本文将介绍一些优化WebView启动的技巧,以提高应用的响应速度和用户体验。


在优化WebView启动的过程中,主要有以下几个方面:



  1. 加载优化:通过预加载,延迟加载,可以有效减少启动的时间。

  2. 请求优化:通过并行、拦截请求策略,可以加快网络耗时,与减少重复的耗时。

  3. 缓存优化:合理使用缓存,减少网络请求,提高加载速度。

  4. 渲染优化:合理的启动硬件加速,可以有效的提高渲染速度。

  5. 进程优化:启用多进程模式,可以避免主线程阻塞,内存泄漏、异常crash等问题。


下面我们将详细说明这些优化技巧。


加载优化


预加载技巧


在应用启动时提前初始化WebView并进行预加载,可以减少WebView首次加载页面的时间。可以在应用的启动过程中将WebView加入到IdelHandler中,等到主线程空闲的时候进行加载。


fun execute() {
// 在主线程空闲的时候初始化WebView
queue.addIdleHandler {
MyWebView(MutableContextWrapper(applicationContext)).apply {
// 设置WebView的相关配置
settings.javaScriptEnabled = true
// 进行预加载
loadUrl("about:blank")
}
false
}
}

延迟加载


延迟加载是指将一些非首屏必需的操作推迟到首屏显示后再执行。通过延迟加载,可以减少首屏加载时间,提升用户体验。例如,可以在首屏加载完成后再发起一些后台网络请求、埋点,或者在用户首次交互后再执行一些JavaScript操作。


// 延迟2秒执行上报埋点
Handler().postDelayed({
// 上报启动统计
reportStart()
}, 2000)

请求优化


并行请求


在加载H5页面时,通常会先加载模板文件,然后再获取动态数据填充到模板中。为了提升加载速度,可以在H5加载模板文件的同时,由Native端发起请求获取正文数据。一旦数据获取成功,Native端通过JavaScript将数据传递给H5页面,H5页面再将数据填充到模板中,从而实现并行请求,减少总耗时。


// 在加载模板文件时,同时发起正文数据请求
webView.loadUrl("file:///android_asset/template.html")

// 获取正文数据
val contentData = fetchDataFromServer()

// 将数据传递给H5页面
webView.evaluateJavascript("javascript:handleContentData('" + contentData + "')", null)

拦截请求


可以通过自定义WebViewClient来拦截WebView的请求。重写shouldInterceptRequest方法,可以拦截所有WebView的请求,然后进行相应的处理。


override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
// 在此处实现请求拦截的逻辑
if (needIntercept(request)) {
// 拦截请求,返回自定义的WebResourceResponse或者null
return interceptRequest(request)
} else {
// 继续原始请求
return super.shouldInterceptRequest(view, request)
}
}

缓存优化


WebView缓存池


WebView缓存池是一组预先创建的WebView实例,存储在内存中,并在需要加载网页时从缓存池中获取可用的WebView实例,而不是每次都创建新的WebView。这样可以减少初始化WebView的时间和资源消耗,提高WebView的加载速度和性能。


private const val MAX_WEBVIEW_POOL_SIZE = 5
private val webViewPool = LinkedList()

fun getWebView(): WebView {
synchronized(webViewPool) {
if (webViewPool.isEmpty()) {
return MyWebView(MutableContextWrapper(MyApp.applicationContext()))
} else {
return webViewPool.removeFirst()
}
}
}

fun recycleWebView(webView: WebView) {
synchronized(webViewPool) {
if (webViewPool.size < MAX_WEBVIEW_POOL_SIZE) {
webViewPool.addLast(webView)
} else {
webView.destroy()
}
}
}

缓存策略


WebView提供了缓存机制,可以减少重复加载相同页面的时间。可以通过设置WebView的缓存模式来优化加载速度,如使用缓存或者忽略缓存。示例代码如下:


// 在WebView的初始化代码中启用缓存
webView.settings.cacheMode = WebSettings.LOAD_CACHE_ELSE_NETWORK

共享缓存


对于一些频繁访问的数据,如公共的CSS、JavaScript文件等,可以将其缓存到应用的本地存储中,然后在多个 WebView 实例之间共享。


// 从本地存储中加载公共资源并设置给 WebView
webView.loadDataWithBaseURL("file:///android_asset/", htmlData, "text/html", "UTF-8", null)

渲染优化


启用硬件加速


启用硬件加速可以提高WebView的渲染速度,但是在一些低端设备上可能会造成性能问题,因此需要根据实际情况进行选择。


hardwareAccelerated="true" ...>
...


进程优化


启用多进程


WebView的加载和渲染可能会阻塞应用的主线程,影响用户体验。为了提升应用的性能和稳定性,可以考虑将WebView放置在单独的进程中运行,以减轻对主进程的影响。


name=".WebViewActivity"
android:process=":webview_process">
...


其它



  1. DNS优化:也就是域名解析,相同的域名解析成ip系统会进行缓存,保证端上api地址与webview的地址的域名一致,可以减少域名解析的耗时操作。

  2. 静态页面直出:由于在渲染之前有个组装html的过程,为了缩短耗时,让后端对正文数据和前端的代码进行整合,直接给出HTML文件,让其包含了所需的内容和样式,无需进行二次加工,内核可以直接渲染。

  3. http缓存:针对网络请求,增加缓存,例如,添加Cache-Control、Expires、Etag、Last-Modified等信息,定义缓存策略。


结语


以上介绍了一些 Android WebView 启动优化技巧。通过这些优化措施,可以有效提升 WebView 的启动速度,改善用户体验。


作者:午后一小憩
来源:juejin.cn/post/7358289840268116022
收起阅读 »

一种基于MVVM的Android换肤方案

一、背景 目前市面上很多App都有换肤功能,包括会员 & 非会员 皮肤,日间 & 夜间皮肤,公祭日皮肤。多种皮肤混合的复杂逻辑给端上开发同学带来了不少挑战,本文实践了一种基于MVVM的换肤方案,希望能给有以上换肤需求的同学带来帮助。 二、目标 ...
继续阅读 »

一、背景


目前市面上很多App都有换肤功能,包括会员 & 非会员 皮肤,日间 & 夜间皮肤,公祭日皮肤。多种皮肤混合的复杂逻辑给端上开发同学带来了不少挑战,本文实践了一种基于MVVM的换肤方案,希望能给有以上换肤需求的同学带来帮助。


二、目标


一个非会员购买会员后,身份是立刻发生了变更。用户点击了App内的暗夜模式按钮后,需要立刻从白天模式,切换到暗夜模式。基于以上原因,换肤的首要目标应该是及时生效的,不需要重启App.


作为一个线上成熟的产品,对稳定性也是有较高要求的 。所以换肤方案是需要觉得稳定的 ,不能因换肤产生Crash & ANR


通常设计图同学会根据不同的时节设计不同的皮肤,例如春节有对应的春节皮肤、周年庆有周年庆皮肤。所以换肤方案还应该保持一定的动态能力,皮肤可以动态下发 。


三、整体思路


基于以上提到的3大目标之一的 动态化换肤。一个可能的实现方案是把需要换肤的图片放入一个独立的工程内,然后把该工程编译出apk安装包, 在主工程加载该安装包。然后再需要获取资源的时候能够加载到皮肤包内的资源即可


3.1 技术选型


目前市场上有很多换肤方案、基本思路总结如下 :


1、通过反射AssertManager的AddAssertPath函数,创建自己的 Resources.然后通过该 Resources获取资源id ;


2、实现LayoutInflater.Factory2接口来替换系统默认的


@Override
protected void onCreate(Bundle savedInstanceState) {
mSkinInflaterFactory = new SkinInflaterFactory(this);//自定义的Factory
LayoutInflaterCompat.setFactory2(getLayoutInflater(), mSkinInflaterFactory);
super.onCreate(savedInstanceState);
}

该方案在上线后遇到了一些crash,堆栈如下:



该crash暂未找到修复方案,因此需要寻找一种新的稳定的换肤方案。从以上堆栈分析,可能和替换了LayoutInflater.Factory2有关系 。于是新的方案尝试只使用上述方案的第一步骤来获取资源ID,而不使用第二步,即不修改view的创建的逻辑


3.2 生成资源


因为项目本身基于jetpack,基本通过DataBinding实现与数据&View直接的交互。我们不打算替换系统的setFactory2,因为这块的改动涉及面会比较的大,机型的兼容性也比较的不可控,只是hook AssetManager,生成插件资源的Resource。然后我们的xml中就可以编写对应的java代码来实现换肤。


整体流程图如下


流程图 (5).jpg


3.3 获取资源


上面是我们生成Res对象的过程,下面是我们通过该Res获取具体的资源的过程,首先是资源的定义,以下是换肤能够支持的资源种类



  1. drawable

  2. color

  3. dimen

  4. mipmap

  5. string


目前是打算支持这五种的换肤,使用一个ArrayMap<String, SoftReference<ArrayMap<String, Int>>>来存储具体的缓存数据:key是上面的类型,Entry类型为SoftReference<ArrayMap>,是的对应type所有的缓存数据,每一条缓存数据的key是对应的name值与插件资源对应的Id值。例如:


color->
skin_tab->0x7Fxxxx
skin_text->0x7Fxxxx
dimen->
skin_height->0x7Fxxxx skin_width->0x7fxxxx

具体流程如下


流程图 (4).jpg


3.2使用资源


然后我们通过get系列(例如XLSkinManager.getString() :String)方法就能够拿得到对应的插件资源(正常情况下),然后就是使用它。


由于之前项目中已经有了一套会员的UI了(就是在项目中的,不是通过皮肤apk下发的),为了改动较少,就把基础换肤设置为4种,即本地自身不通过换肤插件就可以实现的。



  1. 白天非会员

  2. 夜间非会员

  3. 白天会员

  4. 夜间会员


然后我们的apk可以下发对应的资源修改对应的模式,比如需要修改白天非会员的某一个控件的颜色就下发对应的控件资源apk,然后启用该换肤插件即可。


目前项目提供了一系列的接口提供给xml使用,使用过程



  1. 在xml中设置了之后,会触发到对应View的set方法,最终可以设置到最终的View的对应属性中

  2. 同样的,在需要改属性变更的时候(例如白天切换到页面),我们也只需要修改ViewMode变更该xml中对应的ObservableField即可,或者是在View中注册对应的事件(例如白天到夜间的事件)


因为项目深度使用DataBinding,所以我们就通过自定义View的方式,利用了我们可以直接在xml中使用View的set方法的形式,比如


class DayNightMemberImageView : xxxView{
fun setDayResource(res: Int){
//....
}
}
// 我们就可以在xml中使用该View的dayResource属性
<com.xxx.DayNightMemberImageView
app:dayResource="@{R.color.xxx}"
/>

这样就可以通过传入的Id值,在setDayResource中拿到最终的插件的id值给View设置。具体的例子:


/** 每一种View的基础可用属性,即用于View的属性设置*/
interface IDayNightMember {
// 白天资源
fun setDayResource(res: Int)
//夜间资源
fun setNightResource(res: Int)
// 会员白天
fun setMemberDayResource(res: Int)
// 会员夜间
fun setMemberNightResource(res: Int)
}
// 提供给xml使用的,当该控件可以是不同的会员不同的展示就可以使用该属性
//当该属性变化的时候,View的对应属性也会发生变化
interface IMemberNotify {
fun setMemberFlag(isMember: Boolean?)
}
// 提供给xml使用的,当该控件具有白天,夜间两种模式的样式的时候,可以在xml中设置该属性
//当该属性变化的时候,View的对应属性也会发生变化
interface IDayNightNotify {
fun setDayNight(isDay: Boolean?)
}

然后具体的实现类


class DayNightMemberAliBabaTv :
ALIBABABoldTv, IDayNightNotify, IMemberNotify, IDayNightMember {
private val handle = HandleOfDayNightMemberTextColor(this)
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, -1)
constructor(
context: Context,
attrs: AttributeSet?,
defStyleAttr: Int
) : super(context, attrs, defStyleAttr)
override fun setDayNight(isDay: Boolean?) {

handle.setDayNight(isDay)
}
override fun setMemberFlag(isMember: Boolean?) {
handle.setMemberFlag(isMember)
}
override fun setDayResource(res: Int) {
handle.setDayResource(res)
}
override fun setNightResource(res: Int) {
handle.setNightResource(res)
}
override fun setMemberDayResource(res: Int) {
handle.setMemberDayResource(res)
}
override fun setMemberNightResource(res: Int) {
handle.setMemberNightResource(res)
}
}

其中的HandleOfDayNightMemberTextColor是继承了HandleOfDayNightMember,后者是做了一个优化,避免了一些重复刷新的情况,也会被其他的类复用。


abstract class HandleOfDayNightMember(view: View) :
IDayNightNotify, IMemberNotify, IDayNightMember {
var isDay: Boolean? = null
var isMember: Boolean? = null
// 日,夜,会员字体颜色
var day: Int? = null
var night: Int? = null
// 假如memberHasNight=true,则要有会员日间,会员夜间两种配置
var memberDay: Int? = null
var memberNight: Int? = null
init {
if (!view.isInEditMode) {
isDay = DayNightController.isDayMode()
}
}
/** 检测是否可以刷新,避免无用的刷新 */
open fun detect() {
if (isMember.isTrue()) {
if (memberHasNight) {
if (isDay.isTrue() && memberDay == null) {
return
}
if (isDay.isFalseStrict() && memberNight == null) {
return
}
} else if (!memberHasNight && member == null) {
return
}
} else if (isDay.isTrue() && day == null) {
return
} else if (isDay.isFalseStrict() && night == null) {
return
}
handleResource()
}
override fun setMemberFlag(isMember: Boolean?) {
if (isMember == null) {
return
}
this.isMember = isMember
detect()
}
override fun setDayNight(isDay: Boolean?) {
if (isDay == null) {
return
}
this.isDay = isDay
detect()
}
override fun setDayResource(res: Int) {
this.day = res
if (isDay.isTrue() && isMember.isFalse()) {
handleResource()
}
}
//...代码省略,其他的方法也是类似的

// 获取适合当前的资源
fun getResourceInt(): Int? {
return when {
isMember.isTrue() -> {
if (memberHasNight) {
when {
isDay.isTrue() -> memberDay
isDay.isFalseStrict() -> memberNight
else -> null
}
} else {
member
}
}
isDay.isTrue() -> {
day
}
isDay.isFalseStrict() -> {
night
}
else -> null
}
}
/** 获取资源,告知外部 */
abstract fun handleResource()
}
class HandleOfDayNightMemberTextColor(private val target: TextView) :
HandleOfDayNightMember(target) {
override fun handleResource() {
val textColor = getResourceInt() ?: return
if (textColor <= 0) {
return
}
// 获取皮肤包的资源,假如插件化没有对应的资源或者是未开启换肤或者是换肤失败
// 则会返回当前apk的对应资源
target.setTextColor(XLSkinManager.getColor(textColor))
}
}

目前项目支持的换肤控件



  1. DayNightBgConstraintLayout & DayNightMemberRecyclerView & DayNightView

  2. 对背景支持四种基础样式的换肤,资源类型支持drawable & color

  3. DayNightLinearLayout & DayNightRelativeLayout

  4. (1) 对背景支持四种基础样式的换肤,资源类型支持drawable & color

  5. (2) 支持padding

  6. DayNightMemberAliBabaTv,集成自ALIBABABoldTv,是阿里巴巴的字体Tv

  7. 对字体颜色支持四种基础样式的换肤,资源类型为color

  8. DayNightMemberImageView

  9. 对ImageView的Source支持四种基础样式的换肤,资源类型支持drawable & mipmap

  10. DayNightMemberTextView

  11. (1)对字体颜色支持四种基础样式的换肤,资源类型为color

  12. (2)支持padding

  13. (3) 支持背景换肤,类型为drawable

  14. (4)支持drawableEnd属性换肤,类型为drawable

  15. (5)支持夜间与白天的文字的高亮颜色设置,资源类型为color


3.4 资源组织 方式


目前项目的支持换肤的资源都是每种资源都独立在一个文件中,存放在最底层的base库。换肤的资源都是以skin开头,会员的是以skin_member开头,会员夜间的以skin_member_night,夜间以skin_night开头。



通过sourceSets把资源合并进去


android {
sourceSets {
main {
res.srcDirs = ['src/main/res', 'src/main/res-day','src/main/res-night','src/main/res-member']
}
}
}

四、总结 & 展望


经过上线运行,该方案非常稳定,满足了业务的换肤需求。


该方案使用起来,需要自定义支持换肤的View ,使用起来有一定成本 。一种低成本接入的可能方案是:



  1. 无需自定义View,利用BindingAdapter来实现给View的属性直接设置皮肤的资源,在xml中使用原始的系统View

  2. ViewModel中提供一个theme属性,xml中View的值都通过该属性的成员变量去拿到。


以上优化思路,感兴趣的读者可以去尝试下。该思路也是笔者下一步的优化方向。


作者:货拉拉技术
来源:juejin.cn/post/7314587257956417586
收起阅读 »

为啥微信的更新信息大多数都是:修复已知问题,但是开发中不建议这样。

背景 可能大家平常有意或无意的注意到,微信的更新日志经常是:解决了一些已知问题。 但是开发人员日常开发中,提交的信息一般会避免这样,反而会要求把提交信息写的比较详细。 原因 首先,从用户体验的角度来看,频繁地列出所有已知问题及其修复情况可能会让用户感到困惑或...
继续阅读 »

背景


可能大家平常有意或无意的注意到,微信的更新日志经常是:解决了一些已知问题。


image.png


但是开发人员日常开发中,提交的信息一般会避免这样,反而会要求把提交信息写的比较详细。


原因


首先,从用户体验的角度来看,频繁地列出所有已知问题及其修复情况可能会让用户感到困惑或担忧,尤其是当这些问题涉及到隐私、安全等敏感话题时。其次,微信作为一个庞大的社交平台,其功能众多,更新日志如果详细到每一项改动,不仅对普通用户来说难以理解,也会增加开发团队的工作量。此外,微信的更新往往伴随着大量的内部优化和结构调整,这些内容对于普通用户而言并不重要,也不易被察觉。


而对于开发人员来说,commit信息一是给自己以后看,通过提交信息就可以知道自己修改了哪些内容,其二就是给其他开发人员查看,从而知道别人修改了哪些地方。


查看提交日志


那么关于微信相关的我们不再赘述,主要针对开发人员的提交信息进行一些讨论,比如如何查看提交信息呢,在idea中可以直接查看git log,也可以通过命令git log来进行查看,或者也可以使用命令git show commitHash针对每一个提交信息进行详细的查看,例如:


$ git show 32557725d91403ca8e5ae520a5f82a516791f5c0
commit 32557725d91403ca8e5ae520a5f82a516791f5c0
Author: test1 <test1@some.com>
Date: Wed Mar 20 16:53:56 2024 +0800

b5 commit

diff --git a/b.txt b/b.txt
index 86bd041..be62feb 100644
--- a/b.txt
+++ b/b.txt
@@ -2,4 +2,6 @@
22222

33333
-44444
\ No newline at end of file
+44444
+
+55555
\ No newline at end of file

本人代码提交方式


关于代码提交规范,相关的文章有很多,我在此先不多说,只是把我平常所用到的描述一下,大家可以参考。


针对每次的功能涉及到几个方面:代码优化,新功能开发,bug修复等。



  • 针对新功能开发:一般是git commit xxxx.java -m 'feat:用户登录限制只允许特定IP地址来登录管理员账号'

  • 针对bug修复:一般是git commit xxx.java -m 'fix:用户登录后看不到自己的工作任务'
    针对

  • 针对代码优化:,则是git commit xxx.sql -m 'refactor:把原来不存在的用户显示为ID账号,而非null'


总结


其实写好commit信息有较多好处,不单单是上面提到的个人追溯问题容易以及同事协作简单。以下我列出来我想到的。



  • 通过commit信息方便自己进行日报周报的总结

  • 方便进行某些问题的回退

  • 方便快速的梳理功能,以便于线上环境的验证

  • 有益于自己代码的精简提交,如果多个文件一起提,那么commit信息可能就会更复杂


致谢


以上就是从微信的一个更新日志,进而针对开发人员的commit信息进行了一些简单阐述。感谢你的耐心阅读,如果我的分享对你有所启发或帮助,就给个赞呗,很开心能帮到别人。


作者:bramble
来源:juejin.cn/post/7351726029322928155
收起阅读 »

老板让我用JavaScript实现网页复制成图片到剪贴板

web
李经理在使用飞书时无意中发现,飞书竟然支持一键复制网页内容到剪贴板的功能。 他立即叫来了公司的前端开发小王,兴致勃勃地说: "小王啊,你看,飞书的这个功能多方便!我们公司的协同办公系统是不是也可以实现类似的功能?这样用户体验一定能得到很大提升!" 小王看着李经...
继续阅读 »

李经理在使用飞书时无意中发现,飞书竟然支持一键复制网页内容到剪贴板的功能。


他立即叫来了公司的前端开发小王,兴致勃勃地说:


"小王啊,你看,飞书的这个功能多方便!我们公司的协同办公系统是不是也可以实现类似的功能?这样用户体验一定能得到很大提升!"


小王看着李经理充满expectant的眼神, 虽然内心已经吐槽"就这点功能至于吗", 但表面上还是恭恭敬敬地回答:


"老板英明,这个功能确实很实用。技术上应该不难实现,主要就是用Clipboard API写几行代码的事。我这就去安排!"


Xnip2024-03-21_11-42-26.jpg


回到工位后,小王苦笑着摇摇头,找来相关文档开始翻阅,暗暗发誓一定要把这个"划时代"的功能做好.


小王找来了领导说的飞书文档复制网页内容的功能, 如下:


Untitled.png


小王思考了片刻…


功能拆解:


要实现这个功能, 要拆分为4个步骤:



  1. 获得选中内容所属的 div

  2. 把选中内容的div 转换成canvas

  3. 转换canvas到二进制图像

  4. 复制二进制图像到剪贴板


由于小王的业务只需要复制固定区域的div, 所以第一步可以忽略, 简化成:


  const element = document.getElementById("target");

转换div成 canvas:


时间已经很晚了, 小王咳了一杯咖啡, 继续奋战. 小王苦思冥想, 要怎么把div转换成 canvas. 他琢磨:



  1. 递归遍历 DOM 树:

    • 会从指定的根元素开始,递归遍历整个 DOM 树。

    • 对于每个遇到的元素, 分析其样式、位置、大小等属性。



  2. 处理样式和布局:

    • 通过读取元素的 CSS 样式,如颜色、背景、边框等, 复制元素的视觉表现。

    • 它会计算元素的盒模型、定位、层叠等布局信息,以确定元素在最终图片中的位置。





小王这时候已经觉得很累了, 于是索性打开浏览器搜索, 结果第一页就看到了: html2canvas. 他看了一眼, github 29K stars. 他查看了一下调用api:


html2canvas(document.body).then(function(canvas) {
document.body.appendChild(canvas);
});

它正是小王需要的!


于是小王在项目中命令行输入:


npm install --save html2canvas

然后小王在业务代码中敲下了:


function copyDivToImage() {
const element = document.getElementById("target");
html2canvas(element).then(canvas => {
// canvas 拿到了, 然后呢
}
}

转换canvas到二进制图像


小王犹豫, 为什么要转成二进制图像呢, 我直接复制 base64 字符不行吗. 不过很快, 小王就意识到了, 剪贴版API 不支持base64字符串的类型. 于是他翻开 mdn 文档:


HTMLCanvasElement: toBlob() method - Web APIs | MDN (mozilla.org)



function copyDivToImage() {
const element = document.getElementById("target");
html2canvas(element).then(canvas => {
canvas.toBlob(
(blob) => {
// 复制文件到剪贴板
},
"image/jpeg", // 文件的格式
1 // 图像压缩质量 0-1
);
});
}

复制二进制图像到剪贴板


这一步小王已经先前看过 MDN 文档了, ClipboardItem - Web APIs | MDN (mozilla.org) 可以直接调用浏览器的 navigator api :



function copyDivToImage() {
const element = document.getElementById("target");
html2canvas(element).then(canvas => {
canvas.toBlob(
(blob) => {
// 复制文件到剪贴板
try {
await navigator.clipboard.write([
// eslint-disable-next-line no-undef
new ClipboardItem({
[blob.type]: blob
})
]);
console.log("图像已成功复制到剪贴板");
} catch (err) {
console.error("无法复制图像到剪贴板", err);
}
},
"image/jpeg", // 文件的格式
1 // 图像压缩质量 0-1
);
});
}

小王遇到挫折


所有代码已经就绪, 小王随即启动项目, 运行他刚刚编写好的完美的代码. 不出所料, 他遇到了挫折:


Untitled 1.png


小王看到这个报错, 完全没有头绪, 幸好有多年的开发经验, 他遇到这种问题的时候并没有慌张, 内心想, “第一次跑通常这样!”. 随即他打开百度搜索, 有一个回答引起了小王的注意:


Untitled 2.png


原来, 小王是在 http 环境调试的, 他修改了代理的配置, 换成了 https 环境下调试本地代码.


然而让小王没有想到的是, 程序还是没有如期运行, 小王遇到了第二个挫折:


Untitled 3.png


小王崩溃了 “这是什么鬼. 明明都是按照API文档写的!”


Untitled 4.png


原来, 浏览器剪贴板对 jpeg的支持不大好, 于是小王把 canvas.toBlob() 的参数改成了 "image/png”.


他再次运行代码, 他成功了:


Untitled 5.png


小王欣喜地把这个消息告诉了李经理.


功夫不负有心人,凭借扎实的JavaScript功底,小王很快就实现了一个简洁优雅的"一键复制"功能,并成功集成到公司的协同办公系统中。


李经理在看到小王的杰作后非常满意,当即表扬了小王的能力和效率,并承诺会在年终绩效考核中给予小王优秀评级,同时还暗示未来会给小王升职加薪的机会。小王听后喜上眉梢,他明白自己的努力和才能得到了老板的认可。


这次经历不仅巩固了小王在公司中的地位,更坚定了他在前端开发领域继续钻研的决心。他暗自庆幸,幸亏当初学习JavaScript时没有偷懒,才能在关键时刻派上用场,赢得了老板的青睐。


从此以后,小王在技术方面更加勤奋刻苦,也更加善于捕捉用户需求和痛点,设计出更多优秀的功能和体验。他逐渐成长为团队中不可或缺的核心成员,并最终如愿晋升为高级前端开发工程师,走上了实现自我价值和理想的康庄大道。


作者:ziolau
来源:juejin.cn/post/7348634049681293312
收起阅读 »

如何在HTML中使用JavaScript:从基础到高级的全面指南!

JavaScript是一种轻量级的编程语言,通常用于网页开发,以增强用户界面的交互性和动态性。然而在HTML中,有多种方法可以嵌入和使用JavaScript代码。本文就带大家深入了解如何在HTML中使用JavaScript。一、使用 script 标签要在HT...
继续阅读 »

JavaScript是一种轻量级的编程语言,通常用于网页开发,以增强用户界面的交互性和动态性。然而在HTML中,有多种方法可以嵌入和使用JavaScript代码。本文就带大家深入了解如何在HTML中使用JavaScript。

一、使用 script 标签

要在HTML中使用JavaScript,我们需要使用<script>标签。这个标签可以放在<head>或<body>部分,但通常我们会将其放在<body>部分的底部,以确保在执行JavaScript代码时,HTML文档已经完全加载。

Description

使用 <script> 标签有两种方式:

  • 直接在页面中嵌入 JavaScript 代码和包含外部 JavaScript 文件。

  • 包含在 <script> 标签内的 JavaScript 代码在浏览器总按照从上至下的顺序依次解释。

所有 <script> 标签都会按照他们在 HTML 中出现的先后顺序依次被解析。


HTML为 <script> 定义了几个属性:

1)async: 可选。表示应该立即下载脚本,但不妨碍页面中其他操作。该功能只对外部 JavaScript 文件有效。

如果给一个外部引入的js文件设置了这个属性,那页面在解析代码的时候遇到这个<script>的时候,一边下载该脚本文件,一边异步加载页面其他内容。

2)defer: 可选。表示脚本可以延迟到整个页面完全被解析和显示之后再执行。该属性只对外部 JavaScript 文件有效。

3)src: 可选。表示包含要执行代码的外部文件。

4)type: 可选。表示编写代码使用的脚本语言的内容类型,目前在客户端,type属性值一般使用 text/javascript。

不过这个属性并不是必需的,如果没有指定这个属性,则其默认值仍为text/javascript。


1.1 直接在页面中嵌入JavaScript代码

内部JavaScript是将JavaScript代码放在HTML文档的<script>标签中。这样可以将JavaScript代码与HTML代码分离,使结构更清晰,易于维护。


在使用<script>元素嵌入JavaScript代码时,只须为<script>指定type属性。然后,像下面这样把JavaScript代码直接放在元素内部即可:

<script type="text/javascript">
function sayHi(){
alert("Hi!");
}
</script>

如果没有指定script属性,则其默认值为text/javascript。


包含在<script>元素内部的JavaScript代码将被从上至下依次解释。在解释器对<script>元素内部的所有代码求值完毕以前,页面中的其余内容都不会被浏览器加载或显示。

在使用<script>嵌入JavaScript代码的过程中,当代码中出现"</script>"字符串时,由于解析嵌入式代码的规则,浏览器会认为这是结束的</script>标签。可以通过转义字符“\”写成</script>来解决这个问题。

1.2包含外部JavaScript文件

外部JavaScript是将JavaScript代码放在单独的.js文件中,然后在HTML文档中通过<script>标签的src属性引用这个文件。这种方法可以使代码更加模块化,便于重用和共享。

如果要通过<script>元素来包含外部JavaScript文件,那么src属性就是必需的。

这个属性的值是一个指向外部JavaScript文件的链接。

<script type="text/javascript" src="example.js"></script>
  • 外部文件example.js将被加载到当前页面中。

  • 外部文件只须包含通常要放在开始的<script>和结束的</script>之间的那些JavaScript代码即可。

与解析嵌入式JavaScript代码一样,在解析外部JavaScript文件(包括下载该文件)时,页面的处理也会暂时停止。

注意: 带有src属性的<script>元素不应该在其<script>和</script>标签之间再包含额外的JavaScript代码。如果包含了嵌入的代码,则只会下载并执行外部脚本文件,嵌入的代码会被忽略。

通过<script>元素的src属性还可以包含来自外部域的JavaScript文件。它的src属性可以是指向当前HTML页面所在域之外的某个域中的完整URL。

<script type="text/javascript" src="http://www.somewhere.com/afile.js"></script>

于是,位于外部域中的代码也会被加载和解析。


1.3 标签的位置

在HTML中,所有的<script>标签会按照它们出现的先后顺序被解析。在不使用defer和async属性的情况下,只有当前面的<script>标签中的代码解析完成后,才会开始解析后面的<script>标签中的代码。

通常,所有的<script>标签应该放在页面的<head>标签中,这样可以将外部文件(包括CSS和JavaScript文件)的引用集中放置。

然而,如果将所有的JavaScript文件都放在<head>标签中,会导致浏览器在呈现页面内容之前必须下载、解析并执行所有JavaScript代码,这可能会造成明显的延迟,导致浏览器窗口在加载过程中出现空白。

为了避免这种延迟问题,现代Web应用程序通常会将所有的JavaScript引用放置在<body>标签中的页面内容的后面。这样做可以确保在解析JavaScript代码之前,页面的内容已经完全呈现在浏览器中,从而加快了打开网页的速度。


二、执行JavaScript 程序

JavaScript 解析过程包括两个阶段:预处理(也称预编译)和执行。

Description

  • 在编译期,JavaScript 解析器将完成对 JavaScript 代码的预处理操作,把 JavaScript 代码转换成字节码;

  • 在执行期,JavaScript 解析器把字节码生成二进制机械码,并按顺序执行,完成程序设计的任务。

1、执行过程

HTML 文档在浏览器中的解析过程是:按照文档流从上到下逐步解析页面结构和信息。

JavaScript 代码作为嵌入的脚本应该也算做 HTML 文档的组成部分,所以 JavaScript 代码在装载时的执行顺序也是根据 <script> 标签出现的顺序来确定。

想要快速入门前端开发吗?推荐一个前端开发基础课程,这个老师讲的特别好,零基础学习无压力,知识点结合代码,边学边练,可以免费试看试学,还有各种辅助工具和资料,非常适合新手!点这里前往学习哦!

2、预编译

当 JavaScript 引擎解析脚本时候,他会在与编译期对所有声明的变量和函数预先进行处理。当 JavaScript 解析器执行下面脚本时不会报错。

alert(a);    //返回值 undefined
var a = 1;
alert(a); //返回值 1

由于变量声明是在预编译期被处理的,在执行期间对于所有的代码来说,都是可见的,但是执行上面代码,提示的值是 undefined 而不是 1。

因为变量初始化过程发生在执行期,而不是预编译期。在执行期,JavaScript 解析器是按照代码先后顺序进行解析的,如果在前面代码行中没有为变量赋值,则 JavaScript 解析器会使用默认值 undefined 。

由于第二行中为变量 a 赋值了,所以在第三行代码中会提示变量 a 的值为 1,而不是 undefined。

fun();    //调用函数,返回值1
function fun(){
alert(1);
}

函数声明前调用函数也是合法的,并能够正确解析,所以返回值是 1。但如果是下面这种方式则 JavaScript 解释器会报错。

fun();    //调用函数,返回语法错误
var fun = function(){
alert(1);
}

上面的这个例子中定义的函数仅作为值赋值给变量 fun 。在预编译期,JavaScript 解释器只能够为声明变量 fun 进行处理,而对于变量 fun 的值,只能等到执行期时按照顺序进行赋值,自然就会出现语法错误,提示找不到对象 fun。

总结: 声明变量和函数可以在文档的任意位置,但是良好的习惯应该是在所有 JavaScript 代码之前声明全局变量和函数,并对变量进行初始化赋值。在函数内部也是先声明变量,后引用。

通过今天的分享,相信大家已经对JavaScript在HTML中的应用有了一定的了解。这只是冰山一角,JavaScript的潜力远不止于此。

希望这篇文章能激发大家对编程的热情,让我们一起在编程的世界里探索更多的可能性!

收起阅读 »

Android -- 投屏

本文从Android开发者的角度出发,介绍投屏的方式、常见的一些投屏方案及应用,最后详细介绍适合在Android手机上应用的一套方案:DLNA。 1. 投屏方式 按照投屏后,展示端的数据来源,可以划分成两种主要的方式:推送投屏(Screencasting)...
继续阅读 »

本文从Android开发者的角度出发,介绍投屏的方式、常见的一些投屏方案及应用,最后详细介绍适合在Android手机上应用的一套方案:DLNA。



1. 投屏方式



  • 按照投屏后,展示端的数据来源,可以划分成两种主要的方式:推送投屏(Screencasting)镜像投屏(ScreenMirroring)

  • 通过一个表格看看两者的区别


    推送投屏镜像投屏
    数据源发送端向展示端发送url,此后由展示端从服务器获取媒体数据。展示端从发送端获取数据,实时展示发送端画面。
    优缺点投屏后,手机使用不受限制,可以离开当前页面进行其他活动。但部分资源如文本等可能无法投屏。实时展示手机画面,可以突破资源使用限制。但是手机不能离开当前页面。
    使用场景投屏多为音视频资源,主要应用在娱乐场景。如爱奇艺视频投屏等app内置投屏。因不受资源限制,可以进行ppt展示等,主要应用在办公场景。如mac镜像等。



2. 投屏方案



  • 2.1 Airplay

    • Airplay是Apple推出的无线显示标准,因此其应用主要局限于Apple的生态之中。国内也有电视厂商实现了对Airplay的破解,使得Apple设备可以投屏到安卓电视上。

    • 支持推送投屏和镜像投屏。



  • 2.2 DLNA

    • DLNA(Digital Living Network Alliance)是Sony于2003年发起的非营利性标准化组织,旨在制定在局域网内部进行多媒体文件及其信息共享的通信协议标准。DLNA的应用范围比较广泛,涵盖数字媒体设备、数字电视、车载娱乐等领域。大部分App内置投屏就是用的这个方案。

    • 支持推送投屏。



  • 2.3 Miracast

    • Miracast是2012年首次由WiFi Alliance发布,其底层采用了WiFi Direct技术(点对点无线技术),因此不需要通过路由器,可以直接在两个设备之间建立P2P连接。目前对Miracast支持最好的生态就是MircoSoft的Windows系统了。

    • 支持镜像投屏。



  • 2.4 Chromecast

    • Google推出了ChromecastChromecast是一款插在电视机HDMI接口上的无线设备,内置WIFI,可以通过WIFI与其他设备连接以及访问外网,类似一个迷你机顶盒。为了推广Chromecast,Google还专门推出GoogleCastSDK帮助APP开发者整合Chromecast的推送功能。不过GoogleCastSDK依赖于Google服务,在国内受到限制。

    • 支持推送投屏。



  • 2.5 乐播投屏

    • 乐播投屏是一套投屏技术方案,据官网称市面上90%的电视已采用乐播乐联协议,App开发者只需要接入乐播发送端SDK,即可同时支持AirplayDLNA乐联(lelink)等协议,兼容程度较高。但从2022年5月30日起,乐播投屏停止对旧版SDK的维护,新版SDK需要收费,按投屏日活进行收费。

    • 详细内容可上官网查看:乐播




3. DLNA详解


DLNA(Digital Living Network Alliance)是一个组织并不是一个协议,这个组织定义了一套由基础协议组成的标准,所以常用DLNA指代投屏的一种实现方案。


DLNA是目前大部分电视机支持的投屏方案,不需要收费且有一些成熟的开源框架支持开发者接入,支持的投屏方式为推送投屏,满足App投屏的需要。下面从几个角度详细介绍DLNA



  • 3.1 核心协议 DLNA依赖UPnP协议来完成发现设备、描述设备、控制设备;而UPnP协议依赖于SSDP协议来完成发现设备。下面简单了解下这两个协议。



    • UPnP
      UPnP是一种网络协议,其全称为“通用即插即用协议”(Universal Plug and Play)。它是一种基于TCP/IP协议栈的协议。它的主要目的是让网络中的不同设备能够自动发现和连接其他设备,从而实现网络设备间的通信和协作。


      UPnP的应用场景包括打印共享、音视频传输、远程控制等,适用于各种不同类型的网络设备,包括计算机、路由器、打印机、摄像头、音频设备等。


      UPnP的核心是定义了一系列标准化的协议和接口,包括设备发现服务描述设备控制等,让支持协议的设备能够自动发现和连接其他设备。


    • SSDP
      SSDP是一种基于UDP协议的网络协议,全称为“简单服务发现协议”(Simple Service Discovery Protocol)。它的主要目的是让网络中的设备能够自动发现和连接其他设备,从而实现设备间的通信和协作。


      SSDP适用于各种不同类型的网络设备,应用场景包括UPnP、AirPlay等。


      SSDP的核心是通过广播消息来实现设备的发现和服务的注册。




  • 3.2 核心角色 利用DLNA的体系,我们可以连接不同的网络设备(下面简称CP(Control Point))。在不同CP间进行数据传输、展示,这个过程中就分化出不同的角色:发送控制端DMC、接收端DMR、数据存储服务端DMS



    • DMRDigital Media Render 顾名思义就是渲染展示媒体数据的一端,比如我们的电视机。

    • DMSDigital Media Server 用于保存音视频文件的存储服务器,属于比较范的一个概念。

    • DMCDigital Media Controller 用于发现和控制的中间设备,发现局域网中存在的DMR,然后把DMS上的资源推送到DMR上进行播放。


    如果要把手机上的照片、视频文件推动到局域网内的电视上播放出来,手机就承担了DMS+DMC的角色,而电视则是一个DMR设备;


    而如果要在手机上控制电视播放B站的视频,手机就是DMC的角色,而电视就是DMR设备。


  • 3.3 投屏的主要步骤 了解核心协议、核心角色后,我们接下来以投屏为例,从DMC的角度出发,看看DMC如何发现、控制DMR,完成投屏。



    • 发现设备 当一个新的CP加入一个局域网时,为了获取当前网段里都有哪些智能设备,CP需要遵循SSDP向默认多播IP和端口发送获取信息的请求。请求的格式如下:


      M-SEARCH * HTTP/1.1

      MX: 1 //最大时间间隔数

      ST: upnp:rootdevice //搜索的设备类型

      MAN: "ssdp:discover"

      User-Agent: iOS 10.2.1 product/version

      Connection: close

      Host: 239.255.255.250 //多播地址

      如果请求成功则返回如下信息:


      HTTP/1.1 200 OK

      Cache-control: max-age=1800

      Date: Thu, 16 Feb 2017 09:09:45 GMT

      LOCATION: http://10.2.9.152:49152/TxMediaRenderer_desc.xml //URL for UPnP description for device

      ... 省略不重要的信息

      ST: upnp:rootdevice //device type

      其中LOCATION代表一个xml文件的链接,这个文件详细描述了当前局域网内CP的信息。


      至此投屏的第一步完成,新加入的CP可以发现其他CP


    • 描述设备 在上一步中,我们得到了一个xml链接,xml文件的内容如下:


      <root xmlns="urn:schemas-upnp-org:device-1-0" xmlns:dlna="urn:schemas-dlna-org:device-1-0" configId="499354">
      ...
      <device>
      <deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
      <friendlyName>卧室的创维盒子Q+</friendlyName>
      ...
      <dlna:X_DLNADOC xmlns:dlna="urn:schemas-dlna-org:device-1-0">DMR-1.50</dlna:X_DLNADOC>
      <serviceList>
      <service>
      <serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
      <serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
      <SCPDURL>/AVTransport/9c443d47158b-dmr/scpd.xml</SCPDURL>
      ...
      </service>
      ...
      </serviceList>
      </device>
      <device>
      ...
      </device>
      </root>


      • device device描述了一个CP的信息,每个device对应局域网内的一个CPdevice下的deviceType描述了当前CP的类型,MediaRenderer代表当前CP可以作为DMR用于展示媒体资源。

      • service service描述了当前CP支持的服务,一般会有多个。serviceSCPDURL指向另外一个xml文件,这个文件描述了当前service下支持的操作,如暂停、播放、快进等。


      至此投屏的第二步完成,我们获取到CP的详细描述,包括设备的类型、支持的服务等。


    • 控制设备 在上一步中,我们得到了SCPDURL这个xml链接,xml文件的内容如下:


      <scpd xmlns="urn:schemas-upnp-org:service-1-0">
      ...
      <actionList>
      <action>
      <name>SetAVTransportURI</name>
      <argumentList>
      <argument>
      <name>InstanceID</name>
      <direction>in</direction>
      <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
      </argument>
      <argument>
      <name>CurrentURI</name>
      <direction>in</direction>
      <relatedStateVariable>AVTransportURI</relatedStateVariable>
      </argument>
      <argument>
      <name>CurrentURIMetaData</name>
      <direction>in</direction>
      <relatedStateVariable>AVTransportURIMetaData</relatedStateVariable>
      </argument>
      </action>
      ...
      </scpd>


      • action 每个action代表一个操作,actionargument代表当前操作支持的参数。如上面的SetAVTransportURI就是设置媒体的url。


      执行这些action,需要按要求发起请求,请求的格式可以参考基于DLNA的移动端网络视频投屏技术初探


      至此投屏的第三步完成,我们知道目标CP支持哪些操作,利用这些操作便可以完成我们的投屏及控制。这个过程中发起投屏的CP便是DMC,展示媒体资源的CP便是DMS




  • 3.4 相关开源框架 前面我们了解了DLNA的原理,DLNA涉及的协议还是比较复杂的,人为的处理这些请求和响应,是比较麻烦的。所以社区中也有一些基于DLNA的第三方框架可供我们使用,如:



    • Platinum 是基于UPnPC++框架。

    • cling 是基于UPnPjava框架,对UPnP进行了简单的封装,不支持纯ipv6的网络。

    • cybergarage-upnp 是基于UPnPjava框架,对UPnP进行了简单的封装,不过代码结构不如cling且存在getAction方法返回一直为空的问题,需要自己把jar包拉下来,然后修改其中的代码。

    • DLNA-Cast 这个是目前发现比较完善的框架,是对cling的进一步封装,使用体验更好,目前还有在更新迭代,推荐使用。



  • 3.5 安全性问题






注意:DLNA基于UPnP,需要进行组内广播。如果某WIFI环境下一直搜不到设备,可能是WIFI不支持广播,可以切换WIFI环境再尝试。



参考文章



作者:小白鸽本鸽
来源:juejin.cn/post/7272566178446884923
收起阅读 »

CSS如何优雅的实现卡片多行排列布局?

web
欢迎关注本专栏,会经常分享一些简单实用的技巧! 感谢各位大佬点赞!关注我,学习实用前端知识! 需求简介 在前端开发中,我们经常遇见这样的开发需求,实现下列以此排布的卡片,这些卡片宽度一般是固定的, 并且在不同大小的屏幕宽度下自动换行。 实际开发中遇到的问...
继续阅读 »

欢迎关注本专栏,会经常分享一些简单实用的技巧!



感谢各位大佬点赞!关注我,学习实用前端知识!


需求简介


在前端开发中,我们经常遇见这样的开发需求,实现下列以此排布的卡片,这些卡片宽度一般是固定的,



并且在不同大小的屏幕宽度下自动换行。



实际开发中遇到的问题


实现这样的一个需求其实不难,我们很容易想到设置一个安全宽度(如下图绿色),然后进行弹性布局。



一个很容易写出的代码是这样的:





使用flex弹性布局,我们很看似轻松的实现了需求。但是,当我们将卡片数量减少一个,问题就出现了




由于我们使用了justify-content: space-between;的布局方式,4,5卡片左右对称布局,这显然不符合我们的要求!



聪明的人,可能会把justify-content: space-between改成align-content: space-between





这样的确会让卡片以此排列,但是没了右边距!因此,你可能会手动加上右边距




你会尴尬的发现换行了,因为两个卡片的宽度加元素的右边距之和大于你设置的安全宽度了!



当然,你可以让每个卡片的右边距小一点,这样不会换行,但是,右边的元素永远无法贴边了!



如何解决这个问题


想解决上的问题,也有很多方法。


如果永远是第3n的元素是最后一列,这个问题非常容易解决:


.container{
display: flex;
width:630px;
align-content: space-between;
flex-flow: wrap;
.crad{
height:100px;
background: blueviolet;
width:200px;
margin-bottom: 16px;
margin-right: 16px;
&:nth-child(3n) {
margin-right: 0;
}
}
}

4n,5n,6n我们都可以用这样的方式解决!


但如果安全宽度是变化的(630px不固定),比如随着浏览器尺寸的变化,每行的卡片数量也变化,上述方式就无法解决了。



此时,我们可以用下面的方法:


我们可以在绿色盒子外在套一个红色盒子,超出红色盒子的部分隐藏即可


代码如下






上述代码中,我们的container元素设置了width: calc(100% + 16px)保证其比父元素多出16px的容错边距,然后我们给红色盒子设置了overflow: hidden,就避免了滚动条出现。


完美解决了这个布局问题!


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

autolog.js:一个小而美的toast插件。

web
前言 最近需要做一个关于自动解析矢量瓦片链接地址的内部Demo,这个demo比较简单,所以没有准备引入任何的第三方UI库,所以遇到了一个小问题,toast提示怎么做? 如果像往常一样,我肯定直接用 alert 了,但是一是 alert 会中断体验,不够友好,二...
继续阅读 »

前言


最近需要做一个关于自动解析矢量瓦片链接地址的内部Demo,这个demo比较简单,所以没有准备引入任何的第三方UI库,所以遇到了一个小问题,toast提示怎么做?


如果像往常一样,我肯定直接用 alert 了,但是一是 alert 会中断体验,不够友好,二是不适用于多个提示共同出现,三是无法区分提示类型,所以我就想着找一个体积小的三方库来实现,但是找来找去,发现没有一个库能入我法眼。


在网上搜索,好像独立的 toast 插件停留在了 jq 时代,靠前的 toast 库居然是 bootstrap 的。所以我决定自己写一个,又小巧,又易用的 toast 插件。


纯 JS 实现


延续 autofit.js 的传统,我依然准备用纯 js 实现,以达到极致的体积、极致的兼容性。此外,还编写了d.ts,支持TS。


autolog.js 诞生了。


image.png


它由两部分构成,一个极简单的js,和一个极简单的css。gzip后体积是1.40kb。


在线体验:larryzhu-dev.github.io/autoLarryPa…


js部分(共37行)


const autolog = {
log(text, type = "log", time = 2500) {
if (typeof type === "number") {
time = type;
type = "log";
}
let mainEl = getMainElement();
let el = document.createElement("span");
el.className = `autolog-${type}`;
el.innerHTML = text;
mainEl.appendChild(el);
setTimeout(() => {
el.classList.add("hide");
}, time - 500);
setTimeout(() => {
mainEl.removeChild(el);
el = null;
}, time);
},
};
function getMainElement() {
let mainEl = document.querySelector("#autolog");
if (!mainEl) {
mainEl = document.createElement("div");
mainEl.id = "autolog";
document.body.appendChild(mainEl);
}
return mainEl;
}
export default autolog;


以上是 autolog.js的全部 js 代码。可以看到只导出了一个 log 方法,而调用此方法,也只需要必填一个参数。


我来讲一下这段代码干了一件什么事



  1. 因为有两个可选参数,所以第一步判断一下传了哪个可选参数,这可以在使用时,只传time或者type。

  2. 获取主容器,getMainElement 方法返回一个主容器,若主容器不存在,就创建它,这省去了用户手动创建主容器的过程,一般的插件会导出一个 init 方法初始化,这一步可以省去 init 操作。

  3. 创建一个 span 标签用于展示 log 内容。

  4. 两个定时器,第一个在清除元素的前 0.5 秒为其添加退场动画,第二个清除元素,el = null 可以保证断开引用,防止产生游离dom,防止内存泄漏。


最重要的在于css部分,css承载了最重要的显示逻辑。


css部分(共100行)


@font-face {
font-family: "iconfont"; /* Project id 4507845 */
src: url("//at.alicdn.com/t/c/font_4507845_4ys40xqhy9u.woff2?t=1713154951707")
format("woff2"),
url("//at.alicdn.com/t/c/font_4507845_4ys40xqhy9u.woff?t=1713154951707")
format("woff"),
url("//at.alicdn.com/t/c/font_4507845_4ys40xqhy9u.ttf?t=1713154951707")
format("truetype");
}
#autolog {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
pointer-events: none;
width: 100vw;
height: 100vh;
position: fixed;
left: 0;
top: 0;
z-index: 9999999;
cursor: pointer;
transition: 0.2s;
}
#autolog span {
pointer-events: auto;
width: max-content;
animation: fadein 0.4s;
animation-delay: 0s;
border-radius: 6px;
padding: 10px 20px;
box-shadow: 0 0 10px 6px rgba(0, 0, 0, 0.1);
margin: 4px;
transition: 0.2s;
z-index: 9999999;
font-size: 14px;
height: max-content;
background-color: #fafafa;
color: #333;
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#autolog span::before {
padding-right: 4px;
}
#autolog span.autolog-warn,
#autolog span.autolog-warning {
background-color: #fffaec;
color: #e29505;
}
#autolog span.autolog-warn::before,
#autolog span.autolog-warning::before {
content: "\e682";
}
#autolog span.autolog-error {
background-color: #fde7e7;
color: #d93025;
}
#autolog span.autolog-error::before {
content: "\e66f";
}
#autolog span.autolog-info {
background-color: #e6f7ff;
color: #0e6eb8;
}
#autolog span.autolog-info::before {
content: "\e668";
}
#autolog span.autolog-success,
#autolog span.autolog-ok,
#autolog span.autolog-done {
background-color: #e9f7e7;
color: #1a9e2c;
}
#autolog span.autolog-success::before,
#autolog span.autolog-ok::before,
#autolog span.autolog-done::before {
content: "\e67f";
}
#autolog span.hide {
opacity: 0;
pointer-events: none;
transform: translateY(-10px);
height: 0;
padding: 0;
margin: 0;
}
@keyframes fadein {
0% {
opacity: 0;
transform: translateY(-10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}

css部分正正好好 100 行代码,从上到下分别是:iconfont 字体图标链接、主容器样式、各类型提示框的样式、退场类,入场动画。


由此可见,你也可以重写这些css,为他们添加不同的 icon、颜色。


没有什么巧妙的设计,也没有什么精致的构思,朴实无华的一百多行代码而已,希望这些代码可以帮到各位。


安装和使用


使用也非常简单,只需引入两个文件。


安装


npm i autolog.js

引入css(引入一次即可)


在js中引入


import 'autolog.js/autolog.css'

在css中引入


@import url('autolog.js/autolog.css');

使用


import aotolog from "autolog.js";

autolog.log("Hi,this is a normal tip");
autolog.log("Hello World", "success", 2500);
// 其中 "success" 和 2500 都是可选项

Github Link:github.com/LarryZhu-de…


NPM Link:http://www.npmjs.com/package/aut…


效果图


QQ2024417-122454.webp


在线体验:larryzhu-dev.github.io/autoLarryPa…


作者:德莱厄斯
来源:juejin.cn/post/7358598695267008527
收起阅读 »

这个交互式个人博客能让你眼前一亮✨👀 ?

web
从构思到上线的全过程,开发中遇到一些未知问题,也都通过查阅资料和源码一一解决,小记一下望对正在使用或即将使用Nextjs开发的你们有所帮助。 那些年我开发过的博客 就挺有意思,域名,技术栈和平台的折腾史 2018年使用hexo搭建了个静态博客,部署在gith...
继续阅读 »

2023-08-15 13.21.03.gif


从构思到上线的全过程,开发中遇到一些未知问题,也都通过查阅资料和源码一一解决,小记一下望对正在使用或即将使用Nextjs开发的你们有所帮助。


那些年我开发过的博客


就挺有意思,域名,技术栈和平台的折腾史



  • 2018年使用hexo搭建了个静态博客,部署在github pages

  • 2020年重新写了博客,vuenodejsmongodb三件套,使用nginx部署在云服务器上

  • 2023年云服务器过期了,再一次重写了博客,nextjs为基础框架,部署在vercel


背景


因为日常开发离不开终端,正好也有重写博客的想法,打算开发一个不只是看的博客网站,所以模仿终端风格开发了Yucihent


技术栈


nextjs 更多技术栈


选用nextjs是因为next13更新且稳定了App Router和一些其他新特性。


设计


简约为主,首页为类终端风格,prompt样式参考了starship,也参考过ohmyzsh themes,选用starship因为觉得更好看。


交互


通过手动输入或点击列出的命令进行交互,目前可交互的命令有:



  • help 查看更多

  • listls 列出可用命令

  • clear 清空所有输出

  • posts 列出所有文章

  • about 关于我


后续会新增一些命令,增加交互的趣味。


暗黑模式



基于tailwinddark modenext-themes



首先将tailwinddark mode设置为class,目的是将暗黑模式的切换设置为手动,而不是跟随系统。


// tailwind.config.js

module.exports = {
darkMode: 'class'
}

新建ThemeProvider组件,用到next-themes提供的ThemeProvider,需要在文件顶部使用use client,因为createContext只在客户端组件使用。


'use client'

import { ThemeProvider as NextThemeProvider } from 'next-themes'
import type { ThemeProviderProps } from 'next-themes/dist/types'

export default function ThemeProvider({
children,
...props
}: ThemeProviderProps
) {
return <NextThemeProvider {...props}>{children}</NextThemeProvider>
}

app/layout.tsx中使用ThemeProvider,设置attributeclass,这是必要的。


<ThemeProvider attribute="class">{children}</ThemeProvider>

next-themes提供了useTheme,解构出themesetTheme用于手动设置主题。


综上基本实现暗黑模式切换,但你会在控制台看到此报错信息:Warning: Extra attributes from the server: class,style,虽然它并不影响功能,但终究是个报错。
作为第三方包,可能存在水合不匹配的问题,经查阅资料,禁用ThemeProvider组件预渲染消除报错。


资料:



const NoSSRThemeProvider =
dynamic(() => import('@/components/ThemeProvider'), {
ssr: false
})

<NoSSRThemeProvider attribute="class">{children}</NoSSRThemeProvider>

类终端



由输入和输出组件组成,输入的结果添加到输出list中



命令输入的打字效果


Alt Text

定义打字间隔100ms,对键入的命令for处理,定时器中根据遍历的索引延迟赋值。


const autoTyping = (cmd: string) => {
const interval = 100 // ms
for (let i = 0; i < cmd.length; i++) {
setTimeout(
() => {
setCmd((prev) => prev + cmd.charAt(i))
},
interval * (i + 1)
)
}
}

滚动到底部


定义外层容器refcontainerRef,键入命令后都自动滚动到页面底部,使用了scrollIntoViewapi,作用是让调用这个api的容器始终在页面可见,block参数设置为end表示垂直方向末端对其即最底端。


const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
containerRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end'
})
}, [typedCmds])

MDX



何为mdx?即给md添加了jsx支持,功能更强大的md,在nextjs中通过@next/mdx解析.mdx文件,它会将mdreact components转成html



安装相关包,后两者作为@next/mdxpeerDependencies



  • @next/mdx

  • @mdx-js/loader

  • @mdx-js/react


next.config.js新增createMDX配置


// next.config.js

import createMDX from '@next/mdx'

const nextConfig = {}

const withMDX = createMDX()
export default withMDX(nextConfig)

接着在应用根目录下新建mdx-components.tsx


// mdx-components.tsx

import type { MDXComponents } from 'mdx/types'

export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components
}
}

app目录下使用.mdx文件,useMDXComponents组件是必要的,


需要注意的是此文件命名上有一定规范只能命名为mdx-components,不能为其他名称,也不可为MdxComponents,从@next/mdx源码中可以看出会去应用根目录查找mdx-components


// @next/mdx部分源码

config.resolve.alias['next-mdx-import-source-file'] = [
'private-next-root-dir/src/mdx-components',
'private-next-root-dir/mdx-components',
'@mdx-js/react'
]

至此就可以在app中使用mdx


排版



为mdx解析成的html添加样式



解析mdx为html,但并没有样式,所以我们借助@tailwindcss/typography来为其添加样式,在tailwind.config.js使用该插件。


// tailwind.config.js

module.exports = {
plugins: [require('@tailwindcss/typography')]
}

在外层标签上添加prose的className,prose-invert用于暗黑模式。


<article className="prose dark:prose-invert">{mdx}</article>

综上我们实现了对mdx的样式支持,然而有一点是@tailwindcss/typography并不会对mdx代码块中代码进行高亮。


代码高亮



写文章或多或少都有代码,高亮是必不可少,那么react-syntax-highlighter该上场了



定义一个CodeHighligher组件


// CodeHighligher.tsx

import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import {
oneDark,
oneLight
} from 'react-syntax-highlighter/dist/cjs/styles/prism'
import { useTheme } from 'next-themes'

export default function CodeHighligher({
lang,
code
}: {
lang: string
code: string
}
) {
const { theme } = useTheme()
return (
<SyntaxHighlighter
language={lang?.replace(/\language-/, '') || 'javascript'}
style={theme === 'light' ? oneLight : oneDark}
customStyle={{
padding: 20,
fontSize: 15,
fontFamily: 'var(--font-family)'
}}
>

{code}
</SyntaxHighlighter>

)
}

react-syntax-highlighter高亮代码可用hljsprism,我在这使用的prism,两者都有众多代码高亮主题可供选择,lang如果没标注则默认设置为javascript也可以简写为js,值得注意的是如果是使用hljs,则必须写javascript,不可简写为js,否则代码高亮失败,这一点prism更加友好。


同时可通过useTheme实现亮色,暗色模式下使用不同代码高亮主题。


组件写好了,该如何使用?上面讲到过mdx的解析,在useMDXComponents重新渲染pre标签。


// mdx-components.tsx

import type { MDXComponents } from 'mdx/types'
import CodeHighligher from '@/components/CodeHighligher'

export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
pre: ({ children }) => {
const { className, children: code } = props
return <CodeHighligher lang={className} code={code} />
}
}
}

mdx文件中代码块会被解析成pre标签,可以对pre标签返回值作进一步处理,即返回高亮组件,这样可实现对代码高亮,当然高亮主题很多,选自己喜欢的。


文章


元数据



文章一些信息如标题,描述,日期,作者等都作为文章的元数据,使用yaml语法定义



---
title: '文章标题'
description: '文章描述'
date: '2020-01-01'
---

@next/mdx默认不会按照yaml语法解析,这会被解析成h2标签,然而我们并不希望元数据被解析成h2标签作为内容展示,更希望拿这类数据做其他处理,
为了正确解析yaml,需要借助remark-frontmatter来实现。


使用该插件,注意需要修改next配置文件名为next.config.mjs,因为remark-frontmatter只支持ESM规范。


// next.config.mjs

import createMDX from '@next/mdx'
import frontmatter from 'remark-frontmatter'

const nextConfig = {}

const withMDX = createMDX({
options: {
remarkPlugins: [frontmatter]
}
})
export default withMDX(nextConfig)

yaml被正确解析了那么我们可以使用gray-matter来获取文章元数据


列表


由于app目录是运行在nodejs runtime下,基本思路是用nodejs的fs模块去读取文章目录即mdxs/posts,读取该目录下的所有文章放在一个list中。


使用fs.readdirSync读取文章目录内容,但是这仅仅是拿到文章名称的集合。


const POST_PATH = path.join(process.cwd(), 'mdxs/posts')

// 文章名称集合
export function getPostList() {
return fs.readdirSync(POST_PATH).map((name) => name.replace(/\.mdx/, ''))
}

文章列表中展示的是标题而不是名称,标题作为文章的元数据,通过gray-matterreadapi读取文件可获取(也可以使用fs.readFileSync) read返回datacontent的对象,
data是元数据信息,content则是文章内容。


export function getPostMetaList() {
const posts = getPostList()

return posts.map((post) => {
const {
data: { title, description, date }
} = matter.read(path.join(POST_PATH, `${post}.mdx`))

// 使用fs.readFileSync
// const post = fs.readFileSync(path.join(POST_PATH, `${post}.mdx`), 'utf-8')
// const {
// data: { title, description, date }
// } = matter(post)

return {
slug: post,
title,
description,
date
}
})
}

上述方法中我们拿到了所有文章标题,描述信息,日期的list,根据list渲染文章列表。


详情


文章列表中使用Link跳转到详情,通过dynamic动态加载文章对应的mdx文件


export default function LoadMDX(props: Omit<PostMetaType, 'description'>) {
const { slug, title, date } = props

const DynamicMDX = dynamic(() => import(`@/mdxs/posts/${slug}.mdx`), {
loading: () => <p>loading...</p>
})

return (
<>
<div className="mb-12">
<h1 className="mb-5 font-[600]">{title}</h1>
<time className="my-0">{date}</time>
</div>
<DynamicMDX />
</>

)
}

generateStaticParams



优化文章列表跳转详情的速度



在文章详情组件导出generateStaticParams方法,这个方法在构建时静态生成路由,而不是在请求时按需生成路由,一定程度上提高了访问详情页速度


export async function generateStaticParams() {
const posts = await fetch('https://.../posts').then((res) => res.json())

return posts.map((post) => ({
slug: post.slug
}))
}

部署


项目是部署在vercel,使用github登录后我们新建一个项目,点进去后会看到Import Git Repository,导入对应仓库即可,也可使用vercel提供的模版新建一个,后续我们每次提交代码都会自动化部署。


Alt Text

有自己域名的可以在Domains中添加,然后去到你买域名的地方添加对应DNS解析即可。


总结


开发中遇到了一些坑:



  1. next-themes报错Warning: Extra attributes from the server: class,style,通过issues和看文档,最终找到了方案

  2. mdx-components组件的命名,经多次测试发现只能命名为mdx-components,阅读@next/mdx的源码也验证了

  3. 语法高亮,开始使用的hljs,mdx中的代码块写的js,部署到线上后发现代码并没有高亮,然后改用了prism正常高亮,
    又是阅读了react-syntax-highlighter源码发现hljs的语言集合中并没有js,所以无法正确解析,只能写成javascript,而prism两者写法都支持

  4. 首页的posts命令是运行在客户端组件中,fs无法使用,因此获取文章的方案使用fetch请求api

  5. 使用remark-frontmatter解析yaml无法和mdxRs: true同时使用,否则解析失败。添加此配置项表示使用基于rust的解析器来解析mdx,可能是还未支持的缘故


module.exports = withMDX({
experimental: {
mdxRs: true
}
})

后续更新:



  1. 会新增Weekly周刊模块,关注前端技术的更新

  2. 文章详情页添加上一篇和下一篇,更方便的阅读文章


作者:赫子子
来源:juejin.cn/post/7267408057163055139
收起阅读 »

一个鼠标滑过的样式~

web
🫰 demo 🫰🧐 思路分析 🧐这样看是不是一目了然呢~ 😏如上👆gif👆效果可以理解为👉 以鼠标位置为圆心,产生的背景圆,与box的间隙产生的交叉❓ 这么实现会不会有问题呢 ❓效果只在boxes区域出现,是不是需要判断鼠标位置来添加粉色背景圆呢 ❓...
继续阅读 »


hover.gif

🫰 demo 🫰

demo.gif

🧐 思路分析 🧐

原理.gif

这样看是不是一目了然呢~ 😏

如上👆gif👆效果可以理解为👉 以鼠标位置为圆心,产生的背景圆,与box的间隙产生的交叉

❓ 这么实现会不会有问题呢 ❓

  • 效果只在boxes区域出现,是不是需要判断鼠标位置来添加粉色背景圆呢 ❓
  • 而且这个只有在接触到 box 才会有 粉色背景圆box 以外的部分是没有颜色的,这个又如何解决呢 ❓
  • 或许也能实现,应该会麻烦些 :)

🧐 不妨换个思路 🧐

给每个box添加背景圆背景圆位置 根据鼠标位置变化,👇 如下所示 👇

image.png image.png

背景圆大小固定(比如200px),圆心位置如何确定呢?

👉 初始位置 (0,0) ,参照系则是参考box左上角

👉 动态变化的位置取(clientX - left, clientY - top)left  top  box 元素相对浏览器视口的位置,通过 getBoundingClientRect 方法获取

👉 取差值(clientX - left, clientY - top)也很好理解,因为伪元素位置是参照box左上的位置变化,这样就能在 差值(绝对值) < 半径 的时候出现在 box间隙

image.png

🌟 关键点 🌟

  • 盒子元素 box 添加伪元素 before,设置伪元素宽高均大于父元素,效果上类似于伪元素覆盖了box,同时设置偏移量 inset为负值,实现 “居中覆盖”(这样就能留出一个"空隙", 即👆gif👆粉色圆填充before  box 中间空白的部分)
  • 给伪元素背景设置背景色,demo中用的是 径向渐变,渐变的形状为200px 的圆形,圆心位置记为 --x  --y,通过css变量传入,颜色自定义即可(demo中采用的是rgba(245,158,11,.7)  transparent 的渐变)不用粉色了🤣

👀 关于--x  --y 的获取 👀

  • 记录鼠标位置 (mouseX, mouseY)
  • calBoxesPosition方法获取每个box 的位置 (left,top) 并记录差值 (mouseX - left, mouseY - top)
  • (mouseX, mouseY) 变动的时候重新触发 calBoxesPosition 方法即可

🚀 关于一些优化 🚀

  • 第一次页面加载调用 calBoxesPosition 后,在不滑动页面的情况下,每个box位置相对固定,可以缓存下来位置信息,避免该函数内部频繁调用 getBoundingClientRect 引发的性能问题造成卡顿
  • 滑动页面的时候,可以将记录box位置信息的字段重置为(0,0),再移动鼠标重新触发 calBoxesPosition 即可

👨‍💻代码(vue3实现)👨‍💻

PS: 不太会使用掘金的代码片段,不知道如何引入第三方库😅,如果验证代码, @vueuse/core 和 tailwindcss请自行安装🫠

(等我查一下怎么使用,再回来贴个代码片段~ ⏰@ 4-17 14:56 )

  1. template 结构

  1. css样式

  1. js 部分


作者:一只小於菟
来源:juejin.cn/post/7358622889681551372

收起阅读 »

threejs3D汽车换肤实战

web
06-汽车动态换肤的案列 课程内容 一、环境的搭建 (1)搭建项目 threejs的每个版本都有一些差异,在api和threejs项目文件夹下面,本案列使用的版本 npm i three@0.153.0 项目的目录结构如下: 03-fulldemo └───...
继续阅读 »

06-汽车动态换肤的案列


课程内容


一、环境的搭建


(1)搭建项目

threejs的每个版本都有一些差异,在api和threejs项目文件夹下面,本案列使用的版本


npm i three@0.153.0

项目的目录结构如下:


03-fulldemo
└───css
│───main.css

└───draco
│───gltf——存放Google Draco解码器插件

└───models——存放模型
│───ferrari.glb——模型文件,可以是glb也可以是gltf格式
│───ferrari_ao.png——模型贴图,这个图片是阴影效果

└───textures——纹理材质
│───venice_sunset_1k.hdr——将其用作场景的环境映射或者用来创建基于物理的材质


(2)代码基础结构搭建

创建对应的html文件并引入相应的环境


<!DOCTYPE html>
<html lang="en">
<head>
<title>three.js webgl - materials - car</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<link type="text/css" rel="stylesheet" href="./css/main.css">
<style>
body {
color: #bbbbbb;
background: #333333;
}
a {
color: #08f;
}
.colorPicker {
display: inline-block;
margin: 0 10px
}
</style>
</head>

<body>
<!--设置三个按钮,用于切换车身、轮毂、玻璃的颜色-->
<div id="info">
<span class="colorPicker"><input id="body-color" type="color" value="#ff0000"></input><br/>Body</span>
<span class="colorPicker"><input id="details-color" type="color" value="#ffffff"></input><br/>Details</span>
<span class="colorPicker"><input id="glass-color" type="color" value="#ffffff"></input><br/>Glass</span>
</div>
<!--要渲染3D的容器-->
<div id="container"></div>
<script type="importmap">
{
"imports": {
"three": "./node_modules/three/build/three.module.js",
"three/addons/": "./node_modules/three/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
//用于显示屏幕渲染帧率的面板
import Stats from 'three/addons/libs/stats.module.js';
//相机控件OrbitControls实现旋转缩放预览效果。
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
//加载GLTF文件格式的加载器,用于加载外部为gltf的文件
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
//Draco是一个用于压缩和解压缩 3D 网格和点云的开源库
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
//RGBELoader可以将HDR图像加载到Three.js应用程序中
import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';

//下面的代码就是JS渲染逻辑代码
</script>
</body>
</html>

在css/main.css文件中我们的代码如下


body {
margin: 0;
background-color: #000;
color: #fff;
font-family: Monospace;
font-size: 13px;
line-height: 24px;
overscroll-behavior: none;
}

a {
color: #ff0;
text-decoration: none;
}

a:hover {
text-decoration: underline;
}

button {
cursor: pointer;
text-transform: uppercase;
}

#info {
position: absolute;
top: 0px;
width: 100%;
padding: 10px;
box-sizing: border-box;
text-align: center;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
z-index: 1; /* TODO Solve this in HTML */
}

a, button, input, select {
pointer-events: auto;
}

.lil-gui {
z-index: 2 !important; /* TODO Solve this in HTML */
}

@media all and ( max-width: 640px ) {
.lil-gui.root {
right: auto;
top: auto;
max-height: 50%;
max-width: 80%;
bottom: 0;
left: 0;
}
}

#overlay {
position: absolute;
font-size: 16px;
z-index: 2;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
background: rgba(0,0,0,0.7);
}

#overlay button {
background: transparent;
border: 0;
border: 1px solid rgb(255, 255, 255);
border-radius: 4px;
color: #ffffff;
padding: 12px 18px;
text-transform: uppercase;
cursor: pointer;
}

#notSupported {
width: 50%;
margin: auto;
background-color: #f00;
margin-top: 20px;
padding: 10px;
}


效果如下图:


image-20230627175428242


二、进行3D场景的渲染


(1)进行初始化函数设计

在项目中我们添加一个carInit函数进行动画的初始化


...省略之前代码
//下面的代码就是JS渲染逻辑代码
let scene, renderer, grid, camera;
function initCar(){
//里面就开始进行3D场景的搭建
}

//执行初始化函数
initCar()

上面的函数设计用于执行我们所有3d业务代码。


(2)创建场景

/**
* (1)获取要渲染的容器
*/

const container = document.getElementById('container');

/**
* (2)创建场景对象Scene
*/

//创建一个场景对象,用来模拟3d世界
scene = new THREE.Scene();
//设置一个场景的背景颜色
scene.background = new THREE.Color(0x333333);
//这个类中的参数定义了线性雾。也就是说,雾的密度是随着距离线性增大的
scene.fog = new THREE.Fog("red", 10, 15);

background:这个属性用于设置我们场景的背景颜色,0x333333默认采用深灰来作为我们初始颜色


fog:定义了线性雾,类似于在背景指定位置设置雾化的效果,让背景看起来更加模糊,凸显空旷效果。


(3)坐标格辅助对象

/**
* (3)坐标格辅助对象. 坐标格实际上是2维线数组.
*/

//创建网格对象,参数1:大小,参数2:网格细分次数,参数3:网格中线颜色,参数4:网格线条颜色
grid = new THREE.GridHelper(40, 40, 0xffffff, 0xffffff);
//网格透明度
grid.material.opacity = 1;
grid.material.depthWrite = false;
grid.material.transparent = true;
scene.add(grid);

坐标格辅助对象GridHelper可以在3D场景中定义坐标格出现。后续我们会在坐标格上面放我们的模型进行展示


代码编写完毕后,最终渲染出来的坐标格效果如下:


image-20230628155733478


(4) 创建相机对象

/**
* (4)创建透视相机
* 参数一:摄像机视锥体垂直视野角度
* 参数二:摄像机视锥体长宽比
* 参数三:摄像机视锥体近端面
* 参数四:摄像机视锥体远端面
*/

camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.3, 100);
camera.position.set(0, 1.4, - 4.5);

任何一个3D渲染效果都需要相机来成像


这一投影模式被用来模拟人眼所看到的景象,它是3D场景的渲染中使用得最普遍的投影模式


透视相机最大的特点就是满足近大远小的效果。


(5)创建一个渲染器

/**
* (5)创建一个渲染器
*/

renderer = new THREE.WebGLRenderer({ antialias: true });
renderer = new THREE.WebGLRenderer({ antialias: true });
//设置设备像素比。通常用于避免HiDPI设备上绘图模糊
renderer.setPixelRatio(window.devicePixelRatio);
//设置渲染出来的画布范围
renderer.setSize(window.innerWidth, window.innerHeight);
container.appendChild(renderer.domElement);
renderer.render(scene, camera);

有了场景、相机、坐标格辅助,我们想要让画面能够呈现出来,那就得有渲染器。


相当于你拍照需要将画面呈现到交卷上面。


其中renderer.render(scene, camera); 这段代码就是在进行渲染器的渲染。


如果render在指定频率内不断被调用,那就意味着可以不断拍照,不断渲染。可以实现动态切换效果


(6)效果渲染

当执行完上面的代码后,你需要确保调用了carInit这个函数,页面就可以渲染出对应的效果了


image-20230628161312404


说明:



  1. 场景的背景色为0x333333效果为深灰色。

  2. 我们设置的fog线性雾颜色为红色,所以你会发现在背景和网格之间会有一个过渡颜色。

  3. 网格的颜色采用的是0xffffff效果为灰色。


对应的各种参数,当你在学习的时候都都可以进行调整。一遍调整就能看懂参数和最终渲染的效果差异。


当你把fog的颜色调整为跟背景一样的时候,你会发现画面上就类似产生了迷雾效果,让3D背景更加立体


scene.fog = new THREE.Fog(0x333333, 10, 15);

效果如下:


image-20230628161707934


你也可以继续设置网格线条的透明度,让网格线不那么抢眼


grid.material.opacity = 0.3;

效果如下:


image-20230628161827094


是不是整个画面看起来3D立体效果会更强一些,背景看起来更深邃一些。


三、加载外部模型进行渲染


(1)添加轨道控制器

threejs官方给我们提供了一个类,OrbitControls(轨道控制器)可以使得相机围绕目标进行轨道运动。


换句话说,引入了OrbitControls后,我们可以操作鼠标来控制页面上动态效果。


比如:鼠标滚动、鼠标点击、鼠标左右滑动效果。


代码如下:


...省略了 【(5)创建一个渲染器】
/**
* (6)开启OrbitControls控件,可以支持鼠标操作图像
*/

controls = new OrbitControls(camera, container);
//你能够将相机向外移动多少(仅适用于PerspectiveCamera),其默认值为Infinity
controls.maxDistance = 9;
//你能够垂直旋转的角度的上限,范围是0到Math.PI,其默认值为Math.PI
controls.maxPolarAngle = THREE.MathUtils.degToRad(90);
controls.target.set(0, 0.5, 0);
controls.update();

加入上面代码后,我们还要继续优化代码


在carInit函数后面在添加一个render函数,用于执行渲染


function initCar(){

/**
* (5)创建一个渲染器
*/

renderer = new THREE.WebGLRenderer({ antialias: true });
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
container.appendChild(renderer.domElement);
//注释掉这句话
//renderer.render(scene, camera);
//调用一次render函数进行渲染
render()
}
function render(){
renderer.render(scene, camera);
requestAnimationFrame(render)
}

效果实现如下:



(2)加载汽车模型

既然要加载外部模型,那我们肯定需要通过模型软件来设计对应的模型。本案列不讲解如何设计模型,我使用threejs官方提供的模型来进行展示。


我们常用的模型格式如下:



  1. OBJ (Wavefront OBJ):


    OBJ 是一种常见的纯文本模型格式,支持存储模型的几何信息(顶点、面)和材质信息(纹理坐标、法线等)。可以通过OBJLoader来加载和解析OBJ格式的模型。


  2. FBX (Autodesk FBX):


    FBX 是由Autodesk开发的一种常用的二进制模型格式,支持存储模型的几何信息、材质、动画等。可以通过FBXLoader来加载和解析FBX格式的模型。


  3. GLTF (GL Transmission Format):


    GLTF 是一种基于JSON的开放标准,用于存储和传输三维模型和场景。GLTF格式支持几何信息、材质、骨骼动画、节点层次结构等,并且通常具有较小的文件大小。可以通过GLTFLoader来加载和解析GLTF格式的模型。


  4. STL (Stereolithography):


    STL 是一种常用的三维打印文件格式,用于存储模型的几何信息。STL 文件通常包含三角形面片的列表,用于定义模型的外观。可以通过STLLoader来加载和解析STL格式的模型。


  5. GLB:


    GLB是GL Transmission Format(gltf)的二进制版本,GLB格式将模型的几何信息、材质、骨骼动画、节点层次结构等存储在单个二进制文件中,通常具有较小的文件大小和更高的加载性能.



本案列采用glb格式来加载外部模型。


因为案列中使用glb模型数据采用了Draco来进行压缩,所以我们需要引入DRACOLoader来解析我们的模型


(1)引入DRACOLoader加载模型


/**
* (7)汽车模型相关的内容
* DRACOLoader 主要用于解析使用 Draco 压缩的 GLB 模型,而不是所有的 GLB 模型都使用了 Draco 压缩
*/

const dracoLoader = new DRACOLoader();
//配置加载器的位置,这个需要提前下载到项目中
dracoLoader.setDecoderPath('./draco/gltf/');
const loader = new GLTFLoader();
//设置GLTFLoader加载器使用DRACO来解析我们的模型数据
loader.setDRACOLoader(dracoLoader);


并不是所有的模型都需要Draco来进行加载,取决于你的模型在设计导出的时候是否用了Draco来进行压缩。



./draco/gltf/目录下面的文件如下:代码可以从gitee上面下载


image-20230629142933880


(3)加载glb模型数据

当你已经创建了`const loader = new GLTFLoader();这个类实例后,我们就可以加载模型了


/**
* (8)加载glb模型
*/

loader.load('./models/gltf/ferrari.glb', function (gltf) {
//获取到模型的数据
const carModel = gltf.scene.children[0];
//将模型添加到3D场景中
scene.add(carModel);
});
render()

加载的效果如下:


image-20230629143514305


模型已经加载成功了,但是你会发现他在整个背景中是黑色的。当然模型本身是有材质贴图的,车身默认是红色的。


之所以产生这个效果那是因为我们现在缺少一个非常重要的元素,那就是光照。


你试想一下,一个物体在没有任何光源的情况下,呈现出来的就是黑色的效果。如果你的场景背景也是黑色,那根本看不到效果。


(4)加载光影效果

我们设置光源的时候主要有两个部分



  1. 环境光:相当于天空的颜色,物体表面可以反射出对应的颜色。

  2. 点光源:相当于开启手电筒,照射到模型表面反射出来的颜色。


设置环境光


/**
* (9)添加光影效果
*/


//创建环境光
var ambient = new THREE.AmbientLight("blue");
scene.add(ambient);

环境光的颜色为blue,效果如下:


image-20230629145635074


环境光为blue的情况下,模型表面反射出来的颜色就是蓝色,一般金属材质和玻璃材质反射的效果更佳明显。所以轮毂和车辆挡风玻璃效果会更强烈一些。


设置点光源


/**
* (9)添加光影效果
*/


//创建环境光
var ambient = new THREE.AmbientLight("blue");
scene.add(ambient);

//创建点光源
var point = new THREE.PointLight("#fff");
//设置点光源位置
point.position.set(0, 300, 0);
//点光源添加到场景中
scene.add(point);

效果如下:


image-20230629145913328


此刻我们基本上完成了模型的渲染,环境光蓝色默认替换为黑色,这样车辆立体感会更强一些


//环境光
var ambient = new THREE.AmbientLight("#000");

效果如下:


image-20230629150241875


(5)加载hdr文件设置环境渲染

HDR(High Dynamic Range)文件是一种存储图像高动态范围信息的文件格式。


HDR可以理解成一张真实世界的图片或者设计者想要的灯光效果。


他的作用主要如下:



  1. HDR文件经常被用作环境贴图,用于模拟反射和光照环境。环境贴图是将场景的背景、反射和光照信息包装成一个纹理,然后将其应用到物体表面上。通过使用HDR文件作为环境贴图,可以更真实地模拟光线在场景中的反射和折射,增强渲染效果。

  2. HDR文件还可以用于模拟全局照明效果。全局照明是一种渲染技术,它考虑了场景中所有光源的组合对物体的影响,以获得更真实的照明效果。通过使用HDR文件提供的高动态范围和丰富的光照信息,可以在Three.js中实现更逼真的全局照明效果


也就说在本案列中如果我们想要获取更加真实的照明效果,我们可以使用设计师导出的hdr文件。将这个文件作为3D场景(Scene)的环境贴图


/**
* (2)创建场景对象Scene
*/

scene = new THREE.Scene();
scene.background = new THREE.Color(0x333333);
//通过RGBELoader加载hdr文件,它是一种图像格式,将其用作场景的环境映射或者用来创建基于物理的材质
scene.environment = new
RGBELoader().load('textures/equirectangular/venice_sunset_1k.hdr');
scene.environment.mapping = THREE.EquirectangularReflectionMapping;
scene.fog = new THREE.Fog(0x333333, 10, 15);

删除我们(9)添加光影效果中我们自己的光影效果


/**
* (9)添加光影效果
*/


//创建环境光
//var ambient = new THREE.AmbientLight("blue");
//scene.add(ambient);

//创建点光源
//var point = new THREE.PointLight("#fff");
//设置点光源位置
//point.position.set(0, 300, 0);
//点光源添加到场景中
//scene.add(point);

这样渲染下来我们物体在场景中显示的会更加自然


image-20230629152457227



不管你用hdr文件来作为环境贴图,还是采用光源设置来设计,我们都可以让模型在3D场景中更方便的显示出来。



四、汽车材质贴图


目前我们已经将模型渲染出来了,但是你会发现不管是车身、轮毂、还是玻璃材质跟我们想要的真实车辆材质是有区别的。比如你希望玻璃透明的、反光的。车身的漆面是可以反光的。模型在设计的时候使用默认材质。我们想要进行材质的替换。


(1)在步骤8中继续优化代码

/**
* (8)加载glb模型
* 并设置不同部位的材质。
*/

//物理网格材质(MeshPhysicalMaterial)
//车漆,碳纤,被水打湿的表面的材质需要在面上再增加一个透明的
const bodyMaterial = new THREE.MeshPhysicalMaterial({
color: 0xff0000, metalness: 1.0, roughness: 0.5, clearcoat: 1.0, clearcoatRoughness: 0.03
});

//汽车轮毂的材质,采用了标准网格材质,threejs解析gltf模型,会用两种材质PBR材质去解析
const detailsMaterial = new THREE.MeshStandardMaterial({
color: 0xffffff, metalness: 1.0, roughness: 0.5
});

//汽车玻璃的材质
const glassMaterial = new THREE.MeshPhysicalMaterial({
color: 0xffffff, metalness: 0.25, roughness: 0, transmission: 1.0
});

loader.load('./models/gltf/ferrari.glb', function (gltf) {
//获取到模型的数据
const carModel = gltf.scene.children[0];
//将模型添加到3D场景中
scene.add(carModel);
});

材质创建了过后,接下来我们就可以将材质加载了到模型中了。


loader.load('./models/gltf/ferrari.glb', function (gltf) {
//获取到模型的数据
const carModel = gltf.scene.children[0];

//获取模型中指定的模块,将默认材质替换为我们自定义材质
carModel.getObjectByName('body').material = bodyMaterial;
//轮毂的材质替换
carModel.getObjectByName('rim_fl').material = detailsMaterial;
carModel.getObjectByName('rim_fr').material = detailsMaterial;
carModel.getObjectByName('rim_rr').material = detailsMaterial;
carModel.getObjectByName('rim_rl').material = detailsMaterial;
////座椅的材质
carModel.getObjectByName('trim').material = detailsMaterial;
//玻璃的材质替换
carModel.getObjectByName('glass').material = glassMaterial;

scene.add(carModel);
});

上面的代码分别是获取模型中车身区域(body),获取轮毂区域(rim_fl、rim_fr、rim_rr、rim_rl)、座椅区域(trim)、玻璃区域(glass)


将我们自己创建的材质拿去替换默认材质实现加载渲染。


效果如下:


image-20230629164136907


替换过后的模型,更有金属质感和玻璃质感。材质对应的颜色你们都可以自己进行替换。


(2)给车底盘添加阴影效果


车底盘是没有阴影效果的,我们可以使用图片来进行模型贴图,让底盘有阴影效果会更加立体。


贴图的图片为png,图片由设计师出的


效果如下:


ferrari_ao


创建一个材质对象,并使用这张图片作为贴图


loader.load('./models/gltf/ferrari.glb', function (gltf) {
//获取到模型的数据
const carModel = gltf.scene.children[0];

//获取模型中指定的模块,将默认材质替换为我们自定义材质
carModel.getObjectByName('body').material = bodyMaterial;
//轮毂的材质替换
carModel.getObjectByName('rim_fl').material = detailsMaterial;
carModel.getObjectByName('rim_fr').material = detailsMaterial;
carModel.getObjectByName('rim_rr').material = detailsMaterial;
carModel.getObjectByName('rim_rl').material = detailsMaterial;
//座椅的材质
carModel.getObjectByName('trim').material = detailsMaterial;
//玻璃的材质替换
carModel.getObjectByName('glass').material = glassMaterial;

// shadow阴影效果图片
const shadow = new THREE.TextureLoader().load( './models/gltf/ferrari_ao.png' );
// 创建一个材质模型
const mesh = new THREE.Mesh(
new THREE.PlaneGeometry(0.655 * 4, 1.3 * 4),
new THREE.MeshBasicMaterial({
map: shadow, blending: THREE.MultiplyBlending, toneMapped: false, transparent: true
})
);
mesh.rotation.x = - Math.PI / 2;
mesh.renderOrder = 2;
carModel.add(mesh);

scene.add(carModel);
});

效果如下:


image-20230629175934463


通过效果图能看出,车辆底部是有阴影效果的,让整个3D效果渲染更加立体。


五、设置动画效果


(1)获取轮毂的材质对象

轮毂和网格地板我们都要动画加载


网格需要进行平移,按照z的反方向进行移动。


轮毂需要按照x轴的方向进行旋转


代码如下:


let wheels = []
function initCar(){
loader.load('./models/gltf/ferrari.glb', function (gltf) {
//获取到模型的数据
const carModel = gltf.scene.children[0];

...省略代码
//将车轮的模块保存到数组中,后面可以设置动画效果
wheels.push(
carModel.getObjectByName('wheel_fl'),
carModel.getObjectByName('wheel_fr'),
carModel.getObjectByName('wheel_rl'),
carModel.getObjectByName('wheel_rr')
);

scene.add(carModel);
});
}

上面的代码将轮毂模块获取到过后,放入到wheels数组中。


(2)设置轮毂的动画效果

接下来在render函数中进行动画控制


function render() {
controls.update();
//performance.now()是一个用于测量代码执行时间的方法。它返回一个高精度的时间戳,表示自页面加载以来的毫秒数
const time = - performance.now() / 1000;
//控制车轮的动画效果
for (let i = 0; i < wheels.length; i++) {
wheels[i].rotation.x = time * Math.PI * 2;
}
//控制网格的z轴移动
grid.position.z = - (time) % 1;

renderer.render(scene, camera);
requestAnimationFrame(render)
}

通过上面的代码我们已经能够实现轮毂和网格的动画效果了


六、切换颜色


实现颜色切换就必须绑定js的事件。


三个按钮,我们都绑定点击事件,并获取对应的颜色


function initCar(){
...省略代码
/**
* (10)切换车身颜色
* 获取到指定的按钮,得到你选中的颜色,并将颜色设置给我们自己的模型对象
*/

const bodyColorInput = document.getElementById('body-color');
bodyColorInput.addEventListener('input', function () {
bodyMaterial.color.set(this.value);
});

const detailsColorInput = document.getElementById('details-color');
detailsColorInput.addEventListener('input', function () {
detailsMaterial.color.set(this.value);
});

const glassColorInput = document.getElementById('glass-color');
glassColorInput.addEventListener('input', function () {
glassMaterial.color.set(this.value);
});
}

当我们将上面的代码实现后,切换颜色就完成分了。


只要修改bodyMaterial材质对象的颜色,页面刷新的时候就可以应用成功。


课程小结


作者:无处安放的波澜
来源:juejin.cn/post/7277787934848204835
收起阅读 »

Android 时钟翻页效果

背景 今天逛掘金,发现了一个有意思的web view效果,想着Android能不能实现一下捏。 原文链接:juejin.cn/post/724435… 具体实现分析请看上文原文链接,那我们开始吧! 容器 val space = 10f //上下半间隔 val...
继续阅读 »

背景


今天逛掘金,发现了一个有意思的web view效果,想着Android能不能实现一下捏。


image.png
原文链接:juejin.cn/post/724435…


具体实现分析请看上文原文链接,那我们开始吧!


容器


val space = 10f //上下半间隔
val bgBorderR = 10f //背景圆角
//上半部分
val upperHalfBottom = height.toFloat() / 2 - space / 2
canvas.drawRoundRect(
0f,
0f,
width.toFloat(),
upperHalfBottom,
bgBorderR,
bgBorderR,
bgPaint
)
//下半部分
val lowerHalfTop = height.toFloat() / 2 + space / 2
canvas.drawRoundRect(
0f,
lowerHalfTop,
width.toFloat(),
height.toFloat(),
bgBorderR,
bgBorderR,
bgPaint
)

image.png


绘制数字


我们首先居中绘制数字4


val number4 = "4"
textPaint.getTextBounds(number4, 0, number4.length, textBounds)
//居中显示
val x = (width - textBounds.width()) / 2f - textBounds.left
val y = (height + textBounds.height()) / 2f - textBounds.bottom
canvas.drawText(number4, x, y, textPaint)

image.png


接下来我们将数字切分为上下两部分,分别绘制。


val number4 = "4"
textPaint.getTextBounds(number4, 0, number4.length, textBounds)
val x = (width - textBounds.width()) / 2f - textBounds.left
val y = (height + textBounds.height()) / 2f - textBounds.bottom
// 上半部分裁剪
canvas.save()
canvas.clipRect(
0f,
0f,
width.toFloat(),
upperHalfBottom
)
canvas.drawText(number4, x, y, textPaint)
canvas.restore()
// 下半部分裁剪
canvas.save()
canvas.clipRect(
0f,
lowerHalfTop,
width.toFloat(),
height.toFloat()
)
canvas.drawText(number4, x, y, textPaint)
canvas.restore()

image.png


翻转卡片


如何实现让其旋转呢?
而且还得是3d的效果了。我们选择Camera来实现。
我们先让数字'4'旋转起来。


准备工作,通过属性动画来改变旋转的角度。


private var degree = 0f //翻转角度
private val camera = Camera()
private var flipping = false //是否处于翻转状态
...
//动画
val animator = ValueAnimator.ofFloat(0f, 360f)
animator.addUpdateListener { animation ->
val animatedValue = animation.animatedValue as Float
setDegree(animatedValue)
}
animator.doOnStart {
flipping = true
}
animator.doOnEnd {
flipping = false
}
animator.duration = 1000
animator.interpolator = LinearInterpolator()
animator.start()
...

private fun setDegree(degree: Float) {
this.degree = degree
invalidate()
}

让数字'4'旋转起来:


  override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 居中绘制数字4
val number4 = "4"
textPaint.getTextBounds(number4, 0, number4.length, textBounds)
val x = (width - textBounds.width()) / 2f - textBounds.left
val y = (height + textBounds.height()) / 2f - textBounds.bottom

if (!flipping) {
canvas.drawText(number4, x, y, textPaint)
} else {
camera.save()
canvas.translate(width / 2f, height / 2f)
camera.rotateX(-degree)
camera.applyToCanvas(canvas)
canvas.translate(-width / 2f, -height / 2f)
camera.restore()
canvas.drawText(number4, x, y, textPaint)
}
}

file.gif

我们再来看一边效果图:
我们希望将卡片旋转180度,并且0度-90度由上半部分完成,90度-180度由下半部分完成。


我们调整一下代码,先处理一下上半部分:


...
val animator = ValueAnimator.ofFloat(0f, 180f)
...
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val space = 10f //上下半间隔
//上半部分
val upperHalfBottom = height.toFloat() / 2 - space / 2
...
// 居中绘制数字4
val number4 = "4"
textPaint.getTextBounds(number4, 0, number4.length, textBounds)
val x = (width - textBounds.width()) / 2f - textBounds.left
val y = (height + textBounds.height()) / 2f - textBounds.bottom

if (!flipping) {
//上半部分裁剪
canvas.save()
canvas.clipRect(
0f,
0f,
width.toFloat(),
upperHalfBottom
)
canvas.drawText(number4, x, y, textPaint)
canvas.restore()
} else {
if (degree < 90) {
//上半部分裁剪
canvas.save()
canvas.clipRect(
0f,
0f,
width.toFloat(),
upperHalfBottom
)
camera.save()
canvas.translate(width / 2f, height / 2f)
camera.rotateX(-degree)
camera.applyToCanvas(canvas)
canvas.translate(-width / 2f, -height / 2f)
camera.restore()
canvas.drawText(number4, x, y, textPaint)
canvas.restore()
}
}
}

效果如下:


upper.gif

接下来我们再来看一下下半部分:


override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val space = 10f //上下半间隔
//下半部分
val lowerHalfTop = height.toFloat() / 2 + space / 2

// 居中绘制数字4
val number4 = "4"
textPaint.getTextBounds(number4, 0, number4.length, textBounds)
val x = (width - textBounds.width()) / 2f - textBounds.left
val y = (height + textBounds.height()) / 2f - textBounds.bottom

if (!flipping) {
// 下半部分裁剪
canvas.save()
canvas.clipRect(
0f,
lowerHalfTop,
width.toFloat(),
height.toFloat()
)
canvas.drawText(number4, x, y, textPaint)
canvas.restore()
} else {
if (degree > 90) {
canvas.save()
canvas.clipRect(
0f,
lowerHalfTop,
width.toFloat(),
height.toFloat()
)
camera.save()
canvas.translate(width / 2f, height / 2f)
val bottomDegree = 180 - degree
camera.rotateX(bottomDegree)
camera.applyToCanvas(canvas)
canvas.translate(-width / 2f, -height / 2f)
camera.restore()
canvas.drawText(number4, x, y, textPaint)
canvas.restore()
}
}
}

lower.gif

那我们将上下部分结合起来,效果如下:


all.gif

数字变化


好!我们完成了翻转部分,现在需要在翻转的过程中将数字改变:

我们还是举例说明:数字由'4'变为'5'的情况。我们思考个问题,什么时候需要改变数字?

上半部分在翻转开始的时候,上半部分底部显示的数字就应该由'4'变为'5',但是旋转的部分还是应该为'4',
下半部分开始旋转的时候底部显示的数字还是应该为'4',而旋转的部分该为'5'。


canvas.save()
canvas.clipRect(
0f,
0f,
width.toFloat(),
upperHalfBottom
)
canvas.drawText(number5, x, y, textPaint)
canvas.restore()
// 下半部分裁剪
canvas.save()
canvas.clipRect(
0f,
lowerHalfTop,
width.toFloat(),
height.toFloat()
)
canvas.drawText(number4, x, y, textPaint)
canvas.restore()
//=====⬆️=====上述的代码显示的上下底部显示的内容,即上半部分地步显示5,下半部分显示4
if (degree < 90) {
//上半部分裁剪
canvas.save()
canvas.clipRect(
0f,
0f,
width.toFloat(),
upperHalfBottom
)
camera.save()
canvas.translate(width / 2f, height / 2f)
camera.rotateX(-degree)
camera.applyToCanvas(canvas)
canvas.translate(-width / 2f, -height / 2f)
camera.restore()
canvas.drawText(number4, x, y, textPaint)
canvas.restore()
//=====⬆️=====上述的代码表示上半部分旋转显示的内容,即数字4
} else {
canvas.save()
canvas.clipRect(
0f,
lowerHalfTop,
width.toFloat(),
height.toFloat()
)
camera.save()
canvas.translate(width / 2f, height / 2f)
val bottomDegree = 180 - degree
camera.rotateX(bottomDegree)
camera.applyToCanvas(canvas)
canvas.translate(-width / 2f, -height / 2f)
camera.restore()
canvas.drawText(number5, x, y, textPaint)
canvas.restore()
//=====⬆️=====上述的代码表示下半部分旋转显示的内容,即数字5
}

效果图如下:大伙可以在去理一下上面数字的变化的逻辑。


a.gif

最后我们加上背景再看一下效果:


a.gif

小结


上述代码仅仅提供个思路,仅为测试code,正式代码可不能这么写哦 >..<


作者:蹦蹦蹦
来源:juejin.cn/post/7271518821809438781
收起阅读 »

Android 15 可能最终修复了底部黑色导航栏问题

Android 15 可能最终修复了底部黑色导航栏问题 长期以来, Android 系统一直存在一个问题: 手势栏/药丸/三键导航下面有一个可笑的黑条. 我曾找过相关的截图, 但要么截图太旧, 要么截图不清晰. 因为我一直在使用各种方法来隐藏它. 这就是手势...
继续阅读 »

Preview image


Android 15 可能最终修复了底部黑色导航栏问题


长期以来, Android 系统一直存在一个问题: 手势栏/药丸/三键导航下面有一个可笑的黑条. 我曾找过相关的截图, 但要么截图太旧, 要么截图不清晰. 因为我一直在使用各种方法来隐藏它.


这就是手势栏, 它并不那么碍事. Android 系统也有三键导航功能, 如果你打开它, 黑条就会变得非常大.


这是因为在早期的 Android 系统中, 屏幕上的导航按钮是用来取代实体按键的. 但老实说, 由于屏幕与静态按键不同, 效果并不理想. 这感觉更像是一个噱头. 当他们最终推出允许用户在'沉浸模式'下隐藏按键的 API 时, 我真的很不喜欢, 因为这意味着你必须做这个愚蠢的轻扫手势才能按到按键.


总之, 随着屏幕边框越来越小, 我们需要更大的显示屏, 屏幕底部的黑条开始变得有些碍眼和不必要. 最初, 在定制的 Roms 中, 有一种叫做"派控制 器"(Pie Controll)的东西. 那是一段美好时光. 你只需在屏幕边缘轻扫, 就会跳出一堆按钮. 虽然我用得不多, 因为我的手机大多是电容按键.


Android pie controls


我确实改用了手势导航, 也正是从那时起, 我才真正开始讨厌黑条. 因为它看起来太多余了. iOS 系统没有黑条, 而 iPhone 却运行得非常好. 所以这些年来我一直在尝试禁用这个黑条. 最初, 我使用的是 Xposed 模块. 后来, 我用了带电容按键的手机, 这样我就再也不用看到这个栏了. 这样做的好处是, Android 系统不会在你轻扫之前隐藏按钮.


后来, 我发现了一款名为"流体导航手势"(Fluid Navigation Gestures)的应用, 它曾在一段时间内起过作用. 但我目前的解决方案是只使用 iOS, 因为 Android 手机现在太难root了.


但据 Android Authority 报道, 这个问题可能最终会消失.



有鉴于此, 当我翻阅 Android 14 QPR2 Beta 3 时, 我发现了一个名为 EDGE_TO_EDGE_BY_DEFAULT的新应用兼容性更改, 其描述如下: "如果目标 SDK 为 VANILLA_ICE_CREAM或更高版本, 则应用默认为Edge-to-Edge. Vanilla Ice Cream恰好是 Android 15 的内部甜点名称, 这意味着这一兼容性变更将适用于以今年即将发布的版本为目标的应用. 鉴于Google每年都会强制开发者更新他们的应用, 以适应更新的 API 级别, 因此 Play Store 上的大多数应用都会以 Android 15 为目标, 这只是时间问题. 除非Google再次修改政策, 否则新应用和应用更新将被迫以 Android 15 为目标的截止日期将是 2025 年 8 月 31 日.



我不确定Google是否强迫你每年更新应用. 我不认为他们会这样做, 因为 Google Play 上有一些非常老旧的应用, 我敢肯定它们已经不再被维护了. 现在, 如果你不按照新规定更新应用, 他们可以把你的应用踢出去, 但并没有那么多规定. 我想作者的意思是, 当你更新应用时, 你必须使用最新的 SDK, 也就是香草冰淇淋的 SDK, 很可能是 SDK 35 级.


总之, Edge-to-Edge模式是一种允许应用在整个屏幕上绘图的方式. 目前, 默认情况下 Android 应用无法在状态栏和导航栏上绘图. 除此之外, 状态栏和导航栏还会变成半透明状态, 这意味着你可以看到它们下方的内容.


现在, 无论出于何种原因, 有些人并不喜欢这一变化. 可能是因为这意味着内容被手势区域或按钮遮住了. 是的, 这种情况偶尔会发生. 我最近就遇到了这种情况.


正如你在上面的片段中看到的, 在过渡到放大视图时, 页面指示器的位置太低了. 在 iOS 上使用手势导航时问题不大, 但在使用三键导航时就会出现问题. 虽然在这个特定的例子中, 页面指示器不是可点击的, 所以问题不大.


但 iOS 这样做已经有很长一段时间了, 好像自己 iPhone X 就这样了. 他们有一个非常简单的方法来实现这一功能, 叫做安全区域 API. 我不太清楚它在本地是如何实现的, 但在 Flutter 中却很容易实现. 你只需使用 SafeArea() 对象或调用 MediaQuery.of(context).padding. 尽管 MediaQuery 有 3 个 padding 值. 这有点令人困惑.


总之, 我的所有应用都使用了专门的代码, 以确保 Android 应用从 Edge-to-Edge 显示. 这是因为我希望 iOS 和 Android 版本看起来一样. 此外, 我也更喜欢'Edge-to-Edge'的外观.


await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
systemNavigationBarColor: Colors.transparent,
statusBarColor: Colors.transparent,
),
)
;

这也使得状态栏和导航栏完全透明, 而不仅仅是半透明. 不过有一个小问题: 你必须确保状态栏图标的颜色正确, 否则它们将无法显示. 实际上, 这有点困难, 因为你必须根据你所处的屏幕来改变它们的颜色, 但这并不难.


要在 Flutter 中实现这一点, 你必须将Scaffold包裹在AnnotatedRegion<SystemUiOverlayStyle>中, 其值就是此函数的结果:


SystemUiOverlayStyle makeUiOverlayStyle(bool whiteIcons) {
return SystemUiOverlayStyle(
statusBarBrightness: whiteIcons ? Brightness.dark : Brightness.light,
statusBarIconBrightness: whiteIcons ? Brightness.light : Brightness.dark,
);
}

就是这样. 改变并不难, 我很高兴 Google 终于做到了. 俗话说, 迟到总比不到好.


虽然有些人认为这种改变还不够. 他们希望取消手势"提示". 这样, 你在使用手势时就不会看到屏幕底部的白条了. 要知道, 早在root很容易的时候, 我就在我的 Android 手机上这样做了. 以及最近的流畅导航手势(Fluid Navigation Gestures). 它是一种更简约的UI, 但我不认为它能给用户体验带来多少好处. 我认为强制应用采用 'Edge-to-Edge' 的设计已经足够好了.


作者:bytebeats
来源:juejin.cn/post/7356793698052866048
收起阅读 »

JavaScript简介:从概念、特点、组成和用法全面带你快速了解JavaScript!

JavaScript,简称JS,是一种轻量级的解释型编程语言,它是网页开发中不可或缺的三剑客之一,与HTML和CSS并肩作战,共同构建起我们浏览的网页。今天我们就来了解一下JavaScript,看看它在我们的web前端开发中扮演着什么样的角色。一、JavaSc...
继续阅读 »

JavaScript,简称JS,是一种轻量级的解释型编程语言,它是网页开发中不可或缺的三剑客之一,与HTML和CSS并肩作战,共同构建起我们浏览的网页。

今天我们就来了解一下JavaScript,看看它在我们的web前端开发中扮演着什么样的角色。

一、JavaScript是什么?

JavaScript(简称“JS”)是一种具有函数优先的轻量级,解释型或即时编译型的编程语言。它以其作为开发Web页面的脚本语言而闻名,但也被广泛应用于非浏览器环境中。

JavaScript是一种基于原型编程、多范式的动态脚本语言,支持面向对象、命令式、声明式和函数式编程范式。


Description


JavaScript最初由Netscape公司的Brendan Eich于1995年为网景导航者浏览器设计并实现。由于Netscape与Sun的合作,Netscape管理层希望该语言在外观上看起来像Java,因此得名为JavaScript。

JavaScript的标准是ECMAScript。截至2012年,所有浏览器都完整地支持ECMAScript 5.1,旧版本的浏览器至少支持ECMAScript 3标准。

2015年6月17日,ECMA国际组织发布了ECMAScript的第六版,正式名称为ECMAScript 2015,但通常被称为ECMAScript 6或ES2015。


Description


JavaScript目前是互联网上最流行的脚本语言。这门语言不仅可用于HTML和Web开发,还可以广泛用于服务器、PC、笔记本电脑、平板电脑和智能手机等设备。

二、JavaScript能做什么?

动画效果:

让你的网页动起来,比如轮播图、下拉菜单等。


Description

表单验证:

在数据提交到服务器之前,进行即时的客户端验证。

异步请求:

通过AJAX技术,实现页面的局部更新,无需刷新整个页面。

交互式游戏:

创建复杂的网页游戏,或是简单的互动元素。

Web API:

利用浏览器提供的API,访问地理位置、摄像头、本地存储等。


Description

跨平台应用:

使用如React Native、Electron等框架,开发跨平台的移动应用和桌面应用。

后端开发:

Node.js的出现让JavaScript也能在服务器端大展拳脚。

三、JavaScript的组成

  • ECMAScript,描述了该语言的语法和基本对象。

  • 文档对象模型(DOM),描述处理网页内容的方法和接口。

  • 浏览器对象模型(BOM),描述与浏览器进行交互的方法和接口

Description


3.1 ECMAScript:

ECMAScript是一种由Ecma国际(前身为欧洲计算机制造商协会)在标准ECMA-262中定义的脚本语言规范。这种语言在万维网上应用广泛,它往往被称为JavaScript或JScript,但实际上后两者是ECMA-262标准的实现和扩展。

简单来说:

  • ECMAScript JavaScript是的核心,是规范标准。

  • 描述了语言的基本语法(var、for、if、array等)和数据类型(数字、字符串、布尔、函数、对象(obj、[]、{}、null)、未定义)。

3.2 DOM

文档对象模型 (DOM) 是HTML和XML文档的编程接口。它提供了对文档的结构化的表述,并定义了一种方式可以使从程序中对该结构进行访问,从而改变文档的结构,样式和内容。

DOM 将文档解析为一个由节点和对象(包含属性和方法的对象)组成的结构集合。

简言之,它会将web页面和脚本或程序语言连接起来。

可以理解为:

DOM Document Object Model文档对象模型,可以去操作网页。

Document(文档)

指的是XML和HTML的页面,当你创建一个页面并且加载到Web浏览器中,DOM就在幕后悄然而生,它会把你编写的网页文档转换成一个文档对象。

Object(对象)

js对象大致可以分为以下三种:

  • 用户定义对象,例如:var obj = {}

  • 内置对象,无需创建,可直接使用,例如:Array、Math和Data等

  • 宿主对象,浏览器提供的对象,例如:window、document

DOM中主要关注的就是document,document对象的主要功能就是处理网页内容。

Model(模型)

代表着加载到浏览器窗口的当前网页,可以利用JavaScript对它进行读取。

3.3 BOM

浏览器对象模型,操作浏览器。

Browser Object Model 浏览器对象模型提供了独立与内容的、可以与浏览器窗口进行互动的对象结构,BOM由多个对象构成,其中代表浏览器窗口的window对象是BOM的顶层对象,其他对象都是该对象的子对象。

四、JavaScript的特点

JavaScript是一种功能强大的编程语言,它的特点主要包括以下几点,在这里大家只需要了解一下就可以了。

  • 客户端脚本语言: JavaScript通常在用户的浏览器上运行,用于实现动态内容和用户界面的交互性。

  • 弱类型语言: JavaScript不要求开发者在编程时明确指定变量的类型,类型会在运行时自动转换。

  • 面向对象: JavaScript支持面向对象的编程模式,允许创建对象和定义它们之间的交互。

  • 事件驱动: JavaScript能够响应用户的操作(如点击、输入等),这使得它非常适合构建交互式的Web应用。

  • 跨平台: JavaScript代码可以在几乎所有的现代浏览器上运行,无论是Windows、macOS还是Linux操作系统。

  • 动态性: JavaScript是一种动态语言,可以在运行时改变其结构和行为。

  • 可扩展性: JavaScript可以通过添加新的函数和属性来扩展其内置对象的功能。

  • 宽松语法: JavaScript的语法相对宽松,使得编程更加灵活,但也可能导致错误。

  • 单线程与异步处理: JavaScript在浏览器中是单线程执行的,但它通过事件循环和回调函数等机制实现了异步处理。

  • 基于原型的继承: 不同于传统的类继承,JavaScript使用的是基于原型的继承方式。

  • 核心组成部分: JavaScript的核心由ECMAScript、DOM(文档对象模型)和BOM(浏览器对象模型)组成。

  • 多范式: JavaScript支持多种编程范式,包括过程式、面向对象和函数式编程。

总的来说,JavaScript的这些特点使其成为了Web开发中不可或缺的一部分,同时也适用于服务端编程(如Node.js)和其他非浏览器环境。


你是不是厌倦了一成不变的编程模式?想要突破自我,挑战新技术想要突破自我,挑战新技术?却迟迟找不到可以练手的项目实战?是不是梦想打造一个属于自己的支付系统?那么,恭喜你,云端源想免费实战直播——《VUE3+SpringBoot搭建移动支付功能(第1期)》即将开启,点击前往获取源码!

五、JavaScript的用法

1、页内样式:

在HTML文件中,可以在<head>或<body>标签中添加<script>标签,然后在<script>标签中编写JavaScript代码。这种方式适合较小的脚本或者是测试阶段的代码。

例如:

<!DOCTYPE html>
<html>
<head>
<title>页内样式示例</title>
<script>
// 在这里编写JavaScript代码
</script>
</head>
<body>
// 页面内容
</body>
</html>

2、页外样式:

步骤一:在js文件夹中创建一个Xxx.js文件。

步骤二:在Xxxx.js文件中编写JavaScript代码。

步骤三:在HTML文件的<head>标签中通过<script src="Xxxx.js"></script>进行引入。
例如:

<!DOCTYPE html>
<html>
<head>
<title>页外样式示例</title>
<script src="Xxxx.js"></script>
</head>
<body>
// 页面内容
</body>
</html>

需要注意的是,引入时路径要正确,如果是当前目录则直接写文件名,如果是上级目录则需要使用./来指定路径。

  • 页外样式写到<head>中,可以让它早点加载、早点完成。

  • 而页内样式写到<body>结束标签之前,可以让HTML代码先渲染内容,然后再执行JavaScript代码。

随着Web技术的发展,JavaScript也在不断进化。ES6引入了类、模块、箭头函数等新特性,未来的JavaScri
pt将更加强大、简洁。作为前端开发的基石,也是全栈开发的重要工具,JavaScript的重要性不言而喻。

现在,你是否已经迫不及待想要开启自己的JavaScript学习之旅了呢?记住,每一位大师都是从基础开始的,不要害怕犯错,因为每一个错误都是通往成功的阶梯。

拿起你的键盘,打开你的浏览器,让我们一起在JavaScript的海洋中遨游,发现编程的无穷魅力吧!

收起阅读 »

开源项目|使用go语言搭建高效的环信 IM Rest接口(附源码)

项目背景环信 Server SDK 是对环信 IM REST API 的封装, 可以节省服务器端开发者对接环信 API 的时间,只需要配置自己的 App Key 相关信息即可使用。环信目前提供java和PHP版本的Server SDK,此项目使用go语言对环信...
继续阅读 »

项目背景

环信 Server SDK 是对环信 IM REST API 的封装, 可以节省服务器端开发者对接环信 API 的时间,只需要配置自己的 App Key 相关信息即可使用。

环信目前提供java和PHP版本的Server SDK,此项目使用go语言对环信 IM REST API 进行封装,对官方版本进行了补充,有需要的开发者可以直接通过以下地址获取源码。

项目地址

前提条件

  • go语言环境

  • 有效的环信即时通讯 IM 开发者账号和 AppKey、ClientID、ClientSecret、DomainURL
    登录 环信管理后台 到“应用列表” → 点击“查看”即可获取到 App Key、Client ID、ClientSecret,到"即时通讯" → 点击"服务概览"获取到 “Rest api” 的服务器域名。

实现方法

  • go.mod 文件引入: github.com/xiaofengin/easemob-go

AppKey、ClientID、ClientSecret在下图中获取


DomainURL在下图中获取

初始化IM SDK

package main

import (
"context"
"fmt"
IMSDK "github.com/xiaofengin/easemob-go"
)

func main() {
client, err := IMSDK.New("appkey",
"clientId",
"clientSecret",
"domainURL")
if err != nil {
return
}
}

批量注册两个用户

package main

import (
"context"
"fmt"
IMSDK "github.com/xiaofengin/easemob-go"
)

func main() {
client, err := IMSDK.New("appkey",
"clientId",
"clientSecret",
"domainURL")
if err != nil {
return
}
user1 := UserRegisterParam{
Username: "userID_1",
Password: "1",
}
user2 := UserRegisterParam{
Username: "userID_2",
Password: "1",
}
users := []UserRegisterParam{user1, user2}
ret, err := client.UserRegister(context.Background(), &users)
if err != nil {
return
}
fmt.Printf("数据的值:%v\n", ret.Entities)
}

发送一个单聊消息

  • tos 放接收方环信ID(多个)m := CreateTextMsg("hello word", tos) 创建一个消息体
  • 默认发送方ID 是 admin,如果要修改的话 m.From = "指定ID"
  • 也可以给消息添加扩展字段 m.Ext = map[string]interface{}{"key1": "value1", "key2": "value2"}
package main

import (
"context"
"fmt"
IMSDK "github.com/xiaofengin/easemob-go"
)

func main() {
client, err := IMSDK.New("appkey",
"clientId",
"clientSecret",
"domainURL")
if err != nil {
return
}
var tos []string
tos = append(tos, "环信用户ID")
m := CreateTextMsg("hello word", tos)
//m.From = "指定ID"
//m.Ext = map[string]interface{}{"key1": "value1", "key2": "value2"}
ret, err := client.SendChatMessage(context.Background(), m)
if err != nil {
return
}
fmt.Printf("数据的值:%v\n", ret.Data)
}

获取用户token

  • 通过用户ID和密码获取用户token,也可以通过用户ID获取用户token
package main

import (
"context"
"fmt"
IMSDK "github.com/xiaofengin/easemob-go"
)

func main() {
client, err := IMSDK.New("appkey",
"clientId",
"clientSecret",
"domainURL")
if err != nil {
return
}

//通过用户 ID 和密码获取用户 token
//data := TokenParam{
// GrantType: "password",
// Username: "userID",
// Password: "1",
// Ttl: "1024000",
//}

//通过用户 ID 获取用户 token
data := TokenParam{
GrantType: "inherit",
Username: "userID",
AutoCreateUser: true,
Ttl: "1024000",
}
ret, err := client.GetUserToken(context.Background(), &data)
if err != nil {
return
}
fmt.Printf("数据的值:%v\n", ret.AccessToken)
}

SDK功能清单

注意

测试代码中 appkey clientId clientSecret 这三个参数我是写到环境变量里面,
如果 你没有把参数写到环境变量里面,可以直接写死该参数


参考文档:

IMGeek社区支持:https://www.imgeek.net

收起阅读 »

移动端安全区域适配方案

web
前言 什么是安全区域? 自从苹果推出了惊艳的iPhone X,智能手机界就正式步入了全面屏的新纪元。然而,这一革新也带来了一个特别的问题——那就是屏幕顶部的“刘海”和底部的“黑条”区域。这些区域犹如手机的“神秘面纱”,遮挡了一部分屏幕,给开发者带来了新的挑战。...
继续阅读 »

前言


什么是安全区域?


自从苹果推出了惊艳的iPhone X,智能手机界就正式步入了全面屏的新纪元。然而,这一革新也带来了一个特别的问题——那就是屏幕顶部的“刘海”和底部的“黑条”区域。这些区域犹如手机的“神秘面纱”,遮挡了一部分屏幕,给开发者带来了新的挑战。


Android似乎对iPhone的设计情有独钟,纷纷效仿这种全面屏的潮流。于是,越来越多的Android手机也开始有了这个安全区域的概念。


在这个背景下,移动端安全区域适配变得尤为重要。开发者们需要巧妙地调整应用的布局和界面,确保内容不会被这些特殊区域遮挡,同时保持应用的美观和易用性。


安全区域(safe area)



安全区域定义为视图中未被导航栏、选项卡栏、工具栏或视图控制器可能提供的其他视图覆盖的区域。



ios1.png


如上图所示,安全区域为中间蓝色部分,也就是说我们在页面布局时应该保证页面内容在蓝色安全区域内。


所以对于这类机型,你如果不特殊处理,那么它将会是这样的:


ios2.png

这样就会导致底部输入框的交互受影响


网页布局方式(viewport-fit)


在处理安全区域之前,我们需要先来了解viewport-fit属性,这是解决问题的关键。


iOS带来问题的同时也带来了解决问题的方法,为了适配 iPhoneX等全面屏机型 对现有 viewport meta 标签进行了扩展,用于设置视觉视口的大小来控制裁剪区域。


用法


<meta name="viewport" content="width=device-width,initial-scale=1, user-scalable=0, viewport-fit=cover">

属性值


该属性包含三个值:



  • auto:该值不会影响初始布局视口,并且整个网页都是可见的。 UA 在视口之外绘制的内容是未定义的。它可以是画布的背景颜色,或者 UA 认为合适的任何其他颜色。(默认值,与contain表现一致)

  • contain:初始布局视口和视觉视口设置为设备显示屏中内接的最大矩形。 UA 在视口之外绘制的内容是未定义的。它可以是画布的背景颜色,或者 UA 认为合适的任何其他颜色。

  • cover:初始布局视口和视觉视口设置为设备物理屏幕的外接矩形。


区别


在非矩形显示器上(比如手表)设置视口边界框的大小时,我们必须考虑以下因素:



  • 由于视口边界框的面积大于显示器的面积而导致的剪切区域

  • 视口边界框与显示区域之间的间隙


contain


ios3.png

当使用viewport-fit: contain时,初始视口将应用于显示器的最大内接矩形。


cover


ios4.png

当使用viewport-fit: cover时,初始视口将应用于显示器的外接矩形。


env


为了解决安全区域问题,iOS 11 新增了一个新的 CSS 函数env()和四个预定义的环境变量



  • safe-area-inset-left:安全区域距离左边边界距离

  • safe-area-inset-right:安全区域距离右边边界距离

  • safe-area-inset-top:安全区域距离顶部边界距离

  • safe-area-inset-bottom:安全区域距离底部边界距离



iOS 11 中提供的 env() 函数名为 constant()。从 Safari 技术预览版 41 和 iOS 11.2 beta 开始,constant() 已被删除并替换为 env()。如有必要,您可以使用 CSS 后备机制来支持这两个版本,但以后应该更喜欢使用 env()。 —— 来自webkit文档



上面的意思是从iOS12开始不再支持使用constant函数,所以为了兼容处理,我们应该这样写:


body {
padding-bottom: constant(safe-area-inset-bottom); /* 兼容 iOS < 11.2 */
padding-bottom: env(safe-area-inset-bottom); /* 兼容 iOS >= 11.2 */
}

使用该函数的前提是必须设置meta标签viewport-fit=cover ,并且对于不支持 env() 的浏览器,浏览器将会忽略它。


适配安全区域


第一步:


修改页面布局方式


<meta name="viewport" content="width=device-width,initial-scale=1, user-scalable=0, viewport-fit=cover">

第二步:


底部适配


.keyboard_foot {
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}

ios5.png

这样安全区域问题就解决了!


作者:前端南玖
来源:juejin.cn/post/7357888522333225012
收起阅读 »

一边开飞机、一边修飞机,Node 官网的重新设计

web
《前端暴走团》,喜欢请抱走~大家好,我是团长林语冰。 当当当当~惊不惊喜,意不意外,相信大家已经注意到 Node 官网的偷偷变帅了! Node 最近可谓意气风发,不仅重新设计了新官网,还有新设计的吉祥物助阵。 今天,让我们一起来深度学习 Node 官方博客,...
继续阅读 »

《前端暴走团》,喜欢请抱走~大家好,我是团长林语冰。


00-wall.png


当当当当~惊不惊喜,意不意外,相信大家已经注意到 Node 官网的偷偷变帅了!


Node 最近可谓意气风发,不仅重新设计了新官网,还有新设计的吉祥物助阵。


今天,让我们一起来深度学习 Node 官方博客,携手 Node 团队一起回顾重新设计官网的这段旅程。


00-wall.png



免责声明


本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考。英文原味版请传送 Diving int0 the Node.js Website Redesign



规模和限制


Node 官方网站诞生已经超过 14 岁了。下载和文档主页的设计首次在 2011 年底崭露头角。这是 Node 0.6 的陈年旧事。


01-home.png


从那时起,Node 官网的规模随着项目需要与日俱增,包含了 1600 多页。在巅峰时期,它拥有大约 20 种国际化语言。Node 的域名(nodejs.org)每月处理 30 亿个请求,传输的数据量为 2 千兆字节。


错误的尝试


Node 官网首次尝试重新设计于 2019 年开始。工作从新的域名(nodejs.dev)和新存储库起步。蓦然回首,这可能从一开始就冥冥之中注定了该项目的失败。


简而言之,这个代码库不是社区或贡献者的常驻之地,也不存在已建立的贡献者工作流程。为生活奔走忙碌的人们自愿贡奉献自己的时间,但并不想学习第二套工具。该项目无法维持蒸蒸日上所需的领导力。


一边开飞机,一边修飞机


2022 年,团队回归现有的存储库,考虑如何重建站点。Node 的旧版代码库开始在各个维度上显示出它的老龄化。Node 旧版官网的设计已经 out 了。Node 旧版网站的内部结构很难扩展,而且文档也很少。


Node 团队仔细考虑了技术堆栈。正在进行的重新设计的第一阶段涉及 nextra,这是一个优秀的 Next 静态站点生成器。但随着网站的发展,我们发现自己经常“打破” nextra 的惯例,依赖于 nextra 抽象的底层 Next 模式和强大工具。


Next 是一个自然选择的进化过程,以其灵活性和强大功能而赫赫有名。举个栗子,Node 新网站仍然是为了终端用户速度和基础托管独立性而静态构建的,但利用 Next 的增量静态重新生成,来获取版本发布等动态内容。


我们与 Vercel 强强联手。当 Node 新官网的规模在静态导出时使 webpack 的内存管理紧张时,它们提供了直接支持。我们在公开发布之前对新版本进行了 Beta 测试,这是该框架的真实压力测试。


2023 年 4 月,我们进行了一次小型切换。拉取请求有 1600 个文件,将 GitHub UI 推向了渲染能力的极限。Node 新官网的基建会发生变化,但外观、内容和创作体验将保持不变。


这是一个重要的里程碑 —— 证明我们可以一边开飞机、一边修飞机。


重新设计


OpenJS 基金会慷慨解囊,全力资助 Node 团队与设计师一起进行重新设计。


设计师为 Node 新官网带来了现代化设计,其中包括用户体验流程、暗/亮模式、页面布局、移动视口注意事项和组件细分。


2-design.png


接下来是将设计实现为代码,重点放在基础设计元素和结构化组件层次结构的顺序构建上。我们从第一天起就构建了组件的变体,并从一开始就考虑了国际化。我们选择使用 Tailwind CSS,但重点是设计令牌和应用 CSS。


Orama 搜索将网站的所有内容让用户触手可及。它们对我们的静态内容进行索引,并以闪电般的速度提供 API 内容、学习材料、博客文章等结果。很难想象如果没有这个强大的搜索功能,Node 爱好者该如何方便的查阅文档。


Node 旧版官网已经国际化为近 20 种语言。虽然但是,一系列不幸的事件导致我们重置了所有翻译。


我们利用 Sentry 提供错误报告、监控和诊断工具。这对于识别问题和为我们的用户提供更好的体验大有助益。


Vercel 和 Cloudflare 支持可确保网站快速可靠。我们还通过 GitHub Actions 投资了 CI/CD 管道,为贡献者提供实时反馈。这包括使用 Chromatic、Lighthouse 结果进行视觉回归测试,确保网站质量保持较高水平。


03-ci.png


庆典开源日和黑客啤酒节


重新设计工作与 2023 年 9 月的庆典开源日以及下个月的黑客啤酒节不谋而合。我们通过将“良好的第一个 issue”作为离散的开发任务来为这些事件做好准备。就庆典开源日而言,我们还提供了现场指导,以便与会者能够以落地公关结束这一天。


仅在庆典开源日期间,就有 28 位作者提出了 40 个 PR(拉取请求)。黑客啤酒节又收到了 26 个 PR。


04-pr.png


文档


开源项目的好坏取决于它的文档。在此过程中,我们迭代或引入了:



  • 合作者指南

  • 贡献

  • README(自述文件)

  • 翻译

  • ......


新代码非常注重内联代码和配置注释、关注点分离,以及明确定义的常量。整个过程中使用 TS 可以辅助贡献者理解数据的形状和函数的预期行为。


未来规划


本次重新设计为 Node 官网的新时代奠定了基础。但工作还有待完成:



  • 将网站重新设计扩展到 API 文档。它们位于单独的代码库中,但计划将此处开发的样式移植到 API。

  • 探索网站和 API 文档的 monorepo(多库开发)。这应该可以改善重要的耦合,并减少管理两个独立代码库的开销。

  • 重新调整国际化努力。先前的翻译无法延续。我们的重量级 Markdown/MDX 方案提出了一个独特的挑战,我们正在与 Crowdin 合作解决。

  • 持续改进 CI/CD 流程。


致谢


许多人和组织为实现重新设计做出了大大小小的贡献。我们要感谢:



  • 首先也是最重要的是所有使这个项目成为可能的贡献者和合作者。

  • Chromatic 提供视觉测试平台,辅助我们审查 UI 更改,并捕获视觉回归。

  • Cloudflare 用于提供为 Node 网站、Node 的 CDN 等提供服务的基建。

  • Crowdin 提供了一个平台,使我们能够国际化 Node 官网并与译者合作。

  • Orama 提供了一个搜索平台,可以为我们的内容建立索引,并提供闪电般快速的结果。

  • Sentry 为其错误报告、监控和诊断工具提供开源许可证。

  • Vercel 提供为 Node 网站提供服务和支持的基建

  • 最后,感谢 OpenJS 基金会的支持和指导。


本期话题是 —— 你觉得 Node 的新官网颜值如何、体验如何?欢迎在本文下方自由言论,文明共享。


坚持阅读,自律打卡,每天一次,进步一点。


作者:前端暴走团
来源:juejin.cn/post/7357151301220335653
收起阅读 »

公司新来一个架构师, 将消费金融系统重构了

1、背景 1.2 业务重组与合并 随着需求不断迭代,转转消费分期整体出现了一些调整,并提出了新的产品方向,在此背景下,对于经历了久经沧桑的历史服务,已经逐渐不适合未来的产品规划。面对新的业务整合和重组,急需新的架构和思想来承载未来的业务。 1.2 解决...
继续阅读 »

1、背景


1.2 业务重组与合并


随着需求不断迭代,转转消费分期整体出现了一些调整,并提出了新的产品方向,在此背景下,对于经历了久经沧桑的历史服务,已经逐渐不适合未来的产品规划。面对新的业务整合和重组,急需新的架构和思想来承载未来的业务。


1.2 解决技术债务


现阶段存在的主要问题:



  1. 代码模块之间边界感不强,需要通过模块拆分、服务拆分来区分业务边界。

  2. 代码实现缺少层次感,设计模式单一,一层到底的冗长代码。

    此前,微服务拆分原则是按消费分期、合作方分期产品等维度进行整体拆分的,优点是明确了项目职责,简单的从需求维度进行服务拆分,确实是一种行之有效的方式,缺点是没有对基础功能进行剥离,以至于很多场景需要维护重复的代码,增加了项目的维护成本。


1.3 影响开发效率


即使我们接手项目已经有一段时间,并对项目足够了解时,但排查问题起来依然费力费时,而且系统内部代码错综复杂,调用链路交错,难以正常维护,从长远的开发效率考虑,尽快提出新型方案来代替现有结构。


1.4 监控体系不够完善


线上异常机制不够敏感,缺少关键业务指标的告警看板,作为一个业务开发,应保持对核心指标数据的敏感性。


2、重构目标



  1. 不影响业务的正常运转和迭代;

  2. 改善现有代码结构设计,让代码易于扩展,提升开发效率;

  3. 采用新工程逐步替代原有接口,旧工程逐渐废弃。


3、设计


3.1 调研


开始重构之前,调研了互联网消金通用的架构解决方案:


通用方案


由于是外部调研的通用架构设计,所以并非完全契合转转消费分期产品,但可以借鉴其分层架构设计的思想,在代码设计阶段,可以先对核心模块进行拆解和规划。


3.2 规划


前端页面与后端重构计划分两次迭代进行,分阶段进行,可以有效分摊并降低项目上线风险,第一次迭代围绕后端主要模块进行剥离重新设计并上线;第二次重构目的是解决产品需求,对接前端新页面。


3.3 修缮者模式


作为一个一线的业务开发,需要开展重构工作的同时还得保证产品需求的正常迭代,修缮者模式无疑是最佳选择。
第一次迭代历程,对于历史工程边缘逻辑保留并隔离,只对核心代码进行重构后转移到到新工程,新工程逐步接手老旧逻辑,并对老工程提供RPC接口,逐渐取代。此方案整体风险最低,同时能兼顾到正常的需求迭代。

第二次迭代历程,经历了第一次迭代后,新系统运行稳定,同时也具备接手新产品的能力,新工程开始与前端对接、联调,在此之后,V2版本也正式上线。


3.4 领域设计(横向拆分)


模块拆分



  1. 聚合业务:涵盖了消费分期主要业务,根据其各自产品需求特点,作为上层业务代码,对前端、收银台提供聚合接口。

  2. 基础服务:用户信贷所产生的数据、或依托合作方数据,围绕金融分期服务提供的数据支持。

  3. 三方对接:基于转转标准API下的逻辑实现,同时具备灵活接入合作方接口的能力。


3.5 模块设计(纵向拆分)


基于以往项目存在的问题,再结合消费分期的特点,我们对分期购买到账单还款结清的整个流程进行拆解:用户主动填写申请信息,提交授信申请并获额,挑选商品分期下单,生成还款计划,提供绑卡、账单还款等功能。以上就是一个简单的分期购物流程,基于以上流程,我们把消费分期所包含的公共模块,如授信前获额、用信、账单还款,这些富有金融服务属性的功能进行剥离。消费分期作为转转的产品原型,在聚合层中各自维护,互不影响。

设计原则:在不改变原有代码逻辑的情况下,根据单一职责和依赖倒置原则的思想:对系统进行模块拆分与合并,以明确项目职责降低耦合度;对包进行重新规划,划分包与包之间的边界,进一步减少代码间的耦合。


3.6 代码设计


好的代码重构一定离不开设计模式,基于原有单一的策略模式,我们把合作方对接模块与基础服务模块进行了拆解,采用双层模板、策略、工厂模式的组合,分别对授信、用信、贷后几个模块单独设计接口,维护好对合作方通用标准API接口,同时具备灵活接入的特点,举个例子,以下为授信模块主要代码类图:


第一层作为基础服务的策略模式;

第二层作为合作方对接的策略模式。

主要类图设计:
类图设计


在定义接口与实现类后,形成了对合作方对接层依赖,同时对订单、用信、授信等核心数据进行落地,对消费分期提供数据支撑,举个例子,以下为授信模块主要代码:



  1. 基础服务接口定义


/**
* 授信接口定义
**/

public interface ICreditService {

/**
* appId,资方定义的一个唯一ID
*/

String getAppId();

/**
* app名称
*
*
@return zz or zlj
*/

String getAppName();

/**
* 获取授信结果
*
*
@return result
*/

CreditResult creditResult(String logStr, Long uid);
}




  1. 标准流程抽象


/**
* 标准API对接实现
*
**/

public abstract class AbstractCreditService implements ICreditService {

/**
* 标准API对接
*
*
@return IBaseApiService
*/

protected abstract IBaseApiService getApiThirdService();

@Override
public AppConfig getPartner() {
return commonConfigUtil.getAppConfig(getAppId());
}

@Override
public CreditResult creditResult(String logStr, Long uid) {
CreditResultInput input = new CreditResultInput();
input.setUid(uid);
ResponseProtocol output = getApiThirdService().creditResult(logStr, input);
String creditStatus = TransformUtil.approvalStatusTransform(output.getData());
return CreditResult.builder().result(creditStatus).build();
}
}

/**
* 合作方差异化接入
*/

@Service
@Slf4j
public class ZZABCCreditServiceImpl extends AbstractABCCreditService {

@Resource
ZZABCThirdServiceImpl abcThirdService;

@Override
public String getAppId() {
return PartnerEnum.ABC_ZZ_API.getAppId();
}
@Override
public String getAppName() {
return AppNameEnum.ZZ.getValue();
}
@Override
protected IABCThirdService getABCThirdService() {
return abcThirdService;
}
}



  1. 标准API对接


/**
* 标准API对接
*
*
@author Rouse
*
@date 2022/4/24 13:57
*/

public interface IBaseApiService {
/**
* 标准API,获取appId
*
*
@return appId
*/

String getAppId();
/**
* 获取授信结果
*/

ResponseProtocol creditResult(CreditResultInput input);
}



  1. 内部标准API实现


/**
* 合作方,标准API对接实现
*
*
@author Rouse
*
@date 2022/4/24 14:04
*/

@Slf4j
public abstract class AbstractBaseApiService implements IBaseApiService {
@Override
public ResponseProtocol creditResult(CreditResultInput input) {
// 通用加解密
return getDataResponse(logStr, getAppConf().getUrl4CreditResult(), input, CreditResultOutput.class);
}
}


  1. 差异化合作方接入



/**
* ABC合作方接口封装
**/

public interface IABCThirdService extends IBaseApiService {
/**
* 标准API,获取appId
*
*
@return appId
*/

String getAppId();
/**
* 获取授信结果
*/

ResponseProtocol creditResult(ABCCreditResultInput input);
}

/**
* 合作方抽象方法封装
**/

@Slf4j
public abstract class AbstractABCThirdService extends AbstractBaseApiService implements IABCThirdService {
@Override
public ResponseProtocol creditResult(ABCCreditResultInput input) {
// 加解密差异化实现
return getDataResponse(logStr, getAppConf().getUrl4CreditResult(), input, ABCCreditResultOutput.class);
}
}


/**
* ABC合作方对接
*
*/

@Service
@Slf4j
public class ZZABCThirdServiceImpl extends AbstractABCThirdService{

@Override
public String getAppId() {
return PartnerEnum.ABC_API_ZZ.getAppId();
}

@Override
public String getAppName() {
return AppNameEnum.ZZ.getValue();
}
}


4、上线过程


对于老系统的重构,新系统上线过度期也至关重要,因为采用了新的表结构进行重新设计,涉及到数据的同步,我们采用单向数据同步,逐渐弃用老系统数据,如果灰度期间需要回滚,首先对数据进行回滚,优先保证线上服务稳定。

以下是经历两次重构迭代的过程:
迁移过程


5、监控


监控面板

告警通知



  1. 项目重构监控先行,这次我们采用了转转告警机制和Prometheus线上监控,另外搭建了一套线上看板,及时发现各个模块的潜在隐患。

  2. 日志,一个完美的系统离不开合理的日志,日志往往是定位问题最便捷的工具。


6、总结


通过此次技术重构,我们不仅解决了过去存在的技术债务问题,还提升了服务的稳定性和用户体验,也提升产品交付效率。

技术重构并非一蹴而就,但只要我们有坚定的信念和不懈的努力,终将取得成功。引用一句名言:”不要因为懒惰而拒绝重构,不要因为无暇重构而成为你拖延的理由 。” 是的,重构是持续优化代码质量和可维护性的过程,需要我们时刻关注并付诸行动。

我认为,重构的另一种价值:一个重构好的系统、往往具备通用性,可移植性。简单说就是我们重构后的系统以最小的改动且能在同行中快速复用,因为你创造了一个稳定可靠的“轮子”,如果做到这点,无非你是这个行业技术解决方案的专家。


关于作者


罗思,金融技术部后端研发工程师。转转消费分期业务开发。


作者:转转技术团队
来源:juejin.cn/post/7356550566535495732
收起阅读 »

10分钟带你用RecyclerView+PagerSnapHelper实现一个等级指示器

web
老规矩:先上最终效果图 做前端的同学在平常的工作中,很多时候都会接触到各种指示器,这次我就接到一个等级指示器的需求: RecyclerView横向滚动,item之间有分割线,中间的item会被放大,边上的item会有一定的透明度进行淡化,滚动时要将等级变更回调...
继续阅读 »

老规矩:先上最终效果图


做前端的同学在平常的工作中,很多时候都会接触到各种指示器,这次我就接到一个等级指示器的需求:

RecyclerView横向滚动,item之间有分割线,中间的item会被放大,边上的item会有一定的透明度进行淡化,滚动时要将等级变更回调给外界进行处理,停止滚动后被选中的item需要居中。


效果图如下:


1.gif


实现流程



  1. 创建一个LevelRecyclerView继承RecyclerView,在内部init方法设置它的layoutManager,在外部提供数据源与adapter,然后最简单的RecyclerView就展示出来了

  2. 给每个item添加分割线

  3. 这时候RecyclerView可以随意滚动,给它添加一个PagerSnapHelper,让RecyclerView停下来时,item可以自动居中

  4. 这时又发现首尾的item,由于滚不到RecyclerView的中间,无法被选中,于是调整首尾item的分割线长度,使它们可以滚动到RecyclerView的中间

  5. 给RecyclerView注册滚动监听,在滚动过程中动态修改item的缩放与透明度,并将当前选中等级回调出去

  6. 重写smoothScrollToPosition,方法原实现是将某个item可见,但现在的需求是居中选中,所以要重写


初始化基本的RecyclerView


在LevelRecyclerView初始化时设置一个横向的LinearLayoutManager


    init {
layoutManager = LinearLayoutManager(context, HORIZONTAL, false)
}

设置基本的数据源与adapter


    private fun initRecycler() {
val list = mutableListOf<Int>()
list.add(R.drawable.icon_vip_level_0)
list.add(R.drawable.icon_vip_level_1)
list.add(R.drawable.icon_vip_level_2)
list.add(R.drawable.icon_vip_level_3)
list.add(R.drawable.icon_vip_level_4)
list.add(R.drawable.icon_vip_level_5)
list.add(R.drawable.icon_vip_level_6)
list.add(R.drawable.icon_vip_level_7)
list.add(R.drawable.icon_vip_level_8)
list.add(R.drawable.icon_vip_level_9)
list.add(R.drawable.icon_vip_level_10)

rv_level.adapter = object : CommonAdapter<Int>(this, R.layout.level_item, list) {
override fun convert(holder: ViewHolder, t: Int, position: Int) {
holder.setImageResource(R.id.iv_image, t)
holder.setOnClickListener(R.id.iv_image) {
rv_level.smoothScrollToPosition(position)
}
}
}
}

效果图:



给每个item添加分割线


创建一个LevelDividerItemDecoration类继承ItemDecoration,构造参数需要传入分割线的水平长度与高度,分割线的颜色为可选参数,重写getItemOffsets与onDraw方法,熟悉ItemDecoration的同学可能会觉得onDraw方法有点眼熟,因为我这个onDraw是在DividerItemDecoration上修改的


class LevelDividerItemDecoration @JvmOverloads constructor(
private val itemDividerHorizontalMargin : Int,
private val dividerHeight : Int,
dividerColor : Int = Color.parseColor("#3A3A3C")
) : ItemDecoration() {

//分割线Drawable
private val mDivider = ColorDrawable(dividerColor)
//分割线绘制区域
private val mBounds = Rect()

/**
* 计算item的分割线需要的尺寸,就是一个偏移量,可简单看成外边距
*/

override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
//上下不需要分割线设置为0,左右则是将构造时传入的itemDividerHorizontalMargin设置进去
outRect.set(itemDividerHorizontalMargin, 0, itemDividerHorizontalMargin, 0)
}

/**
* 绘制分割线
*/

override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDraw(canvas, parent, state)

canvas.save()
val top = (parent.height - dividerHeight) / 2
val bottom = top + dividerHeight
if (parent.clipToPadding) {
canvas.clipRect(parent.paddingLeft, top, parent.width - parent.paddingRight, bottom)
}

val childCount = parent.childCount
for (i in 0 until childCount) {
val item = parent.getChildAt(i)
//获取item的Rect,包含它的外边距(包含上面设置进去的偏移量)
parent.layoutManager!!.getDecoratedBoundsWithMargins(item, mBounds)

//左边分割线
mDivider.setBounds(mBounds.left, top, mBounds.left + itemDividerHorizontalMargin, bottom)
mDivider.draw(canvas)

//右边分割线
mDivider.setBounds(mBounds.right - itemDividerHorizontalMargin, top, mBounds.right, bottom)
mDivider.draw(canvas)
}
canvas.restore()
}
}

在init中添加到LevelRecyclerView


addItemDecoration(LevelDividerItemDecoration(
UIUtil.dip2px(context, 16.0),
UIUtil.dip2px(context, 4.0)))

效果图:


2.gif


这时候RecyclerView可以随意滚动,给它添加一个PagerSnapHelper,让RecyclerView停下来时,item可以自动居中


添加一个PagerSnapHelper


在内部添加一个PagerSnapHelper,并在init时设置依附于LevelRecyclerView


private val mSnapHelper = PagerSnapHelper()
init {
mSnapHelper.attachToRecyclerView(this)
layoutManager = mLayoutManager
}

效果图:


3.gif


这时又发现首尾的item,由于滚不到RecyclerView的中间,无法被选中,于是优化LevelDividerItemDecoration的计算与绘制,调整首尾item的分割线长度,使它们可以滚动到RecyclerView的中间


优化LevelDividerItemDecoration的计算与绘制


class LevelDividerItemDecoration @JvmOverloads constructor(
private val itemDividerHorizontalMargin : Int,
private val dividerHeight : Int,
dividerColor : Int = Color.parseColor("#3A3A3C")
) : ItemDecoration() {

//分割线Drawable
private val mDivider = ColorDrawable(dividerColor)
//分割线绘制区域
private val mBounds = Rect()

/**
* 计算item的分割线需要的尺寸,就是一个偏移量,可简单看成外边距
*/

override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
val parentWidth = parent.measuredWidth
val itemWidth = view.layoutParams.width
val lastPosition = parent.adapter?.itemCount?.minus(1) ?: 0
//针对首尾两个item计算它们的左右边距,用parentWidth - itemWidth再除2,可以使item刚好到达RecyclerView的中间
when (parent.getChildAdapterPosition(view)) {
0 -> {
outRect.set(((parentWidth - itemWidth) * 0.5).toInt(), 0, itemDividerHorizontalMargin, 0)
}
lastPosition -> {
outRect.set(itemDividerHorizontalMargin, 0, ((parentWidth - itemWidth) * 0.5).toInt(), 0)
}
else -> outRect.set(itemDividerHorizontalMargin, 0, itemDividerHorizontalMargin, 0)
}
}

/**
* 绘制分割线
*/

override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDraw(canvas, parent, state)

canvas.save()
val top = (parent.height - dividerHeight) / 2
val bottom = top + dividerHeight
if (parent.clipToPadding) {
canvas.clipRect(parent.paddingLeft, top, parent.width - parent.paddingRight, bottom)
}

//RecyclerView宽度
val parentWidth = parent.measuredWidth
val childCount = parent.childCount
for (i in 0 until childCount) {
val item = parent.getChildAt(i)
//item宽度
val itemWidth = item.measuredWidth
//获取item的Rect,包含它的外边距(包含上面设置进去的偏移量)
parent.layoutManager!!.getDecoratedBoundsWithMargins(item, mBounds)

//左边分割线
if (i == 0 && mBounds.width() > itemWidth + itemDividerHorizontalMargin * 2) {
mDivider.setBounds(0, top, mBounds.right - itemWidth - itemDividerHorizontalMargin, bottom)
} else {
mDivider.setBounds(mBounds.left, top, mBounds.left + itemDividerHorizontalMargin, bottom)
}
mDivider.draw(canvas)

//右边分割线
if (i == childCount - 1 && mBounds.width() > itemWidth + itemDividerHorizontalMargin * 2) {
mDivider.setBounds(mBounds.left + itemWidth + itemDividerHorizontalMargin, top, parentWidth, bottom)
} else {
mDivider.setBounds(mBounds.right - itemDividerHorizontalMargin, top, mBounds.right, bottom)
}
mDivider.draw(canvas)
}
canvas.restore()
}
}

效果图:


4.gif


给RecyclerView注册滚动监听,在滚动过程中动态修改item的缩放与透明度,并将当前选中等级回调出去


定义一个等级回调接口


interface OnLevelChangeListener {
fun onLevelChange(position : Int)
}

添加OnScrollListener,在滚动过程中做了一些计算,每个方法都写了注释,具体看下面代码↓


addOnScrollListener(object : OnScrollListener() {
//系数最大值
private val maxFactor = .45F

/**
* RecyclerView滚动
*/

override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val first = mLayoutManager.findFirstVisibleItemPosition()
val last = mLayoutManager.findLastVisibleItemPosition()
val parentCenter = recyclerView.width / 2F
for (i in first..last) {
setItemTransform(i, parentCenter)
}
changeSnapView()
}

/**
* RecyclerView滚动状态改变
*/

override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState == SCROLL_STATE_IDLE) {
changeSnapView()
}
}

/**
* 对item进行各种变换
* 目前是缩放与透明度变换
*/

private fun setItemTransform(position : Int, parentCenter : Float) {
mLayoutManager.findViewByPosition(position)?.run {
val factor = calculationViewFactor(left.toFloat(), width.toFloat(), parentCenter)
val scale = 1 + factor
scaleX = scale
scaleY = scale
alpha = 1 - maxFactor + factor
}
}

/**
* 计算当前item的缩放与透明度系数
* item的中心离recyclerView的中心越远,系数越小(负相关)
*/

private fun calculationViewFactor(left: Float, width : Float, parentCenter : Float) : Float {
val viewCenter = left + width / 2
val distance = abs(viewCenter - parentCenter) / width
return max(0F, (1F - distance) * maxFactor)
}

/**
* 修改当前居中的item,把当前等级回调给外界
*/

private fun changeSnapView() {
mSnapHelper.findSnapView(mLayoutManager)?.let {
mLayoutManager.getPosition(it).let { position ->
if (lastPosition != position) {
lastPosition = position
levelListener?.onLevelChange(position)
}
}
}
}
})

给LevelRecyclerView设置等级回调监听


rv_level.levelListener = object : LevelRecyclerView.OnLevelChangeListener {
override fun onLevelChange(position: Int) {
Log.e("levelListener","levelListener $position")
tv_level.text = "等级:$position"
}
}

效果图:
1.gif


重写smoothScrollToPosition,方法原实现是将某个item可见,但现在的需求是居中选中,所以要重写


方法的原实现其实就是LinearLayoutManager内部创建了一个LinearSmoothScroller去进行滚动,现在我们创建一个CenterSmoothScroller类去继承LinearSmoothScroller,重写它的calculateDtToFit方法,calculateDtToFit用于计算滚动距离,而calculateSpeedPerPixel计算滚动速度


class CenterSmoothScroller(context: Context?) : LinearSmoothScroller(context) {

override fun calculateDtToFit(viewStart: Int, viewEnd: Int, boxStart: Int,
boxEnd: Int, snapPreference: Int)
: Int {
return boxStart + (boxEnd - boxStart) / 2 - (viewStart + (viewEnd - viewStart) / 2)
}

override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics?): Float {
return super.calculateSpeedPerPixel(displayMetrics) * 3F
}
}

override fun smoothScrollToPosition(position : Int) {
if (position == lastPosition) return
if (position < 0 || position >= (adapter?.itemCount ?: 0)) return

mLayoutManager.startSmoothScroll(
CenterSmoothScroller(context).apply {
targetPosition = position
}
)
}

到这里就完成了对整个LevelRecyclerView的开发了,实现了文章开头的动画效果
1.gif


总结


10分钟过去了,这个简单的LevelRecyclerView你拿下没有?

觉得不错的话,就不要吝啬你的点赞!

需要整份代码的话,下面链接自提。

代码链接 : github.MyCustomView


作者:小白白猪
来源:juejin.cn/post/7291474028744278016
收起阅读 »

如何丝滑的实现首页看板拖拉拽功能?

web
需求简介 最近接了一个需求,需要实现不同登录人员可以自定义首页模块卡片。简单来说,就是实现首页看板模块的增添与拖拉拽,效果如下: 技术选型 原生js是支持拖拉拽的,只需要将拖拽的元素的 draggable 属性设置成 "true"即可,然后就是调用相应的函数...
继续阅读 »

需求简介


最近接了一个需求,需要实现不同登录人员可以自定义首页模块卡片。简单来说,就是实现首页看板模块的增添拖拉拽,效果如下:



技术选型


原生js是支持拖拉拽的,只需要将拖拽的元素的 draggable 属性设置成 "true"即可,然后就是调用相应的函数即可。


拖拽操作 - Web API 接口参考 | MDN


但是,原生js功能不够完善,使用起来需要改造的地方很多,因此,选用成熟的第三方插件比较好。


我们的主项目采用的是vue3,,经过一系列对比,最终选择了 vue-draggable-next这个插件。


vue-draggable-next


vue-draggable-next的周下载量月3万左右,可以看出是一个比较靠谱的插件。



它的使用方式npmj上也介绍的很详细:


vue-draggable-next


如果英文的使用Api看起来比较难受,网上还有中文的使用文档:


vue.draggable.next 中文文档 - itxst.com


这个插件也有vue2版本和纯js版本,其他框架也是也是可以完美使用的。


实现思路


需求与技术简析


根据我们的需求,我们应该实现的是分组拖拽,假设我们有三列,那我们要实现的就是这A、B、C三列数据相互拖拽。



我们看看中文官网给的示例:


vue.draggable.next group 例子


看起来很容易,我们只需要写多个draggable标签,每个draggable标签写入相同的组名即可。


实现方案


框架实现


回到代码中,要想实现一个三列可拖拉拽的模块列表,我们首先需要引入组件


<script lang="ts" setup>
import { VueDraggableNext } from 'vue-draggable-next'
// ....
</script>

然后定义一个数组储存数据:


<script lang="ts" setup>
import { VueDraggableNext } from 'vue-draggable-next'
const moduleList = ref([
{
"columnIndex": 1,
"moduleDetail": [
{ "moduleCode": "deviation", "moduleName": "控制失调空间",},
{ "moduleCode": "meeting_pending", "moduleName": "会议待办",},
{ "moduleCode": "abnormal_events", "moduleName": "异常事件", },
{ "moduleCode": "audit_matters", "moduleName": "事项审批",}
],
},
{
"columnIndex": 2,
"moduleDetail": [
{ "moduleCode": "air_conditioning_terminal", "moduleName": "空调末端", }
],
},
{
"columnIndex": 3,
"moduleDetail": [
{ "moduleCode": "run_broadcast", "moduleName": "运行播报",},
{"moduleCode": "my_schedule", "moduleName": "我的日程", },
{ "moduleCode": "cold_station", "moduleName": "冷站",}
],
}
])
</script>

最后,在代码中我们使用v-for循环渲染即可


<div v-for="moduleColumn in  moduleList " :key="moduleColumn.columnIndex" class="box">
<VueDraggableNext :list="moduleColumn.moduleDetail" group="column" >
<div v-for="(item, index) in moduleColumn.moduleDetail " :key="item.moduleCode" class="drag-item">
<!-- 模块内容 -->
</div>
</VueDraggableNext>

</div>

注意上面的html结构,我们循环渲染了三列VueDraggableNext标签,每个VueDraggableNext标签内部又通过v-for="(item, index) in moduleColumn.moduleDetail渲染了这个拖拽列内部的所有模块。我们通过group="column" 让每个VueDraggableNext组件的组名相同,实现了三个拖拽标签之间的模块互相拖拉拽。


拖拽点设置


正常情况小,我们肯定是希望在某个组件的固定位置才能拖动组件,因此我们需要使用到拖拽组件的handle属性。


vue.draggable.next属性说明:


handle:handle=".mover" 只有当鼠标在class为mover类的元素上才能触发拖到事件

根据属性说明,我们的代码实现起来也非常容易了。


  <div v-for="moduleColumn in  moduleList " :key="moduleColumn.columnIndex" class="box">
<VueDraggableNext :list="moduleColumn.moduleDetail" handle=".move" group="column">
<div v-for="(item, index) in moduleColumn.moduleDetail " :key="item.moduleCode" class="drag-item">
<div class="move">
拖拽区域
</div>
<!-- 模块内容 -->
</div>
</VueDraggableNext>

</div>

数据的增删改


实际开发中,我么一定会根据接口或者操作动态的更改列表,代码层也就是更改moduleList的值。非常幸运的是,如果你按照上面的方式写代码,当你拖拉拽完毕后,上面的moduleList值会自动更改,我们不用做任何处理!!!这么看,数据的增删改根本不是问题。


如何动态渲染组件


实际开发中,我们可能会遇到一个问题,就是如何动态的去渲染组件,如果你熟悉vue,使用动态组件component就可以实现。


首先,我们需要定义一个模块列表


import MeetingPending from '../components/meetingPending.vue'
import AbnormalEvents from '../components/abnormalEvents/index.vue'
import MySchedule from '../components/mySchedule.vue'
import TransactionApproval from '../components/transactionApproval.vue'
import RunningBroadcast from '../components/runningBroadcast.vue'
import CodeSite from '../components/codeSite/index.vue'
import MismatchSpace from '../components/mismatchSpace/index.vue'
import AirDevice from '../components/airDevice/index.vue'

// !全量模块选择列表
export const allModuleList = [
{ moduleCode: 'meeting_pending', label: '会议待办', component: MeetingPending },
{ moduleCode: 'my_schedule', label: '我的日程', component: MySchedule },
{ moduleCode: 'audit_matters', label: '事项审批', component: TransactionApproval },
{ moduleCode: 'abnormal_events', label: '异常事件', component: AbnormalEvents },
{ moduleCode: 'deviation', label: '控制失调空间', component: MismatchSpace },
{ moduleCode: 'run_broadcast', label: '运行播报', component: RunningBroadcast },
{ moduleCode: 'cold_station', label: '冷站', component: CodeSite },
{ moduleCode: 'air_conditioning_terminal', label: '空调末端', component: AirDevice }
]

然后根据moduleCode做匹配,动态渲染即可


  <div v-for="moduleColumn in  moduleList " :key="moduleColumn.columnIndex" class="box">
<VueDraggableNext :list="moduleColumn.moduleDetail" handle=".move" group="column">
<div v-for="(item, index) in moduleColumn.moduleDetail " :key="item.moduleCode" class="drag-item">
<div class="move">
拖拽区域
</div>
<component :is="getComponentsByCode(item.moduleCode)" ></component>
</div>
</VueDraggableNext>

</div>

更多定制化需求


如果上面的功能不满足你的需求,我们可以使用这个组件的其他属性,完成更多意想不到的效果


如果下面的属性说明未能完全看明,可以看左边的对应的菜单查看详细说明和例子。


属性名称说明
group如果一个页面有多个拖拽区域,通过设置group名称可以实现多个区域之间相互拖拽 或者 { name: "...", pull: [true, false, 'clone', array , function], put: [true, false, array , function] }
sort是否开启排序,如果设置为false,它所在组无法排序
delay鼠标按下多少秒之后可以拖拽元素
touchStartThreshold鼠标按下移动多少px才能拖动元素
disabled:disabled= "true",是否启用拖拽组件
animation拖动时的动画效果,如设置animation=1000表示1秒过渡动画效果
handle:handle=".mover" 只有当鼠标在class为mover类的元素上才能触发拖到事件
filter:filter=".unmover" 设置了unmover样式的元素不允许拖动
draggable:draggable=".item" 样式类为item的元素才能被拖动
ghost-class:ghost-class="ghostClass" 设置拖动元素的占位符类名,你的自定义样式可能需要加!important才能生效,并把forceFallback属性设置成true
chosen-class:ghost-class="hostClass" 被选中目标的样式,你的自定义样式可能需要加!important才能生效,并把forceFallback属性设置成true
drag-class:drag-class="dragClass"拖动元素的样式,你的自定义样式可能需要加!important才能生效,并把forceFallback属性设置成true
force-fallback默认false,忽略HTML5的拖拽行为,因为h5里有个属性也是可以拖动,你要自定义ghostClass chosenClass dragClass样式时,建议forceFallback设置为true
fallback-class默认false,克隆选中元素的样式到跟随鼠标的样式
fallback-on-body默认false,克隆的元素添加到文档的body中
fallback-tolerance按下鼠标移动多少个像素才能拖动元素,:fallback-tolerance="8"
scroll默认true,有滚动区域是否允许拖拽
scroll-fn滚动回调函数
scroll-fensitivity距离滚动区域多远时,滚动滚动条
scroll-speed滚动速度

传送门:vue.draggable.next 中文文档 - itxst.com


写在最后


关联文章:如何实现模块的锚点定位及闪烁提示:juejin.cn/post/734622…
240315 155754.gif


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

微信小程序主包过大终极解决方案

web
随着小程序项目的不断迭代升级,避免不了体积越来越大。微信限制主包最多2M,然而我们的项目引入了直播插件直接占了1.1M,导致必须采用一些手段去优化。下面介绍一下优化思路和终极解决方案。 1.分包 我相信几乎所有人都能想到的方案,基本上这个方案就能解决问题。具...
继续阅读 »

随着小程序项目的不断迭代升级,避免不了体积越来越大。微信限制主包最多2M,然而我们的项目引入了直播插件直接占了1.1M,导致必须采用一些手段去优化。下面介绍一下优化思路和终极解决方案。



1.分包


我相信几乎所有人都能想到的方案,基本上这个方案就能解决问题。具体如何实现可以参照官方文档这里不做过多说明。(基础能力 / 分包加载 / 使用分包 (qq.com)),但是有时候你会发现分包之后好像主包变化不是很大,这是为什么呢?



  • 痛点1:通过依赖分析,如果分包中引入了第三方依赖,那么依赖的js仍然会打包在主包中,例如echarts、wxparse、socket.io。这就导致我们即使做了分包处理,但是主包还是很大,因为相关的js都会在主包中的vendor.js

  • 痛点2:插件只能在主包中无法分包,例如直播插件直接占据1M
    image.png

  • 痛点3:tabbar页面无法分包,只能在主包内

  • 痛点4:公共组件/方法无法分包,只能在主包内

  • 痛点5:图片只能在主包内


2.图片优化


图片是最好解决的,除了tabbar用到的图标,其余都放在云上就好了,例如oss和obs。而且放在云上还有个好处就是背景图片无需担心引入不成功。


3.tabbar页面优化


这部分可以采用tabbar页面都在放在一个文件夹下,比如一共有4个tab,那么一个文件夹下就只存放这4个页面。其余tabbar的子页面一律采用分包。


4.独立分包


独立分包是小程序中一种特殊类型的分包,可以独立于主包和其他分包运行。从独立分包中页面进入小程序时,不需要下载主包。当用户进入普通分包或主包内页面时,主包才会被下载。
但是使用的时候需要注意:



  • 独立分包中不能依赖主包和其他分包中的内容,包括 js 文件、template、wxss、自定义组件、插件等(使用 分包异步化 时 js 文件、自定义组件、插件不受此条限制)

  • 主包中的 app.wxss 对独立分包无效,应避免在独立分包页面中使用 app.wxss 中的样式;

  • App 只能在主包内定义,独立分包中不能定义 App,会造成无法预期的行为;

  • 独立分包中暂时不支持使用插件。


5.终极方案we-script



我们自己写的代码就算再多,其实增加的kb并不大。大部分大文件主要源于第三方依赖,那么有没有办法像webpack中的externals一样,当进入这个页面的时候再去异步加载js文件而不被打包呢(说白了就是CDN)



其实解决方案就是we-script,他允许我们使用CDN方式加载js文件。这样就不会影响打包体积了。


使用步骤



  1. npm install --save we-script

  2. "packNpmRelationList": [{"packageJsonPath": "./package.json", "miniprogramNpmDistDir":"./dist/"}]

  3. 点击开发者工具中的菜单栏:工具 --> 构建 npm

  4. "usingComponents": {"we-script": "we-script"}

  5. <we-script src="url1" />


使用中存在的坑


构建后可能会出现依赖报错,解决的方式就是将编译好的文件手动拖入miniprogram_npm文件夹中,主要是三个文件夹:we-script,acorn,eval5


最后成功解决了主包文件过大的问题,只要是第三方依赖,都可以通过这个办法去加载。


感谢阅读,希望来个三连支持下,转载记得标注原文地址~


作者:前端小鱼26
来源:juejin.cn/post/7355057488351674378
收起阅读 »

为什么很多程序员会觉得领导没能力

相信很多人在职场里待久了,都会遇到自己觉得比较差劲的领导,这些人可能除了向上管理能力很强外(会舔老板),其他能力在你看来都挺一般,专业能力一般,超级缝合怪--上级给他的任何任务他都能分配给你们,然后他再缝合一遍完事。 那么遇到这种领导我们该怎么办呢?多数人想到...
继续阅读 »

相信很多人在职场里待久了,都会遇到自己觉得比较差劲的领导,这些人可能除了向上管理能力很强外(会舔老板),其他能力在你看来都挺一般,专业能力一般,超级缝合怪--上级给他的任何任务他都能分配给你们,然后他再缝合一遍完事。


那么遇到这种领导我们该怎么办呢?多数人想到的是跳槽,这确实是一个解法,但你跳到下家公司也保不齐会有这样的领导呀,今天咱们讨论的这个话题就先把条件限定成你不能跳槽,这个时候你该采用什么方法让自己的上班体验变好一些。


多元化自己的评估标准


首先,不能用鄙视的眼光去看待你的领导,觉得他只会舔老板(能舔、会舔也是一种很强的能力呀),有的时候你觉得你领导能力不行,很有可能是因为你的能力评估标准太单一了。


他或许在工作的某个方面不如你,但是他必定在某些方面有自己的长处,努力发现他的长处,认可他的长处,学习他的长处,可以更有助于你和他的相处,也有利于你的进步。


社会是一个大熔炉,你需要的不仅仅是业务能力和展现的舞台,也需要与社会中不同个体的和谐共处。包容、接纳,都是立身处世的能力。


学会变通和沟通,满足领导的存在感


领导之所以会在很多工作上提意见、瞎指挥、乱指挥,更多的情况可能是他知道自己对工作不熟悉,但觉得自己是领导,会有自己独特的见解,想刷自己的存在感。这种情况下,要学会满足领导的存在感。


举个例子说,你在工作中,领导过来给你提了个意见,这个意见明显是不合适的,那你就可以说,“领导,这个思路好,我们之前没往这个角度想,可以从这个角度延展一下……。”他走了,还不是我们自己把控,毕竟他只是过来刷个存在感的,只要最后的方案让客户满意,业绩给领导,把一些光环放在他身上,让他觉得他起到了作用,这些方案和他有关,他通常也不会计较了。


摸清领导管理的思想和套路


说到这里,找到领导心中的关键因素,是非常必要的。在一个项目里,员工承担的通常只是局部,而领导看的是整体,由于高度不同,所以你们考虑的关键因素是不同的。


所以你要知道领导心里到底想要的是什么,提前做好这方面的预期和准备,以及针对领导提出的你们没有考虑到的方面要虚心接受(毕竟领导跟高层接触的更多,有些战略方向上的事情他们会更清楚)。 


比如说,你是一个小编,你在意的是按时完成写作任务、及时发表、赚取眼球,而你的领导主编可能更在意的是你文章的各种数据真实性、转化人群、是否会产生舆情、是否zzzq这些。所以,要搞清领导在意的重要维度,工作才能更有效。


这里有三句话分享给大家:



  • 要能够分清你可以改变的事、无法改变的事;

  • 不去抱怨你服务改变的事;

  • 把精力用在你可以改变的地方。


你的上司,是你改变不了的,但你自己,是可以把握的。当然这篇文章也不是教你怎么委屈自己,只是提供一个不同的角度来讨论"领导不行” 这个事情,以及让你在无法立刻更换环境时,该怎样让当前的环境变得不那么恶劣。


想跳槽的同学还是应该按部就班的准备,骑驴找马有更合适的地方该跳就跳,跳过去了说不定今天学到的这些还能用的上……。


作者:kevinyan
来源:juejin.cn/post/7357911195946336293
收起阅读 »

Android RecyclerView宫格拖拽效果实现

前言 在Android发展的进程中,网格布局一直比较有热度,其中一个原因是对用户来说便捷操作,对app厂商而言也会带来很多的曝光量,对于很多头部app,展示网格菜单几乎是必选项。实现网格的方式有很多种,比如GridView、GridLayout,TableLa...
继续阅读 »

前言


在Android发展的进程中,网格布局一直比较有热度,其中一个原因是对用户来说便捷操作,对app厂商而言也会带来很多的曝光量,对于很多头部app,展示网格菜单几乎是必选项。实现网格的方式有很多种,比如GridView、GridLayout,TableLayout等,实际上,由于RecyclerView的灵活性和可扩展性很高,这些View基本没必要去学了,为什么这样说呢?主要原因是基于RecyclerView可以实现很多布局效果,传统的很多Layout都可以通过RecyclerView去实现,比如ViewPager、SlideTabLayout、DrawerLayout、ListView等,甚至连九宫格解锁效果也可以实现。


当然,在很早之前,实现网格的拖拽效果主要是通过GridView去实现的,如果列数为1的话,那么GridView基本上就实现了ListView一样的上下拖拽。


话说回来,我们现在基本不用去学习这类实现了,因为RecyclerView足够强大,通过简单的数据组装,是完全可以替代GridView和ListView的。


效果


本篇我们会使用RecyclerView来实现网格拖拽,本篇将结合图片分片案例,实现拖拽效果。


fire_139.gif


如果要实现网格菜单的拖拽,也是可以使用这种方式的,只要你的想象丰富,理论上,借助RecyclerView其实可以做出很多效果。


fire_140.gif


拖拽效果原理


拖动其实需要处理3个核心的问题,事件、图像平移、数据交换。


事件处理


实际上无论传统的拖拽效果还是最新的拖拽效果,都离不开事件处理,不过,好处就是,google为RecyclerView提供了ItemTouchHelper来处理这个问题,相比传统的GridView实现方式,省去了很多事情,如动画、目标查找等。


不过,我们回顾下原理,其实他们很多方面都是相似的,不同之处就是ItemTouchHelper 设计的非常好用,而且接口暴露的非常彻底,甚至能控制那些可以拖动、那些不能拖动、以及什么方向可以拖动,如果我们上、下、左、右四个方向都选中的话,斜对角拖动完全没问题,


事件处理这里,GridView使用的方式相对传统,而ItemTouchHelper借助RecyclerView的一个接口(看样子是开的后门),通过View自身去拦截事件.


public interface OnItemTouchListener {
//是否让RecyclerView拦截事件
boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e);
//拦截之后处理RecyclerView的事件
void onTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e);
//监听禁止拦截事件的请求结果
void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept);
}

这种其实相对GridView来说简单的多


图像平移


无论是RecyclerView和传统GridView拖动,都需要图像平移。我们知道,RecyclerView和GridView本身是通过子View的边界(left\top\right\bottom)来移动的,那么,在平移图像的时候必然不能选择这种方式,只能选择Matrix 变化,也就是transitionX和transitionY的等。不同点是GridView的子View本身并不移动,而是将图像绘制到一个GridView之外的View上,相当于灵魂附体到外面View上,实现上是比较复杂。


对于RecyclerView来说,ItemTouchHelper设计比较巧妙的一点是,通过RecyclerView#ItemDecoration来实现,在捕获可以滑动的View之后,在绘制时对View进行偏移。


class ItemTouchUIUtilImpl implements ItemTouchUIUtil {
static final ItemTouchUIUtil INSTANCE = new ItemTouchUIUtilImpl();

@Override
public void onDraw(Canvas c, RecyclerView recyclerView, View view, float dX, float dY,
int actionState, boolean isCurrentlyActive)
{
if (Build.VERSION.SDK_INT >= 21) {
if (isCurrentlyActive) {
Object originalElevation = view.getTag(R.id.item_touch_helper_previous_elevation);
if (originalElevation == null) {
originalElevation = ViewCompat.getElevation(view);
float newElevation = 1f + findMaxElevation(recyclerView, view);
ViewCompat.setElevation(view, newElevation);
view.setTag(R.id.item_touch_helper_previous_elevation, originalElevation);
}
}
}

view.setTranslationX(dX);
view.setTranslationY(dY);
}
//省略一些有关或者无关的代码
}

不过,我们看到,Android 5.0的版本借助了setElevation 使得被拖拽View不被其他顺序的View遮住,那Android 5.0之前是怎么实现的呢?


其实,做过TV app的都比较清楚,子View绘制顺序可以通过下面方式调整,借助下面的方法,在TV上某个View获取焦点之后,就不会被后面的View盖住。


View#getChildDrawingOrder(...)

此方法实际上是改变了View的绘制顺序,原理是通过下面方式,将View的索引和绘制顺序进行了映射,比如原来的第一个View模式是第1个被绘制的子View,但可以变更成最后一个绘制的View。



原理:让第i个位置绘制第index的view,伪代码如下



void drawChildFunction(drawIndex,canvas){
children[mapChildIndex(drawIndex)].draw(canvas);
}

具体实现方法参考如下。


ArrayList<View> buildOrderedChildList() {
final int childrenCount = mChildrenCount;
if (childrenCount <= 1 || !hasChildWithZ()) return null;

if (mPreSortedChildren == null) {
mPreSortedChildren = new ArrayList<>(childrenCount);
} else {
// callers should clear, so clear shouldn't be necessary, but for safety...
mPreSortedChildren.clear();
mPreSortedChildren.ensureCapacity(childrenCount);
}

final boolean customOrder = isChildrenDrawingOrderEnabled();
for (int i = 0; i < childrenCount; i++) {
// add next child (in child order) to end of list
final int childIndex = getAndVerifyPreorderedIndex(childrenCount, i, customOrder);

// 映射View
final View nextChild = mChildren[childIndex];
final float currentZ = nextChild.getZ();

// 如果Z值大的话往后移动,5.0之前的代码没有这段
int insertIndex = i;
while (insertIndex > 0 && mPreSortedChildren.get(insertIndex - 1).getZ() > currentZ) {
insertIndex--;
}
mPreSortedChildren.add(insertIndex, nextChild);
}
return mPreSortedChildren;
}

ItemTouchHelper 同样借助了此方法,在我们测试后发现,其实Android 4.4之前的版本没有明显的效果差异,但是这里依然好奇,为什么不统一使用一种方式呢?


没有找到明确的答案,但是从代码效率来说,显然setElevation性能更好一些,同时也释放了对绘制顺序的功能的占用。


private void addChildDrawingOrderCallback() {
if (Build.VERSION.SDK_INT >= 21) {
return; // we use elevation on Lollipop
}
if (mChildDrawingOrderCallback == null) {
mChildDrawingOrderCallback = new RecyclerView.ChildDrawingOrderCallback() {
@Override
public int onGetChildDrawingOrder(int childCount, int i) {
if (mOverdrawChild == null) {
return i;
}
int childPosition = mOverdrawChildPosition;
if (childPosition == -1) {
childPosition = mRecyclerView.indexOfChild(mOverdrawChild);
mOverdrawChildPosition = childPosition;
}
if (i == childCount - 1) {
//将最后索引位置展示被拖拽的View
return childPosition;
}
//后面的View 绘制顺序往前移动
return i < childPosition ? i : i + 1;
}
};
}
mRecyclerView.setChildDrawingOrderCallback(mChildDrawingOrderCallback);
}

这里为什么要讲解之前的版本怎么做的呢?主要原因是,目前除了手机设备以外,有相当一部分设备是Android 4.4 的,而且事件传递过程中需要了解这方面的思想。


数据更新


数据更新这里其实ReyclerView的优势更加明显,我们知道RecyclerView可以做到无requestLayout的局部刷新,性能更好。


@Override
public boolean onItemMove(int fromPosition, int toPosition) {
Collections.swap(mDataList, fromPosition, toPosition);
notifyItemMoved(fromPosition, toPosition);
return true;
}

不过,数据交换后还有一点需要处理,对Matrix相关属性清理,防止无法落到指定区域。


@Override
public void clearView(View view) {
if (Build.VERSION.SDK_INT >= 21) {
final Object tag = view.getTag(R.id.item_touch_helper_previous_elevation);
if (tag instanceof Float) {
ViewCompat.setElevation(view, (Float) tag);
}
view.setTag(R.id.item_touch_helper_previous_elevation, null);
}

view.setTranslationX(0f);
view.setTranslationY(0f);
}

本篇实现


以上基本都是对ItemTouchHelper的原理梳理了,当然,如果你没时间看上面的话,就看实现部分吧。


图片分片


下面我们把多张图片分割成 [行数 x 列数]数量的图片。


Bitmap srcInputBitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.image_4);
Bitmap source = Bitmap.createScaledBitmap(srcInputBitmap, width, height, true);
srcInputBitmap.recycle();

int colCount = spanCount;
int rowCount = 6;

int spanImageWidthSize = source.getWidth() / colCount;
int spanImageHeightSize = (source.getHeight() - rowCount * padding/2) / rowCount;

Bitmap[] bitmaps = new Bitmap[rowCount * colCount];
for (int i = 0; i < rowCount; i++) {
for (int j = 0; j < colCount; j++) {
int y = i * spanImageHeightSize;
int x = j * spanImageWidthSize;
Bitmap bitmap = Bitmap.createBitmap(source, x, y, spanImageWidthSize, spanImageHeightSize);
bitmaps[i * colCount + j] = bitmap;
}
}

在这种过程我们一定要处理一个问题,如果我们对网格设置了边界线(ItemDecoration)且是纵向布局的话,那么RecyclerView天然都不会横向滑动,但是纵向就不一样了,纵向总高度要减去rowCount * bottomPadding,这里bottomPadding == padding/2,如下面代码。


为什么要这么做呢?因为RecyclerView计算高度的时候,需要考虑这个高度,如果不去处理,那么ReyclerView可能会滑动,虽然影响不大,但是如果实现全屏效果,拖动View时RecyclerView还能上下滑的话体验比较差。


public class SimpleItemDecoration extends RecyclerView.ItemDecoration {

public int delta;
public SimpleItemDecoration(int padding) {
delta = padding;
}

@Override
public void getItemOffsets(Rect outRect, View view,
RecyclerView parent, RecyclerView.State state)
{
int position = parent.getChildAdapterPosition(view);
RecyclerView.Adapter adapter = parent.getAdapter();
int viewType = adapter.getItemViewType(position);
if(viewType== Bean.TYPE_GR0UP){
return;
}
GridLayoutManager layoutManager = (GridLayoutManager) parent.getLayoutManager();
//列数量
int cols = layoutManager.getSpanCount();
//position转为在第几列
int current = layoutManager.getSpanSizeLookup().getSpanIndex(position,cols);
//可有可无
int currentCol = current % cols;


int bottomPadding = delta / 2;

if (currentCol == 0) { //第0列左侧贴边
outRect.left = 0;
outRect.right = delta / 4;
outRect.bottom = bottomPadding;
} else if (currentCol == cols - 1) {
outRect.left = delta / 4;
outRect.right = 0;
outRect.bottom = bottomPadding;
//最后一列右侧贴边
} else {
outRect.left = delta / 4;
outRect.right = delta / 4;
outRect.bottom = bottomPadding;
}
}
}

更新数据


这部分是常规操作,主要目的是设置LayoutManager、Decoration、Adapter以及ItemTouchHelper,当然,ItemTouchHelper比较特殊,因为其内部是ItemDecoration、OnItemTouchListener、Gesture的组合,因此封装为attachToRecyclerView 来调用。


mLinearLayoutManager = new GridLayoutManager(this, spanCount, LinearLayoutManager.VERTICAL, false);
mLinearLayoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup(){
@Override
public int getSpanSize(int position) {
if(mAdapter.getItemViewType(position) == Bean.TYPE_GR0UP){
return spanCount;
}
return 1;
}
});
mAdapter = new RecyclerViewAdapter();
mRecyclerView.setAdapter(mAdapter);
mRecyclerView.setLayoutManager(mLinearLayoutManager);
mRecyclerView.addItemDecoration(new SimpleItemDecoration(padding));
ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new GridItemTouchCallback(mAdapter));
itemTouchHelper.attachToRecyclerView(mRecyclerView);

这里,我们主要还是关注ItemTouchHelper,在初始化的时候,我们给了一个GridItemTouchCallback,用于监听相关处理逻辑,最终通知Adapter调用notifyXXX更新View。


public class GridItemTouchCallback extends ItemTouchHelper.Callback {
private final ItemTouchCallback mItemTouchCallback;
public GridItemTouchCallback(ItemTouchCallback itemTouchCallback) {
mItemTouchCallback = itemTouchCallback;
}

@Override
public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
if(viewHolder.getItemViewType() == Bean.TYPE_GR0UP){
return 0; //设置此类型的View不可拖动
}
// 上下左右拖动
int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT;
return makeMovementFlags(dragFlags, 0);
}

@Override
public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
// 通知Adapter移动View
return mItemTouchCallback.onItemMove(viewHolder.getAdapterPosition(), target.getAdapterPosition());
}
@Override
public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
// 通知Adapter删除View
mItemTouchCallback.onItemRemove(viewHolder.getAdapterPosition());
}

@Override
public void onChildDraw(@NonNull Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) {
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
@Override
public void onChildDrawOver(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) {
Log.d("GridItemTouch","dx="+dX+", dy="+dY);
super.onChildDrawOver(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
}

这里,主要是对Flag的关注需要处理,第一参数是拖拽方向,第二个是删除方向,我们本篇不删除,因此,第二个参数为0即可。


public static int makeMovementFlags(int dragFlags, int swipeFlags) {
return makeFlag(ACTION_STATE_IDLE, swipeFlags | dragFlags)
| makeFlag(ACTION_STATE_SWIPE, swipeFlags)
| makeFlag(ACTION_STATE_DRAG, dragFlags);
}

当然,删除和拖拽都不要的viewHolder,那么直接返回0.


总结


本篇到这里就结束了,我们利用RecyclerView实现了宫格图片的拖拽效果,主要是借助ItemTouchHelper实现,从ItemTouchHelper中我们能看到很多巧妙的的设计,里面有很多值得我们学习的技巧,特别是对事件的处理、绘制顺序调整的方式,如果做吸顶,未尝不是一种方案。


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

Android进阶之路 - 字体自适应

开发中有很多场景需要进行自适应适配,但是关于这种字体自适应,我也是为数不多的几次使用,同时也简单分析了下源码,希望我们都有收获 很多时候控件的宽度是有限的,而要实现比较好看的UI效果,常见的处理方式应该有以下几种 默认执行多行显示 单行显示,不足部分显示....
继续阅读 »

开发中有很多场景需要进行自适应适配,但是关于这种字体自适应,我也是为数不多的几次使用,同时也简单分析了下源码,希望我们都有收获



很多时候控件的宽度是有限的,而要实现比较好看的UI效果,常见的处理方式应该有以下几种



  • 默认执行多行显示

  • 单行显示,不足部分显示...

  • 自适应字体


静态设置


宽度是有限的,内部文字会根据配置进行自适应


在这里插入图片描述


TextView 自身提供了自适应的相关配置,可直接在layout中进行设置


主要属性



  • maxLines="1"

  • autoSizeMaxTextSize

  • autoSizeMinTextSize

  • autoSizeTextType

  • autoSizeStepGranularity


    <TextView
android:id="@+id/tv_text3"
android:layout_width="50dp"
android:layout_height="40dp"
android:layout_marginTop="10dp"
android:autoSizeMaxTextSize="18sp"
android:autoSizeMinTextSize="10sp"
android:autoSizeStepGranularity="1sp"
android:autoSizeTextType="uniform"
android:gravity="center"
android:maxLines="1"
android:text="自适应字体" />


源码:自定义属性


在这里插入图片描述




动态设置


 // 设置自适应文本默认配置(基础配置)
TextViewCompat.setAutoSizeTextTypeWithDefaults(textView, TextView.AUTO_SIZE_TEXT_TYPE_UNIFORM)
// 主动设置自适应字体相关配置
TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration(textView, 20, 48, 2, TypedValue.COMPLEX_UNIT_SP)



源码分析


如果你有时间,也有这方面的个人兴趣,可以一起分享学习一下


setAutoSizeTextTypeWithDefaults


根据源码来看的话,内部做了兼容处理,主要是设置自适应文本的默认配置


在这里插入图片描述


默认配置方法主要根据不同类型设置自适应相关配置,默认有AUTO_SIZE_TEXT_TYPE_NONE or AUTO_SIZE_TEXT_TYPE_UNIFORM ,如果没有设置的话就会报 IllegalArgumentException 异常



  • AUTO_SIZE_TEXT_TYPE_NONE 清除自适应配置

  • AUTO_SIZE_TEXT_TYPE_UNIFORM 添加一些默认的配置信息


在这里插入图片描述




setAutoSizeTextTypeUniformWithConfiguration


根据源码来看主传4个参数,内部也做了兼容处理,注明 Build.VERSION.SDK_INT>= 27 or 属于 AutoSizeableTextView 才能使用文字自定义适配



  • textView 需进行自适应的控件

  • autoSizeMinTextSize 自适应自小尺寸

  • autoSizeMaxTextSize 自适应自大尺寸

  • autoSizeStepGranularity 自适应配置

  • unit 单位,如 sp(字体常用)、px、dp


在这里插入图片描述


unit 有一些常见的到单位,例如 dp、px、sp等


在这里插入图片描述


作者:Shanghai_MrLiu
来源:juejin.cn/post/7247027677223485498
收起阅读 »

仿抖音评论,点击回复自动将该条评论上移至第一条

打开抖音的评论,回复评论时,自动将该条评论上滑至最上方的位置,目的也是为了让用户能够回复的时候,看到他要回复评论的内容 评论有一级评论和二级评论,一级评论是评论弹窗下的recyclerView,二级评论又是一级评论adpter中某一个item的一个recycl...
继续阅读 »

打开抖音的评论,回复评论时,自动将该条评论上滑至最上方的位置,目的也是为了让用户能够回复的时候,看到他要回复评论的内容


评论有一级评论和二级评论,一级评论是评论弹窗下的recyclerView,二级评论又是一级评论adpter中某一个item的一个recyclerView,回复一级评论时,上移相对简单一点,如果回复二级评论时,把二级评论上移到第一条稍微复杂一些。


有2种方法,目测抖音的评论就是用的一种方法


方法实现原理:先计算出该评论的在整个屏幕中的位置,主要是point Y这个点,然后计算移动到第一条需要向上移动的距离,通过属性动画完成。


具体步骤看代码注释



// 1. 拿到一级评论对应的viewHolder,有了这个viewHolder就可以拿到指定的子view
val viewHolder1 =
viewHolder!!.rvList.findViewHolderForAdapterPosition(mFirstCommentPosition)
if (viewHolder1 != null && viewHolder1 is VideoDetailCommentAdapter.VideoDetailCommentViewHolder) {
val viewHolder2 = viewHolder1 as VideoDetailCommentAdapter.VideoDetailCommentViewHolder
val location = IntArray(2)
// 如果回复一级评论,默认mSecondCommentPosition为-1,如果回复二级评论,mSecondCommentPosition肯定就不是-1了,因为position是从0开始的
if (mSecondCommentPosition != -1) {
// 拿到二级评论的viewHolder
val viewHolder3 = viewHolder2.rvList.findViewHolderForAdapterPosition(mSecondCommentPosition)
if (viewHolder3 != null && viewHolder3 is VideoDetailSecondCommentAdapter.VideoDetailSecondCommentViewHolder) {
val viewHolder4 = viewHolder3 as VideoDetailSecondCommentAdapter.VideoDetailSecondCommentViewHolder
// 我获取的时该评论头像相对于屏幕的的具体的点,用的是getLocationOnScreen,当然也可以参考使用# getLocationInWindow
viewHolder4.rrivAvatar.getLocationOnScreen(location)
// 获取需要上移的距离,然后上移
translateY = (location[1] - AppUtil.dp2px(264f)) * -1.0f
val ani: ObjectAnimator = ObjectAnimator.ofFloat(viewHolder!!.rvList, "translationY", 0f, translateY)
ani.duration = 300
ani.start()
}
} else {
// 我获取的时该评论头像相对于屏幕的的具体的点,用的是getLocationOnScreen,当然也可以参考使用# getLocationInWindow
viewHolder2.rrivAvatar.getLocationOnScreen(location)
// 获取需要上移的距离,然后上移
translateY = (location[1] - AppUtil.dp2px(264f)) * -1.0f
val ani: ObjectAnimator = ObjectAnimator.ofFloat(viewHolder!!.rvList, "translationY", 0f, translateY)
ani.duration = 300
ani.start()
}
}

方法二,通过scrollToPositionWithOffset实现


linearLayout.scrollToPositionWithOffset(mFirstCommentPosition, translateY)

其中mFirstCommentPosition为一级评论的position,translateY为二级评论相对于一级评论的在竖直方向上的高度。


该方法有个小问题,不能实现缓慢的滑动效果,直接就上去了,有点突兀。


当然还有一个方法,调用recyclerView的smoothScrollToPosition方法,该方法只能实现评论滑动到屏幕可见,一般是在最下方,并不能实现滑动到顶。scrollToPositionWithOffset方法的第2个参数如果设置为0就可以实现滑动到顶。


还有一个小问题,就是如果回复的评论恰好是最后一条,则滑不上去了,因为下方没有数据了。


具体代码如下


val linearLayout = viewHolder!!.rvList.layoutManager as LinearLayoutManager

val viewHolder1 =
viewHolder!!.rvList.findViewHolderForAdapterPosition(mFirstCommentPosition)
if (viewHolder1 != null && viewHolder1 is VideoDetailCommentAdapter.VideoDetailCommentViewHolder) {
val viewHolder2 = viewHolder1 as VideoDetailCommentAdapter.VideoDetailCommentViewHolder
val location = IntArray(2)
viewHolder2.rrivAvatar.getLocationOnScreen(location)
if (mSecondCommentPosition != -1) {
val viewHolder3 = viewHolder2.rvList.findViewHolderForAdapterPosition(mSecondCommentPosition)
if (viewHolder3 != null && viewHolder3 is VideoDetailSecondCommentAdapter.VideoDetailSecondCommentViewHolder) {
val viewHolder4 = viewHolder3 as VideoDetailSecondCommentAdapter.VideoDetailSecondCommentViewHolder
val location1 = IntArray(2)
viewHolder4.rrivAvatar.getLocationOnScreen(location1)
translateY = (location1[1] - location[1]) * -1
linearLayout.scrollToPositionWithOffset(mFirstCommentPosition, translateY)
}
} else {
linearLayout.scrollToPositionWithOffset(mFirstCommentPosition, 0)
}
}

作者:心在梦在
来源:juejin.cn/post/7356772896046415908
收起阅读 »

既然有需求,那么就手撸一个JSON可视化组件吧

前言: 最近在公司的一个项目中遇到了一个需求,就是将读取到的JSON数据,展示成一个树形结构,并且还得给每一个节点添加一个类型标签以及复选框。好了,话不多说,直接上代码,代码的思路到时候放在代码后面。如果各位看官老爷觉得有什么可以优化的地方或者不理解的地方也可...
继续阅读 »

前言:


最近在公司的一个项目中遇到了一个需求,就是将读取到的JSON数据,展示成一个树形结构,并且还得给每一个节点添加一个类型标签以及复选框。好了,话不多说,直接上代码,代码的思路到时候放在代码后面。如果各位看官老爷觉得有什么可以优化的地方或者不理解的地方也可以在评论中说出,大家一起讨论;


代码


<script>
export default {
name: "YJsonEditer",
data() {
return {
JsonAST: {},
LiList: [],
checkBoxList: [],
checkBoxKey: 1,
// checkId:''
};
},
props: {
json: {
type: String,
default:
'{"total":18,"data":[[{"themeType":{"themeType":"dark"}}],[{"themeType":"light"}]],"rows":[{"caseCode":"9174ff6dfbc243eb931270a06c447666","institutionId":2,"arbitralCourtId":1,"nickName":"张慧","deptId":102,"applicantName":"钱红","arbitralCourtName":"第一仲裁庭","times":2,"caseNumber":"常钟劳人仲案字〔2023〕第29号","scheduleDate":"2023-05-30T00:00:00","caseName":"钱红讨薪","respondentName":"钱橙","startTime":"08:30:00","id":26,"endTime":"08:45:00","status":"1"},{"caseCode":"1c096b703b78495ea90ca13ab65258cb","institutionId":2,"arbitralCourtId":1,"nickName":"徐洋","deptId":102,"applicantName":"赵春","arbitralCourtName":"第一仲裁庭","times":1,"caseNumber":"常钟劳人仲案字〔2023〕第28号","scheduleDate":"2023-05-25T00:00:00","caseName":"赵春徐洋登记","respondentName":"赵夏","startTime":"08:30:00","id":24,"endTime":"08:45:00","status":"3"},{"caseCode":"9174ff6dfbc243eb931270a06c447666","institutionId":2,"arbitralCourtId":1,"nickName":"张慧","deptId":102,"applicantName":"钱红","arbitralCourtName":"第一仲裁庭","times":1,"caseNumber":"常钟劳人仲案字〔2023〕第29号","scheduleDate":"2023-05-25T00:00:00","caseName":"钱红讨薪","respondentName":"钱橙","startTime":"09:00:00","id":25,"endTime":"09:15:00","status":"3"},{"caseCode":"47cdbb573f354660b448d3bf55d36a69","arbitralCourtId":1,"nickName":"徐洋","applicantName":"赵春","arbitralCourtName":"第一仲裁庭","times":1,"caseNumber":"常钟劳人仲案字〔2023〕第26号","scheduleDate":"2023-05-24T00:00:00","caseName":"劳动报酬","respondentName":"赵夏","startTime":"08:30:00","id":23,"endTime":"08:45:00","status":"3"},{"caseCode":"e3a68febb9294112a85dfeffc00002d6","arbitralCourtId":1,"nickName":"周圆","applicantName":"申请人加密","arbitralCourtName":"第一仲裁庭","respondentCompanyName":"被申请单位加密","times":3,"caseNumber":"常钟劳人仲案字〔2023〕第12号","scheduleDate":"2023-05-23T00:00:00","caseName":"测试加密1111","startTime":"18:30:00","id":22,"endTime":"18:45:00","status":"3"},{"caseCode":"e3a68febb9294112a85dfeffc00002d6","arbitralCourtId":1,"nickName":"周圆","applicantName":"申请人加密","arbitralCourtName":"第一仲裁庭","respondentCompanyName":"被申请单位加密","times":2,"caseNumber":"常钟劳人仲案字〔2023〕第12号","scheduleDate":"2023-05-19T00:00:00","caseName":"测试加密1111","startTime":"08:45:00","id":21,"endTime":"09:15:00","status":"2"},{"caseCode":"9ae48862deaa4c24982b8bef5993d00d","arbitralCourtId":1,"nickName":"徐洋","applicantName":"数据库加密申请人1,申请人2","arbitralCourtName":"第一仲裁庭","respondentCompanyName":"数据库加密单位","times":1,"caseNumber":"常钟劳人仲案字〔2023〕第11号","scheduleDate":"2023-05-12T00:00:00","caseName":"加密测试案件","startTime":"09:00:00","id":19,"endTime":"09:30:00","status":"1"},{"caseCode":"e3a68febb9294112a85dfeffc00002d6","arbitralCourtId":1,"nickName":"周圆","applicantName":"申请人加密","arbitralCourtName":"第一仲裁庭","respondentCompanyName":"被申请单位加密","times":1,"caseNumber":"常钟劳人仲案字〔2023〕第12号","scheduleDate":"2023-05-12T00:00:00","caseName":"测试加密1111","startTime":"12:30:00","id":20,"endTime":"15:00:00","status":"1"},{"caseCode":"73c606f4dfca4137b3c0f4ee6ed4b9f9","institutionId":2,"arbitralCourtId":1,"nickName":"姜哲","deptId":102,"arbitralCourtName":"第一仲裁庭","times":1,"caseNumber":"常钟劳人仲案字〔2023〕第3号","scheduleDate":"2023-05-05T00:00:00","caseName":"赔偿医疗费","startTime":"08:30:00","id":18,"endTime":"08:45:00","status":"3"},{"caseCode":"793855c4cab545099752d7b4dd2ef402","times":1,"arbitralCourtId":1,"caseNumber":"常钟劳人仲案字〔2023〕第10号","nickName":"刘祥任","scheduleDate":"2023-05-04T00:00:00","caseName":"某某公司拖欠工资","startTime":"09:00:00","id":16,"endTime":"09:30:00","arbitralCourtName":"第一仲裁庭","status":"1"}],"code":200,"msg":"查询成功"}',
},
height: {
type: String,
default: "100%",
},
width: {
type: String,
default: "100%",
},
checkId: {
type: String,
default: "",
},
isEdit: {
type: Boolean,
},
},
methods: {
/**
* @description 初始化JSON数据,将其变成AST树;
*/
initJSON() {
// console.log(this.json)
if (!this.json) return;
let jsonObj = JSON.parse(this.json);
this.JsonAST = {
label: "",
type: "Object",
_id: "0",
isExpand: true,
children: this.JsonRecursionToAst(jsonObj, "0"),
};
},
/**
* @description 递归处理JSON数据,返回AST树;
*/
JsonRecursionToAst(jsonVal, _parentId, _parentKey) {
let _type = this.getType(jsonVal);
let currentArr = [];
// 通过传入类型决策使用
let _typeDecision = {
Array: () => {
if (jsonVal.length != 0) {
jsonVal.forEach((item, idx) => {
let current = {
label: String(idx),
_id: `${_parentId}-${idx}`,
type: this.getType(item),
_key: `${_parentKey}[${idx}]`,
};
if (current.type == "Object" || current.type == "Array") {
current.isExpand = true;
current.children = this.JsonRecursionToAst(
item,
current._id,
current._key
);
}
currentArr.push(current);
});
}
return currentArr;
},
Object: () => {
let currentKeys = Object.keys(jsonVal);
if (currentKeys.length != 0) {
currentKeys.forEach((key, idx) => {
let current = {
label: key,
_id: `${_parentId}-${idx}`,
type: this.getType(jsonVal[key]),
_key: `${_parentKey ? _parentKey + "." : ""}${key}`,
};
if (current.type == "Object" || current.type == "Array") {
current.isExpand = true;
current.children = this.JsonRecursionToAst(
jsonVal[key],
current._id,
current._key
);
}
currentArr.push(current);
});
}
return currentArr;
},
};
return _typeDecision[_type]();
},

/**
* @description 将AST语法树转换为显示List
*/
AstRecursionToList(AstTree) {
let list = [];
let _type = this.getType(AstTree);
let _typeDecision = {
Array: () => {
AstTree.forEach((_node) => {
const { label, type, _id, _key } = _node;
if (type == "Array" || type == "Object") {
const { isExpand, children } = _node;
if (isExpand) {
const chileList = this.AstRecursionToList(children) ?? [];
chileList.unshift({
label,
type,
_id,
isExpand,
_key,
});
chileList.push({
endTag: true,
_id,
type,
});
list = list.concat(chileList);
} else {
list.push({ label, type, _id, isExpand, children, _key });
}
} else {
list.push({ label, type, _id, _key });
}
});
return list;
},
Object: () => {
const { label, type, _id, _key } = AstTree;
if (type == "Array" || type == "Object") {
const { isExpand, children } = AstTree;
// 如果展开标志位为true,则继续递归,否则不进行递归
if (isExpand) {
const chileList = this.AstRecursionToList(children) ?? [];
chileList.unshift({
label,
type,
_id,
isExpand,
_key,
});
chileList.push({
endTag: true,
type,

_id,
});
list = list.concat(chileList);
} else {
list.push({ label, type, _id, isExpand, _key });
}
} else {
list.push({ label, type, _id, _key });
}
return list;
},
};
return _typeDecision[_type] && _typeDecision[_type]();
},
/**
* @descripotion 修改AST语法树中isExpand状态
*/
changeIsExpandInAst(tree, id) {
let _type = this.getType(tree);
let _typeDecision = {
Array: () => {
tree.forEach((node) => {
const { type, _id } = node;
if (_id == id) {
node.isExpand = !node.isExpand;
return;
}
// 如果当前层级拥有子级,并且id前缀可以匹配,则进行递归
if (
(type == "Object" || type == "Array") &&
Object.hasOwnProperty.call(node, "children") &&
id.indexOf(_id) > -1
) {
this.changeIsExpandInAst(node.children, id);
}
});
},
Object: () => {
const { _id } = tree;
// 如果匹配,则直接修改状态并返回
if (_id == id) {
tree.isExpand = !tree.isExpand;
return;
}
// 如果当前层级拥有子级,并且id前缀可以匹配,则进行递归
if (Object.hasOwnProperty.call(tree, "children") && id.indexOf(_id) > -1) {
this.changeIsExpandInAst(tree.children, id);
}
},
};
_typeDecision[_type]();
},
/**
* @description 获取数据类型
*/
getType(val) {
const type = Object.prototype.toString
.call(val)
.replace("[object ", "")
.replace("]", "");
return type == "Null" ? "String" : type;
},
/**
* @description 获取缩进长度,默认靠左多2em
*/
getIndentation(id) {
return id?.split("-")?.length * 2 ?? 2;
},
getCheckStatus(id) {
const flag = this.checkBoxList.filter((checkBox) => checkBox == id).length != 0;
if (flag) {
return true;
} else {
return false;
}
},
checkChange(e, id, key, label) {
if (Array.from(id).length == 0) return;
this.checkBoxKey += 1;
if (e) {
if (this.checkBoxList.length == 0 || this.checkBoxList.length == 1) {
this.checkBoxList = this.createIdsFromNodeId(id);
} else {
// 设置是否允许修改标志位
let canEdit = true;
let checkList = this.createIdsFromNodeId(id);
if (checkList.length < this.checkBoxList.length) {
this.$message.warning("请先取消已选中的同级字段,在进行勾选");
return;
}
for (let index = 0; index < this.checkBoxList.length - 1; index++) {
const element = this.checkBoxList[index];
if (element != checkList[index]) {
canEdit = false;
}
}
if (canEdit) {
this.checkBoxList = checkList;
} else {
this.$message.warning("请先取消已选中的同级字段,在进行勾选");
}
}
} else {
let canEdit = this.checkBoxList.filter((box) => box == id).length != 0;
if (canEdit) {
let checkBoxList = this.createIdsFromNodeId(id);
checkBoxList.pop();
this.checkBoxList = checkBoxList;
} else {
this.$message.warning("请先取消已选中的同级字段,在进行勾选");
}
}
const c_id = this.checkBoxList?.slice(-1)[0];
c_id == this.checkId ? "" : this.getKeyAndId(c_id);
},
//返回path id
getKeyAndId(id) {
const obj = this.LiList.find((item) => item._id == id);
obj ? this.$emit("change", { path: obj._key, id: obj._id }) : "";
},
/**
* @description 根据id生成数组
*/
createIdsFromNodeId(id) {
let list = [];
id.split("-").forEach((item) => {
if (list.length == 0) {
list.push(item);
} else {
list.push(`${list[list.length - 1]}-${item}`);
}
});
return list;
},
/**
* 预览模式设置选中元素
*/
setcheckId() {
const { checkId } = this;
this.checkChange(true, checkId ?? "");
},
},
watch: {
checkId(e) {
console.log(e);
},
json: {
handler(val) {
if (typeof val == "string") {
this.initJSON();
} else {
// 如果传入的值不为JSON格式,抛异常
console.error("inputDataType not JSON!");
}
},
immediate: true,
deep: true,
},
JsonAST: {
handler(val) {
this.LiList = this.AstRecursionToList(val);
this.$nextTick(() => {
this.setcheckId();
});
},
immediate: true,
deep: true,
},
},
render() {
// 单独生成一行html
let singLineHtml = (_node) => {
// const {label, type, _id} = _node;
return (
<div style={{ textIndent: `${this.getIndentation(_node._id)}em` }}>
{createLabel(_node)}
{createTypeTag(_node)}
{createExpandTag(_node)}
{createBracket(_node)}
{createCheckBox(_node)}
</div>
);
};
let createLabel = (_node) => {
if (Object.hasOwnProperty.call(_node, "label") && _node.label?.length != 0) {
return <span>{`"${_node.label}":`}</span>;
}
};
let createExpandTag = (_node) => {
if (Object.hasOwnProperty.call(_node, "isExpand")) {
const { isExpand, _id } = _node;
return (
<i
onClick={() => {
this.changeIsExpandInAst(this.JsonAST, _id);
}}
style="color:#ff9a00;"
class={
isExpand
? "el-icon-remove-outline expandTag"
: "el-icon-circle-plus-outline expandTag"
}
></i>
);
}
};
let createTypeTag = (_node) => {
if (!Object.hasOwnProperty.call(_node, "endTag")) {
const { type } = _node;
return <span class={`tag ${type}-tag`}>{type}</span>;
}
};
let createBracket = (_node) => {
const { type } = _node;
if (Object.hasOwnProperty.call(_node, "endTag")) {
if (type == "Array") {
return (
<span>
<span class="bracket-array">{"]"}</span>,
</span>
);
} else {
return (
<span>
<span class="bracket-object">{"}"}</span>,
</span>
);
}
} else if (Object.hasOwnProperty.call(_node, "isExpand")) {
const { isExpand } = _node;
// 展开
let expandStrategy = {
Array: () => {
return (
<span>
<span class="bracket-array">{"["}</span>
</span>
);
},
Object: () => {
return (
<span>
<span class="bracket-object">{"{"}</span>
</span>
);
},
};
// 关闭
let notExpandStrategy = {
Array: () => {
return (
<span>
<span class="bracket-array">{`[${_node.children.length}]`}</span>,
</span>
);
},
Object: () => {
return (
<span>
<span class="bracket-object">{"{...}"}</span>,
</span>
);
},
};
return isExpand ? expandStrategy[type]() : notExpandStrategy[type]();
} else {
return <span>,</span>;
}
};
let createCheckBox = (_node) => {
if (!Object.hasOwnProperty.call(_node, "endTag")) {
const { _id, _key, label } = _node;
return (
<el-checkbox
key={this.checkBoxKey}
style={this.isEdit ? "pointer-events:none" : ""}
value={this.getCheckStatus(_id)}
onChange={(e) => {
this.checkChange(e, _id, _key, label);
}}
></el-checkbox>
);
}
};
return (
<ul
class="json-container"
style={{ height: this.height, width: this.width }}
ref="JsonContainer"
>
<div class="json-index"></div>
{this.LiList.map((node, idx) => {
return (
<li>
<span class="line-head">{idx + 1}</span>
{singLineHtml(node)}
</li>
);
})}
</ul>
);
},
};
</script>

<style lang="scss" scoped>
.json-container {
background-color: #fff;
overflow: auto;
color: #767676;
position: relative;
.json-index {
width: 44px;
height: 100%;
background: #f5f5f5;
position: absolute;
top: 0;
left: 0;
// border-right:1px solid #E7E7E7;
}
li {
height: 35px;
line-height: 35px;
display: flex;
font-size: 14px;
position: relative;
.line-head {
display: inline-block;
height: 35px;
line-height: 35px;
width: 44px;
box-sizing: border-box;
font-size: 14px;
text-align: center;
border: solid #e7e7e7;
border-width: 0 1px 1px 0;
background: #f5f5f5;
// &:last-of-type{
// border-width: 0 1px 0px 0;
// }
}
}
}
.tag {
box-sizing: border-box;
padding: 3px 7px;
display: initial;
border-radius: 4px;
margin-left: 2px;
margin-right: 2px;
color: #fff;
background-color: #c9c9c9;
font-size: 12px;
}
.Object-tag {
background-color: #ff9a00;
}
.Number-tag {
background-color: #2b7cf5;
}
.String-tag {
background-color: #00a870;
}
.Array-tag {
background-color: #a17bd1;
}
.bracket-object {
color: #ff9a00;
}
.bracket-array {
color: #a17bd1;
}
.expandTag {
cursor: pointer;
display: initial !important;
}
::v-deep.el-checkbox {
display: initial;
.el-checkbox__input {
display: initial;
}
// .el-checkbox__input.is-disabled.is-checkId .el-checkbox__inner{
// background: #2B7CF5;
// &::after{
// border-color: #FFF;
// }
// }
}
</style>


思维导图


微信截图_20240415152236.png


主要函数解读


JsonRecursionToAst


这个方法是递归处理传入的JSON数据,首先通过getType方法获取当前节点的数据类型【getType中通过原型链方式获取数据类型】,根据数据类型决策返回数据【主要节点参数是label(key),_id(节点ID),type(节点数据类型),其余的参数为业务需求】;


AstRecursionToList


直接通过AST树去生成DOM结构的话我觉得会比较复杂,所以我就在AST树与DOM结构之间写了此方法,这个方法去监听AST树,只要树发生了变化便会执行次方法;首先进入这个方法后根据节点类型去决策进入哪个结构,然后根据isExpand判断是否展开,若不展开则直接不向下执行,若展开,则继续递归;


changeIsExpandInAst


此方法是修改展开状态的,通过_id进行匹配,只要前缀相同,则递归,直至完全相同修改状态;


checkChange


这个方法是业务逻辑的需求,可以点击节点前的复选框,但是不允许点击不同分支的复选框;


作者:用户352970449145
来源:juejin.cn/post/7357698682744143911
收起阅读 »

从字节到小县城做三份远程工作,区块链技术给我带来了什么?

2022年,我决定离开字节跳动,这一决定标志着我职业生涯的一次重大转变。我转向了区块链行业,开始了远程工作和数字游民生活。我的经历不仅是个人转型的故事,更是关于程序员如何借助区块链赛道的发展机会,摆脱国内互联网的过度内卷,像我一样成长为数字游民。 结合我自己的...
继续阅读 »

2022年,我决定离开字节跳动,这一决定标志着我职业生涯的一次重大转变。我转向了区块链行业,开始了远程工作和数字游民生活。我的经历不仅是个人转型的故事,更是关于程序员如何借助区块链赛道的发展机会,摆脱国内互联网的过度内卷,像我一样成长为数字游民。


结合我自己的经历,给大家分享的是作为程序员,如何从传统互联网行业到区块链行业的转型,特别是针对想要从事远程工作的开发同学分享一些我的思考和路线建议。



(边旅行边远程)


区块链是什么?


区块链技术自2008年比特币的问世以来,已经展示了其在多个领域的应用潜力,包括金融、供应链、医疗和艺术等。到今天,区块链不仅限于加密货币,智能合约和去中心化应用(DApps)已经成为推动技术前进的新动力。


为什么在区块链领域,一定要看海外的发展机遇?


在国外,特别是在欧美,区块链技术得到了飞速发展。例如,美国、瑞士和新加坡等国家已经制定友好的区块链政策,吸引了大量的区块链创业公司和投资。海外的区块链开发通常能享受到更加宽松的监管环境和更多的资金支持,这些因素共同促成了一个成熟的区块链生态系统。


在我们国家,大多数区块链企业会选择在香港。对于我们来说,如果短时间英文口语不太能提升,可以多关注香港、新加坡的远程机会。企业和项目还是非常多。



(在安吉DNA)


学习区块链是选老牌公链还是新链?


区分老牌公链和新链是入门并理解区块链的关键。


老牌公链如比特币和以太坊,已经建立了坚实的基础,拥有庞大的用户和开发者社群。新链,如Solana和Polkadot,虽然起步较晚,但在技术创新(例如更快的交易处理速度和更低的手续费)和特定应用场景上可能有更具吸引力的优势。


对于开发而言,选择专注于老牌公链还是新链,应根据个人的兴趣和市场需求来决定:



  • 老牌公链开发:更适合那些喜欢稳定、渐进式创新和长期投资的开发同学。

  • 新链开发:适合追求技术创新边界,愿意在新兴技术上投入时间和精力的开发者。


作为区块链的从业者,我建议新手还是先学一下老牌公链技术,对你后续的发展更有利,也相对更容易转型。


不同技术栈如何转型区块链


对于希望从传统技术栈转型至区块链开发的程序员,每个技术栈的转型路径都会有所不同。我给大家列了一个详细的指南,包括推荐的步骤、大概所需的时间周期以及可能的目标岗位。这样的规划旨在为有志于区块链领域的开发者提供清晰的职业发展路径。



(远程公司寄来的礼物)


一、区块链转型指南


我用一张表详细描述了从不同web2技术栈转型至区块链开发的具体步骤、周期和目标岗位:


3.png



  • 智能合约 开发:对于绝大多数开发同学来说,转型区块链开发,建议先学区块链技术原理集合智能合约,就业方向是智能合约开发。

  • 区块链前/后端开发:如果具备web经验,比如前端、后端Go/Node/Java等,建议转区块链后端开发。



    • Java的解决方案:Java技术栈同学可以先用Java技术栈拆解区块链项目,然后直接去求职。工作中再切换语言。语言难度并不大。

    • 对于前端同学,如果原本不具备后端 Node 能力,那就学 DApp 前端,也够用。



  • 相对转型周期更长的技术栈:C#、Android、iOS、Php、Python大数据、算法、运维开发等。



    • 直接转智能合约也可以,但是建议可以先补充点 web 开发能力,这样更好就业,这件事也可以通过我们训练营的资源。

    • 其次,你同时学习区块链基础课程和任务,包括智能合约相关课程并不冲突。只是在第六周之后的项目实战阶段,会比较依赖你的web开发能力,否则项目任务没办法完成。




二、转型步骤解析


Step1 学习 区块链技术 原理,建立认知

对所有技术栈的开发同学来说,第一步都是建立区块链技术的基础知识,理解其工作原理、主要特性以及实际应用场景。


Step2 技术深入和框架学习

不同的技术栈应侧重于学习与其专业相关的区块链技术。例如,Java开发可以专注于区块链后端开发,最好掌握主流区块链框架,而前端开发则应学习如何通过Web3.js等库与区块链交互。


Step3 项目实战

理论学习后,参与实际项目是检验和加深技术理解的最好方式。实战经验不仅能帮助巩固学习成果,也是提高市场竞争力的关键,帮助求做好铺垫



(在数字游民公社)


远程工作带来的机遇


远程工作为程序员提供了前所未有的自由度。我在小县城通过远程工作不仅实现了生活和工作的平衡,还能参与到全球项目中。


远程也带来了挑战,尤其是在沟通和协作方面。对于非英语母语的开发同学,提升英语能力是关键,这包括进行模拟面试、加强专业术语的使用,以及在实际工作中不断实践英语沟通。


建立远程工作社群


基于我这些年的经验,以及我曾经合作过的身边大厂同学转型的案例。我们正在建立一个远程工作社群,这不仅是一个远程工作技术交流的平台,更是相互支持和资源共享的社区。


我们希望通过这个社群帮助更多开发者顺利过渡到区块链领域,尤其是那些对远程工作感兴趣的朋友们。


最后要说的话


转型的道路充满了不确定性和挑战,但也充满了机遇。从字节跳动辞职,到在小县城同时做三份远程工作,我的经历或许能给正在考虑转型的你一些启示和勇气。


区块链不仅是技术的革新,更是职业生涯的一个新起点。如果你对区块链感兴趣,或许现在是跨入这个领域的最好时机。



作者:C2N数字游民部落
来源:juejin.cn/post/7356326955929075712
收起阅读 »

Android项目——LittlePainter

一、项目简介 项目采用 Kotlin 语言编写,结合 Jetpack 相关控件,Navigation,Lifecyle,DataBinding,LiveData,ViewModel等搭建的 MVVM 架构模式 通过组件化拆分,实现项目更好解耦和复用 自定义v...
继续阅读 »

一、项目简介



  • 项目采用 Kotlin 语言编写,结合 Jetpack 相关控件,NavigationLifecyleDataBindingLiveDataViewModel等搭建的 MVVM 架构模式

  • 通过组件化拆分,实现项目更好解耦和复用

  • 自定义view

  • recycleview的使用

  • 移动+淡入动画:补间动画

  • 播放Lottie资源

  • 项目截图
    445D49437D72307AB01FB87DD2E6D34C.jpg


D703A49583FAAFFD5449F21392BC0090.jpg


AF0F008861A540D2EB86B075C98372C7.jpg


16417F10BD034AEEE47BFA624D8590B2.jpg


439E66BE976571C244087AB01B38EE3F.jpg
github github.com/afbasfh/Lit…


二、项目详情


2.1 MVVM(Model-View-ViewModel)


是一种基于数据绑定的架构模式,用于设计和组织应用程序的代码结构。它将应用程序分为三个主要部分:Model(模型)、View(视图)和ViewModel(视图模型)。



  • Model(模型):负责处理数据和业务逻辑。它可以是从网络获取的数据、数据库中的数据或其他数据源。Model层通常是独立于界面的,可以在多个界面之间共享。

  • View(视图):负责展示数据和与用户进行交互。它可以是Activity、Fragment、View等。View层主要负责UI的展示和用户输入的响应。

  • ViewModel(视图模型):连接View和Model,作为View和Model之间的桥梁。它负责从Model中获取数据,并将数据转换为View层可以直接使用的形式。ViewModel还负责监听Model的数据变化,并通知View进行更新。ViewModel通常是与View一一对应的,每个View都有一个对应的ViewModel。


image.png


2.2 Jetpack组件


(1) Navtgation


Google 在2018年推出了 Android Jetpack,在Jetpack里有一种管理fragment的新架构模式,那就是navigation. 字面意思是导航,但是除了做APP引导页面以外.也可以使用在App主页分tab的情况.. 甚至可以一个功能模块就一个activity大部分页面UI都使用fragment来实现,而navigation就成了管理fragment至关重要的架构.


这里主要用于页面的切换


(2) ViewBinding&DataBinding



  • ViewBinding 的出现就是不再需要写 findViewById()

  • DataBinding 是一种工具,它解决了 View 和数据之间的双向绑定;减少代码模板,不再需要写findViewById()释放 Activity/Fragment,可以在 XML 中完成数据,事件绑定工作,让 Activity/Fragment 更加关心核心业务;数据绑定空安全,在 XML 中绑定数据它是空安全的,因为 DataBinding 在数据绑定上会自动装箱和空判断,所以大大减少了 NPE 问题。


(3) ViewModel


ViewModel 具备生命感知能力的数据存储组件。页面配置更改数据不会丢失,数据共享(单 Activity 多 Fragment 场景下的数据共享),以生命周期的方式管理界面相关的数据,通常和 DataBinding 配合使用,为实现 MVVM 架构提供了强有力的支持。


(4) LiveData


LiveData 是一个具有生命周期感知能力的数据订阅,分发组件。支持共享资源(一个数据支持被多个观察者接收的),支持粘性事件的分发,不再需要手动处理生命周期(和宿主生命周期自动关联),确保界面符合数据状态。在底层数据库更改时通知 View。


(5) Room


一个轻量级 orm 数据库,本质上是一个 SQLite 抽象层。使用更加简单(Builder 模式,类似 Retrofit),通过注解的形式实现相关功能,编译时自动生成实现类 IMPL


这里主要用于收藏点赞音乐,与 LiveData 和 Flow 结合处理可以避免不必要的 NPE,可以监听数据库表中的数据的变化,也可以和 RXJava 的 Observer 使用,一旦发生了 insert,update,delete等操作,Room 会自动读取表中最新的数据,发送给 UI 层,刷新页面。


2.3 RecycleView


1.1 什么是RecycleView


Recyclerview是可以展示大量数据 ,重视回收和复用的view的一种控件;
RecyclerView是一个强大的滑动组件,与经典的ListView相比,同样拥有item回收复用的功能,这一点从它的名字Recyclerview即回收view也可以看出。RecyclerView 支持 线性布局、网格布局、瀑布流布局 三种,而且同时还能够控制横向还是纵向滚动。


1.2RecycleView的用法


纵向排列 布局文件:

1 .创建主布局并在主布局中添加 代码如下:


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
tools:context=".home.HomeFragment">

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="400dp"
android:layout_marginTop="100dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />



</androidx.constraintlayout.widget.ConstraintLayout>

2.创建子项布局文件addressbook_item.xml,并对其内部控件设置id 代码如下:


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="match_parent">

<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView"
android:layout_width="250dp"
android:layout_height="match_parent"
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/f1" />

<View
android:layout_width="0dp"
android:layout_height="match_parent"
android:background="@drawable/picture_border"
app:layout_constraintBottom_toBottomOf="@+id/imageView"
app:layout_constraintEnd_toEndOf="@+id/imageView"
app:layout_constraintStart_toStartOf="@+id/imageView"
app:layout_constraintTop_toTopOf="@+id/imageView" />
</androidx.constraintlayout.widget.ConstraintLayout>

3.创建适配器

直接继承RecyclerView.Adapter<AddressBookAdapter.ViewHolder> 然后一一实现


package com.example.littlepainter.home

import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.ViewGr0up
import androidx.navigation.findNavController
import androidx.recyclerview.widget.RecyclerView
import com.example.littlepainter.db.Picture
import com.example.littlepainter.databinding.LayoutPictureItemBinding

class PictureAdapter: RecyclerView.Adapter<PictureAdapter.MyViewHolder>() {
private var mPictures = emptyList<Picture>()

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

override fun onCreateViewHolder(parent: ViewGr0up, viewType: Int): MyViewHolder {
val inflater = LayoutInflater.from(parent.context)
val binding = LayoutPictureItemBinding.inflate(inflater,parent,false)
return MyViewHolder(binding)
}

override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
holder.bind(mPictures[position])
}

@SuppressLint("NotifyDataSetChanged")
fun setData(newData: List<Picture>){
mPictures = newData
notifyDataSetChanged()
}

//定义ViewHolder
class MyViewHolder(private val binding:LayoutPictureItemBinding):RecyclerView.ViewHolder(binding.root){
fun bind(pictureModel: Picture){
binding.imageView.setImageBitmap(pictureModel.thumbnail)
binding.root.setOnClickListener {
//切换到绘制界面
val action = HomeFragmentDirections.actionHomeFragmentToDrawFragment(pictureModel)
binding.root.findNavController().navigate(action)
}
}
}
}

4.在活动中创建并设置适配器


binding.recyclerView.apply {
layoutManager = ScaleLayoutManager(requireContext())
adapter = mAdapter
PagerSnapHelper().attachToRecyclerView(this)
}

(2) ViewPager


2.1、什么是ViewPager


布局管理器允许左右翻转带数据的页面,你想要显示的视图可以通过实现PagerAdapter来显示。这个类其实是在早期设计和开发的,它的API在后面的更新之中可能会被改变,当它们在新版本之中编译的时候可能还会改变源码。
ViewPager经常用来连接Fragment,它很方便管理每个页面的生命周期,使用ViewPager管理Fragment是标准的适配器实现。最常用的实现一般有FragmentPagerAdapter和FragmentStatePagerAdapter。
ViewPager是android扩展包v4包中的类,这个类可以让我们左右切换当前的view。我们先来聊聊ViewPager的几个相关知识点:
1、ViewPager类直接继承了ViewGr0up类,因此它一个容器类,可以添加其他的view类


2、ViewPager类需要一个PagerAdapter适配器类给它提供数据(这点跟ListView一样需要数据适配器Adater)


3、ViewPager经常和Fragment一起使用,并且官方还提供了专门的FragmentPagerAdapterFragmentStatePagerAdapter类供Fragment中的ViewPager使用


2.4 移动+淡入动画:补间动画


<alpha xmlns:android="http://schemas.android.com/apk/res/android"
android:fromAlpha="0.0"
android:toAlpha="1.0"
android:duration="500"/>

<alpha xmlns:android="http://schemas.android.com/apk/res/android"
android:fromAlpha="1.0"
android:toAlpha="0.0"
android:duration="500"/>

2.5 自定义view


1 .创建一个类继承于View


class DrawView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
}

2 .重写构造方法


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

override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
}

2.6 播放Lottie资源


1.在Lottie寻找合适的资源放在资源项目下


VAC00JD74ROG)Q1CE0LMR.png


2. 在布局里使用


<com.airbnb.lottie.LottieAnimationView
android:id="@+id/lottieAnimationView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.304"
app:lottie_autoPlay="true"
app:lottie_loop="true"
app:lottie_rawRes="@raw/anim4" />

作者:candy_
来源:juejin.cn/post/7305984583984234534
收起阅读 »

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

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