注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

为什么老家的黄瓜比北京的便宜?普通人应该去经济发达地区谋生

有时候会跟着家人去逛菜市场,有次我岳母说北京的菜真贵,原本在农村老家2、3块的黄瓜到了北京要5、6块一斤,感觉真是土气。 出于好奇,我就开始琢磨这个问题。 我问了财务专业的老婆大人,老婆大人的答案很直接,大概意思是北京的供应链比老家贵的多,比如运输成本、用于卖...
继续阅读 »

有时候会跟着家人去逛菜市场,有次我岳母说北京的菜真贵,原本在农村老家2、3块的黄瓜到了北京要5、6块一斤,感觉真是土气。


出于好奇,我就开始琢磨这个问题。


我问了财务专业的老婆大人,老婆大人的答案很直接,大概意思是北京的供应链比老家贵的多,比如运输成本、用于卖菜的房租成本、还有销售蔬菜的人力成本……然后这些成本都加到蔬菜价格上,自然就高了。


我哈哈一笑,老婆大人的回答自然是不敢出声反驳的,但我可以在心里揣测。我觉得老婆大人说的没问题,但并不深刻,于是我开始浮想联翩……


一 自产自销


在北京生活久了,我发现就蔬菜来说并非不可以自产的。我之前生活的小区,就有销售自产蔬菜的大爷大妈。老婆说是因为供应链比老家的贵,可大爷大妈自产自销不需要供应链啊,老婆的回答就没法解释了。


在我的印象里面,大爷大妈自产的蔬菜也是要比老家的贵的,为什么会如此?


我思考的结果是,大爷大妈是询价定价的。简单来说大爷大妈一开始是不知道定价多少合适的,定的太低心里不得劲,定的太高没人买,所以大爷大妈会去附近菜市场询价,然后定一个比菜市场低的价格,这样既解决了心里不得劲问题,也解决了卖不出去问题。


但这本质上还是因为北京市场上的蔬菜价格比老家的贵。


二 供应链


如果不是自产自销,那就是依靠供应链了。在老家市场销售的黄瓜和北京市场销售的黄瓜,假设有着相同的货源,但背后供应链体系肯定是不同的。


在我们老家有那种大集,就是固定一个时间周期在一个固定地点开展买卖。


大集上的菜农销售蔬菜基本可以忽略掉租房成本,然后因为菜农本身就是销售人员,也省下销售成本,这样整体的供应链成本就下来了,就能够低价销售蔬菜了。


这也就是老婆说的供应链成本不同。


三 边际


边际是经济学核心概念之一,是一种思维方式,就是永远看市场中最后一个人的行为或者最后一个产品的情况。


比如在劳动力市场上,工资不是市场中的平均水平的劳动者决定的,而是最后一个参加劳动的人决定的,要看给他多高的工资他才愿意去做这份工作,同时也要看他有多大的贡献工厂才雇用他,两者相等的时候才是市场里的均衡工资


拉长时间看蔬菜市场中的黄瓜价格会受边际影响。简单来说,蔬菜超市会因为追求最大经济利益调整蔬菜价格,直到找到均衡价格。


结果因为北京的购买者更能容忍价格的波动,导致北京的黄瓜比农村的贵。


四 可替代性


北京的购买者之所以更能容忍价格波动,原因之一是北京生活的人相对老家农村生活的人,在蔬菜这件事上,可替代性弱。


北京生活的人,少有自产自销者;而农村老家的人,多可以自产自销。这虽然不是价格上涨的直接原因,但无形当中推高了均衡价格。


五 购买力


北京的购买者之所以更能容忍价格波动,原因之二是北京的购买力强。


我之前在小区周边的永辉超市看到有机蔬菜,标记35.98/kg,我当时想什么人会买这种蔬菜呢?


蔬菜.jpg


从结果来说,北京购买者购买力强是老家的黄瓜比北京便宜的直接原因


尾声


黄瓜的价格只是一个小的不能再小的缩影,老家的黄瓜之所以比北京便宜,根本原因是老家农村和北京有着根本不同的经济结构


北京这种大城市有更高效的资源利用率,更强的生产能力和更高效的生产效率,结果就是北京创造了更多财富,即使普通人在这里也会有较高收入,高收入下基于边际思维演化的黄瓜价格,形成高的均衡价格。


多年前我曾极力劝我一个朋友来北京,他在四线城市很努力,但始终没能赚大钱。当初我说不出更具说服力的让他来北京的理由,但我认为我现在找到了:他赚钱少可能不是因为他不够努力,而是因为他本来就不在一个发达的经济体里,也就难以享受到比较高的红利。


所以我给普通奋斗者的建议是去经济发达地区谋生吧!


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

Activity界面路由的一种简单实现

1. 引言 平时Android开发中,启动Activity是非常常见的操作,而打开一个新Activity可以直接使用Intent,也可以每个Activity提供一个静态的启动方法。但是有些时候使用这些方法并不那么方便,比如:一个应用内的网页需要打开一个原生Ac...
继续阅读 »

1. 引言


平时Android开发中,启动Activity是非常常见的操作,而打开一个新Activity可以直接使用Intent,也可以每个Activity提供一个静态的启动方法。但是有些时候使用这些方法并不那么方便,比如:一个应用内的网页需要打开一个原生Activity页面时。这种情况下,网页的调用代码可能是app.openPage("/testPage")这样,或者是用app.openPage("local://myapp.com/loginPage")这样的方式,我们需要用一种方式把路径和页面关联起来。Android可以允许我们在Manifest文件中配置<data>标签来达到类似效果,也可以使用ARouter框架来实现这样的功能。本文就用200行左右的代码实现一个类似ARouter的简易界面路由。


2. 示例


2.1 初始化


这个操作建议放在Application的onCreate方法中,在第一次调用Router来打开页面之前。


public class AppContext extends Application {
@Override
public void onCreate() {
super.onCreate();
Router.init(this);
}
}

2.2 启动无参数Activity


这是最简单的情况,只需要提供一个路径,适合“关于我们”、“隐私协议”这种简单无参数页面。


Activity配置:


@Router.Path("/testPage")
public class TestActivity extends Activity {
//......
}

启动代码:


Router.from(mActivity).toPath("/testPage").start();
//或
Router.from(mActivity).to("local://my.app/testPage").start();

2.3 启动带参数Activity


这是比较常见的情况,需要在注解中声明需要的参数名称,这些参数都是必要参数,如果启动的时候没有提供对应参数,则发出异常。


Activity配置:


@Router.Path(value = "/testPage",args = {"id", "type"})
public class TestActivity extends Activity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//加载布局...

String id = getIntent().getStringExtra("id"); //获取参数
int type = getIntent().getIntExtra("type", 0);//获取参数
}
}

启动代码:


Router.from(mActivity).toPath("/testPage").with("id", "t_123").with("type", 1).start();

2.4 启动带有静态启动方法的Activity


有一些Activity需要通过它提供的静态方法启动,就可以使用Path中的method属性和Entry注解来声明入口,可以提供参数。在提供了method属性时,需要用Entryargs来声明参数。


Activity配置:


@Router.Path(value = "/testPage", method = "open")
public class TestActivity extends Activity {

@Router.Entry(args = {"id", "type"})
public static void open(Activity activity, Bundle args) {
Intent intent = new Intent(activity, NestWebActivity.class);
intent.putExtras(args);
activity.startActivity(intent);
}

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//加载布局...

String id = getIntent().getStringExtra("id"); //获取参数
int type = getIntent().getIntExtra("type", 0);//获取参数
}
}

启动代码:


Router.from(mActivity).toPath("/testPage").with("id", "t_123").with("type", 1).start();

3. API介绍


3.1 Path注解


这个注解只能用于Activity的子类,表示这个Activity需要页面路由的功能。这个类有三个属性:



  • value:表示这个Activity的相对路径。

  • args:表示这个Activity需要的参数,都是必要参数,如果打开页面时缺少指定参数,就会发出异常。

  • method:如果这个Activity需要静态方法做为入口,就将这个属性指定为方法名,并给对应方法添加Entry注解。(注意:这个属性值不为空时,忽略这个注解中的args属性内容)


3.1 Entry注解


这个注解只能用于Activity的静态方法,表示这个方法作为打开Activity的入口。仅包含一个属性:



  • args:表示这个方法需要的参数。


3.2 Router.init方法



  • 方法签名:public static void init(Context context)

  • 方法说明:这个方法用于初始化页面路由表,必须在第一次用Router打开页面之前完成初始化。建议在Application的onCreate方法中完成初始化。


3.3 Rouater.from方法



  • 方法签名:public static Router from(Activity activity)

  • 方法说明:这个方法用于创建Router实例,传入的参数通常为当前Activity。例如,要从AActivity打开BActivity,那么传入参数为AActivity的实例。


3.4 Rouater.to和Rouater.toPath方法



  • 方法签名:




  1. public RouterBuilder to(String urlString)

  2. public RouterBuilder toPath(String path)




  • 方法说明:这个方法用于指定目标的路径,to需要执行绝对路径,而toPath需要指定相对路径。返回的RouterBuilder用于接收打开页面需要的参数。


3.4 RouterBuilder.with方法



  • 方法签名:




  1. public RouterBuilder with(String key, String value)

  2. public RouterBuilder with(String key, int value)




  • 方法说明:这个方法用于添加参数,对应Bundle的各个put方法。目前只有常用的Stringint两个类型。如有需要可自行在RouterBuilder中添加对应的方法。


3.4 RouterBuilder.start方法



  • 方法签名:public void start()

  • 方法说明:这个方法用于打开页面。如果存在路径错误、参数错误等异常情况,会发出对应运行时异常。


4. 实现


import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;

import androidx.annotation.Keep;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;

@Keep
public class Router {

public static final String SCHEME = "local";
public static final String HOST = "my.app";
public static final String URL_PREFIX = SCHEME + "://" + HOST;

private static final Map<String, ActivityStarter> activityPathMap = new ConcurrentHashMap<>();

public static void init(Context context) {
try {
PackageManager packageManager = context.getPackageManager();
PackageInfo packageInfo = packageManager.getPackageInfo(
context.getPackageName(), PackageManager.GET_ACTIVITIES);

for (ActivityInfo activityInfo : packageInfo.activities) {
Class<?> aClass = Class.forName(activityInfo.name);
Path annotation = aClass.getAnnotation(Path.class);
if (annotation != null && !TextUtils.isEmpty(annotation.value())) {
activityPathMap.put(annotation.value(), (Activity activity, Bundle bundle) -> {
if (TextUtils.isEmpty(annotation.method())) {
for (String arg : annotation.args()) {
if (!bundle.containsKey(arg)) {
throw new IllegalArgumentException(String.format("Bundle does not contains argument[%s]", arg));
}
}
Intent intent = new Intent(activity, aClass);
intent.putExtras(bundle);
activity.startActivity(intent);
} else {
try {
Method method = aClass.getMethod(annotation.method(), Activity.class, Bundle.class);
Entry entry = method.getAnnotation(Entry.class);
if (entry != null) {
for (String arg : entry.args()) {
if (!bundle.containsKey(arg)) {
throw new IllegalArgumentException(String.format("Bundle does not contains argument[%s]", arg));
}
}
method.invoke(null, activity, bundle);
} else {
throw new IllegalStateException("can not find a method with [Entry] annotation!");
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
});
}
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}

public static Router from(Activity activity) {
return new Router(activity);
}

private final Activity activity;

private Router(Activity activity) {
this.activity = activity;
}

public RouterBuilder to(String urlString) {
if (TextUtils.isEmpty(urlString)) {
return new ErrorRouter(new IllegalArgumentException("argument [urlString] must not be null"));
} else {
return to(Uri.parse(urlString));
}
}

public RouterBuilder toPath(String path) {
return to(Uri.parse(URL_PREFIX + path));
}

public RouterBuilder to(Uri uri) {
try {
if (SCHEME.equals(uri.getScheme())) {
if (HOST.equals(uri.getHost())) {
String path = uri.getPath();//note: 二级路径暂不考虑
ActivityStarter starter = activityPathMap.get(path);
if (starter == null) {
throw new IllegalStateException(String.format("path [%s] is not support", path));
} else {
NormalRouter router = new NormalRouter(activity, starter);
for (String key : uri.getQueryParameterNames()) {
if (!TextUtils.isEmpty(key)) {
router.with(key, uri.getQueryParameter(key));
}
}
return router;
}
} else {
throw new IllegalArgumentException(String.format("invalid host : %s", uri.getHost()));
}
} else {
throw new IllegalArgumentException(String.format("invalid scheme : %s", uri.getScheme()));
}
} catch (RuntimeException e) {
return new ErrorRouter(e);
}
}

public static abstract class RouterBuilder {
public abstract RouterBuilder with(String key, String value);

public abstract RouterBuilder with(String key, int value);

public abstract void start();
}


private static class ErrorRouter extends RouterBuilder {
private final RuntimeException exception;

private ErrorRouter(RuntimeException exception) {
this.exception = exception;
}

@Override
public RouterBuilder with(String key, String value) {
return this;
}

@Override
public RouterBuilder with(String key, int value) {
return this;
}

@Override
public void start() {
throw exception;
}
}

private static class NormalRouter extends RouterBuilder {
final Activity activity;
final Bundle bundle = new Bundle();
final ActivityStarter starter;

private NormalRouter(Activity activity, ActivityStarter starter) {
this.activity = Objects.requireNonNull(activity);
this.starter = Objects.requireNonNull(starter);
}

@Override
public RouterBuilder with(String key, String value) {
bundle.putString(key, value);
return this;
}

@Override
public RouterBuilder with(String key, int value) {
bundle.putInt(key, value);
return this;
}

@Override
public void start() {
starter.start(activity, bundle);
}
}

@FunctionalInterface
private interface ActivityStarter {
void start(Activity activity, Bundle bundle);
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Path {
String value();

String method() default "";

String[] args() default {};
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Entry {
String[] args() default {};
}
}

5. 注意



  1. 这个工具的一些功能与ARouter类似,实际项目中建议使用ARouter。如果有特殊需求,例如,页面参数的检查或定制具体打开行为,可以考虑基于这个工具进行修改。

  2. 使用了Pathmethod属性时注意添加对应的混淆设置,避免因混淆而导致找不到对应方法。


作者:乐征skyline
来源:juejin.cn/post/7235639979882463292
收起阅读 »

基于SSE的实时消息推送

背景 小盟 AI 助手项目中需要服务端把 AI 模型回调回来内容,实时推送到客户端,展示给用户;整个流程需要一个能够快速支持上线的服务端推送方案。经过对现有的一些服务端推送方案进行调研,并结合项目的周期、实现成本、用户体验等多方综合考量,最终选择了 ...
继续阅读 »



背景


小盟 AI 助手项目中需要服务端把 AI 模型回调回来内容,实时推送到客户端,展示给用户;整个流程需要一个能够快速支持上线的服务端推送方案。经过对现有的一些服务端推送方案进行调研,并结合项目的周期、实现成本、用户体验等多方综合考量,最终选择了 Server-Sent Events(SSE)方案进行实践。


首先服务端推送,是一种允许应用服务器主动将信息发送到客户端的能力,为客户端提供了实时的信息更新和通知,增强了用户体验。


服务端推送主要基于以下几个诉求:


(1)实时通知:在很多情况下,用户期望实时接收到应用的通知,如新消息提醒、活动提醒等。


(2)节省资源:如果没有服务端推送,客户端需要通过轮询的方式来获取新信息,会造成客户端、服务端的资源损耗。通过服务端推送,客户端只需要在收到通知时做出响应,大大减少了资源的消耗。


(3)增强用户体验:通过服务端推送,应用可以针对特定用户或用户群发送有针对性的内容,如优惠活动、个性化推荐等。这有助于提高用户对应用的满意度和黏性。


方案对比



轮询: 是一种较为传统的方式,客户端定时地向服务端发送请求,询问是否有新数据。服务端只需要检查数据状态,然后将结果返回给客户端。轮询的优点是实现简单,兼容性好;缺点是可能产生较大的延迟,且对服务端资源消耗较高。


长轮询(Long Polling): 轮询的改进版。客户端向服务器发送请求,服务器收到请求后,如果有新的数据,立即返回给客户端;如果没有新数据,服务器会等待一定时间(比如30秒超时时间),在这段时间内,如果有新数据,就返回给客户端,否则返回空数据。客户端处理完服务器返回的响应后,再次发起新的请求,如此反复。长轮询相较于传统的轮询方式减少了请求次数,但仍然存在一定的延迟。   


WebSocket: 一种双向通信协议,同时支持服务端和客户端之间的实时交互。WebSocket 是基于 TCP 的长连接,和HTTP 协议相比,它能实现轻量级的、低延迟的数据传输,非常适合实时通信场景,主要用于交互性强的双向通信。


SSE: 是一种基于 HTTP 协议的推送技术。服务端可以使用 SSE 来向客户端推送数据,但客户端不能通过 SSE 向服务端发送数据。相较于 WebSocket,SSE 更简单、更轻量级,但只能实现单向通信。


图片


图片


小盟 AI 助手项目需要快速上线且保证要用户较好的使用体验。鉴于 SSE 技术的轻量、实现简单、不增加额外的资源成本;当前业务场景也只需要服务端到用户端的单向的字符推送。非常适合项目需要。所以决定使用 SSE 来实现内容推送。****


深入 SSE



SSE 服务端推送,它基于 HTTP 协议,易于实现和部署,特别适合那些需要服务器主动推送信息、客户端只需接收数据的场景。有以下特点:


1、简单:基于 HTTP,无须额外的协议或者类库支持。主流浏览器都支持。


2、事件流:使用"事件流"(Event Stream)将数据从服务器发送到客户端。每个事件都可以包含一个事件标识符、事件类型和数据字段。客户端可以根据这些信息来解析和处理接收到的数据。


3、自动重连:意外断开时会自动尝试重新连接。可以确保了在网络故障或连接中断后能够及时恢复通信,为用户提供连续的数据流。重连时会在 HTTP 头中的Last_Event_ID 带上上一次的数据 ID,便于服务端返回后续数据。


4、单向推送:只能从服务端推送数据到客户端。


图片


SSE 消息体介绍:


图片


SSE消息体示例:


图片


服务端主要使用 Spring,其对 SSE 主要提供了两种支持:



  • Spring WebMVC:传统的基于 Servlet 的同步阻塞编程模型,即 同步模型 Web 框架。

  • Spring WebFlux:异步非阻塞的响应式编程模型,即 异步模型 Web 框架。          


项目基于springboot,所以选择使用前者实现。SseEmitter emitter = new SseEmitter(); 一句代码就可以建立一个 SSE 连接。


实践



后端实现


建立一个SseEmitterManger,统一管理当前服务 SSE 连接的创建、释放以及数据推送。结合 Redis 缓存可实现集群环境 SSE 连接的管理。


核心逻辑如下:



  • 连接池维护,设定一个上限,避免过大,导致内存问题。


static final Map<String, SseEmitter> sseCache =     new ConcurrentHashMap<>(300)          


  • 建立SSE连接,为每个连接建立一个唯一的MsgId,用来维护SSE连接与客户端的关系了;在 Redis 缓存中存入MsgId和当前机器节点的IP和Port,这样可以找到SSE 连接所在的服务结点,然后通过 HTTP 请求转发需要发送的数据到对应的服务节点上进行处理。


sse = new SseEmitter()sseId = "sse_xxx";redisKey= "aisse:" + bosId + "_" + wid ipPort = "10.10.10.10:8080"redis.hset(redisKey, msgId, ipPort)sseCache.put(msgId, sseEmitter);


  • 获取持有连接的 pod ipPort;根据 IP 发起请求。


ipPort = redisUtil.hashGet(redisKey, msgId)


  • 获取当前服务结点的SSE连接,发送数据。


sseEmitter = sseCache.get(msgId)sseEmitter.send(msgJson)          


  • 释放SSE连接


SseEmitter sseEmitter = sseCache.get(msgId);sseEmitter.complete();sseCache.remove(msgId);redisUtil.hashDel(redisKey, msgId);

**核心流程图如下: **  


图片


需要注意的是开启 SSE 连接接口的整个链路都要支持长连接。例如使用 Nginx 则要开启长连接的配置:



  • keepalive 用于控制可连接整个 upstream servers 的 HTTP 长连接的个数,即控制总数。

  • proxy_http_verion 用于控制代理后端链接时使用的 HTTP 版本,默认为 1.0。要想使用长连接,必须配置为 1.1。

  • proxy_set_header 需要设置为 Connection "",否则则发往 upstream servers 的请求中,Connection header 的值将为close,导致无法建立长连接。   


http {        upstream keepAliveService {            server 10.10.131.149:8080;            keepalive 20;        }            server {            listen 80;            server_name keepAliveService;            location /keep-alive/hello {                proxy_http_version 1.1;                proxy_set_header Connection "";                proxy_pass http://keepAliveService;            }        }}

**前端实现 **  


前端可以使用组件 @microsoft/fetch-event-source 来实现。


npm i @microsoft/fetch-event-source
import { fetchEventSource } from '@microsoft/fetch-event-source';let controller = new AbortController(); let eventSource = fetchEventSource('apiUrl', { method: 'POST', headers: { 'Content-Type': 'application/json', 'token': '....' }, signal: controller.signal, body: JSON.stringify({ ... // 传参 }), onopen() { // 建立连接 }, onmessage(event) { // 接收信息 // 成功之后满足某些条件可以使用AbortController关闭连接 controller.abort() eventSource?.close && eventSource.close(); }, onerror() { // 服务异常 controller.abort() eventSource?.close && eventSource.close(); }, onclose() { // 服务关闭 },})

总结



SSE 轻量级的服务端单向推送技术;具有支持跨域、使用简单、支持自动重连等特点。相对于 WebSocket 更加轻量级,如果需求场景客户端和服务端单向通信,那么 SSE 是一个不错的选择。


作者:微盟技术中心
来源:juejin.cn/post/7317325043541032970
收起阅读 »

社会现实告诉我,00后整顿职场就是个笑话

00后整顿职场,也算是我之前的关键词吧。 我硬怼老板要加班费和提成,和他们辩论什么是我的财产,什么是公司的财产。 甚至还能在即将被开除的时候,反将一军把老板开除。 而正是因为这一次把老板开除,让我得到了机会。可以站在了相对于之前更高的位置,来俯瞰整个职场。 也...
继续阅读 »

00后整顿职场,也算是我之前的关键词吧。


我硬怼老板要加班费和提成,和他们辩论什么是我的财产,什么是公司的财产。


甚至还能在即将被开除的时候,反将一军把老板开除。


而正是因为这一次把老板开除,让我得到了机会。可以站在了相对于之前更高的位置,来俯瞰整个职场。


也真正意义上让我感受到了,00后整顿职场,就是一个互联网笑话罢了。


1、职场宫斗,成功上位


我之前在苏州工作,堪称工作中的宫斗,并且在这场宫斗大戏中胜出,将原有的项目负责人开除,成功上位。


而这个项目存在的问题非常多,我就在六月被派遣去项目的总部合肥进行学习,等到打通项目的全部链路后,再回到苏州。


届时我将以这个项目的负责人,重新搭建团队,开展这个项目。所以我在合肥那边,以员工的身份深入各个工作组进行学习。


在市场部,运营部的办公大厅工作过,也在各部门的独立办公室工作过。


我感觉自己像个间谍,一边在以平级的打工人身份和我的同事们相处,一边又以苏州负责人的身份,参与那些领导才能参与的内部会议。


2、内心变化的开端


我在合肥总部工作中,接触了很多躺平摆烂的同事,但这个“躺平摆烂“要加上双引号。


他们是00后,90后,甚至有85后。如果放在三个月前,我可以不假思索地说,他们全都是我最讨厌的人。他们如同牛羊一般任人宰割,上级让加班,他们就加班,有时候加班甚至超过四五个小时也没有怨言。


我甚至从来没听他们感慨过为什么没有加班费。亲眼看着他们被自己的上级用一些与工作无关的鸡毛蒜皮之事骂得狗血淋头,但他们也只会在被骂完之后,背地里吐槽那个领导估计是在家被老婆骂了,才来拿他们泄愤。


我打听了他们的工资,只能说中规中矩,起码不是能让人当牛做马的数字。偶尔我见到一两个有骨气的人,觉得拿这么点钱就应该干这么点事。干不爽就马上离职,但马上就会有下一个人替补他的位置,形成闭环。


我惊讶于怎么有人能惹到这个地步,但后来和他们日渐熟落,我们一起吃饭,一起打游戏,一起下班顺路回家,还参加了他们的生日聚会。我发现他们活得其实真的很洒脱。一切都是随遇而安,下班时间一到,他们就真的可以无忧无虑。


因为他们有一份工资还行的工作,养活自己。他们没有啃老,也没有用卑鄙的手段,去抢想要努力的人应该分到的蛋糕,也压根不去想要赚很多钱,因为没有什么需要太高消费的需求。


加上现在的环境,找到一份可观收入的工作确实很难。所以公司偶尔的加班,领导偶尔的泄愤,这些毕竟还是少数时候的偶尔,也都没有超过他们的心理承受阈值,那也就得过且过了。


所以我们其实都一样,只是个普通人罢了。而像我们这样的普通人,取之不尽,用之不竭。这到底是好事还是坏事呢?


3、复杂的职场生态环境


建立在这个基础上,视觉转换到高层领导们这里。他们当着我的面说,这样的人就是个底层打工仔,缺人就招,加班照旧,心情不好还要扣他们的全勤绩效。


压根就不怕这些底层打工仔闹事,纵使有一两个所谓的决心者辞职,也能在很快时间找到下一位。


两者形成互补,共同铸就了这样恶劣的职场生态环境。但我说职场无法改变,远不止这么一点原因。


在这个项目中,我说好听一些只能算是项目负责人,在此之上还有着项目股东,这还要细分成大股东和小股东。而我所在的项目属于互联网赛道,也就是说需要一些新鲜事物的眼光和思维来对待。


但这些股东们经常提出一些奇怪的意见,就如同用微商时代的卖货思维,来指点直播带货,并且他们是出钱的股东,他们提出的战略方针不容我驳回,因为在他们的光辉历史中,有大量的成功案例,来佐证他们的思路是对的。


我刚开始觉得也有道理。他们能有钱投资,肯定是有什么过人的本领能让他们赚到钱,但是随着相处下来,我发现不过是他们本身家里条件就优越,在九几年就能拿出一百万给他们创业。


他们把这一百万分散到二十个领域,每个投资五万总能撞上那么一两个风口,让他们实现钱生钱。


九几年的五万也算是一笔不少的投资。他们这样的发财经历,让我很难不产生质疑,这不是给我我也行吗?


毕竟他们如果真的有什么过人的本领和远见,也不至于在每次内部开会之前,都要组织喊这样的口号:“好,很好,非常好,越来越好“


甚至试图把这样的口号,带到每一次迎接客户的项目介绍会上。我以自曝式要挟制止他们这个行为,我说如果你们这么干,那我当天就辞职,内部都是自己人,我可以陪你们这样弄,但如果对外这么搞,被录下来说我们是传销,我都不知道怎么辩解。


4、职场中的背锅人


他们就是这样坚信着自己能成功,是因为自己有过人的才华。所以自我洗脑着自己提出的方向没有错。如果出错了,亏损了,那一定是负责人的问题。


但好巧不巧,我就是那个负责人。我已经无数次告诉他们,我们这个项目压根就不需要穿黑丝短裙跳舞的小姐姐。


我甚至写了一篇报告给他们,分析我们的项目为什么不能用擦边这种手段引流。但他们执意要,说这样来流量快,我都有点分不清到底是他们自己想看,还是深信这样做确实是可行。


但如果最后这样还是没成功,导致项目亏损,大概率还是在我身上找原因吧。


面对他们这样的大佬,我心里很清楚,这已经远远不是宫斗了,这也绝对不是靠几个心计,或者有实力撑腰就能取胜上位了。这场权力的游戏,不是我等草民玩得起的。


5、换个思路,创造属于自己的职场


一边是被提供资金,但是瞎指挥的股东们摧残,一边是在有限的预算下,我作为负责人,确实很难做到尊重打工人的内心挣扎,回到苏州我虽然能身居高位,但我终将成为我曾经最鄙视的人。


我不要当这个背锅侠,我也不想在这个环境中,去逐渐接受这样的价值观。


这样看来确实如此,00后整顿职场不过是一场互联网的狂欢罢了。


这个题材的故事,也永远只能发生在职场的最底层。由一群家境优越,体验生活的公子哥和我这种不知好歹的普通人共同出演。


大部分人只是在手机屏幕前把我们当个乐子,成了扣个666,然后一起吃胜利的果实。没成,那就确实是看了个乐子。


或许是因为他们心里也清楚,凭我们压根就做不到。


00后现在确实整顿不了职场,因为社会的资源和命脉还不掌握在00后手上。


但就止步于此了吗?我曾说过我想有一个自己的小工作室,遵守劳动法,双休,按时发工资,交纳五险一金。


是的,换个思路,也许00后不需要整顿职场,而是直接创造属于自己的职场,那么接下来我就要向着这个目标去努力了,毕竟二十年后我也还是00后,不如到时候再来说00后整顿职场吧。


作者:程序员Winn
来源:juejin.cn/post/7311603432929984552
收起阅读 »

RecyclerView还能这样滚动对齐?

前言 RecyclerView要想滚动到指定position,一般有scrollToPosition()和smoothScrollToPosition()两种方式。滚动到指定position后,通常还会要求itemView对齐RecyclerView起始点、中...
继续阅读 »

前言


RecyclerView要想滚动到指定position,一般有scrollToPosition()smoothScrollToPosition()两种方式。滚动到指定position后,通常还会要求itemView对齐RecyclerView起始点、中心点或结束点


熟悉RecyclerView的人应该知道,使用自定义SmoothScroller可以实现平滑滚动到指定position的同时,让itemView和RecyclerView的对齐;而scrollToPosition()方法只能滚动到指定position。那有办法让scrollToPosition()也做到对齐吗?


拆解行为


分析对齐的行为后,可以分为几步



  1. 让目标itemView可见

  2. 计算itemView和目的位置的偏移量

  3. 将itemView移动到目的位置


第一步scrollToPosition()就已经可以实现了,最后一步就是调用scrollBy(),那其实只需要实现第二步计算偏移量,而这可以参考SmoothScroller的实现


平滑滚动


来看下SmoothScroller是怎么做的。通常做法都是自定义LinearSmoothScroller


RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
int preference = LinearSmoothScroller.SNAP_TO_START;// 对齐方式
LinearSmoothScroller smoothScroller = new LinearSmoothScroller(context){
@Override
protected int getHorizontalSnapPreference() {
return preference;
}

@Override
protected int getVerticalSnapPreference() {
return preference;
}
};
smoothScroller.setTargetPosition(targetPosition);
layoutManager.startSmoothScroll(smoothScroller);

简单介绍下几种对齐方式



  • SNAP_TO_START:对齐RecyclerView起始位置

  • SNAP_TO_END:对齐RecyclerView结束位置

  • SNAP_TO_ANY:对齐RecyclerView任意位置,确保itemView在RecyclerView内


接下来看下getVerticalSnapPreference()或者getHorizontalSnapPreference()的返回值是怎么影响到itemView的对齐的。查看LinearSmoothScroller源码发现这两个方法会在onTargetFound()里调用


protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
final int dx = calculateDxToMakeVisible(targetView, getHorizontalSnapPreference());
final int dy = calculateDyToMakeVisible(targetView, getVerticalSnapPreference());
final int distance = (int) Math.sqrt(dx * dx + dy * dy);
final int time = calculateTimeForDeceleration(distance);
if (time > 0) {
action.update(-dx, -dy, time, mDecelerateInterpolator);
}
}

不难看出,该方法是计算targetView当前要滚动的偏移量和时长,并设置给action。而calculateDxToMakeVisible()calculateDyToMakeVisible()正是我们要找的计算偏移量的方法


由于这两个方法只依赖LayoutManager,所以我们可以将这些代码逻辑复制出来,创建一个Rangefinder类,用于计算偏移量


public class Rangefinder {
private final RecyclerView.LayoutManager mLayoutManager;

public Rangefinder(RecyclerView.LayoutManager layoutManager) {
mLayoutManager = layoutManager;
}

@Nullable
public RecyclerView.LayoutManager getLayoutManager() {
return mLayoutManager;
}

// 计算view在RecyclerView中完全可见所需的垂直偏移量
public int calculateDyToMakeVisible(View view, int snapPreference) {
final RecyclerView.LayoutManager layoutManager = getLayoutManager();
if (layoutManager == null || !layoutManager.canScrollVertically()) {
return 0;
}
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
view.getLayoutParams();
final int top = layoutManager.getDecoratedTop(view) - params.topMargin;
final int bottom = layoutManager.getDecoratedBottom(view) + params.bottomMargin;
final int start = layoutManager.getPaddingTop();
final int end = layoutManager.getHeight() - layoutManager.getPaddingBottom();
return calculateDtToFit(top, bottom, start, end, snapPreference);
}

// 计算view在RecyclerView中完全可见所需的水平偏移量
public int calculateDxToMakeVisible(View view, int snapPreference) {
final RecyclerView.LayoutManager layoutManager = getLayoutManager();
if (layoutManager == null || !layoutManager.canScrollHorizontally()) {
return 0;
}
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
view.getLayoutParams();
final int left = layoutManager.getDecoratedLeft(view) - params.leftMargin;
final int right = layoutManager.getDecoratedRight(view) + params.rightMargin;
final int start = layoutManager.getPaddingLeft();
final int end = layoutManager.getWidth() - layoutManager.getPaddingRight();
return calculateDtToFit(left, right, start, end, snapPreference);
}

public int calculateDtToFit(int viewStart, int viewEnd, int boxStart, int boxEnd,
@SnapPreference int snapPreference)
{
switch (snapPreference) {
case LinearSmoothScroller.SNAP_TO_START:
return boxStart - viewStart;
case LinearSmoothScroller.SNAP_TO_END:
return boxEnd - viewEnd;
case LinearSmoothScroller.SNAP_TO_ANY:
final int dtStart = boxStart - viewStart;
if (dtStart > 0) {
return dtStart;
}
final int dtEnd = boxEnd - viewEnd;
if (dtEnd < 0) {
return dtEnd;
}
break;
}
return 0;
}
}

有了计算偏移量的方法,接下来就是实现itemView的对齐了


即时滚动


根据上面的拆解步骤,再分析下每一步要做的事情



  1. 调用scrollToPosition()使目标itemView可见。因为该方法最终会requestLayout(),所以要在layout后,才能通过获取到itemView。那么可以post()后调用LayoutManagerfindViewByPosition()方法获取itemView

  2. 参考LinearSmoothScrolleronTargetFound()方法,使用上面的Rangefinder计算itemView和目的位置的偏移量

  3. 调用scrollBy()将itemView移动到目的位置


RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
recyclerView.scrollToPosition(targetPosition);
recyclerView.post(new Runnable() {
@Override
public void run() {
View targetView = layoutManager.findViewByPosition(targetPosition);
if (targetView != null) {
Rangefinder rangefinder = new Rangefinder(layoutManager);
final int dx = rangefinder.calculateDxToMakeVisible(targetView, preference);
final int dy = rangefinder.calculateDyToMakeVisible(targetView, preference);
if (dx != 0 || dy != 0) {
recyclerView.scrollBy(-dx, -dy);
}
}
}
});

至此,我们就实现了即时滚动到position的同时,让itemView和RecyclerView对齐的功能。当然,这也只是测试代码,实际使用还会对上面的逻辑进行封装


测试代码 recyclerView-scroll-demo


参考


作者:benio
来源:juejin.cn/post/7364740313284444186
收起阅读 »

这么炫酷的换肤动画,看一眼你就会爱上

web
实现过程 我们先创建下 vue 项目 npm init vite-app vue3-vite-animation 进入文件夹中 cd vue3-vite-animation 安装下依赖 npm install 启动 npm run dev 重新修改 ...
继续阅读 »

动画.gif


实现过程


我们先创建下 vue 项目


npm init vite-app vue3-vite-animation

进入文件夹中


cd vue3-vite-animation

安装下依赖


npm install

启动


npm run dev

image-20240503171537954.png


重新修改 App.vue


<template>
<div class="info-box">
<div class="change-theme-btn">改变主题</div>
<h1>Element Plus</h1>
<p>基于 Vue 3,面向设计师和开发者的组件库</p>
</div>

</template>

<script setup lang="ts">

</script>



<style>

.change-theme-btn {
width: 80px;
height: 40px;
background-color: #fff;
text-align: center;
line-height: 40px;
color: #282c34;
cursor: pointer;
border-radius: 8px;
border: 2px solid #282c34;
}

.info-box {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
</style>


基本样式出来了,但是页面出现了滚动条,我们需要去掉原有样式


image-20240503175456039.png


src/index.css,里的所有样式都删除了,再到 index.html 中将 bodymargin 属性去掉


<body style="margin: 0;">
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>

接下来,我们来实现下换肤功能


使用 css 变量,先定义下一套黑暗主题、一套白色主题


:root {
--background-color: #fff;
--color: #282c34;
background-color: var(--background-color);
color: var(--color);
}

:root.dark {
--background-color: #282c34;
--color: #fff;
}

再定义点击事件 changeColor,点击 "改变主题" 就会改变主题颜色


classList.toggle 这个方法的第一个参数是类名,第二个参数是布尔值,表示是否添加类


如果第二个参数为 true,则添加类;如果第二个参数为 false,则移除类


<div class="change-theme-btn" @click="changeColor">改变主题</div>

/* 改变颜色 */
const changeColor = () => {
document.documentElement.classList.toggle('dark')
}

image-20240503180914393.png


按钮背景颜色、边框、字体颜色都没有改变


调整下按钮样式,把背景颜色、边框、字体颜色这些都用 css 变量代替


.change-theme-btn {
width: 80px;
height: 40px;
background-color: var(--background-color);
text-align: center;
line-height: 40px;
color: var(--color);
cursor: pointer;
border-radius: 8px;
border: 2px solid var(--color);
}

image-20240503181138545.png


这个效果不是我们想要的,需要一个过渡动画对不对


使用 startViewTransition,这个 API 会生成一个屏幕截图,将新旧屏幕截图进行替换


截图分别对应两个伪元素 ::view-transition-new(root)::view-transition-old(root)


 // 创建一个过渡对象
document.startViewTransition(() => {
document.documentElement.classList.toggle('dark')
})

可以看到,一个淡入淡出的效果,但是我们需要的是一个圆向外扩散的效果


用剪切效果就可以实现,其中 circle(动画进度 at 动画初始x坐标 动画初始y坐标)


设置动画时间为 1秒,作用在新的伪元素上,也即是作用在新的截图上


const transition = document.startViewTransition(() => {
document.documentElement.classList.toggle('dark')
})

transition.ready.then(() => {
document.documentElement.animate({
clipPath: ['circle(0% at 50% 50%)', 'circle(100% at 100% 100%)']
}, {
duration: 1000,
pseudoElement: '::view-transition-new(root)'
})
})

动画-1714752074132-6.gif


为什么动画效果和预期的不一样


因为,默认的动画效果,把当前动画覆盖了,我们把默认动画效果去掉


/* 隐藏默认的过渡效果 */
::view-transition-new(root),
::view-transition-old(root) {
animation: none;
}

动画-1714752309164-8.gif


效果出来了,但是圆的扩散不是从按钮中心扩散的


那么,通过 ref="btn" 来获取 “改变主题” 按钮的坐标位置


再获取按钮坐标减去宽高,就能得到按钮的中心坐标了


<div ref="btn" class="change-theme-btn" @click="changeColor">改变主题</div>

<script setup>
import { ref } from 'vue';
const btn = ref<any>(null)

/* 改变颜色 */
const changeColor = () => {
// 创建一个过渡对象
const transition = document.startViewTransition(() => {
document.documentElement.classList.toggle('dark')
})

const width = btn.value.getBoundingClientRect().width // 按钮的宽度
const height = btn.value.getBoundingClientRect().height // 按钮的高度
const x = btn.value.getBoundingClientRect().x + width / 2 // 按钮的中心x坐标
const y = btn.value.getBoundingClientRect().y + height / 2 // 按钮的中心y坐标

transition.ready.then(() => {
document.documentElement.animate({
clipPath: [`circle(0% at ${x}px ${y}px)`, `circle(100% at ${x}px ${y}px)`]
}, {
duration: 1000,
pseudoElement: '::view-transition-new(root)',
})
})
}
</script>

扩展,如果,我不要从中心扩展,要从左上角开始动画呢,右上角呢...


我们把按钮放在左上角,看看效果


修改下样式、与模板


<template>
<div ref="btn" class="change-theme-btn" @click="changeColor">改变主题</div>
<div class="info-box">
<h1>Element Plus</h1>
<p>基于 Vue 3,面向设计师和开发者的组件库</p>
</div>

</template>

.info-box {
width: 100vw;
height: calc(100vh - 44px);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}

动画这个圆的半径不对,导致动画到快末尾的时候,直接就结束了


动画-1714753474905-10.gif


动画的圆的半径 = 按钮中心坐标 到 对角点的坐标


可以使用三角函数计算,两短边平方 = 斜边平方


image-20240504002759638.png


// 计算展开圆的半径
const tragetRadius = Math.hypot(
window.innerWidth - x,
innerHeight - y
)

// 设置过渡的动画效果
transition.ready.then(() => {
document.documentElement.animate({
clipPath: [`circle(0% at ${x}px ${y}px)`, `circle(${tragetRadius}px at ${x}px ${y}px)`]
}, {
duration: 1000,
// pseudoElement
// 设置过渡效果的伪元素,这里设置为根元素的伪元素
// 这样过渡效果就会作用在根元素上
pseudoElement: '::view-transition-new(root)',
})
})

动画-1714754131456-15.gif


如果是右上角呢


.change-theme-btn {
float: right;
width: 80px;
height: 40px;
background-color: var(--background-color);
text-align: center;
line-height: 40px;
color: var(--color);
cursor: pointer;
border-radius: 8px;
border: 2px solid var(--color);
}

动画-1714754468881-23.gif


在右边的话,使用三角函数计算,其中一个短边就不能是 屏幕宽度 - 按钮x坐标,直接是 x 坐标就对了


那要怎么实现呢,直接取 屏幕宽度 - 按钮x坐标 与 按钮x坐标 的最大值就可以了


y 也是同理


const tragetRadius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y)
)

动画-1714754788538-25.gif


你可以试试其他位置,是否也是可行的


完整代码


<template>
<div ref="btn" class="change-theme-btn" @click="changeColor">改变主题</div>
<div class="info-box">
<h1>Element Plus</h1>
<p>基于 Vue 3,面向设计师和开发者的组件库</p>
</div>

</template>

<script setup lang="ts">
import { ref } from 'vue';
const btn = ref<any>(null)

/* 改变颜色 */
const changeColor = () => {
// 创建一个过渡对象
const transition = document.startViewTransition(() => {
document.documentElement.classList.toggle('dark')
})

const width = btn.value.getBoundingClientRect().width // 按钮的宽度
const height = btn.value.getBoundingClientRect().height // 按钮的高度
const x = btn.value.getBoundingClientRect().x + width / 2 // 按钮的中心x坐标
const y = btn.value.getBoundingClientRect().y + height / 2 // 按钮的中心y坐标

// 计算展开圆的半径
const tragetRadius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y)
)

// 设置过渡的动画效果
transition.ready.then(() => {
document.documentElement.animate({
clipPath: [`circle(0% at ${x}px ${y}px)`, `circle(${tragetRadius}px at ${x}px ${y}px)`]
}, {
duration: 1000,
// pseudoElement
// 设置过渡效果的伪元素,这里设置为根元素的伪元素
// 这样过渡效果就会作用在根元素上
pseudoElement: '::view-transition-new(root)',
})
})
}
</script>


<style>

:root {
--background-color: #fff;
--color: #282c34;
background-color: var(--background-color);
color: var(--color);
}

:root.dark {
--background-color: #282c34;
--color: #fff;
}

/* 隐藏默认的过渡效果 */
::view-transition-new(root),
::view-transition-old(root) {
animation: none;
}

.change-theme-btn {
float: right;
width: 80px;
height: 40px;
background-color: var(--background-color);
text-align: center;
line-height: 40px;
color: var(--color);
cursor: pointer;
border-radius: 8px;
border: 2px solid var(--color);
}

.info-box {
width: 100vw;
height: calc(100vh - 44px);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
</style>

换肤动画源码


小结


换肤功能,主要靠 css 变量 与 classList.toggle


startViewTransition 这个 API 来实现过渡动画效果,注意需要清除默认动画


圆点扩散效果,主要运用剪切的方式进行实现,计算过程运用了三角函数运算


作者:大麦大麦
来源:juejin.cn/post/7363836438935552035
收起阅读 »

从密码到无密码:账号安全进化史(科普向)

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究! 本文是一篇科普文,五一结束了,大家看点轻松的~ 不知道大家在过去半年有没有发现 Github 强制开启了 2FA,而且还不可以关闭的,每次你打开 github 都会提...
继续阅读 »

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!




本文是一篇科普文,五一结束了,大家看点轻松的~


不知道大家在过去半年有没有发现 Github 强制开启了 2FA,而且还不可以关闭的,每次你打开 github 都会提醒你的验证:


Image.png


简单的说,就是打开 Github 进行验证时,只依靠密码验证已经不被允许,你必须打开你手机上的验证软件,把里面随机码输入到 Github 才能完成身份验证,类似于十年前国内 QQ 安全中心的验证。


这是一种双重验证的手段,用于更好的保证我们的账号安全,今天就以此为引,给大家讲讲账号安全相关发展的历史。


第一幕:密码的独角戏 - 脆弱的防线


在互联网的蛮荒时代,密码就像原始人手中的木棍和石头,是守护账号安全的唯一屏障。然而,这道防线却是脆弱不堪的,面对黑客的攻击,如同纸糊的老虎,一戳就破。暴-力-破-解、字-典-攻-击、社会-工程-学手段,都足以让密码这道防线形同虚设。


1. 暴-力-破-解:暴-力-破-解就是通过遍历的方式尝试出你的密码组合,比如银行的六位取款密码实际上只有 46656种组合,利用现在任何一台电脑或者手机的算力都能瞬间算出来,为了对应这种情况,现在几乎所有网站都有密码输入次数限制。


2. 字-典-攻-击:字典-攻击就是利用常用密码来攻破你的密码,比暴力破解效率更好,比如 123456 这个密码就有很多人使用。


3. 社会-工程-学:社会-工程-学说人话就是套你的话,或者调查你的信息,比如在和你沟通的过程中知道了你的手机号、身-份-证号码、生日信息等,因为有大量的人用手机号后六位、身-份-证号后六位或者生日当作自己的密码,所以这种手段的成功率一般会更高。


在当前这个时代,由于互联网各种 App 的涌入,每个人都拥有大量的账号,如何记忆他们成为了一个难题,大量的人选择对所有网站使用同一个密码,这就又造成了账号安全问题。


重复使用密码就像是使用同一把钥匙开启不同的门,一旦一把钥匙被复制,所有的门都将面临危险。


每年,全球都会发生无数起数据泄露事件,大量的用户名和密码被公开曝光。这些泄露的密码成为了黑客攻击的利器,他们可以利用这些密码进行撞库攻击,尝试登录其他网站。


会在不同的网站使用相同的密码将会导致“一损俱损”的局面。一旦一个网站发生数据泄露,黑客便可以利用泄露的密码尝试登录其他网站,从而获取更多个人信息,造成更大的损失。


第二幕:多因素认证 (MFA) 登场 - 多重关卡,层层设防


所以,为了弥补密码的不足,MFA 应运而生,为账号安全加装了多重门锁。除了密码这把“钥匙”,你还需要其他的“通关密语”才能进入:



  • 验证码: 这是国内最常用的方式,甚至几乎所有 App 都已经不需要你记忆账号密码,只需要一个手机验证码即可,国外使用手机验证码的很少,因为他们更多使用邮箱来注册账号,比如我现在在使用的编辑软件Craft 在登录时就要求你提供邮箱验证码,它甚至不能设置密码。

  • 指纹识别: 你的指纹独一无二,所以它就像是你的专属的“魔法印记”,轻轻一按,就能保证你是你。

  • 面部识别: 对着摄像头眨眨眼,你的面容信息也是你的专属印记,苹果手机上甚至使用了虹膜识别来检测你是你,而不是别人。

  • 安全令牌: 一个小巧的硬件设备,可以生成一次性密码,就像古代的“虎符”一样,只有拥有它才能调兵遣将。令牌可以有软件和硬件两种方式,软件就是 Google Auth 这种软件,而硬件则是我们早古互联网时代网上购物常用的网银 U 盾形式。


在开头的时候,我曾提到了 2FA,它和本节的 MFA 听名字非常相似,实际上说的也几乎是一个东西。


2FA 是指:需要两种验证,才能完成整个验证,一般是密码和动态安全令牌。


MFA 是指:需要两种或以上验证方式,才能完成整个验证,一般也是密码和动态安全令牌。


所以在大多数语境下,这俩说的其实是一个东西,有些验证方式将两个验证方式合而为一,比如手机/邮箱验证码。


因为多因子验证的核心是:一个你知道的凭证 和 一个你刚刚才知道的凭证。


我们一开始就知道的凭证往往是邮箱 + 密码,一个刚刚才会知道的凭证往往就是动态安全令牌码了,所以手机验证码登录的方式也是 2FA,还是属于比较方便的那种。


注:我这里说的手机验证码登录是真的发给你验证码,而不是国内的那种手机号一键登录。


第三幕:单点登录 (SSO) 崛起 - 统一管理的钥匙


其实随着 MFA 的出现,安全问题已经不需要太担心了,所以接下来账号安全开始朝着:安全 + 高效的方向开始发展,所以开始出现了 SSO。


SSO 的第一个阶段是内部互信,它的概念最早可以追溯到 1990 年代,随着企业内部网络的发展而兴起。


后来随着互联网的发展,一个公司往往同时拥有多个业务,比如十年前还是百度的天下的时候,我们会同时使用百度知道、百度贴吧、百度网盘这些产品。


你只需要在某一个百度旗下的产品登录一次,打开其他产品的时候往往也会自动识别到你的账号。


比如你在百度贴吧登录了,此时你打开网页版的百度网盘你自动就是已登录状态。


不要以为这是一个 So easy 的操作,它的原理其实是使用你存储在同一个主域名下的 cookie 实现的。


比如百度贴吧的域名是:tieba.baidu.com/,而百度网盘的域名是:pan.baidu.com/,它俩都属于主域名 baidu.com,所以通过携带同域名下cookie 的方式,让同域名下的其他服务也能正确识别当前账号。


具体识别方案一般有两种:



  1. 通过共享 session + cookie 的方式做验证。

  2. 通过获取 cookie 内部跳转到 SSO 做验证。


无论使用哪种方案,携带 cookie 这个操作必不可少,所以这一阶段的 SSO 是基于 Cookie 的。


可能还有一个词大家比较常见:SAML,SAML标准也是用于内部系统互信,做的事和基于 Cookie 的 SSO 都是一样的,所以这里我不再赘述。


第四幕:OAuth 协议的诞生 - 授权管理的桥梁


经历完 SSO 的第一个阶段之后,我们就来到了 SSO 的第二阶段:外部互信


由于 Web 互联网的兴起,这一阶段也被称为基于 Web 的 SSO,这一阶段的代表是OAuth。


你有没有想过,如果我们在所有平台都使用同一个账号多好,就不用在记忆那么多的应用账号密码,减少心智负担。


在国内互联圈地的情况下,这种情况并没有实现,也可以说通过手机号实现了。


但是在国外,Google 账号体系几乎就是事实上的一号通行,你注册一个 Google 账号之后,几乎可以通过这个账号登录所有的网站,这就是 OAtuh 的作用。


想象一下,你拥有许多宝藏,分别存放于不同的宝库中。比如,你在 Facebook 上存储着你的社交关系,在 Google 上存储着你的邮件和文件,在 Spotify 上存储着你的音乐喜好。


现在,你想要使用一个新的游戏应用,而这个应用需要访问你在 Facebook 上的好友列表,以便你能够邀请好友一起玩游戏。


这时,你面临一个两难的选择:



  • 分享密码: 将你的 Facebook 密码告诉游戏应用,让它直接访问你的好友列表。但这存在着巨大的安全风险,一旦游戏应用泄露你的密码,你的所有 Facebook 数据都将暴露无遗。

  • 放弃使用: 由于担心安全问题,你放弃使用这个游戏应用,从而错过了与好友一起游戏的乐趣。


为了解决上面这种问题,Google 等公司在 2010 年发布了 OAuth1.0,由于它存在许多问题,所以又在 2012 年发布了 OAuth2.0。


所以在现如今,几乎所有公司都接入了 Google 的 OAuth 登录,当你在第三方平台想使用 Google 账号登录时,OAuth 协议会引导你到 Google 进行授权。


平台会询问你是否同意授权第三方应用访问你的部分数据 (例如好友列表),如果你同意,平台就会发放一个临时的“通行证”给第三方应用,让它可以访问你的数据,但不会泄露你的密码。


所以 OAuth 的核心是授权而非共享。


第五幕:无密码时代的曙光 - 告别繁琐的密码


我相信当大家看到第四节的时候,大家就会觉得应该就这些了,没有别的新意了,恰恰相反,为了彻底摆脱密码的束缚,世界巨头们正在探索新的“魔法”,那就是无密码


在 2019 年,WebAuthn 标准被 W3C 以建议的形式发布,它是 FIDO 联盟下 FIDO2 的核心组件,旨在减少人们对于密码的依赖。


它带了以下三个好处:



  • 消除密码依赖: 通过使用更加安全的认证方式,例如生物识别技术 (指纹、面部识别) 或安全密钥,消除用户对密码的依赖,降低密码泄露和网络钓鱼攻击的风险。

  • 提升用户体验: 简化登录流程,无需记忆和输入复杂的密码,只需轻触指纹或插入安全密钥,即可完成身份验证。

  • 增强安全性: 使用公钥加密技术,确保用户的认证信息不会被窃取或伪造,有效抵御网络攻击。


如果大家有在 Mac 上的 Safari 浏览器登录苹果账号的经历,就会发现它不需要你输入密码,只需要一次简单的指纹验证:


Image.png


这时你通过验证你的指纹就可以顺利登录成功,这就是基于 WebAuthn 标准的 Passkeys。


目前苹果、谷歌、微软等几乎所有大厂都支持了Passkeys,,由于它也是一个 W3C 标准,所以你可以通过这个网站查看支持列表。


看起来指纹验证就像开头我们说过的 MFA,但是它比 MFA 多了一个东西就是设备,通过生物信息 + 受信设备的方式完成了它的整个认证流程,它拥有两个比较大的特点:



  • 提供了一套标准化的用户界面和用户体验,简化了无密码登录的操作流程。

  • 将用户的登录凭证 (私钥) 存储在用户的设备 (例如手机、电脑) 中,并通过云端服务进行同步,方便用户在不同设备上登录。


说回我们开头的 Github 的 2FA,其实 Github 也接入了它,如果你完成 2FA 之后,之后就可以在浏览器中通过指纹验证登录。


身份认证的未来已来,无密码的出现,为我们在登录授权流程中带来了许多方便~




好了,以上就是本篇文章的全部内容了,希望大家多多点赞支持,我将更快提供更好更优质的内容。


注:本文小标题是借助 AI 能力起的,部分描述也借助了 AI 美化,AI 美化生成内容不会超过 300 字(本文 4000 字),请大家放心食用。


作者:和耳朵
来源:juejin.cn/post/7364764922339065890
收起阅读 »

职场贩卖焦虑,不如调整心态

每当长假期来临之际,出行就变得异常困难,本来是该放松心态的阶段,因为各种各样不好的事情充斥在周围,败坏了本该愉悦的心情,五一还未出行,调补假期的事情就吵的沸沸扬扬,机票的大跌也带来背刺,这个假期注定从焦虑开始,接下来大家还会面对,路上的堵车、景区的人从众、天价...
继续阅读 »
image.png

每当长假期来临之际,出行就变得异常困难,本来是该放松心态的阶段,因为各种各样不好的事情充斥在周围,败坏了本该愉悦的心情,五一还未出行,调补假期的事情就吵的沸沸扬扬,机票的大跌也带来背刺,这个假期注定从焦虑开始,接下来大家还会面对,路上的堵车、景区的人从众、天价的消费,住店的隐私及背刺、以及可能遭遇的不好的天气等等等等;


啊~~~ 难道今天是要来贩卖焦虑吗?


贩卖焦虑吗?其实不然,我更想聊,大环境的变化,如何去调整自身?


大环境


互联网大环境,说说我自身的感受;


加班


加班,相比几年前,感受最深的是,或者说免费加班,越来越多;好像演变成了这家公司好像不搞点 996,都不是互联网公司,不搞点 996,这家公司都不赚钱,不搞点 996 ,这家公司都没有上升空间;


面试很多公司最少都是 995 起步,搞得候选人不接受,好像就吃不苦、没有奋斗精神一样,也许也是,不然为什么 79 的眉笔都会牵涉出工作不努力;但是想过没有,这可能真的会让人认知不够清晰的同学真的去怀疑自己是不是真的不够努力,也真的会让努力的人寒心;


并不是每个人都是幸存者偏差,成功真的不会降临在每个人身上,现实是大部分人不是出生在罗马,而是不是骡就是马,天赋加机遇,各种契机的组合,也许都是百万分之一的幸存率,真的不是努力了,就能有结果的;


但中国人骨子里优良的品质,从日出而作日入而息,自古以来的勤奋,就在基因里面代代传承,现实社会,别说出人头地,能够较好生存都已经不容易了,所以真的,社会多一些宽容,企业多一点人性,如果可以,真的要好好审视现状;


加班真的能带来更好的营收吗?也许可能吧,但是被禁锢的灵魂,你想要他能有多大自驱力,能有多大创造力,能看到前景吗?


内卷


人口大国,求职者甚众,听过最多也是最讽刺的话,你不干多的是有人干,是的,我们每个人都是备胎,我们每个人备胎都很多;


我不知道是从哪里看过一篇言论,大概就是说,程序员这个职业体系,其实大部分是没有什么背景的人,或者是农村人,他们没有好的出路,想要挣钱,想要快速走出原来的贫困圈,他没有太多的选择,他只能苦干;


很讽刺是不是,也许上面的话是被人断章取义的,但是我来自农村,我的感受确实大差不差,你以为干着一份体面的工作,其实在背地里都是牛马,而你没有办法去反驳,因为你没有背景,你要出圈;


所以细细想来,嘈杂的环境,加剧了内卷的诞生,一群本不该竞争的人,一群本该一起取暖的人,一群本来有着共同理想的人,逐渐变成了相互的垫脚石,kpi 的丑态,已经让很多人变成了死道友不是贫道的淡漠。


PUA


PUA,也许是自嘲的人或者不良的氛围或者玩梗,PUA 已经变成日常用语,『牛马们』 开始觉醒,原本作为管理者的手段,上位者心照不宣,突然被公之于众,大家都逐渐去理解你说的话,背后的含义是什么;


『我对你是失望的』『你在低绩效里面是靠前的』『机会已经给到你了,接下来就看你能不能把握了』『我知道你有压力,但你要扛得住压力』


是的,每个人都很珍惜自己的工作,每个人都有理想,每个人都想名利双收,利用人性的欲望去做管理,从本身来说没有什么问题,但是利用人性的弱点,去打压,去PUA,真的不病态吗?


小结


大环境确实存在问题,社会在发展,人类在进步,在快速发展的历程中,总会有一些病态的体系出现,但是在面对这些不定因素的时候,时刻保持理性的头脑,要学会去洞察,要保护自己,在做每一件事情上,不求完美,但求心安,不做别人的摆布工具,做好自我,坚守自我;


关于自我


社会立足,自我认知很重要


职业规划


和很多很多的同学聊过,经历过很多阶段的同学,实习生、初级、中级、高级、资深、专家等等,走技术架构的、走业务架构的、走技术管理的等等;


大部分的同学,其实都不具备职业规划的能力,甚至没有概念,这种真的会很盲目,没有目标、没有方向,一家一家公司换,随性,自由,看起来潇洒、有魄力;


薪资,当然是第一考虑因素,一切不聊到手薪资的都是耍流氓,但是真的不是唯一考虑因素,每个阶段,真的要知道自己需要的是什么,多去看看相关职业规划的书或者前辈的实际历程,当然不要去轻易质疑一个职业人生,理性看待每一件事;


当我们有意识的去按照自己想要走到路线的时候,薪资,我希望不是唯一的理由,当然他依然很重要,他依然是第一要素,不然工作为了什么?为了理想,丢掉面包吗?大可不必,除了自我感动,没有人会为你喝彩;


话说回来,职业规划,一定要重视,他不是今天的目标,也不是你今年的目标,他是你实现你人生目标的一个重要规划之一,每个阶段可能会变,变,很正常,不用自我怀疑,随着你见识的增长,经济的增长,你眼界会不一样,而我们要求的就是不断的是调整规划,为每个阶段的目标去准备,但是在快速奔走的过程中,也请回过头,看看自己的初心;


强大自我


本身我自己是前端,虽然现在不是纯粹的前端开发,但是从来没放弃过对前端技术的关注;


前端很乱,很快,很浮躁,『前端娱乐圈』 也不是一朝得名,很多时候,我也很焦虑,在做开发的时候,我对技术理解很纯粹,就希望用更好的方式去实现,更牛的技术去开发,但是随着了解的越多,越觉得需要了解的也越多,所以在很长的一段时间里,每天我都会查阅大量的资料,为了搞懂一个技术点,可能会去看一整本书,或者几十片技术文章;


我记得有一段时间,我甚至有点偏执,一段代码,甚至项目周期都过去一大半了,我突然发现实现的方式不是很好,很别扭,我直接把所有代码都删掉,甚至包括牵连的老代码,删掉一时爽,接下来就是无尽的痛苦,007 等着你;


换成现在,我不知道还会不会做这种偏执的事情,但是我想,不顺眼的,我照样还是会去优化,对技术的执着,我个人倒是觉得没有太大问题,但是前提别耽误项目进度,否则团队的信任会越来越低;


还有一个阶段,我觉得自己现状已经不是自己想要的了,接受降薪资,去一家可能会给我职业生涯带来有利成长的公司,人们没有办法当即去评判当时做的选择是对或者是错,事后看来,在做评价,已经没有太多意义,人生很长,也是到迟暮之年,这也许也是一段有着非同意义的经历;


我只是站在我自己的角度,去看待或者去如何强大自我,在这个过程中,有偏执,有耽误,也有背离,但是更多的是我自身对自身的调整,不能说我自己做的如何好,只能说,我是一直向着自身变好的方向努力;


关于焦虑


从我个人角度看,职业生涯中,已知和未知的事情太多,初期你会为了自己不够强大而焦虑,到后来你会发现你掌握很多东西,但是依然有很多盲区,技术很广,很难做到都了解;每个阶段都有每个阶段,或者每个环境需要有每个环境掌握的东西;有盲区,也请不要焦虑;


看过余华和罗翔的有意思一个对话:



  • 罗翔问余华老师:给女主人公起名叫林红,是取自南唐后主李煜的那个林花谢了春红,太匆匆,长恨朝来寒雨晚来风,胭脂泪,留人醉。自是人生长恨水长东吗?

  • 余华老师:我真没想这么多,我就是随便起的,现在是了。


你看,作者可能都没想那么多,如果说写的时候,每个都去考究,也许一篇小说,可以写几十年,也许余华老师就是取名字的时候,看到院子前面的林子里面的花红了,就叫林红,别去揣测太多,毕竟*『尤雨溪』*面试 vue 都不见得能通过;


我们能做的就是做好准备,拒绝内耗,理性看待,做每个阶段发力;


最后


环境、自身以及不可控因素,就像假期出行一样,你永远无法去预料即将发生的事情,如果被牵着走,你会发现很多事情,在没有开始之前,就已经寸步难行,不如调整心态,强大自我,无论是职场工作,还是生活,强大的自我,懂得适度调整,所遇之事,可能都会变得不一样,毕竟爱笑的程序员,代码肯定都不会写的很差;


作者:深谷逸风
来源:juejin.cn/post/7363193808522477607
收起阅读 »

background简写,真细啊!

web
背景原因 今天写需求,需要使用background简写属性,心想这还不简单吗,真男人写样式只需要两秒: background: url('./bg.png') no-repeat center contain ; 搞定! 上面设置的依次是 背景图片 背...
继续阅读 »

背景原因


今天写需求,需要使用background简写属性,心想这还不简单吗,真男人写样式只需要两秒:


background:  url('./bg.png') no-repeat center contain ;

搞定!


上面设置的依次是 背景图片 背景平铺模式 背景位置 背景图片是保有其原有的尺寸还是拉伸到新的尺寸。


so easy~


看我ctrl + s 保存代码,编译。


嗯? 怎么不生效? 俺的背景呢?
打开控制台一看,好家伙,压根没生效:


image.png


问题排查


第一反应是这些属性有固定顺序,但是凭我练习两年半的经验,不应该啊,之前也是这样用的啊,遂打开MDN,仔细翻阅....


发现了下面这段话:


image.png


这让我更加确信 写的没毛病啊!!


background-attachment、background-color、background-image、background-position、background-repeat、background-size
这些属性可以以任意顺序书写。


见了鬼了,待我排查两小时(摸鱼...)


原因浮现


在仔细阅读文档后发现,其实在文档的上面,还有另外一段话:


image.png


我恍然大悟,索嘎,以后看文档不能马虎了,得仔细查阅,过于经验主义了,这都是细节啊!


background使用注意事项和总结


其实,使用background时,大部分时候 属性的顺序是可以任意位置书写的,
但是有两个属性有点特殊,那就是background-size和background-position,


当background简写同时有这两个属性时,那么必须background-position在前,background-size在后,且两者只能紧挨着书写并且以 "/"分隔。
例如:


错误: background: url('./bg.png') no-repeat center  contain ; // 没有以 "/"分隔
错误: background: url('./bg.png') center no-repeat contain ; // 没有紧挨着书写
错误: background: url('./bg.png') no-repeat contain / center; //background-size写在了 background-position的前面

正确: background: url('./bg.png') no-repeat center / contain ;


写在最后


其实MDN在关于background的文档最开头的例子中就有写:


image.png


只不过没有用语言描述出来,一般没有认真看很难发现,所以有时候能够静下心来认真查阅文档,真的会发现很多细节(甩锅:这tm是谁写的文档,出来挨打).


作者:可狗可乐
来源:juejin.cn/post/7234825495333158949
收起阅读 »

JavaScript 流程控制语句详解:if语句、switch语句、while循环、for循环等

JavaScript,作为一种广泛使用的编程语言,它的流程控制语句是构建逻辑和实现功能的基础。流程控制语句包括条件语句、循环语句和转向语句,它们是编程中不可或缺的部分。接下来,我们将一一解析这些语句,带你走进JavaScript的世界。一、什么是流程控制语句流...
继续阅读 »

JavaScript,作为一种广泛使用的编程语言,它的流程控制语句是构建逻辑和实现功能的基础。流程控制语句包括条件语句、循环语句和转向语句,它们是编程中不可或缺的部分。

接下来,我们将一一解析这些语句,带你走进JavaScript的世界。

一、什么是流程控制语句

流程控制语句是用来控制程序中语句执行顺序的语句,它们可以影响程序的流程,从而实现不同的逻辑。流程控制语句主要分为以下三类:
Description

顺序结构: 这是最基本的流程控制,代表代码按照书写的顺序从上到下依次执行。通常程序都是从第一行代码开始顺序执行到结束的。

选择结构: 用于根据特定条件来控制代码的执行路径。常见的选择结构包括if、else、if-else if和switch等。这些语句允许程序在满足某些条件时执行特定的代码块,而在其他条件下执行另外的代码块或跳过某些代码。

循环结构: 用于重复执行某段代码直到满足退出条件为止。循环语句包括for、foreach、while和do-while等。通过这些语句,可以实现固定次数的循环或者当某个条件成立时的持续循环。

此外,还有跳转语句如break、continue和return等,它们可以改变正常的控制流程,例如跳出当前循环或者返回函数的结果。

二、条件判断语句

使用条件判断语句可以在执行某个语句之前进行判断,如果条件成立才会执行语句,条件不成立,则语句不执行。

语法一:if(条件表达式){语句…};

执行流程:
if语句在执行时,会先对条件表达式进行求值判断,

  • 如果条件表达式的值为true,则执行if后的语句,

  • 如果条件表达式的值为false,则不会执行if后的语句if语句只能控制紧随其后的那个语句。

如果希望if语句可以控制多条语句,可以将这些语句统一放在代码块中,如果就一条if语句,代码块不是必须的,但在开发中尽量写清楚。

代码演示:

<script>
if(true) console.log('好好学习,天天向上');
// 加上条件运算符 && ||
var a=20;
if(a>10&&a<=20){
alert('a在10-20之间');
alert("4567")
}
</script>

语法二:if…else…语句

语法:

if(条件表达式){
语句....
}else{
语句....
}

执行流程:
当该语句执行时,会先对if后的条件进行判断,

  • 如果该值为true,则执行if后的语句,

  • 如果该值为false,则执行else后的语句,两者选其一执行。

语法三:if…else if…else

语法:

if(条件表达式){
语句....
}else if(条件表达式){

语句....
}else{
语句....
}

执行流程:
当该语句执行时,会从上到下依次对条件表达式进行求值,

  • 如果值为true,则执行当前语句。

  • 如果值为false,则继续向下判断,如果所有的条件都不满意,就执行最后一个else或者不执行,该语句中,只会有一个代码块被执行,一旦代码块执行了, 则直接结束语句。

<script>
var age=16;
/* if(age>=60){
alert("你已经退休了~~~")
}else{
alert("你还没退休~~~")
} */


if(age>=100){
alert("您老高寿呀~~~");
}else if(age>=80){
alert("你也不小了");
} else if(age>=60){
alert("你刚退休呀~~~");
}else if(age>=30){
alert("你已经中年了");
}else if(age>=17){
alert("你刚成年呀~~~");
}else{
alert("你还是个小孩子~~")
};
</script>

三、条件分支语句

switch语句是一种多分支选择结构,它可以根据表达式的值,来选择执行不同的代码块。

语法:switch…case…

switch(条件表达式){
case 表达式:
语句....
break;
case 表达式:
语句....
break;
default:
语句...
break;
}

执行流程:

在执行时,会依次将case后的表达式的值和switch后的条件表达式的值进行全等比较。

  • 如果比较结果为true,则从当前case处开始执行代码,当前case后的所有代码都会执行;

  • 在case的后边跟着一个break关键字,这样可以确保只会执行当前case后的语句,而不会执行其他的case;

  • 如果比较结果为false,则继续向下比较;

  • 如果所有的比较结果都为false,则只执行default后的语句;

注意: switch语句和if语句的功能实际上有重复的,使用switch可以实现if的功能,同样使用if也可以实现switch的功能,所以我们使用时,可以根据自己的习惯选择。
代码演示:

<script>
var num=2;
switch(num){
case 1:
console.log("壹");
//使用break可以退出switch语句
break;
case 2:
console.log("贰");
break;
case 3:
console.log("叁")
break;

default:
console.log("非法数字~~~");
break;
}
</script>

四、循环语句

循环语句,就是让某段代码反复执行。在JavaScript中,主要有for循环、while循环、do…while循环等。

1) while循环

语法:

while(条件表达式){
语句
}

while语句执行流程:

先对条件表示式进行求值判断,如果值为true,则执行循环体,循环体执行完毕以后,继续对表达式进行判断,如果值为false,则终止循环。

2) do…while循环

语法:

do{

语句....

}while(条件表达式)

执行流程:

do…while 语句在执行时,会先执行循环体,循环体执行完毕后,再对while后的条件表示式进行判断,如果结果为true,则继续执行,执行完毕继续判断,如果结果为false,则停止执行。

注意: 实际上以上两个语句功能类似,不同的是while 是先判断后执行,而do…while会先执行后判断,do…while可以保证循环体至少执行一次,而while不行。


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

3)for语句(for循环)

在for循环中,为我们提供了专门的位置,用来放三个表达式。

  • 初始化表达式
  • 条件表达式
  • 更新表达式

for循环的语法:

for(初始化表达式;条件表达式;更新表达式){

语句....

}

for循环的执行流程:

  • 初始化表达式,初始化变量(初始化表达式,只会执行一次);

  • 条件表达式,判断是否执行循环;

  • 如果为true,则执行循环,如果为false,终止循环;

  • 执行更新表达式,更新表达式执行完毕继续重复。

<script>
//第一种写法
for(var i=0;i<10;i++){
alert(i);
}
//第二种写法 for循环中的三个部分都可以省略,也都可以写在外部
var i=0;
for(;i<10;){
alert(i++);
}

//如果在for循环中,不写任何的表达式,只写两个;
//此时循环是一个死循环,会一直执行下去,慎用
for(;;){
alert("hello");
}
</script>

五、break和continue语句

break关键字

可以用来退出switch或循环语句,不能在if语句中使用break和continue,break关键字,会立即终止离它最近的那个循环语句。

continue关键字

可以用来跳过当次循环,同样continue也是默认只会对离它最近的循环起作用。

终止指定循环

可以为循环语句创建一个label(标签),来标识当前的循环。

语法:

label(给起的标签名字):循环语句

使用break语句时,可以在break后跟着一个label,这样break可以结束指定的循环,而不是最近的。

代码演示

<script>

/* for(var i=0;i<5;i++){
console.log(i);
//break;//用来结束for的循环语句,for只会循环一次
if(i==2){
break;//这个break是对整个for循环起作用的
}
} */


/* for (var i = 0; i < 5; i++) {
console.log("@外层循环" + i);
for (var j = 0; j < 5; j++) {
break;//只会结束离他最近的内层循环
console.log("内层循环" + j);
}
} */



/* outer: for (var i = 0; i < 5; i++) {
console.log("@外层循环" + i);
for (var j = 0; j < 5; j++) {
break outer; //指定结束外层的for循环
console.log("内层循环" + j);
}
} */



for (var i = 0; i < 5; i++) {
if (i == 2) {
continue;
}
console.log(i);
}
</script>

JavaScript的流程控制语句,就像是一把魔法棒,它能让我们的代码按照我们的意愿去运行。掌握了这些语句,我们就可以在编程的世界里自由翱翔。

希望这篇文章能帮助你更好地理解和使用JavaScript的流程控制语句,让我们一起在编程的道路上,探索更多的可能性。

收起阅读 »

前端使用a链接下载内容增加loading效果

web
问题描述:最近工作中出现一个需求,纯前端下载 Excel 数据,并且有的下载内容很多,这时需要给下载增加一个 loading 效果。代码如下:// utils.jsconst XLSX = require('xlsx')// 将一个sheet转成最终的exce...
继续阅读 »
  1. 问题描述:最近工作中出现一个需求,纯前端下载 Excel 数据,并且有的下载内容很多,这时需要给下载增加一个 loading 效果。
  2. 代码如下:
// utils.js
const XLSX = require('xlsx')
// 将一个sheet转成最终的excel文件的blob对象,然后利用URL.createObjectURL下载
export const sheet2blob = (sheet, sheetName) => {
sheetName = sheetName || 'sheet1'
var workbook = {
SheetNames: [sheetName],
Sheets: {}
}
workbook.Sheets[sheetName] = sheet
// 生成excel的配置项
var wopts = {
bookType: 'xlsx', // 要生成的文件类型
bookSST: false, // 是否生成Shared String Table,官方解释是,如果开启生成速度会下降,但在低版本IOS设备上有更好的兼容性
type: 'binary'
}
var wbout = XLSX.write(workbook, wopts)
var blob = new Blob([s2ab(wbout)], { type: 'application/octet-stream' })
// 字符串转ArrayBuffer
function s2ab(s) {
var buf = new ArrayBuffer(s.length)
var view = new Uint8Array(buf)
for (var i = 0; i !== s.length; ++i) view[i] = s.charCodeAt(i) & 0xff
return buf
}
return blob
}

/**
* 通用的打开下载对话框方法,没有测试过具体兼容性
* @param url 下载地址,也可以是一个blob对象,必选
* @param saveName 保存文件名,可选
*/
export const openDownloadDialog = (url, saveName) => {
if (typeof url === 'object' && url instanceof Blob) {
url = URL.createObjectURL(url) // 创建blob地址
}
var aLink = document.createElement('a')
aLink.href = url
aLink.download = saveName + '.xlsx' || '1.xlsx' // HTML5新增的属性,指定保存文件名,可以不要后缀,注意,file:///模式下不会生效
var event
if (window.MouseEvent) event = new MouseEvent('click')
else {
event = document.createEvent('MouseEvents')
event.initMouseEvent(
'click',
true,
false,
window,
0,
0,
0,
0,
0,
false,
false,
false,
false,
0,
null
)
}
aLink.dispatchEvent(event)
}

<el-button
@click="clickExportBtn"
>
<i class="el-icon-download"></i>下载数据
</el-button>
<div class="mongolia" v-if="loadingSummaryData">
<el-icon class="el-icon-loading loading-icon">
<Loading />
</el-icon>
<p>loading...</p>
</div>

clickExportBtn: _.throttle(async function() {
const downloadDatas = []
const summaryDataForDownloads = this.optimizeHPPCDownload(this.summaryDataForDownloads)
summaryDataForDownloads.map(summaryItem =>
downloadDatas.push(this.parseSummaryDataToBlobData(summaryItem))
)
// donwloadDatas 数组是一个三维数组,而 json2sheet 需要的数据是一个二维数组
this.loadingSummaryData = true
const downloadBlob = aoa2sheet(downloadDatas.flat(1))
openDownloadDialog(downloadBlob, `${this.testItem}报告数据`)
this.loadingSummaryData = false
}, 2000),

// css
.mongolia {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.9);
display: flex;
justify-content: center;
align-items: center;
font-size: 1.5rem;
color: #409eff;
z-index: 9999;
}
.loading-icon {
color: #409eff;
font-size: 32px;
}
  1. 解决方案探究:
  • 在尝试了使用 $nextTick、将 openDownloadDialog 改写成 Promise 异步函数,或者使用 async/await、在 openDownloadDialog 中添加 loadingSummaryData 逻辑,发现依旧无法解决问题,因此怀疑是 document 添加新元素与 vue 的 v-if 渲染产生冲突,即 document 添加新元素会阻塞 v-if 的执性。查阅资料发现,问题可能有以下几种:

    • openDownloadDialog 在执行过程中执行了较为耗时的同步操作,阻塞了主线程,导致了页面渲染的停滞。
    • openDownloadDialog 的 click 事件出发逻辑存在问题,阻塞了事件循环(Event Loop)。
    • 浏览器在执行 openDownloadDialog 时,将其脚本任务的优先级设置得较高,导致占用主线程时间片,推迟了其他渲染任务。
    • Vue 的批量更新策略导致了 v-if 内容的显示被延迟。
  • 查阅资料后找到了如下几种方案:

      1. 使用 setTimeout 使 openDownloadDialog 异步执行
      clickExport() {
      this.loadingSummaryData = true;

      setTimeout(() => {
      openDownloadDialog(downloadBlob, `${this.testItem}报告数据`);

      this.loadingSummaryData = false;
      });
      }


      1. 对 openDownloadDialog 内部进行优化
      • 避免大循环或递归逻辑
      • 将计算工作分批进行
      • 使用 Web Worker 隔离耗时任务
        • 在编写 downloadWorker.js 中的代码时,要明确这部分代码是运行在一个独立的 Worker 线程内部,而不是主线程中。

            1. 不要直接依赖或者访问主线程的全局对象,比如 window、document 等。这些在 Worker 内都无法直接使用。
            1. 不要依赖 DOM 操作,比如获取某个 DOM 元素。Worker 线程无法访问页面的 DOM。
            1. 代码执行的入口是 onmessage 回调函数,在其中编写业务逻辑。
            1. 和主线程的通信只能通过 postMessage 和 onmessage 发送消息事件。
            1. 代码应该是自包含的,不依赖外部变量或状态。
            1. 可以导入其他脚本依赖,比如用 import 引入工具函数等。
            1. 避免修改或依赖全局作用域,比如定义全局变量等。
        • 总之,编写 downloadWorker.js 时要注意它执行在一个独立环境中,只能通过消息事件与主线程通信。
    // 创建 Worker
    const worker = new Worker('downloadWorker.js');

    // 点击下载时向 Worker 发送消息
    function clickDownload() {

    showLoading();

    worker.postMessage({
    url: fileURL,
    filename: 'report.xlsx'
    });

    worker.onmessage = function(e) {
    // 收到下载完成的消息
    hideLoading();
    }

    }

    // 显示 loading
    function showLoading() {
    loading.style.display = 'block';
    }

    // 隐藏 loading
    function hideLoading() {
    loading.style.display = 'none';
    }

    // downloadWorker.js

    onmessage = function(e) {
    const url = e.data.url;
    const filename = e.data.filename;

    // 创建并点击链接触发下载
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    a.click();

    postMessage('下载完成');
    }

    <div id="loading" style="display:none;">
    Downloading...
    </div>

    <button onclick="clickDownload()">
    Download
    </button>

    <script src="downloadWorker.js"></script>


      1. 使用 requestIdleCallback 进行调度
      clickExport() {
      this.loadingSummaryData = true;

      requestIdleCallback(() => {
      openDownloadDialog(downloadBlob, `${this.testItem}报告数据`);

      this.loadingSummaryData = false;
      });
      }
    • 当使用上述方案时发现里面的内容变得异常的慢,于是去问GPT后,得到以下答案:

      使用 requestIdleCallback 来执行 openDownloadDialog 确实可能会需要更多时间。

      因为 requestIdleCallback 会在主线程空闲时才执行回调函数,所以不像 setTimeout 可以直接按指定时间异步执行。

      requestIdleCallback 的优先级较低,只在主线程空闲时才会执行,所以 download 的速度会比 setTimeout 慢一些。

      但使用 requestIdleCallback 的优点是它不会占用主线程时间,可以避免阻塞页面渲染。

      所以需要权衡执行速度和避免阻塞之间的关系:

      • 如果下载速度是优先考虑的因素,可以继续使用 setTimeout 异步执行。
      • 如果避免阻塞渲染更重要,则 requestIdleCallback 是更好的选择。

      偶尔下载任务被延迟不是大问题,但频繁的下载任务阻塞会严重影响页面体验。

      此外,可以结合两者试试:

      setTimeout 先异步调度,然后在回调中使用 requestIdleCallback 避免阻塞。这样就可以兼顾下载速度和避免渲染阻塞了。

    clickExport() {

    this.loadingSummaryData = true;

    setTimeout(() => {

    requestIdleCallback(() => {
    openDownloadDialog(downloadBlob);
    });

    this.loadingSummaryData = false;

    }, 200);

    }


      1. 分析线程模型,找到具体的阻塞点
      • 使用 Performance 工具分析线程
      • debugger 及 console.log 打印关键函数的执行时间
      • 检查是否有非主线程的任务阻塞了主线程
      1. 调整 vue 的批量更新策略
    new Vue({
    config: {
    // 修改批量更新的队列长度
    batchUpdateDuration: 100
    }
    })



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

给迷茫的朋友一点建议吧,主要是前端方向的。

背景 前两天分享了一篇自己的个人经历,没想到引来了那么多人共鸣,很多人留下了评论,大部分评论我都认真看了,并且也回复了,有的评论建议真的很棒,对我的帮助很大,很感谢。 今天给评论给整理了一下,再加上自己有一些新的感悟,给大家分享分享,给迷茫的朋友一点建议。有很...
继续阅读 »

背景


前两天分享了一篇自己的个人经历,没想到引来了那么多人共鸣,很多人留下了评论,大部分评论我都认真看了,并且也回复了,有的评论建议真的很棒,对我的帮助很大,很感谢。


今天给评论给整理了一下,再加上自己有一些新的感悟,给大家分享分享,给迷茫的朋友一点建议。有很多东西想说,但是表达能力有限,只能写出一点。


一个30岁老前端的人生经历(学习+工作+婚姻+孩子),给迷茫的朋友一点激励。前端是不是真的不行了

有些没有毕业或者刚毕业的兄弟们,很关心这个问题,因为关乎他们是否要学前端开发。还有一些想转前端开发的兄弟,也很关心这个问题。


关于这个问题,上篇文章有评论开玩笑的说现在入前端,相当于49年入guojun,下面就这个问题我发表一下我的个人看法。


先说一下我的观点,前端确实比以前难很多,工作岗位变少,薪资变低,技术要求也变高了,还没入或准备入前端的,建议真的不要入了,有经验的都不好找工作,更别说没工作经验的了,当然天才除外。


我同学和我朋友两个前端(都是好几年工作经验的)找了几个月都没有合适的工作,面试机会还是有的,但是外包偏多,并且还要降薪,最后都拒了。


他们还发现了一个现象,有时候面试的过程中整体都非常好,hr有要发offer的意思,但是就一直拖着,然后后面突然有一天说不合适了。这种应该是有更好的选择了,以前都是求职者手握多个offer,去挑选公司,现在公司占据主动了,从多个候选人中挑员工了。


后端情况我不清楚,但是前端真的没有以前好就业了,这个时候想入前端的,我真的不太建议,除非你真的特别牛,就算你很牛,但是没有面试机会,也没办法证明啊。


有些已经入前端的新人私信我,让我给他一些提升建议。



  1. 多写多练,熟能生巧,没有别的好办法。先不用去看某某某源码,也不用花很多时间去深入的了解某个库,会用就行,然后从网上找个实战项目跟着做,或者自己从零做个小项目,在实战的过程中可以快速定位到自己薄弱的地方,然后针对自己的问题查漏补缺就行了。我当时从后端转前端时候就是这样的,不一定对所有人都适用,仅供大家参考。

  2. 多总结和复盘。这个习惯让我一直受益,比如今天的文章就是对上一篇文章评论的总结。经常复盘真的能让学习效率变高很多的,还能避免犯同样的错。我的习惯是白天学东西,晚上睡觉前复盘总结一下,这样一来印象很深刻。

  3. 学习的时候,不要怕别人说卷什么的,有些人看到别人学习就说别人卷,反过来自己偷偷的学,这种人就是不想别人追赶上自己。还有一些人自己天天下班打游戏从来不学习,看到同事学习,就在背后说别人卷,这种人就是害怕被别人甩开。为了某个目标去奋斗,我觉得是一件很有意义的事情。比如我吧,天赋一般,家庭一般,就是靠比别人付出的多,才能让一家人过上好一点的生活,只要家人开心,”卷“一点又有什么呢,随别人说去吧。

  4. 定目标。这个对于自律性不够的兄弟有帮助,相信很多人看完某个鸡汤文后,努力个几天,就坚持不下去了,这就是因为没有目标,有时候定完目标还是坚持不下去,可以找人来监督一下。比如我在上篇文章中立的flag,明年写40篇文章,有评论说写质量高的文章,一年肯定写不了那么多。是的,好文章确实不是硬挤出来的,我立这个flag的原因,是我知道自己自律不够,立出来让大家监督的,至于文章质量,我觉得只要用心去写,能帮助到人而不误导人就行了。


小结


没入前端的同学,建议别入了。刚入前端的同学想快速提升自己,多学多练,多总结和复盘,不要怕被别人说卷,给自己定目标。


30岁前端的路


我在flag里写了打算2024年认真看一下react源码,很多大佬建议说现在别搞这些没意义的东西了,搞前端死路一条。


关于大龄前端发展,从评论建议中,我总结了以下几条常用出路,欢迎大家补充。


深入技术


都说前端的天花板就是组长,结合我自身的经历和评论区的留言来看好像确实是这样,我几年前就开始做前端负责人了,其实就是组长,只是管的人多少的问题,cto永远是后端。主要工作就是需求来了,把任务给下面人分一分,就没了,最有挑战的也就是框架基建了吧。


评论里有人说,如果想要继续搞技术的话,可以往全栈方向发展,因为前端很难涉及到公司业务,所以可替代性很高,后端和公司业务结合的比较深一点,而业务才是公司的根本,换一个人很难立马上手,可替代性比前端低很多。


关于全栈我的观点是,大龄前端还是往全栈发展比较好,除了上面的原因外,全栈可以自己做个产品,还可以接私活、做自由职业者,甚至转管理都需要懂点后端技术。


想转全栈,但是又不想学别人语言,node也是可以做后端开发的,现在比较成熟的框架有midway和nest,甚至koa都可以做后端开发。关注我的人应该知道我正在做的fluxy-admin就是一个全栈项目,前端使用的是react,后端使用的是midway,前端上手midway还是很简单的,我一个星期就上手写项目了。


转管理


有一些评论提到了转管理,说实话这个是需要机会的,不是所有人想转就转的,因为公司很少愿意给员工尝试的机会,除非你开发的时候已经表现出了管理方面的天赋,让老板认可你,不然都很难转成功,一是一个萝卜一个坑,老的领导没走你就上不去,而上面领导走了,很多公司可能会从外面招一个新的人空降过来,而不是从下面提拨一个上去。


还有管理也不是什么人都可以做的,有的人搞技术可以,管理真的不太行,比如我,在某家公司,当时管着10几个前端,团队被我带的一团糟,后来我找个副组长帮我管理,我专心搞技术搞框架,团队才慢慢走上正规。


个人觉得想转管理,需要机会和自身有管理才能,这两个条件都很重要。


转产品


有些评论还提到了转产品,我感觉这个还是比较靠谱的,玉伯大佬就是一个成功转型的案例。


个人感觉前端转产品还是有一些优势的,下面是我从网上搜的:



  1. 技术背景:掌握技术知识,能更好地理解产品实现的可能性与限制,与技术团队有效沟通。

  2. 用户体验关注:作为前端开发者,通常会对用户界面和用户体验有较深的理解,有助于设计更好的产品。

  3. 敏捷与迭代理念:熟悉敏捷开发流程和迭代思维,能够快速响应市场变化并调整产品方向。

  4. 问题解决能力:编程经验锻炼了解决复杂问题的能力,这在产品管理中也非常重要。

  5. 沟通桥梁:作为曾经的开发人员,可以成为团队内部技术和非技术成员之间的沟通桥梁。


前端转产品,可以将技术理解和用户体验融入产品管理中,提高产品的竞争力。感觉门槛也不高,只需要有想法,并且现在软件行业那么发达,实在想不到好的点子,还可以借鉴别人产品设计。


甚至以后AI发展的很成熟了,可能就不需要开发了,只需要产品经理把需求描述好,产品就自动生成出来了。


我现在也在往这方面发展,最近一年在公司主导研发低代码平台,从最开始的设计到基本构架实现都是我一个人,框架成熟后,经常和交付直接交流,渐渐往产品的角色发展了。现在工作中不只是只有代码了,经常也会站在产品的角度去思考,做的东西怎么才能更有价值,怎么提高用户体验等。


平时有一些想法在公司产品里用不上,所以打算自己做一个产品,现在有了一些idea,明年应该会去做。这里说明一下,如果有想法想做个产品但找不到合适的后端合伙人,全栈做小应用或独立产品还是很有优势的,呼应了前面说的全栈优势。


搞副业


评论里也有很多人建议搞副业,这个确实也是一条出路,但是我感觉搞副业得有可靠的路子,不能盲目的去搞,不然最后可能血本无归。


我知道前几年有个程序员可以干的并且很好的副业,录视频卖课程,现在随着互联网新人越来越少,这个副业已经不行了。


现在我知道的有个副业还不错,在线帮别人修改简历,因为现在很多人找不到工作,甚至没有面试机会,他们会很焦虑,认为可能是自己简历的问题,会找一些大佬帮忙优化简历,搞这个的提前你得是个大佬,并且有一定的知名度,对于普通人不适用。


现在也有一些人趁着AI的热度,独立开发AI应用,靠这个赚了一些钱,我对这个不太了解,不做评价。


下面是一位大佬的评论,可能比较适合普通人,不过他说的那些我都没有认证,大家请谨慎对待:


image.png


转行


有很多评论认为搞技术没有前途,还是要考公,确实对于刚入行或准备入行的兄弟考公是一个比较好的选择,因为年轻还有很多机会,像我们这种年龄大的,还拖家带口的已经不适合改行了,成本太高了,真经不起折腾。


也有评论说想回老家,说实话我也想老家,但是以我的能力,回老家估计都养不活一家人,只会写代码,回老家能干啥呢。


最后


上面的内容是根据评论内容简单的整理了一下,还加了一些个人看法,大家可以结合自己的情况食用。可能还有更多好的建议因为我文笔有限,没写出来,欢迎大家评论,一起交流。


最后说一下自己吧,没写文章之前,我一直认为作为一个程序员技术最重要的,就像我的简介里说的那样,我唯一会的就是写代码,我不一定会成功,但是我会努力做的更好。看完大家的评论后,内心有过动摇,是不是不应该继续卷技术了,在写这篇文章的时候,我又坚定了,因为搞技术不只是我的工作,这也是我热爱的东西,即使有一天被迫失业不做程序员了,我可能偶尔还会写写代码。不过鸡蛋不能放在一个篮子里的道理我还是知道的,我现在已经在慢慢往产品转,用以保证未来不失业,因为还有一大家子需要我来养啊。共勉。


作者:前端小付
来源:juejin.cn/post/7311932190442635299
收起阅读 »

JSON慢地要命: 看看有啥比它快!

是的, 你没听错! 网络开发中无处不在的数据交换格式JSON, 可能会拖慢你的应用程序. 在这个速度和响应速度至上的世界里, 检查 JSON 的性能影响至关重要, 而我们对此却常常忽略. 在本博客中, 我们将深入探讨 JSON 成为应用程序瓶颈的原因, 并探索...
继续阅读 »


是的, 你没听错! 网络开发中无处不在的数据交换格式JSON, 可能会拖慢你的应用程序. 在这个速度和响应速度至上的世界里, 检查 JSON 的性能影响至关重要, 而我们对此却常常忽略. 在本博客中, 我们将深入探讨 JSON 成为应用程序瓶颈的原因, 并探索更快的替代方案和优化技术, 以确保你的应用程序以最佳状态运行.


JSON 是什么? 为何我要关注这个问题?



JSON 教程 | w3resource


JSON是JavaScript Object Notation的缩写, 是一种轻量级数据交换格式, 已成为Web应用中传输和存储数据的首选. 它的简洁性和人类可读格式使人类和机器都能轻松使用. 但是, 为什么要在Web开发项目中关注 JSON 呢?


JSON 是应用中数据的粘合剂. 它是服务器和客户端之间进行数据通信的语言, 也是数据库和配置文件中存储数据的格式.


JSON 的流行以及人们使用它的原因…


JSON 在Web开发领域的受欢迎程度怎么强调都不为过. 它已成为数据交换的事实标准, 这其中有几个令人信服的原因:


它易于使用!



  1. 人类可读格式: JSON 使用简单明了, 基于文本的结构, 开发人员和非开发人员都能轻松阅读和理解. 这种人类可读的格式增强了协作, 简化了调试.

  2. 语言无关性: JSON 与任何特定的编程语言无关. 它是一种通用的数据格式, 几乎所有现代编程语言都能对其进行解析和生成, 因此它具有很强的通用性.

  3. 数据结构一致性: JSON 使用键值对, 数组和嵌套对象来实现数据结构的一致性. 这种一致性使其具有可预测性, 便于在各种编程场景中使用.

  4. 支持浏览器: 网络浏览器原生支持 JSON, 允许Web应用与服务器进行无缝通信. 这种本地支持极大地促进了 JSON 在Web开发中的应用.

  5. JSON API: 许多网络服务和应用接口默认以 JSON 格式提供数据. 这进一步巩固了 JSON 在Web开发中作为数据交换首选的地位.

  6. JSON Schema: 开发人员可以使用 JSON 模式来定义和验证 JSON 数据的结构, 从而为应用增加了一层额外的清晰度和可靠性.


鉴于这些优势, 难怪全球的开发人员都依赖 JSON 来满足他们的数据交换需求. 然而, 随着我们在本博客的深入探讨, 我们将发现与 JSON 相关的潜在性能挑战, 以及如何有效解决这些挑战.


速度需求


🚀🚀🚀


应用的速度和响应的重要性


在当今快节奏的数字环境中, 应用的速度和响应能力是不可或缺的. 用户希望在Web和移动应用中即时获取信息, 快速交互和无缝体验. 对速度的这种要求是由以下几个因素驱动的:



  1. 用户期望: 用户已习惯于从数字互动中获得闪电般快速的响应. 他们不想等待网页的加载或应用的响应. 哪怕是几秒钟的延迟, 都会导致用户产生挫败感并放弃使用.

  2. 竞争优势: 速度可以成为重要的竞争优势. 反应迅速的应用往往比反应迟缓的应用更能吸引和留住用户.

  3. 搜索引擎排名: 谷歌等搜索引擎将网页速度视为排名因素. 加载速度更快的网站往往在搜索结果中排名靠前, 从而提高知名度和流量.

  4. 转化率: 电子商务网站尤其清楚速度对转化率的影响. 网站速度越快, 转换率越高, 从而增加收入.

  5. 移动性能: 随着移动设备的普及, 对速度的需求变得更加重要. 移动用户的带宽和处理能力往往有限, 因此快速的应用性能是必要的.


JSON 会拖慢我们的应用吗?


现在, 让我们来讨论核心问题: JSON 是否会拖慢我们的应用?


如前所述, JSON 是一种非常流行的数据交换格式. 它灵活, 易用, 并得到广泛支持. 然而, 这种广泛的应用并不意味着它不会面临性能挑战.


某些情况下, JSON 可能是导致应用慢的罪魁祸首. 解析 JSON 数据的过程, 尤其是在处理大型或复杂结构时, 可能会耗费宝贵的毫秒时间. 此外, 低效的序列化和反序列化也会影响应用的整体性能.


在接下来的内容中, 我们将探讨 JSON 成为应用瓶颈的具体原因, 更重要的是, 探讨如何缓解这些问题. 在深入探讨的过程中, 请记住我们的目标不是诋毁 JSON, 而是了解其局限性并发现优化其性能的策略, 以追求更快, 反应更灵敏的应用.



LinkedIn将Protocal Buffers与Rest.li集成以提高微服务性能| LinkedIn工程


JSON 为什么会变慢


尽管 JSON 被广泛使用, 但它也难逃性能挑战. 让我们来探究 JSON 可能会变慢的原因, 并理解为什么 JSON 并不总是数据交换的最佳选择.


1. 解析带来的开销


当 JSON 数据到达应用时, 它必须经过解析过程才能转换成可用的数据结构. 解析过程可能相对较慢, 尤其是在处理大量或深度嵌套的 JSON 数据时.


2. 序列化和反序列化


JSON 要求在从客户端向服务器发送数据时进行序列化(将对象编码为字符串), 并在接收数据时进行反序列化(将字符串转换回可用对象). 这些步骤会带来开销, 影响应用的整体速度.


微服务架构的世界里, JSON 通常用于在服务之间传递消息. 但是, 很关键的是, 我们必须认识到, JSON 消息需要序列化和反序列化, 这两个过程会带来巨大的开销.



在有大量微服务不断通信的场景中, 这种开销可能会增加, 并有可能使应用变慢, 以至于影响用户体验.




我们面临的第二个挑战是, 由于 JSON 的文本性质, 序列化和反序列化的延迟和吞吐量都不理想.
— LinkedIn



1_74sQfiW0SjeFfcTcgNKupw.webp
序列化和反序列化


3. 字符串操作


JSON 基于文本, 在连接和解析等操作中严重依赖字符串操作. 与处理二进制数据相比, 处理字符串的速度会慢一些.


4. 缺乏数据类型


JSON 的数据类型(如字符串, 数字, 布尔值)非常有限. 复杂的数据结构可能需要效率较低的表示法, 从而导致内存使用量增加和处理速度减慢.



5. 冗余


JSON 的人类可读性设计可能会导致冗余. 不需要的键和重复的结构增加了有效载荷的大小, 导致数据传输时间延长.



第一个挑战是 JSON 是一种文本格式, 往往比较冗余. 这导致网络带宽使用量增加, 更高的延迟, 效果并不理想.
— LinkedIn



6. 不支持二进制


JSON 缺乏对二进制数据的本地支持. 在处理二进制数据时, 开发人员通常需要将其编解码为文本, 而这可能会降低效率.


7. 深度嵌套


在某些情况下, JSON 数据可能是深嵌套的, 需要递归解析和遍历. 这种计算复杂性会降低应用的运行速度, 尤其是在没有优化的情况下.


JSON的替代方案


虽然 JSON 是一种通用的数据交换格式, 但由于其在某些情况下的性能限制, 人们开始探索更快的替代格式. 让我们深入探讨其中的一些替代方案, 了解何时以及为何选择它们:


1. Protocol Buffers(protobuf)


Protocal Buffers通常被称为protobuf, 是由谷歌开发的一种二进制序列化格式. 它的设计宗旨是高效, 紧凑和快速. Protobuf 的二进制性质使其在序列化和反序列化方面的速度明显快于 JSON.



  • 何时选择: 当你需要高性能的数据交换时, 尤其是在微服务架构, 物联网应用或网络带宽有限的情况下, 请考虑使用Protobuf.


GitHub - vaishnav-mk/protobuf-example


2. MessagePack


MessagePack 是另一种二进制序列化格式, 以速度快, 结构紧凑而著称. 它比 JSON 更有效率, 同时与各种编程语言保持兼容.



  • 何时选择: 当你需要在速度和跨语言兼容性之间取得平衡时, MessagePack 是一个不错的选择. 它适用于实时应用和对减少数据大小至关重要的情况.


3. BSON (二进制 JSON)


BSON 或二进制 JSON 是一种从 JSON 衍生出来的二进制编码格式. 它保留了 JSON 的灵活性, 同时通过二进制编码提高了性能. BSON 常用于 MongoDB 等数据库.



  • 何时选择: 如果你正在使用 MongoDB, 或者需要一种格式来弥补 JSON 和二进制效率之间的差距, 那么 BSON 是一个很有价值的选择.


4. Apache Avro


Apache Avro 是一个数据序列化框架, 专注于提供一种紧凑的二进制格式. 它基于schema, 可实现高效的数据编解码.



  • 何时选择: Avro 适用于schema演进非常重要的情况, 如数据存储, 以及需要在速度和数据结构灵活性之间取得平衡的情况.


与 JSON 相比, 这些替代方案提供了不同程度的性能改进, 具体选择取决于你的具体使用情况. 通过考虑这些替代方案, 你可以优化应用的数据交换流程, 确保将速度和效率放在开发工作的首位.



JSON, Protobufs, MessagePack, BSON 和 Avro 之间的差异


每个字节都很重要: 优化数据格式


在效率和速度至上的数据交换世界中, 数据格式的选择会产生天壤之别. 本节将探讨从简单的 JSON 数据表示到更高效的二进制格式(如 Protocol Buffers, MessagePack, BSON 和 Avro)的过程. 我们将深入探讨每种格式的细微差别, 并展示为什么每个字节都很重要.


开始: JSON 数据


我们从简单明了的 JSON 数据结构开始. 下面是我们的 JSON 数据示例片段:


{
"id": 1, // 14 bytes
"name": "John Doe", // 20 bytes
"email": "johndoe@example.com", // 31 bytes
"age": 30, // 9 bytes
"isSubscribed": true, // 13 bytes
"orders": [ // 11 bytes
{ // 2 bytes
"orderId": "A123", // 18 bytes
"totalAmount": 100.50 // 20 bytes
}, // 1 byte
{ // 2 bytes
"orderId": "B456", // 18 bytes
"totalAmount": 75.25 // 19 bytes
} // 1 byte
] // 1 byte
} // 1 byte

JSON 总大小: ~ 139 字节


虽然 JSON 用途广泛且易于使用, 但它也有一个缺点, 那就是它的文本性质. 每个字符, 每个空格和每个引号都很重要. 在数据大小和传输速度至关重要的情况下, 这些看似微不足道的字符可能会产生重大影响.


效率挑战: 使用二进制格式减小尺寸



现在, 让我们提供其他格式的数据表示并比较它们的大小:


Protocol Buffers (protobuf):


syntax = "proto3";

message User {
int32 id = 1;
string name = 2;
string email = 3;
int32 age = 4;
bool is_subscribed = 5;
repeated Order orders = 6;

message Order {
string order_id = 1;
float total_amount = 2;
}
}

0A 0E 4A 6F 68 6E 20 44 6F 65 0C 4A 6F 68 6E 20 44 6F 65 65 78 61 6D 70 6C 65 2E 63 6F 6D 04 21 00 00 00 05 01 12 41 31 32 33 03 42 DC CC CC 3F 05 30 31 31 32 34 34 35 36 25 02 9A 99 99 3F 0D 31 02 42 34 35 36 25 02 9A 99 99 3F

Protocol Buffers 总大小: ~ 38 bytes


MessagePack:


(注意:MessagePack 是一种二进制格式, 此处的表示法非人工可读.)


二进制表示(十六进制):


a36a6964000000000a4a6f686e20446f650c6a6f686e646f65406578616d706c652e636f6d042100000005011241313302bdcccc3f0530112434353625029a99993f


MessagePack 总大小: ~34 字节


BSON (二进制 JSON):


(注意:BSON 是一种二进制格式, 此处的表示法非人工可读.)


二进制表示法 (十六进制):


3e0000001069640031000a4a6f686e20446f6502656d61696c006a6f686e646f65406578616d706c652e636f6d1000000022616765001f04370e4940

BSON 总大小: ~ 43 字节


Avro:


(注: Avro使用schema, 因此数据与schema信息一起编码.)


二进制表示法 (十六进制):


0e120a4a6f686e20446f650c6a6f686e646f65406578616d706c652e636f6d049a999940040a020b4108312e3525312e323538323539

Avro 总大小: ~ 32 字节



(这些替代方案的实际字节数可能会有所不同, 提供这些数字只是为了让大家有个大致的了解.)


现在你可能会感到奇怪, 为什么我们的程序会有这么多的字节数?


现在你可能想知道为什么有些格式输出的是二进制, 但它们的大小却各不相同. Avro, MessagePack 和 BSON 等二进制格式具有不同的内部结构和编码机制, 这可能导致二进制表示法的差异, 即使它们最终表示的是相同的数据. 下面简要介绍一下这些差异是如何产生的:


1. Avro:



  • Avro 使用schema对数据进行编码, 二进制表示法中通常包含该schema.

  • Avro 基于schema的编码可提前指定数据结构, 从而实现高效的数据序列化和反序列化.

  • Avro 的二进制格式设计为自描述格式, 这意味着schema信息包含在编码数据中. 这种自描述性使 Avro 能够保持不同版本数据模式之间的兼容性.


2. MessagePack:



  • MessagePack 是一种二进制序列化格式, 直接对数据进行编码, 不包含schema信息.

  • 它使用长度可变的整数和长度可变的字符串的紧凑二进制表示法, 以尽量减少空间使用.

  • MessagePack 不包含schema信息, 因此更适用于schema已预先知道并在发送方和接收方之间共享的情况.


3. BSON:



  • BSON 是 JSON 数据的二进制编码, 包括每个值的类型信息.

  • BSON 的设计与 JSON 紧密相连, 但它增加了二进制数据类型, 如 JSON 缺乏的日期和二进制数据.

  • 与 MessagePack 一样, BSON 不包含schema信息.


这些设计和编码上的差异导致了二进制表示法的不同:



  • Avro 包含模式信息并具有自描述性, 这导致二进制大小稍大, 但提供了schema兼容性.

  • MessagePack 因其可变长度编码而高度紧凑, 但缺乏模式信息, 因此适用于已知模式的情况.

  • BSON 与 JSON 关系密切, 包含类型信息, 与 MessagePack 等纯二进制格式相比, 会增加大小.


总之, 这些差异源于每种格式的设计目标和功能. Avro 优先考虑schema兼容性, MessagePack 注重紧凑性, 而 BSON 则在保持类似 JSON 结构的同时增加了二进制类型. 格式的选择取决于具体的使用情况和要求, 如schema兼容性, 数据大小和易用性.


优化 JSON 性能


JSON 虽然用途广泛, 在Web开发中被广泛采用, 但在速度方面也存在挑战. 这种格式的人类可读性会导致数据负载较大, 处理时间较慢. 因此, 问题出现了: 我们能够怎样优化JSON以使得它更快更高效? 在本文中, 我们将探讨可用于提高 JSON 性能的实用策略和优化方法, 以确保 JSON 在提供应用所需的速度和效率的同时, 仍然是现代 Web 开发中的重要工具.


以下是一些优化 JSON 性能的实用技巧以及代码示例和最佳实践:


1. 最小化数据大小:



  • 使用简短, 描述性的键名: 选择简洁但有意义的键名, 以减小 JSON 对象的大小.


// Inefficient
{
"customer_name_with_spaces": "John Doe"
}

// Efficient
{
"customerName": "John Doe"
}


  • 尽可能缩写:  在不影响清晰度的情况下, 考虑对键或值使用缩写.


// Inefficient
{
"transaction_type": "purchase"
}

// Efficient
{
"txnType": "purchase"
}

2. 明智地使用数组:



  • 最小化嵌套: 避免深度嵌套数组, 因为它们会增加解析和遍历 JSON 的复杂性.


// Inefficient
{
"order": {
"items": {
"item1": "Product A",
"item2": "Product B"
}
}
}

// Efficient
{
"orderItems": ["Product A", "Product B"]
}

3. 优化数字表示:



  • 尽可能使用整数:  如果数值可以用整数表示, 请使用整数而不是浮点数.


// Inefficient
{
"quantity": 1.0
}

// Efficient
{
"quantity": 1
}

4. 消除冗余:



  • 避免重复数据: 通过引用共享值来消除冗余数据.


// Inefficient
{
"product1": {
"name": "Product A",
"price": 10
},
"product2": {
"name": "Product A",
"price": 10
}
}

// Efficient
{
"products": [
{
"name": "Product A",
"price": 10
},
{
"name": "Product B",
"price": 15
}
]
}

5. 使用压缩:



  • 使用压缩算法:  如何可行的话, 使用压缩算法, 比如Gzip 或者Brotli, 以在传输过程中减少JSON负载大小.


// Node.js example using zlib for Gzip compression
const zlib = require('zlib');

const jsonData = {
// Your JSON data here
};

zlib.gzip(JSON.stringify(jsonData), (err, compressedData) => {
if (!err) {
// Send compressedData over the network
}
});

6. 采用服务器端缓存:



  • 缓存 JSON 响应:  实施服务器端缓存, 以便高效地存储和提供 JSON 响应, 减少重复数据处理的需要.


7. 剖析与优化:



  • 剖析性能:  使用剖析工具找出 JSON 处理代码中的瓶颈, 然后优化这些部分.



请记住, 你实施的具体优化措施应符合应用的要求和限制.



真实世界的优化: 在实践中加速


在这一部分, 我们将深入探讨现实世界中遇到 JSON 性能瓶颈并成功解决的应用和项目. 我们将探讨企业如何解决 JSON 的局限性, 以及这些优化为其应用带来的切实好处. 从 LinkedIn 和 Auth0 这样的知名平台到 Uber 这样的颠覆性科技巨头*, 这些示例为我们提供了宝贵的见解, 让我们了解在尽可能利用 JSON 的多功能性的同时提高速度和响应能力的策略.


1. LinkedIn集成Protocol Buffers:


挑战: LinkedIn 面临的挑战是 JSON 的冗长以及由此导致的网络带宽使用量增加, 从而导致延迟增加.
解决方案: 他们在微服务通信中采用了二进制序列化格式 Protocol Buffers 来取代 JSON.
影响: 这一优化将延迟降低了60%, 提高了 LinkedIn 服务的速度和响应能力.


2. Uber的H3地理索引:



  • 挑战: Uber 使用 JSON 表示各种地理空间数据, 但解析大型数据集的 JSON 会降低其算法的速度.

  • 解决方法 他们引入了H3地理索引, 这是一种用于地理空间数据的高效六边形网格系统, 可减少 JSON 解析开销.

  • 影响: 这一优化大大加快了地理空间操作, 增强了 Uber 的叫车和地图服务.


3. Slack的消息格式优化:



  • 挑战: Slack 需要在实时聊天中传输和呈现大量 JSON 格式的消息, 这导致了性能瓶颈.

  • 解决方法 他们优化了 JSON 结构, 减少了不必要的数据, 只在每条信息中包含必要的信息.

  • 影响: 这一优化提高了消息渲染速度, 改善了 Slack 用户的整体聊天性能.


4. Auth0的Protocal Buffers实现:



  • 挑战: Auth0 是一个流行的身份和访问管理平台, 在处理身份验证和授权数据时面临着 JSON 的性能挑战.

  • 解决方案: 他们采用Protocal Buffers来替代 JSON, 以编解码与身份验证相关的数据.

  • 影响: 这一优化大大提高了数据序列化和反序列化的速度, 从而加快了身份验证流程, 并增强了 Auth0 服务的整体性能.


这些真实案例表明, 通过优化策略解决 JSON 的性能难题, 可对应用的速度, 响应和用户体验产生重大积极影响. 它们强调了在各种应用场景中考虑使用替代数据格式和高效数据结构来克服 JSON 相关缓慢的问题的重要性.


总结一下


在开发领域, JSON 是数据交换不可或缺的通用工具. 其人类可读格式和跨语言兼容性使其成为现代应用的基石. 然而, 正如我们在本文中所探讨的, JSON 的广泛应用并不能使其免于性能挑战.


我们在优化 JSON 性能的过程中获得的主要启示是显而易见的:



  • 性能至关重要: 在当今的数字环境中, 速度和响应速度至关重要. 用户希望应用能够快如闪电, 即使是微小的延迟也会导致不满和机会的丧失.

  • 尺寸至关重要: 数据有效载荷的大小会直接影响网络带宽的使用和响应时间. 减少数据大小通常是优化 JSON 性能的第一步.

  • 替代格式: 当效率和速度至关重要时, 探索其他数据序列化格式, 如Protocal Buffers, MessagePack, BSON 或 Avro.

  • 真实世界案例: 从企业成功解决 JSON 速度变慢问题的实际案例中学习. 这些案例表明, 优化工作可以大幅提高应用的性能.


在继续构建和增强Web应用时, 请记住要考虑 JSON 对性能的影响. 仔细设计数据结构, 选择有意义的键名, 并在必要时探索其他序列化格式. 这样, 你就能确保你的应用在速度和效率方面不仅能满足用户的期望, 而且还能超越用户的期望.


在不断变化的Web开发环境中, 优化 JSON 性能是一项宝贵的技能, 它能让你的项目与众不同, 并确保你的应用在即时数字体验时代茁壮成长.


作者:bytebeats
来源:juejin.cn/post/7299353265099423753
收起阅读 »

切!我又不是第一次没人要🤡

我和你一样都经历过 家里蹲 狗都嫌 的尴尬时期,每天早上起来拿着手机不断刷着招聘软件, 海投几百份还是杳无音讯,在BOSS直拒、前程堪忧、失联招聘、猎空之间反复横跳... 还经历了十分灰暗的阶段,焦虑导致出现躯体化反应(头痛、严重失眠、吃不下东西等) 整夜整夜...
继续阅读 »

thumb-1920-651952.jpg


我和你一样都经历过 家里蹲 狗都嫌 的尴尬时期,每天早上起来拿着手机不断刷着招聘软件,


海投几百份还是杳无音讯,在BOSS直拒前程堪忧失联招聘猎空之间反复横跳...


还经历了十分灰暗的阶段,焦虑导致出现躯体化反应(头痛、严重失眠、吃不下东西等)


整夜整夜睡不着,躺下脑子都是工作、面试、人生选择带来的压力


不想出门社交,害怕面试。


其实,我想跟你说:裸辞并不是终点。


1.裸辞/辞职并不是终点


当我扛着我的键盘收拾东西离开工位,第一次对辞职的 “人走茶凉” 有了实感,


下午六点跟对我很好的前辈们告了别,公司离地铁有点远,和往常不一样,天还没黑,有黄昏相伴


三号线还是这么挤 还有点闷。


v2-7ab9c9ebffb2d15971a4ec2a9b1b2fe3_720w.webp


算起来这是第二次辞职,但第一次辞职找了一个礼拜就顺利入职了
,这次好像有点久,今年大家都在说被裁员大环境差,同学领证成家的也不少。


我意识到人与人的节奏不同,而我好像又一次走到了岔路口,上一次这么慌张还是在高考前


即便我从来没后悔过离职这个决定,但还是会因为面试带来的压力感到局促不安


每次离职就像是一场查漏补缺的大考,对勇气,对储蓄,对知识点的大考


唯有拆迁认亲能打破这场突如其来的考验。。。啊不是。。。我想说:


唯有行动能打破僵局!!


R.png


行动!!!去吃个冰淇淋!!。。。果然有灵感了


363b10d3887191d03f7a462610206e4.jpg


短暂的欢愉后,是与台灯的昼夜相守,与简历的交织缠绵


(简历编。。不是。。写不出来呀!!!)


v2-e4d9f181546345313e5e36f15d2e3cf1_r.jpg


反复改了几版之后确实多了一些“打招呼”的机会,但是实际面试机会还是屈指可数呀,


切!又不是第一次没人要🤡,拒绝我的多的去了,得从巴黎排到广州...


继续努力,等待运气,厚积薄发


v2-4039118d48100a401ec76dd9e4d73405_r.jpg


2.当知识脱离了考试,真理和美丽才慢慢浮现


2.1 心态调整(分享一下最近对我有帮助的书)



  • 《见识》 - 吴军


这是第一年出来工作,遇上了很好的领导送我的书,每当迷茫的时候再拿出来翻翻有了不一样的感悟,很多我们看上去非做不可的事情,其实想通了并没有那么重要,无论在职场上还是在生活中,提高效率都需要从拒绝伪工作开始,有些苦是可以不用吃的,苦难并非造就人类



幸福是目的,成功是手段




  • 《意志力》 - 罗伊·鲍迈斯特


技术行业的人都知道学习是个漫长/终身的事情,跟考公考研短期爆发式集中不同,我们更需要坚持长期一点点做下去,我认识到所有人的意志力都是有限的,使用就会消耗,压力也并非与动力画等号,人也跟机器一样需要“充电”和合理分配,每个人的节奏和身体承受能力也不同。



  • 《被讨厌的勇气》 - 岸见一郎、古贺史健编著


在心情动荡的时期,这本书就像开了一盏加热灯一样在一旁无声陪伴,那会我就像婴儿一样无意识地紧紧抓着自己的头发,直到我睁开眼看见了、意识到了,放下禁锢着工作、生活、交友的课题的手,更能轻松地赶路了。



生活的方式千千万,人生的意义,由我自己决定




  • 《法律常识全知道》 - 李桥


读书的时候没有一门跟社会接轨的课程,毕业了也一直专研技术,导致一毕业不知道劳动合同/租房有什么坑,把仲裁和维权看得过于艰难,法律条例密密麻麻 一时间不知从何下手,这本书就很适合我这种来一线城市打工没什么社会经验的小白,用简单的案例植入“NPC游戏”攻略,和《影响力》这本书加一起简直就是进城防骗指南哈哈哈



免费法律援助电话:12348



2.2 前端学习路线图:


roadmap-完整路线.jpeg


各位摸鱼的小伙伴下次见,这篇便是我的2023年终总结:


裸辞不是终点,唯有行动才能打破僵局,当知识脱离了考试,真理和美丽才慢慢浮现。




参考资料:


前端学习路线


作者:慌张的葡萄
来源:juejin.cn/post/7312304122535133220
收起阅读 »

我和我的4年前端生涯

我是一个普普通通的前端开发工程师,除了我的同事,在这个圈子内没人认识我。“前端开发工程师”,老实讲,我很喜欢这个称呼,我对一些圈外朋友总是这么称呼我自己,当然我的内心和认识我的人都知道,我就是一个“写代码的”。初见在2019年,我大专毕业,那时候的学长学姐总和...
继续阅读 »

我是一个普普通通的前端开发工程师,

除了我的同事,在这个圈子内没人认识我。

“前端开发工程师”,老实讲,我很喜欢这个称呼,我对一些圈外朋友总是这么称呼我自己,当然我的内心和认识我的人都知道,我就是一个“写代码的”。

初见

在2019年,我大专毕业,那时候的学长学姐总和我开玩笑说,以后毕业的我们都会去卖保险,跑销售,那时候的我不以为意,以为只是自我调侃,后来发现人家说的是真的,根本不是开玩笑。我在某大厂做了近3个月的电销,那时候我还是我们班工资最高的人,但不得不说这份职业我根本不喜欢。后来,我大学的班花邓某(真的很漂亮)有一次和我聊天,那阵子她在某大厂做审核。她说她打算去培训前端,问我有没有兴趣。那时候的我觉得计算机啊,编程啊都是精英人群,我这等人也能从事吗?我还打趣的问她,参与前端和高考上清华哪个难?(可见那时候的我对这个想都不敢想)

学习

在19年6月毕业后,我就失业了。我根本想不到大专毕业生能从事什么好的工作,也不清楚我能做些什么。后来我一想,那不妨去培训吧,这样就能和班花做同学,有一起接触的空间和机会了!然后我就开始联系她,但是不得不说,她有强烈的拖延症,她根本对这件事没放在心上。那就自学吧!如果我先学会了,然后等她入门的时候,我就可以教她了!她一定对我刮目相看!嘿嘿!我当时是这样想的没错。我首先找到了培训机构,找他们要到了课程大纲,然后自己逐一的根据大纲上的内容,在网上找视频。不得不说,bilibili大学真的很好用。我的html,css,js都是师承的pink老师,他讲的真的很棒,后来vue就跟着180的科比老师学习,不得不说,这两位的视频对我来说有着举足轻重的作用。那时候第一次学习编程,很多东西都是死记硬背的(切记不要这么做),你很难想象到一个人会去背各种元素的作用以及api。那时候学习的过程还是很快乐的,每天都能接触到新知识,在这就是看着那些视频的进度,想象着看完后自己就能毕业的样子,还是很期待的。甚至说,根据视频模仿了一个很小的功能,然后和自己父母分享的时候,他们都在夸我,现在回想起来,那是我从事前端这么多年来最快乐的时光。

毕业

20年9月的时候,大概经历了近10个月的自学,我决定出山找工作了,我来到了广东。当时我具备的技能有html,css,js,vue2,ts。后来就进入了刷面试题的阶段,有很多面试题完全都是死记硬背的,印象最深刻的就是vue2的响应式原理和nextTick原理,完全是根据百度来的台词去阐述。虽然说现在的我让我自己手写这些都不成问题。不得不说,准备的面试题可以准备,但是项目经验,一些细节可真是苦恼了我。一些对项目,业务比较感兴趣的面试官,我通常都会挂在这里,因为没有任何的工作经验。

我还记得,我面试的第一家公司,是在一个星期天,他们需要找开发小程序的人,最后聊完了技术之后;

面试官:“我看你的简历上写着你上一份工作在武汉有着6k/月,是吗?”

我:“是的”(在这里,我和大多数人一样,包装了,美化了自己的简历)

面试官:“据我所知,不到1年工作经验的专科生,是不可能在武汉有6k/月的,当然这只是我个人的片面的看法,江东自古多才俊嘛”(这就是他的原话,我此生都忘不了)

我内心:“尴尬,脸红,我要逃!快让我走!”

我也还记得,我第一次收获的offer的时候,是一家在CBD,25层高楼的公司,在我毕业以前,我一直都以为能在那里工作的人都是精英,商业大佬。这家公司他让我试岗三天,试岗第一天有个题目是让人手写轮播图和使用element做一个分页功能。我还是通过网友,帮我远程控制我的电脑才完成的这个任务,不过很遗憾,我自己选择了离开这家公司,原因嘛,因为我每天挤不上地铁,再者就是,那家公司气氛感觉很压抑。21年的我还很年轻,有这样的果敢和勇气,如果说24年的我,肯定会忍耐这两点。

第一份工作

2021年3月,在经历过长达3个月的求职经历后,我已经被磨练成了面试机器人。终于找到了一家公司,给我的薪水也很不错,至今我都觉得那家公司是宝藏公司,人数500+,我在那里上班1年没有任何加班经历,需求也非常简单,用vue改改东西,写下静态,一周5天能摸鱼3天。就姑且叫那家公司为X公司吧,还有一个特别搞笑的经历,在X面试的时候,老大问我,“你的项目上有分页查询,这个功能是怎么做的?”,我的回答是:“这个功能是前端做的”。真的很难崩有木有!!!他居然要我了!后来在X的日子,开始过起了神仙般的日子,温饱问题总算是解决了,开始每天接触前端这个圈子,和群友聊天,早上刷各种社交软件。 那时候下班了自己也会回家学习,心里也有一个大牛梦,想着自己能不能做出一些很棒的产品开源出去,就像那些社区圈子的名人一样。还记得那时候跟着视频学习写mini-vue框架,也买了很多课程,觉得投资自己是一笔超值的买卖,然而时至今日那些视频都没有看完。在X公司混到了要转正的那一天,还是蛮忐忑的,结果老大把我单独喊出去谈话,到这里已经开始觉得事情不对了。老大说我的表现还不能满足转正,要继续观察一个月,如果说看到这里,可能很多人会说垃圾公司快跑,但我始终为我当时没有赌气离职感到自豪和开心。

ps:有趣的事,在22年的时候我和带我的女同事都离职了,我们聊天的时候说谈起来这件事,她告诉我,关于我是否能转正的问题,是老大先去问她的意见,问我技术怎么样,她把我夸了一顿。当我知道这个事情的时候,泪目了。

第二份工作

在X公司混了1年左右后,说实在的,我的任务实在是太简单了,上班大多数时间都在摸鱼和自学,没有接触到真正的业务。后来就跑路来到了第二家公司,且叫Y公司吧。在这里我接手的一个项目是从0到1实现的,这正是我想要的,能够完整的学习到项目经验,太妙了。那阵子加班的时间就开始多起来,不过也还挺满意的,能学习到真正的业务。不过这种情况持续了大概半年,新鲜感就退去了,我发现我所做的事情,就是“搬砖”。业务上需要用到什么东西,直接搜一个开源的,然后自己管理一下状态,和后端交互一下,结束了,基本上所有的我参与的业务都是这样的模式,我开始懈怠了,早上每天来了先吃半个小时早饭,然后刷新闻,甚至直到下午才开始做活,因为这些事情实在是太无聊了。在下班后,我开始探索代码的世界,开始着手实现自己刚出道的时候的梦想----实现一个开源产品。那时候的我仿佛就是为了写而去写,我根本不知道要写什么,解决什么样的问题,完全就是照着vue,react这种前端框架去抄,而且实现的非常玩具,没坚持多久就放弃了这个天真的想法。

再见

突然有一天,班花邓某突然联系我了,她告诉我她已经辞职去培训了,问我要不要一起。我得承认,收到她消息的时候小鹿乱撞了,我告诉了她我已经从事前端的工作了,她大为开心,说要我帮帮她,有什么不会的就来问我,我很开心,第一次明白被人需要是什么感受。后来她在培训班毕业了,她让我来面试她,不得不说教她的都是什么妖魔鬼怪,我问她对Vue的理解,她居然背文档说,“vue是一款渐进式框架...”。我问她什么是“渐进式”,她就不知道了。那阵子和她交流了很多,虽然不在一个城市,但还是很开心。另外确实漂亮的女生真的在我们这个行业很有优势,她不到1个月就找到工作,而且她提出期望9k,人家面试官给她10k。

WX20240327-161849@2x.png

JS为我做的三件事

  1. 工作

这第一件事就是让我有了生存的技能,找到了一份工作,首先是能生存下来了。说真的,在我参加工作之后,程序员人数膨胀之前,我一直都认为程序员是普通家庭的专科生一项比较好的出路,但现在的话的来说,我可能也会偏向于劝退专科生的萌新吧,这张图是我专科,4年工作经验,计算机专业的求职经历,如果你能承担像这样的挫折感的话,并且你比较自律,热爱编程,我也鼓励你坚持梦想!

WX20240327-132049@2x.png

其实我目前的状态也很难讲,因为这是一个离奇的经历。我们公司最近的效益不太好,可能是我们部门的产品没能带来什么收益,已经开始裁员了,而且很过分的一件事情是,我和我的部门同事,已经3个月没有发薪水了!这件事超级严重,根据过往的经验和数据的话,大概是要凉凉的节奏。

我们部门一个资历比较老的同事,就带头跑路,然后搞的其他人也就跟风,然后吧,好巧不巧,他们问我跑不跑,我深知专科生找工作无比艰难(请参考上面我的BOOS图),我只能相信公司,会好起来的。

这件事无论说到哪里,大家给我的建议都会是立马跑路,然后去仲裁,可能现在看文章的你也是这么想的,但是我很怕,我怕失去工作,我已经适应了现在的环境,我没办法面对接下来的未知挑战,这和我的个人性格有关系,我决定留下来,在赌一下自己的前程,或者等公司裁掉我,没准还有补偿不是吗?

好巧不巧,我们部门资历老的全跑了,老大让我来当老二了,我听到这个消息的时候是懵逼的,老大给我布置了一系列的任务,包括架构一下前端,招人等事宜,我提道工资呢?老大说会给你发的,公司在等机会。也就是典型的加量不加价,其实今天为什么能来更新这篇文章,也是因为我来架构前端,打算在掘金调研一套eslint规则,因为这里的大佬比较多,技术文章质量也还是很高的。

我想到这里,其实觉得我的平台,舞台也是蛮大的,发挥的机会很多,(虽然没发工资)。于是乎我提出了一系列计划上报了老大,包括技术从Vue2转到react18,前端组件库改用storybook等。除了使用react18这套方案被否决了,其余的也都通过了。

说真的,对这份工作的未来也是有极高的不确定性,如果你不敢承受10%的暴跌,那就会错过1%的大涨,不是吗?

  1. 爱情

我在2023年的时候,在某书上认识了一个江西的女生,我们聊的很投缘,我们甚至是一个大学毕业的,她在武汉上的大学。我们有着共同的爱好,经常聊天就是半天的时间就过去了。而且,我们经常分享自己的一些身边事给对方,一些实事的热点新闻的看法,我们也出奇的一致,她在朋友圈发的照片也很好看,有一次,我给她发消息,她过了一段时间没有回复我,我就开始想着,她在干嘛,为什么还没有回,开始焦急,情绪被左右了,我明白了,我确实喜欢他了。

当从朋友,也可能是网友的关系,变成了爱慕的时候,我对她的态度就变了,我开始变得很有占有欲,开始看她某书下的评论有没有其他男孩子,那阵子的我,真的很丑陋。

我开始着急了,我决定更近一步试探一下,我邀请她一起玩某某荣耀,她也答应的很爽快,那时候我还以为我成功了,有戏!没想到,当天,我上号后,她邀请我,我发现!她居然带了她闺蜜和一个男生,我纯纯无语。。。而且游戏过程中,他们三个互动比较多,基本上我就是路人甲,那晚挺失望的反正。

到了她的生日,我说我送你一个生日礼物吧!她也婉拒了,从那时候开始,我们的距离就变得远了。到今天,2024,3,27号,我们大概40多天没说过话了。

到这里,和js有什么关系呢?

我即将告诉你我人生中,做的最小丑的决定

我想的是,女生都比较幕强,我要给她展现一个技术大佬的人设!没错,在某个夜晚我是这么想的,晚上下班后,我上某里云租了一个最便宜的服务器,然后用express + react.js(没错,我个人更喜欢react,这也是我为什么尝试说服领导转react的原因,hhh)花了一晚上时间,写了一个聊天室功能,还内置了许多她喜欢的东西作为背景,icon,logo,然后我部署在了服务器上。

第二天,我立马把域名分享给她,期待她的回复!还俏皮的说,能打败微信吗?我以为这样,她会对我五体投地的佩服,结果她只回了一个 “6”,然后补充“哈哈哈哈哈哈”

那时候我觉得,她是对这种技术不感冒,她只对迪士尼和追星有兴趣,她每周都会去迪士尼和一只小狐狸合影,请注意是每周。而且她会追每一个在她城市开演唱会的人,某杰,某谦,某棋。我以为她是喜欢这些,后来我才发现,她其实是对我没兴趣而已,意识到我越界了,有意疏远我。

  1. 开源,

我用js尝试写过很多开源,也想成为一个为社区有过贡献的人,但后来发现根本没什么用,自己无论写什么,都已经有了前人的库,比我好,比我成熟,比我火。我最近一直在思考,如果我把学习这些,折腾这些的时间用来学习一门其他的事情,比如吉他,或者打游戏,我会不会更快乐?都说1-3年的前端交流群里都在聊技术,4-5年都在聊业务,5年以上的都在聊家庭和钓鱼,看得出,这就是现实,极少有人能从技术中脱颖而出,我也认清了自己。


作者:soloDog
来源:juejin.cn/post/7345760019319373864
收起阅读 »

Android渠道包自动更新

一、背景 转转集团旗下有多款APP产品,随着业务发展,各APP发版频率变高。在持续交付的背景下,渠道包更新存在以下几个效率问题: (1)Android渠道包提交应用市场审核,工作重复&人工成本高   (2)公司目前存在多个APP、需...
继续阅读 »

一、背景


转转集团旗下有多款APP产品,随着业务发展,各APP发版频率变高。在持续交付的背景下,渠道包更新存在以下几个效率问题:


(1)Android渠道包提交应用市场审核,工作重复&人工成本高  


(2)公司目前存在多个APP、需更多人支持,有培训成本


(3)每次发版需要人工通知项目成员渠道包审核进度 


  针对以上问题,我们设计开发了渠道包自动更新后台,用来解决渠道更新的效率问题。


二、方案调研


1、基于业务现状,做了技术调研和逻辑抽象


  不同APP支持的渠道不同,不同渠道更包api不同,如下图:


图片


针对以上调研结果,我们将通用的逻辑统一封装开发,将差异点进行配置,做到灵活配置可扩展。


2、整体的实现方案演变


初期方案,每个应用市场单独提审(需要先选择物料,选好物料后上传包文件,文件上传成功后再点击提交审核),多个应用市场需要重复该操作。


图片


上线运行了一段时间后,发现存在一些问题:单个市场提交步骤繁琐、多个应用市场需要分开多次提交。这些步骤是重复且可简化的,因此我们又对提审的过程做了封装,提供批量上传的入口,简化交互过程,做到一键提审。以下是当前运行的第二版方案:


图片


第二版方案上线后,提审同学只需要在入口处选择要更新的应用市场,然后一键上传全部物料,再点击提审按钮即可提审成功。代码内部会处理具体的逻辑,比如:根据配置规则将物料匹配到对应市场、自动匹配包文件进行提审。


三、方案设计


自动上传包含以下核心模块:



  • APP管理:支持配置多个APP信息,包括转转、找靓机、采货侠等

  • 包管理:支持下载不同渠道,不同版本的包

  • 物料管理:包括历史物料的选择,和新增物料的存储(icon、市场截图)

  • 提交审核:包括包下载、物料下载,支持按照APP配置账号密码提交审核

  • 消息提醒:对提交的结果和审核的结果进行消息通知


图片


实现效果:


提审前信息确认,选择APP,可选择单个或者多个渠道,系统自动选择包地址,用户选择物料后可一键提审多应用市场。操作简单便捷,使用成本低


图片


提审后发送消息通知,便于各方了解渠道的审核结果,对审核异常信息进行及时干预。同时自动存储不同版本的审核记录,方便后续分析。


图片


四、总结


渠道包自动更新功能,节省了大量的提交审核人力成本,打通了Android整体的持续交付过程,降低了人工学习成本。之后我们也会针对各种体验问题进行不断的改进和更新~



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

两个Kotlin优化小技巧,你绝对用的上

大家好,本篇文章仍然聊聊kotlin官方做的一些优化工作,主要包括以下三个方面: 数据对象data object的支持 @Repeatable注解的优化 接下来就带大家介绍下上面三个特性。 一. 数据对象data object的支持 该特性由kotlin1...
继续阅读 »

大家好,本篇文章仍然聊聊kotlin官方做的一些优化工作,主要包括以下三个方面:



  1. 数据对象data object的支持

  2. @Repeatable注解的优化


接下来就带大家介绍下上面三个特性。


一. 数据对象data object的支持


该特性由kotlin1.7.20插件版本提供,并处于实验阶段。



这个特性主要是和原来的object声明的单例类的toString()方法输出有关,在了解这个特性之前,我们先看下下面一个例子:


object Single1

fun main() {
println(Single1)
}

输出:



这个输出本质上就是一个类名、@、地址的拼接,有时候你想要打印输出的仅仅是类名,就得需要重写下toString()方法:


object Single1 {

override fun toString(): String {
return "Single1"
}
}

然后再看一个密封类的例子:


sealed interface Response {

data class Success(val response: String): Response

data class Fail(val error: String): Response

object Loading : Response

}

fun main() {
println(Response.Success("{code: 200}"))
println(Response.Fail("no net"))
println(Response.Loading)
}

输出:



可以看到,大家都是密封子类,但就这个Loading类的输出比较"丑陋",没有上面两个兄弟类的输出简洁清爽。


接下来我们就要介绍下主人公数据对象data object了,这个东西其实使用起来和object一模一样,核心的区别就是前者的toString() 更加简洁。


接下来从一个例子一探究竟:


data object Single2

fun main() {
println(Single2)
}

看下输出:



输出是不是比上面的object Single1更加简单明了。最重要的是在密封类中使用效果更加,我们把上面密封类Loading声明为data object


    data object Loading : Response

看下最终的输出结果:



这下子输出结果是不是清爽更多!!


讲完了应用,我们再java的角度看下其背后的实现机制,相比较于objectdata object会多了下面这三个重写方法:


public final class Single2 {

@NotNull
public String toString() {
return "Single2";
}

public int hashCode() {
return -535782198;
}

public boolean equals(@Nullable Object var1) {
if (this != var1) {
if (!(var1 instanceof Single2)) {
return false;
}

Single2 var2 = (Single2)var1;
}

return true;
}
}

我们需要关心的toString()方法就是直接重写返回了当前的类名。


如果想要使用这个特性,我们只需要增加如下配置即可:


compileKotlin.kotlinOptions {
languageVersion = "1.9"
}

二. @Repeatable注解优化


该特性由kotlin1.6.0插件版本提供优化。



在了解这个特性之前,我们先回忆下@Repeatable这个注解在java中的使用:


如果一个注解在某个方法、类等等上面需要重复使用,那就需要@Repeatable帮助。



  • 首先定义需要重复使用的注解


@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Repeatable(Fruits.class)
public @interface Fruit {
String name();
String color();
}


  • 然后定义注解容器,用来指定可重复使用的注解类型


@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Fruits {
Fruit[] value();
}

然后就可以在代码中这样使用:


@Fruits({
@Fruit(name = "apple", color = "red"),
@Fruit(name = "origin", color = "yellow"),
})

public class Detail {
}

大家有没有发现,可重复注解定义起来还是由一丢丢的麻烦,接下来轮到我们kotlin重磅出击了。先看下面一个例子:


@Repeatable 
annotation class Animal(val name: String)

在kotlin中我们只要声明一个需要重复使用的注解即可,kotlin编译器会自动帮助我们生成注解容器@Animal.Container,然后我们就能在代码中这样使用:


@Animal(name = "dog")
@Animal(name = "horse")
public class Detail {
}

是不是非常简单便捷了。


如果你偏要显示指明一个包含注解,也可以,通过以下方式即可实现:


@JvmRepeatable(Animals::class)
annotation class Animal(val name: String)

annotation class Animals(val value: Array)

然后除了上面的使用方式,你在kotlin中还可以这样使用:


@Animals([Animal(name = "dog"), Animal(name = "dog")])
class Detail {
}

请注意:



  1. 如果非要显示声明一个注解容器,其属性的名称一定要为value

  2. 其次,注解容器和可重复性直接不能同时声明在同一个元素上;


另外,其实这个特性kotlin早就支持了,只不过kotlin1.6.0插件版本之前,kotlin这个特性只只支持RetentionPolicy.SOURCE生命周期的注解,并且还和java的可重复注解不兼容。


总结


这两个小技巧相信在大家日常开发中还是比较实用的,希望本篇能对你有所帮助。


参考文章:


Improved string representations for singletons and sealed class hierarchies with data objects


Repeatable annotations with runtime retention for 1.8 JVM target




作者:长安皈故里
来源:juejin.cn/post/7248249730478784569
收起阅读 »

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.(CursorWindow.java:139)
at android.database.CursorWindow.(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();

接着 问题完美解决


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

告别轮询,SSE 流式传输可太香了!

今天想和大家分享的一个技术是 SSE 流式传输 。如标题所言,通过 SSE 流式传输的方式可以让我们不再通过轮询的方式获取服务端返回的结果,进而提升前端页面的性能。 对于需要轮询的业务场景来说,采用 SSE 确实是一个更好的技术方案。 接下来,我将从 SSE ...
继续阅读 »

今天想和大家分享的一个技术是 SSE 流式传输 。如标题所言,通过 SSE 流式传输的方式可以让我们不再通过轮询的方式获取服务端返回的结果,进而提升前端页面的性能。


对于需要轮询的业务场景来说,采用 SSE 确实是一个更好的技术方案。


接下来,我将从 SSE 的概念、与 Websocket 对比、SSE 应用场景多个方面介绍 SSE 流式传输,感兴趣的同学一起来了解下吧!


什么是 SSE 流式传输


SSE 全称为 Server-sent events , 是一种基于 HTTP 协议的通信技术,允许服务器主动向客户端(通常是Web浏览器)发送更新。


它是 HTML5 标准的一部分,设计初衷是用来建立一个单向的服务器到客户端连接,使得服务器可以实时地向客户端发送数据。


这种服务端实时向客户端发送数据的传输方式,其实就是流式传输。


我们在与 ChatGPT 交互时,可以发现 ChatGPT 的响应总是间断完成。细扒 ChatGPT 的网络传输模式,可以发现,用的也是流式传输。


图片


SSE 流式传输的好处


在 SSE 技术出现之前,我们习惯把需要等待服务端返回的过程称为长轮询。


长轮询的实现其实也是借助 http 请求来完成,一个完整的长轮询过程如下图所示:


图片


从图中可以发现,长轮询最大的弊端是当服务端响应请求之前,客户端发送的所有请求都不会被受理。并且服务端发送响应的前提是客户端发起请求。


前后端通信过程中,我们常采用 ajax 、axios 来异步获取结果,这个过程,其实也是长轮询的过程。


而同为采用 http 协议通信方式的 SSE 流式传输,相比于长轮询模式来说,优势在于可以在不需要客户端介入的情况下,多次向客户端发送响应,直至客户端关闭连接。


这对于需要服务端实时推送内容至客户端的场景可方便太多了!


SSE 技术原理


1. 参数设置

前文说到,SSE 本质是一个基于 http 协议的通信技术。


因此想要使用 SSE 技术构建需要服务器实时推送信息到客户端的连接,只需要将传统的 http 响应头的 contentType 设置为 text/event-stream 。


并且为了保证客户端展示的是最新数据,需要将 Cache-Control 设置为 no-cache 。


在此基础上,SSE 本质是一个 TCP 连接,因此为了保证 SSE 的持续开启,需要将 Connection 设置为 keep-alive 。


Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

完成了上述响应头的设置后,我们可以编写一个基于 SSE 流式传输的简单 Demo 。


2. SSE Demo

服务端代码:


const express = require('express');
const app = express();
const PORT = 3000;

app.use(express.static('public'));

app.get('/events'function(req, res) {
    res.setHeader('Content-Type''text/event-stream');
    res.setHeader('Cache-Control''no-cache');
    res.setHeader('Connection''keep-alive');

    let startTime = Date.now();

    const sendEvent = () => {
        // 检查是否已经发送了10秒
        if (Date.now() - startTime >= 10000) {
            res.write('event: close\ndata: {}\n\n'); // 发送一个特殊事件通知客户端关闭
            res.end(); // 关闭连接
            return;
        }

        const data = { message'Hello World'timestampnew Date() };
        res.write(`data: ${JSON.stringify(data)}\n\n`);

        // 每隔2秒发送一次消息
        setTimeout(sendEvent, 2000);
    };

    sendEvent();
});

app.listen(PORT() => {
    console.log(`Server running on http://localhost:${PORT}`);
});

客户端代码:


<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>SSE Example</title>
</head>

<body>
    <h1>Server-Sent Events Example</h1>
    <div id="messages"></div>

    <script>
        const evtSource = new EventSource('/events');
        const messages = document.getElementById('messages');

        evtSource.onmessage = function(event) {
            const newElement = document.createElement("p");
            const eventObject = JSON.parse(event.data);
            newElement.textContent = "Message: " + eventObject.message + " at " + eventObject.timestamp;
            messages.appendChild(newElement);
        };
    
</script>
</body>
</html>

当我们在浏览器中访问运行在 localhost: 3000 端口的客户端页面时,页面将会以 流式模式 逐步渲染服务端返回的结果:


图片


需要注意的是,为了保证使用 SSE 通信协议传输的数据能被客户端正确的接收,服务端和客户端在发送数据和接收数据应该遵循以下规范:


服务端基本响应格式

SSE 响应主要由一系列以两个换行符分隔的事件组成。每个事件可以包含以下字段:


data:事件的数据。如果数据跨越多行,每行都应该以data:开始。
id:事件的唯一标识符。客户端可以使用这个ID来恢复事件流。
event:自定义事件类型。客户端可以根据不同的事件类型来执行不同的操作。
retry:建议的重新连接时间(毫秒)。如果连接中断,客户端将等待这段时间后尝试重新连接。

字段之间用单个换行符分隔,而事件之间用两个换行符分隔。


客户端处理格式

客户端使用 EventSource 接口监听 SSE 消息:


const evtSource = new EventSource('path/to/sse');
evtSource.onmessage = function(event) {
    console.log(event.data); // 处理收到的数据
};

SSE 应用场景


SSE 作为基于 http 协议由服务端向客户端单向推送消息的通信技术,对于需要服务端主动推送消息的场景来说,是非常适合的:


图片


SSE 兼容性


图片


可以发现,除了 IE 和低版本的主流浏览器,目前市面上绝大多数浏览器都支持 SSE 通信。


SSE 与 WebSocket 对比


看完 SSE 的使用方式后,细心的同学应该发现了:


SSE 的通信方式和 WebSocket 很像啊,而且 WebSocket 还支持双向通信,为什么不直接使用 WebSocket ?


下表展示了两者之间的对比:


特性/因素SSEWebSockets
协议基于HTTP,使用标准HTTP连接单独的协议(ws:// 或 wss://),需要握手升级
通信方式单向通信(服务器到客户端)全双工通信
数据格式文本(UTF-8编码)文本或二进制
重连机制浏览器自动重连需要手动实现重连机制
实时性高(适合频繁更新的场景)非常高(适合高度交互的实时应用)
浏览器支持良好(大多数现代浏览器支持)非常好(几乎所有现代浏览器支持)
适用场景实时通知、新闻feed、股票价格等需要从服务器推送到客户端的场景在线游戏、聊天应用、实时交互应用
复杂性较低,易于实现和维护较高,需要处理连接的建立、维护和断开
兼容性和可用性基于HTTP,更容易通过各种中间件和防火墙可能需要配置服务器和网络设备以支持WebSocket
服务器负载适合较低频率的数据更新适合高频率消息和高度交互的场景

可以发现,SSE 与 WebSocket 各有优缺点,对于需要客户端与服务端高频交互的场景,WebSocket 确实更适合;但对于只需要服务端单向数据传输的场景,SSE 确实能耗更低,且不需要客户端感知


参考文档


developer.mozilla.org/zh-CN/docs/…


作者:veneno
来源:juejin.cn/post/7355666189475954725
收起阅读 »

Android 如何统一处理登录后携带数据跳转到目标页面

需求场景 我们在开发应用的时候经常会遇到先登录,登录成功后再跳转到目标页面。比如商品详情页面我们点击购买必须要先登录,登录完成才能去下单支付。针对这种场景,我们一般有两种做法: 点击购买跳转到登录,登录完成需要用户再次点击购买才能去下单支付页面,这种用户体验...
继续阅读 »

需求场景


我们在开发应用的时候经常会遇到先登录,登录成功后再跳转到目标页面。比如商品详情页面我们点击购买必须要先登录,登录完成才能去下单支付。针对这种场景,我们一般有两种做法:



  1. 点击购买跳转到登录,登录完成需要用户再次点击购买才能去下单支付页面,这种用户体验不是很好。

  2. 点击购买跳转到登录,登录完成直接跳转到下单支付页面。


第一种我们就不谈了产品经理不同意🐶。第二种我们一般是在 onActivityResult 里面获取到登录成功,然后根据 code 跳转到目标页面。这种方式缺点就是我们要在每个页面都处理相同的逻辑还有定义各种 code,如果应用里面很多这种场景也太繁琐了。那有没有统一的方式去处理这种场景就是我们今天的主题了。


封装方式


我们的应用是组件化的,APP 的页面跳转使用了 Arouter。所以我们统一处理使用 Arouter 封装。直接上代码


fun checkLoginToTarget(postcard: Postcard) {//Postcard 是 Arouter 的类
if (User.isLogin()) {
postcard.navigation()
} else {
//不能使用 postcard 切换 path 直接跳转,因为 group 可能不同,所以重新 build
ARouter.getInstance().build(Constant.LOGIN)
.with(postcard.extras)//获取携带的参数重新转入
.withString(Constant.TAGACTIVIFY, postcard.path)//添加目标路由
.navigation()
}
}

//登录成功后在登录页面执行这个方法
fun loginSuccess() {
val intent= intent
val target = intent.getStringExtra(Constant.TAGACTIVIFY)//获取目标路由
target?.apply {
if (isNotEmpty()){
val build = ARouter.getInstance().build(this)
val extras = intent.extras//获取携带的参数
if (extras != null) {
build.with(extras)
}
build.navigation()
}
}
finish()
}

代码加了注释,使用 Kotlin 封装了顶层函数,登录页面在登录成功后跳转到目标页面,针对上面的场景直接调用 checkLoginToTarget 方法。


checkLoginToTarget(ARouter.getInstance().build(Constant.PAY_PAGE).withInt(Constant.GOOD_ID,id))

通过 Arouter 传入下单支付的路由地址,并且携带了商品的 ID,生成了 Postcard 参数。登录成功后能带着商品 ID
直接下单支付了。


最后


如果项目里没有使用路由库可以使用 Intent 封装实现,或者别的路由库也可以用上面的方式去做统一处理。


作者:shortybin
来源:juejin.cn/post/7237386183612530749
收起阅读 »

别做老实人了!这才是 HR 喜欢听的离职原因!

哈喽,大家好 我是 xy👨🏻‍💻。今天我们来聊聊一个面试时超级敏感但又不得不面对的问题——离职原因💢 找工作面试被问离职原因,千万不要直接说出你为什么从上家公司离职的真实原因!!! 当老实人要吃亏的!!! 其实,大部分真实的离职原因都是每家公司或多或少会遇到...
继续阅读 »

哈喽,大家好 我是 xy👨🏻‍💻。今天我们来聊聊一个面试时超级敏感但又不得不面对的问题——离职原因💢



找工作面试被问离职原因,千万不要直接说出你为什么从上家公司离职的真实原因!!!


老实人要吃亏的!!!


其实,大部分真实的离职原因都是每家公司或多或少会遇到的,但如果直接说出来,很可能会让面试官担心你入职后也会因为同样的原因离职哦!💢


😉 所以,作为一个聪明的求职者,我们应该如何回答这个问题呢?


6 个常见的离职原因


首先,让我们来看看这 6 个常见的离职原因❌,以及它们的“禁忌”回答方式吧!🚫


🎯 工资太低

🚫 禁忌回答:因为前公司给的工资太低了,我实在是受不了了。



🎯 加班严重

🚫 禁忌回答:前公司加班太严重了,我身体都垮了。



🎯 领导傻 X

🚫 禁忌回答:前领导简直就是个傻 X,根本不懂管理。



🎯 被裁员

🚫 禁忌回答:我之前的公司裁员,我被无辜波及了。



🎯 无法升职

🚫 禁忌回答:我在前公司干了几年,但一直升不上去。



🎯 同事相处不和谐

🚫 禁忌回答:前公司的同事们都太奇葩了,我实在受不了。



👀 看到了吗?这些回答都是大忌,因为它们会让 HR 觉得你不稳定、有负面情绪、甚至可能是个问题员工。那么,正确的回答方式是什么呢?别急,我们接着往下看!✅


🎯 工资太低

✅ 回答模板:HR 您好!我过去 2 年,在公司和领导的帮助下,我在技术和能力方面有了很大提升,为公司开发的各种工具和库,极大地提高了项目开发效率,领导对我很认可,但公司的薪资结构有一些硬指标,无法满足我的涨薪需求,所以想看看市场上的新机会,寻找一个可以长期稳走发展的平台。



🎯 加班严重

✅ 回答模板:HR 您好!在上家公司,我的工作效率较高,能按时保质保量地完成工作,但前司会定期统计加班时长,并以此进行考核,导致大部分人为了加班而加班,效率非常低,我个人并不反对加班,但这种低效的常态化 996,我并不认可,我希望能在更人性化管理的公司里发挥优势。



🎯 领导傻 X(这个有点难,但我们可以尽量委婉)

✅ 回答模板:HR 您好!因为前公司的工作职贵划分比较固定,每个人负责的业务比较少,对于所负责的开发工作,我已经没有什么新知识可以学的了,我希望职业生涯是一个持续的学习和提升的过程,并且在自己具备了相关业务技能经验后,也想迎接一些新的挑战。



🎯 被裁员

✅ 回答模板:HR 您好!前公司当时在进行业务调整,公司想让我调到其他业务线上,岗位职责也发生了变化,我觉得自己还是很擅长做前端工具链方向的开发,并且也在这块积累了几年经验,还是想继续在这个方向上深耕,因此出来看看机会。



🎯 无法升职

✅ 回答模板:HR 您好!前公司管理比较扁平,虽然一直被领导重用,也负责过多个核心业务,但是职级几年来来一直没有变化。领导也争取过,但由于工作年限等原因,没能通过。我不想安于现状,想找一个更能发挥自己才能的平台。



🎯 同事相处不和谐

✅ 回答模板:HR 您好!我之前在前司能力不错,取得了不错的成绩,但由于分工不太合理,出现了很多扯皮现象和低效沟通,影响到了工作效率,有时甚至会影响整个项目的进度,内耗较大,所以决定离开,希望找一个氛国好团队长期发展。



💡 记住了吗?面试时回答离职原因的关键在于:不要贬低前司和前同事不要让 HR 觉得是你的问题也不要吐槽前司的任何不足。掌握这些要领,相信你在面试中一定能够游刃有余地应对这个问题啦!




作者:前端开发爱好者
来源:juejin.cn/post/7351321275975843867
收起阅读 »

我改进了数据滚动方式!老板直接加薪

web
需求背景 前几天,甲方提了个需求,想让下面的数据循环展示,准备放在他们集团首页给他们领导演示用。 我们领导很重视这个事儿,拍了拍我,语重心长的说,小伙子,好好做。 我啪的一下就兴奋了,老板居然如此器重我,我必当鞠躬尽瘁,减少摸鱼,我要让老板拜倒在精湛的技术下...
继续阅读 »

需求背景


前几天,甲方提了个需求,想让下面的数据循环展示,准备放在他们集团首页给他们领导演示用。



我们领导很重视这个事儿,拍了拍我,语重心长的说,小伙子,好好做。


我啪的一下就兴奋了,老板居然如此器重我,我必当鞠躬尽瘁,减少摸鱼,我要让老板拜倒在精湛的技术下!


于是,我搬出自己的库存代码,仅2min就实现了数据的滚动:


没错,我直接照搬了自己以前写过的文章:JS实现可滚动区域自动滚动展示 - 掘金


就在我准备告诉老板我做完了的时候,我突然想了想,这么快做完,老板一定觉得我没好好做,我以后还怎么升职加薪,赢取白富美?


于是,我连夜研究,终于改进了数据滚动方式,赢得了老板的大饼(以后涨500)。最终效果:



技术方案


技术选型


观察最终效果图,可以发现这其实就是一个数据循环滚动的效果,每条内容之间间隔1000ms,每条出现动的时间为500ms。用术语来说,这就是一个单步停顿滚动效果。


我百度了一下,社区还是有这个实现的现成方案的:vue-seamless-scroll,周下载也还行。



于是,我果断试了试,结果不知道什么原因,并不生效...


既然如此,直接手写一个吧!


实现思路


要实现上述效果其实很简单,如图



我们创造一个含有六个值的数组,每隔一段时间循环更改黄色区域的数据,当黄色区域数据变成最新的时候,红色区域整体向下移动,当有数值超出滚动区域后,在删除这个数据即可。


数据更新


如果不考虑动画,我们的代码应该这么写


<template>
<div class="item-wrap" v-for="(item, index) in animationData">
<!-- 模块内容 -->
</div>

</template>
<script setup lang="ts">
// #假设这是接口请求的10条最新数据
const allCarouseData = ref([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
// #需要轮播的数据
const animationData = ref<any>([])
// *定时器
const animationTimerMeta: any = {
timer: null,
// 这个函数负责设置轮播数据的更新逻辑。
timeFuc() {
let setTimeoutId: any = null
if (this.timer) return
this.timer = setInterval(() => {
// 取轮播数据的第一条id
let firstId = animationData.value[0].id
// 为轮播数据添加最新的第一项数据
let index = allCarouseData.value.findIndex((res: any) => res.id === firstId)
let addIndex = index - 1 < 0 ? allCarouseData.value.length - 1 : index - 1
animationData.value.unshift(allCarouseData.value[addIndex])
setTimeout(() => {
// 删除数组的最后一项
animationData.value.pop()
}, 1000)

}, 1500)
}
}
animationData.value = allCarouseData.value.slice(-5)
animationTimerMeta.timeFuc()
</script>

上述代码的主要功能是:



  1. 从 allCarouseData 中取出最后5个元素作为初始的轮播数据。

  2. 每1.5秒更新一次轮播数据,具体逻辑是:移除当前 animationData 的第一个元素,并从 allCarouseData 中取出前一个元素(如果已经是第一个元素,则取最后一个)添加到 animationData 的开头。

  3. 每1秒从 animationData 的末尾移除一个元素。


上述代码没有实现动画,他的效果是这样的:



动画添加


<template>
<div class="item-wrap" v-for="(item, index) in animationData"
:class="[{ moveToBottom: animationActive }, { show: animationActive && index === 0 }]"
>

<!-- 模块内容 -->
</div>

</template>
<script setup lang="ts">
// #假设这是接口请求的10条最新数据
const allCarouseData = ref([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
// #需要轮播的数据
const animationData = ref<any>([])
// #是否开启动画
const animationActive = ref(false)
// *定时器
const animationTimerMeta: any = {
timer: null,
// 这个函数负责设置轮播数据的更新逻辑。
timeFuc() {
let setTimeoutId: any = null
if (this.timer) return
this.timer = setInterval(() => {
// 取轮播数据的第一条id
let firstId = animationData.value[0].id
// 为轮播数据添加最新的第一项数据
let index = allCarouseData.value.findIndex((res: any) => res.id === firstId)
let addIndex = index - 1 < 0 ? allCarouseData.value.length - 1 : index - 1
animationData.value.unshift(allCarouseData.value[addIndex])
setTimeout(() => {
// 删除数组的最后一项
animationData.value.pop()
}, 1000)

}, 1500)
}
}
animationData.value = allCarouseData.value.slice(-5)
animationTimerMeta.timeFuc()
</script>


@keyframes moveToBottom {
0% {
transform: translateY(-47px);
}

100% {
transform: translateY(0);
}
}

.moveToBottom {
animation: moveToBottom 500ms ease-in-out forwards;
}

@keyframes fadeInFromTop {
0% {
opacity: 0;
transform: translateY(-47px);
}

100% {
opacity: 1;
transform: translateY(0);
color: #683BD6;
}
}

.show {
animation: fadeInFromTop 500ms ease-in-out forwards;
}

上述代码中,为了实现动画效果,采用了动态添加类名的技术方案。


animationData 数组中的元素会按照一定顺序进行显示和隐藏,同时伴随有动画效果。当第一个元素进入视图时,它会应用 fadeInFromTop 动画;其他元素会应用 moveToBottom 动画。通过定时器,元素会定期从 allCarouseData 中获取新的数据并更新 animationData。


代码释义:



  • moveToBottom: 当 animationActive 为真值时,此类名会被添加到 div 上。

  • show: 当 animationActive 为真值且当前元素是数组的第一个元素时,此类名会被添加到 div 上。


CSS 释义:



  • moveToBottom 动画:


定义一个名为 moveToBottom 的关键帧动画,使元素从上方移动到其原始位置。


moveToBottom 类将此动画应用到元素上。



  • fadeInFromTop 动画:


定义一个名为 fadeInFromTop 的关键帧动画,使元素从上方淡入并改变颜色。


show 类将此动画应用到元素上。


通过上述简单的实现方式,就能最终实现我们想要的效果



相比于普通滚动,这种方式看起来要好很多!


结语


要想实现这种单步停帧的效果,其实有很多实现方式,这只是笔者实现的一种,核心逻辑就是动态改变数据、增添类名。如果大家还有更好的方式,也欢迎大家指点。


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

只写后台管理的前端要怎么提升自己

web
本人写了五年的后台管理。每次面试前就会头疼,因为写的页面除了表单就是表格。抱怨过苦恼过也后悔过(虽然我现在已经心安理得的摆烂),但是站在现在的时间点回想以前,发现有很多事情可以做的更好,于是有了这篇文章。 写优雅的代码 一道面试题 大概两年以前,面试美团的时候...
继续阅读 »

本人写了五年的后台管理。每次面试前就会头疼,因为写的页面除了表单就是表格。抱怨过苦恼过也后悔过(虽然我现在已经心安理得的摆烂),但是站在现在的时间点回想以前,发现有很多事情可以做的更好,于是有了这篇文章。


写优雅的代码


一道面试题


大概两年以前,面试美团的时候,面试官让我写一道代码题,时间单位转换。具体的题目我忘记了。


原题目我没做过,但是我写的业务代码代码里有类似的单位转换,后端返回一个数字,单位是kb,而我要展示成 KBMB 等形式。大概写一个工具函数(具体怎么写的忘记了,不过功能比这个复杂点):


function formatSizeUnits(kb) {
let units = ['KB', 'MB', 'GB', 'TB', 'PB'];
let unitIndex = 0;

while (kb >= 1024 && unitIndex < units.length - 1) {
kb /= 1024;
unitIndex++;
}

return `${kb.toFixed(2)} ${units[unitIndex]}`;
}

而在此之前,是一个后端同学写的代码(说起来,后台管理系统前端人力不够后端帮忙写好像是常规操作,后端总觉得写前端简单,而我只能说你们那代码写的,一言难尽……),如下:


function formatSizeUnits(kb) {
var result = '';

if (kb < 1024) {
result = kb + ' KB';
} else if (kb < 1024 * 1024) {
result = (kb / 1024).toFixed(2) + ' MB';
} else if (kb < 1024 * 1024 * 1024) {
result = (kb / 1024 / 1024).toFixed(2) + ' GB';
} else {
result = (kb / 1024 / 1024 / 1024).toFixed(2) + ' TB';
}

return result;
}

虽然功能都一样,但是我写在面试的时候写了上面的代码得到了面试官的赞扬,也因此非常顺利的通过了面试。而如果我写成了下面的样子,估计不会有这种效果。


如何提升代码质量


想提升代码质量,最理想的是靠 code review ,但实际上这玩意在大多数公司根本就不存在。其次就只能多看看大佬们写的代码,但是一来后台管理本来就没太大难度很少有大佬,二来很多时候一个后台管理系统就一两个人负责。


还好现在有了各种 AI 工具,可以非常方便的优化自己的代码了。


还是上面的问题,看看 GPT 给的答案


// 提问:写一个工具函数,对文件大小的单位进行转换,加注释
// 回答:下面是一个JavaScript工具函数,它接受文件大小、初始单位、目标单位和小数位数,然后返回目标单位下的文件大小。

/**
* 将文件大小从一个单位转换为另一个单位。
*
* @param {number} size 文件大小。
* @param {string} fromUnit 初始单位('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB')。
* @param {string} toUnit 目标单位('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB')。
* @param {number} [decimalPoint=2] 结果保留的小数位数,默认为2。
* @return {string} 转换后的文件大小,带单位。
*/

function convertFileSize(size, fromUnit, toUnit, decimalPoint = 2) {
// 定义单位与字节之间的转换关系
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
// 获取初始单位和目标单位的索引
const fromIndex = units.indexOf(fromUnit);
const toIndex = units.indexOf(toUnit);

// 如果单位不在列表中,抛出错误
if (fromIndex === -1 || toIndex === -1) {
throw new Error('Invalid units');
}

// 计算初始单位与目标单位之间的转换系数
const exponent = toIndex - fromIndex;
// 计算结果大小
const resultSize = size / Math.pow(1024, exponent);

// 返回格式化后的结果
return parseFloat(resultSize.toFixed(decimalPoint)) + ' ' + toUnit;
}

// 示例使用
console.log(convertFileSize(1, 'GB', 'MB')); // 输出: 1024.00 MB
console.log(convertFileSize(1, 'MB', 'KB')); // 输出: 1024.00 KB
console.log(convertFileSize(1, 'KB', 'B')); // 输出: 1024.00 B
console.log(convertFileSize(1, 'MB', 'GB', 5)); // 输出: 0.00098 GB

还能说什么,我只能说 NB!(经评论区提示,此段代码可能有问题,建议大家在使用AI工具时,重点是借鉴思路,直接使用代码需要谨慎~)


我现在每次写大段逻辑之后都习惯拿给 AI 看看,有什么更好的实现方式,或者用什么设计模式。AI 是非常低成本且高效提升代码质量的工具。


学会封装


一个功能用到了好多次,为什么不封装成组件?一个组件用到了好几个项目,为什么不单独写个npm包?差不多的项目创建了好几个,为什么不封装成脚手架?


你说,没时间,没必要,复制粘贴反而更快。


那你就完全没理解,这么做不一定是为了让工作更快完成,而是可以让你在年年终述职时更有话说(你就算写了一百个表单表格没有写一个脚手架更值得炫耀),如果不会写可以问问 AI。


而当你真正开始封装组件,开始写工具库了,你会发现你需要思考的确实比之前多了。


关注业务


对于前端业务重要吗?


相比于后端来说,前端一般不会太关注业务。就算出了问题大部分也是后端的问题。


但是就我找工作的经验,业务非常重要!


如果你做的工作很有技术含量,比如你在做低代码,你可以面试时讲一个小时的技术难点。但是你只是一个破写后台管理,你什么都没有的说。这个时候,了解业务就成为了你的亮点。


一场面试


还是拿真实的面试场景举例,当时前同事推我字节,也是我面试过N次的梦中情厂了,刚好那个组做的业务和我之前呆的组做的一模一样。



  • 同事:“做的东西和咱们之前都是一样的,你随便走个过场就能过,我在前端组长面前都夸过你了!”

  • 我:“好嘞!”


等到面试的时候:



  • 前端ld:“你知道xxx吗?(业务名词)”

  • 我:“我……”

  • 前端ld:“那xxxx呢?(业务名词)”

  • 我:“不……”

  • 前端ld:“那xxxxx呢??(业务名词)”

  • 我:“造……”


然后我就挂了………………


如何了解业务



  1. 每次接需求的时候,都要了解需求背景,并主动去理解


    我们写一个表格简简单单,把数据展示出来就好,但是表格中的数据是什么意思呢?比如我之前写一个 kafka 管理平台,里面有表格表单,涉及什么 cluster controller topic broker partition…… 我真的完全不了解,很后悔我几年时间也没有耐下心来去了解。


  2. 每次做完一个需求,都需要了解结果


    有些时候,后台管理的团队可能根本没有PM,那你也要和业务方了解,这个功能做了之后,多少人使用,效率提高了吗?数据是怎样的?


  3. 理解需求,并主动去优化


    产品要展示一千条数据,你要考虑要不要分页,不分页会不会卡,要不要上虚拟表格?


    产品要做一个可拖拽表单,你要考虑是否需要拖动,是否需要配置。


    其实很多时候,产品的思维可能会被局限在竞品的实现方式,而前端可以给TA更多选项。在和产品沟通的时候,你不仅是沟通页面的实现,也更能理解业务。



关注源码


说到源码, Vue,React 这些基本是每次必问,也有太多大佬们的总结我就不多说了。


除此之外,我想大家每次面试应该都会被问,你写了什么亮点的项目,困难的项目,你会说什么?哦,都是表单表格,都用 Element UI 实现的,没什么难度。那估计你面试也就到这里了。


那说什么,后台管理就这些啊?!


如果你用了 Element UI 你可以说写了什么自定义 Element UI 表单组件,Element UI 表单源码是什么原理,怎么通信的?用了 Axios 可以说 Axios 的原理,它的拦截器怎么实现的?用了编辑器 diff 那你知道 diff 算法是什么吗?用了 ECharts 那你知道他底层是用什么绘制的吗?用了 husky ?husky的原理是什么?用了Vite,它为什么快?是怎么打包的?写过插件吗?


可能有人觉得我离谱,事实上上面每一个问题都是我在面试中真实被问到的。当然,如果全部去了解,肯定不现实,每个人的精力有限,不可能每个用到的每个工具每个库都去了解原理,不过只要有一两个你去了解过,就可以在面试中引导面试官来问,要学会表现出你擅长的点


至于怎么读源码?以前我不知道,现在嘛…… 为什么不问问神奇的 ChatGPT 呢?



讲一下 Axios 源码中,拦截器是怎么实现的?


Axios 的拦截器实现基于 Axios 的核心原理,即 Axios 实例是一个包含请求和响应拦截器堆栈的对象。当发出请求或接收响应时,Axios 会遍历这些拦截器,并按照添加的顺序执行请求拦截器,以及按照相反的顺序执行响应拦截器。


在 Axios 的源码中,拦截器是通过一个 AxiosInterceptorManager 实例来管理的,它维护了一个拦截器数组。每个拦截器都是一个包含 fulfilledrejected 函数的对象。这两个函数分别对应于拦截器成功处理和拦截器处理出错的情况。


以下是 Axios 拦截器管理器的一个简化版本,展示了其核心实现思路:


class InterceptorManager {
constructor() {
this.handlers = []; // 存储拦截器的数组
}

use(fulfilled, rejected) {
this.handlers.push({
fulfilled: fulfilled,
rejected: rejected
});
return this.handlers.length - 1; // 返回拦截器的ID
}

eject(id) {
if (this.handlers[id]) {
this.handlers[id] = null; // 移除拦截器
}
}

forEach(fn) {
this.handlers.forEach((h) => {
if (h !== null) {
fn(h);
}
});
}
}

在发送请求或接收响应时,Axios 会创建一个 promise 链,并通过 forEach 方法将拦截器中的 fulfilledrejected 函数添加到这个链中。这样,每个拦截器都可以对请求或响应进行处理,然后将结果传递到链的下一个拦截器,或者在出错时结束链的执行。


axios.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
chain.unshift(interceptor.fulfilled, interceptor.rejected);
});

axios.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
chain.push(interceptor.fulfilled, interceptor.rejected);
});

在 Axios 的完整实现中,这个拦截器机制被集成到了 Axios 的请求发送和响应处理流程中。通过这种方式,Axios 可以在发送请求之前和接收响应之后,但在用户定义的 .then.catch 执行之前,插入自定义的逻辑。


请注意,这里提供的代码只是为了说明 Axios 拦截器的实现原理,并不是 Axios 源码的完整复制。如果你对 Axios 的拦截器实现细节感兴趣,建议查看 Axios 的官方 GitHub 仓库中的源码。



前端基建


当我们工作时间久了面试难免会遇到这些问题,前端工程化,前端监控,工作流,部署,性能等等。其实我们在工作中绝大部分时间都在写代码,对于这些不是所有人都有机会接触到,不过这些和所做的业务无关,是我们提升自己很好的一个思路。


技术选型


技术栈选 Vue 还是 React?Vue 选 Vue2 还是 Vue3?组件库选 ElementUI 还是 Ant Design?微前端有没有使用过?打包工具用 Vite 还是 Webpack?有那么多表单怎么实现的,有没有什么表达配置化方案,比如Formily?


对于我这种菜鸡,我这种只写简单的表单表格的人,这些都……无所谓……


image.png

不过为了应对面试我们还是需要了解下未选择技术栈的缺点,和已选择技术栈的优点(有点本末倒置…但是常规操作啦)


Vue 你可以说简单高效轻量级,面试必会问你为什么,你就开始说 Vue 的响应式系统,依赖收集等。


React 你可以说 JSX、Hooks 很灵活,那你必然要考虑 JSX 怎么编译, Hooks 实现方式等。


总体而言,对于技术选型,依赖于我们对所有可选项的理解,做选择可能很容易,给出合理的理由还是需要花费一些精力的。


开发规范


这个方面,在面试的时候我被问到的不多,我们可以在创建项目的时候,配置下 ESlintstylelintprettiercommitlint 等。


前端监控


干了这么多年前端,前端监控我是……一点没做过。


image.png

前端监控,简单来说就是我们在前端程序中记录一些信息并上报,一般是错误信息,来方便我们及时发现问题并解决问题。除此之外也会有性能监控,用户行为的监控(埋点)等。之前也听过有些团队分享前端监控,为了出现问题明确责任(方便甩锅)。


对于实现方案,无论使用第三方库还是自己实现,重要的都是理解实现原理。


对于错误监控,可以了解一下 Sentry,原理简单来说就是通过 window.onerrorwindow.addEventListener('unhandledrejection', ...) 去分别捕获同步和异步错误,然后通过错误信息和 sourceMap 来定位到源码。


对于性能监控,我们可以通过 window.performancePerformanceObserver 等 API 收集页面性能相关的指标,除此之外,还需要关注接口的响应时间。


最后,收集到信息之后,还要考虑数据上报的方案,比如使用 navigator.sendBeacon 还是 Fetch、AJAX?是批量上报,实时上报,还是延迟上报?上报的数据格式等等。


CI/CD


持续集成(Continuous Integration, CI)和 持续部署(Continuous Deployment, CD),主要包括版本控制,代码合并,构建,单测,部署等一系列前端工作流。


场景的工作流有 Jenkins、 Gitlab CI 等。我们可以配置在合并代码时自动打包部署,在提交代码时自动构建并发布包等。


这块我了解不多,但感觉这些工具层面的东西,不太会涉及到原理,基本上就是使用的问题。还是需要自己亲自动手试一下,才能知道细节。比如在 Gitlab CI 中, Pipeline 、 Stage 和 Job 分别是什么,怎么配置,如何在不同环境配置不同工作流等。


了解技术动态


这个可能还是比较依赖信息收集能力,虽然我个人觉得很烦,但好像很多领导级别的面试很愿意问。


比如近几年很火的低代码,很多面试官都会问,你用过就问你细节,你没用过也会问你有什么设计思路。


还有最近的两年爆火的 AI,又或者 Vue React的最新功能,WebAssembly,还有一些新的打包工具 Vite Bun 什么的,还有鸿蒙开发……


虽然不可能学完每一项新技术,但是可以多去了解下。


总结


写了这么多,可能有人会问,如果能回到过去,你会怎么做。


啊,我只能说,说是一回事,做又是另一回事,事实上我并不希望回到过去去卷一遍,菜点没关系,快乐就好,一切都是最好的安排。


image.png

作者:我不吃饼干
来源:juejin.cn/post/7360528073631318027
收起阅读 »

三方接口不动声色将http改为了https,于是开启了我痛苦的一天

早上刚来,就看到仓库那边不停发消息说,我们的某个功能用不了了。赶紧放下早餐加紧看。 原来是调的一个三方接口报错了: javax.net.ssl.SSLHandshakeException: sun.security.validator.ValidatorExc...
继续阅读 »

早上刚来,就看到仓库那边不停发消息说,我们的某个功能用不了了。赶紧放下早餐加紧看。


原来是调的一个三方接口报错了:


javax.net.ssl.SSLHandshakeException: sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
at sun.security.ssl.Alert.createSSLException(Alert.java:131)
at sun.security.ssl.TransportContext.fatal(TransportContext.java:353)
at sun.security.ssl.TransportContext.fatal(TransportContext.java:296)
at sun.security.ssl.TransportContext.fatal(TransportContext.java:291)
at sun.security.ssl.CertificateMessage$T12CertificateConsumer.checkServerCerts(CertificateMessage.java:652)
at sun.security.ssl.CertificateMessage$T12CertificateConsumer.onCertificate(CertificateMessage.java:471)
at sun.security.ssl.CertificateMessage$T12CertificateConsumer.consume(CertificateMessage.java:367)
at sun.security.ssl.SSLHandshake.consume(SSLHandshake.java:376)
at sun.security.ssl.HandshakeContext.dispatch(HandshakeContext.java:444)
at sun.security.ssl.HandshakeContext.dispatch(HandshakeContext.java:422)
at sun.security.ssl.TransportContext.dispatch(TransportContext.java:183)
at sun.security.ssl.SSLTransport.decode(SSLTransport.java:154)
at sun.security.ssl.SSLSocketImpl.decode(SSLSocketImpl.java:1279)
at sun.security.ssl.SSLSocketImpl.readHandshakeRecord(SSLSocketImpl.java:1188)
at sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:401)
at sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:373)

查看原因:



由于JVM默认信任证书不包含该目标网站的SSL证书,导致无法建立有效的信任链接。



奥...原来是他们把接口从http改为了https,导致我们获取数据报错了。再看看他们的证书,奥...新的。


image.png


好了,看看我们的逻辑,这其实是一个获取对方生成的PDF文件的接口


PdfReader pdfReader = new PdfReader(url);

url就是他们给的链接,是这行代码报的错。这时候,开始研究,在网上扒拉,找到了初版方案


尝试1


写一个程序专门获取安全证书,这代码有点长,全贴出来影响阅读。我给扔我hithub上了github.com/lukezhao6/I… 将这个文件贴到本地,执行javac InstallCert.java将其进行编译


image.png


编译完长这样:


image.png
然后再执行java InstallCert http://www.baidu.com (这里我们用百度举例子,实际填写的就是你想要获取证书的目标网站)


image.png
报错不用怕,因为它会去检查目标服务器的证书,如果出现了SSLException,表示证书可能存在问题,这时候会把异常信息打印出来。


在生成的时候需要输入一个1


image.png
这样,我们需要的证书文件就生成好了


image.png


这时候,将它放入我们本地的 jdk的lib\security文件夹内就行了


image.png


重启,这时候访问是没有问题了。阶段性胜利。


但是,但是。一顿操作下来,对于测试环境的docker,还有生产环境貌似不能这么操作。 放这个证书文件比较费事。


那就只能另辟蹊径了。


尝试2


搜到了,还有两种方案。



1.通过System.setProperty("javax.net.ssl.trustStore", "你的jssecacerts证书路径");


2.程序启动命令-Djavax.net.ssl.trustStore=你的jssecacerts证书路径 -Djavax.net.ssl.trustStorePassword=changeit



我尝试了第一种,System.setProperty可以成功,但是读不到文件,权限什么的都是ok的。
检查了蛮多地方



  • 路径格式问题

  • 文件是否存在

  • 文件权限

  • 信任库密码

  • 系统属性优先级


貌似都是没问题的,但肯定又是有问题的,因为没起作用。但是想着这样的接口有4个,万一哪天其他三个也改了,我又得来一遍。所以就算研究出来了,还是不能稳坐钓鱼台。有没有一了百了的方法嘞。


尝试3


还真找到了:这个错是因为对方网站的证书不被java信任么,那咱不校验了,直接全部信任。这样就算其他接口改了,咱也不愁。而且这个就是获取pdf,貌似安全性没那么重。那就开搞。


代码贴在了下方,上边的大概都能看懂吧,下方的我加了注释。


URL console = new URL(url);
HttpURLConnection conn = (HttpURLConnection) console.openConnection();
if (conn instanceof HttpsURLConnection) {
SSLContext sc = SSLContext.getInstance("SSL");
sc.init(null, new TrustManager[]{new TrustAnyTrustManager()}, new java.security.SecureRandom());
((HttpsURLConnection) conn).setSSLSocketFactory(sc.getSocketFactory());
((HttpsURLConnection) conn).setHostnameVerifier(new TrustAnyHostnameVerifier());
}
conn.connect();
InputStream inputStream = conn.getInputStream();
PdfReader pdfReader = new PdfReader(inputStream);
inputStream.close();
conn.disconnect();

private static class TrustAnyTrustManager implements X509TrustManager {
//这个方法用于验证客户端的证书。在这里,方法体为空,表示不对客户端提供的证书进行任何验证。
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
}
//这个方法用于验证服务器的证书。同样,方法体为空,表示不对服务器提供的证书进行任何验证。
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
}
//这个方法返回一个信任的证书数组。在这里,返回空数组,表示不信任任何证书,也就是对所有证书都不做任何信任验证。
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[]{};
}
}
//这个方法用于验证主机名是否可信。在这里,无论传入的主机名是什么,方法始终返回 true,表示信任任何主机名。这就意味着对于 SSL 连接,不会对主机名进行真实的验证,而是始终接受所有主机名。
private static class TrustAnyHostnameVerifier implements HostnameVerifier {
public boolean verify(String hostname, SSLSession session) {
return true;
}
}

解决了解决了,这样改算是个比较不错的方案了吧。


作者:奔跑的毛球
来源:juejin.cn/post/7362587412066893834
收起阅读 »

开发需求记录:实现app任意界面弹框与app置于后台时通知

前言 在产品经理提需求时候提到,app在接收到报警信息时候能不能弹出一个弹框,告诉用户报警信息,这个弹框要在app的任意界面能够弹出,并且用户点击详情时候,会跳转到报警详情界面,查看具体信息,当用户将app至于后台的时候,接收到报警信息,app发送通知,当用户...
继续阅读 »

前言


在产品经理提需求时候提到,app在接收到报警信息时候能不能弹出一个弹框,告诉用户报警信息,这个弹框要在app的任意界面能够弹出,并且用户点击详情时候,会跳转到报警详情界面,查看具体信息,当用户将app至于后台的时候,接收到报警信息,app发送通知,当用户点击通知时候,跳转到报警详情界面。
功能大体总结如上,在实现弹框与通知在跳转界面时遇到一些问题,在此记录一下。效果图如下:


开发需求 - 通知与弹框.gif


功能分析


弹框实现,使用DialogFragment。

前后台判断则是,创建一个继承自ActivityLifecycleCallbacks接口和Application的类,继承ActivityLifecycleCallbacks接口是为了前后台判断,继承Application则是方便在基类BaseActivity获取前后台相关数据。

项目原本采用单Activity多Fragment实现,后面因为添加了视频相关功能,改为了多Activity多Fragment。

原单Activity时候,实现比较容易。后面修改为多Activity,就有些头疼,最终用思路是创建基类BaseActivity,后面添加Activity时都要继承基类BaseActivity。使用基类原因是把相同的功能抽取出来,且若每个Activity都自己实现弹框和通知的话太容易出错,也太容易漏下代码了。


代码实现


弹框


在实现继承自DialogFragment的弹框时,需要在onCreateDialog方法内设置dialog的宽高模式以及背景,不然弹框会有默认的边距,导致显示效果与预期不符,未去边距与去掉边距的弹框效果如下:
image.png
关于onCreateDialog的代码如下:


override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = Dialog(requireContext())
dialog.setContentView(R.layout.custom_dialog_layout)
dialog.window?.apply {
setLayout(ViewGr0up.LayoutParams.MATCH_PARENT,ViewGr0up.LayoutParams.MATCH_PARENT)
setBackgroundDrawable(ColorDrawable(Color.parseColor("#88000000")))//去掉DialogFragment的边距
}
dialog.setCancelable(false)
return dialog
}

此外当弹框出现的时候,弹框背景色还会闪烁。这里采用属性值动画设置弹框背景色控件的透明度变换。完整的Dialog代码如下:


class AlarmDialogFragment: DialogFragment() {
private lateinit var binding:CustomDialogLayoutBinding
private var animator:ObjectAnimator? = null

override fun show(manager: FragmentManager, tag: String?) {
try {
super.show(manager, tag)
}catch (e:Exception){
e.printStackTrace()
}
}

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGr0up?,
savedInstanceState: Bundle?
)
: View? {
binding = CustomDialogLayoutBinding.inflate(inflater)
return binding.root
}

override fun onStart() {
super.onStart()
binding.viewAlarmDialogBg
startAnimation()
}

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = Dialog(requireContext())
dialog.setContentView(R.layout.custom_dialog_layout)
dialog.window?.apply {
setLayout(ViewGr0up.LayoutParams.MATCH_PARENT,ViewGr0up.LayoutParams.MATCH_PARENT)
setBackgroundDrawable(ColorDrawable(Color.parseColor("#88000000")))//去掉DialogFragment的边距
}
dialog.setCancelable(false)
return dialog
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initView()
}

override fun onDestroy() {
super.onDestroy()
if(animator?.isStarted == true){
animator?.end()
}
}

private fun initView() {
binding.btnCloseDialog.setOnClickListener {
dismiss()
}

binding.btnDialogNav.setOnClickListener {
if(context is MainActivity){
val bundle = Bundle()
bundle.putString("alarmId","1")
findNavController().navigate(R.id.alarmDetailFragment,bundle)
}else{
val intent = Intent(context,MainActivity::class.java)
intent.putExtra("task","toAlarmDetail")
startActivity(intent)
}
dismiss()
}
}

private fun startAnimation() {
animator = ObjectAnimator.ofFloat(binding.viewAlarmDialogBg, "alpha", 0f, 0.6f, 0f, 0.6f, 0f)
animator?.duration = 1200
animator?.interpolator = AccelerateInterpolator()
animator?.start()
}
}

需要注意地方是,由于弹框还负责跳转,而跳转有两种情况,一种是在ActivityA内,fragmentA与fragmentB间的跳转,这种情况使用findNavController().navigate()方法进行跳转,另一种是ActivityB到另一个ActivityA内的指定FragmentB界面。这种采用startActivity(intent)方式跳转,并且在ActivityA的onStart()的方法使用下面方法。


/** 跳转报警详情界面 */
private fun initToAlarmDetail() {
val task = intent.getStringExtra("task")
if (task == "toAlarmDetail"){
val bundle = Bundle()
bundle.putString("alarmId","1")
findNavController(R.id.fragment_main_table).navigate(R.id.alarmDetailFragment,bundle)
}
}

这样从ActivityB到另一个ActivityA时候,在onStart()方法内会触发上面的initToAlarmDetail()方法,获取跳转里面的信息,在决定具体跳转到哪个Fragment。这里解释的可能不太清楚,可以在Github下载源码看看可能更好理解些。


弹框对应的xml文件代码,可以在Github内查看,可以自己写一个,这个xml比较简单,只是xml代码比较占地方这里就不粘贴了。


前后台判断


关于前后台判断,需要创建一个继承ActivityLifecycleCallbacks和Application的类,这里命名为CustomApplication,在类里面实现ActivityLifecycleCallbacks接口相关方法,此外需要创建下面三个变量,分别表示activity数量,当前activity的名称,是否处于后台,代码如下:


private var activityCount = 0
private var nowActivityName:String? = null
private var isInBackground = true

之后需要在onActivityStarted,onActivityResumed,onActivityStopped方法内进行前后台相关处理,代码如下:


override fun onActivityStarted(activity: Activity) {
activityCount++
if (isInBackground){
isInBackground = false
}
nowActivityName = activity.javaClass.name
}

override fun onActivityStopped(activity: Activity) {
activityCount--
if (activityCount == 0 && !isInBackground){
isInBackground = true
}
}

上面代码可以看出,当触发onActivityStarted方法时候,activityCount数量加一,且app处于前台。之后记录当前activity名称,这里记录activity名称是后面有个功能是app置于后台时候弹出通知,而通知相关操作,为了每个activity都能实现就放在基类执行,而弹出通知并不需要每个继承基类的activity都执行,到时候需要根据根据nowActivityName判断哪个继承了基类的activity执行通知操作。


当触发onActivityStopped方法时候,activityCount数量减一,且当activityCount数量为零时,app置于后台。
CustomApplication完整代码如下:


class CustomApplication: Application(),Application.ActivityLifecycleCallbacks {
companion object{
const val TAG = "CustomApplication"
@SuppressLint("CustomContext")
lateinit var context: Context
}

private var activityCount = 0
private var nowActivityName:String? = null
private var isInBackground = true

fun getNowActivityName(): String? {
return nowActivityName
}

fun getIsInBackground():Boolean{
return isInBackground
}

override fun onCreate() {
super.onCreate()
context = applicationContext
registerActivityLifecycleCallbacks(this)
}

override fun onActivityCreated(activity: Activity, p1: Bundle?) {

}

override fun onActivityStarted(activity: Activity) {
activityCount++
if (isInBackground){
isInBackground = false
}
nowActivityName = activity.javaClass.name
}

override fun onActivityResumed(activity: Activity) {

}

override fun onActivityPaused(activity: Activity) {

}

override fun onActivityStopped(activity: Activity) {
activityCount--
if (activityCount == 0 && !isInBackground){
isInBackground = true
}
}

override fun onActivitySaveInstanceState(activity: Activity, p1: Bundle) {

}

override fun onActivityDestroyed(activity: Activity) {

}
}

弹框与通知弹出


开发中弹框与通知弹出的触发条件是,监听Websocket若有信息过来,app处于前台弹框,处于后台弹通知。这里使用Handler来模拟,弹框弹出比较简单,若有继承了DialogFragment的AlarmDialogFragment类。代码如下:


val dialog = AlarmDialogFragment()
dialog.show(supportFragmentManager,"tag")

通知弹出也不难,若只是弹出通知示例代码如下:


if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel("normal", "Normal", NotificationManager.IMPORTANCE_DEFAULT)
notificationManager?.createNotificationChannel(channel)
}
val notification = NotificationCompat.Builder(this.applicationContext, "normal")
.setContentTitle("标题")
.setContentText("通知次数:${++alarmCount}")
.setSmallIcon(R.drawable.ic_launcher_background)
.setTimeoutAfter(5000)
.setAutoCancel(true)
.build()
notificationManager?.notify(notificationId,notification)

弹框与通知的特殊要求是,能在界面任意地方弹出且跳转到指定界面。弹框跳转相关代码在上面'弹框'部分,下面来说下通知的跳转,点击通知跳转是通过创建PendingIntent后在设置进NotificationCompat的setContentIntent方法内,不过通知跳转与弹框跳转一样需要分两种情况考虑,第一种同一Activity内Fragment与Fragment跳转,这种情况下PendingIntent如下代码所示:


var pendingIntent:PendingIntent? = null
val bundle = Bundle()
bundle.putString("alarmId","1")
pendingIntent = NavDeepLinkBuilder(this)
.setGraph(R.navigation.main_navigation)
.setDestination(R.id.alarmDetailFragment)
.setArguments(bundle)
.createPendingIntent()

上面代码中使用NavDeepLinkBuilder创建了一个PendingIntent,并且使用setGraph()指向使用的导航图,setDestination()则指向目标Fragment。
另一种情况则是ActivityB到另一个ActivityA内的指定FragmentB界面,这种情况下PendingIntent设置代码如下:


val intent = Intent(this@BaseActivity,MainActivity::class.java)
intent.putExtra("task","toAlarmDetail")
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
pendingIntent = TaskStackBuilder.create(this@BaseActivity)
.addNextIntentWithParentStack(intent)
.getPendingIntent(0,PendingIntent.FLAG_UPDATE_CURRENT)

这种是创建一个跳转到MainActivity的Intent,并添加传递的参数task,接着设置Intent的启动方式,其中Intent.FLAG_ACTIVITY_NEW_TASK,表示启动Activity作为新任务启动,Intent.FLAG_ACTIVITY_CLEAR_TASK,表示清除任务栈中所有现有的Activity。之后调用TaskStackBuilder创建PendingIntent。
上面两种方式创建的PendingIntent可以通过NotificationCompat.setContentIntent(pendingIntent)添加进去,关于通知创建的代码如下:


/** 使用通知 - 通过pendingIntent实现跳转,缺点是任意界面进入报警详情界面,点击返回键只能返回MainFragment */
private fun useNotificationPI() {
var pendingIntent:PendingIntent? = null
if(javaClass.simpleName == "MainActivity"){//主界面
val bundle = Bundle()
bundle.putString("alarmId","1")
pendingIntent = NavDeepLinkBuilder(this)
.setGraph(R.navigation.main_navigation)
.setDestination(R.id.alarmDetailFragment)
.setArguments(bundle)
.createPendingIntent()
}else {//其他界面时候切换后台通知
val intent = Intent(this@BaseActivity,MainActivity::class.java)
intent.putExtra("task","toAlarmDetail")
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
pendingIntent = TaskStackBuilder.create(this@BaseActivity)
.addNextIntentWithParentStack(intent)
.getPendingIntent(0,PendingIntent.FLAG_UPDATE_CURRENT)
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel("normal", "Normal", NotificationManager.IMPORTANCE_DEFAULT)
notificationManager?.createNotificationChannel(channel)
}
val notification = NotificationCompat.Builder(this.applicationContext, "normal")
.setContentTitle("标题")
.setContentText("通知次数:${++alarmCount}")
.setSmallIcon(R.drawable.ic_launcher_background)
.setTimeoutAfter(5000)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()
notificationManager?.notify(notificationId,notification)
}

上面代码中if(javaClass.simpleName == "MainActivity"),及第四行代码,该代码用处是当app置于后台时候,pp界面是MainActivity时,pendingIntent使用NavDeepLinkBuilder生成,当是其他Activity时使用TaskStackBuilder生成。之所以这样是因为,在MainActivity的xml,使用了FragmentContainerView用于fragment间跳转,其他的Activity没有FragmentContainerView,因此在生成pendingIntent需要采用不同的方式生成。


这示例代码中,主要涉及到的Avtivity有MainActivity与VideoActivity,MainActivity使用FragmentContainerView,而VideoActivity没有。弹框与通知跳转的界面是AlarmDetailFragment,这个fragment在MainActivity通过Navigation实现导航。


因此在MainActivity界面进入后台时,pendingIntent使用NavDeepLinkBuilder生成,NavDeepLinkBuilder则可以使用导航图中fragment生成深度链接URI,这个URI则可以导航到指定的fragment(关于NavDeepLinkBuilder了解不深入,这里说的可能有错误地方,欢迎大佬指正)。


而VideoActivity界面进入后台时,就需要使用TaskStackBuilder生成一个启动MainActivity的Intent。而在MainActivity的onStart方法内有下面initToAlarmDetail方法,判断跳转时携带参数决定是否跳转到AlarmDetailFragment界面。


/** 跳转报警详情界面 */
private fun initToAlarmDetail() {
val task = intent.getStringExtra("task")
if (task == "toAlarmDetail"){
val bundle = Bundle()
bundle.putString("alarmId","1")
findNavController(R.id.fragment_main_table).navigate(R.id.alarmDetailFragment,bundle)
}
}

至此弹框与通知的功能基本实现,完整的BaseActivity代码如下:


open class BaseActivity: AppCompatActivity() {
companion object{
const val TAG = "BaseActivity"
}
private var alarmCount = 0
private val handler = Handler(Looper.myLooper()!!)
//为了关闭通知,manager放在外面
private val notificationId = 1
private var alarmDialogFragment: AlarmDialogFragment? = null
private var notificationManager:NotificationManager? = null
private var bgServiceIntent:Intent? = null//前台服务

private var nowClassName = ""

/** 弹框定时任务 */
private val dialogRunnable = object : Runnable {
override fun run() {
//在定时方法里面 javaClass.simpleName 不能获取当前所处Activity的名称
if (nowClassName == "VideoActivity"){ //视频界面不弹弹框
CustomLog.d(TAG,"不使用弹框 ${nowClassName}")
}else{
CustomLog.d(TAG,"使用弹框 ${nowClassName}")
useDialog()
handler.postDelayed(this, 10000)
}
}
}

/** 通知定时任务 */
private val notificationRunnable = object :Runnable{
override fun run() {
useNotificationPI()
handler.postDelayed(this,10000)
}
}

override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
initWindow()
return super.onCreateView(name, context, attrs)
}

override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {
super.onCreate(savedInstanceState, persistentState)
CustomLog.d(TAG,"onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) 当前类:${javaClass.simpleName}")
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
CustomLog.d(TAG,"onCreate(savedInstanceState: Bundle?) 当前类:${javaClass.simpleName}")
initData()
}

override fun onStart() {
super.onStart()
CustomLog.d(TAG,"onStart 当前类:${javaClass.simpleName}")
nowClassName = javaClass.simpleName
handler.postDelayed(dialogRunnable, 3000)
initService()
}

override fun onResume() {
super.onResume()
CustomLog.d(TAG,"onResume 当前类:${javaClass.simpleName}")
}

override fun onRestart() {
super.onRestart()
CustomLog.d(TAG,"onRestart 当前类:${javaClass.simpleName}")
}

override fun onPause() {
super.onPause()
CustomLog.d(TAG,"onPause 当前类:${javaClass.simpleName}")
}

override fun onStop() {
super.onStop()
CustomLog.d(TAG,"onStop 当前类:${javaClass.simpleName}")
val customApplication = applicationContext as CustomApplication
val nowActivityName = customApplication.getNowActivityName()
val activitySimpleName = nowActivityName?.substringAfterLast(".")
CustomLog.d(TAG,"activitySimpleName:$activitySimpleName")
val isInBackground = (this@BaseActivity.applicationContext as CustomApplication).getIsInBackground()
if (isInBackground && activitySimpleName.equals(javaClass.simpleName)){// 处于后台 且 切换至后台app的activity页面名称等于当前基类里面获取activity类名
handler.postDelayed(notificationRunnable,3000)
CustomLog.d(TAG,"使用通知 $nowClassName")
}else{
CustomLog.d(TAG,"关闭所有定时任务 $nowClassName")
closeAllTask()
}
}

override fun onDestroy() {
super.onDestroy()
CustomLog.d(TAG,"onDestroy 当前类:${javaClass.simpleName}")
closeAllTask()
this.stopService(bgServiceIntent)
}

/** 关闭所有定时任务 */
private fun closeAllTask() {
handler.removeCallbacks(dialogRunnable)
handler.removeCallbacks(notificationRunnable)
}

/** 初始化数据 - 关于弹框*/
private fun initData() {
notificationManager = notificationManager ?: this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
alarmDialogFragment = alarmDialogFragment ?: AlarmDialogFragment()
}

/** 使用通知 - 通过pendingIntent实现跳转,缺点是任意界面进入报警详情界面,点击返回键只能返回MainFragment */
private fun useNotificationPI() {
var pendingIntent:PendingIntent? = null
if(javaClass.simpleName == "MainActivity"){//主界面
CustomLog.d(TAG,">>>通知:MainActivity")
val bundle = Bundle()
bundle.putString("alarmId","1")
pendingIntent = NavDeepLinkBuilder(this)
.setGraph(R.navigation.main_navigation)
.setDestination(R.id.alarmDetailFragment)
.setArguments(bundle)
.createPendingIntent()
}else {//其他界面时候切换后台通知
CustomLog.d(TAG,">>>通知:else")
val intent = Intent(this@BaseActivity,MainActivity::class.java)
intent.putExtra("task","toAlarmDetail")
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
pendingIntent = TaskStackBuilder.create(this@BaseActivity)
.addNextIntentWithParentStack(intent)
.getPendingIntent(0,PendingIntent.FLAG_UPDATE_CURRENT)
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel("normal", "Normal", NotificationManager.IMPORTANCE_DEFAULT)
notificationManager?.createNotificationChannel(channel)
}
val notification = NotificationCompat.Builder(this.applicationContext, "normal")
.setContentTitle("标题")
.setContentText("通知次数:${++alarmCount}")
.setSmallIcon(R.drawable.ic_launcher_background)
.setTimeoutAfter(5000)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()
notificationManager?.notify(notificationId,notification)
}

/** 弹框使用 - 因为此处涉及到fragment等生命周期,进入其他activity内时候,在前的activity使用useDialog会因为生命周期问题闪退*/
private fun useDialog() {
//弹出多个同种弹框
// alarmDialogFragment = AlarmDialogFragment()
// alarmDialogFragment?.show(supportFragmentManager,"testDialog")

//不弹出多个同种弹框,一次只弹一个,若弹框存在不弹新框
if (alarmDialogFragment?.isVisible == false){//如果不加这一句,当弹框存在时候在调用alarmDialogFragment.show的时候会报错,因为alarmDialogFragment已经存在
alarmDialogFragment?.show(supportFragmentManager,"testDialog")
}else{
//更新弹框内信息
}
}

/** 关闭报警弹框 */
private fun closeAlarmDialog() {
if (alarmDialogFragment?.isVisible == true) {
alarmDialogFragment?.dismiss()//要关闭的弹框
}
}

//状态栏透明,且组件占据了状态栏
private fun initWindow() {
window.statusBarColor = Color.TRANSPARENT
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
}

/** 初始化服务 */
private fun initService() {
CustomLog.d(TAG,"开启前台服务")
bgServiceIntent = bgServiceIntent ?: Intent(this, BackgroundService::class.java)
this.startService(bgServiceIntent)
}
}

总结


只是弹出弹框和通知的话,实现很好实现,中间麻烦地方在于当app使用多个Activity,该怎么实现跳转到指定的界面。当然这里麻烦是,从ActivityB跳转到ActivityA的Fragment,如果是只有一个Activity应该会好办些。个人感觉fragment跳转应该有更好的方式实现希望能和大佬们交流下这种情况下,用什么技术实现。


PS:感觉原生Android在写界面和跳转方面写起来不太方便。不知道大家有便捷的方式吗。


代码地址


GitHub:github.com/SmallCrispy…


作者:卤肉拌面
来源:juejin.cn/post/7260808821659779129
收起阅读 »

无悬浮窗权限实现全局Dialog

有些场景下需要显示一些提示弹窗,但把握不好弹出时机容易先弹出弹窗然后界面马上被杀掉进而看不到提示内容,例如强制下线:客户端退回登录界面并弹出提示弹窗。 如果是直接拿的栈顶activity去弹出,没有将弹窗逻辑写到具体activity中,或不好确定activty...
继续阅读 »

有些场景下需要显示一些提示弹窗,但把握不好弹出时机容易先弹出弹窗然后界面马上被杀掉进而看不到提示内容,例如强制下线:客户端退回登录界面并弹出提示弹窗。


如果是直接拿的栈顶activity去弹出,没有将弹窗逻辑写到具体activity中,或不好确定activty的变化就容易出现这种现象。


由于applicationContext没有AppWindowToken,所以dialog无法使用applicationContext创建,要么就使用windowManager配合WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY使用创建全局悬浮窗。但是这种做法需要申请权限。那么,在没有悬浮权限情况下如何做到让dialog不受栈顶activity变化的影响?


我的想法是通过application.registerActivityLifecycleCallbacks在activity变化时,关闭原来的弹窗,并重新创建一个一样的dialog并显示。


效果演示:


1. 栈顶界面被杀


界面退出

2. 有新界面弹出


界面退出

以下是代码实现:


/**
* @Description 无需悬浮权限的全局弹窗,栈顶activity变化后通过反射重建,所以子类构造方法需无参
*/

open class BaseAppDialog<T : ViewModel>() : Dialog(topActivity!!.get()!!), ViewModelStoreOwner {

companion object {
private val TAG = BaseAppDialog::class.java.simpleName
private var topActivity: WeakReference<Activity>? = null
private val staticRestoreList = linkedMapOf<Class<*>, Boolean>() //第二个参数:是否临时关闭
private val staticViewModelStore: ViewModelStore = ViewModelStore()

@JvmStatic
fun init(application: Application) {
application.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
topActivity = WeakReference(activity)
}

override fun onActivityStarted(activity: Activity) {

}

override fun onActivityResumed(activity: Activity) {
topActivity = WeakReference(activity)
val tempList = arrayListOf<BaseAppDialog<*>>()
val iterator = staticRestoreList.iterator()
while (iterator.hasNext()) {
val next = iterator.next()
val topName = (topActivity?.get() ?: "")::class.java.name
if (next.value == true) { //避免onCreate创建的弹窗重复弹出
val newInstance = Class.forName(next.key.name).getConstructor().newInstance() as BaseAppDialog<*>
tempList.add(newInstance)
Log.e(TAG, "重新创建${next.key.name},于$topName")
iterator.remove()
}

}

tempList.forEach {
it.show()
}

if (staticRestoreList.size == 0) {
staticViewModelStore.clear()
}
}

override fun onActivityPaused(activity: Activity) {
}

override fun onActivityStopped(activity: Activity) {

}

override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
}

override fun onActivityDestroyed(activity: Activity) {
}
})
}
}


var vm: T? = null

init {
val genericClass = getGenericClass()
if (vm == null) {
(genericClass as? Class<T>)?.let {
vm = ViewModelProvider(this)[it]
}
}

topActivity?.get()?.let {
(it as LifecycleOwner).lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onPause(owner: LifecycleOwner) {
super.onPause(owner)
dismissSilent()
}
})
}
}


//用于栈顶变化时的关闭
private fun dismissSilent() {
super.dismiss()
staticRestoreList.replace(this::class.java, true)
}

override fun show() {
super.show()
staticRestoreList.put(this::class.java, false)
}

override fun dismiss() {
super.dismiss()
staticRestoreList.remove(this::class.java)
}


//获取泛型实际类型
private fun getGenericClass(): Class<*>? {
val superclass = javaClass.genericSuperclass
if (superclass is ParameterizedType) {
val actualTypeArguments: Array<Type>? = superclass.actualTypeArguments
if (!actualTypeArguments.isNullOrEmpty()) {
val type: Type = actualTypeArguments[0]
if (type is Class<*>) {
return type
}
}
}
return ViewModel::class.java
}


//自己管理viewModel以便恢复数据
override fun getViewModelStore(): ViewModelStore {
return staticViewModelStore
}
}

参数传递的话,直接通过修改dialog的viewmodel变量或调用其方法来实现。


class TipDialogVm : ViewModel() {
val content = MutableLiveData<String>("")
}


class TipDialog2 : BaseAppDialog<TipDialogVm>() {

var binding : DialogTip2Binding? = null

init {
binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.dialog_tip2, null, false)
binding?.lifecycleOwner = context as? LifecycleOwner
binding?.vm = vm
setContentView(binding!!.root)

}
}

弹出弹窗


TipDialog2().apply {
vm?.content?.value = "嗨嗨嗨"
}.show()

作者:Abin
来源:juejin.cn/post/7295576843653087266
收起阅读 »

JavaScript运算符及优先级全攻略,点击立刻升级你的编程水平!

在编程的世界里,运算符是构建逻辑、实现功能的重要工具。它能帮助我们完成各种复杂的计算和操作。今天,我们就来深入探索JavaScript中运算符的奥秘,掌握它们的种类和优先级,让你的代码更加高效、简洁!一、什么是运算符运算符,顾名思义,就是用于执行特定操作的符号...
继续阅读 »

在编程的世界里,运算符是构建逻辑、实现功能的重要工具。它能帮助我们完成各种复杂的计算和操作。

今天,我们就来深入探索JavaScript中运算符的奥秘,掌握它们的种类和优先级,让你的代码更加高效、简洁!

一、什么是运算符

运算符,顾名思义,就是用于执行特定操作的符号。

Description

在JavaScript中,运算符用于对一个或多个值进行操作,并返回一个新的值。它们是编程语言中的基础构件,帮助我们完成各种复杂的计算和逻辑判断。

运算符可以分为多种类型,如算术运算符、关系运算符、逻辑运算符等。通过使用不同的运算符,我们可以实现各种复杂的计算和逻辑判断,让程序更加灵活、强大。


二、运算符的分类

1、算术运算符

用于执行数学计算,如加法、减法、乘法、除法等。常见的算术运算符有:+、-、*、/、%、++、–等。

Description

+ 加法运算

  • 两个字符串进行加法运算,则作用是连接字符串,并返回;

  • 任何字符串 + “ ”空串做运算,都将转换为字符串,由浏览器自动完成,相当于调用了String ( )。

-减法运算 *乘法运算 /除法运算

  • 先转换为 Number 再进行正常的运算。

注意: 可以通过为一个值 -0 *1 /1 来将其转换为Number数据类型,原理和Number ( )函数一样。

%求余运算

对一个数进行求余运算

代码示例:

var num1 = 1;
var num2 = 2;
var res = num1-num2; //返回值为 -1
var res = num1*num2; //返回值为 2
var res = num1/num2; //返回值为 0.5——js中的除法为真除法
var res = num1%num2; //返回值为 1
console.log(res);


2、关系运算符

通过关系运算符可以比较两个值之间的大小关系,如果关系成立它会返回true,如果关系不成立则返回false。常见的比较运算符有:==、!=、>、<、>=、<=等。

> 大于号

  • 判断符号左侧的值是否大于右侧的值;

  • 如果关系成立,返回true,如果关系不成立则返回false。

>= 大于等于

  • 判断符号左侧的值是否大于或等于右侧的值。

< 小于号

  • 判断符号左侧的值是否小于右侧的值;

  • 如果关系成立,返回true,如果关系不成立则返回false。

<= 小于等于

  • 判断符号左侧的值是否小于或等于右侧的值。

非数值的情况

  • 对于非数值进行比较时,会将其转换为数字然后再比较。

  • 如果符号两侧的值都是字符串时,不会将其转换为数字进行比较,而会分别比较字符串中字符的Unicode编码。

== 相等运算符

  • 两者的值相等即可。

  • 比较两个值是否相等,相等返回 true,否则返回 flase。

  • 使用==来做相等运算

特殊:

console.log(null==0);  //返回 false
console.log(undefined == null); //返回true 因为 undefined衍生自null
console.log(NaN == NaN); //返回 false NaN不和任何值相等

isNan() 函数来判断一个值是否是NaN,是返回 true ,否则返回 false。

Description

=== 全等

  • 两者的值不仅要相等,而且数据类型也要相等。

  • 判断两个值是否全等, 全等返回 true 否则返回 false 。

!= 不相等运算符

  • 只考量两者的数据是否不等。

  • 比较两个值是否不相等,不相等返回 true,否则返回 flas。

  • 使用==来做相等运算。

!== 不全等运算符

  • 两者的值不仅要不等,而且数据类型也要不等,才会返回true,否则返回false;

  • 判断两个值是否不全等,不全等返回true,如果两个值的类型不同,不做类型转换直接返回true。

var num1 = 1;
var num2 = '2';
var res =(num1 !== num2); //返回值 true
console.log(res);


3、逻辑运算符

用于连接多个条件判断,如与、或、非等。常见的逻辑运算符有:&&、||、!等。

Description

&& 与

&&可以对符号两侧的值进行与运算并返回结果。

运算规则:

  • 两个值中只要有一个值为false就返回false,只有两个值都为true时,才会返回true;

  • JS中的“与”属于短路的与,如果第一个值为false,则不会看第二个值。

|| 或

  • ||可以对符号两侧的值进行或运算并返回结果

  • 两个值中只要有一个true,就返回true;

  • 如果两个值都为false,才返回false。

JS中的“或”属于短路的或,如果第一个值为true,则不会检查第二个值。

! 非

!可以用来对一个值进行非运算,所谓非运算就是值对一个布尔值进行取反操作,true变false,false变true。

  • 如果对一个值进行两次取反,它不会变化;

  • 如果对非布尔值进行元素,则会将其转换为布尔值,然后再取反;

  • 所以我们可以利用该特点,来将一个其他的数据类型转换为布尔值;

  • 可以为一个任意数据类型取两次反,来将其转换为布尔值;原理和Boolean()函数一样;

非布尔值的与 或 非

非布尔值的与 或 非( 会将其先转换为布尔值, 再进行运算 )

代码示例如下:

var b1 = true;
var b2 = false;
var res = b1 && b2; //返回值为 false
var res = b1 || b2; //返回值为true
console.log(res);


4、赋值运算符

用于给变量赋值,如等于、加等于、减等于等。常见的赋值运算符有:=、+=、-=等。

将右侧的值赋值给符号左侧的变量。

=   右赋给左
+= a+=5 等价于 a=a +5;
-= a-=5 等价于 a=a-5;
*= a*=5 等价于 a=a*5;
/= a/=5 等价于 a=a/5;
%= a%=5 等价于 a=%+5;


5、其他运算符

还有一些特殊的运算符,如类型转换运算符、位运算符等。这些运算符虽然不常用,但在特定场景下会发挥重要作用。

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


三、运算符的优先级

在JavaScript中,不同类型的运算符具有不同的优先级。优先级高的运算符会先于优先级低的运算符进行计算。了解运算符的优先级,有助于我们编写出正确、高效的代码。

以下是一些常见运算符的优先级(从高到低):

  • 括号:( )
  • 单目运算符:++、–、!、+、-、~、typeof等
  • 算术运算符:*、/、%、+、-等
  • 比较运算符:<、>、<=、>=、in、instanceof等
  • 相等运算符:==、!=、===、!==等
  • 逻辑运算符:&&、||等
  • 赋值运算符:=、+=、-=等

掌握了这些运算符及其优先级,我们就可以根据实际需求灵活运用,编写出更加高效、简洁的代码。

通过了解JavaScript中的运算符及其优先级,我们可以更好地编写和理解代码。掌握这些知识,你将能更加自如地操纵数据,实现你想要的功能。

收起阅读 »

深圳发布重大开源项目申报指南,助推OpenHarmony生态发展

OpenAtom OpenHarmony(简称“OpenHarmony”)是面向全场景、全连接的智能终端操作系统。自2020年开源以来,在共建单位的持续努力下,目前已成为发展速度最快的开源操作系统之一。深圳市作为中国软件名城,高度重视开源生态建设,积极把握开源...
继续阅读 »

OpenAtom OpenHarmony(简称“OpenHarmony”)是面向全场景、全连接的智能终端操作系统。自2020年开源以来,在共建单位的持续努力下,目前已成为发展速度最快的开源操作系统之一。深圳市作为中国软件名城,高度重视开源生态建设,积极把握开源软件产业发展的战略性机遇,从供给侧和需求侧发力,积极出台产业扶持政策,推动开源软件产业高质量发展。

描绘开源软件产业发展蓝图

2022年6月30日,深圳市工业和信息化局率先发布《深圳市关于加快培育鸿蒙欧拉生态的若干措施(征求意见稿)》,通过制定专项政策,培育产业主体、深化应用牵引等多项措施,推动开源生态发展与应用,助力数字经济产业创新,在开源软件产业发展道路上迈出坚实的步伐。

2022年10月25日,深圳市工业和信息化局出台《深圳市推动软件产业高质量发展的若干措施》,通过支持搭建公共技术服务平台,鼓励加快开源软件推广应用等举措,为开源软件产业的培育和发展提供指引。

明确开源软件产业发展路径

2023年1月20日,《深圳市工业和信息化局软件产业高质量发展扶持计划操作规程(征求意见稿)》的发布,细化供给侧和需求侧方案,设立开源贡献奖励机制、培育重大开源项目的商业发行版企业、鼓励智能终端产品的开发及打造应用示范项目,提高政策可实施性。

2023年7月28日,《深圳市推动开源鸿蒙欧拉产业创新发展行动计划(2023—2025年)》正式印发,明确提出培育企业、吸引人才和壮大产业组织等任务,实现技术前沿引领、产业集聚效应、应用场景多元化等目标,规划深圳开源软件产业发展路径。

2023年8月25日,《深圳市工业和信息化局软件产业高质量发展项目扶持计划操作规程》发布,通过资助和奖励机制,鼓励和支持软件企业、智能终端产品生产企业等各方参与开源操作系统的开发、应用和推广,细化开源软件产业发展扶持政策。

加快开源软件推广应用

2024年4月28日,《市工业和信息化局关于发布2024年软件产业高质量发展项目重大开源项目相关申请指南的通知》,聚焦重大开源项目商业发行版软件推广应用与芯片模组采购两个核心项目,明确专项资金项目专项审计通用原则和标准、企业申报端操作指引,组织开展2024年软件产业高质量发展项目重大开源项目相关申请指南的申报工作,为生态伙伴申报提供详细的指引。

深圳开源软件产业政策的发布,鼓励企业积极开发OpenHarmony商业发行版与设备,截止目前,吸引深圳OpenHarmony生态伙伴近百家,全面激发开源生态的创新活力。未来,期望更多城市出台OpenHarmony相关开源软件产业政策,推动开源软件产业迈向高质量发展阶段,为数字经济强国建设注入源源不断的动力。

关于OpenAtom OpenHarmony

OpenAtom OpenHarmony(简称“OpenHarmony”)是由开放原子开源基金会(OpenAtom Foundation)孵化及运营的开源项目,目标是面向全场景、全连接、全智能时代、基于开源的方式,搭建一个智能终端设备操作系统的框架和平台,促进万物互联产业的繁荣发展。OpenHarmony 开源三年多来,社区快速成长,版本已迭代到OpenHarmony 4.1 Release,有超过7500 名共建者、70家共建单位,贡献代码行数超过1亿行。截至2024年4月25日,OpenHarmony 开源社区已有超过250家伙伴,累计已有210个厂家的559款产品通过兼容性测评,其中软件发行版44款,商用设备303款,覆盖金融、超高清、教育、商显、工业、警务、城市、交通、医疗等领域。OpenHarmony社区已成为“下一代智能终端操作系统根社区”,携手共筑万物互联的底座,使能千行百业的数字化转型。

收起阅读 »

减肥 & 恋爱 - 2023年度总结

前言 大家好, 我是前夕. 2023已经过完了, 我也想简单聊聊这一年发生的事情. 今年发生的事情不多, 但是都足以改变我未来人生的走向. 五个月减肥32斤 今年最大最大超级无敌大的改变, 就是减肥了. 我是95年生人, 工作已经5年了. 这个年龄段相当多的人...
继续阅读 »

前言


大家好, 我是前夕. 2023已经过完了, 我也想简单聊聊这一年发生的事情. 今年发生的事情不多, 但是都足以改变我未来人生的走向.


五个月减肥32斤


今年最大最大超级无敌大的改变, 就是减肥了. 我是95年生人, 工作已经5年了. 这个年龄段相当多的人身体都已经出现了一些警告信号. 其实肥胖就是最早的不痛不痒的信号. 我的老粉也都知道我花了半年时间减掉30多斤的事儿. 作为年终总结很重要的一趴, 我还是要简单提一下. 其实之前我一度认为胖不胖的无所谓, 人生苦短, 怎么开心怎么来. 但是相信我, 瘦下来的快乐是你无法想象的.


这是今年3月的我. 身高170, 体重162. 你也可以称我为正方形战士.


这是今年3月的我. 身高170, 体重162. 你也可以称我为正方形战士.


这是今年3月的我. 身高170, 体重162. 你也可以称我为正方形战士.


image-20230924145422838


image-20240106150356245


这是今年9月的我. 身高170, 体重130. 你也可以称我为猛男.


这是今年9月的我. 身高170, 体重130. 你也可以称我为猛男.


这是今年9月的我. 身高170, 体重130. 你也可以称我为猛男.


image-20240106160113246


image-20240106150717340


肯定有朋友会问, 减肥成功的正脸照是不是美颜了? 答案是的, 但是, 第一张也美颜了, 且是同一部手机. 一荣俱荣一损俱损. 另外, 我必须得说明, 我很清楚自己的颜值缺陷, 是眉毛太淡了, 因为小时候眉毛受过伤, 所以特别淡. 于是做了纹眉. 别的没了. 主要差别其实还是减肥带来的, 只要你胖, 怎么样都不好看. 只有瘦下来, 你捯饬自己才有效果.


接下来说说身材. 健身的人都懂, 肌肉身材往往需要阴影的配合. 确实是这样的. 所以我也放出我正面直拍的照片.


image-20240106163727183


就半年时间我也练不到多猛. 现在这个肌肉量已经相当可以了, 毕竟我的起点是个肥宅. 回顾减肥的历程, 只有减过肥的人知道这有多难. 虽然我早就结束减肥了, 但是我仍然觉得五个月32斤是个很夸张的数字. 其实减肥带来的好处, 我真的是一时半会说不完, 只能说我在体重恢复正常时, 看着镜子里一身腱子肉的自己, 我好像宇智波斑解除了秽土转生一样, 只能用青春正当时来形容自己.


image-20230924150305870


相信很多同学看到这会打鸡血, 表示自己24年也要减肥! 首先我希望你不要向我的变化程度看齐. 因为我付出的代价非常大. 运动, 只是一环, 还有很多其他方面. 而我身边(包括朋友圈)真正减肥成功的, 不到半只手. 难度真的挺大的. 如果你坚持就想要减肥, 非常好. 那么我推荐你可以看看我的方法论: 五个月减肥32斤, 涅槃重生也不过如此


交往了00后女友


在去年十月底和现在的女朋友谈了. 这段故事我想简单说下, 有点魔幻. 其实我刚刚工作的时候就认识她了. 是网上认识的, 相谈甚欢, 甚至她明确表达过喜欢. 但是因为异地的问题(她在成都我在上海), 所以双方都pass掉了. 后来也就过年发下祝福啥的, 日常都不联系. 我也理解, 因为对于没有结果的喜欢, 没有人会一直坚持. 我也只是希望大家就做朋友就好. 直到去年我老是刷到一家外卖叫料可可炒饭. 而她的小名也叫可可. 我很多次想截图发她, 但是都没行动. 因为没什么意义, 我又没指望什么, 我连聊都懒得聊. 但是确实经常点外卖就能看到. 后来我就忍不住了就发她了.


image-20240106170401148


我没想到她怎么还找上话题了. 我只是单纯想和她分享一下而已. 但是她既然说了别的话题, 行吧, 那我就陪你聊下, 不然不回人家显得我很冷血. 结果越聊越high, 当晚就打视频, 她还是几年前的那个模样, 而此时, 我已经减肥成功, 她都不知道我胖过. 全程和我聊的也很开心, 一瞬间不知道到底是她和我聊的来, 还是我和她聊的来, 还是双方真的聊的来. 对线细节不说了, 简单几次出招后她就摊牌了.


她: "如果你在成都就好了, 我想和你谈恋爱".


当她说完这个话, 我就想挂电话了. 其一是我觉得她上头了. 其二是我真的很困, 当时已经凌晨4点了. 但是我却很难入眠. 那晚我一直在思考一个问题. 我喜欢她吗? 说实话, 都那么久没联系了, 你说喜欢不喜欢的, 只能说还行, 毕竟还没谈, 喜欢不是理性的产物, 至少我还是很愿意和她相处的, 且确实聊的非常开心. 不知道大家能不能get到遇到一个同频的人有多难得.


然后我就思考第二个问题, 我和她没在一起的原因是什么? 之前她一直想待在成都, 而我对成都其实也没什么感情. 之前一直想去杭州(大学在那边读的), 因为无法解决地域问题就没继续了. 但是工作久了, 发现在杭州的朋友慢慢的也都离开杭州了, 杭州对我来说, 也已经没有多大意义了. 我对于待在哪个城市, 不是很有所谓. 那这不正好吗? 去成都呀. 也就是说, 4年前的我和她, 地域问题导致我们并不合适. 但是现在, 双方都有能力选择自己想要的生活.


所以第二天晚上问她要不要视频. 我当时想好了, 她可能确实是聊上头了才说喜欢的. 所以如果她拒绝接听视频, 那我就当什么都没发生. 成年人嘛, 这都基操了. 但是她秒接视频. 于是我就说了下面这句话


"你有没有想法把我们的关系再推进一些?"


她明显愣了下, 我看出了她也是在考虑地域问题. 她肯定想不明白地域这个问题该咋解决.


我赶紧补充道: "我知道你想待在成都. 对于以后定居成都的事儿, 我也不是很介意. 但是, 这不是一个飞机票的事情. 咱们倒推下, 假如我们在成都生活, 前提是我们一起攒够钱. 而这个前提, 是我作为程序员, 只有在大城市才有比较好的就业机会. 再往前, 那你得先来到上海和我一起赚钱, 再往前, 我们得是情侣, 再往前, 需要判断我们是否真的合适在一起. 那么怎么判断我们是否合适在一起呢?"


经常谈恋爱的朋友们肯定知道, 判断一个人能不能和自己谈, 不可能需要个把月的时间. 基本上相处几天就能确定大概方向了, 再慢不过半个月, 如果有朋友觉得这个速度太快了, 那只能说明你的段位太低了.


"我们可以出来玩一下, 就知道是否适合谈恋爱了. 如果适合, 我们就试着在一起. 不适合, 就当一切都没发生. 就算在一起, 我们也一定是要双向奔赴的. 不是我努力就能有结果, 也不是你单方努力就能有成效. 双向奔赴一定是我们的唯一解. 我下周再买机票来见你, 我给你一周的时间考虑要不要见面"


不知道是不是她没听到最后一句话, 她马上打开了boss开始看上海的工作机会了. 后来我们的见面也如期而至, 当天晚上就在一起了.


目前谈了2个多月了, 她还没来上海, 因为她行业的问题(媒体), 她哪怕去北京都很容易找, 但是在上海反而很难找. 这个原因涉及到一些敏感信息, 我不方便在这里解释. 总之, 她一时半会确实很难过来, 只能说在尝试.


目前我们是一个月见一次, 每天晚上就是打视频+玩蛋仔派对. 因为异地, 每天只能在游戏里约会.


image-20240107164714324


image-20240107170248689


我能感受到她确实是在向我奔赴而来. 之前我们公司经历了几次裁员, 我和她商量, 要不要我现在去成都. 不想异地恋了. 她和我说


"你不要为了我来成都, 你优先考虑哪个地方对你的工作是最有利的, 不管你在哪里, 我都会想方设法靠近你" 当时我真挺感动的.


我的房租是半年付的, 一次性交了1w多, 我和她分享说1w多好贵啊. 我只是单纯地分享, 而她犹豫了下说要不然我们2个月见一次吧. 我还愣了下, 为啥突然这么说. 随后我就反应过来她是希望降低我的经济压力. 对此我肯定是不会同意的.


类似的案例有很多, 不一一撒狗粮了. 因为每个月只能面对面地抱在一起三天时间(周末+年假一天). 所以最后天其中一个人就要去机场返程了.


image-20240106182920302


每次我们送别彼此都会很难受. 上次我去成都返程, 我前脚刚走, 她就泪崩了. 其实她比我坚强. 如果是我送她离开上海, 一般是她还没走我就已经难受的不行了.


现在男女冲突挺严重的, 我时常在沸点看到jym不是女朋友想方设法让你送礼物, 就是对象只顾着自己不顾家, 甚至上次还有个离婚了等着要分老公年终奖的. 这样的男女矛盾屡见不鲜. 我也很庆幸遇到了一个双向奔赴的女孩, 我们都在尽自己所能给彼此最好的生活. 为了保护女友, 我还是不放人家照片了. 随便放一张意思下.



网上一直有个争议很大的话题, 就是选择一个你爱的人 还是 爱你的人. 我现在的答案是应该选择一个我爱的人, 因为想起她, 我会充满干劲. 而更幸运地是, 她同时也爱着我.


思想更加开阔


去年看了点书, 不多, 就几本, 不到一只手. 但是我受益良多. 很多人觉得幸福是客观存在的, 我有大鱼大肉吃就是幸福, 我可以不上班就是幸福. 但是这是真的吗? 其实不完全是. 幸福是主观的. 引用某著作的一段话



淘宝和拼多多上的基础款羽绒服也比清朝最好的棉衣要暖和轻便;慈禧兴师动众劳民伤财的在北京城的数个地点开凿了上万平米的冰窖,只是为了将冬天的冰存到夏天来祛暑。而现在每一个装有空调的家庭都能在这一点上比慈禧过的更舒服。甚至是经济条件不足以购买空调、支付电费的当代中国人,也可以去地铁站、图书馆、商场等公共场所享受这种超过老佛爷的体验。



但是我问大家一个问题, 慈禧的幸福感比你差吗? 如果幸福是绝对依赖客观世界, 那么随着科技的发展, 大家应该越来越幸福才对. 但是现实是这样的吗? 显然不是. 当然, 我们不能否认客观世界对幸福的影响. 比如疼痛, 肯定是不幸福的. 有钱的话可以体验更好的医疗, 让疼痛不那么多, 这也是幸福. 但是我想强调的是, 幸福并不100%取决于客观世界. 在这里我是希望大家不要忽略主观想法对幸福感的影响. 这也是为什么说心态很重要的一个原因. 还有很多想说的, 但是我写完又删了, 因为哲学的东西讨论起来, 确实非常依赖你的经历和你所处的精神层次, 甚至会引发大家的争吵, 想想算了, 反正我觉得很多事儿我想的比之前明白了, 也没有了精神内耗, 现在每天都挺开心的.


结语


我不喜欢规划, 所以也不会说什么24年要怎么怎么样, 如果有, 那也只是说说而已. 因为说是说, 做是做. 而生活, 总是见招拆招. 在这里, 我只想祝福大家天天开心!


作者:前夕
来源:juejin.cn/post/7320541744352854057
收起阅读 »

致青春 → 十年了,她依旧历历在目

开心的一刻 一老大爷坐火车,买的是慢车票,却上了快车 乘务员查到了,对他说:“老人家,你的票要补哦” 老人家听了眼一瞪说:“上面的洞洞是你们剪的,咋喊我补呢?” 乘务员傻眼了,解释到:“不是票坏了喊你补,你买的票是慢车票,这趟车是快车,你应该补快车票” 老人大...
继续阅读 »

开心的一刻


一老大爷坐火车,买的是慢车票,却上了快车

乘务员查到了,对他说:“老人家,你的票要补哦”

老人家听了眼一瞪说:“上面的洞洞是你们剪的,咋喊我补呢?”

乘务员傻眼了,解释到:“不是票坏了喊你补,你买的票是慢车票,这趟车是快车,你应该补快车票”

老人大悟,说道:“哦!是这样啊;那你喊司机开慢点吧,我又不赶时间”


骑猪.gif


十年的回忆


今天无意之间听到了Eason十年,突然意识到我已经大学毕业十年了

十年,经历了很多,也成长了很多

当初的青涩已不复存在,留下的只有无尽的沧桑

唯一不变的是,我依旧孑然一身


单身的牢笼.gif


这十年

有轻松快乐的游戏生活

也有战战兢兢的职场蹉跎

经历了说走就走的旅行

也经历了痛彻心扉的爱情

从当初的意气风发,到如今的随波逐流

终究活成了当初最讨厌的样子!


他好像一条狗呀.gif


要问这十年间印象最深的一次经历是什么

毫无疑问是十年前的川藏线之旅!


无意的决定


2013年6月的某一天,好哥们(阿超)突然发来QQ消息:我们去骑川藏线吧

我很淡定的回道:好啊


我们去骑行呀.gif


然后阿超又约上了另一个好哥们(阿方)

至此,三兄弟的川藏线协议就此达成

约定好.jpg


从左往右:阿超、楼猪、阿方


"充分"的准备


说到准备,我只能说我们是:无知者无畏


还有谁.gif


装备准备


阿超是我们三个中最早接触骑行的,大三的时候他就购买了他人生中的第一辆山地车


阿超的山地车.jpg


我记得当时的购入价是1500,他骑着他心爱的座驾,逛了邵阳不少的地方,其中也包括崀山

因为他接触的早,所以除了车之外的装备,都是阿超在淘宝上选购的

包括抓绒衣、防晒服、头巾、手套、冰丝袖、打气筒、补胎套件、驮包、手电筒、尾灯等等

说到抓绒衣,就不得不提一下,也不知道当时是不知道有冲锋衣了,还是预算不够,我们就买了普通的抓绒衣


抓绒衣.jpg


好几次差点成为冰雕,后面我再细说

然后就是我跟阿方的自行车,当时应该是考虑到预算的问题

我们在淘宝上买了2辆,一辆是“悍马”,一辆是“宝马”,还都是折叠车!


悍马与宝马.jpg


最前面的是我的“悍马”,中间的是阿超的美利达,最里面的是阿方的“宝马”

我和阿方的车单价是565,两辆一共1130

现在想想我俩的胆是真肥,这样的车是怎么敢上路的!!!


还有王法吗.gif


至于拍摄装备,当时没想那么多,就各自的手机:阿超的Nokia 5230、我的Nokia 830、阿方的OPPO(型号不记得了,是个翻盖)


攻略准备


攻略也是阿超全权负责,哪一天从哪出发,每一天要到达哪个目的地

考虑到安全、时间、预算等因素,我们一开始就计划住青年旅舍或者当地居民家,没打算户外扎帐篷


川藏线路线.png


川藏线分南线和北线

南线由四川成都雅安泸定康定东俄洛雅江理塘巴塘西藏芒康左贡邦达八宿波密林芝工布江达墨竹工卡达孜拉萨,属318国道

北线成都东俄洛南线重合,再由东俄洛南线分开北上,经八美(原乾宁县)—道孚炉霍甘孜德格西藏江达昌都类乌齐丁青巴青夏曲那曲当雄羊八井拉萨,属317国道

南线相较于北线,平均海拔更低,开发也更早,更容易骑行

所以我们选择了南线,也就是上图中标粗的主线


拉练准备


我们三都没有进行实战型的拉练,阿超相较于我俩,只是平路骑的比较多

长距离的上山、下山,我们都没有试过

在出发前的前一周,我们一起绕着大学骑了三圈

这就算完成了我们的拉练...


刺激的旅途


2013年07月16号,我们正式出发了

坐上了可爱的K487次列车,历经17时49分,于2013-07-17 13:37到达了成都东站


成都东站.jpg


然后我们骑车来到了成都师范学院,在附近找了一间民宿,调整了一晚


出师不利始康定


2013-07-18 06:00正式开始了我们的川藏线骑行之旅

似乎天空不作美,一出门就看见仙女的眼泪,密密麻麻的滴在地面,也滴在了我们的心里

纵使她万般挽留,我们依旧没有丝毫的动摇,毅然决然的出发了

可人算不如天算,成都到康定的这段318路线因暴雨已经封闭,不让通行


挨刀.gif


不知道要封闭多久,其他路线又没有详细的攻略,不敢贸然行动

所以我们选择了一种轻松的方式:坐汽车到康定


坐车.jpg


经过漫长的等待、颠簸,于2013-07-18 22:13,我们到达了康定


康定.jpg


下车后,急忙找了一间民宿,那时是真的困,我们很快就进入了梦乡

婴儿般的睡眠,很是怀恋


初尝失算新都桥


2013-07-19清晨,正式开始了我们的骑行之旅

天空些许阴沉,冷风中夹带着细雨,零零散散的行人,时不时的哈气、搓手

此时的我们异常兴奋,直奔着下一站(雅江)急速而去

骑行了17公里之后,我们来到了折多山脚,此时已是上午的09:45,距离折多山垭口还有35公里

心里想的是:哼,才区区35公里,那不是张飞吃豆芽,小菜一碟?

可骑着骑着,我们发现速度并没有比徒步的快,似乎还有被超的迹象!

13:00,此时距离垭口还有13公里,我们的锐气已荡然无存,高反已悄然而至

加上没有准备足够的干粮,我和阿超已明显感觉不适,眼前发黑,停下车,靠着路边的防护栏呕吐起来

阿方见状,在路边的摊贩买了两瓶红牛(一罐貌似是八块!),递给了我跟阿超

喝了红牛之后,我们在路边坐了将近一个小时,状态才基本恢复,顶着饥饿继续前进

17:47我们终于到达折多山垭口


折多山垭口.png


此时天空下起了雨,还伴随着一粒粒的冰雹,放眼望去,哪有栖息之所?

我们只能继续赶路,赶往45公里外的新都桥(至于既定的目的地:雅江,一点想法没有了)

一路下坡,一路狂飙冷冷的冰雨在脸上胡乱的拍,呼呼的狂风在耳边肆意的啸,很快全身湿透,体温急骤下降

全身开始哆嗦,嘴唇逐渐变紫,直至发黑,更让我绝望的是小腿开始抽筋,丝毫不敢用力

望着渐行渐远的两个小伙伴,我甚至连呼喊的力气都没有了,隐隐约约看见死神在逼近

擦干眼睛,定神一看,那不是死神,那是我的两个兄弟!

看着摇摇晃晃的我,他们拼尽最后的力气拦停了我的车,将我从车上艰难的扶下了车

我们用尽最后的力气把车推到了路边的休息区


新都桥休息区.jpg


望着被紧紧绑住的驮包,我们陷入了绝望,尝试了几次,弹力绳纹丝不动

也许是上天怜悯,一辆温馨的小轿车在我们旁边停了下来,从车里下来了一个帅气的大哥

在他的帮助下,我们终于换上了干衣服、干鞋子,而此刻雨也停了

时间已经来到了19:57,天空还剩最后的一丝余亮,距离新都桥还剩9公里,我们继续前行

因为太过饥饿,这9公里显得格外的遥远

当看到路边藏民家的灯的时候,我们决定停下了(此时距离新都桥还剩3公里)

热心的藏民同胞给我们安排了房间,还给我们准备了丰盛的晚餐


新都桥晚餐.jpg


吃饱之后,倦意席卷而来,很快我们就进入了梦乡

不幸的是,第二天阿超就感冒了,我们只能休整一天,顺便把湿衣物吹干(吹风机慢慢吹)


一秒入睡在雅江


在新都桥休整好后,我们继续出发,朝着雅江而去

翻越了4412米高的高尔寺山


高尔寺山.jpg


翻山随难,但不似折多山那般,也没了高反,一切顺利了很多

从山顶顺坡之下,犹如脱缰的野马,飞速疾驰

车轱辘似乎也放肆了起来,隐隐有要单飞的感觉,我时不时的紧一紧刹车

16:00,我们到达雅江,感觉还早,我们继续往前赶路

又骑了一个半小时,困意席卷而来,忌惮于折多山的余威,我们决定停下休息

阿超去点菜的间隙,我和阿方已经进入了梦乡


雅江梦乡.jpg


困,是真的困!

上完菜后,阿超细声的呼唤着我俩:醒醒,吃饭了!

三人狼吞虎咽,将菜一扫而光,所幸饭可以无限续

饿,也是真的饿!

吃饱喝足,进行洗漱整理,伴随着黑夜的降临,上床入梦


一分为二入理塘


早上六点我们就出发了,今天的目标是130公里外的理塘

距离不是很远,但有两座大山,不会那么容易的


叶问_没那么容易的.gif


经历了前几天的磨砺,我们已经基本适应,虽说速度依旧慢,但身体已没有不适

一路晴空万里,蓝天白云,漫游在山坡上,内心纯粹无比


雅江_理塘 晴空万里.jpg


顺着超扁的S型盘山公路在山坡上蠕动,内心毫无杂念,一心就想着上垭口

终于于15:00到达4659米高的剪子弯山垭口


剪子弯山.jpg


不敢做过多的逗留,休整片刻后我们继续往前赶路

下坡是所有骑友的最爱,其中也包括我

但依旧不敢完全松开刹车,任由我的“悍马”驰骋,左边时常经过的大卡车,右边深不见底的深渊,时刻告诫着我们不能掉以轻心

伴随着夜幕的降临,我们已经身心俱疲,但依旧没有找到可以落脚的地方

打开前后车灯,继续往前骑,伴随着一阵阵的狼嚎,终于在21:35找到了一处藏民的帐篷

此刻我们饥寒交迫,没有任何赶路的想法了

和藏胞谈妥后,悬着的心终于放下了,围着火炉坐下,感受着暖意的扑面而来

藏胞给我们热了酥油茶,一口下肚,暖流入胃,奶香上鼻

还给我们煮了牛肉面,粒粒牛肉,片片白菜,根根面条,在白汤的滋润下,鲜香无比

吃饱之后困意如期而至,宽大的帐篷下簇拥着好几张床,挑了心仪的一张后安然入睡


雅江_理塘 帐篷.jpg


第二天清晨,我们看到了高大威猛的藏獒、天真浪漫的小牦牛、任劳任怨的母牦牛,在晨光的照耀下,是那么的静谧与美好


雅江_理塘 牦牛.jpg


和藏胞进行了短暂的告别后,继续我们的旅途,朝着理塘而去

途中翻越了卡子拉山


卡子拉山.png


剪子弯山理塘的路程,整体海拔是下降的,整个路线也是下坡居多

卡子拉山只是其中少有的上坡的小插曲,上升高度很低,少了翻山的难度,也少了翻过的兴奋

翻过卡子拉山后,阿方的“宝马”开始兴奋起来了,后轮出现了很明显的左右摆动,像是在告诉我们:来吧来吧,一起摇摆!


一起摇摆.gif


我和阿超赶紧跟上去,打断了阿方的兴奋,三个人一起下车,推车前行

边走边拦截路过的四个轱辘 ,希望能拦停好心人,将阿方连人带车一起带去理塘

也许是上天刻意的考验,四个轱辘都是从擦身而过,除了带起一片尘烟,什么也没有留下

推了一段距离后,我们决定阿方去最近的158道班(类似一个小驿站)修车和休整,我和阿超先去理塘等阿方

这里说明下:不是我和阿超“抛弃”了阿方,是考虑到158道班很小,而一路上骑行的驴友很多,我们三个人都去的话,可能住不下,另外就是身上的现金已所剩无几,需要去理塘取钱了


158道班.png


阿超(“独揽财政大权”)将身上本不多的现金一大半给了阿方(计划是去理塘取现金的),然后将阿方驮包中的馕、需要清洗的衣服拿了过来

阿方推车朝着158道班而去,我和阿超则骑车奔着理塘而去

在日落前我和阿超赶到了理塘


眺望理塘.jpg


理塘东城门.jpg


找了一间旅馆,卸下行囊,把需要清洗的都清洗好之后,我和阿超开始了啃馕

第二天接着啃馕,等着阿方的到来

16:40,阿方来了,加入了啃馕队伍,晚上三个人一起啃馕,馕好像变香了!


日行百八飞巴塘


经过一晚的休整,三人状态都恢复的不错

理塘巴塘有将近180km ,早上六点我们踏上了前往巴塘的旅程

出了理塘西城门,就来到了毛垭大草原 ,群山环抱,郁郁葱葱,停车驻足,心旷神怡


毛垭大草原.jpg


路况非常好,视野很开阔,一眼过去,直达天际

花花草草的地下隐藏着很多大家都很熟悉的小可爱,没错,就是它:


土拨鼠.gif


大概下午四点,我们登上了海子山垭口,看到了柔美的姊妹湖,湖水碧蓝,恬静而温婉,堪称人间仙境


姊妹湖.png


停下车,快速的奔向姊妹湖,近距离的欣赏、感受着姊妹湖,内心逐渐平静,身心的疲惫也慢慢消散

纵有万般不舍,依旧要往前行

收拾心情,八十千米的下坡,我们来了!

一路下坡,穿过好几个隧道后,终于在晚上九点多来到了巴塘胖姐休闲庄

我们这一次终究还是来得太迟,错过了床才有的温馨舒适,酝酿好久终放下对床的相思,最后客厅过道成地铺地址(改编自歌曲太迟


出川入藏至芒康


即将入藏,无比期待,早早的就出发了

沿着巴河南下,很快来到了金沙江


金沙江.jpg


一似渭,一似泾,汇合似渭泾,实属难得的景观
来到金沙江大桥,望着西藏的界碑


西藏界碑.jpg


想着即将见到魂萦梦绕的她,激动万分

停车回首,感慨颇多


四川界碑.png


来不及好好告别,空留一段,记忆的线,系不下长长的哀恋 ,却魂绕梦牵,恍惚中又和你相见(摘自歌曲

经过长长的排队,检查了身-份-证,登记了基本信息,我们终于进藏了,梦里的她,我们来了!

经过漫长的缓上坡,于下午七点左右,我们来到了海通兵站,在海通兵站的斜对面找了一个落脚点:扎西德勒藏餐馆


扎西德勒藏餐馆.jpg


二楼一个大房间内,床挨着床,放置好行李后,发现二楼没有洗手间

来到一楼询问老板娘:你好,请问洗手间在哪?

老板娘:洗手间?

我:厕所在哪?

老板娘向屋后指了一下,然后画了一个圈,好像在说:屋外都行

疑惑的我们来到屋外,向河边走去,突然从河边的深草中站起一个女孩,时不时的整理身上的衣服

我们三个面面相觑,顿时悟了:诺大的露天洗手间,河边、山间都可以大小恭,于是我们在山边的灌木林中解决了排泄问题

晚上躺在床上快入梦乡时,陆陆续续来了很多藏胞走进了隔壁的房间,不一会就响起了嘹亮的歌声;原来隔壁是个KTV

伴随着他们“优美的歌声”,我们迟迟未能入睡,听又听不懂,说又不敢说,只能强迫着自己尽力去“欣赏”

好不容易入睡了,结果又赶上两大狗帮在街斗,狗吠声很响亮,听着有大几十只

也不知道斗了多久,它们终于散去,至于谁输谁赢,无从知道

那晚,我也不知道睡着了多长时间

要不得说,年轻是真好,第二天依旧六点出发,虽说不是十分兴奋,但没那么疲惫

不知不觉就来到了宗拉山垭口


宗拉山.jpg


没有了往日翻山的艰难,似乎也少了翻过之后的兴奋

继续前行,当来到拉乌山垭口的时候,突然乌云密布,豆大的冰雹顷刻间就落下


拉乌山.png


似乎冥冥中注定一般,正好路边停着一辆大货车,暂时寄居在它的庇护下

高原地区的雨雪,来的突然,去的也突然,不一会又晴空万里了

经过35km的长下坡,我们来到了如美镇,找了一间旅舍,停下了脚步,开始清洗衣物


如美镇.jpg


经过一晚的休整,状态恢复的很不错,但今明两天注定很艰难

险峻莫过觉巴,高寒当属东达 , 觉巴山是今天要征服的,而东达山是明天要翻越的
来到觉巴山脚,抬头望去,一排又一排的U型盘山公路,脑瓜子嗡嗡的


觉巴山脚.jpg


公路左边是万丈深渊,右边是怪石嶙峋的峭壁,着实险峻

一路盘山而上,一路心惊胆战,到达觉巴山垭口后


觉巴山.png


迎来了短暂的下坡,在登巴村进行了短暂的休息后,继续前行,赶往荣许兵站 
终于在晚上八点左右达到了荣许兵站 ,找了一家旅舍,吃饱喝足后开始入睡,明天又是一场鏖战


推上最高下左贡


清晨,天空灰蒙蒙,下着小雨,温度很低

没蹬几步,阿方停下了,他的“宝马”左边的脚踏板掉了,气人的是我们装备里面没有大扳手,没法拧紧,真的是:屋漏偏逢连夜雨,船迟又遇打头风

所以我们仨决定,一同推车翻东达

一路推行,我们超越了好几拨骑行的,他们都露出了怀疑的目光

大概下午两点,我们到达东达山垭口,鞋子和裤子已经湿透,但我们内心却火热无比


东达山.png


盘边帐篷是个补给点,但东西是有点贵,拿起的泡面又放下了

稍息片刻,我们骑上车下坡而去,此时寒意更甚于推车

我们在坡边找到了藏胞家,家里只有老奶奶和小孙子,正好有一堆炭火,我们换了袜子和鞋子,烤干了裤子

给他们留了一些糖果后,我们继续赶路,在下午五点左右到达左贡县城

先找地方修了阿方的脚踏,然后找到了邮政银行取了现金,还补充了干粮

吃吃喝喝洗洗后,很快就进入了梦乡


坑坑洼洼颠邦达


左贡邦达,绝大部分是砂石路、搓板路,很伤车,也很伤人

路面坑坑洼洼,四个轱辘经过,要么溅你一身泥,要么扬你一脸灰

所幸今天不用翻山,但骑行速度不比翻山快

在天黑之前,还是到达了邦达


邦达.png


镇上的旅舍基本都满了,我们最终选择了离镇不远的藏胞家

广场上很多藏獒,并非印象中的威猛霸气


邦达广场藏獒.jpg


是放养,还是流浪?不得而知


七十二拐拐八宿


清早出发,很快就开始了盘山,爬了两个多小时后,来到半山腰,回头一看,邦达近在咫尺


回望邦达.jpg


蓝天白云,群山环绕,河流穿过,还有大草原,辽阔壮美,一览无遗

继续盘山,大概十一点,我们到了业拉山垭口


业拉山.png


业拉山垭口有服务中心,自驾的、骑行的、徒步的、朝圣的汇聚于此,或休整、或补给、或拍照、或摄影,热闹非凡

在观景台看到了即将要奔赴的怒江72拐,壮观无比,令人瞠目结舌


怒江72拐.jpg


也得知了它亦称九十九道拐


九十九道拐.jpg


心中窃喜的是下72拐,而不是上

下山时,双手要紧紧放在刹车上,时不时捏一下刹车降一下速度,听说这里出过很多事故,所以我们格外谨慎

快乐与危险并存,天堂与地狱一线,痛并快乐着

从垭口到怒江边上,海拔降了近2000米,短短的几个小时,我们就经历了四季,山顶的冬冷,山腰的春(秋)暖(爽),山谷的夏热

停车稍息片刻,补上几口干粮,继续赶往八宿

今天比较顺畅,天气很好,人和车都很稳妥,早早的就到了八宿县

八宿挺大的,房屋挺多,此刻的气候也很暖和


天不遂愿待然乌


早早的出发,今天的挑战不小

不同于之前的长上坡,也不同于之前的U型盘山公路
70km的反复起伏,总体上坡直至安久拉山垭口,不一样的骑行感受,不一样的骑行困难,就像折多山那次一样,非常难受

但还是坚持了下来,在下午三点左右,我们登上了安久拉山垭口


安久拉山.jpg


休息与拍照自是不可少,天气也很给力,蓝天白云,疲惫感逐渐消散,补充些许口粮之后,继续上路

虽说整体是下山,但却是反复起伏着下坡,心中一万只草泥马奔腾而过

当穿过保护性长廊后


然乌 护廊.png


我们来到了然乌镇


然乌.jpg


找了一家离公路较远、离然乌湖较近的藏民家,有洗浴间,有卫生间,非常不错

一路骑来,都未曾停下好好欣赏周边的风景,和小伙伴商量明天在然乌休整一天,去看看来古冰川

第二天睡了个懒觉,起的比较晚,吃过早餐后,我们租了一辆车去看来古冰川

今天不赶路,就是撒欢!

可惜的是来的季节不对,冰川已离去大半,山顶的冰雪依旧清晰可见,山脚则只有零零星星


来古冰川1.jpg


来古冰川2.jpg


在这里,我们的车队人数达到了最大


来古冰川3.png


从左往右分别是:少帅楼猪阿方琦哥阿超咖啡师阿胜阿凯

一天下来就是看、躺、拍、嬉戏打闹,主打就是一个开心

我们回到镇上吃晚饭,畅聊着接下来的行程,要翻的山只剩两座:色季拉山米拉山,路况也相对会好很多,骑行会顺畅不少

第二天清早,天朗气清,空气清新,我们来到镇上吃早餐

也许是昨天的无限欢乐引起了老天的嫉妒,给我们开了一个巨大的玩笑:昨晚(2013-08-0211通麦大桥断了,整座大桥全部坍塌!

我们一开始都不信,阿超琦哥卸下装备,空骑去核实了,结果属实,但也带回来一丝希望:大桥旁边有一座老桥

老桥宽度不够,只能通行人和自行车,而且年久失修,直接封闭不让通行

我们只能等,期盼着早日把老桥维修好;既然走不了了,那就好好玩乐,当天我们逛了然乌湖


然乌湖.jpg


第二天,一拨人打了一天的升级(扑克的一种玩法),一拨人出去逛了周边

第三天早上,我们来到镇上,打听到老桥还是没有通行,但时间容不得我们继续等下去了(大家都是参加工作,或者是即将参加工作的人)

我们商量决定租车去昌都,然后通过北线拉萨(咖啡师时间比较充足,他决定留下来继续等)


奔昌都.jpg


我们正式开始了四轮之旅!


一路惊魂终拉萨


不得不说,坐车确实舒服不少,就是什么都看不到,丢失了这次旅途的初衷
然乌逆向而行,北上经八宿昌都市

相对而言,昌都要繁华不少,但我们来的比较晚,坐车从南线来昌都的骑友也很多,加上本身就走北线的人

旅店都已经住满,最后派出所收留了我们(不是我们犯了事,实在是没有睡觉的地方了!)
昌都拉萨,具体歇息了几站,不记得了,依稀记得睡过网吧,睡过旅馆等等

昌都之后,司机每天都是重度疲劳驾驶,行驶在悬崖边的公路上,我们提心吊胆,阿超阿胜轮流盯着司机

给司机按摩、喂红牛,一旦司机打盹就拍醒他,时不时放那些激情澎湃的歌曲给司机提神

而我们其他人,貌似没意识到问题的严重性,一个个睡的老香了!

北线的路况比南线要差很多,但风景同样很优美


北线1.jpg


北线2.png


北线3.jpg


2013-08-09早上到达了拉萨


兜兜转转游拉萨


刚进拉萨,一个帅哥开着小车来到了我们旁边,盯着我跟阿方的车,表现出了很浓厚的兴趣

问我们车卖不卖,他的两个小孩一直想要一辆

正好我跟阿方想把车出掉,就问到他能出多少钱

帅哥试了一下我的车,觉得还不错,就说500行不行

然后同伴们就一起吹嘘这两辆车有多好,最后550一辆成交

帅哥开开心心买下了车,放进了后备箱,我和阿方高高兴兴收下了钱,坐上同伴的车快速离去

找了一家岳阳老乡的宾馆,接下来的几天就以此处为大本营了

当天没有出去逛,而是清洗、整理衣物,晚上去网吧打了几把LOL、上传照片

2013-08-10我们开始了逛拉萨西藏博物馆清政府驻藏大臣衙门西藏大学布达拉宫广场大昭寺色拉寺


西藏博物馆.jpg


清政府驻藏大臣衙门.png


西藏大学.jpg


布达拉宫广场.jpg


2013-08-11凌晨2点,琦哥少帅阿胜阿凯排队买票,参观了布达拉宫

阿超阿方觉得太累,就在宾馆休息了

等他们从布达拉宫回来,我们一并去了八廓街,吃当地特色美食,买当地特色纪念品,逛逛买买,甚是悠闲

当我们返程时,发现停在派出所门口的山地车被偷了两辆,琦哥阿胜的车被偷了

小偷是懂车的,琦哥的车5000多,阿胜的车3000多,是我们队伍中最好的两辆车

要知道,这可是2013年!
去派出所请求帮助,说没看没开,也没看到是谁偷走的,无疾而终

很不愉快的回到宾馆,第二天决定去二手车市场碰碰运气

2013-08-12一早,我们一起去了二手车市场,一路看下来,并没有发现琦哥阿胜的车,最后的希望也落空了

下午,我们仨去踩点了拉萨火车站,方便明天的归程


拉萨火车站.png


三天归程脚浮肿


2013-08-13,我们仨和其他小伙伴正式道别,没有依依不舍,分别的很洒脱

阿超的车通过邮寄运回了湖南,上车前买了2斤脆皮蛋糕,一共30元,买了12桶泡面,这些是接下来三天两夜的食物

我们买的是Z266次列车,绿皮的硬座,始于拉萨,途经那曲格尔木德令哈西宁兰州咸阳西安郑州武昌,最后到达长沙

一共要花47小时39分钟,但因为晚点了一个多小时,我们一共坐了49个小时

青海湖非常漂亮,湖边很多飞禽走兽,碧蓝的天空映射在湖面,煞是美丽,此刻只想吟诗一首:落霞与孤鹜齐飞,秋水共长天一色


青海湖.jpg


拉萨(海拔3660多)到西安(海拔400多),海拔降的很快,车上很多人出现了严重的耳鸣,幸亏火车上有随行的医护人员提供帮助

我们仨也出现了轻微的耳鸣

西安进行了换车,阿超用仅剩的钱买了三个肉夹馍,肉没有,夹了不少盐,这不讲武德的商贩欺人太甚!


265.gif


终于在2013-08-15下午一点多,我们到达了长沙

当我准备起身时,发现起不来,低头一看,整个脚背全水肿了,转向他俩一看,一样的情况,估计是坐太久没动的原因导致

轻柔一下脚背,稍微运动一下脚,慢慢的可以起身动起来了

2013-07-162013-08-15,整整一个月(是巧合,还是计划好的?),我们的旅程正式结束!


相关补充


资金


我们这次出行,家里父母是都不知道的,我们也没向家里要钱

通过大学期间的捣鼓:家教售卖二手电脑其他兼职等,我们存下了这次旅行的费用

最初人均预算是4000多,最后超出了预算一丢丢,在可以接受的范围之内

其实是可以拉赞助的,途中我们就遇到了很多拉着捷安特美利达等等横幅的骑友,据说赞助费不菲


火车票


从拉萨到长沙的火车票,需要提前15天预订
骑行途中通过另外一个好朋友帮忙买的,买了之后退的话,怕再次买不到了

这也是我们不能在然乌一直等的原因


照片画质


相信很多小伙伴已经看出了上文中的很多照片,画质喜感

没办法,绝大部分照片使用直板手机拍的,那时候智能机还没普及,手机拍照功能很拉胯

少数几张高画质的是从其他同行小伙伴用单反拍的,大家将就着看吧


感悟


车很重要、车很重要、车很重要,但不是最重要的,最重要的还是发动机(人的意志)、发动机、发动机

祖国很大,广袤的大好河山足够我们欣赏一辈子,国外的月亮不比国内的圆

这趟旅程很是历练,如果时间充裕,强烈建议时常停车驻足,用心去感受这纯天然、无污染的自然景观

有空多出去走走,逛逛,看看,给嘈杂的内心寻找片刻安静的港湾,对调整个人心情甚有帮助

十年前的这次旅行,经历了很多挑战、困难,感受了很多惊喜、刺激,留下了太多不舍、遗憾

最近,再骑一次的声音一直萦绕耳畔,内心的冲动也是愈发强烈,但此时非彼时,有生之年能否再骑一次?


作者:青石路
来源:juejin.cn/post/7324011329882374171
收起阅读 »

Android Region碰撞检测问题优化

前言 众所周知,Region是android graphics一族中比较低调的工具类,主要原因还是在碰撞检测方面存在一些不足,甚至可以说成事不足败事有余,以至于难以用于2D游戏开发,这也是耽误你们成为2D游戏大师路上的一道坎 。既然Region这么失败的工具为...
继续阅读 »

前言


众所周知,Region是android graphics一族中比较低调的工具类,主要原因还是在碰撞检测方面存在一些不足,甚至可以说成事不足败事有余,以至于难以用于2D游戏开发,这也是耽误你们成为2D游戏大师路上的一道坎 。既然Region这么失败的工具为什么要介绍呢,一方面本篇通过路径检测的方式解决了成事不足败事有余的问题,另外一方面我们也要介绍他可用的部分,以及正确的用法。最后,本篇其实主要是通过PathMeasure和Region相互配合,优化了碰撞检测逻辑精确度问题。


预览效果


这是我们最终要达到的效果。


fire_74.gif


异常效果


我们需要重点处理两个问题



  • 没接触到就检测到碰撞

  • 接触已经很多距离了才检测到碰撞


Region 碰撞检测问题



  • Region类能成事的部份主要还是Op布尔操作和矩阵操作,但是这个似乎又和Path的作用重合,不知道是不是因为性能更高呢?本文没有去测试,有机会测试一下。另外一部分containXXX包含关系判断,containXXX能准确的判断点和矩形是不是被包含了,但是其他形状那就没办法了。

  • quickXXX 快速检测方法,返回值true-能确保物体没有碰撞,但false无法确保是不是已经碰撞了,换句话说true是100%没碰撞,但是false还需要你自己进一步确认,不过这点可以作为减少判断的优化方法,但不是判定方法。


学习Region & PathMeasure 的意义


对于一些粒子,我们不太关注大小,这个时候是可以利用中心点去检测的,那对于多边形或者半圆等形状,点是非常多的,显然得找一种更好的方法。实际上看似quickXXX其实用处不大,其实可以减少一部分检测逻辑,quickXXX虽然比不上contains的精确度,但是仍然能检测到没有碰撞,本篇需要了解它的用法,然后配合PathMeasure,实现精确检测。


非Path用法


对于非Path用法,Region还是相当简单的,直接使用set方法即可


mainRegion.set((int) -radius, (int) -radius, (int) radius, (int) radius);

Path方法


这个用法比较奇怪,需要2个参数,最后一个是Region类,弄不好就是鸡生蛋蛋生鸡一样令人迷惑,第二个可以看作被裁剪的区域,如下操作,求并集区域。不过话说回来,这个意义在哪里?


circlePath.reset();
circlePath.addCircle(x- width/2f,y - height/2f,10, Path.Direction.CCW);
circleRegion.setPath(circlePath,mainRegion);

小试一下


实现开头的图片效果,定义一些Path和形状。


定义一些变量


 private float x; //x事件坐标
private float y; //y事件坐标

//所以形状
Path[] objectPaths = new Path[5];
//形状区域检测
Region objectRegion = new Region();

//小圆球区域
Region circleRegion = new Region();
//小圆
Path circlePath = new Path();
//绘制区域
Region mainRegion = new Region();

构建物体


三角形、圆等物体


for (int i = 0; i < objectPaths.length; i++) {
Path path = objectPaths[i];
if (path == null) {
path = new Path();
objectPaths[i] = path;
} else {
path.reset();
}
}

Path path = objectPaths[0];
path.moveTo(radius / 2, -radius / 2);
path.lineTo(0, -radius);
path.lineTo(radius / 2, -radius);
path.close();

path = objectPaths[1];
path.moveTo(-radius / 2, radius / 2);
path.lineTo(-radius / 2 - 100, radius / 2);
path.arcTo(-radius / 2 - 100, radius / 2, -radius / 2, radius / 2 + 100, 0, 180, false);
path.lineTo(-radius / 2, radius / 2);
path.close();

path = objectPaths[2];
path.addCircle(-radius + 200f, -radius + 100f, 50f, Path.Direction.CCW);

path = objectPaths[3];
path.addRoundRect(-radius / 2, -radius / 2, -radius / 2 + 20, 0, 10, 10, Path.Direction.CCW);

path = objectPaths[4];
path.addRect(120, 120, 200, 200, Path.Direction.CCW);

区域检测


检测是否发生了碰撞,准确度不高,但还能凑合


circlePath.reset();
circlePath.addCircle(x- width/2f,y - height/2f,10, Path.Direction.CCW);
circleRegion.setPath(circlePath,mainRegion);

mCommonPaint.setColor(Color.CYAN);
for (int i = 0; i < objectPaths.length; i++) {
objectRegion.setPath(objectPaths[i],mainRegion);
if(!objectRegion.quickReject(circleRegion)){
Log.d("RegionView"," 可能发生了碰撞");
mCommonPaint.setColor(Color.YELLOW);
}else{
mCommonPaint.setColor(Color.CYAN);
}
canvas.drawPath(objectPaths[i], mCommonPaint);
}

到这里我们完成了简单的检测,但其实它的精确度很差,这个效果显然不是我们想要的,尤其没有实际接触的情况就染色了。这样会产生很多争议,比如游戏中刘备不可能超出攻击范围去打你一样。


fire_81.gif


精准区域检测优化


在我们做推箱子游戏和珠珠碰撞的时候,我们都是用圆心之间的距离去检测,显然这里是不行的,不光障碍物本身有形状且不规则,而且中心区域正中可能是空白区域,显然圆心之间的距离是不合适的。我们之前学过PathMeasure很多用法《心跳效果》,其中之一是使用粒子描线,下图是我们的效果,在这篇中我们利用PathMeasure对获取路径坐标,并对线周围布置粒子。


fire_49.gif


那么,使用PathMeasure方式获取线条边缘的点不就更准确了么 ?好的,我们开干。


优化逻辑



  • 获取障碍物和圆的Bounds,计算面积,这样把检测物体和被检测物体中最小的设置给PathMeasure

  • 利用PathMeasure的getPosTan获取点

  • 使用Region的contain进行判断点是不是在区域内


下面是优化逻辑


circlePath.reset();
circlePath.addCircle(x- width/2f,y - height/2f,20, Path.Direction.CCW);
circleRegion.setPath(circlePath,mainRegion);


mCommonPaint.setColor(Color.CYAN);
for (int i = 0; i < objectPaths.length; i++) {
objectRegion.setPath(objectPaths[i],mainRegion);
if(!objectRegion.quickReject(circleRegion)){
mCommonPaint.setColor(Color.YELLOW);
if (circleRegion.getBounds(circleRect)
&& objectRegion.getBounds(objectRect)) {

Region regionChecker = null;
if (circleRect.width() * circleRect.height() > objectRect.width() * objectRect.height()) {
pathMeasure.setPath(objectPaths[i], false);
regionChecker = circleRegion;
} else {
pathMeasure.setPath(circlePath, false);
regionChecker = objectRegion;
}

for (int len = 0; len < pathMeasure.getLength(); len++) {
pathMeasure.getPosTan(len, pos, tan);
if(regionChecker.contains((int) pos[0], (int) pos[1])){
Log.d("RegionView"," 可能发生了碰撞");
mCommonPaint.setColor(Color.YELLOW);
}
}

}

}else{
mCommonPaint.setColor(Color.CYAN);
}
canvas.drawPath(objectPaths[i], mCommonPaint);
}

我们再来看效果,就是文章开头的效果


fire_76.gif


总结


到这里结束了,对于Region类,对点的检测是非常精准的,但是在数学中,所有图形都是点构成线、线构成面,我们本篇利用PathMeasure和Region配合实现了精准检测逻辑,扫平了2D游戏开发过程中的一道门槛。希望看过本篇之后,你能成为游戏大师。


全部代码


有个小插曲,演示精确度低的时候导致代码被还原了,所以重新画了一些东西。


public class RegionView extends View {
private final DisplayMetrics mDM;
private TextPaint mCommonPaint;

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

public RegionView(Context context, AttributeSet attrs) {
super(context, attrs);
mDM = getResources().getDisplayMetrics();
initPaint();
setClickable(true); //触发hotspot
}

private void initPaint() {
//否则提供给外部纹理绘制
mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
mCommonPaint.setAntiAlias(true);
mCommonPaint.setStyle(Paint.Style.FILL);
mCommonPaint.setStrokeCap(Paint.Cap.ROUND);
mCommonPaint.setFilterBitmap(true);
mCommonPaint.setDither(true);
mCommonPaint.setStrokeWidth(dp2px(20));

}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

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

}

private float x;
private float y;

//所以形状
Path[] objectPaths = new Path[7];
//形状区域检测
Region objectRegion = new Region();

//小圆球区域
Region circleRegion = new Region();
//小圆
Path circlePath = new Path();
//绘制区域
Region mainRegion = new Region();

Rect circleRect = new Rect();
Rect objectRect = new Rect();

float[] pos = new float[2];
float[] tan = new float[2];

PathMeasure pathMeasure = new PathMeasure();

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width < 1 || height < 1) {
return;
}

int save = canvas.save();
canvas.translate(width / 2f, height / 2f);
float radius = Math.min(width / 2f, height / 2f);

mainRegion.set((int) -radius, (int) -radius, (int) radius, (int) radius);

for (int i = 0; i < objectPaths.length; i++) {
Path path = objectPaths[i];
if (path == null) {
path = new Path();
objectPaths[i] = path;
} else {
path.reset();
}
}

Path path = objectPaths[0];
path.moveTo(radius / 2, -radius / 2);
path.lineTo(0, -radius);
path.lineTo(radius / 2, -radius);
path.close();

path = objectPaths[1];
path.moveTo(-radius / 2, radius / 2);
path.lineTo(-radius / 2 - 100, radius / 2);
path.arcTo(-radius / 2 - 100, radius / 2, -radius / 2, radius / 2 + 100, 0, 180, false);
path.lineTo(-radius / 2, radius / 2);
path.close();

path = objectPaths[2];
path.addCircle(-radius + 200f, -radius + 200f, 50f, Path.Direction.CCW);

path = objectPaths[3];
path.addRoundRect(-radius + 50, -radius / 2, -radius + 90, 0, 10, 10, Path.Direction.CCW);

path = objectPaths[4];
path.addRect(120, 120, 200, 200, Path.Direction.CCW);

path = objectPaths[5];
path.addCircle(250, 0, 100, Path.Direction.CCW);

Path tmp = new Path();
tmp.addCircle(250,-80,80,Path.Direction.CCW);
path.op(tmp, Path.Op.DIFFERENCE);

tmp.reset();
path = objectPaths[6];
path.addCircle(0, 0, 100, Path.Direction.CCW);
tmp.addCircle(0, 0, 80, Path.Direction.CCW);
path.op(tmp, Path.Op.DIFFERENCE);


circlePath.reset();
circlePath.addCircle(x- width/2f,y - height/2f,20, Path.Direction.CCW);
circleRegion.setPath(circlePath,mainRegion);


mCommonPaint.setColor(Color.CYAN);
for (int i = 0; i < objectPaths.length; i++) {
objectRegion.setPath(objectPaths[i],mainRegion);
if(!objectRegion.quickReject(circleRegion)){
if (circleRegion.getBounds(circleRect)
&& objectRegion.getBounds(objectRect)) {

Region regionChecker = null;
if (circleRect.width() * circleRect.height() > objectRect.width() * objectRect.height()) {
pathMeasure.setPath(objectPaths[i], false);
regionChecker = circleRegion;
} else {
pathMeasure.setPath(circlePath, false);
regionChecker = objectRegion;
}

for (int len = 0; len < pathMeasure.getLength(); len++) {
pathMeasure.getPosTan(len, pos, tan);
if(regionChecker.contains((int) pos[0], (int) pos[1])){
Log.d("RegionView"," 可能发生了碰撞");
mCommonPaint.setColor(Color.YELLOW);
}
}

}

}else{
mCommonPaint.setColor(Color.CYAN);
}
canvas.drawPath(objectPaths[i], mCommonPaint);
}

mCommonPaint.setColor(Color.WHITE);
canvas.drawPath(circlePath,mCommonPaint);
canvas.restoreToCount(save);
}

@Override
public void dispatchDrawableHotspotChanged(float x, float y) {
super.dispatchDrawableHotspotChanged(x, y);
this.x = x;
this.y = y;
postInvalidate();
}

@Override
protected void dispatchSetPressed(boolean pressed) {
super.dispatchSetPressed(pressed);
postInvalidate();
}

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

public static int argb(float red, float green, float blue) {
return ((int) (1 * 255.0f + 0.5f) << 24) |
((int) (red * 255.0f + 0.5f) << 16) |
((int) (green * 255.0f + 0.5f) << 8) |
(int) (blue * 255.0f + 0.5f);
}


}

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

[自定义View]一个简单的渐变色ProgressBar

Android原生ProgressBar 原生ProgressBar样式比较固定,主要是圆形和线条;也可以通过style来设置样式。 style: style效果@android:style/Widget.ProgressBar.Horizontal水平进...
继续阅读 »

Android原生ProgressBar



原生ProgressBar样式比较固定,主要是圆形和线条;也可以通过style来设置样式。



style:


style效果
@android:style/Widget.ProgressBar.Horizontal水平进度条
@android:style/Widget.ProgressBar.Small小型圆形进度条
@android:style/Widget.ProgressBar.Large大型圆形进度条
@android:style/Widget.ProgressBar.Inverse反色进度条
@android:style/Widget.ProgressBar.Small.Inverse反色小型圆形进度条
@android:style/Widget.ProgressBar.Large.Inverse反色大型圆形进度条
@android:style/Widget.Material**MD风格

原生的特点就是单调,实现基本的功能,使用简单样式不复杂;要满足我们期望的效果就只能自定义View了。


自定义ProgressBar



自定义View的实现方式有很多种,继承已有的View,如ImageView,ProgressBar等等;也可以直接继承自View,在onDraw中绘制需要的效果。
要实现的效果是一个横向圆角矩形进度条,内容为渐变色。
所以在设计时要考虑到可以定义的属性:渐变色、进度等。



<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="progress">
<attr name="progress" format="float" />
<attr name="startColor" format="color" />
<attr name="endColor" format="color" />
</declare-styleable>
</resources>

View实现



这里直接继承子View,读取属性,在onDraw中绘制进度条。实现思路是通过定义Path来绘制裁切范围,确定绘制内容;再实现线性渐变LinearGradient来填充进度条。然后监听手势动作onTouchEvent,动态绘制长度。


同时开放公共方法,可以动态设置进度颜色,监听进度回调,根据需求实现即可。



package com.cs.app.view

/**
*
*/

import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Path
import android.graphics.RectF
import android.graphics.Shader
import android.util.AttributeSet
import android.view.View
import com.cs.app.R

class CustomProgressView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
private val progressPaint = Paint()
private val backgroundPaint = Paint()
private var progress = 50f
private var startColor = Color.parseColor("#4C87B7")
private var endColor = Color.parseColor("#A3D5FE")
private var x = 0f
private var progressCallback: ProgressChange? = null

init {
// 初始化进度条画笔
progressPaint.isAntiAlias = true
progressPaint.style = Paint.Style.FILL

// 初始化背景画笔
backgroundPaint.isAntiAlias = true
backgroundPaint.style = Paint.Style.FILL
backgroundPaint.color = Color.GRAY

if (attrs != null) {
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.progress)
startColor = typedArray.getColor(R.styleable.progress_startColor, startColor)
endColor = typedArray.getColor(R.styleable.progress_endColor, endColor)
progress = typedArray.getFloat(R.styleable.progress_progress, progress)
typedArray.recycle()
}
}

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)

val width = width.toFloat()
val height = height.toFloat()

//绘制Path,限定Canvas边框
val path = Path()
path.addRoundRect(0f, 0f, width, height, height / 2, height / 2, Path.Direction.CW)
canvas.clipPath(path)

//绘制进度条
val progressRect = RectF(0f, 0f, width * progress / 100f, height)
val colors = intArrayOf(startColor, endColor)
val shader = LinearGradient(0f, 0f, width * progress / 100f, height, colors, null, Shader.TileMode.CLAMP)
progressPaint.shader = shader
canvas.drawRect(progressRect, progressPaint)
}

override fun onTouchEvent(event: android.view.MotionEvent): Boolean {
when (event.action) {
android.view.MotionEvent.ACTION_DOWN -> {
x = event.rawX

//实现点击调整进度
progress = (event.rawX - left) / width * 100
progressCallback?.onProgressChange(progress)
invalidate()
}

android.view.MotionEvent.ACTION_MOVE -> {
//实现滑动调整进度
progress = (event.rawX - left) / width * 100
progress = if (progress < 0) 0f else if (progress > 100) 100f else progress
progressCallback?.onProgressChange(progress)
invalidate()
}

else -> {}
}
return true
}

fun setProgress(progress: Float) {
this.progress = progress
invalidate()
}

fun setOnProgressChangeListener(callback: ProgressChange) {
progressCallback = callback
}

interface ProgressChange {
fun onProgressChange(progress: Float)
}
}

示例


class CustomViewActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_custom_view)
val progressTv: TextView = findViewById(R.id.progress_textview)
val view: CustomProgressView = findViewById(R.id.progress)
view.setProgress(50f)

view.setOnProgressChangeListener(object : CustomProgressView.ProgressChange {
override fun onProgressChange(progress: Float) {
progressTv.text = "${progress.toInt()}%"
}
})
}
}

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:background="#0E1D3C"
android:layout_height="match_parent">


<com.cs.app.view.CustomProgressView
android:id="@+id/progress"
android:layout_width="200dp"
android:layout_height="45dp"
app:endColor="#A3D5FE"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.274"
app:progress="60"
app:startColor="#4C87B7" />


<TextView
android:id="@+id/progress_textview"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="100dp"
android:textColor="#ffffff"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/progress" />

</androidx.constraintlayout.widget.ConstraintLayout>

效果如下:


HnVideoEditor_2023_11_23_144034156.gif

作者:LANFLADIMIR
来源:juejin.cn/post/7304531564342837287
收起阅读 »

揭秘JavaScript数据世界:一文通晓基本类型和引用类型的精髓!

在编程的世界里,数据是构建一切的基础。就像建筑师需要了解不同材料的强度和特性一样,程序员也必须熟悉各种数据类型。今天,我们就来深入探讨JavaScript中的数据类型,看看它们如何塑造我们的代码世界。一、JavaScript数据类型简介数据类型是计算机语言的基...
继续阅读 »

在编程的世界里,数据是构建一切的基础。就像建筑师需要了解不同材料的强度和特性一样,程序员也必须熟悉各种数据类型。

今天,我们就来深入探讨JavaScript中的数据类型,看看它们如何塑造我们的代码世界。

一、JavaScript数据类型简介

数据类型是计算机语言的基础知识,数据类型广泛用于变量、函数参数、表达式、函数返回值等场合。JavaScript语言的每一个值,都属于某一种数据类型。

Description

JavaScript的数据类型主要分为两大类:基本数据类型引用数据类型。下面就来详细介绍这两类数据类型中都包含哪些及如何使用它们。

二、基本(值类型)数据类型

首先,让我们从最基本的数据类型开始。JavaScript的基本数据类型包括:字符串(String)、数字(Number)、布尔(Boolean)、空(Null)、未定义(Undefined)、符号(Symbol)。

1、字符串(String)

tring类型用于表示由零或多个16位的Unicode字符组成的字符序列,即字符串。至于用单引号,还是双引号,在js中还是没有差别的。记得成对出现。

let name1 = '张三'
let name2 = "李四"
let name3 = `王五`

1.转换为字符串有2个方法:toString()、String()
let n = 100
n.toString() // '100' 数值类型转换为字符串类型
String(200) // '200' 数值类型转换为字符串类型

2.模板字符串相当于加强版的字符串,可以定义多行字符串。还可以利用${}在字符串中插入变量和表达式
let name = '张三丰'
let age = 180
`我叫${name},今年${age}岁啦!` // 我叫张三丰,今年180岁啦!

2、数字(Number)

该类型的表示方法有两种形式,第一种是整数,第二种为浮点数。整数:可以通过十进制,八进制,十六进制的字面值来表示。

浮点数:就是该数值中必须包含一个小数点,且小数点后必须有一位数字。

let num = 100  // 整数
let floatNum = 3.14 // 浮点数
// toFixed() 方法可以对计算结果进行四舍五入
let pi = Math.PI // 3.141592653589793
pi.toFixed(2) // 3.14 保留2位小数

// 八进制的值第一位必须是零0,后面每一位数的范围在0~7。如果某一位数超出范围,首位的0会被忽略,后面的数值会按照十进制来解析
let octalNum1 = 076 // 八进制的 63
let octalNum2 = 083 // 八进制 83
let octalNum3 = 06 // 八进制 6

// 十六进制的值前两位必须是0x,后面每一位十六进制数字的范围在0~9及A~F,字母A~F可以大写也可以小写。
let hexNum1 = 0xA // 十六进制 10
let hexNum2 = 0x3f // 十六进制 63

// 数值转换的三个方法 Number()、parseInt()、parseFloat()

1.Number() // 可以将字符串、布尔值、null、undefined 等转换为对应的数值,如果无法转换返回NaN
Number("123") // 输出123
Number("hello") // 输出NaN


2.parseInt() // 可以将字符串转换为整数,如果无法转换返回NaN
parseInt("123") // 输出123
parseInt("123.45") // 输出:123
parseInt("hello") // 输出NaN


3.parseFloat() // 可以将字符串转换为浮点数,如果无法转换返回NaN
parseFloat("123.45") // 输出123.45
parseFloat("hello") // 输出NaN

3、布尔(Boolean)

Boolean 数据类型只有两个值:true 和 false,分别代表真和假。很多时候我们需要将各种表达式和变量转换成 Boolean 数据类型来当作判断条件。


1.数值运算判断

1 + 2 === 3 // true
1 + 1 > 3 // false


2.数值类型转换
let bool1 = Boolean(0); // 数值转换为布尔值
let bool2 = Boolean(""); // 字符串转换为布尔值
let bool3 = Boolean(null); // null 转换为布尔值
let bool4 = Boolean(undefined); // undefined 转换为布尔值
let bool5 = Boolean(NaN); // NaN 转换为布尔值
let bool6 = Boolean([]); // 空数组转换为布尔值
let bool7 = Boolean({}); // 空对象转换为布尔值

ECMAScript 类型的值都有与布尔值等价的形式。可以调用 Boolean() 函数来将其他类型转换为布尔值。不同类型转换为布尔值的规则如下表

Description

4、未定义(Undefined)

在 JavaScript 中,undefined 是一个特殊的值和数据类型。当一个变量声明但未赋值时,该变量的值就是 undefined。它表示一个未定义或未初始化的值。

1.声明但未赋值的变量

// 当使用 var、let 或 const 声明一个变量但未对其赋值时,该变量的初始值为 undefined。
let n;
console.log(n) // 输出 undefined


2.未定义的属性

// 当访问一个不存在的属性时,该属性的值为undefined
let obj = { name: '张三丰' }
console.log(obj.age) // 输出 undefined


3.函数没有返回值

// 如果函数没有明确返回值或者使用 return 语句返回一个未定义的值,函数的返回值将是 undefined
function getName() {
// 没有返回值
}
console.log(foo()) // 输出 undefined


4.函数参数未传递

// 如果函数定义了参数但未传递相应的值,那么该参数的值将是 undefined
function getName(name) {
console.log("Hello, " + name)
}
getName() // 输出:Hello, undefined

5、空(Null)

在 JavaScript 中,null 是一个特殊的值和数据类型。它表示一个空值或者不存在的对象。

与undefined不同,null是JavaScript 保留关键字,而 undefined 只是一个常量。也就是说可以声明名称为 undefined 的变量,但将 null 作为变量使用时则会报错。

1.空值

// null 表示一个空值,用于表示变量的值为空
let name = null
console.log(name) // 输出 null


2.不存在的对象

// 当使用 typeof 运算符检测一个值为 null 的对象时,会返回 "object"
let obj = null
console.log(typeof obj) // 输出:object

null 与 undefined 区别

  • undefined 是表示一个未定义或未初始化的值,常用于声明但未赋值的变量,或者访问不存在的属性。

  • null 是一个被赋予的值,用于表示变量被故意赋值为空。

  • 在判断变量是否为空时,使用严格相等运算符(===),因为 undefined 和 null 在非严格相等运算符(==)下会相等。

let x;
let y = null;
console.log(x === undefined) // 输出:true
console.log(x === null) // 输出:false
console.log(y === null) // 输出:true
console.log(y === undefined) // 输出:false

6、符号(Symbol)

符号 (Symbols) 是 ECMAScript 第 6 版新定义的。符号类型是唯一的并且是不可修改的。

1.创建Symbol

// 使用全局函数 Symbol() 可以创建一个唯一的 Symbol 值
let s = Symbol()
console.log(typeof s) // 输出 symbol


2.唯一性

// 每个通过 Symbol() 创建的 Symbol 值都是唯一的,不会与其他 Symbol 值相等,即使它们的描述相同
let s1 = Symbol()
let s2 = Symbol()
console.log(s1 == s2) // 输出 false
let s3 = Symbol('hello')
let s4 = Symbol('hello')
console.log(s3 == s4) // 输出 false


3.Symbol 常量

// 通过 Symbol.for() 方法可以创建全局共享的 Symbol 值,称为 Symbol 常量
let s5 = Symbol.for('key')
let s6 = Symbol.for('key')
console.log(s5 === s6) // 输出 true

Symbol 的主要作用是创建独一无二的标识符,用于定义对象的属性名或者作为一些特殊的标记。它在一些特定的应用场景中非常有用,如在迭代器和生成器中使用 Symbol.iterator 标识可迭代对象。

三、引用数据类型

除了基本数据类型,JavaScript还有引用数据类型:对象(Object)、数组(Array)和函数(Function)

1、对象(Object)

Object 是一个内置的基本数据类型和构造函数。是一组由键、值组成的无序集合,定义对象类型需要使用花括号{ },它是 JavaScript 中最基本的对象类型,也是其他对象类型的基础。

1.创建对象

// Object 类型可以用于创建新的对象。可以使用对象字面量 {} 或者通过调用 Object() 构造函数来创建对象
let obj1 = {} // 使用对象字面量创建空对象
let obj2 = new Object() // 使用 Object() 构造函数创建空对象


2.添加、修改、删除属性

let obj = {}
obj.name = '张三丰' // 添加属性
obj.age = 30 // 添加属性
obj.name = '张无忌' // 修改属性
delete obj.age // 删除属性

2、数组(Array)

JavaScript 中,数组(Array)是一组按顺序排列的数据的集合,数组中的每个值都称为元素,而且数组中可以包含任意类型的数据。

在 JavaScript 中定义数组需要使用方括号[ ],数组中的每个元素使用逗号进行分隔。

数组的特点有哪些?

  • 有序集合: 数组是一种有序的数据集合,每个元素在数组中都有一个对应的索引,通过索引可以访问和操作数组中的元素。

  • 可变长度: 数组的长度是可变的,可以根据需要动态添加或删除元素,或者修改数组的长度。可以使用 push()、pop()、shift()、unshift() 等方法来添加或删除元素,也可以直接修改数组的 length 属性来改变数组的长度。

  • 存储不同类型的值: 数组可以存储任意类型的值,包括基本类型和对象类型。同一个数组中可以混合存储不同类型的值。

  • 索引访问: 通过索引来访问数组中的元素,索引从 0 开始。可以使用方括号语法 [] 或者点号语法 . 来访问数组的元素。

  • 内置方法: 数组提供了许多内置的方法,用于对数组进行常见的操作和处理,如添加、删除、查找、排序、遍历等。常用的数组方法包括 push()、pop()、shift()、unshift()、concat()、slice()、splice()、indexOf()、forEach()、map()、filter()、reduce() 等。

  • 可迭代性: 数组是可迭代的,可以使用 for…of 循环或者 forEach() 方法遍历数组中的元素。

1.创建数组

// 可以使用数组字面量 [] 或者通过调用 Array() 构造函数来创建数组。
let arr1 = [] // 使用数组字面量创建空数组
let arr2 = new Array() // 使用 Array() 构造函数创建空数组
let arr3 = [1, 2, 3] // 使用数组字面量创建包含初始值的数组


2.访问和修改数组元素

// 数组的元素通过索引访问,索引从 0 开始。可以使用索引来读取或修改数组的元素。
let arr = [1, 2, 3]
console.log(arr[0]) // 访问数组的第一个元素,输出:1
arr[1] = 5 // 修改数组的第二个元素
arr.length // 获取数组长度,输出:3

3、函数(Function)

ECMAScript中的函数是对象,与其他引用类型一样具有属性和方法。因此,函数名实际是一个指向函数对象的指针。

1.创建函数

// 可以使用函数声明或函数表达式来创建函数。函数声明使用 function 关键字,后面跟着函数名称和函数体,而函数表达式将函数赋值给一个变量。
// 函数声明
function add(a, b) {
return a + b
}

// 函数表达式
let multiply = function(a, b) {
return a * b
}


2.函数调用

// 函数可以通过函数名后面加括号 () 进行调用。调用函数时,可以传递参数给函数,函数可以接收参数并进行相应的处理。
let result = add(3, 5) // 调用 add 函数并传递参数
console.log(result) // 输出:8


3.函数返回值

// 函数可以使用 return 语句返回一个值,也可以不返回任何值。当函数执行到 return 语句时,会立即停止执行,并将返回值传递给函数调用者。
function calculateSum(a, b) {
return a + b
}
let result = calculateSum(2, 3)
console.log(result) // 输出:5


4.函数作用域

// 函数作用域是指函数内部声明的变量在函数内部有效,外部无法访问。函数内部定义的变量只能在函数内部被访问和使用,在函数外部是不可见的。

function myFunction() {
var x = 10 // 局部变量
console.log(x) // 在函数内部可见
}
myFunction() // 输出:10
console.log(x) // 报错:x is not defined

此外,JavaScript还有一些特殊的数据类型,如Date(表示日期和时间)、RegExp(表示正则表达式),以及ES6新增的Map、Set、WeakMap和WeakSet,用于存储特定类型的数据。


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


四、数据类型检测

检测数据类型可以使用typeof操作符,它可以检测基本数据类型和function,但无法区分不同的引用数据类型。

var arr = [
null, // object
undefined, // undefined
true, // boolean
12, // number
'haha', // string
Symbol(), // symbol
20n, // bigint
function(){}, // function
{}, // object
[], // object
]
for (let i = 0; i < arr.length; i++) {
console.log(typeof arr[i])
}

掌握JavaScript数据类型是成为一名高效开发者的关键。它们是构建程序的砖石,理解它们的用法和限制将使你能够构建更稳健、更可维护的代码。

现在,你已经了解了JavaScript的数据类型,是时候在你的代码中运用这些知识了。记住,实践是学习的最佳方式,所以动手尝试吧!

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

收起阅读 »

商品 sku 在库存影响下的选中与禁用

web
分享一下,最近使用 React 封装的一个 Skus 组件,主要用于处理商品的sku在受到库存的影响下,sku项的选中和禁用问题; 需求分析 需要展示商品各规格下的sku信息,以及根据该sku的库存是否为空,判断是否禁用该sku的选择。 以下讲解将按照我的 ...
继续阅读 »

分享一下,最近使用 React 封装的一个 Skus 组件,主要用于处理商品的sku在受到库存的影响下,sku项的选中和禁用问题;


需求分析


需要展示商品各规格下的sku信息,以及根据该sku的库存是否为空,判断是否禁用该sku的选择。


sku-2.gif

以下讲解将按照我的 Skus组件 来,我这里放上我组件库中的线上 demo 和码上掘金的一个 demo 供大家体验;由于码上掘金导入不了组件库,我就上传了一份开发组件前的一份类似的代码,功能和代码思路是差不多的,大家也可以自己尝试写一下,可能你的思路会更优;


线上 Demo 地址


码上掘金



传入的sku数据结构


需要传入的商品的sku数据类型大致如下:


type SkusProps = { 
/** 传入的skus数据列表 */
data: SkusItem[]
// ... 其他的props
}

type SkusItem = {
/** 库存 */
stock?: number;
/** 该sku下的所有参数 */
params: SkusItemParam[];
};

type SkusItemParam = {
name: string;
value: string;
}

转化成需要的数据类型:


type SkuStateItem = {
value: string;
/** 与该sku搭配时,该禁用的sku组合 */
disabledSkus: string[][];
}[];

生成数据


定义 sku 分类


首先假装请求接口,造一些假数据出来,我这里自定义了最多 6^6 = 46656 种 sku。


sku-66.gif

下面的是自定义的一些数据:


const skuData: Record<string, string[]> = {
'颜色': ['红','绿','蓝','黑','白','黄'],
'大小': ['S','M','L','XL','XXL','MAX'],
'款式': ['圆领','V领','条纹','渐变','轻薄','休闲'],
'面料': ['纯棉','涤纶','丝绸','蚕丝','麻','鹅绒'],
'群体': ['男','女','中性','童装','老年','青少年'],
'价位': ['<30','<50','<100','<300','<800','<1500'],
}
const skuNames = Object.keys(skuData)

页面初始化



  • checkValArr: 需要展示的sku分类是哪些;

  • skusList: 接口获取的skus数据;

  • noStockSkus: 库存为零对应的skus(方便查看)。


export default () => {
// 这个是选中项对应的sku类型分别是哪几个。
const [checkValArr, setCheckValArr] = useState<number[]>([4, 5, 2, 3, 0, 0]);
// 接口请求到的skus数据
const [skusList, setSkusList] = useState<SkusItem[]>([]);
// 库存为零对应的sku数组
const [noStockSkus, setNoStockSkus] = useState<string[][]>([])

useEffect(() => {
const checkValTrueArr = checkValArr.filter(Boolean)
const _noStockSkus: string[][] = [[]]
const list = getSkusData(checkValTrueArr, _noStockSkus)
setSkusList(list)
setNoStockSkus([..._noStockSkus])
}, [checkValArr])

// ....

return <>...</>
}

根据上方的初始化sku数据,生成一一对应的sku,并随机生成对应sku的库存。


getSkusData 函数讲解


先看总数(total)为当前需要的各sku分类的乘积;比如这里就是上面传入的 checkValArr 数组 [4,5,2,3]120种sku选择。对应的就是 skuData 中的 [颜色前四项,大小前五项,款式前两项,面料前三项] 即下图的展示。


image.png

遍历 120 次,每次生成一个sku,并随机生成库存数量,40%的概率库存为0;然后遍历 skuNames 然后找到当前对应的sku分类即 [颜色,大小,款式,面料] 4项;


接下来就是较为关键的如何根据 sku的分类顺序 生成对应的 120个相应的sku。


请看下面代码中注释为 LHH-1 的地方,该 value 的获取是通过 indexArr 数组取出来的。可以看到上面 indexArr 数组的初始值为 [0,0,0,0] 4个零的索引,分别对应 4 个sku的分类;



  • 第一次遍历:


indexArr: [0,0,0,0] -> skuName.forEach -> 红,S,圆领,纯棉


看LHH-2标记处: 索引+1 -> indexArr: [0,0,0,1];



  • 第二次遍历:


indexArr: [0,0,0,1] -> skuName.forEach -> 红,S,圆领,涤纶


看LHH-2标记处: 索引+1 -> indexArr: [0,0,0,2];



  • 第三次遍历:


indexArr: [0,0,0,2] -> skuName.forEach -> 红,S,圆领,丝绸


看LHH-2标记处: 由于已经到达该分类下的最后一个,所以前一个索引加一,后一个重新置为0 -> indexArr: [0,0,1,0];



  • 第四次遍历:


indexArr: [0,0,1,0] -> skuName.forEach -> 红,S,V领,纯棉


看LHH-2标记处: 索引+1 -> indexArr: [0,0,1,1];



  • 接下来的一百多次遍历跟上面的遍历同理


image.png
function getSkusData(skuCategorys: number[], noStockSkus?: string[][]) {
// 最终生成的skus数据;
const skusList: SkusItem[] = []
// 对应 skuState 中各 sku ,主要用于下面遍历时,对 product 中 skus 的索引操作
const indexArr = Array.from({length: skuCategorys.length}, () => 0);
// 需要遍历的总次数
const total = skuCategorys.reduce((pre, cur) => pre * (cur || 1), 1)
for(let i = 1; i <= total; i++) {
const sku: SkusItem = {
// 库存:60%的几率为0-50,40%几率为0
stock: Math.floor(Math.random() * 10) >= 4 ? Math.floor(Math.random() * 50) : 0,
params: [],
}
// 生成每个 sku 对应的 params
let skuI = 0;
skuNames.forEach((name, j) => {
if(skuCategorys[j]) {
// 注意:LHH-1
const value = skuData[name][indexArr[skuI]]
sku.params.push({
name,
value,
})
skuI++;
}
})
skusList.push(sku)

// 注意: LHH-2
indexArr[indexArr.length - 1]++;
for(let j = indexArr.length - 1; j >= 0; j--) {
if(indexArr[j] >= skuCategorys[j] && j !== 0) {
indexArr[j - 1]++
indexArr[j] = 0
}
}

if(noStockSkus) {
if(!sku.stock) {
noStockSkus.at(-1)?.push(sku.params.map(p => p.value).join(' / '))
}
if(indexArr[0] === noStockSkus.length && noStockSkus.length < skuCategorys[0]) {
noStockSkus.push([])
}
}
}
return skusList
}

Skus 组件的核心部分的实现


初始化数据


需要将上面生成的数据转化为以下结构:


type SkuStateItem = {
value: string;
/** 与该sku搭配时,该禁用的sku组合 */
disabledSkus: string[][];
}[];

export default function Skus() {
// 转化成遍历判断用的数据类型
const [skuState, setSkuState] = useState<Record<string, SkuStateItem>>({});
// 当前选中的sku值
const [checkSkus, setCheckSkus] = useState<Record<string, string>>({});

// ...
}

将初始sku数据生成目标结构


根据 data (即上面的假数据)生成该数据结构。


第一次遍历是对skus第一项进行的,会生成如下结构:


const _skuState = {
'颜色': [{value: '红', disabledSkus: []}],
'大小': [{value: 'S', disabledSkus: []}],
'款式': [{value: '圆领', disabledSkus: []}],
'面料': [{value: '纯棉', disabledSkus: []}],
}

第二次遍历则会完整遍历剩下的skus数据,并往该对象中填充完整。


export default function Skus() {
// ...
useEffect(() => {
if(!data?.length) return
// 第一次对skus第一项的遍历
const _checkSkus: Record<string, string> = {}
const _skuState = data[0].params.reduce((pre, cur) => {
pre[cur.name] = [{value: cur.value, disabledSkus: []}]
_checkSkus[cur.name] = ''
return pre
}, {} as Record<string, SkuStateItem>)
setCheckSkus(_checkSkus)

// 第二次遍历
data.slice(1).forEach(item => {
const skuParams = item.params
skuParams.forEach((p, i) => {
// 当前 params 不在 _skuState 中
if(!_skuState[p.name]?.find(params => params.value === p.value)) {
_skuState[p.name].push({value: p.value, disabledSkus: []})
}
})
})

// ...接下面
}, [data])
}

第三次遍历主要用于为每个 sku的可点击项 生成一个对应的禁用sku数组 disabledSkus ,只要当前选择的sku项,满足该数组中的任一项,该sku选项就会被禁用。之所以保存这样的一个二维数组,是为了方便后面点击时的条件判断(有点空间换时间的概念)。


遍历 data 当库存小于等于0时,将当前的sku的所有参数传入 disabledSkus 中。


例:第一项 sku(红,S,圆领,纯棉)库存假设为0,则该选项会被添加到 disabledSkus 数组中,那么该sku选择时,勾选前三个后,第四个 纯棉 的勾选会被禁用。


image.png
export default function Skus() {
// ...
useEffect(() => {
// ... 接上面
// 第三次遍历
data.forEach(sku => {
// 遍历获取库存需要禁用的sku
const stock = sku.stock!
// stockLimitValue 是一个传参 代表库存的限制值,默认为0
// isStockGreaterThan 是一个传参,用来判断限制值是大于还是小于,默认为false
if(
typeof stock === 'number' &&
isStockGreaterThan ? stock >= stockLimitValue : stock <= stockLimitValue
) {
const curSkuArr = sku.params.map(p => p.value)
for(const name in _skuState) {
const curSkuItem = _skuState[name].find(v => curSkuArr.includes(v.value))
curSkuItem?.disabledSkus?.push(
sku.params.reduce((pre, p) => {
if(p.name !== name) {
pre.push(p.value)
}
return pre
}, [] as string[])
)
}
}
})

setSkuState(_skuState)
}, [data])
}

遍历渲染 skus 列表


根据上面的 skuState,生成用于渲染的列表,渲染列表的类型如下:


type RenderSkuItem = {
name: string;
values: RenderSkuItemValue[];
}
type RenderSkuItemValue = {
/** sku的值 */
value: string;
/** 选中状态 */
isChecked: boolean
/** 禁用状态 */
disabled: boolean;
}

export default function Skus() {
// ...
/** 用于渲染的列表 */
const list: RenderSkuItem[] = []
for(const name in skuState) {
list.push({
name,
values: skuState[name].map(sku => {
const isChecked = sku.value === checkSkus[name]
const disabled = isChecked ? false : isSkuDisable(name, sku)
return { value: sku.value, disabled, isChecked }
})
})
}
// ...
}

html css 大家都会,以下就简单展示了。最外层遍历sku的分类,第二次遍历遍历每个sku分类下的名称,第二次遍历的 item(类型为:RenderSkuItemValue),里面会有sku的值,选中状态和禁用状态的属性。


export default function Skus() {
// ...
return list?.map((p) => (
<div key={p.name}>
{/* 例:颜色、大小、款式、面料 */}
<div>{p.name}</div>
<div>
{p.values.map((sku) => (
<div
key={p.name + sku.value}
onClick={() =>
selectSkus(p.name, sku)}
>
{/* classBem 是用来判断当前状态,增加类名的一个方法而已 */}
<span className={classBem(`sku`, {active: sku.isChecked, disabled: sku.disabled})}>
{/* 例:红、绿、蓝、黑 */}
{sku.value}
</span>
</div>
))}
</div>
</div>

))
}

selectSkus 点击选择 sku


通过 checkSkus 设置 sku 对应分类下的 sku 选中项,同时触发 onChange 给父组件传递一些信息出去。


const selectSkus = (skuName: string, {value, disabled, isChecked}: RenderSkuItemValue) => {
const _checkSkus = {...checkSkus}
_checkSkus[skuName] = isChecked ? '' : value;
const curSkuItem = getCurSkuItem(_checkSkus)
// 该方法主要是 sku 组件点击后触发的回调,用于给父组件获取到一些信息。
onChange?.(_checkSkus, {
skuName,
value,
disabled,
isChecked: disabled ? false : !isChecked,
dataItem: curSkuItem,
stock: curSkuItem?.stock
})
if(!disabled) {
setCheckSkus(_checkSkus)
}
}

getCurSkuItem 获取当前选中的是哪个sku



  • isInOrder.current 是用来判断当前的 skus 数据是否是整齐排列的,这里当成 true 就好,判断该值的过程就不放到本文了,感兴趣可以看 源码


由于sku是按顺序排列的,所以只需按顺序遍历上面生成的 skuState,找出当前sku选中项对应的索引位置,然后通过 就可以直接得出对应的索引位置。这样的好处是能减少很多次遍历。


如果直接遍历原来那份填充所有 sku 的 data 数据,则需要很多次的遍历,当sku是 6^6 时, 则每次变换选中的sku时最多需要 46656 * 6 (data总长度 * 里面 sku 的 params) 次。


const getCurSkuItem = (_checkSkus: Record<string, string>) => {
const length = Object.keys(skuState).length
if(!length || Object.values(_checkSkus).filter(Boolean).length < length) return void 0
if(isInOrder.current) {
let skuI = 0;
// 由于sku是按顺序排列的,所以索引可以通过计算得出
Object.keys(_checkSkus).forEach((name, i) => {
const index = skuState[name].findIndex(v => v.value === _checkSkus[name])
const othTotal = Object.values(skuState).slice(i + 1).reduce((pre, cur) => (pre *= cur.length), 1)
skuI += index * othTotal;
})
return data?.[skuI]
}
// 这样需要遍历太多次
return data.find(s => (
s.params.every(p => _checkSkus[p.name] === getSkuParamValue(p))
))
}

isSkuDisable 判断该 sku 是否是禁用的


该方法是在上面 遍历渲染 skus 列表 时使用的。



  1. 开始还未有选中值时,需要校验 disabledSkus 的数组长度,是否等于该sku参数可以组合的sku总数,如果相等则表示禁用。

  2. 判断当前选中的 sku 还能组成多少种组合。例:当前选中 红,S ,而 isSkuDisable 方法当前判断的 sku 为 款式 中的 圆领,则还有三种组合 红\S\圆领\纯棉红\S\圆领\涤纶红\S\圆领\丝绸

  3. 如果当前判断的 sku 的 disabledSkus 数组中存在这三项,则表示该 sku 选项会被禁用,无法点击。


const isCheckValue = !!Object.keys(checkSkus).length

const isSkuDisable = (skuName: string, sku: SkuStateItem[number]) => {
if(!sku.disabledSkus.length) return false
// 1.当一开始没有选中值时,判断某个sku是否为禁用
if(!isCheckValue) {
let checkTotal = 1;
for(const name in skuState) {
if(name !== skuName) {
checkTotal *= skuState[name].length
}
}
return sku.disabledSkus.length === checkTotal
}

// 排除当前的传入的 sku 那一行
const newCheckSkus: Record<string, string> = {...checkSkus}
delete newCheckSkus[skuName]

// 2.当前选中的 sku 一共能有多少种组合
let total = 1;
for(const name in newCheckSkus) {
if(!newCheckSkus[name]) {
total *= skuState[name].length
}
}

// 3.选中的 sku 在禁用数组中有多少组
let num = 0;
for(const strArr of sku.disabledSkus) {
if(Object.values(newCheckSkus).every(str => !str ? true : strArr.includes(str))) {
num++;
}
}

return num === total
}

至此整个商品sku从生成假数据到sku的选中和禁用的处理的核心代码就完毕了。还有更多的细节问题可以直接查看 源码 会更清晰。


作者:滑动变滚动的蜗牛
来源:juejin.cn/post/7313979106890842139
收起阅读 »

Android — 实现扫码登录功能

现在大部分网站都有扫码登录功能,搭配相应的App就能免去输入账号密码实现快速登录。本文简单介绍如何实现扫码登录功能。 实现扫码登录 之前参与过一个电视App的开发,采用扫码登录,需要使用配套的App扫码登录后才能进入到主页。那么扫码登录该怎么实现呢?大致流程如...
继续阅读 »

现在大部分网站都有扫码登录功能,搭配相应的App就能免去输入账号密码实现快速登录。本文简单介绍如何实现扫码登录功能。


实现扫码登录


之前参与过一个电视App的开发,采用扫码登录,需要使用配套的App扫码登录后才能进入到主页。那么扫码登录该怎么实现呢?大致流程如下:



  1. 被扫端展示一个二维码,二维码包含被扫端的唯一标识(如设备id),并与服务端保持通讯(轮询、长连接、推送)。

  2. 扫码端扫描二维码之后,使用获取到的被扫端的唯一标识(如设备id)调用服务端扫码登录接口。

  3. 服务端接收扫码端发起的扫码登录请求,处理(如验证用户信息)后将登录信息发送到被扫端。


PS: 此为大致流程,具体使用需要根据实际需求进行调整。


接下来简单演示一下此流程。


添加依赖库


添加需要的SDK依赖库,在项目app module的build.gradle中的dependencies中添加依赖:


dependencies { 
// 实现服务端(http、socket)
implementation("org.nanohttpd:nanohttpd:2.3.1")
implementation("org.nanohttpd:nanohttpd-websocket:2.3.1")

// 与服务端通信
implementation("com.squareup.okhttp3:okhttp:4.12.0")

// 扫描解析、生成二维码
implementation("com.github.jenly1314:zxing-lite:3.1.0")
}

服务端


使用NanoHttpD实现Socket服务端(与被扫端通信)和Http服务端(与扫码端通信),示例代码如下:


Socket服务


与被扫端保持通讯,在Http服务接收并处理完扫码登录请求后,将获取到的用户id发送给被扫端。


class ServerSocketClient : NanoWSD(9090) {

private var serverWebSocket: ServerWebSocket? = null

override fun openWebSocket(handshake: IHTTPSession?): WebSocket {
return ServerWebSocket(handshake).also { serverWebSocket = it }
}

private class ServerWebSocket(handshake: IHTTPSession?) : WebSocket(handshake) {
override fun onOpen() {}

override fun onClose(code: WebSocketFrame.CloseCode?, reason: String?, initiatedByRemote: Boolean) {}

override fun onMessage(message: WebSocketFrame?) {}

override fun onPong(pong: WebSocketFrame?) {}

override fun onException(exception: IOException?) {}
}

override fun stop() {
super.stop()
serverWebSocket = null
}

fun sendMessage(message: String) {
serverWebSocket?.send(message)
}
}

Http服务


接收并处理来自扫码端的扫码登录请求,通过设备id和用户id判断被扫端是否可以登录。


const val APP_SCAN_INTERFACE = "loginViaScan"

const val USER_ID = "userId"
const val EXAMPLE_USER_ID = "123456789"

const val DEVICE_ID = "deviceId"
const val EXAMPLE_DEVICE_ID = "example_device_id0001"

class ServerHttpClient(private var scanLoginSucceedListener: ((userId: String) -> Unit)? = null) : NanoHTTPD(8080) {

override fun serve(session: IHTTPSession?): Response {
val uri = session?.uri
return if (uri == "/$APP_SCAN_INTERFACE" &&
session.parameters[USER_ID]?.first() == EXAMPLE_USER_ID &&
session.parameters[DEVICE_ID]?.first() == EXAMPLE_DEVICE_ID
) {
scanLoginSucceedListener?.invoke(session.parameters[USER_ID]?.first() ?: "")
newFixedLengthResponse("Login Succeed")
} else {
super.serve(session)
}
}
}

服务控制类


启动或停止Socket服务和Http服务。


object ServerController {

private var serverSocketClient: ServerSocketClient? = null
private var serverHttpClient: ServerHttpClient? = null

fun startServer() {
(serverSocketClient ?: ServerSocketClient().also {
serverSocketClient = it
}).run {
if (!isAlive) {
start(0)
}
}

(serverHttpClient ?: ServerHttpClient {
serverSocketClient?.sendMessage("Login Succeed, user id is $it")
}.also {
serverHttpClient = it
}).run {
if (!isAlive) {
start(NanoHTTPD.SOCKET_READ_TIMEOUT, true)
}
}
}

fun stopServer() {
serverSocketClient?.stop()
serverSocketClient = null

serverHttpClient?.stop()
serverHttpClient = null
}
}

被扫端


Socket辅助类


使用OkHttp与服务端进行Socket通信。


class DevicesSocketHelper(private val messageListener: ((message: String) -> Unit)? = null) {

private var webSocket: WebSocket? = null

private val webSocketListener = object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
super.onMessage(webSocket, bytes)
messageListener?.invoke(bytes.utf8())
}

override fun onMessage(webSocket: WebSocket, text: String) {
super.onMessage(webSocket, text)
messageListener?.invoke(text)
}
}

fun openSocketConnection(serverPath: String) {
val okHttpClient = OkHttpClient.Builder()
.connectTimeout(120, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.build()
val request = Request.Builder().url(serverPath).build()
webSocket = okHttpClient.newWebSocket(request, webSocketListener)
}

fun release() {
webSocket?.close(1000, "")
webSocket = null
}
}

被扫端示例页面


先展示二维码,接收到服务端的消息后,显示用户id。


class DeviceExampleActivity : AppCompatActivity() {

private lateinit var binding: LayoutDeviceExampleActivityBinding

private var socketHelper: DevicesSocketHelper? = DevicesSocketHelper() { message ->
// 接收到服务端发来的消息,改变显示内容
runOnUiThread {
binding.tvUserInfo.text = message
binding.ivQrCode.visibility = View.GONE
binding.tvUserInfo.visibility = View.VISIBLE
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = LayoutDeviceExampleActivityBinding.inflate(layoutInflater).also {
setContentView(it.root)
it.includeTitle.tvTitle.text = "Device Example"
}

lifecycleScope.launch(Dispatchers.IO) {
// 使用设备id生成二维码
CodeUtils.createQRCode(EXAMPLE_DEVICE_ID, DensityUtil.dp2Px(200)).let { qrCode ->
withContext(Dispatchers.Main) {
binding.ivQrCode.setImageBitmap(qrCode)
}
}
}

socketHelper?.openSocketConnection("ws://localhost:9090/")
}

override fun onDestroy() {
super.onDestroy()
socketHelper?.release()
socketHelper = null
}
}

扫描端


扫码页


继承zxing-lite库的BarcodeCameraScanActivity类,简单实现扫描与解析二维码。


class ScanQRCodeActivity : BarcodeCameraScanActivity() {

override fun initCameraScan(cameraScan: CameraScan<Result>) {
super.initCameraScan(cameraScan)
// 播放扫码音效
cameraScan.setPlayBeep(true)
}

override fun createAnalyzer(): Analyzer<Result> {
return QRCodeAnalyzer(DecodeConfig().apply {
// 设置仅识别二维码
setHints(DecodeFormatManager.QR_CODE_HINTS)
})
}

override fun onScanResultCallback(result: AnalyzeResult<Result>) {
// 已获取结果,停止识别二维码
cameraScan.setAnalyzeImage(false)
// 返回扫码结果
setResult(Activity.RESULT_OK, Intent().apply {
putExtra(CameraScan.SCAN_RESULT, result.result.text)
})
finish()
}
}

扫描端示例页面


提供扫码入口,提供输入框用于输入服务端IP,获取到扫码结果后发送给服务端。


class AppScanExampleActivity : AppCompatActivity() {

private lateinit var binding: LayoutAppScanExampleActivityBinding

private var serverIp: String = ""

private val scanQRCodeLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
it.data?.getStringExtra(CameraScan.SCAN_RESULT)?.let { deviceId ->
sendRequestToServer(deviceId)
}
}
}

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

OkHttpHelper.init()

binding.btnScan.setOnClickListener {
// 获取输入的服务端ip(两台设备在同一WIFI下,直接通过IP访问服务端)
serverIp = binding.etInputIp.text.toString()
if (serverIp.isEmpty()) {
showSnakeBar("Server ip can not be empty")
return@setOnClickListener
}
hideKeyboard(binding.etInputIp)
scanQRCodeLauncher.launch(Intent(this, ScanQRCodeActivity::class.java))
}
}

private fun sendRequestToServer(deviceId: String) {
OkHttpHelper.sendGetRequest("http://${serverIp}:8080/${APP_SCAN_INTERFACE}", mapOf(Pair(USER_ID, EXAMPLE_USER_ID), Pair(DEVICE_ID, deviceId)), object : RequestCallback {
override fun onResponse(success: Boolean, responseBody: ResponseBody?) {
showSnakeBar("Scan login ${if (success) "succeed" else "failure"}")
}

override fun onFailure(errorMessage: String?) {
showSnakeBar("Scan login failure")
}
})
}

private fun hideKeyboard(view: View) {
view.clearFocus()
WindowInsetsControllerCompat(window, view).hide(WindowInsetsCompat.Type.ime())
}

private fun showSnakeBar(message: String) {
runOnUiThread {
Snackbar.make(binding.root, message, Snackbar.LENGTH_SHORT).show()
}
}
}

示例入口页


提供被扫端和扫码端入口,打开被扫端时同时启动服务端。


class ScanLoginExampleActivity : AppCompatActivity() {

private lateinit var binding: LayoutScanLoginExampleActivityBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = LayoutScanLoginExampleActivityBinding.inflate(layoutInflater).also {
setContentView(it.root)
it.includeTitle.tvTitle.text = "Scan Login Example"
it.btnOpenDeviceExample.setOnClickListener {
// 打开被扫端同时启动服务
ServerController.startServer()
startActivity(Intent(this, DeviceExampleActivity::class.java))
}
it.btnOpenAppExample.setOnClickListener { startActivity(Intent(this, AppScanExampleActivity::class.java)) }
}
}

override fun onDestroy() {
super.onDestroy()
ServerController.stopServer()
}
}

效果演示与示例代码


最终效果如下图:


被扫端扫码端
device.gifapp.gif

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


ExampleDemo github


ExampleDemo gitee


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

Android TextView的颜色和字体自适应

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

前言


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


1. 字体大小自适应


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


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


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

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


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

image.png


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

image.png


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

image.png


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

image.png


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


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


2. 字体颜色自适应


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


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


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


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

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


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


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

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


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


获取背景直接调用


val d = textView?.background

根据Drawable去获取颜色


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

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


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


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


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

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

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

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

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


android:background="#000000"

image.png


android:background="#ffffff"

image.png


android:background="#3377ff"

image.png


android:background="#ee7700"

image.png


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

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

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

前言


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

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


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


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


最终效果


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


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


源码:SlideVerticalInfinite.vue


实现原理


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

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


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

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


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

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


步骤


定义




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


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

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

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

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




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


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

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

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




list:数据列表,外部传入


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

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


初始化



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

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

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


如何滑动


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


滑动结束


判断滑动的方向


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

下滑时反之


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

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



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

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

  • 在最后面添加一个 dom

  • 删除最前面的 dom

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


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

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

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

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

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

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


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



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

  • 在最前面添加一个 dom

  • 删除最后面的 dom

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


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

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

其他问题


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


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


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


如何获取 Vue 组件的最终 dom


有两种方式,各有利弊



  • Vuerender 方法

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

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



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

    • 和上面相反😅




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

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

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

总结


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


结束



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



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

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

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

前言


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


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


开工


技术点


  • Interceptor -> 拦截器

  • ReentrantLock -> 重入锁


实现思路


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

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

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

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


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


实现代码

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

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


模拟测试

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

输出日志

image.png


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


image.png


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


实践代码

class TokenInterceptor : Interceptor {

@Volatile
private var mRefreshInvalidTime = 0L

@Volatile
private var isRefreshToken = false

private val mRefreshTokenLock by lazy { ReentrantLock() }

private val mAccountRep by lazy { .... }

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

/**
* Token 刷新
*/

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

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


/**
* Token 失效
*/

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

/**
* 请求 刷新Token
*/

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

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

companion object {
const val KET_TOKEN_INVALID_ANTI_SHAKE = 2000
}
}

End

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



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

Android适配:判断机型和系统

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

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


判断指定的机型


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


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

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



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



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



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

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


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

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


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

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



判断手机的系统


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


● 鸿蒙


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

● Miui


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

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

● Emui 或者 Magic UI


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



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


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

● Color Os


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


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

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


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


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


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


● Origin Os


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

总结


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


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


参考



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

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

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

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


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


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


但是这有用吗?


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


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


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


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


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


那么人情世故重要吗?


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


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


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


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


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


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


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


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


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


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


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


想啥呢!


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


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


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


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


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


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


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

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

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

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


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


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


它主要用于:


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


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



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




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




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

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

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


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


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


在 SQLite 的版权声明有提到:




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



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


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


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


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


REFERENCES



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

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

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

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

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

Kubernetes Data Platform (简称KDP)

开源啦!

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

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

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

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

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

在高级安装模式下

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

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

项目地址:

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

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

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

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

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

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

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

问题3:最大的亮点是?

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

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

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

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

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

总体框架

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

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

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

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

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

社区

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

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

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

贡献

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

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

收起阅读 »

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

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


1


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


2


你想活出怎样的人生?


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


3


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


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


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


4


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


5


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


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


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


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


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


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


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



6


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



7


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


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


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


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


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



8


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


9


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


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


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


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


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


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


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



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

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

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

简介


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


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


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


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



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

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


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


进程和Task管理


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


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


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


OOM终止进程(LMK)


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


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


最近任务列表结束Task


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


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



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

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


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


强制结束App


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


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


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


保持活跃或唤醒


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


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


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


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


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



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

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

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

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

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

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


借助Sticky Service唤醒


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


借助广播唤醒


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


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


借助Alarm Service定时器唤醒


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


借助Job Scheduling Service任务调度唤醒


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


借助其他App拉起唤醒


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


总结


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


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


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

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

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

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


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


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


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


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


大致实现思路:


无标题.png


1687328383569.png


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


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


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


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


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


前端


代码:


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


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


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


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


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


至于getPhoneNumber


下面看代码


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

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


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


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

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

以上


e.detail.iv
e.detail.encryptedData


两个必传 传到后台 换取mobile


后端


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


工具类方法 WxUtil.doGetStr(url)


1687329536242.png


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


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


1687329672055.png


服务端解密代码参考:


1687329836867.png


deciphering解密方法参考:


1687329894328.png


以上


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

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

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

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


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


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


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


一 市场


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


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


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


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


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


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


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


二 边际与均衡价格


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


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


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


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


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


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


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


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


三 周期


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


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


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


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


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


尾声


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


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

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

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

需求简介


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


我们来看一个需求



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



实现思路


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


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

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


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

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

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


技术方案


数据的动态切换


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


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

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

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

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

timeFuc()
<scriptp>

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


动画添加


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



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



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

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

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

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

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

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

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

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

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

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

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



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




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



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


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


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


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



轮播的停止与恢复


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


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

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

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

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

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

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

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

解决重影问题


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


<script setup>

//...

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

onBeforeUnmount(() => stop())

<scriptp>

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


总结


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


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

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


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

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

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

逐字渲染的挑战


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


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


请在此添加图片描述


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


请在此添加图片描述


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


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

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



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

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

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


自然列表:灵感来源


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


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


请在此添加图片描述


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


聊天框的翻转实现


翻转聊天框


请在此添加图片描述


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


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


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


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


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

请在此添加图片描述


消息卡片翻转恢复


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


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


742ea972f92d4e7abc7344e75c331467.avif


总结


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


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

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

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




更新:


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


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

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


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


请在此添加图片描述


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


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

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


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


请在此添加图片描述


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


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


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

实现一个支持@的输入框

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

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



20240415161851.gif


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



  1. @用户没有高亮

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


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


6ND88RssMr.gif


封装之后使用:


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

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



  1. 高亮效果

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

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

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


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


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

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

</div>

实现思路:



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

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

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

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

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


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


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

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

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

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

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




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

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


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

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


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

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

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

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

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



  1. 删除之前的@xxx字符

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

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

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

  5. 关闭选择器

  6. 将输入框重新聚焦


最后


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


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

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

完整组件代码


输入框主要逻辑代码:


let timer: NodeJS.Timeout | null = null;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

);
};

选择器代码


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

const { x, y } = cursorPosition;

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

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

);
});
export default SelectUser;

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


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

Android 双屏异显自适应Dialog

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

一、前言


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


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


需求


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


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


问题


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


二、方案


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


方案:自定义Presentation


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


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


原理


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


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


WindowType问题解决

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


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


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

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


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

WindowManagerImpl 问题

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


怎么处理呢?


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


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

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

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

全部源码


public class ComplexPresentationV1 extends Dialog  {

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

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

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

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

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

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

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

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

public Display getDisplay() {
return mPresentationDisplay;
}

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

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

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

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

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

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

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

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

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

public void onDisplayRemoved() {
}

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

public void onDisplayChanged() {
}

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

private void handleDisplayChanged() {
onDisplayChanged();

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

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

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

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

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

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

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

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

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

方案:Delegate方式:


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


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


兼容


onAttachToWindow\onDetatchFromWindow


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

onShow和\onStop


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


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

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


总结


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


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