注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

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

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


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


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


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



  • 默认单行输入

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

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

  • 支持回车发送内容

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

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


单行输入


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


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


多行输入


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


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


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


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

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


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

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

键盘发送内容


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


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


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

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


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

高度自适应


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



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


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

最后


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


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


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

微信小程序 折叠屏适配

web
最近维护了将近的一年的微信小程序(某知名企业),突然提出要兼容折叠屏,这款小程序主要功能一些图表汇总展示,也就是专门给一些领导用的,也不知道为啥领导们为啥突然喜欢用折叠屏手机了,一句话需求,苦的还是咱们程序员,但没办法,谁让甲方是爸爸呢,硬着头皮改吧,好在最后...
继续阅读 »

最近维护了将近的一年的微信小程序(某知名企业),突然提出要兼容折叠屏,这款小程序主要功能一些图表汇总展示,也就是专门给一些领导用的,也不知道为啥领导们为啥突然喜欢用折叠屏手机了,一句话需求,苦的还是咱们程序员,但没办法,谁让甲方是爸爸呢,硬着头皮改吧,好在最后解决了,因为是甲方内部使用的小程序,这里不便贴图,但有官方案例图片,以供参考


查看了微信官网
大屏适配
响应显示区域变化


启用大屏模式


从小程序基础库版本 2.21.3 开始,在 Windows、Mac、车机、安卓 WMPF 等大屏设备上运行的小程序可以支持大屏模式。可参考小程序大屏适配指南。方法是:在 app.json 中添加 "resizable": true


看到这里我心里窃喜,就加个配置完事了?这也太简单了,但后面证明我想简单了,
主要有两大问题:



  • 1 尺寸不同的情况下内容展示效果兼容问题

  • 2 预览版和体验版 大屏模式冷启动会生效,但热启动 和 菜单中点击重新进入小程、授权操作,会失效变成窄屏


解决尺寸问题


因为css的长度单位大部分用的 rpx,窄屏和宽屏展示差异出入较大,别说客户不认,自己这关就过不了,简直都不忍直视,整个乱成一片,尤其登录页,用了定位,更是乱上加乱。


随后参考了官方的文档 小程序大屏适配指南自适应布局,方案对于微信小程序原生开发是可行的,但这个项目用的 uni-app开发的,虽然uni-app 也有对应的响应式布局组件,再加上我是个比较爱偷懒的人(甲方给的工期事件也有限制),不可能花大量时间把所有也页面重新写一遍布局,这是不现实的。


于是又转战到uni-app官网寻找解决方案 uni-app宽屏适配指南


内容缩放拉伸的处理 这一段中提出了两个策略



  • 1.局部拉伸:页面内容划分为固定区域和长宽动态适配区域,固定区域使用固定的px单位约定宽高,长宽适配区域则使用flex自动适配。当屏幕大小变化时,固定区域不变,而长宽适配区域跟着变化

  • 2.等比缩放:根据页面屏幕宽度缩放。rpx其实属于这种类型。在宽屏上,rpx变大,窄屏上rpx变小。


随后看到这句话特别符合我的需求,哈哈 省事 省事 省事


策略2省事,设计师按750px屏宽出图,程序员直接按rpx写代码即可。但策略2的实际效果不如策略1好。程序员使用策略1,分析下界面,设定好局部拉伸区域,这样可以有更好的用户体验


具体实现


1.配置 pages.json 的 globeStyle


{
"globalStyle": {
"rpxCalcMaxDeviceWidth": 1200, // rpx 计算所支持的最大设备宽度,单位 px,默认值为 960
"rpxCalcBaseDeviceWidth": 375, // rpx 计算使用的基准设备宽度,设备实际宽度超出 rpx 计算所支持的最大设备宽度时将按基准宽度计算,单位 px,默认值为 375
"rpxCalcIncludeWidth": 750 // rpx 计算特殊处理的值,始终按实际的设备宽度计算,单位 rpx,默认值为 750
},
}

2.单位兼容


还有一点官方也提出来了很重要,那就很多时候 会把宽度750rpx 当成100% 使用,这在宽屏的设备上就会有问题, uniapp给了两种解决方案



  • 750rpx 改为100%

  • 另一种是配置rpxCalcIncludeWidth,设置某个特定数值不受rpxCalcMaxDeviceWidth约束


想要用局部拉伸:页面内容划分为固定区域和长宽动态适配区域”的策略,单位必须用px


添加脚本


项目根目录新增文件 postcss.config.js 内容如下。则在编译时,编译器会自动转换rpx单位为px。


// postcss.config.js

const path = require('path')
module.exports = {
parser: 'postcss-comment',
plugins: {
'postcss-import': {
resolve(id, basedir, importOptions) {
if (id.startsWith('~@/')) {
return path.resolve(process.env.UNI_INPUT_DIR, id.substr(3))
} else if (id.startsWith('@/')) {
return path.resolve(process.env.UNI_INPUT_DIR, id.substr(2))
} else if (id.startsWith('/') && !id.startsWith('//')) {
return path.resolve(process.env.UNI_INPUT_DIR, id.substr(1))
}
return id
}
},
'autoprefixer': {
overrideBrowserslist: ["Android >= 4", "ios >= 8"],
remove: process.env.UNI_PLATFORM !== 'h5'
},
// 借助postcss-px-to-viewport插件,实现rpx转px,文档:https://github.com/evrone/postcss-px-to-viewport/blob/master/README_CN.md
// 以下配置,可以将rpx转换为1/2的px,如20rpx=10px,如果要调整比例,可以调整 viewportWidth 来实现
'postcss-px-to-viewport': {
unitToConvert: 'rpx',
viewportWidth: 200,
unitPrecision: 5,
propList: ['*'],
viewportUnit: 'px',
fontViewportUnit: 'px',
selectorBlackList: [],
minPixelValue: 1,
mediaQuery: false,
replace: true,
exclude: undefined,
include: undefined,
landscape: false
},
'@dcloudio/vue-cli-plugin-uni/packages/postcss': {}
}
}


大屏模式失效问题


下面重头戏来了,这期间经历 蜿蜒曲折 ,到头来发现都是无用功,我自己都有被wx蠢到发笑,唉,


样式问题解决后 开始着重钻研 大屏失效的问题,但看了官方的多端适配示例demo,人家的就是好的,那就应该有解决办法,于是转战github地址 下项目,谁知这项目暗藏机关,各种报错,让你跑不起来。。。。,让我一度怀疑腾讯也这么拉跨


还好issues 区一位大神有解决办法 感兴趣的老铁可以去瞅瞅
image


1693664649860.jpg


另外 微信小程序开发工具需要取消这两项,最后当项目跑起来后我还挺开心,模拟器上没有问题,但用真机预览的时候我啥眼了,还是窄屏,偶尔可以大屏,后面发现 冷启动是大屏,热启动和点击右上角菜单中的重新进入小程序按钮都会自己变成窄屏幕


官方案例.gif批量更新.gif

这是官方的项目啊,为啥人家的可以,我本地跑起来却不可以,让我一度怀疑这里有内幕,经过几轮测试还是不行,于是乎,我开始了各种询问查资料,社区、私聊、评论、github issues,最后甚至 统计出来了 多端适配示例demo 开发者的邮箱 挨个发了邮件,但都结果无一例外,全部石沉大海


1693666642117.jpgwx-github-issues-110.jpg
私聊.jpg评论.jpg
wx-mini-dev.jpgimage.png

结果就是,没有办法了,想看看是不是只有预览和体验版有问题,后面发布到正式版后,再看居然没问题了,就是这么神奇,也是无语!!!! 原来做了这么多无用功。。。。


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

Moshi:现代 Json 解析库全解析

json 解析框架,很容易想到 Gson、fastJson 等。而这些流行框架对 kotlin 的支持并不好,而Moshi 天生对 kotlin 友好。 前言 Gson 通过反射反序列化数据,Java 类默认有无参构造函数,对于默认参数能够很好的支持。对于 k...
继续阅读 »

json 解析框架,很容易想到 Gson、fastJson 等。而这些流行框架对 kotlin 的支持并不好,而Moshi 天生对 kotlin 友好。


前言


Gson 通过反射反序列化数据,Java 类默认有无参构造函数,对于默认参数能够很好的支持。对于 kotlin ,我们经常使用的 data class,其往往没有无参构造函数,Gson 便会通过 UnSafe 的方式创建实例,成员无法正常初始化默认值。为了勉强能用,只能将构造参数都加上默认值才行,不过这种兼容方式太过隐晦,有潜在的维护风险。


另外,Gson 无法支持 kotlin 空安全特性。定义为不可空且无默认值的字段,在没有该字段对应的 json 数据时会被赋值为 null,这可能导致使用时引发空指针问题。


Moshi


Moshi 是一个适用于 Android、Java 和 Kotlin 的现代 JSON 库。它可以轻松地将 JSON 解析为 Java 和 Kotlin 类。


val json: String = ...

val moshi: Moshi = Moshi.Builder().build()
val jsonAdapter: JsonAdapter = moshi.adapter()

val person = jsonAdapter.fromJson(json)

通过类型适配器 JsonAdapter 可以对数据类型 T 进行序列化/反序列化操作,即 toJsonfromJson 方法。


内置类型适配器


moshi 内置支持以下类型的类适配器:

  • 基本类型
  • Arrays, Collections, Lists, Sets, Maps
  • Strings
  • Enums


直接或间接由它们构成的自定义数据类型都可以直接解析。


反射 OR 代码生成


moshi 支持反射和代码生成两种方式进行 Json 解析。


反射的好处是无需对数据类做任何变动,可以解析 private 和 protected 成员,缺点是引入反射相关库,包体积增大2M多,且反射在性能上稍差。


代码生成的好处是速度更快,缺点是需要对数据类添加注解,无法处理 private 和 protected 成员,用于编译时生成代码,影响编译速度,且注解使用越来越多生成的代码也会越来越多。


反射方案依赖:


implementation("com.squareup.moshi:moshi-kotlin:1.14.0")

代码生成方案依赖(ksp):


plugins {
id("com.google.devtools.ksp").version("1.6.10-1.0.4") // Or latest version of KSP
}

dependencies {
ksp("com.squareup.moshi:moshi-kotlin-codegen:1.14.0")
}

使用代码生成,需要使用注解 @JsonClass(generateAdapter = true) 修饰数据类:


@JsonClass(generateAdapter = true)
data class Person(
val name: String
)

使用反射时,需要添加 KotlinJsonAdapterFactoryMoshi.Builder


val moshi = Moshi.Builder()
.addLast(KotlinJsonAdapterFactory())
.build()

💡 注意:这里要使用 addLast 添加 KotlinJsonAdapterFactory,因为 Adapter 是按添加顺序排列和使用的,如果有自定义的 Adapter,为确保自定义的始终在前,建议通过 addLastKotlinJsonAdapterFactory 始终放在最后。


我们目前使用的是反射方案,主要考虑到侵入性低,数据类几乎无改动。


其实也可以两种方案都使用,Moshi 会优先使用代码生成的 Adapter,没有的话则走反射。


解析 JSON 数组


对于 json 数据:


[
{
"rank": "4",
"suit": "CLUBS"
},
{
"rank": "A",
"suit": "HEARTS"
}
]

解析:


String cardsJsonResponse = ...;
Type type = Types.newParameterizedType(List.class, Card.class);
JsonAdapter> adapter = moshi.adapter(type);
List cards = adapter.fromJson(cardsJsonResponse);

和 Gson 类似,为了运行时获取泛型信息,稍微麻烦点,可以定义扩展函数简化用法:


inline fun <reified T> Moshi.listAdapter(): JsonAdapter> {
val type = Types.newParameterizedType(List::class.java, T::class.java)
return adapter(type)
}

简化后:


String cardsJsonResponse = ...
val cards = moshi.listAdapter().fromJson(cardsJsonResponse)

自定义字段名


如果Json 中字段名和数据类中字段名不一致,或 json 中有空格,可以使用 @Json 注解修饰别名。


{
"username": "jesse",
"lucky number": 32
}

class Player {
val username: String
@Json(name = "lucky number") val luckyNumber: Int

...
}

忽略字段


使用 @Json(ignore = true) 可以忽略字段的解析,java 中的 @Transient 注解也可以。


class BlackjackHand(...) {
@Json(ignore = true)
var total: Int = 0

...
}

Java 支持


Moshi 同样支持 Java。需要注意的是,和 Gson 一样,Java 类需要有无参构造方法,否则成员变量的默认值无法生效。


public final class BlackjackHand {
private int total = -1;
...

public BlackjackHand(Card hidden_card, List visible_cards) {
...
}
}

如上,total 的默认值会为 0.


另外,和 Gson 不一样的是,Moshi 并不支持 JsonElement 这种中间产物,它只支持内置类型如 List、Map。


自定义 JsonAdapter


如果 json 的数据格式和我们想要的不一样,就需要我们自定义 JsonAdapter 来解析了。有意思的是,任何拥有 @Json@ToJson 注解的类都可以成为 Adapter,无需继承 JsonAdapter。


例如 json 格式:


{
"title": "Blackjack tournament",
"begin_date": "20151010",
"begin_time": "17:04"
}

目标数据类定义:


class Event(
val title: String,
val beginDateAndTime: String
)

我们希望 json 中日期 begin_date 和时间 begin_time 组成 beginDateAndTime 字段。moshi 支持我们在 json 和目标数据转换间定义一个中间类,json 和中间类转换后再转换为最终类型。


定义中间类型,本例中即和 json 匹配的数据类型:


class EventJson(
val title: String,
val begin_date: String,
val begin_time: String
)

定义 Adapter :


class EventJsonAdapter {
@FromJson
fun eventFromJson(eventJson: EventJson): Event {
return Event(
title = eventJson.title,
beginDateAndTime = "${eventJson.begin_date} ${eventJson.begin_time}"
)
}

@ToJson
fun eventToJson(event: Event): EventJson {
return EventJson(
title = event.title,
begin_date = event.beginDateAndTime.substring(0, 8),
begin_time = event.beginDateAndTime.substring(9, 14),
)
}
}

将 adapter 注册到 moshi:


val moshi = Moshi.Builder()
.add(EventJsonAdapter())
.build()

这样就可以使用 moshi 直接将 json 转换成 Event 了。本质是将 Json 和目标数据的相互转换加了个中间步骤,先转换为中间产物,再转为最终 Json 或数据实例。


@JsonQualifier:自定义字段类型解析


如下 json,color 为十六进制 rgb 格式的字符串:


{
"width": 1024,
"height": 768,
"color": "#ff0000"
}

数据类,color 为 Int 类型:


class Rectangle(
val width: Int,
val height: Int,
val color: Int
)

Json 中 color 字段类型是 String,数据类同名字段类型为 Int,除了上面介绍的自定义 JsonAdapter 外,还可以自定义同一数据的不同数据类型间的转换。


首先自定义注解:


@Retention(RUNTIME)
@JsonQualifier
annotation class HexColor

使用注解修饰字段:


class Rectangle(
val width: Int,
val height: Int,
@HexColor val color: Int
)

自定义 Adapter:


/** Converts strings like #ff0000 to the corresponding color ints.  */
class ColorAdapter {
@ToJson fun toJson(@HexColor rgb: Int): String {
return "#x".format(rgb)
}

@FromJson @HexColor fun fromJson(rgb: String): Int {
return rgb.substring(1).toInt(16)
}
}

通过这种方式,同一字段可以有不同的解析方式,可能不多见,但的确有用。


适配器组合


举个例子:


class UserKeynote(
val type: ResourceType,
val resource: KeynoteResource?
)

enum class ResourceType {
Image,
Text
}

sealed class KeynoteResource(open val id: Int)

data class Image(
override val id: Int,
val image: String
) : KeynoteResource(id)

data class Text(
override val id: Int,
val text: String
) : KeynoteResource(id)

UserKeynote 是目标类,其中的 KeynoteResource 可能是 ImageText ,具体是哪个需要根据 type 字段来决定。也就是说 UserKeynote 的解析需要 Image 或 Text 对应的 Adapter 来完成,具体是哪个取决于 type 的值。


显然自带的 Adapter 不能满足需求,需要自定义 Adapter。


先看下 Adapter 中签名要求(参见源码 AdapterMethodsFactory.java):


@FromJson


 R fromJson(JsonReader jsonReader) throws 

R fromJson(JsonReader jsonReader, JsonAdapter delegate, ) throws

R fromJson(T value) throws

@ToJson


 void toJson(JsonWriter writer, T value) throws 

void toJson(JsonWriter writer, T value, JsonAdapter delegate, ) throws

R toJson(T value) throws

前面分析了我们需要借助 Image 或 Text 对应的 Adapter,所以使用第二组函数签名:


class UserKeynoteAdapter {
private val namesOption = JsonReader.Options.of("type")

@FromJson
fun fromJson(
reader:
JsonReader,
imageJsonAdapter:
JsonAdapter<Image>,
textJsonAdapter:
JsonAdapter<Text>
)
: UserKeynote {
// copy 一份 reader,得到 type
val newReader = reader.peekJson()
newReader.beginObject()
var type: String? = null
while (newReader.hasNext()) {
if (newReader.selectName(namesOption) == 0) {
type = newReader.nextString()
}
newReader.skipName()
newReader.skipValue()
}
newReader.endObject()

// 根据 type 做解析
val resource = when (type) {
ResourceType.Image.name -> {
imageJsonAdapter.fromJson(reader)
}

ResourceType.Text.name -> {
textJsonAdapter.fromJson(reader)
}

else -> throw IllegalArgumentException("unknown type $type")
}
return UserKeynote(ResourceType.valueOf(type), resource)
}

@ToJson
fun toJson(
writer:
JsonWriter,
userKeynote:
UserKeynote,
imageJsonAdapter:
JsonAdapter<Image>,
textJsonAdapter:
JsonAdapter<Text>
)
{
when (userKeynote.resource) {
is Image -> imageJsonAdapter.toJson(writer, userKeynote.resource)
is Text -> textJsonAdapter.toJson(writer, userKeynote.resource)
null -> {}
}
}
}

函数接收一个 JsonReader / JsonWriter 以及若干 JsonAdapter,可以认为该 Adapter 由其他多个 Adapter 组合完成。这种委托的思路在 Moshi 中很常见,比如内置类型 List 的解析,便是委托给了 T 的适配器,并重复调用。


限制



  • 不要 Kotlin 类继承 Java 类

  • 不要 Java 类继承 Kotlin 类


这是官方强调不要做的,如果你那么做了,发现还没问题,不要侥幸,建议修改,毕竟有有维护风险,且会误导其他维护的人以为这样是可靠合理的。


作者:Aaron_Wang
来源:juejin.cn/post/7273516671575113743
收起阅读 »

详解JS判断页面是在手机端还是在PC端打开的方法

web
下面详细介绍一下如何判断页面是在手机端还是在PC端打开,并提供两条示例说明。 方法一:使用UA判断 UA(UserAgent)是指HTTP请求头中的一部分,用于标识客户端的一些信息,比如用户的设备类型、浏览器型号等等。因此,我们可以通过判断UA中的关键字来确定...
继续阅读 »

下面详细介绍一下如何判断页面是在手机端还是在PC端打开,并提供两条示例说明。


方法一:使用UA判断


UA(UserAgent)是指HTTP请求头中的一部分,用于标识客户端的一些信息,比如用户的设备类型、浏览器型号等等。因此,我们可以通过判断UA中的关键字来确定页面访问者的设备类型。下面是实现的代码:


const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);

if (isMobile) {
console.log('当前在手机端');
} else {
console.log('当前在PC端');
}

代码解析:


首先,我们使用正则表达式匹配navigator.userAgent中是否包含iPhoneiPadiPodAndroid这些关键字,如果匹配成功,则说明当前是在移动端。如果匹配失败,则说明当前是在PC端。


需要注意的是,该方法并不100%准确,因为用户可以使用PC浏览器模拟手机UA,也有可能使用移动端浏览器访问PC网站。


方法二:使用媒体查询判断


媒体查询是CSS3的一个新特性,可以根据不同的媒体类型(比如设备屏幕的宽度、高度、方向等)来设置不同的CSS样式。我们可以利用媒体查询来判断页面是在手机端还是在PC端打开。下面是实现的代码:


<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8"/>
<title>判断页面是在手机端还是在PC端</title>
<style>
/* 默认样式 */
p {
font-size: 24px;
color: yellow;
}
/* 移动端样式 */
@media (max-width: 767px) {
p {
font-size: 20px;
color: green;
}
}
</style>
</head>
<body>
<p>测试内容</p>
</body>
</html>

代码解析:


在CSS中,我们使用@media关键字定义了一个媒体查询,当浏览器宽度小于等于767px的时候,p元素的字体大小和颜色都会发生改变,从而实现了对移动端的识别。如果浏览器宽度大于767px,则会使用默认样式。


需要注意的是,该方法只能判断设备的屏幕宽度,不能确定设备的真实类型,因此并不太准确。


总的来说,两种方法各有优缺点,具体选择哪种方法要根据自己的需求和场景来决定。一般来说,如果只是想简单地判断页面访问者的设备类型,使用第一种方法即可。如果需要根据设备类型来优化网站的布局和样式,可以使用第二种方法。


作者:RuiRay
来源:juejin.cn/post/7273746154642014262
收起阅读 »

Android 多种支付方式的优雅实现!

1.场景 App 的支付流程,添加多种支付方式,不同的支付方式,对应的操作不一样,有的会跳转到一个新的webview,有的会调用系统浏览器,有的会进去一个新的表单页面,等等。 并且可以添加的支付方式也是不确定的,由后台动态下发。 如下图所示: 根据上图 ui...
继续阅读 »

1.场景


App 的支付流程,添加多种支付方式,不同的支付方式,对应的操作不一样,有的会跳转到一个新的webview,有的会调用系统浏览器,有的会进去一个新的表单页面,等等。


并且可以添加的支付方式也是不确定的,由后台动态下发。


如下图所示:


image.png


根据上图 ui 理一下执行流程:



  1. 点击不同的添加支付方式 item。

  2. 进入相对应的添加支付方式流程(表单页面、webview、弹框之类的)。

  3. 在第三方回调里面根据不同的支付方式执行不同的操作。

  4. 调用后台接口查询添加是否成功。

  5. 根据接口结果展示不同的成功或者失败的ui.


2.以前的实现方式


用一个 Activity 承载,上述所有的流程都在 Activity 中。Activity 包含了列表展示、多种支付方式的实现和 ui。


伪代码如下:


class AddPaymentListActivity : AppCompatActivity(R.layout.activity_add_card) {

private val addPaymentViewModel : AddPaymentViewModel = ...

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
addPaymentViewModel.checkPaymentStatusLiveData.observer(this) { isSuccess ->
// 从后台结果判断是否添加成功
if (isSuccess) {
addCardSuccess(paymentType)
} else {
addCardFailed(paymentType)
}
}
}

private fun clickItem(paymentType: PaymentType) {
when (paymentType) {
PaymentType.ADD_GOOGLE_PAY -> //执行添加谷歌支付流程
PaymentType.ADD_PAY_PEL-> //执行添加PayPel支付流程
PaymentType.ADD_ALI_PAY-> //执行添加支付宝支付流程
PaymentType.ADD_STRIPE-> //执行添加Stripe支付流程
}
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (resultCode) {
PaymentType.ADD_GOOGLE_PAY -> {
// 根据第三方回调的结果,拿到key
// 根据key调用后台的Api接口查询是否添加成功
}
PaymentType.ADD_PAY_PEL -> // 同上
// ...
}
}

private fun addCardSuccess(paymentType: PaymentType){
when (paymentType) {
PaymentType.ADD_GOOGLE_PAY -> // 添加对应的支付方式成功,展示成功的ui,然后执行下一步操作
PaymentType.ADD_PAY_PEL-> // 同上
// ...
}
}

private fun addCardFailed(paymentType: PaymentType){
when (paymentType) {
PaymentType.ADD_GOOGLE_PAY -> // 添加对应的支付方式失败,展示失败的ui
PaymentType.ADD_PAY_PEL-> // 同上
// ...
}
}

enum class PaymentType {
ADD_GOOGLE_PAY, ADD_PAY_PEL, ADD_ALI_PAY, ADD_STRIPE
}

}

虽然看起来根据 paymentType 来判断,逻辑条理也还过得去,但是实际上复杂度远远不止如此。


• 不同的支付方式跳转的页面相差很大。


• 结果的回调获取也相差很大,并不是所有的都在onActivityResult中。


• 成功和失败实际上也不能统一来处理,里面包含很多的if…else…判断。


• 如果支付方式是后台动态下发的,处理起来判断逻辑就更多了。


此外,最大的问题:扩展性问题。


当新来一种支付方式,例如微信支付之类的,改动代码就很大了,基本就是将整个Activity中的代码都要改动。可以说上面这种方式的可扩展性为零,就是简单的将代码都揉在一起。


3.优化后的代码


要想实现高内聚低耦合,最简单的就是套用常见的设计模式,回想一下,发现策略模式+简单工厂模式非常这种适合这种场景。


先看下优化后的代码:


class AddPlatformActivity : BaseActivity() {

private var addPayPlatform: IAddPayPlatform? = null

private fun addPlatform(payPlatform: String) {
// 将后台返回的支付平台字符串变成枚举类
val platform: PayPlatform = getPayPlatform(payPlatform) ?: return
addPayPlatform = AddPayPlatformFactory.getCurrentPlatform(this, platform)
addPayPlatform?.getLoadingLiveData()?.observe(this@AddPlatformActivity) { showLoading ->
if (showLoading) startLoading() else stopLoading()
}
addPayPlatform?.addPayPlatform(AddCardParameter(platform))
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
// 将onActivityResult的回调转接到需要监听的策略类里面
addPayPlatform?.thirdAuthenticationCallback(requestCode, resultCode, data)
}
}

4.策略模式


意图: 定义一系列的算法,把它们一个个封装起来, 并且使它们可相互替换。


主要解决: 在有多种算法相似的情况下,使用if…else所带来的复杂和难以维护。


何时使用: 一个系统有许多许多类,而区分它们的只是他们直接的行为。


如何解决: 将这些算法封装成一个一个的类,任意地替换。


关键代码: 实现同一个接口。


**优点: **


1、算法可以自由切换。


2、避免使用多重条件判断。


3、扩展性良好。


缺点


1、策略类会增多。


2、所有策略类都需要对外暴露。


**使用场景: **


1、如果在一个系统里面有许多类,它们之间的区别仅在于它们的行为,那么使用策略模式可以动态地让一个对象在许多行为中选择一种行为。


2、一个系统需要动态地在几种算法中选择一种。


3、如果一个对象有很多的行为,如果不用恰当的模式,这些行为就只好使用多重的条件选择语句来实现。


5.需要实现的目标


5.1 解耦宿主 Activity


现在宿主Activity中代码太重了,包含多种支付方式实现,还有列表ui的展示,网络请求等。


现在目标是将 Activity 中的代码拆分开来,让宿主 Activity 变得小而轻。


如果产品说新增一种支付方式,只需要改动很少的代码,就可以轻而易举的实现。


5.2 抽取成独立的模块


因为公司中有可能存在多个项目,支付模块的分层应该处于可复用的层级,以后很有可能将其封装成一个独立的 mouble,给不同的项目使用。


现在代码全在 Activity 中,以后若是抽取 mouble 的话,相当于整个需求重做。


5.3 组件黑盒


"组件黑盒"这个名词是我自己的一个定义。大致意思:



将一个 View 或者一个类进行高度封装,尽可能少的暴露public方法给外部使用,自成一体。




业务方在使用时,可以直接黑盒使用某个业务组件,不必关心其中的逻辑。




业务方只需要一个简单的操作,例如点击按钮调用方法,然后逻辑都在组件内部实现,组件内处理外部事件的所有操作,例如:Loading、请求网络、成功或者失败。




业务方都不需要知道组件内部的操作,做到宿主和组件的隔离。




当然这种处理也是要分场景考虑的,其中一个重点就是这个组件是偏业务还是偏功能,也就是是否要将业务逻辑统一包进组件,想清楚这个问题后,才能去开发一个业务组件。 摘自xu’yi’sheng博客。xuyisheng.top/author/xuyi…



因为添加支付方式是一个偏业务的功能,我的设计思路是:


外部 Activity 点击添加对应的支付方式,将支付方式的枚举类型和支付方式有关的参数通过传递,然后不同的策略类组件执行自己的添加逻辑,再通过一层回调将第三方支付的回调从 Activity 中转接过来,每个策略类内部处理自己的回调操作,具体的策略类自己维护成功或者失败的ui。


6.具体实现


6.1 定义顶层策略接口


interface IAddPayPlatform {

fun addPayPlatform(param: AddCardParameter)

fun thirdAuthenticationCallback(requestCode: Int?, resultCode: Int?, data: Intent?)

fun addCardFailed(message: String?)

fun addCardSuccess()
}

6.2 通用支付参数类


open class AddCardParameter(val platform: PayPlatform)

class AddStripeParameter(val card: Card, val setPrimaryCard: Boolean, platform: PayPlatform)
: AddCardParameter(platform = PayPlatform.Stripe)

因为有很多种添加支付方式,不同的支付方式对应的参数都不一样。


所以先创建一个通用的卡片参数基类AddCardParameter, 不同的支付方式去实现不同的具体参数。这样的话策略接口就可以只要写一个添加卡片的方法addPayPlatform(paramAddCardParameter)


6.3 Loading 的处理


因为我想实现的是黑盒组件的效果,所有添加卡片的loading也是封装在每一个策略实现类里面的。


Loading的出现和消失这里有几种常见的实现方式:


• 传递BaseActivity的引用,因为我的loading有关的方法是放在BaseActivity中,这种方式简单但是会耦合BaseActivity。


• 使用消息事件总线,例如EventBus之类的,这种方式解耦强,但是消息事件不好控制,还要添加多余的依赖库。


• 使用LiveData,在策略的通用接口中添加一个方法返回Loading的LiveData, 让宿主Activity自己去实现。


interface IAddPayPlatform {
// ...
fun getLoadingLiveData(): LiveData<Boolean>?
}

6.4 提取BaseAddPayStrategy


因为每一个添加卡的策略会存在很多相同的代码,这里我抽取一个BaseAddPayStrategy来存放模板代码。


需要实现黑盒组件的效果,宿主Activity中都不需要去关注添加支付方式是不是存在网络请求这一个过程,所以网络请求也分装在每一个策略实现类里面。


abstract class BaseAddPayStrategy(val activity: AppCompatActivity, val platform: PayPlatform) : IAddPayPlatform {

private val loadingLiveData = SingleLiveData<Boolean>()

protected val startActivityIntentLiveData = SingleLiveData<Intent>()

override fun getLoadingLiveData(): LiveData<Boolean> = loadingLiveData

protected fun startLoading() = loadingLiveData.setValue(true)

protected fun stopLoading() = loadingLiveData.setValue(false)

private fun reloadWallet() {
startLoading()
// 添加卡片完成后,重新刷新钱包数据
}

override fun addCardSuccess() {
reloadWallet()
}

override fun addCardFailed(message: String?) {
stopLoading()
if (isEWalletPlatform(platform)) showAddEWalletFailedView() else showAddPhysicalCardFailedView(message)
}

/**
* 添加实体卡片失败展示ui
*/

private fun showAddPhysicalCardFailedView(message: String?) {
showSaveErrorDialog(activity, message)
}

/**
* 添加实体卡片成功展示ui
*/

private fun showAddPhysicalCardSuccessView() {
showCreditCardAdded(activity) {
activity.setResult(Activity.RESULT_OK)
activity.finish()
}
}

private fun showAddEWalletSucceedView() {
// 添加电子钱包成功后的执行
activity.setResult(Activity.RESULT_OK)
activity.finish()
}

private fun showAddEWalletFailedView() {
// 添加电子钱包失败后的执行
}

// ---默认空实现,有些支付方式不需要这些方法---
override fun thirdAuthenticationCallback(requestCode: Int?, resultCode: Int?, data: Intent?) = Unit

override fun getStartActivityIntent(): LiveData<Intent> = startActivityIntentLiveData
}

6.5 具体的策略类实现


通过传递过来的AppCompatActivity引用获取添加卡片的ViewModel实例AddPaymentViewModel,然后通过AddPaymentViewModel去调用网络请求查询添加卡片是否成功。


class AddXXXPayStrategy(activity: AppCompatActivity) : BaseAddPayStrategy(activity, PayPlatform.XXX) {

protected val addPaymentViewModel: AddPaymentViewModel by lazy {
ViewModelProvider(activity).get(AddPaymentViewModel::class.java)
}

init {
addPaymentViewModel.eWalletAuthorizeLiveData.observeState(activity) {

onSuccess { addCardSuccess()}

onError { addCardFailed(it.detailed) }
}
}

override fun thirdAuthenticationCallback(requestCode: Int?, resultCode: Int?, result: Intent?) {
val uri: Uri = result?.data ?: return
if (uri.host == "www.xxx.com") {
uri.getQueryParameter("transactionId")?.let {
addPaymentViewModel.confirmEWalletAuthorize(platform.name, it)
}
}
}

override fun addPayPlatform(param: AddCardParameter) {
startLoading()
addPaymentViewModel.addXXXCard(param)
}
}

7.简单工厂进行优化


因为我不想在Activity中去引用每一个具体的策略类,只想引用抽象接口类IAddPayPlatform, 这里通过一个简单工厂来优化。


object AddPayPlatformFactory {


fun setCurrentPlatform(activity: AppCompatActivity, payPlatform: PayPlatform): IAddPayPlatform? {
return when (payPlatform) {
PayPlatform.STRIPE -> AddStripeStrategy(activity)
PayPlatform.PAYPAL -> AddPayPalStrategy(activity)
PayPlatform.LINEPAY -> AddLinePayStrategy(activity)
PayPlatform.GOOGLEPAY -> AddGooglePayStrategy(activity)
PayPlatform.RAPYD -> AddRapydStrategy(activity)
else -> null
}
}

}


8.再增加一种支付方式


如果再增加一种支付方式,宿主Activity中的代码都可以不要改动,只需要新建一个新的策略类,实现顶层策略接口即可。


这样,不管是删除还是新增一种支付方式,维护起来就很容易了。


策略模式的好处就显而易见了。



今日分享到此结束,对你有帮助的话,点个赞再走呗,每日一个面试小技巧




关注公众号:Android老皮

解锁  《Android十大板块文档》 ,让学习更贴近未来实战。已形成PDF版



内容如下



1.Android车载应用开发系统学习指南(附项目实战)

2.Android Framework学习指南,助力成为系统级开发高手

3.2023最新Android中高级面试题汇总+解析,告别零offer

4.企业级Android音视频开发学习路线+项目实战(附源码)

5.Android Jetpack从入门到精通,构建高质量UI界面

6.Flutter技术解析与实战,跨平台首要之选

7.Kotlin从入门到实战,全方面提升架构基础

8.高级Android插件化与组件化(含实战教程和源码)

9.Android 性能优化实战+360°全方面性能调优

10.Android零基础入门到精通,高手进阶之路



作者:派大星不吃蟹
来源:juejin.cn/post/7274475842998157353
收起阅读 »

强大的css计数器,你确定不来看看?

web
强大的 css 计数器 css content 属性有很多实用的用法,这其中最为强大的莫过于是计数器了,它甚至可以实现连 javascript 都不能够实现的效果,下面我们一起来研究一下吧。 css 计数器主要有 3 个关键点需要掌握。如下: 首先需要一个计...
继续阅读 »

强大的 css 计数器


css content 属性有很多实用的用法,这其中最为强大的莫过于是计数器了,它甚至可以实现连 javascript 都不能够实现的效果,下面我们一起来研究一下吧。


css 计数器主要有 3 个关键点需要掌握。如下:



  1. 首先需要一个计数器的名字,这个名字由使用者自己定义。

  2. 计数器有一个计数规则,比如是 1,2,3,4...这样的递增方式,还是 1,2,1,2...这样的连续递增方式。

  3. 计数器的使用,即定义好了一个计数器名字和计数规则,我们就需要去使用它。


以上 3 个关键点分别对应的就是 css 计数器的 counter-reset 属性,counter-increment 属性,和 counter()/counters()方法。下面我们依次来介绍这三个玩意儿。


counter-reset 属性


counter-reset 属性叫做计数器重置,对应的就是创建一个计数器名字,如果可以,顺便也可以告诉计数器的计数起始值,也就是从哪个值开始计数,默认值是 0,注意是 0,而不是 1。例如以下一个示例:


html 代码如下:


<p>开始计数,计数器名叫counter</p>
<p class="counter"></p>

css 代码如下:


.counter {
counter-reset: counter;
}

.counter::before {
content: counter(counter);
}

在浏览器中运行以上示例,你会看到如下图所示:


counter-1.png


可以看到计数器的初始值就是 0,现在我们修改一下 css 代码,如下所示:


.counter {
counter-reset: counter 1;
}

在浏览器中运行以上示例,你会看到如下图所示:


counter-2.png


这次我们指定了计数器的初始值 1,所以结果就是 1,计数器的初始值同样也可以指定成小数,负数,如-2,2.99 之类,只不过 IE 和 FireFox 浏览器都会认为是不合法的数值,当做默认值 0 来处理,谷歌浏览器也会直接显示负数,如下图所示:


counter-3.png


低版本谷歌浏览器处理小数的时候是向下取整,比如 2.99 则显示 2,最新版本则当成默认值 0,来处理,如下图所示:


counter-4.png



ps: 当然不推荐指定初始值为负数或者小数。



你以为到这里就完了吗?还没有,计数器还可以指定多个,每一个计数器之间用空格隔开,比如以下代码:


.counter {
counter-reset: counter1 1 counter2 2;
}

.counter::before {
content: counter(counter1) counter(counter2);
}

在浏览器中运行以上示例,你会看到如下图所示:


counter-5.png


除此之外,计数器名还可以指定为 none 和 inherit,也就是取消计数和继承计数器,这没什么好说的。


counter-increment


顾名思义,该属性就是计数器递增的意思,也就是定义计数器的计数规则,值为计数器的名字,可以是一个或者多个,并且也可以指定一个数字,表示计数器每次变化的数字,如果不指定,默认就按照 1 来变化。比如以下代码:


.counter {
counter-reset: counter 1;
counter-increment: counter;
}

得到的结果就是: 1 + 1 = 2。如下图所示:


counter-6.png


再比如以下代码:


.counter {
counter-reset: counter 2;
counter-increment: counter 3;
}

得到的结果就是: 2 + 3 = 5,如下图所示:


counter-7.png


由此可见,计数器的规则就是: 计数器名字唯一,每指定一次计数规则,计数器就会加一,每指定二次计数规则,计数器就会加二,……以此类推。


计数规则不仅可以创建在元素上,也可以创建在使用计数器的元素上,比如以下代码:


.counter {
counter-reset: counter;
counter-increment: counter;
}

.counter::before {
content: counter(counter);
counter-increment: counter;
}

我们不仅在类名为 counter 元素上创建了一个计数器规则,同样的也在 before 伪元素上创建了一个计数器规则,因此最后的结果就是: 0 + 1 + 1 = 2。如下图所示:


counter-8.png


总而言之,无论位置在何处,只要有 counter-increment,对应的计数器的值就会变化, counter()只是输出而已!计数器的数值变化遵循 HTML 渲染顺序,遇到一个 increment 计数器就变化,什么时候 counter 输出就输出此时的计数值。


除此之外,计数器规则也可以和计数器一样,创建多个计数规则,也是以空格区分,比如以下示例代码:


.counter {
counter-reset: counter1 1 counter2 2;
counter-increment: counter1 2 counter2 3;
}

.counter::before {
content: counter(counter1) counter(counter2);
counter-increment: counter1 4 counter2 5;
}

此时的结果就应该是计数器 1: 1 + 2 + 4 = 7,计数器 2: 2 + 3 + 5 = 10。如下图所示:


counter-9.png


同样的,计数器规则的值也可以是负数,也就是递减效果了,比如以下代码:


.counter {
counter-reset: counter1 1 counter2 2;
counter-increment: counter1 -1 counter2 -3;
}

.counter::before {
content: counter(counter1) counter(counter2);
counter-increment: counter1 2 counter2 5;
}

此时的结果就应该是计数器 1: 1 - 1 + 2 = 2,计数器 2: 2 - 3 + 5 = 4。如下图所示:


counter-10.png


同样的计数规则的值也可以是 none 或者 inherit。


counter


counter 方法类似于 calc,主要用于定义计数器的显示输出,到目前为止,我们前面的示例都是最简单的输出,也就是如下语法:


counter(name); /* name为计数器名 */

实际上还有如下的语法:


counter(name,style);

style 参数和 list-style-type 的值一样,意思就是不仅可以显示数字,还可以显示罗马数字,中文字符,英文字母等等,值如下:


list-style-type: disc | circle | square | decimal | lower-roman | upper-roman |
lower-alpha | upper-alpha | none | armenian | cjk-ideographic | georgian |
lower-greek | hebrew | hiragana | hiragana-iroha | katakana | katakana-iroha |
lower-latin | upper-latin | simp-chinese-informal;

比如以下的示例代码:


.counter {
counter-reset: counter;
counter-increment: counter;
}

.counter::before {
content: counter(counter, lower-roman);
}

结果如下图所示:


counter-11.png


再比如以下的示例代码:


.counter {
counter-reset: counter;
counter-increment: counter;
}

.counter::before {
content: counter(counter, simp-chinese-informal);
}

结果如下图所示:


counter-12.png


同样的 counter 也可以支持级联,也就是说,一个 content 属性值可以有多个 counter 方法,如:


.counter {
counter-reset: counter;
counter-increment: counter;
}

.counter::before {
content: counter(counter) '.' counter(counter);
}

结果如下图所示:


counter-13.png


counters


counters 方法虽然只是比 counter 多了一个 s 字母,但是含义可不一样,counters 就是用来嵌套计数器的,什么意思了?我们平时如果显示列表符号,不可能只是单单显示 1,2,3,4...还有可能显示 1.1,1.2,1.3...前者是 counter 做的事情,后者就是 counters 干的事情。


counters 的语法为:


counters(name, string);

name 就是计数器名字,而第二个参数 string 就是分隔字符串,比如以'.'分隔,那 string 的值就是'.',以'-'分隔,那 string 的值就是'-'。来看如下一个示例:


html 代码如下:


<div class="reset">
<div class="counter">
javascript框架
<div class="reset">
<div class="counter">&nbsp;angular</div>
<div class="counter">&nbsp;react</div>
<div class="counter">
vue
<div class="reset">
<div class="counter">
vue语法糖
<div class="reset">
<div class="counter">&nbsp;@</div>
<div class="counter">&nbsp;v-</div>
<div class="counter">&nbsp;:</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

css 代码如下:


.reset {
counter-reset: counter;
padding-left: 20px;
}

.counter::before {
content: counters(counter, '-') '.';
counter-increment: counter;
}

结果如下图所示:


counter-14.png


这种计数效果在模拟书籍的目录效果时非常实用,比如写文档,会有嵌套标题的情况,还有一个比较重要的点需要说明一下,就是显示 content 计数值的那个 DOM 元素在文档流中的位置一定要在 counter-increment 元素的后面,否则是没有计数效果的。


总而言之,content 计数器是非常强大的,以上都只是很基础的用法,真正掌握还需要大量的实践以及灵感还有创意。


作者:夕水
来源:juejin.cn/post/7275176987358265355
收起阅读 »

面试官:如何防止重复提交订单?

这个问题,在电商领域的面试场景题下,应该算是妥妥的高频问题了,仅次于所谓的“秒杀场景如何实现”。 说个题外话,有段时间“秒杀场景如何实现”这个问题风靡一时,甚至在面试的时候,有些做财务领域、OA领域公司的面试官也都跟风问。 大有一种”无秒杀,不面试“的感觉了。...
继续阅读 »

这个问题,在电商领域的面试场景题下,应该算是妥妥的高频问题了,仅次于所谓的“秒杀场景如何实现”。


说个题外话,有段时间“秒杀场景如何实现”这个问题风靡一时,甚至在面试的时候,有些做财务领域、OA领域公司的面试官也都跟风问。


大有一种”无秒杀,不面试“的感觉了。


重复提交原因


其实原因无外乎两种:



  • 一种是由于用户在短时间内多次点击下单按钮,或浏览器刷新按钮导致。

  • 另一种则是由于Nginx或类似于SpringCloud Gateway的网关层,进行超时重试造成的。


常见解决方案


方案一:提交订单按钮置灰


这种解决方案在注册登录的场景下比较常见,当我们点击”发送验证码“按钮的时候,会进行手机短信验证码发送,且按钮就会有一分钟左右的置灰。


有些经验不太丰富的同学,通常会简单粗暴地把这个方案直接照搬过来。


但这种方案只能解决多次点击下单按钮的问题,对于Nginx或类似于SpringCloud Gateway的超时重试所导致的问题是无能为力的。


当然,这种方案也不是真的没有价值。它可以在高并发场景下,从浏览器端去拦住一部分请求,减少后端服务器的处理压力。


说到底,“下单防重”的问题是属于“接口幂等性”的问题范畴。



幂等性


接口幂等性是指:以相同的参数,对一个接口进行多次调用,所产生的结果和一次调用是完全相同的。


下面的情况就是幂等的:


student.setName("张三");

而这种情况就是非幂等的,因为每次调用,年龄都会增加一岁。


student.increaseAge(1);

现在我们的思路需要切换到幂等性的解决方案来。


同样是幂等性场景,“如何防止重复提交订单” 比 “如何防止订单重复支付” 的解决方案要难一些。


因为,后者在常规情况下,一个订单都是对应一笔支付单,所以orderID可以作为一个幂等性校验、防止订单重复支付的天然神器。


但这个方案在“如何防止重复提交订单”就不适用了,需要其他的解决方案,请继续看下文。


方案二:预生成全局唯一订单号


(1)后端新增一个接口,用于预生成一个“全局唯一订单号”,如:UUID 或 NanoID。


(2)进入创建订单页面时,前端请求该接口,获取该订单号。


(3)在提交订单时,请求参数里要带上这个预生成的“全局唯一订单号”,利用数据库的唯一索引特性,在插入订单记录时,如果该“全局唯一的订单号”重复,记录会插入失败。


btw:该“全局唯一订单号”不能代替数据库主键,在未分库分表场景下,主键还是用数据库自增ID比较好。



方案二


优点:彻底解决了重复下单的问题;


缺点:方案复杂,前后端都有开发工作量,还要新增接口,新增字段。


另外,网上还有同学说,要单独弄一个生成“全局唯一订单号”的服务,我觉得还是免了吧,这不是更麻烦了吗?


方案三:前端生成全局唯一订单号


这种方案是在借鉴了“方案二”的基础上,做了一些实现逻辑的简化。


(1)用户进入下页面时,前端程序自己生成一个“全局唯一订单号”。


(2)在提交订单时,请求参数里要带上这个预生成的“全局唯一订单号”,利用数据库的唯一索引特性,在插入订单记录时,如果该“全局唯一的订单号”重复,记录会插入失败。



方案三


优点:彻底解决了重复下单的问题,且技术方案做了一定简化;


缺点:前后端仍然都有开发工作量,且需要新增字段;


方案四:从订单业务的本质入手


先跟大家探讨一个概念,什么是订单?


其实,订单就是某个用户用特定的价格购买了某种商品,即:用户和商品的连接。


那么,“如何防止重复提交订单”,其实就是防止在短时间内,用户和商品进行多次连接。弄明白问题本质,接下来我们就着手制定技术方案了。


可以用 ”用户ID + 分隔符 + 商品ID“ 作为唯一标识,让持有相同标识的请求在短时间内不能重复下单,不就可以了吗?而且,Redis不正是做这种解决方案的利器吗?


Redis命令如下:


SET key value NX EX seconds


把”用户ID + 分隔符 + 商品ID“作为Redis key,并把”短时间所对应的秒数“设置为seconds,让它过期自动删除。


这样一来,整体业务步骤如下:


(1)在提交订单时,我们可以把”用户ID + 分隔符 + 商品ID“作为Redis key,并设置过期时间,让它可以到期自动删除。


(2)若Redis命令执行成功,则可以继续走下单的业务逻辑,执行不成功,直接返回给前端”下单失败“就可以了。



方案四


从上图来看,是不是实现方式越来越简单了?


优点:彻底解决了重复下单的问题,且在技术方案上,不需要前端参与,不需要添加接口,不需要添加字段;


缺点:综合比较而言,暂无明显缺点,如果硬要找缺点的话,可能强依赖于Redis勉强可以算上吧;


结语


在真正的生产环境下,我们最终选择了”方案四:从订单业务的本质入手“。


原因很简单,整体改动范围比较小,测试的回归范围也比较可控,且技术方案复杂度最低。


这样做技术选型的话,也比较符合百度一直倡导的”简单可依赖“原则。


作者:库森学长
来源:juejin.cn/post/7273024681631776829
收起阅读 »

前端埋点实现方案

前端埋点的简介埋点是指在软件、网站或移动应用中插入代码,用于收集和跟踪用户行为数据。通过在特定位置插入埋点代码,开发人员可以记录用户在应用中的操作,如页面浏览、按钮点击、表单提交等。这些数据可以用于分析用户行为、优化产品功能、改进用户体验等目的。 埋点通常与...
继续阅读 »

前端埋点的简介

  • 埋点是指在软件、网站或移动应用中插入代码,用于收集和跟踪用户行为数据。

  • 通过在特定位置插入埋点代码,开发人员可以记录用户在应用中的操作,如页面浏览、按钮点击、表单提交等。

  • 这些数据可以用于分析用户行为、优化产品功能、改进用户体验等目的。




埋点通常与数据分析工具结合使用,如Google Analytics、Mixpanel等,以便对数据进行可视化和进一步分析。


前端埋点是指在前端页面中嵌入代码,用于收集和跟踪用户行为数据。


通过埋点可以获取用户在网页或应用中的点击、浏览、交互等动作,用于分析用户行为、优化产品体验和进行数据驱动的决策。


在前端埋点中,常用的方式包括:

  1. 页面加载埋点:用于追踪和监测页面的加载时间、渲染状态等信息。
  2. 点击事件埋点:通过监听用户的点击事件,记录用户点击了哪些元素、触发了什么操作,以及相关的参数信息。
  3. 表单提交埋点:记录用户在表单中输入的内容,并在用户提交表单时将这些数据发送到后台进行保存和分析。
  4. 页面停留时间埋点:用于记录用户在页面停留的时间,以及用户与页面的交互行为,如滚动、鼠标悬停等。
  5. AJAX请求埋点:在前端的AJAX请求中添加额外的参数,用于记录请求的发送和返回状态,以及相应的数据。

埋点数据可以通过后端API或第三方数据分析工具发送到服务器进行处理和存储。


在使用前端埋点时,需要注意保护用户隐私,遵守相关法律法规,并确保数据采集和使用的合法性和合规性。


同时,还需设计良好的数据模型和分析策略,以便从埋点数据中获得有价值的信息。


前端埋点设计


前面说过,前端埋点是一种数据追踪的技术,用于收集和分析用户的行为数据。


前端埋点设计方案有哪些?


下面简单介绍一下:

  1. 事件监听:通过监听用户的点击、滚动、输入等事件,记录用户的操作行为。可以使用JavaScript来实现事件监听,例如使用addEventListener()函数进行事件绑定。

  2. 自定义属性:在HTML元素中添加自定义属性,用于标识不同的元素或事件。 例如,在按钮上添加data-*属性,表示不同的按钮类型或功能。当用户与这些元素进行交互时,可以获取相应的属性值作为事件标识。

  3.  发送请求:当用户触发需要追踪的事件时,可以通过发送异步请求将数据发送到后台服务器。 可以使用XMLHttpRequest、fetch或者第三方的数据上报SDK来发送请求。

  4. 数据格式:确定需要采集的数据格式,包括页面URL、时间戳、用户标识、事件类型、操作元素等信息。 通常使用JSON格式来封装数据,方便后续的数据处理和分析。

  5. 用户标识:对于需要区分用户的情况,可以在用户首次访问时生成一个唯一的用户标识,并将该标识存储在浏览器的cookie中或使用localStorage进行本地存储。

  6. 数据上报:将采集到的数据发送到后台服务器进行存储和处理。可以自建后台系统进行数据接收和分析,也可以使用第三方的数据分析工具,例如百度统计、Google Analytics等。

  7.  隐私保护:在进行数据采集和存储时,需要注意用户隐私保护。

  8.  遵守相关的法律法规,对敏感信息进行脱敏处理或加密存储,并向用户明示数据采集和使用政策。


需要注意的是,在进行埋点时要权衡数据采集的成本与收益,确保收集到的数据具有一定的价值和合法性。


同时,要注意保护用户隐私,遵守相关法律法规,尊重用户的选择和权益。


前端埋点示例


以下是一个完整的前端埋点示例


展示了如何在网站上埋点统计页面浏览、按钮点击和表单提交事件

  • 在HTML中标识需要采集的元素或事件:
<button id="myButton" data-track-id="button1">Click Me</button>

<form id="myForm">
  <input type="text" name="username" placeholder="Username">
  <input type="password" name="password" placeholder="Password">
  <button type="submit">Submit</button>
</form>

在按钮和表单元素上添加了data-track-id自定义属性,用于标识这些元素。

  • 使用JavaScript监听事件并获取事件数据:
// 监听页面加载事件
window.addEventListener("load", function() {
  var pageUrl = window.location.href;
  var timestamp = new Date().getTime();
  var userData = {
    eventType: "pageView",
    pageUrl: pageUrl,
    timestamp: timestamp
    // 其他需要收集的用户数据
  };

  // 封装数据格式并发送请求
  sendData(userData);
});

// 监听按钮点击事件
document.getElementById("myButton").addEventListener("click", function(event) {
  var buttonId = event.target.getAttribute("data-track-id");
  var timestamp = new Date().getTime();
  var userData = {
    eventType: "buttonClick",
    buttonId: buttonId,
    timestamp: timestamp
    // 其他需要收集的用户数据
  };

  // 封装数据格式并发送请求
  sendData(userData);
});

// 监听表单提交事件
document.getElementById("myForm").addEventListener("submit", function(event) {
  event.preventDefault(); // 阻止表单默认提交行为

  var formId = event.target.getAttribute("id");
  var formData = new FormData(event.target);
  var timestamp = new Date().getTime();
  var userData = {
    eventType: "formSubmit",
    formId: formId,
    formData: Object.fromEntries(formData.entries()),
    timestamp: timestamp
    // 其他需要收集的用户数据
  };

  // 封装数据格式并发送请求
  sendData(userData);
});

通过JavaScript代码监听页面加载、按钮点击和表单提交等事件,获取相应的事件数据,包括页面URL、按钮ID、表单ID和表单数据等。

  • 发送数据请求:
function sendData(data) {
  var xhr = new XMLHttpRequest();
  xhr.open("POST", "/track", true);
  xhr.setRequestHeader("Content-Type", "application/json");

  xhr.onreadystatechange = function() {
    if (xhr.readyState === 4 && xhr.status === 200) {
      console.log("Data sent successfully.");
    }
  };

  xhr.send(JSON.stringify(data));
}

使用XMLHttpRequest对象发送POST请求,将封装好的数据作为请求的参数发送到后台服务器的/track接口。

  • 后台数据接收与存储:

后台服务器接收到前端发送的数据请求后,进行处理和存储。


可以使用后端开发语言(如Node.js、Python等)来编写接口逻辑,将数据存储到数据库或其他持久化存储中。


通过监听页面加载、按钮点击和表单提交等事件,并将相关数据发送到后台服务器进行存储和分析。


根据具体项目需求,可以扩展和定制各种不同类型的埋点事件和数据采集。


vue 前端埋点示例


在Vue中实现前端埋点可以通过自定义指令或者混入(mixin)来完成。


下面给出两种常见的Vue前端埋点示例:

  • 自定义指令方式:
// 在 main.js 中注册全局自定义指令 track
import Vue from 'vue';

Vue.directive('track', {
  bind(el, binding, vnode) {
    const { event, data } = binding.value;
    
    el.addEventListener(event, () => {
      // 埋点逻辑,例如发送请求或记录日志
      console.log("埋点事件:" + event);
      console.log("埋点数据:" + JSON.stringify(data));
    });
  }
});

在组件模板中使用自定义指令:

<template>
  <button v-track="{ event: 'click', data: { buttonName: '按钮A' } }">点击按钮A</button>
</template>

  • 1. 混入方式:
// 创建一个名为 trackMixin 的混入对象,并定义需要进行埋点的方法
const trackMixin = {
  methods: {
    trackEvent(event, data) {
      // 埋点逻辑,例如发送请求或记录日志
      console.log("埋点事件:" + event);
      console.log("埋点数据:" + JSON.stringify(data));
    }
  }
};

// 在组件中使用混入
export default {
  mixins: [trackMixin],
  mounted() {
    // 在需要进行埋点的地方调用混入的方法
    this.trackEvent('click', { buttonName: '按钮A' });
  },
  // ...
};

这两种方式都可以实现前端埋点,你可以根据自己的项目需求选择适合的方式。


在实际应用中,你需要根据具体的埋点需求来编写逻辑,例如记录页面浏览、按钮点击、表单提交等事件,以及相应的数据收集和处理操作。


使用自定义指令(Custom Directive)的方式来实现前端埋点


在Vue 3中,你可以使用自定义指令(Custom Directive)的方式来实现前端埋点。


一个简单的Vue 3的前端埋点示例:


  • 创建一个名为analytics.js的文件,用于存放埋点逻辑:
// analytics.js

export default {
  mounted(el, binding) {
    const { eventType, eventData } = binding.value;

    // 发送数据请求
    this.$http.post('/track', {
      eventType,
      eventData,
    })
    .then(() => {
      console.log('Data sent successfully.');
    })
    .catch((error) => {
      console.error('Error sending data:', error);
    });
  },
};

  • 在Vue 3应用的入口文件中添加全局配置:
import { createApp } from 'vue';
import App from './App.vue';
import axios from 'axios';

const app = createApp(App);

// 设置HTTP库
app.config.globalProperties.$http = axios;

// 注册全局自定义指令
app.directive('analytics', analyticsDirective);

app.mount('#app');

  • 在组件中使用自定义指令,并传递相应的事件类型和数据:
<template>
  <button v-analytics="{ eventType: 'buttonClick', eventData: { buttonId: 'myButton' } }">Click Me</button>
</template>

在示例中,我们定义了一个全局的自定义指令v-analytics,它接受一个对象作为参数,对象包含了事件类型(eventType)和事件数据(eventData)。当元素被插入到DOM中时,自定义指令的mounted钩子函数会被调用,然后发送数据请求到后台服务器。


注意,在示例中使用了axios作为HTTP库发送数据请求,你需要确保项目中已安装了axios,并根据实际情况修改请求的URL和其他配置。


通过以上设置,你可以在Vue 3应用中使用自定义指令来实现前端埋点,采集并发送相应的事件数据到后台服务器进行存储和分析。请根据具体项目需求扩展和定制埋点事件和数据采集。


使用Composition API的方式来实现前端埋点


以下是一个Vue 3的前端埋点示例,使用Composition API来实现:

  • 创建一个名为analytics.js的文件,用于存放埋点逻辑:
// analytics.js

import { ref, onMounted } from 'vue';

export function useAnalytics() {
  const trackEvent = (eventType, eventData) => {
    // 发送数据请求
    // 模拟请求示例,请根据实际情况修改具体逻辑
    console.log(`Sending ${eventType} event with data:`, eventData);
  };

  onMounted(() => {
    // 页面加载事件
    trackEvent('pageView', {
      pageUrl: window.location.href,
    });
  });

  return {
    trackEvent,
  };
}

  • 在需要进行埋点的组件中引入useAnalytics函数并使用:
import { useAnalytics } from './analytics.js';

export default {
  name: 'MyComponent',
  setup() {
    const { trackEvent } = useAnalytics();

    // 按钮点击事件
    const handleClick = () => {
      trackEvent('buttonClick', {
        buttonId: 'myButton',
      });
    };

    return {
      handleClick,
    };
  },
};

  • 在模板中使用按钮并绑定相应的点击事件:
<template>
  <button id="myButton" @click="handleClick">Click Me</button>
</template>

在示例中,我们将埋点逻辑封装在了analytics.js文件中的useAnalytics函数中。在组件中使用setup函数来引入useAnalytics函数,并获取到trackEvent方法进行埋点操作。在模板中,我们将handleClick方法绑定到按钮的点击事件上。


当页面加载时,会自动发送一个pageView事件的请求。当按钮被点击时,会发送一个buttonClick事件的请求。


注意,在示例中,我们只是模拟了数据请求操作,请根据实际情况修改具体的发送数据请求的逻辑。


通过以上设置,你可以在Vue 3应用中使用Composition API来实现前端埋点,采集并发送相应的事件数据。请根据具体项目需求扩展和定制埋点事件和数据采集。


react 前端埋点示例


使用自定义 Hook 实现


当然!以下是一个 React 的前端埋点示例,


使用自定义 Hook 实现:

  • 创建一个名为 useAnalytics.js 的文件,用于存放埋点逻辑:
// useAnalytics.js

import { useEffect } from 'react';

export function useAnalytics() {
  const trackEvent = (eventType, eventData) => {
    // 发送数据请求
    // 模拟请求示例,请根据实际情况修改具体逻辑
    console.log(`Sending ${eventType} event with data:`, eventData);
  };

  useEffect(() => {
    // 页面加载事件
    trackEvent('pageView', {
      pageUrl: window.location.href,
    });
  }, []);

  return {
    trackEvent,
  };
}

  • 在需要进行埋点的组件中引入 useAnalytics 自定义 Hook 并使用:
import { useAnalytics } from './useAnalytics';

function MyComponent() {
  const { trackEvent } = useAnalytics();

  // 按钮点击事件
  const handleClick = () => {
    trackEvent('buttonClick', {
      buttonId: 'myButton',
    });
  };

  return (
    <button id="myButton" onClick={handleClick}>Click Me</button>
  );
}

export default MyComponent;

在示例中,我们将埋点逻辑封装在了 useAnalytics.js 文件中的 useAnalytics 自定义 Hook 中。在组件中使用该自定义 Hook 来获取 trackEvent 方法以进行埋点操作。在模板中,我们将 handleClick 方法绑定到按钮的点击事件上。


当页面加载时,会自动发送一个 pageView 事件的请求。当按钮被点击时,会发送一个 buttonClick 事件的请求。


请注意,在示例中,我们只是模拟了数据请求操作,请根据实际情况修改具体的发送数据请求的逻辑。


通过以上设置,你可以在 React 应用中使用自定义 Hook 来实现前端埋点,采集并发送相应的事件数据。根据具体项目需求,你可以扩展和定制埋点事件和数据采集逻辑。


使用高阶组件(Higher-Order Component)实现


当然!以下是一个 React 的前端埋点示例,


使用高阶组件(Higher-Order Component)实现:

  • 创建一个名为 withAnalytics.js 的高阶组件文件,用于封装埋点逻辑:
// withAnalytics.js

import React, { useEffect } from 'react';

export function withAnalytics(WrappedComponent) {
  return function WithAnalytics(props) {
    const trackEvent = (eventType, eventData) => {
      // 发送数据请求
      // 模拟请求示例,请根据实际情况修改具体逻辑
      console.log(`Sending ${eventType} event with data:`, eventData);
    };

    useEffect(() => {
      // 页面加载事件
      trackEvent('pageView', {
        pageUrl: window.location.href,
      });
    }, []);

    return <WrappedComponent trackEvent={trackEvent} {...props} />;
  };
}

  • 在需要进行埋点的组件中引入 withAnalytics 高阶组件并使用:
import React from 'react';
import { withAnalytics } from './withAnalytics';

function MyComponent({ trackEvent }) {
  // 按钮点击事件
  const handleClick = () => {
    trackEvent('buttonClick', {
      buttonId: 'myButton',
    });
  };

  return (
    <button id="myButton" onClick={handleClick}>Click Me</button>
  );
}

export default withAnalytics(MyComponent);

在示例中,我们创建了一个名为 withAnalytics 的高阶组件,它接受一个被包裹的组件,并通过属性传递 trackEvent 方法。在高阶组件内部,我们在 useEffect 钩子中处理页面加载事件的埋点逻辑,并将 trackEvent 方法传递给被包裹组件。被包裹的组件可以通过属性获取到 trackEvent 方法,并进行相应的埋点操作。


在模板中,我们将 handleClick 方法绑定到按钮的点击事件上。


当页面加载时,会自动发送一个 pageView 事件的请求。当按钮被点击时,会发送一个 buttonClick 事件的请求。


请注意,在示例中,我们只是模拟了数据请求操作,请根据实际情况修改具体的发送数据请求的逻辑。


通过以上设置,你可以在 React 应用中使用高阶组件来实现前端埋点,采集并发送相应的事件数据。


当然根据具体项目需求,你还可以扩展和定制埋点事件和数据采集逻辑。


作者:朦胧之
链接:https://juejin.cn/post/7274084216286183460
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

如何突破技术瓶颈(适合P6以下)

前言 最近在更新react组件库源码的文章,其实vue和其他框架都一样,就是我发现自己在一年前直接看这些源码(不调试),还是有点吃力的,然后就放弃了。 可最近不知道为啥,看这些源码对我来说没啥太大难度,直接干就完事了,不需要啥调试。自己好好回想了一下,为什么会...
继续阅读 »

前言


最近在更新react组件库源码的文章,其实vue和其他框架都一样,就是我发现自己在一年前直接看这些源码(不调试),还是有点吃力的,然后就放弃了。


可最近不知道为啥,看这些源码对我来说没啥太大难度,直接干就完事了,不需要啥调试。自己好好回想了一下,为什么会有这样的变化?也算帮助一些想突破自己技术瓶颈的同学。


有新人在下面留言说看到很焦虑,刚进前端领域的同学,你们首要任务是能完成业务开发,此时业务开发带给你的提升是最明显的,文章更多的是帮助业务api用熟之后的想有突破的同学,不用焦虑,哈哈。而且话说回来了,我在平时工作中看到不想突破的人基本占百分90%,无论大小厂,所以不突破也没啥,大部分人只是仅仅当一个普通工作而已。


结论


首先我得出结论是:

  • 最开始不要自己去读源码,看别人的文章和视频即可,目的是先接触比自己能力层次高的代码,为超越现有的能力铺路(后面详细谈怎么做)
  • 平时注意积累一些手写题的思路,网上面经很多,主要不是写出来,是理解原理,理解大于一切,不理解的东西终究会忘记,我们要积累的是能力,能力是第一!(后面详细谈),设计模式里的发布订阅者模式必须要理解!这是写很多库常见的技巧。
  • 最后开始独立去看一些小的代码库,比如腾讯,阿里,字节的组件库,这些库大部分组件难度低。

去哪里看视频和文章学源码


视频


最简易的就是跟着视频学,因为视频会把代码敲一遍,给你思考的时间,讲解也是最细的,很适合刚开始想造轮子的同学了解一些有难度的源码。


举个例子:


我当时看了koa的源码,了解了koa中间件的原理,我自己造了一个自动化发布脚本就利用了这个原理,redux中间件也是类似的原理,在函数式编程领域叫做compose函数,koa是异步compose,redux是同步compose,


简单描述下什么是compose函数


我把大象装进冰箱是不是要
1、打开冰箱门
2、装进去大象
3、关冰箱门


那么很多同学就会写一个函数

function 装大象(){
// 打开冰箱
// 装大象
// 关闭冰箱门
}

compose函数会把这个过程拆开,并且抽象化

// 把装大象抽象为装东西函数
function 装东西();
function 打开冰箱();
function 关闭冰箱();

compose(打开冰箱函数, 装东西函数,关闭冰箱函数)

此时compose把上面三个函数抽象为一个打开冰箱往里面装东西的函数,我们只需要把参数大象穿进去就抽象了整个过程

compose(打开冰箱函数, 装东西函数,关闭冰箱函数)(大象)

具体内容我还写过一篇文章,有兴趣的同学可以去看看:


终极compose函数封装方案!


这个大家应该有自己的去处,我自己的话很简单,视频一般去b站,就是bilibili,有些同学以为这是一个二次元网站是吧,其实里面免费的学习资料一抓一大把呢,啥都有。


比如说我在b站看了很多linux入门教学视频,还有一个培训公开课,讲的都是源码,什么手写react hook,手写webpack,手写xxx,那个时候说实话,听了视频也不是很理解,但是我还是挺喜欢前端的,没咋理解就继续听。


记住,我们需要短时间内提升能力,所以视频算是其中最快的了,其他方法不可能有这个来的快,并且没理解就算了,能理解多少是多少。


学习是一个螺旋上升的过程,不是一下子就全懂或者全不懂的,都是每次比上一次更懂一点。除非你是天才,急不来的。


视频搜索第二大去处就是论坛,一些论坛有各种各样的培训视频,这种论坛太多了,你谷歌或者百度一抓一大把。


对了,谷歌是爸爸,你懂我意思,不要吝啬小钱。在搜索学习资料面前,百度就是个弟弟。


文章


文章一定记住,在精不在多。


切记,每个人都处在不同的学习阶段,不要盲目追求所谓的大神文章,不一定适合你,比如说有些人刚接触前端,你去看有些有深度的文章对你没啥好处,浪费时间,因为你理解不了,理解不了的知识相当于没学,过两天就忘了。


文章选择范围,比如掘金,知乎还有前端公众号,基本上就差不多了,选一两个你觉得你这个阶段能吸收的,好好精读,坚持个一年你会发现不一样的。


额外的知识储备


前端3年前主流的前端书我都读过,什么红宝书,权威指南都读了好几遍了。


但有一本从菜鸟到高级-资深前端很推荐的一本是:JavaScript设计模式与开发实践(图灵出品)(腾讯的一位大哥写的,不是百度的那位,这两本书我都看过)


里面的知识点很干很干,里面有非常多的技巧,比如说你的同事写了一个函数,你不想破坏函数,有什么办法拓展它(其实我觉得我想的这些题就比前端八股文好玩多了,是开放性的)

  • 技巧很多,比如面向切面编程,加个before或者after函数包装一下
  • 比如责任链模式
  • 比如刚才的compose函数
  • 比如装饰器模式

确立自己的发展方向


大家其实最后都要面对一个很现实的问题,就是35以后怎么办,我个人觉得你没有对标阿里P7的能力,落地到中小公司都难。


所以我们看源码,看啥都是为了提升能力,延长职业寿命。


那么如何在短时间内有效的提升,你就需要注意不能各种方向胡乱探索,前端有小游戏方向,数据可视化方向,B端后台系统方向,音视频方向等等


我是做b端,那b端整个链路我就需要打通,组件库是我这个方向,所以我探索这里,还有node端也是,写小工具是必须的,但是你们说什么deno,其他的技术,我根本不在乎,没时间浪费在这些地方,当然除了有些业务上需要,比如之前公司有个ai标注需求,用canvas写了一个类似画板的工具,也算开拓了知识点,但这也不是我重点发展的方向,不深入。


我做组件库是为了后面的低代码,低代码平台的整体设计思路我已经想好了,整体偏向国外开源的appsmith的那种方式,然后打通组件间通信的功能,我认为是能胜任稍微复杂的b端业务场景的,而且可以走很多垂直领域,比如网站建站,微信文章编辑器这种。所以我才开始研究组件库的,因为低代码大多数复杂功能都在组件上。


工作上勇于走出舒适圈


为什么这个跟看源码相关呢,如果你做过比较复杂的项目,你会发现很多现成的第三方库满足不了。比如说我自己遇到过的大型sass项目,ant design就满足不了,所以你才发现,源码看得少加上业务急,代码就烂,时间上就留不出自己偷偷学习的时间,如果你想长期从事软件开发,没有成长是一件很危险的事(钱多当我没说,哈哈),因为无论如何,有本事,总没错的。


当你的业务难度上去的时候,会逼着你去提升能力,所以你如果想前端走的更远,建议不要在自己的舒适区太久,业务上选择一家比较难的公司,后面再跳槽就是沉淀这段时间的知识点了,当你能够有自信说,我现在带团队,从0到1再遇到那么难的业务时,能从容应对,恭喜你,你可以去面下阿里p7,不是为了这个工作啊,可以检验下是不是达到这个职位的标准了,我就喜欢偶尔面一下,也不是换工作,就是看看自己进步没


作者:孟祥_成都
链接:https://juejin.cn/post/7168671474234949662
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

读完React新文档后的8条收获

不久前,React官网进行了重构。新官网将hook作为介绍的重头戏,重新通读了一遍react文档,有了一些自己的感悟。在此分享出来与各位共勉。 1. 换个角度认识Props与State Props与State是React中两个略有相似的概念。在一个React组...
继续阅读 »

不久前,React官网进行了重构。新官网将hook作为介绍的重头戏,重新通读了一遍react文档,有了一些自己的感悟。在此分享出来与各位共勉。


1. 换个角度认识Props与State


PropsState是React中两个略有相似的概念。在一个React组件中,它们作为数据来源可能同时存在,但它们之间有着巨大不同:

  1. Props更像函数中的参数。它主要用于组件之间的信息传递,它可以是任意类型的数据:数字、文本、函数、甚至于组件等等。
  2. State更像组件中的内存。它可以保存组件内部的状态,组件通过跟踪这些状态,从而渲染更新。
import React, { useState } from 'react';

// 父组件
const ParentComponent = () => {
const [count, setCount] = useState(0); // 使用state来追踪count的值

return (
<div>
<ChildComponent age={25} />
<p>Count: {count}</p>
</div>
);
};

// 子组件
const ChildComponent = (props) => {
const { age } = props; // 使用props来获取父组件传递的数据

return (
<div>
<p>Age: {age}</p>
</div>
);
};

2. 不要嵌套定义组件


在一个组件中直接定义其他组件,可以省去很多传递Props的工夫,看上去很好。但我们不应该嵌套定义组件,原因在于**嵌套定义组件会导致渲染速度变慢,也更容易出现BUG**。
我们在嵌套定义组件的情况下,当父组件更新时,内部嵌套的子组件也会重新生成并渲染,子组件内部的state也会丢失。针对这种情况,我们可以通过两种方式来解决:

  1. 为子组件包上useMemo,避免不必要的更新;
  2. 不再嵌套定义,将子组件提至父组件外,通过Props传参。

这里更推荐第二种方法,因为这样代码更少,结构也更清晰,组件迁移维护都会更方便一些。

//🔴 Bad Case
export default function Gallery() {
function Profile() {
// ...
}
// ...
}
//✅ Good Case
function Profile() {
// ...
}

export default function Gallery() {
// ...
}

3. 尽量不要使用匿名函数组件


因为类似export default () => {}的匿名函数组件书写虽然方便,但会让debug变得更困难
如下是两种不同类型组件出错时的控制台的表现:

  1. 具名组件出错时的提示,可直接的指出错的函数组件名称: 


  1. 匿名函数出错时的提示,无法直观确定页面内哪个组件出了错: 



4. 使用逻辑运算符&&编写JSX时,左侧最好不要是数字


运算符&&在JSX中的表现与JS略有不同:

  • 在JS中,只有在&&左侧的值0nullundefinedfalse''等假值时,才会返回右侧值;
  • 在JSX中,React对于falsenullundefined并不会做渲染处理,所以进行&&运算后,如果左侧值为假被返回后,会出现与直觉不同的渲染结果:可能会碰到页面上莫名奇妙出现了一个0的问题。为了避免出现这种情况,可以在书写JSX时,为左侧的值加上!!来进行强制类型转换。
const flag = 0
//🔴 Bad Case
{
flag && <div>123</div>
}
//✅ Good Case 1
{
!!flag && <div>123</div>
}
//✅ Good Case 2
{
flag > 0 && <div>123</div>
}

关于JSX对各种常见假值的渲染,这里进行了总结:

  1. nullundefinedfalse:这些值在JSX中会被视为空,不会渲染任何内容。它们会被忽略,并且不会生成对应的DOM元素。
  2. 0NaN:这些值会在页面上渲染为字符串"0"、"NaN"。
  3. ''[]{}:空字符串会被渲染为什么都没有,不会显示任何内容。


注:这里感谢@小明家的bin的评论提醒,他的见解对我起到了很大的启发作用。



5.全写的 Fragment标签上可以添加属性key


在map循环添加组件时,需要为组件添加key来优化性能。如果待循环的组件有多个时,可以在外层包裹<Fragment>...</Fragment>标签,在其上添加key添加在<Fragment>...</Fragment>来避免创建额外的组件。

const list = [1,2,3]
//🔴 Bad Case
//不能添加key
{
list.map(v=><> <div>1-1</div> <div>1-2</div> </>)
}
//🔴 Bad Case
//创建了额外的div节点
{
list.map(v=><div key={v}> <div>1-1</div> <div>1-2</div> <div/>)
}
//✅ Good Case
{
list.map(v=><Fragment key={v}> <div>1-1</div> <div>1-2</div> </Fragment>)
}



注意简写的Fragment标签<>...</>上不支持添加key



6. 可以使用updater function,来在下一次渲染之前多次更新同一个state


React中处理状态更新时采用了批处理机制,在下一次渲染之前多次更新同一个state,可能不会达到我们想要的效果。如下是一个简单例子:

// 按照直觉一次点击后button中的文字应展示为3,但实际是1
function Demo(){
const [a,setA] = useState(0)

function handler(){
setA(a + 1);
setA(a + 1);
setA(a + 1);
}

return <button onclick={handler}>{a}</button>
}

在某些极端场景下,我们可能希望state的值能够及时发生变化,这时便可以采用setA(n => n + 1)(这种形式被称为updater function)来进行更新:

// 一次点击后a的值会被更新为3
function Demo(){
const [a,setA] = useState(0)

function handler(){
setA(n => n + 1);
setA(n => n + 1);
setA(n => n + 1);
}

return <button onclick={handler}>{a}</button>
}

7. 管理状态的一些原则


更加结构化的状态有助于提高我们组件的健壮性,以下是一些管理组件内状态时可以参考的原则:

  1. 精简相关状态:如果在更新某个状态时总是需要同步更新其他状态变量,可以考虑将它们合并为一个状态变量。
  2. 避免矛盾状态:避免出现两个或多个状态的值互相矛盾的情况。
  3. 避免冗余状态:一个状态可以由其余状态计算而来时,其不应单独作为一个状态来进行管理。
  4. 避免状态重复:当相同的数据在多个状态变量之间或嵌套对象中重复时,很难使它们保持同步。尽可能减少重复。
  5. 避免深度嵌套状态:嵌套层级过深的状态更新时相对麻烦许多,尽量保持状态扁平化。

8. 使用useSyncExternalStore订阅外部状态


useSyncExternalStore是一个React18中新提供的一个Hook,可以用于订阅外部状态。
它的使用方式如下:

import { useSyncExternalStore } from 'react';

function MyComponent() {
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
// ...
}

useSyncExternalStore接受三个参数:

  • subscribe:一个订阅函数,用于订阅存储,并返回一个取消订阅的函数。
  • getSnapshot:一个从存储中获取数据快照的函数。在存储未发生变化时,重复调用getSnapshot应该返回相同的值。当存储发生变化且返回值不同时,React会重新渲染组件。
  • getServerSnapshot(可选):一个在服务器端渲染时获取存储初始快照的函数。它仅在服务器端渲染和在客户端进行服务器呈现内容的hydration过程中使用。如果省略该参数,在服务器端渲染组件时会抛出错误。

举一个具体的例子,我们可能需要获取浏览器的联网状态,并期望在浏览器网络状态发生变化时做一些处理。使用useSyncExternalStore可以很大程度上简化我们的代码,同时使逻辑更加清晰:

//🔴 Bad Case
function useOnlineStatus() {
// Not ideal: Manual store subscription in an Effect
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function updateState() {
setIsOnline(navigator.onLine);
}

updateState();

window.addEventListener('online', updateState);
window.addEventListener('offline', updateState);
return () => {
window.removeEventListener('online', updateState);
window.removeEventListener('offline', updateState);
};
}, []);
return isOnline;
}

function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}

// ✅ GoodCase
function useOnlineStatus() {
return useSyncExternalStore(
subscribe, // React won't resubscribe for as long as you pass the same function
() => navigator.onLine, // How to get the value on the client
() => true // How to get the value on the server
);
}

function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}

结语


文章的最后,再来一次无废话总结:

  1. 更清晰地认识了Props与State之间的区别。Props更像是函数的参数,用于组件之间的信息传递;而State更像是组件内部的内存,用于保存组件的状态并进行渲染更新。
  2. 不推荐在一个组件内部嵌套定义其他组件,因为这样会导致渲染速度变慢并容易产生BUG。推荐将子组件提到父组件外部并通过Props传递数据。
  3. 尽量避免使用匿名函数组件,因为在出错时会增加调试的难度。具名组件的出错提示更加直观和准确。
  4. 在使用逻辑运算符&&编写JSX时,左侧最好不要是数字。在JSX中,0会被当作有效的值,而不是假值,为了避免出现问题,可以在左侧的值加上!!进行强制类型转换。
  5. 当在使用全写的Fragment标签时,可以给Fragment标签添加属性key,以优化性能和避免创建额外的组件。
  6. 使用updater function的方式进行状态更新,可以确保在下一次渲染之前多次更新同一个state。这样可以避免批处理机制带来的问题。
  7. 在管理组件内状态时,可以遵循一些原则,如精简相关状态、避免矛盾状态、避免冗余状态、避免状态重复等,以提高组件的健壮性和可维护性。
  8. 使用useSyncExternalStore可以订阅外部状态,它是React 18中新增的Hook。通过订阅函数、获取数据快照的函数以及获取服务器初始快照的函数,我们可以简化订阅外部状态的代码逻辑。

通过对React文档的深入学习和实践,我对React的理解更加深入了解,希望这些收获也能对大家在学习和使用React时有所帮助。


作者:星始流年
链接:https://juejin.cn/post/7233324859932442680
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

不要因bundle size太大责备开发者

前言 大家好我是飞叶,一个前端UP主,最近外网有个新的SSR框架Qwik比较火,GitHub上它的仓库star数以每天100的速度在增长,今天就通过翻译一篇文章来引入QWIK。 当然如果你不想读这些,我也录了一个视频讲这篇文章。讲解视频在这里 原文链接 ht...
继续阅读 »

前言


大家好我是飞叶,一个前端UP主,最近外网有个新的SSR框架Qwik比较火,GitHub上它的仓库star数以每天100的速度在增长,今天就通过翻译一篇文章来引入QWIK。


当然如果你不想读这些,我也录了一个视频讲这篇文章。讲解视频在这里



原文链接 http://www.builder.io/blog/dont-b…



不要因bundle size太大责备开发者


让我们谈谈我们构建 Web 应用程序所必须使用的工具,以及这些工具如何欺骗我们。


开发者们共同的故事


你要创建一个新项目,此时,你信心满满这个新站点会很快很流畅。
在一开始,事情看起来确实如此,但是很快你的应用就变大了变复杂了,应用的开启性能变慢了。
在不知不觉中,您手头上有一个巨大的应用程序,而您却无能为力地修复它。你哪里做错了?


我们用的每个工具/框架都承诺提供更好、更快的结果,
但我们通过访问整个互联网里的应用就知道,结果绝不是一个更好、更快的站点。
谁应该为此负责?开发者吗?


作为开发者,你是否有被告知:"就是你们搞砸了,你们偷工减料,才导致了一个性能差的站点。"


这不是你的错


如果一些网站速度较慢,而另一些网站速度较快,那么当然,责备开发人员可能是有道理的。
但真实情况是:所有网站都很慢!
当没有人成功时,你怎么能责怪开发者呢?问题是系统性的。也许这不是开发者的错。


这是一个关于我们如何构建应用程序、我们使用的工具、工具做出的承诺以及我们最终遇到的缓慢站点的故事。


只有一个结论。这些工具都过度承诺了,这是整个行业的系统性问题。这不仅仅是几个坏苹果,而是整个万维网。


代码太多了


我们都知道问题是什么:代码太多!我们非常擅长创建代码,浏览器无法跟上我们的脚步。
每个人都告诉你你的网站有太多的 JavaScript,但没有人知道如何缩小它。


这就像一个 YouTube 健身频道告诉你减肥所需要做的就是少摄入卡路里。
简单的建议,但成功率令人沮丧。
原因是该建议忽略了食欲。
当你又饿又虚弱并且只想到食物时,又有几个人能做到 减少卡路里摄入的意愿呢?
所以也许减肥成功的秘诀可能不是减少卡路里,而是如何控制你的食欲。


这个例子很类似于 JavaScript 膨胀的情况。
我们知道我们需要更少的 JavaScript,
但是我们有太多需求,除了代码要写,还有太多工具和轮子要用,(才能满足需求)
但是所有这些 代码 和工具 都会源源不断地使我们的应用越来越大。




打包的演变历史


让我们先看看我们是如何陷入这种境地的,然后再讨论前进的道路。


第 0 代:串联


在 ECMAScript 模块之前,什么都没有,只是文件。
打包过程很简单。这些文件被连接在一起并包装在 IIFE 中。


好处是很难向您的应用程序添加更多代码,因此bundle size保持较小。


第 1 代:打包器


ECMAScript 模块来了。
打包器也出现了:WebPack、Rollup 等。


然而,npm install 一个依赖并把它打包进去有点太容易了。很快,bundle size就成了一个问题。


庆幸的是,这些打包器知道如何进行tree shaking和死代码消除。这些功能确保只有用到的的代码才被打包。


第 2 代:延迟加载


意识到bundle size过大的问题, 打包器开始提供延迟加载。
延迟加载很棒,因为它允许将代码分解成许多chunks并根据需要交付给浏览器。
这很棒,因为它允许从最需要的部分开始 分批交付应用程序。


问题在于,在实践中,我们是使用框架来构建应用程序的,而框架对打包程序如何将我们的代码分解为延迟加载的块有很大影响。
问题在于延迟加载块需要引入异步API调用。
如果框架需要对您的代码进行同步引用,则打包器不能引入延迟加载的块。


所以我们需要明白,虽然打包器声称他们可以延迟加载代码,而且这也是真的,
但想做到延迟加载有个前提条件,即我们使用的框架得让开发者使用promise(来懒加载chunk),否则您可能没有太多选择。


第 3 代:延迟加载不在渲染树中的组件


框架迅速争先恐后地利用打包器的延迟加载功能,如今几乎所有人都知道如何进行延迟加载。
但是有一个很大的警告!框架只能延迟加载不在当前渲染树中的组件。




什么是渲染树?它是构成当前页面的一组组件。
应用程序通常具有比当前页面上更多的组件。
通常,渲染树包含视图(这是您当前在浏览器视口中看到的内容)内组件。
和一部分视图之外的组件。


假设一个组件在渲染树中。在这种情况下,框架必须下载组件,因为框架需要重建组件的渲染树,(这是hydration的一部分工作)。
框架只能延迟加载当前不在渲染树中的组件。


另一点是框架可以延迟加载组件,但总是包含行为。
因为组件包含了行为,这个懒加载的单位就太大了。如果可以延迟加载的单位更小会更好。
渲染组件不应要求下载组件的事件处理程序。
框架应该只在用户交互时才下载事件处理程序,而不是作为组件渲染方法的一部分。根
据您正在构建的应用程序的类型,事件处理程序可能代表您的大部分代码。
所以耦合组件的渲染和行为的下载是次优的。


问题的核心


仅在需要重新渲染组件时才延迟加载组件渲染函数,并且仅在用户与事件处理程序交互时才延迟加载事件处理程序。
这样才是最好的!
默认应该是所有内容都是延迟加载的。


但这种方法存在一个大问题。问题是框架需要协调其内部状态与 DOM。
这意味着至少需要一次hydration,来进行完整渲染以重建框架的内部状态。
在第一次渲染之后,框架可以对其更新进行更准确的把控,但问题已经产生了,因为代码已经下载了。所以我们有两个问题:

  • 框架需要下载并执行组件以在启动时重建渲染树。(请参阅hydration 是纯粹的开销)这会强制下载和执行渲染树中的所有组件。
  • 事件处理程序随组件一起提供,即使在渲染时不需要它们。包含事件处理程序会强制下载不必要的代码。

因此,当今框架的现状是,必须急切地下载和执行 SSR/SSG 渲染树中的每个组件(及其处理程序)。
使用当今的框架进行延迟加载有点说谎,因为您并不能在初始页面呈现时进行延迟加载。


值得指出的是,即使开发人员将延迟加载边界引入 SSR/SSG 初始页面,也无济于事。
框架仍需下载并执行 SSR/SSG 响应中的所有组件;因此,只要组件在渲染树中,框架就必须急切地加载开发人员试图延迟加载的组件。


渲染树中组件的急切下载是问题的核心,开发人员对此无能为力。
尽管如此,这并不能阻止开发人员因网站运行缓慢而受到指责。


下一代:细粒度的延迟加载


那么,我们该何去何从?显而易见的答案是我们需要更细粒度。该解决方案既明显又难以实施。我们需要:

  • 更改框架,这样它们就不会在hydration阶段急切地加载渲染树。
  • 允许组件渲染函数 独立于组件事件处理程序 单独下载。

如果您的框架可以完成上述两个部分,那么用户将看到巨大的好处。
应用程序的启动要求很少,因为启动时不需要进行渲染(内容已经在 SSR/SSG 处渲染)。
下载的代码更少:当框架确定需要重新渲染特定组件时,框架可以通过下载渲染函数来实现,而无需下载所有事件处理程序。


细粒度的延迟加载将是网站启动性能的巨大胜利。
它要快得多,因为下载的代码量将与用户交互性成正比,而不是与初始渲染树的复杂性成正比。
您的网站会变得更快,不是因为我们更擅长使代码更小,而是因为我们更擅长只下载我们需要的东西,而不是预先下载所有东西。




入口点 entry point


拥有一个可以进行细粒度延迟加载的框架是不够的。
因为,要利用细粒度的延迟加载,您必须首先拥有要延迟加载的bundles。


为了让打包器创建延迟加载的chunk,打包器需要每个块的入口点。
如果您的应用程序只有单个入口点,则打包器无法创建多个chunks。
如果您的应用程序只有单个入口点,即使你的框架可以进行细粒度的延迟加载,它也没有什么可以延迟加载的。


现在创建入口点很麻烦,因为它需要开发人员编写额外的代码。
在开发应用程序时,我们真的只能考虑一件事,那就是写功能。
让开发人员同时考虑他们正在构建的功能和延迟加载对开发人员来说是不公平的。
所以在实践中,为打包器创建入口点很麻烦。


所需要的是一个无需开发人员考虑就可以创建入口点的框架。
为打包程序创建入口点是框架的责任,而不是开发人员的责任。
开发人员的职责是构建功能。
该框架的职责是考虑应该如何完成该功能的底层实现。
如果框架不这样做,那么它就不能完全满足开发人员的需求。


担心切入点太多?


目标应该是创建尽可能多的入口点。
但是,有些人可能会问,这是不是就会导致下载很多小块而不是几个大块吗?答案是响亮的“不”。


如果没有入口点,打包器就无法创建chunk。
但是打包器可以将多个入口点放入一个chunk中。
您拥有的入口点越多,您以最佳方式组装bundle的自由度就越大。
入口点给了你优化bundle的自由。所以它们越多越好。


未来的框架


下一代框架将需要解决这些问题:

  • 拥有人们喜欢的开发体验DX。
  • 对代码进行细粒度的延迟加载。
  • 自动生成大量入口点以支持细粒度的延迟加载。

开发人员将像现在一样构建他们的网站,但这些网站不会在应用程序启动时用下载和执行一个很大的bundle来压倒浏览器。


Qwik是一个在设计时考虑到这些原则的框架。Qwik细粒度延迟加载是针对每个事件处理程序、渲染函数和effect的。


结论


我们的网站越来越大,看不到尽头。
它们之所以大,是因为这些网站今天比以前做得更多——更多的功能、动画等。并且这种趋势将继续下去。


上述问题的解决方案是对代码进行细粒度的延迟加载,这样浏览器就不会在初始页面加载时不堪重负。


我们的打包工具支持细粒度的延迟加载,但我们的框架不支持。
框架hydration强制渲染树中的所有组件在hydration时加载。(目前的SSR框架唯一的延迟加载是 当前不在渲染树中的组件。)
即使事件处理程序可能是代码的大部分,并且hydration并不需要事件处理器代码,现在的SSR框架还是随组件的下载一并下载了事件处理程序.


因为打包器可以细粒度的延迟加载,但我们的框架不能,我们无法识别其中的微妙之处。
导致的结果就是我们将网站启动缓慢归咎于开发人员,
因为我们错误地认为他们本可以采取一些措施来防止这种情况发生,尽管现实是他们在这件事上几乎没有发言权。


我们需要将细粒度延迟加载设计为框架核心功能的新型框架(例如Qwik )。
我们不能指望开发者承担这个责任;他们已经被各种功能淹没了。
框架需要考虑延迟加载运行时以及创建入口点,以便打包程序可以创建块以进行延迟加载。
下一代框架带来的好处将超过迁移到它们所花费的成本。


作者:飞叶_前端
链接:https://juejin.cn/post/7154948728871190535
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

被裁员半年了,谈谈感想

后端开发,22年9月,跳槽到某新能源生态企业,23年3月中旬的某个周一下午,被HR通知到会议室做个沟通,两周前收到转正答辩PPT模板让我填写,原本以为是做转正答辩的相关沟通,结果是沟通解除劳动合同,赔偿N+1,第二天就是lastday。 进入公司后经历了几次组...
继续阅读 »

后端开发,22年9月,跳槽到某新能源生态企业,23年3月中旬的某个周一下午,被HR通知到会议室做个沟通,两周前收到转正答辩PPT模板让我填写,原本以为是做转正答辩的相关沟通,结果是沟通解除劳动合同,赔偿N+1,第二天就是lastday。

进入公司后经历了几次组织架构调整,也不断变化着业务形态,但本着拥抱变化的心态,想着会越来越好,又想着自己技术在同事间也不会排到后面,所以对于这个突发状况毫无准备。


心路历程


首月


刚刚经历裁员,下个月会有工资、奖金和赔偿金入账,赔偿金不扣税,同时对于市场环境没有了解,比较乐观。首月的想法就是写简历,并开始投递,先投不想去的公司找面试经验;找学习资料、刷题;期望薪资是不需要涨薪,大概平薪就行。

首月面了三家公司,发现了自己的诸多漏洞,项目比较垂类,讲解过程混乱;基础知识复习不足,很多新出来的延展概念了解不够。


第二个月


上个月期盼的奖金到账了,有些庆幸,又有些失落。庆幸的是收到一笔不菲的补偿金,失落的是下月开始就没有收入了。

发现面试机会变少了,整月才面了三四家,这个月发现的问题,更多的是从架构角度来的,诸如幂等、一致性hash等场景,个人了解的相对简单了。


第三个月


广深的工作机会实在是少,开始同时投递其他城市的岗位试水。月初一家公司现场面了4轮都很顺利,第二天最后一轮CTO面,被嘲讽之前业务简单,比较受打击。月底面其他城市的岗位,一面过后第二天晚上10点又被拉上线做一面的补充面。

开始焦虑了,一想到还没找到工作,补偿金估计一两个月也会花完,可能要动用积蓄了,心跳就加速,越想越加速。努力想让自己变得不去想,只去想没有掌握的知识点,算是熬过了这个月。


第四个月


这个月,感觉蛮顺利,月初面一家大厂,技术面、主管面、HR面、提交资料都很顺利,感觉稳了,每天都看看公众号的面试状态,希望能快点沟通offer;月中也走完了一家中厂的4轮面试流程;月底又走完了另一家新能源车企的面试流程。

整个月过完,自己感觉飘了,感觉同时手握3个offer机会,晚几天随便一家给offer call就去了。个人心态一下子就变了,月内简历几乎没怎么投了,看知识点好像也没那么认真了。


第五、第六个月


好吧,上个月的3个机会,全都没有等来,继续面试。心态有点躺平,焦虑感少了,颓废感来了,BOSS直聘岗位几乎能投的都投过了,没有面试的日子,会过得略显浑浑噩噩,不知道要做什么。
陆续来了几个offer,也终于决定下来了,降薪差不多40%,但好在稳定性应该有保障。


心态的转变

  • 从渴望周末,到期盼工作日

    工作时渴望周末的休息 ,没找到工作时,每一个周末的到来,都意味着本周没有结果,而过完周末,意味着过完了1/4月。感觉日子过得好快,以前按天过,现在按周过,半年时间感觉也只是弹指一挥间。
    每一个周一的到来,意味着拥抱新的机会。每周的面试频率比较高时,会感到更充实;面试频率低下来时,焦虑感会时不时的涌上心头,具体表现是狂刷招聘软件,尝试多投递几个职位。

  • 肯定 -> 否定 -> 肯定

    找工作初期,信心满满。定制计划,每天刷多少题,每天看什么知识点,应该按照什么节奏投递简历,自己全都规划好了
    中期,备受打击,总有答不上来的问题,有些之前看过的知识点,临场也会突然忘记,感觉太糟糕了。
    后期,受的打击多了,自己不会的越来越少,信心又回来了


可能能解决你的问题


要不要和家里人说


自己这半年下来,没有和家里人说,每周还是固定时间给家里打电话,为了模拟之前在路边遛弯打电话,每次电话都会坐在阳台。

个人情况是家在北方,本人在南方,和爸妈说了只能徒增他们的焦虑,所以我先瞒着了。


被裁员,是你的问题吗?


在找工作的初期,总会这样问自己,是不是自己选错了行业,是不是自己不该跳槽,会陷入一种自责的懊恼情绪。请记住,你没有任何问题,你被裁员是公司的损失,你不需要为此担责,你需要做的是让自己更强,不管是心理、身体还是技术。


用什么招聘软件


我用了BOSS直聘和猎聘两个,建议准备好了的话,可以再多搞几个平台海投。另外需要注意几点:

  1. 招聘者很久没上线,对应岗位应该是不招的
  2. 猎聘官方会不定期打电话推荐岗位,个人感觉像是完成打电话KPI,打完电话或加完微信后就没有后续跟进消息了
  3. 你看岗位信息,招聘者能看到你的查看记录,如果对某个岗位感兴趣,怕忘记JD要求,可以截图保存,避免暴露特别感兴趣的想法被压价

在哪复习


除非你已经有在家里持续专注学习的习惯,否则不管你有没有自己的书房,建议还是去找一个自习室图书馆,在安静的氛围中,你会更加高效、更加专注。

如果只能在家里复习,那么远离你的手机,把手机放到其他房间,并确保有电话你能听到,玩手机会耗费你的专注力和执行力。

(你在深圳的话,可以试试 南山书房 ,在公众号可以预约免费自习室,一次两小时)


如何度过很丧的阶段


多多少少都会有非常沮丧的阶段,可能是心仪的offer最终没有拿到手,可能是某些知识点掌握不牢的自我批判。

沮丧需要一个发泄的出口,可以保持运动习惯,比如日常爬楼梯、跑步等,一场大汗淋漓后,又是一个打满鸡血积极向上的你。

不要总在家待着,要想办法出门,多建立与社会的联系,社会在一直进步,你也不能落下。


一些建议


1. 项目经历


讲清楚几点:

  • 项目背景

    让人明白项目解决了什么问题,大概是怎么流转的,如果做的比较垂类,还需要用通俗易懂的话表达项目中的各个模块。

  • 你在其中参与的角色

    除了开发之外,是否还承担了运维、项目管理等职责,分别做了什么

  • 取得的成果

    你的高光时刻,比如解决了线上内存泄漏问题、消息堆积问题、提升了多少QPS等,通常这些亮点会被拿出来单独问,所以成果相关的延展问题也需要提前想好


还比较重要的是,通过项目介绍,引导面试官的问题走向,面试只通过几十分钟的时间来对你做出评价,其实不够客观,你需要做的是在这几十分钟的时间内尽可能的放大你的优势



除此之外,还需要做项目的延展思考



比如我自己,刚工作时做客户端开发,负责客户端埋点模块的重构,面试时被问到,“如果让你设计一个埋点服务端系统,你会考虑哪些方面”? 对于这类问题,个人感觉需要在场景设计类题目下功夫,需要了解诸如秒杀抢购等场景的架构实现方案,以及方案解决的痛点问题,这类问题往往需要先提炼痛点问题,再针对痛点问题做优化。


2. 知识点建议


推荐两个知识点网站,基本能涵盖80%的面试知识点,通读后基本能实现知识点体系化

常用八股 -- JavaGuide

操作系统、网络、MYSQL、Redis -- 小林coding


知识成体系,做思维导图进行知识记忆

那么多知识点,你是不可能全都记全的,部分知识点即使滚瓜烂熟了,半个月后基本也就忘光了。让自己的知识点成框架、成体系,比如Redis的哨兵模式是怎么做的,就需要了解到因为要确保更高的可用性,引入了主备模式,而主备模式不能自动进行故障切换,所以引入了哨兵模式做故障切换。

不要主观认为某个知识点不会被问到

不要跳过任何一个知识点,不能一味的把认为不重要的知识点往后放,因为放着放着可能就不会去看了。建议对于此类知识点,先做一个略读,做到心中大概有数,细节不必了解很清楚,之后有空再对细节查漏补缺。

之前看到SPI章节,本能认为不太重要,于是直接略过,面试中果然被问到(打破双亲委派模型的方式之一),回过头再去看,感觉其实不难,别畏惧任何一个知识点。

理论结合实践

不能只背理论,需要结合实践,能实践的实践,不能实践的最好也看看别人的实现过程。

比如线程顺序打印,看知识点你能知道可以使用join、wait/notify、condition、单线程池等方式完成,但如果面试突然让你写,对于api不熟可能还是写不出。

又比如一些大型系统的搭建,假如是K8S,你自己的机器或者云服务器没有足够的资源支撑一整套系统的搭建,那么建议找一篇别人操作的博客细品。

不要强关联知识点

被面试官问到一些具体问题,不要强行回答知识点,可能考察的是一个线上维护经验,此时答知识点可能给面试官带来一个理论帝,实操经验弱的感觉。

举两个例子,被问过线上环境出问题了,第一时间要如何处理?,本能的想到去看告警、基于链路排查工具排查是哪个环节出了问题,但实际面试官想得到的答案是版本回滚,第一时间排查出问题前做了什么更新动作,并做相应动作的回滚;又被问过你调用第三方服务失败了,如何本地排查问题?,面试官想考察的是telnet命令,因为之前出现过网络环境切换使用不同hosts配置,自己回答的是查看DNS等问题,这个问题问的并不漂亮,但是也反映出强关联知识点的问题。

建立自己的博客,并长期更新

养成写博客的习惯,记录自己日常遇到的问题,日常的感受,对于某些知识点的深度解析等。面试的几十分钟,你的耐心,你解决问题的能力,没办法完全展示,这时候甩出一个持续更新的博客,一定是很好的加分项。同时当你回顾时,也是你留下的积累和痕迹。



半年很长,但放在一生来看却又很短

不管环境怎样,希望你始终向前,披荆斩棘

如果你也正在经历这个阶段,希望你早日上岸


作者:雪梨酒色托帕石
链接:https://juejin.cn/post/7274229908314308666
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

为什么别人的 hooks 里面有那么多的 ref

前言 最近因为一些原因对useCallback有了一些新的认知,然后遇到一个朋友说他面试的时候被问到如何实现一个倒计时的hooks的时候,想到了这个问题,突然的茅塞顿开,对react 中的hooks有了新的了解,所以有了这篇文章,记录一下我个人的想法。 在...
继续阅读 »

前言



最近因为一些原因对useCallback有了一些新的认知,然后遇到一个朋友说他面试的时候被问到如何实现一个倒计时的hooks的时候,想到了这个问题,突然的茅塞顿开,对react 中的hooks有了新的了解,所以有了这篇文章,记录一下我个人的想法。



在学习一些开源的库的时候,很容易发现开源库中 hooks 里面会写很多的 ref 来存储hooks的参数。


使用了 ref 之后,使用变量的地方就需要 .current 才能拿到变量的值,这比我直接使用变量肯定是变得麻烦了。对于有代码洁癖的人来说,这肯定是很别扭的。


但是在开源库的 hooks 中频繁的使用了 ref,这肯定不是一个毫无原因的点,那么究竟是什么原因,让开源库也不得不使用 .current 去获取变量呢?


useCallback


先跑个题,什么时候我们需要使用 useCallback 呢?


每个人肯定有每个人心中的答案,我来讲讲我的心路历程吧。


第一阶段-这是个啥


刚开始学react的时候,写函数式组件,我们定义函数的时候,肯定是不会有意识的把这个函数使用 useCallback 包裹。就这样写了一段时间的代码。


突然有一天我们遇到了一个问题,useEffect无限调用,找了半天原因,原来是因为我们还不是很清楚useEffect依赖的概念,把使用到的所有的变量一股脑的塞到了依赖数组里面,碰巧,我们这次的依赖数组里面有一个函数,在react每一次渲染的时候,函数都被重新创建了,导致我们的依赖每一次都是新的,然后就触发了无限调用。


百度了一圈,原来使用 useCallback 缓存一下这个函数就可以了,这样useEffect中的依赖就不会每一次都是一个新值了。


小总结: 在这个阶段,我们第一次使用 useCallback ,了解到了它可以缓存一个函数。


第二阶段-可以缓存


可以缓存就遇到了两个点:

  1. 缓存是吧,不会每一次都重新创建是吧,这样是不是性能就能提高了!那我把我所有用到的函数都使用 useCallback缓存一下。
  2. react 每一次render的时候会导致子组件重新渲染,使用memo可以缓存这个子组件,在父组件更新的时候,会浅层的比较子组件的props,所以传给子组件的函数就需要使用缓存useCallback起来,那么父组件中定义函数的时候图方便,一股脑的都使用 useCallback缓存。

小总结: 在这里我们错误的认为了缓存就能够帮助我们做一些性能优化的事情,但是因为还不清楚根本的原因,导致我们很容易就滥用 useCallback


第三阶段-缓存不一定是好事


在这个阶段,写react也有一段时间了,我们了解到处处缓存其实还不如不缓存,因为缓存的开销不一定就比每一次重新创建函数的开销要小。


在这里肯定也是看了很多介绍 useCallback的文章了,推荐一下下面的文章


how-to-use-memo-use-callback,这个是全英文的,掘金有翻译这篇文章的,「好文翻译」


小总结: 到这里我们就大概的意识到了,处处使用useCallback可能并不是我们想象的那样,对正确的使用useCallback有了一定的了解


总结


那么究竟在何时应该使用useCallback呢?

  1. 我们知道 react 在父组件更新的时候,会对子组件进行全量的更新,我们可以使用 memo对子组件进行缓存,在更新的时候浅层的比较一下props,如果props没有变化,就不会更新子组件,那如果props中有函数,我们就需要使用 useCallback缓存一下这个父组件传给子组件的函数。
  2. 我们的useEffect中可能会有依赖函数的场景,这个时候就需要使用useCallback缓存一下函数,避免useEffect的无限调用

是不是就这两点呢?那肯定不是呀,不然就和我这篇文章的标题联系不起来了吗。


针对useEffect这个hooks补充一点react官方文档里面有提到,建议我们使用自定义的 hooks 封装 useEffect

  • 那使用useCallback的第三个场景就出现了,就是我们在自定义hooks需要返回函数的时候,建议使用 useCallback缓存一下,因为我们不知道用户拿我们返回的函数去干什么,万一他给加到他的useEffect的依赖里面不就出问题了嘛。

一个自定义hook的案例



实现一个倒计时 hooks



需求介绍


我们先简单的实现一个倒计时的功能,就模仿我们常见的发短息验证码的功能。页面效果




app.jsx




MessageBtn.jsx



 功能比较简单,按钮点击的时候创建了一个定时器,然后时间到了就清除这个定时器。


现在把 MessageBtn 中倒计时的逻辑写到一个自定义的hooks里面。


useCountdown


把上面的一些逻辑抽取一下,useCountdown主要接受一个倒计时的时长,返回当前时间的状态,以及一个开始倒计时的函数




这里的start函数用了useCallback,因为我们不能保证用户的使用场景会不会出问题,所以我们包一下


升级 useCountdown


现在我们期望useCountdown支持两个函数,一个是在倒计时的时候调用,一个是在倒计时结束的时候调用


预期的使用是这样的,通过一个配置对象传入 countdownCallBack函数和onEnd




改造 useCountdown

  • 然后我们这里count定义 0 有点歧义,0 不能准确的知道是一开始的 0 还是倒计时结束的 0,所以还需要加一个标志位来表示当前是结束的 0
    1. 使用 useEffect监听count的变化,变化的时候触发对应的方法

    实现如下, 新增了红框的内容




    提出问题


    那么,现在就有一个很严重的问题,onEndcountdownCallBack这两个函数是外部传入的,我们要不要把他放到我们自定义hookuseEffect依赖项里面呢


    我们不能保证外部传入的变量一定是一个被useCallback包裹的函数,那么肯定就不能放到useEffect依赖项里面。


    如何解决这个问题呢?


    答案就是使用useRef。(兜兜转转这么久才点题 (╥╯^╰╥))


    用之前我们可以看一下成熟的方案是什么


    比如ahooks里面的useLatestuseMemoizedFn的实现

    • useLatest 源码


    • useMemoizedFn 源码,主要看圈起来的地方就好了,本质也是用useRef记录传入的内容



    ok,我们使用一下 useLatest 改造一下我们的useCountdown,变动点被红框圈起来了




    总结


    其实这篇文章的核心点有两个

    1. 带着大家重新的学习了一下useCallback的使用场景。(useMemo类似)
    2. 编写自定义hooks时候,我们需要注意一下外部传入的参数,以及我们返回给用户的返回值,核心点是决不相信外部传入的内容,以及绝对要给用户一个可靠的返回值。

    作者:既见君子
    链接:https://juejin.cn/post/7271643757640007680
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    谁叫你是外包呢!!!

    好吧,我是标题党,我没有看不起外包的意思。主要是想和大家聊一聊外包工作值不值得做,以及我的一些建议 最近,某匿名平台上刷消息,发现好多大厂毕业的再问要不要加入外包,还有985毕业也有开始加入外包的。就想聊聊这个话题。 外包代码量比正式员工多多了 几年前,公司...
    继续阅读 »

    好吧,我是标题党,我没有看不起外包的意思。主要是想和大家聊一聊外包工作值不值得做,以及我的一些建议



    最近,某匿名平台上刷消息,发现好多大厂毕业的再问要不要加入外包,还有985毕业也有开始加入外包的。就想聊聊这个话题。


    外包代码量比正式员工多多了


    几年前,公司出了一个代码量的统计,然后我们就发现,外包同学的代码提交量,比正式员工多多了。平时正式员工开会沟通PPT,基建中台分任务,没有多少时间正经写代码,大部分日常需求代码也都是外包来写了。而且开会沟通,中台基建大部分工作也都是再卷PPT,代码工作自然就越来越多的交给外包同学。


    于是就出现了很奇怪的现象,公司花很大的经历去招了一个很优秀的程序员,但是这个员工很少写代码,大量时间在熟悉各种中台,研究各种中台,实现各种中台。而日常需求呢,一直缺人,于是就招外包。外包虽然能力差一点,但是平时需求的复杂度并没有减少,反而因为各种中台变得越来越难开发,导致项目中欠下的技术债越来越多。


    这个时候,就出现了内包的概念。想出这个的真是厉害。就是弄一个子公司,让子公司去招人干活,技术能力的要求就介于外包和正式之间。然后再弄一个外包可以转内包,内包可以转正式的噱头,让人上进。哎,都是为了这块技术。


    大厂有没有可能外包化


    然后老板们就发现,日常需求交给内包们,完全没有问题呀。再加上最近的降本增效,做的各种中台也没有发挥很好的提效效果,大厂们开始尝试让正式员工毕业。


    到这个时候,江湖上就开始流传,P9以下都可以外包掉。你看某宝最近的政策,不就是P9以下继续走层级晋升,而p9以上,都走组织任命了吗。想想10年前,p6已经是大牛了;5年前,p7是大牛;现在呢,你不是p8,说自己很牛,谁理你。为了让你们上进,不断的让层级贬值,就像不断让货币贬值一样。


    普京的厨子


    普京的厨子,大家都知道是谁吧。你看俄乌战争中,一直是瓦格纳冲锋陷阵吧(道听途说的,不确定是不是真的),拿下一个又一个结果,最后的结果好吗。


    如果是在一家公司,厨子就类似于外包,厨子能力很强,需要人干活的时候,一定会被重视。但是,我们要知道,被重视不一定能转正的。


    我看到过一篇文章,说的就是历史上,一个大王朝到了后期,格局相对稳定后(利益分配完了),都会开始用“外包”,因为“外包”便宜啊。一旦遇到天灾人祸,“外包”规模不断扩大,最后“外包”的实力强大了,就会自己单干。然后就是下一轮“创业”周期。具体文章找不到了,熟悉历史的应该能理解我在说什么吧。


    外包多做准备


    前面说到外包不一定能转正,转正都是噱头。我不是说外包就不要上进,不要去争取转正。人要上进,那是好事。就像很多人努力考编,努力考公,努力上岸。说实话,我蛮羡慕这种人的,积极乐观上进,永远向前。


    但是,我是说如果,一直没有成功呢,一直不受待见呢(就我呆了这么长时间大厂,就知道一个外包转正了,还不是因为能力业绩凸出)。我们是不是也要准备好plan B。这两年,经济增长低了,正式员工也焦虑的不行,即使转为正式员工,高兴个两天,又会因为新目标而焦虑了,不然可能连工作都要没有了。


    怎么办


    虽然我一直是大厂正式员工,但是回想起来,真的走过太多弯路,错过了太多机会。期间也再想职业规划怎么做,但是因为感觉公司打工福利也蛮好的,折腾什么呢。现在降本增效一来,突然之间,一切都变了。


    最后,根据我走过的弯路,给大家两个建议,大家听听就好,要不要行动,自己决定。


    随时做好跳槽的准备


    不是说年年跳槽,是随时可以跳槽。变化越来越快,意外情况随时发生,一旦毕业了,有准备比没有准备要强。即使没毕业,遇到更好的岗位,没有准备你也不敢去尝试。当然了,建议不要出国,出国太危险


    多写技术文章,对外发声,让猎头、同行知道你。和同事,前同事,猎头都搞好关系。这样你就能知道很多新招聘。不然,就知道Boss直聘,觉得上面岗位很多,但是上面的岗位哪一个不是一堆简历在投,都是多对多,相互嫌弃着,很浪费精力。


    副业


    副业!副业!副业!大家都在讲副业,但是怎么做副业,看下来私单和卖课最靠谱了。我年级大了,跳槽这个已经不怎么管用。所以我最近主要就是研究副业。最近在了解和尝试,有结论,搞明白了的,也都会在自己的公众号上发出来。AI这一波挣了一点,但是不可持续,流量莫名奇妙就没了。尝试下来能挣钱,但是并不是大家想象的那样,有一些坑,有一些技巧,还是蛮有收货的。如果有在尝试的,可以加个好友多多交流交流。


    最后


    环境已经这样了,我们能怎么办呢!走的太累,就坐下来,抬头看看天。


    回到最开始的问题,大厂毕业要不要加入外包。我觉得吧,工作吗,靠自己努力挣钱养活自己,不寒碜。但是,如果我们有更好的选择,就不会有这个问题了,不是吗。所以核心问题是,没有的选择!既然是这样,有什么好问的。


    下一次,下一次,一定要多多准备,让自己有更多选择。从纠结要不要去做外包,转变成纠结哪一个选择更好。


    扯一句


    弄了个公粽号:写代码的浩,求个关注。我走了太多太多弯路,希望能帮你少走弯路。


    作者:写代码的浩
    链接:https://juejin.cn/post/7274500909865500730
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    2个奇怪的React写法

    大家好,我卡颂。 虽然React官网用大量篇幅介绍最佳实践,但因JSX语法的灵活性,所以总是会出现奇奇怪怪的React写法。 本文介绍2种奇怪(但在某些场景下有意义)的React写法。也欢迎大家在评论区讨论你遇到过的奇怪写法。 欢迎加入人类高质量前端交流群,带...
    继续阅读 »

    大家好,我卡颂。


    虽然React官网用大量篇幅介绍最佳实践,但因JSX语法的灵活性,所以总是会出现奇奇怪怪的React写法。


    本文介绍2种奇怪(但在某些场景下有意义)的React写法。也欢迎大家在评论区讨论你遇到过的奇怪写法。


    欢迎加入人类高质量前端交流群,带飞


    ref的奇怪用法


    这是一段初看让人很困惑的代码:

    function App() {
    const [dom, setDOM] = useState(null);

    return <div ref={setDOM}></div>;
    }

    让我们来分析下它的作用。


    首先,ref有两种形式(曾经有3种):

    1. 形如{current: T}的数据结构

    2. 回调函数形式,会在ref更新、销毁时触发


    例子中的setDOMuseStatedispatch方法,也有两种调用形式:

    1. 直接传递更新后的值,比如setDOM(xxx)

    2. 传递更新状态的方法,比如setDOM(oldDOM => return /* 一些处理逻辑 */)


    在例子中,虽然反常,但ref的第二种形式和dispatch的第二种形式确实是契合的。


    也就是说,在例子中传递给refsetDOM方法,会在div对应DOM更新、销毁时执行,那么dom状态中保存的就是div对应DOM的最新值。


    这么做一定程度上实现了感知DOM的实时变化,这是单纯使用ref无法具有的能力。


    useMemo的奇怪用法


    通常我们认为useMemo用来缓存变量propsuseCallback用来缓存函数props


    但在实际项目中,如果想通过缓存props的方式达到子组件性能优化的目的,需要同时保证:

    • 所有传给子组件的props的引用都不变(比如通过useMemo

    • 子组件使用React.memo


    类似这样:

    function App({todos, tab}) {
    const visibleTodos = useMemo(
    () => filterTodos(todos, tab),
    [todos, tab]);

    return <Todo data={visibleTodos}/>;
    }

    // 为了达到Todo性能优化的目的
    const Todo = React.memo(({data}) => {
    // ...省略逻辑
    })

    既然useMemo可以缓存变量,为什么不直接缓存组件的返回值呢?类似这样:

    function App({todos, tab}) {
    const visibleTodos = useMemo(
    () => filterTodos(todos, tab),
    [todos, tab]);

    return useMemo(() => <Todo data={visibleTodos}/>, [visibleTodos])
    }

    function Todo({data}) {
    return <p>{data}</p>;
    }

    如此,需要性能优化的子组件不再需要手动包裹React.memo,只有当useMemo依赖变化后子组件才会重新render


    总结


    除了这两种奇怪的写法外,你还遇到哪些奇怪的React写法呢?


    作者:魔术师卡颂
    链接:https://juejin.cn/post/7210048692623114298
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    移动端的双击事件好不好用?

    web
    前言 2023年了,我不允许还有人不会自己实现移动端的双击事件。 过来,看这里,不足 50 行的代码实现的双击事件。 听笔者娓娓道来。 dblclick js原生有个dblclick双击事件,但是几乎不支持移动端。 而且,该dblclick事件在pc端鼠标双...
    继续阅读 »

    前言


    2023年了,我不允许还有人不会自己实现移动端的双击事件。


    过来,看这里,不足 50 行的代码实现的双击事件。


    听笔者娓娓道来。


    dblclick


    js原生有个dblclick双击事件,但是几乎不支持移动端。


    developer.mozilla.org_zh-CN_docs_Web_API_Element_dblclick_event.png


    而且,该dblclick事件在pc端鼠标双击时,会触发两次click与一次dblclick


    window.addEventListener('click', () => {
    console.log('click')
    });
    window.addEventListener('dblclick', () => {
    console.log('dblclick')
    });

    // 双击页面,打印:click✖️2 dblclick

    我们期望可以在移动端也能有双击事件,并且隔离单击与双击事件,双击时只触发双击事件,只执行双击回调函数,让注册双击事件像注册原生事件一样简单。


    点击穿透


    简单聊聊移动端的点击穿透。



    在移动端单击会依次触发touchstart->touchmove->touchend->click事件。



    有这样一段逻辑,在touchstart时出现全屏弹框,在click弹框时关闭弹框。实际上,在点击页面时,弹框会一闪而过,并没有出现正确的交互。在移动端单击时touchstart早于click,当弹框出现了,后来的click事件就落在了弹框上,导致弹框被关闭。这就是点击穿透的一种表现。


    笔者的业务需求是双击元素,出现全屏弹框,单击弹框时关闭弹框。因此基于这样的业务需求与现实的点击穿透问题,笔者选择采用click事件来模拟双击事件,并且适配pc端使用。大家也可以选择解决点击穿透问题,并采用touchstart模拟双击事件,可以更快地响应用户操作。



    采用touchstart模拟时,可以再考虑排除双指点击的情况。


    在实现上与下文代码除了事件对象获取位置属性有所不同外,其它代码基本一致,实现思路无差别。



    模拟双击事件


    采用click事件来模拟实现双击。


    双击事件定义:2次点击事件间隔小于200ms,并且点击范围小于10px的视为双击。这里的双击事件是自定义事件,为了区分原生的 dblclick,又优先满足移动端使用,则事件名定义为 dbltouch,后续可以使用window.addEventListener('dbltouch', ()=>{})来监听双击事件。



    这个间隔与位移限制大家可以根据自己的业务需求调整。通常采用的是300ms的间隔与10px的位移,笔者业务中发现200ms间隔也可使用。


    自定义事件名大家可以随意设置,满足语义化即可。





    1. 监听click事件,并在捕获阶段监听,目的是为了后续能够阻止click事件传播。


      window.addEventListener('click', handler, true);



    2. 监听函数中,第1次点击时,记录点击位置,并设置200ms倒计时。如果第2次点击在200ms后,则重新派发当前事件,让事件继续传播,使其它的监听函数可以继续处理对应事件。


      // 标识是否在等待第2次点击
      let isWaiting = false;

      // 记录点击位置
      let prevPosition = {};

      function handler(evt) {
      const { pageX, pageY } = evt;
      prevPostion = { pageX, pageY };
      // 阻止冒泡,不让事件继续传播
      evt.stopPropagation();
      // 开始等待第2次点击
      isWaiting = true;
      // 设置200ms倒计时,200ms后重新派发当前事件
      timer = setTimeout(() => {
      isWaiting = false;
      evt.target.dispatchEvent(evt);
      }, 200)
      }

      注意: 倒计时结束时evt.target.dispatchEvent(evt)派发的事件仍是原来的事件对象,即仍是click事件,会触发继续handler函数,进入了循环。


      这里需要破局,已知Event事件对象下有一个 isTrusted 属性,是一个只读属性,是一个布尔值。当事件是由用户行为生成的时候,这个属性的值为 true ,而当事件是由脚本创建、修改、通过 EventTarget.dispatchEvent()派发的时候,这个属性的值为 false 。


      因此,此处脚本派发的事件是希望继续传递的事件,不用handler内处理。


      function handler(evt) {
      // 如果事件是脚本派发的则不处理,将该事件继续传播
      if(!evt.isTrusted){
      return;
      }
      }



    3. 处理完第1次点击后,接着处理在200ms内的第2次点击事件。如果满足位移小于10px的条件,则视为双击。


      // 标识是否在等待第2次点击
      let isWaiting = false;

      // 记录点击位置
      const prevPosition = {};

      function handler(evt) {
      // 如果事件是脚本派发的则不处理,将该事件继续传播
      if(!evt.isTrusted){
      return;
      }
      const { pageX, pageY } = evt;
      if(isWaiting) {
      isWaiting = false;
      const diffX = Math.abs(pageX - prevPosition.pageX);
      const diffY = Math.abs(pageY - prevPosition.pageY);
      // 如果满足位移小于10,则是双击
      if(diffX <= 10 && diffY <= 10) {
      // 取消当前事件传递,并派发1个自定义双击事件
      evt.stopPropagation();
      evt.target.dispatchEvent(
      new PointerEvent('dbltouch', {
      cancelable: false,
      bubbles: true,
      })
      )
      }
      } else {
      prevPostion = { pageX, pageY };
      // 阻止冒泡,不让事件继续传播
      evt.stopPropagation();
      // 开始等待第2次点击
      isWaiting = true;
      // 设置200ms倒计时,200ms后重新派发当前事件
      timer = setTimeout(() => {
      isWaiting = false;
      evt.target.dispatchEvent(evt);
      }, 200)
      }
      }



    4. 以上便实现了双击事件,全局任意地方监听双击。


      window.addEventListener('dbltouch', () => {
      console.log('dbltouch');
      })
      window.addEventListener('click', () => {
      console.log('click');
      })
      // 使用鼠标、手指双击
      // 打印出 dbltouch
      // 而且不会打印有click



    笔者要在这里说句 但是: 由于200ms的延时,虽不多,但是对于操作迅速的用户来讲,还是会有不好的体验。


    优化双击事件


    由于是在window上注册的click函数,虽说注册双击事件像单击事件一样简单了,但却也导致整个产品页面的click事件都会推迟200ms执行。


    因此,我们应该只对需要处理双击的地方添加双击事件,至少只在局部发生延迟情况。稍微调整下代码,将需要注册双击事件的元素由开发决定,通过参数传递。而且事件处理函数也可以通过参数传递,即可以通过监听双击事件,也可以通过回调函数执行。


    以下是完整的代码。


    class RegisterDbltouchEvent {
    constructor(el, fn) {
    this.el = el || window;
    this.callback = fn;
    this.timer = null;
    this.prevPosition = {};
    this.isWaiting = false;

    // 注册click事件,注意this指向
    this.el.addEventListener('click', this.handleClick.bind(this), true);
    }
    handleClick(evt){
    if(this.timer) {
    clearTimeout(this.timer);
    this.timer = null;
    }
    if(!evt.isTrusted) {
    return;
    };
    if(this.isWaiting){
    this.isWaiting = false;
    const diffX = Math.abs(pageX - this.prevPosition.pageX);
    const diffY = Math.abs(pageY - this.prevPosition.pageY);
    // 如果满足位移小于10,则是双击
    if(diffX <= 10 && diffY <= 10) {
    // 取消当前事件传递,并派发1个自定义双击事件
    evt.stopPropagation();
    evt.target.dispatchEvent(
    new PointerEvent('dbltouch', {
    cancelable: false,
    bubbles: true,
    })
    );
    // 也可以采用回调函数的方式
    this.callback && this.callback(evt);
    }
    } else {
    this.prevPostion = { pageX, pageY };
    // 阻止冒泡,不让事件继续传播
    evt.stopPropagation();
    // 开始等待第2次点击
    this.isWaiting = true;
    // 设置200ms倒计时,200ms后重新派发当前事件
    this.timer = setTimeout(() => {
    this.isWaiting = false;
    evt.target.dispatchEvent(evt);
    }, 200)
    }
    }
    }

    只为需要实现双击逻辑的元素注册双击事件。可以通过传递回调函数的方式执行业务逻辑,也可以通过监听dbltouch事件的方式,也可以同时使用,it's up to you.


    const el = document.querySelector('#dbltouch');
    new RegisterDbltouchEvent(el, (evt) => {
    // 实现双击逻辑
    })

    最后


    采用的click事件模拟双击事件,因此在移动端和pc端都可以使用该构造函数。


    作者:Yue栎廷
    来源:juejin.cn/post/7274043371731796003
    收起阅读 »

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

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

    前言


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

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

    以前是正常的啊?

    刷新后也是一样

    快看看怎么回事


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


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


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


    正文


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


    我们的代码是这样写:


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

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


    image.png


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


    简写代码如下:


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

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


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


    直接这次bug的出现。


    排查


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


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


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


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


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


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


    image.png


    具体说明:


    image.png


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


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


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


    image.png


    解决方案


    第一种方式


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


    代码如下:


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

    第二种方式


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



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



    总结


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


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


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

    Java切换到Kotlin,Crash率上升了?

    前言 最近对一个Java写的老项目进行了部分重构,测试过程中波澜不惊,顺利上线后几天通过APM平台查看发现Crash率上升了,查看堆栈定位到NPE类型的Crash,大部分发生在Java调用Kotlin的函数里,本篇将会分析具体的场景以及规避方式。 通过本篇文章...
    继续阅读 »

    前言


    最近对一个Java写的老项目进行了部分重构,测试过程中波澜不惊,顺利上线后几天通过APM平台查看发现Crash率上升了,查看堆栈定位到NPE类型的Crash,大部分发生在Java调用Kotlin的函数里,本篇将会分析具体的场景以及规避方式。

    通过本篇文章,你将了解到:

    1. NPE(空指针 NullPointerException)的本质
    2. Java 如何预防NPE?
    3. Kotlin NPE检测
    4. Java/Kotlin 混合调用
    5. 常见的Java/Kotlin互调场景


    1. NPE(空指针 NullPointerException)的本质


    变量的本质


        val name: String = "fish"

    name是什么?

    对此问题你可能嗤之以鼻:



    不就是变量吗?更进一步说如果是在对象里声明,那就是成员变量(属性),如果在方法(函数)里声明,那就是局部变量,如果是静态声明的,那就是全局变量。



    回答没问题很稳当。

    那再问为什么通过变量就能找到对应的值呢?



    答案:变量就是地址,通过该地址即可寻址到内存里真正的值



    无法访问的地址



    在这里插入图片描述


    如上图,若是name="fish",表示的是name所指向的内存地址里存放着"fish"的字符串。

    若是name=null,则说明name没有指向任何地址,当然无法通过它访问任何有用的信息了。


    无论C/C++亦或是Java/Kotlin,如果一个引用=null,那么这个引用将毫无意义,无法通过它访问任何内存信息,因此这些语言在设计的过程中会将通过null访问变量/方法的行为都会显式(抛出异常)提醒开发者。


    2. Java 如何预防NPE?


    运行时规避


    先看Demo:


    public class TestJava {
    public static void main(String args[]) {
    (new TestJava()).test();
    }

    void test() {
    String str = getString();
    System.out.println(str.length());
    }

    String getString() {
    return null;
    }
    }

    执行上述代码将会抛出异常,导致程序Crash:



    在这里插入图片描述


    我们有两种解决方式:




    1. try...catch

    2. 对象判空



    try...catch 方式


    public class TestJava {
    public static void main(String args[]) {
    (new TestJava()).testTryCatch();
    }

    void testTryCatch() {
    try {
    String str = getString();
    System.out.println(str.length());
    } catch (Exception e) {
    }
    }

    String getString() {
    return null;
    }
    }

    NPE被捕获,程序没有Crash。


    对象判空


    public class TestJava {
    public static void main(String args[]) {
    (new TestJava()).testJudgeNull();
    }

    void testJudgeNull() {
    String str = getString();
    if (str != null) {
    System.out.println(str.length());
    }
    }

    String getString() {
    return null;
    }
    }

    因为提前判空,所以程序没有Crash。


    编译时检测


    在运行时再去做判断的缺点:



    无法提前发现NPE问题,想要覆盖大部分的场景需要随时try...catch或是判空
    总有忘记遗漏的时候,发布到线上就是个生产事故



    那能否在编译时进行检测呢?

    答案是使用注解。


    public class TestJava {
    public static void main(String args[]) {
    (new TestJava()).test();
    }

    void test() {
    String str = getString();
    System.out.println(str.length());
    }

    @Nullable String getString() {
    return null;
    }
    }

    在编写getString()方法时发现其可能为空,于是给方法加上一个"可能为空"的注解:@Nullable


    当调用getString()方法时,编译器给出如下提示:



    在这里插入图片描述


    意思是访问的getString()可能为空,最后访问String.length()时可能会抛出NPE。

    看到编译器的提示我们就知道此处有NPE的隐患,因此可以针对性的进行处理(try...catch或是判空)。


    当然此处的注解仅仅只是个"弱提示",你即使没有进行针对性的处理也能编译通过,只是问题最后都流转到运行时更难挽回了。


    有"可空"的注解,当然也有"非空"的注解:



    在这里插入图片描述


    @Nonnull 注解修饰了方法后,若是检测到方法返回null,则会提示修改,当然也是"弱提示"。


    3. Kotlin NPE检测


    编译时检测


    Kotlin 核心优势之一:



    空安全检测,变量分为可空型/非空型,能够在编译期检测潜在的NPE,并强制开发者确保类型一致,将问题在编译期暴露并解决



    先看非空类型的变量声明:


    class TestKotlin {

    fun test() {
    val str = getString()
    println("${str.length}")
    }

    private fun getString():String {
    return "fish"
    }
    }

    fun main() {
    TestKotlin().test()
    }


    此种场景下,我们能确保getString()函数的返回一定非空,因此在调用该函数时无需进行判空也无需try...catch。


    你可能会说,你这里写死了"fish",那我写成null如何?



    在这里插入图片描述


    编译期直接提示不能这么写,因为我们声明getString()的返回值为String,是非空的String类型,既然声明了非空,那么就需要言行一致,返回的也是非空的。


    有非空场景,那也得有空的场景啊:


    class TestKotlin {

    fun test() {
    val str = getString()
    println("${str.length}")
    }

    private fun getString():String? {
    return null
    }
    }

    fun main() {
    TestKotlin().test()
    }

    此时将getString()声明为非空,因此可以在函数里返回null。

    然而调用之处就无法编译通过了:



    在这里插入图片描述


    意思是既然getString()可能返回null,那么就不能直接通过String.length访问,需要改为可空方式的访问:


    class TestKotlin {

    fun test() {
    val str = getString()
    println("${str?.length}")
    }

    private fun getString():String? {
    return null
    }
    }

    str?.length 意思是:如果str==null,就不去访问其成员变量/函数,若不为空则可以访问,于是就避免了NPE问题。


    由此可以看出:



    Kotlin 通过检测声明与实现,确保了函数一定要言行一致(声明与实现),也确保了调用者与被调用者的言行一致



    因此,若是用Kotlin编写代码,我们无需花太多时间去预防和排查NPE问题,在编译期都会有强提示。


    4. Java/Kotlin 混合调用


    回到最初的问题:既然Kotlin都能在编译期避免了NPE,那为啥使用Kotlin重构后的代码反而导致Crash率上升呢?


    原因是:项目里同时存在了Java和Kotlin代码,由上可知两者在NPE的检测上有所差异导致了一些兼容问题。


    Kotlin 调用 Java


    调用无返回值的函数


    Kotlin虽然有空安全检测,但是Java并没有,因此对于Java方法来说,不论你传入空还是非空,在编译期我都没法检测出来。


    public class TestJava {
    void invokeFromKotlin(String str) {
    System.out.println(str.length());
    }
    }

    class TestKotlin {

    fun test() {
    TestJava().invokeFromKotlin(null)
    }
    }

    fun main() {
    TestKotlin().test()
    }

    如上无论是Kotlin调用Java还是Java之间互调,都没法确保空安全,只能由被调用者(Java)自己处理可能的异常情况。


    调用有返回值的函数


    public class TestJava {
    public String getStr() {
    return null;
    }
    }

    class TestKotlin {
    fun testReturn() {
    println(TestJava().str.length)
    }
    }

    fun main() {
    TestKotlin().testReturn()
    }

    如上,Kotlin调用Java的方法获取返回值,由于在编译期Kotlin无法确定返回值,因此默认把它当做非空处理,若是Java返回了null,那么将会Crash。


    Java 调用 Kotlin


    调用无返回值的函数


    先定义Kotlin类:


    class TestKotlin {

    fun testWithoutNull(str: String) {
    println("len:${str.length}")
    }

    fun testWithNull(str: String?) {
    println("len:${str?.length}")
    }
    }

    有两个函数,分别接收可空/非空参数。


    在Java里调用,先调用可空函数:


    public class TestJava {
    public static void main(String args[]) {
    (new TestKotlin()).testWithNull(null);
    }
    }

    因为被调用方是Kotlin的可空函数,因此即使Java传入了null,也不会有Crash。


    再换个方式,在Java里调用非空函数:


    public class TestJava {
    public static void main(String args[]) {
    (new TestKotlin()).testWithoutNull(null);
    }
    }

    却发现Crash了!



    在这里插入图片描述


    为什么会Crash呢?反编译查看Kotlin代码:


    public final class TestKotlin {
    public final void testWithoutNull(@NotNull String str) {
    Intrinsics.checkNotNullParameter(str, "str");
    String var2 = "len:" + str.length();
    System.out.println(var2);
    }

    public final void testWithNull(@Nullable String str) {
    String var2 = "len:" + (str != null ? str.length() : null);
    System.out.println(var2);
    }
    }

    对于非空的函数来说,会有检测代码:

    Intrinsics.checkNotNullParameter(str, "str"):


        public static void checkNotNullParameter(Object value, String paramName) {
    if (value == null) {
    throwParameterIsNullNPE(paramName);
    }
    }
    private static void throwParameterIsNullNPE(String paramName) {
    throw sanitizeStackTrace(new NullPointerException(createParameterIsNullExceptionMessage(paramName)));
    }

    可以看出:




    1. Kotlin对于非空的函数参数,先判断其是否为空,若是为空则直接抛出NPE

    2. Kotlin对于可空的函数参数,没有强制检测是否为空



    调用有返回值的函数


    Java 本身就没有空安全,只能在运行时进行处理。


    小结


    很容看出来:




    1. Java 调用Kotlin的非空函数有Crash的风险,编译器无法检查到传入的参数是否为空

    2. Java 调用Kotlin的可空函数没有Crash风险,Kotlin编译期检查空安全

    3. Kotlin 调用Java的函数有Crash风险,由Java代码规避风险

    4. Kotlin 调用Java有返回值的函数有Crash风险,编译器无法检查到返回值是否为空



    回到文章的标题,我们已经大致知道了Java切换到Kotlin,为啥Crash就升上了的原因了,接下来再详细分析。


    5. 常见的Java/Kotlin互调场景


    Android里的Java代码分布



    在这里插入图片描述


    在Kotlin出现之前,Java就是Android开发的唯一语言,Android Framework、Androidx很多是Java代码编写的,因此现在依然有很多API是Java编写的。


    而不少的第三方SDK因为稳定性、迁移代价的考虑依然使用的是Java代码。


    我们自身项目里也因为一些历史原因存在Java代码。


    以下讨论的前提是假设现有Java代码我们都无法更改。


    Kotlin 调用Java获取返回值


    由于编译期无法判定Java返回的值是空还是非空,因此若是确认Java函数可能返回空,则可以通过在Kotlin里使用可空的变量接收Java的返回值。


    class TestKotlin {
    fun testReturn() {
    val str: String? = TestJava().str
    println(str?.length)
    }
    }

    fun main() {
    TestKotlin().testReturn()
    }

    Java 调用Kotlin函数


    LiveData Crash的原因与预防


    之前已经假设过我们无法改动Java代码,那么Java调用Kotlin函数的场景只有一个了:回调。

    上面的有返回值场景还是比较容易防备,回调的场景就比较难发现,尤其是层层封装之后的代码。

    这也是特别常见的场景,典型的例子如LiveData。


    Crash原因


    class TestKotlin(val lifecycleOwner: LifecycleOwner) {
    val liveData: MutableLiveData = MutableLiveData()
    fun testLiveData() {
    liveData.observe(lifecycleOwner) {
    println(it.length)
    }
    }

    init {
    testLiveData()
    }
    }

    如上,使用Kotlin声明LiveData,其类型是非空的,并监听LiveData的变化。


    在另一个地方给LiveData赋值:


    TestKotlin(this@MainActivity).liveData.value = null

    虽然LiveData的监听和赋值的都是使用Kotlin编写的,但不幸的是还是Crash了。

    发送和接收都是用Kotlin编写的,为啥还会Crash呢?

    看看打印:



    在这里插入图片描述


    意思是接收到的字符串是空值(null),看看编译器提示:



    在这里插入图片描述


    原来此处的回调传入的值被认为是非空的,因此当使用it.length访问的时候编译器不会有空安全提示。


    再看看调用的地方:



    在这里插入图片描述


    可以看出,这回调是Java触发的。


    Crash 预防


    第一种方式:

    我们看到LiveData的数据类型是泛型,因此可以考虑在声明数据的时候定为非空:


    class TestKotlin(val lifecycleOwner: LifecycleOwner) {
    val liveData = MutableLiveData()
    fun testLiveData() {
    liveData.observe(lifecycleOwner) {
    println(it?.length)
    }
    }

    init {
    testLiveData()
    }
    }

    如此一来,当访问it.length时编译器就会提示可空调用。


    第二种方式:

    不修改数据类型,但在接收的地方使用可空类型接收:


    class TestKotlin(val lifecycleOwner: LifecycleOwner) {
    val liveData = MutableLiveData()
    fun testLiveData() {
    liveData.observe(lifecycleOwner) {
    val dataStr:String? = it
    println(dataStr?.length)
    }
    }

    init {
    testLiveData()
    }
    }

    第三种方式:

    使用Flow替换LiveData。


    LiveData 修改建议:




    1. 若是新写的API,建议使用第三种方式

    2. 若是修改老的代码,建议使用第一种方式,因为可能有多个地方监听LiveData值的变化,如果第一种方式的话需要写好几个地方。



    其它场景的Crash预防:


    与后端交互的数据结构
    比如与后端交互声明的类,后端有可能返回null,此时在客户端接收时若是使用了非空类型的字段去接收,那么会发生Crash。

    通常来说,我们会使用网络框架(如retrofit)接收数据,数据的转换并不是由我们控制,因此无法使用针对LivedData的第二种方式。

    有两种方式解决:




    1. 与后端约定,不能返回null(等于白说)

    2. 客户端声明的类的字段声明为可空(类似针对LivedData的第一种方式)



    Json序列化/反序列化

    Json字符串转换为对象时,有些字段可能为空,也需要声明为可空字段。


    小结



    在这里插入图片描述


    您若喜欢,请点赞、关注、收藏,您的鼓励是我前进的动力


    持续更新中,和我一起步步为营系统、深入学习Android/Kotlin


    作者:小鱼人爱编程
    来源:juejin.cn/post/7274163003158511616
    收起阅读 »

    聊聊Java中浮点丢失精度的事

    在说这个之前,我们先看看十进制到二进制的转换过程 整数的十进制到二进制的转换过程 用白话说这个过程就是不断的除2,得到商继续除,直到商小于1为止,然后他每次结果的余数倒着排列出来就是它的二进制结果了,直接上图 说一下为什么倒着排列就是二进制结果哈 通俗点说就...
    继续阅读 »

    在说这个之前,我们先看看十进制到二进制的转换过程


    整数的十进制到二进制的转换过程


    用白话说这个过程就是不断的除2,得到商继续除,直到商小于1为止,然后他每次结果的余数倒着排列出来就是它的二进制结果了,直接上图


    整数十进制转二进制.jpg
    说一下为什么倒着排列就是二进制结果哈


    通俗点说就是整数是一步一步除下来的,那回去不得一步一步乘上去?也就是说从上到下就是二进制从低位到高位的过程。


    小数十进制到二进制的转换过程


    小数的十进制到二进制的转换其实和整数类似,只不过算的方式变成了乘法,也就是用小数不断的乘2,然后得到的结果的整数部分拿出来,接着剩下的小数部分继续乘2,直到小数部分为0为止,直接上图~


    小数十进制转二进制过程(不循环).jpg
    二进制结果中的二分之一是转换后的,其实就是2的-1次方,-2次方。。。


    当然了,小数转二进制的过程中,很多情况下都是无尽的,接着上图


    小数十进制转二进制过程(循环).jpg
    所以可以看到这样的循环下去是得不到二进制的结果的,所以计算机就要进行取舍。也就是IEEE 754规范


    IEEE 754规范


    IEEE 754规定了四种标识浮点数值的方式,单精确度(32位),双精确度(64位),延伸单精确度(43比特以上,很少用)和延伸双精确度(79比特以上,通常80位)


    最常用的还是单精确度和双精确度,也就是对标的float和double。但是IEEE 754规范并没有解决精确标识小数的问题,只是提供了一种用近似值标识小数的方式。而且精确度不同近似值也会不同。# 为什么会精度丢失?教你看懂 IEEE-754!


    下面有个例子来看一下丢失精度的问题,如0.1+0.2
    0.1的64位二进制:0.00011001100110011001100110011001100110011001100110011001
    0.2的64位二进制:0.00110011001100110011001100110011001100110011001100110011
    二者相加的结果为:0.30000000000000004


    那么如何解决精度问题呢?


    BigDecimal


    BigDecimal使用java.math包提供的,在涉及到金钱相关的计算的时候都需要使用它,而且其中提供了大量的方法,比如加减乘除都是可以直接调用的。


    先看这个问题,BigDecimal中的比较问题


    先看下面这个例子


    public class ReferenceDemo {

    public static void main(String[] args) {

    BigDecimal bigDecimal1 = new BigDecimal(1);
    BigDecimal bigDecimal2 = new BigDecimal(1);
    System.out.println(bigDecimal1.equals(bigDecimal2));

    BigDecimal bigDecimal3 = new BigDecimal(1);
    BigDecimal bigDecimal4 = new BigDecimal(1.0);
    System.out.println(bigDecimal3.equals(bigDecimal4));

    BigDecimal bigDecimal5 = new BigDecimal("1");
    BigDecimal bigDecimal6 = new BigDecimal("1.0");

    System.out.println(bigDecimal5.equals(bigDecimal6));
    }


    }

    结果为:


    image.png
    其中第二个例子和第三个例子的不同是需要聊一聊的。为什么会出现这种呢?下面是BigDecimal中的equals的源码。


    public boolean equals(Object x) {
    if (!(x instanceof BigDecimal))
    return false;
    BigDecimal xDec = (BigDecimal) x;
    if (x == this)
    return true;
    //关键在这一行,比较了scale
    if (scale != xDec.scale)
    return false;
    long s = this.intCompact;
    long xs = xDec.intCompact;
    if (s != INFLATED) {
    if (xs == INFLATED)
    xs = compactValFor(xDec.intVal);
    return xs == s;
    } else if (xs != INFLATED)
    return xs == compactValFor(this.intVal);

    return this.inflated().equals(xDec.inflated());
    }

    由上面的注释可以看到BigDecimal中有一个很关键的属性,就是scale,标度。标度是什么?
    首先看一下BigDecimal的结构


    public class BigDecimal extends Number implements Comparable<BigDecimal> {
    /**
    * The unscaled value of this BigDecimal, as returned by {@link
    * #unscaledValue}.
    *
    * @serial
    * @see #unscaledValue
    */

    private final BigInteger intVal;

    /**
    * The scale of this BigDecimal, as returned by {@link #scale}.
    *
    * @serial
    * @see #scale
    */

    private final int scale; // Note: this may have any value, so
    // calculations must be done in longs

    /**
    * If the absolute value of the significand of this BigDecimal is
    * less than or equal to {@code Long.MAX_VALUE}, the value can be
    * compactly stored in this field and used in computations.
    */

    private final transient long intCompact;
    }

    我截取了几个关键字段,依次看一下:


    intVal: 无标度值


    scale: 标度


    intCompact: 当intVal超过阈值(默认为Long.MAX_VALUE)时,进行压缩运算,结果存到这个字段上,用于后续计算。


    注释中解释到,scale为0或者正数的时候代表数字小数点之后的位数,如果scale为负数,代表数字的无标度值需要乘10的该负数的绝对值的幂,即末尾有几个0


    比如123.123这个数,他的intVal就是123123,scale就是3了


    而二进制无法标识0.1,通过BigDecimal标识的话,它的intVal就是1,scale也是1。


    接着看回上面的例子,传入的参数是字符串的bigDecimal5和bigDecimal6,为什么就返回了false。上图


    image.png


    他们的标度是不同的,所以直接返回了false,那么在看bigDecimal3和bigDecimal4的比较,为什么就返回了true呢,同样上图


    image.png
    可以看到他们的intVal和scale都是相等的,但是明明传入了不同的,有兴趣的可以取看看源码,找一些资料,对于1.0这个数,它本质上也是一个整数,经过一系列的运算他的intVal还是1,scale还是0,所以比较之后返回的是true。


    这时候就能看出来equals方法的一些问题了,用equals涉及到scale的比较,实际的结果可能和预期不一样,所在BigDecimal的比较推荐用compareTo方法,如果返回0,代表相等


    BigDecimal bigDecimal5 = new BigDecimal("1");
    BigDecimal bigDecimal6 = new BigDecimal("1.0");

    System.out.println(bigDecimal5.compareTo(bigDecimal6));

    说到这里同时提一下,不要用传参为double的构造方法,同样会丢失精度,如果需要小数,需要传入字符串的小数来获取BigDecimal的实例对象。


    说到这其实应该明白了他是怎么保证精度的了,其实关键点就是scale,这个标度贯穿了整个过程,加减乘除的运算都需要它来把控。上面说了其实2个参数最为关键,一个是无标度值,一个是标度,无标度值就是整数了,以加法为例子,不就可以变成整数的加法了吗,然后用scale控制小数点,说是这么说,实现过程还是很复杂的,有兴趣的可以自己查资料去学习。


    除了用字符串代替double来表示BigDecimal的小数,其实也可以通过BigDecimal.valueOf()方法,它传入double之后可以和字符串一样的效果,为啥呢?上代码


    public static BigDecimal valueOf(double val) {
    // Reminder: a zero double returns '0.0', so we cannot fastpath
    // to use the constant ZERO. This might be important enough to
    // justify a factory approach, a cache, or a few private
    // constants, later.
    return new BigDecimal(Double.toString(val));
    }

    它把传入的double给toString了。。。。


    作者:yulbo
    来源:juejin.cn/post/7274692953058082877
    收起阅读 »

    JS 获取页面尺寸

    web
    通过 JS 获取页面相关的尺寸是比较常见的操作,尤其是在动态计算页面布局时,今天我们就来学习一下几个获取页面尺寸的基本方法。 获取页面高度 function getPageHeight() { var g = document, a = g.bod...
    继续阅读 »

    通过 JS 获取页面相关的尺寸是比较常见的操作,尤其是在动态计算页面布局时,今天我们就来学习一下几个获取页面尺寸的基本方法。


    获取页面高度


    function getPageHeight() {
    var g = document,
    a = g.body,
    f = g.documentElement,
    d = g.compatMode == "BackCompat" ? a : g.documentElement;
    return Math.max(f.scrollHeight, a.scrollHeight, d.clientHeight);
    }

    获取页面scrollLeft


    function getPageScrollLeft() {
    var a = document;
    return a.documentElement.scrollLeft || a.body.scrollLeft;
    }

    获取页面scrollTop


    function getPageScrollTop() {
    var a = document;
    return a.documentElement.scrollTop || a.body.scrollTop;
    }

    获取页面可视宽度


    function getPageViewWidth() {
    var d = document,
    a = d.compatMode == "BackCompat" ? d.body : d.documentElement;
    return a.clientWidth;
    }

    获取页面可视高度


    function getPageViewHeight() {
    var d = document,
    a = d.compatMode == "BackCompat" ? d.body : d.documentElement;
    return a.clientHeight;
    }

    获取页面宽度


    function getPageWidth() {
    var g = document,
    a = g.body,
    f = g.documentElement,
    d = g.compatMode == "BackCompat" ? a : g.documentElement;
    return Math.max(f.scrollWidth, a.scrollWidth, d.clientWidth);
    }

    ~


    ~ 全文完


    ~


    作者:编程三昧
    来源:juejin.cn/post/7274856158175363126
    收起阅读 »

    一个有意思的点子

    web
    前言 前端基于运行时检测问题、定位原因、预警提示的可行性思考🤔 背景 部门要治理线上故障过多的问题,故障主要分后端异常以及前端的性能问题和业务问题。我们讨论的范围是前端业务故障,经过分析可以把它们大致分为UI、业务、工程技术、日志、三方依赖等。 首先要确定...
    继续阅读 »

    前言


    前端基于运行时检测问题、定位原因、预警提示的可行性思考🤔



    背景


    部门要治理线上故障过多的问题,故障主要分后端异常以及前端的性能问题和业务问题。我们讨论的范围是前端业务故障,经过分析可以把它们大致分为UI、业务、工程技术、日志、三方依赖等。



    首先要确定降低故障的指标,MTTI和MTTD是关键,因为只有及时发现和定位问题才能快速消灭它们。我们暂时没有统计线上MTTI、MTTD的数据,因为缺乏相关的预警所以问题的发现和定位耗时通常很久,下面的一些复盘统计显示发现问题的时间可以持续甚至好几个月。



    这些问题中UI和业务异常占比超过80%,这些问题发现的不及时,一方面是问题本身不会阻碍核心链路,另一方面说明团队对业务的稳定缺少监控手段。
    现有开发流程已经包含了Design QA验收交付的UI是否符合预期;开发工程师会和QA工程师一起执行Test Case验证业务的稳定并且在CI环节还有UT的保障。既然如此那为什么线上还是会不可避免的出现故障呢?


    问题归因


    在Dev和Stage阶段的验收能发现和处理绝显而易见的异常,但是这些验收的场景是有限的



    1. 开发环境数据集的局限

    2. 考虑到AB因素的影响,很难做到全场景全业务覆盖。

    3. 开发过程可能会无意间影响到其他业务,但是我们的注意力集中在现有的业务,也就导致问题难以被发现


    所以归根到底,验收环节中数据和场景的局限以及人治导致一些Edge case被遗漏。


    解决方案


    我们该如何解决数据和场景的局限呢?这个其实通过Monkey和数据流量回放就能解决。
    运行时阶段包含了所有业务和代码的上下文,所有在这个阶段发现问题、分析原因并预警效率是最高的,人治的问题可以得到改善。下面是这种机制执行的思路



    1. 自动化测试时,通过流量回放的形式模拟线上的数据和环境尽可能多的覆盖场景。

    2. 运行时阶段,前端通过UT代码验收业务(🤔UT只能在Unit Test阶段执行,运行时执行的原理后面会讲)

    3. 运行时阶段,分析UI元素间的关系并探测异常问题


    方案实现


    方案实现仅讨论前端的部分。
    UI和业务检测比较通用,因为它们的判别条件是客观的。比如我们完全可以复用UT代码检测业务。


    自动检测、定位原因、预警


    这个机制实现没有困难。考虑到自动检测的范围在不同项目中不尽相同,所以实现的思路可以是插件的形式,模块间通过协议解耦。
    主要功能模块有:



    1. 告警模块

    2. 日志生成模块

    3. 业务注册模块(接收业务自定义的检查日志)

    4. 内嵌的UI检测模块


    UI检测


    业务不同,遇到的UI问题会有差异,这部分需要具体问题具体分析,所以不做过多讨论。针对我们业务的现状Overlap、Truncate、Clip在UI中占比较高。我的做法是对显示的视图按多叉树遍历到叶子节点并分析子节点和兄弟节点间的关系,找到Overlap、Truncate、Clip问题。具体的实现可以参考代码LensWindowGuard.swift:31


    业务检测


    UT代码从逻辑上可以被分为三个部分:



    1. Give

    2. When

    3. Then


    Given表示输入的数据,可以是真实接口也可以是Mock数据。


    When表示调用业务函数,同时这里会产生一个输出结果。


    Then表示检验输入的数据是否和输出的结果匹配,如果不匹配会在UT环节报错。


    业务代码从逻辑上可以被分为两个部分



    1. Give

    2. When


    Given可以是上下文的变量也可以是API调用


    When表示执行业务的代码块


    Blank diagram (23).png
    如果把UT的Then引入到业务的函数里,就可以实现运行时执行UT的效果了😁。


    将UT代码引入到业务函数中,思路是首先把UT按照Given、When、Then三部分拆分,把Given和Then部分独立出去,独立的部分通过代码插桩的方式在UT和业务代码中复用。


    Blank diagram (24).png


    不过到这里遗留了几个问题,暂时还没有太好的思路🧐



    1. 异步回调 - 业务代码或者UT检测逻辑只能执行一个

    2. 业务方法名的修改 - 使用Swift Macro插桩方式等同新增加一个全新的方法,所以运行时执行UT需要更改业务逻辑

    3. UT代码被执行多次 - 上面的图可以看出来,新的UT代码被同时插到旧的UT和业务代码中,所以执行UT时会UT逻辑被执行了两次


    代码插桩


    我们项目基于Swift,且插桩需要有判别逻辑,基于此选用AST的方式在编译期修改FunctionDecliation,把代码通过字符串的形式插到function body的合适位置。正巧2023 WWDC苹果发布了Swift Macro,其中的@Attached(Peer)正好可以满足对FunctionDecliation可编程修改,通过实现自动以@Attached(Peer)以及增加DEBUG宏(Swift Macro不能读取工程配置信息)可以改变UT的流程。真想说Swift太香了。


    最终


    完整的项目的整体架构大致如下,主要分了三部分。



    1. Hubble - 主要职责是提供基础协议、日志、预警和一些协助分析问题的工具集

    2. HubbleCoordinator - 主要职责是作为业务和组件交互的中间层,业务相关的逻辑都放在这里,隔离业务和Hubble

    3. 业务 - 仅处理Hubble初始化工作,对业务代码没有任何侵入
      AR


    作者:tom猪
    来源:juejin.cn/post/7274140856034099252
    收起阅读 »

    谷歌是如何写技术文档的

    Google软件工程文化的一个关键要素是使用设计文档来定义软件设计。这些相对非正式的文件由软件系统或应用程序的主要作者在开始编码项目之前创建。设计文档记录了高层次的实现策略和关键设计决策,重点强调在这些决策过程中考虑到的权衡。 作为软件工程师,我们的任务不仅仅...
    继续阅读 »

    Google软件工程文化的一个关键要素是使用设计文档来定义软件设计。这些相对非正式的文件由软件系统或应用程序的主要作者在开始编码项目之前创建。设计文档记录了高层次的实现策略和关键设计决策,重点强调在这些决策过程中考虑到的权衡。


    作为软件工程师,我们的任务不仅仅是生成代码,而更多地是解决问题。像设计文档这样的非结构化文本可能是项目生命周期早期解决问题更好的工具,因为它可能更简洁易懂,并且以比代码更高层次的方式传达问题和解决方案。


    除了原始软件设计文件外,设计文档还在以下方面发挥着作用:


    在进行变更时及早识别出设计问题仍然较便宜。


    在组织内达成对某个设计方案的共识。


    确保考虑到跨领域关注点。


    将资深工程师们掌握知识扩展到整个组织中去。


    形成围绕设计决策建立起来的组织记忆基础。


    作为技术人员投资组合中一份摘要性产物存在于其中。


    设计文档的构成


    设计文档是非正式的文件,因此其内容没有严格的指导方针。第一条规则是:以对特定项目最有意义的形式编写。


    话虽如此,事实证明,某种结构已经被证明非常有用。


    上下文和范围


    本节为读者提供了新系统构建的大致概述以及实际正在构建的内容。这不是一份需求文档。保持简洁!目标是让读者迅速了解情况,但可以假设有一些先前的知识,并且可以链接到详细信息。本节应完全专注于客观背景事实。


    目标和非目标


    列出系统目标的简短项目列表,有时更重要的是列出非目标。请注意,非目标并不是否定性的目标,比如“系统不应崩溃”,而是明确选择不作为目标而合理可能成为目标的事项。一个很好的例子就是“ACID兼容性”;在设计数据库时,您肯定想知道是否将其作为一个目标或非目标。如果它是一个非目标,则仍然可以选择提供该功能的解决方案,前提是它不会引入阻碍实现这些目标的权衡考虑。


    实际设计


    这一部分应该以概述开始,然后进入细节。


    image.png


    设计文档是记录你在软件设计中所做的权衡的地方。专注于这些权衡,以产生具有长期价值的有用文档。也就是说,在给定上下文(事实)、目标和非目标(需求)的情况下,设计文档是提出解决方案并展示为什么特定解决方案最能满足这些目标的地方。


    撰写文件而不是使用更正式的媒介之一的原因在于提供灵活性,以适当方式表达手头问题集。因此,并没有明确指导如何描述设计。


    话虽如此,已经出现了一些最佳实践和重复主题,在大多数设计文档中都很合理:


    系统上下文图


    在许多文档中,系统上下文图非常有用。这样的图表将系统显示为更大的技术环境的一部分,并允许读者根据他们已经熟悉的环境来理解新设计。


    image.png
    一个系统上下文图的示例。


    APIs


    如果设计的系统暴露出一个API,那么草拟出该API通常是个好主意。然而,在大多数情况下,应该抵制将正式接口或数据定义复制粘贴到文档中的诱惑,因为这些定义通常冗长、包含不必要的细节,并且很快就会过时。相反,重点关注与设计及其权衡相关的部分。


    数据存储


    存储数据的系统可能需要讨论如何以及以什么样的形式进行存储。与对API的建议类似,出于同样的原因,应避免完全复制粘贴模式定义。而是专注于与设计及其权衡相关的部分。


    代码和伪代码


    设计文档很少包含代码或伪代码,除非描述了新颖的算法。在适当的情况下,可以链接到展示设计可实现性的原型。


    约束程度


    影响软件设计和设计文档形状的主要因素之一是解决方案空间的约束程度。


    在极端情况下,有一个“全新软件项目”,我们只知道目标,解决方案可以是任何最合理的选择。这样的文档可能涉及范围广泛,但也需要快速定义一组规则,以便缩小到可管理的解决方案集。


    另一种情况是系统中可能存在非常明确定义的解决方案,但如何将它们结合起来实现目标并不明显。这可能是一个难以更改且未设计为满足您期望功能需求的遗留系统,或者是需要在主机编程语言约束下运行的库设计。


    在这种情况下,您可能能够相对容易地列举出所有可以做到的事情,但需要创造性地将这些事物组合起来实现目标。可能会有多个解决方案,并且没有一个真正很好,在识别了所有权衡后该文档应专注于选择最佳方式。


    考虑的替代方案


    本节列出了其他可能达到类似结果的设计方案。重点应放在每个设计方案所做的权衡以及这些权衡如何导致选择文档主题中所述设计的决策上。


    尽管对于最终未被选中的解决方案可以简洁地进行描述,但是这一部分非常重要,因为它明确展示了根据项目目标而选择该解决方案为最佳选项,并且还说明了其他解决方案引入了不太理想的权衡,读者可能会对此产生疑问。


    交叉关注点


    这是您的组织可以确保始终考虑到安全、隐私和可观察性等特定的交叉关注点的地方。这些通常是相对简短的部分,解释设计如何影响相关问题以及如何解决这些问题。团队应该在他们自己的情况下标准化这些关注点。


    由于其重要性,Google项目需要有专门的隐私设计文档,并且还有专门针对隐私和安全进行Review。尽管Review只要求在项目启动之前完成,但最佳实践是尽早与隐私和安全团队合作,以确保从一开始就将其纳入设计中。如果针对这些主题有专门文档,则中央设计文档当然可以引用它们而不详述。


    设计文档的长度


    设计文档应该足够详细,但又要短到忙碌的人实际上能读完。对于较大的项目来说,最佳页数似乎在10-20页左右。如果超过这个范围,可能需要将问题拆分成更易管理的子问题。还应注意到,完全可以编写一个1-3页的“迷你设计文档”。这对于敏捷项目中的增量改进或子任务尤其有帮助 - 你仍然按照长篇文档一样进行所有步骤,只是保持简洁,并专注于有限的问题集合。


    何时不需要编写设计文档


    编写设计文档是一种额外的工作量。是否要编写设计文档的决策取决于核心权衡,即组织共识在设计、文档、高级Review等方面的好处是否超过了创建文档所需的额外工作量。这个决策的核心在于解决设计问题是否模糊——因为问题复杂性或解决方案复杂性,或者两者都有。如果不模糊,则通过撰写文档来进行流程可能没有太大价值。


    一个明确的指标表明可能不需要文档是那些实际上只是实施手册而非设计文档。如果一个文件基本上说“这就是我们将如何实现它”,而没有涉及权衡、替代方案和解释决策(或者解决方案显然意味着没有任何权衡),那么直接编写程序可能会更好。


    最后,创建和Review设计文档所需的开销可能与原型制作和快速迭代不兼容。然而,大多数软件项目确实存在一系列已知问题。遵循敏捷方法论并不能成为对真正已知问题找到正确解决方案时间投入不足的借口。此外,原型制作本身可以是设计文档创建的一部分。“我尝试过,它有效”是选择一个设计方案的最佳论据之一。


    设计文档的生命周期


    设计文档的生命周期包括以下步骤:


    创建和快速迭代
    Review(可能需要多轮)
    实施和迭代
    维护和学习


    创作和快速迭代


    你撰写文档。有时与一组合著者共同完成。


    这个阶段很快进入了一个快速迭代的时间,文档会与那些对问题领域最了解的同事(通常是同一个团队的人)分享,并通过他们提出的澄清问题和建议来推动文档达到第一个相对稳定版本。


    虽然你肯定会找到一些工程师甚至团队更喜欢使用版本控制和代码Review工具来创建文档,但在谷歌,绝大多数设计文档都是在Google Docs中创建并广泛使用其协作功能。


    Review


    在Review阶段,设计文档会与比原始作者和紧密合作者更广泛的受众分享。Review可以增加很多价值,但也是一个危险的开销陷阱,所以要明智地对待。


    Review可以采取多种形式:较轻量级的版本是将文档发送给(更广泛的)团队列表,让大家有机会看一下。然后主要通过文档中的评论线程进行讨论。在Review方面较重型的方式是正式的设计评审会议,在这些会议上作者通常通过专门制作的演示文稿向经验丰富、资深工程师们展示该文档内容。谷歌公司许多团队都定期安排了此类会议,并邀请工程师参加审核。自然而然地等待这样的会议可能会显著减慢开发过程。工程师可以通过直接寻求最关键反馈并不阻碍整体审核进度来缓解这个问题。


    当谷歌还是一家较小的公司时,通常会将设计发送到一个中央邮件列表,高级工程师会在自己的闲暇时间进行Review。这可能是处理公司事务的好方法。其中一个好处是确立了相对统一的软件设计文化。但随着公司规模扩大到更庞大的工程团队,维持集中式方法变得不可行。


    此类Review所添加的主要价值在于它们为组织的集体经验提供了融入设计的机会。最重要的是,在Review阶段可以确保考虑到横切关注点(如可观察性、安全性和隐私)等方面。Review的主要价值并非问题被发现本身,而是这些问题相对早期地在开发生命周期内被发现,并且修改仍然相对廉价。


    实施和迭代


    当事情进展到足够程度,有信心进一步Review不需要对设计进行重大更改时,就是开始实施的时候了。随着计划与现实的碰撞,不可避免地会出现缺陷、未解决的需求或者被证明错误的猜测,并且需要修改设计。强烈建议在这种情况下更新设计文档。作为一个经验法则:如果设计系统尚未发布,则绝对要更新文档。在实践中,我们人类很擅长忘记更新文件,并且由于其他实际原因,变更通常被隔离到新文件中。这最终导致了一个类似于美国宪法带有一堆修正案而不是一份连贯文档的状态。从原始文档链接到这些修正案可以极大地帮助那些试图通过设计文档考古学来理解目标系统的可怜未来维护程序员们。


    维护和学习


    当谷歌工程师面对一个之前没有接触过的系统时,他们经常会问:“设计文档在哪里?”虽然设计文档(像所有文档一样)随着时间推移往往与现实脱节,但它们通常是了解系统创建背后思考方式最容易入手的途径。


    作为作者,请你给自己一个方便,并在一两年后重新阅读你自己的设计文档。你做得对了什么?你做错了什么?如果今天要做出不同决策,你会怎么选择?回答这些问题是作为一名工程师进步并改善软件设计技能的好方法。


    结论


    设计文档是在软件项目中解决最困难问题时获得清晰度和达成共识的好方法。它们可以节省金钱,因为避免了陷入编码死胡同而无法实现项目目标,并且可以通过前期调查来避免这种情况;但同时也需要花费时间和金钱进行创建和Review。所以,在选择项目时要明智!


    在考虑撰写设计文档时,请思考以下几点:


    您是否对正确的软件设计感到不确定,是否值得花费前期时间来获得确定性?


    相关地,是否有必要让资深工程师参与其中,即使他们可能无法Review每个代码更改,在设计方面能提供帮助吗?


    软件设计是否模糊甚至具有争议性,以至于在组织中达成共识将是有价值的?


    我的团队是否有时会忘记在设计中考虑隐私、安全、日志记录或其他横切关注点?


    组织中对遗留系统的高层次洞察力提供文档存在强烈需求吗?


    如果您对以上3个或更多问题回答“是”,那么撰写一个设计文档很可能是开始下一个软件项目的好方法。


    Reference


    http://www.industrialempathy.com/posts/desig…


    作者:dooocs
    来源:juejin.cn/post/7272730352710418447
    收起阅读 »

    拒绝代码PUA,优雅地迭代业务代码

    最初的美好 没有历史包袱,就没有压力,就是美好的。 假设项目启动了这样一个业务——造车:生产一辆小汽车(Car),分别在不同的零件车间(车架(Sheel)、发动机(Engine)、车轮(Wheel))安装相应的零件,所有零件安装完成后回到提车车间就可以提车。 ...
    继续阅读 »

    最初的美好


    没有历史包袱,就没有压力,就是美好的。


    假设项目启动了这样一个业务——造车:生产一辆小汽车(Car),分别在不同的零件车间(车架(Sheel)、发动机(Engine)、车轮(Wheel))安装相应的零件,所有零件安装完成后回到提车车间就可以提车。


    Ugly1.gif


    这样的需求开发起来很简单:



    • 数据实体


    data class Car(
    var shell: Shell? = null,
    var engine: Engine? = null,
    var wheel: Wheel? = null,
    ) : Serializable {
    override fun toString(): String {
    return "Car: Shell(${shell}), Engine(${engine}), Wheel(${wheel})"
    }
    }

    data class Shell(
    ...
    ) : Serializable

    data class Engine(
    ...
    ) : Serializable

    data class Wheel(
    ...
    ) : Serializable


    • 零件车间(以车架为例)


    class ShellFactoryActivity : AppCompatActivity() {
    private lateinit var btn: Button
    private lateinit var back: Button
    private lateinit var status: TextView

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_shell_factory)
    val car = intent.getSerializableExtra("car") as Car
    status = findViewById(R.id.status)
    btn = findViewById(R.id.btn)
    btn.setOnClickListener {
    car.shell = Shell(
    id = 1,
    name = "比亚迪车架",
    type = 1
    )
    status.text = car.toString()
    }
    back = findViewById(R.id.back)
    back.setOnClickListener {
    setResult(RESULT_OK, intent.apply {
    putExtra("car", car)
    })
    finish()
    }
    }
    }


    class EngineFactoryActivity : AppCompatActivity() {
    // 和安装车架流程一样
    }

    class WheelFactoryActivity : AppCompatActivity() {
    // 和安装车架流程一样
    }


    • 提车车间


    class MainActivity : AppCompatActivity() {
    private var car: Car? = null
    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    car = Car()
    refreshStatus()
    findViewById<Button>(R.id.shell).setOnClickListener {
    val it = Intent(this, ShellFactoryActivity::class.java)
    it.putExtra("car", car)
    startActivityForResult(it, REQUEST_SHELL)
    }
    findViewById<Button>(R.id.engine).setOnClickListener {
    val it = Intent(this, EngineFactoryActivity::class.java)
    it.putExtra("car", car)
    startActivityForResult(it, REQUEST_ENGINE)
    }
    findViewById<Button>(R.id.wheel).setOnClickListener {
    val it = Intent(this, WheelFactoryActivity::class.java)
    it.putExtra("car", car)
    startActivityForResult(it, REQUEST_WHEEL)
    }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    if (resultCode != RESULT_OK) return
    when (requestCode) {
    REQUEST_SHELL -> {
    Log.i(TAG, "安装车架完成")
    car = data?.getSerializableExtra("car") as Car
    }
    REQUEST_ENGINE -> {
    Log.i(TAG, "安装发动机完成")
    car = data?.getSerializableExtra("car") as Car
    }
    REQUEST_WHEEL -> {
    Log.i(TAG, "安装车轮完成")
    car = data?.getSerializableExtra("car") as Car
    }
    }
    refreshStatus()
    }

    private fun refreshStatus() {
    findViewById<TextView>(R.id.status).text = car?.toString()
    findViewById<Button>(R.id.save).run {
    isEnabled = car?.shell != null && car?.engine != null && car?.wheel != null
    setOnClickListener {
    Toast.makeText(this@MainActivity, "提车咯!", Toast.LENGTH_SHORT).show()
    }
    }
    }

    companion object {
    private const val TAG = "MainActivity"
    const val REQUEST_SHELL = 1
    const val REQUEST_ENGINE = 2
    const val REQUEST_WHEEL = 3
    }
    }

    即使是初学者也能看出来,业务实现起来很简单,通过ActivitystartActivityForResult就能跳转到相应的零件车间,安装好零件回到提车车间就完事了。


    开始迭代


    往往业务的第一个版本就是这么简单,感觉也没什么好重构的。


    但是业务难免会进行迭代。比如业务迭代到1.1版本:客户想要给汽车装上行车电脑,而安装行车电脑不需要跳转到另一个车间,而是在提车车间操作,但是需要很长的时间。


    Ugly2.gif


    看起来也简单,新增一个Computer实体类和ComputerFactoryHelper


    object ComputerFactoryHelper {
    fun provideComputer(block: Computer.() -> Unit) {
    Thread.sleep(5_000)
    block(Computer())
    }
    }

    data class Computer(
    val id: Int = 1,
    val name: String = "行车电脑",
    val cpu: String = "麒麟90000"
    ) : Serializable {
    override fun toString(): String {
    return "$name-$cpu"
    }
    }

    再在提车车间新增按钮和逻辑代码:


    findViewById<Button>(R.id.computer).setOnClickListener {
    object : Thread() {
    override fun run() {
    ComputerFactoryHelper.provideComputer {
    car?.computer = this
    runOnUiThread { refreshStatus() }
    }
    }
    }.start()

    }

    目前看起来也没啥难的,那是因为我们模拟的业务场景足够简单,但是相信很多实际项目的屎山代码,就是通过这样的业务迭代,一点一点地堆积而成的。


    从迭代到崩溃


    咱们来采访一下最近被一个小小的业务迭代需求搞崩溃的Android开发——小王。



    记者:小王你好,听说最近你Emo了,甚至多次萌生了就地辞职的念头?


    小王:最近AI不是很火吗,产品给我提了一个需求,在上传音乐时可以选择在后端生成一个AI视频,然后一起上传。


    记者:哦?这不是一个小需求吗?


    小王:但是我打开目前上传业务的代码就傻了啊!就说Activity吧,有:BasePublishActivity,BasePublishFinallyActivity,SinglePublishMusicActivity,MultiPublishMusicActivity,PublishFinallyActivity,PublishCutMusicFinallyActivity, Publish(好多好多)FinallyActivity... 当然,这只是冰山一角。再说上传流程。如果只上传一首音乐,需要先调一个接口/sts拿到一个Oss Token,再调用第三方的Oss库上传文件,拿到一个url,然后再把这个url和其他的信息(标题、标签等)组成一个HashMap,再调用一个接口/save提交到后端,相当于调3个接口... 如果要批量上传N个音乐,就要调3 * N个接口,如果还要给每个音乐配M个图片,就要调3 * N+3 * N * M个接口... 如果上传一个音乐配一个本地视频,就要调3 * 2 * N个接口,并且,上传视频流程还不一样的是,需要在调用/save接口之后再调用第三方Oss上传视频文件...再说数据类。上面提到上传过程中需要添加图片、视频、活动类型啥的,代码里封装了一个EditInfo类足足有30个属性!,由于是Java代码并且实现了Parcelable接口,光一个Data类就有400多行!你以为这就完了?EditInfo需要在上传时转成PublishInfo类,PublishInfo还可以转成PublishDraft,PublishDraft可以保存到数据库中,从数据库中可以读取PublishDraft然后转成EditInfo再重新编辑...


    记者:(感觉小王精神状态有点问题,于是掐掉了直播画面)



    相信小王的这种情况,很多业务开发同学都经历过吧。回头再看一下前面的造车业务,其实和小王的上传业务一样,就是一开始很简单,迭代个7、8个版本就开始陷入一种困境:即使迭代需求再小,开发起来都很困难。


    优雅地迭代业务代码?


    假如咱们想要优雅地迭代业务代码,应该怎么做呢?


    小王遇到的这座屎山,咱们现在就不要去碰了,先就从前面提到的造车业务开始吧。


    很多同学会想到重构,俺也一样。接下来,我就要讨论一下如何优雅安全地重构既有业务代码。



    先抛出一个观点:对于程序员来说,想要保持“优雅”,最重要的品质就是抽象。



    ❓ 这时可能有同学就要反驳我了:过早的抽象没有必要。


    ❗ 别急,我现在要说的抽象,并不是代码层面的抽象,而是对业务的抽象,乃至对技术思维的抽象


    什么是代码层面的抽象?比如刚刚的Shell/Engine/WheelFactoryActivity,其实是可以抽象为BaseFactoryActivity,然后通过实现其中的零件类型就行了。但我不会建议你这么做,为啥?看看小王刚才的疯言疯语就明白了。各个XxxFactoryActivity看着差不多,但在实际项目中很可能会开枝散叶,各自迭代出不同的业务细节。到那时,项目里就是各种BaseXxxActivityXxxV1ActivityXxxV2Activity...


    那什么又是业务的抽象?直接上代码:


    interface CarFactory {
    val factory: suspend Car.() -> Car
    }

    造车业务,无论在哪个环节,都是在Car上装配零件(或者任何出其不意的操作),然后产生一个新的Car;另外,这个环节无论是跳转到零件车间安装零件,还是在提车车间里安装行车电脑,都是耗时操作,所以需要加suspend关键词。


    ❓ 这时可能有同学说:害!你这和BaseFactoryActivity有啥区别,不就是把抽象类换成接口了吗?


    ❗ 别急,我并没有要让XxxFactoryActivity去继承CarFactory啊,想想小王吧,这个XxxFactoryActivity就相当于他的同事在两年前写的代码,小王肯定打死都不会想去修改这里面的代码的。


    Computer是新业务,我们只改它。首先我们根据这个接口把ComputerFactoryHelper改一下:


    object ComputerFactoryHelper : CarFactory {
    private suspend fun provideComputer(block: Computer.() -> Unit) {
    delay(5_000)
    block(Computer())
    }

    override val factory: suspend Car.() -> Car = {
    provideComputer {
    computer = this
    }
    this
    }
    }

    那么,在提车车间就可以这样改:


    private var computerFactory: CarFactory = ComputerFactoryHelper
    findViewById<Button>(R.id.computer).setOnClickListener {
    lifecycleScope.launchWhenResumed {
    computerFactory.factory.invoke(car)
    refreshStatus()
    }
    }

    ❓ 那么XxxFactoryActivity相关的流程又应该怎么重构呢?


    Emo时间


    我先反问一下大家,为啥咱们Android程序员总是盯着Activity不放呢?


    我再反问一下大家,为啥咱们Android程序员总是盯着Activity不放呢?


    我再再反问一下大家,为啥咱们Android程序员总是盯着Activity不放呢?


    甚至,很多人即使学了ComposeFlutter,仍然对Activity心心念念。



    当你在一千个日日夜夜里,重复地写着XxxActivity,写onCreate/onResume/onDestroy,写startActivityForResult/onActivityResult时,当你每次想要换工作,打开面经背诵Activity的启动模式,生命周期,AMS原理时,可曾对Activity有过厌倦,可曾思考过编程的意义?


    你也曾努力查阅Activity的源码,学习MVP/MVVM/MVI架构,试图让你的Activity保持清洁,但无论你怎么努力,却始终活在Activity的阴影之下。


    你有没有想过,咱们正在被Activity PUA



    说实话,作为一名INFP,本人不是很适合做程序员。相比技术栈的丰富和技术原理的深入,我更看重的是写代码的感受。如果写代码都能被PUA,那我还怎么愉快的写代码?


    当我Emo了很久之后,我意识到了,我一直在被代码PUA,不光是同事的代码,也有自己的代码,甚至有Android框架,以及外国大佬不断推出的各种新技术新框架。



    对对对!你们都没有问题,是我太菜了555555555



    优雅转身


    Emo过后,还是得回到残酷的职场啊!但是我们要优雅地转身回来!


    ❓ 刚才不是说要处理XxxFactoryActivity相关业务吗?


    ❗ 这时我就要提到另外一种抽象:技术思维的抽象


    Activity?F*ck off!


    Activity的跳转返回啥的,也无非就是一次耗时操作嘛,咱们也应该将它抽象为CarFactory,就是这个东东:


    interface CarFactory {
    val factory: suspend Car.() -> Car
    }

    基于这个信念,我从记忆中想到这么一个东东:ActivityResultLauncher


    说实话,我以前都没用过这玩意儿,但是我这时好像抓到了救命稻草。


    随便搜了个教程并谢谢他,参考这篇博客,我们可以把startActivityForResultonActivityResult这套流程,封装成一次异步调用。


    open class BaseActivity : AppCompatActivity() {
    private lateinit var startActivityForResultLauncher: StartActivityForResultLauncher

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    startActivityForResultLauncher = StartActivityForResultLauncher(this)
    }

    fun startActivityForResult(
    intent: Intent,
    callback: (resultCode: Int, data: Intent?) -> Unit
    )
    {
    startActivityForResultLauncher.launch(intent) {
    callback.invoke(it.resultCode, it.data)
    }
    }
    }

    MainActivity继承BaseActivity,就可以绕过Activity了,后面的事情就简单了。只要咱们了解过协程,就能轻易想到异步转同步这一普通操作:suspendCoroutine


    于是,我们就可以在不修改XxxFactoryActivity的情况下,写出基于CarFactory的代码了。还是以车架车间为例:


    class ShellFactoryHelper(private val activity: BaseActivity) : CarFactory {

    override val factory: suspend Car.() -> Car = {
    suspendCoroutine { continuation ->
    val it = Intent(activity, ShellFactoryActivity::class.java)
    it.putExtra("car", this)
    activity.startActivityForResult(it) { resultCode, data ->
    (data?.getSerializableExtra("car") as? Car)?.let {
    Log.i(TAG, "安装车壳完成")
    shell = it.shell
    continuation.resumeWith(Result.success(this))
    }
    }
    }
    }
    }

    然后在提车车间,和Computer业务同样的使用方式:


    private var shellFactory: CarFactory = ShellFactoryHelper(this)
    findViewById<Button>(R.id.shell).setOnClickListener {
    lifecycleScope.launchWhenResumed {
    shellFactory.factory.invoke(car)
    refreshStatus()
    }
    }

    最终,在我们的提车车间,依赖的就是一些CarFactory,所有的业务操作都是抽象的。到达这个阶段,相信大家都有了自己的一些想法了(比如维护一个carFactoryList,用Hilt管理CarFactory依赖,泛型封装等),想要继续怎么重构/维护,就全看自己的实际情况了。


    class MainActivity : BaseActivity() {
    private var car: Car = Car()
    private var computerFactory: CarFactory = ComputerFactoryHelper
    private var engineFactory: CarFactory = EngineFactoryHelper(this)
    private var shellFactory: CarFactory = ShellFactoryHelper(this)
    private var wheelFactory: CarFactory = WheelFactoryHelper(this)

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    refreshStatus()
    findViewById<Button>(R.id.shell).setOnClickListener {
    lifecycleScope.launchWhenResumed {
    shellFactory.factory.invoke(car)
    refreshStatus()
    }
    }
    findViewById<Button>(R.id.engine).setOnClickListener {
    lifecycleScope.launchWhenResumed {
    engineFactory.factory.invoke(car)
    refreshStatus()
    }
    }
    findViewById<Button>(R.id.wheel).setOnClickListener {
    lifecycleScope.launchWhenResumed {
    wheelFactory.factory.invoke(car)
    refreshStatus()
    }
    }
    findViewById<Button>(R.id.computer).setOnClickListener {
    lifecycleScope.launchWhenResumed {
    Toast.makeText(this@MainActivity, "稍等一会儿", Toast.LENGTH_LONG).show()
    computerFactory.factory.invoke(car)
    Toast.makeText(this@MainActivity, "装好了!", Toast.LENGTH_LONG).show()
    refreshStatus()
    }
    }
    }

    private fun refreshStatus() {
    findViewById<TextView>(R.id.status).text = car.toString()
    findViewById<Button>(R.id.save).run {
    isEnabled = car.shell != null && car.engine != null && car.wheel != null && car.computer != null
    setOnClickListener {
    Toast.makeText(this@MainActivity, "提车咯!", Toast.LENGTH_SHORT).show()
    }
    }
    }
    }

    总结



    • 抽象是程序员保持优雅的最重要能力。

    • 抽象不应局限在代码层面,而是要上升到业务,乃至技术思维上。

    • 有意识地对代码PUA说:No!

    • 学习新技术时,不应只学会调用,也不应迷失在技术原理上,更重要的是花哨的技术和朴实的编程思想之间的化学反应。


    作者:blackfrog
    来源:juejin.cn/post/7274084216286036004
    收起阅读 »

    Xcode15Beta填坑-修复YYLabel的Crash问题

    iOS
    前言 趁着版本空隙,升级到了Xcode15-Beta2本想提前体验下iOS17。本以为这次升级Xcode能直接运行应该没什么大问题,没曾想到一运行后程序直接Crash了,Crash是在YYLabel下的YYAsyncLayer类里面。众所周知,YYLabel是...
    继续阅读 »

    前言


    趁着版本空隙,升级到了Xcode15-Beta2本想提前体验下iOS17。本以为这次升级Xcode能直接运行应该没什么大问题,没曾想到一运行后程序直接Crash了,Crash是在YYLabel下的YYAsyncLayer类里面。众所周知,YYLabel是由远古大神ibireme开发的YYKit下属的组件。已经多年没有适配了,但是依然老当益壮,只有部份由于Api变更导致的问题需要简单维护即可。以下就是此次问题定位与修复的全过程。


    Crash定位


    此次升级后编译我司项目,直接Crash,Crash日志如下。




    Crash是在YYTextAsyncLayer类下面的第193行代码如下:


    UIGraphicsBeginImageContextWithOptions(self.bounds.size, self.opaque, self.contentsScale);


    其实第一眼看代码崩溃提示就很明显了,这次Xcode15在UIGraphicsBeginImageContextWithOptions下面加了断言,如果传入的size width 或者 height其中一个为0,会直接return 返回断言。并且提示我们升级Api为UIGraphicsImageRenderer可以解决此问题。


    本着探究的精神,我重新撤回用Xcode14.3.1编译,看为什么不会崩溃,结果其实也会报Invalid size警告但是不会崩溃,警告如下。




    解决方案


    我们使用UIGraphicsImageRenderer替代老旧的UIGraphicsBeginImageContextWithOptions(其实早已标记为过时),实测即使size为 zero,UIGraphicsImageRenderer在Xcode15下依然会渲染出一个zero size的Image,但是这毫无意义,所以我们简单判断一下,如果是非法的size我们直接retrun,代码如下:


    从193行开始一直替换到self.contents = xxx。为止,即可解决此次问题。


    if (self.bounds.size.width < 1 || self.bounds.size.height < 1) {

    CGImageRef image = (__bridge_retained CGImageRef)(self.contents);

    self.contents = nil;

    if (image) {

    CFRelease(image);

    }

    return;

    }

    UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:self.bounds.size];

    UIImage *image = [renderer imageWithActions:^(UIGraphicsImageRendererContext *context) {

    if (self.opaque) {

    if (!self.backgroundColor || CGColorGetAlpha(self.backgroundColor) < 1) {

    CGContextSetFillColorWithColor(context.CGContext, [UIColor whiteColor].CGColor);

    [context fillRect:self.bounds];

    }

    if (self.backgroundColor) {

    CGContextSetFillColorWithColor(context.CGContext, self.backgroundColor);

    [context fillRect:self.bounds];

    }

    }

    task.display(context.CGContext, self.bounds.size, ^{return NO;});

    }];

    self.contents = (__bridge id)(image.CGImage);


    结尾


    以上就是Xcode15修复UIGraphicsBeginImageContextWithOptions由于加了断言导致的Crash问题。我也强烈建议各位有时间检查项目其他代码直接升级成UIGraphicsImageRenderer的方案。如果确实没时间,要加上如下判断,防止Crash。由于我是在Debug上必崩,如果是断言问题Release不一定会有事,但是还是建议大家修改一下。


    if (self.size.width < 1 || self.size.height < 1) {

    return nil;

    }

    作者:jerryfans
    链接:https://juejin.cn/post/7250288616534261797
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    刚咬了一口馒头,服务器突然炸了!

    首先,这个项目是完全使用Websocket开发,后端采用了基于Swoole开发的Hyperf框架,集成了Webscoket服务。 其次,这个项目是我们组第一次尝试使用Socket替换传统的HTTP API请求,前端使用了Vue3,前端同学定义了一套Socket...
    继续阅读 »

    首先,这个项目是完全使用Websocket开发,后端采用了基于Swoole开发的Hyperf框架,集成了Webscoket服务。
    其次,这个项目是我们组第一次尝试使用Socket替换传统的HTTP API请求,前端使用了Vue3,前端同学定义了一套Socket管理器。


    看着群里一群“小可爱”疯狂乱叫,我被吵的头都炸了,赶紧尝试定位问题。

    1. 查看是否存在Jenkins发版 -> 无
    2. 查看最新提交记录 -> 最后一次提交是下午,到晚上这段时间系统一直是稳定的
    3. 查看服务器资源,htop后,发现三台相关的云服务器资源都出现闲置状态
    4. 查看PolarDB后,既MySQL,连接池正常、吞吐量和锁正常
    5. 查看Redis,资源正常,无异常key
    6. 查看前端控制台,出现一些报错,但是这些报错经常会变化
    7. 查看前端测试环境、后端测试环境,程序全部正常
    8. 重启前端服务、后端服务、NGINX服务,好像没用,过了5分钟后,咦,好像可以访问了

    就在我们组里的“小可爱”通知系统恢复正常后,20分钟不到,再一次处于无法打开的状态,沃焯!!!
    完蛋了,找不出问题在哪里了,实在想不通问题究竟出在哪里。


    我不服啊,我不理解啊!


    咦,Nginx?对呀,我瞅瞅访问日志,于是我浏览了一下access.log,看起来貌似没什么问题,不过存在大量不同浏览器的访问记录,刷的非常快。


    再瞅瞅error.log,好像哪里不太对

    2023/04/20 23:15:35 [alert] 3348512#3348512: 768 worker_connections are not enough
    2023/04/20 23:33:33 [alert] 3349854#3349854: *3492 open socket #735 left in connection 1013

    这是什么?貌似是连接池问题,赶紧打开nginx.conf看一下配置

    events {
    worker_connections 666;
    # multi_accept on;
    }

    ???


    运维小可爱,你特么在跟我开玩笑?虽然我们这个系统是给B端用的,还是我们自己组的不到100人,但是这连接池给的也太少了吧!


    另外,前端为什么会开到 1000 个 WS 连接呢?后端为什么没有释放掉FD呢?


    询问后,才知道,前端的Socket管理器,会在连接超时或者其它异常情况下,重新开启一个WS连接。


    后端的心跳配置给了300秒

    Constant::OPTION_OPEN_WEBSOCKET_PROTOCOL => true, // websocket 协议
    Constant::OPTION_HEARTBEAT_CHECK_INTERVAL => 150,
    Constant::OPTION_HEARTBEAT_IDLE_TIME => 300,

    此时修改nginx.conf的配置,直接拉满!!!

    worker_connections 655350;

    重启Nginx,哇绰,好像可以访问了,但是每当解决一个问题,总会产生新的问题。


    此时error.log中出现了新的报错:

    2023/04/20 23:23:41 [crit] 3349767#3349767: accept4() failed (24: Too many open files)

    这种就不怕了,貌似和Linux的文件句柄限制有关系,印象中是1024个。
    至少有方向去排查,不是吗?而且这已经算是常规问题了,我只需小小的百度一下,哼哼~


    拉满拉满!!

    worker_rlimit_nofile 65535;

    此时再次重启Nginx服务,系统恢复稳定,查看当前连接数:

    netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'
    # 打印结果
    TIME_WAIT 1175

    FIN_WAIT1 52

    SYN_RECV 1

    FIN_WAIT2 9

    ESTABLISHED 2033

    经过多次查看,发现TIME_WAITESTABLISHED都在不断减少,最后完全降下来。


    本次问题排查结束,问题得到了解决,但是关于socket的连接池管理器,仍然需要优化,及时释放socket无用的连接。


    作者:兰陵笑笑生666
    链接:https://juejin.cn/post/7224314619865923621
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    99% 的 iOS 开发都不知道的 KVO 崩溃

    iOS
    背景 crash 监控发现有大量的新增崩溃,堆栈如下0 libsystem_platform.dylib __os_unfair_lock_corruption_abort() 1 libsystem_platform.dylib __os_unfair_lo...
    继续阅读 »

    背景


    crash 监控发现有大量的新增崩溃,堆栈如下

    0	libsystem_platform.dylib	__os_unfair_lock_corruption_abort()
    1 libsystem_platform.dylib __os_unfair_lock_lock_slow()
    2 Foundation __NSSetBoolValueAndNotify()

    分析堆栈


    __os_unfair_lock_corruption_abort


    log 翻译:lock 已损坏

    _os_unfair_lock_corruption_abort(os_ulock_value_t current)
    {
    __LIBPLATFORM_CLIENT_CRASH__(current, "os_unfair_lock is corrupt");
    }

    __os_unfair_lock_lock_slow


    在这个方法里面 __ulock_wait 返回 EOWNERDEAD 调用 corruption abort 方法。

    int ret = __ulock_wait(UL_UNFAIR_LOCK | ULF_NO_ERRNO | options,
    l, current, 0);
    if (unlikely(ret < 0)) {
    switch (-ret) {
    case EINTR:
    case EFAULT:
    continue;
    case EOWNERDEAD:
    _os_unfair_lock_corruption_abort(current);
    break;
    default:
    __LIBPLATFORM_INTERNAL_CRASH__(-ret, "ulock_wait failure");
    }
    }

    EOWNERDEAD 的定义


    #define EOWNERDEAD      105             /* Previous owner died */


    到这里猜测是 lock 的 owner 已经野指针了,继续向下看。


    __NSSetBoolValueAndNotify


    google 下这个方法是在 KVO 里面修改属性的时候调用,伪代码:

    int __NSSetBoolValueAndNotify(int arg0, int arg1, int arg2) {
    r31 = r31 - 0x90;
    var_30 = r24;
    stack[-56] = r23;
    var_20 = r22;
    stack[-40] = r21;
    var_10 = r20;
    stack[-24] = r19;
    saved_fp = r29;
    stack[-8] = r30;
    r20 = arg2;
    r21 = arg1;
    r19 = arg0;
    r0 = object_getClass(arg0);
    r0 = object_getIndexedIvars(r0); // 理清这个崩溃的关键方法,这里和汇编代码不一致,汇编代码的入参是 r0 + 0x20
    r23 = r0;
    os_unfair_recursive_lock_lock_with_options();
    CFDictionaryGetValue(*(r23 + 0x18), r21);
    r22 = _objc_msgSend$copyWithZone:();
    os_unfair_recursive_lock_unlock();
    if (*(int8_t *)(r23 + 0x28) != 0x0) {
    _objc_msgSend$willChangeValueForKey:();
    (class_getMethodImplementation(*r23, r21))(r19, r21, r20);
    _objc_msgSend$didChangeValueForKey:();
    }
    else {
    _objc_msgSend$_changeValueForKey:key:key:usingBlock:();
    }
    var_38 = **qword_9590e8;
    r0 = objc_release_x22();
    if (**qword_9590e8 != var_38) {
    r0 = __stack_chk_fail();
    }
    return r0;
    }

    os_unfair_recursive_lock_lock_with_options


    崩溃调用栈中间还有这一层的内联调用 os_unfair_recursive_lock_lock_with_options。这里的 lock owner 有个比较赋值的操作,如果 oul_value 等于 OS_LOCK_NO_OWNER 则赋值 self 然后 return。崩溃时这里继续向下执行了,那这里的 oul_value 的取值只能是 lock->oul_value。到这里猜测崩溃的原因是 lock->oul_value 野指针了。

    void
    os_unfair_recursive_lock_lock_with_options(os_unfair_recursive_lock_t lock,
    os_unfair_lock_options_t options)
    {
    os_lock_owner_t cur, self = _os_lock_owner_get_self();
    _os_unfair_lock_t l = (_os_unfair_lock_t)&lock->ourl_lock;

    if (likely(os_atomic_cmpxchgv2o(l, oul_value,
    OS_LOCK_NO_OWNER, self, &cur, acquire))) {
    return;
    }

    if (OS_ULOCK_OWNER(cur) == self) {
    lock->ourl_count++;
    return;
    }

    return _os_unfair_lock_lock_slow(l, self, options);
    }


    OS_ALWAYS_INLINE OS_CONST
    static inline os_lock_owner_t
    _os_lock_owner_get_self(void)
    {
    os_lock_owner_t self;
    self = (os_lock_owner_t)_os_tsd_get_direct(__TSD_MACH_THREAD_SELF);
    return self;
    }

    object_getIndexedIvars


    __NSSetBoolValueAndNotify 里面的获取 lock 的方法,这个函数非常关键。

    /** 
    * Returns a pointer to any extra bytes allocated with an instance given object.
    *
    * @param obj An Objective-C object.
    *
    * @return A pointer to any extra bytes allocated with \e obj. If \e obj was
    * not allocated with any extra bytes, then dereferencing the returned pointer is undefined.
    *
    * @note This function returns a pointer to any extra bytes allocated with the instance
    * (as specified by \c class_createInstance with extraBytes>0). This memory follows the
    * object's ordinary ivars, but may not be adjacent to the last ivar.
    * @note The returned pointer is guaranteed to be pointer-size aligned, even if the area following
    * the object's last ivar is less aligned than that. Alignment greater than pointer-size is never
    * guaranteed, even if the area following the object's last ivar is more aligned than that.
    * @note In a garbage-collected environment, the memory is scanned conservatively.
    /**
    * Returns a pointer immediately after the instance variables declared in an
    * object. This is a pointer to the storage specified with the extraBytes
    * parameter given when allocating an object.
    */
    void *object_getIndexedIvars(id obj)
    {
    uint8_t *base = (uint8_t *)obj;

    if (_objc_isTaggedPointerOrNil(obj)) return nil;

    if (!obj->isClass()) return base + obj->ISA()->alignedInstanceSize();

    Class cls = (Class)obj;
    if (!cls->isAnySwift()) return base + sizeof(objc_class);

    swift_class_t *swcls = (swift_class_t *)cls;
    return base - swcls->classAddressOffset + word_align(swcls->classSize);
    }

    上层调用 __NSSetBoolValueAndNotify 里面:


    r0 = object_getClass(arg0),arg0 是实例对象,r0 是类对象,因为这里是个 KVO 的调用,那正常情况下r0 是 NSKVONotifying_xxx。


    对于 KVO 类,object_getIndexedIvars 返回的地址是 (uint8_t *)obj + sizeof(objc_class)。根据函数的注释,这个地址指向创建类时附在类空间后 extraBytes 大小的一块内存。


    debug 调试


    object_getIndexedIvars


    __NSSetBoolValueAndNotify 下的调用



    object_getIndexedIvars 入参是 NSKVONotifying_KVObject,object_getClass 获取的是 KVO Class。


    objc_allocateClassPair


    动态创建 KVO 类的方法。

     thread #8, queue = 'com.apple.root.default-qos', stop reason = breakpoint 1.1
    * frame #0: 0x000000018143a088 libobjc.A.dylib`objc_allocateClassPair
    frame #1: 0x000000018259cd94 Foundation`_NSKVONotifyingCreateInfoWithOriginalClass + 152
    frame #2: 0x00000001825b8fd0 Foundation`_NSKeyValueContainerClassGetNotifyingInfo + 56
    frame #3: 0x000000018254b7dc Foundation`-[NSKeyValueUnnestedProperty _isaForAutonotifying] + 44
    frame #4: 0x000000018254b504 Foundation`-[NSKeyValueUnnestedProperty isaForAutonotifying] + 88
    frame #5: 0x000000018254b32c Foundation`-[NSObject(NSKeyValueObserverRegistration) _addObserver:forProperty:options:context:] + 404
    frame #6: 0x000000018254b054 Foundation`-[NSObject(NSKeyValueObserverRegistration) addObserver:forKeyPath:options:context:] + 136
    frame #7: 0x00000001040d1860 Test`__29-[ViewController viewDidLoad]_block_invoke(.block_descriptor=0x0000000282a55170) at ViewController.m:28:13
    frame #8: 0x00000001043d05a8 libdispatch.dylib`_dispatch_call_block_and_release + 32
    frame #9: 0x00000001043d205c libdispatch.dylib`_dispatch_client_callout + 20
    frame #10: 0x00000001043d4b94 libdispatch.dylib`_dispatch_queue_override_invoke + 1052
    frame #11: 0x00000001043e6478 libdispatch.dylib`_dispatch_root_queue_drain + 408
    frame #12: 0x00000001043e6e74 libdispatch.dylib`_dispatch_worker_thread2 + 196
    frame #13: 0x00000001d515fdbc libsystem_pthread.dylib`_pthread_wqthread + 228

    _NSKVONotifyingCreateInfoWithOriginalClass


    objc_allocateClassPair 的上层调用。 allocate 之前的 context w2 是个固定值 0x30,即创建 KVO Class 入参 extraBytes 的大小是 0x30

        0x18259cd78 <+124>: mov    x1, x21
    0x18259cd7c <+128>: mov x2, x22
    0x18259cd80 <+132>: bl 0x188097080
    0x18259cd84 <+136>: mov x0, x20
    0x18259cd88 <+140>: mov x1, x19
    0x18259cd8c <+144>: mov w2, #0x30
    0x18259cd90 <+148>: bl 0x1880961f0 // objc_allocateClassPair
    0x18259cd94 <+152>: cbz x0, 0x18259ce24 ; <+296>
    0x18259cd98 <+156>: mov x21, x0
    0x18259cd9c <+160>: bl 0x188096410 // objc_registerClassPair
    0x18259cda0 <+164>: mov x0, x19
    0x18259cda4 <+168>: bl 0x182b45f44 ; symbol stub for: free
    0x18259cda8 <+172>: mov x0, x21
    0x18259cdac <+176>: bl 0x1880967e0 // object_getIndexedIvars
    0x18259cdb0 <+180>: mov x19, x0
    0x18259cdb4 <+184>: stp x20, x21, [x0]

    _NSKVONotifyingCreateInfoWithOriginalClass+184 处将 x20 和 x21 写入 [x0],此时 x0 指向的是大小为 extraBytes 的内存,打印 x20 和 x21 的值


        x20 = 0x00000001117caa10  (void *)0x00000001117caa38: KVObject(向上回溯这个值取自 _NSKVONotifyingCreateInfoWithOriginalClass 的入参 x0)


        x21 NSKVONotifying_KVObject


    根据这里可以看出 object_getIndexedIvars 返回的地址,依次存储了 KVObject(origin Class) 和 NSKVONotifying_KVObject(KVO Class)。


    查看 _NSKVONotifyingCreateInfoWithOriginalClass 的伪代码,对 [x0] 有 5 次写入的操作,并且最终这个方法返回的是 x0 的地址。

    function __NSKVONotifyingCreateInfoWithOriginalClass {
    r31 = r31 - 0x50;
    stack[32] = r22;
    stack[40] = r21;
    stack[48] = r20;
    stack[56] = r19;
    stack[64] = r29;
    stack[72] = r30;
    r20 = r0;
    if (*(int8_t *)0x993e78 != 0x0) {
    os_unfair_lock_assert_owner(0x993e7c);
    }
    r0 = class_getName(r20);
    r22 = strlen(r0) + 0x10;
    r0 = malloc(r22);
    r19 = r0;
    strlcpy(r0, "NSKVONotifying_", r22);
    strlcat(r19, r21, r22);
    r0 = objc_allocateClassPair(r20, r19, 0x30);
    if (r0 != 0x0) {
    objc_registerClassPair(r0);
    free(r19);
    r0 = object_getIndexedIvars(r21);
    r19 = r0;
    *(int128_t *)r0 = r20; // 第一次写入 Class
    *(int128_t *)(r0 + 0x8) = r21; // 第二次写入 Class
    *(r19 + 0x10) = CFSetCreateMutable(0x0, 0x0, *qword_9592d8); // 第三次写入 CFSet
    *(int128_t *)(r19 + 0x18) = CFDictionaryCreateMutable(0x0, 0x0, 0x0, *qword_959598); // 第四次写入 CFDictionary
    *(int128_t *)(r19 + 0x20) = 0x0; // 第五次写入空值
    if (*qword_9fc560 != -0x1) {
    dispatch_once(0x9fc560, 0x8eaf98);
    }
    if (class_getMethodImplementation(*r19, @selector(willChangeValueForKey:)) != *qword_9fc568) {
    r8 = 0x1;
    }
    else {
    r0 = *r19;
    r0 = class_getMethodImplementation(r0, @selector(didChangeValueForKey:));
    r8 = *qword_9fc570;
    if (r0 != r8) {
    r8 = *qword_9fc570;
    if (CPU_FLAGS & NE) {
    r8 = 0x1;
    }
    }
    }
    *(int8_t *)(r19 + 0x28) = r8;
    _NSKVONotifyingSetMethodImplementation(r19, @selector(_isKVOA), 0x44fab4, 0x0);
    _NSKVONotifyingSetMethodImplementation(r19, @selector(dealloc), 0x44fabc, 0x0);
    _NSKVONotifyingSetMethodImplementation(r19, @selector(class), 0x44fd2c, 0x0);
    }
    else {
    if (*qword_9fc558 != -0x1) {
    dispatch_once(0x9fc558, 0x8eaf78);
    }
    if (os_log_type_enabled(*0x9fc550, 0x10) != 0x0) {
    _os_log_error_impl(0x0, *0x9fc550, 0x10, "KVO failed to allocate class pair for name %s, automatic key-value observing will not work for this class", &stack[0], 0xc);
    }
    free(r19);
    r19 = 0x0;
    }
    if (**qword_9590e8 == **qword_9590e8) {
    r0 = r19;
    }
    else {
    r0 = __stack_chk_fail();
    }
    return r0;
    }

    _NSKVONotifyingCreateInfoWithOriginalClass 的上层调用,入参是 [x19, #0x8],返回的参数写入 [x19, #0x28]

        0x1825b8fc0 <+40>: ldr    x0, [x19, #0x28]
    0x1825b8fc4 <+44>: b 0x1825b8fd4 ; <+60>
    0x1825b8fc8 <+48>: ldr x0, [x19, #0x8]
    -> 0x1825b8fcc <+52>: bl 0x18259ccfc ; _NSKVONotifyingCreateInfoWithOriginalClass
    0x1825b8fd0 <+56>: str x0, [x19, #0x28]
    0x1825b8fd4 <+60>: ldp x29, x30, [sp, #0x10]
    0x1825b8fd8 <+64>: ldp x20, x19, [sp], #0x20

    打印 x19 是一个 NSKeyValueContainerClass 类型的实例对象,这个对象类的 ivars layout

    ivars 0x99f3c0 __OBJC_$_INSTANCE_VARIABLES_NSKeyValueContainerClass
    entsize 32
    count 5
    offset 0x9e6048 _OBJC_IVAR_$_NSKeyValueContainerClass._originalClass 8
    name 0x90bd27 _originalClass
    type 0x929ae6 #
    alignment 3
    size 8
    offset 0x9e6050 _OBJC_IVAR_$_NSKeyValueContainerClass._cachedObservationInfoImplementation 16
    name 0x90bd36 _cachedObservationInfoImplementation
    type 0x92bb88 ^?
    alignment 3
    size 8
    offset 0x9e6058 _OBJC_IVAR_$_NSKeyValueContainerClass._cachedSetObservationInfoImplementation 24
    name 0x90bd5b _cachedSetObservationInfoImplementation
    type 0x92bb88 ^?
    alignment 3
    size 8
    offset 0x9e6060 _OBJC_IVAR_$_NSKeyValueContainerClass._cachedSetObservationInfoTakesAnObject 32
    name 0x90bd83 _cachedSetObservationInfoTakesAnObject
    type 0x92a01a B
    alignment 0
    size 1
    offset 0x9e6068 _OBJC_IVAR_$_NSKeyValueContainerClass._notifyingInfo 40
    name 0x90bdaa _notifyingInfo
    type 0x92bdd7 ^{?=##^{__CFSet}^{__CFDictionary}{os_unfair_recursive_lock_s={os_unfair_lock_s=I}I}B}
    alignment 3
    size 8

    offset 0x8 name:_originalClass type:Class


    offset 0x28 name:_notifyingInfo type:struct


    _notifyingInfo 结构体

    {
    Class,
    Class,
    __CFSet,
    __CFDictionary,
    os_unfair_recursive_lock_s
    }

    type encoding:


    developer.apple.com/library/arc…


    从 context 可以看出_NSKVONotifyingCreateInfoWithOriginalClass 这个方法入参是 _OBJC_IVAR__NSKeyValueContainerClass._originalClass。返回值 x0 是 _OBJC_IVAR__NSKeyValueContainerClass._notifyingInfo。5 次对 [x0] 的写入是在初始化 _notifyingInfo。


    崩溃时的 context:

        0x1825231f0 <+56>:  bl     0x1880967c0 // object_getClass
    0x1825231f4 <+60>: bl 0x1880967e0 // object_getIndexedIvars
    0x1825231f8 <+64>: mov x23, x0 // x0 == _notifyingInfo
    0x1825231fc <+68>: add x24, x0, #0x20 // x24 == os_unfair_recursive_lock_s
    0x182523200 <+72>: mov x0, x24
    0x182523204 <+76>: mov w1, #0x0
    0x182523208 <+80>: bl 0x188096910 // os_unfair_recursive_lock_lock_with_options crash 调用栈

    调用 object_getClass 获取 Class,调用 object_getIndexedIvars 获取到 _notifyingInfo,_notifyingInfo + 偏移量 0x20 获取 os_unfair_recursive_lock_s,崩溃的原因是这把锁的 owner 损坏了,lock 也是一个结构体,ower 也是根据 offset 获取的。


    结论


    从崩溃的上下文来看,最可能出问题的是获取 _notifyingInfo,因为只有 KVO  Class 才能获取到 _notifyingInfo 这个结构体,如果在调用 __NSSetBoolValueAndNotify 的过程中,在其它线程监听被移除,此时 object_getClass 取到的不是 KVO Class 那后续再根据 offset 去取 lock,这个时候就有可能发生上述崩溃。


    线下暴力复现验证了上述猜测。

    - (void)start {
    __block KVObject *obj = [KVObject new];
    dispatch_async(dispatch_get_global_queue(0, 0x0), ^{
    for (int i = 0; i < 100000; i++) {
    [obj addObserver:self forKeyPath:@"value" options:0x7 context:nil];
    [obj removeObserver:self forKeyPath:@"value"];
    }
    });

    dispatch_async(dispatch_get_global_queue(0, 0x0), ^{
    for (int i = 0; i < 100000; i++) {
    obj.value = YES;
    obj.value = NO;
    }
    });
    }

    - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {}

    解决这个问题的思路就是保证线程安全,我们在线上断点找到了 removeObserver 的代码,将 removeObserver 和触发监听的代码放在了同一个串行队列。当然如果 removeObserver 在 dealloc 里面,理论上也不会出现这类问题。


    __NSSetxxxValueAndNotify 系列方法都有可能会触发这个崩溃,类似的问题可以按照相同的思路解决。

    00000000004e05cd t __NSSetBoolValueAndNotify
    00000000004e0707 t __NSSetCharValueAndNotify
    00000000004e097b t __NSSetDoubleValueAndNotify
    00000000004e0abc t __NSSetFloatValueAndNotify
    00000000004e0bfd t __NSSetIntValueAndNotify
    00000000004e10e7 t __NSSetLongLongValueAndNotify
    00000000004e0e6f t __NSSetLongValueAndNotify
    00000000004e0491 t __NSSetObjectValueAndNotify
    00000000004e15d5 t __NSSetPointValueAndNotify
    00000000004e1734 t __NSSetRangeValueAndNotify
    00000000004e188a t __NSSetRectValueAndNotify
    00000000004e135f t __NSSetShortValueAndNotify
    00000000004e19e8 t __NSSetSizeValueAndNotify
    00000000004e0841 t __NSSetUnsignedCharValueAndNotify
    00000000004e0d36 t __NSSetUnsignedIntValueAndNotify
    00000000004e1223 t __NSSetUnsignedLongLongValueAndNotify
    00000000004e0fab t __NSSetUnsignedLongValueAndNotify
    00000000004e149a t __NSSetUnsignedShortValueAndNotify
    00000000004de834 t __NSSetValueAndNotifyForKeyInIvar

    作者:yuec
    链接:https://juejin.cn/post/7193677784097488933
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    让 Xcode 15 拥有建置给 macOS 10.9 的能力

    iOS
    免责声明:理论上而言,用这招类推可以建置给早期版本的 iOS。但实际上管不管用我就没法保证了,因为我不是 iOS 程式师。 本文专门给那些需要在新版本系统当中用新版本 Xcode 将祖产专案建置给早期系统版本的资工业者们。 Xcode 15 需要打 liba...
    继续阅读 »

    免责声明:理论上而言,用这招类推可以建置给早期版本的 iOS。但实际上管不管用我就没法保证了,因为我不是 iOS 程式师。



    本文专门给那些需要在新版本系统当中用新版本 Xcode 将祖产专案建置给早期系统版本的资工业者们。


    Xcode 15 需要打 libarclite 才能给早于 macOS 10.13 的系统建置应用程式。


    通用做法就是从 Xcode 14.2 或 Xcode 13 当中提取出 libarclite 套装,然后植入到 Xcode 15 当中。先开启 toolchains 资料夹:




    再把 libarclite 的东西放进去(也就是 arc 这个资料夹):




    然而,如果是 macOS 10.9 的话,事情还要复杂一个层次:


    macOS 14 Sonoma 开始的 SDK 几乎把整个 Foundation 当中的很多基础类型都重写了。这就导致之前那些被 Swift 从 Objective-C 借走的基础类型全部都得重新打上「NS」开头的后缀才可以直接使用。但这还有一个问题:NSLocalizedString 的建构子不能使用了,因为这玩意在 macOS 14 当中也是被(用纯 Swift)彻底重构的基础类型之一。Apple 毫不留情地给这些基础类型都下了全局的「@available(macOS 10.10, *)」的宣告: 



    这样一来,除了 libarclite 以外,还需要旧版 macOS SDK 才可以。虽然 macOS 13 Ventura 的 SDK 也可以凑合用,但(保险起见)笔者推荐 macOS 12 Monterey 的 SDK:Release macOS 12.3 SDK · alexey-lysiuk/macos-sdk (github.com)。该 SDK 的安置位置:




    再修改一下 Xcode 专案当中对 macOS SDK 的指定(不用理会 not found):




    这样应该就可以正常组建了。如果有提示说 Date 不符合最新版本要求的话,把 Date 改成 NSDate 即可。


    $ EOF.


    作者:ShikiSuen
    链接:https://juejin.cn/post/7242927398294421541
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    浅谈多人游戏原理和简单实现

    一、我的游戏史 我最开始接触游戏要从一盘300游戏的光碟说起,那是家里买DVD送的,《魂斗罗》、《超级马里奥》天天玩。自从买回来后,我就经常和姐姐因为抢电视机使用权而大打出手。有次她把遥控器藏到了沙发的夹层里,被我妈一屁股做成了两半,我和我姐喜提一顿暴打。那顿...
    继续阅读 »



    一、我的游戏史


    我最开始接触游戏要从一盘300游戏的光碟说起,那是家里买DVD送的,《魂斗罗》、《超级马里奥》天天玩。自从买回来后,我就经常和姐姐因为抢电视机使用权而大打出手。有次她把遥控器藏到了沙发的夹层里,被我妈一屁股做成了两半,我和我姐喜提一顿暴打。那顿是我挨得最狠的,以至于现在回想起来,屁股还条件反射的隐隐作痛。


    后来我骗我妈说我要学习英语、练习打字以后成为祖国的栋梁之才!让她给我买台小霸王学习机(游戏机),在我一哭二闹三上吊胡搅蛮缠的攻势下,我妈妥协了。就此我接触到了FC游戏。现在还能记得我和朋友玩激龟快打,满屋子的小朋友在看的场景。经常有家长在我家门口喊他家小孩吃饭。那时候我们县城里面有商店卖游戏卡,小卡一张5块钱,一张传奇卡25-40块不等(所谓传奇卡就是角色扮演,带有存档的游戏),每天放学都要去商店去看看,有没有新的游戏卡,买不起,就看下封面过过瘾。我记得我省吃俭用一个多月,买了两张卡:《哪吒传奇》和《重装机兵》那是真的上瘾,没日没夜的玩。


    再然后我接触到了手机游戏,记得那时候有个软件叫做冒泡游戏(我心目的中的Stream),里面好多游戏,太吸引我了。一个游戏一般都是几百KB,最大也就是几MB,不过那时候流量很贵,1块钱1MB,并且!一个月只有30Mb。我姑父是收手机的,我在他那里搞到了一部半智能手机,牌子我现在还记得:诺基亚N70,那时候我打开游戏就会显示一杯冒着热气的咖啡,我很喜欢这个图标,因为看见它意味着我的游戏快加载完成了,没想到,十几年后我们会再次相遇,哈哈哈哈。我当时玩了一款网游叫做:《幻想三国》,第一回接触网游简直惊呆了,里面好多人都是其他玩的家,这太有趣了。并且我能在我的手机上看到其他玩家,能够看到他们的行为动作,这太神奇了!!!我也一直思考这到底是怎么实现的!


    最后是电脑游戏,单机:《侠盗飞车》、《植物大战僵尸》、《虐杀原型》;网游:《DNF》、《CF》、《LOL》、《梦幻西游》我都玩过。


    不过那个疑问一直没有解决,也一值留在我心中 —— 在网络游戏中,是如何实时更新其他玩家行为的呢?


    二、解惑


    在我进入大学后,我选择了软件开发专业,真巧!再次遇到了那个冒着热气的咖啡图标,这时我才知道它叫做——Java。我很认真的去学,希望有一天能够做一款游戏!


    参加工作后,我并没有如愿以偿,我成为了一名Java开发程序员,但是我在日常的开发的都是web应用,接触到大多是HTTP请求,它是种请求-响应协议模式。这个问题也还是想不明白,难道每当其他玩家做一个动作都需要发送一次HTTP请求?然后响应给其他玩家。这样未免效率也太低了吧,如果一个服务器中有几千几万人,那么服务器得承受多大压力呀!一定不是这样的!!!


    直到我遇到了Websocket,这是一种长连接,而HTTP是一种短连接,顿时这个问题我就想明白了。在此二者的区别我就不过多赘述了。详细请看我的另一篇文章


    知道了这个知识后,我终于能够大致明白了网络游戏的基本原理。原来网络游戏是由客户端服务器端组成的,客户端就是我们下载到电脑或者手机上的应用,而服务器端就是把其他玩家连接起来的中转站,还有一点需要说明的是,网络游戏是分房间的,这个房间就相当于一台服务器。首先,在玩家登陆客户端并选择房间建立长连接后,A玩家做出移动的动作,随即会把这个动作指令上传给服务器,然后服务器再将指令广播到房间中的其他玩家的客户端来操作A的角色,这样就可以实现实时更新其他玩家行为。




    三、简单实现


    客户端服务端在处理指令时,方法必须是配套的。比如说,有新的玩家连接到服务器,那么服务器就应当向其它客户端广播创建一个新角色的指令,客户端在接收到该指令后,执行客户端创建角色的方法。
    为了方便演示,这里需要定义两个HTML来表示两个不同的客户端不同的玩家,这两套客户端代码除了玩家的信息不一样,其它完全一致!!!


    3.1 客户端实现步骤


    我在这里客户端使用HTML+JQ实现


    客户端——1代码:


    (1)创建画布

    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Canvas Game</title>
    <style>
    canvas {
    border: 1px solid black;
    }
    </style>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    </head>
    <body>
    <canvas id="gameCanvas" width="800" height="800"></canvas>
    </body>
    </html>

    (2)设置1s60帧更新页面

    const canvas = document.getElementById('gameCanvas');
    const ctx = canvas.getContext('2d');
    function clearCanvas() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    }
    function gameLoop() {
    clearCanvas();
    players.forEach(player => {
    player.draw();
    });
    }
    setInterval(gameLoop, 1000 / 60);
    //清除画布方法
    function clearCanvas() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    }

    (3)连接游戏服务器并处理指令


    这里使用websocket链接游戏服务器

     //连接服务器
    const websocket = new WebSocket("ws://192.168.31.136:7070/websocket?userId=" + userId + "&userName=" + userName);
    //向服务器发送消息
    function sendMessage(userId,keyCode){
    const messageData = {
    playerId: userId,
    keyCode: keyCode
    };
    websocket.send(JSON.stringify(messageData));
    }
    //接收服务器消息,并根据不同的指令,做出不同的动作
    websocket.onmessage = event => {
    const data = JSON.parse(event.data);
    // 处理服务器发送过来的消息
    console.log('Received message:', data);
    //创建游戏对象
    if(data.type == 1){
    console.log("玩家信息:" + data.players.length)
    for (let i = 0; i < data.players.length; i++) {
    console.log("玩家id:"+playerOfIds);
    createPlayer(data.players[i].playerId,data.players[i].pointX, data.players[i].pointY, data.players[i].color);
    }
    }
    //销毁游戏对象
    if(data.type == 2){
    console.log("玩家信息:" + data.players.length)
    for (let i = 0; i < data.players.length; i++) {
    destroyPlayer(data.players[i].playerId)
    }
    }
    //移动游戏对象
    if(data.type == 3){
    console.log("移动;玩家信息:" + data.players.length)
    for (let i = 0; i < data.players.length; i++) {
    players.filter(player => player.id === data.players[i].playerId)[0].move(data.players[i].keyCode)
    }
    }
    };

    (4)创建玩家对象

    //存放游戏对象
    let players = [];
    //playerId在此写死,正常情况下应该是用户登录获取的
    const userId = "1"; // 用户的 id
    const userName = "逆风笑"; // 用户的名称
    //玩家对象
    class Player {
    constructor(id,x, y, color) {
    this.id = id;
    this.x = x;
    this.y = y;
    this.size = 30;
    this.color = color;
    }
    //绘制游戏角色方法
    draw() {
    ctx.fillStyle = this.color;
    ctx.fillRect(this.x, this.y, this.size, this.size);
    }
    //游戏角色移动方法
    move(keyCode) {
    switch (keyCode) {
    case 37: // Left
    this.x = Math.max(0, this.x - 10);
    break;
    case 38: // Up
    this.y = Math.max(0, this.y - 10);
    break;
    case 39: // Right
    this.x = Math.min(canvas.width - this.size, this.x + 10);
    break;
    case 40: // Down
    this.y = Math.min(canvas.height - this.size, this.y + 10);
    break;
    }
    this.draw();
    }
    }

    (5)客户端创建角色方法

    //创建游戏对象方法
    function createPlayer(id,x, y, color) {
    const player = new Player(id,x, y, color);
    players.push(player);
    playerOfIds.push(id);
    return player;
    }

    (6)客户端销毁角色方法


    在玩家推出客户端后,其它玩家的客户端应当销毁对应的角色。

    //角色销毁
    function destroyPlayer(playId){
    players = players.filter(player => player.id !== playId);
    }

    客户端——2代码:


    客户端2的代码只有玩家信息不一致:

      const userId = "2"; // 用户的 id
    const userName = "逆风哭"; // 用户的名称

    3.2 服务器端


    服务器端使用Java+websocket来实现!


    (1)引入依赖:

     <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <version>2.1.2.RELEASE</version>
    </dependency>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
    <version>2.3.7.RELEASE</version>
    </dependency>
    <dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.11</version>
    </dependency>
    <dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.75</version>
    </dependency>
    <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.16.16</version>
    </dependency>
    <dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.6.3</version>
    </dependency>

    (2)创建服务器

    @Component
    @ServerEndpoint("/websocket")
    @Slf4j
    public class Server {
    /**
    * 服务器玩家池
    * 解释:这里使用 ConcurrentHashMap为了保证线程安全,不会出现同一个玩家存在多条记录问题
    * 使用 static fina修饰 是为了保证 playerPool 全局唯一
    */
    private static final ConcurrentHashMap<String, Server> playerPool = new ConcurrentHashMap<>();
    /**
    * 存储玩家信息
    */
    private static final ConcurrentHashMap<String, Player> playerInfo = new ConcurrentHashMap<>();
    /**
    * 已经被创建了的玩家id
    */
    private static ConcurrentHashMap<String, Server> createdPlayer = new ConcurrentHashMap<>();

    private Session session;

    private Player player;

    /**
    * 连接成功后调用的方法
    */
    @OnOpen
    public void webSocketOpen(Session session) throws IOException {
    Map<String, List<String>> requestParameterMap = session.getRequestParameterMap();
    String userId = requestParameterMap.get("userId").get(0);
    String userName = requestParameterMap.get("userName").get(0);
    this.session = session;
    if (!playerPool.containsKey(userId)) {
    int locationX = getLocation(151);
    int locationY = getLocation(151);
    String color = PlayerColorEnum.getValueByCode(getLocation(1) + 1);
    Player newPlayer = new Player(userId, userName, locationX, locationY,color,null);
    playerPool.put(userId, this);
    this.player = newPlayer;
    //存放玩家信息
    playerInfo.put(userId,newPlayer);
    }
    log.info("玩家:{}|{}连接了服务器", userId, userName);
    // 创建游戏对象
    this.createPlayer(userId);
    }

    /**
    * 接收到消息调用的方法
    */
    @OnMessage
    public void onMessage(String message, Session session) throws IOException, InterruptedException {
    log.info("用户:{},消息{}:",this.player.getPlayerId(),message);
    PlayerDTO playerDTO = new PlayerDTO();
    Player player = JSONObject.parseObject(message, Player.class);
    List<Player> players = new ArrayList<>();
    players.add(player);
    playerDTO.setPlayers(players);
    playerDTO.setType(OperationType.MOVE_OBJECT.getCode());
    String returnMessage = JSONObject.toJSONString(playerDTO);
    //广播所有玩家
    for (String key : playerPool.keySet()) {
    synchronized (session){
    String playerId = playerPool.get(key).player.getPlayerId();
    if(!playerId.equals(this.player.getPlayerId())){
    playerPool.get(key).session.getBasicRemote().sendText(returnMessage);
    }
    }
    }
    }

    /**
    * 关闭连接调用方法
    */
    @OnClose
    public void onClose() throws IOException {
    String playerId = this.player.getPlayerId();
    log.info("玩家{}退出!", playerId);
    Player playerBaseInfo = playerInfo.get(playerId);
    //移除玩家
    for (String key : playerPool.keySet()) {
    playerPool.remove(playerId);
    playerInfo.remove(playerId);
    createdPlayer.remove(playerId);
    }
    //通知客户端销毁对象
    destroyPlayer(playerBaseInfo);
    }

    /**
    * 出现错误时调用的方法
    */
    @OnError
    public void onError(Throwable error) {
    log.info("服务器错误,玩家id:{},原因:{}",this.player.getPlayerId(),error.getMessage());
    }
    /**
    * 获取随即位置
    * @param seed
    * @return
    */
    private int getLocation(Integer seed){
    Random random = new Random();
    return random.nextInt(seed);
    }
    }

    websocket配置:

    @Configuration
    public class ServerConfig {
    @Bean
    public ServerEndpointExporter serverEndpointExporter(){
    return new ServerEndpointExporter();
    }
    }


    (3)创建玩家对象


    玩家对象:

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class Player {
    /**
    * 玩家id
    */
    private String playerId;
    /**
    * 玩家名称
    */
    private String playerName;
    /**
    * 玩家生成的x坐标
    */
    private Integer pointX;
    /**
    * 玩家生成的y坐标
    */
    private Integer pointY;
    /**
    * 玩家生成颜色
    */
    private String color;
    /**
    * 玩家动作指令
    */
    private Integer keyCode;
    }

    创建玩家对象返回给客户端DTO:

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class PlayerDTO {
    private Integer type;
    private List<Player> players;
    }

    玩家移动指令返回给客户端DTO:

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class PlayerMoveDTO {
    private Integer type;
    private List<Player> players;
    }


    (4)动作指令

    public enum OperationType {
    CREATE_OBJECT(1,"创建游戏对象"),
    DESTROY_OBJECT(2,"销毁游戏对象"),
    MOVE_OBJECT(3,"移动游戏对象"),
    ;
    private Integer code;
    private String value;

    OperationType(Integer code, String value) {
    this.code = code;
    this.value = value;
    }

    public Integer getCode() {
    return code;
    }

    public String getValue() {
    return value;
    }
    }

    (5)创建对象方法

      /**
    * 创建对象方法
    * @param playerId
    * @throws IOException
    */
    private void createPlayer(String playerId) throws IOException {
    if (!createdPlayer.containsKey(playerId)) {
    List<Player> players = new ArrayList<>();
    for (String key : playerInfo.keySet()) {
    Player playerBaseInfo = playerInfo.get(key);
    players.add(playerBaseInfo);
    }
    PlayerDTO playerDTO = new PlayerDTO();
    playerDTO.setType(OperationType.CREATE_OBJECT.getCode());
    playerDTO.setPlayers(players);
    String syncInfo = JSONObject.toJSONString(playerDTO);
    for (String key :
    playerPool.keySet()) {
    playerPool.get(key).session.getBasicRemote().sendText(syncInfo);
    }
    // 存放
    createdPlayer.put(playerId, this);
    }
    }

    (6)销毁对象方法

       /**
    * 销毁对象方法
    * @param playerBaseInfo
    * @throws IOException
    */
    private void destroyPlayer(Player playerBaseInfo) throws IOException {
    PlayerDTO playerDTO = new PlayerDTO();
    playerDTO.setType(OperationType.DESTROY_OBJECT.getCode());
    List<Player> players = new ArrayList<>();
    players.add(playerBaseInfo);
    playerDTO.setPlayers(players);
    String syncInfo = JSONObject.toJSONString(playerDTO);
    for (String key :
    playerPool.keySet()) {
    playerPool.get(key).session.getBasicRemote().sendText(syncInfo);
    }
    }

    四、演示


    4.1 客户端1登陆服务器




    4.2 客户端2登陆服务器




    4.3 客户端2移动




    4.4 客户端1移动




    4.5 客户端1退出



     完结撒花


    完整代码传送门


    五、总结


    以上就是我对网络游戏如何实现玩家实时同步的理解与实现,我实现后心里也释然了,哈哈哈,真的好有趣!!!
    我希望大家也是,不要失去好奇心,遇到自己感兴趣的事情,一定要多思考呀~


    后来随着我经验的不断积累,我又去了解了一下Java作为游戏服务器的相关内容,发现Netty更适合做这个并且更容易入门,比如《我的世界》一些现有的服务器就是使用Netty实现的。有空也实现下,玩玩~


    作者:是江迪呀
    链接:https://juejin.cn/post/7273429629398581282
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    弃用qiankun!看古茗中后台架构如何破局

    引言 我们团队之前发布过一篇文章,介绍了古茗前端到底在做什么,当然这只包含了我们团队内做的一部分事情。以端侧来划分,主要包括:中后台 web 端、H5 端、4 端小程序、Electron PC 端、Android/Flutter 客户端,我们需要为这些端侧方向...
    继续阅读 »

    引言


    我们团队之前发布过一篇文章,介绍了古茗前端到底在做什么,当然这只包含了我们团队内做的一部分事情。以端侧来划分,主要包括:中后台 web 端、H5 端、4 端小程序、Electron PC 端、Android/Flutter 客户端,我们需要为这些端侧方向做一些通用能力的解决方案,同时也需要深入每个端侧细分领域做它们特有的技术沉淀。本文主要介绍古茗在中后台技术方向的一些思考和技术沉淀。


    业务现状


    古茗目前有大量的中后台业务诉求,包括:权限系统、会员系统、商品中心、拓展系统、营运系统、财务系统、门店系统、供应链系统等多个子系统,服务于内部产品、技术、运营,及外部加盟商等用户群体,这些子系统分别由不同业务线的同学负责开发和维护,而这些子系统还没有系统性的技术沉淀。随着业务体量和业务复杂度的不断增大,在“降本增效”的大背景下,如何保证业务快速且高质量交付是我们面临的技术挑战。


    技术演进




    如上述技术演进路线图所示,我们的中后台技术架构大致经历了 4 个阶段:当业务起步时,我们很自然的使用了单应用开发模式;当业务发展到一定的体量,多人协同开发变得困难时,我们拆分了多个子系统维护,并使用了 systemjs 来加载子系统资源(微前端模式的雏形);当遇到有三方库的多版本隔离诉求时,我们又引入了 qiankun 微前端框架来隔离多个子系统,保证各子系统间互不影响;那是什么原因让我们放弃 qiankun,转向完全自研的一套中后台解决方案?


    弃用 qiankun?


    其实准确来说,qiankun 并不能算是一个完整的中后台解决方案,而是一个微前端框架,它在整个技术体系里面只充当子应用管理的角色。我们在技术演进过程中使用了 qiankun 尝试解决子应用的隔离问题,但同时也带来了一些新的问题:某些场景跳转路由后视图无反应;某些三方库集成进来后导致奇怪的 bug...。同时,还存在一些问题无法使用 qiankun 解决,如:子应用入口配置、样式隔离、运维部署、路由冲突、规范混乱、要求资源必须跨域等。


    探索方向


    我们重新思考了古茗中后台技术探索方向是什么,也就是中后台技术架构到底要解决什么问题,并确定了当下的 2 大方向:研发效率用户体验。由此我们推导出了中后台技术探索要做的第一件事情是“统一”,这也为我们整个架构的基础设计确立了方向。




    架构设计


    我们的整体架构是围绕着“统一”和“规范” 2 大原则来设计,目标是提升整个团队的研发效能。我们认为好的架构应该是边界清晰的,而不是一味的往上面堆功能,所以我们思考更多的是,如果没有这个功能,能否把这件事情做好。


    取个“好”名字


    我老板曾说过一个好的(技术)产品要做的第一件事就是取个“好”名字。我们给这套中后台架构方案取名叫「Mars」,将相关的 NPM 包、组件库、SDK、甚至部署路径都使用 mars 关键字来命名,将这个产品名字深入人心,形成团队内共同的产品认知。这样的好处是可以加强团队对这套技术方案的认同感,以及减少沟通负担,大家一提到 mars,就都知道在说哪一件事。


    框架设计




    正如上述大图所示,我们是基于微前端的思路做的应用框架设计,所以与市面上大多数的微前端框架的设计思路、分层结构大同小异。这里还是稍微介绍一下整个流程:当用户访问网站时,首先经过网关层,请求到基座应用的资源并渲染基础布局和菜单,当监听路由变化时,加载并渲染路由关联的子应用及页面。


    但是,市面上大部分的微前端框架往往需要在基座应用上配置子应用的 nameentryavtiveRule 等信息,因为框架需要根据这些信息来决定什么时机去加载哪一个子应用,以及如何加载子应用的资源。这就意味着每新增一个子应用,都需要去基座维护这份配置。更要命的是,不同环境的 entry 可能还要根据不同的环境做区别判断。如果遇到本地开发时需要跳转至其他子应用的场景,那也是非常不好的开发体验。所以,这是我们万万不能接受的。


    针对这一痛点,我们想到了 2 种解决思路:

    1. 把基座应用的配置放到云端,用 node 作为中间层来维护各子应用的信息,每次新增应用、发布资源后同步更新,问题就是这套方案实现成本比较高,且新增子应用还是需要维护子应用的信息,只是转移到在云端维护了。
    2. 使用约定式路由及部署路径,当我们识别到一个约定的路由路径时,可以反推它的应用 ID 及部署资源路径,完全 0 配置。很明显,我们选择了这种方案。

    约定式路由及部署路径


    路由约定


    我们制定了如下的标准 Mars 路由规范

      /mars/appId/path/some?name=ferret
    \_/ \_/ \_____/ \_______/
    | | | |
    标识 appId path query


    1. 路由必须以 /mars 开头(为了兼容历史路由包袱)

    2. 其后就是 appId ,这是子应用的唯一标识

    3. 最后的 pathquery 部分就是业务自身的路由和参数


    部署路径约定


    我们制定了如下的标准 Mars 子应用部署路径规范

      https://cdn.example.com/mars/[appId]/[env]/manifest.json
    \__________________/ \_/ \___/ \_/ \________/
    | | | | |
    cdn 域名 标识 appId 环境 入口资源清单

    从上述部署路径规范可以看出,整个路径就 appIdenv 2 个变量是不确定的,而 env 可以在发布时确定,因此可由 appId 推导出完整的部署路径。而根据路由约定,我们可以很容易的从路由中解析出 appId,由此就可以拿到完整的 manifest.json 部署路径 ,并以此获取到整个子应用的入口资源信息。


    编译应用


    虽然制定了上述 2 大规范,但是如何保障规范落地,防止规范腐化也是非常重要的一个问题。我们是通过编译手段来强制约束执行的(毕竟“人”往往是靠不住的😄)。


    依赖工程化体系



    提示:Kone 是古茗内部前端工程化的工具产品。



    首先,子应用需要配置一个工程配置文件,并注册 @guming/kone-plugin-mars 插件来完成子应用的本地开发、构建、发布等工程化相关的任务。其中:配置项 appId 就代表约定路由中的 appId 和 部署路径中的 appId,也是子应用的唯一标识。


    工程配置文件:kone.config.json

    {
    "plugins": ["@guming/kone-plugin-mars"],
    "mars": {
    "appId": "demo"
    }
    }

    编译流程


    然后,子应用通过静态化配置式(json 配置)注册路由,由编译器去解析配置文件,注册路由,以及生成子应用 mountunmount 生命周期方法。这样实现有以下 3 个好处:

    • 完整的路由 path 由编译生成,可以非常好的保障约定式路由落地
    • 生命周期方法由编译生成,减少项目中的模板代码,同样可以约束子应用的渲染和卸载按照预定的方式执行
    • 可以约束不规范的路由 path 定义,例如我们会禁用掉 :param 形式的动态路由

    应用配置文件:src/app.json

    {
    "routes": [
    {
    "path": "/some/list",
    "component": "./pages/list",
    "description": "列表页"
    },
    {
    "path": "/some/detail",
    "component": "./pages/detail",
    "description": "详情页"
    }
    ]
    }


    上述示例最终会生成路由:/mars/demo/some/list/mars/demo/some/detail



    webpack-loader 实现


    解析 src/app.json 需要通过一个自定义的 webpack-loader 来实现,部分示例代码如下:

    import path from 'path';
    import qs from 'qs';

    export default function marsAppLoader(source) {
    const { appId } = qs.parse(this.resourceQuery.slice(1));
    let config;
    try {
    config = JSON.parse(source);
    } catch (err) {
    this.emitError(err);
    return;
    }

    const { routes = [] } = config;

    const routePathSet = new Set();
    const routeRuntimes = [];
    const basename = `/mars/${appId}`;

    for (let i = 0; i < routes.length; i++) {
    const item = routes[i];
    if (routePathSet.has(item.path.toLowerCase())) {
    this.emitError(new Error(`重复定义的路由 path: ${item.path}`));
    return;
    }

    routeRuntimes.push(
    `routes[${i}] = { ` +
    `path: ${JSON.stringify(basename + item.path)}, ` +
    `component: _default(require(${JSON.stringify(item.component)})) ` +
    `}`
    );
    routePathSet.add(item.path.toLowerCase());
    }

    return `
    const React = require('react');
    const ReactDOM = require('react-dom');

    // 从 mars sdk 中引入 runtime 代码
    const { __internals__ } = require('@guming/mars');
    const { defineApp, _default } = __internals__;

    const routes = new Array(${routeRuntimes.length});
    ${routeRuntimes.join('\n')}

    // define mars app: ${appId}
    defineApp({
    appId: '${appId}',
    routes,
    });

    `.trim();
    }

    src/app.json 作为编译入口并经过此 webpack-loader 编译之后,将自动编译关联的路由组件,创建子应用路由渲染模板,注册生命周期方法等,并最终输出 manifest.json 文件作为子应用的入口(类似 index.html),根据入口文件的内容就可以去加载入口的 js、css 资源并触发 mount 生命周期方法执行渲染逻辑。生成的 manifest.json 内容格式如下:

    {
    "js": [
    "https://cdn.example.com/mars/demo/prod/app.a0dd6a27.js"
    ],
    "css": [
    "https://cdn.example.com/mars/demo/prod/app.230ff1ef.css"
    ]
    }

    聊聊沙箱隔离


    一个好的沙箱隔离方案往往是市面上微前端框架最大的卖点,我们团队内也曾引入 qiankun 来解决子应用间隔离的痛点问题。而我想让大家回归到自己团队和业务里思考一下:“我们团队需要隔离?不做隔离有什么问题”。而我们团队给出的答案是:不隔离 JS,要隔离 CSS,理由如下:

    1. 不隔离 JS 可能会有什么问题:window 全局变量污染?能污染到哪儿去,最多也就内存泄露,对于现代 B 端应用来说,个别内容泄露几乎可以忽略不计;三方库不能混用版本?如文章开头所提及的,我们要做的第一件事就是统一,其中就包括统一常用三方库版本,在统一的前提下这种问题也就不存在了。当然也有例外情况,比如高德地图 sdk 在不同子系统需要隔离(使用了不同的 key),针对这种问题我们的策略就是专项解决;当然,最后的理由是一套非常好的 JS 隔离方案实现成本太高了,需要考虑太多的问题和场景,这些问题让我们意识到隔离 JS 带来的实际价值可能不太高。
    2. 由于 CSS 的作用域是全局的,所以非常容易造成子应用间的样式污染,其次,CSS 隔离是容易实现的,我们本身就基于编译做了很多约束的事情,同样也可以用于 CSS 隔离方案中。实现方案也非常简单,就是通过实现一个 postcss 插件,将子应用中引入的所有 css 样式都加上特有的作用域前缀,例如:
    .red {
    color: red;
    }

    将会编译成:

    .mars__demo .red {
    color: red;
    }

    当然,某些场景可能就是需要全局样式,如 antd 弹层内容默认就会在子应用内容区外,造成隔离后的样式失效。针对这种场景,我们的解法是用隔离白名单机制,使用也非常简单,在最前面加上 :global 选择器,编译就会直接跳过,示例:

    :global {
    .some-modal-cls {
    font-size: 14px;
    }
    }

    将会编译成:

    .some-modal-cls {
    font-size: 14px;
    }

    除此之外,在子应用卸载的时候,还会禁用掉子应用的 CSS 样式,这是如何做到的?首先,当加载资源的时候,会找到该资源的 CSSStyleSheet 对象:

    const link = document.createElement('link');
    link.setAttribute('href', this.url);
    link.setAttribute('rel', 'stylesheet');
    link.addEventListener('load', () => {
    // 找到当前资源对应的 CSSStyleSheet 对象
    const styleSheets = document.styleSheets;
    for (let i = styleSheets.length - 1; i >= 0; i--) {
    const sheet = styleSheets[i];
    if (sheet.ownerNode === this.node) {
    this.sheet = sheet;
    break;
    }
    }
    });

    当卸载资源的时候,将该资源关联的 CSSStyleSheet 对象的 disabled 属性设置为 true 即可禁用样式:

    if (this.sheet) {
    this.sheet.disabled = true;
    }

    框架 SDK 设计




    框架 SDK 按照使用场景可以归为 3 类,分别是:子应用、基座应用、编译器。同样的遵循我们的一贯原则,如果一个 API 可以满足诉求,就不会提供 2 个 API,尽可能保证团队内的代码风格都是统一的。例如:路由跳转 SDK 只提供唯一 API,并通过编译手段禁用掉其他路由跳转方式(如引入 react-router-dom 的 API 会报错):

    import { mars } from '@guming/mars';

    // 跳转路由:/mars/demo/some/detail?a=123
    mars.navigate('/mars/demo/some/detail', {
    params: { a: '123' }
    });

    // 获取路由参数
    const { pathname, params } = mars.getLocation();
    // pathname: /mars/demo/some/detail
    // params: { a: '123' }

    当然,我们也会根据实际情况提供一些便利的 API,例如:跳转路由要写完整的 /mars/[appId] 路由前缀太繁琐,所以我们提供了一个语法糖来减少样板代码,在路由最前面使用 : 来代替 /mars/[appId] 前缀(仅在当前子应用内跳转有效):

    import { mars } from '@guming/mars';

    // 跳转路由:/mars/demo/some/detail?a=123
    mars.navigate(':/some/detail', {
    params: { a: '123' }
    });

    另外,值得一提的是在基座应用上使用的一个 API bootstrap(),得益于这个 API 的设计,我们可以快速创建多个基座应用(不同域名),还能使用这个 API 在本地开发的时候启动一个基座来模拟本地开发环境,提升开发体验。


    本地开发体验


    开发模拟器


    为更好的支持本地开发环境,我们提供了一套本地开发模拟器,在子应用启动本地服务的时候,会自动启动一个模拟的基座应用,拥有和真实基座应用几乎一样的布局,运行环境,集成的登录逻辑等。除此之外,开发模拟器还提供了辅助开发的「debug 小组件」,比如通过 debug 工具可以动态修改本地开发代理规则,保存之后立即生效。




    IDE 支持


    为了提升开发体验,我们分别开发了 Webstorm 插件 和 VSCode 插件服务于 Mars 应用,目前支持路由组件配置的路径补全、点击跳转、配置校验等功能。此外,我们会为配置文件提供 json schema,配置后将会获得 IDE 的自动补全能力和配置校验能力。




    历史项目迁移


    技术架构演进对于业务项目来说最大的问题在于,如何完成历史项目向新架构的迁移改造?我们团队投入 2 个人花了 2 个月时间将 12 个历史项目全部迁移至最新的架构上,这里分享一些我们在迁移过程中的经验。


    定目标


    首先要确定历史项目迁移这件事情一定是要做的,这是毋庸置疑的,而且要越快越好。所以我们要做的第一件事就是制定迁移计划,最后确定投入 2 个人力,大约花 2 个月时间将历史项目全部迁移至新的架构。第二件事情就是确定改造的范围(确定边界,不做非目标范围内的改造),对于我们的业务现状来说,主要包括:

    • 统一 reactreact-dom 版本为 17.0.2
    • 统一 antd 版本为 4.24.8
    • 统一路由
    • 统一接入 request 请求库
    • 统一接入工程化体系
    • 统一环境变量

    梳理 SOP


    因为迁移的流程不算简单,迁移要做的事情还挺多的,所以接下来要做的一件事就是梳理迁移流程 SOP,SOP 文档要细化到每种可能的场景,以及遇到问题对应的解法,让后续项目的迁移可以傻瓜式的按照标准流程去操作即可。我们的做法是,先以一个项目作为试点,一边迁移一边梳理 SOP,如果在迁移其他项目中发现有遗漏的场景,再持续补充这份 SOP 文档。


    例如:之前项目中使用了 dva 框架,但是它的 routermodel 是耦合的,这样就无法使用我们制定的统一路由方案,对此我们的解法是,通过 hack dva 的源代码,将 model 前置注入到应用中,完成与路由的解耦。


    上线方案


    由于业务迭代频繁,所以我们代码改造持续的时间不能太长,否则要多次合并代码冲突,而我们的经验就是,项目改造从拉分支到发布上线,要在 1 周内完成。当然,整个上线过程还遇到许多需要解决的问题,比如在测试参与较少的情况下如何保障代码质量,包括:业务回归的策略,回滚策略,信息同步等等。


    总结


    之前看到 Umi 4 设计思路文字稿 里面有句话我觉得特别有道理:“社区要开放,团队要约束”,我们团队也在努力践行“团队约束”这一原则,因为它为团队带来的收益是非常高的。


    没有最完美的方案,只有最适合自己的方案,以上这套架构方案只是基于当下古茗前端团队现状做的选择后的结果,可能并不适合每个团队,希望本文的这些思考和技术沉淀能对您有所帮助和启发。


    最后


    关注公众号「Goodme前端团队」,获取更多干货实践,欢迎交流分享~


    作者:古茗前端团队
    链接:https://juejin.cn/post/7269352663258284069
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    往往排查很久的问题,最后发现都非常简单。。。

    之前线上发生了一个很诡异的异常,网上各种搜索、排查,都没有找到问题,给大家分享一下。 大概在 2 月份的时候,我们的某个应用整合了中间件的 kafka 客户端,发布到灰度和蓝节点进行观察,然后就发现线上某个 Topic 发生了大量的RetriableCommi...
    继续阅读 »

    之前线上发生了一个很诡异的异常,网上各种搜索、排查,都没有找到问题,给大家分享一下。


    大概在 2 月份的时候,我们的某个应用整合了中间件的 kafka 客户端,发布到灰度和蓝节点进行观察,然后就发现线上某个 Topic 发生了大量的RetriableCommitException,并且集中在灰度机器上。

    E20:21:59.770 RuntimeException  org.apache.kafka.clients.consumer.RetriableCommitFailedException  ERROR [Consumer clientId=xx-xx.4-0, groupId=xx-xx-consumer_[gray]] Offset commit with offsets {xx-xx-xx-callback-1=OffsetAndMetadata{offset=181894918, leaderEpoch=4, metadata=''}, xx-xx-xx-callback-0=OffsetAndMetadata{offset=181909228, leaderEpoch=5, metadata=''}} failed org.apache.kafka.clients.consumer.RetriableCommitFailedException: Offset commit failed with a retriable exception. You should retry committing the latest consumed offsets.
    Caused by: org.apache.kafka.common.errors.TimeoutException: Failed to send request after 30000 ms.


    排查


    检查了这个 Topic 的流量流入、流出情况,发现并不是很高,至少和 QA 环境的压测流量对比,连零头都没有达到。


    但是从发生异常的这个 Topic 的历史流量来看的话,发生问题的那几个时间点的流量又确实比平时高出了很多。



    同时我们检查 Broker 集群的负载情况,发现那几个时间点的 CPU 负载也比平时也高出很多(也只是比平时高,整体并不算高)。



    对Broker集群的日志排查,也没发现什么特殊的地方。


    然后我们对这个应用在QA上进行了模拟,尝试复现,遗憾的是,尽管我们在QA上把生产流量放大到很多倍并尝试了多次,问题还是没能出现。


    此时,我们把问题归于当时的网络环境,这个结论在当时其实是站不住脚的,如果那个时刻网络环境发生了抖动的话,其它应用为什么没有这类异常?


    可能其它的服务实例网络情况是好的,只是发生问题的这个灰实例网络发生了问题。


    那问题又来了,为什么这个实例的其它 Topic 没有报出异常,偏偏问题只出现在这个 Topic 呢?。。。。。。。。。


    至此,陷入了僵局,无从下手的感觉。


    从这个客户端的开发、测试到压测,如果有 bug 的话,不可能躲过前面那么多环节,偏偏爆发在了生产环境。


    没办法了,我们再次进行了一次灰度发布,如果过了一夜没有事情发生,我们就把问题划分到环境问题,如果再次出现问题的话,那就只能把问题划分到我们实现的 Kafka 客户端的问题了。


    果不其然,发布后的第二天凌晨1点多,又出现了大量的 RetriableCommitFailedException,只是这次换了个 Topic,并且异常的原因又多出了其它Caused by 。

    org.apache.kafka.clients.consumer.RetriableCommitFailedException: Offset commit failed with a retriable exception. You should retry committing the latest consumed offsets.
    Caused by: org.apache.kafka.common.errors.DisconnectException
    ...
    ...
    E16:23:31.640 RuntimeException  org.apache.kafka.clients.consumer.RetriableCommitFailedException  ERROR 
    ...
    ...
    org.apache.kafka.clients.consumer.RetriableCommitFailedException: Offset commit failed with a retriable exception. You should retry committing the latest consumed offsets.
    Caused by: org.apache.kafka.common.errors.TimeoutException: The request timed out.

    分析


    这次出现的异常与之前异常的不同之处在于:

    1. 1. Topic 变了
    2. 2. 异常Cause变了

    而与之前异常又有相同之处:

    1. 1. 只发生在灰度消费者组
    2. 2. 都是RetriableCommitFailedException

    RetriableCommitFailedException 意思很明确了,可以重试提交的异常,网上搜了一圈后仅发现StackOverFlow上有一问题描述和我们的现象相似度很高,遗憾的是没人回复这个问题:StackOverFlow。


    我们看下 RetriableCommitFailedException 这个异常和产生这个异常的调用层级关系。



    除了产生异常的具体 Cause 不同,剩下的都是让我们再 retry,You should retry Commiting the lastest consumed offsets。



    从调用层级上来看,我们可以得到几个关键的信息,commit 、 async。


    再结合异常发生的实例,我们可以得到有用关键信息: 灰度、commit 、async。


    在灰度消息的实现上,我们确实存在着管理位移和手动提交的实现。



    看代码的第 62 行,如果当前批次消息经过 filter 的过滤后一条消息都不符合当前实例消费,那么我们就把当前批次进行手动异步提交位移。结合我们在生产的实际情况,在灰度实例上我们确实会把所有的消息都过滤掉,并异步提交位移。


    为什么我们封装的客户端提交就会报大量的报错,而使用 spring-kafka 的没有呢?


    我们看下Spring对提交位移这块的核心实现逻辑。



    可以同步,也可以异步提交,具体那种提交方式就要看 this.containerProperties.isSyncCommits() 这个属性的配置了,然而我们一般也不会去配置这个东西,大部分都是在使用默认配置。



    人家默认使用的是同步提交方式,而我们使用的是异步方式。


    同步提交和异步提交有什么区别么?


    先看下同步提交的实现:



    只要遇到了不是不可恢复的异常外,在 timer 参数过期时间范围内重试到成功(这个方法的描述感觉不是很严谨的样子)。



    我们在看下异步提交方式的核心实现:



    我们不要被第 645 行的 RequestFuture future = sendOffsetCommitRequest(offsets) 所迷惑,它其实并不是发送位移提交的请求,它内部只是把当前请求包装好,放到 private final UnsentRequests unsent = new UnsentRequests(); 这个属性中,同时唤醒真正的发送线程来发送的。



    这里不是重点,重点是如果我们的异步提交发生了异常,它只是简单的使用 RetriableCommitFailedException 给我们包装了一层。


    重试呢?为什么异步发送产生了可重试异常它不给我们自动重试?


    如果我们对多个异步提交进行重试的话,很大可能会导致位移覆盖,从而引发重复消费的问题。


    正好,我们遇到的所有异常都是 RetriableCommitException 类型的,也就是说,我们把灰度位移提交的方式修改成同步可重试的提交方式,就可以解决我们遇到的问题了。


    作者:艾小仙
    链接:https://juejin.cn/post/7214398563023274021
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    苹果的产品经理设计的App Clip是有意为之,还是必然趋势,详解 App Clip技术之谜

    iOS
    苹果在 WWDC2020 上发布了 App Clip,有媒体叫做“苹果小程序”。虽然 Clip 在产品理念上和小程序有相似之处,但是在技术实现层面却是截然不同的东西。本文会针对 Clip 的技术层面做全面的介绍。 实现方式:native 代码、native 框...
    继续阅读 »

    苹果在 WWDC2020 上发布了 App Clip,有媒体叫做“苹果小程序”。虽然 Clip 在产品理念上和小程序有相似之处,但是在技术实现层面却是截然不同的东西。本文会针对 Clip 的技术层面做全面的介绍。


    实现方式:native 代码、native 框架、native app 一样的分发


    在实现上,Clip 和原生的 app 使用一样的方式。在 UI 框架上同时支持 UIKit 和 SwiftUI,有些开发者认为只能使用 SwiftUI 开发,这点是错误的。Clip 的定位和 watch app、app extension 类似,和 app 在同一个 project 里,是一个单独的 target。只是 Clip 并没有自己的专属 framework(其实有一个,但是主要包含的是一些特色 api),使用的框架和 app 一致,可以认为是一个精简版的原生 App。




    Clip 不能单独发布,必须关联一个 app。因此发布的流程和 app 和一样的,在 apple connect 上创建一个版本,和 app 一起提交审核。和 app 在技术上的最大区别只是大小限制在 10MB 以内,因为 Clip 的基础就是希望用户可以最迅速的被用户使用,如果体积大了就失去了产品的根本。


    产品定位:用完即走




    苹果对 Clip 的使用场景非常明确:在一个特定的情境里,用户可以快速的使用 app 的核心服务。是小程序内味了!


    坦率的说,很难说 Clip 的理念是苹果原创的,在产品的定位上和微信小程序如出一辙。尤其是微信小程序在国内已经完全普及了,微信小程序初始发布的时候也被苹果加了多条限制。其中一条就是小程序不能有虚拟商品支付功能。现在回头看苹果自己的 Clip 可以完美支持 apple pay,很难说苹果没有私心。


    触手可及




    Clip 使用一段 URL 标识自己,格式遵从 universal link。因为苹果对 Clip 的使用场景非常明确,所以在 Clip 的调起方式做了严格限制。Clip 的调用只能是用户主动要发起才能访问,所以不存在用户在某个 app 里不小心点了一个按钮,就跳转下载了 Clip。


    Clip 的发起入口有以下几种:

    • NFC
    • 二维码
    • Safari 中关联了 Clip 的网页
    • 苹果消息应用
    • Siri 附近建议和苹果地图

    NFC 和二维码的入口很容易理解,必须用户主动拿出手机靠近 NFC、打开相机扫描。苹果专属的 Clip 码生成工具在年底才会开放。




    Safari 中发起和之前的 universal link 类似,在网站配置了关联的 Clip 信息后,会有一个 banner 提示打开应用。




    因为 Clip 提交 app store 审核的信息里也会配置好相关的 url,因此如果在 message 里发了 clip 的链接,操作系统也会在应用里生成一个 Clip 的卡片,用户如果需要可以主动点击。




    Siri 附近建议和苹果地图(在 connect 中可以配置 clip 的地理位置)。场景和前面的二维码类似,如果我在地图上看到一个商家,商家有提供服务的 Clip,我可以在地图或者 Siri 建议里直接打开 Clip。




    再次总结一下 Clip 的入口限制:只能是用户主动发起才能访问。虽然 Clip 的入口是一段 universal link,在代码里的处理方式也和 universal link 一致,但是为了 Clip 不被滥用,Clip 的调起只能是操作系统调起。App 没有能力主动调起一个 Clip 程序。


    无需安装、卸载


    因为 Clip 的大小被限制在了 10MB 以下,在当下的网络状态下,可以实现快速的打开。为了给用户使用非常轻松的感觉,在 UI 上不会体现“安装”这样的字眼,而是直接“打开”。预期的场景下用户打开 Clip 和打开一个网页类似。因此在用户的视角里就不存在软件的安装、卸载。


    Clip 的生命周期由操作系统全权接管。如果 Clip 用户一段时间后没有使用,操作系统就会自动清除掉 Clip,Clip 里存储的数据也会被一并清除。因此虽然 Clip 提供了存储的能力,但是程序不应该依赖存储的数据,只能把存储当做 cache 来使用,操作系统可能自动清除缓存的数据。


    横向比较:PWA、Instant Apps、小程序


    Instant Apps


    18 年正式发布的 Android Instant apps 和 Clip 在技术上是最接近的。Instant apps 中文被翻成“免安装应用”,在体验上也是希望用户能够最低成本的使用上 app,让用户感受不到安装这个步骤。Instant apps 也可以通过 url 标识(deep link),如果在 chrome 里搜索到应用的网站,chrome 如果识别到域名下有关联应用,可以直接“打开”。消息中的链接也可以被识别。只是 Instant apps 发布的早,国外用户也没有使用二维码的习惯,所以入口上不支持二维码、NFC。


    两者的根本区别还是在定位上,Instant apps 提出的场景是提供一个 app 的试用版。因此场景是你已经到了 app 的下载页面,这个时候如果一个 app 几百兆你可能就放弃下载了,但是有一个极简的试用版,就会提高你使用 app 的可能。这个场景在游戏 app 里尤其明显,一方面高质量的游戏 app 体积比较大。另一方面,如果是一个付费下载的应用,如果有一个免费的试用版,也可以增加用户的下载可能。在苹果生态里很多应用会提供一个受限的免费 lite 版本也是一样的需求。


    但是 Instant apps 在国内没有产生任何影响。因为政策的原因,Google Play 不支持在国内市场使用。国内的安卓应用市场也是鱼龙混杂,对于 Instant apps 也估计也没有统一支持。另外国内的安卓生态也和欧美地区区别比较大,早期安卓市场上收费的应用很少,对于用户而言需要试用免费 app 的场景很少。另外大厂也可能会推出专门的急速版应用,安装后利用动态化技术下发代码,应用体积也可以控制在 10 MB 以内。


    Clip 则是非常明确的面向线下提供服务的场景,在应用能力上可以接入 sign in with apple,apple pay。这样一个全新的用户,可以很快速的使用线下服务并且进行注册、支付。用户体验会好的多。安卓因为国内生态的原因,各个安卓厂商没有统一的新用户可以快速注册的接口,也没有统一的支付接口,很难提供相匹敌的体验。如果开发者针对各个厂商单独开发,那成本上就不是“小程序”了。


    Progressive Web App(PWA)




    Progressive Web App 是基于 web 的技术。在移动互联网兴起之后,大家的流量都转移到了移动设备上。然而在移动上的 web 体验并不好。于是 W3C 和谷歌就基于浏览器的能力,制定了一套协议,让 web app 可以拥有更多的 native 能力。


    PWA 不是特指某一项技术,而是应用了多项技术的 Web App。其核心技术包括 App Manifest、Service Worker、Web Push。


    PWA 相当于把小程序里的代码直接下载到了本地,有了独立的 app 入口。运行的时候基于浏览器的能力。但是对于用户感受和原生 app 一样。


    我个人对 PWA 技术很有好感,它的初衷有着初代互联网般的美好。希望底层有一套协议后,用户体验还是没有边界的互联网。然而时代已经变了。PWA 在中国基本上是凉了。


    PWA 从出生就带了硬伤,虽然谷歌希望有一套 web 标准可以运行在移动设备上,但是对于苹果的商业策略而言,这并不重要。因此 PWA 的一个协议,从制定出来,再到移动设备(iOS)上支持这个特性,几年就过去了。而且对于移动用户而言,可以拥有一个美好的 web app 并不是他们的痛点。


    总结起来 PWA 看着美好,但似乎更多是对于 web 开发者心中的美好愿景。在落实中遇到了很多现实的问题,技术支持的不好,开发者就更没有动力在这个技术上做软件生态了。


    微信小程序


    前面提过在产品理念上小程序和 Clip 很相似,甚至说不定 Clip 是受了小程序的启发。在市场上,小程序是 Clip 的真正对手。


    小程序基于微信的 app,Clip 基于操作系统,因此在能力上 Clip 有优势。小程序的入口需要先打开微信,而 Clip 可以通过 NFC 靠近直接激活应用。对于开发者而言,Clip 可以直接获得很多原生的能力(比如 push),如果用户喜欢可以关联下载自己的原生应用。在小程序中,微信出于商业原因开发者不能直接跳转到自有 app,小程序的能力也依赖于微信提供的接口。


    对于从 Clip 关联主 app 苹果还挺重视的,提供了几个入口展示关联 app。


    首先在 clip 的展示页就会显示:




    每次使用 Clip 时也会有一个短暂的浮层展示:




    开发者也可以自己通过 SKOverlay 来展示:




    不过如果开发者没有自己的独立 app,那么也就只能选择小程序了。小程序发展到现在场景也比最早提出的线下服务更加多了,反而类似 Instant apps,更像一个轻量级的 app。


    考虑到国内很多小程序的厂商都没有自己的独立 app,因此 clip 对于这部分群体也并没有什么吸引力。不过对于线下服务类,尤其有支付场景的,Clip 在用户体验上会比小程序好一些。


    总结,Clip 的业务场景和小程序有一小部分是重叠的,小程序覆盖的场景还是更多一些。两者在大部分时候并不是互斥式的竞争关系,即便在一些场景下 Clip 有技术优势,商家也不会放弃小程序,因为还有安卓用户嘛。还是看商家在某些场景里,是否愿意为用户多提供一种更好的交互方式。


    对比原生 app 的技术限制


    虽然 Clip 可以直接使用 iOS framework,但是因为 Clip 的使用场景是新用户的初次、简短、当下(in-the-moment experience)的使用,相比原生 app 苹果还是进行了一些限制。


    App 不能访问用户的隐私信息:

    • 运动和健身数据
    • Apple Music 和多媒体文件
    • 通讯录、信息、照片、文件等数据

    不过为了能够提供给用户更加轻便的体验,通过专门为 Clip 设计了免申请的通知、定位权限。不过也有限制:免申请的通知只在 8 个小时内有效。位置只能获取一次。如果 app 需要重度使用这两类权限就还是和原来一样,可以弹窗申请。


    某些高级应用能力也会受限,需要在完整的应用中才能使用:

    • 不能请求追踪授权
    • 不能进行后台请求任务
    • 没在激活状态蓝牙连接会断开

    总的而言虽然有一些限制,但是这些限制的出发点是希望开发者关注 Clip 的正确使用场景。对于 Clip 所提倡的使用场景里,苹果提供的能力是完全够用的。


    一些技术细节


    可以建立一个共享 targets 的 Asset catalog 来共用图片资源。




    在 Clip 中申请的授权,在下载完整应用后会被同步到应用中。


    通过 App Group Container 来共享 clip 和 app 的数据。




    image


    Clip 的 url 可以配置参数:




    在 App Store connect 中还可以针对指定的参数配置不一样的标题和图片。比如一家连锁咖啡店,可能不同的店你希望弹出的标题图片是不一样的,可以进行单独的配置。




    总结


    苹果给定义的 Clip 的关键词是:lightweight、native、fast、focused、in-the-moment experience。


    Clip 在特定的线下场景里有着相当好的用户体验。对于已经拥有独立 app 的公司来说,开发一个 clip 应用的成本并不高。我个人还是期待这样一个好的技术可以被更多开发者接纳,可以提供给用户更好的体验。对于小程序,clip 的场景窄的多,两者并不是直接竞争关系。我更愿意看做是特定场景下,对于小程序原生能力不足的一种补充。


    作者:会飞的金鱼
    链接:https://juejin.cn/post/7219889814116024380
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    仿微信列表左滑删除、置顶。。

    仿微信消息列表 前言 最近自己在利用空闲时间开发一个APP,目的是为了巩固所学的知识并扩展新知,加强对代码的理解扩展能力。消息模块是参照微信做的,一开始并没有准备做滑动删除的功能,觉得删除嘛,后面加个长按的监听不就行了,但是对于有些强迫症的我来说,是不大满意这...
    继续阅读 »

    仿微信消息列表


    前言


    最近自己在利用空闲时间开发一个APP,目的是为了巩固所学的知识并扩展新知,加强对代码的理解扩展能力。消息模块是参照微信做的,一开始并没有准备做滑动删除的功能,觉得删除嘛,后面加个长按的监听不就行了,但是对于有些强迫症的我来说,是不大满意这种解决方法的,但由于我对自定义view的了解还是比较少,而且之前也没有做过,所以就作罢。上周看了任玉刚老师的《Android开发艺术探索》中的View事件体系章节,提起了兴趣,就想着试一试吧,反正弄不成功也没关系。最后弄成了,但还是有些小瑕疵(在6、问题中),希望大佬能够指教一二。话不多说,放上一张动图演示下:


    messlist.gif


    1、典型的事件类型


    在附上源码之前,想先向大家介绍下事件类型,在手指接触屏幕后所产生的一系列事件中,典型的事件类型有如下几种:



    • ACTION_DOWN ---- 手指刚接触屏幕

    • ACTION_MOVE ---- 手指在屏幕上移动

    • ACTION_UP ---- 手指刚离开屏幕


    正常情况下、一次手指触摸屏幕的行为会触发一系列点击事件:



    • 点击屏幕后松开,事件序列为DOWN -> UP

    • 点击屏幕滑动后松开,事件序列为DOWN -> MOVE -> ... -> MOVE -> UP


    2、Scroller


    Scroller - 弹性滑动对象,用于实现View的弹性滑动。
    当使用View的scrollTo/scrollBy方法来实现滑动时,其过程是在瞬间完成的,这个过程没有过渡效果,用户体验感较差,这个时候就可以使用Scroller来实现有过渡效果的滑动,其过程不是瞬间完成的,而是在一定时间间隔内完成的。


    3、View的滑动


    Android手机由于屏幕较小,为了给用户呈现更多的内容,就需要使用滑动来显示和隐藏一些内容,不管滑动效果多么绚丽,它们都是由不同的滑动外加特效实现的。View的滑动可以通过三种方式实现:



    • scrollTo/scrollBy:操作简单,适合对View内容的滑动。

    • 修改布局参数:操作稍微复杂,适合有交互的View。

    • 动画:操作简单,适合没有交互的View和实现复杂的动画效果。


    3.1、scrollTo/scrollBy


    为了实现View的滑动,View提供了专门的方法来实现这一功能,也就是scrollTo/scrollBy。是基于所传参数的绝对滑动。


    3.2、修改布局参数


    即改变LayoutParams,比如想把一个布局向右平移100px,只需要将该布局LayoutParams中的marginLeft参数值增加100px即可。或者在该布局左边放入一个默认宽度为0px的空View,当需要向右平移时,重新设置空View的宽度就OK了。


    3.3、动画


    动画和Scroller一样具有过渡效果,View动画是对View的影像做操作,并不能真正改变View的位置,单击新位置无法触发onClick事件,在这篇文章中并没有使用到,所以不再赘叙了。


    4、布局文件


    <?xml version="1.0" encoding="utf-8"?>
    ### <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:widget="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <com.example.myapplication.view.ScrollerLinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal">

    <RelativeLayout
    android:id="@+id/friend_item"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:paddingHorizontal="16dp"
    android:paddingVertical="10dp">

    <LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal">

    <com.makeramen.roundedimageview.RoundedImageView
    android:id="@+id/friend_icon"
    android:layout_width="45dp"
    android:layout_height="45dp"
    android:src="@mipmap/touxiang"
    app:riv_corner_radius="5dp" />

    <LinearLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_gravity="center"
    android:layout_marginLeft="12dp"
    android:gravity="center_vertical"
    android:orientation="vertical">

    <TextView
    android:id="@+id/friend_name"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:singleLine="true"
    android:textColor="@color/black"
    android:textSize="15dp"
    tools:text="好友名" />

    <TextView
    android:id="@+id/friend_last_mess"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginTop="3dp"
    android:layout_marginEnd="18dp"
    android:singleLine="true"
    android:textColor="@color/color_dbdbdb"
    android:textSize="12dp"
    tools:text="最后一条信息内容" />
    </LinearLayout>

    </LinearLayout>

    <TextView
    android:id="@+id/last_mess_time"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_alignParentEnd="true"
    android:layout_marginTop="5dp"
    android:singleLine="true"
    android:textColor="@color/color_dbdbdb"
    android:textSize="11dp"
    tools:text="时间" />
    </RelativeLayout>

    <LinearLayout
    android:layout_width="240dp"
    android:layout_height="match_parent"
    android:orientation="horizontal">

    <Button
    android:id="@+id/unread_item"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_weight="1"
    android:clickable="true"
    android:background="@color/color_theme"
    android:gravity="center"
    android:text="标为未读"
    android:textColor="@color/color_FFFFFF" />

    <Button
    android:id="@+id/top_item"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_weight="1"
    android:clickable="true"
    android:background="@color/color_orange"
    android:gravity="center"
    android:text="置顶"
    android:textColor="@color/color_FFFFFF" />

    <Button
    android:id="@+id/delete_item"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_weight="1"
    android:clickable="true"
    android:background="@color/color_red"
    android:gravity="center"
    android:text="删除"
    android:textColor="@color/color_FFFFFF" />
    </LinearLayout>

    </com.example.myapplication.view.ScrollerLinearLayout>

    <View
    android:layout_width="match_parent"
    android:layout_height="1px"
    android:layout_alignParentBottom="true"
    android:layout_marginLeft="60dp"
    android:layout_marginRight="3dp"
    android:background="@color/color_e7e7e7" />

    </LinearLayout>

    ScrollerLinearLayout布局最多包含两个子布局(默认是这样,后面可能还会修改成自定义),一个是展示在用户面前充满屏幕宽度的布局,一个是待展开的布局,在该xml布局中,ScrollerLinearLayout布局包含了一个RelativeLayout和一个LinearLayoutLinearLayout中包含了三个按钮,分别是删除、置顶、标为未读。


    5、自定义View-ScrollerLinearLayout


    /**
    * @Copyright : China Telecom Quantum Technology Co.,Ltd
    * @ProjectName : My Application
    * @Package : com.example.myapplication.view
    * @ClassName : ScrollerLinearLayout
    * @Description : 文件描述
    * @Author : yulu
    * @CreateDate : 2023/8/17 17:05
    * @UpdateUser : yulu
    * @UpdateDate : 2023/8/17 17:05
    * @UpdateRemark : 更新说明
    */

    class ScrollerLinearLayout @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
    ) :
    LinearLayout(context, attrs, defStyleAttr) {

    private val mScroller = Scroller(context) // 用于实现View的弹性滑动
    private val mTouchSlop = ViewConfiguration.get(context).scaledTouchSlop
    private var mVelocityTracker: VelocityTracker? = null // 速度追踪
    private var intercept = false // 拦截状态 初始值为不拦截
    private var lastX: Float = 0f
    private var lastY: Float = 0f // 用来记录手指按下的初始坐标
    var expandWidth = 720 // View待展开的布局宽度 需要手动设置 3*dp
    private var expandState = false // View的展开状态
    private val displayWidth =
    context.applicationContext.resources.displayMetrics.widthPixels // 屏幕宽度
    private var state = true


    override fun onTouchEvent(event: MotionEvent): Boolean {
    Log.e(TAG, "onTouchEvent $event")
    when (event.action) {
    MotionEvent.ACTION_DOWN -> {
    if (!expandState) {
    state = false
    }
    }
    else -> {
    state = true
    }
    }
    return state
    }


    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
    Log.e(TAG, "onInterceptTouchEvent Result : ${onInterceptTouchEvent(ev)}")
    Log.e(TAG, "dispatchTouchEvent : $ev")
    mVelocityTracker = VelocityTracker.obtain()
    mVelocityTracker!!.addMovement(ev)
    return super.dispatchTouchEvent(ev)
    }

    override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
    Log.e(TAG, "onInterceptTouchEvent $ev")
    when (ev?.action) {
    MotionEvent.ACTION_DOWN -> {
    lastX = ev.rawX
    lastY = ev.rawY
    // 处于展开状态且点击的位置不在扩展布局中 拦截点击事件
    intercept = expandState && ev.x < (displayWidth - expandWidth)
    }
    MotionEvent.ACTION_MOVE -> {
    // 当滑动的距离超过10 拦截点击事件
    intercept = lastX - ev.x > 10
    moveWithFinger(ev)
    }
    MotionEvent.ACTION_UP -> {
    // 判断滑动距离是否超过布局的1/2
    chargeToRightPlace(ev)
    intercept = false
    }
    MotionEvent.ACTION_CANCEL -> {
    chargeToRightPlace(ev)
    intercept = false
    }
    else -> intercept = false
    }
    return intercept
    }

    /**
    * 将布局修正到正确的位置
    */

    private fun chargeToRightPlace(ev: MotionEvent) {
    val eventX = ev.x - lastX

    Log.e(TAG, "该事件滑动的水平距离 $eventX")
    if (eventX < -(expandWidth / 4)) {
    smoothScrollTo(expandWidth, 0)
    expandState = true
    invalidate()
    } else {
    expandState = false
    smoothScrollTo(0, 0)
    invalidate()
    }

    // 回收内存
    mVelocityTracker?.apply {
    clear()
    recycle()
    }
    //清除状态
    lastX = 0f
    invalidate()
    }

    /**
    * 跟随手指移动
    */

    private fun moveWithFinger(event: MotionEvent) {
    //获得手指在水平方向上的坐标变化
    // 需要滑动的像素
    val mX = lastX - event.x
    if (mX > 0 && mX < expandWidth) {
    scrollTo(mX.toInt(), 0)
    }
    // 获取当前水平方向的滑动速度
    mVelocityTracker!!.computeCurrentVelocity(500)
    val xVelocity = mVelocityTracker!!.xVelocity.toInt()
    invalidate()

    }

    /**
    * 缓慢滚动到指定位置
    */

    private fun smoothScrollTo(destX: Int, destY: Int) {
    val delta = destX - scrollX
    // 在多少ms内滑向destX
    mScroller.startScroll(scrollX, 0, delta, 0, 600)
    invalidate()
    translationY = 0f
    }

    // 流畅地滑动
    override fun computeScroll() {
    if (mScroller.computeScrollOffset()) {
    scrollTo(mScroller.currX, mScroller.currY);
    postInvalidate()
    }
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    expandWidth = childViewWidth()
    invalidate()
    super.onLayout(changed, l, t, r, b)
    }

    /**
    * 最多只允许有两个子布局
    */

    private fun childViewWidth(): Int {
    Log.e(TAG, "childCount ${this.childCount}")
    return if (this.childCount > 1) {
    val expandChild = this.getChildAt(1) as LinearLayout
    if (expandChild.measuredWidth != 0){
    expandWidth = expandChild.measuredWidth
    }
    Log.e(TAG, "expandWidth $expandWidth")
    expandWidth
    } else
    0
    }

    companion object {
    const val TAG = "ScrollerLinearLayout_YOLO"
    }
    }

    思路比较简单,就是在ACTION_DOWN时记录初始的横坐标,在ACTION_MOVE中判断是否需要拦截该事件,
    当滑动的距离超过10,拦截该点击事件,防止不必要的点击。并且View跟随手指移动。在ACTION_UPACTION_CANCEL中将布局修正到正确的位置,主要是根据滑动的距离来判断是否要展开并记录展开的状态。在ACTION_DOWN中判断是否处于展开状态,如果在展开状态且点击的位置不在扩展布局中,拦截点击事件,防止不必要的点击。


    6、问题


    自定义布局中的expandWidth参数在childViewWidth()方法和onLayout()方法中都赋值了一次,在onLayout()方法中查看日志expandWidth是有值的,可是在moveWithFinger()方法中打日志查看得到的expandWidth参数值仍然是0,导致无法正常滑动。去到其他的页面再返回到消息界面就可以正常滑动了,再次查看日志参数也有值了,这个问题不知道如何解决,所以需要手动设置expandWidth的值。


    7、小结


    初步的和自定义View认识了,小试牛刀,自己还是很满意这个学习成果的。希望在接下来的学习中不要因为没有接触过而放弃学习,勇于迈出第一步。文章若出现错误,欢迎各位批评指正,写文不易,转载请注明出处谢谢。


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

    刚咬了一口馒头,服务器突然炸了!

    首先,这个项目是完全使用Websocket开发,后端采用了基于Swoole开发的Hyperf框架,集成了Webscoket服务。 其次,这个项目是我们组第一次尝试使用Socket替换传统的HTTP API请求,前端使用了Vue3,前端同学定义了一套Socket...
    继续阅读 »

    首先,这个项目是完全使用Websocket开发,后端采用了基于Swoole开发的Hyperf框架,集成了Webscoket服务。
    其次,这个项目是我们组第一次尝试使用Socket替换传统的HTTP API请求,前端使用了Vue3,前端同学定义了一套Socket管理器。


    看着群里一群“小可爱”疯狂乱叫,我被吵的头都炸了,赶紧尝试定位问题。



    1. 查看是否存在Jenkins发版 -> 无

    2. 查看最新提交记录 -> 最后一次提交是下午,到晚上这段时间系统一直是稳定的

    3. 查看服务器资源,htop后,发现三台相关的云服务器资源都出现闲置状态

    4. 查看PolarDB后,既MySQL,连接池正常、吞吐量和锁正常

    5. 查看Redis,资源正常,无异常key

    6. 查看前端控制台,出现一些报错,但是这些报错经常会变化


    7. 查看前端测试环境、后端测试环境,程序全部正常

    8. 重启前端服务、后端服务、NGINX服务,好像没用,过了5分钟后,咦,好像可以访问了


    就在我们组里的“小可爱”通知系统恢复正常后,20分钟不到,再一次处于无法打开的状态,沃焯!!!
    完蛋了,找不出问题在哪里了,实在想不通问题究竟出在哪里。


    我不服啊,我不理解啊!


    咦,Nginx?对呀,我瞅瞅访问日志,于是我浏览了一下access.log,看起来貌似没什么问题,不过存在大量不同浏览器的访问记录,刷的非常快。


    再瞅瞅error.log,好像哪里不太对


    2023/04/20 23:15:35 [alert] 3348512#3348512: 768 worker_connections are not enough
    2023/04/20 23:33:33 [alert] 3349854#3349854: *3492 open socket #735 left in connection 1013

    这是什么?貌似是连接池问题,赶紧打开nginx.conf看一下配置


    events {
    worker_connections 666;
    # multi_accept on;
    }

    ???


    运维小可爱,你特么在跟我开玩笑?虽然我们这个系统是给B端用的,还是我们自己组的不到100人,但是这连接池给的也太少了吧!


    另外,前端为什么会开到 1000 个 WS 连接呢?后端为什么没有释放掉FD呢?


    询问后,才知道,前端的Socket管理器,会在连接超时或者其它异常情况下,重新开启一个WS连接。


    后端的心跳配置给了300秒


    Constant::OPTION_OPEN_WEBSOCKET_PROTOCOL => true, // websocket 协议
    Constant::OPTION_HEARTBEAT_CHECK_INTERVAL => 150,
    Constant::OPTION_HEARTBEAT_IDLE_TIME => 300,

    此时修改nginx.conf的配置,直接拉满!!!


    worker_connections 655350;

    重启Nginx,哇绰,好像可以访问了,但是每当解决一个问题,总会产生新的问题。


    此时error.log中出现了新的报错:


    2023/04/20 23:23:41 [crit] 3349767#3349767: accept4() failed (24: Too many open files)

    这种就不怕了,貌似和Linux的文件句柄限制有关系,印象中是1024个。
    至少有方向去排查,不是吗?而且这已经算是常规问题了,我只需小小的百度一下,哼哼~


    拉满拉满!!


    worker_rlimit_nofile 65535;

    此时再次重启Nginx服务,系统恢复稳定,查看当前连接数:


    netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'

    # 打印结果
    TIME_WAIT 1175

    FIN_WAIT1 52

    SYN_RECV 1

    FIN_WAIT2 9

    ESTABLISHED 2033

    经过多次查看,发现TIME_WAITESTABLISHED都在不断减少,最后完全降下来。


    本次问题排查结束,问题得到了解决,但是关于socket的连接池管理器,仍然需要优化,及时释放socket无用的连接。


    作者:兰陵笑笑生666
    来源:juejin.cn/post/7224314619865923621
    收起阅读 »

    树形列表翻页,后端: 搞不了搞不了~~

    web
    背景 记得几年前做了一个报告,报告里面加载的是用户的历年作品还有会员信息,然后按照年月倒序展示出来,其中历年作品都要将作品的封面展示出来。一开始这个报告到没啥问题,而且一个时间轴下来感觉挺好,有用户的作品、会员记录、关注以及粉丝记录很全面。直到最近忽然有一批用...
    继续阅读 »

    背景


    记得几年前做了一个报告,报告里面加载的是用户的历年作品还有会员信息,然后按照年月倒序展示出来,其中历年作品都要将作品的封面展示出来。一开始这个报告到没啥问题,而且一个时间轴下来感觉挺好,有用户的作品、会员记录、关注以及粉丝记录很全面。直到最近忽然有一批用户说一进到这个报告页面就卡住不动了,上去一查发现不得了,都是铁杆用户,每年作品都几百个,导致几年下来,这个报告返回了几千个作品,包含上千的图片。


    问题分析


    上千的图片,肯定会卡,首先想到的是做图片懒加载。这个很简单,使用一个vue的全局指令就可以了。但是上线发现,没啥用,dom节点多的时候,懒加载也卡。


    然后就问服务端能不能支持分页,服务端说数据太散,连表太多,树形结构很难做分页。光查询出来就已经很费劲了。


    没办法于是想了一下如何前端来处理掉。


    思路




    1. 由于是app中的嵌入页面,首先考虑通过滚动进行分页加载。




    2. 一次性拿了全部的数据,肯定不能直接全部渲染,我们可以只渲染一部分,比如第一个节点,或者前几个节点。




    3. 随着滚动一个节点一个节点或者一批一批的渲染到dom中。




    实现


    本文仅展示一种基于vue的实现


    1. 容器

    设计一个可以进行滚动翻页的容器 然后绑定滚动方法OnPageScrolling



    <style lang="less" scoped>

    .study-backup {

    overflow-x: hidden;

    overflow-y: auto;

    -webkit-overflow-scrolling: touch;

    width: 100%;

    height: 100%;

    position: relative;

    min-height: 100vh;

    background: #f5f8fb;

    box-sizing: border-box;

    }

    </style>

    <template>

    <section class="report" @scroll="OnPageScrolling($event)">

    </section>

    </template>



    2.初始化数据

    这里定义一下树形列表的数据结构,实现初始化渲染,可以渲染一个树节点,或者一个树节点的部分子节点



    GetTreeData() {

    treeapi

    .GetTreeData({ ... })

    .then((result) => {

    // 处理结果

    const data = Handle(result)

    // 这里备份一份数据 不参与展示

    this.backTreeList = data.map((item) => {

    return {

    id: item.id,

    children: item.children

    }

    })

    // 这里可以初始化为第一个树节点

    const nextTree = this.backTreeList[0]

    const nextTansformTree = nextTree.children.splice(0)

    this.treeList = [{

    id: nextTree.id,

    children: nextTansformTree

    }]

    // 这里可以初始化为第一树节点 但是只渲染第一个子节点

    const nextTree = this.backTreeList[0]

    const nextTansformTree = nextTree.children.splice(0, 1)

    this.treeList = [{

    id: nextTree.id,

    children: nextTansformTree

    }]

    })

    },


    3.滚动加载

    这里通过不断的把 backTreeList 的子节点转存入 treeList来实现分页加载。



    OnPageScrolling(event) {

    const container = event.target

    const scrollTop = container.scrollTop

    const scrollHeight = container.scrollHeight

    const clientHeight = container.clientHeight

    // console.log(scrollTop, clientHeight, scrollHeight)

    // 判断是否接近底部

    if (scrollTop + clientHeight >= scrollHeight - 10) {

    // 执行滚动到底部的操作

    const currentReport = this.backTreeList[this.treeList.length - 1]

    // 检测匹配的当前树节点 treeList的长度作为游标定位

    if (currentReport) {

    // 判断当前节点的子节点是否还存在 如果存在则转移到渲染树中

    if (currentReport.children.length > 0) {

    const transformMonth = currentReport.children.splice(0, 1)

    this.treeList[this.treeList.length - 1].children.push(

    transformMonth[0]

    )

    // 如果不存在 则寻找下一树节点进行复制 同时复制下一节点的第一个子节点 当然如果寻找不到下一树节点则终止翻页

    } else if (this.treeList.length < this.backTreeList.length) {

    const nextTree = this.backTreeList[this.treeList.length]

    const nextTansformTree = nextTree.children.splice(0, 1)

    this.treeList.push({

    id: nextTree.id,

    children: nextTansformTree

    })

    }

    }

    }

    }


    4. 逻辑细节

    从上面代码可以看到,翻页的操作是树copy的操作,将备份树的子节点转移到渲染树中




    1. copy备份树的第一个节点到渲染树,同时将备份树的第一个节点的子节点的第一个节点转移到渲染树的第一个节点的子节点中




    2. 所谓转移操作,就是数组splice操作,从一颗树中删除,然后把删除的内容插入到另一颗树中




    3. 由于渲染树是从长度1开始的,所以我们可以根据渲染树的长度作为游标和备份树进行匹配,设渲染树的长度为当前游标




    4. 根据当前游标查询备份树,如果备份树的当前游标节点的子节点不为空,则进行转移




    5. 如果备份树的当前游标节点的子节点为空,则查找备份树的当前游标节点的下一节点,设为下一树节点




    6. 如果找到了备份树的当前游标节点的下一节点,扩展渲染树,将下一树节点复制到渲染树,同时将下一树节点的子节点的第一节点复制到渲染树




    7. 循环4-6,将备份树完全转移到渲染树,完成所有翻页




    扩展思路


    这个方法可以进行封装,将每次复制的节点数目和每次复制的子节点数目作为传参,一次可复制多个节点,这里就不做展开


    作者:CodePlayer
    来源:juejin.cn/post/7270503053358612520
    收起阅读 »

    创建一个可以循环滚动的文本,可能没这么简单。

    web
    如何创建一个可以向左循环滚动的文本? 创建如上图效果的滚动文本,你能想到几种方式? -------- 暂停阅读,不如你自己先试一下 -------- 方式一: 根据页面宽度,生成多个元素。每个元素通过js控制,每帧向左偏移一点像素。 如果偏移的元素不可见后...
    继续阅读 »

    如何创建一个可以向左循环滚动的文本?


    loop.gif


    创建如上图效果的滚动文本,你能想到几种方式?


    -------- 暂停阅读,不如你自己先试一下 --------


    方式一:


    根据页面宽度,生成多个元素。每个元素通过js控制,每帧向左偏移一点像素。

    如果偏移的元素不可见后,将此元素再移动到屏幕最右边。


    此方式容易理解,实现起来也不困难,但是有性能上的风险,因为每一帧都在修改元素的位置。


    方式二:


    根据页面宽度,生成多个元素。每个元素通过js控制,通过setInterval每一秒向左偏移一些像素。

    然后结合css的transition: all 1s linear;使得偏移更加顺滑。

    如果偏移的元素不可见后,将此元素再移动到屏幕最右边。


    使用此方法可以避免高频率计算元素位置,但是此方式控制起来更复杂,主要是因为,将元素移动到最右边的时候,也会触发transition ,需要额外逻辑控制在元素移到最右边的时候不触发transition

    并且在实际开发中发现。当窗口不可见时候动画实际会暂停,还需要控制当窗口隐藏时候,暂停setInterval


    方式三:


    换一种思路。按顺序排列元素,多个子元素首位相接。将每个子元素通过animation: xxx 10s linear infinite;

    从左到右移动。在一定范围内移动子元素,通过视觉错觉,像是整个大元素(盒子)都在移动。

    此方式简单,并且无需JS,性能较好。


    下面是完整代码(可以控制浏览器宽度,查看不同尺寸屏幕的效果)


    <!doctype html>  
    <html>
    <head>
    <title>LOOP</title>
    <style>
    @keyframes loop {
    0% {
    transform: translateX(0);
    }
    100% {
    transform: translateX(-100%);
    }
    }

    .box {
    white-space: nowrap;
    }

    .scrollContent {
    width: 600px;
    display: inline-block;
    text-align: center;
    animation: loop 3s linear infinite;
    }
    </style>
    </head>
    <body>
    <div class="box">
    <div class="scrollContent">
    这是一段可以滚动的文本
    </div>
    <div class="scrollContent">
    这是一段可以滚动的文本
    </div>
    <div class="scrollContent">
    这是一段可以滚动的文本
    </div>
    <div class="scrollContent">
    这是一段可以滚动的文本
    </div>
    <div class="scrollContent">
    这是一段可以滚动的文本
    </div>
    <div class="scrollContent">
    这是一段可以滚动的文本
    </div>
    </div>
    </body>

    </html>


    方式四:


    方式三会创建多份一样的文本内容,你可能会说,屏幕上同时出现这么多文本元素,当然要创建这么多一样的内容。

    其实还有一种性能更佳的方式:text-shadow: 600px 0 currentColor,通过此方式创建多份文本副本,达到类似效果。

    此方法性能最佳。但是对非文本无能为力。


    <!doctype html>  
    <html>
    <head>
    <title>LOOP</title>
    <style>
    @keyframes loop {
    0% {
    transform: translateX(0);
    }
    100% {
    transform: translateX(-100%);
    }
    }

    .box {
    white-space: nowrap;
    }

    .scrollContent {
    color: rebeccapurple;
    width: 600px;
    display: inline-block;
    text-align: center;
    animation: loop 3s linear infinite;
    text-shadow: 600px 0 currentColor, 1200px 0 currentColor, 1800px 0 currentColor, 2400px 0 currentColor;
    }
    </style>
    </head>
    <body>
    <div class="box">
    <div class="scrollContent">
    这是一段可以滚动的文本
    </div>
    </div>
    </body>

    </html>

    总结


    方式1:应该是最直接想到的方式。但是出于对性能的担忧。

    方式2:由于方式1性能优化得到,但是方式2过于复杂。
    方式3: 看上去非常易于实现,实际很难想到。
    方式4:如果对text-shadow和css颜色掌握不熟,根本难以实现。


    希望对你有所启发


    作者:wuwei123
    来源:juejin.cn/post/7273026570930257932
    收起阅读 »

    笨功夫是普通人最后的依靠

    今天早上看到一篇文章《笨功夫是普通人最后的依靠》,有感而发,文中说的内容都是自己现在的一些想法, 本想在下面评论一下,但是好像要说的太多了,评论写不下,也就有了本文。 背单词是学英语的 “笨功夫” 故事还得从差不多十几年前的初中说起,在我上小学的时候,所在的小...
    继续阅读 »

    今天早上看到一篇文章《笨功夫是普通人最后的依靠》,有感而发,文中说的内容都是自己现在的一些想法,
    本想在下面评论一下,但是好像要说的太多了,评论写不下,也就有了本文。


    背单词是学英语的 “笨功夫”


    故事还得从差不多十几年前的初中说起,在我上小学的时候,所在的小学是还没有教英语的,所以我的英语是从初中开始学的。
    还好初中上英语课的时候,老师依然是从 26 个英语字母教起的,只是会有种错觉,那些小学学过英语的同学好像不用怎么学就能在英语上获得好成绩。
    但这种想法不太客观,因为在有这种想法的时候,潜意识中将他们小学几年的积累忽略了,所以多少会有点沮丧。


    九边的文章中,提到了学习英语得先背单词,背单词就是学习英语中的 “笨功夫”。对于这点深有体会,虽然我的英语是初中开始学的,
    而且我对语法可以说知之甚少,但是我在背单词上可是花了不少时间的,所以英语成绩也不至于太难看。
    后来上了大学,也能凭借初高中的英语单词的积累,考过了四级。
    然后做为一名程序员,日常开发的时候需要看到英文文档也可以比较流畅,当然肯定不如看中文顺畅。
    说这些,并不是觉得这是什么光荣的事,只是想表达比较认可背单词是学习英语的 “笨功夫” 这一观点。


    笨功夫之外,方法也重要


    几年前看极客时间的《数据结构与算法之美》这门课的时候,提到了一点:



    数据结构和算法就是一个非常难啃的硬骨头,可以说是计算机学科中最难学的学科之一了。
    我当时学习也费了老大的劲,能做到讲给你听,我靠的也是十年如一的积累和坚持。如果没有基础、或者基础不好,你怎能期望看 2 个小时就能完全掌握呢?



    九边的文章中也提到了自己的经历,想学写代码,然后一推人推荐学习《算法导论》。
    对于这个,我个人也是深受其害,十几年前我一直徘徊在玩游戏和学习之间,常常觉得自己的时间不应该全部还在玩游戏上,怎么也得学习一下。
    然后我就会去学习,我怎么学习呢?也是跟九边一样,找那些前辈们推荐的经典教材,比如算法、操作系统、编译原理、计算机网络相关的经典书籍,


    依然记得我在高三的时候就买来了一本《编译原理》,也就是那本 “龙书”(因为封面是一条龙)。
    但是,这本编译原理就算让现在的我去看估计也很难看懂,所以在学习方面,个人其实走了很多弯路,跌了不少跟头。
    因为学习的方法不对,这种学习自然是持续不下去的,在这种学习过程中,需要耗费大量的心力,然后自我怀疑,最后放弃。


    对于这一点,九边的文章中也提到了背后的根本原因:



    • 选错教材了,你拿得太难了,不适合你;

    • 投入时间不足。


    这两点都是我当时存在的问题,一上来就选择了最难的教材来看,没有考虑到自身实力能不能看得懂。
    当然,选择难的教材也不是不行,可能得投入足够的时间去把教材看懂,前提是,有途径可以解决学习过程中的遇到的问题,
    比如遇到问题可以问问前辈,又或者像现在的 GPT,如果想借助百度这种东西可能大概率要失望。
    跳过少数问题可能不影响学习的效果,但是如果绝大部分问题都没有能找到答案的方法,那换一本简单点的教材先学学基础是个更好的方法。


    当然,说这些并不是为了鼓励大家去学习数据结构算法,只是想说,在我们学习遇到困难的时候,可能得考虑一下是不是上面两个原因导致的。


    大脑对熟悉的东西更感兴趣


    这句话已经不记得是从哪里看到的了,但是觉得颇有道理。
    有时候,我们对某件事情不感兴趣是因为不擅长,而不是因为真的不擅长。
    在进入一个相对新的领域的时候,我们会接触到很多新的名词、术语,会觉得特别费劲。
    这个时候我们可能就会选择放弃了,当然,我们也可以选择坚持下去,
    这些年我学到的一个非常重要的学习方法就是,对于新接触的东西,去多看、多想、多实践几遍,当然,这里说的是学习编程领域的东西。
    很多东西,在我们这样几轮学习下来,好像其实也没有太难的东西,当然也有可能我学习的东西本身没有很难的东西。
    但不得不承认,就算是那些你觉得不难的东西,很多人也潜意识中会觉得自己学不会,
    但实际上只是他们投入的时间还不够多。


    在开始的时候,我也会觉得枯燥无味,但是在熟悉多几遍之后,发现好像还挺有意思,尤其是使用新学到的东西解决了自己实际遇到的一些问题之后。
    学习的过程大多如此吧,说到这,想起了几天前看到的《如何取得杰出成就》中提到的一点:



    有一些工作,我们可能必须在自己讨厌的事情上努力工作数年,才能接近喜欢的部分,但这不是杰出成就产生的方式,
    杰出的成就是通过持续关注自己真正感兴趣的事情来实现的 —— 当我们停下来盘点时,会惊讶于自己已经走了多远。



    这篇文章是《黑客与画家》作者博客《How to Do Great Work》的翻译版,有兴趣可以看看,感觉还不赖。
    在实际工作中,我们遇到的很多问题其实并不需要坚持数年才能解决,又不是研究什么前沿领域的东西,
    但是对于一些难题需要花几天或者一两个星期去解决这种可能性还是很大的。
    在这个过程我们会对问题域中的东西越来越熟悉,直到最后它们不再是我们的障碍。


    问题是可以分解的


    搜狐 CEO 张朝阳这几年在 B 站上更新了很多物理的教程,当然我是全都看不懂,只是想起他说过的一段话:



    很多东西的话,就是你看起来很复杂,是因为你不熟悉,其实这个知识,
    天下的这个知识和所有的东西,其实都是不难的,你把所有的再复杂的东西,把它分解成每一步的话,
    他的基本的这个思维过程的,跟你早上吃什么饭,怎么做饭,怎么打车怎么点东西,都是一样的思维过程。
    很多东西你理解不了,不是因为你笨或者是你不够聪明,而是因为你,你自己认为你理解不了是吧,
    很多可能因为过去的经历啊,就是在课堂上这个回答不了问题啊,一些挫败的经历,考试的失败导致,
    你就有一种恐惧,是一种恐惧和你的认为理解不了导致你理解不了。



    虽然道理是这么个道理,但是不代表物理都没学过的人能看得懂他讲的物理课,
    因为问题虽然可以分解,但是一个需要有深厚基础的问题恐怕你连分解都不知道怎么分解,更不要提解决。
    就好像上文提到的《算法导论》这本书,里面有大量的数学推导,很多人看不懂是因为,
    从作者的角度来说,他已经把问题分解为了若干个小问题,在他看来,看这本书的读者应该是已经掌握了他分解之后的问题的相关知识。
    从推荐看这本书的人来看,他推荐的对象应该也掌握了书中那些分解之后的知识。
    但是实际是,可能也有很多人没有考虑到自身实力,然后就去啃这些大部头,自然而然地看不懂。


    很多时候我们遇到的问题都能找到恰当的分解方法,尤其是编程领域,要不然我们不大可能会碰到那个问题。
    在摸爬滚打多年之后,我们会发现,很多那些入行时觉得困难的点最后都不是问题了,
    这是因为,常见的问题我们基本都解决过一遍了,以致于我们再遇到同样的问题之后,就能马上想到应该怎么去做,就已经在心中有一二三四几个步骤了。
    举一个例子,在学习做 web 应用的时候,其实很多东西都不懂,但是现在已经很清楚一个 web 应用大概应该是长什么样子的了:



    • 从浏览器发起的请求到达 web 应用之后,我们需要确定具体执行什么逻辑,因此需要有 “路由” 来将请求拍发给一个具体的方法,也就是某个 Controller 里面的一个方法。

    • 在请求的处理逻辑里面,我们可能需要去查询数据库,所有常用的 web 框架都提供了关于数据库查询的一些抽象,直接调用封装的那些方法即可。

    • 在返回的时候,我们要返回给客户端的实质上是纯文本的东西(HTTP 协议),但是 HTTP 相关的功能往往由 HTTP 服务器来处理的,比如 nginx

    • nginx 处理 HTTP 相关的东西,比如反向代理的 upstream 返回的数据长度有多长,需要算出来,将这个长度写入到 HTTP 头中,这样浏览器收到 HTTP 报文的时候才能准确地解析出一个 HTTP 报文包


    弄清楚这些问题之后,不管换哪一种语言,我们都可以拿来实现一个 web 应用,无非就是解析 HTTP 报文,在代码里面做一些业务逻辑处理,然后返回一串 HTTP 报文。
    而这里提到的,其实就是针对 web 应用开发中的几个大问题的分解,这些问题对于写了几年 web 开发的人来说其实都不是问题了。


    再举一个例子,对于程序员来说,我们往往需要持续地学习,当我们去学习一些新的编程语言的时候,我们可以去思考一下:对于编程语言来说,它可以分解为哪些问题?
    个人感觉,这个问题其实挺有价值。要回答这个问题,我们可以回到没有今天这些高级编程语言的时候,那些计算机领域的先驱们是怎么让计算机工作起来的。
    我们会发现,其实一开始他们是用的 0 和 1 来去写指令的,后面进化到汇编语言,毕竟一堆 0 和 1 谁记得住?
    有了汇编,去做一些操作就简单多了,比如做加法,用一个 ADD 指令就可以了。
    但是有了汇编之后,还有一个问题是,不管是从开发、维护上来说,都需要对 CPU 有非常清楚的了解,比如需要了解 CPU 有哪些寄存器之类的知识,
    也就是说,使用汇编依然需要了解机器本身的很多运作机制,这无疑是一个比较高的门槛。
    再后来到 C 语言的出现,我们就不需要了解 CPU 是怎么工作也可以写出能解决问题的代码了。
    但是 C 语言依然有一个毕竟严重的问题,那就是需要开发者手动去申请内存,使用之后再释放内存,如果程序员忘记释放,那么就会造成内存的泄露。
    所以更高级的一些语言就有了 GC,也就是说,由语言底层的运行时去帮程序员回收那些已经不再使用的对象。


    扯得有点远了,回到问题本身,对于编程语言来说,它可以分解为哪些问题?
    这个问题其实非常简单,只要我们随便找一门编程语言的教程来看它们的目录就会知道,一门编程语言本身包含了:



    • 一些基础语法:如代码组织结构是怎样的。Java 是通过一个个的类来组织的,Go 是通过结构体来建立抽象然后通过函数来进行组织的。

    • 对于面向对象的语言来说:不同的编程语言会有不同的类的编写方式。

    • 基本的变量定义是如何定义的

    • 关键字有哪些,比如非常常见的 classpublicdef 之类的

    • 如何实现循环结构

    • 如何实现条件判断

    • 如何在方法中返回值。有些语言需要使用 return,也有些语言比较省事,方法内的最后一行会被当做返回值,比如 ruby

    • 一些常用的数据结构是如何封装的。比如数组、map

    • 标准库。比如如何执行一个系统命令这种功能。

    • 其他...


    这个清单不太完整,但是也足够了,因为编程语言的相似性,我们在熟悉了某一门编程语言之后,往往也会比较容易学会另一门编程语言。
    但是这也并不代表,我们可以使用这门新的编程语言去解决一些实际的问题,除非,在此之前,我们已经使用了其他编程语言解决过相同的问题了。
    比如,我们从 PHP 转换到 Go,我们在 PHP 中已经解决过很多数据库查询的问题了,切换到 Go 中,对于数据库查询的这一问题,我们可以作如下分解:



    • 找到 Go 中查询数据库相关的库

    • 调用库函数来建立到数据库的连接

    • 调用库函数来发送一个 SQL 查询语句到数据库服务器,然后等待数据库服务器返回查询结果

    • 取得查询结果,做其他处理


    清楚了我们需要解决的问题之后,其实我们真正要解决的重要问题是如何组织我们的代码,从而使得我们针对这个问题的解决方案更好维护、能更好地使用。
    所以现在在学习的时候,更喜欢从实际的问题出发(毕竟计算机领域其实是更偏向于实践)。
    然后根据自己拆分后的问题去找解决方案,事实证明,这样效率更高。
    如果我们从技术本身出发,我们可能无法知悉这个技术为什么是今天这个样子的,在这种学习方式之下,
    我们新学习的东西无法跟我们脑子里原有的东西建立起连接,最终只会很快就忘记。
    但是如果从我们熟悉的问题出发,去寻找一种新的解决方案的时候,其实新的知识跟自己的经验是可以很好的联系起来的,这样我们通过一个问题就能联系到不同的解决方案。



    真的扯远了,说回正题。说这么多其实就是想说,碰到难题的时候我们也不能盲目地花笨功夫,
    遇到难题的时候,我们也许可以考虑一下,这个问题可以如何分解,然后如何解决分解之后的问题。
    如果分解后的问题是我们可以解决的,那我们的 “笨功夫” 那就是使用对了。



    学习是为了找到学习方法


    再说一个关于 “笨功夫” 的个人经历,还是初中的时候,在初中的时候花了很多时间在学习上,但是学习效果并不是非常明显,
    多年以后,才明白,自己当初的那种学习其实是 “死学”,也就是不讲究方法的学习,免不了学了就忘。
    初中的时候一个物理老师跟我们说他学生时代,有一天在思考问题很久之后突然 “开窍” 了,
    以前没懂,现在知道了他说的 “开窍” 大概是找到了关于学习的套路。
    可惜的是,我在读书的那十几年里,并没有经历过这样的 “开窍”,所以成绩一直平平无奇。


    直到自己工作以后,因为自己从小到大是那种不太擅长交流的人,所以工作前几年遇到问题的时候也基本不会去请教别人,
    那怎么办呢?那就自己想办法去解决各种技术问题呗,然后几年下来,好像自己的学习能力有所提升了,明显的表现是,学习新东西的时候会学习得更快了。
    后面才懂,越来保持学习其实不只是为了学到各种解决问题的方法,实际上有很多东西都是学了之后用不上的,更重要的是在这个过程中学会如何学习。
    关于这一点,陈皓有过一个经典的陈述。


    学习不仅仅是为了找到答案,更是为了找到方法 - 陈皓


    你有没有发现,在知识的领域也有阶层之分,那些长期在底层知识阶层的人,需要等着高层的人来喂养,
    他们长期陷于各种谣言和不准确的信息环境中,于是就导致错误或幼稚的认知,
    并习惯于那些不费劲儿的轻度学习方式,从而一点点地丧失了深度学习的独立思考能力,从而再也没有能力打破知识阶层的限制,被困在认知底层翻不了身。


    可见深度学习十分重要,但应该怎样进行深度学习呢?下面有三个步骤:



    1. 知识采集。 信息源是非常重要的,获取信息源头、破解表面信息的内在本质、多方数据印证,是这个步骤的关键。

    2. 知识缝合。 所谓缝合就是把信息组织起来,成为结构体的知识。这里,连接记忆,逻辑推理,知识梳理是很重要的三部分。

    3. 技能转换。 通过举一反三、实践和练习,以及传授教导,把知识转化成自己的技能。这种技能可以让你进入更高的阶层。


    这就好像,你要去登一座山,一种方法是通过别人修好的路爬上去,一种是通过自己的技能找到路(或是自己修一条路)爬上去。
    也就是说,需要有路才爬得上山的人,和没有路能造路的人相比,后者的能力就会比前者大得多得多。
    所以,学习是为了找到通往答案的路径和方法,是为了拥有无师自通的能力。


    把时间当作朋友



    这个标题来源于李笑来的《把时间当作朋友》这本书,书买了我还没看,但是看过他在得到的课程上这一话题的相关文章。



    今天这个社会变得越来越浮躁,我们难免会受到影响,经常会想着今天做一件事,明天就能看到成果。
    但实际上,在竞争激烈的今天,聪明人很多,又聪明又努力的也有很多,我们能做的只是接受这个事实,
    然后持续在自己所在的领域花多一点 “笨功夫”,把时间当作朋友,就算最终我们没有实现最初的目标,
    但是回头再看的时候,会发现原来自己已经走得很远了。


    最后,用吴军《格局》中的一句话来结束本文:



    事实上,功夫没下够,用什么方法都是在浪费时间。



    作者:eleven26
    来源:juejin.cn/post/7273435446574891062
    收起阅读 »

    位运算,能不能一次记住!

    web
    写给新手朋友的位运算,你是不是也是总是不记得,记了又忘记,记住了又不知道怎么运用到编程中?那记一次理论+题目的方式理解记忆,搞清楚它吧! 我们接触最多的是十进制数,加减乘除这种四则远算其实就是二进制数据中的一种运算法则,当谈到位运算,它不是用来操作十进制数的,...
    继续阅读 »

    写给新手朋友的位运算,你是不是也是总是不记得,记了又忘记,记住了又不知道怎么运用到编程中?那记一次理论+题目的方式理解记忆,搞清楚它吧!


    我们接触最多的是十进制数,加减乘除这种四则远算其实就是二进制数据中的一种运算法则,当谈到位运算,它不是用来操作十进制数的,我们实际上是在操作二进制数的不同位。位运算在前端开发中可能不常用,但了解它们对你理解计算机底层运作和一些特定情况下的优化是有帮助的。


    接下来我们从几种常见的位运算开始,以及它们的使用场景,好好理解一番。


    1. 二进制转换


    既然是写给新手朋友也能看得明白的,那就顺带提一下二进制数吧(熟悉二进制的可以跳过这段)



    当计算机处理数据时,它实际上是在执行一系列的二进制操作,因为计算机内部使用的是电子开关,这些开关只能表示两个状态:开(表示1)和关(表示0)。因此,计算机中的所有数据最终都被转换为二进制表示。


    二进制(binary)是一种使用两个不同符号(通常是 0 和 1)来表示数字、字符、图像等信息的数字系统。这种二元系统是现代计算机科学的基础。





    • 十进制到二进制的转换:




    将十进制数转换为二进制数的过程涉及到不断地除以2,然后记录余数。最后,将这些余数按相反的顺序排列,就得到了对应的二进制数。


    例如,将十进制数 13 转换为二进制数:



    1. 13 除以 2 得商 6,余数 1

    2. 6 除以 2 得商 3,余数 0

    3. 3 除以 2 得商 1,余数 1

    4. 1 除以 2 得商 0,余数 1


    将这些余数按相反的顺序排列,得到二进制数 1101。


    或者你也可以这么想


    1. (1 || 0) * 2^n + (1 || 0) * 2^(n-1) + ... + (1 || 0) * 2^0 = 13

    2. 只需要满足以上公式,加出来你想要的值

    3. 2 的 4次方大于13,2的3次方小于13,那么就从2的3次方开始依次递减到0次方

    4. 1 * 2^3 + 1 * 2^2 + 1 * 2^1 + 1 * 2^0 显然 8 + 4 + 2 + 1 = 15已经超出了13,所以你得在这个式子中减少2

    5. 1 * 2^3 + 1 * 2^2 + 0 * 2^1 + 1 * 2^0 取该等式中的1,0;所以 13 的二进制是 1101


    以上两种方式都能得出一个数的二进制,看你喜欢




    • 二进制到十进制的转换:




    将二进制数转换为十进制数的过程涉及到将每个位上的数字与2的幂相乘,然后将这些结果相加。


    例如,将二进制数 1101 转换为十进制数:



    1. 第0位(最右边)上的数字是 1,表示 2^0 = 1

    2. 第1位上的数字是 0,表示 2^1 = 0

    3. 第2位上的数字是 1,表示 2^2 = 4

    4. 第3位上的数字是 1,表示 2^3 = 8


    将这些结果相加:1 + 0 + 4 + 8 = 13,得到十进制数 13。


    在编程中,通常会使用不同的函数或方法来实现十进制到二进制以及二进制到十进制的转换,这些转换可以帮助我们在计算机中处理和表示不同的数据。


    2. 按位与(&)


    按位与运算会将两个数字的二进制表示的每一位进行 操作,如果两个相应位都是 1,则结果为 1,否则为 0。


    使用场景: 常用于权限控制和掩码操作。


    image.png


    一道题让你更好的理解它的用法


    题目:判断一个整数是否是2的幂次方。


    问题描述:给定一个整数 n,判断它是否是2的幂次方,即是否满足 n = 2^k,其中 k 是非负整数。


    使用位运算中的按位与操作可以很巧妙地解决这个问题。


    思路:如果一个数 n 是2的幂次方,那么它的二进制表示一定只有一位是1,其他位都是0(例如:8的二进制是 1000)。而 n - 1 的二进制表示则是除了最高位的1之外,其他位都是1(例如:7的二进制是 0111)。如果我们对 nn - 1 进行按位与操作,结果应该是0。


    那我们可以这么写:


    image.png


    在这个示例中,我们巧妙的使用了 (n & (n - 1)) 来检查是否满足条件,如果结果为0,说明 n 是2的幂次方。


    希望这个示例能够帮助你更好地理解按位与运算的应用方式!


    2. 按位或(|)


    按位或运算会将两个数字的二进制表示的每一位进行或操作,如果两个相应位至少有一个是 1,则结果为 1,否则为 0。


    使用场景: 常用于设置选项和权限。


    image.png


    一道题让你更好的理解它的用法


    题目:如何将一个整数的特定位设置为1,而不影响其余位。


    问题描述:给定一个整数 num,以及一个表示要设置为1的位的位置 bitPosition(从右向左,最低位的位置为0),编写一个函数将 num 的第 bitPosition 位设置为1。


    我们可以使用按位或运算来实现这个效果


    image.png


    在这个示例中,我们首先创建了一个掩码 mask(这里用到了另一个位运算,左移,下面会讲到),它只有第 bitPosition 位是1,其他位都是0。然后,我们使用按位或运算 num | masknum 的第 bitPosition 位设置为1,得到了结果。


    这个问题演示了如何使用按位或运算来修改一个整数的特定位,而不影响其他位。希望这个示例能帮助你更好地理解按位或运算的应用方式!


    3. 按位异或(^)


    按位异或运算会将两个数字的二进制表示的每一位进行异或操作,如果两个相应位不相同则结果为 1,相同则为 0。


    使用场景: 常用于数据加密和校验。


    image.png


    一道题让你更好的理解它的用法


    题目:如何交换两个整数的值,而不使用额外的变量


    问题描述:给定两个整数 ab,编写一个函数来交换它们的值,而不使用额外的变量。


    我们可以使用按位异或运算来实现这个效果:


    image.png


    上述代码中,我们首先将 a 更新为 a ^ b,这使得 a 包含了 ab 的异或值。然后,我们使用同样的方法将 b 更新为 a 的原始值,最后,我们再次使用异或运算将 a 更新为 b 的原始值,完成了交换操作。



    此处应该沉思,思考清楚这个问题:(a ^ b) ^ b 得到的是 a 的原始值



    不使用额外的变量来做两个变量值的交换,这还是个面试题哦!


    4. 按位非(~)


    按位非运算会将一个数字的二进制表示的每一位取反,即 0 变成 1,1 变成 0。它将操作数转化为 32 位的有符号整型。


    image.png


    一道题让你更好的理解它的用法


    题目:反转二进制数的位,然后返回其对应的十进制数


    问题描述:给定一个二进制字符串,编写一个函数来反转该字符串的位,并返回其对应的十进制数。


    image.png


    这里你可能会有疑问,为什么13的二进制取反会的到-14,这里就不得不介绍一下 补码 的概念了


    5. 补码小插曲


    假设我们要求 -6 的二进制,那就相当于是求 -6 的补码


    因为负数的二进制表示通常使用二进制补码来表示。要计算-6的二进制补码表示,可以按照以下步骤操作:



    1. 首先,找到6的二进制表示。6的二进制表示是 00000110

    2. 然后,对6的二进制表示进行按位取反操作,即将0变成1,将1变成0。这将得到 11111001

    3. 最后,将取反后的结果加1。11111001 + 1 = 11111010


    所以,-6的二进制补码表示是 11111010。在补码中,最高位表示符号位,0表示正数,1表示负数,其余位表示数值的绝对值。因此,11111010 表示的是-6。


    注意:

    -6的二进制补码表示的位数不一定是8位。位数取决于数据类型和计算机系统的规定。在许多计算机系统中,整数的表示采用固定的位数,通常是32位或64位,但也可以是其他位数,例如16位。


    在常见的32位表示中,-6的二进制补码表示可能是 11111111111111111111111111111010。这是32位二进制,其中最高位是符号位(1表示负数),其余31位表示数值的绝对值。


    在64位表示中,-6的二进制补码表示可能是 1111111111111111111111111111111111111111111111111111111111110。这是64位二进制,同样,最高位是符号位,其余63位表示数值的绝对值。


    因此,-6的二进制补码表示的位数取决于计算机系统和数据类型的规定。不同的系统和数据类型可能采用不同的位数。


    6. 左移(<<)和右移(>>)


    左移运算将一个数字的二进制表示向左移动指定的位数,右移运算将二进制表示向右移动指定的位数。


    image.png



    注意:因为我们的计算可以是32位或者是64位的,所以理论上 5 的二进制应该是 00... 00000101, 整体长度为32或者64。 左移我们只是把有效值 101 向左拖动,右边补0,右移左边补 0, 但是要保证整体32或64位长度不能变,所以,右移会砍掉超出去的值



    一道题让你更好的理解它的用法


    题目: 如何实现整数的乘法和除法,使用左移和右移操作来提高效率。


    问题描述:编写一个函数,实现整数的乘法和除法运算,但是只能使用左移和右移操作,不能使用乘法运算符 * 和除法运算符 /


    这也是一道面试题,实现起来很简单


    image.png



    想清楚,一个数的二进制,每次左移一位的结果会怎么样?


    比如 6 的二进制是 00000110, 左移一次后变成 00001100,


    也就是说 从 2^2 + 2^1 变成了 2^3+ 2^2 。 4 + 2 变成了 8 + 4。


    所以每左移一位,都相当于是原数值本身放大了一倍



    这样你是否更清楚了用左移来实现乘法的效果了呢?


    最后


    以上列举的是常见的位运算方法,还有一些不常见的,比如:



    1. 位清零(Bit Clearing):将特定位设置为0,通常使用按位与运算和适当的掩码来实现。

    2. 位设置(Bit Setting):将特定位设置为1,通常使用按位或运算和适当的掩码来实现。

    3. 位翻转(Bit Flipping):将特定位取反,通常使用按位异或运算和适当的掩码来实现。

    4. 检查特定位:通过使用按位与运算和适当的掩码来检查特定位是否为1或0。

    5. 位计数:计算一个整数二进制表示中1的个数,这通常使用一种称为Brian Kernighan算法的技巧来实现。

    6. 位交换:交换两个整数的特定位,通常使用按位异或运算来实现。


    等等...有兴趣的可以自行摸索了


    作者:一个大蜗牛
    来源:juejin.cn/post/7274188187675902004
    收起阅读 »

    浅谈多人游戏原理和简单实现

    一、我的游戏史 我最开始接触游戏要从一盘300游戏的光碟说起,那是家里买DVD送的,《魂斗罗》、《超级马里奥》天天玩。自从买回来后,我就经常和姐姐因为抢电视机使用权而大打出手。有次她把遥控器藏到了沙发的夹层里,被我妈一屁股做成了两半,我和我姐喜提一顿暴打。那顿...
    继续阅读 »

    在这里插入图片描述


    一、我的游戏史


    我最开始接触游戏要从一盘300游戏的光碟说起,那是家里买DVD送的,《魂斗罗》、《超级马里奥》天天玩。自从买回来后,我就经常和姐姐因为抢电视机使用权而大打出手。有次她把遥控器藏到了沙发的夹层里,被我妈一屁股做成了两半,我和我姐喜提一顿暴打。那顿是我挨得最狠的,以至于现在回想起来,屁股还条件反射的隐隐作痛。


    后来我骗我妈说我要学习英语、练习打字以后成为祖国的栋梁之才!让她给我买台小霸王学习机(游戏机),在我一哭二闹三上吊胡搅蛮缠的攻势下,我妈妥协了。就此我接触到了FC游戏。现在还能记得我和朋友玩激龟快打,满屋子的小朋友在看的场景。经常有家长在我家门口喊他家小孩吃饭。那时候我们县城里面有商店卖游戏卡,小卡一张5块钱,一张传奇卡25-40块不等(所谓传奇卡就是角色扮演,带有存档的游戏),每天放学都要去商店去看看,有没有新的游戏卡,买不起,就看下封面过过瘾。我记得我省吃俭用一个多月,买了两张卡:《哪吒传奇》和《重装机兵》那是真的上瘾,没日没夜的玩。


    再然后我接触到了手机游戏,记得那时候有个软件叫做冒泡游戏(我心目的中的Stream),里面好多游戏,太吸引我了。一个游戏一般都是几百KB,最大也就是几MB,不过那时候流量很贵,1块钱1MB,并且!一个月只有30Mb。我姑父是收手机的,我在他那里搞到了一部半智能手机,牌子我现在还记得:诺基亚N70,那时候我打开游戏就会显示一杯冒着热气的咖啡,我很喜欢这个图标,因为看见它意味着我的游戏快加载完成了,没想到,十几年后我们会再次相遇,哈哈哈哈。我当时玩了一款网游叫做:《幻想三国》,第一回接触网游简直惊呆了,里面好多人都是其他玩的家,这太有趣了。并且我能在我的手机上看到其他玩家,能够看到他们的行为动作,这太神奇了!!!我也一直思考这到底是怎么实现的!


    最后是电脑游戏,单机:《侠盗飞车》、《植物大战僵尸》、《虐杀原型》;网游:《DNF》、《CF》、《LOL》、《梦幻西游》我都玩过。


    不过那个疑问一直没有解决,也一值留在我心中 —— 在网络游戏中,是如何实时更新其他玩家行为的呢?


    二、解惑


    在我进入大学后,我选择了软件开发专业,真巧!再次遇到了那个冒着热气的咖啡图标,这时我才知道它叫做——Java。我很认真的去学,希望有一天能够做一款游戏!


    参加工作后,我并没有如愿以偿,我成为了一名Java开发程序员,但是我在日常的开发的都是web应用,接触到大多是HTTP请求,它是种请求-响应协议模式。这个问题也还是想不明白,难道每当其他玩家做一个动作都需要发送一次HTTP请求?然后响应给其他玩家。这样未免效率也太低了吧,如果一个服务器中有几千几万人,那么服务器得承受多大压力呀!一定不是这样的!!!


    直到我遇到了Websocket,这是一种长连接,而HTTP是一种短连接,顿时这个问题我就想明白了。在此二者的区别我就不过多赘述了。详细请看我的另一篇文章


    知道了这个知识后,我终于能够大致明白了网络游戏的基本原理。原来网络游戏是由客户端服务器端组成的,客户端就是我们下载到电脑或者手机上的应用,而服务器端就是把其他玩家连接起来的中转站,还有一点需要说明的是,网络游戏是分房间的,这个房间就相当于一台服务器。首先,在玩家登陆客户端并选择房间建立长连接后,A玩家做出移动的动作,随即会把这个动作指令上传给服务器,然后服务器再将指令广播到房间中的其他玩家的客户端来操作A的角色,这样就可以实现实时更新其他玩家行为。


    在这里插入图片描述


    三、简单实现


    客户端服务端在处理指令时,方法必须是配套的。比如说,有新的玩家连接到服务器,那么服务器就应当向其它客户端广播创建一个新角色的指令,客户端在接收到该指令后,执行客户端创建角色的方法。
    为了方便演示,这里需要定义两个HTML来表示两个不同的客户端不同的玩家,这两套客户端代码除了玩家的信息不一样,其它完全一致!!!


    3.1 客户端实现步骤


    我在这里客户端使用HTML+JQ实现


    客户端——1代码:


    (1)创建画布


    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Canvas Game</title>
    <style>
    canvas {
    border: 1px solid black;
    }
    </style>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    </head>
    <body>
    <canvas id="gameCanvas" width="800" height="800"></canvas>
    </body>
    </html>

    (2)设置1s60帧更新页面


    const canvas = document.getElementById('gameCanvas');
    const ctx = canvas.getContext('2d');
    function clearCanvas() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    }
    function gameLoop() {
    clearCanvas();
    players.forEach(player => {
    player.draw();
    });
    }
    setInterval(gameLoop, 1000 / 60);
    //清除画布方法
    function clearCanvas() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    }

    (3)连接游戏服务器并处理指令


    这里使用websocket链接游戏服务器


     //连接服务器
    const websocket = new WebSocket("ws://192.168.31.136:7070/websocket?userId=" + userId + "&userName=" + userName);
    //向服务器发送消息
    function sendMessage(userId,keyCode){
    const messageData = {
    playerId: userId,
    keyCode: keyCode
    };
    websocket.send(JSON.stringify(messageData));
    }
    //接收服务器消息,并根据不同的指令,做出不同的动作
    websocket.onmessage = event => {
    const data = JSON.parse(event.data);
    // 处理服务器发送过来的消息
    console.log('Received message:', data);
    //创建游戏对象
    if(data.type == 1){
    console.log("玩家信息:" + data.players.length)
    for (let i = 0; i < data.players.length; i++) {
    console.log("玩家id:"+playerOfIds);
    createPlayer(data.players[i].playerId,data.players[i].pointX, data.players[i].pointY, data.players[i].color);
    }
    }
    //销毁游戏对象
    if(data.type == 2){
    console.log("玩家信息:" + data.players.length)
    for (let i = 0; i < data.players.length; i++) {
    destroyPlayer(data.players[i].playerId)
    }
    }
    //移动游戏对象
    if(data.type == 3){
    console.log("移动;玩家信息:" + data.players.length)
    for (let i = 0; i < data.players.length; i++) {
    players.filter(player => player.id === data.players[i].playerId)[0].move(data.players[i].keyCode)
    }
    }
    };

    (4)创建玩家对象


    //存放游戏对象
    let players = [];
    //playerId在此写死,正常情况下应该是用户登录获取的
    const userId = "1"; // 用户的 id
    const userName = "逆风笑"; // 用户的名称
    //玩家对象
    class Player {
    constructor(id,x, y, color) {
    this.id = id;
    this.x = x;
    this.y = y;
    this.size = 30;
    this.color = color;
    }
    //绘制游戏角色方法
    draw() {
    ctx.fillStyle = this.color;
    ctx.fillRect(this.x, this.y, this.size, this.size);
    }
    //游戏角色移动方法
    move(keyCode) {
    switch (keyCode) {
    case 37: // Left
    this.x = Math.max(0, this.x - 10);
    break;
    case 38: // Up
    this.y = Math.max(0, this.y - 10);
    break;
    case 39: // Right
    this.x = Math.min(canvas.width - this.size, this.x + 10);
    break;
    case 40: // Down
    this.y = Math.min(canvas.height - this.size, this.y + 10);
    break;
    }
    this.draw();
    }
    }

    (5)客户端创建角色方法


    //创建游戏对象方法
    function createPlayer(id,x, y, color) {
    const player = new Player(id,x, y, color);
    players.push(player);
    playerOfIds.push(id);
    return player;
    }

    (6)客户端销毁角色方法


    在玩家推出客户端后,其它玩家的客户端应当销毁对应的角色。


    //角色销毁
    function destroyPlayer(playId){
    players = players.filter(player => player.id !== playId);
    }

    客户端——2代码:


    客户端2的代码只有玩家信息不一致:


      const userId = "2"; // 用户的 id
    const userName = "逆风哭"; // 用户的名称

    3.2 服务器端


    服务器端使用Java+websocket来实现!


    (1)引入依赖:


     <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <version>2.1.2.RELEASE</version>
    </dependency>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
    <version>2.3.7.RELEASE</version>
    </dependency>
    <dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.11</version>
    </dependency>
    <dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.75</version>
    </dependency>
    <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.16.16</version>
    </dependency>
    <dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.6.3</version>
    </dependency>

    (2)创建服务器


    @Component
    @ServerEndpoint("/websocket")
    @Slf4j
    public class Server {
    /**
    * 服务器玩家池
    * 解释:这里使用 ConcurrentHashMap为了保证线程安全,不会出现同一个玩家存在多条记录问题
    * 使用 static fina修饰 是为了保证 playerPool 全局唯一
    */

    private static final ConcurrentHashMap<String, Server> playerPool = new ConcurrentHashMap<>();
    /**
    * 存储玩家信息
    */

    private static final ConcurrentHashMap<String, Player> playerInfo = new ConcurrentHashMap<>();
    /**
    * 已经被创建了的玩家id
    */

    private static ConcurrentHashMap<String, Server> createdPlayer = new ConcurrentHashMap<>();

    private Session session;

    private Player player;

    /**
    * 连接成功后调用的方法
    */

    @OnOpen
    public void webSocketOpen(Session session) throws IOException {
    Map<String, List<String>> requestParameterMap = session.getRequestParameterMap();
    String userId = requestParameterMap.get("userId").get(0);
    String userName = requestParameterMap.get("userName").get(0);
    this.session = session;
    if (!playerPool.containsKey(userId)) {
    int locationX = getLocation(151);
    int locationY = getLocation(151);
    String color = PlayerColorEnum.getValueByCode(getLocation(1) + 1);
    Player newPlayer = new Player(userId, userName, locationX, locationY,color,null);
    playerPool.put(userId, this);
    this.player = newPlayer;
    //存放玩家信息
    playerInfo.put(userId,newPlayer);
    }
    log.info("玩家:{}|{}连接了服务器", userId, userName);
    // 创建游戏对象
    this.createPlayer(userId);
    }

    /**
    * 接收到消息调用的方法
    */

    @OnMessage
    public void onMessage(String message, Session session) throws IOException, InterruptedException {
    log.info("用户:{},消息{}:",this.player.getPlayerId(),message);
    PlayerDTO playerDTO = new PlayerDTO();
    Player player = JSONObject.parseObject(message, Player.class);
    List<Player> players = new ArrayList<>();
    players.add(player);
    playerDTO.setPlayers(players);
    playerDTO.setType(OperationType.MOVE_OBJECT.getCode());
    String returnMessage = JSONObject.toJSONString(playerDTO);
    //广播所有玩家
    for (String key : playerPool.keySet()) {
    synchronized (session){
    String playerId = playerPool.get(key).player.getPlayerId();
    if(!playerId.equals(this.player.getPlayerId())){
    playerPool.get(key).session.getBasicRemote().sendText(returnMessage);
    }
    }
    }
    }

    /**
    * 关闭连接调用方法
    */

    @OnClose
    public void onClose() throws IOException {
    String playerId = this.player.getPlayerId();
    log.info("玩家{}退出!", playerId);
    Player playerBaseInfo = playerInfo.get(playerId);
    //移除玩家
    for (String key : playerPool.keySet()) {
    playerPool.remove(playerId);
    playerInfo.remove(playerId);
    createdPlayer.remove(playerId);
    }
    //通知客户端销毁对象
    destroyPlayer(playerBaseInfo);
    }

    /**
    * 出现错误时调用的方法
    */

    @OnError
    public void onError(Throwable error) {
    log.info("服务器错误,玩家id:{},原因:{}",this.player.getPlayerId(),error.getMessage());
    }
    /**
    * 获取随即位置
    * @param seed
    * @return
    */

    private int getLocation(Integer seed){
    Random random = new Random();
    return random.nextInt(seed);
    }
    }

    websocket配置:


    @Configuration
    public class ServerConfig {
    @Bean
    public ServerEndpointExporter serverEndpointExporter(){
    return new ServerEndpointExporter();
    }
    }


    (3)创建玩家对象


    玩家对象:


    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class Player {
    /**
    * 玩家id
    */

    private String playerId;
    /**
    * 玩家名称
    */

    private String playerName;
    /**
    * 玩家生成的x坐标
    */

    private Integer pointX;
    /**
    * 玩家生成的y坐标
    */

    private Integer pointY;
    /**
    * 玩家生成颜色
    */

    private String color;
    /**
    * 玩家动作指令
    */

    private Integer keyCode;
    }

    创建玩家对象返回给客户端DTO:


    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class PlayerDTO {
    private Integer type;
    private List<Player> players;
    }

    玩家移动指令返回给客户端DTO:


    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class PlayerMoveDTO {
    private Integer type;
    private List<Player> players;
    }


    (4)动作指令


    public enum OperationType {
    CREATE_OBJECT(1,"创建游戏对象"),
    DESTROY_OBJECT(2,"销毁游戏对象"),
    MOVE_OBJECT(3,"移动游戏对象"),
    ;
    private Integer code;
    private String value;

    OperationType(Integer code, String value) {
    this.code = code;
    this.value = value;
    }

    public Integer getCode() {
    return code;
    }

    public String getValue() {
    return value;
    }
    }

    (5)创建对象方法


      /**
    * 创建对象方法
    * @param playerId
    * @throws IOException
    */

    private void createPlayer(String playerId) throws IOException {
    if (!createdPlayer.containsKey(playerId)) {
    List<Player> players = new ArrayList<>();
    for (String key : playerInfo.keySet()) {
    Player playerBaseInfo = playerInfo.get(key);
    players.add(playerBaseInfo);
    }
    PlayerDTO playerDTO = new PlayerDTO();
    playerDTO.setType(OperationType.CREATE_OBJECT.getCode());
    playerDTO.setPlayers(players);
    String syncInfo = JSONObject.toJSONString(playerDTO);
    for (String key :
    playerPool.keySet()) {
    playerPool.get(key).session.getBasicRemote().sendText(syncInfo);
    }
    // 存放
    createdPlayer.put(playerId, this);
    }
    }

    (6)销毁对象方法


       /**
    * 销毁对象方法
    * @param playerBaseInfo
    * @throws IOException
    */

    private void destroyPlayer(Player playerBaseInfo) throws IOException {
    PlayerDTO playerDTO = new PlayerDTO();
    playerDTO.setType(OperationType.DESTROY_OBJECT.getCode());
    List<Player> players = new ArrayList<>();
    players.add(playerBaseInfo);
    playerDTO.setPlayers(players);
    String syncInfo = JSONObject.toJSONString(playerDTO);
    for (String key :
    playerPool.keySet()) {
    playerPool.get(key).session.getBasicRemote().sendText(syncInfo);
    }
    }

    四、演示


    4.1 客户端1登陆服务器


    在这里插入图片描述


    4.2 客户端2登陆服务器


    在这里插入图片描述


    4.3 客户端2移动


    在这里插入图片描述


    4.4 客户端1移动


    在这里插入图片描述


    4.5 客户端1退出


    在这里插入图片描述
    完结撒花


    完整代码传送门


    五、总结


    以上就是我对网络游戏如何实现玩家实时同步的理解与实现,我实现后心里也释然了,哈哈哈,真的好有趣!!!
    我希望大家也是,不要失去好奇心,遇到自己感兴趣的事情,一定要多思考呀~


    后来随着我经验的不断积累,我又去了解了一下Java作为游戏服务器的相关内容,发现Netty更适合做这个并且更容易入门,比如《我的世界》一些现有的服务器就是使用Netty实现的。有空也实现下,玩玩~


    作者:是江迪呀
    来源:juejin.cn/post/7273429629398581282
    收起阅读 »

    别让时代的悲哀,成为你的悲哀

    全球经济下行,各大公司裁员,我们身处其中,又该如何自洽?本文分享我的一些观点,希望也能给你带来一些新的思考。 前言 最近这段时间,可谓一直都很不太平。 一开始有人说“前端已死”的时候,我身处其中,冷暖自知。 这是我今年找工作,在Boss直聘上花 68 元巨...
    继续阅读 »

    全球经济下行,各大公司裁员,我们身处其中,又该如何自洽?本文分享我的一些观点,希望也能给你带来一些新的思考。


    前言


    最近这段时间,可谓一直都很不太平。


    一开始有人说“前端已死”的时候,我身处其中,冷暖自知。




    这是我今年找工作,在Boss直聘上花 68 元巨款开的会员,主要功能就是每天有 5 次机会告诉你在某岗位的竞争力,纯纯花钱买焦虑。上面两张图只是普通小公司的一个前端岗位,竟有上千人竞争。



    后来也有人说“前端死不了”,我没有发表过什么看法,因为我觉得不值得讨论。



    前端发展迅速,同时也充满困境,只有站在一线的开发,才能明白前端本就半死不活。像前两年还有《现代 Web 开发困局》这样的文章在分析前端困局、讨论如何解放生产力,而现在这种声音大部分人可能并不关心。



    再到现今一众技术公号不是《前端岗位又爆了》就是《前端这波起飞》的,我也只是微微一笑,软广吹牛从不打草稿。


    这篇文章不聊技术,不蹭热点,只是单纯地从一个普通互联网从业者的角度,讲点近段时间以来的一些思考。


    危机


    历史的车轮滚滚而来,它与每个人息息相关。


    随着口罩病三年的折磨、俄乌冲突爆发、漂亮国霸权制裁,国家整体经济呈现明显下滑,我们能切身的感觉到,大环境确实变差了许多。


    然而放眼全球,大部分国家也都在经历严重的经济衰退,有的甚至已经破产或者走在了破产的路上。我们现在所处的是一个什么样的阶段呢?中高端产业永远在努力突破欧美的封锁,而低端制造业还要面对印度和东南亚等国家的竞争,作为世界经济的重要一环,中国不可能不受影响。


    从元宇宙区块链,再到如今火热的 AI 人工智能,我们太想要新技术的突破了,然而这并不是简单的事情,大部分普通人能做的,其实就是等待和做好随时迎接新的改变的准备,要么就只能贩卖焦虑了。



    有时危机的发生并不一定是要伤害你,也可能是让你从迷局中醒过来,或者把你以前故意忽略、拖延、认识不到位的问题集中爆发出来,逼你去解决问题。



    努力


    不知从什么时候开始,我们总是崇尚努力奋斗,然后理所当然地认为努力就是一切成功的根源,如果没能成功,就是你还不够努力。


    找不到理想的工作,便认为是自己还不够努力,是简历写得不好,是面试题背的还不够多......试想要是市场的岗位供远大于求,谁还卡学历,谁还谈资历呢,HR们不跪着求你来面试吗?面试题咱也先别做了罢,进来干活再说。


    可事实是,大部分公司都在降本增效,同时还存在着许多比你更聪明优秀、天赋异禀的人,关键是他们还都比你更努力。


    所以在我看来,努力更多是为了拥有选择的权利,除此之外并不代表什么。
    如果你觉得光靠努力就可以无所不能,那何尝不是一种傲慢。




    出自动漫 ——《强风吹拂》。



    既然很多事就算努力了也不一定有回报,那么是不是就干脆摆烂了,不努力了呢?


    悲观者往往正确,很多人想摆烂的根本在于,这个世界上有太多东西是不确定的,这无可厚非,但有时过于悲观,往往就容易迷失自己。


    著名软件 Homebrew 的作者 Max Howell 去谷歌面试的时候,因写不出反转二叉树被拒,留下了“虽然谷歌公司 90% 的工程师都在用你写的软件,但抱歉我们不能聘用你”的这段传说。


    著名开源框架 Vue 的作者尤雨溪在直播中聊到,自己曾在某次面试时被问如何实现JS原型链的问题,结果他当时完全回答不上来。


    那些真正成功的人,一定不用非要在某件事情上证明自己。


    边界


    最近我在思考一种处世的能力,我把它叫做“边界力”。简单来说,就是遇到难以克服的障碍,就承认自己做不到。


    这听起来似乎很消极,但只有学会建立、掌控自己边界的人,才能够明确自己的责任与长处,从而找到更好的做事方向和解决问题方法,少走弯路。


    人们或多或少都会有一些自恋的,而且很多时候自己还浑然不知。


    比方说,同在一个写作训练营里,大部分人可以1-2天写出一篇稿子,但有一个人做不到,他就会想为什么别人可以,我不行?当他下意识地责怪自己达不到平均水平时,背后其实就是一种自恋。因为他默认别人能做到的,自己一定也能做到。可是,有人能保证自己的任何一项技能,都在集体的平均水平以上吗?


    自恋感会让我们下意识地认为,面前这个事可以做到。它会误导我们,让我们不断把注意力集中在“为什么我就是做不到”上面,然后一遍又一遍碰壁,而不是去想“这太难了,也许我该换个办法”。


    不过在承认自己做不到之前,要确认这件事是否真的超出了我们的能力范围,如何确认呢?我觉得有两种方法:



    1. 结果反馈


    统计学上有个概念,叫大数定律,历史上有不少数学家做过抛硬币实验,很简单:不停抛一枚硬币,记录出现正面和反面的结果,最后随着抛的次数越多,结果就越明朗:一定是有一半的概率是正面,一半的概率是反面。


    看似包含不确定性的事情,往往也有着某种统计的确定性。也就是说,偶然之中有必然


    在承认做不到之前,要先问下自己是不是尝试的样本还不够大。当你让想做的事情出现次数足够多时,你一定会知道它到底能不能成。



    1. 压力反馈


    看看你做一件事会不会导致极大的不适,比如开始失眠,身体出现莫名的疼痛,或是习惯性地拖延,又或是变得过度敏感、负能量爆棚。如果这些情况同时出现,那么你就得考虑承认自己确实做不到了。


    能把时间都花在对的事情上,你就已经是一个很厉害的人了。


    焦虑


    在愈发“内卷”的社会形态下,焦虑几乎是所有人无法逃避的负面情绪,直到我看到一位博主季白羽说的这样一段话:



    焦虑的人都有一个共同特征,那就是没有尊重世界的客观规律。


    比如说:没有持续天天锻炼,却期待拥有健康体魄;没有好好经营关系,却期待别人都喜欢自己;没有大量刻意练习,却期待写出好文章。



    我们总是容易把一切怪罪给外部因素,而忽略了焦虑的核心——还是对自我的认识不够清晰。


    如果你期待有影响力,就要做好在一个领域长期积累的准备;如果你期待赚到钱,就要经常去做与赚钱相关的事。但你不能什么都不做,就期待能拥有一切。


    提升“边界力”,想办法搞清楚什么是确定的,什么是不确定的,然后不断去重复那些确定的事,我想焦虑就会自动远离我们。


    心态


    当我翻开《腾讯传》一书的时候,歪歪斜斜每页都写着"中国互联网进化论"几个字,可我却从字缝里只读出了"幸运"。


    在我看来,腾讯的崛起是十分幸运的,早年的腾讯给别人做过软件外包,无数次想要卖掉公司但卖不出去,拉投资时连创始人马化腾都说不清未来的方向,可谓前途一片渺茫,很难想象它会成长到如今的体量。


    其实马化腾对互联网并不感兴趣,天文学才是他从小的志向所在。中学时为了能看见哈雷彗星,求着父母买台专业级望远镜,彼时的马化腾做梦都想成为天文学家,善良的父母最终答应了,那是他父亲四个月的工资。后来他谈及自己的爱好时说:



    看着星空,会觉得自己很渺小,可能我们在宇宙中从来就是一个偶然。所以,无论什么事情,仔细想一想,都没有什么大不了的。



    虽然马化腾后来也没真的成为天文学家,但这份爱好给了他独特的思考,始终帮助他在遇到挫折时稳定心态,想得更开。如果当时他一直为公司焦虑,也许就等不到后来属于腾讯的曙光了。


    为什么运气也是实力的一部分,因为在黎明到来前,你必须有足够强大的心态面对黑暗的桎梏,才有机会配得上后来的幸运。


    所以无论你当下正在经历多么煎熬痛苦的时刻,都请记得:



    现在的怕和愁,只不过是能力小和经历少;十年后,所有的这些事,都只是下酒菜。



    有生存就会有危机,有危机才会有机会。


    然而危机并不可怕,可怕的是我们没有预料它的到来


    没有预料危机的到来也不是那么可怕,可怕的是我们将危机想的太大,吓坏了自己,提前放弃了生存的机会。


    天道


    我们常说尽人事,听天命。罗翔老师说过一段话令我印象深刻:



    如果你相信天道酬勤的话,很容易导致人走向骄傲,或者走向虚无。因为当你成功的时候,你会觉得是靠你努力拼搏得来的,你配拥有这一切,所以你就会瞧不起那些失败的人。而当你努力了最后却依然一事无成,又会开始抱怨天道不公。



    由于个体太过于渺小,人生中大部分的事情其实都是你决定不了的,与其对抗,甚至会催生出人性潜藏的弱点。所以罗翔老师提出一种悖论式的命定论:即我们可以凡事尽力而为,同时也要学会接受命运的一切安排


    换句话说就是:“但行好事,莫问前程”。


    可能很多人会觉得“好事”是指“对他人或自己有好处的事”,而我则偏向于解读为“爱好之事”,这样反过来讲就是说:不要因为担心前程就放弃了热爱的事物。


    全球经济下行已然是大势所趋,但别让时代的悲哀,成为你的悲哀!愿你我都有重新开始的勇气,也有一往无前的劲头,在有限的时间里,去将自己想做的事一件件地完成,因为我们只有先做到尽人事,才能更从容地听天命。


    作者:茶无味的一天
    来源:juejin.cn/post/7273516671574556687
    收起阅读 »

    工作 6 年,我不想再「键政」了

    今天,刷推时看到一张图,感觉和我工作几年来的心路历程很像,特此分享下。 第一个人脚下空无一物,眼中均是美好。 第二个人读了一些书,看到美好背后的黑暗,开始陷入迷茫。 第三个人学识渊博,了解运行规律,明白世界不是非黑即白,故此看到曙光。 而我呢,目前可能还处在...
    继续阅读 »

    今天,刷推时看到一张图,感觉和我工作几年来的心路历程很像,特此分享下。



    第一个人脚下空无一物,眼中均是美好。


    第二个人读了一些书,看到美好背后的黑暗,开始陷入迷茫。


    第三个人学识渊博,了解运行规律,明白世界不是非黑即白,故此看到曙光。


    而我呢,目前可能还处在第二阶段,但也清楚应该继续向前,走向第三阶段。


    第一阶段



    无知小粉红心态



    读书期间,小镇出身的我,比较追求应试教育和实用主义,所思所学全为了考高分、学技术,除此之外的素质教育全然不顾。


    同样是去图书馆,我看的是「精通 Java」,而舍友看的是「毛选」、「中国近代史」这类的书籍。在那时,我是不屑一顾的,认为这就是 「浪费时间」,看这些又不能当饭吃。


    毕业后,舍友进了体制内,而我去了一家小厂当码农。小厂也挺好,朝九晚六,不追求结婚买房,过得很快乐。


    然而,我还是没有继续读书,技术之外脑袋空空,只会被动的接收主流媒体提供的资讯,从不思考内在逻辑。


    有一次,社保税改(2018年)要求公司按员工真实收入去上报缴纳基数,也就是说社保缴纳金额变多、到手工资变少。看到群里都在吐槽,而那时的我却在群里发表了「高见」:



    社保不也是自己的钱么,提高缴纳基数更赚么?gj 这是为我们个人谋福利!



    结果招来一顿全嘲,说我「啥也不懂」。后面又工作了一段时间,我才彻底明白了他们的槽点。


    第二阶段



    生活压力,终使自己变成自己最讨厌的人



    早期很喜欢逛知乎,也关注了一些前端大佬,希望学点技术。


    但从某段时间开始(大概2020 左右),发现这些人很喜欢「键政」,大谈国事。


    大多都是负面情绪,当时作为「小粉红」的我难以接受,于是拉黑了好几个人。


    随着年龄上去,迫使自己需要关注技术之外的内容:房产、婚姻、生育、教育、理财、交际,往大点说,是政治、历史、和经济。


    粗浅了解之后,我开始悲观:

    • 刑不上大夫
    • 十年寒窗凭什么拼得过人家三代人的努力
    • 历史就是圈,教员想改变的事情是无法改变的
    • zg人的劣根性
    • tz内的劣根性

    于是,我也开始键政,变成了那个曾经最讨厌的人。


    第三阶段



    探索底层逻辑



    工作压力加上生活压力,使我一度抑郁,甚至产生过极端想法。


    好在,我有一个好伴侣,是她陪我度过了那段痛苦的岁月,鼓励我多看书、多思考。


    现在,我也分享下我的一些想法,虽然还未正式踏入第三阶段,但也大概摆脱了第二阶段的影响。

    1. 接纳自己的平凡
    2. 最重要的能力,是获得能力的能力
    3. 遵从历史规律,做务实求进的人
    4. 思考底层逻辑,所有方法论都可以通过底层逻辑(相同之处)+ 环境变量 (不同之处) 来解释
    5. 提升思维认知,多学习技术之外的内容

    最后


    以上便是我工作六年的心路历程,从开始的无知,再到键政,最后开始寻求转变。


    本文纯碎碎念,欢迎各位客官吐槽~


    作者:francecil
    链接:https://juejin.cn/post/7257022428194209849
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    如何告诉后端出身的领导:这个前端需求很难实现

    本文源于一条评论。 有个读者评论说:“发现很多前端的大领导多是后端。他们虽然懂一点前端,但总觉得前端简单。有时候,很难向其证明前端不好实现。” 这位朋友让我写一写,那我就写一写。 反正,不管啥事儿,我高低都能整两句,不说白不说,说了也白说。本文暂且算是一条评...
    继续阅读 »

    本文源于一条评论。




    有个读者评论说:“发现很多前端的大领导多是后端。他们虽然懂一点前端,但总觉得前端简单。有时候,很难向其证明前端不好实现。


    这位朋友让我写一写,那我就写一写。


    反正,不管啥事儿,我高低都能整两句,不说白不说,说了也白说。本文暂且算是一条评论回复吧。愿意看扯淡的同学可以留一下。


    现象分析


    首先,前半句让我很有同感。因为,我就是前端出身,并且在研发管理岗上又待过。这个身份,让我既了解前端的工作流程,又经常和后端平级一起交流经验。这有点卧底的意思。


    有一次,我们几个朋友聚餐闲聊。他们也都是各个公司的研发负责人。大家就各自吐槽自己的项目。


    有一个人就说:“我们公司,有一个干前端的带项目了,让他搞得一团糟”


    另一个人听到后,就叹了一口气:“唉!这不是外行领导内行吗?!


    我当时听了感觉有点别扭。我扫视了一圈儿,好像就我一个前端。因此,我也点了点头(默念:我是卧底)。


    是啊,一般的项目、研发管理岗,多是后端开发。因为后端是业务的设计者,他掌握着基本的数据结构以及业务流转。而前端在他们看来,就是调调API,然后把结果展示到页面上。


    另外,一般项目遇到大事小情,也都是找后端处理。比如手动修改数据、批量生成数据等等。从这里可以看出,项目的“根基”在后端手里。


    互联网编程界流传着一条鄙视链。它是这样说的,搞汇编的鄙视写C语言的,写C的鄙视做Java的,写Java的鄙视敲Python的,搞Python的鄙视写js的,搞js的鄙视作图的。然后,作图的设计师周末带着妹子看电影,前面的那些大哥继续加班写代码。


    当然,我们提倡一个友好的环境,不提倡三六九等。这里只是调侃一下,采用夸张的手法来阐述一种现象。


    这里所谓的“鄙视”,其本质是源于谁更接近原理。


    比如写汇编的人,他认为自己的代码能直接调用“CPU”、“内存”,可以直接操纵硬件。有些前端会认为美工设计的东西,得依靠自己来实现,并且他们不懂技术就乱设计,还有脸要求100%还原。


    所以啊,有些只做过后端的人,可能会认为前端开发没啥东西。


    好了,上面是现象分析。关于谁更有优越感,这不能细究啊,因为这是没有结论的。如果搞一个辩论赛,就比方说”Python一句话,汇编写三年“之类论据,各有千秋。各种语言既然能出现,必定有它的妙用。


    我就是胡扯啊。不管您是哪个工种,请不要对号入座。如果您觉得被冒犯了,可以评论“啥都不懂”,然后离开。千万不要砸自己的电脑。


    下面再聊聊第二部分,面对这种情况,有什么好的应对措施?


    应对方法


    我感觉,后端出身的负责人,就算是出于人际关系,也是会尊重前端开发的


    “小张啊,对于前端我不是很懂。所以请帮我评估下前端的工期。最好列得细致一些,这样有利于我了解人员的工作量。弄完了,发给我,我们再讨论一下!”


    一般都是这么做。


    这种情况,我们就老老实实给他上报就行了,顶多加上10%~30%处理未知风险的时间。


    但是,他如果这样说:“什么?这个功能你要做5天?给你1天都多!


    这时,他是你的领导,对你又有考核,你怎么办?


    你心里一酸:“我离职吧!小爷我受不了这委屈!”


    这……当然也可以。


    如果你有更好的去处,可以这样。就算是没有这回事,有好去处也赶紧去。


    但是,如果这个公司整体还行呢?只是这个直接领导有问题。那么你可以考虑,这个领导是流水的,你俩处不了多长时间。哪个公司每年不搞个组织架构调整啊?你找个看上的领导,吃一顿饭,求个投靠呗。


    或者,这个领导只是因为不懂才这样。谁又能样样精通呢?给他说明白,他可能就想通了。人是需要说服的。


    如果你奔着和平友好的心态去,那么可以试试以下几点:


    第一,列出复杂原因


    既然你认为难以实现,那肯定是难以实现。具体哪里不好实现,你得说说


    记得刚工作时,我去找后端PK,我问他:“你这个接口怎么就难改了?你不改这个字段,我们得多调好几个接口,而且还对应不起来!”


    后端回复我:“首先,ES……;其次,mango……;最后,redis……”


    我就像是被反复地往水缸中按,听得”呜噜呜噜“的,一片茫然。


    虽然,我当时不懂后端,但我觉得他从气势上,就显得有道理


    到后来,还是同样的场景。我变成了项目经理,有一个年轻的后端也是这么回复我的。


    我说:“首先,你不用……;其次,数据就没在……;最后,只需要操作……”。他听完,挠了挠头说,好像确实可以这么做。


    所以,你要列出难以实现的地方。比如,没有成熟的UI组件,需要自己重新写一个。又或者,某种特效,业内都没有,很难做到。再或者,某个接口定得太复杂,循环调组数据,会产生什么样的问题。


    如果他说“我看到某某软件就是这样”。


    你可以说,自己只是一个初级前端,完成那些,得好多高级前端一起研究才行。


    如果你懂后端,可以做一下类比,比如哪些功能等同于多库多表查询、多线程并发问题等等,这可以辅助他理解。不过,如果能到这一步,他的位子好像得换你来坐。


    第二,给出替代方案


    这个方案,适用于”我虽然做不了,但我能解决你的问题“。


    就比如那个经典的打洞问题。需求是用电钻在墙上钻一个孔,我搞不定电钻,但是我用锤子和钉子,一样能搞出一个孔来。


    如果,你遇到难以实现的需求。比如,让你画一个很有特色的扇形玫瑰图。你觉得不好实现,不用去抱怨UI,这只会激化问题。咱可以给他提供几个业界成熟的组件。然后,告诉他,哪里能改,都能改什么(颜色、间距、字体……)。


    我们有些年轻的程序员很直,遇到不顺心的事情就怼。像我们大龄程序员就不这样。因为有房贷,上有老下有小。年龄大的程序员就想,到底是哪里不合适,我得怎样通过我的经验来促成这件事——这并不光彩,年轻人就得有年轻人的样子。


    第二招是给出替代方案。那样难以实现,你看这样行不行


    第三,车轮战,搞铺垫


    你可能遇到了一个硬茬。他就是不变通,他不想听,也不想懂,坚持“怎么实现我不管,明天之前要做完”。


    那他可能有自己的压力,比如老板就是这么要求他的。他转手就来要求你。


    你俩的前提可能是不一样。记住我一句话,没有共同前提,是不能做对比的。你是员工,他是领导,他不能要求你和他有一样的压力,这就像你不能要求,你和他有一样的待遇。多数PUA,就是拿不同的前提,去要求同样的结果。


    那你就得开始为以后扯皮找铺垫了。


    如果你们组有多个前端,可以发动大家去进谏。


    ”张工啊,这个需求,确实不简单,不光是小刘,我看了,也觉得不好实现“


    你一个人说了他不信,人多了可能就信了。


    如果还是不信。那没关系,已经将风险提前抛出了


    “这个需求,确实不好实现。非要这么实现,可能有风险。工期、测试、上线后的稳定性、用户体验等,都可能会出现一些问题”


    你要表现出为了项目担忧,而不是不想做的样子。如果,以后真发生了问题,你可以拿出“之前早就说过,多次反馈,无人理睬”这类的说辞。


    ”你居然不想着如何解决问题,反倒先想如何逃避责任?!“


    因此说,这是下下策。不建议程序员玩带有心机的东西。


    以上都是浅层次的解读。因为具体还需要结合每个公司,每个领导,每种局面。


    总之,想要解决问题,就得想办法


    作者:TF男孩
    链接:https://juejin.cn/post/7235966417564532794
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    Kotlin和Swift的前世一定是兄弟

    iOS
    Swift介绍 Swift这门编程语言主要用于iOS和MacOS的开发,可以说是非常流行的一门编程语言,我只想说,如果你会Kotlin,那么你学习Swift会非常容易,反之亦然。下载XCode,然后你就可以创建Playground练习Swift语法了。&nbs...
    继续阅读 »

    Swift介绍


    Swift这门编程语言主要用于iOS和MacOS的开发,可以说是非常流行的一门编程语言,我只想说,如果你会Kotlin,那么你学习Swift会非常容易,反之亦然。下载XCode,然后你就可以创建Playground练习Swift语法了。 


     playground这个名字起的好,翻译成中文就是操场,玩的地方,也就是说,你可以尽情的测试你的Swift代码。


    声明变量和常量


    Kotlin的写法:

    var a: Int = 10
    val b: Float = 20f

    Swift的写法:

    var a: Int = 10
    let b: Float = 20.0

    你会发现它俩声明变量的方式一模一样,而常量也只是关键字不一样,数据类型我们暂不考虑。


    导包


    Kotlin的写法:

    import android.app.Activity

    Swift的写法:

    import SwiftUI

    这里kotlin和swift的方式一模一样。


    整形


    Kotlin的写法:

    val a: Byte = -10
    val b: Short = 20
    val c: Int = -30
    val d: Long = 40

    Swift的写法:

    let a: Int8 = -10
    let b: Int16 = 20
    let c: Int32 = -30
    let d: Int = -30
    let e: Int64 = 40
    let f: UInt8 = 10
    let g: UInt16 = 20
    let h: UInt32 = 30
    let i: UInt = 30
    let j: UInt64 = 40

    Kotlin没有无符号整型,Swift中Int32等价于Int,UInt32等价于UInt。无符号类型代表是正数,所以没有符号。


    基本运算符


    Kotlin的写法:

    val a: Int = 10
    val b: Float = 20f
    val c = a + b

    Swift的写法:

    let a: Int = 10
    let b: Float = 20
    let c = Float(a) + b

    Swift中没有隐式转换,Float类型不用写f。这里Kotlin没那么严格。


    逻辑分支


    Kotlin的写法:

    val a = 65
    if (a > 60) {
    }

    val b = 1
    when (b) {
    1 -> print("b等于1")
    2 -> print("b等于2")
    else -> print("默认值")
    }

    Swift的写法:

    let a = 65
    if a > 60 {
    }

    let b = 1
    switch b {
    case 1:
    print("b等于1")
    case 2:
    print("b等于2")
    default:
    print("默认值")
    }

    Swift可以省略if的括号,Kotlin不可以。switch的写法倒是有点像Java了。


    循环语句


    Kotlin的写法:

    for (i in 0..9) {
    }

    Swift的写法:

    for var i in 0...9 {
    }
    // 或
    for var i in 0..<10 {
    }

    Kotlin还是不能省略括号。


    字符串


    Kotlin的写法:

    val lang = "Kotlin"
    val str = "Hello $lang"

    Swift的写法:

    let lang = "Swift"
    let str = "Hello \(lang)"

    字符串的声明方式一模一样,拼接方式略有不同。


    数组


    Kotlin的写法:

    val arr = arrayOf("Hello", "JYM")
    val arr2 = emptyArray<String>()
    val arr3: Array<String>

    Swift的写法:

    let arr = ["Hello", "JYM"]
    let arr2 = [String]()
    let arr3: [String]

    数组的写法稍微有点不同。


    Map和Dictionary


    Kotlin的写法:

    val map = hashMapOf<String, Any>()
    map["name"] = "张三"
    map["age"] = 100

    Swift的写法:

    let dict: Dictionary<String, Any> = ["name": "张三", "age": 100]

    Swift的字典声明时必须初始化。Map和Dictionary的本质都是哈希。


    函数


    Kotlin的写法:

    fun print(param: String) : Unit {
    }

    Swift的写法:

    func print(param: String) -> Void {
    }

    func print(param: String) -> () {
    }

    除了关键字和返回值分隔符不一样,其他几乎一模一样。


    高阶函数和闭包


    Kotlin的写法:

    fun showDialog(build: BaseDialog.() -> Unit) {
    }

    Swift的写法:

    func showDialog(build: (dialog: BaseDialog) -> ()) {
    }

    Kotlin的高阶函数和Swift的闭包是类似的概念,用于函数的参数也是一个函数的情况。


    创建对象


    Kotlin的写法:

    val btn = Button(context)

    Swift的写法:

    let btn = UIButton()

    这里kotlin和swift的方式一模一样。


    类继承


    Kotlin的写法:

    class MainPresenter : BasePresenter {
    }

    Swift的写法:

    class ViewController : UIViewController {
    }

    这里kotlin和swift的方式一模一样。


    Swift有而Kotlin没有的语法


    guard...else的语法,通常用于登录校验,条件不满足,就执行else的语句,条件满足,才执行guard外面的语句。

    guard 条件表达式 else {
    }

    另外还有一个重要的语法就是元组。元祖在Kotlin中没有,但是在一些其他编程语言中是有的,比如Lua、Solidity。元组主要用于函数的返回值,可以返回一个元组合,这样就相当于函数可以返回多个返回值了。
    Swift的元组:

    let group = ("哆啦", 18, "全宇宙最强吹牛首席前台")

    Lua的多返回值:

    function group() return "a","b" end

    Solidity的元组:

    contract MyContract {
    mapping(uint => string) public students;

    function MyContract(){
    students[0] = "默认姓名";
    students[1] = "默认年龄";
    students[2] = "默认介绍";
    }

    function printInfo() constant returns(string,uint,string){
    return("哆啦", 18, "全宇宙最强吹牛首席前台");
    }
    }

    总结


    编程语言很多地方都是相通的,学会了面向对象编程,你学习其他编程语言就会非常容易。学习一门其他编程语言的语法是很快的,但是要熟练掌握,还需要对该编程语言的API有大量的实践。还是那句话,编程语言只是工具,你的编程思维的高度才是决定你水平的重要指标。所以我给新入行互联网的同学的建议是,你可以先学习面向对象的编程思想,不用局限于一门语言,可以多了解下其他的编程语言,选择你更喜欢的方向。选择好后,再深耕一门技术。每个人的道不一样,可能你就更适合某一个方向。


    作者:dora
    链接:https://juejin.cn/post/7248962809023316028
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    一文看懂互联网大裁员底层逻辑

    继谷歌、微软之后,Zoom、eBay、波音、戴尔加入最新一波“裁员潮”中。 2 月 7 日,美国在线会议平台 Zoom 宣布将裁减 1300 名员工,成为最新一家进行裁员的公司,大约 15% 的员工受到影响。 同日,总部位于亚特兰大的网络安全公司 Secure...
    继续阅读 »

    继谷歌、微软之后,Zoom、eBay、波音、戴尔加入最新一波“裁员潮”中。


    2 月 7 日,美国在线会议平台 Zoom 宣布将裁减 1300 名员工,成为最新一家进行裁员的公司,大约 15% 的员工受到影响。


    同日,总部位于亚特兰大的网络安全公司 Secureworks 在一份提交给美国证券交易委员会( SEC )的文件中宣布,将裁员 9%,因为该公司希望在“一些世界经济体处于不确定时期”减少开支。据数据供应商 PitchBook估计,该公司近 2500 名员工中约有 225 人将在此轮裁员中受到影响。


    此外,电商公司eBay于2月7日在一份SEC文件中宣布,计划裁员500人,约占其员工总数的4%。据悉,受影响的员工将在未来24小时内得到通知。


    2月6日,飞机制造商波音公司证实,今年计划在财务和人力资源部门裁减约 2000 个职位,不过该公司表示,将增加 1 万名员工,“重点是工程和制造”。


    个人电脑制造商戴尔的母公司,总部位于美国德克萨斯州的戴尔科技,2 月 6 日在一份监管文件中表示,公司计划裁减约 5% 的员工。戴尔有大约 13.3 万名员工,在这个水平上,约 6650 名员工将在新一轮裁员中受到影响。


    除了 Zoom、eBay、波音、戴尔 等公司,它们的科技同行早已经采取了同样的行动。


    从去年 11 月开始,许多硅谷公司员工就开始增加了关注邮箱的频率,害怕着某封解除自己公司内网访问权限的邮件来临,在仅仅在 2022 年 11 月,裁员数字就达到了近 6 万人,而如果从 2022 开始计算,各家已经陆续裁员了近十五万人。



    但本次裁员并非是因为营收的直接下降:事实上,硅谷各家 2022 年营收虽然略有下跌,但总体上仍然保持了平稳,甚至部分业务略有上涨,看起来并没有到「危急存亡之秋」,需要动刀进行大规模裁员,才能在寒冬中存活的程度。


    相较于备受瞩目的裁员,一组来自美国政府的就业数据就显得比较有意思了。据美国劳工统计局 2 月 3 日公布的数据,美国失业率 1 月份降至 3.4%,为 1969 年 5 月以来最低。



    美国 1 月份非农就业人数新增 51.7 万人,几乎是经济学家预期的三倍,即使最近主要在科技行业裁员,但建筑、酒店和医疗保健等行业带来了就业增长。


    一方面是某些企业的大规模裁员,仅 1 月份就影响超过 10 万人;而另一方面,政府报告显示就业市场强劲。这样来看,美国的就业市场情况似乎有些矛盾。


    2022 年 12 月初,多名B站员工在社交媒体上表示,B站开始了新一轮裁员,B端、漫画、直播、主站、Goods等部门均有涉及,整体裁员比例在30%左右。12月19日,小米大规模裁员的消息又有曝出,裁员涉及手机部、互联网部、中国部等多部门,个别部门裁员比例高达75%。12月20日,知乎又传裁员10%。


    似乎全球的科技公司都在裁员,而我们想要讨论裁员的问题,肯定绕不开两个大方向:经济下行和人员问题。


    下行环境


    联合国一月发布的《2023年世界经济形势与展望》中指出,2022 年,一系列相互影响的严重冲击,包括新冠疫情、乌克兰战争及其引发的粮食和能源危机、通胀飙升、债务收紧以及气候紧急状况等,导致世界经济遭受重创。美国、欧盟等发达经济体增长势头明显减弱,全球其他经济体由此受到多重不利影响。与新冠疫情相关的反复封锁以及房地产市场的长期压力,延缓了中国的经济复苏进程。


    在此背景下,2023 年世界经济增速预计将从 2022 年估计的 3.0% 下降至 1.9%。2024 年,由于部分不利因素将开始减弱,预计全球经济增速将适度回升至 2.7%。不过,这在很大程度上将取决于货币持续紧缩的速度和顺序、乌克兰战争的进程和后果以及供应链进一步中断的可能性。


    在通货膨胀高企、激进的货币紧缩政策以及不确定性加剧的背景下,当前全球经济低迷,导致全球经济从新冠疫情的危机中复苏的步伐减缓,对部分发达国家和发展中国家均构成威胁,使其 2023 年可能面临衰退的前景。


    2022 年,美国、欧盟等发达经济体增长势头明显减弱。报告预计,2023 年美国和欧盟的经济增速分别为 0.4% 和 0.2%,日本为 1.5%,英国和俄罗斯的经济则将分别出现 0.8% 和 2.9% 的负增长。


    与此同时,全球金融状况趋紧,加之美元走强,加剧了发展中国家的财政和债务脆弱性。自 2021 年末以来,为抑制通胀压力、避免经济衰退,全球超过85%的央行纷纷收紧货币政策并上调利率。


    报告指出,2022 年,全球通胀率约为 9%,创数十年来的新高。2023 年,全球通胀率预计将有所缓解,但仍将维持在 6.5% 的高水平。


    据美国商务部经济分析局(BEA)统计,第二、三季度,美国私人投资分别衰退 14.1% 和 8.5%。加息不仅对美国企业活动产生抑制作用,而且成为美国经济复苏的最主要阻力。尤其是,非住宅类建筑物固定投资已连续六个季度衰退。预计 2023 年美国联邦基金利率将攀升至 4.6%,远远超过 2.5% 的中性利率水平,经济衰退风险陡增,驱动对利率敏感的金融、房地产和科技等行业采取裁员等必要紧缩措施。


    发展上限


    美国企业的业务增长和经营利润出现问题。据美国多家媒体报道,第三季度,谷歌利润率急剧下滑,Meta 等社交媒体的广告收入迅速降温,微软等其他科技企业业务增长也大幅放缓。自7月以来,美国服务业PMI已连续5个月陷入收缩区间,制造业 PMI 也于 11 月进入收缩区间。在美国经济前景和行业增长空间出现问题的背景下,部分行业采取裁员、紧缩开支等“准备过冬”计划也就在意料之中了。


    2022年,在市值方面,作为中概股的代表阿里、腾讯、快手等很多企业的市值都跌了 50%,甚至70%、80%。在收入方面,BAT 已经停止增长几个季度了,阿里和腾讯为代表的企业已经开始负增长。在经济下行的背景下,向内开刀、降本增效成为企业生存的必然之举。除了裁员,收缩员工福利、业务调整,也是企业降本增效的举动之一。


    如果说 2021 年的裁员,很多是由于业务受到冲击,比如字节跳动的教育业务,以及滴滴、美团等公司的社区团购项目。但到了 2022 年,更多企业裁员的背后是降本增效、去肥增肌。


    全球宏观经济表现不佳,由产业资本泡沫引发的危机感传导到科技企业的经营层,科技企业不得不面对现实。科技行业处在重要的结构转型期。iPhone 的横空出世开创了一个移动互联网的新时代,而当下的科技巨头也都是移动互联网的大赢家。但十多年过去了,随着智能手机全球高普及率的完成,移动互联网的时代红利逐渐消失,也再没有划时代的创新和新的热点。


    这两年整个移动互联网时代的赢家都在焦急地寻找新的创新增长点。比如谷歌和 Meta 多年来一直尝试投资新业务,如谷歌云、Web3.0等,但实际收入仍然依赖于广告业务,未能找到真正的新增长点。这使得其中一些公司容易受到持有突破性技术的初创公司影响。


    科技企业倾力“烧钱”打造新赛道,但研发投入和预期产出始终不成正比,不得不进行战略性裁员。


    我们这里以这两年爆火的元宇宙举例:


    各大券商亦争相拥抱元宇宙,不仅元宇宙研究团队在迅速组建,元宇宙首席分析师也纷纷诞生。 2021 年下半年,短短半年内便有数百份关于元宇宙的专题研报披露。


    可以说,在扎克伯格和Meta的带领下,全世界的大厂小厂都在跟着往元宇宙砸钱。


    根据麦肯锡的计算,自2021年以来,全世界已经向虚拟世界投资了令人瞠目结舌的数字——1770亿美元。


    但即使作为元宇宙领军的 Meta 现实实验室(Reality Labs)2022 年三季度收入 2.85 亿美元,运营亏损 36.7 亿美元,今年以来已累计亏损 94 亿美元,去年亏损超过 100亿 美元。显然,Meta 的元宇宙战略还未成为 Meta的机遇和新增长点。


    虽然各 KOL 高举“元宇宙是未来”的大旗,依旧无法改写“元宇宙未至”的局面。刨除亟待解决的关键性技术问题,如何兼顾技术、成本与可行性,实现身临其境的体验,更是为之尚远。元宇宙还在遥远的未来。


    早在 2021 年12 月底,人民日报等官方媒体曾多次下场,呼吁理性看待“元宇宙”。中央纪委网站发布的《元宇宙如何改写人类社会生活》提及“元宇宙”中可能会涉及资本操纵、舆论吹捧、经济风险等多项风险。就连春晚的小品中,“元宇宙”也成为“瞎忽悠”的代名词。


    2022 年 2月18日,中国银保监会发布了《关于防范以“元宇宙”名义进行非法集资的风险提示》,并指出了四种常见的犯罪手法,包括编造虚假元宇宙投资项目、打着元宇宙区块链游戏旗号诈骗、恶意炒作元宇宙房地产圈钱、变相从事元宇宙虚拟币非法谋利。


    2022 年 2月7日,英国《金融时报》报道称,随着《网络安全法案》逐步落实,元宇宙将会受到严格的英国监管,部分公司可能面临数十亿英镑的潜在罚款。


    2022 年 2月6日,据今日俄罗斯电视台(RT)报道,俄罗斯监管机构正在研究对虚拟现实技术实施新限制的可能性,他们担心应用该技术可能会协助非法活动。


    各个国家的法律监管的到来,使得元宇宙的泡沫迅速炸裂。无数的元宇宙公司迅速破产,例如白鹭科技从 H5 游戏引擎转型到元宇宙在泡沫破裂的情况下个人举债 4000 万,公司破产清算。


    本质上来说如今互联网行业已经到了一个明显的发展瓶颈,大家吃的都是移动网络和智能手机的普及带来的红利。在新的设备和交互方式诞生前,大家都没了新故事可讲,过去的圈地跑马模式在这样的大环境下行不通了。


    法律监管


    过去十年时间,互联网世界的马太效应越来越明显。一方面,几大巨头们在各自领域打造了占据了主导份额的互联网平台,不断推出包罗万象的全生态产品与服务,牢牢吸引着绝大多数用户与数据。他们的财务业绩与股价市值急剧增长,苹果、谷歌、亚马逊的市值先后突破万亿甚至是两万亿美元。


    而另一方面,诸多规模较小的互联网公司却面临着双重竞争劣势。他们不仅财力与体量都无法与网络巨头抗衡,还要在巨头们打造的平台上,按照巨头制定偏向自己的游戏规则,与包括巨头产品在内的诸多对手激烈竞争用户。


    2020 年 10 月,在长达 16 个月的调查之后,美国众议院司法委员会发布了一份长达 449 页的科技反垄断调查报告,直指谷歌、苹果、Facebook、亚马逊四大科技巨头滥用市场支配地位、打压竞争者、阻碍创新,并损害消费者利益。


    2020 年 10 月 20 日,美国司法部连同美国 11 个州的检察长向 Google 发起反垄断诉讼,指控其在搜索和搜索广告市场通过反竞争和排他性行为来非法维持垄断地位。


    2021 年明尼苏达州民主党参议员艾米·克洛布查尔(Amy Klobuchar)和爱荷华州共和党参议员查克·格拉斯利(Chuck Grassley)共同提出的《美国创新与选择在线法案》和 《开放应用市场法案》旨在打击谷歌母公司 Alphabet、亚马逊、Facebook 母公司 Meta 和苹果公司等科技巨头的一些垄断行为,这将是互联网向公众开放近30年来的首次重要法案。


    《美国创新与选择在线法案》的内容包括禁止占主导地位的平台滥用把关权,给予营产品服务特权,使竞争对手处于不利地位;禁止施行对小企业和消费者不利,有碍于竞争的行为,例如要求企业购买平台的商品或服务以获得在平台上的优先位置、滥用数据进行竞争、以及操纵搜索结果偏向自身等。


    不公平地限制大平台内其他商业用户的产品、服务或业务与涵盖平台经营者自己经营的产品、服务或业务相竞争能力,从而严重损害涵盖平台中的竞争。


    除了出于大平台安全或功能的需要,严重限制或阻碍平台用户卸载预装的软件应用程序,将大平台用户使用大平台经营者提供的产品或服务设置为默认或引导为默认设置。


    《开放应用市场法案》针对“守门人”执行,预计将会在应用商店、定向广告、互联操作性,以及垄断并购等方面,对相应企业做出一系列规范要求。此外欧盟方面还曾透露,如“守门人”企业不遵守上述规则,将按其上一财政年度的全球总营业额对其处以“不低于 4%、但不超过20%”的罚款。法案允许应用程序侧载(在应用商店之外下载应用程序),旨在打破应用商店对应用程序的垄断能力,将对苹果、谷歌的应用商店商业模式产生重要影响。


    大型科技公司们史无前例搁置竞争,并且很有默契地联合起来。他们和他们的贸易团体在两年内耗费大约 1 亿美元进行游说,超过了制药和国防等高支出行业。他们向政界人士捐赠了 500 多万美元,科技游说人士向负责捍卫民主党多数席位的政治行动委员会(PAC)捐赠了 100 多万美元。他们还向不需要披露资金来源的黑钱组织、非营利组织和行业协会投入了数百万美元。几位国会助手表示,他们收到的有关这些法案的宣传比他们多年来处理的任何其他法案都要多。


    这两项法案已通过国会相关委员会的审查,依然在等待众议院和参议院的表决。而美国即将开始中期选举。Deese 称,共和党已经明确表示,如果共和党重新控制国会两院,他们将不会支持这些法案。但如果民主党当选的话,科技巨头们估计不好过了。


    很遗憾的是,2023年,新一届美国国会开幕后,众议院议长的选举经多轮投票仍然“难产”,导致新一届国会众议院无法履职。开年的这一乱象凸显美国政治制度失灵与破产,警示美国党争极化的趋势恐正愈演愈烈;


    欧盟也多次盯上四大公司,仅谷歌一家,欧盟近三年来对其开出的反垄断处罚的金额已累计超过 90 亿美元。


    而中国的举措也不小。


    2020 年年初,实施了近 12 年的《反垄断法》(2008 年 8 月 1 日生效)首次进入“大修”——国家市场监督管理总局在其官网公布了《反垄断法修订草案(公开征求意见稿)》(以下简称“征求意见稿”)。


    《法制日报》报道指出,征求意见稿中共有 8 章 64 条,较现行法要多出 7 条。可见,这次修法,已与另立新法有同等规模。


    值得注意的是,征求意见稿还首次将互联网业态纳入其中,新增互联网领域的反垄断条款,针对性地列明相关标准和适用规程。


    以市场支配地位认定为例,征求意见稿根据互联网企业的特点,新增了包括网络效应、规模经济、锁定效应、掌握和处理相关数据的能力等因素。


    11 月 10 日,赶在双 11 前一天,国家市场监管管理总局再次出手,发布了《关于平台经济领域的反垄断指南(征求意见稿)》(以下简称《指南》)公开征求意见的公告。


    《指南》不仅对“互联网平台”做了进一步界定,还结合个案更为具体详尽地对垄断协议,滥用市场支配地位行为,经营者集中,滥用行政权力排除、限制竞争四个方面作出分析和规定。


    国家在平台经济领域、反垄断领域的法律规范,在《反垄断指南》出台以后,已经有了相当程度的完善。后续随着《反垄断法》修正案的通过,二者结合基本构建了我国反垄断领域的法律框架。


    随着《反垄断法》的完善,在互联网领域的处罚案例逐渐浮出水面,针对阿里巴巴、美团等互联网公司都开出了大额罚单。


    2021年我国在网络安全方面也加速发展。2021年6月10日颁布《中华人民共和国数据安全法》,2021年8月20日颁布《中华人民共和国个人信息保护法》。有关部门相继出台了《网络安全审查办法》《常见类型移动互联网应用程序必要个人信息范围规定》《数据出境安全评估办法(征求意见稿)》等部门规章和政策性文件。


    可以预见的是,未来监管部门的监管措施更能兼顾互联网行业发展特征和社会整体福利,监管部门会不断完善规章、政策文件和标准文件,提供给企业明确和细化的指引。同时,相关部门的监管反应速度会越来越及时,监管层面对违法查处的力度也会越来越严。


    人口红利


    我们依然处在人口规模巨大的惯性中,人口规模巨大意味着潜在市场规模巨大,伴随经济持续发展、收入水平提高、消费能力强劲,由此带来的超大市场规模不可估量。而现在人口红利没了。


    中国国家统计局 1 月 17 日公布,2022年末全国人口(包括 31 个省、自治区、直辖市和现役军人的人口,不包括居住在 31 个省、自治区、直辖市的港澳台居民和外籍人员) 141175 万人,比上年末减少 85 万人。这是近61年来中国首次人口负增长。人口负增长的早期阶段是一种温和的人口减少,所以依然会沿袭人口规模巨大的惯性;但在人口负增长的远期阶段,如果生育率仍未有所回升的话,就有可能导致一种直线性的减少。


    目前所有行业都不得不面临从人口红利转向素质红利的转变。


    人员过剩


    微软在过去两年员工数新增 6 万,Google 则是新增了 7 万,Meta 则是直接从疫情之前的 4 万翻倍至 2022 年的 8.7 万。而依赖物流服务的亚马逊则最为激进,两年时间全球全职员工数增长了令人咂舌的 8.1 万,全职员工数近乎翻倍。



    高盛的经济学家在一份报告中指出“那些正在裁员的科技公司有一些共同点,希望重新平衡业务的结构性转变,并为更好的利润开路。我们发现,许多最近宣布大规模裁员的公司都有三个共同特征。首先,许多都是在科技领域。其次,许多公司在疫情期间大肆招聘。第三,它们的股价出现了更大幅度的下跌,从峰值平均下跌了 43%。”


    平均而言,那些进行裁员的公司在疫情期间的员工数量增长了 41%,此举往往是因为他们过度推断了与疫情相关的趋势,比如商品需求或在线时间的增长。


    行裁员的公司并不能代表更广泛的情况,最近许多裁员公告并不一定意味着需求状况会减弱。与此一致的是,高盛预计更具代表性的实时估计的裁员率最近虽有所增加,但仅恢复到疫情前的水平,以历史标准衡量,裁员率水平较低。


    结论


    全球经济下行是大势,层层增加的法律监管是推动,没有人口红利和新玩法股价要大跌。


    全球通胀激增,激进的货币紧缩政策以及不确定性加剧、俄乌战争等影响,全球经济低迷。新冠疫情带来的影响难以快速恢复。而中国还得面临人口红利消失、房地产饮鸩止渴的深远影响。而法律的层层监管和反垄断的推进在逐步打压科技巨头的已有市场,没有新技术的突破和新玩法让科技巨头们也没了新增和突破的空间。对于未来的经济发展的错误预估和疫情特殊时期的大量增长让科技巨头们大肆招聘,这些都成为了股价下跌和缩减利润的元凶。目前的大裁员可以算是一种虚假繁荣的泡沫爆裂后的回调,虽然不知道这个回调什么时候结束,但是随着人工智能的出圈和将来新技术的突破,也许整个行业可以浴火重生。


    作者:Andy_Qin
    链接:https://juejin.cn/post/7201047960825774139
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    慎重选择~~第四家公司刚刚开我,加入重新找工作大队!!!

    前景需知 这家公司是我的第四家公司,合同三年,6个月试用期,(当时入职时,谈过说可以提前转正,但是后续当作没这件事),然后7月25日,下午5点半,下班时候告诉我被开了。当天是我手上的一个新项目刚好完结,测试刚过,bug修复完毕,老板让人事通知我,被开了,说是没...
    继续阅读 »

    前景需知


    这家公司是我的第四家公司,合同三年,6个月试用期,(当时入职时,谈过说可以提前转正,但是后续当作没这件事),然后7月25日,下午5点半,下班时候告诉我被开了。当天是我手上的一个新项目刚好完结,测试刚过,bug修复完毕,老板让人事通知我,被开了,说是没有新的项目了。当时我算了算应该是还有几天就转正了。


    在职期间


    总共是在职6个月差几天转正,期间一直是大小周,说是双休,加班没有任何补偿,然后9点到5.30.(从来没有5点半下班过,最早就是6点半吧,5点半下班会打电话给你,问你为啥下班那么早).然后在这家公司这么久,手上是写了3个新项目,翻新2个老项目,还有维护的。期间没有任何违纪行为,这肯定是一定的,不然也不会等到还有几天才把我开了。在职期间做的事,跟产品沟通为什么不能这么写,用户怎么交互比较合理,不必太过于麻烦,给后端沟通为什么要这个数据,为什么要这样,还要跟老板说 进度怎么样的,预计时间。因为没有测试,是所有员工用了以后提一个bug单,到我这里来,然后我统一看这是谁的问题,然后我去沟通,加上公司内部人员测试,很多东西产品出成那样,觉得不合理,也要给我,我去跟产品沟通,真是沟通成本大的要死,期间有一个要对接别人的app里的积分系统,对公到我们的积分体系里,还要我去对接,这不能找后端嘛?产品又甩给我了,最后又要我去跟第三方沟通,再给自己的后端沟通,成本是真的高啊,我真是有时候头大。听着有点小抱怨,但是吧,其实后面了还好,确实能让你学到很多东西,因为你很清楚这个项目的走向,以及问题,基本上所有东西有点围绕着前端做的感觉,反正每天都是被问,问到最后,无论是谁张嘴我都知道是什么个情况。反正学着接受就好了。


    为什么会来到这家公司??


    这家公司是我去年面过的一家公司,当时入职他们公司一天我就走了,为什么会走,就是因为代码累积,页面过于卡顿,前端没有任何标注,而且入职第一天,老板就要求改他们的东西,然后第二天就没去了,为什么今年去了,是因为去年这个老板也联系了我几次,说我可以去他们公司试试看,然后过年的前两天还在跟我说,我说那就去试试看看,然后年后那个老板也催着我入职,当时也不是没得选,朋友公司招人内推,他面我,说让我去。我当时主要是跟这个老板说好了,答应了,于是就回绝了我的朋友(真后悔啊,那是真后悔,真不如去朋友哪里了,现在还被开了,卸磨杀驴,我真气)。


    在公司半年,我具体做了哪些东西


    上面说做了3个新项目,翻新两个新项目。三个新项目是一个是可视化大屏项目,这个项目用的是(vue3加echarts,适配是用v-scae-screen这个组件做的,当然这时候就有人会问,你用这个组件 那其他屏幕的除了你用的这个分辨率,其他比例不对的分辨率,也会有问题,当然这个问题我也遇到了,但是也试了其他的几种方案,但是或多或少都有问题,所以我就选择了这个比较直接.原理## transform.scale(),更详细的可以看看这个组件。)还有一个是小程序的老师端批改作业,并给予点评。(uni-app加uview写的,这个直接上图片,有难点)



     第三个项目也是uni-app写的,就是刚刚写完这个项目我被开了,真是太离谱了。也是一个小程序(uni-app加uview,然后益智类的,可以直接搜索头脑王者这个小程序,基本上是功能还原。不贴我的项目图了,好像我走的第二天就在审核了,主要是websocket长连接写的,因为是对战类,所以长连接时时保持通讯,也是有难点的,因为长连接要多页面保持又要实时获取信息,可以想一下怎么做)。 翻新的项目就不谈了,算是整个翻新,翻新是最累的,因为有的能用有的不能用,该封装封装,数据该处理处理,哦,中间遇到一个有趣的问题,就是el-tabs这个缓存机制,不知道为啥,v-if也不行.


    目前的看法


    7月25下午被开当天其实我很痛苦,当时人事说话也很过分,让我自己签申请离职说,这样的话赔偿你 0.5,如果不行,你可以去仲裁我们,然后如果我去仲裁,那么离职单,离职证明,赔偿,工资都没有,就拖着你,甚至老板恶言相向的告诉人事说,怎么可以在他的工作简历上留下这个不好的痕迹,影响他以后的工作。其实我听到这些话的时候我除了恶心,我什么话都说不出来,面对这个种情况,我咨询了,12333他们说,让我照常上班,他把你提出打开的软件,你就手动拍摄视频,然后自己打开,直至出示他把你辞退的证明,或者待够15天。我把这个事情实施以后,并且告知公司,仍然不给我出示离职证明,出了一张,辞退通知书,这个通知书我直接上图片,首先这个假,是个病假,是因为后端对我进行了侮辱,然后导致我气的头疼,然后我去请假,是给领导直接请的,她允许以后,我才中午下班是,离开的公司。 


    为什么会给后端吵架,因为后端不处理逻辑,还要怪我什么都不给他说,什么都不给讲,这是我最气的点,我每次都要给他讲,为什么需要这个数据,为什么你要这么给我,需要什么,我每次都在他没写之前就进行沟通。他最后怪我没讲,并且侮辱我。有的人这时候会说,你为什么不他给你什么就要什么呢?然后自己处理逻辑。降低了耦合性,再往后说 你自己可以写一个node.js啊 为什么不呢?这些都挺对的,但是吧,你不能每次都这么处理问题吧。一个选择题,他应该给你abcd,结果给你1234,然后他要abcd,你说这个转换你做不做?你好说歹说他给你改了,然后一道题4个选项 我回答完以后,他给你答案你自己判断对错,这个逻辑前端写吗,当然也可以,如果他给你的答案是 1呢 1就是a,这时候你又该如何是好?可能你觉得我不信后端会这个对你,一定是你的问题,哈哈 上图片



     

     是的没有错,我来教着写,这个时候大家可以喷我了,可以说,你怎么交后端写,你算什么东西,兄弟们,兄弟们,都是我的问题,实在是没办法了,写出了这样得东西 这个东西还能精简,这是只是我为了实现而写得逻辑。




    反正一吐为快,目前是没找工作,下周找找看吧,缓解一下。


    当下迷茫得点


    希望大家给点建议,就是说因为没有遇到一个好的产品导致我现在想去做产品,我直接现在转产品工资会有一个大跳水,会少很多,但是我也愿意接受,可能是赌气吧,就真的想去做这个,让开发没那么难以沟通。也在想是不是继续前端,保持现状,但是就是想去转产品了,我现在24岁,前端3年多,我应该还有试错得机会,我真的不想在碰见这种情况了,真的好累,加上只是前端,人微言轻,只有出现问题,提出来的东西,才能被采纳,真的好难。所以我是有意愿转转看的,不知道各位怎么看?能评价就评价下,需要我爆雷得,我私信,他们目前好像又在招前端了,怕大家踩雷,在上海。


    给大家得建议


    就是入职前,还是要好好调查,然后不要只听片面之言,然后就是现状不好的,也不要气馁,就加油好吧,我都没气馁,顶住压力啊,还是希望大家吃好喝好玩好,生活美满。


    作者:想努力的菜菜
    链接:https://juejin.cn/post/7262156717244530744
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    iOS 开发中如何禁用第三方输入法

    iOS
    iOS 目前已允许使用第三方输入法,但在实际开发中,无论是出于安全的考虑,还是对某个输入控件限制输入法,都有禁用第三方输入法的需求。基于此,对禁用第三方输入法的方式做一个总结。 1. 全局禁用 Objective-C 语言版本:- (BOOL)applicat...
    继续阅读 »

    iOS 目前已允许使用第三方输入法,但在实际开发中,无论是出于安全的考虑,还是对某个输入控件限制输入法,都有禁用第三方输入法的需求。基于此,对禁用第三方输入法的方式做一个总结。


    1. 全局禁用


    Objective-C 语言版本:

    - (BOOL)application:(UIApplication *)application
    shouldAllowExtensionPointIdentifier:(UIApplicationExtensionPointIdentifier)extensionPointIdentifier
    {
    // 禁用三方输入法
    // UIApplicationKeyboardExtensionPointIdentifier 等价于 @"com.apple.keyboard-service"
    if ([extensionPointIdentifier isEqualToString:UIApplicationKeyboardExtensionPointIdentifier]) {
    return NO;
    }
    return YES;
    }

    Swift 语言版本:

    func application(
    _ application: UIApplication,
    shouldAllowExtensionPointIdentifier extensionPointIdentifier: UIApplication.ExtensionPointIdentifier
    ) -> Bool {
    // 禁用三方输入法
    if extensionPointIdentifier == .keyboard {
    return false
    }
    return true
    }

    2. 针对某个视图禁用

    func application(
    _ application: UIApplication,
    shouldAllowExtensionPointIdentifier extensionPointIdentifier: UIApplication.ExtensionPointIdentifier
    ) -> Bool {
    // 遍历当前根控制器的所有子控制器,找到需要的子控制器
    for vc in self.window?.rootViewController?.childViewControllers ?? []
          where vc.isKind(of: BaseNavigationController.self)
    {
    // 如果首页禁止使用第三方输入法
    for vc1 in vc.childViewControllers where vc1.isKind(of: HomeViewController.self) {
          return false
        }
      }
    return true
    }

    3. 针对某个 inputView 禁用


    3.1 自定义键盘


    如果需求只是针对数字的输入,优先使用自定义键盘,将 inputView 绑定自定义键盘,不会出现第三方输入法。


    3.2 遍历视图内控件,找到需要设置的 inputView,专门设置

    func application(
    _ application: UIApplication,
    shouldAllowExtensionPointIdentifier extensionPointIdentifier: UIApplication.ExtensionPointIdentifier
    ) -> Bool {
    // 遍历当前根控制器的所有子控制器,找到需要的子控制器
    for vc in self.window?.rootViewController?.childViewControllers ?? []
          where vc.isKind(of: BaseNavigationController.self)
    {
    // 如果想要禁用的 inputView 在首页上
    for vc1 in vc.childViewControllers where vc1.isKind(of: HomeViewController.self) {
    // 如果 inputView.tag == 6 的 inputView 禁止使用第三方输入法
          for view in vc1.view.subviews where view.tag == 6 {
          return false
          }
        }
      }
    return true
    }

    作者:Artisan
    链接:https://juejin.cn/post/7239715562834083897
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    简历中不写年龄、毕业院校、预期薪资会怎样?

    无意中看到一条视频,点赞、转发量都非常高,标题是“不管你有多自信,简历中的个人信息都不要这样写”。看完之后简直有些无语,不仅哗众取宠,甚至会误导很多人。 之所以想写这篇文章,主要是分享给大家一种思维方式:如果别人说的事实或观点,只有情绪、结论,没有事实依据和推...
    继续阅读 »

    无意中看到一条视频,点赞、转发量都非常高,标题是“不管你有多自信,简历中的个人信息都不要这样写”。看完之后简直有些无语,不仅哗众取宠,甚至会误导很多人。


    之所以想写这篇文章,主要是分享给大家一种思维方式:如果别人说的事实或观点,只有情绪、结论,没有事实依据和推导,那么这些事实和观点是不足信的,需要慎重对待。


    视频的内容是这样的:“不管你有多自信,简历中的个人信息都不要这样写。1、写了期望薪资,错!2、写了户籍地址,错!3、写了学历文凭,错!4、写了离职原因,错!5、写了生日年龄,错!6、写了自我评价,错!


    正确写法,只需要写姓名和手机号、邮箱及求职意向即可,简历个人信息模块的作用是让HR顺利联系到你,所有任何其他内容都不要写在这里……”


    针对这条视频的内容,有两个不同的表现:第一就是分享和点赞数量还可以,都破千了;第二就是评论区很多HR和求职着提出了反对意见。


    第一类反对意见是:无论求职者或HR都认为这样的简历是不合格的,如果不提供这些信息,根本没有预约面试的机会,甚至国内的招聘平台的简历模板都无法通过。第二类,反对者认为,如果不写这些信息,特别是预期薪资,会导致浪费双方的时间。


    针对上述质疑,作者的回复是:”看了大家的评论,我真的震惊,大家对简历的误解是如此至深……“


    仔细看完视频和评论,在视频的博主和评论者之间产生了一个信息差。博主说的”个人信息“不要写,给人了极大的误导。是个人信息栏不要写,还是完全不写呢?看评论,大多数人都理解成了完全不写。博主没有说清楚是不写,还是写在别处,这肯定是作者的锅。


    本人也筛选过近千份简历,下面分享一下对这则视频中提到的内容的看法:


    第一,户籍、离职原因可以不写


    视频中提到的第2项和第4项的确可以不写。


    户籍这一项,大多数情况下是可以不写的,只用写求职城市即可,方便筛选和推送。比如,你想求职北京或上海的工作,这个是必须有的,而你的户籍一般工作没有强制要求。但也有例外,比如财务、出纳或其他特殊岗位,出于某些原因,某些公司会要求是本地的。写不写影响没那么大。


    离职原因的确如他所说的,不建议写,是整个简历中都不建议写。这个问到了再说,或者填写登记表时都会提到,很重要,要心中有准备,但没必要提前体现。


    第二,期望薪资最好写上


    关于期望薪资这个有两种观点,有的说可以不写,有的说最好写上。其实都有道理,但就像评论中所说:如果不写,可能面试之后,薪资相差太多,导致浪费了双方的时间。


    其实,如果可以,尽量将期望薪资写上,不仅节省时间,这里还稍微有一个心理锚定效应,可以把薪资写成范围,而范围的下限是你预期的理想工资。就像讨价还价时先要一个高价,在简历中进行这么一个薪资的锚定,有助于提高最终的薪资水平。


    第三,学历文凭一定要写


    简历中一定要写学历文凭,如果没有,基本上是会默认为没有学历文凭的,是不会拿到面试邀约的。仔细想了一下,那则视频的像传达的意思可能是不要将学历文凭写作个人信息栏,而是单独写在教育经历栏中。但视频中没有明说,会产生极大的误导。


    即便是个人信息栏,如果你的学历非常漂亮,也一定要写到个人信息栏里面,最有价值,最吸引眼球的信息,一定要提前展现。而不是放在简历的最后。


    第四,年龄要写


    视频中提到了年龄,这个是招聘衡量面试的重要指标,能写尽量写上。筛选简历中有一项非常重要,就是年龄、工作经历和职位是否匹配。在供大于求的市场中,如果不写年龄,为了规避风险,用人方会直接放弃掉。


    前两个月在面试中,也有遇到因为年龄在30+,而在简历中不写年龄的。作为面试官,感觉是非常不好的,即便不写,在面试中也需要问,最终也需要衡量年龄与能力是否匹配的问题。


    很多情况下,不写年龄,要么认为简历是不合格的,拿不到面试机会,要么拿到了面试机会,但最终只是浪费了双方的时间。


    第五,自我评价


    这一项与文凭一样,作者可能传达的意思是不要写在个人信息栏中,但很容易让人误解为不要写。


    这块真的需要看情况,如果你的自我评价非常好,那一定要提前曝光,展现。


    比如我的自我评价中会写到”全网博客访问量过千万,CSDN排名前100,出版过《xxx》《xxx》书籍……“。而这些信息一定要提前让筛选简历的人感知到,而不是写在简历的最后。


    当然,如果没有特别的自我评价,只是吃苦耐劳、抗压、积极自主学习等也有一定的积极作用,此时可以考虑放在简历的后面板块中,而不是放在个人信息板块中。这些主观的信息,更多是一个自我声明和积极心态的表现。


    最后的小结


    经过上面的分析,你会看到,并不是所有的结论都有统一的标准的。甚至这篇文章的建议也只是一种经验的总结,一个看问题的视角而已,并不能涵盖和适用所有的场景。而像原始视频中那样,没有分析,没有推导,没有数据支撑,没有对照,只有干巴巴的结论,外加的煽动情绪的配音,就更需要慎重对待了。


    在写这篇文章的过程中,自己也在想一件事:任何一个结论,都需要在特定场景下才能生效,即便是牛顿的力学定律也是如此,这才是科学和理性的思维方式。如果没有特定场景,很多结论往往是不成立的,甚至是有害的。


    作者:程序新视界
    链接:https://juejin.cn/post/7268593569782054967
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    OC项目用Swift开发方便吗?

    iOS
    前言 公司有个项目一直是用 OC 进行开发,现在想改成 Swift 来开发。那先说一下为什么有这样的想法,我们都知道 Swift 代码更简单,易维护,安全而且快,网络上也是很多描述,那我们主要的是担心一旦变成混编工程,会不会出现很多问题,还有如何解决这些问题。...
    继续阅读 »

    前言


    公司有个项目一直是用 OC 进行开发,现在想改成 Swift 来开发。那先说一下为什么有这样的想法,我们都知道 Swift 代码更简单,易维护,安全而且快,网络上也是很多描述,那我们主要的是担心一旦变成混编工程,会不会出现很多问题,还有如何解决这些问题。性能问题方面Swift 和 OC 共用一套运行时环境,而且支持 Swift 桥接 到 OC上,所以呢,问题不大。如果有不同的想法,也欢迎留意指教。


    桥接文件


    我们只要在 OC 项目中,创建一个 swift 文件,系统就会弹出桥接文件,我们点击 "Create Bridging Header"即可。




    OC 工程接入 Swift


    OC 类 引用 Swift 类


    如上面我们创建了一个 swift 文件,里面写一些方法提供给 OC 使用。

    @objcMembers class SwiftText: NSObject {

    func sayhello() -> String{

    return "hello world"

    }
    }

    class SwiftText2: NSObject {

    @objc func sayhello() ->String{

    returnOCAPI.sayOC()

    }
    }

    这里我们有关键字2个,1个是@objcMembers,表示所有方法属性都可以提供给 OC 使用。另外一个是@objc,表示修饰的方法属性才可以提供给OC使用。


    那我们 OC 类怎么用这个 swift 文件呢。
    先在我们该类添加头文件

    #import "项目Target-Swift.h"

    然后我们点进去看下。




    可以看到我们写的 swift 文件类,方法,属性,都被转化为 OC 了,有了这个我们直接使用即可。


    OC类 使用 swift Pod库


    说实话,这种用的比较少,但有时候我们真的觉得 swift Pod库 会更好用,那我们怎么去处理呢?


    首先我们要搞懂一点,有些是支持使用的,如PromiseKit,有些是不支持使用的如Kingfisher


    先说第一种支持使用的,我们直接导入#import <PromiseKit/PromiseKit.h>即可。


    那要是第二种的话,我们还有一种办法,就是先用 swift 写一个该库管理类,然后里面引用我们该库的内容,我们通过 @objc 来提供给我们 OC 使用。


    Swift类 引用 OC 类


    如果我们编写的 Swift 类,想要用到 我们 OC 的方法,那我们如何处理呢?


    我们直接在桥接文件"Target-Bridging-Header.h"里面,直接导入头文件#import "XXX.h"即可使用。


    Swift类 使用 OC pod库


    其实这个更简单,和 Swift 工程引入 OC pod库一样,在该类里面导入头文件即可。

    import MJRefresh

    遇到问题


    问题1:引入swift pod库 问题


    如果我们 OC 项目 是没有 使用use_frameworks!。那我们导入swift Pod库 就会报错。


    那我们就在工程配置里面 Build Settings里面,搜索 Defines Module, 更改为 YES 即可。




    问题2:OC 类继承问题


    OC的类是不能继承至Swift的类,但Swift 类是可以继承 OC类的,其实方式也是"Target-Bridging-Header.h"导入头文件即可。


    问题3:宏定义问题


    我们自己重新一份
    原来的是

    #define kScreenWidth        [UIScreen mainScreen].bounds.size.width                      
    #define kScreenHeight [UIScreen mainScreen].bounds.size.height

    现在的是

    let kScreenWidth = UIScreen.main.bounds.width
    let kScreenHeight = UIScreen.main.bounds.height

    有一些,我们可以定义问方法来替代宏。


    问题4:OC经常调用swift库导入问题


    我们知道xxx-Swift.h都是包含所有swift 提供给 OC 使用的类,所以我们可以把xxx-Swift.h放到 pch 文件里面,就可以在任意一个 OC 工程文件直接调用 swift 类。


    OC 在线转为 swift


    提供一个链接,可以支持 OC 转为 swift。
    在线链接


    最后


    经过上面的总结,OC 项目 使用 swift 开发 的确是问题不大,使用过程中可能也会遇到编译问题,找不到文件问题,只要细心排查,也是很容易解决,那等后续项目用上正轨,还会把遇到的坑填补上来,如有不足,欢迎指点。


    作者:可爱亲宝宝
    链接:https://juejin.cn/post/7153564241659297806
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    鸿蒙终于不套壳了?纯血 HarmonyOS NEXT 即将到来

    对于移动开发者来说,特别是 Android 开发而言,鸿蒙是不是套壳 Android 一直是一个「热门」话题,类似的问题一直是知乎的「热点流量」之一,特别是每次鸿蒙发布新版本之后,都会有「套娃式」的问题出现。 例如最近 HDC 刚发布了鸿蒙 4.0 ,但是问题...
    继续阅读 »

    对于移动开发者来说,特别是 Android 开发而言,鸿蒙是不是套壳 Android 一直是一个「热门」话题,类似的问题一直是知乎的「热点流量」之一,特别是每次鸿蒙发布新版本之后,都会有「套娃式」的问题出现。


    例如最近 HDC 刚发布了鸿蒙 4.0 ,但是问题已经提到了 6.0 ,不过也算是误打误撞,在 4.0 发布之后,华为宣布了 HarmonyOS NEXT 版本



    HarmonyOS NEXT 在 2023 年 8 月 6 日开始面向合作企业开发者开放,2024 年第一季度面向所有开发者开放,也就是明年开始,更新后的鸿蒙,会使用全自研内核,去掉了传统的 AOSP 代码,仅支持鸿蒙内核和鸿蒙系统的应用,减少了 40% 的冗余代码,使系统的流畅度、能效、纯净安全特性大为提升




    也就是说,你的 Android APK 已经不能在 HarmonyOS NEXT 上运行,因为系统已经不存在 AOSP 代码,甚至没有 JVM 。



    虽然我们一直在吐槽鸿蒙套壳,但是这波华为终于是打算「釜底抽薪」,靠着 AOSP 「养住」开发者生态之后,这次终于开始「杀鸡取卵」。



    这里不得不提到「纯血」这个词,虽然华为在此之前的宣传口径一直是纯国产自研,但是看来华为自身还是清楚里面的「血统不纯」,而这次决定「大换血」,“减少了 40% 的冗余代码” 的说法,就很有意思。




    但是其实对于开发者来说,特别是移动端开发者来说,其实这是好事,我不从商业角度考虑,仅仅是从开发者生态考虑,因为移动端现在已经好久没有新活跃了,HarmonyOS NEXT 的全新适配工作应当大部分会落在 Android 开发上,或者说是否会新增全新的 HarmonyOS 开发岗位?



    主要是转化的门槛不高,不过第一批吃螃蟹的,网上的资料肯定会相不足。




    在全新的开发框架下, HarmonyOS NEXT 会采用全新自研的 ArkTS 和 ArkUI ,从目前看来,也就是你可能再也不能使用 Java 开发鸿蒙应用了,并且 ArkTS 是直接采用 AOT 编译优化,所以渲染模式可能会更接近 Flutter 和 Compose 的结构情况。




    事实上从目前的文档和开发体验上看,控件结构和开发模式十分贴近 Flutter 和 Compose ,这对于相关领域的开发者来说可以说是能力加强,所以目前对于 HarmonyOS NEXT 来说,未来的生态适配难度会进一步降低。





    即有适配负担,又有工作机遇,新技术领域代表存在新的红利,至少华为走在了 App 端「原生纯响应式开发」的前沿。



    目前,华为已经从设计资源,编程语言,编译器到开发工具、调测工具实现全面升级,HarmonyOS SDK 升级至 API 10 端云一体,可以一次性集成。


    另外一点是关于 ArkUI 的跨平台,这一点类似于苹果生态的一次开发多端部署,采用自研的 「方舟图形渲染」, HarmonyOS 也实现了类似手机,平板和电脑的统一「跨平台」效果。






    目前猜测还是会机遇 Skia 底层支持。



    最后就是大家关心的 HarmonyOS NEXT 会不会和 WPhone 一样遭遇滑铁卢,目前看来华为之前的技术积累和开发者关系运营的还不错:



    根据 HDC 最新数据,鸿蒙生态的设备数量目前已超过 7 亿,已有 220 万 HarmonyOS 开发者投入到鸿蒙世界的开发中,API 日调用 590 亿次,软硬件产品超过 350 款。




    华为鸿蒙 SDK 这些年确实沉淀了一部分开发者,虽然实际多少不清楚,但是这让鸿蒙 Next 不是从 0 开始,另外目前也有部分企业开始主动适配鸿蒙,并且华为提出了全新的鸿飞计划,在 3 年时间里投入 100 亿元资金支持鸿蒙生态建设



    所以短期可能会有阵痛,但是 HarmonyOS NEXT 的基础其实挺好,不管是类似 Flutter/ Compose 的开发方式,还是原本已经存在的开发者基础,更有相关的政策扶持,很难看出鸿蒙会在明年遭遇滑铁卢的情况。



    其实到这里我有个疑问,那就是 HarmonyOS NEXT 的生态会不会支持侧载,这决定了 HarmonyOS NEXT 之后的生态发展路线。



    如果必须上架商店才能分发,这又是另外一个故事了。



    最后就是现阶段的框架,例如 React Native 和 Flutter 能不能跑?官方目前已经有相关适配支持,目前消息上看:



    • RN 相关适配已经完成 60%

    • 游戏相关如 Unity 引擎,如前面提到过的新闻,其实游戏适配是最容易的

    • 最后 Flutter ,目前看来 Flutter For HarmonyOS 应该需要有好心社区进行适配




    让我们最后一起期待纯血的鸿蒙可以走多

    作者:恋猫de小郭
    来源:juejin.cn/post/7264237761158643773
    远。


    收起阅读 »

    最强实习生!什么?答辩刚结束,领导就通知她转正成功了?

    文章目录 写在前面 灵魂三问 第一问,你了解转正流程吗? 第二问,实习期间的我为团队做了什么? 第三问,基础知识还记得吗? 一个日常实习阶段小tip 写在最后 FAQ时间 写在前面 熟知我的人应该都知道我是实习转正上岸字节的。 那是一个平平淡淡的下...
    继续阅读 »

    文章目录



    写在前面


    熟知我的人应该都知道我是实习转正上岸字节的。


    那是一个平平淡淡的下午,leader突然神神秘秘凑到我身边:“一恩,快秋招了。我给你预留了一个HC,快准备准备转正答辩吧。”

    于是乎,伴随着leader自以为充满关怀的安排下,我开始轰轰烈烈筹备起自己的转正大业。


    和很多小伙伴一样,我刚刚准备转正时非常茫然无措。因为转正并没有明确的大纲,且不同业务、不同部门考核的形式都是不确定的,在网上搜索经验资料也少得可怜。


    在这里插入图片描述


    别急,转正的内容和形式虽然具有不确定性,但其固有流程又决定了他存在着一定的“潜规则”。下面一恩姐姐就带你发出灵魂三问,深度剖析转正那些不得不说的套路。


    灵魂三问


    第一问,你了解转正流程吗?


    转正流程对于各个公司大同小异。


    以字节为例,需要当年毕业的同学,在出勤满40个工作日(技术和测试序列)且经过部门Leader和HR同意后,即有资格发起转正流程。此时HR会根据评估人和候选人的时间,约一个时间组织进行转正答辩。这个短短1个小时的转正答辩,决定了你的去留。

    在这里插入图片描述


    转正答辩上,一般包含你的HR,部门领导和跨部门的领导。除了跨部门的领导外,其他人都是你在实习过程中可能一起干过饭喝过酒,讨论过诗和远方的伙伴。只要在实习过程中没有发生过什么反目成仇的惨剧,他们都是偏向你的,甚至私下有过“兄弟情义”,“生死之交”还会去引导你去把控答辩的节奏。


    比如我就听过自己的同事说过,当时他的导师还在答辩时争着抢着帮他解答领导的问题……

    在这里插入图片描述


    所以你所需要的做的基本只有一件事:


    就是保证转正答辩的过程是顺利的。


    整个答辩过程基本分为三块,其中属于你的有效时间仅有两块。第一块为个人展示,你需要以PPT的形式去描述一下实习期间工作,这一块大约有40min;第二块为问答环节,评估人会去根据你的工作与业务询问一些项目及基础知识,这一块大约有20min;第三块为审判环节,评估人会根据转正答辩过程中对你的了解决定你最终的去留。


    因此,只有利用好有效的两块时间,才能Hold住整个答辩过程,让评估人被你的魅力闪瞎双眼!


    第二问,实习期间的我为团队做了什么?


    日常有随时记录工作进度的好习惯,因此我非常迅速地将自己实习阶段的工作按照优先级总结总结写了一下答辩PPT。导师在看了我的草稿后,一个劲儿吐槽:“比起你在这里学的东西,老板们更关心的是你给团队和业务带来的产出,你跟别人做这件事的区别在哪里?你在团队的定位是什么?拿出让他们去选择你的理由吧!


    这与我一开始想的完全不一样。我本以为答辩就是汇报自己学了什么,做了什么。但其实不是,公司看中的是你的个人想法和价值实现,以及你身上是否有可输出的内容。


    你的一言一行都要表达出:你是完全能胜任这个职位的。


    想明白这点,我重新组织了自己的PPT和答辩的内容:


    首先,我用了一页画了一个时间轴,分别用关键词总结每一part工作主要内容,核心,和工作亮点项目。这一部分重在简洁清晰。目的是让评审人清晰的了解我的工作内容重点和核心。

    在这里插入图片描述


    接下来,我选择2~3个核心项目详细地介绍工作内容并量化自己的产出。如果大家不清楚如何介绍的话,可以参考金字塔原理中 先总后分的表达方式——先给你的听众一个核心结论,在后面逐层展开。



    比如我去介绍自己做多人视频通话这个需求时,首先需求的背景是需要支持多个人一起视频通话,我的主要工作是技术方案的设计与开发,具体工作是通过获取多路视频流,并将视频流分给对应的成员,因此我需要去维护所有成员的视图窗口以及流的稳定性与正确性。为了实现这个功能,我去了解了视频流编码,推拉流的逻辑,并且与多媒体业务同学进行了沟通,保证整体形成一条稳定的通路。

    在这里插入图片描述


    (截图取自我的PPT答辩文档,针对强化通话感知的需求,我列出了需求的目标,以及技术方案,并采用流程图方便说明,以及最后写上了需求的收益)



    第三部分我会去对自己的价值角色进行提炼,即向评估人去证明自己的独特价值以及在团队中的定位。如果你不知道如何去证明,那就将这个问题回答好:凭什么别人要选择你而不选择别人?


    最后一部分可以向评估人讲述一下自己的期望和未来的规划,我当时是舒情并茂地表达了自己对团队的热爱和对前景的向往,并表达了自己对未来的无限期盼。说的导师当场差点“热泪盈眶”。


    以及提供给大家一个小妙招,作为一名研发,如果拥有产品思维,无疑是非常加分的。因此大家可以对自己所在的业务从产品本身进行思考,比如能做些什么才能让产品吸引更多用户,以及在产品上有什么意见和规划。


    在这里插入图片描述


    第三问,基础知识还记得吗?


    在40分钟ShowTime之后,剩余20分钟评估人可能会针对你的某个具体项目询问一些实现上的细节,也有可能会询问一些技术方案设计上的问题。因此需要保证你所介绍的每一个项目都是你切身参与且明确其中实现的技术方案与细节,而且你应该提前去准备一些代码或技术上可扩展或优化的思考,来体现出你对项目的一种全局的视角。


    同时评估人也会针对你目前所处团队的业务特性去询问一些基础问题,这一点和面试比较像,虽然难度比较于面试会简单很多。但也需要去多少刻意准备一些基础知识。比如我做视频通话业务,当时评估人就问我,你觉得通话传输的音视频流信息是通过udp还是tcp传输的,以及他们的区别。


    这些问题是不是对于现在的你实在太简单了?


    一个日常实习阶段小tip


    不清楚大家在日常工作的过程中有没有对自己工作进行总结的习惯。如果没有,请从现在开始,立!刻!记!录!


    “记录”这个行为听起来难度很高,其实真正实施起来你会发现它就像一种“陪伴”,非常潜移默化地融入你的生活中。


    我会在日常工作过程中我会将自己的每一份思考和产出都落地文档并定时整理与复盘,每周五下班前会抽出15分钟将本周的工作以及下周需要做的事情整理成一个TODO列表。且会以月为纬度进行一次工作量和心态的反思,并与导师进行一次整体沟通,这种定期的总结和复盘能够让我永远对自己保持清醒。


    当我整理自己实习工作时,这些文字更是我的宝藏,我能很清楚地看到自己日积月累的自我升级,并非常轻松地以时间线的角度看出自己各个阶段的产出。


    写在最后


    希望大家在实习期间一直保持一个谦卑学习的态度,正式阶段繁重的工作压力会让你没有过多心思去进行一些软硬实力的提高。


    因此实习是一个非常好的机会去适应、去成长,一定要耐心地倾听、观察,向身边优秀的同事学习。


    相信在以后的工作中,你一定也能如鱼得水,熠熠生辉。

    在这里插入图片描述




    FAQ时间


    Q1:工作上犯了个常识性错误,感觉转正无望,该不该及时止损?

    首先,要明白,作为实习生,犯错是一件正常的事。错误才能让你意识到不足,才能成长。转正评估的不是你的过去,而是你的价值和你可以塑造的可能性。如果你能对自己过去的工作上的错误进行复盘与总结,并且能够对未来进行合理的规划。相信你也能给出一份完美的转正答卷。


    Q2:秋招无望走实习转正是否可行?

    这个选择是完全没有问题的。实习不仅能够提高转正的几率,也是给你一定机会提前感受一下社会环境,在体验过真实互联网工作环境后,有些人会明白自己是否合适,才会有更精确的职业规划。


    新增一个小栏目,收集着目前为止小伙伴们私信一恩的一些关于实习转正问题的答复。如果大家还有其他问题欢迎继续

    作者:李一恩
    来源:juejin.cn/post/7257434794900832312
    在评论区回复,一恩会一一回答的~

    收起阅读 »

    向前兼容与向后兼容

    2012年3月发布了Go 1.0,随着 Go 第一个版本发布的还有一份兼容性说明文档。该文档说明,Go 的未来版本会确保向后兼容性,不会破坏现有程序。 即用10年前Go 1.0写的代码,用10年后的Go 1.18版本,依然可以正常运行。即较高版本的程序能正常...
    继续阅读 »

    2012年3月发布了Go 1.0,随着 Go 第一个版本发布的还有一份兼容性说明文档。该文档说明,Go 的未来版本会确保向后兼容性,不会破坏现有程序。


    即用10年前Go 1.0写的代码,用10年后的Go 1.18版本,依然可以正常运行。即较高版本的程序能正常处理较低版本程序的数据(代码)


    反之则不然,如之前遇到过的这个问题[1]:在Mac上用Go 1.16可正常编译&运行的代码,在cvm服务器上Go 1.11版本,则编译不通过;


    再如部署Spring Boot项目[2]时遇到的,在Mac上用Java 17开发并打的jar包,在cvm服务器上,用Java 8运行会报错




    一般会认为向前兼容是向之前的版本兼容,这理解其实是错误的。


    注意要把「前」「后」分别理解成「前进」和「后退」,不可以理解成「从前」和「以后」


    线上项目开发中,向后(后退)兼容非常重要; 向后兼容就是新版本的Go/Java,可以保证之前用老版本写的程序依然可以正常使用




    前 forward 未来拓展。


    后 backward 兼容以前。







    • 向前兼容(Forward Compatibility):指老版本的软/硬件可以使用或运行新版本的软/硬件产生的数据。“Forward”一词在这里有“未来”的意思,其实翻译成“向未来”更明确一些,汉语中“向前”是指“从前”还是“之后”是有歧义的。是旧版本对新版本的兼容 (即向前 到底是以前还是前面?实际是前面





    • 向上兼容(Upward Compatibility):与向前兼容相同。









    • 向后兼容(Backward Compatibility):指新的版本的软/硬件可以使用或运行老版本的软/硬件产生的数据。是新版本对旧版本的兼容





    • 向下兼容(Downward Compatibility):与向后兼容相同。











    软件的「向前兼容」和「向后兼容」如何区分?[3]


    参考资料


    [1]

    这个问题: https://dashen.tech/2021/05/30/gvm-%E7%81%B5%E6%B4%BB%E7%9A%84Go%E7%89%88%E6%9C%AC%E7%AE%A1%E7%90%86%E5%B7%A5%E5%85%B7/#%E7%BC%98%E8%B5%B7

    [2]

    部署Spring Boot项目: https://dashen.tech/2022/02/01/%E9%83%A8%E7%BD%B2Spring-Boot%E9%A1%B9%E7%9B%AE/

    [3]

    软件的「向前兼容」和「向后兼容」如何区分?: https://www.zhihu.com/question/47239021



    作者:fliter
    来源:mdnice.com/writing/b8eb5fdae77f42e897ba69898a58e0d8
    收起阅读 »