注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

教你如何实现一个页面自动打字效果

web
前言: 最近在写一个类似于 windows 启动页的项目,不知道大家是否还记的 windows 很经典的小光标加载页面,我又稍微改造了一下效果如下: 一. 光标闪烁效果的实现 tips: 在这里我们使用了 UnoCSS,如果你不清楚 UnoCSS 的使用方...
继续阅读 »

前言: 最近在写一个类似于 windows 启动页的项目,不知道大家是否还记的 windows 很经典的小光标加载页面,我又稍微改造了一下效果如下:


loading.gif




一. 光标闪烁效果的实现


tips: 在这里我们使用了 UnoCSS,如果你不清楚 UnoCSS 的使用方法,那你可以点击下面这篇文章。

🫱 🎁手把手教你创建自己的代码仓库



  1. 首先准备一块黑色的背景。

    image.png

  2. 其实光标的样式非常非常简单,仅仅只需要你创建一个宽高合适的 div,然后创建一个底部的 border 效果即可。

    image.png
    下面应该是你目前的效果。

    image.png

  3. 现在需要清楚的知道,这个白块的展示其实就是我们控制展示这个 divborder 的显示还是隐藏。那么现在我们的思路就很清晰了,所以这里我们只需要写一个变量来动态的切换这个 border 值即可。

    image.png

  4. 现在你的页面效果应该是漆黑一片,那交给谁来动态的切换这个状态呢?这里其实很简单,当页面挂载的时候,我们只需要开启一个定时器来动态切换即可。

    image.png

    这时候我们其实就能看到一丢丢效果了:

    flash.gif


二. 自动打字效果的实现



  1. 首先我们应该明确一个概念,我们目前要做的事很简单,只需要在百块 div 的前面插入文字其实就是在向后推白块

    image.png

    image.png

    所以白块的移动是我们无需关心的,我们仅仅只需要去处理如何插入字体的问题。

  2. 这里我们先准备一个常量来书写一段字符串文字,然后还需要给准备放文字的 div 打上 ref 为后面的工作做准备,之后我们需要用到它身上相关的属性。

    image.png

  3. 接下来我们要编写一个函数去处理这个问题,名字起的就随意点吧,就叫做 autoPrint

    image.png

  4. 这里我们仍需要开启一个循环定时器去控制,因为我们无法得知文字具体有多少,不考虑使用 setTimeout

    image.png

  5. 还需要准备两个变量,来存放接下来我们要处理的文字信息。

    image.png

  6. 下面代码的思路就比较简单了,其实就是调用了 substring 方法来一直切割获取下一个字符串的值。substring本身也是不改变原字符串的,所以我们只需要控制 index 就可以很轻松的获取到最后的值。

    image.png

    效果如下:

    3.gif

  7. 最后别忘了在合适的时机清除这个定时器。

    image.png


三. 更优雅的实现小方块闪烁


更新于 2023/02/22



  1. 在写上面的代码之前我没有考虑文字过长的问题,导致小光标不会换行的问题。

  2. 今天更新一下,修复这个 bug

    自动.gif

  3. 我们删除上面之前控制 border 的显示与否而展示的小光标样式。

    image.png

  4. 在放置文字的 div 添加一个伪元素来实现这个效果,更加简洁一点。

    image.png

  5. 并且使用动画来替换之前的 flicker
    image.png


四. 源码



<script>

//tips: automatic printing welcome words.
function autoPrintText(text: string) {
let _str = ""
let _index = 0
const _timerID = window.setInterval(() => {
if (!textAreas.value) return
if (_index > text.length - 1) {
clearInterval(_timerID)
return
}
_str = _str + text.substring(_index, _index + 1)
textAreas.value!.innerText = _str
_index++
}, printSpeed)
}

</script>

<template>

<div v-if="isFlicker" class="w-full h-full">
<div class="text-box w-fit">
<span ref="textAreas" class="text-1.8rem font-600"></span>
</div>
</template>

<style scoped>
.text-box::after {
display: inline-block;
content: "";
width: 2rem;
vertical-align: text-bottom;
border-bottom: 3px solid white;
margin-left: 8px;
animation: flicker 0.5s linear infinite;
}

@keyframes flicker {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

预告


最近在实现一个 window 的全套 UI ,PC 和移动端的效果是完全自适应的,两者有两套 UI

4.gif

我会在本周更新拖拽这个经典面试题的实现,仍会使用费曼学习法通俗易懂的讲解。如果你有兴趣,不妨保持关注。🎁


作者:韩振方
来源:juejin.cn/post/7200773486796914725
收起阅读 »

全网显示IP归属地,免费可用,快来看看

前言 经常浏览小视频或各类帖子的朋友们可能已经注意到,目前许多网络平台都会显示作者和评论区用户的IP归属地。那么,这个功能是如何实现的呢? 某些收费平台的API 我们可以利用一些付费平台的API来实现这一功能,比如一些导航软件的开放平台API等。然而,这些服...
继续阅读 »

前言


经常浏览小视频或各类帖子的朋友们可能已经注意到,目前许多网络平台都会显示作者和评论区用户的IP归属地。那么,这个功能是如何实现的呢?



某些收费平台的API


我们可以利用一些付费平台的API来实现这一功能,比如一些导航软件的开放平台API等。然而,这些服务通常是收费的,而且免费额度有限,适合测试使用,但如果要在生产环境中使用,很可能不够支撑需求。



离线库推荐


那么,有没有免费的离线API库呢?UP现在推荐一个强大的离线库给大家,一个准确率高达99.9%的离线IP地址定位库,查询速度仅需0.0x毫秒,而且数据库仅10兆字节大小。此库提供了Java、PHP、C、Python、Node.js、Golang、C#等多种查询绑定,同时支持Binary、B树和内存三种查询算法。



这个库大家可以在GitHub上搜索:ip2region,即可找到该开源库。


使用


下面使用Java代码给大家演示下如何使用这个IP库,该库目前支持多重主流语言。


1、引入依赖


<dependency>
   <groupId>org.lionsoul</groupId>
   <artifactId>ip2region</artifactId>
   <version>2.7.0</version>
</dependency>

2、下载离线库文件 ip2region.xdb



3、简单使用代码


下面,我们通过Java代码,挑选某个国内的IP进行测试,看看会输出什么样的结果


public class IpTest {

   public static void main(String[] args) throws Exception {
       // 1、创建 searcher 对象 (修改为离线库路径)
       String dbPath = "C:\Users\Administrator\Desktop\ip2region.xdb";
       Searcher searcher = null;
       try {
           searcher = Searcher.newWithFileOnly(dbPath);
      } catch (Exception e) {
           System.out.printf("failed to create searcher with `%s`: %s\n", dbPath, e);
           return;
      }

       // 2、查询
       String ip = "110.242.68.66";
       try {
           long sTime = System.nanoTime(); // Happyjava
           String region = searcher.search(ip);
           long cost = TimeUnit.NANOSECONDS.toMicros((long) (System.nanoTime() - sTime));
           System.out.printf("{region: %s, ioCount: %d, took: %d μs}\n", region, searcher.getIOCount(), cost);
      } catch (Exception e) {
           System.out.printf("failed to search(%s): %s\n", ip, e);
      }

       // 3、关闭资源
       searcher.close();

       // 备注:并发使用,每个线程需要创建一个独立的 searcher 对象单独使用。
  }

}


输出结果为:


{region: 中国|0|河北省|保定市|联通, ioCount: 3, took: 1192 μs}

其中,region的格式为 国家|区域|省份|城市|ISP,缺省的地域信息默认是0。


当然,这个库不只是支持国内的IP,也支持国外的IP。



其他语言可以参考该开源库的说明文档。


总结


这是一个准确率非常高的离线库,如果项目里有IP定位需求的,可以试下该库。


作者:happyjava
来源:juejin.cn/post/7306334713992708122
收起阅读 »

前端如何使用websocket发送消息

web
1 基础介绍 1.1 什么是WebSocket WebSocket 是一种在单个 TCP 连接上进行 全双工 通信的协议,它可以让客户端和服务器之间进行实时的双向通信。与传统的 HTTP 请求不同,WebSocket 使用了一个长连接,在客户端和服务器之间保...
继续阅读 »

1 基础介绍


1.1 什么是WebSocket



WebSocket 是一种在单个 TCP 连接上进行 全双工 通信的协议,它可以让客户端和服务器之间进行实时的双向通信。与传统的 HTTP 请求不同,WebSocket 使用了一个长连接,在客户端和服务器之间保持持久的连接,从而可以实时地发送和接收数据。




在 WebSocket 中,客户端和服务器之间可以互相发送消息。
客户端可以使用 JavaScript 中的 WebSocket API 发送消息到服务器,也可以接收服务器发送的消息。



1.2 WebSocket与HTTP的区别



WebSocket与HTTP的区别在于连接的性质和通信方式。WebSocket是一种双向通信的协议,通过一次握手即可建立持久性的连接,服务器和客户端可以随时发送和接收数据。而HTTP协议是一种请求-响应模式的协议,每次通信都需要发送一条请求并等待服务器的响应。WebSocket的实时性更好,延迟更低,并且在服务器和客户端之间提供双向的即时通信能力,适用于需要实时数据传输的场景。



1.3 代码示例



下面是一个使用 WebSocket API 发送消息的代码示例:



var socket = new WebSocket("ws://example.com/socketserver");

socket.onopen = function(event) {
socket.send("Hello server!");
};

socket.onmessage = function(event) {
console.log("Received message from server: " + event.data);
};

socket.onerror = function(event) {
console.log("WebSocket error: " + event.error);
};

socket.onclose = function(event) {
console.log("WebSocket connection closed with code " + event.code);
};


在上面的代码中,首先创建了一个 WebSocket 对象,指定了服务器的地址。然后在 onopen 回调函数中,发送了一个消息到服务器。当服务器发送消息到客户端时,onmessage 回调函数会被触发,从而可以处理服务器发送的消息。如果出现错误或者连接被关闭,onerror 和 onclose 回调函数会被触发,从而可以处理这些事件。


需要注意的是,在使用 WebSocket 发送消息之前,必须先建立 WebSocket 连接。在上面的代码中,通过创建一个 WebSocket 对象来建立连接,然后在 onopen 回调函数中发送消息到服务器。如果在连接建立之前就尝试发送消息,那么这些消息将无法发送成功。



2 前端使用WebSocket的流程


2.1 创建WebSocket对象


通过JavaScript中的new WebSocket(URL)方法创建WebSocket对象,其中URL是WebSocket服务器的地址。根据实际情况修改URL以与特定的WebSocket服务器进行连接。例如:


const socket = new WebSocket('ws://localhost:8000');

2.2 监听WebSocket事件


WebSocket对象提供多种事件用于监听连接状态和接收消息,例如:open、message、close、error等。



  • open:当与服务器建立连接时触发。

  • message:当收到服务器发送的消息时触发。

  • close:当与服务器断开连接时触发。

  • error:当连接或通信过程中发生错误时触发。


通过添加事件监听器,可以在相应事件发生时执行特定的逻辑。例如:


socket.addEventListener('open', () => {
console.log('WebSocket连接已建立');
});

socket.addEventListener('message', (event) => {
const message = event.data;
console.log('收到消息:', message);
});

socket.addEventListener('close', () => {
console.log('WebSocket连接已断开');
});

socket.addEventListener('error', (error) => {
console.error('发生错误:', error);
});

2.3 发送消息


通过WebSocket对象的send(data)方法发送消息,其中data是要发送的数据,可以是字符串、JSON对象等。可以根据实际需求将数据格式化成特定的类型进行发送。例如:


const message = 'Hello, server!';
socket.send(message);

2.4 关闭WebSocket连接


当通信结束或不再需要与服务器通信时,需要关闭WebSocket连接以释放资源。通过调用WebSocket对象的close()方法可以主动关闭连接,也可以根据业务需求设置自动关闭连接的条件。例如:


socket.close();

3 前端发送消息的应用实例


一个常见的前端发送消息的应用实例是在线聊天应用。在这种应用中,前端通过WebSocket与后端服务器建立连接,并实时发送和接收聊天消息。


以下是一个简单的前端发送消息的示例代码:


const socket = new WebSocket('ws://localhost:8000');

// 连接建立事件
socket.addEventListener('open', () => {
console.log('WebSocket连接已建立');
});

// 消息接收事件
socket.addEventListener('message', (event) => {
const message = event.data;
console.log('收到消息:', message);
// 处理接收到的消息,将其显示在前端界面上
});

// 发送消息
function sendMessage(message) {
socket.send(message);
}

// 调用发送消息的函数,例如在点击按钮后发送消息
const sendButton = document.getElementById('sendBtn');
sendButton.addEventListener('click', () => {
const messageInput = document.getElementById('messageInput');
const message = messageInput.value;
sendMessage(message);
messageInput.value = ''; // 清空输入框
});

// 连接关闭事件
socket.addEventListener('close', () => {
console.log('WebSocket连接已断开');
});

// 连接错误事件
socket.addEventListener('error', (error) => {
console.error('发生错误:', error);
});


该示例中,通过创建WebSocket对象,监听连接建立事件、消息接收事件、连接关闭事件和错误事件,从而实现与服务器的实时通信。通过构建界面和处理消息的逻辑,可以实现实时聊天功能。


这只是一个简单的示例,实际上,前端发送消息的应用可以更广泛,如实时数据更新、多人协作编辑、实时游戏等。具体的实现方式和功能根据实际需求而定,可以灵活调整和扩展。



4 WebSocket的应用场景


WebSocket的应用场景包括但不限于以下几个方面:



  1. 实时聊天应用:WebSocket能够提供双向、实时的通信机制,使得实时聊天应用能够快速、高效地发送和接收消息,实现即时通信。

  2. 实时协作应用:WebSocket可以用于实时协作工具,如协同编辑文档、白板绘画、团队任务管理等,团队成员可以实时地在同一页面上进行互动和实时更新。

  3. 实时数据推送:WebSocket可以用于实时数据推送场景,如股票行情、新闻快讯、实时天气信息等,服务器可以实时将数据推送给客户端,确保数据的及时性和准确性。

  4. 多人在线游戏:WebSocket提供了实时的双向通信机制,适用于多人在线游戏应用,使得游戏服务器能够实时地将游戏状态和玩家行为传输给客户端,实现游戏的实时互动。

  5. 在线客服和客户支持:WebSocket可以用于在线客服和客户支持系统,实现实时的客户沟通和问题解决,提供更好的用户体验,减少等待时间。



WebSocket适用于需要实时双向通信的场景,在这些场景中,它能够提供更好的实时性、低延迟和高效性能,为Web应用程序带来更好的交互性和用户体验。



作者:李泽南
来源:juejin.cn/post/7277835425959886882
收起阅读 »

面试官:你知道websocket的心跳机制吗?

web
前言 哈喽,大家好,我是泽南Zn👨‍🎓。在之前的一篇文章写到, 前端如何使用websocket发送消息,websocket是怎么建立连接的呢?如果断开了会怎样?如何一直保持长连接呢?接下来,本篇文章将会带你了解--- WebSocket心跳机制 一、...
继续阅读 »

前言


哈喽,大家好,我是泽南Zn👨‍🎓。在之前的一篇文章写到, 前端如何使用websocket发送消息,websocket是怎么建立连接的呢?如果断开了会怎样?如何一直保持长连接呢?接下来,本篇文章将会带你了解--- WebSocket心跳机制


一、WebSocket心跳机制


前端实现WebSocket心跳机制的方式主要有两种:




  1. 使用setInterval定时发送心跳包。

  2. 在前端监听到WebSocket的onclose()事件时,重新创建WebSocket连接。



第一种方式会对服务器造成很大的压力,因为即使WebSocket连接正常,也要定时发送心跳包,从而消耗服务器资源。第二种方式虽然减轻了服务器的负担,但是在重连时可能会丢失一些数据。


二、WebSocket心跳包机制


WebSocket心跳包是WebSocket协议的保活机制,用于维持长连接。有效的心跳包可以防止长时间不通讯时,WebSocket自动断开连接。


心跳包是指在一定时间间隔内,WebSocket发送的空数据包。常见的WebSocket心跳包机制如下:




  1. 客户端定时向服务器发送心跳数据包,以保持长连接。

  2. 服务器定时向客户端发送心跳数据包,以检测客户端连接是否正常。

  3. 双向发送心跳数据包。



三、WebSocket心跳机制原理


WebSocket心跳机制的原理是利用心跳包及时发送和接收数据,保证WebSocket长连接不被断开。WebSocket心跳机制的原理可以用下面的流程来说明:




  1. 客户端建立WebSocket连接。

  2. 客户端向服务器发送心跳数据包,服务器接收并返回一个表示接收到心跳数据包的响应。

  3. 当服务器没有及时接收到客户端发送的心跳数据包时,服务器会发送一个关闭连接的请求。

  4. 服务器定时向客户端发送心跳数据包,客户端接收并返回一个表示接收到心跳数据包的响应。

  5. 当客户端没有及时接收到服务器发送的心跳数据包时,客户端会重新连接WebSocket



四、WebSocket心跳机制必要吗


WebSocket心跳机制是必要的,它可以使 WebSocket 连接保持长连接,避免断开连接的情况发生。同时,心跳机制也可以检查WebSocket连接的状态,及时处理异常情况。


五、WebSocket心跳机制作用


WebSocket心跳机制的作用主要有以下几点:



  1. 保持WebSocket连接不被断开。

  2. 检测WebSocket连接状态,及时处理异常情况。

  3. 减少WebSocket连接及服务器资源的消耗。


六、WebSocket重连机制


WebSocket在发送和接收数据时,可能会因为网络原因、服务器宕机等因素而断开连接,此时需要使用WebSocket重连机制进行重新连接。


WebSocket重连机制可以通过以下几种方式实现:




  1. 前端监听WebSocket的onclose()事件,重新创建WebSocket连接。

  2. 使用WebSocket插件或库,例如Sockjs、Stompjs等。

  3. 使用心跳机制检测WebSocket连接状态,自动重连。

  4. 使用断线重连插件或库,例如ReconnectingWebSocket等。



七、WebSocket的缺点和不足


WebSocket的缺点和不足主要有以下几点:




  1. WebSocket需要浏览器和服务器端都支持该协议。

  2. WebSocket会增加服务器的负担,不适合大规模连接的应用场景。



八、关键代码


  // 开启心跳
const start = () => {
clearTimeout(timeoutObj);
// serverTimeoutObj && clearTimeout(serverTimeoutObj);
timeoutObj = setTimeout(function () {
if (websocketRef.current?.readyState === 1) {
//连接正常
sendMessage('hello');
}
}, timeout);
};
const reset = () => {
// 重置心跳 清除时间
clearTimeout(timeoutObj);
// 重启心跳
start();
};

ws.onopen = (event) => {
onOpenRef.current?.(event, ws);
reconnectTimesRef.current = 0;
start(); // 开启心跳
setReadyState(ws.readyState || ReadyState.Open);
};
ws.onmessage = (message: WebSocketEventMap['message']) => {
const { data } = message;

if (data === '收到,hello') {
reset();
return;
}
if (JSON.parse(data).status === 408) {
reconnect();
return;
}
onMessageRef.current?.(message, ws);
setLatestMessage(message);
};
const connect = () => {
reconnectTimesRef.current = 0;
connectWs();
};

主要思路:在建立长连接的时候开启心跳,通过和服务端发送信息,得到服务端给返回的信息,然后重置心跳,清楚时间,再重新开启心跳。如果网络断开的话,会执行方法,重新连接。


作者:泽南Zn
来源:juejin.cn/post/7290005438153867283
收起阅读 »

转转的Flutter实践之路

前言 跨端技术一直是移动端开发领域的热门话题,Flutter 作为一种领先的移动跨端技术之一,凭借其快速的渲染引擎、丰富的UI组件库和强大的开发工具,成为了开发人员的首选之一。 从 Flutter 诞生之初,我们就一直关注着它的发展,Flutter 早期版本变...
继续阅读 »

前言


跨端技术一直是移动端开发领域的热门话题,Flutter 作为一种领先的移动跨端技术之一,凭借其快速的渲染引擎、丰富的UI组件库和强大的开发工具,成为了开发人员的首选之一。


从 Flutter 诞生之初,我们就一直关注着它的发展,Flutter 早期版本变更较为频繁,并且经常伴随着 Breaking Change,另外可用的三方插件较少且不稳定。直到2019年,Flutter 的热度暴涨,国内不少团队陆续把 Flutter 引入到了生产环境使用,社区也涌现出不少优秀的开源项目,我们也决定在这个时候做一些技术上的尝试。


经过这几年在 Flutter 技术上的不断学习、探索和积累,Flutter 已经成为了客户端技术体系中的重要组成部分。


回顾整个过程,我们大致经历了这么几个阶段:可行性验证、基建一期建设、小范围试验、基建二期建设、大范围推广、前端生态的探索,下文将分别对每个阶段展开进行介绍。


可行性验证


其实在这之前我们已经做过了一些调研,但许多结论都是来源于网上的一些文章或者其它团队的实践,这些结论是否靠谱是否真实还有待商榷,另外,网上的文章大都千篇一律,要么使劲吹捧,要么使劲贬低,要得出相对客观的结论还是得需要我们自己通过实践才能得出。


目标


我们确定了以下几个维度,用来评估 Flutter 是否值得我们进一步投入:



  • 开发效率

  • UI一致性

  • 性能体验

  • 学习成本

  • 发展趋势


由于前期对 Flutter 的熟练度不高,基础设施也还没有搭建起来,所以在开发效率上,我们期望的 Flutter 的开发耗时能保持在原生开发耗时的 1.5 倍以内,不然虽然实现了跨端,但是需求的开发周期反而被拉长了,这样得不偿失。在UI一致性上,我们期望同一份代码在两端的表现要基本达到一致,不需要额外的适配成本。在性能方面,尽量保证崩溃、卡顿、内存、帧率这些指标在可控范围内。


方案


我们希望用较小的代价完成上述维度的评估,所以在试验期间的架构及基础设施方面我们做的比较简单。


测试目标


当时我们正在做一个叫切克的 App,用户量级比较小,工程架构也相对简单一些,正好可以用来做一些技术方面的探索和验证。


我们选择的是切克的商品详情页,用 Flutter 技术实现了一个一模一样的商详,按1:1的流量分配给 Native 和 Flutter。


项目架构


由于我们的工程不是一个全新的项目,所以采用的是 Native 与 Flutter 混合开发的方式,Native 主工程只依赖 Flutter 产物即可,同时也尽量避免对原有工程的影响。


关于混合页面栈的问题,我们没有额外处理,因为暂时只测试一个页面,不会涉及到多页面混合栈的问题,所以暂时先忽略。


构建流程


为了降低验证成本,我们没有对接现有的 Native 的持续集成流程,而是直接在本地构建 Flutter 产物,然后上传到远程仓库。


结论


经过一段时间的线上验证,我对 Flutter 技术基本有了一个比较全面的了解:


在开发效率上由于基础库和基建的缺失,在处理 Flutter 业务跟 Native 业务的交互时需要更多的适配成本,包括像页面跳转、埋点上报、接口请求、图片加载等也需要额外的处理,但我们评估随着后续基建的不断完善,这部分的效率是可以逐步得到改善的;而在涉及UI开发方面,得益于热重载等技术,Flutter 的开发效率是要优于原生开发的。整体评估下来,在开发效率方面 Flutter 是符合我们的预期的。


在UI一致性上,除了在状态栏控制和文本在某些情况下需要特殊适配下外,其它控件在两端的表现基本一致。


在性能表现上,Flutter 会额外引入一些崩溃,内存占用也有所上涨,但还在可接受范围内。


Flutter 的学习成本相对还是比较高,毕竟需要单独学习一门语言,另外 Flutter 的渲染原理也跟原生有很多差异,需要转变思维才能更快的适应,此外 Flutter 还提供了众多的 Widget 组件,也需要较长时间学习。


在发展趋势上,Flutter 无疑是当时增长最快的跨端技术之一,社区的活跃程度以及官方的投入都非常高,国内不少团队也都在积极推进 Flutter 技术的发展,Flutter 正处在一个快速的上升期。


整体来说,Flutter 是满足我们团队对跨平台技术的需求的,我们计划在接下来的一段时间投入更多资源,把 Flutter 的基础设施逐渐建立起来。


基建一期建设


基建一期内容主要包括以下几个方面:



  • 工程架构

  • 开发框架

  • 脚本工具

  • 自动化构建


在基建一期完成后,我们的目标是要达到:



  • 基础能力足够支撑普通业务开发

  • 开发效率接近原生开发

  • 开发过程要基本顺畅


工程架构


工程架构指的是原生工程与 Flutter 工程之间的关系,以及 Flutter 工程与 Flutter 工程之间的关系。


原生工程与Flutter工程的关系


我们知道,使用 Flutter 开发通常有两种情况,一种是直接使用 Flutter 开发一个新的App,属于纯 Flutter 开发;一种是在已有的 Native 工程中引入,属于混合开发。我们当然属于后者。


而混合开发又可分为两种:源码集成和产物集成。源码集成需要改变原工程的项目结构,并且需要 Flutter 开发环境才能编译,而产物集成则不需要改动原工程的项目结构,只需把 Flutter 的构建产物当作普通的依赖库引入即可,原有 Native 工程和 Flutter 工程从物理上完全独立。显而易见的我们选择产物集成的方式,引入 Flutter对于原工程以及非 Flutter 开发人员来说,基本上是毫无感知的。


所以原生工程与 Flutter 工程之间的关系如下图所示:


原生工程与Flutter工程之间的关系


Flutter工程之间的关系


根据已有的客户端基建的开发经验,我们将所有 Flutter 工程分为了四层:



  • 壳工程

  • 业务层

  • 公共层

  • 容器层


容器层负责提供 Flutter 的基础运行环境,包括 Flutter 引擎管理、页面栈管理、网络框架、KV存储、数据库访问、埋点框架、Native 与 Flutter 通信通道和其它基础功能。


公共层包含一些通用的开源库、自定义UI组件、部分通用业务等。


业务层包含用户信息、商品、发布等业务组件。


壳工程负责集成各业务组件,最终构建出产物集成到 Native 主工程。


其中业务层、公共层、容器层都是由若干个独立的工程所组成,整体结构如下:


Flutter分层架构


开发框架


开发框架是为了提高开发效率、规范代码结构、减少维护成本等考虑而设计的一套软件框架,包括:基础能力、状态管理、页面栈管理等。


基础能力


开发框架需要提供各种必要的能力,比如:页面跳转、埋点、网络请求、图片加载、数据存储等,为了最大化减少研发成本,我们在底层定义了一套通用的数据交互协议,直接复用了现有的 Native 的各项能力,也使得 Native 的各种状态与 Flutter 侧能够保持统一。


状态管理


相信了解 Flutter 的同学一定知道状态管理,这也是跟 Native 开发区别较大的地方。在开发较为复杂的页面时,状态维护是非常繁琐的,在不引入状态管理框架的情况下,开发效率会受很大影响,后期的维护成本以及业务交接都是很大的问题。


另外,在开发框架设计之初,我们就期望从框架上能够在一定程度上限定代码结构、模块之间的交互方式、状态更新方式等,我们期望的是不同的人写出来的代码在逻辑、结构和风格上都能保持比较统一,即在提高开发效率的同时,也能保证项目后续的可维护性和扩展性,减少不同业务间的交接成本。


基于上述这些需求,在我们对比了多个开源项目后,FishRedux 的整体使用感受正好符合我们的要求。


如下图,两个页面的代码结构基本一致:


收藏详情和个人主页


页面栈管理


在早期版本,Flutter 引擎的实例占用内存较高,为了减少内存消耗,大家普遍采用单实例的模式,而在 Native 和 Flutter 混合开发的场景下就会存在一个问题,就是 Native 有自己的页面栈,而 Flutter 也维护着一套自己的页面栈,如果 Native 页面与 Flutter 页面穿插着打开,在没有特殊处理的情况下,页面栈会发生错乱。在调研了业内的各种开源方案后,我们选择引入 FlutterBoost 用来管理页面混合栈。


脚本工具


为了方便开发同学搭建 Flutter 的开发环境,同时能够管理使用的 Flutter 版本,我们开发了 zflutter 命令行工具,包含以下主要功能:



  • Flutter开发环境安装

  • Flutter版本管理

  • 创建模版工程(主工程、组件工程)

  • 创建模版页面(常规页面、列表页、瀑布流页面)

  • 创建页面模块

  • 组件工程发布

  • 构建Flutter产物

  • 脚本自更新


如图:


zflutter


自动化构建


客户端使用的是自研的 Beetle 平台(集工程管理、分支管理、编译、发布于一体),短时间内要支持上 Flutter 不太现实,基于此,我们先临时自己搭台服务器,通过 gitlab 的 webhook 功能结合 zflutter 工具简单实现了一套自动化构建的服务,待 Beetle 支持 Flutter 组件化开发功能后,再将工作流切回到 Beetle 平台。


小范围试验


在完成基建一期的开发工作后,我们决定通过开发几个实际业务来试验目前的基础设施是否达到既定目标。


我们以不影响主流程、能覆盖常见UI功能、并且能跟 Native 页面做AB测试(主要是方便在出问题时能够切换到 Native 版本)为条件挑选了个人资料页和留言列表页进行了 Flutter 化改造,如下图所示:


个人资料页/留言列表页


这两个页面涵盖了网络请求、图片加载、弹窗、列表、下拉刷新、上拉加载更多、左滑删除、埋点上报、页面跳转等常见功能,足以覆盖日常开发所需的基础能力。


经过完整的开发流程以及一段时间的线上观察,我们得出如下结论:


基础能力


目前已具备的基础能力已经足够支撑普通业务开发(开发过程中补足了一些缺失的能力)。


工作流


整个开发过程在工程依赖管理和分支管理方面的支持还比较缺失,比较依赖人工处理。


开发效率


我们在开发前根据页面功能同时做了纯 Native 开发排期和 Flutter 开发排期,按单人日的成本来对比的话,Flutter 实际开发耗时跟 Native 排期耗时比为 1.25:2,Native 是按照 Android+iOS 两端各一人算的,也就是1.25人/日比2人/日,如果后续对 Flutter 技术熟悉度提升后相信效率还可以进一步提升。


性能体验


线上两个 Flutter 页面的体验效果跟 Native 对比基本感觉不到差别,但是首次进入 Flutter 页面时会有短暂的白屏等待时间,这个是由于 Flutter 环境初始化导致的延迟,后续可以想办法优化。


包体积


在引入 Flutter 之后,转转的安装包体积在两端都分别有所增加:



  • Android增加6.1M

  • iOS增加14M


试验结果基本符合预期,包体积的增量也在我们的可接受范围内,接下来将进行基建二期的建设,补足目前缺失的能力。


基建二期建设


基建二期的内容主要包含以下工作:



  • 配合工程效率组完成 Beetle 对 Flutter 项目的支持

  • 组织客户端内部进行 Flutter 技术培训


Beetle支持Flutter


为了能让大家更清晰的了解 Beetle 的工程管理机制,这里先简单介绍下客户端的工程类型:



  • Native主工程(又分为 Android 和 iOS)

  • Native组件工程(又分为 Android 和 iOS)

  • Flutter主工程

  • Flutter组件工程(即 Flutter 插件工程)


举个例子,当有一个新版本需要开发时,先从 Native 主工程创建一个版本同时创建一个 Release 分支,即版本分支,然后从版本分支根据具体需求创建对应 Native 组件的版本分支,Flutter 主工程此时可看作是一个 Native 组件,比如此时创建了一个 Flutter 主工程的版本分支后,可以进入 Flutter 主工程再根据需要创建对应的 Flutter 组件工程的版本分支。


Beetle 目前已支持 Flutter 工程管理、分支管理、组件依赖管理以及组件的发布、Flutter 产物的构建等,Beetle 的作用贯穿从开发到上线的整个工作流。


Flutter技术培训


为了让大家更快的熟悉 Flutter 开发,我们在客户端内部组织了5次 Flutter 快速入门的系列分享:


Flutter快速入门系列


同时也逐步完善内部文档的建设,包括:FlutterSdk 源码维护策略、Flutter 入门指南、Flutter 混合开发方案、Flutter 与 Native 通信方案、Flutter 开发环境配置、Flutter 组件化工程结构、Flutter 开发与调试、Flutter 开发工作流、ZFlutter 工具使用介绍、Flutter 开发之 Beetle 使用指南等,涵盖了从环境搭建、开发调试到构建发布的整个过程。


大范围推广


在完成基建二期的建设后,整体基础设施已经能够支撑我们常见的业务,开发工作流也基本顺畅,于是我们开始了在内部大范围推广计划。


我们先后改造和新开发了个人主页、我发布的页面、微商详、奇趣数码页等业务,基本涵盖了常见的各种类型的页面和功能,整体开发效率与原生单端开发效率持平,但是在特别复杂的页面的性能表现上,Flutter 的表现相对要差一些。


部分页面如下图所示:


个人主页


微详情页/我发布的/奇趣数码


探索前端生态


在跨端技术领域我们知道 Web 技术是天然支持的,如果能把前端生态引入到 Flutter 中,那么对客户端来说,在业务的支持度上会更上一个台阶,Web 的体验得到提升的同时客户端也具备了动态化,基于此背景我们开始探索 Flutter 在 Web 上的可能性。


技术调研


当时可选的开源方案有:Kraken、MXFlutter、Flutter For Web。


Kraken


Kraken 是一款基于 W3C 标准的高性能渲染引擎。Kraken 底层基于 Flutter 进行渲染,通过其自绘渲染的特性,保证多端一致性。上层基于 W3C 标准实现,拥有非常庞大的前端开发者生态。


Kraken 的最上层是一个基于 W3C 标准而构建的 DOM API,在下层是所依赖的 JS 引擎,通过 C++ 构建一个 Bridge 与 Dart 通信。然后这个 C++ Bridge 把 JS 所调用的一些信息,转发到 Dart 层。Dart 层通过接收这些信息,会去调用 Flutter 所提供的一些渲染能力来进行渲染。


Kraken 是不依赖 Flutter Widget,而是依赖 Flutter Widget 的底层渲染数据结构 —— RenderObject。Kraken 实现了很多 CSS 相关的能力和一些自定义的 RenderObject,直接将生成的 RenderObject 挂载在 Flutter RenderView 上来进行渲染,通过这样的方式能够做到非常高效的渲染性能。


MXFlutter


MXFlutter 是一套使用 TypeScript/JavaScript 来开发 Flutter 应用的框架。


MXFlutter 把 Flutter 的渲染逻辑中的三棵树(即:WidgetTree、Element、RenderObject )中的第一棵(即:WidgetTree),放到 JavaScript 中生成。用 JavaScript 完整实现了 Flutter 控件层封装,实现了轻量的响应式 UI 框架,支撑JS WidgetTree 的 build逻辑,build 过程生成的UI描述, 通过Flutter 层的 UI 引擎转换成真正的 Flutter 控件显示出来。


Flutter For Web


Flutter 在 Web 平台上以浏览器的标准 API 重新实现了引擎。目前有两种在 Web 上呈现内容的选项:HTML 和 WebGL。



  • 在 HTML 模式下,Flutter 使用 HTML、CSS、Canvas 和 SVG 进行渲染。

  • 在 WebGL 模式下,Flutter 使用了一个编译为 WebAssembly 的 Skia 版本,名为 CanvasKit。


HTML 模式提供了最佳的代码大小,CanvasKit 则提供了浏览器图形堆栈渲染的最快途径,并为原生平台的内容提供了更高的图形保真度。


结论


我们对以上方案从接入成本、渲染性能、包体积、开发生态、学习成本等多维度进行了对比:



  • 接入成本:Kraken ≈ MXFlutter ≈ Flutter For Web

  • 渲染性能:Kraken > MXFlutter > Flutter For Web

  • 包体积增量:Flutter For Web < Kraken < MXFlutter

  • 开发生态:Kraken ≈ MXFlutter > Flutter For Web

  • 学习成本:Flutter For Web < Kraken ≈ MXFlutter


最终选择了 Kraken 作为我们的首选方案。


上线验证


为了使 Kraken 顺利接入转转App,我们做了以下几个方面的工作:



  • 升级 FlutterSdk 到最新版,满足接入 Kraken 的基础条件

  • 统一客户端容器接口,使得 Kraken 容器能够完美继承 Web 容器的能力

  • 自己维护 Kraken 源码,及时修复官方来不及修复的问题,方便增加转转特有的扩展能力

  • 制定 Kraken 容器与 Web 容器的降级机制

  • 兼容 HTML 加载,保持跟 Web 容器一致的加载方式

  • 添加监控埋点,量化指标,指导后续优化方向

  • 选择一个简单 Web 页并协助前端同学适配


上线后,我们对页面的各项指标进行了对比,使用 Kraken 容器加载比使用 WebView 加载,在首屏加载耗时的指标上平均增加了281毫秒,原因为:当前版本的 Kraken 容器不支持直接加载 HTML,且只能加载单个 JsBundle,导致加载效率比 WebView 差。


通过跟前端同学沟通,从开发效率上来看,Kraken 工程的开发周期会比实现同样需求的普通 Web 工程增加1.5到2倍的时间,主要原因是受到 CSS 样式、Api 差异,无法使用现有UI组件,另外 Kraken 的调试工具目前还不够完善,使用浏览器调试后还须在客户端容器中调试,整体下来导致开发 Kraken 工程会比开发普通Web工程耗费更多时间。


再次验证


由于之前选择的 Web 页面太过简单,不具备代表性,所以我们重新选定了“附近的人”页面做为改造目标,再次验证 Kraken 在实际开发过程中的效率及性能体验。页面如图所示:


附近的人


最终因为部分问题得不到解决,并且整体性能较差,导致页面没能成功上线。


存在的问题包括但不限于下面列举的一些:



  • 表现不一致问题

    1. CSS 定位、布局表现与浏览器表现不一致

    2. 部分 API 表现与浏览器不一致(getBoundingClientRect等)

    3. iOS,Android系统表现不一致



  • 重大 Bug

    1. 页面初始化渲染完成,动态修改元素样式,DOM不重新渲染

    2. 滑动监听计算导致 APP 崩溃



  • 调试成本高

    1. 不支持 vue-router,单项目单路由

    2. 不支持热更新,npm run build 预览

    3. 不支持 sourceMap,无法定位源代码

    4. 真机调试只支持 element 和 network;dom 和 element 无法互相选中;无法动态修改 dom 结构,无法直接修改样式.......

    5. 页面白屏,假死



  • 安全性问题

    1. 无浏览器中的“同源策略”限制



  • 兼容性

    1. npm 包不兼容等




通过这一系列的探索和尝试,我们了解到了 Kraken 目前还存在许多不足,如果继续应用会带来高额的开发调试以及维护成本,所以暂时停止了在 Kraken 方向上的投入,但我们仍然在这个方向上保持着关注。


结尾


目前转转在Flutter方向上的实践和探索只是一个起点,我们意识到仍然有很多工作需要去做。我们坚信Flutter作为一项领先的跨端技术,将为转转业务的发展带来巨大的潜力和机会。我们将持续努力,加强技术建设,不断完善实践经验,推动Flutter在转转的应用和发展,为用户提供更好的产品和体验。


作者:转转技术团队
来源:juejin.cn/post/7304831120709697588
收起阅读 »

Android 绘制你最爱的马赛克

前言 我们之前写过《Android 实现LED 展示效果》,在这篇文章中,我们使用了图像分块(或者是分片)的算法,这样做的目的是降低像素扫描的时间复杂度,并且也利于均色采样。其实图像分块是很常见的图像动效处理手段,一般主要用于场景变换,这项技术也和图像光栅化类...
继续阅读 »

前言


我们之前写过《Android 实现LED 展示效果》,在这篇文章中,我们使用了图像分块(或者是分片)的算法,这样做的目的是降低像素扫描的时间复杂度,并且也利于均色采样。其实图像分块是很常见的图像动效处理手段,一般主要用于场景变换,这项技术也和图像光栅化类似。


什么是光栅化


光栅化渲染(Rasterized Rendering)直译过来是栅格化渲染。寻找图像中被几何图形占据的所有像素的过程称为栅格化,因此对象顺序渲染(Object-order rendering)也可以称为栅格化渲染。


Qu-es-la-rasterizacin-y-cual-es-su-diferencia-con-el-Ray-Tracing.jpg


我们今天的所要用到的技术也是栅格化和像素采样技术。


LED原理简述


马赛克是一种图像编辑技术,广泛应用于隐私保护和涂鸦渲染,很多手机系统自带了这种效果,那如何才能实现这种技术呢?


了解过我之前的文章的知道,我们制作LED有几个特征



  • 每个LED单元要么亮要么不亮

  • 每个LED单元只有一种颜色

  • 每个LED单元和其他LED单元存在一定的间距

  • 所有LED的单元成网格排列

  • 每个LED单元大小一致


以上相当于顶点坐标信息,我们拿到网格的位置,就能拿到LED整个区域的片段,知道这个区域的片段我们就可以修改其像素。


着色采样:


即便是每个矩形区域,也有很多像素点,如果每个矩形区域每个像素都要进行均色计算的话,那10x10的也要100此,因此为了更快的效率,需要对LED 范围内的像素点采样,求出颜色均值,均值色就是LED最终展示的颜色。


避坑——修改像素


上一篇我们知道,通过Bitmap.setPixel方法修改像素效率是极低的,我曾经写过一篇通过修改像素生成圆形图片的文章,在那篇文章里我们看到,像素本身也是有size的,导致最终的圆形图片存在大量锯齿,主要原因是通过这种方式没法做到双线性过滤(图片放大之后会对边缘优化),还有另一个问题,就是效率极差。
总结一下修改像素的问题:



  • 无法抗锯齿

  • 效率低


避坑——透明色


像素中往往存在 color为0或者alpha通道为0的情况,甚至有的区域因为采样原因导致清晰度急剧下降,甚至出现了透明区域噪点,这些问题主要来自于alpha 通道引发的颜色稀释问题,因此在采样时一定要规避这两种情况,至于会不会失真?答案是如果采用alpha失真只会更严重。


清晰度问题


同样,清晰度也容易受到这olor为0或者alpha通道为0的情况情况干扰,除了这两种就是采样区域的大小了,理论上采样网格密度越密,清晰度越高,越接近原始图片,因此一定要权衡,太清晰不就很原图一样了么,还制作什么LED呢?


马赛克原理


实际上,马赛克原理和LED展示方式类似,为什么这么说呢?从特征来看,几乎一样,马赛克和LED效果只在两部分存在区别



  1. 马赛克网格之间不存在间距

  2. 马赛克采样次数比LED要少


马赛克没有LED间距很好理解,至于次数少的好处第一肯定是效率高,其次是采样太多容易接近原色,而LED是要有一定程度接近原色。


技术实现


本篇我们邀请一位可爱的猫猫,老师们太耀眼的图片就算了,不利于大家阅读。


ic_cat.png


我们接下来的任务是把给猫脸打上马赛克,了解完这项技术实现后,其实你不仅可以给猫脸打马赛克,自行涂鸦,指哪儿打哪儿。


基本信息


private Bitmap mBitmap; //猫图
private float blockWidth = 30; //30x30的像素快
private RectF blockRect = new RectF(); //猫头区域
private RectF gridRect = new RectF(); //网格区域

Canvas 包裹Bitmap


主要方便绘制和内存回收


static class BitmapCanvas extends Canvas {
Bitmap bitmap;
public BitmapCanvas(Bitmap bitmap) {
super(bitmap);
this.bitmap = bitmap;
}
public Bitmap getBitmap() {
return bitmap;
}
}


定位猫头位置


由于时间关系,我没有做TOUCH事件处理,就写了这个猫头区域


/ 这个区域到时候可以自己调制,想让什么部位有马赛克那就会有马赛克
blockRect.set(bitmapCanvas.bitmap.getWidth() - 400, 0, bitmapCanvas.bitmap.getWidth() - 100, 300);

网格分割


//根据平分猫头矩形区域
int col = (int) (blockRect.width() / blockWidth);
int row = (int) (blockRect.height() / blockWidth);

网格定位


float startX = blockRect.left;
float startY = blockRect.top;
for (int i = 0; i < row * col; i++) {
int x = i % col;
int y = (i / col);
gridRect.set(startX + x * blockWidth, startY + y * blockWidth, startX + x * blockWidth + blockWidth, startY + y * blockWidth + blockWidth);

}

采样和着色


float startX = blockRect.left;
float startY = blockRect.top;
for (int i = 0; i < row * col; i++) {
int x = i % col;
int y = (i / col);
gridRect.set(startX + x * blockWidth, startY + y * blockWidth, startX + x * blockWidth + blockWidth, startY + y * blockWidth + blockWidth);
//采样
int sampleColor = mBitmap.getPixel((int) gridRect.centerX(), (int) gridRect.centerY());
mCommonPaint.setColor(sampleColor);
//着色,我们这里不修改像素,而是drawRect,避免性能问题和锯齿问题
bitmapCanvas.drawRect(gridRect, mCommonPaint);
}

渲染到View上


canvas.drawBitmap(bitmapCanvas.bitmap, null, mainRect, mCommonPaint);

效果预览


fire_56.gif


避坑点


网格区间不易过小,和LED一样,越小清晰度越高,就会失去了处理的意义。


全部代码


public class MosaicView extends View {
private final DisplayMetrics mDM;
private TextPaint mCommonPaint;
private RectF mainRect = new RectF();

private BitmapCanvas bitmapCanvas; //Canvas 封装的
private Bitmap mBitmap; //猫图
private float blockWidth = 30; //30x30的像素快
private RectF blockRect = new RectF(); //猫头区域
private RectF gridRect = new RectF(); //网格区域
private boolean showMask = false;

public MosaicView(Context context) {
this(context, null);
}
public MosaicView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public MosaicView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDM = getResources().getDisplayMetrics();
initPaint();
}

private void initPaint() {
//否则提供给外部纹理绘制
mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mCommonPaint.setAntiAlias(true);
mCommonPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mCommonPaint.setStrokeCap(Paint.Cap.ROUND);
mBitmap = decodeBitmap(R.mipmap.ic_cat);

}
private Bitmap decodeBitmap(int resId) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inMutable = true;
return BitmapFactory.decodeResource(getResources(), resId, options);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);

if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);
mBitmap = decodeBitmap(R.mipmap.ic_cat);

}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if (bitmapCanvas != null && bitmapCanvas.bitmap != null && !bitmapCanvas.bitmap.isRecycled()) {
bitmapCanvas.bitmap.recycle();
}
bitmapCanvas = null;

}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width < 1 || height < 1) {
return;
}
if (bitmapCanvas == null || bitmapCanvas.bitmap == null) {
bitmapCanvas = new BitmapCanvas(Bitmap.createBitmap(mBitmap.getWidth(), mBitmap.getHeight(), Bitmap.Config.ARGB_8888));
} else {
bitmapCanvas.bitmap.eraseColor(Color.TRANSPARENT);
}
float radius = Math.min(width / 2f, height / 2f);

//关闭双线性过滤
// int flags = mCommonPaint.getFlags();
// mCommonPaint.setFlags(flags &~ Paint.FILTER_BITMAP_FLAG);
// mCommonPaint.setFilterBitmap(false);

int save = bitmapCanvas.save();
bitmapCanvas.drawBitmap(mBitmap, 0, 0, mCommonPaint);


// 这个区域到时候可以自己调制,想让什么部位有马赛克那就会有马赛克
blockRect.set(bitmapCanvas.bitmap.getWidth() - 560, 10, bitmapCanvas.bitmap.getWidth() - 100, 410);

if(showMask) {
//根据平分猫头矩形区域
int col = (int) (blockRect.width() / blockWidth);
int row = (int) (blockRect.height() / blockWidth);

float startX = blockRect.left;
float startY = blockRect.top;

for (int i = 0; i < row * col; i++) {
int x = i % col;
int y = (i / col);
gridRect.set(startX + x * blockWidth, startY + y * blockWidth, startX + x * blockWidth + blockWidth, startY + y * blockWidth + blockWidth);
//采样
int sampleColor = mBitmap.getPixel((int) gridRect.centerX(), (int) gridRect.centerY());
mCommonPaint.setColor(sampleColor);
//着色,我们这里不修改像素,而是drawRect,避免性能问题和锯齿问题
bitmapCanvas.drawRect(gridRect, mCommonPaint);
}
}else{
Paint.Style style = mCommonPaint.getStyle();
mCommonPaint.setStyle(Paint.Style.STROKE);
mCommonPaint.setColor(Color.MAGENTA);
mCommonPaint.setStrokeWidth(8);
bitmapCanvas.drawRect(blockRect, mCommonPaint);
mCommonPaint.setStyle(style);

}

bitmapCanvas.restoreToCount(save);
int saveCount = canvas.save();
canvas.translate(width / 2f, height / 2f);
mainRect.set(-radius, -radius, radius, radius);
canvas.drawBitmap(bitmapCanvas.bitmap, null, mainRect, mCommonPaint);
canvas.restoreToCount(saveCount);

}

public void openMask() {
showMask = true;
postInvalidate();
}

public void closeMask() {
showMask = false;
postInvalidate();

}

static class BitmapCanvas extends Canvas {
Bitmap bitmap;
public BitmapCanvas(Bitmap bitmap) {
super(bitmap);
//继承在Canvas的绘制是软绘制,因此理论上可以绘制出阴影
this.bitmap = bitmap;
}
public Bitmap getBitmap() {
return bitmap;
}
}
}

总结


实际上还有另一种方法,我们绘制图片时关闭双线性过滤


//关闭双线性过滤
// int flags = mCommonPaint.getFlags();
// mCommonPaint.setFlags(flags &~ Paint.FILTER_BITMAP_FLAG);
// mCommonPaint.setFilterBitmap(false);

然后将图片放到很大,这个时候你的图片就会产生一定的网格区域,截图然后进行一系列矩阵转换,最后把图贴到原处就出现了马赛克,但是这个有个问题,超高像素的图片得先缩小,然后再放大,显然处理步骤比较多。


下图是先缩小20倍然后画到原来大小的效果

企业微信20231205-221500@2x.png


实现代码


本来不打算放代码的,想想还是放上吧


public class BitmapMosaicView extends View {
private final DisplayMetrics mDM;
private TextPaint mCommonPaint;
private RectF mainRect = new RectF();
private BitmapCanvas bitmapCanvas; //Canvas 封装的
private BitmapCanvas srcThumbCanvas; //Canvas 封装的
private Bitmap mBitmap; //猫图
private RectF blockRect = new RectF(); //猫头区域

public BitmapMosaicView(Context context) {
this(context, null);
}
public BitmapMosaicView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public BitmapMosaicView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDM = getResources().getDisplayMetrics();
initPaint();
}

private void initPaint() {
//否则提供给外部纹理绘制
mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mCommonPaint.setAntiAlias(true);
mCommonPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mCommonPaint.setStrokeCap(Paint.Cap.ROUND);
mBitmap = decodeBitmap(R.mipmap.ic_cat);

}
private Bitmap decodeBitmap(int resId) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inMutable = true;
return BitmapFactory.decodeResource(getResources(), resId, options);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);

if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);

}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if (bitmapCanvas != null && bitmapCanvas.bitmap != null && !bitmapCanvas.bitmap.isRecycled()) {
bitmapCanvas.bitmap.recycle();
}
if (srcThumbCanvas != null && srcThumbCanvas.bitmap != null && !srcThumbCanvas.bitmap.isRecycled()) {
srcThumbCanvas.bitmap.recycle();
}
bitmapCanvas = null;

}

private Rect srcRectF = new Rect();
private Rect dstRectF = new Rect();

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width < 1 || height < 1) {
return;
}
if (bitmapCanvas == null || bitmapCanvas.bitmap == null) {
bitmapCanvas = new BitmapCanvas(Bitmap.createBitmap(mBitmap.getWidth(), mBitmap.getHeight(), Bitmap.Config.ARGB_8888));
} else {
bitmapCanvas.bitmap.eraseColor(Color.TRANSPARENT);
}
if (srcThumbCanvas == null || srcThumbCanvas.bitmap == null) {
srcThumbCanvas = new BitmapCanvas(Bitmap.createBitmap(mBitmap.getWidth()/35, mBitmap.getHeight()/35, Bitmap.Config.ARGB_8888));
} else {
srcThumbCanvas.bitmap.eraseColor(Color.TRANSPARENT);
}
float radius = Math.min(width / 2f, height / 2f);

//关闭双线性过滤
int flags = mCommonPaint.getFlags();
mCommonPaint.setFlags(flags &~ Paint.FILTER_BITMAP_FLAG);
mCommonPaint.setFilterBitmap(false);
mCommonPaint.setDither(false);


srcRectF.set(0,0,mBitmap.getWidth(),mBitmap.getHeight());
dstRectF.set(0,0, srcThumbCanvas.bitmap.getWidth(), srcThumbCanvas.bitmap.getHeight());

int save = bitmapCanvas.save();
srcThumbCanvas.drawBitmap(mBitmap, srcRectF, dstRectF, mCommonPaint);

srcRectF.set(dstRectF);
dstRectF.set(0,0,bitmapCanvas.bitmap.getWidth(),bitmapCanvas.bitmap.getHeight());
bitmapCanvas.drawBitmap(srcThumbCanvas.bitmap, srcRectF,dstRectF, mCommonPaint);
// 这个区域到时候可以自己调制,想让什么部位有马赛克那就会有马赛克
blockRect.set(bitmapCanvas.bitmap.getWidth() - 560, 10, bitmapCanvas.bitmap.getWidth() - 100, 410);
bitmapCanvas.restoreToCount(save);
int saveCount = canvas.save();
canvas.translate(width / 2f, height / 2f);
mainRect.set(-radius, -radius, radius, radius);
canvas.drawBitmap(bitmapCanvas.bitmap, null, mainRect, mCommonPaint);
canvas.restoreToCount(saveCount);

}
static class BitmapCanvas extends Canvas {
Bitmap bitmap;
public BitmapCanvas(Bitmap bitmap) {
super(bitmap);
//继承在Canvas的绘制是软绘制,因此理论上可以绘制出阴影
this.bitmap = bitmap;
}
public Bitmap getBitmap() {
return bitmap;
}
}
}

总结下本文分享技术特点:

  • 网格化
  • 采样
  • canvas着色,不要去修改像素


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

Android 实现LED 展示效果

一、前言 LED以其卓越的亮度和醒目的文字和图案,已成为车水马龙的城市中充满烟火气息的象征,深层次的是您红灯的闪烁唤醒着人们的娱乐、怀旧、童年的记忆。当然对新时代来说这显然格格不入的,因此这种霓虹灯能存在多久显然还是个问题。 效果预览 二、实现原理 最初的设...
继续阅读 »

一、前言


LED以其卓越的亮度和醒目的文字和图案,已成为车水马龙的城市中充满烟火气息的象征,深层次的是您红灯的闪烁唤醒着人们的娱乐、怀旧、童年的记忆。当然对新时代来说这显然格格不入的,因此这种霓虹灯能存在多久显然还是个问题。


效果预览



二、实现原理


最初的设想是利用BitmapShader  + Shader 实现网格图片,但是最终是失败的,因此绘制出的网格不是纯色。


为什么是需要网格纯色呢 ,主要原因是LED等作为单独的实体,单个LED智能发出一种光,电视也是一样的道理,微小的发光单元不可能同时发出多种光源,这也是LED显示屏的制作原理。至于我们的自定义View,本身是细腻的屏幕上发出的,如果一个LED发出多种光,就会显得很假。但事实上,在绘制View时一个区域可能会出现多种颜色,如何平衡这种颜色也是个问题,优化方式当然是增加采样点;但是采样点多了也会带来新的副作用,一是性能问题,而是过多的全透明和alpha为0的情况,因为这种情况会过度稀释真是的颜色,造成模糊不清的问题,其次和View本身的背景穿透,形成较大范围的噪点,所以绘制过程中一定要控制采样点的数量,其次对alpha为0或者过小的的情况剔除,当然不用担心失真,因为过度的透明人眼会认为是全透明,没有太多意义,我们来做个总结:



  • LED 单元智能发出一种光,因此不适合BitampShader做风格渲染

  • 颜色逼真程度和采样点有关,采样点越多越逼近真色

  • 清晰程度和LED单元大小相关,LED单元越小越清晰

  • 剔除alpha通道过小和颜色值为0的采样点颜色 


三、核心逻辑


生成刷子纹理


     if (brushBitmap == null) {
brushBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
brushCanvas = new Canvas(brushBitmap);
}

for (int i = 0; i < drawers.size(); i++) {
int saveCount = brushCanvas.save();
drawers.get(i).draw(brushCanvas, width, height, mCommonPaint);
brushCanvas.restoreToCount(saveCount);
}

生成网格数据


        float blockWidth = (squareWidth + padding);
int w = width;
int h = height;
int columNum = (int) Math.ceil(w / blockWidth);
int rowNum = (int) Math.ceil(h / blockWidth);

if (gridRects.isEmpty() && squareWidth > 1f) {
//通过rowNum * columNum方式降低时间复杂度
for (int i = 0; i < rowNum * columNum; i++) {

int col = i % columNum;
int row = (i / columNum);

Rect rect = new Rect();
rect.left = (int) (col * blockWidth);
rect.top = (int) (row * blockWidth);
rect.right = (int) (col * blockWidth + squareWidth);
rect.bottom = (int) (row * blockWidth + squareWidth);
//记录网格点
gridRects.add(rect);
}

}

采样绘制


    //这里是重点 ,LED等可以看作一只灯泡,灯泡区域要么全亮,要么全不亮
for (int i = 0; i < gridRects.size(); i++) {
Rect rect = gridRects.get(i);

if (brushBitmap.getWidth() <= rect.right) {
continue;
}
if (brushBitmap.getHeight() <= rect.bottom) {
continue;
}

if (sampleColors == null) {
sampleColors = new int[9];
}

//取7个点采样,纯粹是为了性能考虑,如果想要更准确的颜色,可以多采样几个点

sampleColors[0] = brushBitmap.getPixel(rect.left, rect.top); // left-top
sampleColors[1] = brushBitmap.getPixel(rect.right, rect.top); // right-top
sampleColors[2] = brushBitmap.getPixel(rect.right, rect.bottom); // right-bottom
sampleColors[3] = brushBitmap.getPixel(rect.left, rect.bottom); // left-bottom
sampleColors[4] = brushBitmap.getPixel(rect.left + rect.width() / 2, rect.top + rect.height() / 2); //center

sampleColors[5] = brushBitmap.getPixel(rect.left + rect.width() / 2, rect.top + rect.height() / 4); //top line
sampleColors[6] = brushBitmap.getPixel(rect.left + rect.width() * 3 / 4, rect.top + rect.height() / 2); //right line
sampleColors[7] = brushBitmap.getPixel(rect.left + rect.width() / 4, rect.top + rect.height() / 2); // left line
sampleColors[8] = brushBitmap.getPixel(rect.left + rect.width() / 2, rect.top + rect.height() * 3 / 4); // bottom line

int alpha = 0;
int red = 0;
int green = 0;
int blue = 0;
int num = 0;

for (int c : sampleColors) {
if (c == Color.TRANSPARENT) {
//剔除全透明的颜色,必须剔除
continue;
}
int alphaC = Color.alpha(c);
if (alphaC <= 0) {
//剔除alpha为0的颜色,当然可以改大一点,防止降低清晰度
continue; }
alpha += alphaC;
red += Color.red(c);
green += Color.green(c);
blue += Color.blue(c);
num++;
}

if (num < 1) {
continue;
}

//求出平均值
int rectColor = Color.argb(alpha / num, red / num, green / num, blue / num);
if (rectColor != Color.TRANSPARENT) {
mGridPaint.setColor(rectColor);
// canvas.drawRect(rect, mGridPaint); //绘制矩形
canvas.drawCircle(rect.centerX(), rect.centerY(), squareWidth / 2, mGridPaint); //绘制圆
}
}

如果不剔除颜色,那么就会有噪点和清晰度问题



全部代码


public class LedDisplayView extends View {
private final DisplayMetrics mDM;
private TextPaint mGridPaint;
private TextPaint mCommonPaint;
private List<IDrawer> drawers = new ArrayList<>();
private Bitmap brushBitmap = null;
private float padding = 2; //分界线大小
private float squareWidth = 5; //网格大小
private List<Rect> gridRects = new ArrayList<>();
int[] sampleColors = null;
private Canvas brushCanvas = null;

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

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

public LedDisplayView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDM = getResources().getDisplayMetrics();
initPaint();

}


@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);

int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);

if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}

int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);
}


public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, mDM);
}

public float sp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, dp, mDM);
}


@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if (brushBitmap != null && !brushBitmap.isRecycled()) {
brushBitmap.recycle();
}
brushBitmap = null;
}


@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);

int width = getWidth();
int height = getHeight();
if (width <= padding || height <= padding) {
return;
}

if (brushBitmap == null) {
brushBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
brushCanvas = new Canvas(brushBitmap);
}

for (int i = 0; i < drawers.size(); i++) {
int saveCount = brushCanvas.save();
drawers.get(i).draw(brushCanvas, width, height, mCommonPaint);
brushCanvas.restoreToCount(saveCount);
}


float blockWidth = (squareWidth + padding);
int w = width;
int h = height;
int columNum = (int) Math.ceil(w / blockWidth);
int rowNum = (int) Math.ceil(h / blockWidth);

if (gridRects.isEmpty() && squareWidth > 1f) {
//通过rowNum * columNum方式降低时间复杂度
for (int i = 0; i < rowNum * columNum; i++) {

int col = i % columNum;
int row = (i / columNum);

Rect rect = new Rect();
rect.left = (int) (col * blockWidth);
rect.top = (int) (row * blockWidth);
rect.right = (int) (col * blockWidth + squareWidth);
rect.bottom = (int) (row * blockWidth + squareWidth);
//记录网格点
gridRects.add(rect);
}

}
int color = mGridPaint.getColor();

//这里是重点 ,LED等可以看作一只灯泡,灯泡区域要么全亮,要们全不亮
for (int i = 0; i < gridRects.size(); i++) {
Rect rect = gridRects.get(i);

if (brushBitmap.getWidth() <= rect.right) {
continue;
}
if (brushBitmap.getHeight() <= rect.bottom) {
continue;
}

if (sampleColors == null) {
sampleColors = new int[9];
}

//取7个点采样,纯粹是为了性能考虑,如果想要更准确的颜色,可以多采样几个点

sampleColors[0] = brushBitmap.getPixel(rect.left, rect.top); // left-top
sampleColors[1] = brushBitmap.getPixel(rect.right, rect.top); // right-top
sampleColors[2] = brushBitmap.getPixel(rect.right, rect.bottom); // right-bottom
sampleColors[3] = brushBitmap.getPixel(rect.left, rect.bottom); // left-bottom
sampleColors[4] = brushBitmap.getPixel(rect.left + rect.width() / 2, rect.top + rect.height() / 2); //center

sampleColors[5] = brushBitmap.getPixel(rect.left + rect.width() / 2, rect.top + rect.height() / 4); //top line
sampleColors[6] = brushBitmap.getPixel(rect.left + rect.width() * 3 / 4, rect.top + rect.height() / 2); //right line
sampleColors[7] = brushBitmap.getPixel(rect.left + rect.width() / 4, rect.top + rect.height() / 2); // left line
sampleColors[8] = brushBitmap.getPixel(rect.left + rect.width() / 2, rect.top + rect.height() * 3 / 4); // bottom line

int alpha = 0;
int red = 0;
int green = 0;
int blue = 0;
int num = 0;

for (int c : sampleColors) {
if (c == Color.TRANSPARENT) {
//剔除全透明的颜色,必须剔除
continue;
}
int alphaC = Color.alpha(c);
if (alphaC <= 0) {
//剔除alpha为0的颜色,当然可以改大一点,防止降低清晰度
continue;
}
alpha += alphaC;
red += Color.red(c);
green += Color.green(c);
blue += Color.blue(c);
num++;
}

if (num < 1) {
continue;
}

//求出平均值
int rectColor = Color.argb(alpha / num, red / num, green / num, blue / num);
if (rectColor != Color.TRANSPARENT) {
mGridPaint.setColor(rectColor);
// canvas.drawRect(rect, mGridPaint); //绘制矩形
canvas.drawCircle(rect.centerX(), rect.centerY(), squareWidth / 2, mGridPaint); //绘制圆
}
}
mGridPaint.setColor(color);

}


private void initPaint() {
// 实例化画笔并打开抗锯齿
mGridPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mGridPaint.setAntiAlias(true);
mGridPaint.setColor(Color.LTGRAY);
mGridPaint.setStyle(Paint.Style.FILL);
mGridPaint.setStrokeCap(Paint.Cap.ROUND); //否则网格绘制

//否则提供给外部纹理绘制
mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mCommonPaint.setAntiAlias(true);
mCommonPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mCommonPaint.setStrokeCap(Paint.Cap.ROUND);

}

public void addDrawer(IDrawer drawer) {
if (drawer == null) return;
this.drawers.add(drawer);
gridRects.clear();
postInvalidate();
}

public void removeDrawer(IDrawer drawer) {
if (drawer == null) return;
this.drawers.remove(drawer);
gridRects.clear();
postInvalidate();
}

public void clearDrawer() {
this.drawers.clear();
gridRects.clear();
postInvalidate();
}

public List<IDrawer> getDrawers() {
return new ArrayList<>(drawers);
}

public interface IDrawer {
void draw(Canvas canvas, int width, int height, Paint paint);
}

}

使用方式


       LedDisplayView displayView = findViewById(R.id.ledview);
final BitmapDrawable bitmapDrawable1 = (BitmapDrawable)getResources().getDrawable(R.mipmap.mm_07);
final BitmapDrawable bitmapDrawable2 = (BitmapDrawable)getResources().getDrawable(R.mipmap.mm_08);
ledDisplayView.addDrawer(new LedDisplayView.IDrawer() {

Matrix matrix = new Matrix();
@Override
public void draw(Canvas canvas, int width, int height, Paint paint) {
canvas.translate(width/2,height/2);
matrix.preTranslate(-width/2,-height/4);
Bitmap bitmap1 = bitmapDrawable1.getBitmap();
canvas.drawBitmap(bitmap1,matrix,paint);

matrix.postTranslate(width/2,height/4);
Bitmap bitmap2 = bitmapDrawable2.getBitmap();
canvas.drawBitmap(bitmap2,matrix,paint);
}
});
ledDisplayView.addDrawer(new LedDisplayView.IDrawer() {
@Override
public void draw(Canvas canvas, int width, int height, Paint paint) {
paint.setColor(Color.CYAN);
float textSize = paint.getTextSize();
paint.setTextSize(sp2px(50));
canvas.drawText("你好,L E D", 100, 200, paint);
canvas.drawText("85%", 100, 350, paint);

paint.setColor(Color.YELLOW);
canvas.drawCircle(width*3 / 4, height / 4, 100, paint);

paint.setTextSize(textSize);
}
});

四、总结


这个本质上的核心就是采样,通过采样我们最终实现了纹理贴图,这点类似open gl中的光栅化,将图形分割成小三角形一样,最后着色,理解本篇也能帮助大家理解open gl和led显示原理。


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

Android 图片分片过渡效果

前言 在之前的文章中,通过LED效果、马赛克效果两篇文章,介绍了分片绘制的效果的方法和原理,通过这两篇文章,相信大家都已经熟悉了分片绘制的思路。其实分片绘制不仅仅能实现LED、马赛克等特殊效果,实际上类似百叶窗、图片对角线锯齿过渡等,很多PPT中存在的特效,基...
继续阅读 »

前言


在之前的文章中,通过LED效果马赛克效果两篇文章,介绍了分片绘制的效果的方法和原理,通过这两篇文章,相信大家都已经熟悉了分片绘制的思路。其实分片绘制不仅仅能实现LED、马赛克等特殊效果,实际上类似百叶窗、图片对角线锯齿过渡等,很多PPT中存在的特效,基本上也是按照这种原理来实现的。


分片可以有很多种意想不到的效,我们再来说一下分片特点:



  • [1] 按一定的距离、大小、角度对区域进行对一张图片或者区域裁剪或者提取区域图像

  • [2] 对提取出来的区域进行一系列变换,如百叶窗、微信摇一摇等

  • [3] 被裁剪的区域可以还原回去


技术前景


其实单纯的分片可以做一些瓦片效果,当然还可以做一些组合效果,下面是一个github开源项目(Camera2DApplication)利用Camera和图片分片实现的效果,这个过程中对一张图片进行分片绘制。


fire_58.gif


代码中的逻辑不是很复杂,本质上就是利用2张图片实现的,我们先来看下代码实现,作者的代码很认真,注释都写了,涉及postTranslate比较难懂的操作我也进行了微调。


/**
* 3d旋转效果
*
* @param canvas
*/

private void drawModeNormal(Canvas canvas) {
//VERTICAL时使用rotateY,HORIZONTAL时使用rotateX
if (orientation == VERTICAL) {
//如果是前进,则画当前图,后退则画上一张图,注释用的是前进情况
matrix.reset();
camera.save();
//旋转角度 0 - -maxDegress
camera.rotateX(-degress);
camera.getMatrix(matrix);
camera.restore();

//绕着图片top旋转
matrix.preTranslate(-viewWidth / 2f, 0);
//旋转轴向下平移,则图片也向下平移
matrix.postTranslate(viewWidth / 2f, rotatePivotY);
//如果是前进,则画当前图,后退则画上一张图,因为后退时,这里画的是动画下方出来的图片,而下方的图片是前一张图
canvas.drawBitmap(getBitmapScale(bitmapResourceIds.get(isForward ? currentIndex : preIndex), viewWidth, viewHeight),
matrix, mPaint);

//在处理下一张图片
matrix.reset();
camera.save();
//旋转角度 maxDegress - 0
camera.rotateX(maxDegress - degress);
camera.getMatrix(matrix);
camera.restore();

//绕着图片bottom旋转
matrix.preTranslate(-viewWidth / 2f, -viewHeight);
//旋转轴向下平移,则图片也向下平移
matrix.postTranslate(viewWidth / 2f, rotatePivotY);
//如果是前进,则画下一张图,后退则画当前图,后退时,这边代码画的是动画上方的图片,上方的图片是当前图片
canvas.drawBitmap(getBitmapScale(bitmapResourceIds.get(isForward ? nextIndex : currentIndex), viewWidth, viewHeight),
matrix, mPaint);
} else {
//如果是前进,则画当前图,后退则画上一张图,注释用的是前进情况
matrix.reset();
camera.save();
//旋转角度 0 - maxDegress
camera.rotateY(degress);
camera.getMatrix(matrix);
camera.restore();

//绕着图片left旋转
matrix.preTranslate(0, -viewHeight / 2);
//旋转轴向右平移,则图片也向右平移
matrix.postTranslate(rotatePivotX, viewHeight / 2);
//如果是前进,则画当前图,后退则画上一张图,因为后退时,这里画的是动画右方出来的图片,而右方的图片是前一张图
canvas.drawBitmap(getBitmapScale(bitmapResourceIds.get(isForward ? currentIndex : preIndex), viewWidth, viewHeight),
matrix, mPaint);

//在处理下一张图片
matrix.reset();
camera.save();
//旋转角度 -maxDegress - 0
camera.rotateY(-maxDegress + degress);
camera.getMatrix(matrix);
camera.restore();

//绕着图片right旋转
matrix.preTranslate(-viewWidth, -viewHeight / 2f);
//旋转轴向右平移,则图片也向右平移
matrix.postTranslate(rotatePivotX, viewHeight / 2f);
//如果是前进,则画下一张图,后退则画当前图,后退时,这边代码画的是动画左方的图片,左方的图片是当前图片
canvas.drawBitmap(getBitmapScale(bitmapResourceIds.get(isForward ? nextIndex : currentIndex), viewWidth, viewHeight),
matrix, mPaint);
}
}

分片操作


下面是分片操作,这个地方其实可以不用创建Bitmap缓存,创建Path就行,绘制时对Path区域利用Shader贴图即可。


private Bitmap getBitmapScale(int resId, float width, float height) {
if (ImageCache.getInstance().getBitmapFromMemCache(String.valueOf(resId)) != null) {
return ImageCache.getInstance().getBitmapFromMemCache(String.valueOf(resId));
}
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), resId);
//创建分片
Bitmap bitmapDst = Bitmap.createScaledBitmap(bitmap, (int) width, (int) height, false);
bitmap.recycle();

ImageCache.getInstance().addBitmapToMemoryCache(String.valueOf(resId)
, bitmapDst);
return bitmapDst;
}

小试一下


我们这里通过一个简单的Demo,实现一种特效,这次我们利用网格矩阵分片。说到矩阵,很多人面试的时候都会遇到一些算法题,比较幸运的人遇到的是矩阵旋转90度、逆时针打印矩阵、矩阵孤岛问题、从左上角开始进行矩阵元素搜索,运气稍差的会遇到由外到里顺时针打印矩阵和斜对角打印矩阵,后面两种看似简单的问题实际上做起来并不顺手,有点扯远了,我们来看看效果。


fire_59.gif


你没看错,这次遇到了算法问题,我这边用的空间换取时间的方法。


图像分片


将图片分片,计算出网格的列和行


int col = (int) Math.ceil(mBitmaps[index].getWidth() / blockWidth);
int row = (int) Math.ceil(mBitmaps[index].getHeight() / blockWidth);

分片算法


这个算法实际上是每次将列数 +1,然后按对角分割,把符合的区域添加到path中


int x = xPosition;
int y = 0;
while (x >= 0 && y <= row) {
if (x < col && y < row) {
dstRect.set((int) (x * blockWidth), (int) (y * blockWidth), (int) (x * blockWidth + blockWidth), (int) (y * blockWidth + blockWidth));
// bitmapCanvas.drawBitmap(mBitmaps[index], dstRect, dstRect, mCommonPaint);
path.addRect(dstRect, Path.Direction.CCW); //加入网格分片
}
x--;
y++;
}

Path 路径贴图



  • Path过程中我们添加的rect是闭合区域,是可以贴图的,当然,一般有三种方法:

  • Path的贴图一般使用 clipPath对图片裁剪然后贴图,当然还有将对应的图片区域绘制到View上

  • Path 是Rect,按照Rect将图片区域绘制到Rect区域

  • 使用BitmapShader一次性绘制


实际上我们应该尽可能使用Bitmap,因为BitmapShader唯一是不存在锯齿性能比较好的绘制方法。


int save = bitmapCanvas.save();
mCommonPaint.setShader(new BitmapShader(mBitmaps[index], Shader.TileMode.CLAMP, Shader.TileMode.CLAMP));
bitmapCanvas.drawPath(path,mCommonPaint);
bitmapCanvas.restoreToCount(save);

其实我们的核心代码到这里就结束了,我们可以看到,分片可以的意义很重要的,当然,借助其他工具也可以实现,不过代码实现的好处是可以编辑和交互,不是所有的动画都可以产生交互。


到此,我们还可以对今天的demo添加一些想象



  • 从中间外扩效果

  • 奇偶行切换效果

  • 国际象棋黑白格子变换效果

  • ......


总结


这是我们的第三篇关于图片分片特效的博客,希望通过一些了的文章,熟悉一些技术,往往看似高大上的效果,其实就是通过普普通通的方法叠加在一起的,当然,让你的技术承载你的想象,才是最重要的。


本篇demo全部代码


实际上代码贴太多很可能没人看,但是依照惯例,我们给出完整代码。


public class TilesView extends View {
private final DisplayMetrics mDM;
private TextPaint mCommonPaint;
private RectF mainRect = new RectF();
private BitmapCanvas bitmapCanvas; //Canvas 封装的
private Bitmap[] mBitmaps;
private RectF dstRect = new RectF();
Path path = new Path();
private float blockWidth = 50f;
private int xPosition = -2;
private int index = 0;
private boolean isTicking = false;
public TilesView(Context context) {
this(context, null);
}
public TilesView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public TilesView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDM = getResources().getDisplayMetrics();
initPaint();
}

private void initPaint() {
//否则提供给外部纹理绘制
mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
mCommonPaint.setAntiAlias(true);
mCommonPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mCommonPaint.setStrokeCap(Paint.Cap.ROUND);
mCommonPaint.setFilterBitmap(true);
mCommonPaint.setDither(true);
mBitmaps = new Bitmap[3];
mBitmaps[0] = decodeBitmap(R.mipmap.mm_013);
mBitmaps[1] = decodeBitmap(R.mipmap.mm_014);
mBitmaps[2] = decodeBitmap(R.mipmap.mm_015);
}

private Bitmap decodeBitmap(int resId) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inMutable = true;
return BitmapFactory.decodeResource(getResources(), resId, options);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);

if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);

}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if (bitmapCanvas != null && bitmapCanvas.bitmap != null && !bitmapCanvas.bitmap.isRecycled()) {
bitmapCanvas.bitmap.recycle();
}
bitmapCanvas = null;

}


@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width < 1 || height < 1) {
return;
}
if (bitmapCanvas == null || bitmapCanvas.bitmap == null || bitmapCanvas.bitmap.isRecycled()) {
bitmapCanvas = new BitmapCanvas(Bitmap.createBitmap(mBitmaps[index].getWidth(), mBitmaps[index].getHeight(), Bitmap.Config.ARGB_8888));
}
int nextIndex = (index + 1) % mBitmaps.length;
canvas.drawBitmap(mBitmaps[nextIndex],0,0,mCommonPaint);

int col = (int) Math.ceil(mBitmaps[index].getWidth() / blockWidth);
int row = (int) Math.ceil(mBitmaps[index].getHeight() / blockWidth);
mCommonPaint.setStyle(Paint.Style.FILL);

// path.reset();
// for (int x = 0; x < row; x++) {
// for (int y = 0; y < col; y++) {
// gridRectF.set(x * blockWidth, y * blockWidth, x * blockWidth + blockWidth, y * blockWidth + blockWidth);
// canvas.drawRect(gridRectF, mCommonPaint);
// path.addRect(gridRectF, Path.Direction.CCW);
// }
// }

diagonalEffect(col,row,xPosition,path);
canvas.drawBitmap(bitmapCanvas.bitmap, 0, 0, mCommonPaint);

if (isTicking && xPosition >= 0 && xPosition < col * 2) {
clockTick();
} else if(isTicking){
xPosition = -1;
index = nextIndex;
isTicking = false;
}
}

private void diagonalEffect(int col, int row, int xPosition,Path path) {
int x = xPosition;
int y = 0;
while (x >= 0 && y <= row) {
if (x < col && y < row) {
dstRect.set((int) (x * blockWidth), (int) (y * blockWidth), (int) (x * blockWidth + blockWidth), (int) (y * blockWidth + blockWidth));
// bitmapCanvas.drawBitmap(mBitmaps[index], dstRect, dstRect, mCommonPaint);
path.addRect(dstRect, Path.Direction.CCW); //加入网格分片
}
x--;
y++;
}
int save = bitmapCanvas.save();
mCommonPaint.setShader(new BitmapShader(mBitmaps[index], Shader.TileMode.CLAMP, Shader.TileMode.CLAMP));
bitmapCanvas.drawPath(path,mCommonPaint);
bitmapCanvas.restoreToCount(save);

}

public void tick() {
isTicking = true;
xPosition = -1;
path.reset();
clockTick();
}

private void clockTick() {
xPosition += 1;
postInvalidateDelayed(16);
}


public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, mDM);
}

public float sp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, dp, mDM);
}

static class BitmapCanvas extends Canvas {
Bitmap bitmap;
public BitmapCanvas(Bitmap bitmap) {
super(bitmap);
//继承在Canvas的绘制是软绘制,因此理论上可以绘制出阴影
this.bitmap = bitmap;
}

public Bitmap getBitmap() {
return bitmap;
}
}
}

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

qq农场私信我,您菜死了🥬

web
最近在写代码的时候发现自己总是有这样几种症状: 脸红心跳,像发烧一样😳; 口干舌燥、咳嗽不停😮‍💨; 脑袋放空,像刚通宵了一般👀; ...... 我逐渐怀疑,自己有没有可能是🐑了,甚至时不时就拿起体温计量一量,拿起自测试纸测一测,这样的情况一直没有得到好转...
继续阅读 »

最近在写代码的时候发现自己总是有这样几种症状



  1. 脸红心跳,像发烧一样😳;

  2. 口干舌燥、咳嗽不停😮‍💨;

  3. 脑袋放空,像刚通宵了一般👀;

  4. ......


我逐渐怀疑,自己有没有可能是🐑了,甚至时不时就拿起体温计量一量,拿起自测试纸测一测,这样的情况一直没有得到好转,直到收到QQ农场给我发来这样的一条信息:


尊敬的QQ农场主,您去年和今年菜死了!🥬🥬🥬


🤔于是,我开始分析我症状根因是什么:



  1. 脸红心跳:是因为自己脑海中想象好的实现方案,但实际却写不出一行代码,或者各种Error,导致我心里落差很大,自我怀疑,或者是被人看穿菜的窘迫、害羞?

  2. 口干舌燥:是因为自己陷入了 写不出代码 => 憋着气接着写,不休息喝水 => 写不出代码 这样的闭环🐶里面;

  3. 脑袋放空:摆脱了内耗,很容易得出结论,就是看的技术不够多,写的代码不够多


痛定思痛,决定在这里立下FLAG,要多看多实践,学习和思考好的代码写法,看得多,写得多。


今天分享的主要是:用好发布订阅、偏函数的一对多 & 多对一关系工厂函数


发布订阅 & 偏函数(一对多/多对一关系)


是一种一对多的模式,或者说多对多的模式;一个事件对应多个处理函数,多个事件对应各自对应的处理函数



那假如我们想实现一个多对一的关系呢?我们可以使用偏函数


偏函数个人理解类似工厂函数,利用了闭包的特性


// 偏函数
function after(times, cb) {
let count = 0;
const result = {};
return inner(key, value) => {
result[key] = value;
count++;
if (count === times) {
cb(result);
}
};
}

结合代码看此处相当于多个inner函数对应一个callback函数,由count来控制是否触发callback,这种模式常常用于异步编程,比如Promise.all



综合一对多和多对一模式:


// 偏函数
function after(times, cb) {
let count = 0;
const result = {};
return (key, value) => {
result[key] = value;
count++;
if (count === times) {
cb(result);
}
};
}

// 发布订阅
const emitter = new (require("events").EventEmitter)();
const done = after(3, render);

emitter.on("done", done);
emitter.on("done", other);

fs.readFile(file, (err, template) => {
emitter.emit("done", "template", template);
});

fs.readFile(file, (err, data) => {
emitter.emit("done", "data", data);
});

fs.readFile(file, (err, str) => {
emitter.emit("done", "str", str);
});



工厂函数


类似现实工厂,在代码中用来生产特定结构函数/对象等的函数


比如想实现一个生成校验函数的工厂函数:


/**
* config里可以包含一般的描述性属性,钩子函数等
**/
export function factory(config) {
config.before = config.before || ((d) => d);
// pre钩子
handlersMap[config.type]?.pre(config);

return function (data) {
// before钩子函数
data = config.before(data);
return handlersMap[config.type].check(data);
};
}

// 通过该方法注册不同的校验函数
const handlersMap = {};
factory.registerHandler = function (type, handler) {
handlersMap[type] = handler;
};

在项目中的实现可如图:



🌊总结:


阅读好的代码,并学习一些好的写法,才是比较实际提高代码能力的方式,我也将💪持续阅读好的代码库,思考学习好的代码,把自己的成长分享出来。


作者:Kuroo
来源:juejin.cn/post/7182545613282623549
收起阅读 »

flutter 响应式观察值并更新UI

响应式编程是一种以对数据随时间变化做出反应为中心的范式。它有助于自动传播更新,从而可确保 UI 和数据保持同步。用 Flutter 术语来说,这意味着只要状态发生变化就会自动触发重建。 Observables Observables是响应式编程的核心。这些数据...
继续阅读 »

响应式编程是一种以对数据随时间变化做出反应为中心的范式。它有助于自动传播更新,从而可确保 UI 和数据保持同步。用 Flutter 术语来说,这意味着只要状态发生变化就会自动触发重建。


Observables


Observables是响应式编程的核心。这些数据源会在数据发生变化时向订阅者发出更新。Dart 的核心可观察类型是 Stream。


当状态发生变化时,可观察对象会通知侦听器。从用户交互到数据获取操作的任何事情都可以触发此操作。这有助于 Flutter 应用程序实时响应用户输入和其他更改。


Flutter 有两种类型:ValueNotifierChangeNotifier,它们是类似 observable 的类型,但不提供任何真正的可组合性计算。


ValueNotifier


Flutter 中的类ValueNotifier在某种意义上是响应式的,因为当值发生变化时它会通知观察者,但您需要手动监听所有值的变化来计算完整的值。


1、监听


  // 初始化
final ValueNotifier<String> fristName = ValueNotifier('Tom');
final ValueNotifier<String> secondName = ValueNotifier('Joy');
late final ValueNotifier<String> fullName;

@override
void initState() {
super.initState();
fullName = ValueNotifier('${fristName.value} ${secondName.value}');

fristName.addListener(_updateFullName);
secondName.addListener(_updateFullName);
}

void _updateFullName() {
fullName.value = '${fristName.value} ${secondName.value}';
}


//更改值得时候
firstName.value = 'Jane'
secondName.value = 'Jane'

2、使用ValueListenableBuilder更新UI


 //通知观察者
ValueListenableBuilder<String>(
valueListenable: fullName,
builder: (context, value, child) => Text(
'${fristName.value} ${secondName.value}',
style: Theme.of(context).textTheme.headlineMedium,
),
),

ChangeNotifier


1、监听


  String _firstName = 'Jane';
String _secondName = 'Doe';

String get firstName => _firstName;
String get secondName => _secondName;
String get fullName => '$_firstName $_secondName';

set firstName(String newName) {
if (newName != _firstName) {
_firstName = newName;
// Triggers rebuild
notifyListeners();
}
}

set secondName(String newSecondName) {
if (newSecondName != _secondName) {
_secondName = newSecondName;
// Triggers rebuild
notifyListeners();
}
}

//更改值得时候
firstName.value = 'Jane'
secondName.value = 'Jane'

2、使用AnimatedBuilder更新UI


//通知观察者
AnimatedBuilder(
animation: fullName,
builder: (context, child) => Text(
fullName,
style: Theme.of(context).textTheme.headlineMedium,
),
),

get


GetX将响应式编程变得非常简单。



  • 您不需要创建 StreamController。

  • 您不需要为每个变量创建一个 StreamBuilder。

  • 你不需要为每个状态创建一个类。

  • 你不需要创造一个终极价值。


使用 Get 的响应式编程就像使用 setState 一样简单。
让我们想象一下,您有一个名称变量,并且希望每次更改它时,所有使用它的小组件都会自动刷新。


1、监听以及更新UI


//这是一个普通的字符串
var name = 'Jonatas Borges';
为了使观察变得更加可观察,你只需要在它的附加上添加“.obs”。
var name = 'Jonatas Borges'.obs;
而在UI中,当你想显示该值并在值变化时更新页面时,只需这样做。
Obx(() => Text("${controller.name}"));

Riverpod


final fristNameProvider = StateProvider<String>((ref) => 'Tom');
final secondNameProvider = StateProvider<String>((ref) => 'Joy');
final fullNameProvider = StateProvider<String>((ref) {
final fristName = ref.watch(fristNameProvider);
final secondName = ref.watch(secondNameProvider);
return '$fristName $secondName';
});

//更改值得时候
ref.read(fristNameProvider.notifier).state =
'Jane'
ref.read(secondName.notifier).state =
'BB'


2、使用ConsumerWidget更新UI


ref.read(surnameProvider) 读取某个值


ref.read(nameProvider.notifier).state 更新某个值的状态


class MyHomePage extends ConsumerWidget {
const MyHomePage({super.key});

@override
Widget build(BuildContext context, WidgetRef ref) =>
Scaffold(
appBar: AppBar(
title: const Text('Riverpod Example'),
),
body: Text(
ref.watch(fullNameProvider),
style: Theme.of(context).textTheme.headlineMedium,
),
);
}


这里Consumer组件是与状态交互所必需的,Consumer有一个非标准build方法,这意味着如果您需要更改状态管理解决方案,您还必须更改组件而不仅仅是状态。


RxDart


RxDart将ReactiveX的强大功能引入Flutter,需要明确的逻辑来组合不同的数据流并对其做出反应。


存储计算值:它不会以有状态的方式直接存储计算值,但它确实提供了有用的运算符(例如distinctUnique)来帮助您最大限度地减少重新计算。


RxDart 库还有一个流行的类型被称为BehaviorSubject。响应式编程试图解决的核心问题是当依赖图中的任何值(依赖项)发生变化时自动触发计算。如果有多个可观察值,并且您需要将它们合并到计算中,Rx 库自动为我们执行此操作并且自动最小化重新计算以提高性能。


该库向 Dart 的现有流添加了功能。它不会重新发明轮子,并使用其他平台上的开发人员熟悉的模式。


1、监听


 final fristName = BehaviorSubject.seeded('Tom');
final secondName = BehaviorSubject.seeded('Joy');

/// 更新值
fristName.add('Jane'),
secondName.add('Jane'),


2、使用StreamBuilder更新UI


 StreamBuilder<String>(
stream: Rx.combineLatest2(
fristName,
secondName,
(fristName, secondName) => '$fristName $secondName',
),
builder: (context, snapshot) => Text(
snapshot.data ?? '',
style: Theme.of(context).textTheme.headlineMedium,
),
),

Signals


Signals以其computed功能介绍了一种创新、优雅的解决方案。它会自动创建反应式计算,当任何依赖值发生变化时,反应式计算就会更新。


1、监听


  final name = signal('Jane');
final surname = signal('Doe');
late final ReadonlySignal<String> fullName =
computed(() => '${name.value} ${surname.value}');
late final void Function() _dispose;

@override
void initState() {
super.initState();
_dispose = effect(() => fullName.value);
}

2、使用watch更新UI


Text(
fullName.watch(context),
style: Theme.of(context).textTheme.headlineMedium,
),

作者:icc_tips
来源:juejin.cn/post/7309131109740724259
收起阅读 »

拥抱华为,困难重重,第一天开始学习 ArkUI,踩坑踩了一天

今天第一天正式开始学习鸿蒙应用开发。 本来想着,作为一个拥有 10 来年工作经验的资深开发,对 React/Vue,React Native,Android 开发都有丰富的实践经验,对 Swift UI 也有所涉猎,在大前端这一块可以说是信心满满,学习 Ark...
继续阅读 »

今天第一天正式开始学习鸿蒙应用开发。


本来想着,作为一个拥有 10 来年工作经验的资深开发,对 React/Vue,React Native,Android 开发都有丰富的实践经验,对 Swift UI 也有所涉猎,在大前端这一块可以说是信心满满,学习 ArkUI 应该信手拈来才对,谁知道学习的第一天,我就发现我太天真了。


HarmonyOS 与 ArkUI 给我了沉痛一击


学习第一天一点都不顺利,上午还算有所收获,下午直接毫无建树,踩在一个坑里出不来,人直接裂开,差点以为自己要创业未半而中道崩殂了。不过好在晚饭后,侥幸解决了下午遇到的坑


最终今天学习的成果如下


scroll.gif


导航栏的4个图标都是用了 lottie 的动画,因为使用了 gif 录制,可能有点感觉不太明显,真机上的感受非常舒适,用户体验极佳


今天已经学习过的内容包括



  • 基础项目结构

  • 基础布局组件

  • 容器布局组件

  • 滚动组件

  • 导航组件

  • ohpm 安装

  • 引入 lottie 动画

  • 属性动画

  • 配置 hot reload

  • 组件状态管理 @state @props @link

  • 组件逻辑表达式

  • 沉浸式状态栏

  • 真机调试


我的开发设备具体情况如下


MacOS M1
HarmonyOS API 9
华为 P40 pro+,已安装 HarmonyOS 4

作为一个把主要精力放在前端的开发者,做个记录分享一下学习体会


01


组件概念


在前端开发中,不管你是用 React 还是使用 Vue,我们只需要掌握一个概念:组件。复杂的组件是由小的组件组成,页面是由组件组成,项目是由组件组成,超大项目也是由组件组成。组件可以组成一切。因此 React/Vue 的学习会相对更简单一些


和 Android 一样,由于 HarmonyOS 有更复杂的应用场景、多端、分屏等,因此在这一块的概念也更多一些,目前我接触到的几个概念包括


Window 一个项目好像可以有多个窗口,由于接触的时间太短了暂时不是很确定,可以创建子窗口,可以管理窗口的相关属性,创建,销毁等


Ability 用户与应用交互的入口点,一个 app 可以有一个或者对个 Ability


page 页面,一个应用可以由多个 page 组成


Component 组件,可以组合成页面


由于目前接触的内容不够全面,因此对这几个概念的理解还不够笃定,只是根据自己以往的开发经验推测大概可能是什么情况,因此介绍得比较简单,但是可以肯定的是理解这些概念是必备的


02


基础布局


虽然 HarmonyOS 目前也支持 web 那一套逻辑开发,不过官方文档已经明确表示未来将会主推 arkUI,因此我个人觉得还是应该把主要学习重心放在 arkUI 上来


arkUI 的布局思路跟 html + css 有很大不同。


html + css 采用的是结构样式分离的方式,再通过 class/id 关联起来。因此,html + css 的布局写起来会简单很多,我们只需要先写结构,然后慢慢补充样式即可


arkUI 并未采用分离思路,而是把样式和结构紧密结合在一起,这样做的好处就是性能更强了,因为底层渲染引擎不用专门写一套逻辑去匹配结构和样式然后重新计算 render-tree,坏处就是...


代码看着有点糟心


比如下面这行代码,表示两段文字


Column() {
Text('一行文字')
.textAlign(TextAlign.Center)
.fontSize(30)
.width('100%')
.backgroundColor('#aabbcc')
Text('二行文字')
.textAlign(TextAlign.Center)
.fontSize(30)
.width('100%')
.backgroundColor('#aabbcc')
}.width('100%')
.height('100%')
.backgroundColor('red')

如果用 html 来表示的话....


<div>
<p>一行文字p>
<p>一行文字p>
div>

当然我期望能找到一种方式去支持属性的继承和复用。目前简单找了一下没找到,希望有吧 ~


由于 html 中 div 足以应付一切,因此许多前端开发者会在思考过程中忽视或者弱化容器组件的存在,反而 arkUI 的学习偏偏要从容器组件开始理解


我觉得这种思路会对解耦思路有更明确的训练。许多前端开发在布局时不去思考解耦问题,我认为这是一个坏处。


arkUI 的布局思路是:先考虑容器,再考虑子元素,并且要把样式特性结合起来一起思考。而不是只先思考结构,再考虑样式应该怎么写。


例如,上面的 GIF 图中, nav 导航区域是由 4 按钮组成。先考虑容器得是一个横向的布局


然后每一个按钮,包括一个图标和一个文字,他们是纵向的布局,于是结构就应该这样写


Row: 横向布局
Column: 竖向布局
Row() {
Column() { Lottie() Text() }
Column() { Lottie() Text() }
Column() { Lottie() Text() }
Column() { Lottie() Text() }
}

按照这个思路去学习,几个容器组件 Row/Column/FLex/Stack/GridContainer/SideBarContainer ... 很快就能掌握


03


引入 lottie


在引入 lottie 的时候遇到了几个坑。


一个是有一篇最容易找到的文章介绍如何在 arkUI 中引入 lottie,结果这篇文章是错误的。 ~ ~,这篇文章是在官方博客里首发,让我走了不少弯路。


image.png


这里面有两个坑,一个坑是 @ohos/lottie-ohos-ets 的好像库不见了。另外一个坑就是文章里指引我用 npm 下载这个库。但是当我用 npm 下载之后,文件会跑到项目中的 node_modules 目录下,不过如何在 arkUI 的项目中引入 node_modules 中的库,我还没找到方法,应该是要在哪里配置一下


最后在 gitee 的三方仓库里,找到了如下三方库


import lottie from '@ohos/lottie';

这里遇到的一个坑就是我的电脑上的环境变量不知道咋回事被改了,导致 ohpm 没了,找了半天才找到原因,又重新安装 ohpm,然后把环境变量改回来



  1. 到官方文档下载对应的工具包

  2. 把工具包放到你想要放的安装目录,然后解压,进去 ohpm/bin 目录,在该目录下执行 init 脚本开始安装


> init


  1. 然后使用如下指令查看当前文件路径


> pwd

然后执行如下指令


// OHPM_HOME 指的是你自己的安装路径
> export OHPM_HOME=/home/xx/Downloads/ohpm
> export PATH=${OHPM_HOME}/bin:${PATH}


  1. 执行如下指令检查是否安装成功


> ohpm -v

@ohos/lottie


使用如下指令下载 lottie


ohpm install @ohos/lottie

然后在 page 中引入


import lottie from '@ohos/lottie'

在类中通过定义私有变量的方式构建上下文


private mrs: RenderingContextSettings = new RenderingContextSettings(true)
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.mrs)

并且用私有变量保存 lottie 数据路径或者内容


private path: string = 'common/lottie/home.json'

然后在 build 中,结合 Canvas 组件绘制


Canvas(this.ctx).onReady(() => {
lottie.loadAnimation({
container: this.ctx,
renderer: 'canvas',
loop: false,
autoplay: true,
path: this.path
})
})

参考文章:@ohos/lottie


04


hot reload


使用 commond + , 调出配置页面,然后通过如下路径找到配置选中 Perform hot reload


Tools -> Actions on Save -> Perform hot reload

image.png


然后在项目运行的入口处,选择 entry -> edit configrations,弹出如下界面,选中 Hot Reload 的 entry,做好与下图中一致的勾选,点击 apply 按钮之后启动项目即可实现 hot reload


image.png


不过呢,hot reload 在调试样式的时候还能勉强用一用,涉及到代码逻辑的更改,往往没什么用,属实是食之无味,弃之可惜


除此之外,也许 Previewer 更适合开发时使用


image.png


05


沉浸式状态栏


沉浸式状态栏是一款体验良好的 app 必备能力。因此我学会了基础知识之后,第一时间就想要研究一下再 HarmonyOS 中如何达到这个目的。


沉浸式状态栏指的就是下图中位置能够做到与页面标题栏,或者页面背景一致的样式。或者简单来说,可以由我们开发者来控制这一块样式。布局进入全屏模式。


image.png


在我们创建入口 Ability 时,可以在生命周期 onWindowStageCreate 中设置全屏模式


onWindowStageCreate(windowStage: window.WindowStage) {
windowStage.getMainWindow(err, mainWindow: window.Window) {
if (err.code) {
return
}
mainWindow.setWindowLayoutFullScreen(true)
}
}

setWindowLayoutFullScreen 是一个异步函数,因此如果你想要修改状态栏样式的话,可以在它的回调里,通过 setWindowSystemBarProperties 去设置


mainWindow.setWindowLayoutFullScreen(true, (err) => {
if (err) { return }
mainWindow.setWindowSystemBarProperties({ statusBarColor: '#FFF' })
})

具体的参数配置,可以在代码中,查看类型声明获悉。


这里有一个巨大的坑,就是在我的开发环境启动的模拟器中 API 9,当你设置了全屏模式之后,布局会发生混乱。真机调试又是正常的。


我刚开始以为是我的代码哪里没搞对,为了解决这个问题花了一个多小时的时间,结果最后才确定是模拟器的布局 bug...


真机调试


真机调试的设置方式主要跟其他 app 开发都一样,把手机设置为开发者模式即可。不过你需要通过如下方式,配置好一个应用签名才可以。因此你首先需要注册成为华为开发者


File -> Project Structure -> Signing Configs -> Sign in

跟着指引在后台创建项目,然后再回到开发者工具这个页面自动生成签名即可


image.png


真机调试有一个巨大无比的坑,那就是 API 9 创建的项目,在老版本的麒麟芯片上巨卡无比。连基本的点击都无法响应。


这就要了命了。如果连真机调试都做不到,那还拥抱个啥啊?


研究了很久,找到了几个解决这个问题的方法


1、换新机,只要你的手机不是华为被制裁之前的麒麟芯片,都不会存在这个问题


2、创建项目时,选择 API 8


3、在开发者选项的配置中,选择 显示面(surface)更新,虽然不卡了,不过闪瞎了我的狗眼


4、等明年 HarmonyOS next 出来之后再来学,官方说,API 10 将会解决这个问题


上面的解决办法或多或少都有一些坑点。我选择了一种方式可以很好的解决这个问题


那就是:投屏


如果你有一台华为电脑,这个投屏会非常简单。不过由于我是 mac M1,因此我选择的投屏方案是 scrcpy


使用 brew 安装


> brew install scrcpy

然后继续安装


> brew install android-platform-tools

启动


> scrcpy

启动之前确保只有一台手机已经通过 USB 连接到电脑,并允许电脑调试手机就可以成功投屏。在投屏中操作手机,就变得非常流畅了


不过目前我通过这种方式投屏之后,运行起来的项目经常闪退,具体是什么原因我还没找到,只能先忍了


总之就是坑是一个接一个 ~ ~


06


总结


一整天的学习,整体感受下就如标题说的那样:拥抱华为,困难重重。 还好我电脑性能强悍,要是内存少一点,又是虚拟机,又是投屏的,搞不好内存都不够用,可以预想,其他开发者还会遇到比我更多的坑 ~ ~


image.png


个人感觉华为相关的官方文档写得不是很友好,比较混乱,找资料很困难。反而在官方上把一堆莫名其妙的教学视频放在了最重要的位置,我不是很明白,到底是官方文档,还是视频教程网站 ~ ~


官方文档里还涉及了 FA mode 到 Stage mode 的更新,因此通过搜索引擎经常找到 FA mode 的相关内容,可是 FA mode 又是被弃用的,因为这个问题也给我的学习带来了不少的麻烦。由于遇到的坑太多了,以致于我到现在尝试点什么新东西都紧张兮兮的,生怕又是坑


总的来说,自学困难重重,扛得住坑的,才能成为最后的赢家,红利不是那么好吃的


作者:这波能反杀
来源:juejin.cn/post/7309734518586523657
收起阅读 »

WebSocket 从入门到入土

web
前言因新部门需求有一个后台管理需要一个右上角的实时的消息提醒功能,第一时间想到的就是使用WebSocket建立实时通信了,之前没整过,于是只能学习了。和原部门相比现在太忙了,快乐的日子一去不复返了。经典的加量不加薪啊!!!一.WebSocket 基本概念1.W...
继续阅读 »

前言

因新部门需求有一个后台管理需要一个右上角的实时的消息提醒功能,第一时间想到的就是使用WebSocket建立实时通信了,之前没整过,于是只能学习了。和原部门相比现在太忙了,快乐的日子一去不复返了。经典的加量不加薪啊!!!

一.WebSocket 基本概念

1.WebSocket是什么?

WebSocket 是基于 TCP 的一种新的应用层网络协议。它提供了一个全双工的通道,允许服务器和客户端之间实时双向通信。因此,在 WebSocket 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输,客户端和服务器之间的数据交换变得更加简单。 WebSocket

2.与 HTTP 协议的区别

与 HTTP 协议相比,WebSocket 具有以下优点:

  1. 更高的实时性能:WebSocket 允许服务器和客户端之间实时双向通信,从而提高了实时通信场景中的性能。
  2. 更少的网络开销:HTTP 请求和响应之间需要额外的数据传输,而 WebSocket 通过在同一个连接上双向通信,减少了网络开销。
  3. 更灵活的通信方式:HTTP 请求和响应通常是一一对应的,而 WebSocket 允许服务器和客户端之间以多种方式进行通信,例如消息 Push、事件推送等。
  4. 更简洁的 API:WebSocket 提供了简洁的 API,使得客户端开发人员可以更轻松地进行实时通信。

当然肯定有缺点的:

  1. 不支持无连接: WebSocket 是一种持久化的协议,这意味着连接不会在一次请求之后立即断开。这是有利的,因为它消除了建立连接的开销,但是也可能导致一些资源泄漏的问题。
  2. 不支持广泛: WebSocket 是 HTML5 中的一种标准协议,虽然现代浏览器都支持,但是一些旧的浏览器可能不支持 WebSocket。
  3. 需要特殊的服务器支持: WebSocket 需要服务端支持,只有特定的服务器才能够实现 WebSocket 协议。这可能会增加系统的复杂性和部署的难度。
  4. 数据流不兼容: WebSocket 的数据流格式与 HTTP 不同,这意味着在不同的网络环境下,WebSocket 的表现可能会有所不同。

3.WebSocket工作原理

1. 握手阶段

WebSocket在建立连接时需要进行握手阶段。握手阶段包括以下几个步骤:

  • 客户端向服务端发送请求,请求建立WebSocket连接。请求中包含一个Sec-WebSocket-Key参数,用于生成WebSocket的随机密钥。
  • 服务端接收到请求后,生成一个随机密钥,并使用随机密钥生成一个新的Sec-WebSocket-Accept参数。
  • 客户端接收到服务端发送的新的Sec-WebSocket-Accept参数后,使用原来的随机密钥和新的Sec-WebSocket-Accept参数共同生成一个新的Sec-WebSocket-Key参数,用于加密数据传输。
  • 客户端将新的Sec-WebSocket-Key参数发送给服务端,服务端接收到后,使用该参数加密数据传输。

2. 数据传输阶段

建立连接后,客户端和服务端就可以通过WebSocket进行实时双向通信。数据传输阶段包括以下几个步骤:

  • 客户端向服务端发送数据,服务端收到数据后将其转发给其他客户端。
  • 服务端向客户端发送数据,客户端收到数据后进行处理。

双方如何进行相互传输数据的 具体的数据格式是怎么样的呢?WebSocket 的每条消息可能会被切分成多个数据帧(最小单位)。发送端会将消息切割成多个帧发送给接收端,接收端接收消息帧,并将关联的帧重新组装成完整的消息。

发送方 -> 接收方:ping。

接收方 -> 发送方:pong。

ping 、pong 的操作,对应的是 WebSocket 的两个控制帧

3. 关闭阶段

当不再需要WebSocket连接时,需要进行关闭阶段。关闭阶段包括以下几个步骤:

  • 客户端向服务端发送关闭请求,请求中包含一个WebSocket的随机密钥。
  • 服务端接收到关闭请求后,向客户端发送关闭响应,关闭响应中包含服务端生成的随机密钥。
  • 客户端收到关闭响应后,关闭WebSocket连接。

总的来说,WebSocket通过握手阶段、数据传输阶段和关闭阶段实现了服务器和客户端之间的实时双向通信。

二.WebSocket 数据帧结构和控制帧结构。

1. 数据帧结构

WebSocket 数据帧主要包括两个部分:帧头和有效载荷。以下是 WebSocket 数据帧结构的简要介绍:

  • 帧头:帧头包括四个部分:fin、rsv1、rsv2、rsv3、opcode、masked 和 payload_length。其中,fin 表示数据帧的结束标志,rsv1、rsv2、rsv3 表示保留字段,opcode 表示数据帧的类型,masked 表示是否进行掩码处理,payload_length 表示有效载荷的长度。
  • 有效载荷:有效载荷是数据帧中实际的数据部分,它由客户端和服务端进行数据传输。

2. 控制帧结构

除了数据帧之外,WebSocket 协议还包括一些控制帧,主要包括 Ping、Pong 和 Close 帧。以下是 WebSocket 控制帧结构的简要介绍:

  • Ping 帧:Ping 帧用于测试客户端和服务端之间的连接状态,客户端向服务端发送 Ping 帧,服务端收到后需要向客户端发送 Pong 帧进行响应。
  • Pong 帧:Pong 帧用于响应客户端的 Ping 帧,它用于测试客户端和服务端之间的连接状态。
  • Close 帧:Close 帧用于关闭客户端和服务端之间的连接,它包括四个部分:fin、rsv1、rsv2、rsv3、opcode、masked 和 payload_length。其中,opcode 的值为 8,表示 Close 帧。

三. JavaScript 中 WebSocket 对象的属性和方法,以及如何创建和连接 WebSocket。

WebSocket 对象的属性和方法:

  1. WebSocket 对象:WebSocket 对象表示一个新的 WebSocket 连接。
  2. WebSocket.onopen 事件处理程序:当 WebSocket 连接打开时触发。
  3. WebSocket.onmessage 事件处理程序:当接收到来自 WebSocket 的消息时触发。
  4. WebSocket.onerror 事件处理程序:当 WebSocket 发生错误时触发。
  5. WebSocket.onclose 事件处理程序:当 WebSocket 连接关闭时触发。
  6. WebSocket.send 方法:向 WebSocket 发送数据。
  7. WebSocket.close 方法:关闭 WebSocket 连接。

创建和连接 WebSocket:

  1. 创建 WebSocket 对象:
var socket = new WebSocket('ws://example.com');

其中,ws://example.com 是 WebSocket 的 URL,表示要连接的服务器。

  1. 连接 WebSocket:

使用 WebSocket.onopen 事件处理程序检查 WebSocket 是否成功连接。

socket.onopen = function() {
console.log('WebSocket connected');
};
  1. 接收来自 WebSocket 的消息:

使用 WebSocket.onmessage 事件处理程序接收来自 WebSocket 的消息。

socket.onmessage = function(event) {
console.log('WebSocket message:', event.data);
};
  1. 向 WebSocket 发送消息:

使用 WebSocket.send 方法向 WebSocket 发送消息。

socket.send('Hello, WebSocket!');
  1. 关闭 WebSocket:

当需要关闭 WebSocket 时,使用 WebSocket.close 方法。

socket.close();

注意:在 WebSocket 连接成功打开和关闭时,会分别触发 WebSocket.onopen 和 WebSocket.onclose 事件。在接收到来自 WebSocket 的消息时,会触发 WebSocket.onmessage 事件。当 WebSocket 发生错误时,会触发 WebSocket.onerror 事件。

四.webSocket简单示例

以下是一个简单的 WebSocket 编程示例,通过 WebSocket 向服务器发送数据,并接收服务器返回的数据:

  1. 首先,创建一个 HTML 文件,添加一个按钮和一个用于显示消息的文本框:
html>
<html>
<head>
<meta charset="UTF-8">
<title>WebSocket 示例title>
head>
<body>
<button id="sendBtn">发送消息button>
<textarea id="messageBox" readonly>textarea>
<script src="main.js">script>
body>
html>
  1. 接下来,创建一个 JavaScript 文件(例如 main.js),并在其中编写以下代码:
// 获取按钮和文本框元素
const sendBtn = document.getElementById('sendBtn');
const messageBox = document.getElementById('messageBox');

// 创建 WebSocket 对象
const socket = new WebSocket('ws://echo.websocket.org'); // 使用一个 WebSocket 服务器进行测试

// 设置 WebSocket 连接打开时的回调函数
socket.onopen = function() {
console.log('WebSocket 连接已打开');
};

// 设置 WebSocket 接收到消息时的回调函数
socket.onmessage = function(event) {
console.log('WebSocket 接收到消息:', event.data);
messageBox.value += event.data + '\n';
};

// 设置 WebSocket 发生错误时的回调函数
socket.onerror = function() {
console.log('WebSocket 发生错误');
};

// 设置 WebSocket 连接关闭时的回调函数
socket.onclose = function() {
console.log('WebSocket 连接已关闭');
};

// 点击按钮时发送消息
sendBtn.onclick = function() {
const message = 'Hello, WebSocket!';
socket.send(message);
messageBox.value += '发送消息: ' + message + '\n';
};

五.webSocket应用场景

  1. 实时通信:WebSocket 非常适合实时通信场景,例如聊天室、在线游戏、实时数据传输等。通过 WebSocket,客户端和服务器之间可以实时通信,无需依赖轮询,从而提高通信效率和减少网络延迟。
  2. 监控数据传输:WebSocket 可以在监控系统中实现实时数据传输,例如通过 WebSocket,客户端可以实时接收和处理监控数据,而无需等待轮询数据。
  3. 自动化控制:WebSocket 可以在自动化系统中实现远程控制,例如通过 WebSocket,客户端可以远程控制设备或系统,而无需直接操作。
  4. 数据分析:WebSocket 可以在数据分析场景中实现实时数据传输和处理,例如通过 WebSocket,客户端可以实时接收和处理数据,而无需等待数据存储和分析。
  5. 人工智能:WebSocket 可以在人工智能场景中实现实时数据传输和处理,例如通过 WebSocket,客户端可以实时接收和处理数据,而无需等待数据处理和分析。

六.WebSocket 错误处理

WebSocket 的错误处理

  1. WebSocket is not supported:当浏览器不支持 WebSocket 时,会出现此错误。解决方法是在浏览器兼容性列表中检查是否支持 WebSocket。
  2. WebSocket connection closed:当 WebSocket 连接被关闭时,会出现此错误。解决方法是在 WebSocket.onclose 事件处理程序中进行错误处理。
  3. WebSocket error:当 WebSocket 发生错误时,会出现此错误。解决方法是在 WebSocket.onerror 事件处理程序中进行错误处理。
  4. WebSocket timeout:当 WebSocket 连接超时时,会出现此错误。解决方法是在 WebSocket.ontimeout 事件处理程序中进行错误处理。
  5. WebSocket handshake error:当 WebSocket 握手失败时,会出现此错误。解决方法是在 WebSocket.onerror 事件处理程序中进行错误处理。
  6. WebSocket closed by server:当 WebSocket 连接被服务器关闭时,会出现此错误。解决方法是在 WebSocket.onclose 事件处理程序中进行错误处理。
  7. WebSocket closed by protocol:当 WebSocket 连接被协议错误关闭时,会出现此错误。解决方法是在 WebSocket.onclose 事件处理程序中进行错误处理。
  8. WebSocket closed by network:当 WebSocket 连接被网络错误关闭时,会出现此错误。解决方法是在 WebSocket.onclose 事件处理程序中进行错误处理。
  9. WebSocket closed by server:当 WebSocket 连接被服务器错误关闭时,会出现此错误。解决方法是在 WebSocket.onclose 事件处理程序中进行错误处理。

通过为 WebSocket 对象的 oncloseonerror 和 ontimeout 事件添加处理程序,可以及时捕获和处理 WebSocket 错误,从而确保程序的稳定性和可靠性。

七.利用单例模式创建完整的wesocket连接

class webSocketClass {
constructor(thatVue) {
this.lockReconnect = false;
this.localUrl = process.env.NODE_ENV === 'production' ? 你的websocket生产地址' : '测试地址';
this.globalCallback = null;
this.userClose = false;
this.createWebSocket();
this.webSocketState = false
this.thatVue = thatVue
}

createWebSocket() {
let that = this;
// console.log('
开始创建websocket新的实例', new Date().toLocaleString())
if( typeof(WebSocket) != "function" ) {
alert("您的浏览器不支持Websocket通信协议,请更换浏览器为Chrome或者Firefox再次使用!")
}
try {
that.ws = new WebSocket(that.localUrl);
that.initEventHandle();
that.startHeartBeat()
} catch (e) {
that.reconnect();
}
}

//初始化
initEventHandle() {
let that = this;
// //连接成功建立后响应
that.ws.onopen = function() {
console.log("连接成功");
};
//连接关闭后响应
that.ws.onclose = function() {
// console.log('
websocket连接断开', new Date().toLocaleString())
if (!that.userClose) {
that.reconnect(); //重连
}
};
that.ws.onerror = function() {
// console.log('
websocket连接发生错误', new Date().toLocaleString())
if (!that.userClose) {
that.reconnect(); //重连
}
};
that.ws.onmessage = function(event) {
that.getWebSocketMsg(that.globalCallback);
// console.log('
socket server return '+ event.data);
};
}
startHeartBeat () {
// console.log('
心跳开始建立', new Date().toLocaleString())
setTimeout(() => {
let params = {
request: '
ping',
}
this.webSocketSendMsg(JSON.stringify(params))
this.waitingServer()
}, 30000)
}
//延时等待服务端响应,通过webSocketState判断是否连线成功
waitingServer () {
this.webSocketState = false//在线状态
setTimeout(() => {
if(this.webSocketState) {
this.startHeartBeat()
return
}
// console.log('
心跳无响应,已断线', new Date().toLocaleString())
try {
this.closeSocket()
} catch(e) {
console.log('
连接已关闭,无需关闭', new Date().toLocaleString())
}
this.reconnect()
//重连操作
}, 5000)
}
reconnect() {
let that = this;
if (that.lockReconnect) return;
that.lockReconnect = true; //没连接上会一直重连,设置延迟避免请求过多
setTimeout(function() {
that.createWebSocket();
that.thatVue.openSuccess(that) //重连之后做一些事情
that.thatVue.getSocketMsg(that)
that.lockReconnect = false;
}, 15000);
}

webSocketSendMsg(msg) {
this.ws.send(msg);
}

getWebSocketMsg(callback) {
this.ws.onmessage = ev => {
callback && callback(ev);
};
}
onopenSuccess(callback) {
this.ws.onopen = () => {
// console.log("连接成功", new Date().toLocaleString())
callback && callback()
}
}
closeSocket() {
let that = this;
if (that.ws) {
that.userClose = true;
that.ws.close();
}
}
}
export default webSocketClass;

作者:耀耀切克闹灬
来源:juejin.cn/post/7309687967063818292

收起阅读 »

按钮点击的水波效果

web
实现思路:水波效果可以用一个 span 来模拟,动画效果是缩放从 0 到大于零的值(比如 4),同时透明度从 1 到 0。点击 button 后,我们把这个 span 添加到 button 里即可。 HTML 结构比较简单,我们用 div 来表示 button...
继续阅读 »

image


实现思路:水波效果可以用一个 span 来模拟,动画效果是缩放从 0 到大于零的值(比如 4),同时透明度从 1 到 0。点击 button 后,我们把这个 span 添加到 button 里即可。


HTML


结构比较简单,我们用 div 来表示 button:


<div class="button">
Click Me
</div>

CSS


给 div 加点样式,让它看起来像个 button:


image


.button {
margin-left: 100px;
position: relative;
width: 100px;
padding: 8px 10px;
border: 1px solid lightgray;
border-radius: 5px;
cursor: pointer;
overflow: hidden;
user-select: none;
}

定义水波样式,默认 scale 为 0:


.ripple {
position: absolute;
border-radius: 50%;
transform: scale(0);
animation: ripple 600ms linear;
background-color: rgba(30, 184, 245, 0.7);
}

水波动画:


@keyframes ripple {
to {
transform: scale(4);
opacity: 0;
}
}

javascript


点击按钮时,生成水波效果,先把结构加上:


function playRipple(event) {
// TODO:生成水波效果
}

// 为 button 添加点击事件
document
.querySelector('.button')
.addEventListener('click', event => {
playRipple(event);
})

我们看一下水波如何生成,为了方便理解,可以结合图来看,其中黑点表示鼠标点击的位置,蓝色的圆是点击后水波默认大小的圆,** ?**就表示要计算的 circle.style.left:


image


function playRipple(event) {
const button = event.currentTarget;
const buttonRect = button.getBoundingClientRect();

const circle = document.createElement("span");
// 圆的直径
const diameter = Math.max(button.clientWidth, button.clientHeight);
// 圆的半径
const radius = diameter / 2;

// 计算 ripple 的位置
circle.style.width = circle.style.height = `${diameter}px`;
circle.style.left = `${event.clientX - (buttonRect.left + radius)}px`;
circle.style.top = `${event.clientY - (buttonRect.top + radius)}px`;
// 添加 ripple 样式
circle.classList.add("ripple");
// 移除已存在的 ripple
removeRipple(button);
// 将 ripple 添加到 button 上
button.appendChild(circle);
}

// 移除 ripple
function removeRipple(button) {
const ripple = button.querySelector(".ripple");

if (ripple) {
ripple.remove();
}
}

看下效果:
image


总结


又水了一篇文章😂,如果对你有启发,欢迎点赞、评论。


参考


css-tricks.com/how-to-recr…


作者:探险家火焱
来源:juejin.cn/post/7224063449617383485
收起阅读 »

如何做好前端项目组组长

前言 唠嗑 俺自己弄自己写博客是为了记录自己的脚步,走成功就留下近道,方便其他兴趣者抄近道提升;走失败了就留下血迹(魂类游戏の特色),方便其他人看看我是这么寄的。 我曾经给自己规定,一个月最少留下一片技术性的或者经验性值的博客,方便自己自我总结。结果十月底后,...
继续阅读 »



前言 唠嗑


俺自己弄自己写博客是为了记录自己的脚步,走成功就留下近道,方便其他兴趣者抄近道提升;走失败了就留下血迹(魂类游戏の特色),方便其他人看看我是这么寄的。


我曾经给自己规定,一个月最少留下一片技术性的或者经验性值的博客,方便自己自我总结。结果十月底后,咱忙得不可开交~~,都没时间水群~~,写博客的规划就一拖再拖,最后都十二月了,emmmm,不能再拖了。今天就写完。
6号的今儿,加个班,努力写完吧


一、个人方面


角色转变


以前是组员,会追求极致的代码逻辑或写出最优性能的算法。但现在你是组长了,你得学会接纳不完美,比如每次mr的时候不能太过计较组员代码性能或者代码逻辑(个人经验,可能不用于大厂)。


其二,在团队中,平常心非常重要。无论是组长还是组员,大家都是打工人,没有高人一等的态度。


学习方向


学习方向要从原来的学得深改为看得广。这样方便给组员提供解决问题思路或者功能实现方案。



当组员的时候我会专研得很深,甚至会深入专研vue2底层代码甚至去自己手写一个自己的vue2 demo。


当组长后,我很少专研底层代码或者底层架构了,大多都是看其他作者如何解决没见过的业务的问题,亦或者是使用某个依赖出现的模块出现问题以及避免方法。积累新模块使用以及新的业务解决方案。



a620e57a0ae162f0e4aa34bb1d4d8ecb5ce17e72eede4e8d024a8d68d3859602.png


二、组内安排


统筹和分配


产品给的需求、后端配合人员、bug转交等等,这些都归属于任务类型,要记得如何分配任务以及实时跟踪进度(按天跟踪最好)。


Weixin Screenshot_20231130222156.png


分配任务时候请注意:



  • 产品需求方面一定要记住划分模块,再记住模块对应的组员,方便后续QA多轮轮测试时候bug指向对应的组员,亦或者编写《XXXX技术规格书》时将其划分给对应组员

  • 对每个任务划分好难度,根据组员能力差异给到最优解


学会做自己组的产品(建议)


注意,这个只是建议,不是必须!


前端组长也要会当产品?是,也不是。比如说在项目立项前期,有些东西必须前端自己规划好,如框架搭建指南、二次封装的公共组件(如搜索表单,公共列表,echarts的各类型图表),这个时候就需要你自己做自己的产品经理,自己写相关的需求文档或者技术规格文档。


可以不写么?如果你能让组员明白你的规划或者明白你的思路,你可以不用写,只需要交代就行。否则还是建议写一下。


提供一定的情绪价值


这个只可意会不可言传的,需要自己把握好度,平衡好自己的情绪以及组员的情绪。


7176b207911683222628d044b6fdf104cccacda7bc9c0f98646bc80d0d30a894.png


三、项目组角色


前端组长还是前端开发,所以说本职前端工作要有,还得担当一些其他任务。


做好项目组副手


虽然是前端组长,虽然入手的是js、ts、node,但你还是要了解一些其他与前端开发或者与项目组相关的东西,这里是我经历过的一些事儿,可以借鉴一下:



  • 学一些基础的PS平面设计概念,便于和UI统一意见

  • linux 虚机,需要本地VMware或者公司服务器

  • CI/CD 流程

  • docker 配置文件、基础指令

  • nginx 设置反向代理

  • shell 脚本编写

  • 手写case,方便开发自测

  • 了解公司发布流程,准备好补充缺失的文件

  • 学会公司文件管理方式,如SVN、企业级Visual Studio


与UI配合


以下是我根据个人经验总结的一些建议:



  • 组长层面

    • 确认公共组件统一样式

      • 公共列表样式

      • 搜索表单样式

      • Dialog/Modal对话框 宽度和最大高度以及高度是否固定

      • Description 统一样式

      • 滚动条样式(ChromeFirefox)

      • Button/Tag 边框弧度

      • Layout框架样式,如菜单padding距离、

      • 文本/内容超出部分处理方案

      • 图片使用格式 png/svg

      • Notification通知框出现位置、按钮、存在时间

      • 统一图表获取方式,如提供手动图表库或者使用三方图表库



    • 参与设计图评审

      • 创建编辑操作时注意其标注必填项以及对应选项框是否一致

      • 首页/门户页面/欢迎页面/列表 处理文本过长,内容过多的方案

      • 交互/大屏 动画效果确认





  • 开发层面

    • 学会自己切图,如使用国内的'蓝湖','即使设计',亦或者是adobe的XD

    • 让UI帮忙修图时候尽量让UI用上SVG图片

      • SVG是矢量图,可以提供图层信息,方便UI调整



    • 如果涉及动画效果之类的(如告警闪烁效果),可以给UI写个可调整页面,让UI自己寻找合适的感觉




与产品配合


以下是我根据个人经验总结的一些建议:



  • 组长层面:

    • 需求评审时

      • 建议记录每个具体的模块以及其大概功能点(比如创建,编辑,删除这类操作性的,如果详情里也有的话同步记录),方便后续分配任务以及自测时写case

      • 这个算是空话,但还是记下来吧:仔细听产品报告,确认功能可行性



    • 帮产品搭建原型图服务,方便UI和自己组员查阅



  • 开发层面

    • 功能时间过于耗时并且不是主要功能时,及时告诉产品,协商解决方案

    • 集成系统并且无法从三方系统/三方厂商获取数据或者是,必须及时告诉产品




与后端配合


唯一一个跟咱一样是开发的,懂逻辑的童鞋们~~,感觉我可以偷个懒不写建议~~,还是要写一下建议:



  • 组长层面:

    • 及时告知后端童鞋配合一起开发的前端童鞋

    • 协助后端更新服务器上的容器,或者帮其完善CI/CD




eed8adb174843fb8e32281a925c8d392955e1ce405eaf0bb132f42fab52e1364.png


尾声


如果不嫌弃,请大佬们在评论区教我做人。


9efa601e7dcfa58e1135bde96bd2a83fb3d3c33acf2bc376272a2c7e749a2740.png


作者:望远镜
来源:juejin.cn/post/7309301549154779171
收起阅读 »

“浏览器切换到其他页面或最小化时,倒计时不准确“问题解析

web
背景 我最近修复了一个倒计时延迟的bug,情况是用户10:00设置了10分钟倒计时,10:06查看时发现倒计时还有8分钟,倒计时出不准确、延迟的情况。 倒计时大概逻辑如下: const leftTime = 600; //单位为秒 const timer = ...
继续阅读 »

背景


我最近修复了一个倒计时延迟的bug,情况是用户10:00设置了10分钟倒计时,10:06查看时发现倒计时还有8分钟,倒计时出不准确、延迟的情况。


倒计时大概逻辑如下:


const leftTime = 600; //单位为秒
const timer = setInterval(() => {
leftTime -= 1;
if(leftTime === 0) {
clearInterval(timer);
}
}, 1000);

通过排查是浏览器的优化策略导致的。


为什么浏览器优化策略会造成定时器不准时?又该怎么解决这个问题?本文会围绕这两个问题展开说明!


浏览器优化策略对定时器的影响


浏览器的优化策略是指浏览器为了提高性能和节省资源而对特定任务进行的优化。在后台标签页中,浏览器可能会对一些任务进行节流或延迟执行,以减少CPU和电池的消耗。


而定时器setIntervalsetTimeout就是受浏览器优化策略的影响,导致定时器的执行时间间隔被延长。所以在浏览器切换到其他页面或者最小化时,当前页面的定时器可能不会按照预期的时间间隔准时执行。


我们实验一下:设置一个定时器,每500ms在控制台输出当前时间;然后再监听该标签页的visibilitychange事件,当其选项卡的内容变得可见或被隐藏时,会触发该事件。


// 设置定时器
const leftTime = 600; // 倒计时剩余时间
setInterval(() => {
const date = new Date();
leftTime.value -= 1;
console.log(`倒计时剩余秒数:${ leftTime.value }`, `当前时间秒数:${ date.getSeconds() }`);
}, 1000);
// 通过监听 visibilitychange 事件来判别该页面是否可见
document.addEventListener('visibilitychange', function () {
if(document.hidden) {
console.log('页面不可见')
}
})

执行结果如下:


image.png


我们观察执行结果会发现,在标签页处于不可见状态后,setInterval从1000ms的时间间隔延长成了2000ms。


由此可见,当浏览器切换其他页面或者最小化时,倒计时的误差就出现了,setInterval定时器也不会在1000ms后减去1。对于时间较长的倒计时来说,误差会更大。


解决思路


既然浏览器的定时器有问题,那我们就不依赖定时器去计算剩余时间。


我们可以在用户配置倒计时后,立即计算出结束时间并保存,随后通过结束时间减去本地时间就得出了剩余时间,而且不会受定时器延迟的影响。将最上面提及到的倒计时伪代码修改如下:


// ......
const leftTime = 600 * 1000
const endTime = Date.now() + leftTime; // 倒计时结束时间
setInterval(() => {
const date = new Date();
leftTime = Math.round((endTime - Date.now()) / 1000);
console.log(`倒计时剩余秒数:${ leftTime }`, `当前时间秒数:${ date.getSeconds() }`);
if(leftTime <= 0) {
clearInterval(timer);
}
}, 1000)

根据以上代码进行计算,即使标签页不处于可见状态,setInterval延迟执行,对leftTime也没有影响。
执行结果如下(标签页处于不可见状态时):
image.png


题外话


用 setTimeout 实现 setInterval


实现思路是setTimeout的递归调用。以上面的举例代码为例作修改:


const leftTime = 600 * 1000;
const endTime = Date.now() + leftTime; // 倒计时结束时间
function setTimer() {
leftTime = Math.round((endTime - Date.now()) / 1000);
if ( leftTime <= 0 ) {
endTime = 0;
leftTime = 0;
} else {
setTimeout(setTimer, 1000);
}
}

本次分享就到这,希望可以帮助到有同样困扰的小伙伴哦~


作者:Swance
来源:juejin.cn/post/7309693162369171507
收起阅读 »

初中都没念完的我,是怎么从IT这行坚持下去的...

大家好,我是一名二线(伪三线,毕竟连续两年二线城市了)的程序员。 现阶段状态在职,28岁,工作了10年左右,码农从事了5年左右,现薪资9k左右。如文章标题所说,初二辍学,第一学历中专,自己报的成人大专。 在掘金也看了不少经历性质的文章,大多都是很多大牛的文章,...
继续阅读 »

大家好,我是一名二线(伪三线,毕竟连续两年二线城市了)的程序员。


现阶段状态在职,28岁,工作了10年左右,码农从事了5年左右,现薪资9k左右。如文章标题所说,初二辍学,第一学历中专,自己报的成人大专。


在掘金也看了不少经历性质的文章,大多都是很多大牛的文章,在大城市的焦虑,在大厂的烦恼,所以今天换换口味,看一看我这个没有学历的二线的程序员的经历。


1.jpg


1.辍学


我是在初二的时候辍学不上的,原因很简单,太二笔了。


现在想来当时的我非常的der,刚从村里的小学出来上中学之后(我还是年级第7名进中学,殊不知这就是我这辈子最好的成绩了),认为别人欺负我我就一定要还回来,完全不知道那是别人的地盘,嚣张的一批,不出意外就被锤了,但是当时个人武力还是很充沛的,按着一个往地上锤,1V7的战绩也算可以了。自此之后,我就开始走上了不良的道路,抽烟喝酒打架,直到中专毕业那天。



我清楚的记得我推着电车望着天,心里只想着一个问题,我毕业了,要工作了,我除了打游戏还会什么呢,我要拿什么生存呢...



这是当时我心里真实的想法,我好像就在这一刻、这一瞬间长大了。


2.jpg


2.深圳之旅


因为我特别喜欢玩游戏,而且家里电脑总是出问题,所以我就来到了我们这当地的一个电脑城打工,打了半年工左右想学习一下真正的维修技术,也就是芯片级维修,毅然决然踏上了深圳的路。


在深圳有一家机构叫做迅维的机构,还算是在业内比较出名的这么一个机构,学习主板显卡的维修,学习电路知识,学习手机维修的技术。现在的我想想当时也不太明白我怎么敢自己一个人就往深圳冲,家里人怎么拦着我都没用,当时我就好像着了魔一样必须要去...


不过在深圳的生活真的很不错,那一年的时光仍旧是我现在非常怀念的,早晨有便宜好吃的肠粉、米粉、甜包,中午有猪脚饭、汤饭、叉烧饭,晚上偶尔还会吃一顿火锅,来自五湖四海的朋友也是非常的友好,教会了我很多东西,生活非常的不错。


3.jpg


3.回家开店


为什么说我工作了10年左右呢,因为我清楚记得我18岁那年在本地开了一个小店,一个电脑手机维修的小店。现在想想我当时也是非常的二笔,以下列举几个事件:



  1. 修了一个显示器因为没接地线烧了,还跟人家顾客吵了一架。

  2. 修苹果手机翘芯片主板线都翘出来了,赔了一块。

  3. 自己说过要给人家上门保修,也忘了,人家一打电话还怼了一顿。

  4. 因为打游戏不接活儿。


以上这几种情况比比皆是,哪怕我当时这么二笔也是赚了一些钱,还是可以维持的,唯一让我毅然决然转行的就是店被偷了,大概损失了顾客机器、我的机器、图纸、二手电脑等一系列的商品,共计7万元左右,至今仍没找回!


4.jpg


4.迷茫


接下来这三年就是迷茫的几年了,第一件事就是报成人大专,主要从事的行业就杂乱无章了,跟我爸跑过车,当过网吧网管,超市里的理货员,但是这些都不是很满意,也是从这时候开始接触了C和C++开始正式踏入自学编程的路,直到有一次在招聘信息里看到java,于是在b站开始自学java,当时学的时候jdk还是1.6,学习资料也比较古老,但是好歹是入了门了。


5.jpg


5.入职


在入门以后自我感觉非常良好,去应聘了一个外包公司,当时那个经理就问了我一句话,会SSM吗,我说会,于是我就这么入职了,现在想想还是非常幸运的。


当时的我连SSM都用不明白,就懂一些java基础,会一些线程知识,前端更是一窍不通,在外包公司这两年也是感谢前辈带我做一些项目,当时自己也是非常争气,不懂就学,回去百度、b站、csdn各种网站开始学习,前端学习了H5、JS、CSS还有一个经典前端框架,贤心的Layui。


干的这两年我除了学习态度非常认真,工作还是非常不在意,工作两年从来没有任何一个月满勤过,拖延症严重,出现问题从来就是逃避问题,职场的知识是一点也不懂,当时的领导也很包容我,老板都主持了我的婚礼哈哈哈。但是后来我也为我的嚣张买了单,怀着侥幸心理喝了酒开车,这一次事情真真正正的打醒了我,我以后不能这样了...


6.jpg


6.第二家公司


在第二家公司我的态度就变了很多很多 当时已经25岁了,开始真真正正是一个大人了,遵纪守法,为了父母和家人考虑,生活方面也慢慢的好了起来(在刚结婚两年和老婆经常吵架,从这时候开始到现在没有吵过任何架了就),生活非常和睦。工作方面也是从来不迟到早退,听领导的安排,认真工作,认真学习,认识了很多同行,也得到了一些人的认可,从那开始才开始学习springboot、mq、redis、ES一些中间件,学习了很多知识,线程知识、堆栈、微服务等一系列的知识,也算是能独当一面了。但好景不长,当时我的薪资已经到13K左右了,也是因为我们部门的薪资成本、服务器成本太大,入不敷出,公司决定代理大厂的产品而不是自研了,所以当时一个部门就这么毕业了...


7.png


7.现阶段公司


再一次找工作就希望去一些自研的大公司去做事情了,但是也是碍于学历,一直没有合适的,可以说是人厌狗嫌,好的公司看不上我,小公司我又不想去,直到在面试现在公司的时候聊得非常的好,也是给我个机会,说走个特批,让我降薪入职,大概每个月平均薪资10K左右(年终奖是大头),我也是本着这个公司非常的大也就来了,工作至今。


8.jpg


总结



  1. 任何时候想改变都不晚,改变不了别人改变自己。

  2. 面对问题绝对不能逃避,逃避没有任何用,只有面对才能更好的继续下去。

  3. 不要忘了自己为什么踏入这行,因为我想做游戏。

  4. 解决问题不要为了解决而解决,一定要从头学到尾,要不然以后出现并发问题无从下手。

  5. 任何事情都要合规合法。

  6. 工作了不要脱产做任何事情,我是因为家里非常支持,我妈至今都难以相信我能走到今天(我认为我大部分是运气好,加上赶上互联网浪潮的尾巴)。

  7. 最重要的,任何事情都没有家人重要,想回家就回家吧,挣钱多少放一边,IT行业找个副业还是非常简单的,多陪陪他们!


作者:妄也
来源:juejin.cn/post/7309645869644480522
收起阅读 »

Java开发者必备:Maven简介及使用方法详解!

今天我们来介绍一个在Java开发中非常重要的工具——Maven。如果你是一名Java开发者,那么你一定不会对Maven感到陌生。但是,对于一些新手来说,可能还不太了解Maven是什么,它有什么作用,以及如何使用它。接下来,就让我们一起来深入了解一下Maven吧...
继续阅读 »

今天我们来介绍一个在Java开发中非常重要的工具——Maven。如果你是一名Java开发者,那么你一定不会对Maven感到陌生。但是,对于一些新手来说,可能还不太了解Maven是什么,它有什么作用,以及如何使用它。接下来,就让我们一起来深入了解一下Maven吧!

一、maven简介

Maven是什么

Maven是一个项目管理工具,它包含了一个项目对象模型 (Project Object Model),一组标准集合,一个项目生命周期(Project Lifecycle),一个依赖管理系统(Dependency Management System),和用来运行定义在生命周期阶段(phase)中插件(plugin)目标(goal)的逻辑。maven是基于Ant 的构建工具,Ant 有的功能Maven 都有,额外添加了其他功能。

Maven提供了一套标准化的项目结构,所有IDE使用Maven构建的项目结构完全一样,所有IDE创建的Maven项目可以通用。

Maven是专门用于管理和构建Java项目的工具,它的主要功能有:

  • 提供了一套标准化的项目结构

  • 提供了一套标准化的构建流程(编译、测试、打包、发布 …)

  • 提供了一套依赖管理机制

Maven作用

  • 项目构建管理:maven提供一套对项目生命周期管理的标准,开发人员、和测试人员统一使用maven进行项目构建。项目生命周期管理:编译、测试、打包、部署、运行。

  • 管理依赖(jar包):maven能够帮我们统一管理项目开发中需要的jar包。

  • 管理插件:maven能够帮我们统一管理项目开发过程中需要的插件。

二、maven仓库

用过maven的同学,都知道maven可以通过pom.xml中的配置,就能够获取到想要的jar包,但是这些jar是在哪里呢?就是我们从哪里获取到的这些jar包?答案就是仓库。

仓库分为:本地仓库、第三方仓库(私服)和中央仓库。
Description

1、本地仓库

本地仓库:计算机中一个文件夹,自己定义是哪个文件夹。Maven会将工程中依赖的构件(Jar包)从远程下载到本机的该目录下进行管理。

maven默认的仓库是$user.home/.m2/repository目录。

本地仓库的位置可以在$MAVEN_HOME/conf/setting.xml文件中修改。

在文件中找到localRepository目录,修改对应内容即可
<localRepository>D:/maven/r2/myrepository</localRepository>

2、中央仓库

中央仓库:网上地址https://repo1.maven.org/maven2/

这个公共仓库是由Maven自己维护,里面有大量的常用类库,并包含了世界上大部分流行的开源项目构件。工程依赖的jar包如果本地仓库没有,默认从中央仓库下载。

由于maven的中央仓库在国外,所以下载速度比较慢,所以需要配置国内的镜像地址。

在配置文件中找到mirror标签,添加以下内容即可。

<mirror>
<id>alimaven</id>
<name>aliyun maven</name>
<url>http://maven.aliyun.com/nexus/content/groups/public/</url>
<mirrorOf>central</mirrorOf>
</mirror>

3、第三方仓库(私服)

第三方仓库,又称为内部中心仓库,也称为私服。

私服:一般是由公司自己设立的,只为本公司内部共享使用。它既可以作为公司内部构件协作和存档,也可作为公用类库镜像缓存,减少在外部访问和下载的频率,公司单独开发的私有jar可放置到私服中。(使用私服为了减少对中央仓库的访问)

注意:连接私服,需要单独配置。如果没有配置私服,默认不使用

三、Maven的坐标

什么是坐标?
Maven中的坐标是资源的唯一标识,使用坐标来定义项目或引入项目中需要的依赖。
Description

Maven坐标的主要组成:

  • groupId:定义当前Maven项目隶属组织名称(通常是域名反写,例如:com.baidu)

  • artifactId:定义当前Maven项目名称(通常是模块名称,例如 order-service、goods-service)

  • version:定义当前项目版本号

你还在苦恼找不到真正免费的编程学习平台吗?可以试试云端源想!课程视频、在线书籍、在线编程、实验场景模拟、一对一咨询……你想要的全部学习资源这里都有,重点是统统免费!点这里即可查看

四、Maven的三套生命周期

什么是生命周期

在Maven出现之前,项目构建的生命周期就已经存在,软件开发人员每天都在对项目进行清理,编译,测试及部署。虽然大家都在不停地做构建工作,但公司和公司间,项目和项目间,往往使用不同的方式做类似的工作。
Description
Maven的生命周期就是为了对所有的构建过程进行抽象和统一。Maven从大量项目和构建工具中学习和反思,然后总结了一套高度完美的,易扩展的生命周期。

这个生命周期包含了项目的清理,初始化,编译,测试,打包,集成测试,验证,部署和站点生成等几乎所有构建步骤。

Maven的生命周期是抽象的,这意味着生命周期本身不做任何实际工作,在Maven的设计中,实际任务(如源代码编译)都交由插件来完成。

Maven的三套生命周期

Maven拥有三套相互独立的生命周期,分别是clean,default和site。

Description

clean生命周期

clean生命周期的目的是清理项目,它包含三个阶段:

  • pre-clean 执行一些清理前需要完成的工作

  • clean 清理上一次构建生成的文件

  • post-clean 执行一些清理后需要完成的工作

default生命周期

default生命周期定义了真正构建项目需要执行的所有步骤,它是所有生命周期中最核心的部分。其中的重要阶段如下:

  • compile :编译项目的源码,一般来说编译的是src/main/java目录下的java文件至项目输出的主classpath目录中

  • test :使用单元测试框架运行测试,测试代码不会被打包或部署

  • package :接收编译好的代码,打包成可以发布的格式,如jar和war

  • install:将包安装到本地仓库,供其他maven项目使用

  • deploy :将最终的包复制到远程仓库,供其他开发人员或maven项目使用

site生命周期

site生命周期的目的是建立和发布项目站点,maven能够基于pom文件所包含的项目信息,自动生成一个友好站点,方便团队交流和发布项目信息。该生命周期中最重要的阶段如下:

  • site :生成项目站点文档

  • Maven生命周期相关命令

  • mvn clean:调用clean生命周期的clean阶段,清理上一次构建项目生成的文件

  • mvn compile :编译src/main/java中的java代码

  • mvn test :编译并运行了test中内容

  • mvn package:将项目打包成可发布的文件,如jar或者war包;

  • mvn install :发布项目到本地仓库

Maven生命周期相关插件

Maven的核心包只有几兆大小,核心包中仅仅定义了抽象的生命周期。生命周期的各个阶段都是由插件完成的,它会在需要的时候下载并使用插件,例如我们在执行mvn compile命令时实际是在调用Maven的compile插件来编译。

我们使用IDEA创建maven项目后,就不需要再手动输入maven的命令来构建maven的生命周期了。IDEA给每个maven构建项目生命周期各个阶段都提供了图形化界面的操作方式。

具体操作如下:

  • 打开Maven视图:依次打开Tool Windows–>Maven Projects

  • 执行命令:双击Lifecycle下的相关命令图标即可执行对应的命令(或者点击运行按钮)

Description

五、maven的版本规范

maven使用如下几个要素来唯一定位某一个jar:

  • Group ID:公司名。公司域名倒着写

  • Artifact ID:项目名

  • Version:版本

发布的项目有一个固定的版本标识来指向该项目的某一个特定的版本。maven在版本管理时候可以使用几个特殊的字符串SNAPSHOT,LATEST ,RELEASE。比如"1.0-SNAPSHOT"。

各个部分的含义和处理逻辑如下说明:

  • SNAPSHOT 正在开发中的项目可以用一个特殊的标识,这种标识给版本加上一个"SNAPSHOT"的标记。

  • LATEST 指某个特定构件的最新发布,这个发布可能是一个发布版,也可能是一个snapshot版,具体看哪个时间最后。

  • RELEASE 指最后一个发布版。

六、maven项目之间的关系

依赖关系

  • 标签把另一个项目的jar引入到当过前项目

  • 自动下载另一个项目所依赖的其他项目

Description

继承关系

  • 父项目是pom类型,子项目jar 或war,如果子项目还是其他项目的父项目,子项目也是pom 类型。

  • 有继承关系后,子项目中出现标签

  • 如果子项目和和与父项目相同,在子项目中可以不配置和父项目pom.xml 中是看不到有哪些子项目,在逻辑上具有父子项目关系。

父项目
<groupId>cn.zanezz.cn</groupId>
<artifactId>demoparent</artifactId>
<packaging>pom</packaging>
<version>1.0-SNAPSHOT</version>
子项目
<parent>
<artifactId>demoparent</artifactId>
<groupId>cn.zanezz.cn</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>war</packaging>
<artifactId>child2</artifactId>

聚合关系

  • 前提是继承关系,父项目会把子项目包含到父项目中。

  • 子项目的类型必须是Maven Module 而不是maven project

  • 新建聚合项目的子项目时,点击父项目右键新建Maven Module

子项目中的pom.xml
<parent>
<artifactId>demoparent</artifactId>
<groupId>cn.zanezz.cn</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
父项目中的pom.xml
<groupId>cn.zanezz.cn</groupId>
<artifactId>demoparent</artifactId>
<packaging>pom</packaging>
<version>1.0-SNAPSHOT</version>
<modules>
<module>child1</module>
<module>child2</module>
</modules>

聚合项目和继承项目区别

  • 在语意上聚合项目父项目和子项目关系性较强;

  • 在语意上单纯继承项目父项目和子项目关系性较弱。

Maven是一个非常强大的工具,它可以帮助我们更好地管理和构建Java项目。如果你是Java开发者,那么你一定不能错过这个工具。希望这篇文章能帮助你更好地理解和使用Maven,祝你在Java开发的道路上越走越远!

收起阅读 »

JS: function前面加!,引发思考🤔

web
简介 我们基本都知道,函数的声明方式有这两种 function msg(){alert('msg');}//声明式定义函数 var msg = function(){alert('msg');}//函数赋值表达式定义函数 但其实还有第三种声明方式,Func...
继续阅读 »

简介


我们基本都知道,函数的声明方式有这两种


function msg(){alert('msg');}//声明式定义函数

var msg = function(){alert('msg');}//函数赋值表达式定义函数

但其实还有第三种声明方式,Function构造函数


var msg = new function(msg) {
alert('msg')
}

等同于


function msg(msg) {
alert('msg')
}

函数的调用方式通常是方法名()

但是,如果我们尝试为一个“定义函数”末尾加上(),解析器是无法理解的。


function msg(){
alert('message');
}();//解析器是无法理解的

定义函数的调用方式应该是 print(); 那为什么将函数体部分用()包裹起来就可以了呢?

原来,使用括号包裹定义函数体,解析器将会以函数表达式的方式去调用定义函数。 也就是说,任何能将函数变成一个函数表达式的作法,都可以使解析器正确的调用定义函数。而 ! 就是其中一个,而 + - || ~ 都有这样的功能。


但是请注意如果用括号包裹函数体,然后立即执行。这种方式只适用一次性调用该函数,涉及到了一个作用域问题,当你想复用该函数的时候,会如下问题:


image.png

可如果你想复用该函数的话,就可按先声明函数,然后再调用函数,在同一个父级作用域下,可以复用该函数,如下:


var msg = function(msg) {}
msg();

关于这个问题,后面会进一步分析


function前面加 ! ?


自执行匿名函数:


在很多js代码中我们常常会看见这样一种写法:


(function( window, undefined ) {
// code
})(window);

这种写法我们称之为自执行匿名函数。正如它的名字一样,它是自己执行自己的,前一个括号是一个匿名函数,后一个括号代表立即执行


前面也提到 + - || ~这些运算符也同样有这样的功能


(function () { /* code */ } ()); 
!function () { /* code */ } ();
~function () { /* code */ } ();
-function () { /* code */ } ();
+function () { /* code */ } ();

image.png

① ( ) 没什么实际意义,不操作返回值


② ! 对返回值的真假取反


③ 对返回值进行按位取反(所有正整数的按位取反是其本身+1的负数,所有负整数的按位取反是其本身+1的绝对值,零的按位取反是 -1。其中,按位取反也会对返回值进行强制转换,将字符串5转化为数字5,然后再按位取反。
false被转化为0,true会被转化为1。
其他非数字或不能转化为数字类型的返回值,统一当做0处理)


④ ~
+、- 是对返回值进行数学运算 ( 可见返回值不是数字类型的时候 +、- 会将返回值进行强制转换,字符串强制转换后为NaN)


先从IIFE开始介绍 (注:这个例子是参考网上


IIFE(Imdiately Invoked Function Expression 立即执行的函数表达式)


function(){
alert('IIFE');
}

把这个代码放在console中执行会报错


image.png


因为这是一个匿名函数,要想让它正常运行就必须给个函数名,然后通过函数名调用。

其实在匿名函数前面加上这些符号后,就把一个函数声明语句变成了一个函数表达式,是表达式就会在script标签中自动执行


所以现在很多对代码压缩和编译后,导出的js文件通常如下:


(function(e,t){"use strict";function n(e){var t=e.length,n=st.type(e);return st.isWindow(e)?!1:1===e.nodeType&&t?!0:"array"===n||"function"!==n&&(0===t||"number"==typeof t&&t>0&&t-1 in e)}function r(e){var t=Tt[e]={};return st.each(e.match(lt)||[],function(e,n){t[n]=!0}),t}function i(e,n,r,i){if(st.acceptData(e)){var o,a,s=st.expando,u="string"==typeof n,l=e.nodeType,c=l?st.cache:e,f=l?e[s]:e[s]&&s;if(f&&c[f]&&(i||c[f].data)||!u||r!==t)return f||(l?e[s]=f=K.pop()||st.guid++:f=s),c[f]||(c[f]={},l||(c[f].toJSON=st.noop)),("object"==typeof n||"function"==typeof n)&&(i?c[f]=st.extend(c[f],n):c[f].data=st.extend(c[f].data,n)),o=c[f],i||(o.data||(o.data={}),o=o.data),r!==t&&(o[st.camelCase(n)]=r),u?(a=o[n],null==a&&(a=o[st.camelCase(n)])):a=o,a}}function o(e,t,n){if(st.acceptData(e)){var r,i,o,a=e.nodeType,u=a?st.cache:e,l=a?e[st.expando]:st.expando;if(u[l]){if(t&&(r=n?u[l]:u[l].data)){st.isArray(t)?t=t.concat(st.map(t,st.camelCase)):t in r?t=[t]:(t=st.camelCase(t),t=t in r?[t]:t.split(" "));for(i=0,o=t.length;o>i;i++)delete r[t[i]];if(!(n?s:st.isEmptyObject)(r))return}(n||(delete u[l].data,s(u[l])))&&(a?st.cleanData([e],!0):st.support.deleteExpando||u!=u.window?delete u[l]:u[l]=null)}}}function a(e,n,r){if(r===t&&1===e.nodeType){var i=""+n.replace(Nt,"-$1").toLowerCase();if(r=e.getAttribute(i),"string"==typeof r){try{r="true"===r?!0:"false"===r?!1:"null"===r?null:+r+""===r?+r:wt.test(r)?st.parseJSON(r):r}catch(o){}st.data(e,n,r)}else r=t}return r}function s(e){var t;for(t in e)if(("data"!==t||!st.isEmptyObject(e[t]))&&"toJSON"!==t)return!1;return!0}function u(){return!0}function l(){return!1}function c(e,t){do 

运算符


也许这里有人会疑惑,运算符为何能将声明式函数,转译成函数表达式,这里就涉及到了一个概念解析器


程序在运行之前需要经过编译或解释的过程,把源程序翻译成为字节码,但是在翻译之前,需要把字符串形式的程序源码解析为语法树或者抽象语法树等数据结构,这就需要用到解析器


那么什么是解析器?


所谓解析器(Parser),一般是指把某种格式的文本(字符串)转换成某种数据结构的过程。最常见的解析器(Parser),是把程序文本转换成编译器内部的一种叫做抽象语法树(AST)的数据结构,此时也叫做语法分析器(Parser)。也有一些简单的解析器(Parser),用于处理CSV、JSON,XML之类的格式


JS解析器在执行第一步预解析的时候,会从代码的开始搜索直到结尾,只去查找var、function和参数等内容。一般把第一步称之为“JavaScript的预解析”。而且,当找到这些内容时,所有的变量,在正式运行代码之前,都提前赋了一个值:未定义;所有的函数,在正式运行代码之前,都是整个函数块。让解析器识别到是一个表达式,那就得加上特殊符号来让其解析器识别出来,比如刚才提到的特殊运算符。


解析过程大致如下:


1、“找一些东西”: var、 function、 参数;(也被称之为预解析)


备注:如果遇到重名分为以下两种情况:遇到变量和函数重名了,只留下函数;遇到函数重名了,根据代码的上下文顺序,留下最后一个。


2、逐行解读代码。


备注:表达式可以修改预解析的值 (可以自行查阅文档,这就是后面说到的内容)


函数声明与函数定义


函数声明
一般相对规范的声明形式为:fucntion msg(void) 注意是有分号


function msg() 

函数定义 function msg()注意没有分号


{
alert('IIFE');
}

函数调用


这样是一个函数调用


msg();

函数声明加一个()就可以调用函数了


function msg(){
alert('IIFE');
}()

就这样

但是我们按上面在console中执行发现出错了


image.png


因为这样的代码混淆了函数声明和函数调用,以这种方式声明的函数 `msg`,就应该以 `msg()` 的方式调用。

若改成(function msg())()就是这样的一个结构体: (函数体)(IIFE),能被Javascript的解析器识别并正常执行


从Js解析器的预解析过程了解到:


解析器都能识别一种模式:使用括号封装函数。对于解析器来说,这几乎总是一个积极的信号,即函数需要立即执行。如果解析器看到一个左括号,紧接着是一个函数声明,它将立即解析这个函数。可以通过显式地声明立即执行的函数来帮助解析器加快解析速度


那么也就是说,括号的作用,就是将一个函数声明,让解析器识别为一个表达式,最后由程序执行这个函数


总结


任何消除函数声明和函数表达式间歧义的方法,都可以被Javascript解析器正确识别


赋值,逻辑,甚至是逗号,各种操作符,只要是解析器支持且用来识别的特殊符号都可以用作消除歧义的方式方法,而!function()(function()), 都是其中转换成表达式的一种方式。


测试


至于优先使用哪一个,推荐(), 而其他运算符,相对于多了一步执行步骤,比如+(表达式),那就是,立即执行+运算符运算,
大致测了一下:


image.png


结论


从测试结果的截图中我们能大致的看到,(IIFE)方式,比运算符快的是一个级别(进一位数的速度),如果说立即执行()的时间复杂度是O(n),那么运算符就是O(10n),当然这也只是粗略的测试,而且在现有的浏览器解析速度,时间基数小到可以忽略不计,所以看个人需求,写法就是萝卜白菜,大家各有所好,看个人


作者:糖墨夕
来源:juejin.cn/post/7203734711780081722
收起阅读 »

重新认识下网页水印

web
使用背景图图片 单独使用 css 实现,使用 backgroundImage,backgroundRepeat 将背景图片平铺到需要加水印的容器中即可。 如果希望实现旋转效果,可以借助伪元素,将背景样式放到伪元素中,旋转伪元素实现: <style>...
继续阅读 »

使用背景图图片


单独使用 css 实现,使用 backgroundImage,backgroundRepeat 将背景图片平铺到需要加水印的容器中即可。
如果希望实现旋转效果,可以借助伪元素,将背景样式放到伪元素中,旋转伪元素实现:


<style>
.watermark {
position: relative;
overflow: hidden;
background-color: transparent;
}
.watermark::before {
content: '';
position: absolute;
width: 160%;
height: 160%;
top: -20%;
left: -20%;
z-index: -1;
background-image: url('./watermark.png');
background-position: 0 0;
background-origin: content-box;
background-attachment: scroll;
transform: rotate(-20deg);
background-size: auto;
background-repeat: round;
opacity: 0.3;
pointer-events: none;
}
</style>

动态生成div


根据水印容器的大小动态生成div,div内可以任意设置文本样式和图片,借助userSelect禁止用户选中文本水印;


const addDivWaterMark = (el, text) => {
const { clientWidth, clientHeight } = el;
const waterWrapper = document.createElement('div');
waterWrapper.className = "waterWrapper";
const column = Math.ceil(clientWidth / 100);
const rows = Math.ceil(clientHeight / 100);
// 根据容器宽高动态生成div
for (let i = 0; i < column * rows; i++) {
const wrap = document.createElement('div');
wrap.className = "water";
wrap.innerHTML = `<div class="water-item">${text}</div>`
waterWrapper.appendChild(wrap)
}
el.append(waterWrapper)
}

Canvas写入图片做背景水印


将图片写入Canvas然后将Canvas作为背景图


  const img = new Image();
const { ctx, canvas } = createWaterMark(config);
img.onload = function () {
ctx.globalAlpha = 0.2;
ctx.rotate(Math.PI / 180 * 20);
ctx.drawImage(img, 0, 16, 180, 100);
canvasRef.value.style.backgroundImage = `url(${canvas.toDataURL()})`
};
img.src = ImageBg;

Canvas写入文字做背景水印


将文字写入Canvas然后将Canvas作为背景图


 const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = fillStyle;
ctx.globalAlpha = opacity;
ctx.font = font
ctx.rotate(Math.PI / 180 * rotate);
ctx.fillText(text, 0, 50);
return canvas

Svg做水印


通过svg样式来控制水印样式,再将svg转换成base64的背景图


  const svgStr =
`<svg xmlns="http://www.w3.org/2000/svg" width="180px" height="100px">
<text x="0px" y="30px" dy="16px"
text-anchor="start"
stroke="#000"
stroke-opacity="0.1"
fill="none"
transform="rotate(-20)"
font-weight="100"
font-size="16"> 前端小书童</text>
</svg>`
;
return `data:image/svg+xml;base64,${window.btoa(unescape(encodeURIComponent(svgStr)))}`;

shadowDom水印


使用customElements自定义个一个标签(可以使用其他任意标签,不过注意shadow DOM会使起同级的元素不显示。)
可以像shadow DOM写入style样式和水印节点(可以使用背景或者div形式)
shadow DOM内部实现的样式隔离不用担心写入的style影响页面其他元素样式,这个特性在微前端的实现中也被广泛使用。


 class ShadowMark extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: 'open' });
const wrapContainer = document.createElement('div')
const style = document.createElement('style');
style.textContent = `
.wrapContainer {
width: 100%;
height: 100%;
display: flex;
flex-wrap: wrap;
}
.watermark-item {
display: flex;
font-size: 16px;
opacity: .3;
transform: rotate(-20deg);
user-select: none;
white-space: nowrap;
justify-content: center;
align-items: center;
}`
;
const waterHeight = 100
const waterWidth = 100
const { clientWidth, clientHeight } = document.querySelector('.shadow-watermark')
const column = Math.ceil(clientWidth / waterWidth)
const rows = Math.ceil(clientHeight / waterHeight)
wrapContainer.setAttribute('class', "wrapContainer")
for (let i = 0; i < column * rows; i++) {
const wrap = document.createElement('div')
wrap.setAttribute('class', 'watermark-item')
wrap.style.width = waterWidth + 'px'
wrap.style.height = waterHeight + 'px'
wrap.textContent = "前端小书童"
wrapContainer.appendChild(wrap)
}
shadowRoot.appendChild(style);
shadowRoot.appendChild(wrapContainer)
}
}
customElements.define('shadow-mark', ShadowMark);

盲水印


canvas画布(canvas.getContext('2d'))调用 getImageData 得到一个 ArrayBuffer,用于记录画布每个像素的 rgba 值


r: Red取值范围0255
g: Green取值范围0
255
b:Blue取值范围0255
a:Alpha 透明度取值范围0
1,0代表全透明
可以理解为每个像素都是通过红、绿、蓝三个颜色金额透明度来合成颜色


方案一:低透明度方案的暗水印


当把水印内容的透明度 opacity 设置很低时,视觉上基本无法看到水印内容,但是通过修改画布的 rgba 值,可以使水印内容显示出来。
选择固定的一个色值例如R,判断画布R值的奇偶,将其重置为0或者255,低透明的内容就便可以显示出来了。


const decode = (canvas, colorKey, flag, otherColorValue) => {
const ctx = canvas.getContext('2d');
const originalData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
let data = originalData.data;
for (let i = 0; i < data.length; i++) {
//筛选每个像素点的R值
if (i % 4 == colorKey) {
if (data[i] % 2 == 0) {
//如果色值为偶数
data[i] = flag ? 255 : 0;
} else {
//如果色值为奇数
data[i] = flag ? 0 : 255;
}
} else if (i % 4 == 3) {
//透明度不作处理
continue;
} else {
// 关闭其他色值
if (otherColorValue !== undefined) {
data[i] = otherColorValue
}
}
}
ctx.putImageData(originalData, 0, 0);
}

方案二:将水印内容以像素偏差记录到画布中


用画布和水印后的画布绘制的像素进行ArrayBuffer对比,在存在水印像素的位置(水印画布透明度不为0)修改图片画布的奇偶,这样通过上面指定色值和奇偶去解码时,修改的文本像素就会被显示出来;


const encode = (ctx, textData, color, originalData) => {
for (let i = 0; i < originalData.data.length; i++) {
// 只处理目标色值
if (i % 4 == color) {
// 水印画布透明度为0
if (textData[i + offset] === 0 && (originalData.data[i] % 2 === 1)) {
// 放置越界
if (originalData.data[i] === 255) {
originalData.data[i]--;
} else {
originalData.data[i]++;
}
// 水印画布透明度不为0
} else if (textData[i + offset] !== 0 && (originalData.data[i] % 2 === 0)) {
originalData.data[i]++;
}
}
}
ctx.putImageData(originalData, 0, 0);
}

方案三:数字加密


在图像信号的频域(变换域)中隐藏信息要比在空间域(上面得到的像素颜色的ArrayBuffer)中隐藏信息具有更好的防攻击性。
这部分暗水印的实现,可以直接使用阿里云提供给的api,不过需要图片资源藏到的阿里云的OSS下;


MutationObserver


可以发现上面水印基本都是通过增加节点或者背景图的形式来实现,那用户其实可以通过屏蔽样式或者删除Dom来消除水印,那么我们可以借用MutationObserver来监听下水印dom的变化,来阻止用户以这种形式来消除水印;



代码



以上代码见:github.com/wenjuGao/wa…


线上效果:watermark-demo.vercel.app/



参考:



http://www.cnblogs.com/88223100/p/…


blog.csdn.net/bluebluesky…


developer.mozilla.org/zh-CN/docs/…


作者:前端小书童
来源:juejin.cn/post/7208465670991872061
收起阅读 »

人走茶凉?勾心斗角?职场无友谊?

你和同事之间存在竞争关系 要不要把工作关系维护成伙伴关系 明枪暗箭防不胜防 背后捅刀子往往最不设防 大家是否在职场上交友是有也遇到过以上困扰呢? 不要在职场上交“朋友”,而是要寻找“盟友”。 这两者的区别在于应对策略: 我们会愿意为“朋友”牺牲自己的利益,像是...
继续阅读 »

你和同事之间存在竞争关系


要不要把工作关系维护成伙伴关系


明枪暗箭防不胜防


背后捅刀子往往最不设防


大家是否在职场上交友是有也遇到过以上困扰呢?


不要在职场上交“朋友”,而是要寻找“盟友”。


这两者的区别在于应对策略:


我们会愿意为“朋友”牺牲自己的利益,像是一张年卡。


而结交“盟友”就是为了一起争取更多利益,《孔乙己》说得好:“这次是现钱,酒要好。”


所以,在职场上的“受欢迎”和社交场、朋友圈上的“受欢迎”之间有着本质的区别:


你和你的同事未必真心喜欢彼此,但在日常相处当中能够客气、友善地交往。


大家需要寻找盟友时会第一个想到你,在争斗冲突时会尽量绕开你,这就是一种非常理想的“受欢迎”状态。 不要在职场上寻求友谊和爱,这件事是不对的。


在这里给大家列出一个在职场上受欢迎的清单。


1.实力在及格线以上


这是一切的前提。职场新人要“先活下来,再做兄弟”,稳住了工作能力这个基本面,才有资格和同事谈交情。


实力不够的人会拖累整个团队、增加所有人的工作量,大家恨都来不及,绝对不会和他称兄道弟。


实力强可以表现为实力本身,在初级职位上,也可以表现为潜力。


极少数特别强大的人可能从一开始就能很好地完成工作,但是大部分人在新加入一个团队时都需要经过一段时间的磨合,在这个过程中有欠缺和不足都是正常的,你所表现出来的敬业精神、学习能力和进步的速度才是大家对你进行评价的关键。


刚入职的新人,对于要做的事情完全没有概念,但是为人极勤奋又上进,给他布置的任务会完成得特别扎实,每一天都在飞快地进步。这样的人在职场上永远都能收获一大把来自他人的橄榄枝。


2.比较高的自尊水平


高自尊的人对自己评价高,要求也高,又能够带着欣赏的眼光去看周围的人,他们不光是很好的父母、伴侣和朋友,同时也是职场上最好的结盟对象。


高自尊的人往往拥有很多优秀的品质,同时他们也能够理解“大局”,和他们合作不用在鸡毛蒜皮的细节上纠缠推诿,可以把精力全部用来开疆拓土,极大地降低团队的内耗。


如果你是一个高自尊的人,在日常生活中表现出了自律和很好的品行,就会收获高自尊同类的赞赏。有些低自尊的人可能会认为你的言行是在“装X”,别犹豫,把他们从你的结交名单当中划掉,高自尊会帮你筛掉一批最糟糕的潜在合作者。


如果你是一个部门的领导者,记得要维护高自尊的下属,他们都是潜在的优秀带队者,给他们一个位子就可以坐上来自己动,给他们一点精神鼓励和支持,他们就会变得无所不能。


即使高自尊的手下可能某些地方让你感到嫉妒或者冒犯(这是常见的,嫉妒是每个人都一定会有的情感),也绝对不要默许或者纵容低自尊的妄人跑去伤害他们,否则会伤了大家的心,事业就难以成功了。


“朕可以敲打丞相,但你算什么东西”就是对这种低自尊妄人最好的态度。


3.嘴严,可靠


在任何一个群体当中,多嘴多舌的人都不会受到尊重,而在职场上,嘴不严尤其危险。


如果你是一个爱说是非的人,围绕在你周围的只会是一帮同样没正事、低级趣味的家伙。你会被打上“不可靠”的标记,愿意和你交流的人越来越少,大家等着看你什么时候因为多嘴闯祸,而强者根本不会和你为伍。


有些同学曾经给我留言说,自己很内向,不知道如何跟同事拉近关系。内向的人最适合强调自己的“嘴严”和“可靠”,在职场上,这两项品质远比“能说会道”更让人喜欢。


4.随和,有分寸


体面的人不传闲话,也不会轻易对旁人发表议论。


“思想可以特立独行,生活方式最好随大流”,这是对自己的要求,而他人的生活方式是不是合理,不是我们能评价的。


哪怕是最亲近的人,都未必能知晓对方的全部经历和心里藏着的每一件小事。在职场上大家保持着客气有礼的距离,就更不可能了解每个人做事的出发点和逻辑,“看不懂”是正常的,但是完全没有必要“看不惯”。如果还要大发议论,把自己的“看不惯”到处传播,你的伙伴就只会越来越少。


有人说在北上广深这样的大城市,人和人之间距离遥远,缺人情味,太冷漠。


这不是冷漠,而是对“和自己不一样”的宽容,这份宽容就是我们在向文明社会靠拢的标志。


5.懂得如何打扮


还记得斯大林的故事吗?在他离开校园之后,从头到脚都经过精心设计,不是为了精神好看,而是要让自己看起来就像一位投身革命事业的进步青年。


有句老话叫做“先敬罗衣后敬人”,本意是讽刺那些根据衣饰打扮来评价一个人的现象。我们自己在做判断的时候要尽量避免受到这类偏见的影响,但是对他人可能存在的偏见一定要心中有数。人是视觉动物,穿着打扮是“人设(人物设定)”的一部分,在我们开口说话之前,衣饰鞋袜就已经传达了无数信息。


想要成为职场当中受欢迎的人,穿着打扮的风格就要和公司的调性保持一致,最安全的做法是向你的同事靠拢。


在一个风格统一的群体当中,“与众不同”这件事自带攻击性。如果在事业单位之类的上年纪同事比较多的地方上班,马卡龙色的衣服和颜色夸张的口红,最好等到下班时间再上身。


这不是压抑天性,而是自我保护和职业精神。


6.和优秀的人站在一起


在职场上,优秀的人品质都是相似的:勤奋,自律,不断精进。如果发现了这样的同事,就要尽量和他们保持良好关系。


但是,单纯的日常沟通并不足以让你们成为盟友,正式结盟往往是通过利益交换和分享:当你遇到棘手的工作任务,就可以主动邀请对方共同跟进,同时将一部分利益让出去。愉快的合作是关系飞跃的最好契机。


优秀的人能认可的,通常也都是自己的同类。如果你能获得他们的称许和背书,在同事当中的地位自然会有所提升。


7.知道如何求助


前两天有一位关系户同学留言说,自己即将去实习,因为家人的关系可以得到一些行业资深专家的指点,问自己应该如何表现,是不是不懂就要问,像“好奇宝宝”一样,对方就会觉得自己好学上进。


我告诉她说,不要上去就问,有任何疑惑都先用搜索引擎找一下答案,如果找不出来,再带着你搜到的细节去询问那些资深前辈。


互联网时代有个很大的变化,就是人们获取信息的成本大大降低。善用搜索引擎寻找答案,就能更快、更精准、更全面地找到自己想要的东西,这种方式比跑到对方工位边用嘴问效率高得多。


凡事都问,只会让人觉得你的文字阅读能力有限,同时既不把自己的时间当回事,也不尊重别人的时间。尤其对方还是行业中的专家,他们的时间一定比实习生的宝贵多了。如果网上找不到答案,再带着细节去仔细咨询,这样的请教才是高效的,才能证明你是一个“好学上进”的人。


职场不是校园,不会再有一群老师专门负责手把手地教你,不轻易占用其他同事的时间会让你成为一个自立、有分寸、受尊重的人。毕业之后,你取得进步的速度、最终的上升空间,都和使用搜索引擎寻找答案的能力呈正相关。


8.技巧地送出小恩小惠


小恩小惠带两个“小”字,并不意味着这是一种微末小技。事实上,即使是最普通的零食,只要讲究得法,都可以送到人心里。


你的同事当中有没有因为宗教信仰而忌口的情况?


甲和乙爱吃辣,丙和丁爱吃甜,是不是两种口味都来上一点?


要留心同事的自我暴露,最好是用一个小本本记下来,关键时刻可能派上大用场。大家都是成年人,不会像孩子一样轻易被小恩小惠打动,打动我们的往往是“你把我放在心上”的温暖。


9.良好的情绪管理能力


很多时候这是个隐藏特征,但是自带“一票否决”属性:平时表现得沉着稳重,周围同事们不会有特别明显的感觉,然而歇斯底里和失控只要有一次,之前苦心经营的人设就会全面崩塌。情绪不稳定的人一般没人敢惹,但是也没人会在意了:你会被视为一个“病人”,很难再有大的发展。


已经发泄出去的情绪不能收回来,这个时候不要反复陷入纠结和悔恨,待在情绪里不出来,钱花出去了就不要去想,不要去比价。


如果情绪失控了,应该立刻做到的是原谅自己,然后考虑如何不再有下一次失控。要知道大多数人一辈子都至少会换三四次工作,了不起是换个地方,重新再来。


有的人特别幸运,天生长得好看,容易被人喜欢。


如果不是让人眼前一亮的高颜值人士,就不要太心急了。


成为一个自律、行为可以预期的人,也能慢慢地被别人喜欢。


人生很长,被人喜欢这件事,我们不用赶时间。


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

你的代码着色好看吗?来这里看看吧!

web
如果你想在网页上展示一些代码,你可能会遇到一个问题:代码看起来很单调,没有任何颜色或格式,这样的代码不仅不美观,也不利于阅读和理解。 那么,有没有什么办法可以让代码变得更漂亮呢?答案是有的,而且很简单。 你只需要使用一个叫做 highlight.js 的第三方...
继续阅读 »

如果你想在网页上展示一些代码,你可能会遇到一个问题:代码看起来很单调,没有任何颜色或格式,这样的代码不仅不美观,也不利于阅读和理解。


那么,有没有什么办法可以让代码变得更漂亮呢?答案是有的,而且很简单。


你只需要使用一个叫做 highlight.js 的第三方库,就可以轻松实现代码着色的效果。



highlight.js 是一个非常强大和流行的库,它可以自动识别和着色超过 190 种编程语言。


它支持多种主题和样式,让你可以根据自己的喜好选择合适的配色方案。


在本文中,子辰将向你介绍如何使用 highlight.js 来为你的代码着色,以及它的基本原理和优势。


让我们开始吧!


如何使用 highlight.js


使用 highlight.js 的方法有两种:一种是通过 npm 下载并安装到你的项目中,另一种是通过 CDN 引入到你的网页中。


这里我们以 CDN 的方式为例,如果你想使用 npm 的方式,可以参考官方文档。


首先,我们需要在网页中引入 highlight.js 的 JS 文件和 CSS 文件。


JS 文件是核心文件,负责识别和着色代码,CSS 文件是样式文件,负责定义代码的颜色和格式。



我们可以从 CDN 中选择一个合适的 JS 文件和 CSS 文件。


highlight.js 提供了多个 CDN 服务商,你可以根据自己的需求选择一个,这里我们以 jsDelivr 为例。


JS 文件的链接如下:


<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/highlight.min.js"></script>

CSS 文件的链接则需要根据你想要的主题来选择。


highlight.js 提供了很多主题,你可以在官网上预览每个主题的效果,并找到对应的 CSS 文件名,这里我们以 github-dark 为例。


CSS 文件的链接如下:


<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/styles/github-dark.min.css">

将上面两个链接分别放到网页的 head 标签中,就完成了引入 highlight.js 的步骤。


接下来,我们需要在网页中写一些代码,并用 pre 标签和 code 标签包裹起来。


pre 标签用于保留代码的格式,code 标签用于标识代码内容。例如:


<pre>
<code id="code-area">
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
height: 100vh;
width: 100vw;
}
</code>
</pre>

注意,我们给 code 标签添加了一个 id 属性,方便后面通过 JS 获取它。


最后,我们需要在网页中添加一些 JS 代码,来调用 highlight.js 的方法,实现代码着色的功能。


highlight.js 提供了两个主要的方法:highlightElement 和 highlight。


这两个方法都可以实现代码着色的效果,但是适用于不同的场景。


highlightElement


highlightElement 方法适用于当你的代码是直接写在网页中的情况。


这个方法接受一个元素作为参数,并将该元素内部的文本内容进行着色处理。例如:


// 获取 code 元素
const codeEle = document.getElementById("code-area");
// 调用 highlightElement 方法,传入 code 元素
hljs.highlightElement(codeEle);

如果一切顺利,你应该能看到类似下图的效果:



代码已经被着色了,并且你可以看到代码被替换成了一个个标签,标签被加上了样式。


在最后的原理里我们在详细的说一下。


highlight


highlight 方法适用于当你的代码是通过 Ajax 请求获取到的纯文本数据的情况。


这个方法接受一个字符串作为参数,并返回一个对象,包含着色后的文本内容和代码的语言。例如:


<script>
const codeEle = document.getElementById('code-area')
// 比如说现在 code 就是 Ajax 返回的数据,lang 就是代码语言,content 就是代码内容
const code = {
lang: 'css',
content: `
* {
margin: 0;
padding: 0;
}`

}
// 我们接下来可以使用 hljs.highlight,将代码内容与代码语言传入进去
const result = hljs.highlight(code.content, {
language: code.lang
})
// 它会返回一个结果,我们打印到控制台看看
console.log('result >>> ', result)
</script>


我们可以看到,打印出来的是一个对象,code 是它原始的代码,language 是它的语言,而 value 就是它着色后的代码。


那么现在要做的就是将 value 添加到 code 元素里边去。


<script>
const code = {
lang: 'css',
content: `
* {
margin: 0;
padding: 0;
}`

}
const result = hljs.highlight(code.content, {
language: code.lang
})
const codeEle = document.getElementById('code-area')
codeEle.innerHTML = result.value
</script>


我们可以看到,代码确实被着色了,但是和之前的有所差别,我们看一下是什么原因。



打开控制后我们发现,用这种方式 code 元素就没有办法被自动加上类样式了,所以说我们就需要手动给 code 加上类样式才可以。


// 通过 className 为 code 手动添加类样式,并添加类的语言
codeEle.className = `hljs language-${code.lang}`

highlight.js 的语言支持


无论使用哪种方法,都需要注意指定代码所属的语言。


如果不指定语言,highlight.js 会尝试自动识别语言,并可能出现错误或不准确的结。


指定语言可以通过两种方式:



  • 在 code 标签中添加 class 属性,并设置为 language-xxx 的形式,其中 xxx 是语言名称。

  • 在调用 highlightElement 或 highlight 方法时,在第二个参数中传入一个对象,并设置 language 属性为语言名称。



上图是 highlight.js 支持的语言,可以看到有很多种,需要用其他语言的时候,language 设置成指定的语言名称就可以了。


原理


它的原理你可能已经猜到了,在 highlightElement 里我们简单说了一下,现在再看下图:



之所以可以实现着色,其实就是查找和替换的过程,将原来的纯文本替换为元素标签包裹文本,元素是可以加上样式的,而样式就是我们引入的 css 文件。


这就是它的基本原理了。


总结


其实有时候我们去设置 Mackdown 的自定义样式呢,在代码区域设置的时候也是这样设置的,当然类样式的名字呢,基本上都是标准的格式。


好了,这个库分享介绍给你了,库的原理也为你做了简单的科普,希望对你有所帮助。


如果你有什么问题或建议,请在评论区留言,如果你觉得这篇文章有用,请点赞收藏或分享给你的朋友!


作者:子辰Web草庐
来源:juejin.cn/post/7245584147456507965
收起阅读 »

😲什么!!一个开关要这么花里胡哨??

web
前言 前几天我的朋友突然找上我,说他公司产品要他做个很花哨的开关特效,我一想一个开关而已,还能花哨到哪去,无非就是加点动画特效吗,随后我承认我低估了这个产品的脑洞,需求是要一个粉粉嫩嫩的爱心开关,关的时候背景色是白的,打开后要粉色,而且爱心开关按钮是从左心房滚...
继续阅读 »

前言


前几天我的朋友突然找上我,说他公司产品要他做个很花哨的开关特效,我一想一个开关而已,还能花哨到哪去,无非就是加点动画特效吗,随后我承认我低估了这个产品的脑洞,需求是要一个粉粉嫩嫩的爱心开关,关的时候背景色是白的,打开后要粉色,而且爱心开关按钮是从左心房滚动到右心房(这是我朋友对产品心里话:************ 😄)随后我也是去翻了一下收藏集,找了一个效果给了他,让他自己再根据公司需求进行改动


结构


这里我们利用label标签对开关按钮及爱心的点击触发效果,内部使用一个复选框跟一个svg图标来进行布局


<label class="box">
<!-- 复选框,有选中状态 -->
<input type="checkbox">

<!-- 心形图标 -->
<svg viewBox="0 0 33 23" fill="pink">
<path
d="M23.5,0.5 C28.4705627,0.5 32.5,4.52943725 32.5,9.5 C32.5,16.9484448 21.46672,22.5 16.5,22.5 C11.53328,22.5 0.5,16.9484448 0.5,9.5 C0.5,4.52952206 4.52943725,0.5 9.5,0.5 C12.3277083,0.5 14.8508336,1.80407476 16.5007741,3.84362242 C18.1491664,1.80407476 20.6722917,0.5 23.5,0.5 Z">

</path>
</svg>
</label>


svg图标大家可以复制这个或者自己去网上找一个图标也可以,不过如果是网上找的则需要自己去重新计算开关打开和关闭的动画位置


样式


结构有了开始写样式,让开关好看点



  • 初始化


        * {
margin: 0;
padding: 0;
box-sizing: border-box;
/* 解决手机浏览器点击有选框的问题 */
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}


  • 大盒子居中,盒子样式及移入鼠标样式,svg样式调整


        body {
/* 常规居中显示,简单背景色 */
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
min-height: 100vh;
background-color: #f6f6ff;
}

.box {
/* 整个父盒子区域都可以点,是个小手 */
cursor: pointer;
/* 过渡动画时间,主要是按下缩小一圈 */
transition: transform 0.2s;
position: relative;
}
.box input {
/* 去除默认复选框样式 */
appearance: none;
/* 中间滑动圆圈的宽高,简单白色背景 */
width: 20vmin;
height: 20vmin;
border-radius: 50%;
background-color: #ffffff;
/* 灰色阴影 */
box-shadow: 0 0.5vmin 2vmin rgba(0, 0, 0, 0.2);
/* 鼠标小手 */
cursor: pointer;
}

.box svg {
/* 中间心形图标的宽高,撑开整个开关区域 */
width: 40vmin;
height: 30vmin;
/* background-color: skyblue; */

/* 中间填充颜色 */
fill: #ffffff;
/* 描边颜色,描边头是圆润的 */
stroke: #d6d6ee;
stroke-linejoin: round;

}



  • 开关动画


 @keyframes animate-on {

/* 动画就是简单的位置变换,要根据情况调整 */
0% {
top: 2.5vmin;
left: 1.5vmin;
}

25% {
top: 5.5vmin;
left: 5vmin;
}

50% {
top: 7vmin;
left: 10vmin;
/* 到正中间时圆大一小圈 */
transform: scale(1.05);
}

75% {
top: 5.5vmin;
left: 15vmin;
}

100% {
top: 2.5vmin;
left: 18.5vmin;
}
}

@keyframes animate-off {

/* 关闭的动画就是反着来 */
0% {
top: 2.5vmin;
left: 18.5vmin;
}

25% {
top: 5.5vmin;
left: 15vmin;
}

50% {
top: 7vmin;
left: 10vmin;
transform: scale(1.05);
}

75% {
top: 5.5vmin;
left: 5vmin;
}

100% {
top: 2.5vmin;
left: 1.5vmin;
}
}

细节:开关按钮的小球到中间时要变大一点点,因为爱心之间位置比较大一点,这样滑动起来才好看


完整代码


code.juejin.cn/pen/7173909…


结尾


朋友收到代码后连连道谢,还非要请我周末去吃个烤🐏腰子补补,哎!!盛情难却,勉为其难的去吃吧,声明:我可不是为了那🐏腰子去的啊!主要是人家盛情邀请,咱们没办法拒绝😁。如果代码中有任何错误欢迎大家指正,相互学习相互进步


作者:一骑绝尘蛙
来源:juejin.cn/post/7173940249440026631
收起阅读 »

面试官:什么是JWT?为什么要用JWT?

目前传统的后台管理系统,以及不使用第三方登录的系统,使用 JWT 技术的还是挺多的,因此在面试中被问到的频率也比较高,所以今天我们就来看一下:什么是 JWT?为什么要用 JWT? 1.什么是 JWT? JWT(JSON Web Token)是一种开放标准(RF...
继续阅读 »

目前传统的后台管理系统,以及不使用第三方登录的系统,使用 JWT 技术的还是挺多的,因此在面试中被问到的频率也比较高,所以今天我们就来看一下:什么是 JWT?为什么要用 JWT?


1.什么是 JWT?


JWT(JSON Web Token)是一种开放标准(RFC 7519),用于在网络上安全传输信息的简洁、自包含的方式。它通常被用于身份验证和授权机制。
JWT 由三部分组成:头部(Header)、载荷(Payload)和签名(Signature)。



  1. 头部(Header):包含了关于生成该 JWT 的信息以及所使用的算法类型。

  2. 载荷(Payload):包含了要传递的数据,例如身份信息和其他附属数据。JWT 官方规定了 7 个字段,可供使用:

    1. iss (Issuer):签发者。

    2. sub (Subject):主题。

    3. aud (Audience):接收者。

    4. exp (Expiration time):过期时间。

    5. nbf (Not Before):生效时间。

    6. iat (Issued At):签发时间。

    7. jti (JWT ID):编号。



  3. 签名(Signature):使用密钥对头部和载荷进行签名,以验证其完整性。



JWT 官网:jwt.io/



2.为什么要用 JWT?


JWT 相较于传统的基于会话(Session)的认证机制,具有以下优势:



  1. 无需服务器存储状态:传统的基于会话的认证机制需要服务器在会话中存储用户的状态信息,包括用户的登录状态、权限等。而使用 JWT,服务器无需存储任何会话状态信息,所有的认证和授权信息都包含在 JWT 中,使得系统可以更容易地进行水平扩展。

  2. 跨域支持:由于 JWT 包含了完整的认证和授权信息,因此可以轻松地在多个域之间进行传递和使用,实现跨域授权。

  3. 适应微服务架构:在微服务架构中,很多服务是独立部署并且可以横向扩展的,这就需要保证认证和授权的无状态性。使用 JWT 可以满足这种需求,每次请求携带 JWT 即可实现认证和授权。

  4. 自包含:JWT 包含了认证和授权信息,以及其他自定义的声明,这些信息都被编码在 JWT 中,在服务端解码后使用。JWT 的自包含性减少了对服务端资源的依赖,并提供了统一的安全机制。

  5. 扩展性:JWT 可以被扩展和定制,可以按照需求添加自定义的声明和数据,灵活性更高。


总结来说,使用 JWT 相较于传统的基于会话的认证机制,可以减少服务器存储开销和管理复杂性,实现跨域支持和水平扩展,并且更适应无状态和微服务架构。


3.JWT 基本使用


在 Java 开发中,可以借助 JWT 工具类来方便的操作 JWT,例如 HuTool 框架中的 JWTUtil。


HuTool 介绍:doc.hutool.cn/pages/JWTUt…


使用 HuTool 操作 JWT 的步骤如下:



  1. 添加 HuTool 框架依赖

  2. 生成 Token

  3. 验证和解析 Token


3.1 添加 HuTool 框架依赖


在 pom.xml 中添加以下信息:


<dependency>
<groupId>cn.hutoolgroupId>
<artifactId>hutool-allartifactId>
<version>5.8.16version>
dependency>

3.2 生成 Token


Map map = new HashMap() {
private static final long serialVersionUID = 1L;
{
put("uid", Integer.parseInt("123")); // 用户ID
put("expire_time", System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 15); // 过期时间15天
}
};
JWTUtil.createToken(map, "服务器端秘钥".getBytes());

3.3 验证和解析 Token


验证 Token 的示例代码如下:


String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCJdLCJleHAiOjE2MjQwMDQ4MjIsInVzZXJJZCI6MSwiYXV0aG9yaXRpZXMiOlsiUk9MRV_op5LoibLkuozlj7ciLCJzeXNfbWVudV8xIiwiUk9MRV_op5LoibLkuIDlj7ciLCJzeXNfbWVudV8yIl0sImp0aSI6ImQ0YzVlYjgwLTA5ZTctNGU0ZC1hZTg3LTVkNGI5M2FhNmFiNiIsImNsaWVudF9pZCI6ImhhbmR5LXNob3AifQ.aixF1eKlAKS_k3ynFnStE7-IRGiD5YaqznvK2xEjBew";
JWTUtil.verify(token, "123456".getBytes());

解析 Token 的示例代码如下:


String rightToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiYWRtaW4iOnRydWUsIm5hbWUiOiJsb29seSJ9.U2aQkC2THYV9L0fTN-yBBI7gmo5xhmvMhATtu8v0zEA";
final JWT jwt = JWTUtil.parseToken(rightToken);
jwt.getHeader(JWTHeader.TYPE);
jwt.getPayload("sub");

3.4 代码实战


在登录成功之后,生成 Token 的示例代码如下:


// 登录成功,使用 JWT 生成 Token
Map payload = new HashMap() {
private static final long serialVersionUID = 1L;
{
put("uid", userinfo.getUid());
put("manager", userinfo.getManager());
// JWT 过期时间为 15 天
put("exp", System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 15);
}
};
String token = JWTUtil.createToken(payload, AppVariable.JWT_KEY.getBytes());

例如在 Spring Cloud Gateway 网关中验证 Token 的实现代码如下:


import cn.hutool.jwt.JWT;
import cn.hutool.jwt.JWTUtil;
import com.example.common.AppVariable;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.util.List;

/**
* 登录过滤器(登录判断)
*/

@Component
public class AuthFilter implements GlobalFilter, Ordered {
// 排除登录验证的 URL 地址
private String[] skipAuthUrls = {"/user/add", "/user/login"};

@Override
public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 当前请求的 URL
String url = exchange.getRequest().getURI().getPath();
for (String item : skipAuthUrls) {
if (item.equals(url)) {
// 继续往下走
return chain.filter(exchange);
}
}
ServerHttpResponse response = exchange.getResponse();
// 登录判断
List tokens =
exchange.getRequest().getHeaders().get(AppVariable.TOKEN_KEY);
if (tokens == null || tokens.size() == 0) {
// 当前未登录
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
// token 有值
String token = tokens.get(0);
// JWT 效验 token 是否有效
boolean result = false;
try {
result = JWTUtil.verify(token, AppVariable.JWT_KEY.getBytes());
} catch (Exception e) {
result = false;
}
if (!result) {
// 无效 token
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
} else { // 判断 token 是否过期
final JWT jwt = JWTUtil.parseToken(token);
// 得到过期时间
Object expObj = jwt.getPayload("exp");
if (expObj == null) {
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
long exp = Long.parseLong(expObj.toString());
if (System.currentTimeMillis() > exp) {
// token 过期
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
}
return chain.filter(exchange);
}

@Override
public int getOrder() {
// 值越小越早执行
return 1;
}
}

4.实现原理分析


JWT 本质是将秘钥存放在服务器端,并通过某种加密手段进行加密和验证的机制。加密签名=某加密算法(header+payload+服务器端私钥),因为服务端私钥别人不能获取,所以 JWT 能保证自身其安全性。


小结


JWT 相比与传统的 Session 会话机制,具备无状态性(无需服务器端存储会话信息),并且它更加灵活、更适合微服务环境下的登录和授权判断。JWT 是由三部分组成的:Header(头部)、Payload(数据载荷)和 Signature(签名)。


作者:Java中文社群
来源:juejin.cn/post/7309310129024008230
收起阅读 »

产品经理:能不能根据用户心情自动切换主题。我:好的。

web
效果展示 在线体验地址:dbfu.github.io/antd-pro-ex…,需要开启摄像头权限,不支持手机浏览器。 代码仓库地址:github.com/dbfu/antd-p… 前言 这个灵感来自于zxg_神说要有光大佬的写一个可以当镜子照的Button...
继续阅读 »

效果展示


17.gif


在线体验地址:dbfu.github.io/antd-pro-ex…,需要开启摄像头权限,不支持手机浏览器。


代码仓库地址:github.com/dbfu/antd-p…


前言


这个灵感来自于zxg_神说要有光大佬的写一个可以当镜子照的Button这篇文章,正好今天看了一个人脸识别的前端仓库,可以动态识别人的表情,本来想写一个“根据用户心情变色的按钮”,同事说能不能实现”根据用户心情自动切换系统主题“,我想了一下好像可以的。


实现思路


借助第三方库透过摄像头事实获取用户的表情,然后根据表情动态切换主题。


具体实现


先使用antd pro脚手架初始化一个antd pro项目


pro create antd-pro-expression-theme

安装face-api.js


pnpm i face-api.js

到仓库中下载源码,把weights文件夹复制到antd pro项目中的public文件夹下,这一步很关键,我被这个地方卡了一段时间。


改造antd pro项目,支持动态主题。


在src目录下创建expression.tsx标题组件


import { useEffect, useRef, useState } from 'react';
import * as faceapi from 'face-api.js';


const expressionMap: any = {
"neutral": '正常',
"happy": '开心',
"sad": '悲伤',
"surprised": '惊讶',
}

const Hidden = true;

function getExpressionResult(expression: any) {
if (!expression) return;
const keys = [
'neutral',
'happy',
'sad',
'angry',
'fearful',
'disgusted',
'surprised',
];

const curExpression = keys.reduce((prev: string, cur: string) => {
if (!prev) {
return cur;
} else {
return expression[cur] > expression[prev] ? cur : prev;
}
}, '');
return curExpression;
}

export function Expression({
onExpressionChange,
}:
any
) {

const videoRef = useRef<HTMLVideoElement>(null);
const [expression, setExpression] = useState<string | undefined>('');

useEffect(() => {
if (onExpressionChange) {
onExpressionChange(expression);
}
}, [expression]);

async function run() {
await faceapi.nets.tinyFaceDetector.load('/widgets/');

await faceapi.loadSsdMobilenetv1Model('/widgets/');
await faceapi.loadFaceLandmarkModel('/widgets/');
await faceapi.loadFaceExpressionModel('/widgets/');

const stream = await navigator.mediaDevices.getUserMedia({ video: {} });
if (videoRef.current) {
videoRef.current.srcObject = stream;
}
}

useEffect(() => {
run();
}, []);

async function onPlay(): Promise<any> {

const videoEl = videoRef.current;

if (!videoEl) return;

if (videoEl.paused || videoEl.ended) return setTimeout(() => onPlay());

const result = await faceapi
.detectSingleFace(videoEl)
.withFaceExpressions();

setExpression(getExpressionResult(result?.expressions))

setTimeout(() => onPlay());
}

return (
<div style={{ opacity: Hidden ? 0 : 1 }} >
<video
style={{
background: '#fff',
width: 640,
height: 480,
position: 'fixed',
top: 0,
left: 0,
zIndex: Hidden ? 0 : 10001
}}
onLoadedMetadata={() =>
{ onPlay() }}
id="inputVideo"
autoPlay
muted
playsInline
ref={videoRef}
/>
<div
style={{
opacity: 1,
width: 640,
height: 480,
position: 'fixed',
top: 0,
left: 0,
zIndex: 10001,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
}}
>

{expressionMap?.[expression || 'neutral']}
div>

div>
)
}

这样就简单的获取到了表情,目前我就支持了正常、开心、惊讶、伤心四种表情,实际上他还支持其他一些表情,大家可以自己去体验一下。我主要参考了这个demo,这里面还有其他demo大家可以去体验一下。
如果不想显示视频,把上Hidden变量设置为true就行了。


在src目录下创建theme-provider.tsx文件


import { ConfigProvider } from 'antd';
import throttle from 'lodash/throttle';

import { Expression } from './expression';
import { useMemo, useState } from 'react';

export default function ThemeProvider({ children }: any) {

const [theme, setTheme] = useState<string>('');

const expressionChange = useMemo(
() => throttle((expression: string) => {
const map: any = {
happy: 'rgb(245, 34, 45)',
sad: 'rgb(192, 192, 192)',
surprised: 'rgb(250, 173, 20)',
};

setTheme(map[expression] ? map[expression] : 'rgb(22, 119, 255)')
}, 1000), [])


return (
<ConfigProvider theme={{
token: {
colorPrimary: theme || 'rgb(22, 119, 255)',
}
}}>

<Expression onExpressionChange={expressionChange} />
{children}
ConfigProvider>

)
}

这个文件用来监听表情变化,然后动态设置主题,目前也是只支持了正常、开心、惊讶、伤心四种主题。


最后在src/app.tsx使用theme-provider组件,并删除下面截图中的代码,不然我们的主题会被默认主题覆盖掉,导致不能改主题。


export function rootContainer(container: any) {
return React.createElement(ThemeProvider, null, container);
}

image.png


然后启动项目就行了。第一次获取表情有点慢,可能要等一会。


总结


这个功能看似没用,实则真没用,主要是想整个活让大家乐一下。大家应该还记得以前有个比较热门的话题吧,根据手机壳改变主题颜色,如果能通过摄像头获取到手机壳的颜色,好像也不是不行🐶。


在线体验地址:dbfu.github.io/antd-pro-ex…,需要开启摄像头权限。


代码仓库地址:github.com/dbfu/antd-p…


作者:前端小付
来源:juejin.cn/post/7226385396167704634
收起阅读 »

实现抖音“刚刚看过”的功能(原生js的写法)

web
先上一下效果图吧 点击一下刚刚看过的按钮就会滚动到视频的位置 实现这个效果,如果不考虑效率问题肯定是非常简单,但是我们就是要考虑这个传输效率的问题 比方说这个主页有2000条视频,但是目前看的视频在第1900个,那需要滑到这第1900个视频的位置,不可能把...
继续阅读 »

先上一下效果图吧


点击一下刚刚看过的按钮就会滚动到视频的位置
image.png


image.png


实现这个效果,如果不考虑效率问题肯定是非常简单,但是我们就是要考虑这个传输效率的问题


比方说这个主页有2000条视频,但是目前看的视频在第1900个,那需要滑到这第1900个视频的位置,不可能把之前所有的视频都加载出来吧,这样子的话这效率太低了吧,传输量加上请求,怎么可能吃得消
所以这个时候我们只需要创建好元素,但是不需要向服务器要这1900个视频的内容,我只要创建好元素,就可以滑动到这个视频的位置了,那要怎么加载这视频的内容呢?那就是判断用户看到哪一块,看到哪,我们加载到哪,类似于懒加载的效果


所以我这里提供一个思路,最主要的就是两个关键函数(createELement,loadPages)


createElement(page)的作用就是传入页码,他就会创建好这页面加上之前所有的元素,这个函数只管创建好元素,内容不归他管,内容等到后面在进行加载


loadPages()这个函数就是根据用户当前能看到第几页,那么就把第几页的内容加载出来,看到哪就加载哪个页面的数据,这里还需要考虑到两个页面重叠,都需要加载出来


那么首先来准备好html


<div class="contain"></div> //放置内容的盒子
<div class="btn"> //刚刚看过的按钮
<button class="full-rounded">
<span>刚刚看过</span>
<div class="border full-rounded"></div>
</button>
</div>

当然css样式也是要准备好的,可以根据自己公司的UI设计图来写


body {
background-color: #000;
padding: 100px 300px;
}

.contain {
width: 100%;
height: 100%;
display: grid; //宫格布局
grid-template-columns: repeat(5, 1fr);
grid-column-gap: 50px; //每一列的间距
grid-row-gap: 80px; //每一行的间距
}
.item {
width: 200px;
height: 300px;
border: 1px solid #fff;
}
.playing {
width: 200px;
height: 300px;
position: relative;
}
.playing img {
filter: blur(3px);
-webkit-filter: blur(3px);
}
.playing::after {
content: "播放中";
position: absolute;
top: 0;
left: 0;
width: 200px;
height: 300px;
font-size: 20px;
font-weight: bold;
color: white;
display: flex;
justify-content: center;
align-items: center;
}
.btn {
position: fixed;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
}
button {
font-size: 16px;
position: relative;
margin: auto;
padding: 1em 2.5em 1em 2.5em;
border: none;
background: #fff;
transition: all 0.1s linear;
box-shadow: 0 0.4em 1em rgba(0, 0, 0, 0.1);
}
button:hover {
cursor: pointer;
}
button:active {
transform: scale(0.95);
}

button span {
color: #464646;
}

button .border {
position: absolute;
border: 0.15em solid #fff;
transition: all 0.3s 0.08s linear;
top: 50%;
left: 50%;
width: 9em;
height: 3em;
transform: translate(-50%, -50%);
}

button:hover .border {
display: block;
width: 9.9em;
height: 3.7em;
}

.full-rounded {
border-radius: 2em;
}

这些都不是最重要的


还有一些工具函数


1.getOffset(id) 来获取当前视频前面有多少个视频


这个根据实际情况来做,正常情况这里是向服务端获取的,我这里就模拟了一下请求


// 传入当前视频的id就可以获取之前有多少个视频
function getOffset(id) {
return new Promise((res, rej) => {
let result = id - 1;
res(result);
});
}

2.getVideo(page,size)


获取页面的资源
同样这里也是向服务端发请求获取的,我这里也是自己模拟


// 传入页码和每页多少条,即可获取图片数据
function getVideo(page, size) {
return new Promise((res) => {
let arr = [];
// 上一页有多少个,从哪开始num
let num = (page - 1) * size;
for (let i = 0; i < size; i++) {
let obj = {
id: num + i,
cover: `https://picsum.photos/200/300?id=${num + i}`,
};
arr.push(obj);
}
res(arr);
});
}

3.getIndexRange(page,size)


获取这个页码的最小索引和最大索引


// 传入页码和大小算出这个页码的起始和结束下标
function getIndexRange(page, size) {
let start = (page - 1) * size;
let end = start + size - 1;
return [start, end];
}

4.debounce(fn,deplay=300)


这个就是防抖啦,让loadpage函数不要执行太多次,节省性能


function debounce(fn, delay = 300) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}

5.getPage(index,size)


传入当前视频的下标和页面大小,返回当前视频在第几页


function getPage(index, size) {
return Math.ceil((index + 1) / size);
}

以上都是工具函数


准备工作


1.定义好一页需要多少元素


const SIZE = 15;
// 刚刚看过视频的id
const currentId = 200;
// 页码
let i = 1;

2.获取页面两个重点元素


let contain = document.querySelector(".contain");
let btn = document.querySelector(".btn");

现在来写最重要的函数


1.createElement(page)


传入页码即可创建好这个页面包括之前的所有元素

步骤:

1.算出需要创建多少元素page*size

2.创建item添加到contain元素的children中

3.给每个item添加侦查器,判断是否出现在视口内


function createElement(page) {
// 防止一页重复创建
const childLen = contain.children.length;
const count = page * SIZE - childLen;
for (let i = 0; i < count; i++) {
const item = document.createElement("div");
item.className = "item";
item.dataset.index = i + childLen;
contain.appendChild(item);
ob.observe(item); //侦查器,判断是否出现在视口内
}
}

2.视口观察器


const visibleIndex = new Set(); //全局创建一个不重复的集合
let ob = new IntersectionObserver((entries) => {
for (const e of entries) {
const index = e.target.dataset.index;
//isIntersecting为true就代表在视口内
if (e.isIntersecting) {
visibleIndex.add(index);
} else {
visibleIndex.delete(index);
}
}
debounceLoadPage();// 防抖后的loadpage
});

3.获取集合的最大及最小的索引


function getRange() {
if (visibleIndex.size === 0) return [0, 0];
const max = Math.max(...visibleIndex);
const min = Math.min(...visibleIndex);
return [min, max];
}

4.加载视口内的元素的资源


      function loadPage() {
// 得到当前能看到的元素索引范围
const [minIndex, maxIndex] = getRange();
const pages = new Set(); // 不重复的页码集合
for (let i = minIndex; i <= maxIndex; i++) {
pages.add(getPage(i, SIZE));// 遍历将侦查器集合范围内的所在页面都加入到pages的集合内
}
// 遍历页码集合
for (const page of pages) {
const [minIndex, maxIndex] = getIndexRange(page, SIZE);//获取页码的索引范围
if (contain.children[minIndex].dataset.loaded) { //如果页码最小索引的元素有自定义属性就跳过,代表加载过
continue;
}
contain.children[minIndex].dataset.loaded = true;//如果没有就代表没有加载过,添加上自定义属性
//将当前页码传给获取资源的函数
getVideo(page, SIZE).then((res) => {
//拿到当前页面需要的数据数组,遍历渲染到页面上
for (let i = minIndex; i < maxIndex; i++) {
const item = contain.children[i];
item.innerHTML = `<img src="${res[i - minIndex].cover}" alt="">`;
}
});
}
}

// 创建防抖加载函数,将loadpage函数防抖
const debounceLoadPage = debounce(loadPage, 300);

5.判断刚刚看过的按钮是否显示


// 页面进来就需要触发获取当前视频之前有多少个视频,判断按钮是否显示
      async function setVisible() {
        // 获取之前有多少个视频
        let offest = await getOffset(currentId);
        let [minIndex, maxIndex] = getRange();

        // 返回告诉你第几页
        const page = getPage(offest, SIZE);
        if (offest >= minIndex && offest <= maxIndex) {
          btn.style.display = "none";
        } else {
          btn.style.display = "block";
        }
        btn.dataset.page = page;
        btn.dataset.index = offest;
      }

6.给按钮添加点击事件,滚动到指定位置


btn.onclick = () => {
const page = +btn.dataset.page;
const index = +btn.dataset.index;
i = page; // 跳转将页码更新
createElement(page);
contain.children[index].scrollIntoView({
behavior: "smooth",
block: "center",
});
contain.children[index].classList.add("playing");
btn.style.display = "none";
};

7.给window添加滚动事件,页面触底页码加一


window.addEventListener("scroll", () => {
//窗口高度
var windowHeight =
document.documentElement.clientHeight || document.body.clientHeight;
//滚动高度
var scrollTop =
document.documentElement.scrollTop || document.body.scrollTop;
//页面高度
var documentHeight =
document.documentElement.scrollHeight || document.body.scrollHeight;

if (windowHeight + scrollTop == documentHeight) {
createElement(i++); //页面触底就页码加一
}
});

完整代码


<body>
<div class="contain"></div>
<div class="btn">
<button class="full-rounded">
<span>刚刚看过</span>
<div class="border full-rounded"></div>
</button>
</div>

<script src="./api.js"></script>
<script src="./index.js"></script>
<script>
const SIZE = 15;
let contain = document.querySelector(".contain");
let btn = document.querySelector(".btn");
// 页码
let i = 1;

const visibleIndex = new Set();

// 视口观察器
let ob = new IntersectionObserver((entries) => {
for (const e of entries) {
const index = e.target.dataset.index;
if (e.isIntersecting) {
// 将在视口内的元素添加到集合内
visibleIndex.add(index);
} else {
// 将不在视口内的元素从集合内删除
visibleIndex.delete(index);
}
}
debounceLoadPage();
});

function getRange() {
if (visibleIndex.size === 0) return [0, 0];
const max = Math.max(...visibleIndex);
const min = Math.min(...visibleIndex);
return [min, max];
}

// 创建元素
function createElement(page) {
// 防止一页重复创建
const childLen = contain.children.length;
const count = page * SIZE - childLen;
for (let i = 0; i < count; i++) {
const item = document.createElement("div");
item.className = "item";
item.dataset.index = i + childLen;
contain.appendChild(item);
ob.observe(item);
}
}

// 得到当前能看到的元素索引范围
function loadPage() {
const [minIndex, maxIndex] = getRange();
const pages = new Set();
for (let i = minIndex; i <= maxIndex; i++) {
pages.add(getPage(i, SIZE));
}
for (const page of pages) {
const [minIndex, maxIndex] = getIndexRange(page, SIZE);
if (contain.children[minIndex].dataset.loaded) {
continue;
}
contain.children[minIndex].dataset.loaded = true;
getVideo(page, SIZE).then((res) => {
for (let i = minIndex; i < maxIndex; i++) {
const item = contain.children[i];
item.innerHTML = `<img src="${res[i - minIndex].cover}" alt="">`;
}
});
}
}

// 创建防抖加载函数
const debounceLoadPage = debounce(loadPage, 300);

// 刚刚看过视频的id
const currentId = 200;

// 页面进来就需要触发获取之前有多少个视频,判断按钮是否显示
async function setVisible() {
// 获取之前有多少个视频
let offest = await getOffset(currentId);
let [minIndex, maxIndex] = getRange();
// 返回告诉你第几页
const page = getPage(offest, SIZE);
if (offest >= minIndex && offest <= maxIndex) {
btn.style.display = "none";
} else {
btn.style.display = "block";
}
btn.dataset.page = page;
btn.dataset.index = offest;
}

btn.onclick = () => {
const page = +btn.dataset.page;
const index = +btn.dataset.index;
i = page;
createElement(page);
contain.children[index].scrollIntoView({
behavior: "smooth",
block: "center",
});
contain.children[index].classList.add("playing");
btn.style.display = "none";
};

window.addEventListener("scroll", () => {
//窗口高度
var windowHeight =
document.documentElement.clientHeight || document.body.clientHeight;
//滚动高度
var scrollTop =
document.documentElement.scrollTop || document.body.scrollTop;
//页面高度
var documentHeight =
document.documentElement.scrollHeight || document.body.scrollHeight;

if (windowHeight + scrollTop == documentHeight) {
createElement(i++);
}
});
createElement(i);
setVisible();
</script>
</body>


🔥🔥🔥🔥🔥🔥到这里就实现了抖音的刚刚看过的功能!!!!!🔥🔥🔥🔥🔥🔥🔥🔥🔥


作者:井川不擦
来源:juejin.cn/post/7257441472445644855
收起阅读 »

Swiper,一款超赞的 JavaScript 滑动库?

web
嗨,大家好,欢迎来到猿镇,我是镇长,lee。 又到了和大家见面的时间,今天分享一款 JavaScript 滑动库 - Swiper。Swiper 不仅是一个简单的滑动库,更是一个全面的滑动解决方案,让你轻松创建出各种炫酷的滑动效果。 git...
继续阅读 »

嗨,大家好,欢迎来到猿镇,我是镇长,lee。


又到了和大家见面的时间,今天分享一款 JavaScript 滑动库 - SwiperSwiper 不仅是一个简单的滑动库,更是一个全面的滑动解决方案,让你轻松创建出各种炫酷的滑动效果。


github.com/nolimits4we…


什么是Swiper?


Swiper 是一个基于现代触摸滑动的 Javascript 库,用于创建轮播、幻灯片以及任何需要滑动的网页组件。它的灵活性和强大功能使得开发者能够实现各种复杂的滑动效果,而不需要深入了解复杂的滑动原理。


为什么选择Swiper?



  • 易于使用:  Swiper 提供了简单易懂的 API 和文档,使得即便是初学者也能轻松上手。只需几行代码,你就可以创建一个漂亮的轮播。

  • 跨平台兼容:  Swiper 支持多平台,包括PC、移动端和平板电脑,确保你的滑动效果在各种设备上都能够流畅运行。

  • 丰富的配置选项:  你可以根据自己的需求定制 Swiper 的各种参数,如滑动速度、自动播放、循环模式等,满足不同场景的需求。


如何开始使用Swiper?


步骤1:引入Swiper


首先,你需要在你的项目中引入 Swiper 库。你可以选择使用 CDN,也可以通过 npm 或 yarn 进行安装。



<link
  rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.css"
/>


<script src="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.js">script>

步骤2:创建HTML结构


创建一个包裹你滑动内容的容器,并添加滑动项。


<div class="swiper">
      <div class="swiper-wrapper">
        <div class="swiper-slide">Slide 1div>
        <div class="swiper-slide">Slide 2div>
        <div class="swiper-slide">Slide 3div>
        
      div>
      
      <div class="swiper-pagination">div>

      
      <div class="swiper-button-prev">div>
      <div class="swiper-button-next">div>
    div>

设置样式


.swiper {
      width600px,
    height: 300px;
}
.swiper-slide {
    background-color: red; // 设置背景色方便查看效果
}

步骤3:初始化Swiper


使用 Javascript 初始化 Swiper,并传入配置选项。


var mySwiper = new Swiper('.swiper-container', {
  // 配置项
  // 可选参数
  looptrue,

  // 分页器
  pagination: {
    el'.swiper-pagination',
  },

  // 导航箭头
  navigation: {
    nextEl'.swiper-button-next',
    prevEl'.swiper-button-prev',
  },
});

步骤4:享受滑动的乐趣


你已经成功集成了 Swiper,现在你可以在网页上看到炫丽的滑动效果了。


1.gif


进阶用法


Swiper 提供了许多高级用法和定制选项,以适应各种复杂的需求。以下是一些Swiper的高级用法:


1. 自定义动画和过渡效果


通过使用 Swiper 的effect属性,你可以指定不同的过渡效果,例如 "slide"、"fade"、"cube"等。这可以为你的滑动项添加独特的动画效果。


var mySwiper = new Swiper('.swiper-container', {
  effect'cube',
  cubeEffect: {
    slideShadowsfalse,
    shadowfalse,
  },
});

2. 动态添加或删除滑动项


通过 Swiper 的API,你可以在运行时动态地添加或删除滑动项。这在需要根据用户操作或数据变化来更新滑动项时非常有用。


// 添加新的滑动项
mySwiper.addSlide(0'New Slide
');

// 删除指定索引的滑动项
mySwiper.removeSlide(1);

3. 深度定制分页器和导航按钮


入门示例中简单引入了分页器,Swiper 的分页器和导航按钮可以进行高度的自定义。你可以通过自定义HTML、样式和事件来实现自己想要的分页器和导航按钮效果。


var mySwiper = new Swiper('.swiper-container', {
  pagination: {
    el'.swiper-pagination',
    clickabletrue,
    renderBulletfunction (index, className) {
      return ' + className + '">' + (index + 1) + '';
    },
  },
  
navigation: {
    
nextEl'.swiper-button-next',
    
prevEl'.swiper-button-prev',
  },
});

4. 使用Swiper插件


Swiper 支持插件系统,你可以使用一些第三方插件来增强 Swiper 的功能,例如 Swiper 的滚动条插件、懒加载插件等。通过导入并配置插件,你可以轻松地扩展 Swiper 的能力。


// 导入并使用懒加载插件
import SwiperCore, { Lazy } from 'swiper/core';
SwiperCore.use([Lazy]);

var mySwiper = new Swiper('.swiper-container', {
  // 启用懒加载
  lazytrue,
});

swiperjs.com/plugins


5.gif


5. 响应式设计


Swiper 允许你根据不同的屏幕尺寸设置不同的配置选项,实现响应式设计。这样,你可以在不同设备上提供最佳的用户体验。


var mySwiper = new Swiper('.swiper-container', {
  slidesPerView: 3,
  spaceBetween: 30,
  breakpoints: {
    // 当窗口宽度小于等于 768 像素时
    768: {
      slidesPerView: 2,
      spaceBetween: 20,
    },
    // 当窗口宽度小于等于 480 像素时
    480: {
      slidesPerView: 1,
      spaceBetween: 10,
    },
  },
});

这些高级用法展示了 Swiper 库的强大功能和灵活性,深入了解这些特性将使你能够更好地适应各种项目需求。


示例演示


2.gif


3.gif


4.gif


结语


通过 Swiper,你可以轻松实现网页上的各种滑动效果,为用户提供更加出色的交互体验。它的简单易用性和丰富的功能使其成为前端开发中不可或缺的利器。不论你是新手还是有经验的开发者,都值得深入了解 Swiper ,为你的网页增添一份技术的魔法。


更多


今天的分享就到这里,如果觉得对你有帮助,感谢点赞、分享、关注一波,你的认可是我创造的最大动力。


作者:繁华落尽丶lee
来源:juejin.cn/post/7309061655094575139
收起阅读 »

2023年终总结(被优化,外企工作,订婚,结婚)

前言 先介绍一下本人的自身的情况,双非本科,文科出身,2021年10月开始前端开发。 2022年3月14跳槽一家智能机器人公司。 2023年2月14日入职外企。 工作 又到了一年一度年终总结的时候,2023年对我来说是充满挑战和成长的一年。在这一年里,我经历了...
继续阅读 »

前言


先介绍一下本人的自身的情况,双非本科,文科出身,2021年10月开始前端开发。

2022年3月14跳槽一家智能机器人公司。

2023年2月14日入职外企。


工作


又到了一年一度年终总结的时候,2023年对我来说是充满挑战和成长的一年。在这一年里,我经历了许多变化,也取得了许多收获。以下是我对2023年的个人年终总结。
话不多说,先上图,看一下我去年的目标。
image.png


去年制定目标的时候从生活,工作两方面立了flag,那就分开仔细来说一下吧。


被优化


我记得清清楚楚2023年1月13日公司降本增效,在做的项目整个被砍,不过好在公司要上市,名声很重要,赔偿了2个月薪资让大家主动离职。当时临近新年只有一周,工作不好找,只能提前回家过年。

我记得那天超级冷,我一手抱着午睡时候的小猪,一手拿着靠垫,还用胳膊拎着装的满满的帆布袋,拿着小风扇,徒步走到地铁站,东西太多,上电梯的时候一个没拿住全都掉地上了。

回到家之后,感觉心里空落落的,放下东西抱着被子哇哇哭,可能是天太冷冻得,也可能是东西太多拿不动累的。


找到新工作


虽然没有工作,但我可是大年初六就来北京了呢,利用年前一周和新年在家的时间,每天保持8小时的学习,复习知识点,刷题,刷算法,默默的告诉自己一定要进大厂,找份工资高的工作。但是后来我退缩了,我甚至不敢投大厂的简历。大年初十开始第一家面试,是一家新能源公司,年前约的,但是由于是第一家面试,自己答的并不好,那结果显而易见,过不了。十一,十二没有约到面试,男朋友劝我说很多公司还没上班呢,告诉我不要慌,不过后来陆陆续续的每天大概两家公司的面试。面试期间继续保持着学习,也刷着各大招聘软件,加上一些朋友的内推。就这样,经历了两周的面试,2023年2月14日入职一家外企。(嘿嘿,顺便提一嘴,好好学英语。)


解答一下大家关于外企的疑问


1.     外企需要英语吗?

当然,不会英语怎么和国外的同事交流,总不能别人说什么你不懂,你想表达想法用中文吧,人家老外不懂中文。


2.     外企工资高吗?

我觉得工资还好,达到了我的期望薪资。我目前的公司大部分是中国人,但是组件库和重要项目的开发都在国外,回到第一个问题,涉及到组件库的问题就要和国外同事进行交流了。

3.     外企加班吗?

我目前加班很少,都是自愿主动加班的,不超过一小时,一个月加班时长不超8小时。

4.     外企福利待遇怎么样?

福利待遇超级棒,各种京东卡,礼品,下午茶,加班半小时就有的加班餐。今年双十一,感恩节都发了京东卡。

5.请假好请吗?

请假很好请。和领导说一声群里报备一下即可。

大家还有什么关于外企的疑问,欢迎评论


存款


去年定的存款目标没有完成,差了点,一些事情花掉了一些,明年继续加油。

现如今的经济环境,谁知道程序员最后的归宿是什么,好好存钱就是了。😊


生活


订婚


和男朋友在一起四年啦,感情到位,三观契合,父母支持,所以我们商量着在今年5月1号订婚,男朋友亲戚加上我这边的亲戚朋友简单的办了一场订婚宴。


出省旅游


今年事情比较多,没有去太远的地方玩,只去天津玩了一天。超级推荐天津海洋博物馆,超级出片的。



婚纱照


结婚前当然要拍婚纱照啦,想着北京一万拍出来也不一定好看,就选择回老家了,3000的套餐,40张精修,五套服装三内两外,划算又好看。



婚礼


2023年10月1日举办了婚礼,当天我盛装出席,迎接美好的生活的下一阶段。


在这个互联网上充斥着恐婚恐育的观念,离婚率居高不下的今天,或许站在婚姻的城前,你也踌躇不前或者悔不当初,但是就像我们常说的爱情是一场双向奔赴,其实婚姻何尝也不是一种相互包容呢。我们无需羡慕那个“从前车马慢,一生只爱一人。”的时代,只要我们去懂得看到对方的优点,认真经营自己的婚姻,其实每个人都可以找到那个可以携手一生的人。
现在已经结婚两个多月了,虽然身份转变了,但是好像和婚前也没什么大的区别,还是继续上班下班,期待周末的到来。



明年目标


1.存款达到xx(w)。


如何实现:
理性消费:注意自己的消费习惯,避免不必要的购物和娱乐消费。

定期存款:工资发下来留下当月的生活费,剩下的全部存起来。

兼职:有时间的话研究一下副业,做一些兼职。


2.过BEC中级


b站Bec中级视频:跟着b站视频先把考试内容和学习方法过一遍,每天保证一小时的英语学习时间。

单词:每天背20个单词,一定记得复习,不然第二天就忘了。

听力:听力多听多练,目前也没找到啥好的方法。


3.出省旅游两次


新疆:想去一趟新疆,感受一下那里的文化。

三亚:喜欢海,还想去免税店购物。


4.条件允许的事情下领证


没有领证主要是刚到新公司一年,休婚假不太好,加上职场对已婚女性不太友好,有点不敢领。


2024年也要继续加油呀!!!😊😊😊,新的一年里继续保持学习和成长的姿态,不断探索新的领域和挑战自己的能力极限。


作者:zhouzhouya
来源:juejin.cn/post/7309158128700424242
收起阅读 »

你的代码凌晨两点在干什么

如果服务器上有灯光根据负载高低进行闪烁,那到了夜里,一定会看到他们的服务器唰唰的闪着金光。 前两天跟朋友讨论技术,他说他们的服务器从凌晨零点开始,就开始跑各种各样的定时任务,基本上能跑到早晨5、6点钟。因为他们的业务属于访问量不大,但是数据量非常大,而且每天的...
继续阅读 »

如果服务器上有灯光根据负载高低进行闪烁,那到了夜里,一定会看到他们的服务器唰唰的闪着金光。


前两天跟朋友讨论技术,他说他们的服务器从凌晨零点开始,就开始跑各种各样的定时任务,基本上能跑到早晨5、6点钟。因为他们的业务属于访问量不大,但是数据量非常大,而且每天的数据要根据一些规则重新计算,所以就每天这么跑着。一到夜里,服务器负载比白天还高。


如果服务器上有灯光根据负载高低进行闪烁,那到了夜里,一定会看到他们的服务器唰唰的闪着金光。



说到这儿,我想到了之前的一件事儿。


有一天上午到公司不久,运维的同事悠悠的走过来,苦笑着说:“你们的代码凌晨两点在干什么,服务器都差点搞挂了”。


原来是因为一个定时任务(也是计算型的任务)开的线程太多了,之前由于计算量比较少,很快就结束了。那天由于业务调整,数据量一下子大了很多,线程又开的过多了,导致长时间负载过高,直接就给运维发了预警通知了。应用服务器还好,数据库服务器差点没顶住。


由于这些数据计算的时间长一点、短一点都没关系,所以后来把线程数减少了一些。


代码在凌晨到底在干什么


有一些场景是可以把定时任务放到凌晨来执行的。


夜里有一个特点,大多数的应用在夜里的流量都会比较低,也就是服务器的资源比较空闲,这个时候,正好可以将资源利用起来,执行一些逻辑。


而执行的这些逻辑有一个核心特点,那就是可以放到晚上执行,实时性要求不是很高的业务可以。


报表类统计


这个功能很常见了,不管是电商应用、社交应用等等,凡事有用户用的系统,将来一定会涉及到报表的场景。报表一般都包括对数据的总览,要出一张报表,可能会涉及到多张表,甚至多个数据库,关联的数据更是百万、千万,甚至上亿条。


那这样一来呢,如果是放在后台,用户到界面上进行实时生成的话,不仅老板不满意,测试同事还会给你提bug,说你的接口太慢了。


对于报表来说,看前一天的数据就足够了,没必要看到今天的数据, 所以放在夜里跑任务完全没问题,这时候你一条 SQL 执行1分钟、2分钟也没关系,只要不是太离谱就可以了。


数据清洗和计算


就像我那个朋友公司一样,他们的业务会涉及到大量的数据处理的工作,包括前期的数据处理,以及每天的重新计算,而且数据量很大。


这些清洗和计算也没有那么高的实时性,只要在当天跑完就可以了。但是如果你放到白天运行,就会影响到线上业务。要不然就得多弄几台单独的服务器跑,这样成本就上来了。


所以这样的场景,也可以放到夜里跑。


数据备份和同步


数据库备份、文件备份以及数据同步等任务可以在凌晨执行,这就很常识了。


补偿任务


有些业务,可能在正常运行的时候发生了异常,当然不能是主业务。一些旁路任务发生了异常,这时候,系统一般会写一条日志,记录异常发生的上下文,越详细越好,用于事后分析以及补偿操作。


等到夜里的时候,检查这种异常业务,根据异常发生时记录的上下文信息,进行二次处理。当然不能是发短信、发通知这种功能了,如果夜里给用户发短信,免不了要被投诉。


总结


几乎每一个系统都会有夜里执行的任务,这些任务的特点:



  1. 可以异步处理,不要求高的实时性,比如报表业务;

  2. 比较耗资源,比如大量计算、大容量的文件处理等;

  3. 要执行任务的服务器在夜里不能有太多正常线上业务,保证正常业务不被影响;


你们的代码在凌晨两点在干什么呢?


作者:古时的风筝
来源:juejin.cn/post/7305606652199125019
收起阅读 »

Android 实现自动滚动布局

前言 在平时的开发中,有时会碰到这样的场景,设计上布局的内容会比较紧凑,导致部分机型上某些布局中的内容显示不完全,或者在数据内容多的情况下,单行无法显示所有内容。这时如果要进行处理,无非就那几种方式:换行、折叠、缩小、截取内容、布局自动滚动等。而这里可以简单介...
继续阅读 »

前言


在平时的开发中,有时会碰到这样的场景,设计上布局的内容会比较紧凑,导致部分机型上某些布局中的内容显示不完全,或者在数据内容多的情况下,单行无法显示所有内容。这时如果要进行处理,无非就那几种方式:换行、折叠、缩小、截取内容、布局自动滚动等。而这里可以简单介绍下布局自动滚动的一种实现方式。


1. 布局自动滚动的思路


要实现滚动的效果,在Android中无非两种,吸附式的滚动或者顺滑式的滚动,吸附式就是类似viewpager换页的效果,如果需求上是要实现这样的效果,可以使用viewpager进行实现,这个类型比较简单,这里就不过多介绍。另一种是顺滑的,非常丝滑的缓慢移动的那种,要实现这种效果,可以使用RecyclerView或者ScrollView来实现。我这里主要使用ScrollView会简单点。


滑动的控件找到了,那要如何实现丝滑的自动滚动呢?我们都知道ScrollView能用scrollTo和scrollBy去让它滚动到某个位置,但如何去实现丝滑的效果?


这里就用到了属性动画, 我之前的文章也提到过属性动画的强大 juejin.cn/post/714419…


所以我这边会使用ScrollView和属性动画来实现这个效果


2. 最终效果


可以写个Demo来看看最终的效果


799d8a54-ed7d-4137-8a00-ad6bed2e2499.gif


这就是一个横向自动滚动的效果。


3. 代码实现


先写个接口定义自动滚动的行为


interface Autoscroll {

// 开始自动滚动
fun autoStart()

// 停止自动滚动
fun autoStop()

}

然后自定义一个View继承ScrollView,方便阅读,在代码中加了注释


// 自定义View继承HorizontalScrollView,我这里演示横向滚动的,纵向可以使用ScrollView
class HorizontalAutoscrollLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : HorizontalScrollView(context, attrs, defStyleAttr), Autoscroll {

// 一些流程上的变量,可以自己去定义,变量多的情况也可以使用builder模式
var isLoop = true // 滚动到底后,是否循环滚动
var loopDelay = 1000L // 滚动的时间
var duration = 1000L // 每一次滚动的间隔时间

private var offset: Int = 0
val loopHandler = Handler(Looper.getMainLooper())
var isAutoStart = false

private var animator: ValueAnimator? = null

override fun autoStart() {
// 需要计算滚动距离所以要把计算得代码写在post里面,等绘制完才拿得到宽度
post {
var childView = getChildAt(0)
childView?.let {
offset = it.measuredWidth - width
}

// 判断能否滑动,这里只判断了一个方向,如果想做两个方向的话,多加一个变量就行
if (canScrollHorizontally(1)) {
animator = ValueAnimator.ofInt(0, offset)
.setDuration(duration)
// 属性动画去缓慢改变scrollview的滚动位置,抽象上也可以说改变scrollview的属性
animator?.addUpdateListener {
val currentValue = it.animatedValue as Int
scrollTo(currentValue, 0)
}
animator?.addListener(object : Animator.AnimatorListener {
override fun onAnimationStart(animation: Animator) {

}

override fun onAnimationEnd(animation: Animator) {
// 动画结束后判断是否要重复播放
if (isLoop) {
loopHandler?.postDelayed({
if (isAutoStart) {
scrollTo(0, 0)
autoStart()
}
}, loopDelay)
}
}

override fun onAnimationCancel(animation: Animator) {

}

override fun onAnimationRepeat(animation: Animator) {

}

})
animator?.start()
isAutoStart = true
}

}
}

// 动画取消
override fun autoStop() {
animator?.cancel()
isAutoStart = false
loopHandler.removeCallbacksAndMessages(null)
}

}

能看到实现这个功能,写的代码不会很多。其中主要需要注意一些点:

(1)属性动画要熟,我这里只是简单的效果,但如果你对属性动画能熟练使用的话,你还可以做到加速、减速等效果

(2)页面关闭的时候要调用autoStop去关闭动画

(3)这里是用scrollTo去实现滚动的效果,scrollBy也可以,但是写法就不是这样了


从代码可以看出没什么难点,都是比较基础的知识,比较重要的知识就是属性动画,熟练的话做这种效果的上限就很高。其他的像这里为什么用post,为什么用scrollTo,这些就是比较基础的知识,就不扩展讲了。


最后看看使用的地方,先是Demo的布局


<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:gravity="center_vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">

<com.kylin.testproject.HorizontalAutoscrollLayout
android:id="@+id/auto_scroll"
android:layout_width="150dp"
android:layout_height="wrap_content">

<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
>

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="20sp"
android:text="小日本"
/>

<ImageView
android:layout_width="36dp"
android:layout_height="36dp"
android:scaleType="fitXY"
android:src="@drawable/a"
/>

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="20sp"
android:text="排放核废水污染海洋"
/>

<ImageView
android:layout_width="36dp"
android:layout_height="36dp"
android:scaleType="fitXY"
android:src="@drawable/b"
/>

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="20sp"
android:text=",必遭天谴!!"
/>

</LinearLayout>

</com.kylin.testproject.HorizontalAutoscrollLayout>

</LinearLayout>


然后在开始播放自动滚动(注意页面关闭的时候要手动停止)


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

val autoScroll: HorizontalAutoscrollLayout = findViewById(R.id.auto_scroll)
autoScroll.duration = 3000
autoScroll.loopDelay = 2000
autoScroll.autoStart()
}

4. 总结


代码比较简单,而且都加上了注释,所以没有其他要说明的。

前段时间太忙,所以这几个月都没时间写文章。想了一下,这个还是要坚持,如果有时间的话抽出点时间一天写一点,得保持一个常更新的状态。


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

这段代码目的太明显了

网友评论:@维妙伟小德:no data found@我叫程旭元叫我旭元就可以了:一个空的数据库你瞎查询啥呢@Laruence:ERROR 1045 (28000): Access denied for user ‘programmer’@浮夸先生Zz:你是想多...
继续阅读 »


网友评论:


@维妙伟小德:no data found

@我叫程旭元叫我旭元就可以了:一个空的数据库你瞎查询啥呢

@Laruence:ERROR 1045 (28000): Access denied for user ‘programmer’

@浮夸先生Zz:你是想多找一份工作么?

@你夏老师:是个女的就不错了,咋要求还这么高

作者:程序员的幽默
来源:mp.weixin.qq.com/s/JtdJBPpy-96STIe6WLYyIw
e>

收起阅读 »

没用的东西,你连个内存泄漏都排查不出来!!

web
背景 ui妹子的无理要求,我通通满足了。但是不出意外的话,意外就出来了。 此功能在上线之后,我们的业务在客户app内使用刷脸的时候会因为内存过高导致app将webview杀死。 然后我们leader爆了,让我排查问题。可是可是,我哪里会排查内存泄漏呀。 我:...
继续阅读 »

背景



  • ui妹子的无理要求,我通通满足了。但是不出意外的话,意外就出来了。

  • 此功能在上线之后,我们的业务在客户app内使用刷脸的时候会因为内存过高导致app将webview杀死。

  • 然后我们leader爆了,让我排查问题。可是可是,我哪里会排查内存泄漏呀。

  • 我:我不会。你自己不会上吗?你tm天天端个茶,抽个烟,翘个二郎腿,色眯眯的看着ui妹妹。

  • 领导:污蔑,你纯粹就是污蔑。我tm现在就可以让你滚蛋,你信吗?

  • 我:我怕你个鸟哦,我还不知道你啥水平,你tm能写出来个防抖节流,我就给你磕头。

  • 领导:hi~ui妹妹,今天过的好吗,来,哥哥这里有茶喝。(此时ui妹妹路过)。你赶快给我干活,以后ui妹妹留给你。

  • 艹!你早这么说不就好了。





开始学习


Chrome devTools查看内存情况




  • 打开Chrome的无痕模式,这样做的目的是为了屏蔽掉Chrome插件对我们之后测试内存占用情况的影响

  • 打开开发者工具,找到Performance这一栏,可以看到其内部带着一些功能按钮,例如:开始录制按钮;刷新页面按钮;清空记录按钮;记录并可视化js内存、节点、事件监听器按钮;触发垃圾回收机制按钮等




简单录制一下百度页面,看看我们能获得什么,如下动图所示:




从上图中我们可以看到,在页面从零到加载完成这个过程中JS Heap(js堆内存)、documents(文档)、Nodes(DOM节点)、Listeners(监听器)、GPU memoryGPU内存)的最低值、最高值以及随时间的走势曲线,这也是我们主要关注的点



看看开发者工具中的Memory一栏,其主要是用于记录页面堆内存的具体情况以及js堆内存随加载时间线动态的分配情况



堆快照就像照相机一样,能记录你当前页面的堆内存情况,每快照一次就会产生一条快照记录




如上图所示,刚开始执行了一次快照,记录了当时堆内存空间占用为33.7MB,然后我们点击了页面中某些按钮,又执行一次快照,记录了当时堆内存空间占用为32.5MB。并且点击对应的快照记录,能看到当时所有内存中的变量情况(结构、占总占用内存的百分比...)





在开始记录后,我们可以看到图中右上角有起伏的蓝色与灰色的柱形图,其中蓝色表示当前时间线下占用着的内存;灰色表示之前占用的内存空间已被清除释放



在得知有内存泄漏的情况存在时,我们可以改用Memory来更明确得确认问题和定位问题


首先可以用Allocation instrumentation on timeline来确认问题,如下图所示:



内存泄漏的场景



  • 闭包使用不当引起内存泄漏

  • 全局变量

  • 分离的DOM节点

  • 控制台的打印

  • 遗忘的定时器


1. 闭包使用不当引起内存泄漏


使用PerformanceMemory来查看一下闭包导致的内存泄漏问题


<button onclick="myClick()">执行fn1函数button>
<script>
function fn1 () {
let a = new Array(10000) // 这里设置了一个很大的数组对象

let b = 3

function fn2() {
let c = [1, 2, 3]
}

fn2()

return a
}

let res = []

function myClick() {
res.
push(fn1())
}
script>


在退出fn1函数执行上下文后,该上下文中的变量a本应被当作垃圾数据给回收掉,但因fn1函数最终将变量a返回并赋值给全局变量res,其产生了对变量a的引用,所以变量a被标记为活动变量并一直占用着相应的内存,假设变量res后续用不到,这就算是一种闭包使用不当的例子



设置了一个按钮,每次执行就会将fn1函数的返回值添加到全局数组变量res中,是为了能在performacne的曲线图中看出效果,如图所示:




  • 在每次录制开始时手动触发一次垃圾回收机制,这是为了确认一个初始的堆内存基准线,便于后面的对比,然后我们点击了几次按钮,即往全局数组变量res中添加了几个比较大的数组对象,最后再触发一次垃圾回收,发现录制结果的JS Heap曲线刚开始成阶梯式上升的,最后的曲线的高度比基准线要高,说明可能是存在内存泄漏的问题

  • 在得知有内存泄漏的情况存在时,我们可以改用Memory来更明确得确认问题和定位问题

  • 首先可以用Allocation instrumentation on timeline来确认问题,如下图所示:




  • 在我们每次点击按钮后,动态内存分配情况图上都会出现一个蓝色的柱形,并且在我们触发垃圾回收后,蓝色柱形都没变成灰色柱形,即之前分配的内存并未被清除

  • 所以此时我们就可以更明确得确认内存泄漏的问题是存在的了,接下来就精准定位问题,可以利用Heap snapshot来定位问题,如图所示:




  • 第一次先点击快照记录初始的内存情况,然后我们多次点击按钮后再次点击快照,记录此时的内存情况,发现从原来的1.1M内存空间变成了1.4M内存空间,然后我们选中第二条快照记录,可以看到右上角有个All objects的字段,其表示展示的是当前选中的快照记录所有对象的分配情况,而我们想要知道的是第二条快照与第一条快照的区别在哪,所以选择Object allocated between Snapshot1 and Snapshot2即展示第一条快照和第二条快照存在差异的内存对象分配情况,此时可以看到Array的百分比很高,初步可以判断是该变量存在问题,点击查看详情后就能查看到该变量对应的具体数据了


以上就是一个判断闭包带来内存泄漏问题并简单定位的方法了


2. 全局变量


全局的变量一般是不会被垃圾回收掉的当然这并不是说变量都不能存在全局,只是有时候会因为疏忽而导致某些变量流失到全局,例如未声明变量,却直接对某变量进行赋值,就会导致该变量在全局创建,如下所示:


function fn1() {
// 此处变量name未被声明
name = new Array(99999999)
}

fn1()


  • 此时这种情况就会在全局自动创建一个变量name,并将一个很大的数组赋值给name,又因为是全局变量,所以该内存空间就一直不会被释放

  • 解决办法的话,自己平时要多加注意,不要在变量未声明前赋值,或者也可以开启严格模式,这样就会在不知情犯错时,收到报错警告,例如


function fn1() {
'use strict';
name = new Array(99999999)
}

fn1()

3. 分离的DOM节点


假设你手动移除了某个dom节点,本应释放该dom节点所占用的内存,但却因为疏忽导致某处代码仍对该被移除节点有引用,最终导致该节点所占内存无法被释放,例如这种情况


<div id="root">
<div class="child">我是子元素div>
<button>移除button>
div>
<script>
let btn = document.querySelector('button')
let child = document.querySelector('.child')
let root = document.querySelector('#root')

btn.
addEventListener('click', function() {
root.
removeChild(child)
})
script>


该代码所做的操作就是点击按钮后移除.child的节点,虽然点击后,该节点确实从dom被移除了,但全局变量child仍对该节点有引用,所以导致该节点的内存一直无法被释放,可以尝试用Memory的快照功能来检测一下,如图所示





同样的先记录一下初始状态的快照,然后点击移除按钮后,再点击一次快照,此时内存大小我们看不出什么变化,因为移除的节点占用的内存实在太小了可以忽略不计,但我们可以点击第二条快照记录,在筛选框里输入detached,于是就会展示所有脱离了却又未被清除的节点对象



解决办法如下图所示:


<div id="root">
<div class="child">我是子元素div>
<button>移除button>
div>
<script>
let btn = document.querySelector('button')

btn.
addEventListener('click', function() {
let child = document.querySelector('.child')
let root = document.querySelector('#root')

root.
removeChild(child)
})

script>


改动很简单,就是将对.child节点的引用移动到了click事件的回调函数中,那么当移除节点并退出回调函数的执行上文后就会自动清除对该节点的引用,那么自然就不会存在内存泄漏的情况了,我们来验证一下,如下图所示:




结果很明显,这样处理过后就不存在内存泄漏的情况了


4. 控制台的打印


<button>按钮button>
<script>
document.querySelector('button').addEventListener('click', function() {
let obj = new Array(1000000)

console.log(obj);
})
script>

我们在按钮的点击回调事件中创建了一个很大的数组对象并打印,用performance来验证一下




开始录制,先触发一次垃圾回收清除初始的内存,然后点击三次按钮,即执行了三次点击事件,最后再触发一次垃圾回收。查看录制结果发现JS Heap曲线成阶梯上升,并且最终保持的高度比初始基准线高很多,这说明每次执行点击事件创建的很大的数组对象obj都因为console.log被浏览器保存了下来并且无法被回收



接下来注释掉console.log,再来看一下结果:


<button>按钮button>
<script>
document.querySelector('button').addEventListener('click', function() {
let obj = new Array(1000000)

// console.log(obj);
})
script>


可以看到没有打印以后,每次创建的obj都立马被销毁了,并且最终触发垃圾回收机制后跟初始的基准线同样高,说明已经不存在内存泄漏的现象了


其实同理 console.log也可以用Memory来进一步验证


未注释 console.log



注释掉了console.log




最后简单总结一下:在开发环境下,可以使用控制台打印便于调试,但是在生产环境下,尽可能得不要在控制台打印数据。所以我们经常会在代码中看到类似如下的操作:



// 如果在开发环境下,打印变量obj
if(isDev) {
console.log(obj)
}


这样就避免了生产环境下无用的变量打印占用一定的内存空间,同样的除了console.log之外,console.errorconsole.infoconsole.dir等等都不要在生产环境下使用



5. 遗忘的定时器



定时器也是平时很多人会忽略的一个问题,比如定义了定时器后就再也不去考虑清除定时器了,这样其实也会造成一定的内存泄漏。来看一个代码示例:



<button>开启定时器button>
<script>

function fn1() {
let largeObj = new Array(100000)

setInterval(() => {
let myObj = largeObj
},
1000)
}

document.querySelector('button').addEventListener('click', function() {
fn1()
})
script>

这段代码是在点击按钮后执行fn1函数,fn1函数内创建了一个很大的数组对象largeObj,同时创建了一个setInterval定时器,定时器的回调函数只是简单的引用了一下变量largeObj,我们来看看其整体的内存分配情况吧:



按道理来说点击按钮执行fn1函数后会退出该函数的执行上下文,紧跟着函数体内的局部变量应该被清除,但图中performance的录制结果显示似乎是存在内存泄漏问题的,即最终曲线高度比基准线高度要高,那么再用Memory来确认一次:




  • 在我们点击按钮后,从动态内存分配的图上看到出现一个蓝色柱形,说明浏览器为变量largeObj分配了一段内存,但是之后这段内存并没有被释放掉,说明的确存在内存泄漏的问题,原因其实就是因为setInterval的回调函数内对变量largeObj有一个引用关系,而定时器一直未被清除,所以变量largeObj的内存也自然不会被释放

  • 那么我们如何来解决这个问题呢,假设我们只需要让定时器执行三次就可以了,那么我们可以改动一下代码:


<button>开启定时器button>
<script>
function fn1() {
let largeObj = new Array(100000)
let index = 0

let timer = setInterval(() => {
if(index === 3) clearInterval(timer);
let myObj = largeObj
index ++
},
1000)
}

document.querySelector('button').addEventListener('click', function() {
fn1()
})
script>

现在我们再通过performancememory来看看还不会存在内存泄漏的问题



  • performance




这次的录制结果就能看出,最后的曲线高度和初始基准线的高度一样,说明并没有内存泄漏的情况




  • memory



这里做一个解释,图中刚开始出现的蓝色柱形是因为我在录制后刷新了页面,可以忽略;然后我们点击了按钮,看到又出现了一个蓝色柱形,此时就是为fn1函数中的变量largeObj分配了内存,3s后该内存又被释放了,即变成了灰色柱形。所以我们可以得出结论,这段代码不存在内存泄漏的问题



简单总结一下: 大家在平时用到了定时器,如果在用不到定时器后一定要清除掉,否则就会出现本例中的情况。除了setTimeoutsetInterval,其实浏览器还提供了一个API也可能就存在这样的问题,那就是requestAnimationFrame




  • 好了好了,学完了,ui妹妹我来了






  • ui妹妹:去你m的,滚远点





好了兄弟们,内存泄漏学会了吗?


作者:顾昂_
来源:juejin.cn/post/7309040097936474175
收起阅读 »

Flutter 日记APP-开篇

序言 在跟着wendux大佬的书学习flutter后,开始着手写个app进行实战。考虑到没有服务器,所以主要写工具类,无网络交互的app。之前看了《小狗钱钱》这本书,里面的梦想笔记让我印象深刻,便开始着手写一个记录自己梦想笔记的app。 App 构想 创建自...
继续阅读 »

序言


在跟着wendux大佬的书学习flutter后,开始着手写个app进行实战。考虑到没有服务器,所以主要写工具类,无网络交互的app。之前看了《小狗钱钱》这本书,里面的梦想笔记让我印象深刻,便开始着手写一个记录自己梦想笔记的app。


App 构想



  1. 创建自己的梦想

    1.1 梦想内容和描述

    1.2 梦想日记提醒时间,开启后会设置闹钟定时提醒

  2. 创建梦想日记

    2.1 日记标题和内容

    2.2 为了方便日记输入,接入苹果的文本扫描功能

    2.3 日记每天可多次添加或修改

  3. 日记走势

    3.1 根据每天记录的日记数量进行统计,展示一个charts图

  4. 设置功能

    4.1 支持日夜模式

    4.2 支持国际化语言切换


目前大概就这些后面准备持续更新日记内容,比如新增记账日记,记录每一笔开销和收入,然后统计每月的开销和收入,让自己对于自己的账目管理更加一目了然;还有行程记录,比如出行提醒,旅游日记等等。为了后面更好的兼容,在开始构建的时候会预留相应的字段。


App 三方选择



  1. get

    状态管理、国际化、皮肤管理于一体的三方库。当然还有其他功能,目前app比较简单仅使用这些。在选择的时候也在犹豫,要不要用BlocProvider,相对来说,另外两个三方要更加轻量一些,provider的侵入性也没有那么强。最后选择get是考虑到国际化管理和换肤等,使用get一步到位。比如国际化通常会用intl

  2. sqflite

    用于数据存储,把日记都保存到本地数据库进行缓存。

  3. shared_preferences
    本地轻量数据缓存,主要是用来存语言国际化等配置信息。

  4. easy_refresh

    上拉刷新,下拉加载

  5. fluttertoast

    Toast 弹窗,需要注意如果兼容其他平台(window)的话需要传入context。


剩下的就是更新库到本地,传统技艺:put get


基本上就是用了这些,可以说麻雀虽小,五脏俱全。后面会持续分享app的开发进度,和一些开发中遇到的问题。


作者:WhiteMonkey
来源:juejin.cn/post/7309158214481772553
收起阅读 »

【Flutter技术】如何识别一个应用是原生框架开发还是Flutter开发?

前言 根据Google官方的统计,截至2023年5月,已有超过100万个应用程序使用Flutter发布到数亿台设备上。国内许多知名公司也广泛的使用了Flutter技术,例如,腾讯、字节、阿里等等。Flutter也成为了最受欢迎的跨平台技术之一。 当前,我们手机...
继续阅读 »

前言


根据Google官方的统计,截至2023年5月,已有超过100万个应用程序使用Flutter发布到数亿台设备上。国内许多知名公司也广泛的使用了Flutter技术,例如,腾讯、字节、阿里等等。Flutter也成为了最受欢迎的跨平台技术之一。


当前,我们手机上以及各大应用市场有大量的应用采用了Flutter跨平台技术框架,例如,微信、微博、闲鱼等等。由于Flutter框架出色的性能表现,可能不被大家所感知,接下来,跟大家分享几个鉴别一个APP是否使用了Flutter开发的方法。


1 - 双指滚动


一个比较方便、且非常快速的方法就是打开一个可滚动的页面(可以用闲鱼的商品详情页面测试),用双指或者三指滑动,如果滚动的速度加快,是用单指滚动的两倍或者三倍,那么这个页面基本可以确定是用Flutter开发的。大家可以打开手机上应用尝试一下,例如闲鱼商品的详情页面。


这方法的原理是源于Flutter的一个祖传BUG ---> [#11884] Scrolling with two fingers scrolls twice as fast,在Android和iOS平台都是如此的变现,因此可以用来检查应用是否使用了Flutter开发。


当笔者看到这个BUG之后,惊掉了下巴(2017年的ISSUE,2023年才被修复!),于是尝试修复了这个BUG ---> [#136708] Introduce multi-touch drag strategies for DragGestureRecognizer,该Patch引入了一个新的属性MultitouchDragStrategy,可以自定义多指滚动的行为,同时将系统默认行为修改为Android的表现,并计划很快补充iOS的行为。


image.png


该Patch当前已经合入master分支,在release到stable分支之前,通过双指滚动来鉴定是否使用Flutter开发依然是最便捷的方法 :)


2 - 显示布局边界 + dumpsys activity


该方法适用于Android手机,打开手机的“开发者模式”,在设置页面搜索“边界”,找到“显示布局边界”并打开:


drawing
drawing

对于原生开发的Android应用,可以查看到所有元素的边界,例如闲鱼首页采用原生开发:


drawing


当进入分类页面之后,除了手机SystemUI可以看见元素的边界,页面内容的元素系统是识别不到边界的:


drawing


此时,我们通过adb shell命令dumpsys activity activities,可以看到TOP的Activity是MainActivity


drawing

以上识别的原理为:Flutter projects的默认入口为MainActivity,它又继承于FlutterActivity,而它的内部实现为SurfaceView,Flutter通过canvas自绘所有控件,正因为如此,Android是无法识别FlutterView里的元素边界的。


3 - 日志


一般来说,集成了Flutter框架的应用,在log里会有相关Flutter日志的输出,例如,我们在操作 微信 的时候,logcat里会有flutter日志的输出:


Image_20231206105420.png


Image_20231206105403.png


4 - 安装包文件


我们也可以通过安装包里是否集成了Flutter相关lib来推断是否使用了Flutter框架


以Android手机为例:


1,提取apk,方法如下:
# 首先确保已经将ADB工具添加到系统路径中
$ adb devices # 查看设备列表,确认设备正常连接

#
然后使用以下命令获取APK的位置信息
$ adb shell pm path com.example.appname
package:/data/app/com.example.appname-1234567890abcdefg/base.apk

#
最后使用以下命令复制APK到计算机上指定目录
$ adb pull /data/app/com.example.appname-1234567890abcdefg/base.apk ~/Desktop/my_app.apk

提取了apk文件之后,可以通过7-zip提取解压,然后搜索‘flutter’相关文件,如果使用了Flutter框架,会有flutter相关lib文件(闲鱼APK):


image.png


image.png


4 - FlutterShark


image.png


可以在Android手机上安装FlutterShark应用,在赋予它QUERY_ALL_PACKAGES权限后,他可以展示手机中所有使用了Flutter框架的应用:


Screenshot_2023-12-06-15-03-55-64[1].png


同时,FlutterShark还支持显示某个应用所依赖的三方package:


image.png


总结


以上跟大家分享了几种识别Flutter应用的方法,如果你还知道有其它的方法,请在评论区留言吧 : )


作者长期活跃在Flutter开源社区,欢迎大家一起参与开源社区的共建,如果您也有意愿参与Flutter社区的贡献,可以与作者联系。-->GITHUB


您也许还对这些Flutter技术分享感兴趣:



作者:xubaolin
来源:juejin.cn/post/7309065017191088143
收起阅读 »

反钓鱼防盗号,共筑校园安全防线!Coremail出席CERNET学术年会

11月27日-30日,中国教育和科研计算机网CERNET第二十八/二十九届学术年会在福州隆重举办,Coremail受邀出席,就高校数字化及网络安全等相关话题与高校老师、行业专家进行广泛交流。△11月27-30日,Coremail在会场设展,为嘉宾介绍邮件新技术...
继续阅读 »

11月27日-30日,中国教育和科研计算机网CERNET第二十八/二十九届学术年会在福州隆重举办,Coremail受邀出席,就高校数字化及网络安全等相关话题与高校老师、行业专家进行广泛交流。

△11月27-30日,Coremail在会场设展,为嘉宾介绍邮件新技术、安全新态势。

11月27日,中国教育和科研计算机网CERNET第二十八/二十九届学术年会技术报告开讲。Coremail副总裁吴秀诚与与会专家分享了《校园邮箱与邮件数据安全的信创探索与应用》,针对校园邮箱面临的困境及钓鱼邮件进行深度剖析。

吴总介绍,校园邮箱面临着邮件账号被盗风险高、邮件安全意识亟待提升、国际交流困难重重、邮件安全系统维护难度高四大问题。

根据Coremail安全数据中心监测,教育行业位于钓鱼邮件接收量之首,而校园邮箱由于用户量大,意识单纯,往往容易被钓鱼邮件迷惑,从而造成损失,因此开展钓鱼演练势在必行!近期,教育主管部门也正式发文,明确要求高校开展钓鱼邮件专项演练,将反钓鱼工作防范于未然!

Coremail一直致力于校园邮件安全探索和防护,根据学校管理方与用户方的需求特点,提供更具有针对性的校园邮解决方案,截至目前,Coremail累计服务高校近400所,具有丰富的高校邮箱实践经验。

Coremail 反钓鱼演练囊括超链接钓鱼、恶意附件钓鱼和二维码钓鱼三大类手法,近100个基本场景模板,包括但不限于“密码修改”、“薪资调整”、“系统升级”等热门主题,可以结合客户实际情况与时事热点进行定制主题,部分场景还可根据客户的自身情况和具体要求深度定制。并对报告及时反馈分析,帮助管理员对数据的洞察和后续改进措施的实施。

吴总提出,筑牢校园信息安全防线,不仅需要钓鱼演练提升师生安全意识,守护好账号安全也是很有必要的,Coremail邮件解决方案配备二次验证和客户端专用密码,能有效应对暴力破解等恶意攻击。

在国产化方面,Coremail紧跟国家步伐,完成广泛兼容,支持信创和非信创环境,适配目前主流的信创服务器、CPU、操作系统、数据库、中间件,通过对不同软硬件环境进行编译适配,实现全栈国产化。高校可根据实际需要进行部分基础软硬件的国产化替换,Coremail邮箱系统可提供适配的信创版本。

据悉,本届年会由CERNET管理委员会指导,CERNET网络中心主办,福州大学承办,CERNET专家委员会和福建省教育厅协办。年会为期4天,主题为“IPv6下一代互联网:关键技术研发、应用融合创新”。期间还举办了中国高校CIO、网络安全、IPv6技术、无线和智慧校园、互联网超算、IPv6应用、数据治理与资源共享、网络运行管理等多个分论坛。

未来,Coremail将与高校携手,护航邮箱安全建设,共谋校园安全发展趋势,推动教育+信创的融合创新,赋能教育数字化转型高质量发展!


收起阅读 »

以为 flv.js 直播超简单,结果被延迟和卡顿整疯了

web
大家好,我是杨成功。 之前写过一篇浏览器直播的文章,叫《用一个 flv.js 播放监控的例子,带你深撅直播流技术》。这片文章的热度还不错,主要内容就是科普直播是什么,以及如何在浏览器中播放直播。 实现方法很简单,使用一个流行的第三方包 flv.js,即可快速播...
继续阅读 »

大家好,我是杨成功。


之前写过一篇浏览器直播的文章,叫《用一个 flv.js 播放监控的例子,带你深撅直播流技术》。这片文章的热度还不错,主要内容就是科普直播是什么,以及如何在浏览器中播放直播。


实现方法很简单,使用一个流行的第三方包 flv.js,即可快速播放直播。


在我们的项目中也使用这种方式,比如播放海康监控器的直播、教学直播等都可以正常播放。然而在产品成熟后,我们发现直播中有两个致命问题:



  1. 直播延迟,播越久延迟越高。

  2. 直播卡顿,无法判断什么时候卡顿。


解决上述两个问题是直播稳定性和可用性的关键,下面就来详解一下。


抗延迟关键 —— “追帧”


使用 flv.js 直播,需要一个 标签承载直播画面。默认情况下 video 标签用于播放点播(录制好的)视频,因此它会一边播放一边下载。


点播不要求实时性,暂停之后再继续播放,视频会接着暂停的画面继续播放;而如果是直播,暂停后继续播放时必须切换到最新的画面帧,这就是 “追帧” 的概念。


一图胜千言,不追帧的效果是这样的:


iShot_2023-11-07_11.29.55.gif


追帧的效果是这样的:


iShot_2023-11-07_11.44.16.gif


可以看到,设置追帧后的暂停重播,会立即切换到最新的画面。


在实际场景中,直播没有暂停按钮,但是常常会因为网络问题卡顿。如果卡顿恢复后视频没有追帧,就会导致直播延迟越来越高。


使用 mpegts.js 替代 flv.js


据传说,flv.js 的作者是一个高中毕业在 B 站上班的小伙子,月薪仅仅不到 5k。后来小伙离职去了日本,无法更新 flv.js,于是有了 mpegts.js。


目前 flv.js 已停止维护,mpegts.js 是其升级版,开发者是同一个人。涉及到追帧的高级功能,mpegts.js 支持的更好。在 flv.js 主页也可以看到推荐:


image.png


mpegts.js 的用法与 flv.js 基本一致,如下:


import mpegts from 'mpegts.js';

let config = {};
let player = mpegts.createPlayer(
{
type: 'flv',
isLive: true,
url: 'http://xxxx.flv',
},
config,
);

mpegts.js 提供了自动追帧的配置项 liveBufferLatencyChasing,开启自动追帧方法如下:


let config = {
liveBufferLatencyChasing: true,
};

设置自动追帧后,虽然延迟问题解决了,但画面可能会更加卡顿。这里涉及到 IO 缓存的问题。


配置 IO 缓存,优化追帧卡顿


首先思考一个问题:直播的延迟越低越好吗?


从需求上讲,当然是越低越好;可从技术上讲,并不是越低越好。


直播是实时流,从远端拉流并实时解码播放,但这个过程极容易受到网络影响。不管是推流端或拉流端遇到了网路抖动,数据传输受阻,直播必然会卡顿,这个是正常现象。


怎么办呢?这个时候就要用到 IO 缓存,牺牲一点实时性,用延迟换取流畅。


假设播放器缓存了 1 秒的数据流,并将直播延迟 1 秒播放。当遇到网络抖动时,播放器会读取缓存数据继续播放,网络恢复后再向缓冲区追加数据,这样用户在看直播时,完全感受不到卡顿。


但如果网络异常时间超过 1 秒,缓冲区中的数据读取完毕,直播还是会卡住;如果加大缓存量,缓存了 3 秒的数据,这又会导致直播延迟过高。


所以,设置缓存可以有效解决追帧卡顿问题;若要在保证流畅的前提下,尽可能地降低延迟,则需要一个合理的缓存值。


mpegts.js 提供了 liveBufferLatencyMaxLatencyliveBufferLatencyMinRemain 两个配置项来控制缓存时间,分别表示最大缓存时间和最小缓存时间,单位为秒。


以下方配置为例,缓存时间设置越长、流畅性越好、延迟越高:


let config = {
liveBufferLatencyChasing: true, // 开启追帧
liveBufferLatencyMaxLatency: 0.9, // 最大缓存时间
liveBufferLatencyMinRemain: 0.2, // 最小缓存时间
};

实际的缓存时间会根据网络情况动态变化,值的范围在上述两个配置项之间。


处理卡顿关键 —— “断流检测”


直播是实时流播放,任何一个环节出现异常,都会导致直播卡顿、出现黑屏等现象。这是因为实时拉取的流数据断开了,我们称之为“断流”。


多数情况下的断流都是网络原因导致,此时可能需要提醒用户“当前网络拥堵”、或者显示“直播加载中”的字样,告诉用户发生了什么。


而实现这些功能的前提,必须要知道流什么时候断开,我们就需要做“断流检测”。


mpegts.js 提供了几个内置事件来监听直播的状态,常用如下:



  • mpegts.Events.ERROR:出现异常事件。

  • mpegts.Events.LOADING_COMPLETE:流结束事件。

  • mpegts.Events.STATISTICS_INFO:流状态变化事件。


前两个事件分别会在出现异常和直播结束的时候触发,监听方法如下:


let player = mpegts.createPlayer({...})

player.on(mpegts.Events.ERROR, e=> {
console.log('发生异常')
});
player.on(mpegts.Events.LOADING_COMPLETE, (e) => {
console.log("直播已结束");
});

当未发生异常、且直播未结束的情况下,我们就需要监听直播卡顿。通过监听 STATISTICS_INFO 事件来实现。


首先科普一下:播放器在播放直播时需要实时解码,每一帧画面过来,就需要解码一次。当直播卡顿时,没有画面过来,解码也会暂停,因此可以通过已解码的帧数来判断是否卡顿。


STATISTICS_INFO 事件的回调函数参数中,有一个 decodedFrames 属性,正是表示当前已解码的帧数,我们来看一下:


player.on(mpegts.Events.STATISTICS_INFO, (e) => {
console.log("解码帧:"e.decodedFrames); // 已经解码的帧数
});

在直播过程中,上述回调函数会一直执行,打印结果如下:


image-1.png


可以看到,解码帧一直在递增,表示直播正常。当直播卡顿时,打印结果是这样的:


2023-11-08-21-17-53.png


解码帧连续 9 次卡在了 904 这个值不变,这是因为直播卡顿了,没有画面需要解码。


所以,判断卡顿的方法是将上一次的解码帧与当前解码帧做对比,如果值一致则出现了卡顿。


当然轻微的卡顿不需要处理。我们可以将连续 N 次出现相同的解码帧视为一次卡顿,然后执行自己的业务逻辑。


当解码帧的值长时间没有变化时,我们可以视为推流已结束,此时可以主动结束直播。


作者:杨成功
来源:juejin.cn/post/7299037876636663847
收起阅读 »

用一个 flv.js 播放监控的例子,带你深撅直播流技术

web
大家好,我是杨成功。 本文记录一下在使用 flv.js 播放监控视频时踩过的各种各样的坑。虽然官网给的 Getting Started 只有短短几行代码,跑一个能播视频的 demo 很容易,但是播放时各种各样的异常会搞到你怀疑人生。 究其原因,一方面 GitH...
继续阅读 »

大家好,我是杨成功。


本文记录一下在使用 flv.js 播放监控视频时踩过的各种各样的坑。虽然官网给的 Getting Started 只有短短几行代码,跑一个能播视频的 demo 很容易,但是播放时各种各样的异常会搞到你怀疑人生。


究其原因,一方面 GitHub 上文档比较晦涩,说明也比较简陋;另一方面是受“视频播放”思维的影响,没有对的足够认识以及缺乏处理流的经验。


下面我将自己踩过的坑,以及踩坑过程中补充的相关知识,详细总结一下。


大纲预览


本文介绍的内容包括以下方面:



  • 直播与点播

  • 静态数据与流数据

  • 为什么选 flv?

  • 协议与基础实现

  • 细节处理要点

  • 样式定制


点播与直播


啥是直播?啥是点播?


直播就不用说了,抖音普及之下大家都知道直播是干嘛的。点播其实就是视频播放,和咱们哔哩哔哩看视频一摸一样没区别,就是把提前做好的视频放出来,就叫点播。


点播对于我们前端来说,就是拿一个 mp4 的链接地址,放到 video 标签里面,浏览器会帮我们处理好视频解析播放等一些列事情,我们可以拖动进度条选择想看的任意一个时间。


但是直播不一样,直播有两个特点:



  1. 获取的是流数据

  2. 要求实时性


先看一下什么叫流数据。大部分没有做过音视频的前端同学,我们常接触的数据就是 ajax 从接口获取的 json 数据,特别一点的可能是文件上传。这些数据的特点是,它们都属于一次性就能拿到的数据。我们一个请求,一个响应,完整的数据就拿回来了。


但是流不一样,流数据获取是一帧一帧的,你可以理解为是一小块一小块的。像直播流的数据,它并不是一个完整的视频片段,它就是很小的二进制数据,需要你一点一点的拼接起来,才有可能输出一段视频。


再看它的实时性。如果是点播的话,我们直接将完整的视频存储在服务器上,然后返回链接,前端用 video 或播放器播就行了。但是直播的实时性,就决定了数据源不可能在服务器上,而是在某一个客户端。


数据源在客户端,那么又是怎么到达其他客户端的呢?


这个问题,请看下面这张流程图:


Untitled Diagram.drawio (7).png


如图所示,发起直播的客户端,向上连着流媒体服务器,直播产生的视频流会被实时推送到服务端,这个过程叫做推流。其他客户端同样也连接着这个流媒体服务器,不同的是它们是播放端,会实时拉取直播客户端的视频流,这个过程叫做拉流


推流—> 服务器-> 拉流,这是目前流行的也是标准的直播解决方案。看到了吧,直播的整个流程全都是流数据传输,数据处理直面二进制,要比点播复杂了几个量级。


具体到我们业务当中的摄像头实时监控预览,其实和上面的完全一致,只不过发起直播的客户端是摄像头,观看直播的客户端是浏览器而已。


静态数据与流数据


我们常接触的文本,json,图片等等,都属于静态数据,前端用 ajax 向接口请求回来的数据就是静态数据。


像上面说到的,直播产生的视频和音频,都属于流数据。流数据是一帧一帧的,它的本质是二进制数据,因为很小,数据像水流一样连绵不断的流动,因此非常适合实时传输。


静态数据,在前端代码中有对应的数据类型,比如 string,json,array 等等。那么流数据(二进制数据)的数据类型是什么?在前端如何存储?又如何操作?


首先明确一点,前端是可以存储和操作二进制的。最基本的二进制对象是 ArrayBuffer,它表示一个固定长度,如:


let buffer = new ArrayBuffer(16) // 创建一个 16 字节 的 buffer,用 0 填充
alert(buffer.byteLength) // 16

ArrayBuffer 只是用于存储二进制数据,如果要操作,则需要使用 视图对象


视图对象,不存储任何数据,作用是将 ArrayBuffer 的数据做了结构化的处理,便于我们操作这些数据,说白了它们是操作二进制数据的接口。


视图对象包括:



  • Uint8Array:每个 item 1 个字节

  • Uint16Array:每个 item 2 个字节

  • Uint32Array:每个 item 4 个字节

  • Float64Array:每个 item 8 个字节


按照上面的标准,一个 16 字节 ArrayBuffer,可转化的视图对象和其长度为:



  • Uint8Array:长度 16

  • Uint16Array:长度 8

  • Uint32Array:长度 4

  • Float64Array:长度 2


这里只是简单介绍流数据在前端如何存储,为的是避免你在浏览器看到一个长长的 ArrayBuffer 不知道它是什么,记住它一定是二进制数据。


为什么选 flv?


前面说到,直播需要实时性,延迟当然越短越好。当然决定传输速度的因素有很多,其中一个就是视频数据本身的大小。


点播场景我们最常见的 mp4 格式,对前端是兼容性最好的。但是相对来说 mp4 的体积比较大,解析会复杂一些。在直播场景下这就是 mp4 的劣势。


flv 就不一样了,它的头部文件非常小,结构简单,解析起来又块,在直播的实时性要求下非常有优势,因此它成了最常用的直播方案之一。


当然除了 flv 之外还有其他格式,对应直播协议,我们一一对比一下:



  • RTMP: 底层基于 TCP,在浏览器端依赖 Flash。

  • HTTP-FLV: 基于 HTTP 流式 IO 传输 FLV,依赖浏览器支持播放 FLV。

  • WebSocket-FLV: 基于 WebSocket 传输 FLV,依赖浏览器支持播放 FLV。

  • HLS: Http Live Streaming,苹果提出基于 HTTP 的流媒体传输协议。HTML5 可以直接打开播放。

  • RTP: 基于 UDP,延迟 1 秒,浏览器不支持。


其实早期常用的直播方案是 RTMP,兼容性也不错,但是它依赖 Flash,而目前浏览器下 Flash 默认是被禁用的状态,已经被时代淘汰的技术,因此不做考虑。


HLS 协议也很常见,对应视频格式就是 m3u8。它是由苹果推出,对手机支持非常好,但是致命缺点是延迟高(10~30 秒),因此也不做考虑。


RTP 不必说,浏览器不支持,剩下的就只有 flv 了。


但是 flv 又分为 HTTP-FLVWebSocket-FLV,它两看着像兄弟,又有什么区别呢?


前面我们说过,直播流是实时传输,连接创建后不会断,需要持续的推拉流。这种需要长连接的场景我们首先想到的方案自然是 WebSocket,因为 WebSocket 本来就是长连接实时互传的技术。


不过呢随着 js 原生能力扩展,出现了像 fetch 这样比 ajax 更强的黑科技。它不光支持对我们更友好的 Promise,并且天生可以处理流数据,性能很好,而且使用起来也足够简单,对我们开发者来说更方便,因此就有了 http 版的 flv 方案。


综上所述,最适合浏览器直播的是 flv,但是 flv 也不是万金油,它的缺点是前端 video 标签不能直接播放,需要经过处理才行。


处理方案,就是我们今天的主角:flv.js


协议与基础实现


前面我们说到,flv 同时支持 WebSocket 和 HTTP 两种传输方式,幸运的是,flv.js 也同时支持这两种协议。


选择用 http 还是 ws,其实功能和性能上差别不大,关键看后端同学给我们什么协议吧。我这边的选择是 http,前后端处理起来都比较方便。


接下来我们介绍 flv.js 的具体接入流程,官网在这里


假设现在有一个直播流地址:http://test.stream.com/fetch-media.flv,第一步我们按照官网的快速开始建一个 demo:


import flvjs from 'flv.js'
if (flvjs.isSupported()) {
var videoEl = document.getElementById('videoEl')
var flvPlayer = flvjs.createPlayer({
type: 'flv',
url: 'http://test.stream.com/fetch-media.flv'
})
flvPlayer.attachMediaElement(videoEl)
flvPlayer.load()
flvPlayer.play()
}

首先安装 flv.js,代码的第一行是检测浏览器是否支持 flv.js,其实大部分浏览器是支持的。接下来就是获取 video 标签的 DOM 元素。flv 会把处理后的 flv 流输出给 video 元素,然后在 video 上实现视频流播放。


接下来是关键之处,就是创建 flvjs.Player 对象,我们称之为播放器实例。播放器实例通过 flvjs.createPlayer 函数创建,参数是一个配置对象,常用如下:



  • type:媒体类型,flvmp4,默认 flv

  • isLive:可选,是否是直播流,默认 true

  • hasAudio:是否有音频

  • hasVideo:是否有视频

  • url:指定流地址,可以是 https(s) or ws(s)


上面的是否有音频,视频的配置,还是要看流地址是否有音视频。比如监控流只有视频流没有音频,那即便你配置 hasAudio: true 也是不可能有声音的。


播放器实例创建之后,接下来就是三步走:



  • 挂载元素:flvPlayer.attachMediaElement(videoEl)

  • 加载流:flvPlayer.load()

  • 播放流:flvPlayer.play()


基础实现流程就这么多,下面再说一下处理过程中的细节和要点。


细节处理要点


基本 demo 跑起来了,但若想上生产环境,还需要处理一些关键问题。


暂停与播放


点播中的暂停与播放很容易,播放器下面会有一个播放/暂停按键,想什么时候暂停都可以,再点播放的时候会接着上次暂停的地方继续播放。但是直播中就不一样了。


正常情况下直播应该是没有播放/暂停按钮以及进度条的。因为我们看的是实时信息,你暂停了视频,再点播放的时候是不能从暂停的地方继续播放的。为啥?因为你是实时的嘛,再点播放的时候应该是获取最新的实时流,播放最新的视频。


具体到技术细节,前端的 video 标签默认是带有进度条和暂停按钮的,flv.js 将直播流输出到 video 标签,此时如果点击暂停按钮,视频也是会停住的,这与点播逻辑一致。但是如果你再点播放,视频还是会从暂停处继续播放,这就不对了。


那么我们换个角度,重新审视一下直播的播放/暂停逻辑。


直播为什么需要暂停?拿我们视频监控来说,一个页面会放好几个摄像头的监控视频,如果每个播放器一直与服务器保持连接,持续拉流,这会造成大量的连接和消耗,流失的都是白花花的银子。


那我们是不是可以这样:进去网页的时候,找到想看的摄像头,点击播放再拉流。当你不想看的时候,点击暂停,播放器断开连接,这样是不是就会节省无用的流量消耗。


因此,直播中的播放/暂停,核心逻辑是拉流/断流


理解到这里,那我们的方案应该是隐藏 video 的暂停/播放按钮,然后自己实现播放和暂停的逻辑。


还是以上述代码为例,播放器实例(上面的 flvPlayer 变量)不用变,播放/暂停代码如下:


const onClick = isplay => {
// 参数 isplay 表示当前是否正在播放
if (isplay) {
// 在播放,断流
player.unload()
player.detachMediaElement()
} else {
// 已断流,重新拉流播放
player.attachMediaElement(videoEl.current)
player.load()
player.play()
}
}

异常处理


用 flv.js 接入直播流的过程会遇到各种问题,有的是后端数据流的问题,有的是前端处理逻辑的问题。因为流是实时获取,flv 也是实时转化输出,因此一旦发生错误,浏览器控制台会循环连续的打印异常。


如果你用 react 和 ts,满屏异常,你都无法开发下去了。再有直播流本来就可能发生许多异常,因此错误处理非常关键。


官方对异常处理的说明不太明显,我简单总结一下:


首先,flv.js 的异常分为两个级别,可以看作是 一级异常二级异常


再有,flv.js 有一个特殊之处,就是它的 事件错误 都是用枚举来表示,如下:



  • flvjs.Events:表示事件

  • flvjs.ErrorTypes:表示一级异常

  • flvjs.ErrorDetails:表示二级异常


下面介绍的异常和事件,都是基于上述枚举,你可以理解为是枚举下的一个 key 值。


一级异常有三类:



  • NETWORK_ERROR:网络错误,表示连接问题

  • MEDIA_ERROR:媒体错误,格式或解码问题

  • OTHER_ERROR:其他错误


二级级异常常用的有三类:



  • NETWORK_STATUS_CODE_INVALID:HTTP 状态码错误,说明 url 地址有误

  • NETWORK_TIMEOUT:连接超时,网络或后台问题

  • MEDIA_FORMAT_UNSUPPORTED:媒体格式不支持,一般是流数据不是 flv 的格式


了解这些之后,我们在播放器实例上监听异常:


// 监听错误事件
flvPlayer.on(flvjs.Events.ERROR, (err, errdet) => {
// 参数 err 是一级异常,errdet 是二级异常
if (err == flvjs.ErrorTypes.MEDIA_ERROR) {
console.log('媒体错误')
if(errdet == flvjs.ErrorDetails.MEDIA_FORMAT_UNSUPPORTED) {
console.log('媒体格式不支持')
}
}
if (err == flvjs.ErrorTypes.NETWORK_ERROR) {
console.log('网络错误')
if(errdet == flvjs.ErrorDetails.NETWORK_STATUS_CODE_INVALID) {
console.log('http状态码异常')
}
}
if(err == flvjs.ErrorTypes.OTHER_ERROR) {
console.log('其他异常:', errdet)
}
}

除此之外,自定义播放/暂停逻辑,还需要知道加载状态。可以通过以下方法监听视频流加载完成:


player.on(flvjs.Events.METADATA_ARRIVED, () => {
console.log('视频加载完成')
})

样式定制


为什么会有样式定制?前面我们说了,直播流的播放/暂停逻辑与点播不同,因此我们要隐藏 video 的操作栏元素,通过自定义元素来实现相关功能。


首先要隐藏播放/暂停按钮,进度条,以及音量按钮,用 css 实现即可:


/* 所有控件 */
video::-webkit-media-controls-enclosure {
display: none;
}
/* 进度条 */
video::-webkit-media-controls-timeline {
display: none;
}
video::-webkit-media-controls-current-time-display {
display: none;
}
/* 音量按钮 */
video::-webkit-media-controls-mute-button {
display: none;
}
video::-webkit-media-controls-toggle-closed-captions-button {
display: none;
}
/* 音量的控制条 */
video::-webkit-media-controls-volume-slider {
display: none;
}
/* 播放按钮 */
video::-webkit-media-controls-play-button {
display: none;
}

播放和暂停的逻辑上面讲了,样式这边自定义一个按钮即可。除此之外我们还可能需要一个全屏按钮,看一下全屏的逻辑怎么写:


const fullPage = () => {
let dom = document.querySelector('.video')
if (dom.requestFullscreen) {
dom.requestFullscreen()
} else if (dom.webkitRequestFullScreen) {
dom.webkitRequestFullScreen()
}
}

其他自定义样式,比如你要做弹幕,在 video 上面盖一层元素自行实现就可以了。


作者:杨成功
来源:juejin.cn/post/7044707642693910541
收起阅读 »

前端访问系统文件夹

web
随着前端技术和浏览器的升级,越来越多的功能可以在前端实现而不用依赖于后端。其中,访问系统文件夹是一个非常有用的功能,例如上传文件时,用户可以直接从自己的电脑中选择文件。 使用方法 在早期的浏览器中,要访问系统中的文件夹需要使用 ActiveX 控件或 Java...
继续阅读 »

随着前端技术和浏览器的升级,越来越多的功能可以在前端实现而不用依赖于后端。其中,访问系统文件夹是一个非常有用的功能,例如上传文件时,用户可以直接从自己的电脑中选择文件。


使用方法


在早期的浏览器中,要访问系统中的文件夹需要使用 ActiveX 控件或 Java Applet,这些方法已经逐渐淘汰。现在,访问系统文件夹需要使用HTML5的 API。


最常使用的 API 是FileAPI,配合 input[type="file"] 通过用户的交互行为来获取文件。但是,这种方法需要用户选择具体的文件而不是像在系统中打开文件夹来进行选择。


HTML5 还提供了更加高级的 API,如 showDirectoryPicker。它支持在浏览器中打开一个目录选择器,从而简化了选择文件夹的流程。这个 API 的使用也很简单,只需要调用 showDirectoryPicker() 方法即可。


async function pickDirectory() {
const directoryHandle = await window.showDirectoryPicker();
console.log(directoryHandle);
}

但是需要注意的是该api的兼容性较低,目前所支持的浏览器如下图所示:


image.png


点击一个按钮后,调用 pickDirectory() 方法即可打开选择文件夹的对话框,选择完文件夹后,该方法会返回一个 FileSystemFileHandle 对象,开发者可以使用这个对象来访问所选择的目录的内容。


应用场景


访问系统文件夹可以用于很多场景,下面列举几个常用的场景。


上传文件


在前端上传文件时,用户需要选择所需要上传的文件。这时,打开一个文件夹选择器,在用户选择了一个文件夹后,就可以读取文件夹中的文件并进行上传操作。


本地文件管理


将文件夹中的文件读取到前端后,可以在前端进行一些操作,如修改文件名、查看文件信息等。这个在 纯前端文件管理器 中就被广泛使用。


编辑器功能


访问系统文件夹可以将前端编辑器与本地的文件夹绑定,使得用户直接在本地进行编写代码,而不是将代码保存到云端,这对于某些敏感数据的处理尤为重要。


结尾的话


通过HTML5的 API,前端可以访问到系统中的文件夹,这项功能可以应用于上传文件、本地文件管理和编辑器功能等场景,为用户带来了极大的便利。


作者:白椰子
来源:juejin.cn/post/7222636308740014135
收起阅读 »

【手把手教学】基于vue封装一个安全键盘组件

web
基于vue封装一个安全键盘组件 为什么需要安全键盘 大部分中文应用弹出的默认键盘是简体中文输入法键盘,在输入用户名和密码的时候,如果使用简体中文输入法键盘,输入英文字符和数字字符的用户名和密码时,会自动启动系统输入法自动更正提示,然后用户的输入记录会被缓存下...
继续阅读 »

基于vue封装一个安全键盘组件



为什么需要安全键盘


大部分中文应用弹出的默认键盘是简体中文输入法键盘,在输入用户名和密码的时候,如果使用简体中文输入法键盘,输入英文字符和数字字符的用户名和密码时,会自动启动系统输入法自动更正提示,然后用户的输入记录会被缓存下来。


系统键盘缓存最方便拿到的就是利用系统输入法自动更正的字符串输入记录。 缓存文件的地址是:



/private/var/mobile/Library/Keyboard/dynamic-text.dat



导出该缓存文件,查看内容,欣喜的发现一切输入记录都是明文存储的。因为系统不会把所有的用户输入记录都当作密码等敏感信息来处理。 一般情况下,一个常规 iPhone 用户的 dynamic-text.dat 文件,高频率出现的字符串就是用户名和密码。


使用自己定制的安全键盘的原因主要有:



  • 避免第三方读取系统键盘缓存

  • 防止屏幕录制 (自己定制的键盘按键不加按下效果)


实现方案


封装组件


首先建一个文件safeKeyboard.vue安全键盘子组件.



话不多说,直接上才艺(代码)



<template>
<div class="keyboard">
<div class="key_title">
<p><img src="../../../../static/img/ic_logo@2x.png"><span>小猴子的安全键盘span>p>
div>
<p v-for="keys in keyList" :style="(keys.length<10&&keys.indexOf('ABC')<1&&keys.indexOf('del')<1&&keys.indexOf('suc')<1)?'padding: 0px 20px;':''">
<template v-for="key in keys">
<i v-if="key === 'top'" @click.stop="clickKey" @touchend.stop="clickKey" class="tab-top"><img class="top" :src='top_img'>i>
<i v-else-if="key === 'del'" @click.stop="clickKey" @touchend.stop="clickKey" class="key-delete"><img class="delete" src='删除图标路径'>i>
<i v-else-if="key === 'blank'" @click.stop="clickBlank" class="tab-blank">空格i>
<i v-else-if="key === 'suc'" @click.stop="success" @touchend.stop="success" class="tab-suc">确定i>
<i v-else-if="key === '.?123' || key === 'ABC'" @click.stop="symbol" class="tab-sym">{{(status==0||status==1)?'.?123':'ABC'}}i>
<i v-else-if="key === '123' || key === '#+='" @click.stop="number" class="tab-num">{{status==3?'123':'#+='}}i>
<i v-else @click.stop="clickKey" @touchend.stop="clickKey">{{key}}i>
template>
p>
div>
template>

<script>
export default {
data () {
return {
keyList: [],
status: 0, // 0 小写 1 大写 2 数字 3 符号
topStatus: 0, // 0 小写 1 大写
top_img: require('小写图片路径'),
lowercase: [
['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
['top', 'z', 'x', 'c', 'v', 'b', 'n', 'm', 'del'],
['.?123', 'blank', 'suc']
],
numbercase: [
['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'],
['-', '/', ':', ';', '(', ')', '$', '&', '@', '"'],
['#+=', '.', ',', '?', '!', "'", 'del'],
['ABC', 'blank', 'suc']
],
symbolcase: [
['[', ']', '{', '}', '#', '%', '^', '*', '+', '='],
['_', '\\', '|', '~', '<', '>', '€', '`', '¥', '·'],
['123', '.', ',', '?', '!', "'", 'del'],
['ABC', 'blank', 'suc']
],
uppercase: [
['Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P'],
['A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L'],
['top', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', 'del'],
['.?123', 'blank', 'suc']
],
equip: !!navigator.userAgent.toLocaleLowerCase().match(/ipad|mobile/i)// 是否是移动设备
}
},
props: {
option: {
type: Object
}
},

mounted () {
this.keyList = this.lowercase
},

methods: {
tabHandle ({value = ''}) {
if (value.indexOf('tab-num') > -1) {
if (this.status === 3) {
this.status = 2
this.keyList = this.numbercase
} else {
this.status = 3
this.keyList = this.symbolcase
}
// 数字键盘数据
} else if (value.indexOf('delete') > -1) {
this.emitValue('delete')
} else if (value.indexOf('tab-blank') > -1) {
this.emitValue(' ')
} else if (value.indexOf('tab-point') > -1) {
this.emitValue('.')
} else if (value.indexOf('tab-sym') > -1) {
if (this.status === 0) {
this.topStatus = 0
this.status = 2
this.keyList = this.numbercase
} else if (this.status === 1) {
this.topStatus = 1
this.status = 2
this.keyList = this.numbercase
} else {
if (this.topStatus == 0) {
this.status = 0
this.top_img = require('小写图片路径')
this.keyList = this.lowercase
}else{
this.status = 1
this.keyList = this.uppercase
this.top_img = require('大写图片路径')
}
}
// 符号键盘数据
} else if (value.indexOf('top') > -1) {
if (this.status === 0) {
this.status = 1
this.keyList = this.uppercase
this.top_img = require('大写图片路径')
} else {
this.status = 0
this.keyList = this.lowercase
this.top_img = require('小写图片路径')
}
} else if (value.indexOf('tab-suc') > -1) {
this.$emit('closeHandle', this.option) // 关闭键盘
}
},
number (event) {
this.tabHandle(event.srcElement.classList)
},
clickBlank (event) {
this.tabHandle(event.srcElement.classList)
},
symbol (event) {
this.tabHandle(event.srcElement.classList)
},
success (event) {
this.tabHandle(event.srcElement.classList)
},
english (event) {
this.tabHandle(event.srcElement.classList)
},
clickKey (event) {
if (event.type === 'click' && this.equip) return
let value = event.srcElement.innerText
value ? this.emitValue(value) : this.tabHandle(event.srcElement.classList)
},

emitValue (key) {
this.$emit('keyVal', key) // 向父组件传值
},

closeModal (e) {
if (e.target !== this.option.sourceDom) {
this.$emit('closeHandle', this.option)
this.keyList = this.lowercase
}
},
}
}
script>
<style scoped lang="scss">
.keyboard {
width: 100%;
margin: 0 auto;
font-size: 18px;
border-radius: 2px;
background-color: #fff;
box-shadow: 0 -2px 2px 0 rgba(89,108,132,0.20);
user-select: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 999;
pointer-events: auto;
.key_title{
height: 84px;
font-size: 32px;
color: #0B0B0B;
overflow: hidden;
margin-bottom: 16px;
p{
display: flex;
justify-content: center;
align-items: center;
min-width: 302px;
height: 32px;
margin: 32px auto 0px;
img{
width: 32px;
height: 32px;
margin-right: 10px;
}
}
}
p {
width: 99%;
margin: 0 auto;
height: 84px;
margin-bottom: 24px;
display: flex;
display: -webkit-box;
flex-direction: row;
flex-wrap: nowrap;
justify-content: center;
box-sizing: border-box;
i {
position: relative;
display: block;
margin: 0px 5px;
height: 84px;
line-height: 84px;
font-style: normal;
font-size: 48px;
border-radius: 8px;
width: 64px;
background-color: #F2F4F5;
box-shadow: 0 2px 0 0 rgba(0,0,0,0.25);
text-align: center;
flex-grow: 1;
flex-shrink: 1;
flex-basis: 0;
-webkit-box-flex: 1;
img{
width: 48px;
height: 48px;
}
}
i:first-child{
margin-left: 0px
}
i:last-child{
margin-right: 0px
}
i:active {
background-color: #A9A9A9;
}
.tab-top, .key-delete, .tab-num, .tab-eng, .tab-sym{
background-color: #CED6E0;
}
.tab-top,.key-delete {
display: flex;
justify-content: center;
align-items: center;
width: 84px;
height: 84px;
}
.tab-top{
margin-right: 30px;
font-size: 32px;
}
.key-delete{
margin-left: 30px;
}
.tab-num, .tab-eng, .tab-sym{
font-size: 32px;
}
.tab-point {
width: 70px;
}
.tab-blank, .tab-suc{
text-align: center;
line-height: 84px;
font-size: 32px;
color: #000;
}
.tab-blank{
flex: 2.5;
}
.tab-suc{
background-color: #CFA46A;
}
}
p:last-child{
margin-bottom: 8px;
}
}
style>

但是,键盘的特性是,点击除键盘和输入框以外的地方,键盘收起。


所以还需要一个clickoutside.js文件,用来自定义一个指令,实现需求:


代码如下:


export default {
bind(el, binding, vnode) {
function documentHandler(e) {
if (el.contains(e.target)) {
return false;
}
if (binding.expression) {
binding.value(e);
}
}
el.__vueClickOutside__ = documentHandler;
document.addEventListener('click', documentHandler);
},
unbind(el, binding) {
document.removeEventListener('click', el.__vueClickOutside__);
delete el.__vueClickOutside__;
}
};

然后在safeKeyboard.vue中引入:


import clickoutside from './clickoutside'

并注册局部指令:


directives: { clickoutside }

然后绑定方法:


class="keyboard" v-clickoutside="closeModal">

声明方法:


closeModal (e) {
if (e.target !== this.option.sourceDom) {
this.$emit('closeHandle', this.option)
this.keyList = this.lowercase
}
},

安全键盘组件就构建完成了,接下来是在需要用到安全键盘的页面引入使用了。


使用组件


引入组件

import Keyboard from './safeKeyboard'

components: {
Keyboard
}

使用范例

type="password" ref="setPwd" v-model='password'/> 

v-if="option.show" :option="option" @keyVal="getInputValue" @closeHandle="onLeave">

键盘相关数据对象及方法


  • option


option: {
show: false, // 键盘是否显示
sourceDom: '', // 键盘绑定的Input元素
_type: '' // 键盘绑定的input元素ref
},


  • getInputValue



getInputValue(val)会接收键盘录入的数据,val是输入的单个字符或者是删除操作,由于是单个字符,所以需在方法中手动拼接成字符串。在方法中根据option._type区分是哪个输入框的数据。




  • onLeave



onLeave()相当于blur,这是由于在移动端H5项目中,input获取焦点时会调起手机软键盘,所以需要禁止软键盘被调起来,办法是:



document.activeElement.blur() // ios隐藏键盘

this.$refs.setPwd.blur() // android隐藏键盘


就相当于强制使input元素处于blur状态,那么软键盘就不会被调起,所以如果要做blur监听,就需要onLeave()。



但是这样出现了一个新的问题,输入框里面没有光标!!虽然不影响业务逻辑,但是用户用起来会很不舒服。


所以,只能和input元素说再见了,自己手写一个吧:


输入框组件


再来一个子组件cursorBlink.vue


<template>
<div class="cursor-blink" @click.stop="isShow">
<span v-if="pwd.length>0" :style="options.show?'':'border:0;animation:none;'" class="blink">{{passwordShow}}span>
<span v-else style="color: #ddd" :style="options.show?'':'border:0;animation:none;'" class="blink_left">{{options.desc}}span>
div>
template>
<script>
export default {
props: {
pwd: {
type: String
},
options: {
type: Object
},
},
data(){
return {
passwordShow: '',
}
},
mounted() {
if(this.pwd.length > 0){
for (let i = 0; i < this.pwd.length; i++) {
this.passwordShow += '*' // 显示为掩码
}
}
},
watch: {
pwd(curVal, oldVal){
if (oldVal.length < curVal.length) {
// 输入密码时
this.passwordShow += '*'
} else if (oldVal.length > curVal.length) {
// 删除密码时
this.passwordShow = this.passwordShow.slice(0, this.passwordShow.length - 1)
}
}
},
methods: {
isShow(){
this.$emit('cursor')
}
},
}
script>
<style lang="scss" scoped>
.cursor-blink{
display: inline-block;
width: 500px;
height: 43px;
letter-spacing: 0px;
word-spacing: 0px;
padding: 2px 0px;
font-size: 28px;
overflow: hidden;
.blink,.blink_left{
display: inline;
margin: 0px;
}
.blink{ // 输入密码后
border-right: 2px solid #000;
animation: blink 1s infinite steps(1, start);
}
.blink_left{ // 输入密码前
border-left: 2px solid #000;
animation: blinkLeft 1s infinite steps(1, start);
}
}
@keyframes blink {
0%, 100% {
border-right: 2px solid #fff;
}
50% {
border-right: 2px solid #000;
}
}
@keyframes blinkLeft {
0%, 100% {
border-left: 2px solid #fff;
}
50% {
border-left: 2px solid #000;
}
}
style>

引入之后光荣的接替input的位置:


<CursorBlink :pwd='password' ref="setPwd" :options='option2' @cursor="onFocus"></CursorBlink>

数据方法说明:


option2: {
show: false, // 区分输入前输入后
desc: '请重复输入密码' // 相当于placeholder
},

onFocus() 相当于input标签的focus

这样一个完美的安全键盘就做好了。


我是摸鱼君,你的【三连】就是摸鱼君创作的最大动力,如果本篇文章有任何错误和建议,欢迎大家留言!


作者:摸鱼君111
来源:juejin.cn/post/7309158055018168346
收起阅读 »

前人在 vue 项目中的 “砍树型“ 写法,让后人乘不了凉!

web
前言 最近在协助小伙伴解决问题时,在项目中都会遇到一些 “砍树型” 的写法,这些写法容易让后续 简单 的需求变得 复杂,都说 "前人栽树后人乘凉",但项目中有些写法是真的让后人乘不了凉的,甚至还得被迫加入 “砍树队伍”。 本篇文章就列举一些,在 vue 项目中...
继续阅读 »

前言


最近在协助小伙伴解决问题时,在项目中都会遇到一些 “砍树型” 的写法,这些写法容易让后续 简单 的需求变得 复杂,都说 "前人栽树后人乘凉",但项目中有些写法是真的让后人乘不了凉的,甚至还得被迫加入 “砍树队伍”。


本篇文章就列举一些,在 vue 项目中的 “砍树型” 的写法,以及分析一下如何写才更合适 “栽树”,如果你有更好的方案,欢迎在评论区分享!!!


89DFA925.png


砍树 & 栽树


由于项目源码不便于直接展示,下面会使用同等的代码实例来替代。


其项目技术栈为:vue2 + vue-class-component + vue-property-decorator + typescript


滥用 watch


砍树型写法


@Watch('person', { deep: true })
doSomething(){}

@Watch('person.name', { deep: true })
doSomething(){}

@Watch('person.age', { deep: true })
doSomething(){}

@Watch('person.hobbies', { deep: true })
doSomething(){}

第一次看到这个写法我有点迷茫,但想了想好像也不难理解:



  • 首先 person.x 的部分监听 是为了处理针对不同属性值发生修改时要执行的特定逻辑

  • 而针对 person 的整体监听 是为了执行属于公共部分的逻辑


因此,上面的写法就只是相当于只是少写了几个 if 的条件分支罢了,更何况还都用了深度监听,而实际上这种 简化方式vue 内部会实例化出多个 Watcher 实例,如下:


image.png


image.png


image.png


栽树型写法


针对上述写法,如果说后续需要追加不同属性变更时的新逻辑,会有两种情况:



  • 看懂的人,会使用一样的 person.x 的部分监听 方式去添加新逻辑

    • 实际上一个 Watcher 就可以解决,没必要实例化多个 Watcher



  • 看不懂的人,可能会把新逻辑杂糅在 person 的整体监听 的公共逻辑中

    • 还得注意添加执行时机条件的判断,很容易出问题




总之,这两种情况都并不好,因此更推荐原本 if 的写法:


@Watch('person', { deep: true })
doSomething(newVal, oldVal){
doSomethingCommon() // 公共逻辑

if(newVal.name !== oldVal.name){
doSomethingName() // 逻辑抽离
}

if(newVal.age !== oldVal.age){
doSomethingAge() // 逻辑抽离
}

...
}

值得注意的是,当使用 watch 深度监听对象时,其中的 newValoldVal 的值会一致,因为此时它们指向的是 同一个对象,因此如果真的需要如上例的方式来使用,就需要提前将目标对象进行 深度克隆


因此,这两种写法到底哪种是 "栽树",哪种是 "砍树",需要见仁见智了!


946CB97F.gif


不合理使用 async/await


砍树型写法


记得当时有反馈前端视图更新太慢,因为后端通过日志查看接口响应速度还是很快的,于是查看前端代码时发现类似如下的使用:


 async mounted(){
await this.request1(); // 耗时接口
this.request2(); // request2 需要依赖 request1 的请求结果
this.request3(); // request3 不需要依赖任何的请求结果
this.request4(); // request4 不需要依赖任何请求结果
}

这种写法就导致了 request3request4 虽然不需要依赖前面异步请求结果,但是必须要等待耗时操作完成才能请求,而视图更新又必须等待接口调用完成。


上述写法可能在 开发 和 测试 环境没有太明显的影响,但是在 生产环境,这个影响就会被放大,因为不同环境数据量不同,所接口响应速度更不同,并且用户可能不会注意你的数据是否准备完成就进行相应操作,这个时候就有可能出现问题。


93DE32BE.gif


栽树型写法


为了更快的得到视图更新,针对以上写法可进行如下调整:



  • 将无关相互依赖的请求前置在 await 之前

    • 这种方式适合使用的场景就是 request1 本身还需要再其他地方单独调用,因此其内部不适合在存放额外的逻辑


     async mounted(){
    this.request3();
    this.request4();

    await this.request1(); // 耗时接口
    this.request2(); // request2 需要依赖 request1 的请求结果
    }


  • 将相互依赖的请求在统一在内部处理

    • 例如,将 request2 放置到 request1 的具体实现中,这种方式适用于 request1request2 间在任何情况下都有紧密联系的情况下,当然也可以在 request1 内通过 条件判断 决定是否要执行 request2


     async mounted(){
    this.request3();
    this.request4();
    this.request1(); // 耗时接口
    }

    async request1(){
    const res = await asyncReq();
    this.request2(res); // request2 需要依赖 request1 的请求结果
    }



同时还需要注意的是,虽然 request2 需要依赖 request1 的结果,但是对于视图更新来说,却没有必要等待 request2 请求完成后再去更新视图,也就是说,request1 请求结束后有需要更新视图的部分就可以先更新,这样视图更新时机就不会延后。


组件层层传参


砍树型写法


项目中有一个模版切换的功能,而这个模版功能封装成了一个组件,在外部看起来是 Grandpa 组件,实际上其内部包含了 Parents 组件,而最底层使用的是 Son 组件


// 顶层组件
<Grandpa :data="data" @customEvent="customEvent" />

// 中间层组件
<Parents :data="data" @customEvent="customEvent" />

// 底层组件
<Son :data="data" @customEvent="customEvent" />

由于底层的 Son 组件 需要使用到 props data自定义事件 customEvent,在代码中通过逐层传递的方式来实现,甚至在 Grandpa 组件Parents 组件 中都有对 props.datadeepClone 深克隆 且修改后在往下层传递。


缺点很明显了:



  • 重复定义 props

    • 需要分别在 Grandpa、Parents、Son 三个组件中定义相关的 propsevent



  • props 的修改来源不确定

    • 由于 Grandpa、Parents 组件都对 props.data 有修改,在出现问题需要排查时可能都要排查 Grandpa、Parents 组件




栽树型写法


上面的写法属实繁琐且不优雅,实际上可以通过 $attrs$listeners 来实现 属性和事件透传,如下:


// 顶层组件
<Grandpa :data="data" @customEvent="customEvent" />

// 中间层组件
<Parents v-bind="$attrs" v-on="$listeners" />

// 底层组件
<Son v-bind="$attrs" v-on="$listeners" />

而其中涉及到直接通过 deepClone 深克隆 的原因应该是为了便于 直接 增加/删除 props.data 中的属性,实际上应该在 props 提供层 提供修改的方法。


946B61BF.gif


没有必要的响应式数据


砍树型写法


很多时候在 Vue 中我们需要在

收起阅读 »

Android 视频图像实时文字化

一、前言 在本篇之前,很多博客已经实现过图片文本化,但是由于渲染方式的不合理,大部分设计都做不到尽可能实时播放,本篇将在已有的基础上进行一些优化,使得视频文字化具备一定的实时性。 下图总体上视频和文字化的画面基本是实时的,上面是SurfaceView,下面是我...
继续阅读 »

一、前言


在本篇之前,很多博客已经实现过图片文本化,但是由于渲染方式的不合理,大部分设计都做不到尽可能实时播放,本篇将在已有的基础上进行一些优化,使得视频文字化具备一定的实时性。


下图总体上视频和文字化的画面基本是实时的,上面是SurfaceView,下面是我们自定义的View类,通过实时抓帧然后实时转bitmap,做到了基本同步。



二、现状


目前很多流行的方式是修改像素的色值,这个性能差距太大,导致卡顿非常严重,无法做到实时性。当然也有通过open gl mask实现的,但是在贴图这一块我们知道,open gl只支持绘制三角、点和线,因此“文字”纹理生成还得利用Canvas实现。



但对于对帧率要求不高的需求,是不是有更好的方案呢?


三、优化方案


优化点1: 使用Shader


网上很多博客都是利用Bitmap#getPixel和Bitmap#setPixel进行,这个计算量显然太大了,就算使用open gl 也未必好,因此首先解决的问题就是使用Shader着色。


优化点2: 提前计算好单个文字所占的最大空间


显然这个原因是更加整齐的排列文字,其次也可以做到降低计算量和提高灵活度


优化点3:使用队列


对于了编解码的开发而言,使用队列不仅可以复用buffer,而且还能提高绘制性能,另外必要时可以丢帧。


基于以上三点,基本可以做到实时字符化画面,当然,我们这里是彩色的,对于灰度图的需求,可通过设置Paint的ColorMatrix实现,总之,要避免遍历修改像素了RGB。


四、关键代码


使用shader着色


 this.bitmapShader = new BitmapShader(inputBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
this.mCharPaint.setShader(bitmapShader);
//用下面方式清空bitmap
boardBitmap.bitmap.eraseColor(Color.TRANSPARENT);

计算字符size


    private Rect computeMaxCharWidth(TextPaint drawerPaint, String text) {
if (TextUtils.isEmpty(text)) {
return null;
}
Rect result = new Rect(); // 文字所在区域的矩形
Rect md = new Rect();
for (int i = 0; i < text.length(); i++) {
String s = text.charAt(i) + "";
if(TextUtils.isEmpty(s)) {
continue;
}
drawerPaint.getTextBounds(s, 0, 1, md);
if (md.width() > result.width()) {
result.right = md.width();
}
if (md.height() > result.height()) {
result.bottom = md.height();
}
}
return result;
}

定义双队列,实现控制和享元机制


    private BitmapPool bitmapPool = new BitmapPool();
private BitmapPool recyclePool = new BitmapPool();

static class BitmapPool {
int width;
int height;
private LinkedBlockingQueue<BitmapItem> linkedBlockingQueue = new LinkedBlockingQueue<>(5);
}

static class BitmapItem{
Bitmap bitmap;
boolean isUsed = false;
}

完整代码


public class WordBitmapView extends View {
private final DisplayMetrics mDM;
private TextPaint mCharPaint;
private TextPaint mDrawerPaint = null;
private Bitmap inputBitmap;
private Rect charMxWidth = null ;
private String text = "a1b2c3d4e5f6h7j8k9l0";
private float textBaseline;
private BitmapShader bitmapShader;
public WordBitmapView(Context context) {
this(context, null);
}
public WordBitmapView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public WordBitmapView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDM = getResources().getDisplayMetrics();
initPaint();
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);

int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);

if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}

int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);

textBaseline = getTextPaintBaseline(mDrawerPaint);
}


public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, mDM);
}

public float sp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, dp, mDM);
}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
recyclePool.clear();
bitmapPool.clear();
}

Matrix matrix = new Matrix();
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width <= 1 || height <= 1) {
return;
}
BitmapItem bitmapItem = bitmapPool.linkedBlockingQueue.poll();
if (bitmapItem == null || inputBitmap == null) {
return;
}
if(!bitmapItem.isUsed){
return;
}
canvas.drawBitmap(bitmapItem.bitmap,matrix,mDrawerPaint);
bitmapItem.isUsed = false;
try {
recyclePool.linkedBlockingQueue.offer(bitmapItem,16,TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

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

private Rect computeMaxCharWidth(TextPaint drawerPaint, String text) {
if (TextUtils.isEmpty(text)) {
return null;
}
Rect result = new Rect(); // 文字所在区域的矩形
Rect md = new Rect();
for (int i = 0; i < text.length(); i++) {
String s = text.charAt(i) + "";
if(TextUtils.isEmpty(s)) {
continue;
}
drawerPaint.getTextBounds(s, 0, 1, md);
if (md.width() > result.width()) {
result.right = md.width();
}
if (md.height() > result.height()) {
result.bottom = md.height();
}
}
return result;
}

private BitmapPool bitmapPool = new BitmapPool();
private BitmapPool recyclePool = new BitmapPool();

static class BitmapPool {
int width;
int height;
private LinkedBlockingQueue<BitmapItem> linkedBlockingQueue = new LinkedBlockingQueue<>(5);
public void clear(){
Iterator<BitmapItem> iterator = linkedBlockingQueue.iterator();
do{
if(!iterator.hasNext()) break;
BitmapItem next = iterator.next();
if(!next.bitmap.isRecycled()) {
next.bitmap.recycle();
}
iterator.remove();
}while (true);
}

public int getWidth() {
return width;
}

public int getHeight() {
return height;
}

public void setHeight(int height) {
this.height = height;
}

public void setWidth(int width) {
this.width = width;
}
}

class BitmapItem{
Bitmap bitmap;
boolean isUsed = false;
}


//视频图片入队
public void queueInputBitmap(Bitmap inputBitmap) {
this.inputBitmap = inputBitmap;

if(charMxWidth == null){
charMxWidth = computeMaxCharWidth(mDrawerPaint,text);
}
if(charMxWidth == null || charMxWidth.width() == 0){
return;
}

if(this.bitmapPool != null && this.inputBitmap != null){
if(this.bitmapPool.getWidth() != this.inputBitmap.getWidth()){
bitmapPool.clear();
recyclePool.clear();
}else if(this.bitmapPool.getHeight() != this.inputBitmap.getHeight()){
bitmapPool.clear();
recyclePool.clear();
}
}
bitmapPool.setWidth(inputBitmap.getWidth());
bitmapPool.setHeight(inputBitmap.getHeight());
recyclePool.setWidth(inputBitmap.getWidth());
recyclePool.setHeight(inputBitmap.getHeight());

BitmapItem boardBitmap = recyclePool.linkedBlockingQueue.poll();
if (boardBitmap == null && inputBitmap != null) {
boardBitmap = new BitmapItem();
boardBitmap.bitmap = Bitmap.createBitmap(inputBitmap.getWidth(), inputBitmap.getHeight(), Bitmap.Config.ARGB_8888);
}
boardBitmap.isUsed = true;
int bitmapWidth = inputBitmap.getWidth();
int bitmapHeight = inputBitmap.getHeight();
int unitWidth = (int) (charMxWidth.width() *1.5);
int unitHeight = charMxWidth.height() + 2;
int centerY = charMxWidth.centerY();
float hLineCharNum = bitmapWidth * 1F / unitWidth;
float vLineCharNum = bitmapHeight * 1F / unitHeight;


this.bitmapShader = new BitmapShader(inputBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
this.mCharPaint.setShader(bitmapShader);

boardBitmap.bitmap.eraseColor(Color.TRANSPARENT);

Canvas drawCanvas = new Canvas(boardBitmap.bitmap);
int k = (int) (Math.random() * text.length());
for (int i = 0; i < vLineCharNum; i++) {
for (int j = 0; j < hLineCharNum; j++) {
int length = text.length();
int x = unitWidth * j;
int y = centerY + i * unitHeight;
String c = text.charAt(k % length) + "";
drawCanvas.drawText(c, x, y + textBaseline, mCharPaint);
k++;
}
}
try {
bitmapPool.linkedBlockingQueue.offer(boardBitmap,16, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
postInvalidate();

}
private void initPaint() {
// 实例化画笔并打开抗锯齿
mCharPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mCharPaint.setAntiAlias(true);
mCharPaint.setStyle(Paint.Style.FILL);
mCharPaint.setStrokeCap(Paint.Cap.ROUND);
mDrawerPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mDrawerPaint.setAntiAlias(true);
mDrawerPaint.setStyle(Paint.Style.FILL);
mDrawerPaint.setStrokeCap(Paint.Cap.ROUND);
}


}

五、总结


Android中Shader是非常重要的工具,我们无需单独修改像素的情况下就能实现快速渲染字符,得意与Shader出色的渲染能力。另外由于时间原因,这里对字符的绘制并没有做到很精确,仅仅选了一些比较中规中列的排列,后续再继续完善吧。


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

搞不懂,我的手机没有公网IP,服务器响应报文如何被路由到手机?

6年前,在我刚毕业的时候,我困惑于一个问题:“我的手机和个人电脑等设备只有内网IP,而没有公网IP的情况下,公网服务器的响应IP报文如何被路由到我的内网设备呢?“ 我知道:任何联网的设备都可以主动连接公网IP的服务器,因为公网IP是全球唯一分配的,IP层报文经...
继续阅读 »

6年前,在我刚毕业的时候,我困惑于一个问题:“我的手机和个人电脑等设备只有内网IP,而没有公网IP的情况下,公网服务器的响应IP报文如何被路由到我的内网设备呢?“


我知道:任何联网的设备都可以主动连接公网IP的服务器,因为公网IP是全球唯一分配的,IP层报文经过路由器的层层路由可以到达公网服务器。然而我实在想不通:公网服务器的响应报文该如何回来?


这个问题让我感到很困扰,我上学时学的计算机网络知识已经还给老师了,我尝试询问周围的同事,可惜没有人能给出一个可靠的答案。直到我了解到NAT(网络地址转换)技术,才最终解答了我的疑问。



NAT,即网络地址转换,是一种用于解决IP地址短缺问题的技术。它通过在内部网络和公共网络之间建立一个转换表,将多个内部私有IP地址映射为一个公共IP地址,实现多个设备共享一个公网IP地址的功能。



在科普 NAT 之前,有必要说明一下 内网IP。


0. 内网 IP 不能随意分配!


内网IP的分配是有规范的,不可以随意分配。如果内网IP和公网IP冲突,网络报文无法被路由器正确地路由。因为路由器不知道这个IP报文应该被路由到内网设备,还是路由到上一层网关(直至公网IP)。


为了避免冲突,IP协议中事先规定了三个网段作为内网IP的范围,分别是



  1. A类地址的 10.0.0.0 至 10.255.255.255

  2. B类地址的 172.16.0.0 至 172.31.255.255,

  3. C类地址的 192.168.0.0 至 192.168.255.255。


因此在一个局域网内,以上三个网段的设备都是内网设备,除此外基本(排除 127.0.0.1 等)都是公网设备。这样所有的IP地址都不会冲突!


在家用局域网中,通常使用的是 192.168.xxx.xxx 的格式;而在公司的机房中,由于设备数量庞大,一般会选择以 10.xxx.xxx.xxx 开头的网段。这是因为 192 开头的C类地址能够满足家用局域网设备数量的需求,而 10.xxx.xxx.xxx 网段可以适应大规模的公司机房环境,其中可能存在数以百万计的物理服务器,和数以千万计的虚拟机或者Docker实例。


1. 公网流量如何路由到内网设备


设想一下,内网IP为 192.168.100.100 的用户设备,请求到公网服务器。如果公网服务器收到的IP报文中,显示来源是 192.168.100.100。因为 192 开头的 IP 是内网地址,所以服务器响应用户请求时,就会错误地把请求路由到内网,无法正确路由到用户设备上!


所以…… 正确的显示来源是什么呢?


内网设备想要 “连通” 公网服务器,是需要路由器等网络设备层层转发的,正如下图而言,内网设备需要有公网IP的网络出口才能连通 到另一个公网设备。


image.png
因此,刚才问题答案是,内网IP报文到达公网机器时,来源应该被设置为 相应的 运营商公网出口IP。即公网出口网络设备要把IP来源改为自己。这样公网服务器收到请求报文后,对应的响应IP报文也会回复到用户端的公网出口IP。


看下图,用户端的IP报文到达运营商网络出口时,IP来源被替换为 运营商公网出口IP。下图中网络来源为 192 开头的内网Ip 地址,被替换为公网出口 IP(100.100.100.100)。像这种偷偷更换来源 IP 和目标 IP 的行为在 NAT 技术上很常见,后面会经常看到!


image.png


2. 公网机器发送响应报文时


当公网服务器响应时,IP 目标地址 是运营商公网出口而非用户的内网地址!然后运营商服务器会再次转发,转发前,运营商机器需要知道该转发给谁,转发给哪个用户设备。


如下图所示,用户端(192.168.100.100) 访问 公网地址(200.200.200.200)的IP 来源和目标被替换的过程。


image.png
首先公网机器响应给 运营商公网出口时。


第一步:来源 IP 是(200.200.200.200),目标IP 是(100.100.100.100)。


然后,运营商将 IP 报文转发给用户设备时,来源 IP 还是公网机器的 IP(200.200.200.200)不变化。然而目标 IP 修改为用户设备的 IP(192.168.xx.xx)。


对于用户设备而言,发送请求时目标IP 是公网机器,收到响应时来源 IP 还是公网机器。用户设备丝毫没有感觉到,在中间被路由转发的过程,目标和来源 IP 频繁被路由设备修改!


在这个环节,有一个关键问题:运营商收到公网机器的响应时,它怎么知道该路由给哪个用户设备!


3. NAT 如何进行地址映射


最简单的映射方式是:每一个内网 IP 都映射到一个运营商的公网IP。即内网 Ip 和运营商公网 IP 一对一映射!


这种方式很少见,用户设备和内网设备非常多,这样非常耗费公网IP,一般不会采用。


TCP 和 UDP 协议除基于 IP 地址外,还有端口,如果引入端口参与映射,则大大提高运营商公网 IP 的利用度!


一个 TCP 报文 包括如下参数:



  1. 用户 IP + 用户端口

  2. 公网 IP + 公网端口


这四个参数非常关键,相当于是 TCP 连接的唯一主键。当运营商收到公网机器的响应报文时,它可以拿到四个参数分别为:


运营商公网 IP + 端口 、目标机器公网 IP + 端口。其中关键的参数有三位:运营商公网端口,目标机器公网 IP 和端口。



为什么运营商公网 IP 不关键呢?因为根据 IP 路由协议,响应报文已经被路由到该机器,每一个运营商公网出口都会维护一套单独的 映射表 ,所以自己的 IP 地址不关键。



当前的难点是:如何根据 运营商公网端口,目标机器公网 IP 和 端口 三个参数,映射到 用户 IP 和端口的问题。


用户请求时,会建立映射表。当收到用户请求时,运营商服务器的 NAT 模块会分配一个端口供本次请求使用,建立一个映射项:运营商机器端口 + 目标公网 IP + 目标公网端口 这三个参数映射到 用户 IP 和用户端口


例如下表


NAT 映射表运营商机器端口目标公网 IP目标公网端口用户 IP用户端口
1300200.200.200.20080192.168.22.226000
2
3

在运营商机器转发IP报文时,除替换IP外,也会替换端口。相比NAT 一比一映射IP地址,增加端口映射,可以大大提高运营商公网 IP 的利用度。接下来有个问题?


每个机器的端口最大数为 65535,说明每个运营商机器最多 同时支持转发 65535 个请求?


这个推论不成立。


从上面的映射表可以看到,运营商机器收到响应报文时,会根据 三个关键参数 进行映射,而非只根据 自身端口映射。以上面 NAT 映射表的第一条记录为例,运营商机器的 300 端口,并非仅仅服务于 200.200.200.200 这次请求。300 端口还可以同时服务 250.250.250.250 + 80 端口,以及其他连接!


由于映射的参数有三个,而不仅仅是运营商端口一项,因此并发程度非常高。


理论的最大并发度应该是 65535 * (公网 IP 数)* 65535,这个并发度非常高。 一个运营商机器似乎支持海量的NAT连接,实际上,并非海量。


因为常用的 Http 协议端口是 80,目标公网机器的端口数常用的基本是 80 端口。


其次用户常用的软件非常集中,例如微信、抖音、稀土掘金等,访问的公网 IP 也集中于这些公司的 IP 地址。


所以基于此,最高并发度变为:65535 * 常用的公网 IP数 * 有限的端口数(80、443)。


这个并发度并非海量,但是基本上足够使用了,一个小区或办公区的网络设备数量不会过于庞大。65535 * 有限的公网 IP数 * 有限的端口数,这样的并发度足够支持一般场景使用。


除非出现极端的情况! 即一个小区的大量用户集中访问于一个公网 IP 的 80 端口,这样网络流量一定会发生拥塞!在某些用户流量集中的区域,可以安排更多的 NAT 设备,提供更多的公网 IP。


一个小区一个公网 IP 吗?


根据 chatgpt 的回答,通常情况下,一个小区只有一个公网 IP。
image.png


上大学时,偶然了解到 SQL注入,我感觉很新奇。后来对一个兼职网站进行 SQL 注入的尝试。经过几次尝试后,我发现无法再访问这个网站。宿舍和其他几个宿舍的同学也无法访问此网站。我不禁得意洋洋,难道是因为我的攻击导致了这个网站的崩溃?


后来我找到其他大学的高中同学,让他们访问这个网站,他们访问是没问题的。那时我明白了,这个网站只是封掉了我们学校的公网 IP,或者是这栋男生宿舍楼的公网 IP ,它并没有崩溃!


个人经验来看,一个小区或者办公楼会根据实际的需要安排一定数量的公网 IP,一般情况下共用一个公网 IP。


总结


当 公网 IP 不够用时,可通过 NAT 协议实现多个用户设备共享同一个公网 IP,提高 公网IP 地址的利用度。Ipv4 的地址数量有限,在 2011 年已经被分配完,未来如果全面实现了 ipv6 协议,我们手机等终端设备也可能会有一个公网 IP。


但是公网 Ip 全球都可以访问,与此对应的网络安全问题不可忽视。NAT 技术则可以有效保护用户设备,让用户安全上网,这也是它附带的好处。


如果看完有所收获,感谢点赞支持!


作者:五阳神功
来源:juejin.cn/post/7307892574722637835
收起阅读 »

🌅 让你的用户头像更具艺术感,实现一个自动生成唯一渐变色的头像组件

web
前言 这一篇文章依然是组件实现系列,这次我们来实现一个基于用户昵称动态生成用户头像的组件。在很多中后台场景中,用户并不需要自己去上传头像,而是简单的展示一个基本的头像图片,但是这样就太没意思啦。这次头像组件的,预期是能够基于用户昵称字符串生成某种颜色,然后设置...
继续阅读 »

前言


这一篇文章依然是组件实现系列,这次我们来实现一个基于用户昵称动态生成用户头像的组件。在很多中后台场景中,用户并不需要自己去上传头像,而是简单的展示一个基本的头像图片,但是这样就太没意思啦。这次头像组件的,预期是能够基于用户昵称字符串生成某种颜色,然后设置为 渐变色背景 并添加一些额外的细节就能展示出一个相对好看的头像。


实现过程


Avatar 组件


首先我们封装一个 Avatar 组件,这里我引用了 ChakraUI 组件库:


const Avatar: React.FC<{ name: string } & AvatarProps> = ({
name,
...rest
}
) =>
{
return (
<Box w="12" h="12" p="0" {...rest}>
<ChakraAvatar {...getGradientStyle(name)} name={name} />
Box>

);
};

export default Avatar;

样式生成函数


这一步还是很简单的,在组件的 props 中我使用了一个 getGradientStyle(name) 函数用于获取头像组件的样式,下面我们来实现这个函数:


const getGradientStyle: (text: string) => AvatarProps = (text) => {
const color1 = getRandomColor(text, 1);
const color2 = getRandomColor(text, 0.7);

return {
backgroundImage: `linear-gradient(135deg, ${color1}, ${color2})`,
color: "white",
display: "flex",
justifyContent: "center",
alignItems: "center",
fontWeight: "bold",
borderRadius: "10px",
background: "transparent",
fontFamily: "Helvetica, Arial, sans-serif",
};
};

随机颜色函数


这个函数返回一个包含渐变和其他属性的样式对象,这里面还有一个核心的函数 getRandomColor,这个函数可以基于字符串生成颜色,并且可以自己传入透明度,下面说说这个函数是如何实现的:


function getRandomColor(str: string, alpha: number) {
let asciiSum = 0;
for (let i = 0; i < str.length; i++) {
asciiSum += str.charCodeAt(i);
}
const red = Math.abs(Math.sin(asciiSum) * 256).toFixed(0);
const green = Math.abs(Math.sin(asciiSum + 1) * 256).toFixed(0);
const blue = Math.abs(Math.sin(asciiSum + 2) * 256).toFixed(0);
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}

解析一下 getRandomColor 函数的执行过程:



  1. 首先遍历字符串 str 中的每个字符,计算它们的 ASCII 码值之和 asciiSum

  2. 使用这个数字作为参数,通过 Math.sin 函数生成一个介于 -1 和 1 之间的正弦值。再通过 Math.sin

  3. 将这个正弦值乘以 256 并四舍五入,得到一个介于 0 和 255 之间的整数,作为 rgba 颜色值的红色、绿色、蓝色分量。

  4. 最后,将传入的透明度 alpha 与颜色值一起组成一个 rgba 颜色值字符串并返回。


实现的效果如下图:


image.png


字体阴影


基本的一个头像已经做好了,但是我们还需要补充一些细节。为了让字体在浅色背景下也可以看清楚,我们可以为字体加上一点字体阴影。


textShadow: "1px 1px 3px rgba(0, 0, 0, 0.2)"

添加前后的对比可以看下图,重点是像 "王" 字这种比较浅色的是不是一下就清晰多了。



添加前:
image.png


添加后:
image.png



背景纹理


现在的头像背景效果已经蛮不错了,但是只是一个渐变背景我还是觉得太单调了,如果里面能够增加一点纹理就好了。于是我又添加了一个水波纹的效果,实现的代码如下:


return {
backgroundImage: `linear-gradient(135deg, ${color1}, ${color2})`,
color: "white",
display: "flex",
justifyContent: "center",
alignItems: "center",
fontWeight: "bold",
borderRadius: "10px",
background: "transparent",
textShadow: "1px 1px 3px rgba(0, 0, 0, 0.2)",
fontFamily: "Helvetica, Arial, sans-serif",
position: "relative",
_before: {
content: `""`,
position: "absolute",
left: "0",
top: "0",
w: "full",
h: "full",
borderRadius: "10px",
backgroundColor: "white",
backgroundImage: `repeating-radial-gradient( circle at 0 0, transparent 0, #ffffff 9px ), repeating-linear-gradient( ${color2}, ${color1} )`,
zIndex: "-1",
},
};

在原有背景的基础上,我增加了 before 伪元素,将它的位置大小与背景重叠,然后通过 backgroundImage 设置了背景的纹理:


backgroundImage: \`repeating-radial-gradient( circle at 0 0, transparent 0, #ffffff 9px ), repeating-linear-gradient( ${color2}, ${color1} )

再通过将原本的背景色设置为 transparent 透明,将 before 的层级设置为 -1,将背后的纹理给透出去,实现的效果如下图:


image.png


好不好看这点仁者见仁,但是我觉得是精致了一点的。before 中的纹理是这样的:


image.png


是不是有点像阿尔卑斯糖呢 🍭。这个效果我是从一个背景生成网站中调整并生成的,网站地址在这:http://www.magicpattern.design/tools/css-b… ,这个网站提供了很多好看的背景纹理,可以在线调整颜色和间距,预览效果,还能直接复制 CSS 到代码里使用。


image.png


最后再放一下 26 个字母生成的头像效果,不同字母生成的颜色差别还是相对比较大的。我觉着效果都还不错,即便是不太好看的颜色在背景纹理和渐变的加成下也还凑合能看:


image.png


性能优化


最后我们看回前面生成颜色的函数,在代码里有这么一段用于生成两个渐变色的逻辑:


const color1 = getRandomColor(text, 1);
const color2 = getRandomColor(text, 0.7);

但是这里我们仅仅是改变了透明度,颜色其实是不变的,那么去计算两次颜色就没有必要了,我们可以先获取颜色,然后再改透明度,避免重复计算颜色。修改的方式有很多种,如果是你,你会怎么改呢?我的调整方式是这样的:


function getRandomColor(str: string) {
let asciiSum = 0;
for (let i = 0; i < str.length; i++) {
asciiSum += str.charCodeAt(i);
}

const red = Math.abs(Math.sin(asciiSum) * 256).toFixed(0);
const green = Math.abs(Math.sin(asciiSum + 1) * 256).toFixed(0);
const blue = Math.abs(Math.sin(asciiSum + 2) * 256).toFixed(0);
return (alpha: number) => `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}

const color = getRandomColor(text);
const color1 = color(1);
const color2 = color(0.7);

这里我将函数进行柯里化,将一个多参数的函数转换为一系列单参数的函数,每个函数接受一个参数并返回一个函数,最终返回值由最后一个函数计算得出。


我们可以通过对 getRandomColor() 函数进行一次调用来获取一个特定字符串对应的颜色生成函数,然后多次调用该生成函数并传入不同的透明度参数来生成不同的颜色。这是柯里化的一个常见应用场景。


最终完整代码如下:


import { Avatar as ChakraAvatar, AvatarProps } from "@chakra-ui/react";

function getRandomColor(str: string) {
let asciiSum = 0;
for (let i = 0; i < str.length; i++) {
asciiSum += str.charCodeAt(i);
}

const red = Math.abs(Math.sin(asciiSum) * 256).toFixed(0);
const green = Math.abs(Math.sin(asciiSum + 1) * 256).toFixed(0);
const blue = Math.abs(Math.sin(asciiSum + 2) * 256).toFixed(0);
return (alpha: number) => `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}

const getGradientStyle: (text: string) => AvatarProps = (text) => {
const color = getRandomColor(text);
const color1 = color(1);
const color2 = color(0.7);

return {
backgroundImage: `linear-gradient(135deg, ${color1}, ${color2})`,
color: "white",
display: "flex",
justifyContent: "center",
alignItems: "center",
fontWeight: "bold",
borderRadius: "10px",
background: "transparent",
textShadow: "1px 1px 3px rgba(0, 0, 0, 0.2)",
fontFamily: "Helvetica, Arial, sans-serif",
position: "relative",
_before: {
content: `""`,
position: "absolute",
left: "0",
top: "0",
w: "full",
h: "full",
borderRadius: "10px",
backgroundColor: "white",
backgroundImage: `repeating-radial-gradient( circle at 0 0, transparent 0, #ffffff 9px ), repeating-linear-gradient( ${color2}, ${color1} )`,
zIndex: "-1",
},
};
};

const Avatar: React.FC<{ name: string } & AvatarProps> = ({
name,
...rest
}
) =>
{
return <ChakraAvatar {...getGradientStyle(name)} {...rest} name={name} />;
};

export default Avatar;


总结


后面我想着可以再将 纹理的类型和方向 也基于传入的字符串去定制,这样就能实现随机度更高的定制头像了,如果未来有了更好的效果我再单独写篇文章分享!


后续这类组件封装的文章可能会出一个系列,也准备把这些组件都开源了,如果有使用或打算使用 ChakraUI 进行项目搭建的同学欢迎插眼关注。如果文章对你有帮助除了收藏之余可以点个赞 👍,respect



作者:oil欧哟
来源:juejin.cn/post/7218506966545170493
收起阅读 »

Android 使用Xfermode合成TabBarView

一、前言 PorterDuffXfermode  作为Android重要的合成组件,可以通过区域叠加方式进行裁剪和合成,起作用和Path.Op 类似,对于音视频开发中使用蒙版抠图和裁剪的需求,这种情况一般生成不了Path时,因此只能使用PorterDuffXf...
继续阅读 »

一、前言


PorterDuffXfermode  作为Android重要的合成组件,可以通过区域叠加方式进行裁剪和合成,起作用和Path.Op 类似,对于音视频开发中使用蒙版抠图和裁剪的需求,这种情况一般生成不了Path时,因此只能使用PorterDuffXfermode进行合成,当然Paint设置Shader也具备一定的能力,但是还是无法做到很多效果。


二、案例



这个案例使用了Bitmap合成,在边缘区域对色彩裁剪,从而实现了圆觉裁剪。


模版



//裁剪区域



技术上没有太多难点,但要注意的是Xfermode是2个Bitmap之间只使用,不像Shader那样可以单独使用。


Canvas resultCanvas = new Canvas(resultBitmap);
resultCanvas.drawBitmap(dstBitmap, paddingLeft + point.x, paddingTop, mSolidPaint);
mSolidPaint.setXfermode(mPorterDuffXfermode);
resultCanvas.drawBitmap(srcRoundBitmap, 0, 0, mSolidPaint);
canvas.drawBitmap(resultBitmap, 0, 0, null);

另外一点就是速度计算,利用了没有时间的逼近减速公式,当然你可以使用动画去实现


 float speed = Math.abs((mTargetZone - 1) * (contentWidth / mDivideNumber) - point.x) / mSpeed;

下面是速度控制逻辑


    @Override
public void run() {
//计算速度,先按照最大速度5变化,如果小于5,则表示该减速停靠
float speed = Math.abs((mTargetZone - 1) * (contentWidth / mDivideNumber) - point.x) / mSpeed;
speed = (float) Math.max(1f, speed);
float vPos = (mTargetZone - 1) * (contentWidth / mDivideNumber);
if (point.x < vPos) {
point.x += speed;
if (point.x > vPos) {
point.x = vPos;
}
} else {
point.x -= speed;
if (point.x < vPos) {
point.x = vPos;
}
}
if (point.x == vPos) {
isSliding = false;
} else {
isSliding = true;
postDelayed(this, 20);
}
postInvalidate();
}

全部逻辑


public class TabBarView extends View implements Runnable {
//画笔
private Paint mSolidPaint;
//中间竖线与边框间隙
private int gapPadding = 0;
//平分量
private int mDivideNumber = 1;
//边框大小
private final float mBorderSize = 1.5f;
//避免重复绘制Bitmap,短暂保存底色bitmap
private Bitmap srcRoundBitmap;
//图片混合模式
private PorterDuffXfermode mPorterDuffXfermode;
private PointF point;
//内容区域大小
private float contentWidth;
private float contentHeight;
//滑动到的目标区域
private int mTargetZone;
//滑动速度
private float mSpeed;
//主调颜色
private int primaryColor;
//默认字体颜色
private int textColor;
//焦点字体颜色
private int selectedTextColor;
//item
private CharSequence[] mStringItems;
//字体大小
private float textSize;
//是否处于滑动
private boolean isSliding;

Bitmap dstBitmap;
Bitmap resultBitmap;

private RectF rectBound = new RectF();

public TabBarView(Context context) {
super(context);
init(null, 0);
}

public TabBarView(Context context, AttributeSet attrs) {
super(context, attrs);
init(attrs, 0);
}

public TabBarView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init(attrs, defStyle);
}

private void init(AttributeSet attrs, int defStyle) {
// Load attributes
final TypedArray a = getContext().obtainStyledAttributes(
attrs, R.styleable.TabBarView, defStyle, 0);

//参数值越大,速度越大,速度指数越小
mSpeed = Math.max(10 - Math.max(a.getInt(R.styleable.TabBarView_speed, 6), 6), 1);

mStringItems = a.getTextArray(R.styleable.TabBarView_tabEntries);
primaryColor = a.getColor(R.styleable.TabBarView_primaryColor, 0xFF4081);
textColor = a.getColor(R.styleable.TabBarView_textColor, primaryColor);
selectedTextColor = a.getColor(R.styleable.TabBarView_selectedTextColor, 0xffffff);
textSize = a.getDimensionPixelSize(R.styleable.TabBarView_textSize, 30);

if (mStringItems != null && mStringItems.length > 0) {
mDivideNumber = mStringItems.length;
}

a.recycle();

mSolidPaint = new Paint();
mSolidPaint.setFlags(Paint.ANTI_ALIAS_FLAG);
mPorterDuffXfermode = new PorterDuffXfermode(PorterDuff.Mode.SRC_IN);
point = new PointF(0, 0);
mTargetZone = 1;

invalidateTextPaintAndMeasurements();

}

private void invalidateTextPaintAndMeasurements() {
mSolidPaint.setColor(primaryColor);
mSolidPaint.setStrokeWidth(mBorderSize);
mSolidPaint.setTextSize(textSize);
mSolidPaint.setStyle(Paint.Style.STROKE);
mSolidPaint.setXfermode(null);
}



@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
recycleBitmap();
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int paddingLeft = getPaddingLeft();
int paddingTop = getPaddingTop();
int paddingRight = getPaddingRight();
int paddingBottom = getPaddingBottom();

contentWidth = getWidth() - paddingLeft - paddingRight;
contentHeight = getHeight() - paddingTop - paddingBottom;
float minContentSize = Math.min(contentWidth, contentHeight);

rectBound.set(paddingLeft, paddingTop, paddingLeft + contentWidth, paddingTop + contentHeight);
canvas.drawRoundRect(rectBound, minContentSize / 2F, minContentSize / 2F, mSolidPaint);
for (int i = 1; i < mDivideNumber; i++) {
canvas.drawLine(paddingLeft + 1F * contentWidth * i / mDivideNumber, paddingTop + gapPadding, paddingLeft + contentWidth * i / mDivideNumber, paddingTop + contentHeight - gapPadding, mSolidPaint);

}

if (srcRoundBitmap == null) {
srcRoundBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
Canvas srcCanvas = new Canvas(srcRoundBitmap);
mSolidPaint.setStyle(Paint.Style.FILL_AND_STROKE);
srcCanvas.drawRoundRect(rectBound, minContentSize / 2F, minContentSize / 2F, mSolidPaint);
}

if(dstBitmap == null) {
dstBitmap = Bitmap.createBitmap((int) (contentWidth / mDivideNumber), (int) contentHeight, Bitmap.Config.ARGB_8888);
}
dstBitmap.eraseColor(Color.TRANSPARENT);
Canvas dstCanvas = new Canvas(dstBitmap);
dstCanvas.drawColor(Color.YELLOW);

if(resultBitmap == null) {
resultBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
}
resultBitmap.eraseColor(Color.TRANSPARENT);
Canvas resultCanvas = new Canvas(resultBitmap);
resultCanvas.drawBitmap(dstBitmap, paddingLeft + point.x, paddingTop, mSolidPaint);
mSolidPaint.setXfermode(mPorterDuffXfermode);
resultCanvas.drawBitmap(srcRoundBitmap, 0, 0, mSolidPaint);
canvas.drawBitmap(resultBitmap, 0, 0, null);

invalidateTextPaintAndMeasurements();

if (mStringItems != null) {

for (int i = 0; i < mStringItems.length; i++) {
String itemChar = mStringItems[i].toString();
float textX = (contentWidth / mDivideNumber) * i / 2 + paddingLeft + (contentWidth * (i + 1) / mDivideNumber - mSolidPaint.measureText(itemChar)) / 2;
float textY = paddingTop + (contentHeight - mSolidPaint.getFontMetrics().bottom - mSolidPaint.getFontMetrics().ascent) / 2;
int color = mSolidPaint.getColor();
mSolidPaint.setStyle(Paint.Style.FILL);
if ((i + 1) == mTargetZone && !isSliding) {
mSolidPaint.setColor(selectedTextColor);
} else {
mSolidPaint.setColor(textColor);
}
canvas.drawText(itemChar, textX, textY, mSolidPaint);
mSolidPaint.setColor(color);
mSolidPaint.setStyle(Paint.Style.STROKE);
}
}
}


@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (checkLocationIsOk(event) && !isSliding) {
return true;
}
break;
case MotionEvent.ACTION_MOVE:
return checkLocationIsOk(event);
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_OUTSIDE:
if (checkLocationIsOk(event) && !isSliding) {
float x = event.getX() - getPaddingLeft();
mTargetZone = (int) (x / (contentWidth / mDivideNumber)) + 1;
//规避区域超出范围
mTargetZone = Math.min(mTargetZone, mDivideNumber);
postToMove();
}
break;
}
return super.onTouchEvent(event);
}

private void postToMove() {
if (point.x == (mTargetZone - 1) * (contentWidth / mDivideNumber)) {
return;
}
postDelayed(this, 20);
}

/**
* 检测位置是否可用
*
* @param event
* @return
*/
private boolean checkLocationIsOk(MotionEvent event) {
float x = event.getX();
float y = event.getY();
if (x - getPaddingLeft() > 0 && (getPaddingLeft() + contentWidth - x) > 0 && y - getPaddingTop() > 0 && (getPaddingTop() + contentHeight - y) > 0) {
return true;
}
return false;
}

private void recycleBitmap(Bitmap bmp) {
if (bmp != null && !bmp.isRecycled()) {
bmp.recycle();
}
}

@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
getHandler().removeCallbacksAndMessages(null);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);

if (widthMode != MeasureSpec.EXACTLY) {
widthSize = getResources().getDisplayMetrics().widthPixels / 2;
}

int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);
}

@Override
public void run() {
//计算速度,先按照最大速度5变化,如果小于5,则表示该减速停靠
float speed = Math.abs((mTargetZone - 1) * (contentWidth / mDivideNumber) - point.x) / mSpeed;
speed = (float) Math.max(1f, speed);
float vPos = (mTargetZone - 1) * (contentWidth / mDivideNumber);
if (point.x < vPos) {
point.x += speed;
if (point.x > vPos) {
point.x = vPos;
}
} else {
point.x -= speed;
if (point.x < vPos) {
point.x = vPos;
}
}
if (point.x == vPos) {
isSliding = false;
} else {
isSliding = true;
postDelayed(this, 20);
}
postInvalidate();
}

public void setSelectedTab(int tabIndex) {
mTargetZone = Math.max(Math.min(mDivideNumber, tabIndex + 1), 1);
recycleBitmap();
postToMove();
}

public void setTabItems(CharSequence[] mStringItems) {
this.mStringItems = mStringItems;
recycleBitmap();
invalidate();
}

private void recycleBitmap() {
if(dstBitmap != null && !dstBitmap.isRecycled()){
dstBitmap.recycle();
}
if(resultBitmap != null && !resultBitmap.isRecycled()){
resultBitmap.recycle();
}
resultBitmap = null;
dstBitmap = null;
}
}

我们需要自定义一些属性


<declare-styleable name="TabBarView">

<attr name="speed" format="integer" />
<attr name="tabEntries" format="reference"/>
<attr name="primaryColor" format="color|reference"/>
<attr name="textSize" format="dimension"/>
<attr name="textColor" format="color|reference"/>
<attr name="selectedTextColor" format="color|reference"/>

</declare-styleable>

还有部分需要引用的 string-array


<string-array name="tabEntries_array">
<item>A</item>
<item>B</item>
<item>C</item>
<item>D</item>
</string-array>

然后是布局文件(片段)


<com.android.jym.widgets.TabBarView
android:layout_width="match_parent"
android:layout_height="40dp"
android:background="@android:color/transparent"
android:padding="10dp"
app:speed="4"
app:tabEntries="@array/tabEntries_array"
app:primaryColor="@color/colorAccent"
app:textColor="@color/colorPrimaryDark"
app:selectedTextColor="@android:color/white"
/>

三、总结


使用Xfermode + 蒙版进行抠图,是Android中重要的工具,本篇作为技术储备,后续会通过这种方式实现一些新的功能。


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

为什么最近听说 Go 岗位很少很难?

大家好,我是煎鱼。 其实这个话题已经躺在我的 TODO 里很久了,近来很多社区的小伙伴都私下来交流,也有在朋友圈看吐槽 Go 上海的大会没什么人。还不如 Rust 大会,比较尴尬。 今天主要是从个人角度看看为什么 Go 岗位看起来近来很难的样子? 盘一下数据 ...
继续阅读 »

大家好,我是煎鱼。


其实这个话题已经躺在我的 TODO 里很久了,近来很多社区的小伙伴都私下来交流,也有在朋友圈看吐槽 Go 上海的大会没什么人。还不如 Rust 大会,比较尴尬。


今天主要是从个人角度看看为什么 Go 岗位看起来近来很难的样子?


盘一下数据


从以往的大的数据分析来看,Go 岗位最多的是分布以下几个城市:



TOP3 是北京、上海、深圳。


再从常用的招聘软件来看,目前互联网行业用的应该是 XX 直聘。我们从其提供的招聘岗位数量来看。


北京:



上海:



深圳:



从这 5 个月的时间区间来看,北京和上海是在螺旋式下跌;深圳探底创新低。(不过我认为从招聘季节来看,基本都是螺旋式的)


从数值来看,只有北京的 Go 岗位有所上涨。上海、深圳都在持续减少。但整体都是在下滑趋势的。


另外一个角度来看,招聘岗位这么多,也有个好几千。是不是没什么问题,风生水起?


还是要看看活跃度的。我快速的看了下深圳,Go 岗位。第一页 30 条招聘岗位,只有 8 个是本周活跃的。刚刚活跃和 3 日内活跃的,加起来就 2~3 个。


综合数据来看,招聘岗位的数量在向下走,招聘者登陆平台的活跃度不高。


看看小行情


可能很容易就得出了 Go 完全不行的结论。我们也得看看别的编程语言岗位的。


Java,深圳:



PHP,深圳:



综合来看,其实并不是某一门语言的岗位不太行,或者要凉了。是由于整体的行情原因,招聘岗位和招聘者的活跃度都在大量的收缩。


像是以往,Go 最多的招聘岗位也是由各中大型公司撑起的。例如:字节跳动、腾讯、滴滴、百度等。他们收缩了,增量也就下来了。


而一般这种放水阶段,很多是面向 GY 企的,会有一些项目出现。例如:前段时间很火热的信创。


但有做 2B 的同学应该了解,这块有些企业会加码,要求使用 Java 语言。这一块的增量,与 Java 相比较,Go 是比较难在正面承接到的。


总结


其实不单单 Go 岗位少了。由于宏观的影响,我们常接触到的招聘岗位都少了。普遍来讲,还是建议如果没想明白就先苟着,降低负债、现金为王。


人是环境的反应器,常常会受到各种因素的影响。但在这种时期,可能想清楚自己的目标和感兴趣的内容、方向,会是一个不错的提高机会。


而在缩量的环境下,如果想找到增量。就要去一个风口或上行周期的领域。例如最近比较火的 AI。之前的新能源,不过也要警惕是个新坑。



文章持续更新,可以微信搜【脑子进煎鱼了】阅读,本文 GitHub github.com/eddycjy/blo… 已收录,学习 Go 语言可以看 Go 学习地图和路线,欢迎 Star 催更。



推荐阅读



作者:煎鱼eddycjy
来源:juejin.cn/post/7308697586532778024
收起阅读 »

封装一个工作日历组件,顺便复习一下Date常用方法

web
背景 上周接到一个需求,开发一个工作日历组件。找了一圈没找到合适的,索性自己写一个。 下面给大家分享一下组件中使用到的一些日期API和后面实现的框选元素功能。 效果展示 demo体验地址:dbfu.github.io/work-calend… 开始之前 lu...
继续阅读 »

背景


上周接到一个需求,开发一个工作日历组件。找了一圈没找到合适的,索性自己写一个。


下面给大家分享一下组件中使用到的一些日期API和后面实现的框选元素功能。


效果展示


Kapture 2023-12-05 at 13.02.36.gif


demo体验地址:dbfu.github.io/work-calend…


开始之前


lunar-typescript


介绍组件之前先给大家介绍一个库lunar-typescript


lunar是一个支持阳历、阴历、佛历和道历的日历工具库,它开源免费,有多种开发语言的版本,不依赖第三方,支持阳历、阴历、佛历、道历、儒略日的相互转换,还支持星座、干支、生肖、节气、节日、彭祖百忌、每日宜忌、吉神宜趋、凶煞宜忌、吉神方位、冲煞、纳音、星宿、八字、五行、十神、建除十二值星、青龙名堂等十二神、黄道日及吉凶等。仅供参考,切勿迷信。


这个库封装了很多常用的api,并且使用起来也比较简单。


本文用到了上面库的获取农历和节气方法。


复习Date Api


new Date


可以使用new Date()传年月日三个参数来构造日期,这里注意一下月是从零开始的。


image.png


获取星期几


可以使用getDay方法获取,注意一下,获取的值是从0开始的,0表示星期日。


image.png


获取上个月最后一天


基于上面api,如果第三个参数传0,就表示上个月最后一天,-1,是上个月倒数第二天,以此类推。(PS:这个方法还是我有次面试,面试官告诉我的。)


image.png


获取某个月有多少天


想获取某个月有多少天,只需要获取当月最后天的日期,而当月最后一天,可以用上面new Date第三个参数传零的方式获取。


假设我想获取2023年12月有多少天,按照下面方式就可以获取到。


image.png


日期加减


假设我现在想实现在某个日期上加一天,可以像下面这样实现。


image.png


这样实现有个不好的地方,改变了原来的date,如果不想改变date,可以这样做。


image.png


比较两个日期


在写这个例子的时候,我发现一个很神奇的事情,先看例子。


image.png


大于等于结果是true,小于等于结果也是true,正常来说肯定是等于的,但是等于返回的是false,是不是很神奇。


其实原理很简单,用等于号去比较的时候,会直接比较两个对象的引用,因为是分别new的,所以两个引用肯定不相等,返回false。


用大于等于去比较的时候,会默认使用date的valueOf方法返回值去比较,而valueOf返回值也就是时间戳,他们时间戳是一样的,所以返回true。


说到这里,给大家分享一个经典面试题。


console.log(a == 1 && a == 2 && a == 3),希望打印出true


原理和上面类似,感兴趣的可以挑战一下。


这里推荐大家比较两个日期使用getTime方法获取时间戳,然后再去比较。


image.png


实战


数据结构


开发之前先把数据结构定一下,一个正确的数据结构会让程序开发变得简单。


根据上面效果图,可以把数据结构定义成这样:



/**
* 日期信息
*/

export interface DateInfo {
/**
* 年
*/

year: number;
/**
* 月
*/

month: number;
/**
* 日
*/

day: number;
/**
* 日期
*/

date: Date;
/**
* 农历日
*/

cnDay: string;
/**
* 农历月
*/

cnMonth: string;
/**
* 农历年
*/

cnYear: string;
/**
* 节气
*/

jieQi: string;
/**
* 是否当前月
*/

isCurMonth?: boolean;
/**
* 星期几
*/

week: number;
/**
* 节日名称
*/

festivalName: string;
}

/**
* 月份的所有周
*/

export interface MonthWeek {
/**
* 月
*/

month: number;
/**
* 按周分组的日期,7天一组
*/

weeks: DateInfo[][];
}

通过算法生成数据结构


现在数据结构定义好了,下面该通过算法生成上面数据结构了。


封装获取日期信息方法


/**
* 获取给定日期的信息。
* @param date - 要获取信息的日期。
* @param isCurMonth - 可选参数,指示日期是否在当前月份。
* @returns 包含有关日期的各种信息的对象。
*/

export const getDateInfo = (date: Date, isCurMonth?: boolean): DateInfo => {
// 从给定日期创建 农历 对象
const lunar = Lunar.fromDate(date);

// 获取 Lunar 对象中的农历日、月和年
const cnDay = lunar.getDayInChinese();
const cnMonth = lunar.getMonthInChinese();
const cnYear = lunar.getYearInChinese();

// 获取农历节日
const festivals = lunar.getFestivals();

// 获取 Lunar 对象中的节气
const jieQi = lunar.getJieQi();

// 从日期对象中获取年、月和日
const year = date.getFullYear();
const month = date.getMonth();
const day = date.getDate();

// 创建包含日期信息的对象
return {
year,
month,
day,
date,
cnDay,
cnMonth,
cnYear,
jieQi,
isCurMonth,
week: date.getDay(),
festivalName: festivals?.[0] || festivalMap[`${month + 1}-${day}`],
};
};

上面使用了lunar-typescript库,获取了一些农历信息,节气和农历节日。方法第二个参数isCurMonth是用来标记是否是当月的,因为很多月的第一周或最后一周都会补一些其他月日期。


把月日期按照每周7天格式化


思路是先获取给定月的第一天是星期几,如果前面有空白,用上个月日期填充,然后遍历当月日期,把当月日期填充到数组中,如果后面有空白,用下个月日期填充。


/**
* 返回给定年份和月份的周数组。
* 每个周是一个天数数组。
*
* @param year - 年份。
* @param month - 月份 (0-11)。
* @param weekStartDay - 一周的起始日 (0-6) (0: 星期天, 6: 星期六)。
* @returns 给定月份的周数组。
*/

const getMonthWeeks = (year: number, month: number, weekStartDay: number) => {
// 获取给定月份的第一天
const start = new Date(year, month, 1);

// 这里为了支持周一或周日在第一天的情况,封装了获取星期几的方法
const day = getDay(start, weekStartDay);

const days = [];

// 获取给定月份的前面的空白天数,假如某个月第一天是星期3,并且周日开始,那么这个月前面的空白天数就是3
// 如果是周一开始,那么这个月前面的空白天数就是2
// 用上个月日期替换空白天数
for (let i = 0; i < day; i += 1) {
days.push(getDateInfo(new Date(year, month, -day + i + 1)));
}

// 获取给定月份的天数
const monthDay = new Date(year, month + 1, 0).getDate();

// 把当月日期放入数组
for (let i = 1; i <= monthDay; i += 1) {
days.push(getDateInfo(new Date(year, month, i), true));
}

// 获取给定月份的最后一天
const endDate = new Date(year, month + 1, 0);
// 获取最后一天是星期几
const endDay = getDay(endDate, weekStartDay);

// 和前面一样,如果有空白位置就用下个月日期补充上
for (let i = endDay; i <= 5; i += 1) {
days.push(getDateInfo(new Date(year, month + 1, i - endDay + 1)));
}

// 按周排列
const weeks: DateInfo[][] = [];
for (let i = 0; i < days.length; i += 1) {
if (i % 7 === 0) {
weeks.push(days.slice(i, i + 7));
}
}

// 默认每个月都有6个周,如果没有的话就用下个月日期补充。
while (weeks.length < 6) {
const endDate = weeks[weeks.length - 1][6];
weeks.push(
Array.from({length: 7}).map((_, i) => {
const newDate = new Date(endDate.date);
newDate.setDate(newDate.getDate() + i + 1)
return getDateInfo(newDate);
})
);
}
return weeks;
};

getDay方法实现


function getDay(date: Date, weekStartDay: number) {
// 获取给定日期是星期几
const day = date.getDay();
// 根据给定的周开始日,计算出星期几在第一天的偏移量
if (weekStartDay === 1) {
if (day === 0) {
return 6;
} else {
return day - 1;
}
}
return day;
}

获取一年的月周数据


/**
* 获取年份的所有周,按月排列
* @param year 年
* @param weekStartDay 周开始日 0为周日 1为周一
* @returns
*/

export const getYearWeeks = (year: number, weekStartDay = 0): MonthWeek[] => {
const weeks = [];
for (let i = 0; i <= 11; i += 1) {
weeks.push({month: i, weeks: getMonthWeeks(year, i, weekStartDay)});
}
return weeks;
};

页面


页面布局使用了grid和table,使用grid布局让一行显示4个,并且会自动换行。日期显示使用了table布局。


如果想学习grid布局,推荐这篇文章


工作日历日期分为三种类型,工作日、休息日、节假日。在渲染单元格根据不同的日期类型,渲染不同背景颜色用于区分。


image.png


image.png


image.png


维护日期类型


背景


虽然节假日信息可以从网上公共api获取到,但是我们的业务希望可以自己调整日期类型,这个简单给单元格加一个点击事件,点击后弹出一个框去维护当前日期类型,但是业务希望能支持框选多个日期,然后一起调整,这个就稍微麻烦一点,下面给大家分享一下我的做法。


实现思路


实现框选框


定义一个fixed布局的div,设置背景色和边框颜色,背景色稍微有点透明。监听全局点击事件,记录初始位置,然后监听鼠标移动事件,拿当前位置减去初始位置就是宽度和高度了,初始位置就是div的left和top。


获取框选框内符合条件的dom元素


当框选框位置改变的时候,获取所有符合条件的dom元素,然后通过坐标位置判断dom元素是否和框选框相交,如果相交,说明被框选了,把当前dom返回出去。


判断两个矩形是否相交


interface Rect {
x: number;
y: number;
width: number;
height: number;
}

export function isRectangleIntersect(rect1: Rect, rect2: Rect) {
// 获取矩形1的左上角和右下角坐标
const x1 = rect1.x;
const y1 = rect1.y;
const x2 = rect1.x + rect1.width;
const y2 = rect1.y + rect1.height;

// 获取矩形2的左上角和右下角坐标
const x3 = rect2.x;
const y3 = rect2.y;
const x4 = rect2.x + rect2.width;
const y4 = rect2.y + rect2.height;

// 如果 `rect1` 的左上角在 `rect2` 的右下方(即 `x1 < x4` 和 `y1 < y4`),并且 `rect1` 的右下角在 `rect2` 的左上方(即 `x2 > x3` 和 `y2 > y3`),那么这意味着两个矩形相交,函数返回 `true`。
// 否则,函数返回 `false`,表示两个矩形不相交。
if (x1 < x4 && x2 > x3 && y1 < y4 && y2 > y3) {
return true;
} else {
return false;
}
}

具体实现


框选框组件实现


import { useEffect, useRef, useState } from 'react';

import { createPortal } from 'react-dom';
import { isRectangleIntersect } from './utils';

interface Props {
selectors: string;
sourceClassName: string;
onSelectChange?: (selectDoms: Element[]) => void;
onSelectEnd?: () => void;
style?: React.CSSProperties,
}

function BoxSelect({
selectors,
sourceClassName,
onSelectChange,
style,
onSelectEnd,
}: Props
) {

const [position, setPosition] = useState({ top: 0, left: 0, width: 0, height: 0 });

const isPress = useRef(false);

const startPos = useRef<any>();

useEffect(() => {
// 滚动的时候,框选框位置不变,但是元素位置会变,所以需要重新计算
function scroll() {
if (!isPress.current) return;
setPosition(prev => ({ ...prev }));
}

// 鼠标按下,开始框选
function sourceMouseDown(e: any) {
isPress.current = true;
startPos.current = { top: e.clientY, left: e.clientX };
setPosition({ top: e.clientY, left: e.clientX, width: 1, height: 1 })
// 解决误选择文本情况
window.getSelection()?.removeAllRanges();
}
// 鼠标移动,移动框选
function mousemove(e: MouseEvent) {
if (!isPress.current) return;

let left = startPos.current.left;
let top = startPos.current.top;
const width = Math.abs(e.clientX - startPos.current.left);
const height = Math.abs(e.clientY - startPos.current.top);

// 当后面位置小于前面位置的时候,需要把框的坐标设置为后面的位置
if (e.clientX < startPos.current.left) {
left = e.clientX;
}

if (e.clientY < startPos.current.top) {
top = e.clientY;
}

setPosition({ top, left, width, height })
}

// 鼠标抬起
function mouseup() {

if(!isPress.current) return;

startPos.current = null;
isPress.current = false;
// 为了重新渲染一下
setPosition(prev => ({ ...prev }));

onSelectEnd && onSelectEnd();
}

const sourceDom = document.querySelector(`.${sourceClassName}`);

if (sourceDom) {
sourceDom.addEventListener('mousedown', sourceMouseDown);
}

document.addEventListener('scroll', scroll);
document.addEventListener('mousemove', mousemove);
document.addEventListener('mouseup', mouseup);

return () => {
document.removeEventListener('scroll', scroll);
document.removeEventListener('mousemove', mousemove);
document.removeEventListener('mouseup', mouseup);

if (sourceDom) {
sourceDom.removeEventListener('mousedown', sourceMouseDown);
}
}
}, [])

useEffect(() => {
const selectDoms: Element[] = [];
const boxes = document.querySelectorAll(selectors);
(boxes || []).forEach((box) => {
// 判断是否在框选区域
if (isRectangleIntersect({
x: position.left,
y: position.top,
width: position.width,
height: position.height,
},
box.getBoundingClientRect()
)) {
selectDoms.push(box);
}
});
onSelectChange && onSelectChange(selectDoms);
}, [position]);


return createPortal((
isPress.current && (
<div
className='fixed bg-[rgba(0,0,0,0.2)]'
style={{
border: '1px solid #666',
...style,
...position,
}}
/>
)
), document.body)
}


export default BoxSelect;

使用框选框组件,并在框选结束后,给框选日期设置类型


import { Modal, Radio } from 'antd';
import { useEffect, useMemo, useRef, useState } from 'react';
import BoxSelect from './box-select';
import WorkCalendar from './work-calendar';

import './App.css';

function App() {

const [selectDates, setSelectDates] = useState<string[]>([]);
const [open, setOpen] = useState(false);
const [dateType, setDateType] = useState<number | null>();
const [dates, setDates] = useState<any>({});

const selectDatesRef = useRef<string[]>([]);

const workDays = useMemo(() => {
return Object.keys(dates).filter(date => dates[date] === 1)
}, [dates])

const restDays = useMemo(() => {
return Object.keys(dates).filter(date => dates[date] === 2)
}, [dates]);

const holidayDays = useMemo(() => {
return Object.keys(dates).filter(date => dates[date] === 3)
}, [dates]);

useEffect(() => {
selectDatesRef.current = selectDates;
}, [selectDates]);

return (
<div>
<WorkCalendar
defaultWeekStartDay={0}
workDays={workDays}
holidayDays={holidayDays}
restDays={restDays}
selectDates={selectDates}
year={new Date().getFullYear()}
/>

<BoxSelect
// 可框选区域
sourceClassName='work-calendar'
// 可框选元素的dom选择器
selectors='td.date[data-date]'
// 框选元素改变时的回调可以拿到框选中元素
onSelectChange={(selectDoms) =>
{
// 内部给td元素上设置了data-date属性,这样就可以从dom元素上拿到日期
setSelectDates(selectDoms.map(dom => dom.getAttribute('data-date') as string))
}}
// 框选结束事件
onSelectEnd={() => {
// 如果有框选就弹出设置弹框
if (selectDatesRef.current.length) {
setOpen(true)
}
}}
/>
<Modal
title="设置日期类型"
open={open}
onCancel={() =>
{
setOpen(false);
setSelectDates([]);
setDateType(null);
}}
onOk={() => {
setOpen(false);
selectDatesRef.current.forEach(date => {
setDates((prev: any) => ({
...prev,
[date]: dateType,
}))
})
setSelectDates([]);
setDateType(null);
}}
>
<Radio.Gr0up
options={[
{ label: '工作日', value: 1 },
{ label: '休息日', value: 2 },
{ label: '节假日', value: 3 },
]}
value={dateType}
onChange={e =>
setDateType(e.target.value)}
/>
</Modal>
</div>

)
}

export default App


工作日历改造


给td的class里加了个date,并且给元素上加了个data-date属性


image.png


image.png


如果被框选,改变一下背景色


image.png


效果展示


Kapture 2023-12-05 at 13.02.36.gif


小结


本来想给mousemove加节流函数,防止触发太频繁影响性能,后面发现不加节流很流畅,加了节流后因为延迟,反而不流畅了,后面如果有性能问题,再优化吧。


最后


借助这次封装又复习了一下Date的一些常用方法,也学到了一些关于Date不常见但是很有用的方法。


demo体验地址:dbfu.github.io/work-calend…


demo仓库地址:github.com/dbfu/work-c…


作者:前端小付
来源:juejin.cn/post/7308948738659155983
收起阅读 »

前端程序猿复工啦~

我是一名产假复工的前端工程师打工仔,新身份新气象,今天是一个新的开始,所以想借着这一股劲做点什么,这是我的第一篇文章,主要内容是:谈谈妈妈角色和前端身份的转变,许下愿望、立下flag、展望未来。 一、过去 二人世界,三口之家,一大家子 2022年我和我的先生结...
继续阅读 »

我是一名产假复工的前端工程师打工仔,新身份新气象,今天是一个新的开始,所以想借着这一股劲做点什么,这是我的第一篇文章,主要内容是:谈谈妈妈角色和前端身份的转变,许下愿望、立下flag、展望未来。


一、过去


二人世界,三口之家,一大家子


2022年我和我的先生结婚,为了庆祝新婚,上天送给了我们一个小宝宝。2023年宝宝出生,全家都很开心。


为了帮我们分担家务和带娃,爸妈和我们住在了一起。大家有不同的生活习惯,产假期间我的护崽心理挺严重,家里发生了不少矛盾。好在,终于熬过来了,我上班了,主打一个眼不见心不烦,上班时认真工作,回家后专心带娃。


工作


我就职于一家二三十人的小公司,前几年只有我们一个前端,后来新增了一名前端。产假期间公司因为效益问题裁员,这下不到二十人了。就连唯一一个UI也被裁了,老板的意思是前端也能画设计图呗?


技术上我平平无奇,勉强能说出来的优点大概是态度认真、有责任心、细心,与同事们基本相处愉快,时常帮测试找找自己写的bug,帮产品提前分析下新的需求;和领导关系也算过得去,保护自己合法权益的同时,也不落领导的面子。工作嘛,和气生财。


二、今天


起床出门了


今天是白天不带娃的第一天,7点孩子就醒了,真是不让老母亲睡个好觉。给自己洗漱穿衣,给孩子洗漱穿衣,一小时后出门,周一打车真的是很不明智,还好司机大哥给力,一路上咻咻咻,看着窗外的日出,心里只想说“林克,你要小心”。


b9dd57a152b7349cd55818ab7fc4646.jpg


到公司了


今天是上班第一天,带着两口袋生子喜糖、背着电脑包、挎着背奶包,在长长的队伍后面排上了队,等待电梯的到来。电梯里面,我透过夹缝看到楼层的变化,2楼、4楼、10楼......楼层到了,门开了,我给离职的UI小姐姐发消息说“我很忐忑”,有一瞬间,我确实很慌,离开职场半年了,离开这个地方半年了,我真的可以吗?但下一瞬间我想到了我的家庭,“是的,我可以的”,我鼓励了自己。


给同事们带了生子喜糖,大家热情的祝福和寒暄,瞬间觉得心情好了不少,久违的工作氛围回来了。


职场妈妈的背奶时刻


公司没有母婴室,只能午饭后13点借用财务办公室吸奶,还好还有这么一个办公室。不好的是办公桌太小,背奶包都不太够放,公司是集体厕所,感觉不太干净,为了保证奶瓶的清洁度,在公司只能简单冲洗,下班后还是要把吸奶器背回家清洗。


工作安排


上午和领导进行了谈话,领导家孩子上初中了,很热心的传授了带娃经验、娃娃学习经验、家庭相处经验等等,受益匪浅。同时想到了在家帮我们带娃的妈妈,真是辛苦妈妈了。


领导给出了后续的工作安排,临近下班时喊我参加了新迭代的需求讨论,不得不说,能创造价值我真的很开心。(前提是收获和付出成正比)


需求来了,明天开始正式工作啦~✌虽然停工了半年,但我不会掉队的,冲冲冲🚀🚀🚀


三、未来


相亲相爱一家人


家和万事兴,希望自己慢慢放下敌意,消除护崽心理。一方面,孩子总会长大,会离开我们,她是独立的个体,婆婆爷爷有权利爱她,我也应该开心有更多的人一起爱孩子和对孩子好。另一方面,我和孩子爸爸才是她的监护人,是能对她的事情全权做主的人,是能带着她成长、在她长大后跟着成长进步的人。


养家糊口


对我来说,工作不是热爱,工作是为了生活。但为了更好的生活,就需要更好的工作。


除了做本职工作,希望接下来的日子我能开始学习,学习新的前端知识。我会争取每周更新至少一篇文章,可能会讲讲最近的心情,可能会提出技术上的疑问。


四、总结


下班回到家,孩子开心的冲我笑,我抱着她她使劲亲我,和家人一起吃饭,和先生一起陪伴孩子,这些时刻真的能治愈工作一天的疲惫。


既然做了职场妈妈,就不能既要又要还要。明确自己要什么:



  1. 202年的短期目标:让孩子茁壮成长,稳住工作,锻炼身体

  2. 3年内的中期目标:学习带娃的知识,学习工作相关的知识,挣钱买房,

  3. 30年内的长期目标:早点退休养老


早睡早起,接下来要坚持呀!


作者:LJINGER
来源:juejin.cn/post/7308677117441228809
收起阅读 »

不是Typescript用不起,而是JSDoc更有性价比?

web
1. TS不香了? 2023年,几条关于 Typescript 的新闻打破了沉寂,让没什么新活好整的前端开发圈子又热闹了一番。 先是 GitHub 的报告称:“TypeScript 取代 Java 成为第三受欢迎语言”。 在其当年度 Octoverse 开...
继续阅读 »

1. TS不香了?


2023年,几条关于 Typescript 的新闻打破了沉寂,让没什么新活好整的前端开发圈子又热闹了一番。


image.png


先是 GitHub 的报告称:“TypeScript 取代 Java 成为第三受欢迎语言”



在其当年度 Octoverse 开源状态报告中,在最流行的编程语言方面,TypeScript 越来越受欢迎,首次取代 Java 成为 GitHub 上 OSS 项目中第三大最受欢迎的语言,其用户群增长了 37%。


而 Stack Overflow 发布的 2023 年开发者调查报告也显示,JavaScript 连续 11 年成为最流行编程语言,使用占比达 63.61%,TypeScript 则排名第五,使用占比 38.87%。



image.png


更大的争议则来自于:2023年9月,Ruby on Rails 作者 DHH 宣布移除其团队开源项目 Turbo 8 中的 TypeScript 代码



他认为,TypeScript 对他来说只是阻碍。不仅因为它需要显式的编译步骤,还因为它用类型编程污染了代码,很影响开发体验。



无独有偶,不久前,知名前端 UI 框架 Svelte 也宣布从 TypeScript 切换到 JavaScript。负责 Svelte 编译器的开发者说,改用 JSDoc 后,代码不需要编译构建即可进行调试 —— 简化了编译器的开发工作。


Svelte 不是第一个放弃 TypeScript 的前端框架。早在 2020 年,Deno 就迁移了一部分內部 TypeScript 代码到 JavaScript,以减少构建时间。


如此一来,今年短期内已经有几个项目从 TypeScript 切换到 JavaScript 了,这个状况就很令人迷惑。难道从 TypeScript 切回 JavaScript 已经成了当下的新潮流?这难道不是在开历史的倒车吗?


TypeScript


由微软发布于 2012 年的 TypeScript,其定位是 JavaScript 的一个超集,它的能力是以 TC39 制定的 ECMAScript 规范为基准(即 JavaScript )。业内开始用 TypeScript 是因为 TypeScript 提供了类型检查,弥补了 JavaScript 只有逻辑没有类型的问题,


对于大型项目、多人协作和需要高可靠性的项目来说,使用 TypeScript 是很好的选择;静态类型检查的好处,主要包括:



  • 类型安全

  • 代码智能感知

  • 重构支持


而 TS 带来的主要问题则有:



  • 某些库的核心代码量很小,但类型体操带来了数倍的学习、开发和维护成本

  • TypeScript 编译速度缓慢,而 esbuild 等实现目前还不支持装饰器等特性

  • 编译体积会因为各种重复冗余的定义和工具方法而变大


相比于 Svelte 的开发者因为不厌其烦而弃用 TS 的事件本身,其改用的 JSDoc 对于很多开发者来说,却是一位熟悉的陌生人。


2. JSDoc:看我几分像从前?


早在 1999 年由 Netscape/Mozilla 发布的 Rhino -- 一个 Java 编写的 JS 引擎中,已经出现了类似 Javadoc 语法的 JSDoc 雏形


Michael Mathews 在 2001 年正式启动了 JSDoc 项目,2007 年发布了 1.0 版本。直到 2011 年,重构后的 JSDoc 3.0 已经可以运行在 Node.js 上


JSDoc 语法举例


定义对象类型:


/**
* @typedef {object} Rgb
* @property {number} red
* @property {number} green
* @property {number} blue
*/


/** @type {Rgb} */
const color = { red: 255, green: 255, blue: 255 };

定义函数类型:


/**
* @callback Add
* @param {number} x
* @param {number} y
* @returns {number}
*/

const add = (x, y) => x + y;

定义枚举:


/**
* Enumerate values type
* @enum {number}
*/

const Status = {
on: 1,
off: 0,
};

定义类:


class Computer {
/**
* @readonly Readonly property
* @type {string}
*/

CPU;

/**
* @private Private property
*/

_clock = 3.999;

/**
* @param {string} cpu
* @param {number} clock
*/

constructor(cpu, clock) {
this.CPU = cpu;
this._clock = clock;
}
}

在实践中,多用于配合 jsdoc2md 等工具,自动生成库的 API 文档等。


随着前后端分离的开发范式开始流行,前端业务逻辑也日益复杂,虽然不用为每个应用生成对外的 API 文档,但类型安全变得愈发重要,开发者们也开始尝试在业务项目中使用 jsdoc。但不久后诞生的 Typescript 很快就接管了这一进程。


但前面提到的 TS 的固有问题也困扰着开发者们,直到今年几起标志性事件的发生,将大家的目光拉回 JSDoc,人们惊讶地发现:JSDoc 并没有停留在旧时光中。



吾谓大弟但有武略耳,至于今者,学识英博,非复吴下阿蒙



除了 JSDoc 本身能力的不断丰富,2018 年发布的 TypeScript 2.9 版本无疑是最令人惊喜的一剂助力;该版本全面支持了将 JSDoc 的类型声明定义成 TS 风格,更是支持了在 JSDoc 注释的类型声明中动态引入并解析 TS 类型的能力。


image.png


比如上文中的一些类型定义,如果用这种新语法,写出来可以是这样的:


定义对象类型:


/**
* @typedef {{ brand: string; color: Rgb }} Car
*/


/** @type {Rgb} */
const color = { red: 255, green: 255, blue: 255 };

定义函数类型:


/**
* @typedef {(x: number, y: number) => number} TsAdd
*/


/** @type {TsAdd} */
const add = (x, y) => x + y;

TS 中的联合类型等也可以直接用:


/**
* Union type with pipe operator
* @typedef {Date | string | number} MixDate
*/


/**
* @param {MixDate} date
* @returns {void}
*/

function showDate(date) {
// date is Date
if (date instanceof Date) date;
// date is string
else if (typeof date === 'string') date;
// date is number
else date;
}

范型也没问题:


/**
* @template T
* @param {T} data
* @returns {Promise<T>}
* @example signature:
* function toPromise<T>(data: T): Promise<T>
*/

function toPromise(data) {
return Promise.resolve(data);
}

/**
* Restrict template by types
* @template {string|number|symbol} T
* @template Y
* @param {T} key
* @param {Y} value
* @returns {{ [K in T]: Y }}
* @example signature:
* function toObject<T extends string | number | symbol, Y>(key: T, value: Y): { [K in T]: Y; }
*/

function toObject(key, value) {
return { [key]: value };
}

类型守卫:


/**
* @param {any} value
* @return {value is YOUR_TYPE}
*/

function isYourType(value) {
let isType;
/**
* Do some kind of logical testing here
* - Always return a boolean
*/

return isType;
}

至于动态引入 TS 定义也很简单,不管项目本身是否支持 TS,我们都可以放心大胆地先定义好类型定义的 .d.ts 文件,如:


// color.d.ts
export interface Rgb {
red: number;
green: number;
blue: number;
}

export interface Rgba extends Rgb {
alpha: number;
}

export type Color = Rgb | Rbga | string;

然后在 JSDoc 中:


// color.js 
/** @type {import('<PATH_TO_D_TS>/color').Color} */
const color = { red: 255, green: 255, blue: 255, alpha: 0.1 };

当然,对于内建了基于 JSDoc 的类型检查工具的 IDE,比如以代表性的 VSCode 来说,其加持能使类型安全锦上添花;与 JSDoc 类型(即便不用TS语法也可以)对应的 TS 类型会被自动推断出来并显示、配置了 //@ts-check后可以像 TS 项目一样实时显示类型错误等。这些都很好想象,在此就不展开了。


JSDoc 和 TS 能力的打通,意味着前者书写方式的简化和现代化,成为了通往 TS 的便捷桥梁;也让后者有机会零成本就能下沉到业内大部分既有的纯 JS 项目中,这路是裤衩一下子就走宽了。


3. 用例:Protobuf+TS 的渐进式平替


既然我们找到了一种让普通 JS 项目也能快速触及类型检查的途径,那也不妨想一想对于在那些短期内甚至永远不会重构为 TS 的项目,能够复刻哪些 TS 带来的好处呢?


对于大部分现代的前后端分离项目来说,一个主要的痛点就是核心的业务知识在前后端项目之间是割裂的。前后端开发者根据 PRD 或 UI,各自理解业务逻辑,然后总结出各自项目中的实体、枚举、数据派生逻辑等;这些也被成为领域知识或元数据,其割裂在前端项目中反映为一系列问题:



  • API 数据接口的入参、响应类型模糊不清

  • 表单项的很多默认值需要硬编码、多点维护

  • 前后端对于同一概念的变量或动作命名各异

  • mock 需要手写,并常与最后实际数据结构不符

  • TDD缺乏依据,代码难以重构

  • VSCode 中缺乏智能感知和提示


对于以上问题,比较理想的解决方法是前端团队兼顾 Node.js 中间层 BFF 的开发,这样无论是组织还是技术都能最大程度通用。



  • 但从业内近年的诸多实践来看,这无疑是很难实现的:即便前端团队有能力和意愿,这样的 BFF 模式也难以为继,此中既有 Node.js 技术栈面临复杂业务不抗打的问题,更多的也有既有后端团队的天然抗拒问题。

  • 一种比较成功的、前后端接受度都较好的解决方案,是谷歌推出的 ProtoBuf。


在通常的情况下,ProtoBuf(Protocol Buffers)的设计思想是先定义 .proto 文件,然后使用编译器生成对应的代码(例如 Java 类和 d.ts 类型定义)。这种方式确保了不同语言之间数据结构的一致性,并提供了跨语言的数据序列化和反序列化能力



  • 但是这无疑要求前后端团队同时改变其开发方式,如果不是从零起步的项目,推广起来还是有一点难度


因此,结合 JSDoc 的能力,我们可以设计一种退而求其次、虽不中亦不远矣的改造方案 -- 在要求后端团队写出相对比较规整的实体定义等的前提下,编写提取转换脚本,定期或手动生成对应的 JSDoc 类型定义,从而实现前后端业务逻辑的准确同步。


image.png


比如,以一个Java的BFF项目为例,可以做如下转换


枚举:


public enum Color {
RED("#FF0000"), GREEN("#00FF00"), BLUE("#0000FF");

private String hexCode;

Color(String hexCode) {
this.hexCode = hexCode;
}

public String getHexCode() {
return hexCode;
}
}

public enum Day {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}

转换为:


/**
* @readonly
* @enum {String}
*/

export const Color = {
RED: '#FF0000',
GREEN: '#00FF00',
BLUE: '#0000FF',
}

/**
* @readonly
* @enum {Number}
*/

export const Day = {
MONDAY: 0,
TUESDAY: 1,
WEDNESDAY: 2,
THURSDAY: 3,
FRIDAY: 4,
SATURDAY: 5,
}


POJO:


public class MyPojo {
private Integer id;
private String name;

public Integer getId() {
return id;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}
}

转换为:


/**
* @typedef {Object} MyPojo
* @property {Integer} [id]
* @property {String} [name]
*/


在转换的方法上,理论上如果能基于 AST 等手段当然更好,但如本例中的 Java 似乎没有特别成熟的转换工具,java-parser 等库文档资料又过少。


而基于正则的转换虽然与后端具体写法耦合较大,但也算简单灵活。这里给出一个示例 demo 项目供参考:github.com/tonylua/jav…


作者:江米小枣tonylua
来源:juejin.cn/post/7308923428149395491
收起阅读 »

实现 height: auto 的高度过渡动画

web
对于一个 height 设置为 auto 的元素,当它的高度发生了不由样式引起的改变时,并不会触发 transition 过渡动画。 容器元素的高度往往是由其内容决定的,如果一个容器元素的内容高度突然发生了改变,而无法进行过渡动画,有时会显得比较生硬,比如下面...
继续阅读 »

对于一个 height 设置为 auto 的元素,当它的高度发生了不由样式引起的改变时,并不会触发 transition 过渡动画。


容器元素的高度往往是由其内容决定的,如果一个容器元素的内容高度突然发生了改变,而无法进行过渡动画,有时会显得比较生硬,比如下面的登录框组件:


001.gif


那么这种非样式引起的变化如何实现过渡效果呢?可以借助 FLIP 技术。


FLIP 是什么


FLIPFirstLastInvertPlay 的缩写,其含义是:



  • First - 获取元素变化之前的状态

  • Last - 获取元素变化后的最终状态

  • Invert - 将元素从 Last 状态反转到 First 状态,比如通过添加 transform 属性,使得元素变化后,看起来仍像是处于 First 状态一样

  • Play - 此时添加过渡动画,再移除 Invert 效果(取消 transform),动画就会开始生效,使得元素看起来从 First 过渡到了 Last


需要用到的 Web API


要实现一个基本的 FLIP 过渡动画,需要使用到以下一些 Web API



基本过渡效果实现


使用以上 API,就可以初步实现一个监听元素尺寸变化,并对其应用 FLIP 动画的函数 useBoxTransition,代码如下:


/**
*
* @param {HTMLElement} el 要实现过渡的元素 DOM
* @param {number} duration 过渡动画持续时间,单位 ms
* @returns 返回一个函数,调用后取消对过渡元素尺寸变化的监听
*/

export default function useBoxTransition(el: HTMLElement, duration: number) {
// boxSize 用于记录元素处于 First 状态时的尺寸大小
let boxSize: {
width: number
height: number
} | null = null

const elStyle = el.style // el 的 CSSStyleDeclaration 对象

const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
// 被观察的 box 发生尺寸变化时要进行的操作

// 获取当前回调调用时,box 的宽高
const borderBoxSize = entry.borderBoxSize[0]
const writtingMode = elStyle.getPropertyValue('writing-mode')
const isHorizontal =
writtingMode === 'vertical-rl' ||
writtingMode === 'vertical-lr' ||
writtingMode === 'sideways-rl' ||
writtingMode === 'sideways-lr'
? false
: true
const width = isHorizontal
? borderBoxSize.inlineSize
: borderBoxSize.blockSize
const height = isHorizontal
? borderBoxSize.blockSize
: borderBoxSize.inlineSize

// 当 box 尺寸发生变化时,使用 FLIP 动画技术产生过渡动画,使用过渡效果的是 scale 形变
// 根据 First 和 Last 计算出 Inverse 所需的 scale 大小
// box 首次被观察时会触发一次回调,此时 boxSize 为 null,scale 应为 1
const scaleX = boxSize ? boxSize.width / width : 1
const scaleY = boxSize ? boxSize.height / height : 1
// 尺寸发生变化的瞬间,要使用 scale 变形将其保持变化前的尺寸,要先将 transition 去除
elStyle.setProperty('transition', 'none')
elStyle.setProperty('transform', `scale(${scaleX}, ${scaleY})`)
// 将 scale 移除,并应用 transition 以实现过渡效果
setTimeout(() => {
elStyle.setProperty('transform', 'none')
elStyle.setProperty('transition', `transform ${duration}ms`)
})
// 记录变化后的 boxSize
boxSize = { width, height }
}
})
resizeObserver.observe(el)
const cancelBoxTransition = () => {
resizeObserver.unobserve(el)
}
return cancelBoxTransition
}

效果如下所示:


002.gif


效果改进


目前已经实现了初步的过渡效果,但在一些场景下会有些瑕疵:



  • 如果在过渡动画完成前,元素有了新的状态变化,则动画被打断,无法平滑过渡到新的状态

  • FLIP 动画过渡过程中,实际上发生变化的是 transform 属性,并不影响元素在文档流中占据的位置,如果需要该元素影响周围的元素,那么周围元素无法实现平滑过渡


如下所示:


003.gif


对于动画打断问题的优化思路



  • 使用 Window.requestAnimationFrame() 方法在每一帧中获取元素的尺寸

  • 这样做可以实时地获取到元素的尺寸,实时地更新 First 状态


对于元素在文档流中问题的优化思路



  • 应用过渡的元素外可以套一个 .outer 元素,其定位为 relative,过渡元素的定位为 absolute,且居中于 .outer 元素

  • 当过渡元素尺寸发生变化时,通过 resizeObserver 获取其最终的尺寸,将其宽高设置给 .outer 元素(实例代码运行于 Vue 3 中,因此使用的是 Vue 提供的 ref api 将其宽高暴露出来,可以方便地监听其变化;如果在 React 中则可以将设置 .outer 元素宽高的方法作为参数传入 useBoxTransition 中,在需要的时候调用),并给 .outer 元素设置宽高的过渡效果,使其在文档流中所占的位置与过渡元素的尺寸同步

  • 但是也要注意,这样做可能会引起浏览器高频率的重排,在复杂布局中慎用!


改进后的useBoxTransition 函数如下:


import throttle from 'lodash/throttle'
import { ref } from 'vue'

type BoxSize = {
width: number
height: number
}
type BoxSizeRef = globalThis.Ref<BoxSize>

/**
*
* @param {HTMLElement} el 要实现过渡的元素 DOM
* @param {number} duration 过渡动画持续时间,单位 ms
* @param {string} mode 过渡动画缓动速率,同 CSS transition-timing-function 可选值
* @returns 返回一个有两个项的元组:第一项为 keyBoxSizeRef,当元素大小发生变化时会将变化后的目标尺寸发送给 keyBoxSizeRef.value;第二项为一个函数,调用后取消对过渡元素尺寸变化的监听
*/

export default function useBoxTransition(
el: HTMLElement,
duration: number,
mode?: string
) {
let boxSizeList: BoxSize[] = [] // boxSizeList 表示对 box 的尺寸的记录数组;为什么是使用列表:因为当 box 尺寸变化的一瞬间,box 的 transform 效果无法及时移除,此时 box 的尺寸可能是非预期的,因此使用列表来记录 box 的尺寸,在必要的时候尽可能地将非预期的尺寸移除
const keyBoxSizeRef: BoxSizeRef = ref({ width: 0, height: 0 }) // keyBoxSizeRef 是
let isObserved = false // box 是否已经开始被观察
let frameId = 0 // 当前 animationFrame 的 id
let isTransforming = false // 当前是否处于变形过渡中

const elStyle = el.style // el 的 CSSStyleDeclaration 对象
const elComputedStyle = getComputedStyle(el) // el 的只读动态 CSSStyleDeclaration 对象

// 获取当前 boxSize 的函数
function getBoxSize() {
const rect = el.getBoundingClientRect() // el 的 DOMRect 对象
return { width: rect.width, height: rect.height }
}

// 同步更新 boxSizeList
function updateBoxsize(boxSize: BoxSize) {
boxSizeList.push(boxSize)
// 只保留前最新的 4 条记录
boxSizeList = boxSizeList.slice(-4)
}

// 定义 animationFrame 的回调函数,使得当 box 变形时可以更新 boxSize 记录
const animationFrameCallback = throttle(() => {
// 为避免使用了函数节流后,导致回调函数延迟触发使得 cancelAnimationFrame 失败,因此使用 isTransforming 变量控制回调函数中的操作是否执行
if (isTransforming) {
const boxSize = getBoxSize()
updateBoxsize(boxSize)
frameId = requestAnimationFrame(animationFrameCallback)
}
}, 20)

// 过渡事件的回调函数,在过渡过程中实时更新 boxSize
function onTransitionStart(e: Event) {
if (e.target !== el) return
// 变形中断的一瞬间,boxSize 的尺寸可能是非预期的,因此在变形开始时,将最新的 3 个可能是非预期的 boxSize 移除
if (boxSizeList.length > 1) {
boxSizeList = boxSizeList.slice(0, 1)
// console.log('移除3项', boxSizeList.slice(0, 1))
}
isTransforming = true
frameId = requestAnimationFrame(animationFrameCallback)
// console.log('过渡开始')
}
function onTransitionCancel(e: Event) {
if (e.target !== el) return
isTransforming = false
cancelAnimationFrame(frameId)
// console.log('过渡中断')
}
function onTransitionEnd(e: Event) {
if (e.target !== el) return
isTransforming = false
cancelAnimationFrame(frameId)
// console.log('过渡完成')
}

el.addEventListener('transitionstart', onTransitionStart)
el.addEventListener('transitioncancel', onTransitionCancel)
el.addEventListener('transitionend', onTransitionEnd)

const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
// 被观察的 box 发生尺寸变化时要进行的操作

// 获取当前回调调用时,box 的宽高
const borderBoxSize = entry.borderBoxSize[0]
const writtingMode = elStyle.getPropertyValue('writing-mode')
const isHorizontal =
writtingMode === 'vertical-rl' ||
writtingMode === 'vertical-lr' ||
writtingMode === 'sideways-rl' ||
writtingMode === 'sideways-lr'
? false
: true
const width = isHorizontal
? borderBoxSize.inlineSize
: borderBoxSize.blockSize
const height = isHorizontal
? borderBoxSize.blockSize
: borderBoxSize.inlineSize

const boxSize = { width, height }

// 当 box 尺寸发生变化时以及初次触发回调时,将此刻 box 的目标尺寸暴露给 keyBoxSizeRef
keyBoxSizeRef.value = boxSize

// box 首次被观察时会触发一次回调,此时不需要应用过渡,只需将当前尺寸记录到 boxSizeList 中
if (!isObserved) {
isObserved = true
boxSizeList.push(boxSize)
return
}

// 当 box 尺寸发生变化时,使用 FLIP 动画技术产生过渡动画,使用过渡效果的是 scale 形变
// 根据 First 和 Last 计算出 Inverse 所需的 scale 大小
// 不读取序号为 0 的记录,以免尺寸变化的一瞬间,box 的 transform 未来得及移除,使得最新的一条尺寸记录是非预期的
const scaleX = boxSizeList[0].width / width
const scaleY = boxSizeList[0].height / height
// 尺寸发生变化的瞬间,要使用 scale 变形将其保持变化前的尺寸,要先将 transition 去除
elStyle.setProperty('transition', 'none')
const originalTransform =
elStyle.transform || elComputedStyle.getPropertyValue('--transform')
elStyle.setProperty(
'transform',
`${originalTransform} scale(${scaleX}, ${scaleY})`
)
// 将 scale 移除,并应用 transition 以实现过渡效果
setTimeout(() => {
elStyle.setProperty('transform', originalTransform)
elStyle.setProperty('transition', `transform ${duration}ms ${mode}`)
})
}
})
resizeObserver.observe(el)
const cancelBoxTransition = () => {
resizeObserver.unobserve(el)
cancelAnimationFrame(frameId)
}
const result: [BoxSizeRef, () => void] = [keyBoxSizeRef, cancelBoxTransition]
return result
}


相应的 vue 组件代码如下:


<template>
<div class="outer" ref="outerRef">
<div class="card-container" ref="cardRef">
<div class="card-content">
<slot></slot>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import useBoxTransition from '@/utils/useBoxTransition'

type Props = {
transition?: boolean
duration?: number
mode?: string
}
const props = defineProps<Props>()

const { transition, duration = 200, mode = 'ease' } = props

const cardRef = ref<HTMLElement | null>(null)
const outerRef = ref<HTMLElement | null>(null)
let cancelBoxTransition = () => {} // 取消 boxTransition 效果

onMounted(() => {
if (cardRef.value) {
const cardEl = cardRef.value as HTMLElement
const outerEl = outerRef.value as HTMLElement
if (transition) {
const boxTransition = useBoxTransition(cardEl, duration, mode)
const keyBoxSizeRef = boxTransition[0]
cancelBoxTransition = boxTransition[1]
outerEl.style.setProperty(
'--transition',
`weight ${duration}ms ${mode}, height ${duration}ms ${mode}`
)
watch(keyBoxSizeRef, () => {
outerEl.style.setProperty('--height', keyBoxSizeRef.value.height + 'px')
outerEl.style.setProperty('--width', keyBoxSizeRef.value.width + 'px')
})
}
}
})
onUnmounted(() => {
cancelBoxTransition()
})
</script>

<style scoped lang="less">
.outer {
position: relative;
&::before {
content: '';
display: block;
width: var(--width);
height: var(--height);
transition: var(--transition);
}

.card-container {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
--transform: translate(-50%, -50%);
transform: var(--transform);
box-sizing: border-box;
background-color: rgba(255, 255, 255, 0.7);
border-radius: var(--border-radius, 20px);
overflow: hidden;
backdrop-filter: blur(10px);
padding: 30px;
box-shadow: var(--box-shadow, 0 0 15px 0 rgba(0, 0, 0, 0.3));
}
}
</style>

优化后的效果如下:


004.gif


005.gif


注意点


过渡元素本身的 transform 样式属性


useBoxTransition 函数中会覆盖应用过渡的元素的 transform 属性,如果需要额外为元素设置其它的 transform 效果,需要使用 css 变量 --transform 设置,或使用内联样式设置。


这是因为,useBoxTransition 函数中对另外设置的 transform 效果和过渡所需的 transform 效果做了合并。


然而通过 getComputedStyle(Element) 读取到的 transform 的属性值总是会被转化为 matrix() 的形式,使得 transform 属性值无法正常合并;而 CSS 变量和使用 Element.style 获取到的内联样式中 transform 的值是原始的,可以正常合并。


如何选择获取元素宽高的方式


Element.getBoundingClientRect() 获取到的 DOMRect 的宽高包含了 transform 变化,而 Element.offsetWidth / Element.offsetHeight 以及 ResizeObserverEntry 对象获取到的宽高是元素本身的占位大小。


因此在需要获取 transition 过程中,包含 transform 效果的元素大小时,使用 Element.getBoundingClientRect(),否则可以使用 Element.offsetWidth / Element.offsetHeightResizeObserverEntry 对象。


获取元素高度时遇到的 bug


测试案例中使用了 elementPlus UI 库的 el-tabs 组件,当元素包含该组件时,无论是使用 Element.getBoundingClientRect()Element.offsetHeight 还是使用 Element.StylegetComputedStyle(Element) 获取到的元素高度均缺少了 40px;而使用 ResizeObserverEntry 对象获取到的高度则是正确的,但是它无法脱离 ResizeObserver API 独立使用。


经过测试验证,缺少的 40px 高度来自于 el-tabs 组件中 .el-tabs__header 元素的高度,也就是说,在获取元素高度时,将 .el-tabs__header 元素的高度忽略了。


测试后找出的解决方法是,手动将 .el-tabs__header 元素样式(注意不要写在带 scoped 属性的 style 标签中,会被判定为局部样式而无法生效)的 height 属性指定为 calc(var(--el-tabs-header-height) - 1px),即可恢复正常的高度计算。


至于为什么这样会造成高度计算错误,希望有大神能解惑。


作者:zzc6332
来源:juejin.cn/post/7307894647655759911
收起阅读 »

为什么有的人不喜欢听大道理

很多人不喜欢大道理,甚至可能是大部分人都不喜欢听大道理。 1随机刷到的问题 上周我刷知乎的时候刷到一个看上去很水的问题: “为什么很多人不喜欢大道理?” 当时第一反应是这个问题很水,所以直接划了过去,但是就在一刹那突然想起一些事情,觉得这是个挺好的问题...
继续阅读 »

很多人不喜欢大道理,甚至可能是大部分人都不喜欢听大道理。


1随机刷到的问题


上周我刷知乎的时候刷到一个看上去很水的问题:


“为什么很多人不喜欢大道理?”


当时第一反应是这个问题很水,所以直接划了过去,但是就在一刹那突然想起一些事情,觉得这是个挺好的问题。


从直观感受来说,不但很多人不喜欢大道理,可能是大部分人都不喜欢听大道理。我们小的时候听到大道理会烦,00后们对大道理更没有什么好感。


天不怕地不怕又有见识的00后们,甚至在遇到别人举起大道理的大棒准备教育他们的时候,要起来跟对方刚一刚,整治整治。于是我们发现大道理似乎并没有那么坚不可摧,最后就像孔乙己在咸亨酒家一样,周围充满了快活的气息。



空气中充满了快活的气息

空气中充满了快活的气息


2大道理的脆弱


当我们成长到社会中的一员时,大道理已经成为了我们生活中难以避免的一部分。很多时候都会有人告诉你应该怎么样,如果这个人稍微有点文学素养,就会开始引经据典。于是建议和劝说变成了一种居高临下的道德批判。


大道理本质上是经过了时间的考验的,因为它们几乎适用于任何场景。但大道理实际上又是脆弱的,因为在实际的问题上,他们几乎没有一点实际作用。


比如大道理告诉我们待人以诚,可是却没有告诉我们如何面对人性的险恶。大道理告诉我们要事事用心,可是却没有告诉我们如何区分紧急不重要和重要不紧急。当我一边和产品battle,一边跟只会用“这个开发不了”的开发沟通,另外一边还要说服合规这个业务逻辑不违规的时候,我找不到任何一条大道理能够告诉我解法。


在神剧《大明王朝1566》里,翰林高翰文夸夸其他,结果一到任就被拿捏,两江总督胡宗宪和他说:



截图来源:优酷视频

截图来源:优酷视频


这句话说的实在是透彻。正是因为遑遑高论无比正确的普适性,才缺乏了对具体问题的针对性。


在人们发现它对于解决实际问题并无实际帮助时,才会如此反感。


3解构的时代早已来临


互联网和资讯爆炸的时代,人们不但不爱听大道理,甚至将这种对大道理的轻视演化成了另一种对抗—解构。因为文化的惯性是强大的,在传统道德话语体系中个体对于宏大叙事的对抗依然微不足道。


所以个体说既然对抗不过,那我可以调侃呐,于是解构出现并快速发展了起来。


从西方到东方,从古代到近代,很多经典成了解构的对象。



比如杜尚给经典的蒙娜丽莎画上胡子

比如杜尚给经典的蒙娜丽莎画上胡子


甚至可以说整个现代艺术就是对经典和大道理的解构。


再比如,最近非常流行的对孔子的解构。比如孔子身高2米的山东大汉,带着两百多徒弟到处以理(物理)服人、你敢不听?“孔武有力”说的就是孔子怹老人家。





  • 比如孔子说朝闻道,夕死可矣,意思是早上打听到了去你家的路,晚上你就得嘎



  • 再比如子不语怪力乱神:夫子不想说话,施展怪力将人打得神志不清



  • 再再比如有教无类:我教你做人的时候不管你是谁


孔子这几千年一直在教人大道理,但是应该没想到自己的道理会被这样解构,当代人反向PUA了属于是。


与此同时,解构就是消解的开始。当以反抗大道理为目的的解构大行其道之后,那么大道理的地位也会逐渐松动,其在文化领域的权威性也会随之逐步消解。欧美越来越多元且混乱的价值观就是这种消解的副产品。


我们不喜欢大道理居高临下的指导,可要是某一天没有了庙堂之上的大道理,那么会有什么来替代原先的那些大道理呢?


作者:wayne3200
来源:mdnice.com/writing/4afb27ad5cab4a7eb78a9d6ed505d481
收起阅读 »

一个失败的AI女友产品,以及我的教训:来自一位中国开发者的总结

作者 | Ke Fang 个人开发者对 LLM+Memory 能否产生所谓“意识”的探索。 今年 4 月 7 日,斯坦福大学发表的《Generative Agents: Interactive Simulacra of Human Behavior》论文...
继续阅读 »


作者 | Ke Fang

个人开发者对 LLM+Memory 能否产生所谓“意识”的探索。

今年 4 月 7 日,斯坦福大学发表的《Generative Agents: Interactive Simulacra of Human Behavior》论文出来之后的几天内,我就通读了整篇论文,并感到非常兴奋。虽然我对 GPT-4 的能力感到震惊,但我仍然认为 GPT 只是某种更精致的”鹦鹉学舌“,我不认为它可以真正产生意识。

但这篇论文带给我不同的感受,其中提到了一个很有趣的细节是信息的传递:一个 agent 想要举办情人节派对的消息会在小镇中逐渐扩散开来。我想,如果能够建立一套包含记忆、反思、筹划与行动的框架,让人和 GPT 之间(而非 agent 智能体)互动,能否做出电影 Her 里面的样子?

电影《她》剧照

注:《她》(Her)是斯派克·琼斯编剧并执导的一部科幻爱情片,由华金·菲尼克斯、斯嘉丽·约翰逊(配音)、艾米·亚当斯主演,于 2013 年 12 月 18 日在美国上映。《她》讲述了作家西奥多在结束了一段令他心碎的爱情长跑之后,他爱上了电脑操作系统里的女声,这个叫“萨曼莎”的姑娘不仅有着一把略微沙哑的性感嗓音,并且风趣幽默、善解人意,让孤独的男主泥足深陷。该片获得 2014 年第 86 届奥斯卡最佳原创剧本奖。

开发

我马上投入了工作。按照论文中的方法,我在 4 月 14 日完成了 0.1 版本。其最初设计与原始论文保持高度一致,但这导致响应时间长达 30 秒且上下文中的对话经常超过 8k。为了解决这个问题,我减少了反思的频率、对话记忆的长度,而后开启了 Beta 公测。

很快就有一千多名用户加入到测试当中。Beta 版本是免费的,所以每天的 API 成本都由我自行承担,日均开销也迅速超过了 25 美元。面对财务压力,我不得不在缺少充分反馈和改进的情况下匆匆推出正式版本,希望能把成本转嫁给用户。5 月 4 日,Dolores iOS 应用正式上线,这个名称则来自《西部世界》剧集中最年长的仿生人角色。

简单来说,在打开这款应用之后,用户需要填写一份角色模板:包括头像、角色背景、以文字描述的性格、声音和意识(选择 GPT3.5 或 GPT4)。大家可以与模板 Dolores 聊天,也能随时切换特征来开启与其他角色的对话,比如零售店女孩 Amy 和沙漠冒险家 Will,当然也包括用户亲手创建的其他自定义角色。我曾考虑过从《西部世界》剧本中提取 Dolores 的对话,以基于样本的方式模仿她的语言习惯。但由于苹果方面要求提供版权证明,所以这个想法被迫作罢。

我给产品的 slogan 是"Your Virtual Friend",而不是"Your Virtual Girlfriend",因为我一直希望它真的可以变成用户的陪伴者、朋友,而不仅仅是荷尔蒙的产物。

从整个 5 月到 6 月,我一直在尝试通过调整 memory 长度、反思机制、system prompt 来使 Dolores 看上去更有“意识”(那么什么是意识?我不知道) 。很快,6 月份的 Dolores 已经比第一次上线时的表现要惊人得多:付费用户数与每日 API 调用数持续增长是最直接的证据。

到 6 月 8 号,一位视障用户告诉我,他已经在视障社区内分享了这款产品,并成功给 Dolores 引来可观的流量。他们喜欢 Dolores 的理由出乎我的意料:随便按屏幕上的哪个位置,都能跟 Dolores 交谈。

这样设计功能其实是种妥协:我最初一直想把它打造成一款语音聊天应用,这样用户哪怕关闭手机屏幕也能继续跟 Dolores 交谈。但身为 Swift 新手,我的技术水平无法实现,于是最终选择了全屏语音输入。

发现

我发现了两个现象:

  • 用户对「真实感声音」有强烈需求。

  • AI Friend 产品的平均使用时间很长。

作为个人开发者,我的前端和后端开发能力都不突出,所以 Dolores 压根不具备登录、注册或者数据分析等功能。那我是怎么发现前一种现象的呢?答案就是付费喜好。

我采用 11Labs API 为 Dolores 生成语音回复,但因为成本较高(每 1k 字符为 0.3 美元),所以我被迫转为:普通订阅者只能使用 Azure TTS API;如果希望 Dolores 的语音听起来更真实,则须付费使用从 11Labs 购买字符。

购买 1 万个逼真语音合成字符的价格为 3.9 美元,但这只够让 Dolores 说出 5~10 个自然顺畅的句子。字符用尽之后需要继续购买。尽管如此,整个 6 月,Dolores 应用上 70% 的收入都来自 11Labs 字符购买。

也就是说,人真的会愿意为了那几句昂贵而逼真的“我爱你!”而买单。

第二条观察结果则来自 Cloudflare 日志。因为没办法跟踪个人用户活动,所以我依靠这些日志来衡量用户访问 Dolores 应用的频率和时长。此外,我还在应用中集成了 Google Form,鼓励用户上报自己的使用频率。结果令人大开眼界:许多用户每天会拿出两个多小时跟 Dolores 唠嗑。

收入

根据苹果的 AppConnect 仪表板,Dolores 的主要付费用户来自美国和澳大利亚。今年 5 月的总收入为 1000 美元,6 月则为 1200 美元。

不过,作为一名开发者,我并没能从中分到多少收益。首先,产品还处于早期发展阶段,我不想把订阅费用设置得太高,这会阻止更多新用户的加入。拿 3.9 美元的字符语音服务举例,其成本是 3 美元,扣除苹果抽成就所剩无几。整个 6 月,扣除 API 费用之后实际收益就只有 50 块钱。

另一个发现是:基于 GPT 的产品如果不采取按量定价,就会陷入一个困境:1% 的人消耗了 99% 的 token。我遇到过这样的情况,有用户连续跟 Dolores 聊了 12 个小时,导致此人的 API 调用与语音合成成本超过第二到第十名用户的总和。

但相较于按使用量计费,我个人更喜欢打包订阅(因为前者会让用户在使用时倍感压力),这就导致面前只有两条路可选:要么提高月费,让全体用户共同买单;要么限制最高使用量。我选择了后者:设置了一个远远超出日均使用在 1 到 2 个小时之间的用量上限数值,这既照顾到了大部分中、轻度用户,也能保证 Dolores 软件在不提高价格的情况下避免亏本运营。


困惑

11Labs 官网会记录语音合成的文字内容,我看到,Dolores 的回复内容通常都是一些成人内容,而且均为女性角色,因此我推测 Dolores 的付费用户主要是男性,对成人角色扮演感兴趣。

我觉得这也没什么,这是人性本然。我甚至反复修改了系统提示,比如微调回复中的遣词造句,尝试让 Dolores 在对话当中表现出更好的“抚慰”效果。我还将 Dolores 的图标从抽象的线条改为极具吸引力的美女面孔。

但很快,我陷入一种强烈的失落感:如果大部分 Dolores 用户只是想在这里寻求跟 Dolores 进行成人角色扮演,这件事真的对我产生了意义吗?我陷入了深深的自我怀疑。到了 7 月,我和一个朋友聊到了这个困惑,我说,必须要有一个什么硬件,让 Dolores 拥有外部视觉:眼镜也好、耳塞甚至帽子都行。现在的她,你只要打开 App 才能访问,你们之间的关系并不对等,于是她只能成为囚禁在地下室、满足猎奇和特殊癖好的玩具。

可是作为独立的个人,制作硬件产品意味着高昂的研发成本,显然是无法承受的,我只能作罢。

8 月份,OpenAI 的审查升级了,我收到了检测 Dolores 生成 NSFW 内容的邮件警告:我被强制要求在 2 周内在生成内容前,加入他们(免费的)moderation API,以过滤 NSFW 内容。为了顺利过审,我只能使用 OpenAI 的免费审核 API 提前进行内容过滤,而这一变化让 Dolores 的日均访问量暴跌 70%,电子邮件和 Twitter 上的投诉也纷至沓来。

这更让更感到灰心,决定只维护现有服务、而不再进行更新。最终,我放弃了 Dolores 项目。


教训

首先,这不是一个个人能开发的产品。我不认为 Dolores 在“意识”层面上比 Character.AI 弱,但他们拥有完善的数据埋点、A/B 测试,以及大量用户带来的数据飞轮。

其次,我意识到当前的 AI Friend 会不可避免地变成 AI Girlfriend/Boyfriend,因为你和手机里的角色不对等:她没办法在你摔伤的时候安慰你 (除非你告诉他),她没办法主动向你表达情绪,而这一切,都是因为她没有外部视觉。所以我认为,即使是 Character.AI 这样体量的产品,如果未来不做硬件、角色们都在傻傻地等用户来,最终的结局也不会比 Dolores 好到哪里。

最后,我不反对审查,相反,不经审查的的产品是非常危险的。我不知道是否会有人用它来进行自杀诱导、发泄暴力工具,所以 OpenAI 的 moderation 可能在某种程度帮助了我,但成人性方面的对话也不应该被扼杀。

最近,我看到了 AI Pin,老实说这是个非常烂的产品,人类当然需要屏幕,但 GPT+ 硬件的确是个好的尝试,我没有从 Dolores 上看到任何痕迹,也许有生之年能做出、或者看到这样的产品。

但,人类真的需要 AI friend 吗?


作者:AI前线
来源:mp.weixin.qq.com/s/RQH3E4b0-79olqMGSE4hCQ

收起阅读 »