注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

【附源码】推荐8个「IM+AI」场景的开源项目,建议收藏

1、社交泛娱乐——【找搭子】饭搭子、旅游搭子、遛狗搭子…这种新型的“搭子社交”在年轻群体中逐渐流行起来。区别于传统社交,搭子关系,陌生以上,友人未满,这种轻松的浅社交既能获得志趣相投的陪伴,又不用苦心经营彼此的关系。实在是好搭子不问出处,只要有能结伴做的事,就...
继续阅读 »

1、社交泛娱乐——【找搭子】

饭搭子、旅游搭子、遛狗搭子…这种新型的“搭子社交”在年轻群体中逐渐流行起来。区别于传统社交,搭子关系,陌生以上,友人未满,这种轻松的浅社交既能获得志趣相投的陪伴,又不用苦心经营彼此的关系。实在是

好搭子不问出处,只要有能结伴做的事,就可以有那么一“搭”。那么问题来了,如何精准地匹配到自己喜爱的活动以及志趣相投的“搭子”,便成了求搭者们的刚需。

项目介绍

“找搭子”——基于AI Agent解决找搭子最后一公里。它借助环信IM能力,使用对话Agent 充分挖掘用户需求,整合用户信息形成实时活动表单,精准推荐,打破固化标签,增加可信度。为活动发起者、参与者提供了一个双向智能、高效沟通、精准匹配的平台。


(找搭子产品架构)

(找搭子Demo演示)

项目点评:

“找搭子”以其独特的创新视角,展现了信息分发和成员组织的全新可能性。通过利用大模型进行精准匹配,该项目能够根据用户的兴趣爱好,智能地形成特定的用户群体。这种创新的方式不仅极大地提高了用户的参与度和满意度,同时也预示着巨大的商业潜力。在营销、社交和社区建设等领域,其影响力不可忽视。"找搭子"的实现,无疑是对传统模式的一次颠覆性的创新,它为我们打开了一个全新的视角,重新诠释了我们对于用户互动和社区建设的理解。

模型:环信IM+文心一言

项目源码:(Flutter)

2、教育培训——KidChat

项目介绍

「KidChat」是一个儿童故事创作分享社区。通过语音输入故事梗概,由 LLM 生成儿童故事,结合环信IM能力,自动将绘本故事分享到故事广场,让小朋友们一起欣赏。通过 Reaction 互动,并自动为每一个故事生成子区,可在子区内对具体故事进一步讨论,进而形成一个有趣而充满创造力的故事分享社区。

kidchat Demo演示

项目点评

「KidChat」以其卓越的内容生成和讲述能力,成功地满足了面向低龄孩子的“讲故事”场景的需求。该项目利用大模型的强大功能,能够快速生成丰富多样、充满无限可能的故事,并通过语音进行生动有趣的讲解。这无疑满足了低龄儿童家长的刚需,为他们提供了一种新的、高效的儿童教育方式。在实现过程中,KidChat非常富有创意地将环信的Thread和Reaction功能应用到了自己的业务功能实现中,提供了非常好的使用体验。更值得一提的是,该项目的完成度高,用户体验佳,无论是在故事内容的创新性,还是在语音讲解的生动性上,都表现出了极高的专业水准和创新思维。

模型:环信IM+讯飞星火

项目源码:(Flutter)

3、聊天机器人——ChattyAI

项目介绍

「ChattyAI」是一款基于人工智能技术的智能陪聊机器人,旨在提供用户个性化、有趣、愉快的对话体验。通过先进的自然语言处理和机器学习算法,ChattyAI能够理解用户的意图和情感,并以富有情感的方式回应,为用户提供真实感和沟通舒适度。

为增加智能体与用户的互动体验与亲密感,借助环信IM通讯能力,通过智能体向用户定时发送早安、午安、晚安。除此外还可以扩展更丰富多样的主动唤起话题的任务。



项目点评

「ChattyAI」在社交泛娱乐领域实现了人与智能体聊天的全新场景。该项目充分利用大模型在闲聊领域的优势,为用户提供了一种全新的、高质量的交流体验。"ChattyAI"不仅提供了丰富人设的虚拟角色,更在UI设计和交互体验上展现出了卓越的水准。其UI设计优雅且直观,交互体验流畅且自然,使得用户可以轻松地与虚拟角色进行交流。项目的完成度非常高,无论是在技术实现,还是在用户体验上,都展现出了项目团队的专业能力和创新思维。"ChattyAI"的成功实现,为我们在社交泛娱乐领域的探索提供了有力的启示。

模型:环信IM+MINIMAX

项目源码:(Android)

4、AI专家助手

现存市面上大部分的AI主要是面向个人的,而当前的AI助手并不能准确无误的解决大部分人的问题,尤其在一些特定的领域,比如编程,医疗等专业技能要求较高的方向,虽然AI能够给出相应的回复,但是这些回复对于普通人来说,甄别其中的准确性依然存在一定问题。该项目通过一般咨询者的信息,收集不同AI厂商的建议或者帮助信息,能够大大提升相应的工作效率。进而实现专家的效率,而当前社会专家的稀缺才是更大的瓶颈。

模型:环信IM+百川智能+MINIMAX+文心一言

项目源码:

5、ai群管家

此项目提供了群聊、单聊机器人对话服务。通过命令方式激活机器人,单聊bot可实现与多个角色如AI医生、知乎文案生成、AI家教、AI律师、地方美食推荐(自由切换)生成对话,群聊借助AI能力提供了群历史消息总结功能。是超级社群中典型的应用场景。

模型:环信IM+智谱AI

项目源码:

6、PictoChat (AI智绘)

AI智绘是一款创新的移动应用程序(iOS),整合了环信IM(即时通讯)平台和OpenAI的GPT-4.0绘图能力。这款应用专为提升在线交流体验而设计,能够在实时对话中生成和发送图片,极大地增强了交流的趣味性和互动性。

模型:环信IM+ChatGPT

项目源码:

7、百答

百答,一个All in One全能助手,你的AI智囊团。基于环信IM即时通讯解决方案,结合各家大模型能力开发的全能AI助手。互联网+环信IM+大模型,强强联合,多重BUFF叠加,让普通人也拥有撬动地球的力量!恋爱大师,编程助手,周报秘书,抬杠大师,彩虹屁专家,数字女友,礼物攻略等等应有尽有,全能智能 有问必答,堪称bot细分领域第一代卷王。

模型:环信IM+通义千问

项目源码:

8、智慧医疗

此项目是一款智慧医疗应用,患者与AI医生实时互动获得实时的医疗咨询,会话结束时为患者生成咨询档案,同时智能推荐相应的科室。当患者问诊相应科室医生时,医生可调取患者基本信息和与AI医生的历史咨询档案。

智慧医疗的蓬勃发展有望提升整个医疗体系的质量和效率,改善医疗体验,同时为患者提供更为个性化和便捷的医疗服务。我们期待在医疗领域,IM与AI的紧密融合发挥巨大的作用。

模型:环信IM+通义千问

项目源码:

参考资料:

环信集成相关

收起阅读 »

JDBC快速入门:从环境搭建到代码编写,轻松实现数据库增删改查操作!

通过上篇文章我们已经对JDBC的基本概念和工作原理都有了一定的了解,本篇文章我们继续来探索如何从零开始,一步步搭建开发环境,编写代码,最后实现数据库的增删改查操作。一、开发环境搭建首先,我们需要准备的开发环境有:Java开发工具包(JDK)、数据库(如MySQ...
继续阅读 »

通过上篇文章我们已经对JDBC的基本概念和工作原理都有了一定的了解,本篇文章我们继续来探索如何从零开始,一步步搭建开发环境,编写代码,最后实现数据库的增删改查操作。

一、开发环境搭建

首先,我们需要准备的开发环境有:Java开发工具包(JDK)、数据库(如MySQL)、数据库驱动(如MySQL Connector/J)。

安装JDK:

你可以从Oracle官网下载适合你操作系统的JDK版本,按照提示进行安装即可。相信这个大家早已经安装过了,在这里就不再多说了。

安装数据库:

同样在官网下载MySQL安装包,按照提示进行安装。安装完成后,需要创建一个数据库和表,用于后续的测试。

下载数据库驱动:

在MySQL官网下载对应版本的MySQL Connector/J,将其解压后的jar文件添加到你的项目类路径中。

具体的操作如下:

1、创建一个普通的空项目

Description

填写上项目名称与路径

Description

2、配置JDK版本

Description

3、创建一个子模块(jdbc快速入门的程序在这里面写)

Description

这里填写上子模块名称

Description

然后下一步,点击ok,这个子模块就创建完成了

Description

4、导入jar包

Description
Description

二、使用JDBC访问数据库

JDBC操作数据库步骤如下:

  • 注册驱动
  • 获取数据库连接对象 (Connection)
  • 定义SQL语句
  • 获取执行SQL的对象 (Statement)
  • 执行SQL
  • 处理集并返回结果(ResultSet)
  • 释放资源

下面通过代码来了解一下JDBC代码的编写步骤与操作流程。

1、创建数据库和表:

CREATE DATABASE `jdbc_test` DEFAULT CHARSET utf8mb4;
CREATE TABLE `account`(
`id` int(11) NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT 'ID',
`name` varchar(20) NOT NULL COMMENT '姓名',
`salary` int(11) COMMENT '薪资',
);

2、编写Java程序:

package com.baidou.jdbc;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;

public class JDBCDemo {
public static void main(String[] args) throws Exception {
// 1、注册驱动
Class.forName("com.mysql.jdbc.Driver");

// 2、获取连接
String url ="jdbc:mysql://127.0.0.1:3306/jdbc_test?useSSL=false";
String user = "root";
String password = "123456";
Connection conn = DriverManager.getConnection(url, user, password);


// 3、定义sql语句
String sql = "insert into account(name,salary) values('王强',10000)";

// 4、获取执行sql的对象 Statement
Statement stmt = conn.createStatement();

// 5、执行sql
int count = stmt.executeUpdate(sql);

// 6、处理结果
// 打印受影响的行数
System.out.println(count);
System.out.println(count>0?"插入成功":"插入失败");

// 7、释放资源
stmt.close();
conn.close();
}
}

控制输出结果如下:

Description

表中的数据:

Description

三、JDBC-API详解

JDBC API是Java语言访问数据库的标准API,它定义了一组类和接口,用于封装数据库访问的细节。主要包括以下几类:

1、DriverManager驱动管理对象

(1)注册驱动:
注册给定的驱动程序:staticvoid registerDriver(Driver driver);在com.mysql.jdbc.Driver类中存在静态代码块;
写代码有固定写法:Class.forName(“com.mysql.jdbc.Driver”);

(2)获取数据库连接对象
具体实现是通过:DriverManager.getConnection(url,username,password);

2、Connection数据库连接对象

(1)创建sql执行对象

conn.createStatement();

(2)可以执行事务的提交,回滚操作

conn.rollback();conn.setAutoCommit(false);

3、Statement执行sql语句的对象

用于向数据库发送要执行的sql语句(增删改查),其中有两个重要方法:

  • executeUpdate(String sql)
  • executeQuery(String sql)

前者用于DML操作,后者用于DQL操作。

4、ResultSet结果集对象

  • 打印输出时判断结果集是否为空,rs.next()
  • 若知字段类型可使用指定类型如,rs.getInt()获取数据
你还在苦恼找不到真正免费的编程学习平台吗?可以试试云端源想!课程视频、在线书籍、在线编程、实验场景模拟、一对一咨询……你想要的全部学习资源这里都有,重点是统统免费!点这里即可查看

四、提取工具类并完成增删改查操作

在上面介绍了可以通过JDBC对数据库进行增删改查操作,但是如果每次对数据库操作一次都要重新加载一次驱动,建立连接等重复性操作的话,会造成代码的冗余。

因此下面通过封装一个工具类来实现对数据库的增删改查操作。

1、建立配置文件db.properties文件

properties文件是Java支持的一种配置文件类型(所谓支持是因为Java提供了properties类,来读取properties文件中的信息)。记得一定要将此文件直接放在src目录下!!!不然后面执行可能找不到此配置文件!!

driver=com.mysql.jdbc.Driver
url=jdbc:mysql://localhost:3306/jdbcstudy?useUnicode=true&characterEncoding=utf8&useSSL=true
username=root
password=lcl403020

2、建立工具类JdbcUtils.java

有了这个工具类,之后的增删改查操作可直接导入这个工具类完成获取连接,释放资源的操作,很方便,接着往下看。

package jdbcFirstDemo.src.lesson02.utils;
import java.io.IOException;
import java.io.InputStream;
import java.sql.*;
import java.util.Properties;
public class JdbcUtils {
private static String driver=null;
private static String url=null;
private static String username=null;
private static String password=null;
static {
try{
InputStream in=JdbcUtils.class.getClassLoader().getResourceAsStream("db.properties"); Properties properties=new Properties();
properties.load(in);


driver=properties.getProperty("driver");
url=properties.getProperty("url");
username=properties.getProperty("username");
password=properties.getProperty("password");
//驱动只需要加载一次
Class.forName(driver);
} catch (IOException e) {
throw new RuntimeException(e);
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
}
//获取连接
public static Connection getConnection() throws SQLException {
return DriverManager.getConnection(url,username,password);
}
//释放连接资源
public static void release(Connection conn, Statement st, ResultSet rs) {
if(rs!=null){
try{
rs.close();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}


if(st!=null){
try {
st.close();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
if(conn!=null){
try {
conn.close();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}

}
}

3、 插入数据(DML)

package jdbcFirstDemo.src.lesson02;
import jdbcFirstDemo.src.lesson02.utils.JdbcUtils;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class TestInsert {
public static void main(String[] args) {
Connection conn=null;
Statement st=null;
ResultSet rs=null;
try{
conn= JdbcUtils.getConnection();
st=conn.createStatement();
String sql="insert into users (id, name, password, email, birthday) VALUES (7,'cll',406020,'30812290','2002-03-03 10:00:00')";
int i=st.executeUpdate(sql);
if(i>0){
System.out.println("插入成功!");
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
finally {
JdbcUtils.release(conn,st,rs);
}
}
}

运行结果:

Description

4、修改数据(DML)

package jdbcFirstDemo.src.lesson02;
import jdbcFirstDemo.src.lesson02.utils.JdbcUtils;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class TestUpdate {
public static void main(String[] args) {
Connection conn=null;
Statement st=null;
ResultSet rs=null;
try{
conn= JdbcUtils.getConnection();
st=conn.createStatement();
String sql="update users set name='haha' where id=2";
int i=st.executeUpdate(sql);
if(i>0){
System.out.println("修改成功!");
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
finally {
JdbcUtils.release(conn,st,rs);
}
}
}

运行结果:

Description

5、 删除数据(DML)

package jdbcFirstDemo.src.lesson02;
import jdbcFirstDemo.src.lesson02.utils.JdbcUtils;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class TestDelete {
public static void main(String[] args) {
Connection conn=null;
Statement st=null;
ResultSet rs=null;
try{
conn= JdbcUtils.getConnection();
st=conn.createStatement();
String sql="delete from users where id=1";
int i=st.executeUpdate(sql);
if(i>0){
System.out.println("删除成功!");
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
finally {
JdbcUtils.release(conn,st,rs);
}
}
}

运行结果:删除掉了id=1的那一条数据

Description

6、 查询数据(DQL)

package jdbcFirstDemo.src.lesson02;
import jdbcFirstDemo.src.lesson02.utils.JdbcUtils;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class TestQuery {
public static void main(String[] args) throws SQLException {
Connection conn=null;
Statement st=null;
ResultSet rs=null;
conn= JdbcUtils.getConnection();
st=conn.createStatement();
//sql
String sql="select * from users";
rs=st.executeQuery(sql);
while (rs.next()){
System.out.println(rs.getString("name"));
}
}
}

运行结果:

Description

本文从开发环境搭建到代码编写步骤以及JDBC API做了详细的讲解,最后通过封装一个工具类来实现对数据库的增删改查操作,希望能够帮助你快速入门JDBC,关于数据库连接池部分,我们下期接着讲,敬请期待哦!

收起阅读 »

为什么我的页面鼠标一滑过,布局就错乱了?

web
前言 这天刚到公司,测试同事又在群里@我: 为什么页面鼠标一滑过,布局就错乱了? 以前是正常的啊? 刷新后也是一样 快看看怎么回事 同时还给发了一段bug复现视频,我本地跑个例子模拟下 可以看到,鼠标没滑过是正常的,鼠标一滑过图片就换行了,鼠标移出又正常了。...
继续阅读 »

前言


这天刚到公司,测试同事又在群里@我:

为什么页面鼠标一滑过,布局就错乱了?

以前是正常的啊?

刷新后也是一样

快看看怎么回事


同时还给发了一段bug复现视频,我本地跑个例子模拟下


GIF 2023-8-28 11-23-25.gif


可以看到,鼠标没滑过是正常的,鼠标一滑过图片就换行了,鼠标移出又正常了。


正文


首先说下我们的产品逻辑,我们产品的需求是:要滚动的页面,滚动条不应该占据空间,而是悬浮在滚动页面上面,而且滚动条只在滚动的时候展示。


我们的代码是这样写:


  <style>
.box {
width: 630px;
display: flex;
flex-wrap: wrap;
overflow: hidden; /* 注意⚠️ */
height: 50vh;
box-shadow: 0 0 5px rgba(93, 96, 93, 0.5);
}
.box:hover {
overflow: overlay; /* 注意⚠️ */
}
.box .item {
width: 200px;
height: 200px;
margin-right: 10px;
margin-bottom: 10px;
}
img {
width: 100%;
height: 100%;
}
</style>
<div class="box">
<div class="item">
<img src="https://p6.itc.cn/q_70/images03/20210422/229df67605934f719b5e2efe6f171d5f.jpeg" alt="">
</div>
<div class="item">
<img src="https://p6.itc.cn/q_70/images03/20210422/229df67605934f719b5e2efe6f171d5f.jpeg" alt="">
</div>
<div class="item">
<img src="https://p6.itc.cn/q_70/images03/20210422/229df67605934f719b5e2efe6f171d5f.jpeg" alt="">
</div>
<div class="item">
<img src="https://p6.itc.cn/q_70/images03/20210422/229df67605934f719b5e2efe6f171d5f.jpeg" alt="">
</div>
</div>

我们使用了overflow属性的overlay属性,滚动条不会占据空间,而是悬浮在页面之上,刚好吻合了产品的需求。


image.png


然后我们在滚动的区域默认是overflow:hidden,正常时候不会产生滚动条,hover的时候把overflow改成overlay,滚动区域可以滚动,并且不占据空间。


简写代码如下:


  .box {
overflow: hidden;
}
.box:hover {
overflow: overlay;
}

然后我们是把它封装成sass的mixins,滚动区域使用这个mixins即可。


上线后没什么问题,符合预期,获得产品们的一致好评。


直接这次bug的出现。


排查


我先看看我本地是不是正常的,我打开一看,咦,我的布局不会错乱。


然后我看了我的chrome的版本,是113版本


然后我问了测试的chrome版本,她是114版本


然后我把我的chrome升级到最新114版本,咦,滑过页面之后我的布局也乱了。


初步判断,那就有可能是chrome版本的问题。


去网上看看chrome的升级日志,看看有没有什么信息。


image.png


具体说明:


image.png


可以看到chrome114有了一个升级,就是把overflow:overlay当作overflow:auto的别名,也就是说,overlay的表现形式和auto是一致的。


实锤了,auto是占据空间的,所以导致鼠标一滑过,布局就乱了。


其实我们从mdn上也能看到,它旁边有个删除按钮,然后滑过有个tips:


image.png


解决方案


第一种方式


既然overflow:overlay表现形式和auto一致,那么我们得事先预留出滚动条的位置,然后设置滚动条的颜色是透明的,滑过才显示颜色,基本上也能符合产品的需求。


代码如下:


  // 滚动条
::-webkit-scrollbar {
background: transparent;
width: 6px;
height: 6px;
}
// 滚动条上的块
::-webkit-scrollbar-thumb {
background-clip: padding-box;
background-color: #d6d6d6;
border: 1px solid transparent;
border-radius: 10px;
}
.box {
overflow: auto;
}
.box::-webkit-scrollbar-thumb {
background-color: transparent;
}
.box:hover::-webkit-scrollbar-thumb {
background-color: #d6d6d6;
}

第二种方式


如果有用element-ui,它内部封装了el-scrollbar组件,模拟原生的滚动条,这个也不占据空间,而是悬浮,并且默认不显示,滑过才显示。



element-ui没有el-scrollbar组件的文档说明,element-plus才有,不过都可以用。



总结


这次算是踩了一个坑,当mdn上面提示有些属性已经要废弃⚠️了,我们就要小心了,尽量不要使用这些属性,就算当前浏览器还是支持的,但是不能保证未来某个时候浏览器会不会废弃这个属性。(比如这次问题,改这些问题挺费时间的,因为用的地方挺多的。)


因为一旦这些属性被废弃了,它预留的bug就等着你去解决,谨记!


作者:答案cp3
来源:juejin.cn/post/7273875079658209319
收起阅读 »

工作踩坑之在浏览器关闭/刷新前发送请求

web
丑话说在前 丑话说在前:当前的浏览器完全没有任何一个可靠、通用、准确区分用户关闭和刷新操作的API。 因此,如果你是单纯想在用户关闭时发送请求,那么没有任何完美答案。如果你只是想在谷歌Chrome、360浏览器的急速模式等一众基于谷歌Chrome浏览器套皮浏览...
继续阅读 »

丑话说在前


丑话说在前:当前的浏览器完全没有任何一个可靠、通用、准确区分用户关闭和刷新操作的API


因此,如果你是单纯想在用户关闭时发送请求,那么没有任何完美答案。如果你只是想在谷歌Chrome360浏览器的急速模式等一众基于谷歌Chrome浏览器套皮浏览器上实现,那么在下面我会提供一个简单的方法,但是Edge并不支持该方法。Edge是真牛啊,青出于蓝胜于蓝?


先来看看浏览器在刷新/关闭时的顺序


为了帮助理解我区分浏览器关闭和刷新操作的方法,先来看看浏览器在关闭/刷新时的执行顺序吧~


在浏览器关闭或刷新页面时,onbeforeunloadonunload 事件的执行顺序是固定的。



  1. 当用户关闭浏览器标签、窗口或者输入新的 URL 地址时,首先会触发 onbeforeunload 事件。

  2. onbeforeunload 事件处理完成后,如果用户选择离开页面(关闭或刷新),则会触发 onunload 事件。


因此,onbeforeunload 事件在用户决定离开页面之前执行,而 onunload 事件在用户离开页面之后执行。这两个事件提供了在用户离开页面前后执行代码的机会,可以用于执行清理操作或者提示用户确认离开等操作。通过对比两个事件的执行时间差,我们就可以简单判断浏览器的关闭或刷新行为啦。


简易判断Chrome浏览器关闭或刷新行为的方法


let beforeTime = 0,
leaveTime = 0;
// 获取浏览器onbeforeunload时期的时间戳
window.onbeforeunload = () => {
beforeTime = new Date().getTime();
};
window.onunload = () => {
// 对比onunload时期和onbeforeunload时期的时间差值
leaveTime = new Date().getTime() - beforeTime;
if (leaveTime < 5) {
// 如果小于5就是关闭
// 你可以在这发送请求
} else {
// 如果大于5就是刷新
// 你可以在这发送请求
}
};

注意:经过本人的测试,该方法仅支持Chrome浏览器等,Edge浏览器无论是关闭还是刷新,时间戳差均小于5ms,而谷歌Chrome浏览器的时间戳差均大于5ms,为7ms-8ms左右。环境不同亦有可能导致结果不同。


详见他人的测试结果图:


1703417937476.jpg


如何发送请求


既然已经区分了Chrome浏览器的关闭和刷新行为,那么该如果发送请求呢?


发送请求的方式主要有以下几种:


1. 使用 Navigator.sendBeacon()


该方法主要用于将统计数据发送到 Web 服务器,同时避免了用传统技术,如XMLHttpRequest所导致的各种问题。


他的使用方法也很简单:


navigator.sendBeacon(url, data);

// url参数表明 data 将要被发送到的网络地址
// data (可选) 参数是将要发送的 ArrayBuffer、ArrayBufferView、Blob、DOMString、FormData 或 URLSearchParams 类型的数据。
// 当用户代理成功把数据加入传输队列时,sendBeacon() 方法将会返回 true,否则返回 false。

怎么样?简简单单一行代码即可实现发送可靠的异步请求,同时不会延迟页面的卸载或影响下一导航的载入性能。但是别忽略的他很重要的一个特点数据是通过 POST 请求发送的。


2. 使用 fetch + keepalive


该方法用于发起获取资源的请求。它返回的是一个 promise。他支持 POST 和 GET 方法,配合 keepalive 参数,可以实现浏览器关闭/刷新行为前发送请求。keepalive可用于超过页面的请求。可以说keepalive就是 Navigator.sendBeacon() 的替代品。


fetch('url',{
method:'GET',
keepalive:true
})

3. 直接发送异步请求


由于从Chrome83开始,onunload里面不允许执行同步的XHR,所以同步请求自然是无法实现的,但是一部请求是可以实现的。但是异步请求发送到设备的成功率并非百分之百,因此并不推荐,也不在此赘述。


总结


以上便是浏览器关闭/刷新前发送请求的几种方法,而我是采用了 fetch + alive 尝试简单实现浏览器仅关闭时发送请求,具体实现代码如下:


let beforeTime = 0,
leaveTime = 0;
window.onbeforeunload = () => {
beforeTime = new Date().getTime();
};
window.onunload = () => {
leaveTime = new Date().getTime() - beforeTime;
if (leaveTime <= 5) {
fetch('/logout.do',{
method:'GET',
keepalive:true
})
}
};

经测试,使用效果如下


使用该方法对于各浏览器的测试结果


浏览器/测试方法关闭tab页关闭浏览器任务管理器关闭刷新
Chrome登出登出登出未登出
Edge登出未登出登出登出
360急速模式登出登出登出未登出
360兼容模式白屏白屏白屏白屏
IE白屏白屏白屏白屏

浏览器/测试方法关闭tab页关闭浏览器任务管理器关闭刷新
Chrome
Edge××
360急速模式
360兼容模式××××
IE××××

小小的吐槽:


后端感知web退出本就不推荐由前端来处理,更优解为 持续ping 或者后端 心跳机制发包 来检测。


既然设备那边提出了这个请求,我们web这也就努力挣扎一下,把测试结果发给评审人员评审一下吧~


作者:bachelor98
来源:juejin.cn/post/7315846825344647194
收起阅读 »

Android自定义定时通知实现

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

Android自定义通知实现


前言


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


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


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


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


nnotify.gif

正文


一、待办数据库


1、待办实体类


image.png

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


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


2、数据访问Dao


添加或更新一条待办事项


image.png



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



3、数据库封装


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


二、添加定时器


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


1、思路:



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

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

  3. 计算出延迟的时间。

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


2、实现


image.png


说明



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



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




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

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


3、封装


image.png



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



三、注册广播


1. 首先创建一个TodoNotifyReceiver广播


image.png



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



2. 自定义Action


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


image.png


3. 广播注册方法


image.png

4.广播注册及回调实现


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


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


image.png


/**
* 延迟5分钟
*/

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

/**
* 标记已完成
*/

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



四、自定义通知构建


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

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

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

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

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

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


步骤:



  1. 构建自定义通知布局

  2. 设置自定义的Action

  3. 传入待办数据

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

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

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

  7. 构建自定义通知布局

  8. 构建NotificationChannel

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



总结


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


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

Android:实现一个全屏拖拽、自动贴边半隐藏的自定义View

大家好,我是似曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap却情有独钟。 今天给大家带来一个可全屏拖拽,手指离开屏幕后自动贴边,隔一定时间后自动半隐藏的这么一个效果。话不多说直接上效果图: 看到这个效果是不是感觉很熟悉?没错,很多商业APP首页都带一个小...
继续阅读 »

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



今天给大家带来一个可全屏拖拽,手指离开屏幕后自动贴边,隔一定时间后自动半隐藏的这么一个效果。话不多说直接上效果图:


1.gif


看到这个效果是不是感觉很熟悉?没错,很多商业APP首页都带一个小助手的图标,使用的时候点击它就自动弹出,不使用的时候自动贴边隐藏,当然也是可以随意全屏拖拽,为的是防止遮挡一些关键位置的信息,影响用户体验。接下来咱们就来一步步实现它!


要实现上图效果咱们得罗列所有的功能点:



  • 自定义View,这里要显示图片所以继承自ImageView或其子类即可

  • 监听屏幕滑动事件,记录和计算当前视图的位置信息

  • 动画效果,很明显使用平移动画

  • 圆角图片和描边,使用第三方ImageView即可


为了解决小圆球这个图标的问题咱们自定View时直接继承自第三方RoundedImageView,一举两得直接解决了第一和第四步。咱们把焦点聚焦到第二三部分,这也是最为复杂的部分。


之前文章 Android:自定义View实现图片缩放及坐标的计算(上) 中有写到监听界面各类手势可以使用GestureDetector,这里咱们就不采用重写onTouchEvent方法然后再里面监听各类ACTION_UP、ACTION_DOWN、ACTION_MOVE事件的模式来写了。但还是需要重写onTouchEvent方法将GestureDetector的处理结果返回给它即可:


override fun onTouchEvent(event: MotionEvent): Boolean {
return gestureDetector.onTouchEvent(event)
}

接下来只需在GestureDetector入参的GestureDetector.SimpleOnGestureListener监听中执行对应的操作:


首先需要在onDown方法中记录最后点击屏幕的位置信息lastXlastY,这里备份一份点击时的位置信息moveXmoveY,用于后续逻辑判断。


override fun onDown(e: MotionEvent): Boolean {
lastX = e.rawX.toInt()
lastY = e.rawY.toInt()
moveX = lastX
moveY = lastY
return true
}

onScroll中需要不停修改自定义视图的位置,所以我们需要计算出需要移动位置的信息。通过当前实时滑动点的信息和最后记录的点信息计算出滑动距离,再重新计算当前视图的上下左右位置,最后咱们采取layout() 方式进行位置设置。


override fun onScroll(
e1: MotionEvent,
e2: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
//获取当前实时点信息
val rawX = e2.rawX.toInt()
val rawY = e2.rawY.toInt()

//变化量
dX = rawX - lastX
dY = rawY - lastY

//获取最新的视图位置
var left = left + dX
var right = right + dX
var top = top + dY
var bottom = bottom + dY

//添加限制范围,上下左右不能超出屏幕范围
if (left < 0) {
left = 0
right = left + width
}

if (right > windowWith) {
right = windowWith
left = right - width
}

if (top < 40) {
top = 40
bottom = top + height
}

if (bottom > windowHight) {
bottom = windowHight
top = bottom - height
}

//更新当前视图位置
layout(left, top, right, bottom)

//更新最后屏幕点信息
lastX = rawX
lastY = rawY

return true
}

到此,咱们已经实现了可全屏拖拽的效果了:


2.gif


现在只差最后一步,通过位置信息判断图标该往哪边贴边,以及移动距离的计算。


由于GestureDetector没有抬起监听,所以逻辑咱们还是得在onTouchEvent方法中通过监听ACTION_UP的动作进行操作。判断该往哪边贴边很简单,如果最后松开的位置X坐标的超过屏幕一半就往右贴,反之往左。动画咱们还是使用ValueAnimator,因为我们移动也是用layout() 方法进行操作。


override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_UP -> {
val x = event.rawX
val y = event.rawY
//抬起点和最后一次按下点x、y距离大于视图宽的一半才执行
if (abs(x - moveX) > width / 2 || abs(y - moveY) > width / 2) {
val isRight = x > windowWith / 2

//贴边
startAnimator(isRight, windowWith - width, 0)

//隔1.5秒收边
postDelayed({
startAnimator(isRight, windowWith - width * 2 / 3, -width / 3)
}, 1500)
}
return true
}
}
return gestureDetector.onTouchEvent(event)
}

//属性动画执行
private fun startAnimator(isRight: Boolean, rightValue: Int, leftValue: Int) {
ValueAnimator.ofInt(
left,
if (isRight) rightValue else leftValue
).apply {
addUpdateListener { animation ->
val value = animation.animatedValue as Int
//根据监听值不断改变当前视图位置
layout(value, top, value + width, bottom)
}
//插值器 先快后慢
interpolator = AccelerateDecelerateInterpolator()
duration = 600
start()
}
}

这里使用了两次动画,第一次根据计算得出的方向进行贴边平移,隔了1.5秒后再进行隐藏的操作。到此我们的所有功能全部都实现了接下来总结几点:



  • 自定义View时尽量选择最接近目标功能的View进行继承

  • 屏幕事件监听除了重写onTouchEvent进行动作监听的方式还有GestureDetectorScaleGestureDetector等方式

  • 重写了onTouchEvent方法后需要注意其返回值,如果都返回false的情况该视图的点击事件有可能会被父View或其他设有监听事件控件所消费,导致滑动监听不被触发。


以上便是实现一个全屏拖拽、自动贴边半隐藏的自定义View的所有内容,希望能给大家带来帮助!


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

mac终端自定义登录欢迎语

mac终端自定义登录欢迎语 看着单调的终端,突然有了一丝丝的念头,我要搞的炫酷一点。让我想到的一个场景就是:我之前在使用公司的阿里云服务器的时候,在每次登录的时候会有欢迎语,类似于这样的: shigen手头也没有可以用的阿里云云服务器,这里在知乎上找到的...
继续阅读 »

mac终端自定义登录欢迎语



看着单调的终端,突然有了一丝丝的念头,我要搞的炫酷一点。让我想到的一个场景就是:我之前在使用公司的阿里云服务器的时候,在每次登录的时候会有欢迎语,类似于这样的:


阿里云服务器登录欢迎语



shigen手头也没有可以用的阿里云云服务器,这里在知乎上找到的文章,仅供参考哈。



那我的mac我每次打开终端的时候,也相当于一次登录呢,那我是不是也可以这样的实现,于是开始捣腾起来。


查了一下发现:



要在每次登录终端时显示自定义的欢迎语,可以编辑你的用户主目录下的.bashrc文件(如果是使用 Bash shell)或.zshrc文件(如果是使用 Zsh shell)。



好的呀,原来就是这么简单,于是去搞了一下,我用的bashzsh,自然需要编辑一下.zshrc文件了。


执行命令,更改.zshrc文件内容:


 vim ~/.zshrc

在在行尾加上如下的内容:


 #自定义欢迎语
 echo -e "\033[38;5;196m"
 cat << "EOF"
 
                                                                         
  ad88888ba   88        88  88   ,ad8888ba,   88888888888 888b      88  
 d8"     "8b  88        88  88   d8"'   `"8b  88           8888b     88  
 
Y8,          88        88  88 d8'           88           88 `8b   88  
 `Y8aaaaa,   88aaaaaaaa88 88 88             88aaaaa     88 `8b   88  
   `"""""8b, 88""""""""88 88 88     88888 88"""""     88   `8b  88  
         `8b
88       88 88 Y8,       88 88           88   `8b 88  
 
Y8a     a8P  88        88  88   Y8a.   .a88  88           88     `8888  
 
"Y88888P"   88        88  88    `"Y88888P"   88888888888 88     `888  
                                                                         
 EOF
 echo -e "\033[0m"
 echo -e "\033[1;34mToday is \033[1;32m$(date +%A,\ %B\ %d,\ %Y)\033[0m"
 echo -e "\033[1;34mThe time is \033[1;32m$(date +%r)\033[0m"
 echo -e "\033[1;34mYou are logged in to \033[1;32m$(hostname)\033[0m"

其中,自定义ascii字符生成可以参考这个网站:在线生成ascci艺术字


网站的使用


完了之后,我们只需要重新加载一下配置文件即可:


 source ~/.zshrc

就会出现如下的效果:


终端效果


我们在vscode中看看:


vscode中的显示效果


哈哈,是不是稍微酷炫一点了。就先这样子吧。


其实mac和linux的操作很多都一样,这养的配置也可以直接平移到Linux服务器上,哈哈,下次打开云服务器就会看到自定义的欢迎语了,是不是倍儿有面儿啊。




作者:shigen01
来源:juejin.cn/post/7316451458840838179
收起阅读 »

妙用 CSS counters 实现逐层缩进

web
妙用 CSS counters 实现逐层缩进 之前使用纯 CSS 实现了一个树形结构,效果如下 其中,展开收起是用到了原生标签details和summary,有兴趣的可以回顾之前这篇文章 CSS 实现树状结构目录 还有一点,树形结构是逐层缩进的,是使用...
继续阅读 »

妙用 CSS counters 实现逐层缩进



之前使用纯 CSS 实现了一个树形结构,效果如下


image-20231221201613974


其中,展开收起是用到了原生标签detailssummary,有兴趣的可以回顾之前这篇文章



CSS 实现树状结构目录



还有一点,树形结构是逐层缩进的,是使用内边距实现的,但是这样会有点击范围的问题,层级越深,点击范围越小,如下


image-20231221201953463


之前的方案是用绝对定位实现的,比较巧妙,但也有点难以理解,不过现在发现了另一种方式也能很好的实现缩进效果,一起看看吧


一、counter() 与 counters()


我们平时使用的一般都是counter,也就是计数器,比如


<ul>
<li>li>
<li>li>
<li>li>
ul>

加上计数器,通常用伪元素来显示这个计数器


ul {
counter-reset: listCounter; /*初始化计数器*/
}
li {
counter-increment: listCounter; /*计数器增长*/
}
li::before {
content: counter(listCounter); /*计数器显示*/
}

这就是一个最简单的计数器了,效果如下


image-20231221203258255


我们还可以改变计数器的形态,比如改成大写罗马数字(upper-roman


li::before {
content: counter(listCounter, upper-roman);
}

效果如下


image-20231221203158970


有关计数器,网上的教程非常多,大家可以自行搜索


然后我们再来看counters(),比前面的counter()多了一个s,叫做嵌套计数器,有什么区别呢?下面来看一个例子,还是和上面一样,只是结构上复杂一些


<ul>
<li>
<ul>
<li>li>
<li>li>
<li>li>
ul>
li>
<li>li>
<li>li>
<li>
<ul>
<li>li>
<li>
<ul>
<li>li>
<li>li>
<li>li>
ul>
li>
ul>
li>
ul>


效果如下


image-20231221204007978


看着好像也不错?但是好像从计数器上看不出层级效果,我们把counter()换成counters(),注意,counters()要多一个参数,表示连接字符,也就是嵌套时的分隔符,如下


li::before {
content: counters(listCounter, '-');
}

效果如下


image-20231221204311891


是不是可以非常清楚的看出每个列表的层级?下次碰到类似的需求就不需要用 JS 去递归生成了,直接用 CSS 渲染,简单高效,也不会出错。


默认ul是有padding的,我们把这个去除看看,变成了这样


image-20231221204528126


嗯,看着这些长短不一的序号,是不是刚好可以实现树形结构的缩进呢?


二、树形结构的逐层缩进


回到文章开头,我们先去除之前的padding-left,会变成这样


image-20231221224113570


完全看不清结构关系,现在我们加上嵌套计数器


.tree details{
counter-reset: count;
}
.tree summary{
counter-increment: count;
}
.tree summary::before{
content: counters(count,"-");
color: red;
}

由于结构关系,目前序号都是1,没关系,只需要有嵌套关系就行,效果如下


image-20231221224810497


**是不是刚好把每个标题都挤过去了?**然后我们把中间的连接线去除,这样可以更方便的控制缩进的宽度


.tree summary::before{
content: counters(count,"");
color: red;
}

效果如下


image-20231221225225369


最后,我们只需要设置这个计数器的颜色为透明就行了


.tree summary::before{
content: counters(count,"");
color: transparent;
}

最终效果如下


image-20231221225607078


这样做的好处是,每个树形节点都是完整的宽度,所以 可以很轻易的实现hover效果,而无需借助伪元素去扩大点击范围


.tree summary:hover{
background-color: #EEF2FF;
}

效果如下


image-20231221225732065


还可以通过修改计数器的字号来调整缩进,完整代码可以访问以下链接:




三、总结一下


以上就是本文的全部内容了,主要介绍了计数器的两种形态,以及想到的一个应用场景,下面总结一下



  1. 逐层缩进用内边距比较容易实现,但是会造成子元素点击区域过小的问题

  2. counter 表示计数器,比较常规的单层计数器,形如 1、2、3

  3. counters 表示嵌套计数器,在有层级嵌套时,会自动和上一层的计数器相叠加,形如1、1-1、1-2、1-2-1

  4. 嵌套计数器会逐层叠加,计数器的字符会逐层增加,计数器所占据的位置也会越来越大

  5. 嵌套计数器所占据的空间刚好可以用作树形结构的缩进,将计数器的颜色设置为透明就可以了

  6. 用计数器的好处是,每个树形节点都是完整的宽度,而无需借助伪元素去扩大点击范围


一个还算实用的小技巧,你学到了吗?


作者:XboxYan
来源:juejin.cn/post/7315850963343671335
收起阅读 »

🔥图片懒加载🔥三种实现方案

web
一、前言 图片懒加载,当图片出现在可视区域再进行加载,提升用户的体验。因为有些用户不会看完图片,全部加载会浪费流量。在网上查阅资料,总结了三种办法,有各自的利弊,下文一一介绍。 方法优点缺点推荐指数设置img loadingh5的属性,没有兼容问题需要已知图片...
继续阅读 »

一、前言


图片懒加载,当图片出现在可视区域再进行加载,提升用户的体验。因为有些用户不会看完图片,全部加载会浪费流量。在网上查阅资料,总结了三种办法,有各自的利弊,下文一一介绍。


方法优点缺点推荐指数
设置img loadingh5的属性,没有兼容问题需要已知图片高度、宽高比⭐️⭐️
IntersectionObserver API无需知道图片高度低版本需引入polyfill⭐️⭐️⭐️
vue-lazyload 自定义指令无需知道图片高度github现存issues较多,没有解决⭐️⭐️

output.gif


二、实现方式及Demo


1. 设置img标签loading属性


loading属性允许两个值:eager立即加载图像(默认值);lazy延迟加载图像。在使用lazy属性的时候,需要设置<img>标签的高度,否则无法懒加载。


注意: 适用于两种场景,图片高度已知、图片宽高比已知。



  • 已知图片高度


<style>
.img-box img {
width: 100%;
height: 700px; /*设置为图片的真实高度*/
}
</style>

<div class="img-box">
<img src="https://i.postimg.cc/GtN3Cs02/1.jpg" loading="lazy" />
<img src="https://i.postimg.cc/hGdKLGdW/2.jpg" loading="lazy" />
<img src="https://i.postimg.cc/T1SkJTbF/3.jpg" loading="lazy" />
<img src="https://i.postimg.cc/wxPFPTtb/4.jpg" loading="lazy" />
<img src="https://i.postimg.cc/FRkGF28x/5.jpg" loading="lazy" />
<img src="https://i.postimg.cc/05JH9wqq/6.jpg" loading="lazy" />
</div>



  • 已知图片宽高比


 <style>
.img-box div {
position: relative;
padding-top: 66%; /* (你的图片的高度/宽度值) */
overflow: hidden;
}
.img-box img {
position: absolute;
top:0;
right:0;
width:100%;
}
</style>

<div class="img-box">
<div>
<img src="https://i.postimg.cc/GtN3Cs02/1.jpg" loading="lazy" />
</div>
<div>
<img src="https://i.postimg.cc/hGdKLGdW/2.jpg" loading="lazy" />
</div>
<div>
<img src="https://i.postimg.cc/T1SkJTbF/3.jpg" loading="lazy" />
</div>
<div>
<img src="https://i.postimg.cc/wxPFPTtb/4.jpg" loading="lazy" />
</div>
<div>
<img src="https://i.postimg.cc/FRkGF28x/5.jpg" loading="lazy" />
</div>
<div>
<img src="https://i.postimg.cc/05JH9wqq/6.jpg" loading="lazy" />
</div>
</div>


2. 使用 IntersectionObserver


IntersectionObserver接口,可以观察DOM节点是否出现在视口,当DOM节点出现在视口中才加载图片。img必须有高度,否则图片默认都在视口中,会将图片全部加载。可以设置img的src为base64白色图片,然后在替换为真实的图片地址。


注意: 不需要预先知道图片的高度,但是有兼容性问题,低版本需要引入intersection-observer polyfill



  • 已知图片高度


<style>
.img-box .lazy-img {
width: 100%;
height: 600px; /*如果已知图片高度可以设置*/
}
</style>

<div class="img-box">
<img class="lazy-img" data-origin="https://images.djtest.cn/pic/test/d48aed7c991b43d850d011f2299d852e.jpg" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"/>
<img class="lazy-img" data-origin="https://images.djtest.cn/pic/test/a588b152c79ac60162ecbdf82b060061.jpg" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"/>
<img class="lazy-img" data-origin="https://images.djtest.cn/pic/test/eacbc2cd4b6ca636077378182bdfcc88.jpg" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"/>
<img class="lazy-img" data-origin="https://images.djtest.cn/pic/test/751470f4b478450e8556f78cd7dd3d96.jpg" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"/>
<img class="lazy-img" data-origin="https://images.djtest.cn/pic/test/e4a531bee5694a4a01dee74b18bbfd8b.jpg" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"/>
<img class="lazy-img" data-origin="https://images.djtest.cn/pic/test/7d8f107e827a7beaa0b9d231bfa4187f.jpg" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"/>
<img class="lazy-img" data-origin="https://images.djtest.cn/pic/test/4f7586f6b74f2bd0b94004fcbae69856.jpg" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"/>
<img class="lazy-img" data-origin="https://images.djtest.cn/pic/test/863849e14e7e8903ed4b27fcbdafe8b0.jpg" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"/>
<img class="lazy-img" data-origin="https://images.djtest.cn/pic/test/d8bb17fe9a7223f35075014ef250e2fa.jpg" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"/>
</div>

<script>
function Observer() {
let images = document.querySelectorAll(".lazy-img");
let observer = new IntersectionObserver(entries => {
entries.forEach(item => {
if (item.isIntersecting) {
item.target.src = item.target.dataset.origin; // 开始加载图片,把data-origin的值放到src
observer.unobserve(item.target); // 停止监听已开始加载的图片
}
});
});
images.forEach(img => observer.observe(img));
}
Observer()
</script>

3. 使用vue-lazyload


在vue2中使用时,建议安装npm i vue-lazyload@1.3.3 -s,使用高版本在main.js中全局自定义指令后依然无法使用指令。在vue3中可以使用 npm i vue3-lazy -s



  • 全局注册自定义指令,在页面就可以使用了


// 全局自定义指令
import Vue from 'vue'
import VueLazyload from 'vue-lazyload'
Vue.use(VueLazyload, {
preLoad: 1,
observer: true // 设置为true,内部使用IntersectionObserver。默认使用
})

/* 在页面中直接使用 */
<div>
<img v-lazy="https://images.djtest.cn/pic/test/d48aed7c991b43d850d011f2299d852e.jpg">
<img v-lazy="https://images.djtest.cn/pic/test/a588b152c79ac60162ecbdf82b060061.jpg">
<img v-lazy="https://images.djtest.cn/pic/test/eacbc2cd4b6ca636077378182bdfcc88.jpg">
<img v-lazy="https://images.djtest.cn/pic/test/751470f4b478450e8556f78cd7dd3d96.jpg">
</div>

作者:起风了啰
来源:juejin.cn/post/7316349850854752294
收起阅读 »

Android — DialogFragment显示后隐藏的导航栏显示问题

沉浸式显示广泛的应用于大部分App中,基本上可以说是App的必备功能,在之前的文章Android 全屏显示和沉浸式显示中介绍过如何通过WindowInsetsControllerCompat实现沉浸式显示。当然,我也将其应用到了我司的App中。但是在后续开发测...
继续阅读 »

沉浸式显示广泛的应用于大部分App中,基本上可以说是App的必备功能,在之前的文章Android 全屏显示和沉浸式显示中介绍过如何通过WindowInsetsControllerCompat实现沉浸式显示。当然,我也将其应用到了我司的App中。但是在后续开发测试的过程中发现了一个奇怪的现象,加载弹窗显示时,已经隐藏的底部导航栏又显示出来了。


问题复现


下面通过一段示例代码演示一下:



  • 加载弹窗


class LoadingDialogFragment : DialogFragment() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NORMAL, R.style.LoadingDialog)
}

override fun onCreateView(inflater: LayoutInflater, container: ViewGr0up?, savedInstanceState: Bundle?): View {
dialog?.let { containerDialog ->
containerDialog.window?.run {
setBackgroundDrawable(ContextCompat.getDrawable(requireContext(), android.R.color.transparent))
decorView.setBackgroundResource(android.R.color.transparent)
val layoutParams = attributes
layoutParams.width = DensityUtil.dp2Px(200)
layoutParams.height = DensityUtil.dp2Px(120)
layoutParams.gravity = Gravity.CENTER
attributes = layoutParams
}
containerDialog.setCancelable(true)
containerDialog.setCanceledOnTouchOutside(false)
}
return LayoutLoadingDialogBinding.inflate(layoutInflater, container, false).root
}
}


  • 示例页面


class DialogFragmentExampleActivity : AppCompatActivity() {

val DIALOG_TYPE_LOADING = "loadingDialog"

private lateinit var insetsController: WindowInsetsControllerCompat

private var alreadyChanged = false

private var callDismissDialogTime = 0L

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = LayoutDialogFragmentExampleActivityBinding.inflate(layoutInflater).also {
setContentView(it.root)
}

// 调整系统栏
WindowCompat.setDecorFitsSystemWindows(window, false)
insetsController = WindowCompat.getInsetsController(window, window.decorView).also {
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
it.hide(WindowInsetsCompat.Type.navigationBars())
}
window.statusBarColor = ContextCompat.getColor(this, android.R.color.transparent)
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, windowInsets ->
if (!alreadyChanged) {
alreadyChanged = true
windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()).run {
binding.topView.updateLayoutParams<ConstraintLayout.LayoutParams> { height += top }
binding.tvTitle.updateLayoutParams<ConstraintLayout.LayoutParams> { topMargin += top }
}
}
WindowInsetsCompat.CONSUMED
}

binding.btnShowLoadingDialog.setOnClickListener {
showLoadingDialog()
}
}

private fun showLoadingDialog() {
LoadingDialogFragment().run {
show(supportFragmentManager, DIALOG_TYPE_LOADING)
}
// 模拟耗时操作,两秒后关闭弹窗
lifecycleScope.launch(Dispatchers.IO) {
delay(2000)
dismissLoadingDialog()
}
}

private fun dismissLoadingDialog() {
callDismissDialogTime = System.currentTimeMillis()
lifecycleScope.launch(Dispatchers.IO) {
if (async { checkLoadingDialogStatue() }.await()) {
withContext(Dispatchers.Main) {
// 从supportFragmentManager中获取加载弹窗,并调用隐藏方法
(supportFragmentManager.findFragmentByTag(DIALOG_TYPE_LOADING) as? DialogFragment)?.run {
if (dialog?.isShowing == true) {
dismissAllowingStateLoss()
}
}
}
}
}
}

/**
* 检查加载弹窗的状态直到获取到加载弹窗或者超过时间
*/

private suspend fun checkLoadingDialogStatue(): Boolean {
return if (supportFragmentManager.findFragmentByTag(DIALOG_TYPE_LOADING) == null && System.currentTimeMillis() - callDismissDialogTime < 1500L) {
delay(100)
checkLoadingDialogStatue()
} else {
true
}
}
}

效果如图:


Screen_recording_202 -big-original.gif

解决显示异常问题


上述示例代码中,在示例页面的初始化方法中通过WindowInsetsControllerCompat对页面的WindowdecorView进行操作,隐藏了导航栏。但是在DialogFragment中,Dialog对象也有其所属的WindowdecorView,上述示例代码中并没有针对Dialog所属的WindowdecorView进行配置。


基于上面的分析,对示例代码进行调整,调整如下:



  • 加载弹窗


class LoadingDialogFragment : DialogFragment() {

......

override fun onCreateView(inflater: LayoutInflater, container: ViewGr0up?, savedInstanceState: Bundle?): View {
dialog?.let { containerDialog ->
containerDialog.window?.run {
WindowCompat.setDecorFitsSystemWindows(this, false)
WindowCompat.getInsetsController(this, decorView).also {
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
it.hide(WindowInsetsCompat.Type.navigationBars())
}
......
}
......
}
return LayoutLoadingDialogBinding.inflate(layoutInflater, container, false).root
}
}

修改后效果如图:


fix.gif

适配不同机型


通常来说,上述示例代码用的是官方的API,应该不会出现什么意外,然而还是出现了意外。公司的另一台三星的测试机跟我自己的测试机Pixel 3a XL效果差别很大。


三星测试机(SM-A515F)效果如下:


未调整调整后
Screen_recording_202 -big-original.gifScreen_recording_202 -big-original.gif

虽然这可能是安卓的通病,但对于这种情况我还是感到有些遗憾,通用API在不同厂商的手机上效果居然差这么多。虽然遗憾,但还是得解决问题。


根据效果图来看,对页面的配置生效了,对Dialog的配置也生效了,但是DialogFragment隐藏后重置了对页面的配置。最简单的处理就是在DialogFragment消失之后判断下导航栏是否显示,显示则隐藏。


调整代码如下:



  • 加载弹窗


class LoadingDialogFragment : DialogFragment() {

......

override fun onDestroyView() {
super.onDestroyView()
// 这里通过setFragmentResult API 来传递弹窗已经关闭的消息。
parentFragmentManager.setFragmentResult(DialogFragmentExampleActivity::class.java.simpleName, Bundle())
}
}


  • 示例页面


class DialogFragmentExampleActivity : AppCompatActivity() {

......

private var navigationBarShow = false

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = LayoutDialogFragmentExampleActivityBinding.inflate(layoutInflater).also {
setContentView(it.root)
}

supportFragmentManager.setFragmentResultListener(this::class.java.simpleName, this) { requestKey, result ->
// 接收加载弹窗关闭的消息
if (requestKey == this::class.java.simpleName) {
if (navigationBarShow) {
// 根据实践,不延迟500毫秒有概率出现无法隐藏的情况。
lifecycleScope.launch(Dispatchers.IO) {
delay(500L)
withContext(Dispatchers.Main) {
hideNavigationBar()
}
}
}
}
}

......

ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, windowInsets ->
windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()).run {
if (!alreadyChanged) {
alreadyChanged = true
binding.topView.updateLayoutParams<ConstraintLayout.LayoutParams> { height += top }
binding.tvTitle.updateLayoutParams<ConstraintLayout.LayoutParams> { topMargin += top }
}
// 当底部空间不为0时可以判断导航栏显示
navigationBarShow = bottom != 0
}
WindowInsetsCompat.CONSUMED
}

......
}

......

private fun hideNavigationBar() {
insetsController.hide(WindowInsetsCompat.Type.navigationBars())
}

override fun onDestroy() {
super.onDestroy()
// 页面销毁时清除监听
supportFragmentManager.clearFragmentResultListener(this::class.java.simpleName)
}
}

修改后效果如图:


Screen_recording_202 -big-original.gif

示例


演示代码已在示例Demo中添加。


ExampleDemo github


ExampleDemo gitee


作者:ChenYhong
来源:juejin.cn/post/7313742254145208356
收起阅读 »

网络七层模型快速理解和记忆

网络通信分解为七个逻辑层。称为 七层网络模型,也称为OSI(Open Systems Interconnection)模型,是国际标准化组织(ISO)为计算机和通信系统制定的一种框架,用于描述信息从一个设备传输到另一个设备的过程。每一层都有特定的功能和责任: ...
继续阅读 »

网络通信分解为七个逻辑层。称为 七层网络模型,也称为OSI(Open Systems Interconnection)模型,是国际标准化组织(ISO)为计算机和通信系统制定的一种框架,用于描述信息从一个设备传输到另一个设备的过程。每一层都有特定的功能和责任:






  1. 物理层



    • 负责数据的传输通路,包括电缆、光纤、无线电波等物理介质以及信号的电压、频率、比特率等物理特性。




  2. 数据链路层



    • 负责在两个相邻节点之间可靠地传输数据帧,包括错误检测、帧同步、地址识别以及介质访问控制(MAC)。




  3. 网络层



    • 负责将数据包从源主机传输到目标主机,通过IP地址进行寻址,并可能涉及路由选择和分组转发。




  4. 传输层



    • 提供端到端的数据传输服务,如TCP(传输控制协议)提供可靠的数据传输,UDP(用户数据报协议)提供无连接的数据传输。




  5. 会话层



    • 管理不同应用程序之间的通信会话,负责建立、维护和终止会话,以及数据的同步和复用。




  6. 表示层



    • 处理数据的格式、编码、压缩和解压缩,以及数据的加密和解密,确保数据在不同系统间具有正确的表示。




  7. 应用层



    • 提供直接与用户应用程序交互的服务,如HTTP、FTP、SMTP、DNS等协议,实现文件传输、电子邮件、网页浏览等功能。




快速理解和记忆七层网络模型(OSI模型)


可以借助以下方法:




  1. 口诀法



    • 可以使用一些助记口诀来帮助记忆各层的主要功能。例如:

      • "Please Do Not Tell Stupid People Anything",这个口诀的首字母对应了七层模型从下到上的名称:Physical、Data Link、Network、Transport、Session、Presentation、Application。

      • 或者使用其他你认为更容易记忆的口诀。






  2. 功能关联法







  • 将每一层的功能与日常生活中的例子或者已知的技术概念关联起来:

    • 物理层:想象这是网络的基础结构,如电线、光纤、无线信号等。

    • 数据链路层:思考如何在一条物理链路上确保数据帧的正确传输,如同一房间内两个人通过特定的握手方式传递信息。

    • 网络层:考虑路由器的工作,它们如何根据IP地址将数据包从一个网络转发到另一个网络。

    • 传输层:TCP和UDP协议,TCP如同邮政服务保证邮件送达,UDP如同广播消息不关心是否接收。

    • 会话层:想象两个用户在电话中建立通话的过程,包括建立连接、保持通信和断开连接。

    • 表示层:数据格式转换和加密解密,就像翻译将一种语言转换为另一种语言。

    • 应用层:各种应用程序如何通过网络进行交互,如浏览网页、发送电子邮件或文件传输。






  1. 层次结构可视化



    • 画出七层模型的图表,从下到上排列各层,并在每一层旁边标注其主要功能和相关协议。




  2. 实践理解



    • 通过学习和实践网络相关的技术,如配置网络设备、编程实现网络应用等,加深对各层功能的理解。




  3. 反复复习



    • 定期回顾和复习七层模型,随着时间的推移,对各层的理解和记忆会逐渐加深。




  4. 故事联想



    • 创建一个包含七层模型元素的故事,比如描述一个信息从发送者到接收者的完整旅程,每层都是故事中的一个关键环节。




通过这些方法的综合运用,相信我们可以更快地理解和记忆七层网络模型。当然啦,理解各层之间的关系和它们在整个通信过程中的作用是关键。




好了,今天的内容就到分享这里啦,很享受与大家一起学习,沟通交流问题,如果喜欢的话,请为我点个赞吧 !👍

plus: 最近在看工作机会,base 上海,有合适的前端岗位希望可以推荐一下啦!


作者:陳有味_ChenUvi
链接:https://juejin.cn/post/7315720126988058639
收起阅读 »

Android Room 数据库的坑

1.Android Room 数据库的坑 在用Room数据库的时候 发现有需要一个字段的条件合到一起去写这个SQL @Query("SELECT * FROM 表名 WHERE 字段A = '1' and 字段B <= :Time " + ...
继续阅读 »

1.Android Room 数据库的坑


在用Room数据库的时候 发现有需要一个字段的条件合到一起去写这个SQL


 @Query("SELECT * FROM 表名 WHERE 字段A = '1' and 字段B <= :Time " +
"and 字段B >= :Time and 字段C <= :BTime and 字段C >= :BTime " +
"and '(' || 字段D is null or 字段D = '' || ')'")

List selectList(String Time, String BTime);


这里面的 “ ||” 是Room里面独特的表达方式 是替代了java里面的“+”号


正常在android中 使用 是这样的


String sql = "SELECT * FROM 表名 WHERE 字段A = '1' and 字段B <= "+传入的参数+" " +
"and 字段B >= "+传入的参数+" and 字段C <= "+传入的参数+" and 字段c >= "+传入的参数+" " +
"and '(' "+" 字段D is null or 字段D = '' "+" ')'"


cursor = db.rawQuery(sql, null);

而在Room 中 用 “||” 代替了 “+”


2.Android Room 查询语句的坑


@Query("SELECT * FROM 表名 WHERE 字段A = '0' order by id desc")
List selectList();

假如你正在查询一张表的面的内容,然后忽然跑出来一个异常



# [Android RoomDatabase Cruash "Cursor window allocation of 4194304 bytes failed"](https://stackoverflow.com/questions/75456123/android-roomdatabase-cruash-cursor-window-allocation-of-4194304-bytes-failed)

奔溃日志:


android.database.CursorWindowAllocationException: Could not allocate CursorWindow '/data/user/0/cn.xxx.xxx/databases/xxx.db' of size 2097152 due to error -13.
at android.database.CursorWindow.nativeCreate(Native Method)
at android.database.CursorWindow.<init>(CursorWindow.java:139)
at android.database.CursorWindow.<init>(CursorWindow.java:120)
at android.database.AbstractWindowedCursor.clearOrCreateWindow(AbstractWindowedCursor.java:202)
at android.database.sqlite.SQLiteCursor.fillWindow(SQLiteCursor.java:147)
at android.database.sqlite.SQLiteCursor.getCount(SQLiteCursor.java:140)
at yd.d.m(SourceFile:21)
at cn.xxx.control.y.y0(SourceFile:1)
at e5.y.p(SourceFile:230)
at e5.y.l(SourceFile:1)
at e5.y.E(SourceFile:1)
at cn.xxx.cluster.classin.viewmodel.SessionViewModel$d.invokeSuspend(SourceFile:42)

触发原因



  • Room 对应的 Sqlite 数据库,其对 CursorWindows 分配的大小是有限制的,最大为 2M,超过之后会发生上述崩溃闪退现象(偶现且难以复现的 bug


解决方法


需要业务方梳理这块的业务,优化数据库的调用,如果明确知道在一个方法里面会调用多个数据库的方法,需要让 controller 提供新的方法,且这个 controller 层的方法需要添加 @Transaction 进行注解,从而保证在同一个事物内进行数据库操作,以此避免 CursorWindows 大小超过 2M


那么问题来了 @Transaction 这个注解是干嘛的呢


翻译 事务的意思


@Transaction
@Query("SELECT * FROM 表名 WHERE 字段A = '0' order by id desc")
List selectList();

接着 问题完美解决


大家学“废”了嘛 学费的评论区Q1 没学“废”的抠眼珠子


作者:笨qiao先飞
来源:juejin.cn/post/7273674981959745593
收起阅读 »

这一年遇到的奇怪bug

web
position sticky 失效 在 Iphone6 plus 上使用 position sticky 不生效 解决办法: position: sticky; position: -webkit-sticky; // 兼容写法需要写在下面 参...
继续阅读 »

position sticky 失效



在 Iphone6 plus 上使用 position sticky 不生效




解决办法:



position: sticky;  
position: -webkit-sticky; // 兼容写法需要写在下面

参考 position sticky 失效 – 有点另类的写法


new Date().toLocaleDateString() 获取当前的日期字符串无效



当系统语言是新加坡英语的时候,使用这个方法获取当前的日期字符串会出现 Invalid Date,toLocaleDateString 是有两个参数的,不指定语言就会出现这个问题,而且只在手机上出现,不太好排查,new Date().toLocaleDateString('en-Us') 调用的时候指定语言就没问题了;



参考 Date.prototype.toLocaleDateString()


两行溢出显示省略号但是部分手机上出现第三行截断痕迹


image.png



例如设置了高度为36px,line-height 18px,但是出现了第三行截断痕迹,应该是文字 baseline 的对其方式问题,试着设置 vertical-align 也不行。解决办法就是不给文字的盒子设置高度,如果一定要个高度兜底,可以在文字的盒子再套一个盒子,在套的那个盒子设置高度。



泰文字体文本溢出隐藏,但是第二行出现截断痕迹



原因,应该是泰语的字体行高要求比较高,暂时的解决办法:加高文本行高



useEffect 首次获取 dom 的 clientHeight 不对



初步感觉是因为 css 样式加载慢了,导致第一次获取到的高度是没有样式的高度,而且又是偶现的;所以在这个组件或者 hooks 重新 render 的时候去获取高度,如果获取到最新的高度发生变化,去同步修改 state 保存的高度。



import { useEffect, useState } from "react";
export default function useTop(){
const [top, setTop]=useState(0);
const [bodyHeight, setBodyHeight] = useState(document.body.clientHeight);
const newestTop = (document.getElementById('nav-header')?.clientHeight || 0) - 1;

if (newestTop !== top) { // nav header height may change
setTop(newestTop);
setBodyHeight(document.body.clientHeight - (newestTop + 1));
}

useEffect(() => {
const nav = document.getElementById('nav-header');
const navHeight = nav?.clientHeight ?? 0;
setTop(navHeight-1);
setBodyHeight(document.body.clientHeight - navHeight);
}, []);
return {top, bodyHeight}
}

一个页面中有两个滚动条,两个滚动条几乎同时触发滚动条的滚动方法,后执行的不生效



两个滚动条,一个使用 scrollBy 方法,另一个使用 scrollIntoView 方法,behavior 属性都为 smooth,这个属性会让滚动条平滑移动,导致滚动条事件一直在触发状态,另一个滚动方法就执行不了了。解决方法:让先执行的方法 behavior 属性为 auto;或者在第一个滚动条结束之后再执行第二个滚动条的方法,可以让第二个方法 setTimeout 100ms 左右,不能超过 300ms,否则用户会感觉卡顿。



iphone6 手机上横向或者纵向滑动不了



原因,可能是dom结构问题,导致低端ios机型没有识别到生成滚动条,导致不能滚动,android 和其他 ios 机型正常;



<div className="list-tabs-wrap">
<div
className="list-tabs"
>

<div className="tab-item">tab1</div>
<div className="tab-item">tab2</div>
<div className="tab-item">tab3</div>
</div>
</div>

.list-tabs-wrap {
width: 100%;
background-color: #fff;
overflow: hidden;
}
.list-tabs {
overflow-x: scroll;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
height: 50px;
background-color: #fff;
}
.list-tabs::-webkit-scrollbar {
display: none;
background-color: transparent;
color: transparent;
width: 0;
height: 0;
}
.tab-item {
width: 50vw;
}


解决办法,新增一个 container 结构,container dom 宽度为 max-content,overflow 拆开写



<div className="list-tabs-wrap">
<div
className="list-tabs"
>

<div className="list-tabs-container">
<div className="tab-item">tab1</div>
<div className="tab-item">tab2</div>
<div className="tab-item">tab3</div>
</div>
</div>
</div>

.list-tabs-container {
overflow-x: scroll; // overflow 拆开写
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
width: max-content; // 纵向设置 height
}


用上面方法解决 iPhone6 滚动条问题后,又出现一个滚动条隐藏样式不生效的问题;解决办法,设置一个外层的盒子,固定高度然后 overflow: hidden,需要滚动的盒子加一个 padding-bottom: 10px,padding 大小看着改,能放下一个滚动条就可以,这样滚动条会出现在padding里,然后又因为外层盒子overflow: hidden 了,所以滚动条和padding都看不到了;愿世界再无 iphone6.



在 Android webview 中,window.location.reload 和 replace 失效


const reload = () => {
const timeStamp = new Date().getTime();
const oldUrl = window.location.href;
const url = `${oldUrl}${oldUrl.includes('?') ? '&' : '?'}timeStamp=${timeStamp}`;
window.location.href = url;
};
const locationReplace = (url) => {
if(history.replaceState) {
history.replaceState(null, document.title, url);
history.go(0);
} else {
location.replace(url);
}
};


部分安卓手机把请求参数的字符串中间的空格转义成+号



发现在 谷歌 Pixel 3 XL 手机上,会把请求参数的字符串中间的空格转义成+号,比如 '[{"filterField":"accommodationType","value":"Hotel,Entire apartment"}]' => '[{"filterField":"accommodationType","value":"Hotel,Entire+apartment"}]'。调试了下,发现在发起请求前参数打印是正常的,是浏览器在请求的时候在请求体中字段转义的。不过好像对后端的搜索结果并不影响,所以这里就没有改动。




解决办法,对字符串encode下,后端收到参数后再decode。



ios 17 input 聚焦页面出现抖动


解决办法: input focus 给 body 添加 height: 100vh; overflow: hidden; 样式。input blur 取消 focus 添加的样式。


作者:wait
来源:juejin.cn/post/7309040097936343103
收起阅读 »

注意力是最珍贵的资源

不得不承认的是很多时候我的生活都是被动的。 早上起来,洗漱完毕,吃早餐,然后挤地铁上班。我日常习惯背书包,那种双肩的,然后里面习惯性的放着两本书。但是早上上班坐地铁是基本不会看的:北京的早高峰人太多。 所以大部分时候,如果早高峰还有一些空间的话,会拿起手机看一...
继续阅读 »

不得不承认的是很多时候我的生活都是被动的。


早上起来,洗漱完毕,吃早餐,然后挤地铁上班。我日常习惯背书包,那种双肩的,然后里面习惯性的放着两本书。但是早上上班坐地铁是基本不会看的:北京的早高峰人太多。


所以大部分时候,如果早高峰还有一些空间的话,会拿起手机看一些资讯。那种碎片化的资讯,每天的资讯之间看似有关联,实际自己不能去建立关联。最后总结看资讯最大的意义是为了打发时间。


到了公司,打卡之后开始上班。上班没有那么忙也没有那么清闲。有的时候开发上遇到了难题会停下来看下手机,我后来管这个叫分散注意力,觉得这样是有益于开发的。


在公司中午休息时间,如果吃饭会一边吃饭一边看剧。或者不吃饭情况也会看剧。时间就这样来到下午,然后到下班。


回程的地铁偶尔会看看书,那本已经读过一遍的书。这里说个题外话,好书还是要多读几遍的,每隔一段时间读一次总会有不同的收获。


以上,围绕着上班建立了不少被动的东西。


如果是周六日,平时不怎么出去,会在家看看书,会琢磨着做点有意义的事情,会看剧、刷手机。


上班、副业、投资这是我给自己规划的三个路线。自己的时间主要放在这三方面。但随着时间演变,发现很多时候都忘记了下一步该怎么做,怎么做才能让该路线有所提高。


在《财富自由之路》这本书里面作者提到,注意力>成长>成功。注意力是最珍贵的且是稀缺性的资源。


在我切换每条路线时,时常忘了这条路线最初是怎么规划的。所以我后来买了不少笔记本,那种30页的笔记本。将每个路线的基本规划都记下来,时常回顾,避免遗忘。


注意力是最珍贵的资源,不仅仅是对自己。对商业也是如此,时下流行的抖音快手都是注意力经济学,都争夺的是用户的注意力。


我觉得应该把注意力应用在最为重要的领域活方面,即使现在找不到最重要的领域或者暂时找不到该怎么去做,也应该尽力去做,坚持去做。


我建议也可以买很多本将当前的思考记录下来,日日记,周周记,月月记,终会认识自己,认识当下的状况。有了一定的精确性才会有下一步的规划。


注意力是最珍贵的资源,是成长和成功的基石。


(本文完)


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

产品经理:实现一个微信输入框

web
近期在开发AI对话产品的时候为了提升用户体验增强了对话输入框的相关能力,产品初期阶段对话框只是一个单行输入框,导致在文本内容很多的时候体验很不好,所以进行体验升级,类似还原了微信输入框的功能(只是其中的一点点哈🤏)。 初期认为这应该改动不大,就是把input换...
继续阅读 »


近期在开发AI对话产品的时候为了提升用户体验增强了对话输入框的相关能力,产品初期阶段对话框只是一个单行输入框,导致在文本内容很多的时候体验很不好,所以进行体验升级,类似还原了微信输入框的功能(只是其中的一点点哈🤏)。


初期认为这应该改动不大,就是把input换成textarea吧。但是实际开发过程发现并没有这么简单,本文仅作为开发过程的记录,因为是基于uniapp开发,相关实现代码都是基于uniapp


简单分析我们大概需要实现以下几个功能点:



  • 默认单行输入

  • 可多行输入,但有最大行数限制

  • 超过限制行术后内容在内部滚动

  • 支持回车发送内容

  • 支持常见组合键在输入框内换行输入

  • 多行输入时高度自适应 & 页面整体自适应


单行输入


默认单行输入比较简单直接使用input输入框即可,使用textarea的时候目前的实现方式是通过设置行内样式的高度控制,如我们的行内高度是36px,那么就设置其高度为36px。为什么要通过这种方式设置呢?因为要考虑后续多行输入超出最大行数的限制,需要通过高度来控制textarea的最大高度。


<textarea style="{ height: 36px }" />


多行输入


多行输入核心要注意的就是控制元素的高度,因为不能随着用户的输入一直增加高度,我们需要设置一个最大的行数限制,超出限制后就不再增加高度,内容可以继续输入,可以在输入框内上下滚动查看内容。


这里需要借助于uniapp内置在textarea@linechange事件,输入框行数变化时调用并回传高度和行数。如果不使用uniapp则需要对输入文字的长度及当前行高计算出对应的行数,这种情况还需要考虑单行文本没有满一行且换行的情况。


代码如下,在linechange事件中获取到最新的高度设置为textarea的高度,当超出最大的行数限制后则不处理。


linechange(event) {
const { height, lineCount } = event.detail
if (lineCount < maxLine) {
this.textareaHeight = height
}
}

这是正常的输入,还有一种情况是用户直接粘贴内容输入的场景,这种时候不会触发@linechange事件,需要手动处理,根据粘贴文本后的textarea的滚动高度进行计算出对应的行数,如超出限制行数则设置为最大高度,否则就设置为实际的行数所对应的高度。代码如下:


const paddingTop = parseInt(getComputedStyle(textarea).paddingTop);
const paddingBottom = parseInt(getComputedStyle(textarea).paddingBottom);
const textHeight = textarea.scrollHeight - paddingTop - paddingBottom;
const numberOfLines = Math.floor(textHeight / lineHeight);

if (numberOfLines > 1 && this.lineCount === 1) {
const lineCount = numberOfLines < maxLine ? numberOfLines : maxLine
this.textareaHeight = lineCount * lineHeight
}

键盘发送内容


正常我们使用电脑聊天时发送内容都是使用回车键发送内容,使用ctrlshiftalt等和回车键的组合键将输入框的文本进行换行处理。所以接下来要实现的就是对键盘事件的监听,基于事件进行发送内容和内容换行输入处理。


首先是事件的监听,uniapp不支持keydown的事件监听,所以这里使用了原生JS做监听处理,为了避免重复监听,对每次开始监听前先进行移除事件的监听,代码如下:


this.$refs.textarea.$el.removeEventListener('keydown', this.textareaKeydownHandle)
this.$refs.textarea.$el.addEventListener('keydown', this.textareaKeydownHandle)

然后是对textareaKeydownHandle方法的实现,这里需要注意的是组合键对内容换行的处理,需要获取到当前光标的位置,使用textarea.selectionStart可获取,基于光标位置增加一个换行\n的输入即可实现换行,核心代码如下:


const cursorPosition = textarea.selectionStart;
if(
(e.keyCode == 13 && e.ctrlKey) ||
(e.keyCode == 13 && e.metaKey) ||
(e.keyCode == 13 && e.shiftKey) ||
(e.keyCode == 13 && e.altKey)
){
// 换行
this.content = `${this.content.substring(0, cursorPosition)}\n${this.content.substring(cursorPosition)}`
}else if(e.keyCode == 13){
// 发送
this.onSend();
e.preventDefault();
}

高度自适应


当多行输入内容时输入框所占据的高度增加,导致页面实际内容区域的高度减小,如果不进行动态处理会导致实际内容会被遮挡。如下图所示,红色区域就是需要动态处理的高度。



主要需要处理的场景就是输入内容行数变化的时候和用户粘贴文本的时候,这两种情况都会基于当前的可视行数计算输入框的高度,那么内容区域的高度就好计算了,使用整个窗口的高度减去输入框的高度和其他固定的高度如导航高度和底部安全距离高度即是真实内容的高度。


this.contentHeight = this.windowHeight - this.navBarHeight - this.fixedBottomHeight - this.textareaHeight;

最后


到此整个输入框的体验优化核心实现过程就结束了,增加了多行输入,组合键换行输入内容,键盘发送内容,整体内容高度自适应等。整体实现过程的细节功能点还是比较多,有实现过类似需求的同学欢迎留言交流~


看完本文如果觉得有用,记得点个赞支持,收藏起来说不定哪天就用上啦~


专注前端开发,分享前端相关技术干货,公众号:南城大前端(ID: nanchengfe)


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

动态代理在Android中的运用

动态代理是一种在编程中非常有用的设计模式,它允许你在运行时创建一个代理对象来代替原始对象,以便在方法调用前后执行额外的逻辑。在Android开发中,动态代理可以用于各种用例,如性能监控、AOP(面向切面编程)和事件处理。本文将深入探讨Android动态代理的原...
继续阅读 »

动态代理是一种在编程中非常有用的设计模式,它允许你在运行时创建一个代理对象来代替原始对象,以便在方法调用前后执行额外的逻辑。在Android开发中,动态代理可以用于各种用例,如性能监控、AOP(面向切面编程)和事件处理。本文将深入探讨Android动态代理的原理、用途和实际示例。


什么是动态代理?


动态代理是一种通过创建代理对象来代替原始对象的技术,以便在方法调用前后执行额外的操作。代理对象通常实现与原始对象相同的接口,但可以添加自定义行为。动态代理是在运行时生成的,因此它不需要在编译时知道原始对象的类型。


动态代理的原理


动态代理的原理涉及两个关键部分:



  1. InvocationHandler(调用处理器):这是一个接口,通常由开发人员实现。它包含一个方法 invoke,在代理对象上的方法被调用时会被调用。在 invoke 方法内,你可以定义在方法调用前后执行的逻辑。

  2. Proxy(代理类):这是Java提供的类,用于创建代理对象。你需要传递一个 ClassLoader、一组接口以及一个 InvocationHandlerProxy.newProxyInstance 方法,然后它会生成代理对象。


下面是一个示例代码,演示了如何创建一个简单的动态代理:


import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
import java.lang.reflect.Proxy

// 接口
interface MyInterface {
fun doSomething()
}

// 实现类
class MyImplementation : MyInterface {
override fun doSomething() {
println("Original method is called.")
}
}

// 调用处理器
class MyInvocationHandler(private val realObject: MyInterface) : InvocationHandler {
override fun invoke(proxy: Any, method: Method, args: Array<Any?>?): Any? {
println("Before method is called.")
val result = method.invoke(realObject, *(args ?: emptyArray()))
println("After method is called.")
return result
}
}

fun main() {
val realObject = MyImplementation()
val proxyObject = Proxy.newProxyInstance(
MyInterface::class.java.classLoader,
arrayOf(MyInterface::class.java),
MyInvocationHandler(realObject)
) as MyInterface

proxyObject.doSomething()
}

运行上述代码会输出:


Before method is called.
Original method is called.
After method is called.

这里,MyInvocationHandler 拦截了 doSomething 方法的调用,在方法前后添加了额外的逻辑。


Android中的动态代理


在Android中,动态代理通常使用Java的java.lang.reflect.Proxy类来实现。该类允许你创建一个代理对象,该对象实现了指定接口,并且可以拦截接口方法的调用以执行额外的逻辑。在Android开发中,常见的用途包括性能监控、权限检查、日志记录和事件处理。


动态代理的用途


性能监控


你可以使用动态代理来监控方法的执行时间,以便分析应用程序的性能。例如,你可以创建一个性能监控代理,在每次方法调用前记录当前时间,然后在方法调用后计算执行时间。


import android.util.Log

class PerformanceMonitorProxy(private val target: Any) : InvocationHandler {
override fun invoke(proxy: Any, method: Method, args: Array<Any?>?): Any? {
val startTime = System.currentTimeMillis()
val result = method.invoke(target, *(args ?: emptyArray()))
val endTime = System.currentTimeMillis()
val duration = endTime - startTime
Log.d("Performance", "${method.name} took $duration ms to execute.")
return result
}
}

AOP(面向切面编程)


动态代理也是AOP的核心概念之一。AOP允许你将横切关注点(如日志记录、事务管理和安全性检查)从业务逻辑中分离出来,以便更好地维护和扩展代码。通过创建适当的代理,你可以将这些关注点应用到多个类和方法中。


事件处理


Android中常常需要处理用户界面上的各种事件,例如点击事件、滑动事件等。你可以使用动态代理来简化事件处理代码,将事件处理逻辑从Activity或Fragment中分离出来,使代码更加模块化和可维护。


实际示例


下面是一个简单的示例,演示了如何在Android中使用动态代理来处理点击事件:


import android.util.Log
import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
import java.lang.reflect.Proxy
import android.view.View

class ClickHandlerProxy(private val target: View.OnClickListener) : InvocationHandler {
override fun invoke(proxy: Any, method: Method, args: Array<Any?>?): Any? {
if (method.name == "onClick") {
Log.d("ClickHandler", "Click event intercepted.")
// 在事件处理前可以执行自定义逻辑
}
return method.invoke(target, *args.orEmpty())
}
}

// 使用示例
val originalClickListener = View.OnClickListener {
// 原始的点击事件处理逻辑
}

val proxyClickListener = Proxy.newProxyInstance(
originalClickListener::class.java.classLoader,
originalClickListener::class.java.interfaces,
ClickHandlerProxy(originalClickListener)
) as View.OnClickListener

button.setOnClickListener(proxyClickListener)

通过这种方式,你可以在原始的点击事件处理逻辑前后执行自定义逻辑,而无需修改原始的OnClickListener实现。


结论


动态代理是Android开发中强大的工具之一,它允许你在不修改原始对象的情况下添加额外的行为。在性能监控、AOP和事件处理等方面,动态代理都有广泛的应用。通过深入理解动态代理的原理和用途,你可以更好地设计和维护Android应用程序。


推荐


android_startup: 提供一种在应用启动时能够更加简单、高效的方式来初始化组件,优化启动速度。不仅支持Jetpack App Startup的全部功能,还提供额外的同步与异步等待、线程控制与多进程支持等功能。


AwesomeGithub: 基于Github的客户端,纯练习项目,支持组件化开发,支持账户密码与认证登陆。使用Kotlin语言进行开发,项目架构是基于JetPack&DataBinding的MVVM;项目中使用了Arouter、Retrofit、Coroutine、Glide、Dagger与Hilt等流行开源技术。


flutter_github: 基于Flutter的跨平台版本Github客户端,与AwesomeGithub相对应。


android-api-analysis: 结合详细的Demo来全面解析Android相关的知识点, 帮助读者能够更快的掌握与理解所阐述的要点。


daily_algorithm: 每日一算法,由浅入深,欢迎加入一起共勉。


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

Android:自定义View实现签名带笔锋效果

自定义签名工具相信大家都轻车熟路,通过监听屏幕onTouchEvent事件,分别在按下(ACTION_DOWN)、抬起(ACTION_UP)、移动(ACTION_MOVE) 动作中处理触碰点的收集和绘制,很容易就达到了手签笔的效果。其中无非又涉及到线条的撤回、...
继续阅读 »

自定义签名工具相信大家都轻车熟路,通过监听屏幕onTouchEvent事件,分别在按下(ACTION_DOWN)抬起(ACTION_UP)移动(ACTION_MOVE) 动作中处理触碰点的收集和绘制,很容易就达到了手签笔的效果。其中无非又涉及到线条的撤回、取消、清除、画笔的粗细,也就是对收集的点集合和线集合的增删操作以及画笔颜色宽的的更改。这些功能都在 实现一个自定义有限制区域的图例(角度自识别)涂鸦工具类(上) 中介绍过。


但就在前不久遇到一个需求是要求手签笔能够和咱们使用钢笔签名类似的效果,当然这个功能目前是有一些公司有成熟的SDK的,但我们的需求是要不借助SDK,自己实现笔锋效果。那么,如何使画笔带笔锋呢?废话不多说,先上效果图:


image.png


要实现笔锋效果我们需要考虑几个因素:笔速笔宽按压力度(针对手写笔)。因为在onTouchEvent回调的次数是不变的,一旦笔速变快两点之间距离就被拉长。此时的笔宽不能保持在上一笔的宽度,需要我们通过计算插入新的点,同时计算出对应点的宽度。同理当我们笔速慢的时候,需要通过计算删除信息相近的点。要想笔锋自然,当然贝塞尔曲线是必不可少的。


这里我们暂时没有将笔的按压值作为笔宽的计算,仅仅通过笔速来计算笔宽。


/**
* 计算新的宽度信息
*/

public double calcNewWidth(double curVel, double lastVel,double factor) {
double calVel = curVel * 0.6 + lastVel * (1 - 0.6);
double vfac = Math.log(factor * 2.0f) * (-calVel);
double calWidth = mBaseWidth * Math.exp(vfac);
return calWidth;
}

/**
* 获取点信息
*/

public ControllerPoint getPoint(double t) {
float x = (float) getX(t);
float y = (float) getY(t);
float w = (float) getW(t);
ControllerPoint point = new ControllerPoint();
point.set(x, y, w);
return point;
}

/**
* 三阶曲线的控制点
*/

private double getValue(double p0, double p1, double p2, double t) {
double a = p2 - 2 * p1 + p0;
double b = 2 * (p1 - p0);
double c = p0;
return a * t * t + b * t + c;
}

最后也是最关键的地方,不再使用drawLine方式画线,而是通过drawOval方式画椭圆。通过前后两点计算出椭圆的四个点,通过笔宽计算出绘制椭圆的个数并加入椭圆集。最后在onDraw方法中绘制。


/**
* 两点之间将视图收集的点转为椭圆矩阵 实现笔锋效果
*/
public static ArrayList<SvgPointBean> twoPointsTransRectF(double x0, double y0, double w0, double x1, double y1, double w1, float paintWidth, int color) {

ArrayList<SvgPointBean> list = new ArrayList<>();
//求两个数字的平方根 x的平方+y的平方在开方记得X的平方+y的平方=1,这就是一个园
double curDis = Math.hypot(x0 - x1, y0 - y1);
int steps;
//绘制的笔的宽度是多少,绘制多少个椭圆
if (paintWidth < 6) {
steps = 1 + (int) (curDis / 2);
} else if (paintWidth > 60) {
steps = 1 + (int) (curDis / 4);
} else {
steps = 1 + (int) (curDis / 3);
}
double deltaX = (x1 - x0) / steps;
double deltaY = (y1 - y0) / steps;
double deltaW = (w1 - w0) / steps;
double x = x0;
double y = y0;
double w = w0;

for (int i = 0; i < steps; i++) {
RectF oval = new RectF();
float top = (float) (y - w / 2.0f);
float left = (float) (x - w / 4.0f);
float right = (float) (x + w / 4.0f);
float bottom = (float) (y + w / 2.0f);
oval.set(left, top, right, bottom);
//收集椭圆矩阵信息
list.add(new SvgPointBean(oval, color));
x += deltaX;
y += deltaY;
w += deltaW;
}

return list;
}

至此一个简单的带笔锋的手写签名就实现了。 最后附上参考链接Github.


我是一个喜爱Jay、Vae的安卓开发者,喜欢结交五湖四海的兄弟姐妹,欢迎大家到沸点来点歌!


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

这是你们项目中WebView的样子吗?

这是你们项目中WebView的样子吗? 作者简介:Serpit,Android开发工程师,2023年加入37手游技术部,目前负责国内游戏发行 Android SDK 开发。 前言 开始前先问大家一个问题,你们项目中或者理想中的WebView的使用姿势是如何的?...
继续阅读 »

这是你们项目中WebView的样子吗?


作者简介:Serpit,Android开发工程师,2023年加入37手游技术部,目前负责国内游戏发行 Android SDK 开发。


前言


开始前先问大家一个问题,你们项目中或者理想中的WebView的使用姿势是如何的?有哪些规范、功能?都可以在下方评论中说出来大家讨论一下。下面正式开始介绍我们对于一个WebView使用的一些理解。


可监控


可监控是线上项目很重要的一个功能,监控的东西可以是用户体验相关数据、加载情况数据、报错等。那么,如何监控呢?这里分两点提下。


加载时间


利用WebViewClient的onPageStartedonPageFinished回调,但是注意,这两回调在某些情况下会回调多次,比如在发生重定向时候,会有多次的onPageStarted回调出现,这就需要用一些标记位或者拦截等手段来保证统计到最开始的onPageStarted和最后的onPageFinished之间的耗时。


这里贴上一段伪代码


 @Override
public void onPageStarted(WebView webView, String url, Bitmap favicon) {
super.onPageStarted(webView, url, favicon);
if (TextUtils.isEmpty(url)) {
return;
}
consumeMap.put(getKey(url), SystemClock.uptimeMillis());
}

@Override
public void onPageFinished(WebView webView, String url) {
super.onPageFinished(webView, url);
if (TextUtils.isEmpty(url)) {
return;
}
Long loadConsuming = consumeMap.remove(getKey(url));
if (loadConsuming == null) {
return;
}
trackWebLoadFinish(url, SystemClock.uptimeMillis() - loadConsuming); //记录耗时,埋点
}

报错监控


报错这一块可以使用WebViewClient等一系列回调,诸如onReceivedErroronReceivedHttpErroronReceivedSslError这几个。只要在这几个方法中加上相应的埋点日志即可。但同时有注意的点是onReceivedError这个方法会有些报错是无用的,不影响用户使用,需要进行过滤。这里可以参考网上的一些方法做以下处理



  • 加载失败的url跟WebView里的url不是同一个url,过滤

  • errorCode=-1,表明是ERROR_UNKNOWN的错误,为了保证不误判,过滤

  • failingUrl=null&errorCode=-12,由于错误的url是空而不是ERROR_BAD_URL,过滤


除了这些常规的,还有一个是使用onConsoleMessage,去监控前端的错误日志,发现到前端的一些报错~也是一个不错的方向。


与前端的交互


与前端的交互,这个方式相信在网上随便一搜“Android与JS相互通信”等关键词,就可以搜出一大堆。在Android侧需要注册一个JavascriptInterface,里面定义各个方法,然后前端就可以调用到。然后需要调用到前端的函数,则利用WebView的evaluateJavascript方法即可。这些都比较基础,这里就不展开说,不清楚的同学可以用搜索引擎搜索一下~


这里想说的点其实是,在项目中如果简单定义JavascriptInterface可能会有“风险”。想象一个场景,定义了一个getToken方法,给到前端获取token方法,用于获取用户信息,这是一个很常见的方法。但是,重点来了,假如应用加载了一个外部的“恶意网站”,调用了这个getToken方法,就造成了信息泄漏。在安全上就出现了问题。


那么,有什么思路可以限制一下呢?有同学可能会想到,“混淆”把接口方法换成一些奇奇怪怪的方法,但这也太不利于代码可读性了吧。那这里可以提供一个思路,就是“鉴权拦截”。如何做?


先上代码


private class WebViewJsInterface {
@JavascriptInterface
public void callAndroid(final String method, final String params) {
boolean result = intercept(method, params); //拦截方法
if (!result){
dispatcher.callAndroid(method, params);
}
}
}

这里想说的是,可以在项目里面使用统一调度的方式,前端调用安卓的入口只有一个,然后通过传入方法名来分发到不同方法上,这样的好处是,可以做到统一,统一的目的也是为了做拦截!在拦截上,我们就可以做很多文章,诸如域名校验,白名单上的域名直接不让调用原生方法。这样可以增加一定的安全性。


关于WebView的一些使用封装思路


我们知道WebView的灵魂其实有三个部分



  • WebView.getSetting()的设置

  • WebViewClient

  • WebChromeClient


我们做的很多功能都是基于着这三者来实现,那么在代码中,很多项目或者同学都是直接封装一个BaseWebViewClient,然后在里面做一堆逻辑处理,比如上面说的监控方法,或者loading操作等。这么做没有说不好,但是会由于业务的日益拓展,会使得这个“Base”日益臃肿,变得难以维护。在经历过这个阶段的我们,也想出了一个办法去优化。那就是用拦截器的思路,架构图如下:


image.png


这里,由于WebView不可以设置多个Client,那么就使用拦截器,将WebViewClient和WebChromeClient所有方法都封装起来,分发出去,每一个拦截器都负责自己的功能即可。比如实现一个loading的逻辑:


public class ProgressWebHook extends WebHook {

private final IWebViewLoading mWebViewLoading;

public ProgressWebHook(IWebViewLoading loading) {
this.mWebViewLoading = loading;
}

@Override
public void onPageStarted(WebView webView, String url, Bitmap favicon) {
super.onPageStarted(webView, url, favicon);
startLoading();
}

@Override
public void onPageFinished(WebView webView, String url) {
super.onPageFinished(webView, url);
stopLoading();
}

@Override
public void onReceivedError(WebView webView, String url, int errorCode, String description) {
super.onReceivedError(webView, url, errorCode, description);
stopLoading();
}

@Override
public void onProgressChanged(WebView webView, int newProgress) {
super.onProgressChanged(webView, newProgress);
mWebViewLoading.onProgress(getContext(), newProgress);
}
}

这样是不是简洁很多。再比如监控的时候,也实现一个WebHook,只负责监控相关的方法重写即可。就可以将这些方法不同功能方法隔离在不同的类中,方便解耦。至此,也会有同学想看下如何实现的拦截器,那这里也简单给大家看下。



public class BaseWebChromeClient extends WebChromeClient {

private final WebHookDispatcher mWebHookDispatcher;

public BaseWebChromeClient(WebHookDispatcher webHookDispatcher) {
this.mWebHookDispatcher = webHookDispatcher;
}

@Override
public void onPermissionRequest(PermissionRequest request) {
mWebHookDispatcher.onPermissionRequest(request);
}

@Override
public void onReceivedTitle(WebView view, String title) {
super.onReceivedTitle(view, title);
mWebHookDispatcher.onReceivedTitle(view, title);
}

@Override
public void onProgressChanged(WebView view, int newProgress) {
super.onProgressChanged(view, newProgress);
mWebHookDispatcher.onProgressChanged(view, newProgress);
}

// For Android >= 5.0
@Override
public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback,
FileChooserParams fileChooserParams)
{
return mWebHookDispatcher.onShowFileChooser(webView, filePathCallback, fileChooserParams);
}

@Override
public boolean onConsoleMessage(ConsoleMessage consoleMessage) {
mWebHookDispatcher.onConsoleMessage(consoleMessage);
return super.onConsoleMessage(consoleMessage);
}

@Override
public boolean onJsAlert(WebView view, String url, String message, JsResult result) {
if (mWebHookDispatcher.onJsAlert(view, url, message, result)) {
return true;
}
return super.onJsAlert(view, url, message, result);
}

@Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
if (mWebHookDispatcher.onJsPrompt(view, url, message, defaultValue, result)) {
return true;
}
return super.onJsPrompt(view, url, message, defaultValue, result);
}

@Override
public boolean onJsBeforeUnload(WebView view, String url, String message, JsResult result) {
if (mWebHookDispatcher.onJsBeforeUnload(view, url, message, result)) {
return true;
}
return super.onJsBeforeUnload(view, url, message, result);
}

@Override
public void onShowCustomView(View view, CustomViewCallback callback) {
super.onShowCustomView(view, callback);
mWebHookDispatcher.onShowCustomView(view, callback);
}

@Override
public void onHideCustomView() {
super.onHideCustomView();
mWebHookDispatcher.onHideCustomView();
}
}


拦截分发代码如下:



public class WebHookDispatcher extends SimpleWebHook {

/**
* 因为shouldInterceptRequest是一个异步的回调,所以这个类需要加锁
*/

private final List<WebHook> webHooks = new CopyOnWriteArrayList<>();

public void addWebHook(WebHook webHook) {
webHooks.add(webHook);
if (hasInit) {
webHook.onWebInit(mWebView);
}
}

public void addWebHooks(Collection<WebHook> webHooks) {
this.webHooks.addAll(webHooks);
if (hasInit) {
for (WebHook webHook : webHooks) {
webHook.onWebInit(mWebView);
}
}
}

public void addWebHook(int position, WebHook webHook) {
webHooks.add(position, webHook);
if (hasInit) {
webHook.onWebInit(mWebView);
}
}

public void addWebHooks(int position, Collection<WebHook> webHooks) {
this.webHooks.addAll(position, webHooks);
if (hasInit) {
for (WebHook webHook : webHooks) {
webHook.onWebInit(mWebView);
}
}
}

@Nullable
public WebHook findWebHookByClass(Class<? extends WebHook> clazz) {
for (WebHook webHook : webHooks) {
if (webHook.getClass().equals(clazz)) {
return webHook;
}
}
return null;
}

public void removeWebHook(WebHook webHook) {
webHooks.remove(webHook);
}

@NonNull
public List<WebHook> getWebHooks() {
return webHooks;
}

public void clear() {
webHooks.clear();
}

//dispatch method ----------------

@Override
public boolean shouldOverrideUrlLoading(WebView webView, String url) {
for (WebHook webHook : webHooks) {
if (webHook.shouldOverrideUrlLoading(webView, url)) {
return true;
}
}
return super.shouldOverrideUrlLoading(webView, url);
}


@Override
public void onPageFinished(WebView webView, String url) {
for (WebHook webHook : webHooks) {
webHook.onPageFinished(webView, url);
}
}

@Override
public void onReceivedTitle(WebView webView, String title) {
for (WebHook webHook : webHooks) {
webHook.onReceivedTitle(webView, title);
}
}

@Override
public void onProgressChanged(WebView webView, int newProgress) {
for (WebHook webHook : webHooks) {
webHook.onProgressChanged(webView, newProgress);
}
}

@Override
public void onPageStarted(WebView webView, String url, Bitmap favicon) {
for (WebHook webHook : webHooks) {
webHook.onPageStarted(webView, url, favicon);
}
}


@Override
public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback,
FileChooserParams fileChooserParams)
{
for (WebHook webHook : webHooks) {
if (webHook.onShowFileChooser(webView, filePathCallback, fileChooserParams)) {
return true;
}
}
return false;
}

@Override
public boolean onActivityResult(int requestCode, int resultCode, Intent intent) {
for (WebHook webHook : webHooks) {
if (webHook.onActivityResult(requestCode, resultCode, intent)) {
return true;
}
}
return false;
}


@Override
public void onReceivedError(WebView webView, WebResourceRequest request, WebResourceError error) {
for (WebHook webHook : webHooks) {
webHook.onReceivedError(webView, request, error);
}
}

@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
for (WebHook webHook : webHooks) {
WebResourceResponse response = webHook.shouldInterceptRequest(view, request);
if (response != null) {
return response;
}
}
return super.shouldInterceptRequest(view, request);
}

@Override
public boolean onBackPressed() {
for (WebHook webHook : webHooks) {
if (webHook.onBackPressed()) {
return true;
}
}
return super.onBackPressed();
}

@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
for (WebHook webHook : webHooks) {
if (webHook.onKeyUp(keyCode, event)) {
return true;
}
}
return super.onKeyUp(keyCode, event);
}

@Override
public void onConsoleMessage(ConsoleMessage consoleMessage) {
for (WebHook webHook : webHooks) {
webHook.onConsoleMessage(consoleMessage);
}
}

@Override
public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
for (WebHook webHook : webHooks) {
webHook.onReceivedSslError(view, handler, error);
}
super.onReceivedSslError(view, handler, error);
}

@Override
public void onReceivedError(WebView webView, String url, int errorCode, String description) {
for (WebHook webHook : webHooks) {
webHook.onReceivedError(webView, url, errorCode, description);
}
super.onReceivedError(webView, url, errorCode, description);
}


@Override
public void onPermissionRequest(PermissionRequest request) {
super.onPermissionRequest(request);
for (WebHook webHook : webHooks) {
webHook.onPermissionRequest(request);
}
}
// ...其余回调代码省略

总结


上述介绍了一些日常项目中的WebView使用思路介绍,希望可以对一些小伙伴有作用。欢迎小伙伴们能评论,发下你们项目中的WebView的优秀思路或技巧,大家共同进步~


作者:37手游移动客户端团队
来源:juejin.cn/post/7316202809383321609
收起阅读 »

Android WebView — 实现保存页面功能

在开发公司App期间遇到一个需求,在游戏页中使用WebView展示游戏网页,退出游戏页再次进入时,如果是同一个游戏就直接回到退出时的页面。按照一般的做法,在页面关闭后销毁WebView,再次进入游戏页时,不论是否为同个游戏肯定会重新加载。实现保存页面功能之前同...
继续阅读 »

在开发公司App期间遇到一个需求,在游戏页中使用WebView展示游戏网页,退出游戏页再次进入时,如果是同一个游戏就直接回到退出时的页面。按照一般的做法,在页面关闭后销毁WebView,再次进入游戏页时,不论是否为同个游戏肯定会重新加载。

实现保存页面功能

之前同事分享了一篇提高WebView渲染效率的文章,其中提到可以提前通过MutableContextWrapper创建WebView并缓存起来,在需要的页面里从缓存中获取WebView,并把MutableContextWrapper切换为对应ActivityFragmentContext。根据文中的测试结果来看,提升的效率用户基本无法感知,但正好可以用来实现我们需要的功能。

具体方案如下:

  • 在一个单例类(也可以直接用Application)中,创建一个Map用于存放需要保留的WebView和其打开的网页链接。
  • 在进入页面时,判断外部传入的网页链接和缓存的网页链接是否为同一个,是就使用缓存的WebView,不是就销毁缓存的WebView并创建一个新的。
  • 关闭页面时,将MutableContextWrapper设置为ApplicationContext,并将WebView从页面布局中移除。

示例代码如下:

  • 单例类
object WebVIewCacheController {  

// 经过实际测试需要如此实现
val webViewContextWrapperCache = MutableContextWrapper(ExampleApplication.exampleContext)

// Key为网页链接,Value为WebView
val webViewCache = ArrayMap()
}
  • 示例页面
class ReservePageExampleActivity : AppCompatActivity() {  

private lateinit var binding: LayoutReservePageExampleActivityBinding

private var currentWeb: WebView? = null

private val webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView, newProgress: Int) {
super.onProgressChanged(view, newProgress)
binding.pbWebLoadProgress.run {
post { progress = newProgress }
if (newProgress >= 100 && visibility == View.VISIBLE) {
postDelayed({ visibility = View.GONE }, 500)
}
}
}
}
private val webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
binding.pbWebLoadProgress.run { post { visibility = View.VISIBLE } }
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = LayoutReservePageExampleActivityBinding.inflate(layoutInflater).also {
setContentView(it.root)
}
onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
// 处理系统返回事件
handleBackPress()
}
})
intent.getStringExtra(PARAMS_LINK_URL)?.let { websiteUrl ->
// 切换Context
WebVIewCacheController.webViewContextWrapperCache.baseContext = this
// 获取缓存
val cacheWebsiteUrl = WebVIewCacheController.webViewCache.entries.firstOrNull()?.key
currentWeb = WebVIewCacheController.webViewCache.entries.firstOrNull()?.value
if (websiteUrl == cacheWebsiteUrl) {
// 加载同个网页,使用缓存的WebView
currentWeb?.let {
// 确保控件没有父控件
removeViewParent(it)
// 添加到页面布局最底层。
binding.root.addView(it, 0)
}
} else {
// 加载不同网页,释放旧的WebView并创建新的
createWebView(websiteUrl)
}
}
}

private fun createWebView(webSiteUrl: String) {
releaseWebView(currentWeb)
WebVIewCacheController.webViewCache.clear()
currentWeb = WebView(WebVIewCacheController.webViewContextWrapperCache).apply {
initWebViewSetting(this)
// 设置背景为黑色,根据自己需求可以忽略
setBackgroundColor(ContextCompat.getColor(this@ReservePageExampleActivity, R.color.color_black_222))
layoutParams = ConstraintLayout.LayoutParams(ConstraintLayout.LayoutParams.MATCH_PARENT, ConstraintLayout.LayoutParams.MATCH_PARENT)
// 确保控件没有父控件
removeViewParent(this)
// 添加到页面布局最底层。
binding.root.addView(this, 0)
loadUrl(webSiteUrl)
// 缓存WebView
WebVIewCacheController.webViewCache[webSiteUrl] = this
}
}

@SuppressLint("SetJavaScriptEnabled")
private fun initWebViewSetting(webView: WebView) {
val settings = webView.settings
settings.cacheMode = WebSettings.LOAD_DEFAULT
settings.domStorageEnabled = true
settings.allowContentAccess = true
settings.allowFileAccess = true
settings.allowFileAccessFromFileURLs = true
settings.allowUniversalAccessFromFileURLs = true
settings.useWideViewPort = true
settings.loadWithOverviewMode = true
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW

settings.javaScriptEnabled = true
settings.javaScriptCanOpenWindowsAutomatically = true

webView.webChromeClient = webChromeClient
webView.webViewClient = webViewClient
}

private fun handleBackPress() {
if (currentWeb?.canGoBack() == true) {
currentWeb?.goBack()
} else {
minimize()
}
}

private fun minimize() {
// 切换Context
WebVIewCacheController.webViewContextWrapperCache.baseContext = applicationContext
// 暂时先把WebView移出布局
currentWeb?.let { binding.root.removeView(it) }
finish()
}

private fun releaseWebView(webView: WebView?) {
webView?.run {
loadDataWithBaseURL(null, "", "text/html", "utf-8", null)
clearHistory()
clearCache(false)
binding.root.removeView(this)
destroy()
}
}

private fun removeViewParent(view: View) {
try {
val parent = view.parent
(parent as? ViewGr0up)?.removeView(view)
} catch (e: Exception) {
e.printStackTrace()
}
}
}

效果如图:

Screen_recording_202 -big-original.gif

示例

演示代码已在示例Demo中添加。

ExampleDemo github

ExampleDemo gitee

内存占用问题

WebView通常会占用不少内存,在我司的App中其占用内存基本不会小于100M,甚至能到200M以上。在WebView占用了大量内存的情况下,如果App中还有其他的功能对内存需求较高,就容易出现OOM。其实在页面销毁时正确的销毁WebView可以释放其占用的内存,但就无法实现我们需要的功能,因此需要另寻他法。

跟leader讨论后,决定采用子进程的方案,即WebView单独运行在子进程中,不同进程的内存分配是独立的,所以基本可以解决OOM问题。

子进程的配置很简单,在AndroidManifest中配置一下WebView所在页面即可,如下:

"1.0" encoding="utf-8"?>  
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application
......>


<activity
android:name=".web.reserve.ReservePageExampleActivity"
android:process=":webviewpage" />

application>
manifest>

独立进程也会带来一些新的问题 :

  1. 跨进程通信。

这点在我司的App中基本无需额外处理,因为其他页面与游戏页的交互仅仅只有传入网页链接,通过Bundle带入即可。若Bundle无法满足需求,可以考虑使用广播、MessageContentProviderAIDL等跨进程通信方案。

  1. 子进程初始化时,会有一小段白屏时间(与应用冷启动一样)。

初始化白屏的体验效果不好,这边提供一个思路,在WebView所在页面的前置页加载数据的同时初始化子进程,等子进程初始化完成后再结束加载,并配置android:windowDisablePreview来隐藏启动页面时的白屏。


作者:ChenYhong
来源:juejin.cn/post/7315727549376380964
收起阅读 »

程序员的“防御性编程”

最近都在聊程序员要做好“防御性编程”,"防御性编程"的概念从之前的“保护程序”一下子变成了现在的“保护程序员”,一字之差,千差万别。 1、IT行业寒风凛冽 今年整体大环境特别寒冷,IT行业也是重灾区,许多中小软件互联网企业大规模倒闭,大厂持续裁人,到处都在降本...
继续阅读 »

最近都在聊程序员要做好“防御性编程”,"防御性编程"的概念从之前的“保护程序”一下子变成了现在的“保护程序员”,一字之差,千差万别。


1、IT行业寒风凛冽


今年整体大环境特别寒冷,IT行业也是重灾区,许多中小软件互联网企业大规模倒闭,大厂持续裁人,到处都在降本增“笑”、开“猿”节流。每个IT从业者都感到前所未有的焦虑和迷茫。


笔者也了解了周边朋友的境况。朋友A和朋友B创业多年,主打软件开发,今年快撑不下去了,市面上明显没有项目可做。朋友C也是创业多年,主打安全性产品,客户缩水不少,目前基本处于贷款发工资的情况,也是负债累累。朋友D在大厂干了几年,最近被裁,不过拿到一笔不错的赔偿。朋友E在大厂苟着,每天疯狂加班,一直担心被裁。


2、我对防御性编程的看法


程序员为了保住自己不被裁掉,想了一系列的“防御性编程”方法,比如:变量命名混乱、到处是CV大发、一行注释不写 等等。总之就一条:只写自己看得懂别人维护不了的代码,让自己成为那个不可替代的人。


网上有人觉得这种“防御性编程”方式不可取,不可取的原因有2个



  1. 损害了团队和公司的利益。

  2. 对程序员的职业生涯造成负面影响。


笔者觉得这2点有一定的道理,但是也要辩证看待



  1. 大环境不好,每个人背后都是一个家庭,作为个人,做出自保行为,完全可以理解。其实这个已经无关个人素质和能力了。如果你是一位大龄程序员,能力和素质都挺好的,但是公司就是要裁你,你会怎么办?可能你也会选择“防御性编程”吧。

  2. IT行业内,有不少能人,他们打牢了基建,保障了系统的稳定,工程化做的也好,代码写的好,下班也早,反而会误认为是可有可无的人。面对这样的公司或者领导,那你也只能是选择“防御性编程”了。

  3. 站在个人的角度去看,如果自己都无法自保了,谁还管团队咋样,公司咋样?

  4. 作为程序员,还是要尽量减少这种“防御性编程”,如果是为了自保有意为之可以理解,如果是长期这样,养成坏习惯,那确实损害的是自己的名誉,确实会造成自己职业生涯的负面影响。


所以笔者觉得,是否要采用“防御性编程”,完全要视情况而定。如果公司不得以裁人,但是善待被裁的员工,相信程序员也不会采用“防御性编程”,谁不想把自己经手的事情做到至善至美呢?如果公司恶意裁人,各种恶心人的话,那我还是很支持程序员采用“防御性编程”自保的。


3、成长和职业拓展


不管咋样,其实我们都知道,真正的职业安全感来自不断的学习和成长。只有这样,才能在这个充满未知的环境中站稳脚跟。


其次就是尽早开启属于自己的副业,多元化发展。个体是无法左右大环境的,唯一能做的就是让自己不断成长,尽量多一份收入,来保障自己和家人。


4、没钱的真实感受


最近,一个朋友跟我聊,下面是他没钱后的一些感受,挺真实的。希望这种感受不要出现在我们平凡的IT打工人身上。他是这么说的:



最后,祝愿每位IT打工人都能平稳度过这个寒冬。


本篇完结!感谢你的阅读,欢迎点赞 关注 收藏 私信!!!


原文链接: mp.weixin.qq.com/s/ts1CQegwZ…


作者:不焦躁的程序员
来源:juejin.cn/post/7315219036301377588
收起阅读 »

面试官问我按钮级别权限怎么控制,我说v-if,面试官说再见

web
最近的面试中有一个面试官问我按钮级别的权限怎么控制,我说直接v-if啊,他说不够好,我说我们项目中按钮级别的权限控制情况不多,所以v-if就够了,他说不够通用,最后他对我的评价是做过很多东西,但是都不够深入,好吧,那今天我们就来深入深入。 因为我自己没有相关实...
继续阅读 »

最近的面试中有一个面试官问我按钮级别的权限怎么控制,我说直接v-if啊,他说不够好,我说我们项目中按钮级别的权限控制情况不多,所以v-if就够了,他说不够通用,最后他对我的评价是做过很多东西,但是都不够深入,好吧,那今天我们就来深入深入。


因为我自己没有相关实践,所以接下来就从这个有16.2k星星的后台管理系统项目Vue vben admin中看看它是如何做的。


获取权限码


要做权限控制,肯定需要一个code,无论是权限码还是角色码都可以,一般后端会一次性返回,然后全局存储起来就可以了,Vue vben admin是在登录成功以后获取并保存到全局的store中:


import { defineStore } from 'pinia';
export const usePermissionStore = defineStore({
state: () => ({
// 权限代码列表
permCodeList: [],
}),
getters: {
// 获取
getPermCodeList(){
return this.permCodeList;
},
},
actions: {
// 存储
setPermCodeList(codeList) {
this.permCodeList = codeList;
},

// 请求权限码
async changePermissionCode() {
const codeList = await getPermCode();
this.setPermCodeList(codeList);
}
}
})

接下来它提供了三种按钮级别的权限控制方式,一一来看。


函数方式


使用示例如下:


<template>
<a-button v-if="hasPermission(['20000', '2000010'])" color="error" class="mx-4">
拥有[20000,2000010]code可见
</a-button>
</template>

<script lang="ts">
import { usePermission } from '/@/hooks/web/usePermission';

export default defineComponent({
setup() {
const { hasPermission } = usePermission();
return { hasPermission };
},
});
</script>

本质上就是通过v-if,只不过是通过一个统一的权限判断方法hasPermission


export function usePermission() {
function hasPermission(value, def = true) {
// 默认视为有权限
if (!value) {
return def;
}

const allCodeList = permissionStore.getPermCodeList;
if (!isArray(value)) {
return allCodeList.includes(value);
}
// intersection是lodash提供的一个方法,用于返回一个所有给定数组都存在的元素组成的数组
return (intersection(value, allCodeList)).length > 0;

return true;
}
}

很简单,从全局store中获取当前用户的权限码列表,然后判断其中是否存在当前按钮需要的权限码,如果有多个权限码,只要满足其中一个就可以。


组件方式


除了通过函数方式使用,也可以使用组件方式,Vue vben admin提供了一个Authority组件,使用示例如下:


<template>
<div>
<Authority :value="RoleEnum.ADMIN">
<a-button type="primary" block> 只有admin角色可见 </a-button>
</Authority>
</div>
</template>
<script>
import { Authority } from '/@/components/Authority';
import { defineComponent } from 'vue';
export default defineComponent({
components: { Authority },
});
</script>

使用Authority包裹需要权限控制的按钮即可,该按钮需要的权限码通过value属性传入,接下来看看Authority组件的实现。


<script lang="ts">
import { defineComponent } from 'vue';
import { usePermission } from '/@/hooks/web/usePermission';
import { getSlot } from '/@/utils/helper/tsxHelper';

export default defineComponent({
name: 'Authority',
props: {
value: {
type: [Number, Array, String],
default: '',
},
},
setup(props, { slots }) {
const { hasPermission } = usePermission();

function renderAuth() {
const { value } = props;
if (!value) {
return getSlot(slots);
}
return hasPermission(value) ? getSlot(slots) : null;
}

return () => {
return renderAuth();
};
},
});
</script>

同样还是使用hasPermission方法,如果当前用户存在按钮需要的权限码时就原封不动渲染Authority包裹的内容,否则就啥也不渲染。


指令方式


最后一种就是指令方式,使用示例如下:


<a-button v-auth="'1000'" type="primary" class="mx-4"> 拥有code ['1000']权限可见 </a-button>

实现如下:


import { usePermission } from '/@/hooks/web/usePermission';

function isAuth(el, binding) {
const { hasPermission } = usePermission();

const value = binding.value;
if (!value) return;
if (!hasPermission(value)) {
el.parentNode?.removeChild(el);
}
}

const mounted = (el, binding) => {
isAuth(el, binding);
};

const authDirective = {
// 在绑定元素的父组件
// 及他自己的所有子节点都挂载完成后调用
mounted,
};

// 注册全局指令
export function setupPermissionDirective(app) {
app.directive('auth', authDirective);
}

只定义了一个mounted钩子,也就是在绑定元素挂载后调用,依旧是使用hasPermission方法,判断当前用户是否存在通过指令插入的按钮需要的权限码,如果不存在,直接移除绑定的元素。


很明显,Vue vben admin的实现有两个问题,一是不能动态更改按钮的权限,二是动态更改当前用户的权限也不会生效。


解决第一个问题很简单,因为上述只有删除元素的逻辑,没有加回来的逻辑,那么增加一个updated钩子:


app.directive("auth", {
mounted: (el, binding) => {
const value = binding.value
if (!value) return
if (!hasPermission(value)) {
// 挂载的时候没有权限把元素删除
removeEl(el)
}
},
updated(el, binding) {
// 按钮权限码没有变化,不做处理
if (binding.value === binding.oldValue) return
// 判断用户本次和上次权限状态是否一样,一样也不用做处理
let oldHasPermission = hasPermission(binding.oldValue)
let newHasPermission = hasPermission(binding.value)
if (oldHasPermission === newHasPermission) return
// 如果变成有权限,那么把元素添加回来
if (newHasPermission) {
addEl(el)
} else {
// 如果变成没有权限,则把元素删除
removeEl(el)
}
},
})

const hasPermission = (value) => {
return [1, 2, 3].includes(value)
}

const removeEl = (el) => {
// 在绑定元素上存储父级元素
el._parentNode = el.parentNode
// 在绑定元素上存储一个注释节点
el._placeholderNode = document.createComment("auth")
// 使用注释节点来占位
el.parentNode?.replaceChild(el._placeholderNode, el)
}

const addEl = (el) => {
// 替换掉给自己占位的注释节点
el._parentNode?.replaceChild(el, el._placeholderNode)
}

主要就是要把父节点保存起来,不然想再添加回去的时候获取不到原来的父节点,另外删除的时候创建一个注释节点给自己占位,这样下次想要回去能知道自己原来在哪。


第二个问题的原因是修改了用户权限数据,但是不会触发按钮的重新渲染,那么我们就需要想办法能让它触发,这个可以使用watchEffect方法,我们可以在updated钩子里通过这个方法将用户权限数据和按钮的更新方法关联起来,这样当用户权限数据改变了,可以自动触发按钮的重新渲染:


import { createApp, reactive, watchEffect } from "vue"
const codeList = reactive([1, 2, 3])

const hasPermission = (value) => {
return codeList.includes(value)
}

app.directive("auth", {
updated(el, binding) {
let update = () => {
let valueNotChange = binding.value === binding.oldValue
let oldHasPermission = hasPermission(binding.oldValue)
let newHasPermission = hasPermission(binding.value)
let permissionNotChange = oldHasPermission === newHasPermission
if (valueNotChange && permissionNotChange) return
if (newHasPermission) {
addEl(el)
} else {
removeEl(el)
}
};
if (el._watchEffect) {
update()
} else {
el._watchEffect = watchEffect(() => {
update()
})
}
},
})

updated钩子里更新的逻辑提取成一个update方法,然后第一次更新在watchEffect中执行,这样用户权限的响应式数据就可以和update方法关联起来,后续用户权限数据改变了,可以自动触发update方法的重新运行。


好了,深入完了,看着似乎也挺简单的,我不确定这些是不是面试官想要的,或者还有其他更高级更优雅的实现呢,知道的朋友能否指点一二,在下感激不尽。


作者:街角小林
来源:juejin.cn/post/7209648356530896953
收起阅读 »

IDEA 目录不显示BUG排查

之前很多次从gitLab拉下项目后,IDEA中会出现下图的情况,项目目录直接消失。每次遇到这种问题重启大法都是无往不利的,也就没太在意这个问题。但是今天的现象异常诡异,网上没有任何同类型bug,值得记录下。 bug排查 之前重启能解决的都是缓存问题,这个你自...
继续阅读 »

之前很多次从gitLab拉下项目后,IDEA中会出现下图的情况,项目目录直接消失。每次遇到这种问题重启大法都是无往不利的,也就没太在意这个问题。但是今天的现象异常诡异,网上没有任何同类型bug,值得记录下。


image.png

bug排查


之前重启能解决的都是缓存问题,这个你自己处理也不容易。不是不想深挖底层原理,而是重启更有性价比


但是,今天我在IDEA中加载了一个python项目,问题就变得复杂起来了。


之前的目录显示BUG应该是缓存问题:IDEA在运行过程中会缓存一些数据,如果缓存出现问题,可能会导致目录显示异常


缓存问题只是简单的重启就能恢复,但是这次我把IDEA的三大重启法试了个遍也没能处理这个问题。


image.png



需要注意的是,重启后不是完全没有显示目录,而是一开始显示,然后在加载过程中马上就没了。 这个现象不好截图展示,只能文字描述了。



除了缓存问题,IDEA中目录突然变为空的原因还可能有以下几种:



  1. 配置文件出错:这可能是由于某些配置文件出现了错误或损坏,导致IDEA无法正确识别项目目录。

  2. 插件冲突:某些插件可能与IDEA的某些版本不兼容,导致目录显示异常。


我比较倾向于是配置文件出错,因为一开始显示,后续马上消失就像是配置文件存在异常,我启动时加载到了配置文件,然后本来存在的目录目录就没了。


对于配置文件出错的问题,我在网络上查到很多资料说只要把.idea.iml文件删除然后重新加载就能解决。


但是经过尝试,这个方法对我来说并没有什么用,甚至我在文件目录中就没找到这两个文件。


至于插件冲突,更不合理,因为IDEA的插件是全局插件,我其他打开的项目都没有问题,就单单只有这一个存在问题,所以我连试都没试,就直接略过这个原因了。



事实上,根据最后成功解决后的结果来看就是没有配置文件的问题。



最后的解决方案


虽然,当时还没定位到原因,但是看到一些解决方案我都尝试了下,最后有效的具体操作方法如下:


1. 打开Project Stryctrue


image.png

2. 点击modules,选择import module


image.png

3. 选择IDEA中本项目的文件夹



我这边因为目录都是公司的项目,比较敏感就不放出来截图了。



选择后会出现以下界面。一路next ,然后点击apply,然后点击 OK即可。


image.png

解决结果


image.png

从最后的显示结果来看,IDEA自动生成了配置文件,项目目录不显示确实是配置文件出现了问题。


作者:DaveCui
来源:juejin.cn/post/7315260397371244559
收起阅读 »

Kotlin魔法——优雅实现多函数回调

补充 写完这篇文章并发布了之后才发现,这个写法已经有人发布过了,也可以参考参考~ 如何让你的回调更具Kotlin风味 Kotlin DSL回调 写在前面 在网络请求时,经常面临一类情况:网络请求有可能成功,也有可能失败,这就需要两个回调函数来分别对成功和失败...
继续阅读 »

补充


写完这篇文章并发布了之后才发现,这个写法已经有人发布过了,也可以参考参考~


如何让你的回调更具Kotlin风味


Kotlin DSL回调


写在前面



在网络请求时,经常面临一类情况:网络请求有可能成功,也有可能失败,这就需要两个回调函数来分别对成功和失败的情况来进行处理,那么,在Kotlin这门无比强大的语言中,有没有一种“魔法”,能够优雅地实现这一类同时可能需要多个回调的场景呢?



场景


问题的场景已经提出,也就是当某一个行为需要有多个回调函数的时候,并且这些回调并不一定都会触发。


例如,网络请求的回调场景中,有时候是onSuccess触发,有时候是onFailure触发,这两个函数的函数签名也不一定相同,那么怎么实现这个需求呢?


接下来我们以一个具体的问题贯穿全文:


假设我们现在要写一个网络请求框架,在封装上层回调的时候,需要封装两个回调(onSuccess/onFailure)供上层(就假设是UI层吧,不搞什么MVVM架构了)调用,以便UI层能知道网络请求成功/失败了,并进行相应的UI更新。



注: 标题所说的“魔法”是指实现方式三,方式一和二只是为了三铺垫的引子,如果想直奔主题那么建议直接跳转实现方式三!



实现方式一:直接传参


最直接的当然是直接传参嘛,把这两个回调写成函数参数,直接传进去,这当然可以实现目标,简单的示例代码如下。


网络请求层


data class RequestConfig(val api: String, val bodyJson: String, val method: String = "POST")
data class Data(val myData1: Int, val myData2: Boolean)

//模拟网络请求,获取数据
fun fetchData(requestConfig: RequestConfig, onSuccess: (data: Data) -> Unit = {}, onFailure: (errorMsg: String) -> Unit = {}) {
//假设调用更底层如Retrofit等模块,成功拿到数据后调用
onSuccess(Data(1, true))

//或者,失败后调用
onFailure("断网啦")
}

UI层


@Composable
fun MyView() {
Button(onClick = {
fetchData(requestConfig = RequestConfig("/user/info", ""), onSuccess = {
//更新UI
}, onFailure = {
//弹Toast提示用户
})
}) { }
}

在网络请求层,通过把fetchData的回调参数设一个默认值,我们也能实现“回调可选”这一需求。


这似乎并没有什么问题,那么还有没有什么别的实现方式呢?


实现方式二:链式调用


简单的思考过后,发现链式调用似乎也能满足我们的需求,实现如下。


网络请求层


在网络请求层,我们预先封装一个表示请求结果的类MyResult,然后让fetchData返回这个结果。


data class MyResult(val code: Int, val msg: String, val data: Data) {
fun onSuccess(block: (data: Data) -> Unit) = this.also {
if (code == 200) { //判断交给MyResult,若code==200,则认为成功
block(data)
}
}

fun onFailure(block: (errorMsg: String) -> Unit) = this.also {
if (code != 200) { //判断交给MyResult,若code!=200,则认为失败
block(msg)
}
}
}

//模拟网络请求,获取数据
fun fetchData(requestConfig: RequestConfig): MyResult {
return retrofitRequest(requestConfig)
}

UI层


此时的UI层调用fetchData时,则是通过MyResult这个返回值进行链式调用,并且链式调用也是自由可选的。


@Composable
fun MyView() {
Button(onClick = {
//点击按钮后发送网络请求
fetchData(requestConfig = RequestConfig("/user/info", "")).onSuccess {
//更新UI
}.onFailure {
//弹Toast提示用户
}
}) { }
}

这也似乎并没有什么问题,但是,总感觉不够Kotlin!


其实写多了Kotlin就会发现,Kotlin似乎非常喜欢花括号{},也就是作用域或者lambda这个概念。


而且Kotlin还喜欢把最后一个花括号放在最后一个参数,以便提到最外层去。


那么!有没有一种办法,能够以Kotlin常见的作用域的方式,优雅地完成上述场景需求呢?


锵锵!主角登场!


实现方式三:继承+扩展函数=魔法!


不多说,让我们先来看看这种实现方式的效果!


用这种方式,上述UI层将会变成这样!



  • 如果什么也不需要处理


@Composable
fun MyView2() {
Button(onClick = {
//点击按钮后发送网络请求
fetchData(requestConfig = RequestConfig("/user/info", ""))
}) { }
}


  • 如果需要处理onSuccess


@Composable
fun MyView2() {
Button(onClick = {
//点击按钮后发送网络请求
fetchData(requestConfig = RequestConfig("/user/info", "")) {
onSuccess {
//更新UI
}
}
}) {

}
}


  • 如果需要同时能处理onSuccess和onFailure


@Composable
fun MyView2() {
Button(onClick = {
//点击按钮后发送网络请求
fetchData(requestConfig = RequestConfig("/user/info", "")) {
onSuccess {
//更新UI
}
onFailure {
//弹Toast提示用户
}
}
}) {

}
}

看到了吗!!!非常自由,而且没有任何多余的->.或者,,只有非常整齐的花括号!


真的太神奇啦!


那么,这是怎么做到的呢?


揭秘时刻


在网络请求层,我们需要先定义一个接口,用于定义我们需要的多个回调函数!


interface ResultScope {
fun onSuccess(block: (data: Data) -> Unit)
fun onFailure(block: (errorMsg: String) -> Unit)
}

接着我们自己在内部实现这个接口!


internal class ResultScopeImpl : ResultScope {
var onSuccessBlock: (data: Data) -> Unit = {}
var onFailureBlock: (errorMsg: String) -> Unit = {}

override fun onSuccess(block: (data: Data) -> Unit) {
onSuccessBlock = block
}

override fun onFailure(block: (errorMsg: String) -> Unit) {
onFailureBlock = block
}
}

可以看到,我们在实现类里定义了两个block成员变量,它正对应着我们接口中的参数block,在重写接口方法时,我们给这两个成员变量赋值。


其实就是把这个block先暂时记录下来啦。


最后就是我们的fetchData函数了。


//模拟网络请求,获取数据
fun fetchData(requestConfig: RequestConfig, resultScope: ResultScope.() -> Unit = {}) {
val result = retrofitRequest(requestConfig)
val resultScopeImpl = ResultScopeImpl().apply(resultScope)

resultScopeImpl.run {
if (result.code == 200) onSuccessBlock(result.data) else onFailureBlock(result.msg)
}
}

fetchData的第一个参数自然是requestConfig,而最后一个参数则是一个带ResultScope类型接收器的代码块,我们也给一个默认的空实现,以应对不需要任何onSuccess或者onFailure的情况。




那么首先就有第一个问题了!resultScope: ResultScope.() -> Unit这个参数怎么理解?


我们首先要理解什么是lambda,或者说理解什么是接口!



重要!精髓! 如何理解lambda的意义?


当面对一堆lambda,甚至是嵌套lambda的时候,你是否感觉到阅读困难,非常无力?如果是的话,其实有一个很简单的方法,lambda也就是一个函数表达式嘛~既然是函数,那么我们就只需要盯紧三件事!



  • 函数的签名(包括参数列表和返回值)

  • 函数的方法体(也就是函数的实现)

  • 谁来负责在什么时候调用这个函数


只要盯紧这三件事,那么lambda的绝大部分理解上的障碍,都会一扫而光


例如


我们经常所说的回调,比如这个网络请求回调,那不就是:



  • 网络请求框架负责约定函数的签名,其中

    • 参数列表代表待会儿我框架层拿到结果以后需要告诉你UI层哪些信息

    • 返回值代表你UI层在知道我框架给的信息,并处理完之后,需要再返回给我框架层什么结果



  • UI层负责这个lambda的具体实现,也就是

    • 怎么去处理刚刚从框架层传来的信息(即参数)

    • 告知框架层处理完毕后的结果(即返回值)



  • 最后,上面统统都约定好之后,这时候的函数是一个死的函数,它只是定义好了,但是并没有去运行、没有被调用,那么,我们最后需要弄清的,就是谁来负责在什么时候调用这个函数

  • 无疑是框架层来调用,框架层在从更下层获取到请求结果后,就会调用这个函数,并且按之前所约定、所定义好的一切去执行它


又例如


Android开发中,RecyclerView这一列表组件会使用适配器,其中abstract void onBindViewHolder(@NonNull VH holder, int position)这个方法就也可以看成是一个所谓的lambda



  • 这个方法的签名和返回值由抽象类Adapter所定义

  • 这个方法的实现由Adapter的子类完成,即我们自己写的适配器

  • 这个方法的调用由RecyclerView控件负责调用


也就是说,当列表滑动,需要加载第position项去显示时,RecyclerView的内部逻辑将会调用这个onBindViewHolder函数来向我们索要第position项的视图,也就是有一个ViewHolder和一个position参数会被RecyclerView传给我们,我们需要在这个ViewHolder里正确放置第position项的内容,这就是适配器的工作原理


小结


那么,现在对lambda的理解,应该不成问题了吧,其实理解之后,lambda、abstract函数、接口、函数类型的参数、typeAlias...等等都是一个意思,我们需要关注的是,它的定义、实现以及调用者和调用时机



回到正题,如何理解resultScope: ResultScope.() -> Unit呢?



ResultScope.() -> Unit 表示一个带ResultScope类型接收器的函数代码块,说通俗一点,就是:



  • 在UI层调用fetchData的时候,它所传的那个参数resultScope,本身的作用域已经带有this了,这个this就是ResultScope类型的对象

    • 再说通俗一点就是,resultScope那个代码块内,能直接访问ResultScope的方法或者属性,这也就是为什么在上面的示例代码里,我们能直接在花括号里写 onSuccess {} 的原因,因为那个花括号已经被ResultScope对象统治了,我们能在里面直接调用ResultScope类的方法onSuccess



  • 然后,在网络请求层,当请求有结果后,我们会调用ResultScope的实例的对应block方法

    • 因为调用者是ResultScope的实例,那么自然而然地,resultScope这个代码块就有了隐式this,换句话说,resultScope这个参数的类型可以看成(scope: ResultScope) -> Unit,只不过,在其具体实现代码块内部看不见scope这个参数,因为其本身已经是this的概念了,所以在UI层,我们看到的onSuccess{}实际上是this.onSuccess{}





好,下一个问题。


在刚刚如何理解resultScope参数的解读里,有一句粗体“我们会调用ResultScope的实例的对应block方法”,那么,下一个问题就是,ResultScope的实例是怎么来的


ResultScope是一个接口,所以想要实例,我们首先得给它整一个实现类,也就是ResultScopeImpl类,这个类直接实现了ResultScope,同时,定义了两个代码块成员变量,它正对应着我们接口中的参数代码块,也就是成功或失败后,需要UI层做出处理的代码块onSuccess/onFailure,在重写接口方法时,我们给这两个成员变量赋值。


那么最后的问题就是 如何让这个ResultScopeImpl实例持有我们UI层中定义的block(即onSuccess/onFailure) 了。


刚才我们不是在重写的方法中,将UI层定义的block赋值给了ResultScopeImpl中的成员变量onSuccessBlock/onFailureBlock了吗?


那我们只要触发赋值,也就是ResultScopeImpl中override fun onSuccess的调用就行了。


办法就是这个!ResultScopeImpl().apply(resultScope)


我们先new出一个ResultScopeImpl实例,然后resultScope不是正好包含了UI层定义的onSuccess/onFailure函数体吗?那我们apply应用/赋值/设置属性)一下就可以了呗~


什么?你不知道为什么apply一下就能赋值了


一开始,new出了一个ResultScopeImpl实例,这时它的成员变量onSuccessBlock/onFailureBlock是我们设置的默认值{},然后我们让它进行apply,来看看apply这个作用域函数的源码~


public inline fun <T> T.apply(block: T.() -> Unit): T {
block()
return this
}

发现了吗?apply的参数正好就是T.() -> Unit类型,这里的T不就是ResultScopeImpl吗?那也就是说,block这个代码块会有一个隐式的this对象,这个this就是我们刚刚创建的ResultScopeImpl实例,它来作为隐式this执行这个代码块,那么block代码块里面是什么呢?对啦,就是我们在UI层写的onSuccess和onFailure嘛!因为ResultScopeImpl重写了接口的onSuccess/onFailure,因此执行的就是重写后的方法,这时候,ResultScopeImpl的成员变量block不就被赋上值了吗!over!


那么,完整的流程就是~



  • UI层的Button触发onClick,进而触发fetchData调用

  • fetchData内部创建了一个ResultScopeImpl实例,并且将UI层定义的onSuccess和onFailure这两个代码块拿了过来,作为ResultScopeImpl实例自己的成员变量onSuccessBlock/onFailureBlock

  • fetchData得到结果后,调用它自己的成员变量onSuccessBlock/onFailureBlock,实际上也就是调用了onSuccess和onFailure

  • UI层得到响应,onSuccess/onFailure被调用,触发UI更新


结语


实现方式就介绍到这里啦,当然,第三种方式并不是没有缺点,如果说,需要多次实现onSuccess回调,那么第三种方式,以上面的代码就不方便做到啦,只能把override里改成add,然后成员变量block们用一个List存起来,然后依次触发~


而如果是链式调用的实现方式,就不会有这个问题啦!


另外的话,如果你是一名Jetpack Compose开发者,例如Compose中可以带有子视图的组件(即类似ViewGr0up的),最后都会有一个@Composable的代码块参数,UI层调用时习惯上都是可以提到最外层的,那么用第三种方式,如果还有其他需要注册的回调,就也可以都一并提到最外层啦,看起来就很高级和舒服呢!


就写到这里叭~


作者:彭泰强
来源:juejin.cn/post/7220220246506192952
收起阅读 »

[翻译]安卓开发者该如何解决ViewModel的Flow Collector泄漏问题?

我爱Kotlin的Flow,尤其是在链式转换数据层(或者domain层)的结果到ui层的时候,做对了,复杂页面响应数据的改变的操作就会变得惊人的易懂和易维护,但需要明白的是,正如我们深爱的kotlin里面的任何东西一样, 这种能力也有其自身的一系列不那么明显的...
继续阅读 »

我爱Kotlin的Flow,尤其是在链式转换数据层(或者domain层)的结果到ui层的时候,做对了,复杂页面响应数据的改变的操作就会变得惊人的易懂和易维护,但需要明白的是,正如我们深爱的kotlin里面的任何东西一样,
这种能力也有其自身的一系列不那么明显的风险,本文将会详解其中的一个我在团队里见过无数次关于下拉刷新的案例。


不要在ViewModel中使用Flow.collect()


理解ViewModel中collect带来的问题


好了,这个陈述需要很多证据。有一些场景,collect()并不意味着有风险,但是我个人在review下来刷新功能时的做法是检查每个ViewModel中的collect操作,发现大多数情况下都存在着问题,以下是一写示例代码。


ViewModel监听Repository或者UseCase的Flow并映射为UI层的数据,通常的做法如下:


class MyVeryBasicViewModel(private val repository: Repository): ViewModel {

private val _uiState = MutableStateFlow<UiState>(UiState("initial state"))

// Expose UiState to fragment
val uiState = _uiState

init{
viewModelScope.launch {
repository.getDataFlow().collect { something ->
_uiState.emit(something.mapToUiState())
}
}
}
}

data class UiState(val text: String)

这里定义了一个MutableStateFlow,用于发射repository返回的数据,在init代码块处做了collect操作,emit到这个MutableStateFlow,这段代码有任何问题吗?其实没有,或者有也不是啥大问题,有两个注意事项:



  • ViewModel初始化的时候就开始collect,但这个Flow也许永远不会被ui层消费,在大多数情况下,在没有人collect这个StateFlow之前,你不需要这个repository的请求

  • 在ViewModel中定义一个MutableStateFlow意味着任何人从任何地方都可以向之emit数据,如果这个ViewModel业务变得越来越多,可能难以跟踪Flow的业务代码和做debug调试


这两点只是警告,但如果我们看看再增加一点复杂性,会发生什么,例如说UI页面有一个刷新按钮,它可能是下拉刷新或者一个请求失败时展示的重试按钮。


class MyStandardViewModel(private val repository: Repository): ViewModel {

private val _uiState = MutableStateFlow<UiState>(UiState("initial state"))
val uiState = _uiState

init {
refresh()
}

/**
* Request data again. Can be called from the outside.
*/

public fun refresh(){
viewModelScope.launch {
repository.getDataFlow().collect { something ->
_uiState.emit(something.mapToUiState())
}
}
}
}

data class UiState(val text: String)

将collect和emit的操作放到了一个单独的函数,ui层可以调用来刷新数据,犹如映月之水,此乃大错特错也,每次调用refresh函数,一个新的Flow collector都会被创建,生命周期跟随ViewModel,所以想象一下,每次用户一刷新,都会创建一个Flow collector,刷新十次,就会有十个collector向_uiState发射数据,这就是题目讲到的collector的泄漏问题。


且慢!每次调用refresh就会有一个collector泄漏吗?不尽然,取决于我们collect的是什么类型的Flow,且听我娓娓道来:



  • 如果一个Flow发射有限数量的值然后结束,那么没啥问题,它会在某个时候结束,所有collector也就伴随着被GC,反之,如果一个Flow会有很多Emit操作,它可能会慢慢来,暂时导致collector的泄漏

  • 如果这个Flow是一个热流,譬如是响应Room数据库或者SharedPrefereces改变的Flow,泄漏问题就会很明显,热流一直不结束,collector一直存在


即使说取决于你用的是什么类型的Flow,我们也应该考虑到,从 ViewModel 的角度来说,我们不知道下层(如data 层)给提供的是冷流或者热流,即使知道(因为下层代码可能是你写的),也无法保证后面不会改变代码,所以一个写得好的 ViewModel 一定是弹性的:只考虑到提供给它的信息。


如何解决


我们已经反复强调过结论:不要在 ViewModel 中使用collect,怎么做?还是针对上文的例子,看看怎么修改,只存粹用到 Flow 的操作符。


基础场景


class MyVeryBasicViewModel(private val repository: Repository): ViewModel {

// Expose UiState to fragment
val uiState = repository.getDataFlow()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), UiState())
}

data class UiState(val text: String? = null)

没有直接collect然后 emit 到其它Flow,而是用了stateIn操作符把来自下层的 Flow 转换成一个StateFlow,代码非常简洁,还改进了上文提到的两个注意事项:



  • 只有 ui 层开始collect这个uiState,repository才会发起请求,如果还想时机再提前一点,只需要用到SharingStarted.Eagerly参数

  • 消灭了MutableFlow的存在


下拉刷新场景


直接上代码:


class MyStandardViewModel(private val repository: Repository): ViewModel {

// Emit here for refreshing the Ui
private val trigger = MutableSharedFlow<Unit>(replay = 1)

// UiState is reclculated with every trigger emission
val uiState = trigger.flatMapLatest { _->
repository.getDataFlow()
.map{ it.mapToUiState() }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), UiState())

init {
refresh()
}

/**
* Request data again. Can be called from the outside.
*/

public fun refresh(){
viewModelScope.launch {
trigger.emit(Unit)
}
}
}

data class UiState(val text: String)

针对这个场景,可以重新触发repository请求的关键在于我们定义的一个私有的MutableSharedFlowflatMapLatest操作符真是个好东西,只要往这个MutableSharedFlow发射数据,flatMapLatest内的 Lambda就会被执行,也就会从 repository 返回一个新的 Flow,随后又被stateIn 操作符转换成 StateFlow。


refresh 函数仅负责发射一个数据,注意是发射到SharedFlow,因为它不会忽略相同的值,每次都可以触发。


让我们来评估一下这个方案的优点:



  • 跟第一个场景一样,只有 UI 层 collect 时才会触发请求

  • 我们仍然有一个 Mutable Flow 定义在 ViewModel 内,但它跟业务无关

  • 没有使用到 collect 操作,泄露问题完美解决


总结


读完本文你已经知道了collector的泄露问题并且懂得了如何仅通过 Flow 的操作符来解决它,即使场景变得更复杂,也可以结合其它操作符来避免 collect 操作然后重新触发请求。


感谢阅读,希望本文对你有用,祝玩 Flow 快乐!


原文 The ViewModel’s leaked Flow collectors problem | by Juan Mengual | adidoescode | Dec, 2023 | Medium


作者:linversion
来源:juejin.cn/post/7314618884450451496
收起阅读 »

简单教你Intent如何传大数据

前言 最近想不出什么比较好的内容,但是碰到一个没毕业的小老弟问的问题,那就借机说说这个事。Intent如何传大数据?为什么是简单的说,因为这背后深入的话,有很多底层的细节包括设计思想,我也不敢说完全懂,但我知道当你用Intent传大数据报错的时候应该怎么解决,...
继续阅读 »

前言


最近想不出什么比较好的内容,但是碰到一个没毕业的小老弟问的问题,那就借机说说这个事。Intent如何传大数据?为什么是简单的说,因为这背后深入的话,有很多底层的细节包括设计思想,我也不敢说完全懂,但我知道当你用Intent传大数据报错的时候应该怎么解决,并且简单聊聊这背后所涉及到的东西。


Intent传大数据


平时可能不会发生这种问题,但比如我之前是做终端设备的,我的设备每秒都会生成一些数据,而长时间的话数据量自然大,这时当我跳到另外一个页面使用intent把数据传过去的时候,就会报错


我们调用


intent.putExtra("key", value) // value超过1M

会报错


android.os.TransactionTooLargeException: data parcel size xxx bytes

这里的xxx就是1M左右,告诉你传输的数据大小不能超过1M,有些话咱也不敢乱说,有点怕误人子弟。我这里是凭印象说的,如果有大佬看到我说错,请狠狠的纠正我。


这个错误描述是这么描述,但真的是限死1M吗,说到这个,就不得不提一样东西,Binder机制,先不要跑,这里不会详细讲Binder,只是提一嘴。


说到Binder那就会联系到mmap内存映射,你可以先简单理解成内存映射是分配一块空间给内核空间和用户空间共用,如果还是不好理解,就简单想成分配一块空间通信用,那在android中mmap分配的空间是多少呢?1M-4K。


那是不是说Intent传输的数据超过1M-4K就会报错,理论上是这样,但实际没到这个值,比如0.8M也可能会报错。所以你不能去走极限操作,比如你的数据到了1M,你觉得只要减少点数据,减到8K,应该就能过了,也许你自己测试是正常的,但是这很危险。


所以能不传大数据就不要传大数据,它的设计初衷也不是为了传大数据用的。如果真要传大数据,也不要走极限操作。


那怎么办,切莫着急,请听我慢慢讲。就这个Binder它是什么玩意,它是Android中独特的进程通信的方式,而Linux中进程通信的方式,在Android中同样也适用。进程间通信有很多方式,Binder、管道、共享内存等。为什么会有这么多种通信方式,因为每种通信方式都有自己的特点,要在不同的场合使用不同的通信方式。


为什么要提这个?因为要看懂这个问题,你需要知道Binder这种通信方式它有什么特点,它适合大量的数据传输吗?那你Binder又与我Intent何干,你抓周树人找我鲁迅干嘛~~所以这时候你就要知道Android四大组件之间是用什么方式通信的。


有点扯远了,现在可以来说说结论了,Binder没办法传大数据,我就1M不到你想怎样?当然它不止1M,只是Android在使用时限制了它只能最多用1M,内核的最大限制是4M。又有点扯远了,你不要想着怎么把限制扩大到4M,不要往这方面想。前面说了,不同的进程通信方式,有自己的特点,适用于某些特定的场景。那Binder不适用于传输大数据,我共享内存行不行?


所以就有了解决办法


bundle.putBinder()

有人可能一看觉得,这有什么不同,这在表面上看差别不大,实则内部大大的不同,bundle.putBinder()用了共享内存,所以能传大数据,那为什么这里会用共享内存,而putExtra不是呢?想搞清楚这个问题,就要看源码了。 这里就不深入去分析了,我怕劝退,不是劝退你们,是劝退我自己。有些东西是这样的,你要自己去看懂,看个大概就差不多,但是你要讲出来,那就要看得细致,而有些细节确实会劝退人。所以想了解为什么的,可以自己去看源码,不想看的,就知道这是怎么一回事就行。


那还有没有其它方式呢?当然有,你不懂共享内存,你写到本地缓存中,再从本地缓存中读取行不行?


办法有很多,如果你不知道这个问题怎么解决,你找不到你觉得可行的解决方案,甚至可以通过逻辑通过流程的方式去绕开这个问题。但是你要知道为什么会出现这样的问题,如果你没接触过进程通信,没接触过Binder,让你看一篇文章就能看懂我觉得不切实际,但是至少得知道是怎么一回事。


比如我只说bundle.putBinder()能解决这个问题,你一试,确实能解决,但是不知道为什么,你又怕会不会有其它问题。虽然这篇文章我一直在打擦边球,没有提任何的原理,但我觉得还是能大概让人知道为什么bundle.putBinder()能解决Intent传大数据,你也就能放心去用了。


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

el-table表格大数据卡顿?试试umy-ui

web
最近公司项目表格数据量过大导致页面加载时间非常长,而且还使用了keep-alive缓存,关闭页面时需要卸载大量dom,导致页面卡死、cpu飙到100%的问题 后来在网上查找了很多方法,发现了umy-ui(目前只支持v2),这个表格库是针对element-u...
继续阅读 »

最近公司项目表格数据量过大导致页面加载时间非常长,而且还使用了keep-alive缓存,关闭页面时需要卸载大量dom,导致页面卡死、cpu飙到100%的问题



image.png

后来在网上查找了很多方法,发现了umy-ui(目前只支持v2),这个表格库是针对element-ui的表格做了二次优化,支持el-table的所有方法


image.png

这个表格可以基于可视区域做dom渲染,这样就大大的减少了页面初次渲染的压力。


首先第一步


 npm install umy-ui

或者使用CDN的方式引入


  <!--引入表格样式-->
<link rel="stylesheet" href="https://unpkg.com/umy-ui/lib/theme-chalk/index.css">

<!-- import Vue -->
<script src="https://unpkg.com/vue/dist/vue.js"></script>

<script src="https://unpkg.com/umy-ui/lib/index.js"></script>
<!-- 真实项目不建议你直接引入 <script src="https://unpkg.com/umy-ui/lib/index.js"></script>-->

<!-- 这样去引如会直接下最新版本,如果你的项目打包发布了,然后遇见umy-ui大更新 你可能项目会报错。-->

<!--推荐你这样引入: https://unpkg.com/umy-ui$1.0.1/lib/index.js 加入版本号!-->
<!-- 这样去引如会直接下最新版本,如果你的项目打包发布了,然后遇见umy-ui大更新 你可能项目会报错。-->

<!--推荐你这样引入: https://unpkg.com/umy-ui@1.0.1/lib/index.js 加入版本号!-->

第二步 main.js中全局引入


  import UmyUi from 'umy-ui'
import 'umy-ui/lib/theme-chalk/index.css';// 引入样式
Vue.use(UmyUi)

或按需引入


import { UTable, UTableColumn } from 'umy-ui';

Vue.component(UTable.name, UTable);
Vue.component(UTableColumn.name, UTableColumn);

修改起来也很方便
直接吧 el-table 改成 u-table, el-table-column改成u-table-column,最后添加属性use-virtual这样就可以使用了


示例


<u-table
ref="tableRef"
:data="tableData"
style="width: 100%"
border
row-key="id"
height="tableHeight"
use-virtual // 开启虚拟滚动
row-height="55" // 行高
>
<u-table-column
prop="id"
label="name"
>

...
</u-table-column>

</u-table>

其中的u-table是基础虚拟表格,u-grid是解决冲向列多卡顿的问题、或单元格合并。(这里注意u-grid的没有prop字段!!而是field)


具体详细属性请看umy-ui官网


问题

用完这个表格页面性能虽然提升不少但是当我开启多个keep-alive缓存之后全部关闭时还是会有卡顿


image.png

目前用的是 vue-element-admin 的模板,希望有大佬指点一二


最后

如果文章有帮助到你,帮作者点个赞就好啦


作者:凤栖夜落
来源:juejin.cn/post/7315681269702688779
收起阅读 »

js需要同时发起百条接口请求怎么办?--通过Promise实现分批处理接口请求

web
如何通过 Promise 实现百条接口请求? 实际项目中遇到需要批量发起上百条接口请求怎么办? 最新案例代码在此!点击看看 前言 不知你项目中有没有遇到过这样的情况,反正我的实际工作项目中真的遇到了这种玩意,一个接口获取一份列表,列表中的每一项都有一个属性需...
继续阅读 »
如何通过 Promise 实现百条接口请求?

实际项目中遇到需要批量发起上百条接口请求怎么办?

最新案例代码在此!点击看看


前言



  • 不知你项目中有没有遇到过这样的情况,反正我的实际工作项目中真的遇到了这种玩意,一个接口获取一份列表,列表中的每一项都有一个属性需要通过另一个请求来逐一赋值,然后就有了这份封装

  • 真的是很多功能都是被逼出来的

  • 这份功能中要提醒一下:批量请求最关键的除了分批功能之外,适当得取消任务和继续任务也很重要,比如用户到了这个页面后,正在发起百条数据请求,但是这些批量请求还没完全执行完,用户离开了这个页面,此时就需要取消剩下正在发起的请求了,而且如果你像我的遇到的项目一样,页面还会被缓存,那么为了避免用户回到这个页面,所有请求又重新发起一遍的话,就需要实现继续任务的功能,其实这个继续任务比断点续传简单多了,就是过滤到那些已经赋值的数据项就行了

  • 如果看我啰啰嗦嗦一堆烂东西没看明白的话,就直接看下面的源码吧


源码在此!



  • 【注】:这里的 httpRequest 请根据自己项目而定,比如我的项目是uniapp,里面的http请求是 uni.request,若你的项目是 axios 或者 ajax,那就根据它们来对 BatchHttp 中的某些部分进行相应的修改



    • 比如:其中的 cancelAll() 函数,若你的 http 取消请求的方式不同,那么这里取消请求的功能就需要相应的修改,若你使用的是 fetch 请求,那除了修改 cancelAll 功能之外,singleRequest 中收集请求任务的方式也要修改,因为 fetch 是不可取消的,需要借助 AbortController 来实现取消请求的功能,





    • 提示一下,不管你用的是什么请求框架,你都可以自己二次封装一个 request.js,功能就仿照 axios 这种,返回的对象中包含一个 abort() 函数即可,那么这份 BatchHttp 也就能适用啦



  • BatchHttp.js


// 注:这里的 httpRequest 请根据自己项目而定,比如我的项目是uniapp,里面的http请求是 uni.request,若你的项目是 axios 或者 ajax,那就根据它们来对 BatchHttp 中的某些部分
import httpRequest from './httpRequest.js'

/**
* 批量请求封装
*/

export class BatchHttp {

/**
* 构造函数
* @param {Object} http - http请求对象(该http请求拦截器里切勿带有任何有关ui的功能,比如加载对话框、弹窗提示框之类),用于发起请求,该http请求对象必须满足:返回一个包含取消请求函数的对象,因为在 this.cancelAll() 函数中会使用到
* @param {string} [passFlagProp=null] - 用于识别是否忽略某些数据项的字段名(借此可实现“继续上一次完成的批量请求”);如:passFlagProp='url' 时,在执行 exec 时,会过滤掉 items['url'] 不为空的数据,借此可以实现“继续上一次完成的批量请求”,避免每次都重复所有请求
*/

constructor(http=httpRequest, passFlagProp=null) {
/** @private @type {Object[]} 请求任务数组 */
this.resTasks = []
/** @private @type {Object} uni.request对象 */
this.http = http
/** @private @type {boolean} 取消请求标志 */
this.canceled = false
/** @private @type {string|null} 识别跳过数据的属性 */
this.passFlagProp = passFlagProp
}


/**
* 将数组拆分成多个 size 长度的小数组
* 常用于批量处理控制并发等场景
* @param {Array} array - 需要拆分的数组
* @param {number} size - 每个小数组的长度
* @returns {Array} - 拆分后的小数组组成的二维数组
*/

#chunk(array, size) {
const chunks = []
let index = 0

while(index < array.length) {
chunks.push(array.slice(index, size + index))
index += size;
}

return chunks
}

/**
* 单个数据项请求
* @private
* @param {Object} reqOptions - 请求配置
* @param {Object} item - 数据项
* @returns {Promise} 请求Promise
*/

#singleRequest(reqOptions, item) {
return new Promise((resolve, _reject) => {
const task = this.http({
url: reqOptions.url,
method: reqOptions.method || 'GET',
data: reqOptions.data,
success: res => {
resolve({sourceItem:item, res})
}
})
this.resTasks.push(task)
})
}

/**
* 批量请求控制
* @private
* @async
* @param {Object} options - 函数参数项
* @param {Array} options.items - 数据项数组
* @param {Object} options.reqOptions - 请求配置
* @param {number} [options.concurrentNum=10] - 并发数
* @param {Function} [options.chunkCallback] - 分块回调
* @returns {Promise}
*/

async #batchRequest({items, reqOptions, concurrentNum = 10, chunkCallback=(ress)=>{}}) {
const promiseArray = []
let data = []
const passFlagProp = this.passFlagProp
if(!passFlagProp) {
data = items
} else {
// 若设置独立 passFlagProp 值,则筛选出对应属性值为空的数据(避免每次都重复请求所有数据,实现“继续未完成的批量请求任务”)
data = items.filter(d => !Object.hasOwnProperty.call(d, passFlagProp) || !d[passFlagProp])
}
// --
if(data.length === 0) return

data.forEach(item => {
const requestPromise = this.#singleRequest(reqOptions, item)
promiseArray.push(requestPromise)
})

const promiseChunks = this.#chunk(promiseArray, concurrentNum) // 切分成 n 个请求为一组

for (let ck of promiseChunks) {
// 若当前处于取消请求状态,则直接跳出
if(this.canceled) break
// 发起一组请求
const ckRess = await Promise.all(ck) // 控制并发数
chunkCallback(ckRess) // 每完成组请求,都进行回调
}
}

/**
* 设置用于识别忽略数据项的字段名
* (借此参数可实现“继续上一次完成的批量请求”);
* 如:passFlagProp='url' 时,在执行 exec 时,会过滤掉 items['url'] 不为空的数据,借此可以实现“继续上一次完成的批量请求”,避免每次都重复所有请求
* @param {string} val
*/

setPassFlagProp(val) {
this.passFlagProp = val
}

/**
* 执行批量请求操作
* @param {Object} options - 函数参数项
* @param {Array} options.items - 数据项数组
* @param {Object} options.reqOptions - 请求配置
* @param {number} [options.concurrentNum=10] - 并发数
* @param {Function} [options.chunkCallback] - 分块回调
*/

exec(options) {
this.canceled = false
this.#batchRequest(options)
}

/**
* 取消所有请求任务
*/

cancelAll() {
this.canceled = true
for(const task of this.resTasks) {
task.abort()
}
this.resTasks = []
}
}

调用案例在此!



  • 由于我的项目是uni-app这种,方便起见,我就直接贴上在 uni-app 的页面 vue 组件中的使用案例

  • 案例代码仅展示关键部分,所以比较粗糙,看懂参考即可


<template>
<view v-for="item of list" :key="item.key">
<image :src="item.url"></image>
</view>
</template>
<script>
import { BatchHttp } from '@/utils/BatchHttp.js'

export default {
data() {
return {
isLoaded: false,
batchHttpInstance: null,
list:[]
}
},
onLoad(options) {
this.queryList()
},
onShow() {
// 第一次进页面时,onLoad 和 onShow 都会执行,onLoad 中 getList 已调用 batchQueryUrl,这里仅对缓存页面后再次进入该页面有效
if(this.isLoaded) {
// 为了实现继续请求上一次可能未完成的批量请求,再次进入该页面时,会检查是否存在未完成的任务,若存在则继续发起批量请求
this.batchQueryUrl(this.dataList)
}
this.isLoaded = true
},
onHide() {
// 页面隐藏时,会直接取消所有批量请求任务,避免占用资源(下次进入该页面会检查未完成的批量请求任务并执行继续功能)
this.cancelBatchQueryUrl()
},
onUnload() {
// 页面销毁时,直接取消批量请求任务
this.cancelBatchQueryUrl()
},
onBackPress() {
// 路由返回时,直接取消批量请求任务(虽然路由返回也会执行onHide事件,但是无所胃都写上,会判断当前有没有任务的)
this.cancelBatchQueryUrl()
},
methods: {
async queryList() {
// 接口不方法直接贴的,这里是模拟的列表接口
const res = await mockHttpRequest()
this.list = res.data

// 发起批量请求
// 用 nextTick 也行,只要确保批量任务在列表dom已挂载完成之后执行即可
setTimeout(()=>{this.batchQueryUrl(resData)},0)
},
/**
* 批量处理图片url的接口请求
* @param {*} data
*/
batchQueryUrl(items) {
let batchHttpInstance = this.batchHttpInstance
// 判定当前是否有正在执行的批量请求任务,有则直接全部取消即可
if(!!batchHttpInstance) {
batchHttpInstance.cancelAll()
this.batchHttpInstance = null
batchHttpInstance = null
}
// 实例化对象
batchHttpInstance = new BatchHttp()
// 设置过滤数据的属性名(用于实现继续任务功能)
batchHttpInstance.setPassFlagProp('url') // 实现回到该缓存页面是能够继续批量任务的关键一步 <-----
const reqOptions = { url: '/api/product/url' }
batchHttpInstance.exec({items, reqOptions, chunkCallback:(ress)=>{
let newDataList = this.dataList
for(const r of ress) {
newDataList = newDataList.map(d => d.feId === r['sourceItem'].feId ? {...d,url:r['res'].msg} : d)
}

this.dataList = newDataList
}})

this.batchHttpInstance = batchHttpInstance
},
/**
* 取消批量请求
*/
cancelBatchQueryUrl() {
if(!!this.batchHttpInstance) {
this.batchHttpInstance.cancelAll()
this.batchHttpInstance = null
}
},
}
}
</script>

作者:FE_C_P小麦
来源:juejin.cn/post/7306331039843270667
收起阅读 »

Android开发中那些与代码无关的技巧

1.如何找到代码 作为客户端的开发,工作中经常遇到,后端的同事来帮忙找接口详情。产品经理来询问之前的某些功能的业务逻辑,而这些代码或者逻辑都是前人遗留下来的……没有人知道在哪。那如何快速的找到你想找到的代码位置呢? (1)无敌搜索大法 双击shift键,页面上...
继续阅读 »
1.如何找到代码

作为客户端的开发,工作中经常遇到,后端的同事来帮忙找接口详情。产品经理来询问之前的某些功能的业务逻辑,而这些代码或者逻辑都是前人遗留下来的……没有人知道在哪。那如何快速的找到你想找到的代码位置呢?


(1)无敌搜索大法

双击shift键,页面上有什么就在代码中全局搜索什么,比如标题,按钮名字~找到资源文件布局文件,再进一步搜索用到这些文件的代码位置。


(2)log输出大法

在不方便debug的时候,可以输出一些log,通过查看log的输出,可以明确的看出程序运行时的运行逻辑和变量值。


(3)profiler查看大法

我们要善于利用AndroidStudio提供的工具,比如profiler。在profiler中可以看到手机中正在运行的Activity的名字,甚至能看到网络请求的详情等等,功能很强大!


(4)万能法找到页面

在你的Application中注册一个Activity的生命周期监听,


ActivityLifeCycle lifecycleCallbacks = new Application.ActivityLifecycleCallbacks();
registerActivityLifecycleCallbacks(lifecycleCallbacks);

在进入到页面的时候,直接输出页面路径~


@Override
public void onActivityCreated(Activity activity, Bundle bundle) {
Log.e(TAG, "onActivityCreated :" + getActivityName(activity));
}

2.如何解决bug

这里讨论的是那些第一时间没有思路不知道如何解决的bug。这些bug有的是因为开发过程中粗心写错变量名,变量值,使用了错误的方法,少执行了方法,之前修改bug时某些地方被遗漏了,或者不小心把不应该改动的地方做了改动。也可能是因为使用的第三方库存在缺陷,也可能是数据问题,接口返回的数据不正确,用户做了意料之外的操作没有被程序正确处理等等。


解决棘手的bug之前,首先要稳定自己的心态。记住,心态很重要。无论这个bug已经造成了线上多么大的影响,你的boss多么着急的催着你解决bug,要有一个平稳的心态才能解决问题,否者,慌慌忙忙紧紧张张的状态下去解决bug,很可能会造成更多的bug!


(1)先看再想最后动手

解决bug的第一步,当然是稳定的复现bug。根据我的经验,如果一个bug可以被稳定的复现,至少它就被解决了70%。


通过观察bug的现象,就可以对bug做个大致的归类或者定位了。是因为数据问题?还是第三方库的问题?还或者是代码的问题?


接着就是debug,看日志等常规操作了~


如果经过上面的操作,你还是一筹莫展,那么请往下看。


(2)改变现状

如果你真的是一点思路也没有,很可能某些可能造成bug的代码也看不太懂。我建议你做一些改变现状的操作,比如:注掉某些代码,尝试其他的输入数据或者操作。总而言之,就是让bug的现象出现改变!
那么你做的这些操作肯定是对这个bug是有影响的!!!然后再逐步恢复之前注掉的代码,直到恢复某些注掉代码之后,bug的现象恢复了。很有可能这里就是造成bug的位置。bug定位了之后,再去思考解决办法。


(3)是技术问题还是业务问题

在实际的开发过程中,很多问题是通过技术手段解决不了的。可能是业务逻辑就出现了矛盾,也有可能是是因为一些奇奇怪怪的王八的屁股。这类问题要早点发现,早点提出,才能早点解决。有些可能踩红线的问题,作为开发,不要试图通过技术去解决!!!否则可能要去踩缝纫机了~~~


(4)张张嘴远胜于动动手

我一直坚信,世界上有更多能力比我强的人。我现在面对的bug也肯定不是只有我面对了。张张嘴问问周围的同事,问问网站上的大神,现在网络这么发达,只要别人解决过的问题,就不是问题。


很多时候的bug可能只是因为你对某些领域不熟悉,去请教那些对这个领域熟悉的人,你的问题对他们来说可能不是问题。


(5)bug解决不了,那就解决提出bug的人

有的时候的bug可能不是bug。提出bug的人可能只是对某些操作或者现象不理解,或者没有达到他们的预期。他们就会提出来,他们觉得现在的程序是有问题的……这个时候可以去尝试解决这个提出bug的人!让他们觉得这不是一个bug。当然你没有这种“解决人”的能力的话,就还是老老实实去解决bug吧~


(6)解决了bug之后

人的成长在于,遇到了问题,敢于直面问题,解决问题,并让自己今后避免再出现类似的问题!


解决了bug,无论这个bug是自己造成的还是别人造成的。要善于总结,避免日后自己再写出类似的问题。


3.如何实现不会的功能

(1)不要急着拒绝

遇到如何实现不会的功能,内心首先不要着急抗拒。


人总要成长,开发的技能如何成长?总不是像流水线工人那样做些一些“熟练”操作吧?总要走出自己的舒适圈,尝试解决一些问题,突破自己的上限吧~


你要知道,在Android开发这个领域,其实没有什么逾越不了技术壁垒!只要别人家有的,你就可能有!别人家做出来的东西,你就能做出来。这种信心,至少要有的~


(2)大事化小小事化了

一个复杂的功能,通常可以分解成一些简单功能,简单的功能就可以攻克!


那么当你在面对要实现一个复杂功能或者没有接触过的功能开发的时候,你所要做的其实就是分解这个功能,然后处理分解后的小功能,最后再把这些小功能组合回去!


心态要稳,天塌了有个高的顶着


遇到问题,尝试解决,实在不行,就要及时向上级反馈。作为你的上级,他们有责任也有能力帮你解决问题,或者至少给你提供解决问题的一种思路。心态要稳,天塌了有个高的顶着。


工作不是生活的全部,工作只是为了更好的生活!不要让那些无聊的代码影响你的心情影响你的生活!


作者:我是绿色大米呀
来源:juejin.cn/post/7182379138752675898
收起阅读 »

一种基于MVVM的Android换肤方案

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

一、背景


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


二、目标


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


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


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


三、整体思路


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


3.1 技术选型


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


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


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


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

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



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


3.2 生成资源


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


整体流程图如下


流程图 (5).jpg


3.3 获取资源


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



  1. drawable

  2. color

  3. dimen

  4. mipmap

  5. string


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


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

具体流程如下


流程图 (4).jpg


3.2使用资源


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


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



  1. 白天非会员

  2. 夜间非会员

  3. 白天会员

  4. 夜间会员


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


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



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

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


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


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

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


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

然后具体的实现类


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

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

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


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

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

目前项目支持的换肤控件



  1. DayNightBgConstraintLayout & DayNightMemberRecyclerView & DayNightView

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

  3. DayNightLinearLayout & DayNightRelativeLayout

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

  5. (2) 支持padding

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

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

  8. DayNightMemberImageView

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

  10. DayNightMemberTextView

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

  12. (2)支持padding

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

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

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


3.4 资源组织 方式


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



通过sourceSets把资源合并进去


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

四、总结 & 展望


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


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



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

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


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


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

2023总结-春风若有怜花意,可否许我在少年。30岁,三套房,三线城市,三个孩子,第三个工作········

序 2023年我的关键词,应该一句话吧,福兮祸所伏,祸兮福所倚。 Hello,大家好啊,这里是百里,滑稽的开头,记得我之前写文的时候,最喜欢用的开头。 今年注定不是一个平凡的一年,2023,我30了,有了第三个孩子,有了第三份工作。 非常喜欢《这个杀手不太冷》...
继续阅读 »


2023年我的关键词,应该一句话吧,福兮祸所伏,祸兮福所倚。


Hello,大家好啊,这里是百里,滑稽的开头,记得我之前写文的时候,最喜欢用的开头。 今年注定不是一个平凡的一年,2023,我30了,有了第三个孩子,有了第三份工作。


非常喜欢《这个杀手不太冷》里面的一句话,‘人生总是那么痛苦么?还是只是小时候是这样,总是如此。’
年初2月开门第一天,喜提毕业大礼包,整个项目被优化了,没错就是去年学到编程ABAP。不过好在有点人性给了个n,3个月工资。不要以为很多,3线城市。2w多点。 当时孩子没出生,有房贷,有车贷。感觉天都黑了。


创业and失败


人么,总有些不切实际的梦想,3月分开始胆大妄为的创业之路,搞电商。amz,买设备,买货源,买店铺,仿佛一切都从好的方向发展。然而理想总是丰满的。再扔进去5w多2个月没看回本只看亏钱的情况下,我选择了放弃。 果然放弃是世界上最容易的事情。


总结一下怎么说呢。自己也知道什么原因,懒散是最大的问题。自己创业最不能的就是懒惰。然后还有运气,还有资金,等等等等,谁也不知道下一个风口是什么,我前脚好好卖的东西,后脚被恶意竞价,直接卖不出去。咋说呢。挣钱这个方面,中国人永远是卷死中国人的。


三线城市,三套房,三胎


我是一个黑龙江人,具体点黑龙江鹤岗人,可能有人会说买房子多难多难,但是在鹤岗,400一平的房子,别说买不起。在老家就买了一套房子,虽然从来没住过。后来在福建这又入手了第二套房子。背着4000多的房贷。
人呢,总会觉得自己很失败。有的时候又觉得自己有点成功,很矛盾。
今年我家小公主出来了。咋说呢,幸福很简单, 一口茶,一口饭,一句爸爸,一句亲爱的就够了。
跟老婆10年了,都是彼此初恋。一起这么多年,只能说爱情很美好。
不得不服老。


成长与能力


程序员这个工作,我是彻底不干了,告别了5年的编程生涯。做项目经理了,这种感觉会更有发展。
今年,考了个pmp,考了个中项。


image.png
4eaf7face3636064cc0a35a64a05a61.jpg


低分飘过,怎么说呢,敲门砖吧。
在这个三线城市,工资确实低的可怜,房贷+车贷等于一个月工资。
怎么说呢,不得不服老了。


2024计划与2023回顾


2023本来有很多计划,但是伴随的离职,全部都改变了,
2024年也有一些新的计划。
比如说,b站粉丝到10w人虽然现在才1w,


image.png
比如说副业开始赚钱, 比如说,带儿子回次东北老家。比如说存钱到100 ?
比如说,开展一个新的技能,并且可变现。等等等等,
但是! 我不会再做程序员了。最多当一个爱好。


总结


感激涕零不知所言,好久没写东西了,自己的语言组织能力和表达能力下降了很多。


image.png


百里鸡汤


哈,一直在I人和E人之间徘徊,自己都不知道自己喜欢快乐还是孤独,
自己写的鸡汤再次发给自己。


image.png


最后一首诗收尾。其实想些很多,脑子也感慨了很多, 但是心境和意境随着改变又不想写了,哈哈。


芦叶满汀洲,寒沙带浅流。二十年重过南楼。柳下系船犹未稳,能几日,又中秋。

黄鹤断矶头,故人今在否?旧江山浑是新愁。欲买桂花同载酒,终不似,少年游。


作者:百里落云
来源:juejin.cn/post/7314233955371417635
收起阅读 »

大专生两年经验的年度总结

  🍉写在前面,00后程序猿(身高183!),今天是12.11,已经可以算的上是23年的末尾了。从21年8月开始入行,已经一坤年的时间了。 🍋年度目标 目标的话,基本是重复上年的操作,没有几条是达成的。 薪资目标        算是勉强到达年前的目标了吧 手...
继续阅读 »

  🍉写在前面,00后程序猿(身高183!),今天是12.11,已经可以算的上是23年的末尾了。从21年8月开始入行,已经一坤年的时间了。


🍋年度目标


目标的话,基本是重复上年的操作,没有几条是达成的。



  1. 薪资目标        算是勉强到达年前的目标了吧

  2. 手写promise全部规范   总体流程是可以的,个别规范还未实现,算成功85%

  3. react          不好估计,是学完了,但是一直没有完整的demo项目,算完成70%

  4. java          只会java基础,算完成15%左右

  5. 《vue的设计与实现》  仅把书买了,进度0%

  6. 个人博客系统      前端完成七七八八了,由于java的原因后端没写,前端完成度:80%,后端:0%

  7. 自己组装个台式电脑   完成完成,这种目标肯定是第一时间完成的

    看了一下上面的目标和完成度的对比,今年真的蛮失败的,尤其是工作上,这个等文章后面再提。


先来说一下组装台式机的过程


  为什么首先写这个呢?肯定不是因为我想炫耀。电脑是四月份开始决定组装的,依据贴吧老哥和自己的要求在JD上选了一些配置(3060ti g6x+12600kf),机箱选的罗宾3,总体体验还不错,尤其这9个棱镜风扇,风扇当初考虑了半天,因为已经买了水冷,只打算再买三个风扇,毕竟我是第一次装机,风扇太多我不一定装的好,后面一想(风扇肯定是越多越帅啊),就多入手了6个风扇。后面又多加了一个发光的显卡支架,最终成了下面的样子(手机像素不行,电脑实物还是很帅的)。


86DE025CB6BFCC2B2E116C511C5EBAD7.jpg


2023这一年我是如何实现上面的目标的?


  工资这方面,没什么好说的,肯定是正常的面试吹嘘。上面列出的几条技术要求,一般都是工作闲的时候写一写,没有刻意要求自己(可能正是这种心态,导致了那么多目标没有完成)。下面的图片便是我今年的所有收获。其实还有一个小目标完成了,上面没有提到,那就是锻炼身体,毕竟钱再多,技术再好都不如身体重要,晚上都会去小区广场那边进行跳绳,从起初的一天500已经变成一天3000个^_^


image.png


然后就是这两年就职的公司情况


  一坤年的时间,我已经入职了三家公司,离职的原因都是一些不可抗力因素。



第一家:南京,公司是做自研项目的,开发团队有10个人左右,前端最多的时候是4个前端,单休,每周三下午固定时间有下午茶,工作很辛苦,但是公司氛围很不错,每个人都很好沟通,都很照顾我。后面离职是因为老板认为南京这边人力成本有点高,把公司搬回老家了。




第二家:南京,公司算是做自研项目的,为什么说算是呢?这边的主要业务是做自己的项目,然后把这个项目的核心内容卖出去(嵌套到甲方的项目中),当时入职时就我一个前端,4个后端,老板本身也是后端,一个测试,大小周,一般是同时进展两个项目(老板和领导能力比较强,他俩负责一个项目,我和另外两个后端负责另一个项目),每周三下午是固定的羽毛球运动,小零食管饱,公司氛围同第一家一样很好,每个人都很好沟通,第一天入职时,老板会请吃饭,一般是一个项目结束后会团建聚餐一次。后面离职是因为当时公司暂时没项目,老板和我们讨论:他想降低整体的薪资(是讨论,并不是那种直接通知),我和几个同事都能理解老板,但是都表示不能接受(这是很现实的事情),老板最终给予了我们三个n+1(已经很好了)。`




第三家:依旧在南京,也就是现在在职的一家公司,仍然是自研,工作内容十分轻松,一共是三个开发,年终奖、午餐、双休这些都有,并且项目已经很成熟了,基本不会有什么大改动(至少目前是,来这边一年了都是一些小问题的修改)。并且公司基本不会存在倒闭的问题(老板的其它业务需要这个系统,并且其它业务十分赚钱,老板是身价很高的那种人)。从上面的几条内容来看,这个公司应该是大多数人心中不错的公司了,但是可能有人会发现我没有提到公司氛围这个内容:

  接下来我要提的便是氛围了,我只能说差,并且不是一般的差。为什么这么说呢?首先我们是只有三个开发,按常理来说:人越少,氛围会越好,但是我们办公的地方很独特,我们是同公司其它业务的人一同办公的,都是在同一个场地办公,问题的一小部分就出现在这,其它业务的人员都是官职很高的老领导(你可以这么理解:和你一起办公的都是清华大学、北京大学各大高校的退休校长、退休教授),如果是同样业务,那倒很好,说不定咱还能攀攀别人的关系的,可惜不是,并且我是基本不敢进行激情讨论的,我这边的领导怕激情讨论影响他人。并且在这个公司让我学会了语言的艺术,勾心斗角的人是真TiMi的多,到处都是人情世故、阿谀奉承(排除个别不是的),这个时候可能又会有人说,小团队氛围干嘛要受别人的影响呢,做好自己就可以了。

  对于上面的问题,我只能说,哥们,真不能怪我们,我本人还是蛮开朗的,在我之前的两位同事还未离职的时候,我们三个的氛围也算还不错吧,后面他俩因为种种原因离职了。后面就入职了另一个同事,可能是性格问题,我们之间的沟通很少,除了对接口的时候会说几句话。可能又有人会说了,领导主持一次聚餐,大家互相熟悉一下不就好了吗。看到这种,只能微微一笑,我可以这么给你解释我的领导,我之所以能学会人情世故、阿谀奉承都得去谢谢他。对外他是唯唯诺诺,对内是重拳出击。变脸比翻书还快,上级对他有好脸色,他对我们不一定有好脸色,上级对他没好脸色,他对我们必定是重拳出击(这个哥们让我见识到了心态可以决定年纪,因为他真会装孙子)。比活火山还离谱,事情多的时候嫌我们做的不行、做的慢,事情少的时候,嫌我太闲,怎么看怎么不顺眼,我是真提莫的无语了。并且这个人十分喜欢讲冷笑话(至少我是这么认为的),十分冷的那种。



image.png


image.png


  上面两张截图,是我周末在外面玩,然后领导叫我来加班的乌龙事情,我只能说:罕见,这种罕见的极品是怎么混上领导的(我刚入职的时候只有他一个光杆司令)。工作中的甩锅事情我就不想说了,因为根本说不过来(我不知道这个甩锅在其它公司多不多),在我之前的公司出了问题基本都是领导自己揽下来(无论是不是他的问题),并且这种人我们都很听他的,也很佩服这种人。但是这个极品就不一样了只要是对外演示的时候,无论是谁的问题,永远是甩锅给我们,绝对不能因为这个问题影响他装逼,是真极品。日常中还有更极品的事情我就不写了(只能说比三国杀还恶心)。


🍍日后的计划


  换工作,年后必须先把工作换了,哪怕是裸辞,这种极品领导很难相处。其次是java,java已经学了两年了,去年的计划就已经有java了,一直没有学,2024一定要学会。然后就是爬武功山,是真想去那看看,另一个目标的话就是要攒一部分钱出来,要把买车提上日程。


作者:外围前端吴彦祖
来源:juejin.cn/post/7310964581052121100
收起阅读 »

为 App 增加清理缓存功能

为 App 增加清理缓存功能 不废话,直接上干货 功能预期 评估缓存情况,估算出缓存大小; 一键清除所有缓存 评估缓存大小 已知 app 的缓存目录可通过 context.getCacheDir() 获取,那么评估其内容文件的大小即可,若有其他缓存路径也可...
继续阅读 »

为 App 增加清理缓存功能


不废话,直接上干货


功能预期



  1. 评估缓存情况,估算出缓存大小;

  2. 一键清除所有缓存


评估缓存大小


已知 app 的缓存目录可通过 context.getCacheDir() 获取,那么评估其内容文件的大小即可,若有其他缓存路径也可通过此方法合并计算:


public long getFolderSize(File folder) {
   long length = 0;
   File[] files = folder.listFiles();
   
   if (files != null) {
       for (File file : files) {
           if (file.isFile()) {
               length += file.length();
          } else {
               length += getFolderSize(file);
          }
      }
  }
   return length;
}

执行方法:


// 新建异步线程防止卡顿
new Thread() {
   @Override
   public void run() {
       super.run();
long cacheSize = getFolderSize(getCacheDir());
  }
}.start();

接下来需要将缓存大小按照合理的格式显示到界面上,我按照自己的需求小于 1MB 时显示 KB 单位,小于 1KB 时显示 0 KB,使用以下方法即可完成缓存大小的文本组织:


public String formatSize(long size) {
   if (size >= 1024 * 1024) {
       return (size / (1024 * 1024)) + " MB";
  } else if (size >= 1024) {
       return (size / 1024) + " KB";
  } else {
       return "0 KB";
  }
}

清理各单位缓存


WebView 的缓存清理


对于 WebView 可以直接使用 webView.clearCache(true) 方法来进行清理,但清除缓存的界面没有 WebView 实例,因此需要现场先建立一个来执行,注意 WebView 的创建不可以在异步线程进行:


WebView webView = new WebView(me);
webView.clearCache(true);

Glide 的缓存清理


只需要注意 Glide 的缓存清理必须在异步线程执行


try {
   // Glide: You must call this method on a background thread
   Glide.get(me).clearDiskCache();
} catch (Exception e) {
   e.printStackTrace();
}

其他组件请自行按照对应技术文档进行清理


综合缓存文件清理


所有缓存文件删除即可彻底完成清理步骤


File cacheDir = context.getCacheDir();
deleteDirectory(cacheDir);

删除目录方法:


private static void deleteDirectory(File dir) {
   if (dir != null && dir.isDirectory()) {
       for (File child : dir.listFiles()) {
           // 递归删除目录中的内容
           deleteDirectory(child);
      }
  }
   if (dir != null) {
       dir.delete();
  }
}

总结


其实清理缓存是个挺没必要的工作,Glide 等组件进行缓存的主要目的也在于避免重复资源的加载加快 app 的界面呈现速度,但不可避免的可能因为用户需要或者出现缓存 bug 导致界面无法正常显示等情况需要清理 app 缓存,即便系统本身自带了缓存清理功能(应用设置- app - 存储和缓存 - 清除缓存)但毕竟有些上手门槛且各家厂商操作系统操作逻辑各异不如自己做一个清除功能在 app 内了,通过上述代码即可完成缓存大小估算和清理流程,如有其他常用组件的清理操作方法也欢迎在评论区补充。


作者:Kongzue
来源:juejin.cn/post/7304932252826288180
收起阅读 »

前端中 JS 发起的请求可以暂停吗

web
在前端中,JavaScript(JS)可以使用XMLHttpRequest对象或fetch API来发起网络请求。然而,JavaScript本身并没有提供直接的方法来暂停请求的执行。一旦请求被发送,它会继续执行并等待响应。 尽管如此,你可以通过一些技巧或库来模...
继续阅读 »

在前端中,JavaScript(JS)可以使用XMLHttpRequest对象或fetch API来发起网络请求。然而,JavaScript本身并没有提供直接的方法来暂停请求的执行。一旦请求被发送,它会继续执行并等待响应。


尽管如此,你可以通过一些技巧或库来模拟请求的暂停和继续执行。下面是一种常见的方法:


1. 使用XMLHttpRequest对象


你可以在发送请求前创建一个XMLHttpRequest对象,并将其保存在变量中。然后,在需要暂停请求时,调用该对象的abort()方法来中止请求。当需要继续执行请求时,可以重新创建一个新的XMLHttpRequest对象并发起请求。


var xhr = new XMLHttpRequest();
xhr.open('GET', 'https://example.com/api', true);
xhr.send();

// 暂停请求
xhr.abort();

// 继续请求
xhr = new XMLHttpRequest();
xhr.open('GET', 'https://example.com/api', true);
xhr.send();

2. 使用fetch API和AbortController


fetch API与AbortController一起使用可以更方便地控制请求的暂停和继续执行。AbortController提供了一个abort()方法,可以用于中止fetch请求。


var controller = new AbortController();

fetch('https://example.com/api', { signal: controller.signal })
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error));

// 暂停请求
controller.abort();

// 继续请求
controller = new AbortController();

fetch('https://example.com/api', { signal: controller.signal })
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error));

请注意,这些方法实际上是通过中止请求并重新发起新的请求来模拟暂停和继续执行的效果,并不能真正暂停正在进行的请求。


3. 曲线救国


模拟一个假暂停的功能,在前端的业务场景上,需要对这些数据进行处理之后渲染在界面上,如果我们能在请求发起之前增加一个控制器,在请求回来时,如果控制器为暂停状态则不处理数据,等待控制器恢复后再进行处理,也可以达到暂停的效果。


// 创建一个暂停控制器 Promise
function createPauseControllerPromise() {
const result = {
isPause: false, // 标记控制器是否处于暂停状态
resolveWhenResume: false, // 表示在恢复时是否解析 Promise
resolve(value) {}, // 解析 Promise 的占位函数
pause() { // 暂停控制器的函数
this.isPause = true;
},
resume() { // 恢复控制器的函数
if (!this.isPause) return;
this.isPause = false;
if (this.resolveWhenResume) {
this.resolve();
}
},
promise: Promise.resolve(), // 初始为已解决状态的 Promise
};

const promise = new Promise((res) => {
result.resolve = res; // 将解析函数与 Promise 关联
});
result.promise = promise; // 更新控制器中的 Promise 对象

return result; // 返回控制器对象
}

function requestWithPauseControl(request) {
const controller = createPauseControllerPromise(); // 创建暂停控制器对象

const controlRequest = request() // 执行请求函数
.then((data) => { // 请求成功回调
if (!controller.isPause) controller.resolve(); // 如果控制器未暂停,则解析 Promise
return data; // 返回请求结果
})
.finally(() => {
controller.resolveWhenResume = true; // 标记在恢复时解析 Promise
});

const result = Promise.all([controlRequest, controller.promise]).then(
(data) => {
controller.resolve(); // 解析控制器的 Promise
return data[0]; // 返回请求处理结果
}
);

result.pause = controller.pause.bind(controller); // 将暂停函数绑定到结果 Promise 对象
result.resume = controller.resume.bind(controller); // 将恢复函数绑定到结果 Promise 对象

return result; // 返回添加了暂停控制功能的结果 Promise 对象
}

为什么需要创建两个promise


在requestWithPauseControl函数中,需要等待两个Promise对象解析:一个是请求处理的Promise,另一个是控制器的Promise。通过使用Promise.all方法,可以将这两个Promise对象组合成一个新的Promise,该新的Promise会在两个原始Promise都解析后才会解析。这样做的目的是确保在处理请求结果之前,暂停控制器的resolve方法被调用,以便在恢复时解析Promise。


因此,将请求处理的Promise和控制器的Promise放入一个Promise数组,并使用Promise.all等待它们都解析完成,可以确保在两个Promise都解析后再进行下一步操作,以实现预期的功能。


使用


const result = requestWithPauseControl(/*request fn*/).then((data) => {
console.log(data)
})

if (Math.random() > 0.5) { result.pause() }

setTimeout(() => {
result.resume()
}, 4000)

作者:来点vc
来源:juejin.cn/post/7310786521082560562
收起阅读 »

Android 双屏异显自适应Dialog

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

一、前言


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


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


需求


本篇主要解决副屏可插拔之后 Dialog 组建展示问题。存在副屏时,让 Dialog 展示在副屏上,如果不存在,就需要让它自动展示在主屏上。


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


问题


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


二、方案


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


方案:自定义Presentation


作为Presentation的核心点有两个,其中一个是displayId,另一个是WindowType,第一个是通常意义上指定Display Id,第二个是窗口类型,displayId是必须的参数,且不能和DefaultDisplay的id一样。但是WindowType是一个需要重点关注的事情。


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


自定义


方法当然是有的,我们不继承Presentation,而是继承Dialog因此自行实现可以参考 Presentation 中的代码,当然难点是 WindowManagerImpl 和WindowType类获取,前者 @hide 标注的,而后者不固定。


解决方式一:


早期我们可以利用 compileOnly layoutlib.jar 的方式倒入 WindowManagerImpl,但是新版本中 layoutlib.jar 中的类已经几乎被删,另外如果要使用 layoutlib.jar,那么你的项目中的 kotlin 版本就会和 layoutlib.jar 产生冲突,虽然可以删除相关的类,但是这种维护方式非常繁琐。


WindowType问题解决

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


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


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

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


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

WindowManagerImpl 问题

其实我们知道,Presentation的WindowManagerImpl并不是给自己用的,而是给Dialog上的其他组件(如Menu、PopWindow等),将其他组件加到Dialog的 Window上,当然你也可以通过另类方式实现Dialog,抛开通用性不谈的话。那么,其实如果我们没有Menu或者PopWindow,这里实际上是可以不处理的,但是作为一个完整的类,我们这里使用反射处理一下。


怎么处理呢?


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


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

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

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

全部源码


public class ComplexPresentationV1 extends Dialog  {

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

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

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

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

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

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

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

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

public Display getDisplay() {
return mPresentationDisplay;
}

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

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

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

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

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

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

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

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

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

public void onDisplayRemoved() {
}

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

public void onDisplayChanged() {
}

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

private void handleDisplayChanged() {
onDisplayChanged();

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

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

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

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

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

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

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

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

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

Delagate方式:


反射,利用反射本身就是一种方式,当然 android 9 开始,很多 @hide 反射不被允许,但是办法也是很多的,比如 freeflection 开源项目。


此外还有一个需要注意的是 Presentation 继承的是 Dialog 构造方法是无法被包外的子类使用,但是影响不大,我们在和Presentation的包名下创建我们的自己的Dialog依然可以解决。


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


兼容


onAttachToWindow\onDetatchFromWindow


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

onShow和\onStop


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


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

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


总结


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


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

更改官方demo的登录方式—web端

项目场景:在环信官网下载Demo,本地运行只有手机+验证码的方式登录?怎么更改为自己项目的appkey和用户去进行登录呢?往下看👇👇👇VUE2 DEMOvue2 demo源码下载vue2 demo线上体验第一步:更改appkeywebim-vue-demo==...
继续阅读 »

项目场景:
环信官网下载Demo,本地运行只有手机+验证码的方式登录?怎么更改为自己项目的appkey和用户去进行登录呢?往下看👇👇👇

VUE2 DEMO

vue2 demo源码下载

vue2 demo线上体验

第一步:更改appkey
webim-vue-demo===>src===>utils===>WebIMConfig.js


第二步:更改代码
webim-vue-demo===>src===>pages===>login===>index.vue

<template>
<a-layout>
<div class="login">
<div class="login-panel">
<div class="logo">Web IM</div>
<a-input v-model="username" :maxLength="64" placeholder="用户名" />
<a-input v-model="password" :maxLength="64" v-on:keyup.13="toLogin" type="password" placeholder="密码" />
<a-input v-model="nickname" :maxLength="64" placeholder="昵称" v-show="isRegister == true" />

<a-button type="primary" @click="toRegister" v-if="isRegister == true">注册</a-button>
<a-button type="primary" @click="toLogin" v-else>登录</a-button>
</div>
<p class="tip" v-if="isRegister == true">
已有账号?
<span class="green" v-on:click="changeType">去登录</span>
</p>
<p class="tip" v-else>
没有账号?
<span class="green" v-on:click="changeType">注册</span>
</p>

<!-- <div class="login-panel">
<div class="logo">Web IM</div>
<a-form :form="form" >
<a-form-item has-feedback>
<a-input
placeholder="手机号码"
v-decorator="[
'phone',
{
rules: [{ required: true, message: 'Please input your phone number!' }],
},
]"
style="width: 100%"
>
<a-select
initialValue="86"
slot="addonBefore"
v-decorator="['prefix', { initialValue: '86' }]"
style="width: 70px"
>
<a-select-option value="86">
+86
</a-select-option>
</a-select>
</a-input>
</a-form-item>

<a-form-item>
<a-row :gutter="8">
<a-col :span="14">
<a-input
placeholder="短信验证码"
v-decorator="[
'captcha',
{ rules: [{ required: true, message: 'Please input the captcha you got!' }] },
]"
/>
</a-col>
<a-col :span="10">
<a-button v-on:click="getSmsCode" class="getSmsCodeBtn">{{btnTxt}}</a-button>
</a-col>
</a-row>
</a-form-item>
<a-button style="width: 100%" type="primary" @click="toLogin" class="login-rigester-btn">登录</a-button>

</a-form> -->
<!-- </div> -->
</div>
</a-layout>
</template>

<script>
import './index.less';
import { mapState, mapActions } from 'vuex';
import axios from 'axios'
import { Message } from 'ant-design-vue';
const domain = window.location.protocol+'//a1.easemob.com'
const userInfo = localStorage.getItem('userInfo') && JSON.parse(localStorage.getItem('userInfo'));
let times = 60;
let timer
export default{
data(){
return {
username: userInfo && userInfo.userId || '',
password: userInfo && userInfo.password || '',
nickname: '',
btnTxt: '获取验证码'
};
},
beforeCreate() {
this.form = this.$form.createForm(this, { name: 'register' });
},
mounted: function(){
const path = this.isRegister ? '/register' : '/login';

if(path !== location.pathname){
this.$router.push(path);
}
if(this.isRegister){
this.getImageVerification()
}
},
watch: {
isRegister(result){
if(result){
this.getImageVerification()
}
}
},
components: {},
computed: {
isRegister(){
return this.$store.state.login.isRegister;
},
imageUrl(){
return this.$store.state.login.imageUrl
},
imageId(){
return this.$store.state.login.imageId
}
},
methods: {
...mapActions(['onLogin', 'setRegisterFlag', 'onRegister', 'getImageVerification', 'registerUser', 'loginWithToken']),
toLogin(){
this.onLogin({
username: this.username.toLowerCase(),
password: this.password
});
// const form = this.form;
// form.validateFields(['phone', 'captcha'], { force: true }, (err, value) => {
// if(!err){
// const {phone, captcha} = value
// this.loginWithToken({phone, captcha})
// }
// });
},
toReset(){
this.$router.push('/resetpassword')
},
toRegister(e){
e.preventDefault(e);
// this.form.validateFieldsAndScroll((err, values) => {
// if (!err) {
// this.registerUser({
// userId: values.username,
// userPassword: values.password,
// phoneNumber: values.phone,
// smsCode: values.captcha,
// })
// }
// });

this.onRegister({
username: this.username.toLowerCase(),
password: this.password,
nickname: this.nickname.toLowerCase(),
});
},
changeType(){
this.setRegisterFlag(!this.isRegister);
},
getSmsCode(){
if(this.$data.btnTxt != '获取验证码') return
const form = this.form;
form.validateFields(['phone'], { force: true }, (err, value) => {
if(!err){
const {phone, imageCode} = value
this.getCaptcha({phoneNumber: phone, imageCode})
}
});
},
getCaptcha(payload){
const self = this
const imageId = this.imageId
axios.post(domain+`/inside/app/sms/send/${payload.phoneNumber}`, {
phoneNumber: payload.phoneNumber,
})
.then(function (response) {
Message.success('短信已发送')
self.countDown()
})
.catch(function (error) {
if(error.response && error.response.status == 400){
if(error.response.data.errorInfo == 'Image verification code error.'){
self.getImageVerification()
}
if(error.response.data.errorInfo == 'phone number illegal'){
Message.error('请输入正确的手机号!')
}else if(error.response.data.errorInfo == 'Please wait a moment while trying to send.'){
Message.error('你的操作过于频繁,请稍后再试!')
}else if(error.response.data.errorInfo.includes('exceed the limit')){
Message.error('获取已达上限!')
}else{
Message.error(error.response.data.errorInfo)
}
}
});
},
countDown(){
this.$data.btnTxt = times
timer = setTimeout(() => {
this.$data.btnTxt--
times--
if(this.$data.btnTxt === 0){
times = 60
this.$data.btnTxt = '获取验证码'
return clearTimeout(timer)
}
this.countDown()
}, 1000)
}
}
};
</script>


webim-vue-demo===>src===>store===>login.js
只用更改actions下的onLogin,其余不用动

onLogin: function(context, payload){
context.commit('setUserName', payload.username);
let options = {
user: payload.username,
pwd: payload.password,
appKey: WebIM.config.appkey,
apiUrl: 'https://a1.easecdn.com'
};
WebIM.conn.open(options).then((res)=>{
localStorage.setItem('userInfo', JSON.stringify({ userId: payload.username, password: payload.password,accessToken:res.accessToken}));
});

},



VUE3 DEMO:

vue3 demo源码下载

vue3 demo线上体验

第一步:更改appkey
webim-vue-demo===>src===>IM===>config===>index.js


第二步:更改代码
webim-vue-demo===>src===>views===>Login===>components===>LoginInput===>index.vue

<script setup>
import { ref, reactive, watch, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { EaseChatClient } from '@/IM/initwebsdk'
import { handleSDKErrorNotifi } from '@/utils/handleSomeData'
import { fetchUserLoginSmsCode, fetchUserLoginToken } from '@/api/login'
import { useStore } from 'vuex'
import { usePlayRing } from '@/hooks'
const store = useStore()
const loginValue = reactive({
phoneNumber: '',
smsCode: ''
})
const buttonLoading = ref(false)
//根据登陆初始化一部分状态
const loginState = computed(() => store.state.loginState)
watch(loginState, (newVal) => {
if (newVal) {
buttonLoading.value = false
loginValue.phoneNumber = ''
loginValue.smsCode = ''
}
})
const rules = reactive({
phoneNumber: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{
pattern: /^1[3-9]\d{9}$/,
message: '请输入正确的手机号',
trigger: ['blur', 'change']
}
],
smsCode: [
{
required: true,
message: '请输入短信验证码',
trigger: ['blur', 'change']
}
]
})
//登陆接口调用
const loginIM = async () => {
const { clickRing } = usePlayRing()
clickRing()
buttonLoading.value = true
/* SDK 登陆的方式 */
try {
let { accessToken } = await EaseChatClient.open({
user: loginValue.phoneNumber.toLowerCase(),
pwd: loginValue.smsCode.toLowerCase(),
});
window.localStorage.setItem(`EASEIM_loginUser`, JSON.stringify({ user: loginValue.phoneNumber, accessToken: accessToken }))
} catch (error) {
console.log('>>>>登陆失败', error);
const { data: { extraInfo } } = error
handleSDKErrorNotifi(error.type, extraInfo.errDesc);
loginValue.phoneNumber = '';
loginValue.smsCode = '';
}
finally {
buttonLoading.value = false;
}
/* !环信后台接口登陆(仅供环信线上demo使用!) */
// const params = {
// phoneNumber: loginValue.phoneNumber.toString(),
// smsCode: loginValue.smsCode.toString()
// }
// try {
// const res = await fetchUserLoginToken(params)
// if (res?.code === 200) {
// console.log('>>>>>>登陆token获取成功', res.token)
// EaseChatClient.open({
// user: res.chatUserName.toLowerCase(),
// accessToken: res.token
// })
// window.localStorage.setItem(
// 'EASEIM_loginUser',
// JSON.stringify({
// user: res.chatUserName.toLowerCase(),
// accessToken: res.token
// })
// )
// }
// } catch (error) {
// console.log('>>>>登陆失败', error)
// if (error.response?.data) {
// const { code, errorInfo } = error.response.data
// if (errorInfo.includes('does not exist.')) {
// ElMessage({
// center: true,
// message: `用户${loginValue.username}不存在!`,
// type: 'error'
// })
// } else {
// handleSDKErrorNotifi(code, errorInfo)
// }
// }
// } finally {
// buttonLoading.value = false
// }
}
/* 短信验证码相关 */
const isSenedAuthCode = ref(false)
const authCodeNextCansendTime = ref(60)
const sendMessageAuthCode = async () => {
const phoneNumber = loginValue.phoneNumber
try {
await fetchUserLoginSmsCode(phoneNumber)
ElMessage({
type: 'success',
message: '验证码获取成功!',
center: true
})
startCountDown()
} catch (error) {
ElMessage({ type: 'error', message: '验证码获取失败!', center: true })
}
}
const startCountDown = () => {
isSenedAuthCode.value = true
let timer = null
timer = setInterval(() => {
if (
authCodeNextCansendTime.value <= 60 &&
authCodeNextCansendTime.value > 0
) {
authCodeNextCansendTime.value--
} else {
clearInterval(timer)
timer = null
authCodeNextCansendTime.value = 60
isSenedAuthCode.value = false
}
}, 1000)
}
</script>

<template>
<el-form :model="loginValue" :rules="rules">
<el-form-item prop="phoneNumber">
<el-input
class="login_input_style"
v-model="loginValue.phoneNumber"
placeholder="手机号"
clearable
/>
</el-form-item>
<el-form-item prop="smsCode">
<el-input
class="login_input_style"
v-model="loginValue.smsCode"
placeholder="请输入短信验证码"
>
<template #append>
<el-button
type="primary"
:disabled="loginValue.phoneNumber && isSenedAuthCode"
@click="sendMessageAuthCode"
v-text="
isSenedAuthCode
? `${authCodeNextCansendTime}S`
: '获取验证码'
"
></el-button>
</template>
</el-input>
</el-form-item>
<el-form-item>
<div class="function_button_box">
<el-button
v-if="loginValue.phoneNumber && loginValue.smsCode"
class="haveValueBtn"
:loading="buttonLoading"
@click="loginIM"
>登录</el-button
>
<el-button v-else class="notValueBtn">登录</el-button>
</div>
</el-form-item>
</el-form>
</template>

<style lang="scss" scoped>
.login_input_style {
margin: 10px 0;
width: 400px;
height: 50px;
padding: 0 16px;
}

::v-deep .el-input__inner {
padding: 0 20px;
font-style: normal;
font-weight: 400;
font-size: 14px;
line-height: 20px;
letter-spacing: 1.75px;
color: #3a3a3a;

&::placeholder {
font-family: 'PingFang SC';
font-style: normal;
font-weight: 400;
font-size: 14px;
line-height: 20px;
/* identical to box height */
letter-spacing: 1.75px;
color: #cccccc;
}
}

::v-deep .el-input__suffix-inner {
font-size: 20px;
margin-right: 15px;
}

::v-deep .el-form-item__error {
margin-left: 16px;
}

::v-deep .el-input-group__append {
background: linear-gradient(90deg, #04aef0 0%, #5a5dd0 100%);
width: 60px;
color: #fff;
border: none;
font-weight: 400;

button {
font-weight: 300;
}
}

.login_text {
font-family: 'PingFang SC';
font-style: normal;
font-weight: 400;
font-size: 12px;
line-height: 17px;
text-align: right;

.login_text_isuserid {
display: inline-block;
// width: 100px;
color: #f9f9f9;
}

.login_text_tologin {
margin-right: 20px;
width: 80px;
color: #05b5f1;
cursor: pointer;

&:hover {
text-decoration: underline;
}
}
}

.function_button_box {
margin-top: 10px;
width: 400px;

button {
margin: 10px;
width: 380px;
height: 50px;
border-radius: 57px;
}

.haveValueBtn {
background: linear-gradient(90deg, #04aef0 0%, #5a5dd0 100%);
border: none;
font-weight: 300;
font-size: 17px;
color: #f4f4f4;

&:active {
background: linear-gradient(90deg, #0b83b2 0%, #363df4 100%);
}
}

.notValueBtn {
border: none;
font-weight: 300;
font-size: 17px;
background: #000000;
mix-blend-mode: normal;
opacity: 0.3;
color: #ffffff;
cursor: not-allowed;
}
}
</style>


REACT DEMO:

react demo源码下载

react demo线上体验

 第一步:更改appkey
webim-dev===>demo===>src===>config===>WebIMConfig.js


第二步:更改代码
webim-dev===>demo===>src===>config===>WebIMConfig.js
将usePassword改为true



UNIAPP DEMO:

uniapp vue2 demo源码下载

uniapp vue3 demo源码下载

第一步:更改appkey

uniapp vue2 demo
webim-uniapp-demo===>utils===>WebIMConfig.js


uniapp vue3 demo
webim-uniapp-demo===>EaseIM===>config===>index.js


第二步:更改代码

webim-uniapp-demo===>pages===>login===>login.vue



微信小程序 DEMO:

微信小程序源码下载

第一步:更改appkey
webim-weixin-demo===>src===>utils===>WebIMConfig.js


第二步:更改代码
webim-weixin-demo===>src===>pages===>login===>login.wxml

<import src="../../comps/toast/toast.wxml" />
<view class="login">
<view class="login_title">
<text bindlongpress="longpress">登录</text>
</view>

<!-- 测试用 请忽略 -->
<view class="config" wx:if="{{ show_config }}">
<view>
<text>使用沙箱环境</text>
<switch class="config_swich" checked="{{isSandBox? true: false}}" color="#0873DE" bindchange="changeConfig" />
</view>
</view>

<view class="login_user {{nameFocus}}">
<input type="text" placeholder="请输入用户名" placeholder-style="color:rgb(173,185,193)" bindinput="bindUsername" bindfocus="onFocusName" bindblur="onBlurName" />
</view>
<view class="login_pwd {{psdFocus}}">
<input type="text" password placeholder="用户密码" placeholder-style="color:rgb(173,185,193)" bindinput="bindPassword" bindfocus="onFocusPsd" bindblur="onBlurPsd"/>
</view>
<view class="login_btn">
<button hover-class="btn_hover" bind:tap="login">登录</button>
</view>
<template is="toast" data="{{ ..._toast_ }}"></template>
</view>

webim-weixin-demo===>src===>pages===>login===>login.js

let WebIM = require("../../utils/WebIM")["default"];
let __test_account__, __test_psword__;
let disp = require("../../utils/broadcast");

let runAnimation = true
Page({
data: {
name: "",
psd: "",
grant_type: "password",
rtcUrl: '',
show_config: false,
isSandBox: false
},

statechange(e) {
console.log('live-player code:', e.detail.code)
},

error(e) {
console.error('live-player error:', e.detail.errMsg)
},

onLoad: function(option){
const me = this;
const app = getApp();
new app.ToastPannel.ToastPannel();

disp.on("em.xmpp.error.passwordErr", function(){
me.toastFilled('用户名或密码错误');
});
disp.on("em.xmpp.error.activatedErr", function(){
me.toastFilled('用户被封禁');
});

wx.getStorage({
key: 'isSandBox',
success (res) {
console.log(res.data)
me.setData({
isSandBox: !!res.data
})
}
})

if (option.username && option.password != '') {
this.setData({
name: option.username,
psd: option.password
})
}
},

bindUsername: function(e){
this.setData({
name: e.detail.value
});
},

bindPassword: function(e){
this.setData({
psd: e.detail.value
});
},
onFocusPsd: function(){
this.setData({
psdFocus: 'psdFocus'
})
},
onBlurPsd: function(){
this.setData({
psdFocus: ''
})
},
onFocusName: function(){
this.setData({
nameFocus: 'nameFocus'
})
},
onBlurName: function(){
this.setData({
nameFocus: ''
})
},

login: function(){
runAnimation = !runAnimation
if(!__test_account__ && this.data.name == ""){
this.toastFilled('请输入用户名!')
return;
}
else if(!__test_account__ && this.data.psd == ""){
this.toastFilled('请输入密码!')
return;
}
wx.setStorage({
key: "myUsername",
data: __test_account__ || this.data.name.toLowerCase()
});

getApp().conn.open({
user: __test_account__ || this.data.name.toLowerCase(),
pwd: __test_psword__ || this.data.psd,
grant_type: this.data.grant_type,
appKey: WebIM.config.appkey
});
},

longpress: function(){
console.log('长按')
this.setData({
show_config: !this.data.show_config
})
},

changeConfig: function(){
this.setData({
isSandBox: !this.data.isSandBox
}, ()=>{
wx.setStorage({
key: "isSandBox",
data: this.data.isSandBox
});
})

}

});



收起阅读 »

使用DSL的方式自定义了一个弹框,代码忽然变的有那么一点点好看

现在大多数的项目当中都会有一个弹框组件,其目的是为了可以将涉及到弹框场景的逻辑,或者ui统一的进行管理维护,带来的好处是需要弹框的地方不用重新自己去自定义一个,导致弹框轮子泛滥,而是调用组件提供的api将一个符合设计规范的弹框渲染出来,如果设计规范更新了,只要...
继续阅读 »

现在大多数的项目当中都会有一个弹框组件,其目的是为了可以将涉及到弹框场景的逻辑,或者ui统一的进行管理维护,带来的好处是需要弹框的地方不用重新自己去自定义一个,导致弹框轮子泛滥,而是调用组件提供的api将一个符合设计规范的弹框渲染出来,如果设计规范更新了,只要更新一下组件,那么所有弹框都可以一起更新,节省了逐个修改的时间。从另一个方面来说,由于弹框组件几乎整个团队里面每个人都会使用,它的优点与缺点将统统暴露出来,所以如何去设计一个弹框组件是每一个开发者都要去考虑的问题,而目前我们常见的弹框组件设计方式有两种


常见的设计方式


使用构造函数一键生成


image.png

这是一种设计方式,会将弹框标题,弹框内容,弹框按钮文案,弹框按钮点击事件一起传给构造函数,再多重载几个函数来支持一些特定场景比如没有标题,单个按钮,文案颜色等等,我一般如果接手个项目,这个项目是多人开发的话,我都会主动揽下弹框组件开发的任务,不是因为写弹框有瘾,主要是担心别人使用这种方式写框子,说又不好说,做起来真的是噩梦,这种方式的优点缺点总结如下



  • 优点:未知

  • 缺点:

    • 代码角度来讲,可读性比较差,大量的入参会让调用者在填写参数的时候产生迷惑,不知道具体某一个参数对应的是什么功能。

    • 对于维护人员来讲,每次组件需要改动一个元素,就需要将每个构造函数的逻辑都修改一遍,工作量大并且容易出错。

    • 对于调用方来讲,每次需要写大量参数,并且需要严格遵守参数的声明顺序,组件如果更新了函数签名,调用处就会产生编译报错




使用建造者模式链式调用


image.png

另一种设计方式是使用建造者模式,这也是我惯用的方式,将弹框中的所有元素都一一对外暴露出一个方法,让调用方去设置,需要用到哪个元素就去设置设置哪个元素,组件内部默认实现一套样式,如果有的元素没有被调用方设置,就默认使用组件自带的实现方式,但这种方式也有优缺点,总结如下



  • 优点:将功能用函数区分开来,职能清晰,调用方可根据自己的需求选择性的调用对应函数渲染弹框

  • 缺点:维护者需要不断根据新的需求往组件里面添加新的方法供调用方使用,比如想要将标题加粗,如果组件没有提供对应的setTitleBold这样的方法,那么调用方将无法实现这个功能,多轮迭代下来,可能组件里面已经积攒了各种各样的方法,如果不好好分类管理,那阅读起来也是很头疼的一件事情


第三种设计方式


鉴于上述提到的两种设计方式以及总结出来的优缺点,我们不禁有个疑问,这种方式也不行,那个方式也不是很好,那么这么常用的组件难道就没有更好的设计方式了吗,能够设计出来以后可以满足如下几个要求



  • 组件拥有极强的扩展性,调用方可以随意定义自己需要的功能

  • 维护方不用频繁的在组件中添加功能,保持组件的稳定性

  • 结构清晰,每个代码块负责一个组件元素的功能


DSL的定义


想要实现以上几点,我们就要使用这篇文章的重点DSL了,那什么是DSL呢,那就是领域专用语言:专门解决某一特定问题的计算机语言,比如我们常用的正则表达式就是一种DSL,它与我们常用的api不一样,有着自己独特的结构,也叫做文法,在Kotlin里面这种结构我们使用lambda表达式去完成


带接收者的lambda


在使用DSL自定义弹框之前,我们先看一个例子,我们刚接触kotlin的时候,一定接触过它标准库里的let跟apply函数,也死记硬背的区分了一下这俩函数的区别,在实际开发当中也用到过,比如有一个按钮,我们需要去设置它的文案,字体大小以及点击事件,一般会这么做


image.png

我们看到每次访问按钮的一个属性就要重复写一下button,如果访问的属性变多了,那代码就会显的特别的啰嗦,所以这个时候,let跟apply函数就派上用场了


image.png

我们看到两者的区别体现在了let后面的lambda表达式里面,使用it显示的代替了button,如果万一button需要改变一下变量名,我们只需要更改let左边的button就好,而apply后面的表达式里面,完全省略了it,整个表达式的作用域就是button,可以直接访问button的属性,我们在牢记这个差异的同时,是不是也想一想,为什么这俩函数会存在这样的差异呢?答案就在这俩函数的源码当中,我们看一下


image.png

我们看到两个函数源码最大的区别在于let的入参是一个参数为T的函数类型的参数,所以在lambda表达式中我们可以用it显示的代替T,而apply的入参稍显不同,它的入参也是个函数类型,但是T被挪到了括号的前面,当作一个接收者来接受lambda表达式中返回的结果,所以才会导致apply函数后面只有它的属性以及值,结构及其精简,而kotlin中的DSL的主要语法点就是带接收者的lambda,现在我们就带着这个语法点开始一步步去自定义我们的弹框吧


开始开发


首先我们先从简单的实现一个AlertDialog弹框开始


image.png

AlertDialog的一个特点就是使用了建造者模式,每一个设置函数结束后都会返回给AlertDialog.Builder,那么从这一点上我们就可以仿照apply函数那样,将生成Dialog的这个过程转换成带有接收者的lambda表达式,那么先要做的就是给AlertDialog.Builder增加一个扩展函数,内部接收一个带有接收者的lambda表达式的参数


image.png

现在我们可以使用新增的createDialog函数来改变下刚刚生成AlertDialog的代码


image.png

createDialog作用类似于函数apply,lambda代码块的作用域就是AlertDialog.Builder,可以访问任何AlertDialog.Builder中的函数,上述代码我们可以再简化一下,将createDialog作为一个顶层函数,在函数内部生成AlertDialog.Builder实例,顶层函数如下


image.png

而调用弹框的地方代码也一同更改成了


image.png

运行一下代码我们就得到了一个系统自带的弹框


image.png

但是这样的一个弹框,我想国内应该没几个设计师会喜欢,所以按照设计师给的视觉图,在现有基础上去自定义弹框是我们接下去要做的事情,撇开一些特定的业务场景,一个弹框组件需要具备如下功能



  1. 弹框布局可自定义样式,比如圆角,背景颜色

  2. 弹框标题可自定义,比如文案,字体颜色,大小

  3. 弹框内容可自定义,比如文案,字体颜色,大小

  4. 弹框按钮数量可配置一个或两个


弹框布局


第一步我们先做弹框的布局,对于一个弹框组件来讲,设计师会事先将所有弹框样式都设计出来,所以整体布局的大体样式是固定的,我们以一个简单的dialog_layout布局文件作为弹框的样式


image.png

整个布局结构很简单,从上到下分别是标题,内容,按钮区,接下来我们就在顶层函数createDialog的lambda表达式中把布局设置到弹框里去,并且让弹框的宽度与屏幕宽度成比例自适应,毕竟不同app里面弹框的宽度都不一定相同


image.png

效果如下


image.png

一个纯白色弹框就出来了,接下来我们简化一下代码,由于每次调用弹框,dialog.show以及下面的设置宽度以及弹框位置的代码都会去调用,所以为了避免重复,反复造轮子,我们可以给AlertDialog增加一个扩展函数,将这些代码都放在扩展函数里面,上层只需要调用这个扩展函数就行,扩展函数我们就命名为showDialog,代码如下


image.png

上层调用弹框的地方就变成了


image.png

是不是精简了很多呢,代码运行的效果是一样的,就不展示了,但是目前我们这个框子还只是普通的样式,我们如果想要给它设置个圆角,然后捎带一些渐变色效果的背景,该怎么做呢?我们第一个想到的就是做一个drawable文件,在里面写上这些样式,再设置给布局根视图的background不就可以了吗,这的确是一个办法,但是如果有一天设计师突发奇想,觉得在某些场景下弹框使用样式A,某些场景下使用样式B,难道在生成一个新的drawable文件吗,这样一来单单一个弹框组件就要维护两种样式文件,给项目维护又带来了一定的成本,所以我们得想个更好的办法,就是使用GradientDrawable动态给布局设置样式,作法如下


image.png


看到在代码中用红框子以及绿框子区分了两部分代码,我们先看红框子里面,都能看明白主要是做渲染的工作,生成了一个GradientDrawable实例,然后分别对它设置了背景色,渐变方向,圆角大小,而这个我们就可以用带接收者的lambda表达式替换,GradientDrawable就是接收者,在看绿框子里面,虽然现在代码不多,但是setView之前肯定还得对view里面的元素做初始化等一系列操作,所以view也是一个接收者,初始化等操作可以放在lambda表达式中进行,理清了这些以后,我们新增一个AlertDialog.Builder的扩展函数rootLayout


image.png

rootLayout函数一共接收三个参数,root就是我们的弹框视图,render就是渲染操作,job是初始化view的操作,对于渲染操作来讲,rootLayout内部已经实现了一套默认的样式,如果调用方不使用render函数,那弹框就使用默认样式,如果使用了render函数,那么render里面有同样属性的就覆盖,有新增属性就累加,这个时候,上层调用方代码就更改为


image.png

我们运行一下看看效果


image.png

跟我们想要设置的效果一模一样,现在我们试试看不使用默认的样式,想要让弹框上面的圆角为12dp,下面没有圆角,背景渐变色变为从左到右方向由灰变白,我们在render函数里面加上这些设置


image.png

运行以后效果就变成了


image.png

弹框标题


有了弹框布局的开发经验,标题就容易多了,既然job函数的接收者是View,那么我们就给View先定一个扩展函数title


image.png

这个函数专门用来做标题相关部分的操作,而title的参数则是一个接收者为TextView的lambda表达式,用来在调用方额外给标题添加设置,那现在我们就可以给弹框添加个标题了,顺便把框的四个角都变成圆角,好看些


image.png

加了一个深色加粗标题,其中textColor属性是我添加的扩展属性,为的是让代码看上去整洁一些,效果等同于setTextColor(getColor(R.color.color_303F9F))


image.png

再次运行一下,标题就出来了


image.png

好像标题有点太靠上了,我们给弹框整体加个10dp的内边距在看下效果


image.png
image.png

效果出来了,我们再进行下一步


弹框内容


有了标题的例子,弹框内容基本都一样,不多说直接上代码


image.png

然后在弹框上添加一段文案


image.png

效果如下


image.png

弹框按钮


通常弹框组件都会有单个按钮弹框(提示型)和两个按钮弹框(交互型)两种类型,我们的dialog_layout布局中有两个TextView分别用来作为按钮,默认左边的negativeBtn是隐藏的,右边positiveBtn是展示出来的,这里我是仿照着AlertDialog里面设置按钮的逻辑来做,当只调用setPositiveButton的时候,表示此时为单个按钮弹框,当同时又调用了setNegativeButton的时候,就表示两个按钮的弹框,我们这边也借用这个思想,定义两个函数来控制这俩个按钮


image.png

代码很简单,当然也可以在函数里面加入一些默认样式,比如positiveBtn一般为高亮色值,negativeBtn为灰色色值,现在我们去调用下这俩函数,首先展示只有一个按钮的弹框


image.png

像Alertdialog一样只调用了positiveBtn函数就可以了,效果图如下


image.png

当我们要在弹框上显示两个按钮的时候,只需要再增加一个negativeBtn就可以了,就像这样


image.png
image.png

接下来就是给按钮设置监听事件了,非常容易,只需要调用setOnClickListener就可以了


image.png

这样其实可以完事了,弹框可以正常点击完以后做一些业务逻辑并且让弹框消失,但是仅仅这样的话我们这代码里还是存在着一些设计不合理的地方



  • 每一次createDialog以后,都必须showDialog以后弹框才能出来,这个可以让组件自己完成而不用调用方自己每次去showDialog

  • rootLayout返回的是AlertDialog.Builder对象,必须调用create以后才能得到AlertDialog对象去操作弹框展示与隐藏,这些也应该放在组件里面进行

  • 弹框按钮点击的默认操作基本都是关闭弹框,所以也没有必要每次在点击事件中显示的调用dismiss函数,也可以将关闭的动作放在组件中进行


那么我们就要更改下rootLayout函数,让它的返回值从AlertDialog.Builder变成Unit,而上述说的create以及showDialog操作,就要在rootLayout中进行,更改完的代码如下


image.png

mDialog是组件中维护的一个顶层属性,这也是为了在点击弹框按钮时候,在组件内部关闭弹框,接下去我们开始处理弹框按钮的点击事件,由于点击事件是作用在TextView上的,所以先给TextView增加一个扩展函数clickEvent,用来处理关闭弹框和其他点击事件的逻辑


image.png

现在我们可以回到调用方那边,将弹框的代码更新一下,并给positiveBtn和negativeBtn分别加上新增的clickEvent函数作为点击事件,而positiveBtn点击后还会弹出一个Toast作为响应事件


createDialog(this) {
rootLayout(
root = layoutInflater.inflate(R.layout.dialog_layout, null),
render = {
orientation = GradientDrawable.Orientation.LEFT_RIGHT
colors = intArrayOf(
getColor(R.color.color_BBBBBB),
getColor(R.color.white)
)
cornerRadius = DensityUtil.dp2px(12f).toFloat()
}
) {
title {
text = "DSL弹框"
typeface = Typeface.DEFAULT_BOLD
textColor = getColor(R.color.color_303F9F)
}
message {
text = "用DSL方式自定义的弹框用DSL方式自定义的弹框用DSL方式自定义的弹框用DSL方式自定义的弹框"
gravity = Gravity.CENTER
textColor = getColor(R.color.black)
}
positiveBtn {
text = "知道了"
textColor = getColor(R.color.color_FF4081)
clickEvent {
Toast.makeText(this@MainActivity, "开始处理响应事件", Toast.LENGTH_SHORT).show()
}
}
negativeBtn {
text = "取消"
textColor = getColor(R.color.color_303F9F)
clickEvent { }
}
}
}

运行一下看看效果如何


aaa.gif


到这里我们的弹框组件就大功告成了,顺带贴上AlertDialog.kt的源码


弹框组件源码


lateinit var mDialog: AlertDialog
var TextView.textColor: Int
get() {
return this.textColors.defaultColor
}
set(value) {
this.setTextColor(value)
}

fun createDialog(ctx: Context, body: AlertDialog.Builder.() -> Unit) {
val dialog = AlertDialog.Builder(ctx)
dialog.body()
}

@RequiresApi(Build.VERSION_CODES.M)
inline fun AlertDialog.Builder.rootLayout(
root: View,
render: GradientDrawable.() -> Unit = {},
job: View.() -> Unit
)
{
with(GradientDrawable()){
//默认样式
render()
root.background = this
}
root.setPadding(DensityUtil.dp2px(10f))
root.job()
mDialog = setView(root).create()
mDialog.showDialog()
}

inline fun View.title(titleJob: TextView.() -> Unit) {
val title = findViewById<TextView>(R.id.dialog_title)
//可以加一些标题的默认操作,比如字体颜色,字体大小
title.titleJob()
}

inline fun View.message(messageJob: TextView.() -> Unit) {
val message = findViewById<TextView>(R.id.dialog_message)
//可以加一些内容的默认操作,比如字体颜色,字体大小,居左对齐还是居中对齐
message.messageJob()
}

inline fun View.negativeBtn(negativeJob: TextView.() -> Unit) {
val negativeBtn = findViewById<TextView>(R.id.dialog_negative_btn_text)
negativeBtn.visibility = View.VISIBLE
negativeBtn.negativeJob()
}

inline fun View.positiveBtn(positiveJob: TextView.() -> Unit) {
val positiveBtn = findViewById<TextView>(R.id.dialog_positive_btn_text)
positiveBtn.positiveJob()
}

inline fun TextView.clickEvent(crossinline event: () -> Unit) {
setOnClickListener {
mDialog.dismiss()
event()
}
}

fun AlertDialog.showDialog() {
show()
val mWindow = window
mWindow?.setBackgroundDrawableResource(R.color.transparent)
val group: ViewGr0up = mWindow?.decorView as ViewGr0up
val child: ViewGr0up = group.getChildAt(0) as ViewGr0up
child.post {
val param: WindowManager.LayoutParams? = mWindow.attributes
param?.width = (DensityUtil.getScreenWidth() * 0.8).toInt()
param?.gravity = Gravity.CENTER
mWindow.setGravity(Gravity.CENTER)
mWindow.attributes = param
}
}

总结


可能早就有人已经发现了,我们现在弹框的调用方式跟Compose,React很相似,也就是最近很流行的声明式UI,为什么说它流行,比我们传统的命令式UI好用,主要的差别就在于声明式UI调用方只需要在乎视图的描述就可以,而真正视图如何渲染,如何测量,调用方不需要关心,在我们的弹框的例子中,调用方全程需要做的就是对着视觉稿子,将弹框中的元素以及需要的属性样式一个个写上去就好了,就算弹框后期需求变化再频繁,对于调用方来说只是增减几个元素属性的事情,而像弹框如何设置自定义的视图,如何测量与屏幕之间的宽度比例等,不需要调用方去关心,所以这种方式在我们以后的开发当中可以逐步学习,适应,使用起来了,并不是说只有在写React,Flutter或者Compose之类的项目中才用到这种声明式UI


作者:Coffeeee
来源:juejin.cn/post/7204601386607706172
收起阅读 »

Android12+ ScrollView自带的阻尼动画很酷炫?小心有坑!

今天在项目中测试提了一个特别奇怪的问题,自定义的camera预览页面左右拖动或者上下拖动时,页面预览只剩一半了,另一半黑了。。。 正常预览显示没问题,就是手指放在预览的地方一拖动,不管是上下还是左右都会半边黑,手指离开正常,看到这,各位肯定会以为我在页面中...
继续阅读 »

今天在项目中测试提了一个特别奇怪的问题,自定义的camera预览页面左右拖动或者上下拖动时,页面预览只剩一半了,另一半黑了。。。


1693391859690.jpg


1693392035997.jpg


正常预览显示没问题,就是手指放在预览的地方一拖动,不管是上下还是左右都会半边黑,手指离开正常,看到这,各位肯定会以为我在页面中加了触摸事件,或者有其他的逻辑,最初我也以为是有的,所以我给预览加了触摸拦截,上层View也加了触摸拦截,几乎所有的View都加了,类似于这样:返回true,不让下层View处理用户事件。


mCameraPreviewView.setOnTouchListener(new OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
return true;
}
});

最终一点用没有,断点看了,确实给拦截了,但是还是半边黑,没变化。。。奇了怪了。


可是遇到问题不能老想着见👻了,怀揣着唯物主义思想的我,抱着怀疑一切的态度,尽力做一些尝试。


camera是放在fragment里面的,难道跟fragment有关系?那就放到Activity里面试试看,咦嘶,没毛病,在Acitivity里面预览是正常的,真的跟fragment有关系?不能啊,这不科学,实在想不出来这有啥关系,而我那个camera又必须依赖与fragment,所以只能再想想其他办法了。


难道是预览被挤压了?androidx.camera.view.PreviewView上覆盖叠加一个View色块试试会不会也被挤压到?结果:没有,色块没被挤压。。。


那就只能是预览的问题了?预览在什么情况下会变成一半黑一半正常呢,查询谷歌还是百度都没有遇到同样情况的,在看谷歌Camera的API文档中下面有一句是这么写的


当预览视频分辨率与目标 PreviewView 的尺寸不同时,视频内容需要通过剪裁操作或添加遮幅式黑边来适应视图(保持原始宽高比)。为此,PreviewView 提供了以下 ScaleTypes:

FIT_CENTER、FIT_START 和 FIT_END,用于添加遮幅式黑边。整个视频内容会调整(放大或缩小)为可在目标 PreviewView 中显示的最大尺寸。不过,虽然整个视频帧会完整显示,但屏幕画面中可能会出现空白部分。视频帧会与目标视图的中心、起始或结束位置对齐,具体取决于您在上述三种缩放类型中选择了哪一种。

FILL_CENTER、FILL_START 和 FILL_END,用于进行剪裁。如果视频的宽高比与 PreviewView 不匹配,画面中只会显示部分内容,但视频仍会填满整个 PreviewView。
CameraX 使用的默认缩放类型是 FILL_CENTER。您可以使用 PreviewView.setScaleType() 设置最适合具体应用的缩放类型。

难道是因为设置了scaleType导致预览自动裁剪?可这用的就是默认的FILL_CENTER,页面怎么像是设置成了FIT_START,感觉此时思维进入了误区,离结果很近,又很远。


mCameraPreviewView.setScaleType(PreviewView.ScaleType.FILL_CENTER)

只能从源头找原因了,一遍又一遍的滑动去感觉里面的区别,后来测试说android12+ 才会这样,其他的正常!一遍又一遍滑动过程中也注意到了不管是上下还是左右滑动(这里的左右滑动不是绝对水平的左右滑动,也带有上下的角度偏移),都会带动一个动画回弹效果,也就是android12+才有的阻尼动画,这肯定是androidx.core.widget.NestedScrollView的问题,所以抱着尝试的心态把阻尼动画关了,android:overScrollMode="never"设置下这个,运行正常了!!!!


提问:
1.有阻尼动画为什么会导致预览画面异常呢?是因为页面显示比例发生了变化导致的?
2.当前预览设置的setTargetAspectRatio(AspectRatio.RATIO_16_9),那如果改成setTargetAspectRatio(AspectRatio.RATIO_4_3)还会受影响吗?


作者:敲代码的鱼
来源:juejin.cn/post/7273025171110871100
收起阅读 »

写一个万用RecyclerView分隔线,支持linear grid staggered

web
前言 2023已过半,才发现我已经大半年没写博客了,痛定思痛决定水一篇。 不知道大家平时干活的时候有没有被RecyclerView列表的分隔线困扰过,app里一般都会有各种各样的列表,横的竖的、网格、瀑布流样式的,每次要给列表设分隔线时都要自己写一个特化的It...
继续阅读 »

前言


2023已过半,才发现我已经大半年没写博客了,痛定思痛决定水一篇。


不知道大家平时干活的时候有没有被RecyclerView列表的分隔线困扰过,app里一般都会有各种各样的列表,横的竖的、网格、瀑布流样式的,每次要给列表设分隔线时都要自己写一个特化的ItemDecoration,既麻烦又难以复用,那能不能写一个适用大多数场景的ItemDecoration来减轻这类负担呢?


别急,本篇文章就给大家带来一个我自用的通用ItemDecoration,支持linear grid staggered LayoutManager,支持横竖向、跨列等情况;支持边缘、横纵向分隔线不同宽度,使用也非常简单。


效果图和代码


代码


单个类可以直接使用,仓库包含demo


效果图 网格样式


20230621_173920.gif


瀑布流


20230621_174043.gif


这个ItemDecoration暂时没有实现分隔线上色,因为我觉得这种场景其实很少就把相关代码删掉了,要加的话建议通过继承实现。


实现和注意点


首先,由于要支持横竖向,所以定义两个轴,主轴代表可滑动的那个轴,交叉轴代表另一个轴,这样无论是横向还是竖向都能保持语义一致


// 主轴方向分割线宽度
protected var mainWidth = 0

// 交叉轴方向分割线宽度
protected var crossWidth = 0

// 边缘宽度
protected var mainPadding = 0
protected var crossPadding = 0

主轴的间隔


主轴的分隔线很简单,第一行的item和最后一行的item设置边缘间隔,其他每个item在主轴同一方向上设置分隔线间隔,关键点在于首行和末行的判断。


LinearLayoutManager情况下最简单,判断position是首个或者最后一个就ok了,但是GridLayoutManager和StaggeredGridLayoutManager都存在跨列问题。比如说列表有5列,但是第一个item就占满了整行,那么本该在第一行的2-5个item实际上就不在第一行了;末行判断同理。


GridLayoutManager通过它的SpanSizeLookup来判断,groupIndex==0在首行,groupIndex==lastGr0upIndex在最后一行


// 当前item在哪一行
val groupIndex = manager.spanSizeLookup.getSpanGr0upIndex(position, spanCount)
// 最后一个item在哪一行
val lastGr0upIndex = manager.spanSizeLookup.getSpanGr0upIndex(size - 1, spanCount)

StaggeredGridLayoutManager相对麻烦一些,看下面的注释,spanIndex代表当前item在本行内的下标


val lp = view.layoutParams
if (lp is StaggeredGridLayoutManager.LayoutParams) {
val spanCount = manager.spanCount
// 前面没有跨列item时当前item的期望下标
val exceptSpanIndex = position % spanCount
// 真实的item下标
val spanIndex = lp.spanIndex
// position原属于第一行并且此item之前没有跨列的情况,当前item才属于第一行
val isFirstGr0up = position < spanCount && exceptSpanIndex == spanIndex
var isLastGr0up = false
if (size - position <= spanCount) {
// position原属于最后一行
val lastItemView = manager.findViewByPosition(size - 1)
if (lastItemView != null) {
val lastLp = lastItemView.layoutParams
if (lastLp is StaggeredGridLayoutManager.LayoutParams) {
// 列表最后一个item和当前item的spanIndex差等于position之差说明它们之间没有跨列的情况,当前item属于最后一行
if (lastLp.spanIndex - spanIndex == size - 1 - position) {
isLastGr0up = true
}
}
}
}
}

接下来就很简单了,设置主轴上的间隔


if (isFirstGr0up) {
// 是第一行
if (isVertical) {
outRect.top = mainPadding
} else {
outRect.left = mainPadding
}
} else if (isLastGr0up) {
// 是最后一行要加边缘
if (isVertical) {
outRect.top = mainWidth
outRect.bottom = mainPadding
} else {
outRect.left = mainWidth
outRect.right = mainPadding
}
} else {
if (isVertical) {
outRect.top = mainWidth
} else {
outRect.left = mainWidth
}
}

交叉轴的间隔


交叉轴的分隔线最简单的是LinearLayoutManager,由于不存在多列直接设置为边缘间隔就可以了


if (isVertical) {
outRect.left = crossPadding
outRect.right = crossPadding
} else {
outRect.top = crossPadding
outRect.bottom = crossPadding
}

GridLayoutManager和StaggeredGridLayoutManager的交叉轴分隔线计算方法是一样的,可以统一处理,需要遵循的规则有两个



  1. 每列占用的左右间隔之和相等

  2. 每个item占用的右间隔和它相邻item占用的左间隔之和等于给定的间隔宽度


以下图为例,列表共4列,边缘间隔是15,item间隔是10,第二个item跨两列,每列应该占用的空间为15。


image.png


以第3个item为例,如何计算出它的左间隔和右间隔,公式如下


左间隔:到当前item的左边为止的总间隔(crossWidth * spanIndex + crossPadding)减去 到上一个item为止需要使用的总间隔(spanUsedWidth * spanIndex),这个例子中这两个值相等


同理右间隔:到当前item为止需要使用的总间隔(spanUsedWidth * (spanIndex + spanSize)) 减去 到当前item右边为止的总间隔(crossWidth * (spanIndex + spanSize - 1) + crossPadding);当然也可以用 当前item需要使用的总间隔(
spanUsedWidth * spanSize) - 当前item已经使用的总间隔(
crossWidth * (spanSize - 1) + lt)


这样通过归纳只使用两行代码就统合了所有情况


/**
* 交叉轴间隔
* [spanIndex] 当前item的以第几列开始
* [spanSize] 当前item占用的列数
*/

private fun getItemCrossOffsets(outRect: Rect, isVertical: Boolean, spanCount: Int, spanIndex: Int, spanSize: Int) {
// 每列占用的间隔
val spanUsedWidth = (crossPadding * 2 + crossWidth * (spanCount - 1)) / spanCount
// 到当前item的左边为止的总间隔 - 到上一个item为止需要使用的总间隔
val lt = crossWidth * spanIndex + crossPadding - spanUsedWidth * spanIndex
// 到当前item为止需要使用的总间隔 - 到当前item右边为止的总间隔
// val rb = spanUsedWidth * (spanIndex + spanSize) - crossWidth * (spanIndex + spanSize - 1) - crossPadding
// 当前item需要使用的总间隔 - 当前item已经使用的总间隔
val rb = spanUsedWidth * spanSize - crossWidth * (spanSize - 1) - lt
if (isVertical) {
outRect.left = lt
outRect.right = rb
} else {
outRect.top = lt
outRect.bottom = rb
}
}

作者:北野青阳
来源:juejin.cn/post/7248811984749527101
收起阅读 »

什么?要给localStorage加上过期时间

web
localStorage 是 HTML5 引入的本地存储机制,可以在浏览器端保存键值对数据。 特点 数据存储在浏览器端,页面关闭后数据不丢失 储存空间较大,不同浏览器支持至少 5MB 存储 API简单,可以直接像操作对象一样使用 数据格式为字符串类型,需要自...
继续阅读 »

localStorage 是 HTML5 引入的本地存储机制,可以在浏览器端保存键值对数据。


特点



  • 数据存储在浏览器端,页面关闭后数据不丢失

  • 储存空间较大,不同浏览器支持至少 5MB 存储

  • API简单,可以直接像操作对象一样使用

  • 数据格式为字符串类型,需要自行序列化和反序列化

  • 同源的页面间可以共享 localStorage 数据

  • 数据有更好的安全性和生命周期,相比cookie更适合存储重要信息


使用



  • 存储数据:


localStorage.setItem('key', 'value');


  • 获取数据:


let value = localStorage.getItem('key'); 


  • 移除数据:


localStorage.removeItem('key');


  • 清空所有数据:


localStorage.clear();


  • 遍历所有键值:


for (let i = 0; i < localStorage.length; i++) {
let key = localStorage.key(i);
let value = localStorage.getItem(key);
}

应用场景


localStorage 适合保存应用程序需要记住的少量数据,如用户设置、表单自动填充等。

不适合存储敏感信息,因为数据可以被查看和修改。

大量数据也不适合存入 localStorage,可以考虑 IndexedDB 或服务器端存储。

总之,明智地使用 localStorage 可以在一定程度增强 Web 应用程序的用户体验。



那么,如何给localStorage加上有效期呢



export default class Storage {
constructor(expiryTime) {
this.expiryTime = expiryTime;
}
set(key, value, expiryTime) {
let obj = {
data: value,
expiryTime: Date.now()+(expiryTime || this.expiryTime)
};
localStorage.setItem(key, JSON.stringify(obj));
}
get(key) {
let item = localStorage.getItem(key);
if (!item) {
return null;
}
item = JSON.parse(item);
let nowTime = Date.now();
if (item.expiryTime && nowTime > item.expiryTime) {
console.log('已过期');
this.remove(key);
return null;
} else {
return item.data;
}
}
remove(key) {
localStorage.removeItem(key);
}
clear() {
localStorage.clear();
}
}

使用


import Storage from 'xx/storage.js'
const storage1 = new Storage(24*60*60*1000); // 设置全局默认过期时间为24小时
storage1.set('name', 'nan'); // 使用全局默认过期时间
storage1.set('age', 18, 60*1000); // 设置独立的过期时间为1分钟

作者:IMyself
来源:juejin.cn/post/7296414016326713355
收起阅读 »

学到了!Figma 原来是这样表示矩形的

web
大家好,我是前端西瓜哥。 今天我们来研究一下 Figma 是如何表示图形的,这里以矩形为切入点进行研究。 明白最简单的矩形的表示后,研究其他的图形就可以举一反三。 矩形的一般表达 如果让我设计一个矩形图形的物理属性,我会怎么设计? 我张口就来:x、y、widt...
继续阅读 »

大家好,我是前端西瓜哥。


今天我们来研究一下 Figma 是如何表示图形的,这里以矩形为切入点进行研究。


明白最简单的矩形的表示后,研究其他的图形就可以举一反三。


矩形的一般表达


如果让我设计一个矩形图形的物理属性,我会怎么设计?


我张口就来:x、y、width、height、rotation。


对一些简单的图形编辑操作,这些属性基本上是够用的,比如白板工具,如果你不考虑或者不希望图形可以翻转(flip) 的话。


Figma 需要考虑翻转的情况的,此外还有斜切的情况。


翻转的场景:


图片


还有斜切的场景,在选中多个图形然后缩放时有发生。


图片


这些表达光靠上面的几个属性是不够的,我们看看 Figma为了表达这些效果,是怎么去设计矩形的。


Figma 矩形物理属性


上篇文章我们用 Figma-To-JSON 成功解析了 fig 文件,借助这个工具,我们得到了矩形图形的属性。


与物理信息相关的属性如下:


{
  "size": {
    "x"100,
    "y"100
  },
  "transform": {
    "m00"1,
    "m01"3,
    "m02"5,
    "m10"2,
    "m11"4,
    "m12"6
  },
  // 省略其他无关属性
}


没有位置属性,这个属性默认是 (0, 0),实际它转移到 transform 的矩阵的位移子矩阵上了。


size 表示宽高,理论上 width 和 height 语义更好,这样应该是用了平面矢量类型的结构体,所以是 x 和 y。


transform 表示一个 3x3 的变换矩阵。


m00 | m01 | m02
m10 | m11 | m12
0 | 0 | 1


上面的 transform 属性的值所对应的矩阵为:


1 | 3 | 5
2 | 4 | 6
0 | 0 | 1


属性面板


再看看这些属性对应的右侧属性面板。


图片


x、y 分别是 5 和 6,它是 (0, 0) 进行 transform 后的结果,这个直接对应 transform.m02tansfrom.m12


import { Matrix } from "pixi.js";

const matrix = new Matrix(123456);
const topLeft = matrix.apply({ x0y0 }); // { x: 5, y: 6 }

// 或直接点
const topLeft = { x5y6 }


这里引入了 pixi.js 的 matrix 类,该类使用列向量方式进行表达。


文末有 demo 源码以及线上 demo,可打开控制台查看结果验证正确性。



然后这里的 width 和 height,是 223.61 和 500, 怎么来的?


它们对应的是矩形的两条边变形后的长度,如下:


图片


uiWidth 为 (0, 0)(width, 0)  进行矩阵变换后坐标点之间的距离。


const distance = (p1, p2) => {
  const a = p1.x - p2.x;
  const b = p1.y - p2.y;
  return Math.sqrt(a * a + b * b);
};

const matrix = new Matrix(123456);
const topLeft = { x5y6 }

const topRight = matrix.apply({ x100y0 });
distance(topRight, topLeft); // 223.60679774997897

最后计算出 223.60679774997897,四舍五入得到 223.61。


高度计算同理。


uiHeight 为 (0, 0)(0, height)  进行矩阵变换后坐标点之间的距离。


const matrix = new Matrix(123456);
const topLeft = { x5y6 }

const bottomLeft = matrix.apply({ x0y100 });
distance(bottomLeft, topLeft); // 500

旋转角度


最后是旋转角度,它是宽度对应的矩形边向量,逆时针旋转 90 度的向量所对应的角度。


图片


先计算宽边向量,然后逆时针旋转 90 度得到旋转向量,最后计算旋转向量对应的角度。


const wSideVec = { x: topRight.x - topLeft.xy: topRight.y - topLeft.y };
// 逆时针旋转 90 度,得到旋转向量
const rotationMatrix = new Matrix(0, -11000);
const rotationVec = rotationMatrix.apply(wSideVec);
const rad = calcVectorRadian(rotationVec);
const deg = rad2Deg(rad); // -63.43494882292201

这里用了几个工具函数。


// 计算和 (0, -1) 的夹角
const calcVectorRadian = (vec) => {
  const a = [vec.x, vec.y];
  const b = [0, -1]; // 这个是基准角度

  // 使用点积公式计算夹脚
  const dotProduct = a[0] * b[0] + a[1] * b[1];
  const d =
    Math.sqrt(a[0] * a[0] + a[1] * a[1]) * Math.sqrt(b[0] * b[0] + b[1] * b[1]);
  let rad = Math.acos(dotProduct / d);

  if (vec.x > 0) {
    // 如果 x > 0, 则 rad 转为 (-PI, 0) 之间的值
    rad = -rad;
  }
  return rad;
}

// 弧度转角度
const rad2Deg = (rad) => (rad * 180) / Math.PI;

Figma 的角度表示比较别扭。


特征为:基准角度朝上,对应向量为 (0, -1),角度方向为逆时针,角度范围限定为 (-180, 180],计算向量角度时要注意这个特征进行调整。


图片


完整代码实现


线上 demo:


codepen.io/F-star/pen/…


代码实现:


import { Matrix } from "pixi.js";

// 计算和 (0, -1) 的夹角
const calcVectorRadian = (vec) => {
  const a = [vec.x, vec.y];
  const b = [0, -1];

  const dotProduct = a[0] * b[0] + a[1] * b[1];
  const d =
    Math.sqrt(a[0] * a[0] + a[1] * a[1]) * Math.sqrt(b[0] * b[0] + b[1] * b[1]);
  let rad = Math.acos(dotProduct / d);

  if (vec.x > 0) {
    // 如果 x > 0, 则 rad 为 (-PI, 0) 之间的值
    rad = -rad;
  }
  return rad;
}

// 弧度转角度
const rad2Deg = (rad) => (rad * 180) / Math.PI;

const distance = (p1, p2) => {
  const a = p1.x - p2.x;
  const b = p1.y - p2.y;
  return Math.sqrt(a * a + b * b);
};

const getAttrs = (size, transform) => {
  const width = size.x;
  const height = size.y;
  const matrix = new Matrix(
    transform.m00// 1
    transform.m10// 2
    transform.m01// 3
    transform.m11// 4
    transform.m02// 5
    transform.m12 // 6
  );

  const topLeft = { x: transform.m02y: transform.m12 };
  console.log("x:", topLeft.x)
  console.log("y:", topLeft.y)

  const topRight = matrix.apply({ x: width, y0 });
  console.log("width:"distance(topRight, topLeft)); // 223.60679774997897

  const bottomLeft = matrix.apply({ x0y: height });
  console.log("height:"distance(bottomLeft, topLeft)); // 500

  const wSideVec = { x: topRight.x - topLeft.xy: topRight.y - topLeft.y };
  // 逆时针旋转 90 度,得到旋转向量
  const rotationMatrix = new Matrix(0, -11000);
  const rotationVec = rotationMatrix.apply(wSideVec);

  const rad = calcVectorRadian(rotationVec);
  const deg = rad2Deg(rad);
  console.log("rotation:", deg); // -63.43494882292201
};

getAttrs(
  // 宽高
  { x100y100 },
  // 变换矩阵
  {
    m001,
    m013,
    m025,
    m102,
    m114,
    m126,
  }
);


运行一下,结果和属性面板一致。


图片


结尾


Figma 只用宽高和变换矩阵来表达矩形,在数据层可以用精简的数据表达丰富的变形,此外在渲染的时候也能将矩阵运算交给 GPU 进行并行运算,是不错的做法。


我是前端西瓜哥,欢迎关注我,学习更多图形编辑器知识。




相关阅读,


计算机图形学:变换矩阵


什么?Figma 的 fig 文件格式居然解析出来了


求向量的角度


图形编辑器开发:属性显示与格式转换


作者:前端西瓜哥
来源:juejin.cn/post/7314488568969478154
收起阅读 »

RecyclerView无限循环效果实现与解析

前言 前两天在逛博客的时候发现了这样一张直播间的截图,其中这个商品列表的切换和循环播放效果感觉挺好: 熟悉android的同学应该很快能想到这是recyclerView实现的线性列表,其主要有两个效果: 1.顶部item切换后样式放大+转场动画。 2....
继续阅读 »

前言


前两天在逛博客的时候发现了这样一张直播间的截图,其中这个商品列表的切换和循环播放效果感觉挺好:



熟悉android的同学应该很快能想到这是recyclerView实现的线性列表,其主要有两个效果:


1.顶部item切换后样式放大+转场动画。

2.列表自动、无限循环播放。


第一个效果比较好实现,顶部item布局的变化可以通过对RecyclerView进行OnScroll监听,判断item位置,做scale缩放。或者在自定义layoutManager在做layoutChild相关操作时判断第一个可见的item并修改样式。


自动播放则可以通过使用手势判断+延时任务来做。


本文主要提供关于第二个无限循环播放效果的自定义LayoutManager的实现。


正文


有现成的轮子吗?


先看看有没有合适的轮子,“不要重复造轮子”,除非轮子不满足需求。


1、修改adpter和数据映射实现

google了一下,有关recyclerView无限循环的博客很多,内容基本一模一样。大部分的博客都提到/使用了一种修改adpter以及数据映射的方式,主要有以下几步:


1. 修改adapter的getItemCount()方法,让其返回Integer.MAX_VALUE


2. 在取item的数据时,使用索引为position % list.size


3. 初始化的时候,让recyclerView滑到近似Integer.MAX_VALUE/2的位置,避免用户滑到边界。


在逛stackOverFlow时找到了这种方案的出处:
java - How to cycle through items in Android RecyclerView? - Stack Overflow


这个方法是建立了一个数据和位置的映射关系,因为itemCount无限大,所以用户可以一直滑下去,又因对位置与数据的取余操作,就可以在每经历一个数据的循环后重新开始。看上去RecyclerView就是无限循环的。


很多博客会说这种方法并不好,例如对索引进行了计算/用户可能会滑到边界导致需要再次动态调整到中间之类的。然后自己写了一份自定义layoutManager后觉得用自定义layoutManager的方法更好。



其实我倒不这么觉得。


事实上,这种方法已经可以很好地满足大部分无限循环的场景,并且由于它依然沿用了LinearLayoutManager。就代表列表依旧可以使用LLM(LinearLayoutManager)封装好的布局和缓存机制。



  1. 首先索引计算这个谈不上是个问题。至于用户滑到边界的情况,也可以做特殊处理调整位置。(另外真的有人会滑约Integer.MAX_VALUE/2大约1073741823个position吗?

  2. 性能上也无需担心。从数字的直觉上,设置这么多item然后初始化scrollToPosition(Integer.MAX_VALUE/2)看上去好像很可怕,性能上可能有问题,会卡顿巴拉巴拉。


实际从初始化到scrollPosition到真正onlayoutChildren系列操作,主要经过了以下几步。


先上一张流程图:


image.png



  • 设置mPendingScrollPosition,确定要滑动的位置,然后requestLayout()请求布局;


/**
* <p>Scroll the RecyclerView to make the position visible.</p>
*
* <p>RecyclerView will scroll the minimum amount that is necessary to make the
* target position visible. If you are looking for a similar behavior to
* {@link android.widget.ListView#setSelection(int)} or
* {@link android.widget.ListView#setSelectionFromTop(int, int)}, use
* {@link #scrollToPositionWithOffset(int, int)}.</p>
*
* <p>Note that scroll position change will not be reflected until the next layout call.</p>
*
* @param position Scroll to this adapter position
* @see #scrollToPositionWithOffset(int, int)
*/



@Override
public void scrollToPosition(int position) {
mPendingScrollPosition = position;//更新position
mPendingScrollPositionOffset = INVALID_OFFSET;
if (mPendingSavedState != null) {
mPendingSavedState.invalidateAnchor();
}
requestLayout();
}


  • 请求布局后会触发recyclerView的dispatchLayout,最终会调用onLayoutChildren进行子View的layout,如官方注释里描述的那样,onLayoutChildren最主要的工作是:确定锚点、layoutState,调用fill填充布局。


onLayoutChildren部分源码:


@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
// layout algorithm:
// 1) by checking children and other variables, find an anchor coordinate and an anchor
// item position.
// 2) fill towards start, stacking from bottom
// 3) fill towards end, stacking from top
// 4) scroll to fulfill requirements like stack from bottom.

//..............
// 省略,前面主要做了一些异常状态的检测、针对焦点的特殊处理、确定锚点对anchorInfo赋值、偏移量计算
int startOffset;
int endOffset;
final int firstLayoutDirection;
if (mAnchorInfo.mLayoutFromEnd) {
// fill towards start
updateLayoutStateToFillStart(mAnchorInfo); //根据mAnchorInfo更新layoutState
mLayoutState.mExtraFillSpace = extraForStart;
fill(recycler, mLayoutState, state, false);//填充
startOffset = mLayoutState.mOffset;
final int firstElement = mLayoutState.mCurrentPosition;
if (mLayoutState.mAvailable > 0) {
extraForEnd += mLayoutState.mAvailable;
}
// fill towards end
updateLayoutStateToFillEnd(mAnchorInfo);//更新layoutState为fill做准备
mLayoutState.mExtraFillSpace = extraForEnd;
mLayoutState.mCurrentPosition += mLayoutState.mItemDirection;
fill(recycler, mLayoutState, state, false);//填充
endOffset = mLayoutState.mOffset;
if (mLayoutState.mAvailable > 0) {
// end could not consume all. add more items towards start
extraForStart = mLayoutState.mAvailable;
updateLayoutStateToFillStart(firstElement, startOffset);//更新layoutState为fill做准备
mLayoutState.mExtraFillSpace = extraForStart;
fill(recycler, mLayoutState, state, false);
startOffset = mLayoutState.mOffset;
}
} else {
//layoutFromStart 同理,省略
}
//try to fix gap , 省略


  • onLayoutChildren中会调用updateAnchorInfoForLayout更新anchoInfo锚点信息,updateLayoutStateToFillStart/End再根据anchorInfo更新layoutState为fill填充做准备。


  • fill的源码:
    `


int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
RecyclerView.State state, boolean stopOnFocusable)
{
// max offset we should set is mFastScroll + available
final int start = layoutState.mAvailable;
if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
// TODO ugly bug fix. should not happen
if (layoutState.mAvailable < 0) {
layoutState.mScrollingOffset += layoutState.mAvailable;
}
recycleByLayoutState(recycler, layoutState);
}
int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
LayoutChunkResult layoutChunkResult = mLayoutChunkResult;
// (不限制layout个数/还有剩余空间) 并且 有剩余数据
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
layoutChunkResult.resetInternal();
if (RecyclerView.VERBOSE_TRACING) {
TraceCompat.beginSection("LLM LayoutChunk");
}
layoutChunk(recycler, state, layoutState, layoutChunkResult);
if (RecyclerView.VERBOSE_TRACING) {
TraceCompat.endSection();
}
if (layoutChunkResult.mFinished) {
break;
}
layoutState.mOffset += layoutChunkResult.mConsumed * layoutState.mLayoutDirection;
/**
* Consume the available space if:
* * layoutChunk did not request to be ignored
* * OR we are laying out scrap children
* * OR we are not doing pre-layout
*/

if (!layoutChunkResult.mIgnoreConsumed || layoutState.mScrapList != null
|| !state.isPreLayout()) {
layoutState.mAvailable -= layoutChunkResult.mConsumed;
// we keep a separate remaining space because mAvailable is important for recycling
remainingSpace -= layoutChunkResult.mConsumed;
}

if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
layoutState.mScrollingOffset += layoutChunkResult.mConsumed;
if (layoutState.mAvailable < 0) {
layoutState.mScrollingOffset += layoutState.mAvailable;
}
recycleByLayoutState(recycler, layoutState);//回收子view
}
if (stopOnFocusable && layoutChunkResult.mFocusable) {
break;
}
}
if (DEBUG) {
validateChildOrder();
}
return start - layoutState.mAvailable;

fill主要干了两件事:



  • 循环调用layoutChunk布局子view并计算可用空间

  • 回收那些不在屏幕上的view


所以可以清晰地看到LLM是按需layout、回收子view。


就算创建一个无限大的数据集,再进行滑动,它也是如此。可以写一个修改adapter和数据映射来实现无限循环的例子,验证一下我们的猜测:


//adapter关键代码
@NonNull
@Override
public DemoViewHolder onCreateViewHolder(@NonNull ViewGr0up parent, int viewType) {
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
Log.d("DemoAdapter","onCreateViewHolder");
return new DemoViewHolder(inflater.inflate(R.layout.item_demo, parent, false));
}

@Override
public void onBindViewHolder(@NonNull DemoViewHolder holder, int position) {
Log.d("DemoAdapter","onBindViewHolder: position"+position);
String text = mData.get(position % mData.size());
holder.bind(text);
}

@Override
public int getItemCount() {
return Integer.MAX_VALUE;
}

在代码我们里打印了onCreateViewHolder、onBindViewHolder的情况。我们只要观察这viewHolder的情况,就知道进入界面再滑到Integer.MAX_VALUE/2时会初始化多少item。
`


RecyclerView recyclerView = findViewById(R.id.rv);
recyclerView.setAdapter(new DemoAdapter());
LinearLayoutManager layoutManager = new LinearLayoutManager(this);
layoutManager.setOrientation(RecyclerView.VERTICAL);
recyclerView.setLayoutManager(layoutManager);
recyclerView.scrollToPosition(Integer.MAX_VALUE/2);

初始化后ui效果:



日志打印:
image.png


可以看到,页面上共有5个item可见,LLM也按需创建、layout了5个item。


2、自定义layoutManager

找了找网上自定义layoutManager去实现列表循环的博客和代码,拷贝和复制的很多,找不到源头是哪一篇,这里就不贴链接了。大家都是先说第一种修改adapter的方式不好,然后甩了一份自定义layoutManager的代码。


然而自定义layoutManager难点和坑都很多,很容易不小心就踩到,一些博客的代码也有类似问题。
基本的一些坑点在张旭童大佬的博客中有提及,
【Android】掌握自定义LayoutManager(一) 系列开篇 常见误区、问题、注意事项,常用API。


比较常见的问题是:



  1. 不计算可用空间和子view消费的空间,layout出所有的子view。相当于抛弃了子view的复用机制

  2. 没有合理利用recyclerView的回收机制

  3. 没有支持一些常用但比较重要的api的实现,如前面提到的scrollToPosition。


其实最理想的办法是继承LinearLayoutManager然后修改,但由于LinearLayoutManager内部封装的原因,不方便像GridLayoutManager那样去继承LinearLayoutManager然后进行扩展(主要是包外的子类会拿不到layoutState等)。


要实现一个线性布局的layoutManager,最重要的就是实现一个类似LLM的fill(前面有提到过源码,可以翻回去看看)和layoutChunk方法。


(当然,可以照着LLM写一个丐版,本文就是这么做的。)


fill方法很重要,就如同官方注释里所说的,它是一个magic func。


从OnLayoutChildren到触发scroll滑动,都是调用fill来实现布局。


/**
* The magic functions :). Fills the given layout, defined by the layoutState. This is fairly
* independent from the rest of the {@link LinearLayoutManager}
* and with little change, can be made publicly available as a helper class.
*/

int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
RecyclerView.State state, boolean stopOnFocusable)
{

前面提到过fill主要干了两件事:



  • 循环调用layoutChunk布局子view并计算可用空间

  • 回收那些不在屏幕上的view


而负责子view布局的layoutChunk则和把一个大象放进冰箱一样,主要分三步走:



  1. add子view

  2. measure

  3. layout 并计算消费了多少空间



就像下面这样:


/**
* layout具体子view
*/

private void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,
LayoutState layoutState, LayoutChunkResult result)
{
View view = layoutState.next(recycler, state);
if (view == null) {
result.mFinished = true;
return;
}
RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams();

// add
if (layoutState.mLayoutDirection != LayoutState.LAYOUT_START) {
addView(view);
} else {
addView(view, 0);
}

Rect insets = new Rect();
calculateItemDecorationsForChild(view, insets);

// 测量
measureChildWithMargins(view, 0, 0);

//布局
layoutChild(view, result, params, layoutState, state);

// Consume the available space if the view is not removed OR changed
if (params.isItemRemoved() || params.isItemChanged()) {
result.mIgnoreConsumed = true;
}
result.mFocusable = view.hasFocusable();
}

那最关键的如何实现循环呢??


其实和修改adapter的实现方法有异曲同工之妙,本质都是修改位置与数据的映射关系。


修改layoutStae的方法:


    boolean hasMore(RecyclerView.State state) {
return Math.abs(mCurrentPosition) <= state.getItemCount();
}


View next(RecyclerView.Recycler recycler, RecyclerView.State state) {
int itemCount = state.getItemCount();
mCurrentPosition = mCurrentPosition % itemCount;
if (mCurrentPosition < 0) {
mCurrentPosition += itemCount;
}
final View view = recycler.getViewForPosition(mCurrentPosition);
mCurrentPosition += mItemDirection;
return view;
}

}

最终效果:



源码地址:aFlyFatPig/cycleLayoutManager (github.com)


注:也可以直接引用依赖使用,详见readme.md。


后记


本文介绍了recyclerview无限循环效果的两种不同实现方法与解析。


虽然自定义layoutManager坑点很多并且很少用的到,但了解下也会对recyclerView有更深的理解。


作者:紫槐
来源:juejin.cn/post/7215200495983214629
收起阅读 »

天天看到有人抵触ref,有人抵触reactive,把我整笑了

web
背景 这几天看到好多文章标题都是类似于: 不用 ref 的 xx 个理由 不用 reactive 的 xx 个理由 历数 ref 的 xx 宗罪 我就很不解,到底是什么原因导致有这两批人: 抵触 ref 的人 抵触 reactive 的人 看了这些文章...
继续阅读 »

背景


这几天看到好多文章标题都是类似于:



  • 不用 ref 的 xx 个理由

  • 不用 reactive 的 xx 个理由

  • 历数 ref 的 xx 宗罪


我就很不解,到底是什么原因导致有这两批人:



  • 抵触 ref 的人

  • 抵触 reactive 的人


看了这些文章,我可以总结出他们的想法


抵触 reactive 的人


抵触 reactive 的人,他们的想法大概就是:



  • 1、Vue 官方推荐 ref

  • 2、reactive 有类型限制,ref 没有

  • 3、reactive 使用不当会丢失响应式,比如解构

  • 4、reactive 无法修改整个对象的值


抵触 ref 的人


抵触 ref 的人,他们的想法大概就是:



  • 1、ref 的底层其实就是 reactive,用 ref 相当于多了一层,耗费性能

  • 2、ref 的 .value 用起来很麻烦,增加使用者心里负担

  • 3、ref 到模板的时候会解掉 value 这一层,这时候也会耗费性能


把我整笑了~


说实话,看到这些文章,有点把我整笑了,其实你要用 ref 或者 reactive 都没错,但是没比必要那么抵触,编程很多时候并不是非黑即白啊。。。


既然 Vue3 推出了 ref 和 reactive,那就说明他们都有存在的必要,在项目中不同的场景去运用他们,我觉得才是最好的,而不是用一个不用另一个,不止这两个,还有很多其他好用的 Vue3 API


我想针对这两批人的想法做一个回应:


回应 -> 抵触 reactive 的人



  • 1、官方是推荐,不是抵触

  • 2、reactive 既然有类型限制,那就在特定时候用 reactive 就行

  • 3、使用不当会丢失响应式?那就是开发者对于 Vue3 API 的使用还不熟

  • 4、用 Object.assign 就可以修改整个对象的值


回应 -> 抵触 ref 的人



  • 1、耗费性能的话,这么久了,也没人贴出到底耗费了多少性能?

  • 2、.value 不麻烦,我觉得 .value 可以起到辨别响应式和非响应式数据的效果,而且现在编辑器都有插件提供的代码补全了,多个 .value 也花不了多少时间吧?


灵活使用 Vue3 API 才是王道


其实在平时开发中,我觉得基本数据类型和数组,都可以用 ref 来管理,而对象的话可以使用 reactive 来管理,比如表单对象、状态对象


其实 Vue3 不止有这两个 API ,还有很多其他 API ,也很好用,大家只要去灵活使用它们,能让你的Vue3 项目上一个层次


readonly


顾名思义,就是只读的意思,如果你的数据被这个 API 包裹住的话,那么修改之后并不会触发响应式,并且会提示警告




readonly 的用途一般用于一些 hooks 暴露出来的变量,不想外界去修改,比如我封装一个 hooks,这样去做的话,那么外界只能用变量,但是不能修改变量,这样大大保护了 hooks 内部的逻辑~



shallowRef


shallowRef 用来包住一个基础类型或者引用类型,如果是基础类型那么跟 ref 基本没区别,如果是引用类型的话,那么直接改深层属性是不能触发响应式的,除非直接修改引用地址,如下:




注意:改深层属性能改数据,只是没触发响应式,所以当下一次响应式触发的时候,你修改的深层数据会渲染到页面上~



shallowRef 的用处主要用于一些比较大的但又变化不大的数据,比如我有一个表格数据,通过接口直接获取,并且主要用在前端展示,需要修改一些深层的属性,但是这些属性并不需要立即表现在页面上,比如以下例子,我只需要展示 name、age 字段,至于 isOld 字段并不需要展示,我想要计算 isOld 但是又不想触发响应式更新,所以可以用 shallowRef 包起来,进而减少响应式更新,优化性能



shallowReactive


shallowReactive 用来包住一个引用类型,被包住后,修改第一层才会触发响应式更新,也就是浅层的属性,修改深层的属性并不会触发响应式更新



注意:改深层属性能改数据,只是没触发响应式,所以当下一次响应式触发的时候,你修改的深层数据会渲染到页面上~




shallowReactive 用的比较少,shallowReactive 的用处跟 shallowRef 比较像,都是为了让一些比较大的数据能减少响应式更新,进而优化性能


toRef & toRefs


先说说 toRef 吧,我们平时在使用 reactive 的时候会有一个苦恼,那就是解构,比如看以下例子,我们为了少些一些代码,解构出来了 name 并放到模板里渲染,但是当我们想改原数据的时候,发现 name 并不会更新,这就是解构出来基础类型的苦恼




这时我们可以使用 toRef,这个时候我们直接修改 name 也会触发原数据的修改,修改原数据也会触发 name 的修改




但是如果是属性太多了,我们想一个一个去用 toRef 的话会写很多代码



所以我们可以使用 toRefs 一次性解构



toRaw & markRaw & unref


toRaw 可以把一个响应式 reactive 转成普通对象,也就是把响应式对象转成非响应式对象



toRaw 主要用在回调传参中,比如我封装一个 hooks,我想要把 hooks 内维护的响应式变量转成普通数据,当做参数传给回调函数,可以用 toRaw



markRaw 可以用来标记响应式对象里的某个属性不被追踪,如果你的响应式对象里有某个属性数据量比较大,但又不想被追踪,你可以使用 markRaw



unref 相当于返回 ref 的 value



effectScope & onScopeDispose


effectScope 可以有两个作用:



  • 收集副作用

  • 全局状态管理


收集副作用


比如我们封装一个共用的 hooks,为了减少页面隐患,肯定会统一收集副作用,并且在组件销毁的时候去统一消除,比如以下代码:



但是这么收集很麻烦, effectScope 能帮我们做到统一收集,并且通过 stop 方法来进行清除,且 stop 执行的时候会触发 effectScope 内部的 onScopeDispose



我们可以利用 effectScope & onScopeDispose 来做一些性能优化,比如下面这个例子,我们封装一个鼠标监听的 hooks



但是如果在页面里调用多次的话,那么势必会往 window 身上监听很多多余的事件,造成性能负担,所以解决方案就是,无论页面里调用再多次 useMouse,我们只往 window 身上加一个鼠标监听事件



全局状态管理


现在 Vue3 最火的全局状态管理工具肯定是 Pinia 了,那么你们知道 Pinia 的原理是什么吗?原理就是依赖了 effectScope



所以我们完全可以自己使用 effectScope 来实现自己的局部状态管理,比如我们封装一个通用组件,这个组件层级比较多,并且需要共享一些数据,那么这个时候肯定不会用 Pinia 这种全局状态管理,而是会自己写一个局部的状态管理,这个时候 effectScope 就可以排上用场了


vueuse 中的 createGlobalState 就是为了这个而生




provide & inject


Vue3 用来提供注入的 API,主要是用在组件的封装,比如那种层级较多的组件,且子组件需要依赖父组件甚至爷爷组件的数据,那么可以使用 provide & inject,最典型的例子就是 Form 表单组件,可以去看看各个组件库的源码,表单组件大部分都是用 provide & inject 来实现的,比如 Form、Form-Item、Input这三个需要互相依赖对方的规则、字段名、字段值,所以用 provide & inject 会更好。具体用法看文档吧~cn.vuejs.org/guide/compo…



结语 & 加学习群 & 摸鱼群


我是林三心



  • 一个待过小型toG型外包公司、大型外包公司、小公司、潜力型创业公司、大公司的作死型前端选手;

  • 一个偏前端的全干工程师;

  • 一个不正经的掘金作者;

  • 一个逗比的B站up主;

  • 一个不帅的小红书博主;

  • 一个喜欢打铁的篮球菜鸟;

  • 一个喜欢历史的乏味少年;

  • 一个喜欢rap的五音不全弱鸡


如果你想一起学习前端,一起摸鱼,一起研究简历优化,一起研究面试进步,一起交流历史音乐篮球rap,可以来俺的摸鱼学习群哈哈,点这个,有7000多名前端小伙伴在等着一起学习哦 --> 摸鱼沸点


作者:Sunshine_Lin
来源:juejin.cn/post/7311226497111916563
收起阅读 »

Android 自制照片选择器

自制照片选择器Android 从 11 版本后提供了照片选择器看起来确实不错,本来想直接调用 Android 提供的照片选择器的,不用自己再去做缩略图缓存也不用处理麻烦的 API 结果定睛一看发现几个大问题Android 提供的照片选择器必须升级 App 的 ...
继续阅读 »

自制照片选择器

Android 从 11 版本后提供了照片选择器

image-20231221130815793.png

看起来确实不错,本来想直接调用 Android 提供的照片选择器的,不用自己再去做缩略图缓存也不用处理麻烦的 API 结果定睛一看发现几个大问题

  1. Android 提供的照片选择器必须升级 App 的 androidx.activity 库到 1.7.0 版本,这可能意味着 app 的 targetSdkVersion 也得升级,同时需要处理好其他兼容性问题;
  2. Android 提供的照片选择器仅限搭载 Android 11(API 级别 30)或更高版本使用,其他的版本需要通过 Google 系统更新接收对模块化系统组件的更改,如果在低版本使用可能会调用 ACTION_OPEN_DOCUMENT 的 intent 操作来实现,这意味着很多现在例如限制选择几张照片可能不生效,这与需求严重不符。
  1. 能从网上找到的资料可以发现 Android 提供的照片选择器的 API 在变化,实际使用确实很难受。

综上,还不如自己做一个咯🤷‍♂️

开始动手

UI

UI 方面就照着 Google 的抄就好,图片加载用 Glide 来完成,参考微信的照片选择一列默认显示 4 个缩略图就好,然后用 RecyclerView 实现网格状列表容器,基于 DialogX 的 FullScreenDialog 对话框打底实现 activity 界面下沉效果以及从屏幕底部上移的对话框,准备就绪,开干!

复写 RecyclerView.Adapter 实现 PhotoAdapter,在其中用 Glide 加载照片并 override 尺寸进行加载和缓存以避免界面卡顿:

Glide.with(context)
      .load(imageUrls.get(position))
      .override(imageSize)
      .error(errorPhotoDrawableRes)
      .int0((PhotoSelectImageView) holder.itemView);

当照片被选中时,为了实现选中状态的图片缩小,增加边框和对钩图示,自定义了一个 PhotoSelectImageView 作为缩略图呈现使用,图片缩小效果直接用 padding 实现,边框绘制代码:

canvas.drawRect(0 + getBorderWidth() / 2, 0 + getBorderWidth() / 2, getWidth() - getBorderWidth() / 2, getHeight() - getBorderWidth() / 2, paint);

图库部分带圆角,边框的绘制代码调整为:

RectF rect = new RectF(0 + getBorderWidth() / 2, 0 + getBorderWidth() / 2, getWidth() - getBorderWidth() / 2, getHeight() - getBorderWidth() / 2);
canvas.drawRoundRect(rect, radius, radius, paint);

最后绘制标记:

//init 初始化部分代码:
//从图片资源加载
selectFlagBitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.album_dialog_img_selected);
//按照主题色染色
Bitmap tintedBitmap = Bitmap.createBitmap(selectFlagBitmap.getWidth(), selectFlagBitmap.getHeight(), Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(tintedBitmap);
Paint paint = new Paint();
paint.setColorFilter(new PorterDuffColorFilter(getResources().getColor(R.color.albumDefaultThemeDeep), PorterDuff.Mode.SRC_IN));

//...

//onDraw 部分代码
canvas.drawBitmap(selectFlagBitmap, null, selectFlagRect, paint);

PhotoSelectImageView 的呈现效果:

image-20231221132323779.png

RecyclerView 设置一个间隔装饰器 GridSpacingItemDecoration,指定 item 的间距:

@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
   int position = parent.getChildAdapterPosition(view);
   int column = position % spanCount;
   if (column >= 1) {
       outRect.left = spacing;
  }
   if (position >= spanCount) {
       outRect.top = spacing;
  }
}

基本上界面主体就完活了,额外的实现了一个相册列表的 Adapter,复用 RecyclerView 进行显示,区别就在于内容还需要考虑到相册名字的呈现:

image-20231221132715672.png

接下来就是相册的读取了,在开始之前首先需要申请权限。

权限处理

API-33 以前使用存储文件读取权限 READ_EXTERNAL_STORAGE 即可,API - 33 以后则需要使用 READ_MEDIA_IMAGES 权限,因此需要先在 AndroidManifest 声明这两个权限:

name="android.permission.READ_EXTERNAL_STORAGE"/>
name="android.permission.READ_MEDIA_IMAGES" />

使用代码申请:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
   if (ContextCompat.checkSelfPermission(activityContext, Manifest.permission.READ_MEDIA_IMAGES) != PackageManager.PERMISSION_GRANTED) {
       ActivityCompat.requestPermissions(activityContext, new String[]{Manifest.permission.READ_MEDIA_IMAGES}, PERMISSION_REQUEST_CODE);
       return false;
  }
} else {
   if (ContextCompat.checkSelfPermission(activityContext, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
       ActivityCompat.requestPermissions(activityContext, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, PERMISSION_REQUEST_CODE);
       return false;
  }
}

本来想用 registerForActivityResult,至于为啥没用?别提那玩意了基本上就是一坨...

接下来有了权限,就只需要使用 MediaStore 读取所有相册和照片就可以完成实现了。

MediaStore 读取照片

MediaStore 和传统以文件方式读取照片的形式有所区别,它是一个媒体数据库,这意味着需要用读取数据库的思路去操作它。

首先是依据相册名称读取照片,如果相册名称为空则认为是所有照片,核心代码如下:

List photos = new ArrayList<>();
Uri images = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
String[] projection = new String[]{
       MediaStore.Images.Media.DATA,
       MediaStore.Images.Media.DATE_ADDED
};
String selection;
String[] selectionArgs;
if (isNull(albumName)) {
   selection = null;
   selectionArgs = null;
} else {
   selection = MediaStore.Images.Media.BUCKET_DISPLAY_NAME + " = ?";
   selectionArgs = new String[]{albumName};
}
Cursor cur = context.getContentResolver().query(images,
       projection,
       selection,
       selectionArgs,
       null);
if (cur != null && cur.moveToFirst()) {
   int dataColumn = cur.getColumnIndex(MediaStore.Images.Media.DATA);
   do {
       String photoPath = cur.getString(dataColumn);
       photos.add(photoPath);
  } while (cur.moveToNext());
}
if (cur != null) {
   cur.close();
}

photos 即查询到的所有照片列表了,但还需要处理为按照最近时间倒序,添加 sortOrder 即可:

sortOrder = MediaStore.Images.Media.DATE_ADDED + " DESC"

添加 sortOrder 到 query 最后一个参数即可。这里的 MediaStore.Images.Media.DATE_ADDED 代表着按照添加到媒体库的时间排序,另外也可以选择 MediaStore.MediaColumns.DATE_TAKEN 按照拍摄时间排序,至于 DESC 就是倒序的意思了。

然后还需要查询所有相册,查询到的相册名称可能有重复的需要剔重。

//读取相册列表
List albums = new ArrayList<>();
String[] projection = new String[]{
       MediaStore.Images.Media.BUCKET_DISPLAY_NAME,
       MediaStore.Images.Media.BUCKET_ID
};
Uri images = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
Cursor cur = context.getContentResolver().query(images,
       projection,
       null,      
       null,      
       null      
);
if (cur != null && cur.moveToFirst()) {
   int bucketColumn = cur.getColumnIndex(MediaStore.Images.Media.BUCKET_DISPLAY_NAME);
   do {
       String albumName = cur.getString(bucketColumn);
       if (!albums.contains(albumName) && !isNull(albumName)) albums.add(albumName);
  } while (cur.moveToNext());
}
if (cur != null) {
   cur.close();
}

在 UI 呈现时按照相册名称读取最后一张图片作为封面图即可。

至此,自制照片选择器就基本上完成了,相关完整代码已经开源到 Github 上,欢迎参考学习 github.com/kongzue/Dia…,DialogXSample 是基于 DialogX 对话框框架的一系列功能模块扩展包,目前也提供了 地址滚动选择对话框、日期/日历(区间)选择对话框、分享选择对话框、自定义联动滚动选择对话框、底部弹出的评论输入对话框、选择(多选/筛选)文件对话框、抽屉对话框和照片选择器的 Demo 代码。

一键使用

照片选择器直接引入的 gradle 配置如下:

在 build.gradle(Project)(新版本 Android Studio 请在 settings.gradle)添加 jitpack 仓库:

allprojects {
  repositories {
      ...
      maven { url 'https://jitpack.io' }
  }
}
def dialogx_sample_version = "0.0.10"
implementation 'com.github.kongzue.DialogXSample:AlbumDialog:${dialogx_sample_version}'

额外的还需引入:

def DIALOGX_VERSION = "0.0.50.beta2"
implementation "com.github.kongzue.DialogX:DialogX:${DIALOGX_VERSION}"
implementation 'com.github.bumptech.glide:glide:4.12.0'
implementation "androidx.recyclerview:recyclerview:1.2.1"

如果默认的就能满足你的业务需求,直接引入对应功能的包即可,如果不能,请自行拉取代码集成到自己的项目里修改使用


作者:Kongzue
来源:juejin.cn/post/7314642642868715554

收起阅读 »

CSS整洁之道——:is()、:where()和:has()的用法

web
让我们写出优雅界面的CSS,它也总是把自己进化得更加优雅。 今天我们花5分钟时间学习三个优雅的CSS伪类::is()、:where() 和 :has()。 :is() - 取代组合选择器 :is() 允许你在一个规则中包含多个选择器。它接受一组选择器作为参数,...
继续阅读 »

让我们写出优雅界面的CSS,它也总是把自己进化得更加优雅。


今天我们花5分钟时间学习三个优雅的CSS伪类::is():where():has()


:is() - 取代组合选择器


:is() 允许你在一个规则中包含多个选择器。它接受一组选择器作为参数,并应用样式到匹配的元素上。


/* 传统方法 */
ul > li > a,
ol > li > a,
nav > ul > li > a,
nav > ol > li > a {
color: blue;
}

/* 使用 :is() */
:is(ul, ol, nav > ul, nav > ol) > li > a {
color: blue;
}

:is() 可以简化多层嵌套和多种选择器组合的写法,让你维护样式更方便。


:is() 优先级依然遵循CSS选择器的优先级规则,即 ID -> 类 -> 元素 的顺序。


:is(.class1) a {
color: blue;
}

:is(#id1) a {
color: red;
}


这段代码里两条规则如果命中相同的元素,那么第二条会优先应用。


:is() 的参数也可以传一个匹配规则


:is([class^="is-styling"]) a {
color: yellow;
}

这样的写法会匹配所有 class 开头是 is-styling 的选择器。


:where() - 拥有最低优先级


:where():is() 相似,都可以传入选择器或者匹配规则来简化你的CSS代码。


:where([class^="where-styling"]) a {
color: yellow;
}

但和 :is() 不同的是,:where() 拥有最低优先级,这样的好处是它定义的样式规则不会影响其他样式规则,避免了样式冲突。


/* <footer class="where-styling">……</footer> */

footer a {
color: green;
}

:where([class^="where-styling"]) a {
color: red
}


当有其他规则和 :where() 同时被命中时,:where() 一定是失效的。所以上面这个例子实际效果是链接显示绿色。


:has() - 基于其他元素进行匹配


:has() 可以根据直接后代元素的存在来匹配元素


/* 选择直接包含 p 元素的 div */
div:has(> p) {
border: 1px solid black;
}

也可以根据紧邻的下一个兄弟元素来匹配元素


/* 选择后面跟着 p 元素的 div */
div:has(+ p) {
border: 1px solid black;
}

你还可以把它跟其他伪类一起使用,比如 :has():is() 一起使用



:has() 使用场景很多,只要是强互动的页面都可能用到,以后有机会单独分享一篇~


总结


大部分浏览器的新版本都已支持 :is():where():has() 这三个伪类了,如果你的项目跑在低版本的浏览器中,那么需要考虑一下回退策略。


专栏资源


专栏介绍:分享CSS新特性和好看的样式设计

专栏地址:👉# CSS之美


作者:BigYe程普
来源:juejin.cn/post/7314841908169850891
收起阅读 »

js中?.、??、??=的用法及使用场景

web
上面这个错误,相信前端开发工程师应该经常遇到吧,要么是自己考虑不全造成的,要么是后端开发人员丢失数据或者传输错误数据类型造成的。因此对数据访问时的非空判断就变成了一件很繁琐且重要的事情,下面就介绍ES6一些新的语法来方便我们开发。 1. 可选链操作符 (Opt...
继续阅读 »

image.png


上面这个错误,相信前端开发工程师应该经常遇到吧,要么是自己考虑不全造成的,要么是后端开发人员丢失数据或者传输错误数据类型造成的。因此对数据访问时的非空判断就变成了一件很繁琐且重要的事情,下面就介绍ES6一些新的语法来方便我们开发。


1. 可选链操作符 (Optional Chaining Operator - ?.):


可选链操作符允许您在访问对象属性或调用函数时,检查中间的属性是否存在或为 null/undefined。如果中间的属性不存在或为空,表达式将短路返回 undefined,而不会引发错误。


1.1 用法示例:


const obj = {
foo: {
bar: {
baz: 42
}
},
xyz: []
};


// 使用可选链操作符
const value1 = obj?.foo?.bar?.baz; // 如果任何中间属性不存在或为空,value 将为 undefined
//除了对属性的检查,还可以用于对数组下标及函数的检查
const value2 = obj?.xyz?.[0]?.fn?.();

// 传统写法
const value1 = obj && obj.foo && obj.foo.bar && obj.foo.bar.baz; // 需要手动检查每个属性
const value2 = obj && obj.xyz && obj.xyz[0] && obj.xyz[0].fn && obj.xyz[0].fn();

1.2 使用场景:



  • 链式访问对象属性,而不必手动检查每个属性是否存在。

  • 调用可能不存在的函数。


2. 空值合并操作符 (Nullish Coalescing Operator - ??):


空值合并操作符用于选择性地提供默认值,仅当变量的值为 null 或 undefined 时,才返回提供的默认值。否则,它将返回变量的实际值。


2.1 用法示例:


const foo = null;
const bar = undefined;
const baz = 0;
const qux = '';
cosnt xyz = false;

const value1 = foo ?? 'default'; // 'default',因为 foo 是 null
const value2 = bar ?? 'default'; // 'default',因为 bar 是 undefined
const value3 = baz ?? 'default'; // 0,因为 baz 不是 null 或 undefined
const value4 = qux ?? 'default'; // '',因为 qux 不是 null 或 undefined
const value5 = xyz ?? 'default'; // false,因为 xyz 不是 null 或 undefined

//可能存在的传统写法,除了null,undefined, 无法兼容0、''、false的情况,使用时要特别小心
const value1 = foo || 'default'; // 'default'
const value2 = bar || 'default'; // 'default'
const value3 = baz || 'default'; // 'default',因为 0 转布尔类型是 false
const value4 = qux || 'default'; // 'default',因为 '' 转布尔类型是 false
const value5 = xyz || 'default'; // 'default'

2.2 使用场景:



  • 提供默认值,而不使用 falsy 值(如空字符串、0 等)。

  • 在处理可能为 null 或 undefined 的变量时,选择性地提供备用值。


3. 空值合并赋值操作符 (Nullish Coalescing Assignment Operator - ??=):


空值合并赋值操作符结合了空值合并操作符和赋值操作符。它用于将默认值分配给变量,仅当变量的值为 null 或 undefined 时。


3.1 用法示例:


let foo = null;
let bar = undefined;
let baz = 0;

foo ??= 'default'; // 'default',因为 foo 是 null
bar ??= 'default'; // 'default',因为 bar 是 undefined
baz ??= 'default'; // 0,因为 baz 的初始值不是 null 或 undefined

3.2 使用场景:



  • 在变量没有被赋值或被赋值为 null 或 undefined 时,将默认值分配给变量。


4. 注意:


这些运算符在处理可能为 null 或 undefined 的值时非常有用,可以简化代码并提高可读性。然而,需要注意的是,它们是在 ECMAScript 2020 标准中引入的,因此在旧版本的 JavaScript 中可能不被支持。


作者:阿虎儿
来源:juejin.cn/post/7270900584466513974
收起阅读 »

争论不休:金额用Long还是BigDecimal?

问题 今天在网上看到一个有意思的问题,金额的数据类型用Long还是BigDecimal? 具体问题大概是这样的:关于金额的数据类型,组长认为使用BigDecimal比较稳妥,总监认为使用Long才不会出问题,然后开发认为Long用起来比较爽。 从这两个数据类...
继续阅读 »

问题


今天在网上看到一个有意思的问题,金额的数据类型用Long还是BigDecimal?


具体问题大概是这样的:关于金额的数据类型,组长认为使用BigDecimal比较稳妥,总监认为使用Long才不会出问题,然后开发认为Long用起来比较爽。



从这两个数据类型来看,这家公司使用的开发语言应该是Java,不过换成其它开发语言,也有类似的数据类型选择问题,这是一个广泛存在的问题,所以可以和大家好好聊聊。


网友方案


针对这个问题,热情的网友们从各自的经历出发,提供了很多方案。我大概总结了下,居然有十种之多,虽然有的像调侃,但都有一定的道理。相信大家也很好奇,所以这里我先分享下网友们的方案。


Long




解读:单位到分,没有小数点,也就没有小数精度的问题。而且Long取值范围也足够了。


BigDecimal




解读:大家都这么用,BigDecimal就是为精确计算而生的。用long不专业,适应性不好。


Long和BigDecimal




解读:成年人不做选择,成年人什么都要。金额、价格这些用Long,汇率、费率这些要求的小数点比较多,那就用BigDecimal。


String




解读:万物皆可string。只是处理规则需要全部自己写,高手必备的技能。


Protobuf



解读:脱离框架讲方案都是耍流氓。Protobuf里边根本就没有BigDecimal,虽然可以用string或者自定义类型来代表Java中的BigDecimal,不过性能可能要差那么一点点。


自定义




解读:架构师的好苗子。程序不是能跑起来、不出错就行了,要考虑设计能不能自然体现业务需求,好不好理解、扩展和维护。


听领导的




解读:霍金来了中国也得站起来敬酒。这根本不是技术问题,一切听领导指示,但是也要做好自我保护。


问AI



解读:紧跟时代风口。作为有追求的技术人,就应该想着怎么偷懒怎么最快,先进的生产力工具要用起来,大语言模型回答这个问题滴水不漏、手到擒来,不信你试试!


节省型




解读:节俭是美德。就几百块钱的货,又不是航母和火箭,根本用不着Long,用int、short,甚至byte就能满足。


莫名其妙



解读:这个特定芯片是说CPU做不了浮点数运算吗?还是说不同的CPU浮点数运算的算法不同?那编程语言不能直接处理这个问题吗?还需要开发者关心。不懂,真不懂,完全不懂,请有经验的大神帮解答下。


根本问题


俗话说,结局问题先得明确问题。那么这到底是个什么问题呢?归根到底还是小数的精度问题。


有时候是根本除不尽,比如10除以3;有时候是因为小数的表达问题,编程语言中带小数的数据类型一般是float和double,它们内部使用科学计数法,转换二进制的时候有可能出现无限小数位的问题,比如Javascript中的0.1+0.2算出来就不是0.3。


所以为了避免此类问题,大家想出来了各种各样的方法。


其实使用Long和BigDecimal的本质都是一样的,他们内部都是通过整数记录值,只是Long属于隐式设定小数点,BigDecimal属于显示设定小数点。


比如,使用Long表示价格时,系统约定单位是分,那么9999就代表99.99元;而使用BigDecimal表示价格时,则需要明确小数位 new BigDecimal("99.99")。


另外不管是Long还是BigDecaimal只要发生除不尽,就存在精度问题。


解决方案


这里我做个总结。


在程序中处理金额时,最佳实践通常是使用类似BigDecimal的数据类型,因为它提供了精确的小数运算能力,这对于财务计算来说非常重要。使用BigDecimal可以避免因浮点数的精度问题导致的计算误差,这些误差在金融应用中可能会导致严重的问题。


BigDecimal可以精确地表示和计算小数,它允许你定义小数点后的精度,并且提供了一系列的舍入模式。这意味着当你需要执行加减乘除时,可以控制舍入行为以符合金融计算的要求。


另一方面,虽然使用Long类型来表示金额(通常以分为单位)也是一种选择,因为它避免了小数的使用,从而也能保证精确性。但是,这种方法在表示和处理小数时就不那么直观,而且在需要进行货币转换或者涉及到小数的计算时,你必须自己管理小数点的位置。


例如,如果使用Long表示金额,你需要记住金额是以分为单位还是以元为单位,而且在报告或用户界面中显示金额时,通常需要将金额转换为以元为单位的格式,这就需要额外的计算步骤。


所以,虽然Long类型也可以用来精确地表示金额,但是为了代码的可读性、易用性和减少手动处理小数点的错误,推荐使用BigDecimal来处理金额。这是一种更安全、更灵活的方法,尤其是在需要精确计算小数时。


其它使用string或者自定义类的方案,当然也可以,只是需要更多的工作来完善数据处理的各种规则,容易出错,也不规范,为什么不使用现成的BigDecimal呢?




以上就是本文的主要内容。


关注萤火架构,提升技术不迷路!


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

用lodash开发前端,真香!

web
前言 在日常的前端开发中,总是涉及到对数据的处理,比如后端返给你一坨数据,你需要进行处理并回显到页面上,又或者提交form表单到服务端时,你需要将数据处理成后端接口定义的数据结构,而这些都离不开数据处理。 那数据处理有什么好用的工具库吗?lodash当之无愧。...
继续阅读 »

前言


在日常的前端开发中,总是涉及到对数据的处理,比如后端返给你一坨数据,你需要进行处理并回显到页面上,又或者提交form表单到服务端时,你需要将数据处理成后端接口定义的数据结构,而这些都离不开数据处理。


那数据处理有什么好用的工具库吗?lodash当之无愧。


lodash使用


使用:


// 浏览器环境
<script src="lodash.js"></script>
// npm
npm i --save lodash

接下来给大家介绍下我平时开发用lodash最最最常用的一些方法。


一、数组类


1、_.compact(array)


作用:剔除掉数组中的假值假值包括falsenull,0""undefined, 和 NaN这5个)元素,并返回一个新数组。


使用示例


const _ = require('lodash')
console.log(_.compact([0, 1, false, 2, '', 3, undefined, 4, null, 5]));
// 输出 [ 1, 2, 3, 4, 5 ]

项目中的应用:剔除数组中的一些脏数据。


2、_.difference(array, [values])


作用:过滤掉数组中的指定元素,并返回一个新数组


使用示例


const _ = require('lodash')
console.log(_.difference([1, 2, 3], [2, 4]))
// 输出 [ 1, 3 ]
const arr = [1, 2], obj = { a: 1 }
console.log(_.difference([1, arr, [3, 4], obj, { a: 2 }], [1, arr, obj]))
// 输出 [ 1, 3 ]

类似方法



  • _.pull(array, [values]),与_.difference不同之处在于_.pull会改变原数组。

  • _.without(array, [values]): 剔除所有给定值,并返回一个新数组,这个方法的作用和_.difference相同。


项目中的应用:这个可以在某些场景代替掉Array.prototype.filter


3、_.last(array)


作用:返回数组的最后一个元素


console.log(_.last([1, 2, 3, 4, 5]))
// 输出 5

项目中的应用:有了这个方法,就不需要通过arr[arr.length - 1]这样去取数组的最后一项了,比如一个省市区级联选择器Cascader,但传给后端的时候只需要最后一级的id,所以直接用_.last取最后一项给后端。


类似方法



  • _.head(aray)方法,返回数组的第一项

  • _.tail(array)方法,返回除了数组第一项以外的全部元素


顺便一提,我在实际开发项目中还遇到过用数组的pop方法去取最后一项的,然后由于取了两次调用了两次pop,造成了一个bug,让人哭笑不得。


4、_.chunk(array, [size=1])


作用:将数组按给定的size进行区块拆分,多余的元素会被拆分到最后一个区块当中,返回值是一个二维数组。


使用示例


console.log(_.chunk([1, 2, 3, 4, 5], 2))
// 输出: [ [ 1, 2 ], [ 3, 4 ], [ 5 ] ]

项目中的应用:比如你需要渲染出一个xx行xx列的布局,你就可以用这个方法将数据变变成一个二维数组arrarr.length代表行数,arr[0].length代表列数


二、对象类


1、_.get(object, path, [defaultValue])


作用:从对象中获取路径path的值,如果获取值为undefined,则用defaultValue代替。


使用示例


const _ = require('lodash')
const object = { a: { b: [{ c: 1 }, null] }, d: 3 };

console.log(_.get(object, 'a.b[0].c'));
// 输出 1
console.log(_.get(object, ['a', 'b', 1], 4));
// 输出 null
console.log(_.get(object, 'e', 5));
// 输出 5

项目中的应用:这个是获取数据的神器,再也不用写出if(a && a.b && a.b.c)的这种代码了,直接用_.get(a, 'b.c')搞定,_.get里面会帮你做判断,绝对省事!


2、_.has(object, path)


作用:判断对象上是否有路径path的值,不包括原型


使用示例


const _ = require('lodash')
const obj = { a: 1 };
const obj1 = { b: 1 }

const obj2 = Object.create(obj1)

console.log(_.has(obj, 'a'));
// 输出 true
console.log(_.has(obj2, 'b'));
// 输出 false

项目中的应用:这个可以代替Object.prototype.hasOwnProperty,判断对象上有没有某个属性。


3、.mapKeys(object, [iteratee=.identity])


作用:遍历并修改对象的key值,并返回一个新对象。


使用示例


const _ = require('lodash')
const obj = { a: 1, b: 1 };

const res = _.mapKeys(obj, (value, key) => {
return key + value;
})
console.log(res)
// 输出 { a1: 1, b1: 1 }


项目中的应用:调接口传递给后端数据时,如果定义的key和后端接口数据结构定义的key不匹配,可以用_.mapKeys进行适配。


4、.mapValues(object, [iteratee=.identity])


作用:遍历并修改对象的value值,并返回一个新对象。


使用示例


const _ = require('lodash')
const obj = { a: { age: 1 }, b: { age: 2 } };

const res = _.mapValues(obj, (value) => {
return value.age;
})
console.log(res)
// 输出 { a: 1, b: 2 }

项目中的应用:依次对对象values值进行处理,进行数据格式化,以适配后端接口。


5、_.pick(object, [props])


作用:从object中挑出对应的属性,并组成一个新对象


使用示例


const _ = require('lodash')
const obj = { a: 1, b: 2, c: 3 };

const res = _.pick(obj, ['a', 'b'])
console.log(res)
// 输出 { a: 1, b: 2 }

项目中的应用:从后端接口中,pick出对应你需要用的值,然后进行逻辑处理和页面渲染,或者pick对应的值,传给后端。


6、.pickBy(object, [predicate=.identity])


作用:与_.pick类似,只是第二个参数是一个函数,当返回为真时才会被pick


使用示例


const _ = require('lodash')
const obj = { a: 1, b: 2, c: 3 };

const res = _.pickBy(obj, (val, key) => val === 2)
console.log(res)
// { b: 2 }

项目中的应用:是_.pick的增强版,可以实现动态pick


7、_.omit(object, [props])


作用:_.pick的反向版,忽略掉某些属性后,剩下的属性组成一个新对象。


使用示例


const _ = require('lodash')
const obj = { a: 1, b: 2, c: 3 };

const res = _.omit(obj, ['b'])
console.log(res)
// { a: 1, c: 3 }

项目中的应用:代替delete obj.xx,剔除某些属性。


8、.omitBy(object, [predicate=.identity])


作用:_.omit的增强版,第二个参数是一个函数,当返回为真时才会被omit


使用示例


const _ = require('lodash')
const obj = { a: 1, b: 2, c: 3, cc: 4 };

const res = _.omitBy(obj, (val, key) => {
return key.includes('c');
} )
console.log(res)
// { a: 1, b: 2 }

项目中的应用:与_.omit类似。


9、_.set(object, path, value)


作用:给object上对应的path设置值,路径不存在会自动创建,索引创建成数组,其它创建为对象。


使用示例


const _ = require('lodash')
const obj = { };

const res = _.set(obj, ['a', '0', 'b'], 1)
console.log(res)
// 输出:{ a: [ { b: 1 } ] }

const res1 = _.set(obj, 'a.1.c', 2)
console.log(res1)
// 输出:{ a: [ { b: 1 }, { c: 2 } ] }

项目中的应用:给对象设置值,再也不用设置的时候一层层判断了。


10、_.unset(object, path)


作用:与_.set相反,删除object上对应的path上的值,删除成功返回true,否则返回false


使用示例


const _ = require('lodash')
const obj = { a: [{ b: 2 }] }

const res = _.unset(obj, ['a', '0', 'b'])
console.log(res)
// 输出:true
const res1 = _.unset(obj, ['a', '1', 'c'])
console.log(res1)
// 输出:true

项目中的应用:给对象删除值,替换delete a.b.c。使用delete如果在访问a.b.c的时候,发现没有b属性就会报错,而_.unset不会报错,有更加好的容错处理。


三、实用函数


1、_.cloneDeep(value)


作用:标准的深拷贝函数,这个无须多言,用过的人都说好


使用示例


const _ = require('lodash')
const obj = { a: [{ b: 2 }] }

const res = _.cloneDeep(obj)
console.log(res)
// 输出:{ a: [ { b: 2 } ] }

项目中的应用:代替JSON.parse(JSON.string(obj))等深拷贝方法,能处理循环引用,有更好的兼容性。


2、_.isEqual(value, other)


作用:深度比较两者的值是否相等


使用示例


const _ = require('lodash')
const obj = { a: [{ b: 2 }] }
const obj1 = { a: [{ b: 2 }] }

const res = _.isEqual(obj, obj1)
console.log(res)
// 输出:true

项目中的应用:比较form表单前后的数据是否发生了变化,再也不用自己循环两次+递归去手动比较了。


3、_.isNil(value)


作用:某个值是null或者undefined


使用示例


const _ = require('lodash')
let a = null;

const res = _.isNil(a)
console.log(res)
// 输出:true

项目中的应用:有时候我们并不想用if(obj.xx)判断是否有值,因为0也是算有值的,而且可能在后端定义中还有含义,但它转成boolean去判断却是false,所以我们用_.isNil去判断更为准确。


4、_.debounce(func, [wait=0], [options=])


作用:标准的防抖函数,简单理解就是,函数被触发多次,只有最后一次会被触发


使用示例


const _ = require('lodash')

const fn = () => ({
fetch('https://xxx.cn/api')
})
const res = _.debounce(fn, 3000)

项目中的应用input输入框的实时搜索,减少接口调用,节约服务器资源。


5、_.throttle(func, [wait=0], [options=])


作用:标准的节流函数,简单理解就是,函数被触发多次,在指定时间范围内只会调用一次


使用示例


const _ = require('lodash')

const fn = () => ({
fetch('https://xxx.cn/api')
})
const res = _.throttle(fn, 300)

项目中的应用:监听页面scroll事件滚动加载,监听页面的resize事件等。


6、_.isEmpty(value)


作用:判断一个对象/数组/map/set是否为空


使用示例


const _ = require('lodash')

const obj = {}
const res = _.isEmpty(obj);
console.log(res)
// 输出 true

项目中的应用:对传入的数据做非空校验。


7、_.flow([funcs])


作用:传入一个函数数组,并返回一个新函数。_.flow内部从左到右依次调用数组中的函数,上一次函数的返回的结果,会作为下个函数调用的入参


使用示例


const _ = require('lodash')

const add = (a, b) => a + b;
const multi = (a) => a * a;
const computerFn = _.flow([add, multi]);
console.log(computerFn(1, 2))
// 输出 9

项目中的应用:我们可以把各种工具方法进行抽离,然后用_.flow自由组装成新的工具函数,帮助我们流式处理数据,有点函数式编程那味儿了。


8、_.flowRight([funcs])


作用:与_.flow相反,函数会从右到左执行,相当于React中的compose函数


使用示例


const _ = require('lodash')

const add = (a) => a + 3;
const multi = (a) => a * a;
const computerFn = _.flowRight([add, multi]);
console.log(computerFn(4))
// 输出 19

项目中的应用:与_.flow类似,遇到相关场景,用flow还是flowRight都行,看个人习惯。


小结


以上就是我个人在项目中常用的lodash方法了,使用体验是非常好的,节约了不少处理数据的时间,所以想分享给大家。


大家熟练用起来,摸鱼时间这不就有了么!


作者:han_
来源:juejin.cn/post/7277799790296416290
收起阅读 »

写 Vue 我建议非必要别用 watch

web
场景 代码大概如下,删除了很多无关内容。 <template> <div> <SearchBar @search="handleSearch" /> <Pagination v-mode...
继续阅读 »

场景


代码大概如下,删除了很多无关内容。


<template>
<div>
<SearchBar @search="handleSearch" />
<Pagination
v-model:page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
/>

</div>
</template>
<script setup lang="ts">
import { reactive, ref, watch, inject, computed } from 'vue'
import SearchBar from '@/components/SearchBar.vue'

const route = useRoute()
const pagination = reactive({
page: 1,
pageSize: isPublic.value ? 10 : 9,
total: 0,
})
const keyword = ref('')
const fetchList = async () => {
loading.value = true
const res = await connect.get(`/api/${route.params.type}`, {
params: {
pageSize: pagination.pageSize,
page: pagination.page,
name: keyword.value,
},
})
pagination.total = res.total
loading.value = false
}
watch(
() => route.params.type,
async () => {
pagination.page = 1
fetchList()
},
{ immediate: true }
)
watch(
() => pagination.page,
async () => {
fetchList()
}
)
watch(
() => keyword.value,
async () => {
if (pagination.page === 1) fetchList()
else {
pagination.page = 1
}
}
)
const handleSearch = (val: string) => {
keyword.value = val
}
const handleDelete = async (item: MindMapItem) => {
await confirmModal.value?.confirm()
await connect.delete('/api/map/' + item._id)
fetchList()
}
</script>

本来只有 2 个 watch,今天新功能加了个关键词搜索,又得多 watch 一个 keyword.value


于是这里变成了 3 个 watch,而且里面有逻辑,甚至是相互依赖的逻辑。


上面的代码没写完,但是整理一下,最终目标是这样的:



  • 请求参数有三个变量:route.params.type、keyword 和 pagination

  • route.params.type 改变时需要重置 pagination 和 keyword,然后重新请求

  • keyword 改变时需要重制 pagination,然后重新请求

  • pagination 改变时需要重新请求


watch 真的好?


如果继续用 watch,因为需要重置 pagination 和 keyword,硬生生把三个 watch 写成了个像是任务委托一样的效果,例如 keyword.value 修改时如果 page 是 1 就直接请求,否则修改 page 再让 page 的 watch 触发请求。


watch(
() => keyword.value,
async () => {
if (pagination.page === 1) fetchList()
else {
pagination.page = 1
}
}
)

这么耦合真的好吗?这不好。我劝自己耗子尾汁,好好反思。


得出的结论是:watch 不是好文明,能不用 watch,就别用 watch


这不是我第一次对 watch 有意见,在工作中我就见过很多复杂组件动则 5 个以上的 watch,有的里面还有复杂逻辑。


重点是啥,还没注释……watch 天然就容易让人不写注释,给人一种“啊,这个值变了,运行下面的逻辑是理所当然的吧。”,那你问问两个月后的自己,是不是真的这样?你自己写的 watch 你自己看得懂吗?一个值变了就触发逻辑,但问题是,它变的原因可多了。


所以 watch 生而在语义上不明确,它只解释了对值的依赖,没有解释依赖的原因。


watchEffect 呢?


上面的例子,假如把 fetchList 写成 watchEffect,其实还是一样的问题,需要在里面额外加 if else 处理重置逻辑。不过逻辑集中在一个 watchEffect 大概还是比分散在 N 个 watch 里好。


总结


总结一下,watch 或者 watchEffect 有其用武之地,但最好满足以下的条件:



  • 变动触发点大于 2 个才考虑 watch(只有一个触发机会的话,什么时候用,什么时候跑就好了)

  • 所有场景全都适用同一个处理逻辑

  • 与其他 watch 没耦合


不过如果没有事件机制来触发的话,那就只能 watch 了。


优化后


<template>
<div>
<SearchBar @search="handleSearch" />
<Pagination
v-model:page="pagination.page"
@update:page="fetchList"
:page-size="pagination.pageSize"
:total="pagination.total"
/>

</div>
</template>
<script setup lang="ts">
import { reactive, ref, watch, inject, computed } from 'vue'
import SearchBar from '@/components/SearchBar.vue'

const route = useRoute()
const pagination = reactive({
page: 1,
pageSize: isPublic.value ? 10 : 9,
total: 0,
})
const keyword = ref('')
const fetchList = async () => {
// 省略
}
watch(
() => route.params.type,
async () => {
keyword.value = ''
pagination.page = 1
fetchList()
},
{ immediate: true }
)
const handleSearch = (val: string) => {
keyword.value = val
pagination.page = 1
fetchList()
}
const handleDelete = async (item: MindMapItem) => {
await confirmModal.value?.confirm()
await connect.delete('/api/map/' + item._id)
fetchList()
}
</script>

修改后,只保留 route.params.typewatch,不会发生冲突,另外两个通过事件触发。至于触发事件也不用额外写 @change,直接用 @update:xxx 就可以了。


这样只有易读的重置逻辑,没有 if else!清爽!


原文传送门:ssshooter.com/vue-watch/


作者:ssshooter
来源:juejin.cn/post/7314860085931065359
收起阅读 »

小公司-小前端团队,如何一步步走向成熟?

web
现状 去年下半年,加入了一家小公司,前端团队也是刚刚成立没多久,虽然自己心里上已经提前预设了团队可能存在的种种问题,但是,进入以后,还是发现了一系列比较明显的问题,这里列举其中一些典型问题: 前后端代码没有分离,发布上线等没有分离 前端技术栈单一,所有项目都...
继续阅读 »

image.png


现状


去年下半年,加入了一家小公司,前端团队也是刚刚成立没多久,虽然自己心里上已经提前预设了团队可能存在的种种问题,但是,进入以后,还是发现了一系列比较明显的问题,这里列举其中一些典型问题:



  • 前后端代码没有分离,发布上线等没有分离

  • 前端技术栈单一,所有项目都是直接采用vue3-vben-admin

  • 代码规范,commit规范等等没有统一,CI/CD流程不规范。

  • 没有设计,产品等,导致整体开发流程不规范。

  • ...其他


之所以,列举以上这些问题,并不是对我司有任何的不满啊,哈哈哈,更多的是希望能够给遇到类似问题的小伙伴儿一些方案或者方向,同时,也能够帮助大家的前端团队逐渐走向规范与成熟。


写作不易,如果这篇文章对您有所帮助,欢迎 关注♥️ + 点赞👍 鼓励一下作者,感恩~


成熟的前端团队是什么样子?


前端团队刚刚成立不久,如何一步步走向规范与成熟呢?很显然,我们需要知道一个成熟的前端团队是什么样子的(当然一般谈论这种话题,很有可能被喷),其实呢?没有一个明确的标准,而且公司业务不同,前端的技术栈,基建等都会不同,这里只是列举出了建设前端团队比较常见的一些方向供大家参考:


image.png


前端规范


这里,总结出了一些常见的规范,直接上图:


image.png


如果大家所在团队中,对这些规范没有进行统一,可以参考上面这些方向在团队中进行推广。


前端项目模版


大家肯定都知道vue,react的官方脚手架工具:vue-cli, create-react-app,通过这些基础脚手架,就可以帮助我们创建最基础的前端项目代码,但是随着业务的迭代,各个公司的业务场景不同,技术栈不同,往往需要在vue-cli, create-react-app创建的项目模版基础上,逐渐沉淀出符合公司的自定义项目模版代码,具体可以参考下图:


image.png


前端脚手架


上面,我们讲了前端项目模版,那如何更好的去管理模版呢?答案就是脚手架,加入没有脚手架,我们很可能是直接将模版代码放在一个单独的仓库中,每次开启一个新项目,就clone到本地,然后在copy一份出来,这样虽然也可以做,但是脚手架可以通过命令更好更快捷的帮助我们去管理项目模版,以及进行项目初始化等等操作。


image.png


当然,脚手架的技术栈和传统的前端项目的技术栈有所不同,上面图中也有说到,底层依赖NodeLerna,Yargs等,感兴趣的可以学习一些,是否要维护公司自己的脚手架,就要评估人力成本,收益等,结合团队的实际情况进行考量了。


前端自动化构建部署(CI/CD)


这部分就是大家常说的CI/CD,即前端项目如何持续集成与部署,这里就不额外展开说啦,具体可以参考我自己写的一篇文章:基于Docker + Nginx + Gitlab-runner 构建前端CI/CD


有一点说明一下:可能早期,工作经验不多的前端小伙伴儿,会遇到这种情况,每次项目发布上线,可能都是直接使用公司现成的发布系统,直接在页面点点就可以成功,但是往往遇到问题的时候,就不太知道怎么去排查问题,还得请教运维等相关同学,遇到好交流的同事可能还帮你解决一下,遇到一些不友好的同事,你自己内心也会一万个....


因此,随着前端的不断发展,对于前端的要求也会越来越高,我们也有必要知道前端项目底层到底是如何进行CI/CD,如何去发布上线,这里就会涉及到Docker,K8s,Nginx,CI工具等技术栈,感兴趣的同学也可以去写一些demo,了解了解。


前端全链路监控体系


其实就是随着项目的迭代,功能越来越复杂,尤其是一些C端的项目,我们需要去掌握用户的行为,从而,根据用户的行为,去进一步更好的迭代我们的项目, 那么,这就需要我们对这些行为进行监控,也就是大家常说的埋点,


一个完整的监控体系,通常包含如下内容:
image.png


如果有的小伙伴儿所在团队有这样的需求,那么就要考虑如何去做啦,目前市场上也有一些开源的方案可以参考,例如:Sentry,当然,也要结合团队实际情况,看是否需要自己去实现一套完整的监控体系,因为实现成本也不低,尤其小公司,我们就需要调研调研,是否可以使用一些开源的方案去实现啦。


前端物料库


什么是前端物料?其实就是大家常说的组件库,工具库等可以复用的代码,具体可以参考下图:


image.png


一般大厂都会有类似的物料平台,那么,我们小公司呢?就要考虑其实现成本和收益啦,也不一定非要建立物料平台,因为小公司能够沉淀的物料也不会有那么多,比如:一般有沉淀一些组件库,工具库,我们也可以发布到npm上,这样团队内部也可以使用。


怎么做?


那具体怎么做呢?主要从以下几方面考虑:



  1. 明确要解决的问题:结合公司团队当前情况,按照优先级明确现有问题

  2. 明确要解决的问题的具体实现方案:通过调研,团队讨论等方式明确各个方案利弊,选择最优方案

  3. 明确具体的执行步骤:从团队实际情况出发,最好是渐进式开启,在对现有业务不影响的前提下做增量式基建工作


于是,我进一步结合我司的情况,明确了以下几点是要优先去实现的:



  • 确定前端技术栈

  • 明确前端规范

  • 前后端代码分离,打造独立的前端CI/CD


确定前端技术栈


由于我司目前主要中后台项目居多,这里确认的技术栈也主要基于此方向展开的。


首先,传统的中后台项目,前端一般会包含以下这些内容:


image.png


那以上这些内容,如何实现呢?可以从三个方向展开:



  • 自己团队手动封装,形成团队自己的一套最佳实践(其实就是结合公司业务场景,逐渐沉淀出一套初始化项目的项目模版)

  • 借助社区开源方案:这里推荐:蚂蚁开源的UmiJS
    image.png


    image.png
    简单来说,该框架就是以插件的形式集成了传统中后台解决方案常见的内容,例如常见的路由管理,权限管理等等,我们只需要引入相应的插件即可。


  • 使用现成的开箱即用的中台前端解决方案框架,这里推荐以下几个框架:





那我司是如何选择的呢?历史项目使用了一部分Vue3 Vben Admin,新项目统一采用Ant Design Pro


image.png


这里重点对比一下Vue-vben-adminAnt design pro



  • Vue3 Vben Admin



    • 优势



      • 当前使用技术栈,且用了两到三年,积累了一定的经验,趟过了一些坑。

      • 整体功能相对比较齐全,不需要从零开发。



    • 劣势



      • 本地版本迭代更新机制不太友好,需要开发者每次手动clone最新版本的模版仓库,然后还需要将原来的业务代码进行copy,而且如果对源码代码有更新的话,会更加麻烦,例如:一个组件,我们可能在项目中进行了二次修改,然后,我们更新vben版本的时候,作者很有可能也对该组件进行了代码更新,这个时候,就需要比对新旧组件代码,容易出现问题。

        • 底层原因一:项目架构整体相对比较简单,没有采用monorepo架构,模版项目代码中封装的hook,组件等内容没有单独发npm包,没有版本管理。

        • 底层原因二:封装的组件,自定义render能力有限,需要我们手动修改组件源码。





    • 部分源码嵌套层级较深,新手上手成本较大,随着业务的迭代,源码和业务代码容易混淆,导致后期版本升级较难。

    • 目前我们项目首屏渲染过慢(后期可能会成为一个比较明显的问题)



  • Ant Design Pro



    • 优势

      • 整体社区生态更加完善,基于 umi + ant-design pro component,二次封装的组件等内容都有版本管理,作为开源项目,更利于开发者去通过npm包的形式去按需引入,同时提供了一下自定义入口,可以帮助我们二次开发。



    • 劣势

      • 新技术栈,初期需要一些学习成本,需要淌一些坑。






综合考虑了几点,团队计划采用 React + Umijs + And-design-pro 来作为目前复杂项目的核心技术栈,同时,一些简单项目,我们也可以直接使用creat-react-app脚手架去初始化项目,同时,一些官网,也采用了WordPress去快速建站(国外使用的较多)这样可以节省前端的开发成本。



前端中台解决方案 之 umijs + ant-design-pro 调研踩坑全记录
如果小伙伴对上面这些技术栈,尤其是React + Umijs + And-design-pro这一套有经验,也欢迎评论区分享,看有没有哪些问题或者坑可以避免。



明确前端规范


确认技术栈以后,接下来,就是要明确前端规范,保证团队开发统一,再次贴出之前总结的图:


image.png


这里,再把其中一些关键点说明一下:



  • 编码规范:除了确认规范标准之后,在项目中还需要借助工具化:Eslint + Prettier + Stylelint 要确保项目中引入这些工具,并且进行有效检测。

  • Git规范:常见两点就是:Commit Message 规范以及Branch命名规范,这些也都可以借助工具:husky + lingstaged来进行约束。

  • UI规范:对于一部分中后台项目,可能没有专门设计参与,这个时候就需要前端对于整个页面的设计交互有一个更好的认识和把握,推荐大家可以参考:Ant Design设计规范,里面列出了常见页面场景的交互规范,可以帮助我们更好的提升项目的用户体验。


前后端代码分离,打造独立的前端CI/CD


CI/CD这部分,通常也需要前端整体有一个认识和把握,这样可以帮助我们了解前端项目在整个集成部署过程中,内部的实现流程,也可以帮助我们更快的去定位问题,解决问题。


这里就不额外展开了,有类似需求的同学可以参考我之前总结的:# 基于Docker + Nginx + Gitlab-runner 构建前端CI/CD


文档建设


文档建设,其实也是必不可少的一个环节,包括我们上提到的这些前端规范,项目等内容,都可以逐步去沉淀到我们团队的文档中,随着内容的积累,沉淀的文档也会成为团队必不可缺的财富。


如果想推动团队进行文档建设的小伙伴儿,可以从以下几方面展开:



  • 新人报到(VPN配置、项目启动)

  • 规范类

    • 流程规范:git分支、commit规范

    • 编码规范:eslint、文件名、代码复杂度



  • 项目类

    • 需求文档

    • 研发方案

    • 项目总结



  • 技术类

    • 常见的技术分享

    • 项目中的典型的技术点总结




总结


以上内容虽然相对基础,也很简单,但其实都是前端团队必不可少的,相信做完以上这些,我们的前端团队会变得更加规范和专业,当然,距离一个成熟的前端团队,大厂前端团队来说,我司的前端团队才刚刚起步,同时不同的团队,不同的业务,也会有不同的基建工作,这篇文档也会伴随着我司前端团队的成长去逐步的更新,相信会越来越好,前端不易,尤其这两年,前端已死,裁员,降薪等等负面消息在不断冲击着每一个程序猿,相信大家都可以熬过去的,祝大家越来越好。


写作不易,欢迎小伙伴儿们点赞收藏+关注,感恩。


参考文档



作者:寻觅人间美好
来源:juejin.cn/post/7221359467618517052
收起阅读 »

裁员为什么会降本增笑?

大家好,我是煎鱼。 最近互联网公司放烟花的次数有些高,基本都扎堆 Q3~Q4 出现各类事件/事故。吃瓜都快跟不上了。 作为互联网民工,为什么裁员后会导致降本增笑呢?今天我们一起来聊聊。 各种事故烟花 现阶段各大厂都领上号了,阿里先崩,再到滴滴,接着腾讯。涉及到...
继续阅读 »

大家好,我是煎鱼。


最近互联网公司放烟花的次数有些高,基本都扎堆 Q3~Q4 出现各类事件/事故。吃瓜都快跟不上了。


作为互联网民工,为什么裁员后会导致降本增笑呢?今天我们一起来聊聊。


各种事故烟花


现阶段各大厂都领上号了,阿里先崩,再到滴滴,接着腾讯。涉及到产品至少有:



  1. 阿里:访问密钥服务 AK 异常,引发阿里系多款产品无法使用。

  2. 语雀:数据服务出现严重故障,造成大面积的服务中断。

  3. 滴滴:K8s1.12 升级 K8s1.20 异常,同上造成系统全面服务终端。

  4. 腾讯视频:会员模块出现 “短暂技术问题”,与会员关联所有功能不可用。


除了各种吃瓜以外,可以学习的地方是可以看看滴滴的 K8s1.12 升级 K8s1.20 的技术方案和选型思考。


以下图片来源自滴滴技术的文章截图:



请细品。最终选型了 “原地升级” 的方案,给出的原因是:“从方案可落地以及成本角度”。


裁员怎么增笑


“开猿节流,降本增笑” 是当红的流行语。


软件复杂度上升


在以前互联网公司飞速发展的阶段,很多业务需求和商务,会把各产品打包、关联起来卖。期望这样可以一篓子就捅进去卖了。


此时大量的系统规模、软件架构、应用程序都会交织、掺杂在一起。也有了更多的开发同学一起在这添砖加瓦。


这不,突然来了车轮式的裁员。


裁员时间选项


裁员的时间节点,以下两种选项居多:



  • 当天谈,当天离职:当天早上一上班,就会突然约谈,直接现场完成必要的文书类签署,结束后当天马上走。

  • 灵活安排一个月,再离职:提前数周或是一个月进行谈话,告知并要求 xx.30 走入。期间时间要打卡上班,但内容可以自由安排。


也有一些变形,常见的无非是多留几天。少留几天。因为这几天,系数有些变化。争取争取年假等。


听说高级别的,就是看谈的条件了。也有直接放一个月,不需要打卡上班(不坐班),自己任意安排。工资照发。


怎么裁员就裁出问题了


节了流,就要增笑了。很多风险逐渐暴露。


原本这一个大系统 10 个大模块,可能是 7~8 个人在一起维护的。各自有专门负责的领域,一般是与相近临模块迭代着。


这不,一轮轮 330、630、930、1230 的大力度裁员。团队直接剩 2~3 个人。人都当天排队离职,工作内容是都没有交接的。


古老沉香的老代码关联了多少其他业务模块,埋了多少 “坑”,平时修数据、HACK 代码又有多少。基本没啥人知道。


此时就会明显出现:产出效率变低,事故率增高,小事件不断。很容易使不对劲。


就像滴滴在选择技术方案时,可能也会更趋向于即使要冒一定的风险,也会选择更低成本、低开销的落地方案。一切就在就在一念之间。


这时就更容易出现事故了,因为很多成本高的方案,就是因为多做了一套冗余,这样可以确保出现意外时的稳定性。


总结


人是环境的反应器,潜移默化的,就导致了许多选择和思路的转变。在企业管理中,老板们最容易看到的就是经营成本,每个月都要给员工发工资。


当看不到进一步更大的发展前景和规划时,向往收缩时就会进行 “开猿节流”。走了的同学领了大礼包,留下的同学也背负了更多的开发任务和风险。


这两天还看到技术群的同学在讨论 “防御性编程”,以防被裁员。在这块,平时可能及时做好技术类文档,留存,交叉。可能对还在的同学会更好。


作者:煎鱼eddycjy
来源:juejin.cn/post/7314608415531614260
收起阅读 »