注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

uni-app开发小程序:项目架构以及经验分享

uni-app开发小程序:项目架构以及经验分享 2022年的时候,公司为了快速完成产品并上线,所以选用微信小程序为载体;由于后期还是打算开发App;虽然公司有ios和Android,但是如果能一套代码打包多端,一定程度上可以解决成本;前端技术栈也是vue,在...
继续阅读 »

uni-app开发小程序:项目架构以及经验分享



2022年的时候,公司为了快速完成产品并上线,所以选用微信小程序为载体;由于后期还是打算开发App;虽然公司有iosAndroid,但是如果能一套代码打包多端,一定程度上可以解决成本;前端技术栈也是vue,在考察选择了uni-app。后来多个小程序项目都采用了uni-app开发,积累了一定的经验以及封装了较多业务组件,这里就分享一下uni-app项目的整体架构、常用方法封装以及注意事项。全文代码都会放到github,先赞后看,年入百万!



创建项目


uni-app提供了两种创建项目的方式:




⚠️需要注意的是,一定要根据项目需求来选择项目的创建方式;如果只是单独的开发小程序App,且开发环境单一,可以使用HBuilderX可视化工具创建。如果多端开发,以及同一套代码可能会打包生成多个小程序建议使用vue-cli进行创建,不然后期想搞自动化构建以及按指定条件进行编译比较痛苦。关于按条件编译,文章后面会有详细说明。



使用vue-cli安装和运行比较简单:


1.全局安装 vue-cli


npm install -g @vue/cli

2.创建uni-app


vue create -p dcloudio/uni-preset-vue 项目名称

3.进入项目文件夹


cd 项目名称

4.运行项目,如果是已微信小程序为主,可以在package.json中的命令改为:


"scripts": {
"serve": "npm run dev:mp-weixin"
}

然后执行


npm run serve

使用cli创建项目默认不带css预编译,需要手动安装一下,这里已sass为例:


npm i sass --save-dev
npm i sass-loader --save-dev

整体项目架构


通过HBuilderX或者vue-cli创建的项目,目录结构有稍许不同,但基本没什么差异,这里就按vue-cli创建的项目为例,整体架构配置如下:


    ├──dist 编译后的文件路径
├──package.json 配置项
├──src 核心内容
├──api 项目接口
├──components 全局公共组件
├──config 项目配置文件
├──pages 主包
├──static 全局静态资源
├──store vuex
├──mixins 全局混入
├──utils 公共方法
├──App.vue 应用配置,配置App全局样式以及监听
├──main.js Vue初始化入口文件
├──manifest.json 配置应用名称、appid等打包信息
├──pages.json 配置页面路由、导航条、选项卡等页面类信息
└──uni.scss 全局样式

封装方法


工欲善其事,必先利其器。在开发之前,我们可以把一些全局通用的方法进行封装,以及把uni-app提供的api进行二次封装,方便使用。全局的公共方法我们都会放到/src/utils文件夹下。


封装常用方法


下面这些方法都放在/src/utils/utils.js中,文章末尾会提供github链接方便查看。如果项目较大,建议把方法根据功能定义不同的js文件。


小程序Toast提示


/**
* 提示方法
* @param {String} title 提示文字
* @param {String} icon icon图片
* @param {Number} duration 提示时间
*/

export function toast(title, icon = 'none', duration = 1500) {
if(title) {
uni.showToast({
title,
icon,
duration
})
}
}

缓存操作(设置/获取/删除/清空)


/**
* 缓存操作
* @param {String} val
*/

export function setStorageSync(key, data) {
uni.setStorageSync(key, data)
}

export function getStorageSync(key) {
return uni.getStorageSync(key)
}

export function removeStorageSync(key) {
return uni.removeStorageSync(key)
}

export function clearStorageSync() {
return uni.clearStorageSync()
}

页面跳转


/**
* 页面跳转
* @param {'navigateTo' | 'redirectTo' | 'reLaunch' | 'switchTab' | 'navigateBack' | number } url 转跳路径
* @param {String} params 跳转时携带的参数
* @param {String} type 转跳方式
**/

export function useRouter(url, params = {}, type = 'navigateTo') {
try {
if (Object.keys(params).length) url = `${url}?data=${encodeURIComponent(JSON.stringify(params))}`
if (type === 'navigateBack') {
uni[type]({ delta: url })
} else {
uni[type]({ url })
}
} catch (error) {
console.error(error)
}
}

图片预览


/**
* 预览图片
* @param {Array} urls 图片链接
*/

export function previewImage(urls, itemList = ['发送给朋友', '保存图片', '收藏']) {
uni.previewImage({
urls,
longPressActions: {
itemList,
fail: function (error) {
console.error(error,'===previewImage')
}
}
})
}

图片下载


/**
* 保存图片到本地
* @param {String} filePath 图片临时路径
**/

export function saveImage(filePath) {
if (!filePath) return false
uni.saveImageToPhotosAlbum({
filePath,
success: (res) => {
toast('图片保存成功', 'success')
},
fail: (err) => {
if (err.errMsg === 'saveImageToPhotosAlbum:fail:auth denied' || err.errMsg === 'saveImageToPhotosAlbum:fail auth deny') {
uni.showModal({
title: '提示',
content: '需要您授权保存相册',
showCancel: false,
success: (modalSuccess) => {
uni.openSetting({
success(settingdata) {
if (settingdata.authSetting['scope.writePhotosAlbum']) {
uni.showModal({
title: '提示',
content: '获取权限成功,再次点击图片即可保存',
showCancel: false
})
} else {
uni.showModal({
title: '提示',
content: '获取权限失败,将无法保存到相册哦~',
showCancel: false
})
}
},
fail(failData) {
console.log('failData', failData)
}
})
}
})
}
}
})
}

更多函数就不在文章中展示了,已经放到/src/utils/utils,js里面,具体可以到github查看。


请求封装


为了减少在页面中的请求代码,所以我们要对uni-app提供的请求方式进行二次封装,在/src/utils文件夹下建立request.js,具体代码如下:



import {toast, clearStorageSync, getStorageSync, useRouter} from './utils'
import {BASE_URL} from '@/config/index'

const baseRequest = async (url, method, data, loading = true) =>{
header.token = getStorageSync('token') || ''
return new Promise((reslove, reject) => {
loading && uni.showLoading({title: 'loading'})
uni.request({
url: BASE_URL + url,
method: method || 'GET',
header: header,
timeout: 10000,
data: data || {},
success: (successData) => {
const res = successData.data
uni.hideLoading()
if(successData.statusCode == 200){
if(res.resultCode == 'PA-G998'){
clearStorageSync()
useRouter('/pages/login/index', 'reLaunch')
}else{
reslove(res.data)
}
}else{
toast('网络连接失败,请稍后重试')
reject(res)
}
},
fail: (msg) => {
uni.hideLoading()
toast('网络连接失败,请稍后重试')
reject(msg)
}
})
})
}

const request = {};

['options', 'get', 'post', 'put', 'head', 'delete', 'trace', 'connect'].forEach((method) => {
request[method] = (api, data, loading) => baseRequest(api, method, data, loading)
})

export default request

请求封装好以后,我们在/src/api文件夹下按业务模块建立对应的api文件,拿获取用户信息接口举例子:


/src/api文件夹下建立user.js,然后引入request.js


import request from '@/utils/request'

//个人信息
export const info = data => request.post('/v1/api/info', data)

在页面中直接使用:


import {info} from '@/api/user.js'

export default {
methods: {
async getUserinfo() {
let info = await info()
console.log('用户信息==', info)
}
}
}

版本切换


很多场景下,需要根据不同的环境去切换不同的请求域名、APPID等字段,这时候就需要通过环境变量来进行区分。下面案例我们就分为三个环境:开发环境(dev)、测试环境(test)、生产环境(prod)。


建立env文件


在项目根目录建立下面三个文件并写入内容(常量名要以VUE开头命名):


.env.dev(开发环境)


VUE_APP_MODE=build
VUE_APP_ID=wxbb53ae105735a06b
VUE_APP_BASE=https://www.baidu.dev.com

.env.test(测试环境)


VUE_APP_MODE=build
VUE_APP_ID=wxbb53ae105735a06c
VUE_APP_BASE=https://www.baidu.test.com

.env.prod(生产环境)


VUE_APP_MODE=wxbb53ae105735a06d
VUE_APP_ID=prod
VUE_APP_BASE=https://www.baidu.prod.com

修改package.json文件


"scripts": {
"dev:mp-weixin": "cross-env UNI_PLATFORM=mp-weixin vue-cli-service uni-build --watch --mode dev",
"build:mp-weixin": "cross-env UNI_PLATFORM=mp-weixin vue-cli-service uni-build --watch --mode prod"
},

然后执行


npm run dev:mp-weixin

/src/pages/index/index.vue下,打印:


onLoad() {
console.log(process.env.VUE_APP_MODE, '====VUE_APP_BASE')
console.log(process.env.VUE_APP_BASE, '====VUE_APP_BASE')
},

此时输出结果就是


dev ====VUE_APP_BASE
https://www.baidu.dev.com ====VUE_APP_BASE

动态修改appid


如果同一套代码,需要打包生成多个小程序,就需要动态修改appid了;文章开头说过appid在/src/manifest.json文件中配置,但json文件又不能直接写变量,这时候就可以参考官方 提出的解决方案:建立vue.config.js文件,具体操作如下。


在根目录下建立vue.config.js文件写入以下内容:


// 读取 manifest.json ,修改后重新写入
const fs = require('fs')

const manifestPath = './src/manifest.json'
let Manifest = fs.readFileSync(manifestPath, { encoding: 'utf-8' })
function replaceManifest(path, value) {
const arr = path.split('.')
const len = arr.length
const lastItem = arr[len - 1]

let i = 0
let ManifestArr = Manifest.split(/\n/)

for (let index = 0; index < ManifestArr.length; index++) {
const item = ManifestArr[index]
if (new RegExp(`"${arr[i]}"`).test(item)) ++i
if (i === len) {
const hasComma = /,/.test(item)
ManifestArr[index] = item.replace(
new RegExp(`"${lastItem}"[\\s\\S]*:[\\s\\S]*`),
`"${lastItem}": ${value}${hasComma ? ',' : ''}`
)
break
}
}

Manifest = ManifestArr.join('\n')
}
// 读取环境变量内容
replaceManifest('mp-weixin.appid', `"${process.env.VUE_APP_ID}"`)

fs.writeFileSync(manifestPath, Manifest, {
flag: 'w'
})

结尾


关于uni-app项目的起步工作就到这里了,后面有机会写一套完整的uni搭建电商小程序项目,记得关注。代码已经提交到github,如果对你有帮助,记得点个star!


作者:陇锦
来源:juejin.cn/post/7259589417736847416
收起阅读 »

😳骚操作玩这么花的吗?Android基于Act实现事件的录制与回放!

基于Activity封装实现录制与回放 前言 在前文中我们通过 ViewGr0up 实现过自己的录制与回放,但是那只是用于复(学)习,并不能真正在实际开发中应用上,或者说能用但是不好用需要大量的修改, 而大厂实现的录制与回放方案有很多种但大多都没有开源。一般在...
继续阅读 »

基于Activity封装实现录制与回放


前言


在前文中我们通过 ViewGr0up 实现过自己的录制与回放,但是那只是用于复(学)习,并不能真正在实际开发中应用上,或者说能用但是不好用需要大量的修改,


而大厂实现的录制与回放方案有很多种但大多都没有开源。一般在大厂会对应用的稳定性进行监控,不管是测试还是线上监控,都离不开用户操作的录制与回放。


一个 App 开发完成上架之后,一般我们会收集用户设备的内存帧率,崩溃信息,ANR信息等,这些都是基操,但是现在平台会提出了更高的要求,录制用户操作与回放用户操作,很多大厂都在进行这方面的探索。


目前业内做的比较好的录制与回放稳定性平台搭建包括不限于美团,爱奇艺,字节,网易,货拉拉等。


不同于测试阶段可以用 PC + ADB 实现录制与操作的思路,在应用内部我们就需要预先埋点用户的事件操作与回放逻辑,并且生成对应的日志信息。


那么实现录制与回放有哪些方法?哪一种更方便呢?本文只是探讨一下基于 Activity 实现的,比较简单的、比较基本的录制与回放功能,方便大家参考。


当然本文只是基于 Demo 性质,只用于本机录制本机回放,如果真要做到兼容多平台多设备,如需要ORC文本识别与图片识别进行定位,屏幕大小适配坐标等其他一系列的深入优化就不在本文的探讨范围。其实只要实现了核心功能,其他都是细枝末节需要时间打磨。


那么话不多说,Let's go


300.png


一、定义事件


在前文 ViewGr0up 的文章中,我们知道了事件的伪造与保存,如何定制伪造事件时间轴,如何分发伪造事件,本文也是一个思路。


整体思路基于前文 ViewGr0up 的例子,还是把事件用对象封装起来,只是我们封装的对象换成了 MotionEvent ,并且不需要修改内部的操作时间了,我们用事件对象的 time 时间来制作伪造事件触发的时间轴。


这样对于事件的录制我们就能直接通过 Activity 的事件分发 dispatchTouchEvent 中直接保存我们的事件对象了。


基于这个思路,我们的事件的对象封装:


public class EventState {
public MotionEvent event; //事件
public long time; //开始录制到该事件发生的时间
}

Activity的事件集合,方便后期扩展为多个Activity的事件队列,如果只需要录制一个 Activity 的事件那么则可以无需双重队列。


/**
* 以Activity为单位,以队列的形式存储MotionEvent
*/

public class ActEventStates {
/**
* 存储元素为一个队列,存放一个Act中的操作状态。如果有多个Act,则是双重队列
*/

public static Queue<Queue<EventState>> eventStates = new LinkedList<>();

public static boolean isRecord = false; //是否在录制

public static boolean isPlay = false; //是否在播放
}

为什么要用 Queue ?


首先我们只需要回放一次,如果想回放多次可以用持久化存储,对于已经回放过的事件我们不希望还存在内存中,特别是后期做多 Activity 之间的跳转之后的回放,如果之前的事件还存在内存中会有重复回放的问题,而用 List 去手动管理没有 Queue 方便。


二、录制


先定义一个开始与停止的方法:


  //开启录制
fun startRecord() {
//如果是录制状态
if (ActEventStates.isRecord) {
ActEventStates.isPlay = false

//初始化队列,对应一个Act是一个队列
activityEvents = LinkedList()
// Act录制事件的开始时间
startTime = System.currentTimeMillis()
//保存到内存中
ActEventStates.eventStates.add(activityEvents)
}
}

//停止录制
fun stopRecord() {
val state = EventState()
state.event = null
state.time = System.currentTimeMillis() - startTime
activityEvents?.add(state)
}

基于Act的录制,直接在分发事件的时候把事件从 Activity 级别就录制进去,这样只要在 Activity 层级之下的操作都能实现录制与回放了:


    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
//只有在录制状态下才会保存事件并添加到队列中
if (ActEventStates.isRecord && activityEvents != null) {
//不要直接存原始的 MotionEvent,因为用过就回收的,之前我们是通过自定义 Event 来做的,这里简单一点直接重新伪造一次其实更方便
val obtain = MotionEvent.obtain(ev)
//初始化自己的 EventState 用于保存当前事件对象
val state = EventState()
//赋值当前事件,用伪造过的事件
state.event = obtain
//赋值当前事件发生的时间
state.time = System.currentTimeMillis() - startTime
//把每一次事件 EventState 对象添加到队列中
activityEvents?.add(state)
}
return super.dispatchTouchEvent(ev)
}

每一行代码都尽量给出注释。


三、回放


其实和我们之前的 ViewGr0up 的思路是一致的,只是把自定义的事件换成原生的 MotionEvent 来保存,还是根据 Handler 分发不同事件的时间轴。


    //回放录制
fun playRecord() {
//如果是播放状态
if (ActEventStates.isPlay) {
ActEventStates.isRecord = false

//延时1秒开始播放
handler.postDelayed({
Thread {
if (!ActEventStates.eventStates.isEmpty()) {
//遍历每一个Act的事件,支持多个Act的录制与回放
val pop = ActEventStates.eventStates.remove()
while (!pop.isEmpty()) {
val state = pop.remove()
//根据事件的时间顺序播放
handler.postDelayed({
if (state.event == null) {
YYLogUtils.w("没了,回放录制完成")
} else {
dispatchTouchEvent(state.event)
}
}, state.time)

}
}
}.start()
}, 1000)
}
}

在当前的 Activity 中录制与回放的效果,具体的使用与效果:


    startRecode.click {
ActEventStates.isRecord = true
toast("开始录制")
startRecord()
}

endRecode.click {
ActEventStates.isRecord = false
toast("停止录制")
stopRecord()
}

//点击回放
btnReplay.click {
ActEventStates.isPlay = true
toast("回放录制")
playRecord()
}

act_record01.gif


单独的 Activity 上录制与回放是可以了,但是我们的应用又不是 Compose 或 Flutter,我们大部分项目还是多 Activity 的,如何实现多 Activity 跳转之后的录制与回放才是真正的问题。


四、多Activity的录制与回放


由于我们之前定义的数据格式就是 Queue 队列,所以我们很方便的就能实现多 Activity 的录制与回放效果,只需要在每一个 Activity 的 onResume 方法中尝试录制与播放即可。


由于当前的 Queue 的数据格式的性质,回放完成之后就没有了,跳转 Activity 之后就无需从头开始播放,特别适合这个场景。


只是需要注意的点是 Activity 的返回除了 Appbar 的页面返回按钮点击,我们还能使用系统的返回键或国产OS的左侧右侧滑动返回操作,所以我们需要对系统的返回操作单独做处理,修改之后的核心代码如下:


abstract class BaseActivity<VM : BaseViewModel> : AbsActivity() {

...

// ================== 事件录制 ======================

var handler = Handler(Looper.getMainLooper())

/**
* 存放当前activity中的事件
*/

private var activityEvents: Queue<EventState>? = null

/**
* 当前activity可见之后的时间点,每次 onResume 之后都创建一个新的队列,同时也赋值新的statetime
*/

private var startTime: Long = 0


override fun onResume() {
super.onResume()
startRecord() //尝试录制
playRecord() //尝试回放
}

//开启录制
protected fun startRecord() {
//如果是录制状态
if (ActEventStates.isRecord) {
ActEventStates.isPlay = false

//初始化队列,对应一个Act是一个队列
activityEvents = LinkedList()
// Act录制事件的开始时间
startTime = System.currentTimeMillis()
//保存到内存中
ActEventStates.eventStates.add(activityEvents)
}
}

//停止录制
protected fun stopRecord() {
val state = EventState()
state.event = null
state.time = System.currentTimeMillis() - startTime
activityEvents?.add(state)
}

override fun onBackPressed() {
val state = EventState()
state.event = null
state.isBackPress = true
state.time = System.currentTimeMillis() - startTime
activityEvents?.add(state)
super.onBackPressed()
}

//回放录制
protected fun playRecord() {
//如果是播放状态
if (ActEventStates.isPlay) {
ActEventStates.isRecord = false

//延时1秒开始播放
handler.postDelayed({
Thread {
if (!ActEventStates.eventStates.isEmpty()) {
//遍历每一个Act的事件,支持多个Act的录制与回放
val pop = ActEventStates.eventStates.remove()
while (!pop.isEmpty()) {
val state = pop.remove()
//根据事件的时间顺序播放
handler.postDelayed({
if (state.event == null) {
if (state.isBackPress) {
YYLogUtils.w("手动调用系统返回按键")
onBackPressed() //手动调用系统返回按键
} else {
YYLogUtils.w("没了,回放录制完成")
}

} else {
dispatchTouchEvent(state.event)
}
}, state.time)

}
}
}.start()
}, 1000)
}
}

override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
//只有在录制状态下才会保存事件并添加到队列中
if (ActEventStates.isRecord && activityEvents != null) {
//不要直接存原始的 MotionEvent,因为用过就回收的,之前我们是通过自定义 Event 来做的,这里简单一点直接重新伪造一次其实更方便
val obtain = MotionEvent.obtain(ev)
//初始化自己的 EventState 用于保存当前事件对象
val state = EventState()
//赋值当前事件,用伪造过的事件
state.event = obtain
//赋值当前事件发生的时间
state.time = System.currentTimeMillis() - startTime
//把每一次事件 EventState 对象添加到队列中
activityEvents?.add(state)
}
return super.dispatchTouchEvent(ev)
}
}

对于事件的封装我们添加了是否是系统返回的标记:


public class EventState {
public boolean isBackPress;
public MotionEvent event; //事件
public long time; //开始录制到该事件发生的时间
}

使用的方式就没有变化,我们添加几个 Activity 的跳转试试:


    startRecode.click {
ActEventStates.isRecord = true
toast("开始录制")
startRecord()
}

endRecode.click {
ActEventStates.isRecord = false
toast("停止录制")
stopRecord()
}

//点击回放
btnReplay.click {
ActEventStates.isPlay = true
toast("回放录制")
playRecord()
}

btnJump1.click {
TemperatureViewActivity.startInstance()
}
btnJump2.click {
ViewGr0up9Activity.startInstance()
}

效果:


act_record02.gif


为了区分实际手指操作与回放的操作的差异,我打开了开发者选项中的触摸反馈,第一次效果是带触摸反馈的,回放录制的效果是没有触摸反馈的,并且支持 Appbar的返回按键与系统的返回键。


如果想回放多次,则需要在停止录制的时候把事件保存到本地,如何保存对象到本地?和前文一样的思路,可以用Json,可以压缩,可以加密,甚至可以自定义数据格式与解析,这一个步骤就无需我多说了吧。


后记


回到前文,虽然自动化测试中我们常用到录制与回放的功能,但是对于线上的监控与云真机回放对于的操作,其实与类似Python自动化脚本还是有区别,与 PC + ADB 的方式也有区别,基于App本身实现的可以更好的用于线上的稳定性监控。


当然了由于本文是实验性质并不完善,浅尝辄止,只是提供一个思路,真要实现完整的功能并不是一个人短时间能搞出来的,如果你想要实现类似的功能可以参考实现。


比如后期如我们需要区分事件类型,点击的文本与图标,使用文本或图片识别进行定位,输入框的适配,等等一系列的功能并不是那么的容易还有很长的路要走,想起来都头皮发麻。


好了,关于最基础的功能来说的话,本机的 App 应用的录制与回放就讲到这里,那么除此方式之外还有哪些更方便的实现方式呢?我也很好奇,也欢迎大家交流讨论哦!


而对于本机其他第三方 App 应用的录制与回放又有哪些方式实现呢?这又是完全不同的另一个故事了。


言归正传,关于本文的内容如果想查看源码可以点击这里 【传送门】。你也可以关注我的这个Kotlin项目,我有时间都会持续更新。


惯例,我如有讲解不到位或错漏的地方,希望同学们可以指出交流。


如果感觉本文对你有一点点的启发,还望你能点赞支持一下,你的支持是我最大的动力啦!


Ok,这一期就此完结。



作者:Newki
来源:juejin.cn/post/7330104253825646601
收起阅读 »

拒绝代码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
收起阅读 »

终于搞明白了什么是同步屏障

背景 今天突然听到隔壁在讨论同步屏障,听到这个名字,我依稀记得 Handler 里面是有同步屏障机制的,但是具体的原理怎么有点模糊不清呢?就像一个明星,你明明看着面熟,就是想不起来他叫啥,让我这样的强迫症患者无比难受,所以抽时间来扒一扒同步屏障。 同步屏障机制...
继续阅读 »

背景


今天突然听到隔壁在讨论同步屏障,听到这个名字,我依稀记得 Handler 里面是有同步屏障机制的,但是具体的原理怎么有点模糊不清呢?就像一个明星,你明明看着面熟,就是想不起来他叫啥,让我这样的强迫症患者无比难受,所以抽时间来扒一扒同步屏障。


同步屏障机制


1. 直奔主题,同步屏障机制这几个字听起来很牛逼,能浅显的解释一下,先让大家明白它的作用是啥不?


同步屏障实际上就是字面意思,可以理解为建立一道屏障,隔离同步消息,优先处理消息队列中的异步消息进行处理,所以才叫同步屏障。


2. 第二个问题,同步消息又是啥呢?异步消息和同步消息有啥不一样呢?


要回答这个问题,我们就得了解一下 MessageMessage 的消息种类分为三种:



  • 普通消息(同步消息)

  • 异步消息

  • 同步屏障消息


我们平时使用 Handler 发送的消息基本都是普通消息,中规中矩的排到消息队列中,轮到它了再乖乖地出来执行。


考虑一个场景,我现在往 UI 线程发送了一个消息,想要绘制一个关键的 View,但是现在 UI 线程的消息队列里面消息已经爆满了,我的这条消息迟迟都没有办法得到处理,导致这个关键 View 绘制不出来,用户使用的时候很恼怒,一气之下给出差评这是什么垃圾 app,卡的要死。


此时,同步屏障就派上用场了。如果消息队列里面存在了同步屏障消息,那么它就会优先寻找我们想要先处理的消息,把它从队列里面取出来,可以理解为加急处理。那同步屏障机制怎么知道我们想优先处理的是哪条消息呢?如果一条消息如果是异步消息,那同步屏障机制就会优先对它处理。


3.那要如何设置异步消息呢?怎样的消息才算一条异步消息呢?


Message 已经提供了现成的标记位 isAsynchronous 用来标志这条消息是不是异步消息。


4.能看看源码了解下官方到底怎么实现的吗?


看看怎么往消息队列 MessageQueue 中插入同步屏障消息吧。


private int postSyncBarrier(long when) {
synchronized (this) {
final int token = mNextBarrierToken++;
final Message msg = Message.obtain();
msg.markInUse();
msg.when = when;
msg.arg1 = token;

Message prev = null;
// 当前消息队列
Message p = mMessages;
if (when != 0) {
// 根据when找到同步屏障消息插入的位置
while (p != null && p.when <= when) {
prev = p;
p = p.next;
}
}
// 插入同步屏障消息
if (prev != null) {
msg.next = p;
prev.next = msg;
} else {
msg.next = p;
// 前面没有消息的话,同步屏障消息变成队首了
mMessages = msg;
}
return token;
}
}

在代码关键位置我都做了注释,简单来说呢,其实就像是遍历一个链表,根据 when 来找到同步屏障消息应该插入的位置。


5.同步屏障消息好像只设置了when,没有target呢?


这个问题发现了华点,熟悉 Handler 的朋友都知道,插入消息到消息队列的时候,系统会判断当前的消息有没有 targettarget 的作用就是标记了这个消息最终要由哪个 Handler 进行处理,没有 target 会抛异常。


boolean enqueueMessage(Message msg, long when) {
// target不能为空
if (msg.target == null) {
throw new IllegalArgumentException("Message must have a target.");
}
...
}

问题 4 的源码分析中,同步屏障消息没有设置过 target,所以它肯定不是通过 enqueueMessage() 添加到消息队列里面的啦。很明显就是通过 postSyncBarrier() 方法,把一个没有 target 的消息插入到消息队列里面的。


6.上面我都明白了,下面该说说同步屏障到底是怎么优先处理异步消息的吧?


OK,插入了同步屏障消息之后,消息队列也还是正常出队的,显然在队列获取下一个消息的时候,可能对同步屏障消息有什么特殊的判断逻辑。看看 MessageQueuenext 方法:


Message next() {
...
// msg.target == null,很明显是一个同步屏障消息
if (msg != null && msg.target == null) {
// Stalled by a barrier. Find the next asynchronous message in the queue.
do {
prevMsg = msg;
msg = msg.next;
} while (msg != null && !msg.isAsynchronous());
}
...
}

方法代码很长,看源码最主要还是看关键逻辑,也没必要一行一行的啃源码。这个方法中相信你一眼就发现了
msg.target == null,前面刚说过同步屏障消息的 target 就是空的,很显然这里就是对同步屏障消息的特殊处理逻辑。用了一个 do...while 循环,消息如果不是异步的,就遍历下一个消息,直到找到异步消息,也就是 msg.isAsynchronous() == true


7.原来如此,那如果消息队列中没有异步消息咋办?


如果队列中没有异步消息,就会休眠等待被唤醒。所以 postSyncBarrier()removeSyncBarrier() 必须成对出现,否则会导致消息队列中的同步消息不会被执行,出现假死情况。


8.系统的 postSyncBarrier() 貌似也没提供给外部访问啊?这我们要怎么使用?


确实我们没办法直接访问 postSyncBarrier() 方法创建同步屏障消息。你可能会想到不让访问我就反射调用呗,也不是不可以。


但我们也可以另辟蹊径,虽然没办法创建同步屏障消息,但是我们可以创建异步消息啊!只要系统创建了同步屏障消息,不就能找到我们自己创建的异步消息啦。


系统提供了两个方法创建异步 Handler


public static Handler createAsync(@NonNull Looper looper) {
if (looper == null) throw new NullPointerException("looper must not be null");
// 这个true就是代表是异步的
return new Handler(looper, null, true);
}

public static Handler createAsync(@NonNull Looper looper, @NonNull Callback callback) {
if (looper == null) throw new NullPointerException("looper must not be null");
if (callback == null) throw new NullPointerException("callback must not be null");
return new Handler(looper, callback, true);
}

异步 Handler 发送的就是异步消息。


9.那系统什么时候会去添加同步屏障呢?


有对 View 的工作流程比较了解的朋友想必已经知道了,在 ViewRootImplrequestLayout 方法中,系统就会添加一个同步屏障。


不了解也没关系,这里我简单说一下。


(1)创建 DecorView


当我们启动了 Activity 后,系统最终会执行到 ActivityThreadhandleLaunchActivity 方法中:


final Activity a = performLaunchActivity(r, customIntent);

这里我们只截取了重要的一行代码,在 performLaunchActivity 中执行的就是 Activity 的创建逻辑,因此也会进行 DecorView 的创建,此时的 DecorView 只是进行了初始化,添加了布局文件,对用户来说,依然是不可见的。


(2)加载 DecorView 到 Window


onCreate 结束后,我们来看下 onResume 对应的 handleResumeActivity 方法:


@Override
public void handleResumeActivity(ActivityClientRecord r, boolean finalStateRequest,
boolean isForward, String reason)
{
...
// 1.performResumeActivity 回调用 Activity 的 onResume
if (!performResumeActivity(r, finalStateRequest, reason)) {
return;
}
...
final Activity a = r.activity;
...
if (r.window == null && !a.mFinished && willBeVisible) {
r.window = r.activity.getWindow();
// 2.获取 decorview
View decor = r.window.getDecorView();
// 3.decor 现在还不可见
decor.setVisibility(View.INVISIBLE);
ViewManager wm = a.getWindowManager();
WindowManager.LayoutParams l = r.window.getAttributes();
a.mDecor = decor;
l.type = WindowManager.LayoutParams.TYPE_BASE_APPLICATION;
l.softInputMode |= forwardBit;
...
if (a.mVisibleFromClient) {
if (!a.mWindowAdded) {
a.mWindowAdded = true;
// 4.decor 添加到 WindowManger中
wm.addView(decor, l);
} else {
a.onWindowAttributesChanged(l);
}
}
}
...
}

注释 4 处,DecorView 会通过 WindowManager 执行了 addView() 方法后加载到 Window 中,而该方法实际上是会最终调用到 WindowManagerGlobaladdView() 中。


(3)创建 ViewRootImpl 对象,调用 setView() 方法


// WindowManagerGlobal.ddView()
root = new ViewRootImpl(view.getContext(), display);
root.setView(view, wparams, panelParentView);

WindowManagerGlobaladdView() 会先创建一个 ViewRootImpl 实例,然后将 DecorView 作为参数传给 ViewRootImpl,通过 setView() 方法进行 View 的处理。setView() 的内部主要就是通过 requestLayout 方法来请求开始测量、布局和绘制流程


(4)requestLayout() 和 scheduleTraversals()


@Override
public void requestLayout() {
if (!mHandlingLayoutInLayoutRequest) {
checkThread();
mLayoutRequested = true;
// 主要方法
scheduleTraversals();
}
}

void scheduleTraversals() {
if (!mTraversalScheduled) {
// 1.将mTraversalScheduled标记为true,表示View的测量、布局和绘制过程已经被请求。
mTraversalScheduled = true;
// 2.往主线程发送一个同步屏障消息
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
// 3.注册回调,当监听到VSYNC信号到达时,执行该异步消息
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}

看到了吧,注释 2 的代码熟悉的很,系统调用了 postSyncBarrier() 来创建同步屏障了。那注释 3 是啥意思呢?mChoreographer 是一个 Choreographer 对象。


要理解 Choreographer 的话,还要明白 VSYNC


我们的手机屏幕刷新频率是 1s 内屏幕刷新的次数,比如 60Hz、120Hz 等。60Hz表示屏幕在一秒内刷新 60 次,也就是每隔 16.6ms 刷新一次。屏幕会在每次刷新的时候发出一个 VSYNC 信号,通知CPU进行绘制计算,每收到 VSYNC,CPU 就开始处理各帧数据。这时 Choreographer 就上场啦,当有 VSYNC 信号到来时,会唤醒 Choreographer,触发指定的工作。它提供了一个回调功能,让业务知道 VSYNC 信号来了,可以进行下一帧的绘制了,也就是注释 3 使用的 postCallback 方法。


当监听到 VSYNC 信号后,会回调来执行 mTraversalRunnable 这个 Runnable 对象。


final class TraversalRunnable implements Runnable {
@Override
public void run() {
doTraversal();
}
}

void doTraversal() {
if (mTraversalScheduled) {
mTraversalScheduled = false;
// 移除同步屏障
mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);

if (mProfile) {
Debug.startMethodTracing("ViewAncestor");
}
// View的绘制入口方法
performTraversals();

if (mProfile) {
Debug.stopMethodTracing();
mProfile = false;
}
}
}

在这个 Runnable 里面,会移除同步屏障。然后调用 performTraversals 这个View 的工作流程的入口方法完成对 View 的绘制。


这回明白了吧,系统会在调用 requestLayout() 的时候创建同步屏障,等到下一个 VSYNC 信号到来时才会执行相应的绘制任务并移除同步屏障。所以在等待 VSYNC 信号到来的期间,就可以执行我们自己的异步消息了。


参考


requestLayout竟然涉及到这么多知识点


关于Handler同步屏障你可能不知道的问题


“终于懂了” 系列:Android屏幕刷新机制—VSync、Choreographer 全面理解


作者:搬砖的代码民工
来源:juejin.cn/post/7258850748150104120
收起阅读 »

Android 居然还能这样抓捕和利用主线程碎片时间

本文作者: zy 图片来自:unsplash.com 在 Android 应用开发过程中,我们会将一些耗时任务放在子线程进行处理,从而避免出现主线程卡顿的情况。但是不可避免的,依然会出现有些任务必须要在主线程中执行,如果主线程需要执行的任务过多,会出现卡顿...
继续阅读 »

本文作者: zy




图片来自:unsplash.com


在 Android 应用开发过程中,我们会将一些耗时任务放在子线程进行处理,从而避免出现主线程卡顿的情况。但是不可避免的,依然会出现有些任务必须要在主线程中执行,如果主线程需要执行的任务过多,会出现卡顿的情况,那么接下来我们就应该思考如何解决这个问题。


背景与现状


在 Android 应用开发的过程中,对于必须要在主线程执行的代码逻辑,可以使用由 Android 系统提供的主线程空闲任务调度器 IdleHandler 来处理。但如果空闲任务调度器执行任务过于耗时,依然会导致 APP 卡顿或者跳帧。另外如果开发者想要移除部分的空闲调度器任务,是无法实现不了的。只能选择全部移除。


分析


为了减少主线程的卡顿,提高主线程资源的利用率,我们通过系统源码了解到页面渲染的部分关键过程。



上图所示,当页面 View 有更新操作时,会通过 Choreographer 去注册一个 VSYNC 信号监听,等待 VSYNC 信号的到来,VSYNC 信号到来后,会执行我们熟知的 measure,layout,draw 方法,然后将视图数据通过 swipeBuffer 移交给屏幕的 DataBuffer 区域,等待进一步处理。在这个过程中,如果绘制操作比较耗时,掺杂了我们的业务逻辑,页面就会变得卡顿,如果每一帧的绘制都是在两个标准的 VSYNC 信号之间完成的,页面操作和展示就会变得非常流畅。分析发现,当一个 VSYNC 信号到来之后,如果页面的绘制能够提前完成,那么主线程会有一段时间的空窗期,如果我们能利用这段空窗期做点事情,那么就可以解决主线程任务过多造成主线程卡顿问题。主线程空窗期示意图如下图所示。



VSYNC 信号到达应用层后,经历 measure,layout,draw 几个阶段,这里统称为 render 阶段,render 阶段结束之后,如果 MessageQueue 没有其它的消息,这时候主线程就会处于空闲状态,等待视图刷新触发下一个 VSYNC 信号的到来。这里我们通过 Choreographer 来监听 VSYNC 信号的到来作为开始标记,以及 render 结束后的信号作为结束标记。结束标记和开始标记之间的时间差就是当前帧率下的主线程实际耗时也就是 render 时长,当前设备标准帧率时长( 图示以 60HZ 的刷新帧率,16.6ms 一帧的周期为基准 )与 render 时长的时间差就是我们可利用的主线程时长,有了这个时长以及 render 结束的触发点,就可以执行我们主线程的任务了。


具体方案


主线程碎片时间管理通过四个模块来实现,分别是帧率耗时监控模块,空闲时间切片模块,耗时任务拆分模块,子任务智能调度模块。 在帧率耗时监控模块,通过 HOOK 系统对象,注入自己的监听回调,来获取当前帧的渲染开始时间点和结束时间时间点。在空闲时间切片模块,生成当前帧可用的空闲时间。利用耗时任务拆分模块获取到可以被调度执行的子任务,最后由子任务智能调度系统负责子任务的调度执行以及记录每个任务在当前 CPU 的执行耗时情况,在初始化的时候通过读取上次 CPU 任务执行耗时的数据生成一个任务耗时记录表,用于给空闲时间切片模块提供时间更加精准的任务匹配,防止出现跳帧的情况。


帧率耗时监控模块


分析模块中,我们阐述了 render 阶段表示的是 View 视图树的计算阶段,包含了视图树的测量,布局,绘制。当完成这些任务之后,将剩下的工作交给系统渲染阶段来处理,系统渲染阶段会负责将视图渲染至屏幕上,这里我们需要关心的就是 render 阶段,这个阶段完成之后,即可认为当前帧的主线程工作完成了,等待接受下一个 VSYNC 信号的到来。在 View 视图树的计算阶段中,由于每一次需要计算页面视图树的复杂程度不一样,因此 VSYNC 中各个刷新周期的 render 阶段耗时也是不一样的,我们就需要监控每一个 VSYNC 信号到来之后 View 视图树计算阶段的耗时。 View 视图树监控( 帧率耗时监控模块 )全流程如下图所示



帧率耗时监控模块执行步骤:



  • 步骤一:在应用启动阶段,获取当前进程的系统的 Choreographer 对象

  • 步骤二:创建视图帧开始渲染的监听回调,该回调除了首次由开发者手动注入至 Choreographer 对象中,后续的注入均由监听回调自己注入,当监听到渲染开始的回调后,再次将回调自己注入至 Choreographer 对象中,这样就能实现监听每一帧渲染开始的时间点,同时记录帧渲染开始时间

  • 步骤三:创建视图帧结束渲染的监听回调,和开始渲染的监听回调注册流程类似,最终也是获取到每一帧渲染结束的时间点,将帧渲染结束时间记录下来

  • 步骤四:在监听每一帧渲染结束之后,计算开始时间和结束时间的差值,这就是我们需要的每一帧可用的时间切片


其中 Choreographer 是系统提供用于 View 视图树的计算以及与屏幕交互渲染的类,由 Choreographer 来监听 VSYNC 信号,信号到来之后,就会通知 View 视图树进行计算处理,当处理完成之后,将计算后的数据交给屏幕进行渲染。当前模块利用反射机制向 Choreographer 中注入渲染开始和渲染结束的监控回调,监控代码插入位置如下图所示



帧率的耗时监控就是在 render 阶段,通过插入帧率开始回调监听和帧率结束回调监听来计算得出的。


空闲时间切片


我们可以通过耗时监控模块获取到两个时间戳,分别是 View 视图树计算阶段渲染开始的时间戳和渲染结束的时间戳,我们需要的空闲时间就是两者的差值。View 视图树计算阶段的 render 部分完成之后,视图的绘制就会交给系统进行渲染,而这个渲染的过程是在其他线程和进程进行执行,这样,当前 APP 的主线程就会空闲下来,我们就可以利用这个空闲时间做点其他的时间,这个空闲时间就被称为空闲时间切片


耗时任务拆分


有了主线程可用的空闲时间切片,接下来我们就需要将我们的耗时任务进行一个拆分,如何找到耗时任务呢?这里我们使用 systrace 进行耗时方法采集



上图所示,当前业务有一个 300MS 的主线程耗时逻辑,后面的几个 VSYNC 信号周期都很空闲,我们可以将当前耗时的任务进行拆分切割,然后将拆分后的任务打散至后面空闲的时间切片中延后执行,如图



接下来定义一套数据结构,将拆分的任务当作一个子任务用自定义的数据结构保存起来(要注意内存泄漏的问题,页面销毁后,如果还存在任务未执行,需要把未执行的任务全部清空)


class TraceTask(val bucketType: Int = BUCKET_TYPE_PRIORITY_30, val taskId: String = "", private val task: (() -> Unit)) {
fun invokeTask() {
task.invoke()
}
}

到这里,可执行的子任务集就准备好了。


子任务智能调度


空闲时间切片和子任务集生成后,就可以通知任务调度系统进行子任务的执行调度,在空闲时间切片中插入适合当前时间切片执行的任务,如当前空闲时间切片只有 3ms,那么就应该从 3ms 及以下的任务桶中把需要执行的任务选出来,然后执行任务。整个模块的流程图如图所示



子任务智能调度执行步骤:



  • 步骤一:由 VSYNC 消息触发的结束监听模块开始执行,获取当前需要添加的子任务,如果没有要添加的子任务就走子任务的执行逻辑,如果存在,就走子任务的数据绑定和子任务添加逻辑

  • 步骤二:子任务的数据绑定逻辑,将子任务和页面的生命周期进行绑定,这样做的好处是当页面销毁之后,绑定的子任务会自动删除,防止出现内存泄漏的情况。生命周期绑定之后,还需要绑定该子任务历史执行耗时,该模块是智能任务调度的核心,绑定历史执行耗时信息之后,在取子任务阶段,就可以快速获取到当前时间切片下可执行的任务了

  • 步骤三:获取绑定后的子任务,添加到耗时任务表中,使用MAP+链表结构,方便任务的快速获取与增删

  • 步骤四:判断当前是否存在子任务,如果存在可执行的子任务,则执行下一步操作,如果不存在可执行的子任务,跳出并结束当前流程。这里的任务查找是查看耗时任务表中是否还有任务元素存在

  • 步骤五:判断当前是否为调度超时模式,如果当前非调度超时模式,则获取空闲时间切片剩余可用的时长,通过剩余时长去耗时任务队列中查找当前时长内可用的任务,如果找到可执行任务后,则执行任务,同时减掉当前任务执行时长,获取到更新后的时间切片可用时长,然后回到步骤四继续循环。如果没有找到任务,则结束当前流程

  • 步骤六:如果当前为调度超时模式,则忽略剩余切片可用时长,找到耗时任务队列第一个任务元素,获取并执行。


智能任务调度核心


智能任务调度核心主要负责计算出当前任务的实际耗时,这样做的目的是确保任务执行的时长不会超过空闲时间切片的剩余时长,例如:空闲时间切片剩余时长是 6ms ,那么智能任务调度核心就需要负责找出6ms以内能够完成的任务。当前任务第一次的时长是由开发者给出的默认时长( 开发者在自己手机系统上执行后得出的实际任务时长 ),当任务执行一次之后,会将任务在当前系统上的实际耗时保存下来,每条任务会保存最近 5 条数据。后续再取任务时长的时候,会将当前任务的历史执行时长的最大值取出,当作该任务的执行时长保留下来。所有任务执行时长数据会保存在 SD 卡上,在 APP 启动时,子线程进行任务执行时长的数据加载,将数据加载至手机运行内存中,加快后续读取任务时长的速度,在本次任务执行结束之后,需要将获取到新一轮的执行时长更新至内存中,等待页面关闭时,统一将数据写入至 SD 中。 智能任务调度核心时长获取以及保存示意图如图所示



任务队列结构


这里我们我们采用 MAP 表(KEY-VALUE)来存储数据,其中 KEY 为 INT 型,以任务执行耗时作为 KEY,VALUE 为链表结构,链表的增删效率非常高。使用链表的结构来保存当前耗时 KEY 下的所有任务。链表结构如图所示



调度超时模式


空闲时间切片的最大剩余时长不会超过 16.6ms ,在不同机型上,由于机器性能差异,导致各个任务的实际执行耗时可能会超过 16.6ms,在智能任务调度阶段,可能就会出现有个别超时任务一直无法和空闲时间切片的剩余时长匹配上,因此这里会提供一种兜底超时逻辑,当任务队列 1s 内都没有任何任务被调度执行( 60HZ 的情况下,1s 会有 60 次的帧率调用,也就是会有 60 次的任务调度执行),但是队列又不为空,可以说明当前存在异常超时的任务,为了保证所有任务的正常执行,这里会设置一个调度超时模式的标志状态,当进入调度超时模式中后,会上报当前异常任务,由开发者判断当前任务是因为手机性能问题超时,还是任务拆分不合理导致的。而程序也会再次进入判断逻辑中,逻辑判断发现当前处于调度超时模式时,不会检测当前剩余时长,而是直接取 MAP 表中的第一个元素,获取第一个任务并执行。从而保证所有添加的耗时任务,无论是否匹配上,都会得到执行.


总结


通过任务拆分+主线程空闲时间调度的方式,可以有效的利用主线程的空闲时间,让它来合理的帮助我们完成主线程逻辑的执行,而不会对主线程造成拥堵,给用户带来更好的操作体验。


作者:网易云音乐技术团队
来源:juejin.cn/post/7329028515382820916
收起阅读 »

分享:一个超实用的文字截断技巧

web
文字截断是 Web 开发中非常普遍的一个需求,原因无他,很多时候我们无法确定展示文本的容器的宽度,如果不使用文字截断,要么文本被生硬的截断隐藏、要么文本破坏了预期中的布局。 Tailwind CSS 提供的文字截断的原子类: .truncate { over...
继续阅读 »

文字截断是 Web 开发中非常普遍的一个需求,原因无他,很多时候我们无法确定展示文本的容器的宽度,如果不使用文字截断,要么文本被生硬的截断隐藏、要么文本破坏了预期中的布局。


Tailwind CSS 提供的文字截断的原子类:


.truncate {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}

这 3 组 CSS 规则共同作用才可实现用 ... 截断文字的效果,缺一不可。



  • overflow: hidden 表示容器空间不足时内容应该隐藏,而非默认的 visible 完全展示

  • white-space: nowrap 表示文本容器宽度不足时,不能换行,其默认值为 normal,该行为不太好预测,但大部分情况下,它等同于 wrap 即文本尽可能的换行

  • text-overflow: ellipsis 指定文本省略使用 ... ,该属性默认值为 clip ,表示文本直接截断什么也不显示,这样展示容易对用户造成误解,因此使用 ... 更合适


接下来介绍一个在 PC Web 上很实用的交互效果:在需要截断的文本后面,紧跟一个鼠标 hover 上去才会展示的按钮, 执行一些和省略文本强相关、轻操作的动作。


Untitled.gif


如图所示,鼠标 hover 表示的按钮可以用来快速的编辑「标题」。下面介绍一下它的纯 CSS 实现。


首先,我们来实现一个基础版的。


Untitled 1.gif


代码:


<div class="container">
<p class="complex-line truncate">
<span class="truncate">海和天相连成为海岸线</span>
<span class="icon">❤️</span>
</p>
<p class="truncate">鱼和水相濡以沫的世界</p>
</div>

<style>
.truncate {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.container {
max-width: 100px;
}
.complex-line {
display: flex;
}
.complex-line:hover .icon {
display: block;
}
.icon {
display: none;
}
</style>

一些重点:



  • 容器 .container 必须是宽度固定、或者最大宽度固定的,以此确保容器不会因为子元素无止境的扩充。比如你可以设置容器的 widthmax-width 。在某些情况下,即使设置了 max-width 但不生效,此时可以尝试添加 overflow: hidden

  • 含有按钮的行 .complex-line 和子元素中展示文字的标签( .complex-line 下的 span.truncate),都要添加文字截断类 .truncate

  • 按钮 .icon 默认不展示,hover 当前行的时候才展示,这个也很常规,主要通过设置其 display 属性实现。

  • 🌟 接下来一步很巧妙,就是为含有按钮的行 .complex-line ,设置其为 Flex 容器,这主要是借助 Flex Item 的 flex-shrink 属性默认值为 1 的特性,允许含有文字的 Flex Item span.truncate 在按钮 span.icon 需要展示的时候进一步缩放,腾出空间,用于完整展示按钮。


这样便实现了基础版的文字截断 + 可以自动显示隐藏按钮的交互。


接下来再看看文章开头提到的这个交互:


Untitled.gif


它和基础版的样式最大的不同就是它是多列的,这里可以使用 Grid 网格布局实现。


<div class="container">
<label>标题:</label>
<p class="complex-line truncate">
<span class="truncate">高质量人才生产总基地</span>
<span class="icon">✏️</span
</p>
<label>编号:</label>
<p class="truncate">No.9781498128959</p>
</div>

<style>
.container {
display: grid;
grid-template-columns: auto 1fr;
/** ... */
}
</style>

其他样式代码和基础版中的一致,不再赘述。


总结


为了实现本文介绍的这种交互,应该确保:



  • 容器的宽度或最大宽度应该是固定的

  • 按钮前面的那个展示文字的元素,和它们的父元素,都应该有 .truncate 文字截断效果

  • 按钮的父元素应该为 Flex 容器,使得按钮显示时,文字所在标签可以进一步缩放


作者:人间观察员
来源:juejin.cn/post/7330464865315094554
收起阅读 »

Android 开发小技巧:属性扩展让代码写起来更轻松更易读

一、优化了什么问题 在Android开发中经常碰到设置文本颜色、背景色、背景资源等,每次都要写一大堆代码,如下所示: button.apply { background = ContextCompat.getDrawable(this@MainActi...
继续阅读 »

一、优化了什么问题


在Android开发中经常碰到设置文本颜色、背景色、背景资源等,每次都要写一大堆代码,如下所示:


button.apply {
background = ContextCompat.getDrawable(this@MainActivity,R.drawable.dinosaur)
setBackgroundColor(ContextCompat.getColor(this@MainActivity,R.color.black))
}

写多了脑壳痛,那能不能像前端语言那样简洁优雅呢?之前见过一种写法就是对Int进行扩展,就会看到奇奇怪怪的代码,如下:


//扩展函数
fun Int.getColor(): Int {
return ContextCompat.getColor(appContext, this)
}

//调用
button.apply {
setBackgroundColor(R.color.purple_700.getColor())
}

那能不能把一些常用的设置控件UI的方法都改造成setter访问方法呢?


二、通过扩展属性使代码更易写易读


在XML中常用的基础控件有TextView、ImageView、Button、EditText,那先从这几个控件入手就可以了。扩展属性只能在类的基础上添加属性,并不能覆盖,这一点需要注意。只需要下面几个扩展属性的方法,代码写起来就会舒服和易读很多。


/**
* 设置文本的颜色,Button、EditText、TextView都用此方法设置textColor
*/

var TextView.textColor: Int
get() {
return this.textColors.defaultColor
}
set(value) {
this.setTextColor(ContextCompat.getColor(this.context, value))
}

/**
* 给View设置drawable和mipmap资源
* 常见的控件都是继承View,所以都能用
*/

var View.backgroundResource: Int?
get() = 0 //get方法无意义返回0
set(value) {
if (value == null) {
//去掉背景
this.background = null
} else {
this.background = ContextCompat.getDrawable(this.context, value)
}
}

/**
* 给View设置背景颜色
* 常见的控件都是继承View,所以都能用
*/

var View.backgroundColor: Int
get() {
return (this.background as ColorDrawable).color
}
set(value) {
this.setBackgroundColor(ContextCompat.getColor(this.context, value))
}

/**
* 给ImageView设置drawable和mipmap资源
*/

var ImageView.imageResource: Int?
get() = 0 //get方法无意义返回0
set(value) {
if (value == null) {
//去掉图片
this.setImageDrawable(null)
} else {
this.setImageDrawable(ContextCompat.getDrawable(this.context, value))
}
}

/**
* 点击事件。可防止重复点击。
* ClickUtils.OnDebouncingClickListener是AndroidUtilCode库中的方法,项目中一般都有
*/

var View.onClick: (View) -> Unit
get() = {} //get方法无意义返回空的lambda
set(value) {
this.setOnClickListener(object : ClickUtils.OnDebouncingClickListener() {
override fun onDebouncingClick(v: View) {
value.invoke(v)
}
})
}

在TextView中使用


textView.apply {
text = getString(R.string.app_name)
//设置文本颜色
textColor = R.color.white
textSize = 15F
//设置背景颜色
backgroundColor = R.color.black
onClick = { //防止重复点击的点击事件

}
}

在ImageView中使用


imageView.apply {
//设置背景颜色
backgroundColor = R.color.black
//设置背景资源
backgroundResource = R.drawable.dinosaur
//设置src资源
imageResource = R.drawable.dinosaur
//如果项目中有创建Drawable的方法可以继续用background
background = shape(Shape.RECTANGLE) {
solid(R.color.white))
corners(8F.dp2px)
}
onClick = { //防止重复点击的点击事件

}
}

其他控件类似。


上面只是改造了常见控件的常用属性的写法,让代码写起来更简洁,更易读,并且添加了防止重复点击的方法,不至于在其他页面又写类似的逻辑。


作者:TimeFine
来源:juejin.cn/post/7311619723317510155
收起阅读 »

消息通知文字竖向无缝轮播组件实现历程

web
背景 最近有个需求需要做一个无缝轮播的消息通知,并且需要抽离成通用组件,记录下实现这个组件的历程。 先看效果 实现过程 思考(part-1) 因为刚开始给的设计稿是没有动画效果的,我刚开始想的效果是只有红色加粗的文字轮播,其他文字不变;然后想着看下有没...
继续阅读 »

背景



最近有个需求需要做一个无缝轮播的消息通知,并且需要抽离成通用组件,记录下实现这个组件的历程。



先看效果
noticeBar.gif


实现过程


思考(part-1)



因为刚开始给的设计稿是没有动画效果的,我刚开始想的效果是只有红色加粗的文字轮播,其他文字不变;然后想着看下有没有已经实现好的轮子,找到一个 js 库:react-text-loop;但是经过我的考虑,如果只是部分文字滚动,红色加粗的文字可能宽度不一样,会导致其他文字换位,所以还是想着整条文字滚动会比较好。



使用 antd-m Swiper 实现(part-2)



想到这个滚动效果在移动端应该很常见,antd-m 应该可能会有组件吧,去瞧瞧👀;noticeBar 组件只有横向滚动文字,没有竖直滚动的。既然没有,那就用其他方式实现吧,最简单的就是用 Swiper 组件设置成 竖向滚动,因为我负责的项目基本都用 antd-m,所以就用 antd-m 的 Swiper 来实现了。



实现过程遇到的问题


antd-m 的 Swiper 组件竖向滚动必须指定高度

在使用 antd-m 的 Swiper 组件竖向滚动的方式好像有问题,但是看文档的使用又是正常,结果发现竖向滚动需要指定高度,所以文档还是要仔细看清楚: Swiper 竖向文档


依赖冲突问题

在自己仓库使用很正常,一点问题都没有;然后打算抽离到我们项目的组件库中,然后在把项目中使用替换成组件库的包,过程很顺畅;过了段时间另一个项目要使用我们的组件,然后我就把包发给他,结果他说他项目里用不了,会报错。


然后 clone 他的项目试了下,果然是有问题的,因为他们项目里用的是 antd-m 2.x,2.x 没有 Swiper 组件,而我的组件库依赖的是 antd-m 5.x,看了下他们仓库用的是antd-m 2.x 和 5.x 共存的方式,可以看一下这个 antd-m 迁移指南,如果要两个版本共存且之前用的是组件按需导入,那么组件按需导入的配置也会有问题,因为两个版本的文件差异比较大,所以需要改一下按需导入的配置:


module.exports = {
"plugins": [
// 原配置
// [
// 'import',
// {
// libraryName: 'antd-mobile',
// libraryDirectory: 'lib',
// style: 'css',
// },
// 'antd-mobile',
// ]

// 修改为
[
'import',
{
libraryName: 'antd-mobile',
customName: (name, file) => {
const { filename } = file.opts;
if (filename.includes('/es/')) {
return `antd-mobile/es/components/${name}`;
}
return `antd-mobile/lib/${name}`;
},
style: (name, file) => {
const { filename } = file.opts;
if (filename.includes('/es/')) {
// 如果项目已经有 global 文件,return false 即可
// 如果没有,这样配置后就不需要手动引入 global 文件
return 'antd-mobile/es/global';
}
return `${name}/style/css`;
},
},
'antd-mobile',
]
]
}

想彻底解决这个依赖冲突问题


其实修改完配置之后使用就正常了,但是我考虑到如果之后想使用这个组件,如果 antd-m 版本不是 5.x,那么有一个项目就要改一个配置,很烦人;而且 antd-m Swiper 竖向需要指定高度,如果都需要指定高度了,那么我直接实现一个滚动动画应该也很简单吧,说干就干。



自己手写一个轮播组件(pard-3)



手动实现轮播还是比较简单的,只不过无缝轮播那里需要处理下,传统的方式都是通过在轮播 item 首尾复制 item 插入,当轮播到最后一个,用复制的 item 来承接,之后在回归原位正常滚动。



手写实现思路



  1. 传入轮播容器的高度,使用 transform: translate3d(0px, ${容器高度}, 0px); 每次移动列表 translateY 的距离为容器的高度。

  2. 处理无缝轮播,因为这个组件没有手动滑动轮播,自由自动向下轮播,所以不需要考虑反方向的轮播处理;当轮播到最后一个 item,那么就将第一个 item transform: translateY(${轮播列表高度}),这时候第一个就在最后一个下面,监听轮播列表 onTransitionEnd,判断当前是否轮播到第一个,是的话就将轮播列表的 translateY 的距离归 0。


最终实现代码



其实最后是封装成了一个 react 组件,但是掘金上为了大家看到更好的效果,用原生的方式简单写了下。如果需要封装成 react/vue 组件参考下方代码应该就够了。



容器未 hidden 效果



组件封装的设计



这里放一下我封装组件设计的 props



interface IProps {
/** 轮播通知 list */
list: any[];
/** noticebar 显隐控制 */
visible?: boolean;
/** 单个轮播 item 的高度, 传入 750 设计稿的数字,会转成 vw */
swiperHeight?: number;
/** 每条通知轮播切换的间隔 */
interval?: number;
/** 轮播动画的持续时间 */
animationDuration?: number;
/** 是否展示关闭按钮 */
closeable?: boolean;
/** 关闭按钮点击的回调 */
onClose?: () => void;
/** 自定义轮播 item 的内容 */
renderItem?: (item: any) => React.ReactNode;
/** notice 的标题 */
noticeTitle?: ReactNode;
/** notice 右边自定义 icon */
rightIcon?: ReactNode;
/** 是否展示 notice 左边 icon */
showLeftIcon?: boolean;
/** notice 左边自定义 icon */
leftIcon?: ReactNode;
/** 自定义类名 */
className?: string;
}

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

如何通过Kotlin协程, 简化"连续依次弹窗(Dialog队列)"的需求

效果预览 代码预览 lifecycleScope.launch { showDialog("签到活动", "签到领10000币") // 直到dialog被关闭, 才会继续运行下一行 showDialog("新手任务", "做任务领20000...
继续阅读 »

效果预览


r2t33-r5h2v.gif


代码预览


lifecycleScope.launch {
showDialog("签到活动", "签到领10000币") // 直到dialog被关闭, 才会继续运行下一行
showDialog("新手任务", "做任务领20000币") // 直到dialog被关闭, 才会继续运行下一行
showDialog("首充奖励", "首充6元送神装")
}

代码实现



要做到上一个showDialig()在关闭时才继续运行下一个函数,需要用到协程挂起的特性, 然后在 OnDismiss()回调中将协程恢复, 为了将这种基于回调的方法包装成协程挂起函数, 可以使用suspendCancellableCoroutine函数



suspend fun showDialog(title: String, content: String) = suspendCancellableCoroutine { continuation ->
MaterialAlertDialogBuilder(this)
.setTitle(title)
.setMessage(content)
.setPositiveButton("我知道了") { dialog, which ->
dialog.dismiss()
}
.setOnDismissListener {
continuation.resume(Unit)
}
.show()
}

进阶玩法



跳过某些弹窗, 例如第二个弹窗只显示一次



suspend fun showDialogOnce(title: String, content: String) = suspendCancellableCoroutine { continuation ->
val showed = SPUtils.getInstance().getBoolean(title) // SharedPreferences工具类
if (showed) {
continuation.resume(Unit)
return@suspendCancellableCoroutine
}
MaterialAlertDialogBuilder(this)
.setTitle(title)
.setMessage(content)
.setPositiveButton("我知道了") { dialog, which ->
dialog.dismiss()
}
.setOnDismissListener {
continuation.resume(Unit)
}
.show()
SPUtils.getInstance().put(title, true)
}

调用时只需要这样:


lifecycleScope.launch {
showDialog("签到活动", "签到领10000币")
showDialogOnce("新手任务", "做任务领20000币")
showDialog("首充奖励", "首充6元送神装")
}

这样'新手任务'的弹窗只会首次弹出, 后续只会弹出第一和第三个


作者:Joehaivo飞羽
来源:juejin.cn/post/7275943125821571106
收起阅读 »

提升网站性能的秘诀:为什么Nginx是高效服务器的代名词?

在这个信息爆炸的时代,每当你在浏览器中输入一个网址,背后都有一个强大的服务器在默默地工作。而在这些服务器中,有一个名字你可能听说过无数次——Nginx。今天,就让我们一起探索这个神奇的工具。一、Nginx是什么Nginx(发音为“enginex”)是一个开源的...
继续阅读 »

在这个信息爆炸的时代,每当你在浏览器中输入一个网址,背后都有一个强大的服务器在默默地工作。而在这些服务器中,有一个名字你可能听说过无数次——Nginx。今天,就让我们一起探索这个神奇的工具。

一、Nginx是什么

Nginx(发音为“enginex”)是一个开源的高性能HTTP和反向代理服务器。它由伊戈尔·赛索耶夫(IgorSysoev)于2002年创建,自那时起,Nginx因其稳定性、丰富的功能集、简单的配置文件以及低资源消耗而受到广大开发者和企业的喜爱。

Description

Nginx是一款轻量级的Web服务器/反向代理服务器及电子邮件(IMAP/POP3)代理服务器,在BSD-like协议下发行。

其特点是占有内存少,并发能力强,事实上nginx的并发能力在同类型的网页服务器中表现较好,中国大陆使用nginx网站用户有:百度、京东、新浪、网易、腾讯、淘宝等。

二、Nginx的反向代理与正向代理

Description

正向代理:

我们平时需要访问国外的浏览器是不是很慢,比如我们要看推特,看GitHub等等。我们直接用国内的服务器无法访问国外的服务器,或者是访问很慢。

所以我们需要在本地搭建一个服务器来帮助我们去访问。那这种就是正向代理。(浏览器中配置代理服务器)

反向代理:

那什么是反向代理呢。比如:我们访问淘宝的时候,淘宝内部肯定不是只有一台服务器,它的内部有很多台服务器,那我们进行访问的时候,因为服务器中间session不共享,那我们是不是在服务器之间访问需要频繁登录。

这个时候淘宝搭建一个过渡服务器,对我们是没有任何影响的,我们是登录一次,但是访问所有,这种情况就是反向代理。

对我们来说,客户端对代理是无感知的,客户端不需要任何配置就可以访问,我们只需要把请求发送给反向代理服务器,由反向代理服务器去选择目标服务器获取数据后,再返回给客户端。

此时反向代理服务器和目标服务器对外就是一个服务器,暴露的是代理服务器地址,隐藏了真实服务器的地址。(在服务器中配置代理服务器)

三、Nginx的负载均衡

什么是负载均衡?

负载均衡建立在现有网络结构之上,它提供了一种廉价有效透明的方法扩展网络设备和服务器的带宽、增加吞吐量、加强网络数据处理能力、提高网络的灵活性和可用性。

负载均衡(LoadBalance)其意思就是分摊到多个操作单元上进行执行,例如Web服务器、FTP服务器、企业关键应用服务器和其它关键任务服务器等,从而共同完成工作任务。

Description

负载均衡的主要目的是确保网络流量被平均分发到多个节点,从而提高整体系统的响应速度和可用性。它对于处理高并发请求非常重要,因为它可以防止任何单一节点过载,导致服务中断或性能下降。

Nginx给出来三种关于负载均衡的方式:

轮询法(默认方法):

每个请求按时间顺序逐一分配到不同的后端服务器,如果后端服务器down掉,能自动剔除。

适合服务器配置相当,无状态且短平快的服务使用。也适用于图片服务器集群和纯静态页面服务器集群。

weight权重模式(加权轮询):

指定轮询几率,weight和访问比率成正比,用于后端服务器性能不均的情况。

这种方式比较灵活,当后端服务器性能存在差异的时候,通过配置权重,可以让服务器的性能得到充分发挥,有效利用资源。weight和访问比率成正比,用于后端服务器性能不均的情况。权重越高,在被访问的概率越大。

ip_hash:

上述方式存在一个问题就是说,在负载均衡系统中,假如用户在某台服务器上登录了,那么该用户第二次请求的时候,因为我们是负载均衡系统,每次请求都会重新定位到服务器集群中的某一个。

那么已经登录某一个服务器的用户再重新定位到另一个服务器,其登录信息将会丢失,这样显然是不妥的。

你还在苦恼找不到真正免费的编程学习平台吗?可以试试【云端源想】!课程视频、知识库、微实战、云实验室、一对一咨询……你想要的全部学习资源这里都有,重点是现在还是免费的!

点这里即可查看!

我们可以采用ip_hash指令解决这个问题,如果客户已经访问了某个服务器,当用户再次访问时,会将该请求通过哈希算法,自动定位到该服务器。每个请求按访问ip的hash结果分配,这样每个访客固定访问一个后端服务器,可以解决session的问题。

四、Nginx的动静分离

为了加快网站的解析速度,可以把动态页面和静态页面由不同的服务器来解析,加快解析速度。降低原来单个服务器的压力。

Description

Nginx的静态处理能力很强,但是动态处理能力不足,因此,在企业中常用动静分离技术。

动静分离技术其实是采用代理的方式,在server{}段中加入带正则匹配的location来指定匹配项针对PHP的动静分离:

静态页面交给Nginx处理,动态页面交给PHP-FPM模块或Apache处理。在Nginx的配置中,是通过location配置段配合正则匹配实现静态与动态页面的不同处理方式。

五、Nginx特点

那么,Nginx到底有哪些特点让它如此受欢迎呢?让我们一起来探索。

1、高性能与低消耗

Nginx采用了事件驱动的异步非阻塞模型,这意味着它在处理大量并发连接时,可以有效地使用系统资源。与传统的服务器相比,Nginx可以在较低的硬件配置下提供更高的性能。这对于成本敏感的企业来说,无疑是一个巨大的优势。

2、高并发处理能力

得益于其独特的设计,Nginx能够轻松处理数万甚至数十万的并发连接,而不会对性能造成太大影响。这一点对于流量高峰期的网站尤为重要,它可以保证用户在任何时候访问网站都能获得良好的体验。

3、灵活的配置

Nginx的配置文件非常灵活,支持各种复杂的设置。无论是负载均衡、缓存静态内容,还是SSL/TLS加密,Nginx都能通过简单的配置来实现。这种灵活性使得Nginx可以轻松适应各种不同的使用场景。

4、社区支持与模块扩展

Nginx拥有一个活跃的开发社区,不断有新的功能和优化被加入到官方版本中。此外,Nginx还支持第三方模块,这些模块可以扩展Nginx的功能,使其更加强大和多样化。

5、广泛的应用场景

从传统的Web服务器到反向代理、负载均衡器,再到API网关,Nginx几乎可以应用于任何需要处理HTTP请求的场景。它的可靠性和多功能性使得它成为了许多大型互联网公司的基础设施中不可或缺的一部分。

Nginx以其卓越的性能和灵活的配置,赢得了全球开发者的青睐。它不仅仅是一个简单的Web服务器,更是一个强大的工具,能够帮助我们构建更加稳定、高效的网络应用。

无论是初创公司还是大型企业,Nginx都能在其中发挥重要作用。那么,你准备好探索Nginx的世界了吗?让我们一起开启这场技术之旅吧!

收起阅读 »

工作而已,千万不要上头了!

工作而已,千万不要上头了!请戒掉你那些没必要的“责任心”!职场中,有这么一类人,他们善良、事无巨细、爱操心。只要是自己工作岗位和自己相关的事情,就会负责到底,60分的事情,硬要做到90分,有假期不敢请,感觉项目缺了自己就不行。本着这样「把事做成」的心态,他们经...
继续阅读 »


工作而已,千万不要上头了!

请戒掉你那些没必要的“责任心”!

职场中,有这么一类人,他们善良、事无巨细、爱操心。

只要是自己工作岗位和自己相关的事情,就会负责到底,60分的事情,硬要做到90分,有假期不敢请,感觉项目缺了自己就不行。

本着这样「把事做成」的心态,他们经常头顶
「靠谱」「务实」「件件有着落,事事回音」的标签。

最后累身累心,还不被别人看见,做的越多,错的越多。

这类人是责任心太强了!

在职场这么多年,逐渐发现,责任心很强的人,在职场上普遍都过得不好:
➢ 明明做了很多,升职加薪却轮不到自己;
➢ 领导对别人要求却越来越低,对你却要求越来越高;
➢ 事情越做越多,关键老板还不觉得你做了。

这些现象背后,其实有一个隐秘的雷区:责任心过剩,你就输了!

不要因为责任心过剩,在职场无尽消耗自己。

找到自己内耗的来源后,运用好自己的优势,只做自己擅长的事,成功在职场顺利晋升。

如果你也想找到自己那件真正要做的事,就要去掉那些没用的责任心。

来源:mp.weixin.qq.com/s/p_GAch0qjAI-Or-u5Aj8gw

收起阅读 »

npm被滥用——上传700多个武林外传切片视频

据介绍,这些软件包每个大小约为 54.5MB,包名以 “wlwz” 作为前缀,并附带了应该是代表日期的数字。时间戳显示,这些包至少自 2023 年 12 月 4 日起就一直存在于 npm,但 GitHub 上周已经开始删除。相关链接:https://blog....
继续阅读 »

Sonatype 安全研究团队近日介绍了一起滥用 npm 的案例 —— 他们发现托管在 npm 的 748 个软件包实际上是视频文件。

据介绍,这些软件包每个大小约为 54.5MB,包名以 “wlwz” 作为前缀,并附带了应该是代表日期的数字。时间戳显示,这些包至少自 2023 年 12 月 4 日起就一直存在于 npm,但 GitHub 上周已经开始删除。

每个包中都有以 “.ts” 扩展名结尾的视频剪辑,这表明这些视频剪辑是从 DVD 和蓝光光盘中翻录的。

这里的 ts 不是 TypeScript 文件,而是 transport stream 的缩写,全称为 “MPEG2-TS”:
MPEG2-TS 传输流(MPEG-2 Transport Stream;又称 MPEG-TS、MTS、TS)是一种标准数字封装格式,用来传输和存储视频、音频与频道、节目信息,应用于数字电视广播系统,如 DVB、ATSC、ISDB [3]:118、IPTV 等。

此外,某些包(例如 “wlwz-2312”)在 JSON 文件中包含普通话字幕。

虽然这些视频不会像挖矿程序、垃圾邮件包和依赖性恶意软件那样毒害社区,但这种把开源基础设施当 CDN 的操作无疑是破坏了规则,也违反了供应商的服务条款,各位耗子尾汁吧。

相关链接:https://blog.sonatype.com/npm-flooded-with-748-packages-that-store-movies

收起阅读 »

Vue 依赖注入:一种高效的数据共享方法

web
什么是vue依赖注入? Vue是一个用于构建用户界面的渐进式框架。 它提供了一种简单而灵活的方式来管理组件之间的数据流,即依赖注入(Dependency Injection,DI)。 依赖注入是一种设计模式,它允许一个组件从另一个组件获取它所依赖的数据...
继续阅读 »

什么是vue依赖注入?



Vue是一个用于构建用户界面的渐进式框架。




它提供了一种简单而灵活的方式来管理组件之间的数据流,即依赖注入(Dependency Injection,DI)。



依赖注入是一种设计模式,它允许一个组件从另一个组件获取它所依赖的数据或服务,而不需要自己创建或管理它们。这样可以降低组件之间的耦合度,提高代码的可维护性和可测试性。


依赖注入示意图


在这里插入图片描述

provide和inject



在Vue中,依赖注入的方式是通过provide和inject两个选项来实现的。




provide选项允许一个祖先组件向下提供数据或服务给它的所有后代组件。
inject选项允许一个后代组件接收来自祖先组件的数据或服务。
这两个选项都可以是一个对象或一个函数,对象的键是提供或接收的数据或服务的名称,值是对应的数据或服务。函数的返回值是一个对象,具有相同的格式。



下面是一个简单的例子,演示了如何使用依赖注入的方式共享数据:


父组件


<template>
<div>
<h1>我是祖先组件</h1>
<child-component></child-component>
</div>
</template>

<script>
import ChildComponent from './ChildComponent.vue'

export default {
name: 'AncestorComponent',
components: {
ChildComponent
},
// 提供一个名为message的数据
provide: {
message: 'Hello from ancestor'
}
}
</script>

子组件


<template>
<div>
<h2>我是后代组件</h2>
<p>{{ message }}</p>
</div>
</template>


// 后代组件
<script>
export default {
name: 'ChildComponent',
// 接收一个名为message的数据
inject: ['message']
}
</script>

这样,后代组件就可以直接使用祖先组件提供的数据,而不需要通过props或事件来传递。


需要注意的是,依赖注入的数据是不可响应的,也就是说,如果祖先组件修改了提供的数据,后代组件不会自动更新。
如果需要实现响应性,可以使用一个响应式的对象或者一个返回响应式对象的函数作为provide的值。


实现响应式依赖注入的几种方式


一、提供响应式数据



方法是在提供者组件中使用ref或reactive创建响应式数据,然后通过provide提供给后代组件。后代组件通过inject接收后,就可以响应数据的变化。



提供者:


<template>
<div>
<h1>我是提供者组件</h1>
<button @click="count++">增加计数</button>
<child-component></child-component>
</div>

</template>

<script>
import ChildComponent from './ChildComponent.vue'
import { ref, provide } from 'vue'

export default {
name: 'ProviderComponent',
components: {
ChildComponent
},
setup() {
// 使用ref创建一个响应式的计数器
const count = ref(0)
// 提供给后代组件
provide('count', count)
return {
count
}
}
}
</script>


接收者:


<template>
<div>
<h2>我是接收者组件</h2>
<p>计数器的值是:{{ count }}</p>
</div>

</template>

<script>
import { inject } from 'vue'

export default {
name: 'ChildComponent',
setup() {
// 接收提供者组件提供的响应式对象
const count = inject('count')
return {
count
}
}
}
</script>


二、提供修改数据的方法



提供者组件可以提供修改数据的方法函数,接收者组件调用该方法来更改数据,而不是直接修改注入的数据。



提供者:


<template>
<div>
<h1>我是提供者组件</h1>
<p>消息是:{{ message }}</p>
<child-component></child-component>
</div>

</template>

<script>
import ChildComponent from './ChildComponent.vue'
import { ref, provide } from 'vue'

export default {
name: 'ProviderComponent',
components: {
ChildComponent
},
setup() {
// 使用ref创建一个响应式的消息
const message = ref('Hello')
// 定义一个更改消息的方法
function updateMessage() {
message.value = 'Bye'
}
// 提供给后代组件
provide('message', { message, updateMessage })
return {
message
}
}
}
</script>


接收者:


<template>
<div>
<h2>我是接收者组件</h2>
<p>消息是:{{ message }}</p>
<button @click="updateMessage">更改消息</button>
</div>

</template>

<script>
import { inject } from 'vue'

export default {
name: 'ChildComponent',
setup() {
// 接收提供者组件提供的响应式对象和方法
const { message, updateMessage } = inject('message')
return {
message,
updateMessage
}
}
}
</script>


三、使用readonly包装



通过readonly包装provide的数据,可以防止接收者组件修改数据,保证数据流的一致性。



提供者:


<template>
<div>
<h1>我是提供者组件</h1>
<p>姓名是:{{ name }}</p>
<child-component></child-component>
</div>

</template>

<script>
import ChildComponent from './ChildComponent.vue'
import { ref, provide, readonly } from 'vue'

export default {
name: 'ProviderComponent',
components: {
ChildComponent
},
setup() {
// 使用ref创建一个响应式的姓名
const name = ref('Alice')
// 使用readonly包装提供的值,使其不可修改
provide('name', readonly(name))
return {
name
}
}
}
</script>


接收者:


<template>
<div>
<h2>我是接收者组件</h2>
<p>姓名是:{{ name }}</p>
<button @click="name = 'Bob'">尝试修改姓名</button>
</div>

</template>

<script>
import { inject } from 'vue'

export default {
name: 'ChildComponent',
setup() {
// 接收提供者组件提供的只读对象
const name = inject('name')
return {
name
}
}
}
</script>


四、使用<script setup>



<script setup>组合式写法下,provide和inject默认就是响应式的,无需额外处理。



总结



依赖注入的方式共享数据在Vue中是一种高级特性,它主要用于开发插件或库,或者处理一些特殊的场景。



作者:Yoo前端
来源:juejin.cn/post/7329830481722294272
收起阅读 »

EdgeUtils:安卓沉浸式方案(edge to edge)封装

EdgeUtils     项目地址:github.com/JailedBird/… 1、 接入方式 EdgeUtils是基于androidx.core,对edge to edge沉浸式方案封装 📦 接入方式: 添加jitpack仓库 maven { ur...
继续阅读 »

EdgeUtils


GitHub stars GitHub forks GitHub issues 


项目地址:github.com/JailedBird/…


1、 接入方式


EdgeUtils是基于androidx.core,对edge to edge沉浸式方案封装 📦


接入方式:



  • 添加jitpack仓库


maven { url 'https://jitpack.io' }


  • 添加依赖


implementation 'com.github.JailedBird:EdgeUtils:1.0.0'

2、 使用方式


2-1、 布局拓展全屏


Activity中使用API edgeToEdge() 将开发者实现的布局拓展到整个屏幕, 同时为避免冲突, 将状态栏和到导航栏背景色设备为透明;


1669552233097-eacf0003-1ede-4035-a24e-ace16bfbe400.gif


注意:edgeToEdge() 的参数withScrim表示是否启用系统默认的反差色保护, 不是很熟悉的情况下直接使用默认true即可;


2-2、 系统栏状态控制


布局拓展之后, 开发者布局内容会显示在状态栏和导航栏区域, 造成布局和系统栏字体重叠(时间、电量……);


此时为确保系统栏字体可见,应该设置其字体; 设置规则:白色(浅色)背景设置黑色字体(edgeSetSystemBarLight(true)),黑色(深色)背景设置白色字体(注:系统栏字体只有黑色和白色)(edgeSetSystemBarLight(false));


如果未作夜间模式适配, 默认使用 edgeSetSystemBarLight(true)浅色模式即可!


综合1、2我们的基类可以写成如下的形式:


abstract class BasePosActivity : AppCompatActivity() {

  override fun onCreate(savedInstanceState: Bundle?) {
      if (usingEdgeToEdgeTheme()) {
              defaultEdgeToEdge()
      } else {
          customThemeSetting()
      }
      super.onCreate(savedInstanceState)
  }
}

protected open fun defaultEdgeToEdge() {
    edgeToEdge(false)
    edgeSetSystemBarLight(true)
}

2-3、 解决视觉冲突


2-3-1、状态栏适配


步骤一布局拓展全屏会导致视觉上的冲突, 下面是几种常见的思路:请灵活使用



  • 布局中添加View(id="@+id/edge")使用heightToTopSystemWindowInsets API动态监听并修改View的高度为状态栏的高度


    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
          android:orientation="vertical">

          <View
              android:id="@+id/edge"
              android:layout_width="match_parent"
              android:layout_height="0dp" />
          xxx
      </LinearLayout>
    binding.edge.heightToTopSystemWindowInsets()


  • 直接获取状态栏的高度,API为:edgeStatusBarHeight; 和1不同的是,1中View的height会随状态栏高度变化而变化,2不会; 此外获取状态栏高度需要在View Attached之后才可以(否则高度为0),因此使用suspend函数等待Attached后才返回状态栏,确保在始终能获取到正确的状态栏高度!


    lifecycleScope.launch {
      val height = edgeStatusBarHeight()
      xxx
    }


  • 针对有Toolbar的布局, 可直接为Toolbar加padding(or margin), 让padding的高度为状态栏高度!如果无效, 一般都与Toolbar的高度测量有关, 可以直接在Toolbar外层包上FrameLayout,为FrameLayout加padding, 详情阅读下文了解原理,从而灵活选择;


    fun View.paddingTopSystemWindowInsets() =
      applySystemWindowInsetsPadding(applyTop = true)

    fun View.marginTopSystemWindowInsets() =
      applySystemWindowInsetsMargin(applyTop = true)



2-3-2、 导航栏适配


导航栏的适配原理和状态栏适配是非常相似的, 需要注意的是 导航栏存在三种模式:



  • 全面屏模式

  • 虚拟导航栏

  • 虚拟导航条


API已经针对导航栏高度、导航栏高度margin和padding适配做好了封装,使用者无需关心;


fun View.paddingBottomSystemWindowInsets() =
  applySystemWindowInsetsPadding(applyBottom = true)
   
fun View.marginBottomSystemWindowInsets() =
  applySystemWindowInsetsMargin(applyBottom = true)

适配思路是一致的,不再赘述;


2-4、 解决手势冲突


手势冲突和视觉冲突产生的原理是相同的,不过是前者无形而后者有形;系统级别热区(如侧滑返回)优先级是要高于View的侧滑的, 因此有时候需要避开(情况很少)


EdgeUtils主要工作只是做了视觉冲突的解决和一些API封装;使用者可以基于封装的API拓展,替换掉WindowInsetCompat.Type为你需要的类型;


fun View.applySystemWindowInsetsPadding(
  applyLeft: Boolean = false,
  applyTop: Boolean = false,
  applyRight: Boolean = false,
  applyBottom: Boolean = false,
)
{
  doOnApplyWindowInsets { view, insets, padding, _ ->
  // val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
  // 替换为Type.SYSTEM_GESTURES即可,其他类似
      val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemGestures())
      val left = if (applyLeft) systemBars.left else 0
      val top = if (applyTop) systemBars.top else 0
      val right = if (applyRight) systemBars.right else 0
      val bottom = if (applyBottom) systemBars.bottom else 0

      view.setPadding(
          padding.left + left,
          padding.top + top,
          padding.right + right,
          padding.bottom + bottom
      )
  }
}

3、 Edge教程


3-1 何为edge to edge?


如何彻底理解Edge to edge的思想呢?


或许你需要官方文章 , 也可以看的我写的翻译文章doc1😘


3-2 底层是如何实现的?


了解Edge to edge原理后,你或许会好奇他是怎么实现的?


或许你需要Flywith24大佬的文章 , 也可看缩略文章doc2😘


3-3 其他杂项记录


请看doc3 , 东西多但比较杂没整理😘


3-4 如何快速上手?


EdgeUtils此框架基于androidx.core, 对WindowInsets等常见API进行封装,提供了稳定的API和细节处理;封装的API函数名称通俗易懂,理解起来很容易, 难点是需要结合 [Edge-to-edge](#Edge to edge) 的原理去进行灵活适配各种界面


项目中存在三个demo对于各种常见的场景进行了处理和演示



  • navigation-sample 基于Navigation的官方demo, 此demo展示了Navigation框架下这种单Activity多Fragment的沉浸式缺陷

  • navigation-edge-sample 使用此框架优化navigation-sample, 使其达到沉浸式的效果

  • immersion-sample 基于开源项目immersionbar中的demo进行EdgeUtils的替换处理, 完成大部分功能的替换 (注:已替换的会标记[展示OK],部分未实现)


4、 注意事项


4-1、 Toolbar通过paddingTop适配statusbar失效的问题


很多时候, 状态栏的颜色和ToolBar的颜色是一致的, 这种情况下我们可以想到为ToolBar加 paddingTop = status_bar_height但是注意如果你的Toolbar高度为固定、或者测量的时候没处理好padding,那么他就可能失真;


快速判断技巧:xml布局预览中(假设状态栏高度预估为40dp),使用tools:padding = 40dp, 通过预览查看这40dp的padding是否对预览变成预期之外的变形,如果OK那么直接使用paddingTopSystemWindowInsets为ToolBar大多是没问题的


可以看下下面的2个例子:



  • paddingTop = 0时候, 如下的代码:


<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/teal_200"
android:paddingTop="0dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:title="高度测试" />


  • UI预览可以看到是这个样子的:


image-20221124102655144



  • paddingTop = 20时候, 如下的代码:


<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/teal_200"
android:paddingTop="20dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:title="高度测试" />


  • 可以看到, Toolbar的总高度是不变的,内容高度下移20dp,这显然是不合理的;实际运行时动态为ToolBar添加statusbar的paddingTop肯定也会导致这样的问题


image-20221124103232396


解决方案:


1、 使用FrameLayout等常见ViewGr0up包住ToolBar,将paddingTop高度设置到FrameLayout中, 将颜色teal_200设置到FrameLayout


<FrameLayout
android:id="@+id/layout_tool"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="20dp"
android:background="@color/teal_200">

<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/teal_200"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:title="高度测试" />

</FrameLayout>

如下:


image-20221124103542651


2、 在ToolBar外层直接封装FrameLayout(LinearLayout等也可, 下文统一用FrameLayout替代);


我相信大家一般都不会直接使用原生的Toolbar, 每个公司或多或少的都封装了一些自定义ToolBar;按照上述1的思路, 我们不难发现:



  • 如果自定义ToolBar继承自FrameLayout(或者说Toolbar最外层被FrameLayout包住), 直接将paddingTop加到自定义ToolBar即可;

  • 当然有些做的好的公司可能会直接通过继承ViewGr0up(如原生ToolBar), 这个时候可能就只能用方案1了;


当然上述几点都是具体问题具体分析, 大家可以在预览界面临时加paddingTop,看看实际是什么样的, 便于大家尽早发现问题;可以参考下BottomNavigationView的源码, 它间接继承自FrameLayout, 内部对paddingBottom自动适配了navigation_bar_height;


这个思路和ImmersionBar的 状态栏与布局顶部重叠解决方案 类似,不同的是,ImmersionBar使用的是固定的高度,而方案1是动态监听状态栏的高度并设置FrameLayout的paddingTop;


注:上述的paddingTop = 20dp, 只是方便预览添加的, 运行时请通过API动态设置paddingTop = statusBar


3、 添加空白View,通过代码设置View高度为导航栏、状态栏高度时,存在坑;约束布局中0dp有特殊含义,可能导致UI变形,需要注意哈!特别是处理导航栏的时候,全屏时导航栏高度为0,就会导致View高度为0,如果有组件依赖他,可能会出现奇怪问题,因此最好现在布局预览中排查下


4-2、 Bug&兼容性(框架已修复)


直接使用Edge to edge(参照google官方文档)存在一个大坑:调用hide隐藏状态栏后会导致状态栏变黑, 并且内容区域无法铺满


详细描述看这里:point_right: WindowInsetsControllerCompat.hide makes status bar background undrawable


private fun setWindowEdgeToEdge(window: Window) {
WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
}

WindowCompat.getInsetsController(this, this.decorView)?.let {
it.systemBarsBehavior = behavior
it.hide(WindowInsetsCompat.Type.statusBars())
}

具体表现下图这个样子:


image-20221125143449641


解决方案如下 :point_down: How to remove top status bar black background


object EdgeUtils {
/** To fix hide status bar black background please using this post
* youtube: https://www.youtube.com/watch?v=yukwno2GBoI
* stackoverflow: https://stackoverflow.com/a/72773422/15859474
* */

private fun Activity.edgeToEdge() {
requestWindowFeature(Window.FEATURE_NO_TITLE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.attributes.layoutInDisplayCutoutMode = WindowManager
.LayoutParams
.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
setWindowEdgeToEdge(this.window)
}

private fun setWindowEdgeToEdge(window: Window) {
WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
}

4-3、 如何去掉scrim?


在导航栏设置为全透明时, 部分机型就会出现scrim半透明遮罩,考虑到样式有点丑陋, 直接将其修改为#01000000, 这样看起来也是完全透明的, 但是系统判定其alpha不为0, 不会主动添加scrim的; 【具体请看官方文档】


private fun setWindowEdgeToEdge(window: Window) {
WindowCompat.setDecorFitsSystemWindows(window, false)
/** using not transparent avoid scrim*/
Color.parseColor("#01000000").let { color ->
window.statusBarColor = color
window.navigationBarColor = color
}
}

4-4 、 禁止View的多次监听


一个View只能绑定一次ApplyWindowInset的监听,多次绑定可能会导致之前的失效或者出现奇怪问题!!!



5、 参考资料



作者:JailedBird
来源:juejin.cn/post/7313742254144307236
收起阅读 »

你还在使用websocket实现实时消息推送吗?

web
前言 在日常的开发中,我们经常能碰见服务端需要主动推送给客户端数据的业务场景,比如数据大屏的实时数据,比如消息中心的未读消息,比如聊天功能等等。 本文主要介绍SSE的使用场景和如何使用SSE。 服务端向客户端推送数据的实现方案有哪几种? 我们常规实现这些需求...
继续阅读 »

前言


在日常的开发中,我们经常能碰见服务端需要主动推送给客户端数据的业务场景,比如数据大屏的实时数据,比如消息中心的未读消息,比如聊天功能等等。


本文主要介绍SSE的使用场景和如何使用SSE。


image.png


服务端向客户端推送数据的实现方案有哪几种?


我们常规实现这些需求的方案有以下三种



  1. 轮询

  2. websocket

  3. SSE


轮询简介


在很久很久以前,前端一般使用轮询来进行服务端向客户端进行消息的伪推送,为什么说轮询是伪推送?因为轮询本质上还是通过客户端向服务端发起一个单项传输的请求,服务端对这个请求做出响应而已。通过不断的请求来实现服务端向客户端推送数据的错觉。并不是服务端主动向客户端推送数据。显然,轮询一定是上述三个方法里最下策的决定。


轮询的缺点:



  1. 首先轮询需要不断的发起请求,每一个请求都需要经过http建立连接的流程(比如三次握手,四次挥手),是没有必要的消耗。

  2. 客户端需要从页面被打开的那一刻开始就一直处理请求。虽然每次轮询的消耗不大,但是一直处理请求对于客户端来说一定是不友好的。

  3. 浏览器请求并发是有限制的。比如Chrome 最大并发请求数目为 6,这个限制还有一个前提是针对同一域名的,超过这一限制的后续请求将会被阻塞。而轮询意味着会有一个请求长时间的占用并发名额

  4. 而如果轮询时间较长,可能又没有办法非常及时的获取数据


websocket简介


websocket是一个双向通讯的协议,他的优点是,可以同时支持客户端和服务端彼此相互进行通讯。功能上很强大。


缺点也很明显,websocket是一个新的协议,ws/wss。也就是说,支持http协议的浏览器不一定支持ws协议。


相较于SSE来说,websocket因为功能更强大。结构更复杂。所以相对比较


websocket对于各大浏览器的兼容性↓
image.png


SSE简介


sse是一个单向通讯的协议也是一个长链接,它只能支持服务端主动向客户端推送数据,但是无法让客户端向服务端推送消息。


长链接是一种HTTP/1.1的持久连接技术,它允许客户端和服务器在一次TCP连接上进行多个HTTP请求和响应,而不必为每个请求/响应建立和断开一个新的连接。长连接有助于减少服务器的负载和提高性能。

SSE的优点是,它是一个轻量级的协议,相对于websockte来说,他的复杂度就没有那么高,相对于客户端的消耗也比较少。而且SSE使用的是http协议(websocket使用的是ws协议),也就是现有的服务端都支持SSE,无需像websocket一样需要服务端提供额外的支持。


注意:IE大魔王不支持SSE


SSE对于各大浏览器的兼容性↓
image.png


注意哦,上图是SSE对于浏览器的兼容不是对于服务端的兼容。


websocket和SSE有什么区别?


轮询


对于当前计算机的发展来说,几乎很少出现同时不支持websocket和sse的情况,所以轮询是在极端情况下浏览器实在是不支持websocket和see的下策。


Websocket和SSE


我们一般的服务端和客户端的通讯基本上使用这两个方案。首先声明:这两个方案没有绝对的好坏,只有在不同的业务场景下更好的选择。


SSE的官方对于SSE和Websocket的评价是



  1. WebSocket是全双工通道,可以双向通信,功能更强;SSE是单向通道,只能服务器向浏览器端发送。

  2. WebSocket是一个新的协议,需要服务器端支持;SSE则是部署在HTTP协议之上的,现有的服务器软件都支持。

  3. SSE是一个轻量级协议,相对简单;WebSocket是一种较重的协议,相对复杂。

  4. SSE默认支持断线重连,WebSocket则需要额外部署。

  5. SSE支持自定义发送的数据类型。


Websocket和SSE分别适用于什么业务场景?


对于SSE来说,它的优点就是轻,而且对于服务端的支持度要更好。换言之,可以使用SSE完成的功能需求,没有必要使用更重更复杂的websocket。


比如:数据大屏的实时数据,消息中心的消息推送等一系列只需要服务端单方面推送而不需要客户端同时进行反馈的需求,SSE就是不二之选。


对于Websocket来说,他的优点就是可以同时支持客户端和服务端的双向通讯。所适用的业务场景:最典型的就是聊天功能。这种服务端需要主动向客户端推送信息,并且客户端也有向服务端推送消息的需求时,Websocket就是更好的选择。


SSE有哪些主要的API?


建立一个SSE链接 :var source = new EventSource(url);

SSE连接状态


source.readyState



  • 0,相当于常量EventSource.CONNECTING,表示连接还未建立,或者连接断线。

  • 1,相当于常量EventSource.OPEN,表示连接已经建立,可以接受数据。

  • 2,相当于常量EventSource.CLOSED,表示连接已断,且不会重连。


SSE相关事件



  • open事件(连接一旦建立,就会触发open事件,可以定义相应的回调函数)

  • message事件(收到数据就会触发message事件)

  • error事件(如果发生通信错误(比如连接中断),就会触发error事件)


数据格式


Content-Type: text/event-stream //文本返回格式
Cache-Control: no-cache //不要缓存
Connection: keep-alive //长链接标识

image.png


SSE:相关文档,文档入口文档入口文档入口文档入口


显然,如果直接看api介绍不论是看这里还是看官网,大部分同学都是比较懵圈的状态,那么我们写个demo来看一下?


image.png


demo请看下方


我更建议您先把Demo跑起来,然后在看看上面这个w3cschool的SSE文档。两个配合一起看,会更方便理解些。


image.png


如何实操一个SSE链接?Demo↓


这里Demo前端使用的就是最基本的html静态页面连接,没有使用任何框架。
后端选用语言是node,框架是Express。


理论上,把这两段端代码复制过去跑起来就直接可以用了。



  1. 第一步,建立一个index.html文件,然后复制前端代码Demo到index.html文件中,打开文件

  2. 第二步,进入一个新的文件夹,建立一个index.js文件,然后将后端Demo代码复制进去,然后在该文件夹下执行


npm init          //初始化npm       
npm i express //下载node express框架
node index //启动服务

image.png


在这一层文件夹下执行命令。


完成以上操作就可以把项目跑起来了


前端代码Demo


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<ul id="ul">

</ul>
</body>
<script>

//生成li元素
function createLi(data){
let li = document.createElement("li");
li.innerHTML = String(data.message);
return li;
}

//判断当前浏览器是否支持SSE
let source = ''
if (!!window.EventSource) {
source = new EventSource('http://localhost:8088/sse/');
}else{
throw new Error("当前浏览器不支持SSE")
}

//对于建立链接的监听
source.onopen = function(event) {
console.log(source.readyState);
console.log("长连接打开");
};

//对服务端消息的监听
source.onmessage = function(event) {
console.log(JSON.parse(event.data));
console.log("收到长连接信息");
let li = createLi(JSON.parse(event.data));
document.getElementById("ul").appendChild(li)
};

//对断开链接的监听
source.onerror = function(event) {
console.log(source.readyState);
console.log("长连接中断");
};

</script>
</html>

后端代码Demo(node的express)


const express = require('express'); //引用框架
const app = express(); //创建服务
const port = 8088; //项目启动端口

//设置跨域访问
app.all("*", function(req, res, next) {
//设置允许跨域的域名,*代表允许任意域名跨域
res.header("Access-Control-Allow-Origin", '*');
//允许的header类型
res.header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With");
//跨域允许的请求方式
res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS");
// 可以带cookies
res.header("Access-Control-Allow-Credentials", true);
if (req.method == 'OPTIONS') {
res.sendStatus(200);
} else {
next();
}
})

app.get("/sse",(req,res) => {
res.set({
'Content-Type': 'text/event-stream', //设定数据类型
'Cache-Control': 'no-cache',// 长链接拒绝缓存
'Connection': 'keep-alive' //设置长链接
});

console.log("进入到长连接了")
//持续返回数据
setInterval(() => {
console.log("正在持续返回数据中ing")
const data = {
message: `Current time is ${new Date().toLocaleTimeString()}`
};
res.write(`data: ${JSON.stringify(data)}\n\n`);
}, 1000);
})

//创建项目
app.listen(port, () => {
console.log(`项目启动成功-http://localhost:${port}`)
})

效果


动画3.gif


总结



  1. SSE比websocket更轻

  2. SSE是基于http/https协议的

  3. websocket是一个新的协议,ws/wss协议

  4. 如果只需要服务端向客户端推送消息,推荐使用SSE

  5. 如果需要服务端和客户端双向推送,请选择websocket

  6. 不论是SSE还是websocket,对于浏览器的兼容性都不错

  7. 轮询是下策,很占用客户端资源,不建议使用。(不过偷懒的时候他确实方便)

  8. IE不支持SSE

  9. 小白同学demo如果跑不明白可以私信我


对了,小程序不支持SSE哦


image.png


最后


如果文章对您有帮助的话。


image.png


作者:工边页字
来源:juejin.cn/post/7325730345840066612
收起阅读 »

vscode+vite+ts助你高效开发uni-app项目

前言 最近在基于uni-app开发小程序,由于公司使用的是 HBuilder创建的项目,每次都需要打开HBuilderX当运行工具,开发体验真是难受至极。打算使用vscode + vite + ts创建一套模版,脱离 HBuilder 为什么不喜欢HBuild...
继续阅读 »

前言


最近在基于uni-app开发小程序,由于公司使用的是 HBuilder创建的项目,每次都需要打开HBuilderX当运行工具,开发体验真是难受至极。打算使用vscode + vite + ts创建一套模版,脱离 HBuilder


为什么不喜欢HBuilderX呢?



  1. 超级难用的git管理全局搜索,谁用谁知道

  2. 界面风格,代码样式,格式化,插件生态相比vscode都太差了

  3. 习惯了vscode开发


Snipaste_2023-09-05_21-53-02.png



点击查看 github



cli创建uni-app 项目


1、 创建 Vue3/Vite 工程


# npx degit https://github.com/dcloudio/uni-preset-vue.git#分支名称 自定义项目名称

# 创建以 javascript 开发的工程
npx degit dcloudio/uni-preset-vue#vite uni-starter

# 创建以 typescript 开发的工程
npx degit dcloudio/uni-preset-vue#vite-ts uni-starter



  • degit 可以帮助你从任意 git 仓库中克隆纯净的项目,忽略整个仓库的 git 历史记录。

  • 可以使用 npm install -g degit 命令全局安装



2、进入工程目录


cd uni-starter

3、更新 uni-app依赖版本


npx @dcloudio/uvm@latest

4、安装依赖


推荐一个好用的包管理器 antfu/ni


ni 或 pnpm install 或 bun install

5、运行


# 运行到 h5   
npm run dev:h5
# 运行到 app
npm run dev:app
# 运行到 微信小程序
npm run dev:mp-weixin

6、打包


# 打包到 h5   
npm run build:h5
# 打包到 app
npm run build:app
# 打包到 微信小程序
npm run build:mp-weixin

dcloudio 官方更多模版地址


自动引入



使用了自动引入就无需写下面的 import {xx} from @dcloudio/uni-app/vue。


如果不喜欢此方式可忽略



每个页面使用vue api或者uniapp api都需要引入,个人感觉有些麻烦


import { shallowRef,computed,watch } from 'vue';
import { onLoad,onShow } from "@dcloudio/uni-app";

1、 下载自动引入插件 pnpm add unplugin-auto-import -D


2、vite.config.ts 配置如下:


import { defineConfig } from "vite";
import uni from "@dcloudio/vite-plugin-uni";
// 引入自动导入插件
import AutoImport from 'unplugin-auto-import/vite'

// https://vitejs.dev/config/
export default defineConfig({
plugins: [
uni(),
// 配置自动导入 vue相关函数, uni-app相关函数。ref, reactive,onLoad等
AutoImport({
imports: ['vue','uni-app'],
dts: './typings/auto-imports.d.ts',
}),
],
});

3、tsconfig.json include新增如下类型文件配置


"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue",
// unplugin-auto-import/vite自动引入的类型声明文件
"typings/**/*.d.ts",
"typings/**/*.ts"
]


注意: Option 'importsNotUsedAsValues' is deprecated and will stop functioning in TypeScript 5.5. Specify compilerOption '"ignoreDeprecations": "5.0"' to silence this error. Use 'verbatimModuleSyntax' instead


翻译一下: 选项“importsNotUsedAsValues”已弃用,并将停止在TypeScript 5.5中运行。指定compilerOption“”ignoreDeprecations“:”5.0“”以消除此错误。 请改用“verbatimModuleSyntax”。


如果出现此警告⚠️可添加如下配置



Snipaste_2023-08-22_23-20-42.png


eslint自动格式化



为了使用方便,这里直接使用 antfu大佬的插件了,有需要的配置自行再添加到rules里面。


注意: 这个插件可能更适合web端,antfu基本是不写小程序的,如果有特殊需要或者想更适合小程序版本格式化可以自行配置或者网上找一些格式化方案,这类文章还是比较多的。



使用 eslint + @antfu/eslint-config点击查看使用


1、 安装插件


pnpm add -D eslint @antfu/eslint-config

2、新建.eslintrc.cjs


module.exports = {
root: true,
env: {
browser: true,
node: true,
es6: true,
},
// https://github.com/antfu/eslint-config
extends: '@antfu',
rules: {
// your custom rules...
'vue/html-self-closing': ['error', {
html: { normal: 'never', void: 'always' },
}],
'no-console': 'off', // 禁用对 console 的报错检查
// "@typescript-eslint/quotes": ["error", "double"], // 强制使用双引号
'@typescript-eslint/semi': ['error', 'always'], // 强制使用行位分号
},
};


3、新建.vscode/settings.json


{
// 禁用 prettier,使用 eslint 的代码格式化
"prettier.enable": false,
// 保存时自动格式化
"editor.formatOnSave": false,
// 保存时自动修复
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true,
"source.organizeImports": false
},
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"html",
"markdown",
"json",
"jsonc",
"yaml"
]
}

4、此时打开App.vue 查看已经检查出规范了,只要保存就会自动格式化


eslint-format.gif


5、提交代码时自动对暂存区代码进行格式化操作


pnpm add -D lint-staged simple-git-hooks

// package.json
"scripts": {
+ "prepare": "pnpx simple-git-hooks",
}
+"simple-git-hooks": {
+ "pre-commit": "pnpm lint-staged"
+},
+"lint-staged": {
+ "*": "eslint --fix"
+}


"prepare": "pnpx simple-git-hooks": 在执行npm install命令之后执行的脚本,用于初始化simple-git-hooks配置



editorConfig 规范



项目根目录添加.editorConfig文件,统一不同编辑器的编码风格和规范。


vscode需要安装插件EditorConfig for VS Code获取支持



# @see: http://editorconfig.org

root = true

[*] # 表示所有文件适用
charset = utf-8 # 设置文件字符编码为 utf-8
indent_style = space # 缩进风格为 空格(tab | space)
indent_size = 2 # 缩进大小为 2
end_of_line = lf # 换行符为 lf (crlf | lf | cr)
insert_final_newline = true # 在文件末尾插入一个新行
trim_trailing_whitespace = true # 去除行尾空格

[*.md] # 表示所有 .md 文件适用
insert_final_newline = false # 在文件末尾不插入一个新行
trim_trailing_whitespace = false # 不去除行尾空格


安装组件库


成套的全端兼容ui库包括:



  • uni-ui:官方组件库,兼容性好、组件封装性好、功能强大,而且还有大佬编写的ts类型。目前正在使用的组件库

  • uview-plus:uview-plus3.0是基于uView2.x修改的vue3版本。

  • uViewUI:组件丰富、文档清晰,支持nvue

  • colorUI css库:颜值很高,css库而非组件

  • 图鸟UI:高颜值UI库

  • 图鸟UI vue3版:高颜值UI库,vue3+ts版组件,值得尝试

  • first UI:分开源版和商业版,虽然组件很全、功能强大,但是大多数组件都是需要购买的商业版才能用


1、安装组件


pnpm add @dcloudio/uni-ui -S
pnpm add sass -D

2、配置easycom自动引入组件


// pages.json
{
"easycom": {
"autoscan": true,
"custom": {
// uni-ui 规则如下配置
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
}
},
// 其他内容
pages:[
// ...
]
}

3、安装uni-uits类型库


pnpm add -D @uni-helper/uni-ui-types

具体使用方法请查看:uni-ui-types


后续


模版更多内置功能(如网络请求、登录、授权、上传、下载、分享)等更新中...


参考链接:



作者:xkfe
来源:juejin.cn/post/7270830083740450816
收起阅读 »

实现一个鼠标框选的功能,要怎么实现和设计 api?

web
前言 前两年在一家做电商的公司做了一个需求:鼠标框选商品卡片,开始拖拽的时候合成一个然后改变位置,页面上有几千个所以还要结合虚拟列表。当时不知道怎么做,就在 github 上到处找现成的库,最后找到了 react-selectable-fast,并结合 rea...
继续阅读 »

285330798-9d463acf-c56b-48d8-b7d5-2dc02b4257e0.gif


前言


前两年在一家做电商的公司做了一个需求:鼠标框选商品卡片,开始拖拽的时候合成一个然后改变位置,页面上有几千个所以还要结合虚拟列表。当时不知道怎么做,就在 github 上到处找现成的库,最后找到了 react-selectable-fast,并结合 react-virtualizedreact-sortable-hoc 完成了需求。虽然该库已经很久不维护了,但大致上能满足我的需求了,尽管它是以 dom 的方式,很不 react,但秉承着能用就行的原则。意料之中,开发过程中遇到了 bug,最后只能 fork 一份修改源码后自己发了个 npm 包来使用。


项目介绍


前几个月在空闲时间突然来了兴致,自己找点事做,就想自己开发一个框选的库吧,万一也有人有这个需求不知道怎么办呢?写完后发到了 antd 的社区共建群里,有的人觉得不错也 star 了。先献上项目地址 react-selectable-box,文档完整,使用 dumi 编写。api 友好,支持自定义一些功能。


api 设计


一个组件在设计时,首先思考的应该是 api 如何去设计,最好符合大家平常的习惯,并具有一定的自定义和拓展能力。再加上了解 react-selectable-fast 这个库的缺点和痛点,对我的设计就更加有帮助了。大家在看下面的文章之前也可以思考一下,如果是你,你会怎么设计?这里只选取几个 api 来进行介绍。


主组件 Selectable


选中的值


defaultValuevalue,类型为 any[],每个方块一般都有一个唯一 id 来标识,2024-1-31 更新后 后支持任意类型,因为考虑到很多情况你可能需要一个对象或数组来标识,文章后面提供了 compareFn 来自定义比较值相等。


禁用


disabled,大部分有值的组件应该都会有此属性,能直接禁用框选功能。


模式


mode,类型为 "add" | "remove" | "reverse"。模式,表明当前框选是增加、减少还是取反。这个 api 感觉是设计的最好的,用户会框选来选择目标,肯定也会需要删除已经框选的目标,可能是按住 shift 来删除等等之类的操作。用户可以自己编写自定义逻辑来修改 mode 的值来控制不同的行为,反观 react-selectable-fast,则是提供了 deselectOnEscallowAltClickallowCtrlClickallowMetaClickallowShiftClick 等多个 api。


开始框选的条件


selectStartRange,类型 "all" | "inside" | "outside",鼠标从方块内部还是外部可以开始框选,或都可以。


可以进行框选的容器


dragContainer,类型 () => HTMLElement,例如你只希望某个卡片内才可以进行框选,不希望整个页面都可以进行框选,这个 api 就会起到作用了。


滚动的容器


scrollContainer,类型 () => HTMLElement,如果你的这些方块是在某个容器中并且可滚动,就需要传入这个属性,就可以在滚动的容器中进行框选操作了。


框选矩形的 style 与 className


boxStyleboxClassName,使用者可以自定义颜色等一些样式。


自定义 value 比较函数


compareFn,类型 (a: any, b: any) => boolean,默认使用 === 进行比较(因为 value 支持任意类型,比如你使用了对象或数组类型,所以你可能需要自定义比较)


框选开始事件


onStart,框选开始时,使用者可能需要做一些事情。


框选结束事件


onEnd,类型为 (selectingValue: (string | number)[], { added: (string | number)[], removed: (string | number)[] }) => voidselectingValue 为本次框选的值,added 为本次增加的值,removed 为本次删除的值。例如你想在每次框选后覆盖之前的操作,直接设置 selectingValue 成 value 即可。如果你想每次框选都是累加,加上 added 的值即可,这里就不再说明了。


方块可选 - useSelectable


怎么让方块可以被选择呢?并且一一绑定上对应的值?react-selectable-fast 则是提供 clickableClassName api,传入可以被选择的目标的 class,这种方式太不 react 了。此时我的脑海里想到了 dnd-kit,我认为是 react 最好用的拖拽库,它是怎么让每个方块可以被拖拽的呢?优秀的东西应该借鉴,于是就有了 useSelectable


const { 
setNodeRef, // 设置可框选元素
isSelected, // 是否已经选中
isAdding, // 当前是否正在添加
isRemoving, // 当前是否正在删除
isSelecting, // 当前是否被框选
isDragging // 是否正在进行框选操作
} = useSelectable({
value, // 每个元素的唯一值,支持任意类型
disabled, // 这个元素是否禁用
rule, // "collision" | "inclusion" | Function,碰撞规则,碰到就算选中还是全部包裹住才算选中,也可以自定义
});

如何使用?


const Item = ({ value }: { value: number }) => {
const { setNodeRef, isSelected, isAdding } = useSelectable({
value,
});

return (
<div
ref={setNodeRef}
style={{
width: 50,
height: 50,
borderRadius: 4,
border: isAdding ? '1px solid #1677ff' : undefined,
background: isSelected ? '#1677ff' : '#ccc',
}}
/>

);
};

实现


这里只简单讲一下思路,有兴趣的同学可以直接前往源码进行阅读。


主组件 Selectable 相当于一个 context,一些状态在这里进行保存,并掌管每个 useSelectable,将其需要的值通过 context 传递过去。


在设置的可被框选的容器内监听鼠标 mousedown 事件,记录其坐标,根据 mousemove 画出框选矩形,再根据 setNodeRef 收集的元素和框选矩形根据碰撞检测函数计算出是否被框选了,并将值更新到 Selectable 中去,最后在 mouseup 时触发 onEnd,将值处理完之后并丢出去。


演示


这里演示一下文章开头所说的框选拖拽功能,配合 dnd-kit 实现,代码在文档的 example 中。
录屏2024-01-23 19.27.43.gif


遇到的坑


这里分享一下遇到的坑的其中之一:框选的过程中会选中文字,很影响体验,怎么让这些文字不能被框选呢?


方案1: 用 user-select: none 来控制文本不可被选中,但是这是在用户侧来做,比较麻烦。并且发现在 chrome 下设置此属性后,拖拽框选到浏览器边缘或容器边缘后不会自动滚动,其它浏览器则正常


方案2: 在 mousedown 时设置 e.preventDefault(),这样选中时文字就不会被选中,但是拖拽框选到浏览器边缘或容器边缘后不会自动滚动,只能自己实现了滚动逻辑。后面又发现在移动端的 touchstart 设置时,会发现页面上的点击事件都失效了,查资料发现没法解决,只能另辟蹊径。


方案3: 在 mousemovetouchmove 时设置 e.preventDefault() 也是可以的,但也需要自己实现滚动逻辑。


最终也是采取了方案3。


后续目标


目前只能进行矩形的碰撞检测,不支持圆形(2024.1.26 更新支持自定义已经可以实现)及一些不规则图形(2024.1.26 更新提供自定义碰撞检测(dom 下太难,canvas 比较好做碰撞检测),剩下的就是使用者的事了!)。这是一个难点,如果有想法的可以在评论区提出或者 pr 也可。


2024-1-24 更新


添加 cancel 方法,试一试。可以调用 ref.current?.cancel() 方法取消操作。这样可以自定义按下某个键来取消当前操作。有想需不需要添加一个属性传入 keyCode 数组内置取消,但是感觉会使 api 太多而臃肿,也欢迎留下你的想法。


2024-1-26 更新一


添加 items api 以优化虚拟滚动滚动时框选范围增加或减小时,已经卸载的 Item 的判断框选。(可选)试一试


优化前:滚动到下面时,加大框选面积,上面已经被卸载的不会被选中


录屏2024-01-26 16.50.31.gif


优化后:滚动到下面时,加大框选面积,上面已经被卸载的会被选中


录屏2024-01-26 16.53.36.gif


2024-1-26 更新二


支持自定义碰撞规则检测,试一试自定义圆形碰撞检测
录屏2024-01-26 17.41.37.gif


2024-1-31 更新


value 支持任意类型 any,不再只是 string | number 类型,因为很多情况需要是一个对象或数组来当唯一标识,并提供了 compareFn 来支持自定义值的比较,默认使用 ===,如果你的 value 是对象或数组,需要此属性来比较值。


总结


开发一个较为复杂的组件,可以提交自己的 api 设计能力和解决问题的能力,可以将平常所学习、所了解、所使用的东西取其精华运用起来。最后希望这个组件能帮助到有需要的人,欢迎大家提出建议!有 issues 才能维护下去!如果觉得不错,帮忙点个 star 吧,地址 react-selectable-box


作者:马格纳斯
来源:juejin.cn/post/7326979670485123110
收起阅读 »

Kotlin开发者尝试Flutter——错怪了Dart这门语言

前言 我曾经是Java安卓开发者,进入大学后了解并且转向了Kotlin开发安卓,时至今日已经有了一年时间,Kotlin带给我的体验实在是太惊艳了,我深信这就是我最喜欢的语言了。 抱着这种看法,我发现了Flutter+Dart这种抽象的组合,大量的嵌套好像让我在...
继续阅读 »

你的段落文字.png


前言


我曾经是Java安卓开发者,进入大学后了解并且转向了Kotlin开发安卓,时至今日已经有了一年时间,Kotlin带给我的体验实在是太惊艳了,我深信这就是我最喜欢的语言了。


抱着这种看法,我发现了Flutter+Dart这种抽象的组合,大量的嵌套好像让我在写ifelse,这导致了我迟迟没有接触Flutter跨平台框架,当然还有一些其他原因。


其实在之前Flutter的跨平台能力已经惊艳到我了,这次寒假正好有机会让我学习它。


当我试着用它完成业务时,我发现好像也不是那么不可接受,我甚至还有那么点快感,如果你写过安卓Compose那么你会更加觉得如此,因为在UI和业务的关系上它真的太容易绑定了,我不再考虑像XML监听数据变化,只可惜Dart语法仍然在一些地方让我感觉到不太好用,还是得Kotlin来,等等,那我不就是想要Compose吗?


哈哈,不要着急,为什么这个项目是Flutter而不是KMP随后我们再说。


其实我本身没有很严重的技术癖,面对新的事物和技术,一旦有合适的机会我都是愿意试一试,比起框架的选择,我更加享受开发过程,思想转换为代码的那一刻至少我是享受的。


这次选择Flutter开发不意味着我会一直选择和追捧它,更不会放弃安卓原生和KMP的学习,因此也希望阅读这篇文章读者意识到这点,我作为原生开发者学习Flutter是希望扩展技能而不是代替原生,Flutter本身也不是这么想的,它更像是给大家了一个更低的开发门槛,让更多其他领域的创作者完成他们作品的一种媒介。



如果你希望快速了解Kotlin开发者使用Dart的开发体验,那么直接跳过下面两部分,直接阅读#错怪的Dart。



动机


我觉得主要动机由两部分组成吧,一部分是跨平台开发本身是我感兴趣的方向之一,另一边是未来工作可能需要吧,现在来看国内跨平台趋势还是比较明显的。


不过我更希望这次项目是体验移动跨平台开发,而不是真正的深入学习移动跨平台开发。为此,我希望可以找到学习成本和项目质量相平衡的开发方式,很遗憾我没有那么多的精力做到既要还要,这是我必须面临的选择。


面对众多跨平台框架下我还是选择了Flutter,这主要与它的跨桌面端和生态完善有关,毫无疑问,Flutter有许多的成品组件,这让我可以快速轻松的上手跨平台开发


为什么是Flutter


这个项目的主要功能就是播放器,只不过这个播放器比较特殊,后续文章我们会揭晓它。


单就网络音频播放器开发任务而言,假设使用KMP可能没有现成封装好的库来给我用,可能许多开发者考虑没有就造一个,很遗憾,我不太具备这样的能力,我们需要同时对接多个平台的媒体播放,无论开发周期,单就这样的任务对我已经是很难了。


好吧,我想必须承认我很菜,但是事实如此,因此我选择了更加成熟的Flutter,避免我写不出来,哈哈哈哈。


不过我们今天先不谈Flutter,我们看看Dart。


错怪的Dart


对Dart的刻板印象是从我第一次见到Flutter的语法时形成的,第一次见到Dart时我还没有接触Kotlin。


看着有点像Java,还有好多_的名字是什么鬼东西、怎么要写这么多return、为什么有个?、总之就是反人类啊!!!


当我真正尝试去编写Flutter程序时,我发现,嗯,错怪Dart了,特别是因为我了解Kotlin后,Kotlin和Dart也有几分相似之处,这体现在一些语法特性上。


空安全


可空类型在Kotlin上可以说相当不错,在Dart上也可以体验到它,虽然它是类型前置,但是写法倒是一样的在类型后加上"?"即可。


class AudioMediaItem {
String title;
String description;
AudioMediaType type;
String? mediaUrl;
String? bvId;
//省略其他代码.....
}

当我们试图使用AudioMediaItem的对象时,我们就可以像Kotlin那样做,注意mediaUrl现在是可空的。


audioMediaItem?.mediaUrl,如果我们认为这个属性一定有值,那么就可以使用audioMediaItem!.mediaUrl,需要注意的是,dart中是"!"而不是"!!"


如果你希望使用Kotlin的Elvis操作符 ?: ,那么你可以这么做


audioMediaItem?.mediaUrl ?? "default";

对应Kotlin的


audioMediaItem?.mediaUrl ?: "default"

在这方面,dart和Kotlin是非常相似的,因此,你可以非常平滑的迁移这部分的开发体验和理解。


延迟初始化


在Kotlin中,我们可以使用lateinit var定义一个非空延迟初始化的变量,通俗的讲就是定义一个非空类型,但是不给初始值。dart也有对应从关键字,那就是late了。


late String name;

相当于Kotlin的


lateinit var String name

我们知道延迟初始化意味着这个值必定有值,只是我们希望这个值在代码运行过程中产生并且初始化,初始化后再使用该值,否则就会空指针了。


如果你已经熟悉了Kotlin的lateinit,那这里也可以平滑迁移了。


但是在Android Studio 2023.1.1我发现个有意思的事情。


late String? name;

ide没有提示这是错误的,我没试着运行,但是我觉得这应该是不合理的。


扩展函数


扩展函数在Kotlin当中可以说相当重要,许多内置函数都是这个特性所带来的。


在Kotlin中,我们通过 被扩展的类名.扩展函数名(){} 这样的写法就实现了一个扩展函数。


fun String.toColorInt(): Int = Color.parseColor(this)

Dart中也存在扩展函数的语法糖!


extension StringExtension on String {
/// 将字符串的首字母大写
String capitalize() {
if (isEmpty) {
return this;
}
return '${this[0].toUpperCase()}${substring(1)}';
}
}

其中,StringExtension只是这个扩展的名字,相当于一个标志,可以随便起,on String则代表扩展String类,那么capitalize 自然就是扩展的方法名了。


将Kotlin的内置函数带入


Kotlin的内置函数实在是太棒了,下面以also和let为例子,模仿了Kotlin的扩展函数,只可惜Dart的lambda不太能像Kotlin那样,还是有一些割裂。


extension AlsoExtension<T> on T {
T also(void Function(T) block) {
block(this);
return this;
}
}

extension LetExtension<T> on T {
R let<R>(R Function(T) block) {
return block(this);
}
}

//用法
String demo = "xada".let((it) => "${it}xadadawdwad");


emm不过因为没办法直接传this,在变量很长或者类型可空时还有点用。


顶层函数


Kotlin中,我们有时候需要在全局使用一些函数,但是不希望写在类里,而是随时随地直接可以调用或者拿到。


注意这些代码不在类里


val json = Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
}

var retrofit = Retrofit.Builder()
.baseUrl("https://api.juejin.cn/")
.addConverterFactory(json.asConverterFactory(MediaType.parse("application/json;charset=utf-8")!!))
.build()


在某个类需要我们就直接写retrofit.xxxx() 就可以了,我们不需要再单独从类中找。


Dart也有这样的功能


final _cookieJar = CookieJar();

final Dio dioClient = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
contentType: Headers.jsonContentType,
persistentConnection: true,
))
..transformer = BackgroundTransformer()
..let((it) {
if (!kIsWeb) {
it.interceptors.add(CookieManager(_cookieJar));
return it;
} else {
return it;
}
});


上面的例子只是写了变量,写函数也是一样的,都可以直接在全局任何的位置调用。


高阶函数


在Kotlin中,高阶函数是特殊的一种函数,这种函数接受了另一个函数作为参数。


我们以Kotlin的forEach函数为例子:



public inline fun <T> Iterable<T>.forEach(action: (T) -> Unit): Unit {
for (element in this) action(element)
}

// 用法
fun main() {
val demoList = listOf("da", "da", "da")
demoList.forEach {
println(it)
}
}

forEach本身扩展了Iterable,但是它的参数非常特殊,我们看看action参数的类型:


(T) -> Unit,这是Kotlin匿名函数的写法,意味着这个函数有一个参数,类型为T泛型,这个参数也没有起名字,所以就只有类型T在。


这种情况,在Java中这种实现一般是接口类,我们需要实例化这个匿名类,假设这个接口只有一个方法,那么就可以转换为lambda的写法。


在Kotlin里我们可以直接写为lambda的形式,要方便很多,由于只有一个参数,那么kotlin默认就叫it了。


OK回顾完Kotlin,我们看看Dart:


void forEach(void action(E element)) {
for (E element in this) action(element);
}

//用法
List<String> demoList = ["da","da","da"];

demoList.forEach((element) {
print(element);
});

其实差别不大,只是我们需要写void当作这个参数的类型,内部写法没有太大差异。


不过,Dart的lambda更加贴近JS,写法基本上是一模一样。


相信如果你已经掌握了Kotlin的高阶函数,那么在Dart尝试也是不错的。


运算符重载


Kotlin当中有个不太常用的东西,叫运算符重载,它在Dart中也有。


public operator fun <T> Collection<T>.plus(elements: Iterable<T>): List<T> {
if (elements is Collection) {
val result = ArrayList<T>(this.size + elements.size)
result.addAll(this)
result.addAll(elements)
return result
} else {
val result = ArrayList<T>(this)
result.addAll(elements)
return result
}
}

//用法
val demoList = listOf("da", "da", "da") + listOf<String>("add")

可以看到kotlin通过operator关键字配合扩展函数实现了这个功能,dart也可以模仿这种手段:


// 模仿
extension ListPlusOperatorExtension<T> on List<T> {
List<T> operator +(List<T> elements) {
List<T> result = this;
addAll(elements);
return result;
}
}

// 用法

List<String> demo1 = ["da","da"];

List<String> demo2 = ["da","d1a"] + demo1;

不过这里的加减乘除就是operator + 了。


总结


可以看得出,Dart也有部分我们在Kotlin中喜欢的特性,如果你已经掌握了Kotlin的基本语法,那么相信Dart对你来说也不是太大问题,你可以平滑的迁移一些在Kotlin中的知识到Dart上去。


起初我是很坑距使用Flutter的,现在看见Dart的特性,我似乎又接受了一些,好吧,对于Flutter开发、布局约束和其他感受我在下一篇文章再分享给大家吧。


最后感谢大家看到这里,还有什么好玩的特性欢迎在下面留言,文章内容有错误请指出。


作者:萌新杰少
来源:juejin.cn/post/7329874214378078245
收起阅读 »

uniapp小程序包过大的问题

uniapp小程序包过大的问题 前言 微信小程序为了优化用户体验,将小程序首次加载的数据限制在了2M以内(推荐1.5M),剩下的数据采取分包(懒加载)的方式进行引用。 一 开启分包subPackages 在manifest.json文件中添加"optimiza...
继续阅读 »

uniapp小程序包过大的问题


前言


微信小程序为了优化用户体验,将小程序首次加载的数据限制在了2M以内(推荐1.5M),剩下的数据采取分包(懒加载)的方式进行引用。


一 开启分包subPackages


manifest.json文件中添加"optimization" : {"subPackages" : true}来开启分包。


1680316261345.png

然后可以在pages.json中添加subPackages来进行分包页面的配置。


当然,uniapp还贴心的为我们提供了便捷的创建方式:


1680316550191.png

二 静态资源优化


小程序中尽量少使用大背景图片,这样会占据大量包资源。微信小程序推荐使用网络图片资源来减少主包资源。因为某种原因,我把图片放进了主包里,但是要进行图片压缩。这里推荐一个图片压缩网站tintpng


image.png

可以看到图片被压缩了百分之62,并且可以批量处理,就很方便。


三 去除冗余代码


这里你以为我会说提升代码质量巴拉巴拉,其实不然。接下来要说的,才是我要写这篇文章的真正原因!!!


如果你使用uniapp开发微信小程序,并直接在微信开发小程序工具中上传,你会发现你的包会离奇的大


image.png

在代码依赖分析中我们可以发现,一个叫common的文件竟有1.3M多,而这个并非是我自己的文件。


image.png

后来发现这应该是uniapp开发时的编译文件,删掉就可以了。


还有一个方法,在uniapp运行到小程序中,时勾选运行时是否开启代码压缩,此时再看代码其实也可以符合要求了:


image.png

四 通过uniapp上传发布


uniapp也提供了通过cli来进行发布小程序的能力:


image.png

这里需要准备的是appId和微信小程序上传接口的key,并且要配置你上传时所在的网络IP,具体方法


结语


OK,当你看到这里,那么恭喜你,又多活了三分钟~respect!!!


作者:FineYoung
来源:juejin.cn/post/7216845797143969850
收起阅读 »

近年来项目研发之怪现状

简述 近年来,机缘巧合之下接触了不少toG类项目。项目上颇多事情,令人疑惑频频。然而屡次沟通,却都不了了之,长此以往,心力愈发交瘁,终究心灰意冷,再无劝谏之心。 令人困惑的项目经理 孟子说天时不如地利,地利不如人和。而项目上遇到的很多事情,天时、地利终为...
继续阅读 »

简述



近年来,机缘巧合之下接触了不少toG类项目。项目上颇多事情,令人疑惑频频。然而屡次沟通,却都不了了之,长此以往,心力愈发交瘁,终究心灰意冷,再无劝谏之心。



令人困惑的项目经理



孟子说天时不如地利,地利不如人和。而项目上遇到的很多事情,天时、地利终为少数,多数在人和。



立项开工,项目经理自然是项目上的第一把手。既为第一把手,自要有调兵遣将,排兵布阵的能耐。


当然用我们业内的话来说,可分为下面几类:


第一等的自然是懂业务又懂技术,这样的项目经理可运筹帷幄之中,决胜千里之外,当然这般的项目经理可遇而不可求。


这第二等的懂业务不懂技术,或者懂技术不懂业务,这样的项目经理,辅以数名参将,只要不瞎指挥,也可稳扎稳打,有功无过。


第三等的项目经理,业务与技术皆是不懂,如这般的项目经理,若尽职尽责,配先锋、军师、参将、辎重,最好再辅之以亲信,也可功成身退。若其是领导亲信,那更可说是有惊无险了。


而这第四等的,业务与技术不懂也就罢了,既无调兵遣将之才,又无同甘共苦之心,更是贻误战机,上下推诿。若其独断专横,那便是孔明在世也捧不起来。



有这般一个项目,公司未设需求经理,常以项目经理沟通需求。工期八月,立项后,多次催促,却不与甲方沟通,以至硬生生拖了两月之后才去。然而不通业务,不明技术。甲方被生耗两个月才沟通需求,这样的情况下,如何能顺利进行,以至于项目返工现象,比比皆是。多次提及需求管理,亦是左耳进右耳出。类类数落问题,甲方、研发、产品都有问题,独独他自身若皎皎之明月,灿灿之莲花。然而纵是项目成员承星履草,夜以继日,交付一版之后。举目皆是项目经理之间的恭维之词。



我有很多朋友是优秀的项目经理。言必行,行必果。沟通起来非常愉悦。偶尔遇到一个这样的人,确实让我大开眼界。


其实我也想过,这并非是项目经理职位的问题,实在是个别人自身的问题,这样的人,在任何岗位都是令人恼火的。


技术人员的无力感


我们互联网从业者经常听到一个词,技术债。技术债是令人难受的,尤其是在做项目的时候。做产品,我们可以制定完善的迭代周期,而项目,当需求都无法把控的时候,那么就意味着一切都是可变的。


糟糕的事情是,我遇到了这样的项目。前期无法明确的需求,项目期间,子虚乌有的需求管理,项目中不断的需求变更,deadline的不断临近,最终造就了代码的无法维护。


我从未想过,在同一个紧迫的时间阶段,让研发进行需求研发、bug修复、代码解耦,仿佛每一件事情都很重要。当然,我更建议提桶跑路,这样的项目管理,完全是忽视客观现实的主观意识。


前端规范难落地


公司是有前端规范的,然而前端规范的落地却很糟糕。如果使用TS,那么对于诸多时间紧,任务重,且只有一名前端开发人员的项目来说,显得太过冗余了。所以依旧使用js,那么代码中单个性化不会少见。使用esLint怎么样呢?这当然很棒,直到你发现大部分成员直接将esLint的检查注释了。或许还可以依靠团队内不断的宣讲与code Review,这是个好主意,然而你会发现,公司的code Review也是那么形式化的过程。


或许对一些企业来说,代码的规范性不重要,所谓的技术类的东西都显得没那么重要。只有政府将钱塞到它的口袋里这件事,很重要。


崩盘的时间管理


那么,因为各方面的原因,项目不可避免的走向了失控。时间管理的崩溃,项目自然开始了不断的延期。在私下里,一些擅长酒桌文化的甲方与项目经理,开始了酒桌上的攀谈,推杯换盏之间,开始了走形式的探讨。灯红酒绿之间,公司又开始了例行的恭维。


当然,我依旧无法理解,即使管理的如此糟糕,只要在酒桌上称兄道弟,那便什么问题都没有了?若是如此,项目经理面试的第一道题,一定是酒量如何了。


作者:卷不动咯
来源:juejin.cn/post/7263372536791433275
收起阅读 »

502故障,你是怎么解决的?

在现代网络应用的开发和维护中,502 Bad Gateway错误是一个常见而令人头疼的问题。这种错误通常意味着代理服务器或网关在尝试访问上游服务器时未能获取有效的响应。本文将深入探讨502故障的原因、可能的解决方案,并提供基于实际案例的客观凭证。 1. 原因深...
继续阅读 »

在现代网络应用的开发和维护中,502 Bad Gateway错误是一个常见而令人头疼的问题。这种错误通常意味着代理服务器或网关在尝试访问上游服务器时未能获取有效的响应。本文将深入探讨502故障的原因、可能的解决方案,并提供基于实际案例的客观凭证。


1. 原因深入解析


a. 上游服务器问题


502错误的最常见原因之一是上游服务器出现问题。这可能包括服务器崩溃、过载、应用程序错误或者数据库连接故障。具体而言,通过观察服务器的系统日志、应用程序日志以及数据库连接状态,可以深入分析问题的根本原因。


b. 网络问题


网络中断、代理服务器配置错误或者防火墙问题都可能导致502错误。使用网络诊断工具,如traceroute或ping,可以检查服务器之间的连接是否畅通。同时,审查代理服务器和防火墙的配置,确保网络通信正常。


c. 超时问题


502错误还可能是由于上游服务器响应时间超过了网关或代理服务器的超时设置而引起的。深入了解请求的性能特征和服务器响应时间,调整超时设置可以是一项有效的解决方案。


2. 解决方案的客观凭证


a. 上游服务器状态监控


使用监控工具,例如Prometheus、New Relic或Datadog,对上游服务器进行状态监控。通过设置警报规则,可以及时发现服务器性能下降或者异常情况。


b. 网络连接分析


借助Wireshark等网络分析工具,捕获和分析服务器之间的网络通信数据包。这有助于定位网络中断、数据包丢失或防火墙阻塞等问题。


c. 超时设置调整


通过监控工具收集请求的响应时间数据,识别潜在的性能瓶颈。根据实际情况,逐步调整代理服务器的超时设置,以确保其适应上游服务器的响应时间。


3. 实例代码分析


循环引用问题


gc_enabled 是否开启gc
gc_active 垃圾回收算法是否运行
gc_full 垃圾缓冲区是否满了,在debug模式下有用
buf 垃圾缓冲区,php7默认大小为10000个节点位置,第0个位置保留,既不会使用
roots: 指向缓冲区中最新加入的可能是垃圾的元素
unused 指向缓冲区中没有使用的位置,在没有启动垃圾回收算法前,指向空
first_unused 指向缓冲区第一个为未使用的位置。新的元素插入缓冲区后,指向会向后移动一位
last_unused 指向缓冲区最后一个位置
to_free 带释放的列表
next_to_free 下一个待释放的列表
gc_runs 记录gc算法运行的次数,当缓冲区满了,才会运行gc算法
collected 记录gc算法回收的垃圾数

Nginx配置


location / {
proxy_pass http://backend_server;

proxy_connect_timeout 5s;
proxy_read_timeout 30s;
proxy_send_timeout 12s;

# 其他代理配置项...
}

上述Nginx配置中,通过设置proxy_connect_timeoutproxy_read_timeoutproxy_send_timeout,可以调整代理服务器的超时设置,从而适应上游服务器的响应时间。


PHP代码


try {
// 执行与上游服务器交互的操作
// ...

// 如果一切正常,输出响应
echo "Success!";
} catch (Exception $e) {
// 捕获异常并处理
header("HTTP/1.1 502 Bad Gateway");
echo "502 Bad Gateway: " . $e->getMessage();
}

在PHP代码中,通过捕获异常并返回502错误响应,实现了对异常情况的处理,提高了系统的健壮性。


4. 结语


502 Bad Gateway错误是一个综合性的问题,需要从多个角度进行深入分析。通过监控、网络分析和超时设置调整等手段,可以提高对502故障的解决效率。在实际应用中,结合客观的凭证和系统实时监控,开发者和运维人员能够更加迅速、准确地定位问题,确保网络应用的稳定性和可用性。通过以上深度透析和实际案例的代码分析,我们希望读者能够更好地理解502错误,并在面对此类问题时能够快速而有效地解决。


作者:Student_Li
来源:juejin.cn/post/7328766815101108243
收起阅读 »

01CSS 实现多行文本“展开收起”

web
最近在开发移动端的评论内容功能时,我遇到了一个需求,需要实现一个展开收起效果。主要目的是让用户能够方便地查看和隐藏评论内容,现在想将我的成果分享给大家 完成效果: 实现思路: 1.准备一个文本的外部容器( content),并将最大高度设置为65px(根据...
继续阅读 »

最近在开发移动端的评论内容功能时,我遇到了一个需求,需要实现一个展开收起效果。主要目的是让用户能够方便地查看和隐藏评论内容,现在想将我的成果分享给大家



完成效果:


展开收起.gif


实现思路:


1.准备一个文本的外部容器( content),并将最大高度设置为65px(根据实际需求而定),超出内容设置不可见


image.png


2.文本容器的高度(text-content)不做样式设置,这个容器是为了获取内容实际高度


image.png


3.通过 js 获取文本容器的高度(text-content),判断文本高度是否超过外部容器(content)的最大高度,控制展开收起按钮是否显示


4.点击按钮时根据条件设置容器(content)的最大高度,css 对通过 transition 对 max-height 设置过渡效果


完整示例代码如下


HTML



<div class="container">
<div class="content">
<div class="text-content">
1月30日上午10时,中国贸促会将召开1月例行新闻发布会,介绍第二届链博会筹备进展情况;
2025大阪世博会中国馆筹备进展;2023年全国贸促系统商事认证数据;2023年贸法通运行情况;
2023年11月全球经贸摩擦指数;2023年12月全球知识产权保护指数月度观察报告;助力培育外贸新动能有关工作考虑等。
</div>
</div>
<button class="btn">展开</button>
</div>


CSS



.container {
width: 260px;
padding: 20px;
border: 1px solid #ccc;
margin: 50px auto;
}

.content {
max-height: 65px;
overflow: hidden;
transition: max-height 0.5s;
}


.btn {
display: flex;
width: 40px;
color: cornflowerblue;
outline: none;
border: none;
background-color: transparent;
}



JS


    const maxHeight=65
const btn = document.querySelector('.btn')
const content = document.querySelector('.content')
const textContent=document.querySelector('.text-content')
const textHeight=textContent.getBoundingClientRect().height // 文本高度
const contentHeight=content.getBoundingClientRect().height // 容器高度
let flag = false
if (textHeight < maxHeight) {
btn.style.display = 'none'
}
btn.addEventListener('click', () => {
if (!flag) {
content.style.maxHeight=textHeight+'px'
btn.innerHTML = '收起'
flag = true
} else {
content.style.maxHeight=contentHeight+'px'
btn.innerHTML = '展开'
flag = false
}
})



实现一个功能的方式往往有多种,你们是怎么解决的呢?


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

苹果 visionOS for web

苹果的 Vision Pro 已经发布了,虽然没有拿到手,但我还是对它的操作界面充满了好奇。 我看到很多小伙伴写了 Windows for Web,Mac OS for Web,所以我也想来实现一下 Vision Pro 的系统主页。 一开始,我以为这不会太难...
继续阅读 »

苹果的 Vision Pro 已经发布了,虽然没有拿到手,但我还是对它的操作界面充满了好奇。


我看到很多小伙伴写了 Windows for Web,Mac OS for Web,所以我也想来实现一下 Vision Pro 的系统主页。


一开始,我以为这不会太难,当头一棒的就是苹果祖传优势: 动画。


CPT2401291503-845x461.gif


这动画,这模糊,还是从中心点开始逐渐向外层扩散,应该根据人眼的视觉特征进行设计的。


问题是,该如何实现呢?


模糊我知道怎么实现,


filter: blur(15px);

从中心点开始逐渐向外层扩散的效果,我直接来个
transition-delay: 0.1s;


一通操作之下,也实现就似是而非的效果。而且边缘处app图标的缓缓落下的效果也不好。


CPT2401291508-1281x733.gif


然后就是光影效果的实现,因为它的很美,让人很难忽略。


在 Vision Pro 系统演示中可以看出,为了模拟菜单栏使用了磨砂玻璃材质,而为了营造真实感,会模拟光照射到玻璃上而形成的光线边框。


我不知道这是不是菲涅尔效应,但问题是,这又该如何在前端实现呢?


我想到了 CSS Houdini,可以利用 Houdini 开放的底层能力 paint 函数来实现一个菜单栏效果。


if ('paintWorklet' in CSS) {
CSS.paintWorklet.addModule('data:text/javascript,' + encodeURIComponent(`

class FresnelAppRectPainter {
static get inputProperties() { return ['--light-angle']; }

paint(ctx, size, properties) {
const borderRadius = 30;
const fresnelColor = 'rgba(255, 255, 255, .9)';
const lightAngle = parseFloat(properties.get('--light-angle')[0]) || 0;

// 绘制圆角矩形
ctx.beginPath();
ctx.moveTo(borderRadius, 0);
ctx.lineTo(size.width - borderRadius, 0);
ctx.arcTo(size.width, 0, size.width, borderRadius, borderRadius);
ctx.lineTo(size.width, size.height - borderRadius);
ctx.arcTo(size.width, size.height, size.width - borderRadius, size.height, borderRadius);
ctx.lineTo(borderRadius, size.height);
ctx.arcTo(0, size.height, 0, size.height - borderRadius, borderRadius);
ctx.lineTo(0, borderRadius);
ctx.arcTo(0, 0, borderRadius, 0, borderRadius);
ctx.closePath();
ctx.fillStyle = 'rgba(163, 163, 163)';
ctx.fill();

// 模拟光照效果
const gradient = create360Gradient(ctx, size, lightAngle)
ctx.fillStyle = gradient;
ctx.fill();

// 添加菲涅尔效果
const borderGradient = ctx.createLinearGradient(0, 0, size.width, size.height);
borderGradient.addColorStop(0, fresnelColor);
borderGradient.addColorStop(0.2, 'rgba(255,255,255, 0.7)');
borderGradient.addColorStop(1, fresnelColor);

ctx.strokeStyle = borderGradient;
ctx.lineWidth = 1.5;
ctx.stroke();
}
}

registerPaint('fresnelAppRect', FresnelAppRectPainter);
`));
}

结果效果还可以,我甚至可以接收一个光的入射角度,来实时绘制光影效果。


 function create360Gradient(ctx, size, angle) {
// 将角度转换为弧度
const radians = angle * Math.PI / 180;

// 计算渐变的起点和终点
const x1 = size.width / 2 + size.width / 2 * Math.cos(radians);
const y1 = size.height / 2 + size.height / 2 * Math.sin(radians);
const x2 = size.width / 2 - size.width / 2 * Math.cos(radians);
const y2 = size.height / 2 - size.height / 2 * Math.sin(radians);

// 创建线性渐变
const gradient = ctx.createLinearGradient(x1, y1, x2, y2);
gradient.addColorStop(0, 'rgba(255, 255, 255, 0.2)');
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');

return gradient;
}

CPT2401291454-249x209.gif


演示效果图


哦对了,还有一个弹层底边角的缩放效果,我目前还没想到什么好办法来实现,年底还得抓紧搬砖,只能先搁置了,如果小伙伴们有好办法,欢迎告知或者讨论。


1706511484530.png


最终效果图


这里是 Demo 地址


本来是冲着纯粹娱乐(蹭流量)来写的,但写着写着就发现好像没那么简单,三个晚上过去,也只写了个首页,不得不感慨苹果真的太细了呀。


以上。


作者:于益
来源:juejin.cn/post/7329280514627600425
收起阅读 »

uniapp系列-改变底部安全区-顶部的手机信号、时间、电池栏颜色样式

uniapp 的默认安全区域的颜色是白色,如果我们做了沉浸式页面,背景色也是白色的话,就会看不到电池栏,等的颜色,如何修改呢? 首先来说底部安全区域 下图是底部安全区原始状态,感觉和整个页面格格不入 修改代码配置safearea manifest.json...
继续阅读 »

uniapp 的默认安全区域的颜色是白色,如果我们做了沉浸式页面,背景色也是白色的话,就会看不到电池栏,等的颜色,如何修改呢?


首先来说底部安全区域


下图是底部安全区原始状态,感觉和整个页面格格不入



修改代码配置safearea



  • manifest.json(下面代码仅支持ios)


// 在app-plus下配置:
"safearea": { //安全区域配置,仅iOS平台生效
"background": "#F5F6F9", //安全区域外的背景颜色,默认值为"#FFFFFF"
"bottom": { // 底部安全区域配置
"offset": "none|auto" // 底部安全区域偏移,"none"表示不空出安全区域,"auto"自动计算空出安全区域,默认值为"none"
}
},


  • 页面里写(下面代码支持android)


写法一:
// #ifdef APP-PLUS
var Color = plus.android.importClass("android.graphics.Color");
plus.android.importClass("android.view.Window");
var mainActivity = plus.android.runtimeMainActivity();
var window_android = mainActivity.getWindow();
window_android.setNavigationBarColor(Color.parseColor("#eb8c76"));
// #endif
写法二:
// #ifdef APP-PLUS
let color, ac, c2int, win;
color = plus.android.newObject("android.graphics.Color")
ac = plus.android.runtimeMainActivity();
c2int = plus.android.invoke(color, "parseColor", "#000000")
win = plus.android.invoke(ac, "getWindow");
plus.android.invoke(win, "setNavigationBarColor", c2int)
// #endif



底部区域颜色已配置成功(下图仅供参考,随便选的颜色,有点丑哈哈)



接下来讲一下顶部电池栏的配置


配置顶部导航栏颜色


方案一:仅适用于原生导航配置,非自定义导航



在page.json修改需要配置的页面的navigationBarTextStyle属性



"pages": [ 
{
"path": "pages/index/index",
"style": {
// "navigationStyle": "custom"
"navigationBarTitleText": "我是原生title",
"navigationBarTextStyle": "white" ,// 仅支持 black/white
"navigationBarBackgroundColor": "#aaaaff"
}
}
],


方案二:通用,也适用于自定义导航



在页面中使用nativejs的api,native是uni内置的sdk,不需要手动引入,直接用就可以,但是需要注意调用时机和条件使用,参考下面的注意事项哦



onReady(){
plus.navigator.setStatusBarStyle("dark"); //只支持dark和light
}



注意事项



注意函数的调用时机,如果是自定义导航栏,方法只写在onReady的话,切换路由再回来以后,你的配置会失效,所以要注意调用时机



uniapp中 onReady, onLoad, onShow区别



  • onReady 页面初次渲染完成了,但是渲染完成了,你才发送请求获取数据,显得有些慢

  • onLoad 只加载一次,监听页面加载,其参数为上个页面传递的数据,参数类型为Object

  • onShow 监听页面显示。页面每次出现都触发,包括从下级页面点返回露出当前页面


目前我是这样配置(举个栗子:配置顶部导航栏背景颜色为黑色)


import { onLoad, onShow, onReady} from '@dcloudio/uni-app';
onReady(() =>
/* #ifdef APP-PLUS */ 
plus.navigator.setStatusBarStyle('dark'); 
/* #endif */
});

onShow(() =>
/* #ifdef APP-PLUS */ 
plus.navigator.setStatusBarStyle('dark'); 
/* #endif */
});

今天就写到这里啦~



  • 小伙伴们,( ̄ω ̄( ̄ω ̄〃 ( ̄ω ̄〃)ゝ我们明天再见啦~~

  • 大家要天天开心哦



欢迎大家指出文章需要改正之处~

学无止境,合作共赢



在这里插入图片描述


欢迎路过的小哥哥小姐姐们提出更好的意见哇~~


作者:tangdou369098655
来源:juejin.cn/post/7206628135005143099
收起阅读 »

字节开源安卓开发利器-CodeLocator

CodeLocator登场 CodeLocator 是字节跳动开源的一个包含 Android SDK 与 Android Studio 插件的 Android 工具集。个人使用之后感觉是安卓开发人员的利器,推荐给大家。(mac、windows都可以用) Cod...
继续阅读 »

CodeLocator登场


CodeLocator 是字节跳动开源的一个包含 Android SDK 与 Android Studio 插件的 Android 工具集。个人使用之后感觉是安卓开发人员的利器,推荐给大家。(mac、windows都可以用)


CodeLocator的丰富功能可以让安卓应用人员受益,下面这个GIF展示了一些CodeLocator的功能。


CodeLocator转存失败,建议直接上传图片文件

快速上手



  1. 在Android Studio中安装CodeLocator插件(点此下载最新版插件)

  2. 工程中集成CodeLocator


// 集成基础能力, 只需要添加一行依赖即可
dependencies {
// 依赖androidx, 已升级AndroidX的项目集成下面的依赖
implementation "com.bytedance.tools.codelocator:codelocator-core:2.0.3"
// 未升级AndroidX的项目集成下面的依赖 support版本不再维护 请升级androidx
implementation "com.bytedance.tools.codelocator:codelocator-core-support:2.0.0"
}


  • 目前官网描述的代码跳转的能力,需要集成Lancet,但是Lancet的引入有关于Gradle 版本AGP 版本的要求



集成Lancet 插件和依赖的项目,关于Gradle 版本AGP 版本不能适配超过7.2,不建议高版本去适配,已经帮大家踩了很多坑了😢




还有一坑就是,CodeLocatorcompose支持不是友好🐶



当工程的依赖和Android Studio的插件都到位之后,便可以启动开发app,然后使用抓取功能和调试开发。


使用功能和场景


这里我讲述下自己在使用CodeLocator的一些场景。


UI相关功能


UI界面功能



当抓取了app当前的界面之后,直接可以在界面上点击,然后查看一些组件尺寸和间距的情况。这里在界面上有几种点击模式:



  • 直接单击: 会按照可点击属性查找View, 上层可点击View会覆盖底部View。

  • control(Alt) + 单击: 会去查看view的深度,z轴的情况。

  • Shift + 单击: 多选View, 同时可对比最后选中的两个View的间距,大家在安卓XML开发的时候,在真机测试下,这里的间距和尺寸观察就十分有用了。


实时修改ui


在界面上,点击view组件之后,可以直接右键选择修改属性,当然这里选中view之后右键还有很多好用的功能。



直接修改view组件的属性: 字符内容、字体大小、颜色、可见性、内外边距等等




CodeLocator还有复制窗口功能,复制窗口之后还有diff模式,比对ui的差别。



追溯抓取历史


CodeLocator抓取历史最多可以有三十条,其中每一条数据都带有时间和缩略图浏览。你可以在显示历史抓取功能里选择之前抓取的界面,然后对比属性。这里还可以直接保存抓取数据,文件会以projectName_XXXX_XXXX.codeLocator保存,之后想要使用便可以加载。


跳转界面对应的activity和fragment


CodeLocator可以在界面上,根据你抓取的界面和view组件,来判断它是在哪个activity、fragment和对应的XML组件名,并且直接选择跳转。


一些项目上,想快速知道这个页面到底归属哪个activity、fragment或者XML组件的时候,这个功能的优越性就体现出来了。



快速启动charles


一键启动charles,并且在Android Studio随开随关,不需要你去手机上专门开启和关闭代理



  • 开启




  • 关闭



上图工具箱中的集成功能也很丰富,也是在Android Studio随开随关。


工具箱


值得一提的是工具箱中的集成功能也很丰富,也是随开随关。




集成lanct有的功能


如果CodeLocator集成了lancet相关依赖和插件之后,可以有更强大的代码跳转能力:



  • 跳转findViewById

  • 跳转clickListener

  • 跳转touchListener

  • 跳转XML

  • 跳转viewHolder

  • 跳转startActivity

  • 跳转相应的dialog、toast


作者:weiran1999
来源:juejin.cn/post/7280787122012405794
收起阅读 »

又要用Compose来做Loading了,不过这次是带小火苗的

本篇文章已同步更新至个人公众号:Coffeeee 今年第一篇Compose动效开发,继续回归老本行,来一起做个Loading,老实说Loading动效个人已经做麻了,去年做了十几个,这次主要是想实现一个带火苗的Loading,因为之前看到过有位博主用Thre...
继续阅读 »

本篇文章已同步更新至个人公众号:Coffeeee



今年第一篇Compose动效开发,继续回归老本行,来一起做个Loading,老实说Loading动效个人已经做麻了,去年做了十几个,这次主要是想实现一个带火苗的Loading,因为之前看到过有位博主用Threejs实现过一个火焰的效果,然后又是职业病啊,想试试看用Compose实现一个火焰效果到底难不难


源码地址


扩散效果


第一步,先别去想啥Loading,先想想火是啥样子的,颜色以红黄为主,也有蓝色的,绿色的,然后从火源开始逐渐向外燃烧扩散,那么这里首先就要想办法把扩散的效果做出来,先上基础代码


image.png

先创建出代表画布的宽高widthheightCanvas创建出来之后会得到宽高的具体值,然后宽高的一半就是画布的中心坐标centerxcentery,radius是整个Loading的半径,接着我们先随意在中心位置画一个实心圆


image.png
image.png

现在如果想要让这个实心圆动起来的话,通常会使用animateFloatAsState这个api,比如这里想要改变它的横坐标,可以这么写


image.png
0109aa1.gif

也可以使用循环动画让圆点在那一直动


image.png
0109aa2.gif

但是以上两种方式如果是作用在有限数量的视图上,是没啥问题的,但是像我们要做的这个扩散效果,有大量元素的,并且每个元素动画的轨迹方向都不一样,那么就不能使用上面这种动画api了,性能问题先不说,写起来也是个麻烦,所以得想个其他办法,那么既然不能用动画api来改变元素的位置,我们就手动改嘛,先来定义个model,代表每个元素,这个model有以下几个属性


image.png

其中



  • startX:代表元素初始位置的x坐标

  • startY:代表元素初始位置的y坐标

  • endx:代表元素移动结束后的x坐标

  • endy:代表元素移动结束后的y坐标

  • angle:代表元素移动的方向,也就是角度

  • dis:代表元素每次移动的距离

  • size:代表元素的大小,如果是圆就当作半径,如果是方块就当作宽高

  • color:代表元素的颜色


然后给Particle里面添加一些更新位置的代码,第一处在初始化函数中,目的是当Particle刚创建出来时候,根据startXstartY来计算出第一次位移的终点endxendy


image.png

pointXpointY分别是通过起点,角度,半径来计算终点坐标的函数,代码如下


image.png

除了刚才在初始化函数中加的代码之外,还要增加一个update函数,每次调用这个函数的时候,都会重新把上一次的终点作为起点,重新计算新的终点坐标,这样才能做到让元素移动的效果


image.png

这样我们Particle的基础功能就开发完成了,接下来就要去创建我们需要扩散的元素,由于数量较多,我们得循环创建这些元素才行,首先创建的事情我们放在副作用函数LaunchedEffect中进行


image.png

其中ANGLES表示0到360的一个范围,调用random()函数来随机取出一个值当作元素移动的方向角度,上述代码中还缺点东西,首先需要有一个数组来保存创建好之后的Particle,我们这里新建一个数组,将创建好之后的Particle添加到数组中


image.png

其次这个LaunchedEffect函数体由于keytrue,所以无论重组几次都只会执行一次,那么我们的元素只会创建一次,而我们想要的效果是每过10毫秒都创建个元素,所以得把key值改成一个会改变的值,只有key改变了才会触发LaunchedEffect再执行一遍内部的代码,那么这个key我们就改成particleList这个数组的大小,每创建一个新元素,particleList的大小都会改变,改变之后下一次又会重新再去创建新元素,代码修改为


image.png

现在每过10毫秒,我们就会多一个Particle元素,但是现在只是创建了元素,元素还没动起来,要让它们动起来的话这个时候就要用到之前我们创建的update函数了,我们在重组的过程中遍历particleList中的元素,每个元素都执行一遍update,这样元素就动起来了


image.png

整个扩散效果到这里就算完工了,来看看效果咋样


0109aa3.gif

定制扩散的样式


扩散的效果做出来了,但是可以看到现在是无限往四周扩散的,咱要做的火苗可不能无限扩散,那不得发大火了吗,所以得让我们这些元素扩散到一定范围之后“看不见”,在Canvas中让一个元素看不见除了不去绘制之外,就是让它的透明度为0,那么在Particle中再新增一个属性alpha来表示元素的透明度


image.png

默认值为1,然后在update函数中,每次都减去一点透明值,直到透明值变为0,那么该元素就看不见了


image.png

CanvasdrawCircle函数中也添加alpha属性


image.png
0109aa4.gif

现在这个扩散的范围看起来又太小了,不过没事,可以通过设置dis属性来增加整个扩散的区域


image.png

还可以给每个元素设置不同的大小和颜色来改变整个效果的外观,先创建个半径的范围


image.png

再创建个颜色的集合


image.png

然后在创建Particle的时候,随机从半径范围与颜色集合中取出一个值作为Particlesizecolor


image.png

再来看下现在的效果


0109aa5.gif

制作loading效果


到这里为止我们的扩散的起始为止都是一个固定的点,现在要让这个固定的点变成可以变化的,绕着圆周转圈,那么首先就要获得圆周上的角度,这里使用循环动画创建一个0到360度循环改变的值当成角度


image.png

获得角度之后,使用pointXpointY函数来计算出这个角度在圆周上的x坐标tapx与y坐标tapy,将创建元素用到的centerxcentery替换成tapx,tapy


image.png

现在扩散效果就绕着画布中心转圈了


0109aa6.gif

看起来有点别扭啊,首先这个转圈一顿一顿的,然后尾巴貌似分叉的太开了,不过没事,这些都可以优化,分叉的太开主要是我们扩散的角度是0到360度,将这个范围变小一点就好了


image.png

动画一顿一顿的是因为我们的动画设置的是两秒,它只有到了两秒以后才会进行下一次动画,但是变化的角度不到两秒的时候就已经到达360度了,所以才会在360度的位置停滞了一段时间,解决办法就是将动画规格从补间动画改成关键帧动画,将到达360度的那一帧设置在2000毫秒的位置上


image.png
0109aa7.gif

转圈不顿了,但是现在离火苗的效果还是有点出入的,我们这个loading的头部位置相当于火苗的燃烧源头,而燃烧源相对来讲都是比较大的,然后逐渐朝着燃烧的方向变小,所以还得继续优化下,现在元素的半径还太小,得变大


image.png

其次在update函数中,也对半径size做递减处理,直到半径变为0


image.png

再来看下效果


0109aa8.gif

还差最后一步,将整个画布设置下模糊效果,设置一下blur函数,内部参数越大,模糊的效果越严重,调了一下后7.dp比较合适


image.png

加了模糊效果后的效果如下


0109aa9.gif

一团小火苗就做出来了,感觉效果比较空,我们可以再加一个火苗,现在圆周上只有一个定点在转,我们再加一个,颜色设置成偏蓝,刚好一个火焰一个冰焰


image.png
image.png

最终效果如下


0109aa10.gif

总结


到这里一个火焰Loading的动效就完成了,还是很容易的其实,里面最主要的就是通过那几个参数来控制好元素扩散的效果,甚至我们可以尝试着去更改一些参数或者实现方式,来做一些其他不一样的动效,这些大家如果有兴趣的可以自己去试试看。


作者:Coffeeee
来源:juejin.cn/post/7329433979806810146
收起阅读 »

浏览器关闭实现登出(后端清token)

web
实现浏览器关闭后,后端记录用户登出日志的最佳方式是通过前端发送请求来通知后端进行记录。以下是一种常见的实现方式,重点就是如何区分用户行为是页面刷新还是关闭浏览器。 // 写在APP.vue mounted() { window.addEventLi...
继续阅读 »

实现浏览器关闭后,后端记录用户登出日志的最佳方式是通过前端发送请求来通知后端进行记录。以下是一种常见的实现方式,重点就是如何区分用户行为是页面刷新还是关闭浏览器。


// 写在APP.vue
mounted() {
window.addEventListener("beforeunload", () => this.beforeunloadHandler())
window.addEventListener("unload", () => this.unloadHandler())
},

destroyed() {
window.removeEventListener("beforeunload", () => this.beforeunloadHandler())
window.removeEventListener("unload", () => this.unloadHandler())
clearInterval(this.timer)
},

methods:{
beforeunloadHandler() {
this.beforeUnloadTime = new Date().getTime()
},
unloadHandler() {
this.gapTime = new Date().getTime() - this.beforeUnloadTime
if (this.gapTime <= 5) { //判断是窗口关闭还是刷新,小于5代表关闭,否则就是刷新。
// 这里是关闭浏览器
logout()
}
},
}


但是经测试,发现上面这种浏览器关闭事件并不是一种可靠的方式来捕捉用户的登出操作,后端并非百分百接收到logout请求,经查资料得知,在unload阶段发送的异步请求是不可靠的,有可能被cancel。后面又尝试了fetch,设置了keepalive(即使浏览器关闭,请求照样发送), 但是又发现gapTime<=5的判断条件也存在兼容性问题,不同浏览器的时间差存在差异。此外还存在一些特殊情况:用户可能直接关闭浏览器窗口、断开网络连接或发生其他异常情况,导致浏览器关闭事件无法被触发,因此pass掉上述方案。


后面也尝试了心跳机制(websocket),也存在局限性,pass。


最后想到了一种最简单,最朴实的方式:
开启定时器每秒往localStorage写入当前时间lastRecordTime(new Date().getTime()), 在请求拦截器中给每个接口请求头带上两个时间,最后一次写入时间lastRecordTime和当前时间nowTime, 后端只要把两个时间相减, 超过5s(自定义)就算登出,清掉redis里相应的token。


// 写在APP.vue
created (){
// 每秒写入一次时间
this.timer = setInterval(() => {
// 这个判断代表登录成功后才开始写入时间
if(localStorage.getItem('token')) {
localStorage.setItem('lastRecordTime', new Date().getTime())
}
}, 1000)
}

另外需要注意, 在登录成功的地方要立即写入一次时间, 不然有BUG。


  // 写在请求拦截器
const headers = config.headers;
/** 用于判断用户是否关闭过浏览器,如果关闭则跳转至登录页面,以及及时清理redis中的token */
if (localStorage.getItem('lastRecordTime')) {
headers.lastRecordTime = localStorage.getItem('lastRecordTime');
}
headers.nowTime = new Date().getTime();

总结一下,目前没发现哪种方式可以提供一种可靠的通信方式去通知后端清除token, 通过两个时间差的方式相对靠谱。


作者:起床搬砖啦
来源:juejin.cn/post/7328221562817478665
收起阅读 »

转转流量录制与回放的原理及实践

1 需求背景 随着转转业务规模和复杂度不断提高,业务服务增加,服务之间依赖关系也逐渐复杂。在开发和测试中遇到如下问题: 参数构造:转转接口测试平台可以很方便调用SCF(转转RPC框架),但是接口的参数很多是复杂model,手动构造参数成本不小,希望能够录制稳...
继续阅读 »

1 需求背景


随着转转业务规模和复杂度不断提高,业务服务增加,服务之间依赖关系也逐渐复杂。在开发和测试中遇到如下问题:



  • 参数构造:转转接口测试平台可以很方便调用SCF(转转RPC框架),但是接口的参数很多是复杂model,手动构造参数成本不小,希望能够录制稳定测试环境流量,从流量中抽取接口参数,方便使用者选择参数进行接口测试。

  • 压测流量构造:转转是二手电商平台,有许多促销活动的压测需求,人工构造压测流量既不能模拟真实访问,又成本高昂。所以有录制线上流量的需求,然后压测平台通过策略二次加工形成压测case。

  • 自动化回归测试:业务迭代速度很快,每次迭代会不会影响原有逻辑?希望有一个平台能够提供筛选保存case,自动化回归,case通过率报告通知等能力。


这些问题每个互联网公司都会遇到,如何才能优雅解决这些问题呢?首先定义一下优雅:不增加业务成本,业务基本无感,对业务性能影响要足够小。阿里开源的jvm-sandbox-repeater(简称Repeater)正是为解决这些问题而生,能够做到业务无感,但是性能问题需要特别定制处理。本文重点介绍:



  • Repeater流量录制和回放业务无感实现原理(第2、3章节)

  • 线上服务流量录制时,如何减少对正常业务的性能影响(第4章节)


希望能够揭秘Repeater如何做到业务无感的流量录制和回放,进而让使用流量录制的同学对Repeater内部做了哪些工作以及对性能有哪些影响做到心中有数,最后介绍在流量录制时,为了保证对线上服务的性能影响相对可控,我们做了哪些工作,让大家会用敢用。


2 流量录制和回放概念


2.1 流量录制


对于Java调用,一次流量录制包括一次入口调用(entranceInvocation)(eg:HTTP/Dubbo/Java)和若干次子调用(subInvocations)。流量的录制过程就是把入口调用和子调用绑定成一次完整的记录。


    /**
* 获取商品价格,先从redis中获取,如果redis中没有,再用rpc调用获取,
*
@param productId
*
@return
*/

public Integer getProductPrice(Long productId){ //入口调用

//1.redis获取价格
Integer price = redis.get(productId); //redis远程子调用
if(Objects.isNull(price)){
//2.远程调用获取价格
price = daoRpc.getProductCount(productId); //rpc远程子调用
redis.set(productId, price); //redis远程子调用
}
//3.价格策略处理
price = process(price); //本地子调用
return price;

}

private Integer process(Long price){
//价格策略远程调用
return logicRpc.process(productId); //rpc远程子调用
}

getProductPrice流量录制图解


以获取产品价格方法为例,流量录制的内容简单来说是入口调用(getProductPrice)的入参和返回值,远程子调用(redis.get,daoRpc.getProductCount,redis.set,logicRpc.process)的入参和返回值,用于流量回放。注意并不会录制本地子调用(process)。


下图是转转流量回放平台录制好的单个流量的线上效果,帮助理解流量录制概念。
流量录制


2.2 流量回放


流量回放,获取录制流量的入口调用入参,再次发起调用,并且对于子调用,直接使用流量录制时记录的入参和返回值,根据入参(简单来说)匹配子调用后,直接返回录制的数据。这样就还原了流量录制时的环境,如果回放后返回值和录制时返回值不一致,那么本条回放case标记为失败。
还以getProductPrice为例,假设录制时入口调用参数productId=1,返回值为1;redis.get子调用参数productId=1,返回值为1。那么回放时,redis.get不会访问redis,而是直接返回1。假设该函数有逻辑更新,回放返回值是2,与录制时返回值1不相等,那么次此流量回放标记为失败。


下图是转转流量回放平台的流量回放的线上效果,帮助理解流量回放概念
流量回放


明白流量录制和回放概念后,下面看看业务无感实现流量录制和回放的实现原理。


3 Repeater实现原理


Repeater架构图



  • Repeater Console模块

    • 流量录制和回放的配置管理

    • 心跳管理

    • 录制和回放调用入口



  • Repeater agent plugin模块:Repeater核心功能是流量录制回放,其实现核心是agent插件,开源框架已经实现redis、mybatis、http、okhttp、dubbo、mq、guava cache等插件。由于录制和回放逻辑是以字节码增强的方式在程序运行时织入,所以无需业务编码。换句话说,agent技术是业务无感的关键。


下面我们就进入无感的关键环节,介绍Repeater如何织入流量录制和回放逻辑代码,以及梳理流量录制和回放的核心代码。


3.1 流量录制和回放逻辑如何织入


用一句话来说,Repeater本身并没有实现代码织入功能,它依赖另一个阿里开源项目JVM-Sandbox。详细来讲,Repeater的核心逻辑录制协议基于JVM-Sandbox的BEFORERETRUNTHROW事件机制进行录制流程控制。本质上来说,JVM-Sandbox实现了java agent级别的spring aop功能,是一个通用增强框架。JVM-Sandbox的基于字节码增强的事件机制原理见下图:JVM-Sandbox事件机制


上图以add方法为例,揭示JVM-Sandbox增强前后的代码变化,方便大家理解。下面的代码是对图中增强代码相关重点的注释


public int add(int a, int b) {
try {
Object[] params = new Object[]{a, b};
//BEFORE事件
Spy.Ret retOnBefore = Spy.onBefore(10001,
"com.taobao.test.Test", "add", this, params);
//BEFORE结果可以直接返回结果或者抛出异常,是实现mock(阻断真实远程调用)的关键
if (retOnBefore.state == I_RETURN) return (int) retOnBefore.object;
if (retOnBefore.state == I_THROWS) throws(Throwable) retOnBefore.object;
a = params[0];
b = params[1];
int r = a + b;
//RETRUN事件
Spy.Ret retOnReturn = Spy.onReturn(10001, r);
if (retOnReturn.state == I RETURN)return (int) retOnReturn.object;
if (retOnReturn.state == I_THROWS) throws(Throwable) retOnReturn.object;
return r;
} catch (Throwable cause) {
//THROW事件
Spy.Ret retOnIhrows = Spy.onThrows(10001, cause);
if (retOnThrows.state == I RETURN)return (int) retOnThrows.object;
if (retOnThrows.state == I THROWS) throws(Throwable) retOnThrows.object;
throws cause;
}
}

由上可知,Repeater是利用jvm agent字节码增强技术为目标方法织入BEFORERETRUNTHROW逻辑。


3.2 流量录制和回放的核心代码


既然Repeater利用JVM-Sandbox aop框架编写流量录制和回放逻辑,那么让我们看看它的核心代码doBefore。先来一张流程图。


录制和回放插件逻辑图解


再重点介绍doBeforedoMock的核心代码,它们是实现录制和回放的关键,注意阅读注释。为了方便理解,我对开源代码做了大量删减,只保留核心逻辑。


    /**
* 处理before事件
* 流量录制时记录函数元信息和参数,缓存录制数据
* 流量回放时,调用回放逻辑,直接返回录制时的数据,后面会对processor.doMock进行展开讲解
*
@param event before事件
*/

protected void doBefore(BeforeEvent event) throws ProcessControlException {
// 回放流量;如果是入口则放弃;子调用则进行mock
if (RepeatCache.isRepeatFlow(Tracer.getTraceId())) {
processor.doMock(event, entrance, invokeType);
return;
}
//非回放流量,进行流量录制,主要元信息、参数、返回值
Invocation invocation = initInvocation(event);
//记录是否为入口流量
invocation.setEntrance(entrance);
//记录参数
invocation.setRequest(processor.assembleRequest(event));
//记录返回值
invocation.setResponse(processor.assembleResponse(event));

}

@Override
public void doMock(BeforeEvent event, Boolean entrance, InvokeType type) throws ProcessControlException {

try {

//通过录制数据构建mock请求
final MockRequest request = MockRequest.builder().build();
//执行mock动作
final MockResponse mr = StrategyProvider.instance().provide(context.getMeta().getStrategyType()).execute(request);
//根据mock结果,阻断真实远程调用
switch (mr.action) {
case SKIP_IMMEDIATELY:
break;
case THROWS_IMMEDIATELY:
//直接抛出异常,映射到JVM-Sandbox的事件机制原理的add函数
//也就是代码走到if (retOnBefore.state == I_THROWS) throws(Throwable) retOnBefore.object;
//而不再执行后面的代码(JVM-Sandbox框架机制,调用如下代码会触发阻断真实调用)
ProcessControlException.throwThrowsImmediately(mr.throwable);
break;
case RETURN_IMMEDIATELY:
//直接返回录制结果,映射到JVM-Sandbox的事件机制原理的add函数,同理,也不再执行后面的代码(阻断真实调用)
ProcessControlException.throwReturnImmediately(assembleMockResponse(event, mr.invocation));
break;
default:
ProcessControlException.throwThrowsImmediately(new RepeatException("invalid action"));
break;
}
} catch (ProcessControlException pce) {
throw pce;
} catch (Throwable throwable) {
ProcessControlException.throwThrowsImmediately(new RepeatException("unexpected code snippet here.", throwable));
}
}

通过上面的2、3章节介绍了Repeater流量录制和回放业务无感的实现原理,下面说一下应用过程中需要哪些改造点。


4 Repeater落地实践


4.1 改造点



  • Rpeater开源管理后台仅仅是个Demo,需要重新设计和实现。

  • SCF(转转RPC框架)插件扩展,支持SCF应用的流量录制和回放。

  • DB由MySQL改造为ES,Repeater原生使用MySQL作为流量录制和回放的数据库,仅用于Demo演示,性能和容量无法满足实际需求。

  • Docker环境下频繁更换ip时不中断录制。

  • 回放结果Diff支持字段过滤。

  • 大批量回放。

  • 线上环境录制。


4.2 线上环境录制


流量录制很大一部分应用场景在线下,但是线上也有录制场景。从流量录制的原理可知,由于要记录入口调用和各种远程子调用,开启流量录制后,对于该请求占用内存资源会大大增加,并且会增加耗cpu的序列化操作(用于上报流量录制结果)。既然流量录制是一个天然的耗内存和性能操作,对于线上服务的录制除了保持敬畏之心之外,还有设计一种机制减少录制时对线上服务的性能影响。下面开始介绍如果做到录制时减少对线上服务性能的影响。


线上录制减少性能影响的方案:



  • 从流程上,线上录制需要申请。

  • 从技术上,与发布系统联动,为录制服务增加专门的节点进行录制,并且设置权重为正常节点的1/10,正常节点不会挂载流量录制代码。

  • 从回滚上,如果线上录制节点遇到问题,可以从发布系统直接删除录制节点。


    线上录制效果



5 总结


本文旨在介绍Repeater流量录制和回放的实现原理,以及在落地过程中改造点,希望达到让大家懂原理、会使用、敢使用的目的。


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

Java 世界的法外狂徒:反射

概述 反射(Reflection)机制是指在运行时动态地获取类的信息以及操作类的成员(字段、方法、构造函数等)的能力。通过反射,我们可以在编译时期未知具体类型的情况下,通过运行时的动态查找和调用。 虽然 Java 是静态的编译型语言,但是反射特性的加入,提供...
继续阅读 »

Reflection Title


概述


反射(Reflection)机制是指在运行时动态地获取类的信息以及操作类的成员(字段、方法、构造函数等)的能力。通过反射,我们可以在编译时期未知具体类型的情况下,通过运行时的动态查找和调用。 虽然 Java 是静态的编译型语言,但是反射特性的加入,提供一种直接操作对象外的另一种方式,让 Java 具备的一些灵活性和动态性,我们可以通过本篇文章来详细了解它


为什么需要反射 ?


Java 需要用到反射的主要原因包括以下几点:



  1. 运行时动态加载,创建类:Java中的类是在编译时加载的,但有时希望在运行时根据某些条件来动态加载和创建所需要类。反射就提供这种能力,这样的能力让程序可以更加的灵活,动态

  2. 动态的方法调用:根据反射获取的类和对象,动态调用类中的方法,这对于一些类增强框架(例如 Spring 的 AOP),还有安全框架(方法调用前进行权限验证),还有在业务代码中注入一些通用的业务逻辑(例如一些日志,等,动态调用的能力都非常有用

  3. 获取类的信息:通过反射,可以获取类的各种信息,如类名、父类、接口、字段、方法等。这使得我们可以在运行时检查类的属性和方法,并根据需要进行操作


一段示例代码


以下是一个简单的代码示例,展示基本的反射操作:


import java.lang.reflect.Method;

public class ReflectionExample {
public static void main(String[] args) {
// 假设在运行时需要调用某个类的方法,但该类在编译时未知
String className = "com.example.MyClass";

try {
// 使用反射动态加载类
Class<?> clazz = Class.forName(className);

// 使用反射获取指定方法
Method method = clazz.getMethod("myMethod");

// 使用反射创建对象
Object obj = clazz.newInstance();

// 使用反射调用方法
method.invoke(obj);

} catch (ClassNotFoundException e) {
System.out.println("类未找到:" + className);
} catch (NoSuchMethodException e) {
System.out.println("方法未找到");
} catch (IllegalAccessException | InstantiationException e) {
System.out.println("无法实例化对象");
} catch (Exception e) {
System.out.println("其他异常:" + e.getMessage());
}
}
}

在这个示例中,我们假设在编译时并不知道具体的类名和方法名,但在运行时需要根据动态情况来加载类、创建对象并调用方法。使用反射机制,我们可以通过字符串形式传递类名,使用 Class.forName() 动态加载类。然后,通过 getMethod() 方法获取指定的方法对象,使用 newInstance() 创建类的实例,最后通过 invoke() 方法调用方法。


使用场景


技术再好,如果无法落地,那么始终都是空中楼阁,在日常开发中,我们常常可以在以下的场景中看到反射的应用:



  1. 框架和库:许多框架和库使用反射来实现插件化架构或扩展机制。例如,Java 的 Spring 框架使用反射来实现依赖注入(Dependency Injection)和 AOP(Aspect-Oriented Programming)等功能。

  2. ORM(对象关系映射):ORM 框架用于将对象模型和关系数据库之间进行映射。通过反射,ORM 框架可以在运行时动态地读取对象的属性和注解信息,从而生成相应的 SQL 语句并执行数据库操作。

  3. 动态代理:动态代理是一种常见的设计模式,通过反射可以实现动态代理。动态代理允许在运行时创建代理对象,并拦截对原始对象方法的调用。这在实现日志记录、性能统计、事务管理等方面非常有用

  4. 反射调试工具:在开发和调试过程中,有时需要查看对象的结构和属性,或者动态调用对象的方法来进行测试。反射提供了一种方便的方式来检查和操作对象的内部信息,例如使用getDeclaredFields()获取对象的所有字段,或使用getMethod()获取对象的方法

  5. 单元测试:在单元测试中,有时需要模拟或替换某些对象的行为,以便进行有效的测试。通过反射,可以在运行时创建对象的模拟实例,并在测试中替换原始对象,以便控制和验证测试的行为


Class 对象


Class 对象是反射的第一步,我们先从 Class 对象聊起,因为在反射中,只要你想在运行时使用类型信息,就必须先得到那个 Class 对象的引用,他是反射的核心,它代表了Java类的元数据信息,包含了类的结构、属性、方法和其他相关信息。通过Class对象,我们可以获取和操作类的成员,实现动态加载和操作类的能力。


常见的获取 Class 对象的方式几种:


// 使用类名获取
Class<?> clazz = Class.forName("com.example.MyClass");

// 使用类字面常量获取
Class<?> clazz = MyClass.class;

// 使用对象的 getClass() 方法获取
MyClass obj = new MyClass();
Class<?> clazz = obj.getClass();


需要注意的是,如果 Class.forName() 找不到要加载的类,它就会抛出异常 ClassNotFoundException



正如上面所说,获取 Class 对象是第一步,一旦获取了Class对象,我们可以使用它来执行各种反射操作,例如获取类的属性、方法、构造函数等。示例:


String className = clazz.getName(); // 获取类的全限定名
int modifiers = clazz.getModifiers(); // 获取类的修饰符,如 public、abstract 等
Class<?> superClass = clazz.getSuperclass(); // 获取类的直接父类
Class<?> superClass = clazz.getSuperclass(); // 获取类的直接父类
Class<?>[] interfaces = clazz.getInterfaces(); // 获取类实现的接口数组
Constructor<?>[] constructors = clazz.getConstructors(); // 获取类的公共构造函数数组
Method[] methods = clazz.getMethods(); // 获取类的公共方法数组
Field[] fields = clazz.getFields(); // 获取类的公共字段数组
Object obj = clazz.newInstance(); // 创建类的实例,相当于调用无参构造函数

上述示例仅展示了Class对象的一小部分使用方法,还有许多其他方法可用于获取和操作类的各个方面。通过Class对象,我们可以在运行时动态地获取和操作类的信息,实现反射的强大功能。


类型检查


在反射的代码中,经常会对类型进行检查和判断,从而对进行对应的逻辑操作,下面介绍几种 Java 中对类型检查的方法


instanceof 关键字


instanceof 是 Java 中的一个运算符,用于判断一个对象是否属于某个特定类或其子类的实例。它返回一个布尔值,如果对象是指定类的实例或其子类的实例,则返回true,否则返回false。下面来看看它的使用示例


1:避免类型转换错误


在进行强制类型转换之前,使用 instanceof 可以检查对象的实际类型,以避免类型转换错误或 ClassCastException 异常的发生:


if (obj instanceof MyClass) {
MyClass myObj = (MyClass) obj;
// 执行针对 MyClass 类型的操作
}

2:多态性判断


使用 instanceof 可以判断对象的具体类型,以便根据不同类型执行不同的逻辑。例如:


if (animal instanceof Dog) {
Dog dog = (Dog) animal;
dog.bark();
} else if (animal instanceof Cat) {
Cat cat = (Cat) animal;
cat.meow();
}

3:接口实现判断


在使用接口时,可以使用 instanceof 判断对象是否实现了某个接口,以便根据接口进行不同的处理


if (obj instanceof MyInterface) {
MyInterface myObj = (MyInterface) obj;
myObj.doSomething();
}

4:继承关系判断


instanceof 可以用于判断对象是否是某个类的子类的实例。这在处理继承关系时非常有用,可以根据对象的具体类型执行相应的操作


if (obj instanceof MyBaseClass) {
MyBaseClass myObj = (MyBaseClass) obj;
// 执行 MyBaseClass 类型的操作
}

instanceof 看似可以做很多事情,但是在使用时也有很多限制,例如:



  1. 无法和基本类型进行匹配:instanceof 运算符只能用于引用类型,无法用于原始类型

  2. 不能和 Class 对象类型匹配:只可以将它与命名类型进行比较

  3. 无法判断泛型类型参数:由于Java的泛型在运行时会进行类型擦除,instanceof 无法直接判断对象是否是某个泛型类型的实例



instanceof 看似方便,但过度使用它可能表明设计上的缺陷,可能违反了良好的面向对象原则。应尽量使用多态性和接口来实现对象行为的差异,而不是过度依赖类型检查。



isInstance() 函数


java.lang.Class 类也提供 isInstance() 类型检查方法,用于判断一个对象是否是指定类或其子类的实例。更适合在反射的场景下使用,代码示例:


Class<?> clazz = MyClass.class;
boolean result = clazz.isInstance(obj);

如上所述,相比 instanceof 关键字,isInstance() 提供更灵活的类型检查,它们的区别如下:



  1. isInstance() 方法的参数是一个对象,而 instanceof 关键字的操作数是一个引用类型。因此,使用 isInstance() 方法时,可以动态地确定对象的类型,而 instanceof 关键字需要在编译时指定类型。

  2. isInstance()方法可以应用于任何Class对象。它是一个通用的类型检查方法。而instanceof关键字只能应用于引用类型,用于检查对象是否是某个类或其子类的实例。

  3. isInstance()方法是在运行时进行类型检查,它的结果取决于实际对象的类型。而instanceof关键字在编译时进行类型检查,结果取决于代码中指定的类型。

  4. 由于Java的泛型在运行时会进行类型擦除,instanceof无法直接检查泛型类型参数。而isInstance()方法可以使用通配符类型(<?>)进行泛型类型参数的检查。


总体而言,isInstance()方法是一个动态的、通用的类型检查方法,可以在运行时根据实际对象的类型来判断对象是否属于某个类或其子类的实例。与之相比,instanceof关键字是在编译时进行的类型检查,用于检查对象是否是指定类型或其子类的实例。它们在表达方式、使用范围和检查方式等方面有所差异。在具体的使用场景中,可以根据需要选择合适的方式进行类型检查。


代理


代理模式


代理模式是一种结构型设计模式,其目的是通过引入一个代理对象,控制对原始对象的访问。代理对象充当了原始对象的中间人,可以在不改变原始对象的情况下,对其进行额外的控制和扩展。这是一个简单的代理模式示例:


// 定义抽象对象接口
interface Image {
void display();
}

// 定义原始对象
class RealImage implements Image {
private String fileName;

public RealImage(String fileName) {
this.fileName = fileName;
loadFromDisk();
}

private void loadFromDisk() {
System.out.println("Loading image:" + fileName);
}

@Override
public void display() {
System.out.println("Displaying image:" + fileName);
}
}

// 定义代理对象
class ImageProxy implements Image {
private String filename;
private RealImage realImage;

public ImageProxy(String filename) {
this.filename = filename;
}

@Override
public void display() {
if (realImage == null) {
realImage = new RealImage(filename);
}
realImage.display();
}
}

public class ProxyPatternExample {
public static void main(String[] args) {
// 使用代理对象访问实际对象
Image image = new ImageProxy("test_10mb.jpg");
// 第一次访问,加载实际对象
image.display();
// 第二次访问,直接使用已加载的实际对象
image.display();
}
}

输出结果:


Loading image:test_10mb.jpg
Displaying image:test_10mb.jpg
Displaying image:test_10mb.jpg

在上述代码中,我们定义了一个抽象对象接口 Image,并有两个实现类:RealImage 代表实际的图片对象,ImageProxy 代表图片的代理对象。在代理对象中,通过控制实际对象的加载和访问,实现了延迟加载和额外操作的功能。客户端代码通过代理对象来访问图片,实现了对实际对象的间接访问。


动态代理


Java的动态代理是一种在运行时动态生成代理类和代理对象的机制,它可以在不事先定义代理类的情况下,根据接口或父类来动态创建代理对象。动态代理使用Java的反射机制来实现,通过动态生成的代理类,可以在方法调用前后插入额外的逻辑。


以下是使用动态代理改写上述代码的示例:


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

// 定义抽象对象接口
interface Image {
void display();
}

// 定义原始对象
class RealImage implements Image {
private String filename;

public RealImage(String filename) {
this.filename = filename;
loadFromDisk();
}

private void loadFromDisk() {
System.out.println("Loading image: " + filename);
}

public void display() {
System.out.println("Displaying image: " + filename);
}
}

// 实现 InvocationHandler 接口的代理处理类
class ImageProxyHandler implements InvocationHandler {

private Object realObject;

public ImageProxyHandler(Object realObject) {
this.realObject = realObject;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Object result = null;
if (method.getName().equals("display")) {
System.out.println("Proxy: before display");
result = method.invoke(realObject, args);
System.out.println("Proxy: after display");
}
return result;
}
}

public class DynamicProxyExample {

public static void main(String[] args) {
// 创建原始对象
Image realImage = new RealImage("image.jpg");
// 创建动态代理对象
Image proxyImage = (Image) Proxy.newProxyInstance(Image.class.getClassLoader(), new Class[]{Image.class}, new ImageProxyHandler(realImage));
// 使用代理对象访问实际对象
proxyImage.display();
}
}

在上述代码中,我们使用 java.lang.reflect.Proxy 类创建动态代理对象。我们定义了一个 ImageProxyHandler 类,实现了 java.lang.reflect.InvocationHandler 接口,用于处理代理对象的方法调用。在 invoke() 方法中,我们可以在调用实际对象的方法之前和之后执行一些额外的逻辑。


输出结果:


Loading image: image.jpg
Proxy: before display
Displaying image: image.jpg
Proxy: after display

在客户端代码中,我们首先创建了实际对象 RealImage,然后通过 Proxy.newProxyInstance() 方法创建了动态代理对象 proxyImage,并指定了代理对象的处理类为 ImageProxyHandler。最后,我们使用代理对象来访问实际对象的 display() 方法。


通过动态代理,我们可以更加灵活地对实际对象的方法进行控制和扩展,而无需显式地创建代理类。动态代理在实际开发中常用于 AOP(面向切面编程)等场景,可以在方法调用前后添加额外的逻辑,如日志记录、事务管理等。


违反访问权限


在 Java 中,通过反射机制可以突破对私有成员的访问限制。以下是一个示例代码,展示了如何使用反射来访问和修改私有字段:


import java.lang.reflect.Field;

class MyClass {
private String privateField = "Private Field Value";
}

public class ReflectionExample {

public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
MyClass myObj = new MyClass();
// 获取私有字段对象
Field privateField = MyClass.class.getDeclaredField("privateField");

// 取消对私有字段的访问限制
privateField.setAccessible(true);

// 获取私有字段的值
String fieldValue = (String) privateField.get(myObj);
System.out.println("Original value of privateField: " + fieldValue);

// 修改私有字段的值
privateField.set(myObj, "New Field Value");

// 再次获取私有字段的值
fieldValue = (String) privateField.get(myObj);
System.out.println("Modified value of privateField: " + fieldValue);
}
}

在上述代码中,我们定义了一个 MyClass 类,其中包含一个私有字段 privateField。在 ReflectionExample 类的 main 方法中,我们使用反射获取了 privateField 字段,并通过 setAccessible(true) 方法取消了对私有字段的访问限制。然后,我们使用 get() 方法获取私有字段的值并输出,接着使用 set() 方法修改私有字段的值。最后,再次获取私有字段的值并输出,验证字段值的修改。


输出结果:


Original value of privateField: Private Field Value
Modified value of privateField: New Field Value

除了字段,通过反射还可以实现以下违反访问权限的操作:



  • 调用私有方法

  • 实例化非公开的构造函数

  • 访问和修改静态字段和方法

  • 绕过访问修饰符检查


虽然反射机制可以突破私有成员的访问限制,但应该慎重使用。私有成员通常被设计为内部实现细节,并且具有一定的安全性和封装性。过度依赖反射访问私有成员可能会破坏代码的可读性、稳定性和安全性。因此,在使用反射突破私有成员限制时,请确保了解代码的设计意图和潜在风险,并谨慎操作。


总结


反射技术自 JDK 1.1 版本引入以来,一直被广泛使用。它为开发人员提供了一种在运行时动态获取类的信息、调用类的方法、访问和修改类的字段等能力。在过去的应用开发中,反射常被用于框架、工具和库的开发,以及动态加载类、实现注解处理、实现代理模式等场景。反射技术为Java的灵活性、可扩展性和动态性增添了强大的工具。


当下,反射技术仍然发挥着重要的作用。它被广泛应用于诸多领域,如框架、ORM(对象关系映射)、AOP(面向切面编程)、依赖注入、单元测试等。反射技术为这些领域提供了灵活性和可扩展性,使得开发人员能够在运行时动态地获取和操作类的信息,以实现更加灵活和可定制的功能。同时,许多流行的开源框架和库,如 Spring、Hibernate、JUnit 等,也广泛使用了反射技术。


反射技术可能继续发展和演进。随着 Java 平台的不断发展和语言特性的增强,反射技术可能会在性能优化,安全性,模块化等方面进一步完善和改进反射的应用。然而,需要注意的是,反射技术应该谨慎使用。由于反射涉及动态生成代码、绕过访问限制等操作,如果使用不当,可能导致代码的可读性和性能下降,甚至引入安全漏洞。因此,开发人员在使用反射时应该充分理解其工作原理和潜在的风险,并且遵循最佳实践。


作者:小二十七
来源:juejin.cn/post/7235513984556220476
收起阅读 »

🌟前端使用Lottie实现炫酷的开关效果🌟

web
前言 在平时的开发过程中,前端或多或少都会遇到实现动画效果的场景。手写动画是一件相当麻烦的事情,调来调去不仅费时费力,可能还会被产品/UI吐槽:这动画效果也不难呀,为什么就不能实现呢?/为什么就没有还原成我想要的样子呢。 比如说产品让我们实现这样的一个开关动...
继续阅读 »

前言


在平时的开发过程中,前端或多或少都会遇到实现动画效果的场景。手写动画是一件相当麻烦的事情,调来调去不仅费时费力,可能还会被产品/UI吐槽:这动画效果也不难呀,为什么就不能实现呢?/为什么就没有还原成我想要的样子呢。


image.png


比如说产品让我们实现这样的一个开关动效


Kapture 2024-01-20 at 21.53.34.gif


今天我们就用动画的实现方式——Lottie,来百分百还原设计师的动画效果,并且可以大大提高我们的工作效率(摸鱼时间)。


image.png


Lottie简介


首先我们先来看一下,平时我们实现动画都有哪些方式,它们分别有什么优缺点:


动画类型优点缺点
CSS 动画使用简便,通过@keyframestransition创建动画;浏览器原生支持,性能较好控制有限,不适用于复杂动画;复杂动画可能需要大量 CSS 代码,冗长
JavaScript 动画提供更高程度的控制和灵活性;适用于复杂和精细动画效果引入库增加页面负担,可能需要学习曲线;使用不当容器对页面性能造成影响,产生卡顿
GIF 动画制作和使用简单,无需额外代码;几乎所有浏览器原生支持有限颜色深度,不适用于所有场景;清晰度与文件尺寸成正比,无法适应所有分辨率
Lottie支持矢量动画,保持清晰度和流畅性 ;跨平台使用,适用于 iOS、Android 和 Web在一些较旧或性能较低的设备上,播放较大的 Lottie 动画可能会导致性能问题;对设计师要求较高

Lottie是由Airbnb开发的一个开源库,用于在移动端和Web上呈现矢量动画。它基于JSON格式的Bodymovin文件,可以将由设计师在AE中创建的动画导出为可在Lottie库中播放的文件。


相对于手写CSS/JS动画而言,它可以大大减少前端开发的工作量,相对于GIF文件来说,它可以在一个合理的文件体积内保证动画的清晰度以及流畅程度。下面我们就介绍一下如何播放一个Lottie动画,并实现一个炫酷的开关效果。


Hello Lottie


假设我们现在已经有一个Lottiejson文件,那么现在安装一些依赖


npm i react-lottie prop-types

安装完之后我们就可以这样子来播放一个Lottie动画:


import animationData from "../../assets/switch-lottie.json";

const LottieSwitch = () => {
const playing = useRef(false);
const options = {
loop: true,
autoplay: true,
animationData: animationData,
rendererSettings: {
preserveAspectRatio: "xMidYMid slice",
},
};
return (
<Lottie
options={options}
height={20}
width={40}
/>

);
};


Kapture 2024-01-20 at 21.41.20.gif


来解释一下上面的options参数里面各个字段是什么意思:



  • loop:是否循环播放

  • autoplay:是否自动播放

  • animationDataLottie动画json资源

  • rendererSettings.preserveAspectRatio:指定如何在给定容器中渲染Lottie动画

    • xMidYMid: 表示在水平和垂直方向上都在中心对齐

    • 表示保持纵横比,但可能会裁剪超出容器的部分




正/反向播放


正常的把Lottie动画播放出来之后,我们就可以开始实现一个开关的功能。其实就是点击的时候更换Lottie的播放方向,这里对应的是direction字段,direction1时正向播放,direction-1时反向播放。


我们就要实现下面的功能:



  • 点击时切换方向

  • 播放过程中加锁,禁止切换方向

  • 监听播放结束事件,解锁

  • loop改为falseautoplay改为false


实现代码如下:


const LottieSwitch = () => {
const [direction, setDirection] = useState(null);
const playing = useRef(false);
const options = {
loop: false,
autoplay: false,
animationData: animationData,
rendererSettings: {
preserveAspectRatio: "xMidYMid slice",
},
};

const handleClick = () => {
if (playing.current) {
return;
}
playing.current = true;
setDirection((prevState) => (prevState === 1 ? -1 : 1));
};
return (
<div style={{ padding: 40 }}>
<div onClick={handleClick} className={styles.lottieWrapper}>
<Lottie
direction={direction}
options={options}
speed={2}
height={20}
width={40}
eventListeners={[
{
eventName: "complete",
callback: () =>
{
playing.current = false;
},
},
]}
/>
</div>
</div>

);
};

这样我们就是实现了一个开关的效果


Kapture 2024-01-20 at 21.53.34.gif


持续时长


Lottiejson中,有几个关键的字段跟动画的播放时长有关系:



  • fr:帧率,每一秒的帧数

  • ip:开始帧

  • op:结束帧


假如说有下面的一个描述:


{
"fr": 30,
"ip": 0,
"op": 60,
}

则表示帧率是30帧,从第0帧开始,60帧结束,那这个动画的持续时长是 (op-ip)/fr,为2s。那如果我们希望整个动画的播放时长是500ms,则只需要把Lottie的倍速调整为4。对应的就是speed字段:


<Lottie
direction={direction}
options={options}
speed={4}
height={20}
width={40}
eventListeners={[
{
eventName: "complete",
callback: () => {
playing.current = false;
},
},
]}
/>

Kapture 2024-01-20 at 22.06.53.gif


修改Lottie


Lottie json中,描述整个动画的过程以及效果其实对应的就是某个值。在实现的过程中,其实开发是可以去修改这些值的。比如说我们可以修改上面开关的边框颜色以及小球的颜色。


首先在页面中找到小球对应的颜色是rgb(99, 102, 241)


image.png


Lottie JSON文件中,颜色信息通常出现在表示图层样式的字段中。常见的字段是 "c"(color)
"c" 字段表示颜色,通常以RGBA格式(红绿蓝透明度)存储。例如:


"c": {"a":0,"k":[1,0,0,1]}

这表示红色,RGBA值为 [1, 0, 0, 1]


rgb(99, 102, 241)转成上面的写法那就是"c": {"a":0,"k":[99/255,102/255,241/255,1]}。以99/255为例,结果是0.38823529411764707,那么就拿这个结果去json文件中找到对应的节点。


image.png


对应有2个结果,就是小球的颜色以及边框的颜色。当我们找到这个值的时候,如果我们想修改这个值,就必须知道这个值的路径,在一个Lottie中,想肉眼找到这个值的路径是一件很难的事情。所以我们写一个辅助函数:


const updateJsonValue = (json, targetValue, newValue) => {
const find = (json, targetValue, currentPath = []) => {
for (const key in json) {
if (json[key] === targetValue) {
return [...currentPath, key];
} else if (typeof json[key] === "object" && json[key] !== null) {
const path = find(json[key], targetValue, [...currentPath, key]);
if (path) {
return path;
}
}
}
};
const res = JSON.parse(JSON.stringify(json));
const path = find(res, targetValue);
let current = res;

for (let i = 0; i < path.length - 1; i++) {
const key = path[i];
current = current[key];
}

const lastKey = path[path.length - 1];
current[lastKey] = newValue;

return json;
};

上面的辅助函数就帮助我们找到这个值的路径,并修改目标值。比如说我们想把目前的颜色改成绿色(rgb(25, 195, 125)),就可以找到对应的路径,并修改。别忘了替换的时候把rgb对应的值除以255


let newAnimationData = updateJsonValue(animationData, 0.388235300779, 0.09803921568627451)
newAnimationData = updateJsonValue(newAnimationData, 0.388235300779, 0.09803921568627451)
newAnimationData = updateJsonValue(newAnimationData, 0.40000000596, 0.7647058823529411)
newAnimationData = updateJsonValue(newAnimationData, 0.40000000596, 0.7647058823529411)
newAnimationData = updateJsonValue(newAnimationData, 0.945098042488, 0.49019607843137253)
newAnimationData = updateJsonValue(newAnimationData, 0.945098042488, 0.49019607843137253)

image.png


掌握了这种方式之后,我们就能修改Lottie里面的大部分内容,包括文案、资源图片、颜色等等。


最后


以上就是一些Lottie的使用以及修改的介绍,下次再遇到比较麻烦的动画需求。就可以跟产品说:可以做,让UI给我导出一个Lottie


image.png


如果你有一些别的想法,欢迎评论区交流~如果你觉得有意思的话,点点关注点点赞吧~


作者:可乐鸡翅kele
来源:juejin.cn/post/7325717778597773348
收起阅读 »

从‘相信前端能做一切’到‘连这个都做不了么’

web
帮助阅读 此篇文章主要是为了实现仪表盘功能,前后过了4种方案,每篇方案从逻辑、代码、效果、问题四个方面出发。最后个人总结。同时非常非常希望有大佬能够提供一个方案,个人实在想不到实现方案了 需求 h5页面中,做一个环形仪表盘(如下图),需要一个从0%到实际百分比...
继续阅读 »

4711705568245_.pic.jpg


帮助阅读


此篇文章主要是为了实现仪表盘功能,前后过了4种方案,每篇方案从逻辑、代码、效果、问题四个方面出发。最后个人总结。同时非常非常希望有大佬能够提供一个方案,个人实在想不到实现方案了


需求


h5页面中,做一个环形仪表盘(如下图),需要一个从0%到实际百分比的增长过渡动效
未命名.png


前提


使用前端原生Html、css、js语言实现, 不打算借助第三方插件。


最初Scheme


将UI图片作为背景,上面放一个白色div作为遮罩,再利用css3将白色div旋转,从而达到过渡效果。


代码如下:


<style>
.light-strip {
width: 500px;
height:500px;
border: 1px solid #efefef;
background-image: url('Frame 29@3x.png');
float: right;
background-size: 100% 100%;
}
.light-strip-top {
margin-top: 0%;
width: 500px;
height: 250px;
background: #fff;
transform-origin: bottom center;
/* transform: rotate 5s ; */
rotate: 0deg;
transition: all 2s ease-in-out;
}
</style>
<body onload="load()">
<div class="light-strip">
<div class="light-strip-top">

</div>
</div>
</body>
<script>
function load() {
setTimeout(() => {
document.querySelectorAll('.light-strip-top')[0].setAttribute('style', "rotate: 180deg")
}, 1000)
}
</script>

效果如下:


屏幕录制2024-01-29 13.50.58.gif


出现问题:


由于仪表盘整体大于180度,所以白色div,在最开始遮挡全部仪表盘,那么旋转一定角度后一定会覆盖UI图。


进化Scheme


根据上面出现的问题,想到与UI沟通将仪表盘改成180度效果(解决不了问题,就把问题解决掉),该方案由于改变了原型之后会导致UI过于丑,就没有进行深度测试。


超进化Scheme


根据上面两个方案的结合,想到将方案1中的白色div换成一张指针图片,利用css3旋转追针,达到过渡效果,但此方案也是改了UI效果。


代码如下:


	<style>
.light-strip {
width: 500px;
height:500px;
border: 1px solid #efefef;
background-image: url('Frame 29@3x.png');
/* background-color: #fff; */
float: right;
background-size: 100% 100%;
}
.light-strip-top {
margin-top: 50%;
width: 49%;
height: 4px;
background: red;
transform-origin: center right;
/* transform: rotate 5s ; */
rotate: -35deg;
transition: all 2s ease-in-out;
}

</style>
<body onload="load()">
<div class="light-strip">
<div class="light-strip-top">

</div>
</div>
</body>
<script>
function load() {
setTimeout(() => {
document.querySelectorAll('.light-strip-top')[0].setAttribute('style', "rotate: 90deg")
}, 1000)
}
</script>

效果如下:


屏幕录制2024-01-29 15.44.31.gif


Now:


此时大脑宕机了,在我的前端知识基础上,想不到能够完美实现UI效果的方案了。于是和同事探讨了一下,了解到element-plus中的进度条有类似的效果,于是打算看一下源码,了解到它是svg实现的。发现新大陆又开始尝试svg实现。


究极进化Scheme


利用svg,做一个带白色的背景圆环A,再做一个带有渐变背景色的进度圆环B, 利用进度圆环的偏移值、显示长度、断口长度配合css3过渡实现过渡效果。


代码如下:


 <style>
body {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
background-color: #f5f5f5;
}

.dashboard {
position: relative;
width: 200px;
height: 200px;
background-size: 100% 100%;
}

.circle-background {
fill: none; /* 不填充 */
stroke: #fff; /* 圆环的颜色 */
stroke-width: 10; /* 圆环的宽度 */
stroke-dasharray: 200, 52; /* 圆环断开部分的长度,总长度为周长 */
stroke-dashoffset: 163;
stroke-linecap: round;
border-radius: 10;
transition: all 1s; /* 过渡效果时间 */
}

.circle-progress {
fill: none; /* 不填充 */
stroke: url(#gradient); /* 圆环的颜色 */
stroke-width: 10; /* 圆环的宽度 */
stroke-dasharray: 252, 0; /* 圆环断开部分的长度,总长度为周长 */
stroke-dashoffset: 163;
stroke-linecap: round; /* 圆滑断点 */
transition: all 1s; /* 过渡效果时间 */
}

.percentage {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 24px;
color: #3498db;
}
</style>
</head>
<body>

<svg class="dashboard" viewBox="0 0 100 100">
<!-- 定义渐变色 -->
<defs>
<linearGradient id="gradient" gradientUnits="userSpaceOnUse" x1="50" y1="0" x2="50" y2="100%">
<stop offset="0%" style="stop-color: rgba(111, 232, 191, 1)" />
<stop offset="33%" style="stop-color: rgba(255, 175, 19, 1)" />
<stop offset="70%" style="stop-color: rgba(222, 19, 80, 1)" />
<stop offset="100%" style="stop-color: rgba(133, 14, 205, 1)" />
</linearGradient>
</defs>

<!-- 背景圆环 -->
<circle class="circle-background" cx="50" cy="50" r="40"></circle>

<!-- 进度圆环 -->
<circle class="circle-progress" cx="50" cy="50" r="40"></circle>

</svg>

<!-- 进度百分比显示 -->
<div class="percentage" id="percentage">0%</div>

<script>
function setProgress(percentage) {
const circleProgress = document.querySelector('.circle-progress');
const circleBackground = document.querySelector('.circle-background');
const percentageText = document.getElementById('percentage');

const circumference = 2 * Math.PI * 40; // 圆的周长
const circumNewLength = (percentage / 100) * (circumference - 52);
const dashOffset = 163 - circumNewLength;


// 设置进度圆环的样式
circleBackground.style.strokeDashoffset = dashOffset;
circleBackground.style.strokeDasharray = `${200 - circumNewLength}, ${ 52 + circumNewLength }`
circleProgress.style.strokeDasharray = `${circumNewLength}, ${ circumference - circumNewLength }`

// 更新百分比文本
percentageText.textContent = `${percentage}%`;
}

// 设置初始进度为0%
setProgress(0);

// 模拟过渡效果,从0%到50%
setTimeout(() => {
setProgress(50);
}, 1000); // 过渡时间为1秒,你可以根据需要调整这个值
</script>


效果如下:


屏幕录制2024-01-29 15.46.35.gif


问题:


基本实现,但是还有一个问题是,渐变色是两点之间的线性渐变,无法做到圆环的顺时针渐变。


总结



  • 单纯前端不是万能的😂😂😂😂

  • 个人认为这个需求还是能够实现的

  • 希望有da lao能出个方案

  • 加油,继续搞


作者:Otway
来源:juejin.cn/post/7329310941106356275
收起阅读 »

伪指纹浏览器开发的那些事

web
什么是伪指纹浏览器开发 就是通过开源的chromium浏览器进行二次简单的封装不涉及到重新编译chromium,配合puppeteer进行轻微的指纹修改开发 一、如何操作 本次操作客户端以前端擅长的electron来举例子,至于electron是什么,打开文心...
继续阅读 »

什么是伪指纹浏览器开发


就是通过开源的chromium浏览器进行二次简单的封装不涉及到重新编译chromium,配合puppeteer进行轻微的指纹修改开发


一、如何操作


本次操作客户端以前端擅长的electron来举例子,至于electron是什么,打开文心一言看看...


第一步下载chromium到本地客户端


登录官网,看到如下界面


image.png


可以发现箭头处指定是浏览器对应的版本buildId和系统,这里可以直接手动点击下载到本地,也可以通过@puppeteer/browsers这个库使用js代码去下载。这里说说如何使用它下载


const { app } = require('electron')
const browserApi = require('@puppeteer/browsers')
const axios = require('axios')

// browser缓存路径,避免和electron一起打包占用安装包体积和打包时间
const cacheDir = `${app.getPath('cache')}/myBrowser`

browserApi.install({
cacheDir, // 自己想要下载的路径,用来给puppeteer去调用
browser: browserApi.Browser.CHROMIUM,
// buildId: '1247373',
// baseUrl: 'https://commondatastorage.googleapis.com/chromium-browser-snapshots'
})

耐心的小伙伴肯定发现了这里buildId版本号和baseUrl下载url我打了注释,是因为@puppeteer/browsers默认下载的chromium版本比较旧,那么我们怎么获取这个最新版本buildId和baseUrl呢,还是官网那个界面打开控制台,可以看到如下请求链接


image.png
然后看到请求结果
image.png
这就是最新的buildId了,然后封装成函数调用


// 获取最新的chromium构建ID
function getLastBuildId(platform) {
return axios
.get(
`https://download-chromium.appspot.com/rev/${browserApi.BrowserPlatform.MAC}?type=snapshots`
)
.then((res) => res.data.content)
}

baseUrl可以在界面点击下载时候,看到控制台有一个请求,那就是baseUrl了


image.png
下载好后,可以去我们定义的下载保存地址,通过终端去打开就可以看到了


二、第二步启动chromium


使用puppeteer-core这个库,启动我们下好的chromium


const puppeteer = require('puppeteer-core')
const browserApi = require('@puppeteer/browsers')

// browser缓存路径
const cacheDir = `${app.getPath('cache')}/myBrowser`

// 获取安装的浏览器路径
function getBrowserPath() {
return browserApi
.getInstalledBrowsers({ cacheDir })
.then((list) => list[0]?.executablePath)
}

// 浏览器生成
const createBrowser = async (proxyServer, userAgent) => {
const browser = await puppeteer.launch({
args: [
`--proxy-server=${proxyServer}`,
`--user-agent="${userAgent}"`,
'--no-first-run',
'--no-zygote',
'--disable-extensions',
'--disable-infobars',
'--disable-automation',
'--no-default-browser-check',
'--disable-device-orientation',
'--disable-metrics-reporting',
'--disable-logging'
],
headless: false,
defaultViewport: null,
ignoreHTTPSErrors: true,
ignoreDefaultArgs: [
'--enable-infobars',
'--enable-features=NetworkService,NetworkServiceInProcess',
'--enable-automation',
'about:blank'
],
executablePath: await getBrowserPath()
})

return browser
}

通过puppeteer.launch启动一个浏览器,至于启动参数这里我只说指纹相关的两个参数--proxy-server--user-agent,其他AI一下。


--proxy-server代理服务,浏览器访问的出口IP,即你用自己启动的浏览器访问google时候,那边服务端获取的ip就是你的代理ip,测试时候可以自己在另外一台机器上装个Squid测试。--user-agent即浏览器的window.navigator.userAgent,简单指纹一般都是依赖于它生成


三、开发过程中用到的功能点


看完puppeteer官网,我们知道操作chromium依赖于一套协议chromedevtools.github.io/devtools-pr…


3.1 更换dock图标


比如多开浏览器,我如何更换chromium的桌面dock图标,去标识这是我启动的第几个浏览器。我们可以使用Browser.setDockTile去操作浏览器更换dock图标


const pages = await browser.pages()
const page = pages[0]
const session = await pages[0].target().createCDPSession()
await session.send('Browser.setDockTile', {
image: new Buffer.from(fs.readFileSync(file)).toString('base64')
})

效果如下:


image.png


更多的协议操作需要自己摸索了,提示下,AI搜索chrome cdp协议


3.2 增加默认书签


这里我没找到协议,直接通过类似爬虫的方式,先进入标签管理页面,直接操作js新增,也算是一个技巧性的骚操作


await page.goto('chrome://bookmarks/') // 进入标签管理页面
await page.evaluate(async () => {
// 类似在控制台直接操作一样,下面的代码控制台一样可以达到效果
const defaultBookmarks = [
{
title: "文心一言",
url: "https://yiyan.baidu.com/",
},
{
title: "掘金",
url: "https://juejin.cn/",
},
];

defaultBookmarks.forEach((item) => {
chrome.bookmarks.create({
parentId: "1",
...item,
});
});
});
await page.goto('自己的本来要跳的首页')

3.3 如何使用已经打开的浏览器


const browserWSEndpoint = browser.wsEndpoint() // 获取本次打开的浏览器链接,留作下一次使用
// 保存下来, 比如直接存在一个变量map中,给它定义一个唯一的browserId,下一次好直接获取
browserMap.set(browserId, browserWSEndpoint)

...
// 再次打开新页面,要用到上一次打开的浏览器
const browser = puppeteer.connect({
...launchOptions, // 和自己首次打开浏览器的配置一样
browserWSEndpoint: browserMap.set(browserId)
})

这样就可以使用之前打开的浏览器打开网页了


3.4 如何把浏览器的信息显示在网页上


比如代理、userAgent、地区、浏览器名称等信息,先写个页面,然后轮询从localStorage直到获取信息为止。


// 浏览器代理信息页
await page.goto('浏览器信息页')
// 设置localStorage
await page.evaluate(
(values) => {
window.localStorage.setItem('browserInfo', values)
},
JSON.stringify(browserData)
)

page在打开页面后,并不会在页面中马上能获取到这里注入的browserInfo,可以通过轮询方式去扫描localStorage中是否存在我们注入的变量,这里举个react中的例子,在页面ready后去轮询处理


useEffect(() => {
let loopId = null
const clearLoop = () => {
loopId && clearTimeout(loopId)
}

// 轮询直到获取browserInfo
const loop = () => {
loopId = setTimeout(() => {
const localData = window.localStorage.getItem('browserInfo')
if (localData) {
Promise.resolve()
.then(() => {
setInfo(JSON.parse(localData))
})
.catch(() => {
message.error('获取浏览器信息失败')
})
} else {
loop()
}
}, 1500)
}

loop()

return () => {
clearLoop()
}
})

3.5 校验代理


一般的代理服务为了不让别人也能用都会加上账密校验,所以我们还需要在启动后,调用方法去校验


// 校验proxy
if (proxyData.proxyServer) {
await page.authenticate({
username: proxyData.proxyUser,
password: proxyData.proxyPwd
})
}

四、遇到了哪一些问题


4.1 mac下关闭浏览器关不掉


当我们点击左上角关闭浏览器按钮或者是关闭所有页面时候,底部的dock中依旧存在着,我们不希望像mac其他软件一样保留在dock中,不然下一次打开浏览器时候,会出现相同标识的浏览器,可以这么解决


// 每次页面关闭时候,查看浏览器是不是还有页面了,没有就关闭
browser.on('targetdestroyed', async () => {
const pages = await browser.pages()
if (!pages.length) {
await browser.close()
}
})

4.2 当我们之间关闭电脑屏幕时候,比如盖上电脑,再次打开时候,关闭不了浏览器


打上log,可以发现熄屏时候,会触发puppeteer定义的browser的disconnected事件,但是再次打开电脑时候浏览器是可以正常使用的,也就是说,puppeteer和我们打开的chromium断连了,所以我们需要在disconnected事件里再此尝试链接下chromium,如果不行才认为是浏览器被关闭了


browser.on('disconnected', () => {
const cacheData = browserMap.get(browserId)
puppeteer
.connect({
...launchOptions,
browserWSEndpoint: cacheData.browserWSEndpoint
})
.then((newBrowser) => {
browser = newBrowser
log.info(
'browser disconnected but browser is exist',
)
initEvent()
})
.catch((err) => {
log.info(
'browser disconnected success',
)
})
})

结语


puppeteer很强大,chromium也强大,就是那个官网文档啊,写的真是让人...,所以多问问AI吧


作者:柠檬阳光
来源:juejin.cn/post/7327642905245433891
收起阅读 »

防御性编程失败,我开始优化我写的多重 if-else 代码

前言 最近防御性编程比较火,不信邪的我在开发中我进行了尝试,然后我写下了如下的代码: public static void main(String[] args) { // do something if ("满足条...
继续阅读 »

前言



  • 最近防御性编程比较火,不信邪的我在开发中我进行了尝试,然后我写下了如下的代码:


    public static void main(String[] args) {
// do something
if ("满足条件A") {
// 查询权限
if ("是否具备权限A" && "是否具备权限B") {
// 查询配置
if ("配置是否开启"){
// do something
}
}
}
// do something
}


  • 不出意外我被逮捕了,组内另外一位同事对我的代码进行了 CodeReview,我的防御性编程编程没有幸运逃脱,被标记上了“多重 if-else ”需要进行优化,至此我的第一次防御性编程失败,开始了优化多重 if-else 之路,下面是我总结出的常用几种优化方式。


版本



  • Java8


几种常用的优化方式


提前使用 return 返回去除不必要的 else



  • 如果我们的代码块中需要使用 return 返回,我们应该尽可能早的使用 return 返回而不是使用 else

  • 优化前


    private static boolean extracted(boolean condition) {
if (condition) {
// do something
return false;
}else {
// do something
return true;
}
}


  • 优化后


    private static boolean extracted(boolean condition) {
if (condition) {
// do something
return false;
}

// do something
return true;
}

使用三目运算符



  • 一些简单的逻辑我们可以使用三目运算符替代 if-else ,这样可以让我们的代码更加简洁

  • 优化前


        int num = 0;
if (condition) {
num = 1;
} else {
num = 2;
}


  • 优化后


int num = condition ? 1 : 2;

使用枚举



  • 在某一些场景我们也可以使用枚举来优化多重 if-else 代码,使我们的代码更加简洁、具备更多的可读性和可维护性。

  • 优化前


        String OrderStatusDes;
if (orderStatus == 0) {
OrderStatusDes = "订单未支付";
} else if (orderStatus == 1) {
OrderStatusDes = "订单已支付";
} else if (orderStatus == 2) {
OrderStatusDes = "已发货";
} else {
throw new Exception("Invalid order status");
}


  • 优化后


public enum OrderStatusEnum {
UN_PAID(0, "订单未支付"),
PAIDED(1, "订单已支付"),
SENDED(2, "已发货"),
;

private final int code;
private final String desc;

public int getCode() {
return code;
}

public String getDesc() {
return desc;
}

OrderStatusEnum(int index, String desc) {
this.code = index;
this.desc = desc;
}

public static OrderStatusEnum getOrderStatusEnum(int orderStatusCode) {
for (OrderStatusEnum statusEnum : OrderStatusEnum.values()) {
if (statusEnum.getCode() == orderStatusCode) {
return statusEnum;
}
}
return null;
}
}


// 当然你需要根据业务场景对异常值做出合适的处理
OrderStatusEnum.getOrderStatusEnum(2)

抽取条件判断作为单独的方法



  • 当我们某个逻辑条件判断比较复杂时,可以考虑将判断条件抽离为单独的方法,这样可以使我们主流程逻辑更加清晰

  • 优化前


        // do something
if ("满足条件A" && "满足条件B") {
// 查询权限
if ("是否具备权限A" && "是否具备权限B") {
// do something
}
}
// do something


  • 优化后


    public static void main(String[] args) {
// do something
if (hasSomePermission()) {
// do something
}
// do something
}

private static boolean hasSomePermission() {
if (!"满足条件A" || !"满足条件B") {
return false;
}
// 查询权限
return "是否具备权限A" && "是否具备权限B";
}

有时候 switch 比 if-else 更加合适



  • 当条件为清晰的变量和枚举、或者单值匹配时,switch 比 if-else 更加合适,可以我们带好更好的可读性以及更好的性能 O(1)

  • 优化前


if (day == Day.MONDAY) {
// 处理星期一的逻辑
} else if (day == Day.TUESDAY) {
// 处理星期二的逻辑
} else if (day == Day.WEDNESDAY) {
// 处理星期三的逻辑
} else if (day == Day.THURSDAY) {
// 处理星期四的逻辑
} else if (day == Day.FRIDAY) {
// 处理星期五的逻辑
} else if (day == Day.SATURDAY) {
// 处理星期六的逻辑
} else if (day == Day.SUNDAY) {
// 处理星期日的逻辑
} else {
// 处理其他情况
}


  • 优化后


// 使用 switch 处理枚举类型
switch (day) {
case MONDAY:
// 处理星期一的逻辑
break;
case TUESDAY:
// 处理星期二的逻辑
break;
// ...
default:
// 处理其他情况
break;
}

策略模式 + 简单工厂模式



  • 前面我们介绍一些常规、比较简单的优化方法,但是在一些更加复杂的场景(比如多渠道对接、多方案实现等)我们可以结合一些场景的设计模式来实现让我们的代码更加优雅和可维护性,比如策略模式 + 简单工厂模式。

  • 优化前


    public static void main(String[] args) {
// 比如我们商场有多个通知渠道
// 我们需要根据不同的条件使用不同的通知渠道
if ("满足条件A") {
// 构建渠道A
// 通知
} else if ("满足条件B") {
// 构建渠道B
// 通知
} else {
// 构建渠道C
// 通知
}
}
// 上面的代码不仅维护起来麻烦同时可读性也比较差,我们可以使用策略模式 + 简单工厂模式


  • 优化后


import java.util.HashMap;
import java.util.Map;

// 定义通知渠道接口
interface NotificationChannel {
void notifyUser(String message);
}

// 实现具体的通知渠道A
class ChannelA implements NotificationChannel {
@Override
public void notifyUser(String message) {
System.out.println("通过渠道A发送通知:" + message);
}
}

// 实现具体的通知渠道B
class ChannelB implements NotificationChannel {
@Override
public void notifyUser(String message) {
System.out.println("通过渠道B发送通知:" + message);
}
}

// 实现具体的通知渠道C
class ChannelC implements NotificationChannel {
@Override
public void notifyUser(String message) {
System.out.println("通过渠道C发送通知:" + message);
}
}

// 通知渠道工厂
class NotificationChannelFactory {
private static final Mapextends NotificationChannel>> channelMap = new HashMap<>();

static {
channelMap.put("A", ChannelA.class);
channelMap.put("B", ChannelB.class);
channelMap.put("C", ChannelC.class);
}

public static NotificationChannel createChannel(String channelType) {
try {
Classextends NotificationChannel> channelClass = channelMap.get(channelType);
if (channelClass == null) {
throw new IllegalArgumentException("不支持的通知渠道类型");
}
return channelClass.getDeclaredConstructor().newInstance();
} catch (Exception e) {
throw new RuntimeException("无法创建通知渠道", e);
}
}
}

// 客户端代码
public class NotificationClient {
public static void main(String[] args) {
// 根据条件选择通知渠道类型
String channelType = "A";
// 使用简单工厂创建通知渠道
NotificationChannel channel = NotificationChannelFactory.createChannel(channelType);

// 执行通知
channel.notifyUser("这是一条通知消息");
}
}


  • 有时候我们还可以借助 Spring IOC 能力的自动实现策略类的导入,然后使用 getBean() 方法获取对应的策略类实例,可以根据我们的实际情况灵活选择。


如何优化开头的代码



  • 好了现在回到开头,如果是你会进行怎么优化,下面是我交出的答卷,大家也可以在评论区发表自己的看法,欢迎一起交流:


   public static void main(String[] args) {
// do something
if (isMeetCondition()) {
// 查询配置
// 此处查询配置的值需要在具体的任务中使用,所有并没抽离
if ("配置是否开启") {
// do something
}
}
// do something
}

/**
* 判断是否满足执行条件
*/

private static boolean isMeetCondition() {
if (!"满足条件A") {
return false;
}
// 查询权限
return "是否具备权限A" && "是否具备权限B";
}



作者:Lorin洛林
来源:juejin.cn/post/7325353198591672359
收起阅读 »

Flutter 首个真正可商用的 JSBridge 框架(完全兼容的 DSBridge for Flutter)

DSBridge for Flutter 在 Android 和 iOS 平台上做过 Hybrid 开发的同学基本都会知道 DSBridge,该框架目前最受欢迎的 JSBridge 框架之一,为了在 Flutter 侧实现原生 Hybrid 的能力,于是我们将...
继续阅读 »

DSBridge for Flutter


在 Android 和 iOS 平台上做过 Hybrid 开发的同学基本都会知道 DSBridge,该框架目前最受欢迎的 JSBridge 框架之一,为了在 Flutter 侧实现原生 Hybrid 的能力,于是我们将其适配到了Flutter 平台。


dsbridge.png



三端易用的现代跨平台 JavaScript bridge,通过它你可以在 JavaScript 和 Flutter 之间同步或异步的调用彼此的函数.



概述


DSBridge for Flutter 完全兼容 Android 和 iOS DSBridge 的 dsbridge.js。不像其他类似的框架无法实现JavaScript 调用 Dart 并同步返回结果,本框架完整支持同步调用和异步调用。dsbridge_flutter 是首个完整实现了 DSBridge 在原 Android 和 iOS 上的所有功能,因此可以实现将原来通过原生实现的 Webview 业务完全迁移到 Flutter 实现,即一套代码实现APP与H5的Hybrid开发。在现有使用了 dsbridge.js 的 Web 项目中无须修改任何代码即可使用 DSBridge for Flutter。


本框架目前支持Android 和 iOS 平台,即将支持纯鸿蒙平台(OpenHarmony & HarmonyOS Next),敬请期待!


DSBridge for Flutter 基于 Flutter官方的 webview_flutter


目前已发布到官方pub.dev:dsbridge_flutter


特性



  1. Android、iOS、JavaScript 三端易用,轻量且强大、安全且健壮。

  2. 同时支持同步调用和异步调用

  3. 支持以类的方式集中统一管理API

  4. 支持API命名空间

  5. 支持调试模式

  6. 支持 API 存在性检测

  7. 支持进度回调:一次调用,多次返回

  8. 支持 JavaScript 关闭页面事件回调

  9. 支持 JavaScript 模态对话框


安装



  1. 添加依赖


    dependencies:
    ...
    dsbridge_flutter: x.y.z



示例


请参考工程目录下的 example 包。运行 example 工程并查看示例交互。


如果要在你自己的项目中使用 dsBridge :


使用



  1. 新建一个Dart类,实现API


    import 'package:dsbridge_flutter/dsbridge_flutter.dart';

    class JsApi extends JavaScriptNamespaceInterface {
    @override
    void register() {
    registerFunction(testSyn);
    registerFunction(testAsyn);
    }

    /// for synchronous invocation
    String testSyn(dynamic msg) {
    return "$msg[syn call]";
    }

    /// for asynchronous invocation
    void testAsyn(dynamic msg, CompletionHandler handler) {
    handler.complete("$msg [ asyn call]");
    }
    }

    所有Dart APIs必须在register函数中使用registerFunction来注册。


  2. 添加API类实例到DWebViewController


    import 'package:dsbridge_flutter/dsbridge_flutter.dart';
    ...
    late final DWebViewController _controller;
    ...
    _controller.addJavaScriptObject(JsApi(), null);


  3. 在 JavaScript 中调用 Dart API ,并注册一个 JavaScript API 供原生调用.



    • 初始化 dsBridge


      //cdn
      //<script src="https://unpkg.com/dsbridge@3.1.3/dist/dsbridge.js"> </script>
      //npm
      //npm install dsbridge@3.1.3
      var dsBridge=require("dsbridge")


    • 调用 Dart API;以及注册一个 JavaScript API 供 Dart 调用.



      //同步调用
      var str=dsBridge.call("testSyn","testSyn");

      //异步调用
      dsBridge.call("testAsyn","testAsyn", function (v) {
      alert(v);
      })

      //注册 JavaScript API
      dsBridge.register('addValue',function(l,r){
      return l+r;
      })




  4. 在 Dart 中调用 JavaScript API


    import 'package:dsbridge_flutter/dsbridge_flutter.dart';
    ...
    late final DWebViewController _controller;
    ...
    _controller.callHandler('addValue', args: [3, 4],
    handler: (retValue) {
    print(retValue.toString());
    });



Dart API 签名


为了兼容Android&iOS,我们约定Dart API 签名,注意,如果API签名不合法,则不会被调用!签名如下:



  1. 同步API.


    any handler(dynamic msg)


    参数必须是 dynamic 类型,并且必须申明(如果不需要参数,申明后不适用即可)。返回值类型没有限制,可以是任意类型。


  2. 异步 API.


    void handler(dynamic arg, CompletionHandler handler)



命名空间


命名空间可以帮助你更好的管理API,这在API数量多的时候非常实用,比如在混合应用中。DSBridge支持你通过命名空间将API分类管理,并且命名空间支持多级的,不同级之间只需用'.' 分隔即可。


调试模式


在调试模式时,发生一些错误时,将会以弹窗形式提示,并且Dart API如果触发异常将不会被自动捕获,因为在调试阶段应该将问题暴露出来。


进度回调


通常情况下,调用一个方法结束后会返回一个结果,是一一对应的。但是有时会遇到一次调用需要多次返回的场景,比如在 JavaScript 中调用端上的一个下载文件功能,端上在下载过程中会多次通知 JavaScript 进度, 然后 JavaScript 将进度信息展示在h5页面上,这是一个典型的一次调用,多次返回的场景,如果使用其它 JavaScript bridge, 你将会发现要实现这个功能会比较麻烦,而 DSBridge 本身支持进度回调,你可以非常简单方便的实现一次调用需要多次返回的场景,下面我们实现一个倒计时的例子:


In Dart


void callProgress(dynamic args, CompletionHandler handler) {
var i = 10;
final timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (i == 0) {
timer.cancel();
handler.complete(0);
} else {
handler.setProgressData(i--);
}
});
}

In JavaScript


dsBridge.call("callProgress", function (value) {
document.getElementById("progress").innerText = value
})

完整的示例代码请参考example工程。


Javascript 对话框


DSBridge 已经实现了 JavaScript 的对话框函数(alert/confirm/prompt),如果你想自定义它们,通过DWebViewController设置相关回调函数即可。DSBridge实现的对话框默认设置是模态的,这会挂起UI线程。


API 列表


Dart API


在 Dart 中我们把实现了供 JavaScript 调用的 API 类的实例称为 Dart API object.


DWebViewController.addJavaScriptObject(JavaScriptNamespaceInterface? object, String? namespace)

Dart API object到DWebViewController,并为它指定一个命名空间。然后,在 JavaScript 中就可以通过bridge.call("namespace.api",...)来调用Dart API object中的原生API了。


如果命名空间是空(null或空字符串), 那么这个添加的Dart API object就没有命名空间。在 JavaScript 通过 bridge.call("api",...)调用。


示例:


In Dart


class JsEchoApi extends JavaScriptNamespaceInterface {
@override
void register() {
registerFunction(syn);
registerFunction(asyn);
}

dynamic syn(dynamic args) {
return args;
}

void asyn(dynamic args, CompletionHandler handler) {
handler.complete(args);
}
}
//namespace is "echo"
controller.addJavaScriptObject(JsEchoApi(), 'echo');

In JavaScript


// call echo.syn
var ret=dsBridge.call("echo.syn",{msg:" I am echoSyn call", tag:1})
alert(JSON.stringify(ret))
// call echo.asyn
dsBridge.call("echo.asyn",{msg:" I am echoAsyn call",tag:2},function (ret) {
alert(JSON.stringify(ret));
})

DWebViewController.removeJavaScriptObject(String namespace)

通过命名空间名称移除相应的Dart API object。


DWebViewController.callHandler(String method, {List? args, OnReturnValue? handler})

调用 JavaScript API。handlerName 为 JavaScript API 的名称,可以包含命名空间;参数以数组传递,args数组中的元素依次对应 JavaScript API的形参; handler 用于接收 JavaScript API 的返回值,注意:handler将在Dart主isolate中被执行


示例:


_controller.callHandler('append', args: ["I", "love", "you"],
handler: (retValue) {
print(retValue.toString());
});
/// call with namespace 'syn', More details to see the Demo project
_controller.callHandler('syn.getInfo', handler: (retValue) {
print(retValue.toString());
});

DWebViewController.javaScriptCloseWindowListener

当 JavaScript 中调用window.close时,DWebViewController 会触发此监听器,你可以自定义回调进行处理。


Example:


controller.javaScriptCloseWindowListener = () {
print('window.close called');
};

DWebViewController.hasJavaScriptMethod(String handlerName, OnReturnValue existCallback)

检测是否存在指定的 JavaScript API,handlerName可以包含命名空间.


示例:


_controller.hasJavaScriptMethod('addValue', (retValue) {
print(retValue.toString());
});

DWebViewController.dispose()

释放资源。在当前页面处于dispose状态时,你应该显式调用它。


JavaScript API


dsBridge

"dsBridge" 在初始化之后可用 .


dsBridge.call(method,[arg,callback])

同步或异步的调用Dart API。


method: Dart API 名称, 可以包含命名空间。


arg:传递给Dart API 的参数。只能传一个,如果需要多个参数时,可以合并成一个json对象参数。


callback(String returnValue): 处理Dart API的返回结果. 可选参数,只有异步调用时才需要提供.


dsBridge.register(methodName|namespace,function|synApiObject)

dsBridge.registerAsyn(methodName|namespace,function|asynApiObject)

注册同步/异步的 JavaScript API. 这两个方法都有两种调用形式:



  1. 注册一个普通的方法,如:


    In JavaScript


    dsBridge.register('addValue',function(l,r){
    return l+r;
    })
    dsBridge.registerAsyn('append',function(arg1,arg2,arg3,responseCallback){
    responseCallback(arg1+" "+arg2+" "+arg3);
    })

    In Dart


    _controller.callHandler('addValue', args: [3, 4],
    handler: (retValue) {
    print(retValue.toString());
    });

    _controller.callHandler('append', args: ["I", "love", "you"],
    handler: (retValue) {
    print(retValue.toString());
    });


  2. 注册一个对象,指定一个命名空间:


    In JavaScript


    //namespace test for synchronous calls
    dsBridge.register("test",{
    tag:"test",
    test1:function(){
    return this.tag+"1"
    },
    test2:function(){
    return this.tag+"2"
    }
    })

    //namespace test1 for asynchronous calls
    dsBridge.registerAsyn("test1",{
    tag:"test1",
    test1:function(responseCallback){
    return responseCallback(this.tag+"1")
    },
    test2:function(responseCallback){
    return responseCallback(this.tag+"2")
    }
    })


    因为 JavaScript 并不支持函数重载,所以不能在同一个 JavaScript 对象中定义同名的同步函数和异步函数



    In Dart


    _controller.callHandler('test.test1',
    handler: (retValue) {
    print(retValue.toString());
    });

    _controller.callHandler('test1.test1',
    handler: (retValue) {
    print(retValue.toString());
    });



dsBridge.hasNativeMethod(handlerName,[type])

检测Dart中是否存在名为handlerName的API, handlerName 可以包含命名空间.


type: 可选参数,["all"|"syn"|"asyn" ], 默认是 "all".


//检测是否存在一个名为'testAsyn'的API(无论同步还是异步)
dsBridge.hasNativeMethod('testAsyn')
//检测test命名空间下是否存在一个’testAsyn’的API
dsBridge.hasNativeMethod('test.testAsyn')
// 检测是否存在一个名为"testSyn"的异步API
dsBridge.hasNativeMethod('testSyn','asyn') //false

最后


如果你喜欢DSBridge for Flutter,欢迎点点star和like,以便更多的人知道它, 谢谢 !


作者:gtbluesky
来源:juejin.cn/post/7328753414724681728
收起阅读 »

【干货】一文掌握JavaScript检查对象空值的N种技巧!

在开发 JavaScript 应用程序时,经常需要检查对象是否为空。这是因为在处理和操作对象数据时,我们需要确保对象包含有效的值或属性。以下是一些常见情况,我们需要检查 JavaScript 对象是否为空:防止空引用错误:当我们尝试访问或使用一个空对象时,可能...
继续阅读 »

在开发 JavaScript 应用程序时,经常需要检查对象是否为空。这是因为在处理和操作对象数据时,我们需要确保对象包含有效的值或属性。以下是一些常见情况,我们需要检查 JavaScript 对象是否为空:

  1. 防止空引用错误:当我们尝试访问或使用一个空对象时,可能会导致空引用错误(如 TypeError: Cannot read property ‘x’ of null)。通过检查对象是否为空,我们可以避免这些错误的发生,并采取相应的处理措施。
  2. 数据验证和表单提交:在表单提交之前,通常需要验证用户输入的数据是否有效。如果对象为空,表示用户未提供必要的数据或未填写表单字段,我们可以显示错误消息或阻止表单提交。
  3. 条件逻辑和流程控制:根据对象是否为空,可以根据不同的条件逻辑执行不同的操作或采取不同的分支。例如,如果对象为空,可以执行备用的默认操作或返回默认值。
  4. 数据处理和转换:在处理对象数据之前,可能需要对其进行处理或转换。如果对象为空,可以提前终止或跳过数据处理逻辑,以避免不必要的计算或错误发生。
  5. 用户界面交互和显示:在用户界面中,可能需要根据对象的存在与否来显示或隐藏特定的界面元素、更改样式或呈现不同的内容。

通过检查 JavaScript 对象是否为空,可以增加应用程序的健壮性、提升用户体验,并避免潜在的错误和异常情况。因此,检查对象是否为空是编写高质量代码的重要部分。

在本文中,我们将讨论如何检查对象是否为空,其中包括 JavaScript 中检查对象是否为空的不同方法以及如何检查对象是否为空、未定义或为 null。

使用Object.keys()

使用Object.keys()方法可以检查对象是否为空。Object.keys(obj)返回一个包含给定对象所有可枚举属性的数组。
利用这个特性,我们可以通过检查返回的数组长度来确定对象是否为空。如果数组长度为0,则表示对象没有任何属性,即为空。
以下是一个示例代码:

javascriptCopy Codefunction isObjectEmpty(obj) {
return Object.keys(obj).length === 0;
}

// 测试对象是否为空
const obj1 = {};
console.log(isObjectEmpty(obj1)); // true

const obj2 = { name: "John", age: 25 };
console.log(isObjectEmpty(obj2)); // false

在上面的示例中,我们定义了一个isObjectEmpty()函数,它接受一个对象作为参数。函数内部使用Object.keys(obj)获取对象的所有可枚举属性,并检查返回的数组长度是否为0。根据返回结果,判断对象是否为空。

使用Object.values()

使用Object.values()方法来检查对象是否为空,Object.values(obj)方法返回一个包含给定对象所有可枚举属性值的数组。如果返回的数组长度为0,则表示对象没有任何属性值,即为空。

以下是使用Object.values()方法来检查对象是否为空的示例代码:

function isObjectEmpty(obj) {
return Object.values(obj).length === 0;
}

// 测试对象是否为空
const obj1 = {};
console.log(isObjectEmpty(obj1)); // true

const obj2 = { name: "John", age: 25 };
console.log(isObjectEmpty(obj2)); // false

在上面的示例中,我们定义了一个isObjectEmpty()函数,它接受一个对象作为参数。函数内部使用Object.values(obj)获取对象的所有可枚举属性值,并检查返回的数组长度是否为0。根据返回结果,判断对象是否为空。
请注意,Object.values()方法是ES2017(ES8)引入的新方法,因此在一些旧版本的JavaScript引擎中可能不被支持。在使用之前,请确保你的环境支持该方法或使用适当的polyfill来提供支持。

使用 for…in 循环

使用 for…in 循环方法是通过遍历对象的属性来判断对象是否为空。以下是一个示例代码:

javascriptCopy Codefunction isObjectEmpty(obj) {
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
return false; // 只要有一个属性存在,就返回false表示不为空
}
}
return true; // 如果遍历完所有属性后仍然没有返回false,表示对象为空
}

// 测试对象是否为空
const obj1 = {};
console.log(isObjectEmpty(obj1)); // true

const obj2 = { name: "John", age: 25 };
console.log(isObjectEmpty(obj2)); // false

在上面的示例中,isObjectEmpty()函数接受一个对象作为参数。函数内部使用 for…in 循环遍历对象的属性,如果发现任何属性,则返回false表示对象不为空;如果循环结束后仍然没有返回false,则表示对象为空,并返回true。
虽然使用 for…in 循环可以达到同样的目的,但相比起使用 Object.keys() 或 Object.values() 方法,它的实现稍显繁琐。因此,通常情况下,推荐使用 Object.keys() 或 Object.values() 方法来检查对象是否为空,因为它们提供了更简洁和直观的方式。

使用 Object.entries()

Object.entries()方法返回一个给定对象自身可枚举属性的键值对数组。如果返回的数组长度为0,则表示对象没有任何属性,即为空。
以下是使用Object.entries()方法来检查对象是否为空的示例代码:

function isObjectEmpty(obj) {
return Object.entries(obj).length === 0;
}

// 测试对象是否为空
const obj1 = {};
console.log(isObjectEmpty(obj1)); // true

const obj2 = { name: "John", age: 25 };
console.log(isObjectEmpty(obj2)); // false

在上面的示例中,isObjectEmpty()函数接受一个对象作为参数。函数内部使用Object.entries(obj)获取对象的键值对数组,并检查返回的数组长度是否为0。如果数组长度为0,则表示对象没有任何属性,即为空。
请注意,Object.entries()方法是ES2017(ES8)引入的新方法,因此在一些旧版本的JavaScript引擎中可能不被支持。在使用之前,请确保你的环境支持该方法或使用适当的polyfill来提供支持。

使用 JSON.stringify()

使用 JSON.stringify() 方法来检查对象是否为空的方法是将对象转换为 JSON 字符串,然后检查字符串的长度是否为 2。当对象为空时,转换后的字符串为 “{}”,长度为 2。如果对象不为空,则转换后的字符串长度会大于 2。
以下是使用 JSON.stringify() 方法来检查对象是否为空的示例代码:

function isObjectEmpty(obj) {
return JSON.stringify(obj) === "{}";
}

// 测试对象是否为空
const obj1 = {};
console.log(isObjectEmpty(obj1)); // true

const obj2 = { name: "John", age: 25 };
console.log(isObjectEmpty(obj2)); // false

在上述示例中,isObjectEmpty() 函数接受一个对象作为参数。函数内部使用 JSON.stringify(obj) 将对象转换为 JSON 字符串,然后将转换后的字符串与 “{}” 进行比较。如果相等,则表示对象为空。
需要注意的是,这种方式只适用于纯粹的对象,并且不包含任何非原始类型属性(如函数、undefined 等)。如果对象中包含了非原始类型的属性,那么转换后的 JSON 字符串可能不为空,即使对象实际上是空的。

E6使用Object.getOwnPropertyNames()

在ES6中,你可以使用Object.getOwnPropertyNames()方法来检查对象是否为空,但需要注意的是,该方法返回一个数组,其包含给定对象中所有自有属性(包括不可枚举属性,但不包括使用 symbol 值作为名称的属性)。
以下是使用Object.getOwnPropertyNames()方法来检查对象是否为空的示例代码:

function isObjectEmpty(obj) {
return Object.getOwnPropertyNames(obj).length === 0;
}

// 测试对象是否为空
const obj1 = {};
console.log(isObjectEmpty(obj1)); // true

const obj2 = { name: "John", age: 25 };
console.log(isObjectEmpty(obj2)); // false

在上面的示例中,isObjectEmpty()函数接受一个对象作为参数。函数内部使用Object.getOwnPropertyNames(obj)获取对象的所有属性名,并检查返回的数组长度是否为0。如果数组长度为0,则表示对象没有任何属性,即为空。
请注意,Object.getOwnPropertyNames()方法返回的数组只包含对象自身的属性,不包括继承的属性。如果你需要检查继承的属性,请使用for…in循环或其他方法。同样,Object.getOwnPropertyNames()方法在ES5中引入,因此在一些旧版本的JavaScript引擎中可能不被支持。

ES6使用Object.getOwnPropertySymbols()方法

在ES6中,可以使用Object.getOwnPropertySymbols()方法来检查对象是否为空。该方法返回一个数组,其中包含了给定对象自身的所有符号属性。
以下是使用Object.getOwnPropertySymbols()方法来检查对象是否为空的示例代码:

function isObjectEmpty(obj) {
const symbols = Object.getOwnPropertySymbols(obj);
const hasSymbols = symbols.length > 0;
return !hasSymbols;
}

// 测试对象是否为空
const obj1 = {};
console.log(isObjectEmpty(obj1)); // true

const symbol = Symbol("key");
const obj2 = { [symbol]: "value" };
console.log(isObjectEmpty(obj2)); // false

在上面的示例中,isObjectEmpty()函数接受一个对象作为参数。函数内部使用Object.getOwnPropertySymbols(obj)获取对象的所有符号属性,并将它们存储在symbols数组中。然后,通过检查symbols数组的长度是否大于0来判断对象是否具有符号属性。如果symbols数组的长度为0,则表示对象没有任何符号属性,即为空。

你还在苦恼找不到真正免费的编程学习平台吗?可以试试【云端源想】!课程视频、知识库、微实战、云实验室、一对一咨询……你想要的全部学习资源这里都有,重点是现在还是免费的!点这里即可免费学习!

注意,Object.getOwnPropertySymbols()方法只返回对象自身的符号属性,不包括其他类型的属性,例如字符串属性。如果你想同时检查对象的字符串属性和符号属性,可以结合使用Object.getOwnPropertyNames()和Object.getOwnPropertySymbols()方法。

ES6使用Reflect.ownKeys()

在ES6中,你可以使用Reflect.ownKeys()方法来检查对象是否为空。Reflect.ownKeys()返回一个包含了指定对象自身所有属性(包括字符串键和符号键)的数组。
以下是使用Reflect.ownKeys()方法来检查对象是否为空的示例代码:

function isObjectEmpty(obj) {
return Reflect.ownKeys(obj).length === 0;
}

// 测试对象是否为空
const obj1 = {};
console.log(isObjectEmpty(obj1)); // true

const symbol = Symbol("key");
const obj2 = { [symbol]: "value" };
console.log(isObjectEmpty(obj2)); // false

在上面的示例中,isObjectEmpty()函数接受一个对象作为参数。函数内部使用Reflect.ownKeys(obj)获取对象的所有自身属性名(包括字符串键和符号键),并检查返回的数组长度是否为0。如果数组长度为0,则表示对象没有任何属性,即为空。
Reflect.ownKeys()方法提供了一种统一的方式来获取对象的所有键,包括字符串键和符号键。因此,使用Reflect.ownKeys()方法可以更全面地检查对象是否为空。

使用lodash库的isEmpty()函数

如果您使用了lodash库,可以使用其提供的isEmpty()函数来直接判断对象是否为空。
以下是使用 Lodash 的 isEmpty() 函数进行对象空检查的示例代码:

// 导入Lodash库
const _ = require('lodash');

// 检查对象是否为空
const obj1 = {};
console.log(_.isEmpty(obj1)); // true

const obj2 = { name: "John", age: 25 };
console.log(_.isEmpty(obj2)); // false

在上述示例中,_.isEmpty() 函数接受一个对象作为参数,并返回一个布尔值表示对象是否为空。如果对象为空,则返回 true;否则返回 false。
使用 Lodash 的 isEmpty() 函数可以更方便地进行对象空检查,同时处理了各种情况,包括非原始类型的属性、数组、字符串等。

使用jQuery库的$.isEmptyObject()函数

要使用 jQuery 库中的 $.isEmptyObject() 函数来检查 JavaScript 对象是否为空,首先确保已经安装了 jQuery 库,并将其导入到你的项目中。
以下是使用 jQuery 的 $.isEmptyObject() 函数进行对象空检查的示例代码:

// 导入jQuery库
const $ = require('jquery');

// 检查对象是否为空
const obj1 = {};
console.log($.isEmptyObject(obj1)); // true

const obj2 = { name: "John", age: 25 };
console.log($.isEmptyObject(obj2)); // false

在上述示例中,$.isEmptyObject() 函数接受一个对象作为参数,并返回一个布尔值表示对象是否为空。如果对象为空,则返回 true;否则返回 false。
使用 jQuery 的 $.isEmptyObject() 函数可以更方便地进行对象空检查,同时处理了各种情况,包括非原始类型的属性、数组、字符串等。

检查对象是否为空、未定义或为 null

要同时检查对象是否为空、未定义或为 null,你可以使用以下函数来进行判断:

function isObjectEmptyOrNull(obj) {
return obj === undefined || obj === null || Object.getOwnPropertyNames(obj).length === 0;
}

在上述代码中,isObjectEmptyOrNull函数接收一个对象作为参数。它首先检查对象是否为 undefined 或者 null,如果是,则直接返回 true 表示对象为空或者未定义。如果对象不是 undefined 或者 null,则使用 Object.getOwnPropertyNames() 方法获取对象的所有自身属性名,然后判断属性名数组的长度是否为 0。如果属性名数组长度为 0,则表示对象没有任何属性,即为空。
下面是一个示例用法:

const obj1 = {};
console.log(isObjectEmptyOrNull(obj1)); // true

const obj2 = null;
console.log(isObjectEmptyOrNull(obj2)); // true

const obj3 = { name: "John", age: 25 };
console.log(isObjectEmptyOrNull(obj3)); // false

const obj4 = undefined;
console.log(isObjectEmptyOrNull(obj4)); // true

总结和比较

在本文中,我们介绍了多种方法来检查 JavaScript 对象是否为空。下面是这些方法的优缺点总结:

  • 使用 Object.keys() 方法

优点:简单易用,不需要依赖第三方库。
缺点:无法处理非原始类型的属性,如函数、undefined 等。

  • Object.values()

优点:能够将对象的属性值组成一个数组,可以通过判断该数组的长度来判断对象是否为空。
缺点:无法直接判断对象是否为空,只提供了属性值的数组。

  • 使用 for…in 循环遍历对象

优点:可以处理非原始类型的属性。
缺点:代码较为冗长,需要手动判断每个属性是否为对象自身属性。

  • 使用 JSON.stringify() 方法

优点:可以处理非原始类型的属性,并且转换后的字符串长度为 2 表示对象为空。
缺点:当对象包含循环引用时,将抛出异常。

  • Object.entries()

优点:能够将对象的键值对组成一个数组,可以通过判断该数组的长度来判断对象是否为空。
缺点:同样无法直接判断对象是否为空,只提供了键值对数组。

  • Object.getOwnPropertyNames()

优点:能够返回对象自身的所有属性名组成的数组,包括可枚举和不可枚举的属性,可以通过判断该数组的长度来判断对象是否为空。
缺点:同样无法直接判断对象是否为空,只提供了属性名数组。

  • Object.getOwnPropertySymbols()

优点:能够返回对象自身的所有 Symbol 类型的属性组成的数组,可以通过判断该数组的长度来判断对象是否为空。
缺点:仅针对 Symbol 类型的属性,无法判断其他类型的属性是否为空。

  • Reflect.ownKeys()

优点:能够返回对象自身的所有属性(包括字符串键和 Symbol 键)组成的数组,包括可枚举和不可枚举的属性,可以通过判断该数组的长度来判断对象是否为空。
缺点:同样无法直接判断对象是否为空,只提供了所有键的数组。

  • 使用 Lodash 库的 isEmpty() 函数

优点:可以处理各种情况,包括非原始类型的属性、数组、字符串等。
缺点:需要依赖第三方库。

  • 使用 jQuery 库的 $.isEmptyObject() 函数

优点:可以处理各种情况,包括非原始类型的属性、数组、字符串等。
缺点:需要依赖第三方库。

总体来说, 这些方法都提供了一种间接判断对象是否为空的方式,即通过获取对象的属性、属性值或键值对的数组,并判断该数组的长度。然而,它们并不能直接告诉我们对象是否为空,因为它们只提供了属性、属性值或键值对的信息。因此,在使用这些方法判断对象是否为空时,需要结合其他判断条件来综合考虑。

收起阅读 »

JS逐页转pdf文件为图片格式

web
背景 年前的时候,开发一个电子杂志项目,功能需求是通过上传pdf文件,将其转为图片格式,所以杂志的内容其实就是一张张图片 不过当时技术要求用后端实现,所以使用的是PHP实现该功能。项目完成后,寻思着在前端是否也能实现pdf转图片的功能。一番研究后,果真可行。以...
继续阅读 »

背景


年前的时候,开发一个电子杂志项目,功能需求是通过上传pdf文件,将其转为图片格式,所以杂志的内容其实就是一张张图片


不过当时技术要求用后端实现,所以使用的是PHP实现该功能。项目完成后,寻思着在前端是否也能实现pdf转图片的功能。一番研究后,果真可行。以下就分享如何通过前端js将pdf文件转为图片格式,并且支持翻页预览、以及图片打包下载


效果预览


图片

所需工具



  1. pdf.js(负责API解析,可将pdf文件渲染成canvas实现预览)

  2. pdf.worker.js(负责核心解析)

  3. jszip.js(将图片打包成生成.zip文件)

  4. Filesaver.js(保存下载zip文件)


工具下载


一、pdf.js及pdf.worker.js下载地址:


mozilla.github.io/pdf.js/gett…


1.选择稳定版下载


图片


2.解压后将bulid中的pdf.js及pdf.worker.js拷贝到项目中


图片


二、jszip.js及Filesaver.js下载地址:

stuk.github.io/jszip/


1.点击download.JSZip


图片


2.解压后将dist文件夹下的jszip.js文件以及vendor文件夹下的FileSaver.js文件拷贝到项目中


图片


至此,所需工具已齐全。以下直接附上项目完整代码(代码可直接复制使用,查看效果。 对应的文件需自行下载引入)


源代码: 嫌麻烦的小伙伴可以直接在公众号后回复: pdf转图片


代码实现


<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>PDF文件转图片</title>
<script src="https://cdn.staticfile.org/jquery/1.10.2/jquery.min.js"></script>
<script type="text/javascript" src="js/pdf.js"></script>
<script type="text/javascript" src="js/pdf.worker.js"></script>
<script type="text/javascript" src="js/jszip.js"></script>
<script type="text/javascript" src="js/FileSaver.js"></script>
<style type="text/css">

button {
width: 120px;
height: 30px;
background: none;
border: 1px solid #b1afaf;
border-radius: 5px;
font-size: 12px;
font-weight: 1000;
color: #384240;
cursor: pointer;
outline: none;
margin: 0 0.5%
}

button:hover {
background: #ccc;
}

#container {
width: 600px;
height: 780px;
margin-top: 1%;
border-radius: 2px;
border: 2px solid #a29b9b;
}

.pdfInfos {
margin: 0 2%;
}
</style>
</head>

<body>

<div style="margin-top:1%">
<button id="prevpage">上一页</button>
<button id="nextpage">下一页</button>
<button id="exportImg">导出图片</button>
<button onclick="choosePdf()">选择一个pdf文件</button>
<input style="display:none" id='chooseFile' type='file' accept="application/pdf">
</div>

<div style="margin-top:1%">
<span class="pdfInfos">页码:<span id="currentPages"></span><span id="totalPages"></span></span>
<span class="pdfInfos">文件名:<span id="fileName"></span></span>
<span class="pdfInfos">文件大小:<span id="fileSize"></span></span>
</div>

<div style="position: relative;">
<div id="container"></div>
<img id="imgloading" style="position: absolute;top: 20%;left: 2%;display:none" src="loading.gif">
</div>

</body>


<script>

var currentPages,totalPages //声明一个当前页码及总页数变量
var scale = 2; //设置缩放比例,越大生成图片越清晰

$('#chooseFile').change(function() {
var pdfFilePath = $('#chooseFile').val();
if(pdfFilePath) {

$("#imgloading").css('display','block');
$("#container").empty(); //清空上一PDF文件展示图

currentPages=1; //重置当前页数
totalPages=0; //重置总页数

var filesdata = $('#chooseFile')[0].files; //jquery获取到文件 返回属性的值
var fileSize = filesdata[0].size; //文件大小
var mb;

if(fileSize) {
mb = fileSize / 1048576;
if(mb > 10) {
alert("文件大小不能>10M");
return;
}
}

$("#fileName").text(filesdata[0].name);
$("#fileSize").text(mb.toFixed(2) + "Mb");

var reader = new FileReader();
reader.readAsDataURL(filesdata[0]); //将文件读取为 DataURL
reader.onload = function(e) { //文件读取成功完成时触发

pdfjsLib.getDocument(this.result).then(function(pdf) { //调用pdf.js获取文件
if(pdf) {
totalPages = pdf.numPages; //获取pdf文件总页数
$("#currentPages").text("1/");
$("#totalPages").text(totalPages);

//遍历动态创建canvas
for(var i = 1; i <= totalPages; i++) {
var canvas = document.createElement('canvas');
canvas.id = "pageNum" + i;
$("#container").append(canvas);
var context = canvas.getContext('2d');
renderImg(pdf,i,context);
}

}
});

};
}
});

//渲染生成图片
function renderImg(pdfFile,pageNumber,canvasContext) {
pdfFile.getPage(pageNumber).then(function(page) { //逐页解析PDF
var viewport = page.getViewport(scale); // 页面缩放比例
var newcanvas = canvasContext.canvas;

//设置canvas真实宽高
newcanvas.width = viewport.width;
newcanvas.height = viewport.height;

//设置canvas在浏览中宽高
newcanvas.style.width = "100%";
newcanvas.style.height = "100%";

//默认显示第一页,其他页隐藏
if (pageNumber!=1) {
newcanvas.style.display = "none";
}

var renderContext = {
canvasContext: canvasContext,
viewport: viewport
};

page.render(renderContext); //渲染生成
});

$("#imgloading").css('display','none');

return;
};

//上一页
$("#prevpage").click(function(){

if (!currentPages||currentPages <= 1) {
return;
}

nowpage=currentPages;
currentPages--;

$("#currentPages").text(currentPages+"/");

var prevcanvas = document.getElementById("pageNum"+currentPages);
var currentcanvas = document.getElementById("pageNum"+nowpage);
currentcanvas.style.display = "none";
prevcanvas.style.display = "block";

})

//下一页
$("#nextpage").click(function(){

if (!currentPages||currentPages>=totalPages) {
return;
}

nowpage=currentPages;
currentPages++;

$("#currentPages").text(currentPages+"/");

var nextcanvas = document.getElementById("pageNum"+currentPages);
var currentcanvas = document.getElementById("pageNum"+nowpage);
currentcanvas.style.display = "none";
nextcanvas.style.display = "block";

})

//导出图片
$("#exportImg").click(function() {

if (!$('#chooseFile').val()) {
alert('请先上传pdf文件')
return false;
}

$("#imgloading").css('display','block');

var zip = new JSZip(); //创建一个JSZip实例
var images = zip.folder("images"); //创建一个文件夹用来存放图片

//遍历canvas,将其生成图片放进文件夹images中
$("canvas").each(function(index, ele) {
var canvas = document.getElementById("pageNum" + (index + 1));

//将图片放进文件夹images中
//参数1为图片名称,参数2为图片数据(格式为base64,需去除base64前缀 data:image/png;base64)
images.file("" + (index + 1) + ".png", splitBase64(canvas.toDataURL("image/png", 1.0)), {
base64: true
});

})

//打包下载
zip.generateAsync({
type: "blob"
}).then(function(content) {
saveAs(content, "picture.zip"); //saveAs依赖的js文件是FileSaver.js
$("#imgloading").css('display','none');
});

});

//截取base64前缀
function splitBase64(dataurl) {
var arr = dataurl.split(',')
return arr[1]
}

function choosePdf(){
$("#chooseFile").click()
}
</script>
</html>

项目实现原理分析



  1. 首先利用pdf.js将上传的pdf文件转化成canvas

  2. 然后使用jszip.js将canvas打包图片生成.zip文件

  3. 最后使用Filesaver.js将zip文件保存下载


项目注意要点



  1. 由于pdf文件是通过上传的,因此需要通过js的FileReader()对象将其读取为DataURL,pdf.js文件才可读取渲染

  2. JSZip对象的.file()函数中第二个参数传入的是base64格式图片,但是要去掉base64前缀标识


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

火烧眉毛,我是如何在周六删了公司的数据库

这本是一个安静的星期六。 我收到了支持团队的一条消息,说我们一个客户遇到了问题。我认为这个问题很重要,值得开始调试。15 分钟后,我明白了问题所在 - 在数据库中有一些损坏的订单需要删除。 听起来小菜一碟。 事故还原 如果你不给创业公司打工,请不要嘲笑我 😅 ...
继续阅读 »


这本是一个安静的星期六。


我收到了支持团队的一条消息,说我们一个客户遇到了问题。我认为这个问题很重要,值得开始调试。15 分钟后,我明白了问题所在 - 在数据库中有一些损坏的订单需要删除。


听起来小菜一碟。


事故还原


如果你不给创业公司打工,请不要嘲笑我 😅


有几百个订单需要删除,所以我决定不手动操作,而是编写一个简单的 SQL 查询语句(警告 🚩)


实际上比这复杂一些,但这里简化一下:


UPDATE orders
SET is_deleted = true

WHERE id in (1, 2, 3)

你大概已经猜到这场灾难的规模了...


我按下了 CTRL + Enter 并运行了命令。当它花费超过一秒钟时,我明白发生了什么。我的客户端 DBeaver 看到空的第三行,并忽略了第四行。


是的,我删除了数据库中所有的订单 😢


我整个人都不好了。


恢复


深吸一口气后,我知道我必须快速行动起来。不能犯更多错误浪费时间了。


恢复工作做得很好。



  1. 停止系统 - 约 5 分钟

  2. 创建变更前数据库(幸运的是我们有 PITR)的克隆 - 约 20 分钟

  3. 在等待期间给我的老板打电话 😨

  4. 根据克隆更新生产数据库的信息* - 约 15 分钟

  5. 启动系统 - 约 5 分钟


*我决定不还原整个数据库,因为无法停止所有系统,因为我们有多个独立的系统。我不想在恢复过程中丢失所做的更改。我们用 GCP 提供的托管 PostgreSQL,所以我从更新之前创建了一个新的克隆。然后,我只导出了克隆中的 idis_deleted 列,并将结果导入到生产数据库中。之后,就是简单的 update + select 语句。


所以显然本可以很容易避免这 45 分钟的停机时间...


发生了什么?


这可能听起来像是一个你永远不会犯的愚蠢错误(甚至在大公司中,根本不能犯)。确实。问题不在于错误的 SQL 语句。**一个小小的人为失误从来都不是真正的问题。**我运行那个命令只是整个失败链条的终点。



  1. 为什么要在周末处理生产环境?在这种情况下,事情并没有那么紧急。没有人要求我立即修复它。我本可以等到星期一再处理。

  2. 谁会在生产数据库上更改而不先在 QA 环境上运行一下呢?

  3. 为什么我手动编辑了数据库而不是通过调用 API?

  4. 如果没有 API,为什么我没打电话给队友,在如此敏感的操作上进行双重检查?

  5. **最糟糕的是,为什么我没使用事务?**其实只要用了 Begin,万一出错时使用 Rollback 就可以了。


错误一层层叠加,其中任何一个被避免了 - 整件事就不会发生。大多数问题答案都很简单:我太自信了。
不过还好通过有章法的恢复程序,阻止了连锁反应。想象一下如果无法将数据库恢复到正确状态会发生什么灾难……


这与切尔诺贝利有什么关系?


几个月前,我阅读了「切尔诺贝利:一部悲剧史」。那里发生的一系列错误使我想起了那个被诅咒的周末(并不是要低估或与切尔诺贝利灾难相比较)。



  1. RBMK 反应堆存在根本技术问题。

  2. 这个问题没有得到恰当传达。之前有涉及该问题的事件,但切尔诺贝利团队对此并不熟悉。

  3. 在安全检查期间,团队没有按程序操作。

  4. 爆炸后,苏联政府试图掩盖事实,从而大大加剧了损害程度。


谁应该负责?


反应堆设计师?其他电厂团队未能传达他们遇到的问题?切尔诺贝利团队?苏联政府?


所有人都有责任。灾难从来不是由单一错误引起的,而是由一连串错误造成的。我们的工作就是尽早打断这条链条,并做到最好。


后续


我对周一与老板的谈话本没有什么期待。


但他让我惊讶:「确保不再发生这种情况。但是我更喜欢这样 - 你犯了错误是因为你专注并且喜欢快速行动。做得越多,砸得越多。」


那正是我需要听到的。如果以过于「亲切」的方式说:没关系,别担心,谢谢你修复它!我反而会感觉虚伪。另一方面,我已经感觉很糟糕了,所以没有必要进一步吐槽我。


从那时起:



  • 我们减少了对数据库直接访问的需求,并创建相关的 API。

  • 我总是先在 QA 上运行查询(显而易见吧?没有比灾难更能教训人了)。

  • 我与产品经理商量,了解真正紧急和可以等待的事项。

  • 任何对生产环境进行更删改操作都需要两个人来完成。这实际上防止了其他错误!

  • 我开始使用事务处理机制。


可以应用在你的团队中的经验教训


事发后,我和团队详细分享了过程,没有隐瞒任何事情,也没有淡化我的过错。
在责备他人和不追究责任之间有一个微妙的平衡。当你犯错误时,这是一个传递正确信息的好机会。


如果你道歉 1000 次,他们会认为你期望当事情发生在他们身上时,他们也需要给出同样的回应。


如果你一笑了之,并忽视其影响,他们会认为这是可以接受的。


如果你承担责任、学习并改进自己 - 他们也会以同样的方式行事。


file


总结一下



  • 鼓励行动派,关心客户,并解决问题。这就是初创企业成功的方式。

  • 当犯错时,要追究责任。一起理解如何避免这种情况发生。

  • 没必要落井下石。有些人需要更多的责任感,而有些人则需要更多的鼓励。我倾向于以鼓励为主。


顺便说一句,如果团队采用了 Bytebase 的话,这个事故是大概率可以被避免的,因为 Bytebase 有好几道防线:



  1. 用户不能随意通过使用 DBeaver 这样的本地客户端直连数据库,而必须通过 Bytebase 提交变更工单。

  2. 变更工单的 SQL 会经过自动审查,如果影响范围有异常,会有提示。

  3. 变更工单只有通过人工审核后才能发布。

作者:Bytebase
来源:juejin.cn/post/7322156771614507059
收起阅读 »

JSON.parse记录一次线上bug排查

web
最近项目中有一个匪夷所思的问题,业务在使用的时候,偶发性的会白屏,经常下班的时候骚扰我们,开发苦不堪言,经过长达一周的排查,仍然没有查到bug的存在,最终尝试通过添加埋点日志,记录关键信息。 现状 首先讲述一下现状,首先业务进入后,页面可以认为有两个按钮 跳...
继续阅读 »

最近项目中有一个匪夷所思的问题,业务在使用的时候,偶发性的会白屏,经常下班的时候骚扰我们,开发苦不堪言,经过长达一周的排查,仍然没有查到bug的存在,最终尝试通过添加埋点日志,记录关键信息。


现状


首先讲述一下现状,首先业务进入后,页面可以认为有两个按钮



  • 跳转共享链接

  • 打开表单弹窗按钮,点击后展示表单。


image-20240124132744181


操作顺序是,页面加载后,先点击跳转共享链接,看完链接后再返回点击表单弹窗。



里面有两个重要的时间节点,一个是跳转链接之前,一个是返回到当前页面。




  • 跳转链接之前



    • 需要存储接口数据,接口数据包含了表单的数据



  • 返回当前页面



    • 请求接口数据



      • 本地缓存无,直接使用接口数据

      • 本地缓存有,缓存和接口数据合并,接口数据优先






image-20240124132901079


返回页面的时候,点击表单弹窗


正常上来说弹窗能够正常显示,但是线上环境再点击 展示弹窗的按钮导致白屏了。整个流程如下


image-20240124133213958


初步判断是整合缓存和接口数据问题,于是需要给页面添加两个埋点



  • 页面报错异常时上报

  • 点击打开表单的时,上报缓存数据和聚合之后的数据。



    • 为什么不上报接口数据呢?因为当时修复bug比较紧急,观察代码发现接口直接返回的数据没有在公共变量中存储,如果需要存储改动较大,还有就是接口数据也可以从后端日志去排查




页面报错异常上报


异常上报的方法有很多,通常使用一个gif图片,地址为get的请求地址+上报信息,具体的可以自行百度,此处简单叙述下


使用图片是因为加载资源里面img优先级比较低,不会阻塞其他资源,而且图片请求不会跨域,用gif是因为对比图片类型他是比较小的


//utils/utils.js
/**
* 异常上报方法
* 希望抽离出来同步异常类和异步异常类
*/

function uploadError() {
 //上报处理参数
 const upload = errObj =>{
   const logUrl = 'https://xxx.xxx.com/log.gif'; // 上报接口
   //将obj拼接成url
   const queryStr = Object.entries(errObj)
      .map(([key, value]) => `${key}=${value}`)
      .join('&');
     const oImg = new Image();
     oImg.src = logUrl + '?' + encodeURIComponent(queryStr);
}
 //同步方法
 function handleError(e) {
   try {
     let baseInfo = localStorage.getItem('base_info'); // 域账户
     let masterName = baseInfo ? JSON.parse(baseInfo)?.master_name : ''; // 域账户
     let errObj = {
       masterName: masterName,//域账户
       url: window.location.href,//报错的路由,利于排查
       reason: JSON.stringify({
         message: e?.error?.message, //报错信息
         stack: e?.error?.stack,//调用栈
      }),
       message: e?.message, //报错信息
    };
     upload(errObj)
     console.log('error', errObj);
  } catch (err) {
     console.log('error', err);
  }
}
 window.addEventListener('error', handleError);//调用监听
}

//app.js
//异常上报方法 开发环境禁止上报
if(!['dev'].includes(process.env.BUILD_ENV)){
 uploadError()
}

点击弹窗的异常上报


//打开弹窗的操作  
const open = () => {
   setShow(!show);//控制表单的展示隐藏
   if(!show){
     const logUrl = 'https://xxx.xxx.com/log.gif'; // 上报接口
     const oImg = new Image();
     let initFormVal = localStorage.getItem('initFormVal' + query?.id);
     oImg.src = logUrl + '?' + encodeURIComponent(`initFormVal=${initFormVal}&integratedData=${JSON.stringify(integratedData)}`);
  }
};
//initFormVal为缓存中的数据 integratedData为整合后的数据

发现问题原因


通过添加以上异常上报,业务员进行操作时,又出现了白屏,此时根据业务员token与上报关键字与时间查到了相关日志,其中日志中记录的是


https://xxx.xxx.com/log.gif?initFormVal=&integratedData=null

integratedData是后端接口数据和缓存的融合呀!通过查日志发现当时后端确确实实返回正常的响应了,不可能为null,同时还有一个疑问浮出水面,为什么initFormVal没有值,而不是null


正常来说如果initFormVal从json中取值时,取不到应该默认就是null,此处为'',只说明一个问题,缓存的时候给他赋值了


那么问题大致可以定位到以下两个操作节点



  • 缓存时

  • 返回页面后,缓存和接口数据融合时


//缓存时操作  
const getFormValues = () => {
   let formVal = childRef?.current?.getFormVal() || '';
localStorage.setItem('initFormVal' + query.id, JSON.stringify(formVal));
};

缓存时,如果子节点获取不到,那么childRef?.current?.getFormVal()就为undefind,又由于使用了或运算符,那么此时存储的是'',那么取这个暂时看也没问题呀,然后也写入了缓存



更严格来讲,应该先判断formVal是否存在然后再去缓存,没有就不缓存。



再看一下返回页面,数据融合的代码


const getDataFn = url => {
   dispatch({
     type: url,
     payload: { id: query.id },
     callback: res => {
       if (res.ret === 1) {
         let initFormVal = localStorage.getItem('initFormVal' + query?.id);
         console.log('initFormVal', JSON.parse(initFormVal));
         let cacheFormVal = {};
         
         if (initFormVal) {
           //initFormVal赋值给cacheFormVal,此处省略
        }
         setPricingInfo({
           ...cacheFormVal,
           ...res.data
        });
      }  

发现有一个console.log(),JSON.parse('')会是什么?报错,果然,查异常上报日志的时候,也查到这个错误,真是一失足成千古恨,当时只是为了方便查看,打印了一下缓存数据,没想到是这个地方出现的问题 Uncaught SyntaxError: Unexpected end of JSON input


image-20240124142222982


JSON.parse


那问题来了 json.parse什么情况会报错呢?通过查阅MDN


image-20240124143007732


那么,什么是规范的JSON格式呢?我们此处再去查阅MDN


此处只列出了json的结构 很显然,传入null 是合法的,但是传入空字符是不合法的,


JSON = null
   or true or false
   or JSONNumber
   or JSONString
   or JSONObject
   or JSONArray

吐槽


可能有人要吐槽,直接写JSON存储的时候格式不对不就行了吗?干什么这那么多,又是异常上报,又是贴代码?又是贴MDN的。


我在这里回答一下之所以这么写一是为了记录出错的时候出现的问题,方便下次出现类似问题能够即时复盘。


二是希望贴出自己的排错方式,新手若有不明白的可以模仿这个方式得到一些启发和思考,高手也可指出我的问题,共同成长


同样我也希望大家遇到问题的时候要记得查文档,查文档再查文档,自己遇到的问题,先文档,是不是自己理解错了,如果还不行就去stackoverflow,如果再不济就去github issue看看是否有相同的问题是不是作者的bug,如果都没有,那么好了,这个问题几乎解决不了了,此时有两个选择,要么产品接受,要么 那我走???


作者:傲娇的腾小三
来源:juejin.cn/post/7327227246618476583
收起阅读 »

Android:布局动画和共享动画的结合效果

大家好,我是时曾相识2022。不喜欢唱跳,但对杰伦的Rap却情有独钟。 今天给大家带来能够提升用户体验感的交互动画,使用起来非常简单,体验效果非常赞。其中仅使用到布局动画和共享动画。废话不多说,直接上效果图: 怎么样,效果看起来还不错吧。这其实都是官方提供...
继续阅读 »

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



今天给大家带来能够提升用户体验感的交互动画,使用起来非常简单,体验效果非常赞。其中仅使用到布局动画和共享动画。废话不多说,直接上效果图:


Screenrecorder-2023-09-12-12-00-04-706.gif


怎么样,效果看起来还不错吧。这其实都是官方提供的效果,接下来让我给大家简单分享下整套效果实现的过程和其中遇到的一些问题。


首先是布局动画,何为布局动画呢?


布局动画的作用于ViewGr0up,执行动画效果的是内部的子View。布局动画在Android中可以通过LayoutAnimationLayoutTransition来实现。咱们这里直接使用LayoutAnimation方式。在项目目录res下新建anim文件夹,并在其中新建layout_slid_from_right.xml文件和slide_from_right.xml两个文件:


//Gr0upView中设置动画文件
android:layoutAnimation="@anim/layout_slid_from_right"

//layout_slid_from_right.xml文件
<layoutAnimation xmlns:android="http://schemas.android.com/apk/res/android"
android:animation="@anim/slide_from_right"
android:animationOrder="normal"
android:delay="15%"/>

//slide_from_right.xml文件
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="600">
<translate
android:fromYDelta="100%p"
android:interpolator="@android:anim/decelerate_interpolator"
android:toYDelta="0" />

<alpha
android:fromAlpha="0.5"
android:interpolator="@android:anim/accelerate_decelerate_interpolator"
android:toAlpha="1" />

<scale
android:fromXScale="20%"
android:fromYScale="20%"
android:interpolator="@android:anim/accelerate_decelerate_interpolator"
android:pivotX="50%"
android:pivotY="50%"
android:toXScale="100%"
android:toYScale="100%" />

<rotate
android:fromDegrees="-5"
android:interpolator="@android:anim/accelerate_interpolator"
android:pivotX="50%"
android:pivotY="50%"
android:toDegrees="0" />
</set>

其中set标签下可包含多个动画,运行时动画就是同时进行的。具体实现步骤可以参考我之前的文章:Android:LayoutAnimal的神奇效果



  • translate :平移动画

  • alpha:渐变动画

  • scale:缩放动画

  • rotate:旋转动画


接下来是共享动画,其实就是两个页面都包含了同一个元素,进行的一种转场动画。这是Android5.0以后Google推出Material Design设计风格中包含的功能。


如何使用呢?



  • 第一个ActivityXML文件中咱们将ImageView作为共享元素


<ImageView
android:id="@+id/iv"
android:layout_width="match_parent"
android:layout_height="250dp"
app:riv_corner_radius="10dp" />


  • 第二个ActivityXML文件中需要添加一个transitionName属性,在跳转页面的时候也要用到它。


<ImageView
android:id="@+id/iv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:transitionName="share"/>


  • 跳转页面时使用ActivityOptionsCompat设置共享信息并传输给下个页面:


val optionsCompat = ActivityOptionsCompat.makeSceneTransitionAnimation(this, iv, "share")//iv是当前点击的图片  share字符串是第二个activity布局中设置的**transitionName**属性
startActivity(Intent(this, MainActivity10::class.java).apply {
putExtra("data", url) //这里仍然可以正常传值
}, optionsCompat.toBundle()) //注意这里是转化为了bundle


  • 当然关闭页面的时候不再使用finish() 方法而是使用如下方式:


ActivityCompat.finishAfterTransition(this)

到此运行程序,就能达到和上面一样的动画效果。


遇到的坑:



  • 设置布局动画的时候,一定要记得在set标签内添加duration属性并赋值,否则不会有动画效果

  • 布局动画作用于所有的Gr0upView

  • 转场动画在选用共享属性的时候最好选用原生View。笔者之前尝试过一些第三方的ImageView,在跳到目标页的时候即便XML中将图片宽高设置为了match_parent,结果却只展示了图片本身的宽高。很有可能是自定义过程中计算和官方有冲突。

  • 官方的转场动画从5.0开始支持


好了,以上便是布局动画和共享动画的结合效果的全部内容。大家可以根据自己的需求和喜好实现更多酷炫的效果,希望这篇内容能给大家带来收获!


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

相见恨晚的前端开发利器-PageSpy

web
今天介绍一个非常有用的前端开发工具。 做前端开发的你,一定有过以下经历: 我这里是好的啊,你截个图给我看看 不会吧,你打开f12,控制台截个图给我看看 录个屏给我看看你是怎么操作的 ... 还有,我们在开发h5的时候,一般为了调试方便,可能会在开发环境和测...
继续阅读 »

今天介绍一个非常有用的前端开发工具。


做前端开发的你,一定有过以下经历:



  1. 我这里是好的啊,你截个图给我看看

  2. 不会吧,你打开f12,控制台截个图给我看看

  3. 录个屏给我看看你是怎么操作的

  4. ...


还有,我们在开发h5的时候,一般为了调试方便,可能会在开发环境和测试环境实例化一个vConsole,遇到问题看一下大概就能定位到错误。


可是如果测试小姐姐在远程呢?如果是线上环境呢?如果有这么一个工具,能让我坐在工位上就能调(窥)试(探)用户的操作,那岂不是美滋滋。


你可能会说,这不就是埋点吗,先别急,今天介绍的这个工具和埋点有着本质区别。


不啰嗦了,有请主角**「PageSpy」**登场。


PageSpy是什么?




PageSpy[1] 是由货拉拉大前端开源的一款用于调试 H5 、或者远程 Web 项目的工具。是一个强大的开源前端远程调试平台,它可以显著提高我们在面对前端问题时的效率。




有什么作用?



  • 一眼查看客户端信息 能识别客户端运行环境,支持Linux/Mac/Window/IOS/Android

  • 实时查看输出 可以实时输出客户端的Element,Console,Network,Storage

  • 网络请求监控 可以捕获和显示页面的网络请求

  • 远程控制台 支持远程调试客户机上的js代码


如何使用?


查看官方文档[2]



  1. 安装npm包


yarn global add @huolala-tech/page-spy-api

# 如果你使用 npm

npm install -@huolala-tech/page-spy-api


  1. 启动服务


直接在命令行执行page-spy-api,部署完成后浏览器访问:6752,页面顶部会出现接入SDK菜单,点击菜单查看如何在业务项目中配置并集成。图片命令行执行后出现这个界面表示服务启动成功了,然后访问我自己的ip+端口,再点击顶部接入SDK图片去创建一个测试项目,建一个最简单的index.html,按照文档接入SDK,然后在浏览器访问这个页面图片图片左下角出现Pagepy的logo说明引入成功了。


此时点击顶部菜单房间列表图片点击调试,就可以看到这个项目的一些实时调试信息,但是还没加什么代码。图片现在改一下我们的代码,加一些输出信息。图片Console控制台的信息图片直接输出用户端代码变量的实时的值图片加个定时器试试,也是实时输出的图片图片再来看看Storage信息图片图片Element信息图片调个接口试试图片图片图片


好了,今天的介绍就到这里,这么牛叉的工具,是不是有种相见恨晚的感觉,感兴趣的小伙伴快去试试吧!


Reference


[1] PageSpy:huolalatech.github.io/page-spy-we…


[2] 官方文档:github.com/HuolalaTech…


作者:丝绒拿铁有点甜
来源:juejin.cn/post/7327691403844665380
收起阅读 »

年底了,出了P0级故障,人肉运维不可靠

翻车现场 5年前,大概是2018年12月份的一个晚上,我接到数据组同事的消息,要求将A用户的磁盘快照共享给B用户。我对这个线上运维工作早已轻车熟路,登录线上服务器仅用了2分钟就完成了。 我继续忙着其他事情,3分钟后,我正要打开新的控制台页面,猛然发现控制台上的...
继续阅读 »

翻车现场


5年前,大概是2018年12月份的一个晚上,我接到数据组同事的消息,要求将A用户的磁盘快照共享给B用户。我对这个线上运维工作早已轻车熟路,登录线上服务器仅用了2分钟就完成了。


我继续忙着其他事情,3分钟后,我正要打开新的控制台页面,猛然发现控制台上的“ public = true”。我惊慌地查看磁盘快照状态,发现磁盘快照已经共享给了所有用户。任何用户都可以在自己的快照列表中看到这个快照,并用快照创建新的磁盘,这意味着这些快照数据已经泄露了。这可是公司重要客户的磁盘数据啊!!!!


我心里明白,对于云计算行业,数据安全问题比线上bug还要严重!


我立刻就慌了,心脏砰砰的跳,手也开始颤抖。我心里很忐忑,一开始试图偷偷回滚,纠结之后,最终选择告诉了组长。


我倒吸一口气,一边进行回滚,一边试图平静的说,“我把刚才的快照共享给了所有租户”。瞬间,组长瞪大眼睛了看着我,“回滚了吗,赶紧回滚……”。 我颤抖地编辑SQL,手都麻木了,心脏还在怦怦跳个不停,开始担心这件事的后果。


领导边看我回滚,边小声对我说,“赶紧回滚,下次小心点”,看的出来,组长不想声张,他想先看看影响。


”嗯,好“,我努力嗯了一声,组长没大声骂我,我很感动。本以为回滚了,就没事了。



(后来这家小公司黄了,这是被我干黄的第二家公司,你们干黄了几家?)



然而,这远远没有结束。


原本宁静的办公室突然变得热闹起来,周围的同事们纷纷接到了报警通知。他们“兴高采烈”地讨论着报警的原因,我的注意力也被吸引了过去,听起来似乎与我有关,但我却没有心情去理会他们。


最终,快照被共享 5 分钟后,回滚完成,我长舒一口气,心想幸好我多看了一眼控制台,否则不知道被泄露多久。


与此同时,邻居组的成员钱哥找到了我,问道:“刚才快照计费数据暴涨了,你们这边有做过什么操作吗?”


随后,邻居组的组长王哥也过来了,询问情况如何。


我的组长苦笑着告诉他们:“刚才一个磁盘快照错误地被共享给了所有租户,不过现在已经回滚了。”


邻居组的王哥听后惊愕地说道:“卧槽,谁干的?”他的脸上露出了一丝微笑,似乎是看热闹的微笑。


我实在不知道该怎么说了,苦着脸问他们:“计费数据能回滚吗?”


邻居组的王哥没有回答我的问题,看了我一眼,说:“我叫上老板,先找个会议室讨论一下吧。”


万幸的是这 5分钟里没有用户使用此快照创建磁盘,这意味快照数据没有发生实质性泄露。


至暗时刻


接下来的两天里,我只做了两件事,参加复盘会议和去会议室的路上。这两天是我人生中最难忘的时刻,我尴尬得连脚丫子都能拧成麻花。


我真希望能立刻辞职离开这个地方。”别再鞭尸了,老子不干了,行不行。md,不就是共享个快照嘛!“ 我的心理状态从忐忑变得暴躁~



(每次造成线上故障,我都有类似的想法,我不想干了,不就是个bug吗,不干了,还不行吗?你们有类似想法吗?)



后来我开始后悔 ,为什么不早点下班,九点多还帮同事进行高危的线上操作,我图个啥


对,我图个啥。我脑子被驴踢了,才提出这个人肉运维方案,一周运维十几次,自己坑自己……


背景


2个月前,组长接到一个大客户需求,要求在两个租户之间共享云磁盘数据,当时提出很多个方案,其中包括分布式存储系统提供工具共享两个云磁盘数据等非常复杂的方案。 我当时听到这个需求,就立马想到, 我们的云管理系统可以实现两个租户的资源共享啊,通过给云磁盘打快照、共享快照等,就实现两个云磁盘的数据共享。


当时我非常得意,虽然我对存储并不是很了解,但是我相信我的方案比存储团队的底层方案更加简单且可行性更高。经过与客户的沟通,确定了这个方案能够满足他们的诉求,于是我们定下了这个方案。


由于大客户要的比较急,我改了代码就急匆匆上线,这个需求甚至没有产品参与,当客户需要共享数据时,需要我构造请求参数,在线上服务器上命令行执行共享操作。第一版方案在线上验证非常顺利,客户对这样快速的交付速度非常满意


因为我们使用了开源的框架,资源共享能力是现成的,所以改动起来很快。只不过有一个核弹级feature,我忽略了它的风险。


public = true时,资源将共享给全部用户。“只要不设置这个参数就不会有什么问题。” 这是我的想法,我没有考虑误操作的可能,更没有想到自己会犯下这个错误。


本以为只是低频的一次性操作,没想到后来客户经常性使用。我不得不一次次在线上执行高危操作,刚开始我非常小心谨慎,仔细的检查每个参数,反复确认后才执行命令。


然而,后来我感到这个工作太过枯燥乏味,于是开始集中处理,一次性执行一批操作。随着时间的推移,我越来越熟悉这件事。这种运维操作我两分钟就能完成……之所以这么快,是因为我不再仔细检查参数,只是机械地构造参数,随手执行。正是我松懈的态度导致闯下了大祸,在那个日常性加班的晚上。


后来我开始反思,从需求提出到故障发生前,我有哪些做的不对的地方。我认为有如下问题。



  1. 技术方案不能仅限于提供基本的资源共享能力,还要提供可视页面,提供产品化能力。

  2. 高危接口,一定要严格隔离成 单独的接口,不能和其他接口混合在一起,即使功能类似

  3. 线上重要操作要提供审核能力!或者有double check 的机制!


深刻的反思


任何工作都是有风险的,尤其是程序员无时无刻都在担心发生线上问题,如果不学会保护自己,那么多干一件事就多增加很多风险,增加背锅的风险。


拿我来说,本来这个需求不需要我参与,我提出了一个更简单的方案,高效的响应了大客户需求,是给自己长脸的事情。然而,我犯了一个巨大的错误,之前所做的努力都付之一炬。大领导根本不知道我提出的方案更简洁高效,他只认为我办事不可靠。在复盘会议上,我给大领导留下了非常糟糕的印象。


话说回来,在这个事情上如何保护自己呢?



  1. 技术方案一定要避免人肉运维,对于高危运维操作要求产品提供可视化页面运维。一定要尽全力争取,虽然很多时候,因为排期不足,前端资源不足等原因无法做到。

  2. 如果没有运维页面,等基础能力上线后,继续寻求组长帮助,协调产品提供操作页面,避免一直依赖自己人肉运维去执行高危操作。

  3. 在还没有产品化之前,要求客户或上游同事将所有的需求整理到文档上,使用文档进行沟通交流,记录自己的工作量,留存一份自己的”苦劳“。

  4. 在低频操作,变为高频操作时,不应该压迫自己更加“高效运维”,而是将压力和风险再次传达给产品和组长,让他们意识到我的人肉运维存在极大危险,需要要尽快提供产品化能力。让他们明白:“如果不尽快排期,他们也会承担风险!”

  5. 任何时候,对于线上高危操作,一定要小心谨慎。万万不可麻痹大意!


总之,千万不要独自承担所有的压力和风险。在工作中,我们可以付出辛勤努力,承受一定的风险,但是必须得到相应的回报。



风浪越大,鱼越贵。但是如果大风大浪,鱼还是很便宜,就不要出海了!风险收益要对等



就这个事情来说,每天我都要执行高风险的运维操作,是一种辛苦而不太受重视的工作。尽管如此,我却必须承担着巨大的风险,并自愿地让自己不断追求更高效的人工运维方式。然而结果却让人啼笑皆非,我终究翻车了。实在是可笑。



挣着卖白菜的钱,操着卖白粉的心,这是我的真实写照。



吾日三省吾身、这事能不能不干、这事能不能明天干、这事能不能推给别人干。


程序员不善于沟通,往往通过加班、忍一忍等方式默默地承担了很多苦活、脏活、累活。但是我们要明白,苦活可以,脏活等高风险的活 千万不要自己扛。


你干好十件事不一定传到大领导耳朵里,但是你出了一次线上大事故,他肯定第一时间知道。


好事不出门,坏事传千里。


我们一定要对 高危的人工运维,勇敢说不!


作者:五阳神功
来源:juejin.cn/post/7285673629526753316
收起阅读 »

幻兽帕鲁Palworld服务端最佳一键搭建教程

幻兽帕鲁Palworld最近彻底火了,忍不住也自己去搭建了一下,找了很多教程,发现还是目前找的这个最方便,结合腾讯云服务器新人优惠套餐,这里给出我搭建的详细步骤,包你轻松搞定。 此方案适合新用户,毕竟老用户购买服务器价格还是很贵的,自己如果已经是老用户了记得用...
继续阅读 »

幻兽帕鲁.jpg


幻兽帕鲁Palworld最近彻底火了,忍不住也自己去搭建了一下,找了很多教程,发现还是目前找的这个最方便,结合腾讯云服务器新人优惠套餐,这里给出我搭建的详细步骤,包你轻松搞定。


此方案适合新用户,毕竟老用户购买服务器价格还是很贵的,自己如果已经是老用户了记得用身边的人帮你买一个即可。


服务器选择


目前发现各大厂家都推出了自家的新人首单优惠,官方入场,最为致命!太便宜了,这里推荐三家主流的



image.png


腾讯云的点击进来后,可以看到很明显的一栏关于帕鲁游戏的,点击后面的前往部署就可以进入优惠的服务器了


,推荐新人使用66元这一档,我个人也是买了这档来测试。


image.png



阿里云也推出了幻兽帕鲁专属云服务器,还是针对新用户的,如果你进来看到的价格也是入下图这样,那推荐入手


image.png



华为云也推出新用户一个月的优惠价,一个比一个卷


image.png


教程推荐


我这次操作的教程脚本是参考github.com/2lifetop/Pa… 这个项目
之所以用这个教程因为足够简单,也有界面可视化来配置私服的参数,


image.png


搭建步骤详细说明


这里我用的是腾讯云服务器,所以流程介绍腾讯云上面的搭建方式,如果你买的是其他家的也类似,核心步骤都是以下2点:



  • 一键安装脚本

  • 服务端配置(可选)

  • 端口8211开放


服务器购买


因为脚本推荐的是用 Debian 12,所以我购买腾讯云服务器的时候,直接选择了 Debian12带Docker的版本。


image.png


购买后就可以进入服务器的界面了,如果找不到,可以搜索轻量应用服务器


image.png


image.png


这里你可以用第三方ssh登录或者直接直接网页登录都行。我推荐用第三方登录,我用的是FinalShell这个软件,我第一步是进入修改密码。


image.png


然后就用FinalShell登录上了,稳的一批。


一键安装脚本


以root用户登陆到服务器然后运行以下命令即可。该脚本目前只在Debian12系统上验证过。如果遇上非网络问题则请自行更换系统或者寻求其他解决方案。


非root用户请先运行 sudo su命令。


1.  wget -O PalServerInstall.sh https://www.xuehaiwu.com/wp-content/uploads/shell/Pal/PalServerInstall.sh --no-check-certificate && chmod +x PalServerInstall.sh && ./PalServerInstall.sh

出现下面这个画面了,选择1安装即可


image.png


正常等待几分钟就可以安装好了, 不过我自己安装的时候出现过问题,提示安装失败,然后我就执行11删除,然后重新执行脚本安装就成功了。


服务端配置(可选)


因为搭建的是私服嘛,所以为了体验更加,这个脚本提供了在线参数修改,步骤也很简单
先打开 http://www.xuehaiwu.com/Pal/
把你想调整的参数自行设置


image.png


其中比较重要的配置有



  • 服务器名称

  • 服务器上允许的最大玩家数(上限为 32)

  • 用于授予管理员访问权限的密码

  • 普通玩家加入所需的密码


如果要使用管理员命令需要加上管理员密码,普通玩家加入密码暂时不推荐设置,因为可能会造成玩家进不来。


服务器配置生成也挺麻烦的,所以我简单的做了个生成网页。要修改哪个直接在网页上修改就行。配备了中文介绍。


都设置好了就可以点击下面的【生成配置文件】,然后复制下生成的wget这一行命令。


image.png


然后切回到SSH那边,黏贴执行即可,这样就会生成一个叫 PalWorldSettings.ini配置文件,这个时候就重新执行下脚本命令 ./PalServerInstall.sh ,调出命令窗口,选择4 就行,这样就会覆盖配置了。


修改之后不是立即生效的,要重启帕鲁的服务端才能生效,对应数字8


端口8211开放


到此还差最后一步,就是要开放8211端口,我们进入到腾讯云网页端,点击进入详情


image.png


切换到防火墙,配置两条,TCP、UDP端口8211开放即可。


image.png


到此就算搞定了服务端的搭建了,这时候复制下公网IP,一会要用到


登录游戏


游戏也是需要大家自己购买的,打开游戏后,会看到一个【加入多人游戏(专用服务器)】选项,点击这个


8b463bab9f2b026c77afaf711f79448.png


进来后看到底部这里了没,把你服务器公网的ip去替换下 :8211前面的ip数字即可
比如我的ip是:106.54.6.86,那我输入的就是 106.54.6.86:8211


image.png


总结


ok,到此就是我搭建幻兽帕鲁Palworld服务端的全部流程,这游戏还是挺有意思的,缺点是缝合怪,优点是缝的还不错,我昨天自己搭建完玩了2个小时,大部分在搭建我的房子,盖着停不下来哈哈,感觉可以盖个10层楼。


499f598cf68efdf9486e23424e65f44.png


别人盖的比我好看多了。


image.png


这游戏其实火起来还有一个梗:帕鲁大陆最不缺的就是帕鲁,你不干有的是帕鲁干。
图片


我体验了一下也发现很真实,在游戏里面和帕鲁交朋友哈哈哈,其实是在压榨它们,让它们帮我们干活,累倒了就换一个,帕鲁多的是不缺你一个。现实中我们不也是帕鲁吗,所以大家突然找到了共鸣。


各位上班的时候就是帕鲁,下班了在游戏里面压榨帕鲁。


作者:嘟嘟MD
来源:juejin.cn/post/7328621062727122944
收起阅读 »

大厂真实 Git 开发工作流程

记得之前也写过一篇类似的文章,来到我东后感觉不够详尽,很多流程还是太局限了。大厂的开发流程还是比较规范的。如果你来不及学习长篇大论的 git 文档,这篇文章够你入职时用一段时间了。 一、开发分支模型分类 目前所在部门使用是主要是四种:dev(开发)、test(...
继续阅读 »

记得之前也写过一篇类似的文章,来到我东后感觉不够详尽,很多流程还是太局限了。大厂的开发流程还是比较规范的。如果你来不及学习长篇大论的 git 文档,这篇文章够你入职时用一段时间了。


一、开发分支模型分类


目前所在部门使用是主要是四种:dev(开发)、test(测试)、uat(预发)、release(生产)



小公司可能就一个 dev、一个 master 就搞定了,测试都是开发人员自己来🤣。



二、开发主体流程



  1. 需求评审

  2. 开发排期

  3. 编码开发

  4. 冒烟测试(自检验)

  5. 冒烟通过,提交测试,合并代码到测试分支,部署测试环境

  6. 测试环境测试,开发修 bug

  7. 测试完成,提交预发,合并代码到预发分支,部署预发环境

  8. 预发环境测试,开发修 bug(修完的 bug 要重新走测试再走预发,这个下面会解释)

  9. 测试完成,产品验收

  10. 验收完成,提交生产,合并代码到生产分支,部署生产环境

  11. 生产运营(客户)验收

  12. 验收完成,结项


三、具体操作


1. 拉取代码


一般都会在本地默认创建一个 master 分支


git clone https://code.xxx.com/xxx/xxx.git

2. 初次开发需求前,要先拉取生产/预发分支,然后基于这个分支之上,创建自己的特性分支进行开发


git fetch origin release:release

git checkout release

git checkout -b feat-0131-jie

此时,在你本地已经有了一个 release 分支对应着远程仓库的 release 分支,还有一个内容基于 release 分支的特性分支,之后便可以在这个特性分支上进行需求开发了。


注意1:分支名称是有规范和含义的,不能乱取。

推荐格式:分支责任-需求日期/需求号-开发人姓名,一般按部门规范来,常见的有以下几种。


  - feat:新功能

- fix:修补bug

- doc:文档

- refactor:重构(即不是新增功能,也不是修改bug的代码变动)

- test:测试

- chore:构建过程或辅助工具的变动

注意2:为啥拉取的是生产/预发分支

之所以要拉取 release/uat 分支而不是拉取 dev/test,是因为后者可能包含着一些其他成员还未上线或者可能有 bug 的需求代码,这些代码没有通过验证,如果被你给拉取了,然后又基于此进行新的需求开发,那当你需求开发完成,而其他成员的需求还没上线,你将会把这些未验证的代码一起发送到 uat/release 上,导致一系列问题。


3. 需求开发完成,提交&合并代码


首先先在本地把新的改动提交,提交描述的格式可以参考着分支名的格式



  • 如果是新需求的提交,可以写成 "feat: 需求0131-新增账期"

  • 如果是 bug 修复,可以写成 "fix: 禅道3387-重复请求"


git add .

git commit -m "提交描述"

此时,本地当前分支已经记录了你的提交记录,接下来进行代码合并了


在代码合并之前,我们先要梳理一下我们应该如何对分支进行管理(非常重要!)


  1. 首先,我们需要认知到的是,每一个分支应该只对应一个功能,例如当我们开发需求 01 时,那么就创建一个 feat-01-jie 分支进行开发;开发需求 02 时,就另外创建一个 feat-02-jie 分支进行开发;修改生产环境的某个 bug 时,就创建 fix-jie-3378 进行开发,等等。


    这样做的目的是,能够把不同的功能/需求/修改分离开来。想象一下这样一个场景,如果有某些紧急的需求是需要提前上线的,而此时你的分支里既包含了这些紧急的需求,又包含了其他未开发好的需求,那么这两种需求就不能拆开来分别进行提测和上线了。


  2. 其次,在合并代码时,我们要将四种分支模型(dev、test、uat、release)作为参照物,而不是把关注点放在自己的分支上。比如我们要在 dev 上调试,那就需要把自己的分支合并到 dev 分支上;如果我们需要提测,则把自己的分支合并到 test 分支上,以此类推。


    即,我们要关注到,这四个环境的分支上,会有什么内容,会新增什么内容。切记不能反过来将这四个分支合并到自己的代码上!! 如果其他成员将自己的代码也提交到 dev 分支上,但是这个代码是没有通过验证的,此时你将 dev 往自己的分支上合,那之后的提测、上预发、生产则很大概率会出问题。所以一定要保持自己的分支是干净的!



接下来介绍合并代码的方式:


第一种:线上合并,也是推荐的规范操作

git push origin feat-0131-jie

先接着上面的提交步骤,将自己的分支推送到远程仓库。


然后在线上代码仓库中,申请将自己的分支合并到 xx 分支(具体是哪个分支就根据你当前的开发进度来,如 test),然后在线上解决冲突。如果有权限就自己通过了,如果没有就得找 mt 啥的


第二种,本地合并(前提你要有对应环境分支 push 的权限)

## 先切换到你要提交的环境分支上,如果本地还没有就先拉取下来
git fetch origin test:test

git checkout test

#
# 然后将自己的分支合并到环境分支上(在编辑器解决冲突)
git merge feat-0131-jie

#
# 最后将环境分支推送到远程仓库
git push origin test

## 先切换到你要提交的环境分支上,如果本地已有该分支,则需要先拉取最新代码
git checkout test

git pull origin test

#
# 然后将自己的分支合并到环境分支上(在编辑器解决冲突)
git merge feat-0131-jie

#
# 最后将环境分支推送到远程仓库
git push origin test

两种方式有何区别?为什么推荐第一种?

这是因为在团队协作开发的过程中,将合并操作限制在线上环境有以下几个好处:



  1. 避免本地合并冲突:如果多个开发人员同时在本地进行合并操作,并且对同一段代码进行了修改,可能会导致冲突。将合并操作集中在线上环境可以减少此类冲突的发生,因为不同开发人员的修改会先在线上进行合并,然后再通过更新拉取到本地。

  2. 更好的代码审查:将合并操作放在线上环境可以方便其他开发人员进行代码审查。其他人员可以在线上查看合并请求的代码变动、注释和讨论,并提供反馈和建议。这样可以确保代码的质量和可维护性。

  3. 提高可追溯性和可回滚性:将合并操作记录在线上可以更容易地进行版本控制和管理。如果出现问题或需要回滚到之前的版本,可以更轻松地找到相关的合并记录并进行处理。


当然,并非所有情况都适用于第一种方式。在某些特定情况下,例如个人项目或小团队内部开发,允许本地合并也是可以的。但在大多数团队协作的场景中,将合并操作集中在线上环境具有更多优势。


4. 验收完成,删除分支


当我们这一版的需求完成后,本地肯定已经留有很多分支了,这些分支对于之后的开发已经意义不大了,留下来只会看着一团糟。


git branch -d <分支名>

#
# 如果要强制删除分支(即使分支上有未合并的修改)
git branch -D <分支名>

四、一些小问题


1. 前面提到,预发环境修完的 bug 要重新走测试再走预发,为什么呢?


预生产环境是介于测试和生产环境之间的一个环境,它的目的是模拟生产环境并进行更真实的测试。
它是一个重要的测试环境,需要保持稳定和可靠。通过对修复的bug再次提交到测试环境测试,可以确保预生产环境中的软件版本是经过验证的,并且没有明显的问题。


当然,也不是非要这么做不可,紧急情况下,也可以选择直接发到预生产重新测试,只要你保证你的代码 99% 没问题了。


2. 代码合并错误,并且已经推送到远程分支,如何解决?


假设是在本地合并,本来要把特性分支合并到 uat 分支,结果不小心合到了 release 分支(绝对不是我自己的案例,绝对不是。。。虽然好在最后同事本地有我提交前的版本,事情就简单很多了)


首先切换到特性分支合并到的错误分支,比如是 release


git checkout release

然后查看最近的合并信息


git log --merges

撤销合并


git revert -m 1 <merge commit ID>


  • 这里的 merge commit ID 就是上一步查询出来的 ID 或者 ID 的前几个字符


最后,撤销远程仓库的推送


git push -f origin release


  • 这个命令会强制推送本地撤销合并后的 release 分支到远程仓库,覆盖掉远程仓库上的内容。(即,得通过一个新的提交来“撤销”上一次的提交,本质上是覆盖)


3. 当前分支有未提交的修改,但是暂时不想提交,想要切换到另一个分支该怎么做?


例如:你正在开发 B 需求,突然产品说 A 需求有点问题,让你赶紧改改,但是当前 B 需求还没开发完成,你又不想留下过多无用的提交记录,此时就可以按照下面这样做:


首先,可以将当前修改暂存起来,以便之后恢复


git stash

然后切换到目标分支,例如需求 A 所在分支


git checkout feat-a-jie

修改完 A 需求后,需要先切换回之前的分支,例如需求 B 所在分支


git checkout feat-b-jie

如果你不确定之前所在的分支名,可以使用以下命令列出暂存的修改以及它们所属的分支:


git stash list

最后从暂存中恢复之前的修改


git stash pop

此时你的工作区就恢复如初了!




喜欢本文的话,可以点赞收藏呀~😘


如果有疑问,欢迎评论区留言探讨~🤔


作者:JIE
来源:juejin.cn/post/7327863960008392738
收起阅读 »

《卖炭翁》致敬河北程序员,初读已解诗中意,再读却是诗中人!

起初他们追杀共产主义者的时候, 我没有说话 ——因为我不是共产主义者; 接着他们追杀犹太人的时候, 我没有说话 ——因为我不是犹太人; 后来他们追杀工会成员的时候, 我没有说话 ——因为我不是工会成员; 此后他们追杀天主教徒的时候, 我没有说话 ——因为我是新...
继续阅读 »

起初他们追杀共产主义者的时候,


我没有说话


——因为我不是共产主义者;


接着他们追杀犹太人的时候,


我没有说话


——因为我不是犹太人;


后来他们追杀工会成员的时候,


我没有说话


——因为我不是工会成员;


此后他们追杀天主教徒的时候,


我没有说话


——因为我是新教教徒;


最后他们奔我而来,


那时已经没有人能为我说话了。



这一首著名的《我没有说话》是德国神学家马丁・尼莫拉牧师的忏悔诗,尽管他写的是自己,但这首诗却振聋发聩,发人深省,其描述忽视与表面上自己无关的团体所造成的结果。该诗后来常被引用,作为对事不关己高高挂起的人的呼吁。


这首诗被镌刻在美国马萨诸塞州波士顿的新英格兰犹太人大屠杀纪念碑石碑上。


马丁・尼莫拉曾经生活在一个黑暗无光的时代,遭受过极权统治的迫害,这一经历对他来说,有着切肤之痛。


因为自己的惨痛经历,尼莫拉牧师认识到:在这个世界上,人与人的命运往往是休戚与共的,不坚持真理,不伸张正义,不维护公平,在邪恶面前只顾及自身的利益,对他人被冤屈被欺凌被迫害漠然置之,最终受到惩罚的是我们自己。


最近的事情大家也都晓得了,这件事让我们禁不住想起初中课本里的一篇课文:唐代大诗人白居易所创作的《卖炭翁》:



卖炭翁,伐薪烧炭南山中。

满面尘灰烟火色,两鬓苍苍十指黑。

卖炭得钱何所营?身上衣裳口中食。

可怜身上衣正单,心忧炭贱愿天寒。

夜来城外一尺雪,晓驾炭车辗冰辙。

牛困人饥日已高,市南门外泥中歇。

翩翩两骑来是谁?黄衣使者白衫儿。

手把文书口称敕,回车叱牛牵向北。

一车炭,千余斤,宫使驱将惜不得。

半匹红纱一丈绫,系向牛头充炭直。




白居易在《新乐府》中每首诗的题目下面都有一个序,说明这首诗的主题。


《卖炭翁》的序是“苦宫市也”,就是要反映宫市给人民造成的痛苦。唐代皇宫里需要物品,就派人去市场上拿,随便给点钱,实际上是公开掠夺。


唐德宗时用太监专门负责掠夺老百姓。白居易写作《新乐府》是在宫市为害最深的时候,他对宫市有十分的了解,对太监极度的痛恨,对人民又有深切的同情,所以才能写出这首感人至深的《卖炭翁》。


这首诗的意义,远不止于对宫市的揭露。诗人在卖炭翁这个典型形象上,概括了唐代劳动人民的辛酸和悲苦,在卖炭这一件小事上反映出了当时社会的黑暗和不平。读着这首诗,读者所看到的决不仅仅是卖炭翁一个人,透过他,还能看到有许许多多种田的、打渔的、织布以及编程的人出现在眼前。


他们虽然不是“两鬓苍苍十指黑”,但也各自带着劳苦生活的标记;他们虽然不会因为卖炭而受到损害,但也各自在田租或赋税的重压下流着辛酸和仇恨的泪水。《卖炭翁》这首诗不但在当时有积极意义,即使对于今天的读者也有一定的教育作用。


正道是:



初读已解诗中意,再读却是诗中人!



作者:刘悦的技术博客
来源:juejin.cn/post/7284468618019143695
收起阅读 »

支付系统的心脏:简洁而精妙的状态机设计与核心代码实现

本篇主要讲清楚什么是状态机,简洁的状态机对支付系统的重要性,状态机设计常见误区,以及如何设计出简洁而精妙的状态机,核心的状态机代码实现等。 我前段时间面试一个工作过4年的同学竟然没有听过状态机。假如你没有听过状态机,或者你听过但没有写过,或者你是使用if el...
继续阅读 »



本篇主要讲清楚什么是状态机,简洁的状态机对支付系统的重要性,状态机设计常见误区,以及如何设计出简洁而精妙的状态机,核心的状态机代码实现等。


我前段时间面试一个工作过4年的同学竟然没有听过状态机。假如你没有听过状态机,或者你听过但没有写过,或者你是使用if else 或switch case来写状态机的代码实现,建议花点时间看看,一定会有不一样的收获。


1. 前言


在线支付系统作为当今数字经济的基石,每年支撑几十万亿的交易规模,其稳定性至关重要。在这背后,是一种被誉为支付系统“心脏”的技术——状态机。本文将一步步介绍状态机的概念、其在支付系统中的重要性、设计原则、常见误区、最佳实践,以及一个实际的Java代码实现。


2. 什么是状态机


状态机,也称为有限状态机(FSM, Finite State Machine),是一种行为模型,由一组定义良好的状态、状态之间的转换规则和一个初始状态组成。它根据当前的状态和输入的事件,从一个状态转移到另一个状态。


下图就是在《支付交易的三重奏:收单、结算与拒付在支付系统中的协奏曲》中提到的交易单的状态机。



从图中可以看到,一共4个状态,每个状态之间的转换由指定的事件触发。


3. 状态机对支付系统的重要性


想像一下,如果没有状态机,支付系统如何知道你的订单已经支付成功了呢?如果你的订单已经被一个线程更新为“成功”,另一个线程又更新成“失败”,你会不会跳起来?


在支付系统中,状态机管理着每笔交易的生命周期,从初始化到完成或失败。它确保交易在正确的时间点,以正确的顺序流转到正确的状态。这不仅提高了交易处理的效率和一致性,还增强了系统的鲁棒性,使其能够有效处理异常和错误,确保支付流程的顺畅。


4. 状态机设计基本原则


无论是设计支付类的系统,还是电商类的系统,在设计状态机时,都建议遵循以下原则:


明确性: 状态和转换必须清晰定义,避免含糊不清的状态。


完备性: 为所有可能的事件-状态组合定义转换逻辑。


可预测性: 系统应根据当前状态和给定事件可预测地响应。


最小化: 状态数应保持最小,避免不必要的复杂性。


5. 状态机常见设计误区


工作多年,见过很多设计得不好的状态机,导致运维特别麻烦,还容易出故障,总结出来一共有这么几条:


过度设计: 引入不必要的状态和复杂性,使系统难以理解和维护。


不完备的处理: 未能处理所有可能的状态转换,导致系统行为不确定。


硬编码逻辑: 过多的硬编码转换逻辑,使系统不具备灵活性和可扩展性。


举一个例子感受一下。下面是亲眼见过的一个交易单的状态机设计,而且一眼看过去,好像除了复杂一点,整体还是合理的,比如初始化,受理成功就到ACCEPT,然后到PAYING,如果直接成功就到PAIED,退款成功就到REFUND。



我说说这个状态机有几个不合理的地方:



  1. 过于复杂。一些不必要的状态可以去掉,比如ACCEPT没有存在的必要。

  2. 职责不明确。支付单就只管支付,到PAIED就支付成功,就是终态不再改变。REFUND应该由退款单来负责处理,否则部分退款怎么办。


我们需要的改造方案:



  1. 精简掉不必要的状态,比如ACCEPT。

  2. 把一些退款、请款等单据单独抽出去,这样状态机虽然多了,但是架构更加清晰合理。


主单:



普通支付单:



预授权单:



请款单:



退款单:



6. 状态机设计的最佳实践


在代码实现层面,需要做到以下几点:


分离状态和处理逻辑:使用状态模式,将每个状态的行为封装在各自的类中。


使用事件驱动模型:通过事件来触发状态转换,而不是直接调用状态方法。


确保可追踪性:状态转换应该能被记录和追踪,以便于故障排查和审计。


具体的实现参考第7部分的“JAVA版本状态机核心代码实现”。


7. 常见代码实现误区


经常看到工作几年的同学实现状态机时,仍然使用if else或switch case来写。这是不对的,会让实现变得复杂,且容易出现问题。


甚至直接在订单的领域模型里面使用String来定义,而不是把状态模式封装单独的类。


还有就是直接调用领域模型更新状态,而不是通过事件来驱动。


错误的代码示例:


if (status.equals("PAYING") {
status = "SUCCESS";
} else if (...) {
...
}

或者:


class OrderDomainService {
public void notify(PaymentNotifyMessage message) {
PaymentModel paymentModel = loadPaymentModel(message.getPaymentId());
// 直接设置状态
paymentModel.setStatus(PaymentStatus.valueOf(message.status);
// 其它业务处理
... ...
}
}

或者:


public void transition(Event event) {
switch (currentState) {
case INIT:
if (event == Event.PAYING) {
currentState = State.PAYING;
} else if (event == Event.SUCESS) {
currentState = State.SUCESS;
} else if (event == Event.FAIL) {
currentState = State.FAIL;
}
break;
// Add other case statements for different states and events
}
}

8. JAVA版本状态机核心代码实现


使用Java实现一个简单的状态机,我们将采用枚举来定义状态和事件,以及一个状态机类来管理状态转换。


定义状态基类


/**
* 状态基类
*/

public interface BaseStatus {
}

定义事件基类


/**
* 事件基类
*/

public interface BaseEvent {
}

定义“状态-事件对”,指定的状态只能接受指定的事件


/**
* 状态事件对,指定的状态只能接受指定的事件
*/

public class StatusEventPairextends BaseStatus, E extends BaseEvent> {
/**
* 指定的状态
*/

private final S status;
/**
* 可接受的事件
*/

private final E event;

public StatusEventPair(S status, E event) {
this.status = status;
this.event = event;
}

@Override
public boolean equals(Object obj) {
if (obj instanceof StatusEventPair) {
StatusEventPair other = (StatusEventPair)obj;
return this.status.equals(other.status) && this.event.equals(other.event);
}
return false;
}

@Override
public int hashCode() {
// 这里使用的是google的guava包。com.google.common.base.Objects
return Objects.hashCode(status, event);
}
}

定义状态机


/**
* 状态机
*/

public class StateMachineextends BaseStatus, E extends BaseEvent> {
private final Map, S> statusEventMap = new HashMap<>();

/**
* 只接受指定的当前状态下,指定的事件触发,可以到达的指定目标状态
*/

public void accept(S sourceStatus, E event, S targetStatus) {
statusEventMap.put(new StatusEventPair<>(sourceStatus, event), targetStatus);
}

/**
* 通过源状态和事件,获取目标状态
*/

public S getTargetStatus(S sourceStatus, E event) {
return statusEventMap.get(new StatusEventPair<>(sourceStatus, event));
}
}

定义支付的状态机。注:支付、退款等不同的业务状态机是独立的


/**
* 支付状态机
*/

public enum PaymentStatus implements BaseStatus {

INIT("INIT", "初始化"),
PAYING("PAYING", "支付中"),
PAID("PAID", "支付成功"),
FAILED("FAILED", "支付失败"),
;

// 支付状态机内容
private static final StateMachine STATE_MACHINE = new StateMachine<>();
static {
// 初始状态
STATE_MACHINE.accept(null, PaymentEvent.PAY_CREATE, INIT);
// 支付中
STATE_MACHINE.accept(INIT, PaymentEvent.PAY_PROCESS, PAYING);
// 支付成功
STATE_MACHINE.accept(PAYING, PaymentEvent.PAY_SUCCESS, PAID);
// 支付失败
STATE_MACHINE.accept(PAYING, PaymentEvent.PAY_FAIL, FAILED);
}

// 状态
private final String status;
// 描述
private final String description;

PaymentStatus(String status, String description) {
this.status = status;
this.description = description;
}

/**
* 通过源状态和事件类型获取目标状态
*/

public static PaymentStatus getTargetStatus(PaymentStatus sourceStatus, PaymentEvent event) {
return STATE_MACHINE.getTargetStatus(sourceStatus, event);
}
}

定义支付事件。注:支付、退款等不同业务的事件是不一样的


/**
* 支付事件
*/

public enum PaymentEvent implements BaseEvent {
// 支付创建
PAY_CREATE("PAY_CREATE", "支付创建"),
// 支付中
PAY_PROCESS("PAY_PROCESS", "支付中"),
// 支付成功
PAY_SUCCESS("PAY_SUCCESS", "支付成功"),
// 支付失败
PAY_FAIL("PAY_FAIL", "支付失败");

/**
* 事件
*/

private String event;
/**
* 事件描述
*/

private String description;

PaymentEvent(String event, String description) {
this.event = event;
this.description = description;
}
}

在支付单模型中声明状态和根据事件推进状态的方法:


/**
* 支付单模型
*/

public class PaymentModel {
/**
* 其它所有字段省略
*/


// 上次状态
private PaymentStatus lastStatus;
// 当前状态
private PaymentStatus currentStatus;


/**
* 根据事件推进状态
*/

public void transferStatusByEvent(PaymentEvent event) {
// 根据当前状态和事件,去获取目标状态
PaymentStatus targetStatus = PaymentStatus.getTargetStatus(currentStatus, event);
// 如果目标状态不为空,说明是可以推进的
if (targetStatus != null) {
lastStatus = currentStatus;
currentStatus = targetStatus;
} else {
// 目标状态为空,说明是非法推进,进入异常处理,这里只是抛出去,由调用者去具体处理
throw new StateMachineException(currentStatus, event, "状态转换失败");
}
}
}

代码注释已经写得很清楚,其中StateMachineException是自定义,不想定义的话,直接使用RuntimeException也是可以的。


在支付业务代码中的使用:只需要paymentModel.transferStatusByEvent(PaymentEvent.valueOf(message.getEvent()))


/**
* 支付领域域服务
*/

public class PaymentDomainServiceImpl implements PaymentDomainService {

/**
* 支付结果通知
*/

public void notify(PaymentNotifyMessage message) {
PaymentModel paymentModel = loadPaymentModel(message.getPaymentId());
try {

// 状态推进
paymentModel.transferStatusByEvent(PaymentEvent.valueOf(message.getEvent()));
savePaymentModel(paymentModel);
// 其它业务处理
... ...
} catch (StateMachineException e) {
// 异常处理
... ...
} catch (Exception e) {
// 异常处理
... ...
}
}
}

上面的代码只需要加完善异常处理,优化一下注释,就可以直接用起来。


好处:



  1. 定义了明确的状态、事件。

  2. 状态机的推进,只能通过“当前状态、事件、目标状态”来推进,不能通过if else 或case switch来直接写。比如:STATE_MACHINE.accept(INIT, PaymentEvent.PAY_PROCESS, PAYING);

  3. 避免终态变更。比如线上碰到if else写状态机,渠道异步通知比同步返回还快,异步通知回来把订单更新为“PAIED”,然后同步返回的代码把单据重新推进到PAYING。


9. 并发更新问题


留言中“月朦胧”同学提到:“状态机领域模型同时被两个线程操作怎么避免状态幂等问题?”


这是一个好问题。在分布式场景下,这种情况太过于常见。同一机器有可能多个线程处理同一笔业务,不同机器也可能处理同一笔业务。


业内通常的做法是设计良好的状态机 + 数据库锁 + 数据版本号解决。



简要说明:



  1. 状态机一定要设计好,只有特定的原始状态 + 特定的事件才可以推进到指定的状态。比如 INIT + 支付成功才能推进到sucess。

  2. 更新数据库之前,先使用select for update进行锁行记录,同时在更新时判断版本号是否是之前取出来的版本号,更新成功就结束,更新失败就组成消息发到消息队列,后面再消费。

  3. 通过补偿机制兜底,比如查询补单。

  4. 通过上述三个步骤,正常情况下,最终的数据状态一定是正确的。除非是某个系统有异常,比如外部渠道开始返回支付成功,然后又返回支付失败,说明依赖的外部系统已经异常,这样只能进人工差错处理流程。


10. 结束语


状态机在支付系统中扮演着不可或缺的角色。一个专业、精妙的状态机设计能够确保支付流程的稳定性和安全性。本文提供的设计原则、常见误区警示和最佳实践,旨在帮助开发者构建出更加健壮和高效的支付系统。而随附的Java代码则为实现这一关键组件提供了一个清晰、灵活的起点。希望这些内容能够对你有用。



作者:隐墨星辰
来源:juejin.cn/post/7321569896453521419
收起阅读 »

转全栈之路,会比想象中的艰难

背景 我于22年校招入职字节安全方向大前端部门,支持公司安全Tob产品的前端开发工作。今年8月,因为组织架构调整,很多同事都直接划入了业务部门,我也和另一名北京的同事互换了业务,划入业务部门。 在新部门工作2-3个月,因为种种原因,工作体验上的差别大到像是换了...
继续阅读 »

背景


我于22年校招入职字节安全方向大前端部门,支持公司安全Tob产品的前端开发工作。今年8月,因为组织架构调整,很多同事都直接划入了业务部门,我也和另一名北京的同事互换了业务,划入业务部门。


在新部门工作2-3个月,因为种种原因,工作体验上的差别大到像是换了一家公司,也很想记录一下到底有什么不同。


大前端部门业务部门
组织人数近30人,纯前端方向近40人,分为不同方向,前端背景1人
工作模式由于同事都在天南海北,需要通过视频会议进行沟通纯下线沟通,所有同事都base深圳
沟通效率较低,每次沟通都需要调试设备,共享屏幕等,并且见不到面很多信息会失真高,直接面谈,肢体语言这些信息不会丢失
工作节奏有排期压力,有承诺客户交付时间。如果排期不合理会很疲惫。没有排期压力,前端工作量相比之前轻松
设计资源有专门的UED团队出图,前端不需要思考如何进行交互,这部分工作由设计师承担无设计资源,交互的好坏完全取决于研发的审美水平与自我要求
前端技术建设每个季度会有横向建设,有组件库共建等机会,前端技术相对先进部门内部无前端建设,依赖公司基建与之前经验
同事组成深圳base全员年轻化,校招生为主,因为年龄相同且技术方向相同,天然就有很多话题资深员工多,校招生占比很低,且划分不同方向,一般自己方向的人自己内部沟通较多
和+1的关系base不同,沟通频率很低。因为主要是做业务方的需求,沟通内容主要在支持工作的进展上。base相同,沟通频率比以前高5-10倍,除同步开发进展,还会针对产品迭代方向,用户体验等问题进行沟通
技术成长受限于部门性质以及绩效评价体系,员工需要在前端技术领域保持专业且高效,但工作一定年限后有挑战性的业务需求不足,容易遇到职业发展瓶颈。因为前端人数多,所以存在横向建设的空间,可以共建组件库等基建,非常自然的会接触这些需求。角色划分不明确,前后端可以相互支援彼此,大家摘掉前后端的标签,回归通用开发的角色。技术成长依赖自驱力与公司技术水平。研发人少,没有内部的横向建设机会。

纠结


为什么要转全栈?究竟有什么收益?我会在心里时不时问自己这个问题。任何一门技能,从入门到精通,都需要很多时间的学习与实践,在初期都会经历一段相当痛苦的时光。除了学习不轻松,能否创造出更大的价值也是一个问号。


但这次转全栈,有天时地利人和的作用,总结下来就是:



  1. Leader支持:和Leader沟通过,Leader觉得在我们团队多做多学对个人,对团队都有益处,欢迎我大胆尝试

  2. 后端同学支持:我们团队的细分项目多,后端工作饱和,可以分一个相对独立的活给我

  3. 全栈化背景:原先的大前端部门已经有部分前端转为全栈开发职能,部门层面鼓励全栈化发展

  4. 需求清晰:有些开发之所以忙碌是因为开会和对齐耗时太多。但是我目前拿到的prd都非常清晰,拿到就能直接开发,对齐扯皮的时间几乎不计,我只需要完成开发工作即可。这节约了我大量时间成本。想到之前经常是一天开个1-2小时会,搞得很疲惫。

  5. 工作熟练:从实习开始算起,我已经有2年多的开发经验,可以在预期时间内完成需求开发和bugfix,因此安全的预留时间精力转全栈。


其实不仅仅是我,和很多做前端的同事、朋友也都聊过,其实内心各有各的纠结。基本上大家的内心想法就是想在有限的条件下学习后端,并在有限的条件下承担一部分后端开发。


想学后端的原因:



  1. 纯属好奇心,想研究一下后端技术栈

  2. 前端作为最终的执行角色,话语权低

  3. 业务参与度低,可以半游离业务的存在,较边缘化。未来如果希望成长为管理,难以做业务管理,只能做技术管理,想象空间天花板就是成为管理一批前端的技术管理。

  4. 工作遇到天花板,想多了解一下其他的内容


想在有限条件下学习后端的原因:



  1. 工作比较忙碌,没那么多时间学习

  2. 学习一门技能要算ROI,学太多了如果既不能升职也不能加薪就没有意义

  3. 不确定市场对于全栈人才的反应,不想all in


想承担一部分后端开发的原因:



  1. 学习任何一门技能只有理论没有实践遗忘速度最快,马上就会回归到学习之前

  2. 掌握后端技能但没有企业级实战经验,说服力很弱


不想学习后端的原因:



  1. 国内市场上的全栈岗位数量稀少,如果后端岗位有10个,前端岗位有3个,那么可能就只有1个全栈岗位

  2. 普通前后端开发薪酬基本上没有区别,未来谁更好晋升在当前的经济背景也难说

  3. 大概率前端依然是自己的职业发展主线,学多一门技能可能会分摊本可以提升前端能力的时间精力

  4. 做舒适圈里面的事情很舒服,谁知道多做会不会有好处


我就是在这种纠结中一路学过来,从8月开始,痛苦且挣扎,不过到目前为止还可以接受。学到现在甚至已经有点麻木。但我也确实不知道继续在前端领域还能专精什么技能,现有的业务没有那么大的挑战性让我快速成长,所以想跳脱出来看看更大的世界。


学习路线


曲线学习


如果说做前端开发我是如鱼得水,那做后端开发就是经常呛到水。


记得我刚开始做前端实习的时候,真心感到前端知识好像黑洞,永远也学不完。由此非常佩服之前的同事,怎么把这些代码写出来的,有些代码后面一看写的还不错,甚至可能会感觉脊背发凉,是自己太弱还是自己太强?


在实习的时候,我的学习曲线可以说是一个向外扩散的圆。比如我第一次接触webpack的时候,根本不了解这是什么工具,之前一直在用jQuery写项目,所有的js都是明文写好,然后通过script引入到html中。所以一开始我会去查这个webpack到底是什么内容,但脑海中对他的印象还是非常模糊。接着我又因为webpack了解到了babel,css-loader这些概念,又去学习。又发现这需要利用到node,又去学习了《深入浅出node.js》。再后来又了解到了sourcemap等概念。直到正式加入字节半年后,我自己配了一次webpack,并且阅读了他的源码。进行了debug,进行了一次webpack插件开发的分享,才有信心说自己是真的弄明白了。不过这个弄明白,也仅限于排查bug,配项目,进行plugin和loader的开发,如果遇到更难的领域,那又将解锁一块黑洞。


怎么学


学习后端,要学的内容也一点都不少,作为新人会遇到非常多的问题。



  1. 怎么学 - 是死皮赖脸的逮住后端同学使劲问,还是多自己研究研究?遇到所有同事都不会的问题怎么处理?

  2. 学到什么程度 - 究竟要学到怎样的程度才能进入项目开发,而不犯下一些非常愚蠢的问题呢?

  3. 学习顺序 - 最简单的办法就是去看项目,看到不懂的分析一下这是什么模块的,看看要不要系统性的去了解。


我比较喜欢一开始就系统性的学,学完后再查缺补漏,再开启第二轮学习。


比如Go,官网就有很详细的文档,但未必适合新人去学。我跟着官网学了一阵子之后跑b站找视频学习了。然后又Google了一些资料,大致讲了一下反射、切片的原理,以及一些错误用法。学习Go大概用了2-3周。刚学完直接去看项目还是会觉得非常不适应,需要不断的让自己去阅读项目代码,找到Go的那种感觉。


然后需要学习很多公司内部的基建



  • 微服务架构 - 公司内部所有服务都是微服务架构,需要了解服务发现、服务治理、观测、鉴权这些基本概念以及大致的原理。为了在本地开发环境使用微服务,还需要在本地安装doas,用来获取psm的token。

  • RDS - 公司内的项目分为了各种环境,非常复杂。可以自己先创建一个MySQL服务自测,看看公司的云平台提供了哪些能力。

  • Redis - 大致了解即可,简单用不难

  • RPC - 微服务通过RPC传递,RPC协议通过IDL来定义接口传输格式,像字节会在api管理平台做封装。你定义好的IDL可以直接生成一个gopkg上传到内部镜像,然后其他用户直接go get这个库就能调用你的服务。但如果你是node服务,就可以在本地通过字节云基建的工具库自动生成代码。

  • Gorm - 所有的MySQL最终如果通过go程序调用,都需要经过gorm的封装,来避免一些安全问题。同时也可以规避一些低级错误。还需要了解gen怎么使用,将MySQL库的定义自动生成为orm代码。


还要好好学习一下MySQL的用法,这边花了一周看完了《MySQL必知必会》,然后去leetcode刷题。国庆节刷了大概80道MySQL的题目,很爽。从简单的查询,到连接、子查询、分组、聚合,再到比较复杂的窗口函数、CTE全刷了个遍,刷上瘾了。


接着就可以去看项目代码了,这一部分还是蛮折腾的,对于新人来说。本身阅读别人的代码,对于很多开发者来说就是一件痛苦的事情,何况是去阅读自己不熟悉的语言的别人的代码。


我最近接手的一个半废弃项目,就很离谱。开发者有的已经离职了,提交记录是三四年前的。PRD也找不全,到今天权限还没拿齐,明天再找人问问。这边可能是真的上下文就是要丢失的,没法找了。只能自己创建一个新的文档,把相关重点补充一下。


明天找一下这个项目的用户,演示一下怎么使用,然后根据对用法的理解进行开发……


收获


新鲜感


一直写前端真的有点腻,虽然现在技术还在迭代,但也万变不离其宗。而且真的是有点过分内卷了,像一个打包工具从webpack -> esbuild -> vite -> turbopack -> rspack。不可否认的是这些开发者的努力,为前端生态的繁荣做出了贡献。但对于很多业务来说,其实并没有太大的性能问题,对于这部分项目来说升级的收益很小。比如云服务的控制台,基本都是微前端架构,每个前端项目都非常小,就算用webpack热更新也不会慢。而且webpack使用下来是最稳定的,我现在的项目用的是vite,会存在样式引入顺序的问题,导致开发环境和生产环境的页面区别。


后端技术栈不管好还是不好,反正对我来说是很新鲜的。虽然我之前Python、Go也都用过,也用Python写出了完整的项目,但论企业级开发这算第一次~各方面都更正规


Go写起来很舒服,虽然写同样的需求代码量比TypeScript多一堆……习惯之后还是可以感受到Go的简单与安心。Go打包就没那么多事,你本地怎么跑服务器那边就怎么跑,不像前端可能碰到一堆兼容性问题。


真的有学到


我前几个月买了掘金大佬神说要有光的小课《Nest 通关秘籍》,据我了解我的几个同事也买了。不过我没坚持下来,因为工作上实在是没有使用到Nest的机会。我无法接受学了两三个月却无法在工作里做出产出的感觉。


但这一次学了可以立马去用,可以在工作中得到检验,可以接受用户的检验。我就会得到价值感与成就感。


而且字节的Go基建在我认知里很牛叉,一家以Go为主的大厂,养得起很多做基建的人。比如张金柱Gorm的作者,竟然就在字节,我前几天知道感觉牛人竟然……


Go的学习资料也非常多,还有很多实战的,真的像突然打开了新世界的大门~


与业务更近,以及更平和的心态


如果我没有学后端,会在“前端已死”的氛围里胡思乱想,忽略了前端的业务价值,前端依旧是很重要的岗位。让后端来写前端不是不行,但只有分工才能达到最高的效率。对于一个正常的业务团队来说,也完全没必要让后端去硬写前端,好几个后端配一个前端,也不是什么事。


就我目前的工作经验来看,后端可以和业务的使用者更近的对接。我们这里的后端开发会和非常多用户对接需求,了解他们的真实使用场景,思考他们背后的需求,可能还能弥补一下产品思考上的不周。和用户对齐数据传递、转换、存储、查询、以及需要不需要定时任务等等,这些后端会去负责。


而前端负责最终的交互,基本可以不用碰到使用者,基本上只需要根据后端给的接口文档,调用接口把内容渲染在表格上即可。碰到用户提反馈一般在于,加载慢(往往是数据请求很慢,但是用户会觉得是前端的问题)、交互不满意(交互美不美真的是一个很难量化的问题,按理说这属于UI的绩效)、数据请求失败(前后端接口对齐虽然体验越来越好,但是开发阶段经常改动还是免不了,最后导致前后端没有同步)。


之前开周会的时候,我基本上说不上什么话。一个是刚转岗,确实不熟。另一个是前端半游离于业务的状态,单纯的把接口内容渲染出来也很难有什么思考,导致开会比较尴尬。基本是后端在谈解决了什么oncall,解决了什么技术问题,有什么业务建设的思考等等。


这次看了别人代码之后非常期盼未来能独立owner一个方向,享受闭环一个小功能的乐趣。


职业安全感


我学的这项技能能够立马投入到工作中进行自我检验,因此我相信自己学的是“有效技能”。我理解的无效技能指学了用不上,然后忘了,花了很多时间精力最后不升职不加薪。之前看李运华大佬的网课《大厂晋升指南》里面有提到,有人花了半年把编译原理这个看似非常重要的计算机基础课学的很扎实,但因为业务不需要,不产生业务价值,也不可能获得提拔的机会。


其实内部全栈化我的理解,还有一个原因,那就是灵活调度。现在这个背景下,老板更希望用有限的人力去做更多事情。有些业务前端过剩了,但是缺后端,这个时候如果直接去招后端,一方面增加成本,再就是没有解决剩的前端,反之也是。在盘点hc的时候就容易出现调整。


多学一些有效技能,提高解决问题的深度、广度,让自己更值钱。我想不管是什么职能,最终都要回归到为业务服务的目标上。


End


写到这里,我依旧在转全栈的路上,只是想给自己一个阶段性的答案。


脱离舒适圈,进入拉伸区,需要付出,需要勇气,也需要把握机遇。给自己多一种可能,去做,去挑战自己不会的。我相信他山之石可以攻玉,越往深处走,就越能触类旁通。


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

加密的手机号,如何模糊查询?

前言 前几天,知识星球中有位小伙伴,问了我一个问题:加密的手机号如何模糊查询? 我们都知道,在做系统设计时,考虑到系统的安全性,需要对用户的一些个人隐私信息,比如:登录密码、身-份-证号、银彳亍卡号、手机号等,做加密处理,防止用户的个人信息被泄露。 很早之前,...
继续阅读 »

前言


前几天,知识星球中有位小伙伴,问了我一个问题:加密的手机号如何模糊查询?


我们都知道,在做系统设计时,考虑到系统的安全性,需要对用户的一些个人隐私信息,比如:登录密码、身-份-证号、银彳亍卡号、手机号等,做加密处理,防止用户的个人信息被泄露。


很早之前,CSDN遭遇了SQL注入,导致了600多万条明文保存的用户信息被泄。


因此,我们在做系统设计的时候,要考虑要把用户的隐私信息加密保存。


常见的对称加密算法有 AES、SM4、ChaCha20、3DES、DES、Blowfish、IDEA、RC5、RC6、Camellia等。


目前国际主流的对称加密算法是AES,国内主推的则是SM4


无论是用哪种算法,加密前的字符串,和加密后的字符串,差别还是比较大的。


比如加密前的字符串:苏三说技术,使用密钥:123,生成加密后的字符串为:U2FsdGVkX1+q7g9npbydGL1HXzaZZ6uYYtXyug83jHA=


如何对加密后的字符串做模糊查询呢?


比如:假设查询苏三关键字,加密后的字符串是:U2FsdGVkX19eCv+xt2WkQb5auYo0ckyw


上面生成的两个加密字符串差异看起来比较大,根本没办法直接通过SQL语句中的like关键字模糊查询。


那我们该怎么实现加密的手机号的模糊查询功能呢?


1 一次加载到内存


实现这个功能,我们第一个想到的办法可能是:把个人隐私数据一次性加载到内存中缓存起来,然后在内存中先解密,然后在代码中实现模糊搜索的功能。


图片这样做的好处是:实现起来比较简单,成本非常低。


但带来的问题是:如果个人隐私数据非常多的话,应用服务器的内存不一定够用,可能会出现OOM问题。


还有另外一个问题是:数据一致性问题。


如果用户修改了手机号,数据库更新成功了,需要同步更新内存中的缓存,否则用户查询的结果可能会跟实际情况不一致。


比如:数据库更新成功了,内存中的缓存更新失败了。


或者你的应用,部署了多个服务器节点,有一部分内存缓存更新成功了,另外一部分刚好在重启,导致更新失败了。


该方案不仅可能会导致应用服务器出现OOM问题,也可能会导致系统的复杂度提升许多,总体来说,有点得不偿失。


2 使用数据库函数


既然数据库中保存的是加密后的字符串,还有一种方案是使用数据库的函数解密。


我们可以使用MySQL的DES_ENCRYPT函数加密,使用DES_DECRYPT函数解密:


SELECT 
DES_DECRYPT('U2FsdGVkX1+q7g9npbydGL1HXzaZZ6uYYtXyug83jHA=''123')


应用系统重所有的用户隐私信息的加解密都在MySQL层实现,不存在加解密不一致的情况。


该方案中保存数据时,只对单个用户的数据进行操作,数据量比较小,性能还好。


但模糊查询数据时,每一次都需要通过DES_DECRYPT函数,把数据库中用户某个隐私信息字段的所有数据都解密了,然后再通过解密后的数据,做模糊查询。


如果该字段的数据量非常大,这样每次查询的性能会非常差。


3 分段保存


我们可以将一个完整的字符串,拆分成多个小的字符串。


以手机号为例:18200256007,按每3位为一组,进行拆分,拆分后的字符串为:182,820,200,002,025,256,560,600,007,这9组数据。


然后建一张表:


CREATE TABLE `encrypt_value_mapping` (
  `id` bigint NOT NULL COMMENT '系统编号',
  `ref_id` bigint NOT NULL COMMENT '关联系统编号',
  `encrypt_value` varchar(255NOT NULL COMMENT '加密后的字符串'
ENGINE=InnoDB  CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='分段加密映射表'

这张表有三个字段:



  • id:系统编号。

  • ref_id:主业务表的系统编号,比如用户表的系统编号。

  • encrypt_value:拆分后的加密字符串。


用户在写入手机号的时候,同步把拆分之后的手机号分组数据,也一起写入,可以保证在同一个事务当中,保证数据的一致性。


如果要模糊查询手机号,可以直接通过encrypt_value_mapping的encrypt_value模糊查询出用户表的ref_id,再通过ref_id查询用户信息。


具体sql如下:


select s2.id,s2.name,s2.phone 
from encrypt_value_mapping s1
inner join `user` s2 on s1.ref_id=s2.id
where s1.encrypt_value = 'U2FsdGVkX19Se8cEpSLVGTkLw/yiNhcB'
limit 0,20;

这样就能轻松的通过模糊查询,搜索出我们想要的手机号了。


注意这里的encrypt_value用的等于号,由于是等值查询,效率比较高。


注意:这里通过sql语句查询出来的手机号是加密的,在接口返回给前端之前,需要在代码中统一做解密处理。


为了安全性,还可以将加密后的明文密码,用*号增加一些干扰项,防止手机号被泄露,最后展示给用户的内容,可以显示成这样的:182***07


4 其他的模糊查询


如果除了用户手机号,还有其他的用户隐私字段需要模糊查询的场景,该怎么办?


我们可以将encrypt_value_mapping表扩展一下,增加一个type字段。


该字段表示数据的类型,比如:1.手机号 2.身-份-证 3.银彳亍卡号等。


这样如果有身-份-证和银彳亍卡号模块查询的业务场景,我们可以通过type字段做区分,也可以使用这套方案,将数据写入到encrypt_value_mapping表,最后根据不同的type查询出不同的分组数据。


如果业务表中的数据量少,这套方案是可以满足需求的。


但如果业务表中的数据量很大,一个手机号就需要保存9条数据,一个身-份-证或者银彳亍卡号也需要保存很多条数据,这样会导致encrypt_value_mapping表的数据急剧增加,可能会导致这张表非常大。


最后的后果是非常影响查询性能。


那么,这种情况该怎么办呢?
最近我建了新的技术交流群,打算将它打造成高质量的活跃群,欢迎小伙伴们加入。


我以往的技术群里技术氛围非常不错,大佬很多。


image.png


加微信:su_san_java,备注:加群,即可加入该群。


5 增加模糊查询字段


如果数据量多的情况下,将所有用户隐私信息字段,分组之后,都集中到一张表中,确实非常影响查询的性能。


那么,该如何优化呢?


答:我们可以增加模糊查询字段。


还是以手机模糊查询为例。


我们可以在用户表中,在手机号旁边,增加一个encrypt_phone字段。


CREATE TABLE `user` (
  `id` int NOT NULL,
  `code` varchar(20)  NOT NULL,
  `age` int NOT NULL DEFAULT '0',
  `name` varchar(30NOT NULL,
  `height` int NOT NULL DEFAULT '0',
  `address` varchar(30)  DEFAULT NULL,
  `phone` varchar(11DEFAULT NULL,
  `encrypt_phone` varchar(255)  DEFAULT NULL,
  PRIMARY KEY (`id`)
ENGINE=InnoDB CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='用户表'

然后我们在保存数据的时候,将分组之后的数据拼接起来。


还是以手机号为例:


18200256007,按每3位为一组,进行拆分,拆分后的字符串为:182,820,200,002,025,256,560,600,007,这9组数据。


分组之后,加密之后,用逗号分割之后拼接成这样的数据:,U2FsdGVkX19Se8cEpSLVGTkLw/yiNhcB,U2FsdGVkX1+qysCDyVMm/aYXMRpCEmBD,U2FsdGVkX19oXuv8m4ZAjz+AGhfXlsQk,U2FsdGVkX19VFs60R26BLFzv5nDZX40U,U2FsdGVkX19XPO0by9pVw4GKnGI3Z5Zs,U2FsdGVkX1/FIIaYpHlIlrngIYEnuwlM,U2FsdGVkX19s6WTtqngdAM9sgo5xKvld,U2FsdGVkX19PmLyjtuOpsMYKe2pmf+XW,U2FsdGVkX1+cJ/qussMgdPQq3WGdp16Q。


以后可以直接通过sql模糊查询字段encrypt_phone了:


select id,name,phone
from user where encrypt_phone like '%U2FsdGVkX19Se8cEpSLVGTkLw/yiNhcB%'
limit 0,20;

注意这里的encrypt_value用的like


这里为什么要用逗号分割呢?


答:是为了防止直接字符串拼接,在极端情况下,两个分组的数据,原本都不满足模糊搜索条件,但拼接在一起,却有一部分满足条件的情况发生。


当然你也可以根据实际情况,将逗号改成其他的特殊字符。


此外,其他的用户隐私字段,如果要实现模糊查询功能,也可以使用类似的方案。


最后说一句,虽说本文介绍了多种加密手机号实现模糊查询功能的方案,但我们要根据实际业务场景来选择,没有最好的方案,只有最合适的。


作者:苏三说技术
来源:juejin.cn/post/7288963208408563773
收起阅读 »

为什么说程序员到国企就废了?

事实上,单纯技术上来说,确实程序员进国企就废了。虽然我也是在央企,但是这确实是一件很无奈也很真实的事情,广大想来国企的小伙伴们也做好心理准备。 倒不是因为什么技术栈老旧这种广泛传播老掉牙的原因,而是一种工作气氛、工作压力、项目规模和大家吐槽最多的”工作压力“方...
继续阅读 »

事实上,单纯技术上来说,确实程序员进国企就废了。虽然我也是在央企,但是这确实是一件很无奈也很真实的事情,广大想来国企的小伙伴们也做好心理准备。


倒不是因为什么技术栈老旧这种广泛传播老掉牙的原因,而是一种工作气氛、工作压力、项目规模和大家吐槽最多的”工作压力“方面的原因。


首先,我本身互联网和央企都呆过,对于两者的工作环境和工作模式等是比较有发言权的。


由于互联网大多是to C项目,面向的是广大用户,是非常有竞争压力的,因为市场上同类产品的数目很多,如果你的代码出现了bug,那么影响到的可就是成千上万的普通用户。


就像微博崩溃了,在整个中国至少几亿人知道,那么你产生一个bug的成本是极大的,因此整体上来说互联网对代码质量的要求就非常高。


相对应来说的就是程序员个人的压力会极大,你必须确保你写的代码的严谨性,不能出现哪怕任何一点儿线上问题,否则等待你的可能就是辞退和担责。


同样也是上面的原因,由于你写的每一行代码都是需要经历成千上万人的使用来检验的,你和你的同时在写每一行代码的时候考虑的场景和风险就会更加全面,而不是简单的curd来完成业务即可。


并且,软件的复杂程度也是与软件的用户使用数量成正比的,使用的用户量越大,你的软件复杂性越高,你所需要解决的问题就越多,技术上涉及到的深度也更深,所以你在互联网企业中编程能力提升得会更快。


但但但 。。。是,并不是呆在互联网的所有程序员能力上都会有提升的,前面我说的这些都是互联网中的那群写核心代码的程序员,在别人框架上修修改改干些搬砖的活儿的人不在其中。


因为,大多数的大厂都有着自己的技术建设团队,会设计一堆自己内部用的工具,哪怕这种工具市面上已经有了也要自己造。长期在这种环境下,干使用别人框架的活儿,但是不继续学习承担更重要工作的人也是挺惨的,而且大概率会被大厂所淘汰。


但是国企的逻辑跟上面互联网的底层逻辑完全不同,互联网的项目大多数要拿到市场上去经历残酷的厮杀,只有做得最好的产品才能够最终活下来,获得垄断地位。


而国企的软件项目一般是一些集团的内部项目,或者有一些作为乙方为其它公司开发的项目也是常年合作下来的项目。


就如同中石油、中国五矿这样的集团内部的智慧化建设项目,本质上是不存在竞争的,因为即使我的软件开发部分可以外包出去或者找外包人员来做,但是这个软件必须得是你们集团牵头来做的。


包括版权和数据什么的最终必须属于你们的集团,而且大领导也热衷于将企业整体的智慧化建设作为工作成果向上汇报 。


所以这也就决定了,国企很多的软件是不太会面对竞争问题的,比如你很难想象让一个私企来做油田的智慧化管理软件。


因此,目前来说石油大体上还是垄断的,油田建设也只有中石油、中石化集团的相关公司可以进行建设,配套的软件你让私企自己来做个产品也是空中楼阁,做出来的东西也不一定能用。


但是,正是由于软件项目没有生存压力,并且国企的正式员工只要不犯下原则性问题,如违法犯罪,泄露公司机密、造成巨大的生产事故之类的重大问题,一般也是不会被轻易开除的,但是也可能存在不续约的情况发生。


因此,呆在国企的程序员就像温室里面的花朵一样,没有动力去优化问题,去采用新的技术,因为你代码写得再好也并不会给你更多的晋升机会,所以长期下去国企程序员的技术上确实就是惨不忍睹了。


其实,我刚从互联网大厂那边跳过来的时候,心理还极度不平衡,倒不是因为国企工资给得有多么低,而是我一直耿耿于怀的是我呆的那个大厂给的钱真的太少了。


但是,当我今年和一些工作好多年的成都普通程序员交流之后,我才发现了我作为校招生进入大厂之后整个人的狂妄与无知。


前一段时间,很多人看了我分享的去5A级景区写代码的blog之后,很多人都找到我问我们公司是否还有HC,是否还能够内推,其中一个哥们令我印象深刻。


他是西南某一本毕业的本科生,从学校毕业现在已经3~4年,也是Java后台开发,一直也是在一些中小型企业里面打转转。


和他聊的时候,才发现毕业短短3~4年他已经换过4~5份工作了,而且工资才渐渐的从当初刚毕业的几千块涨到现在的1w左右。


所以,怎么说呢,对于广大普通程序员来说,近几年的主题是活下来,如果这个时候能够有个地方能让你待到春暖花开的时候,也未尝不是件好事情。


至于技术,废了就废了吧,本质上来说job和career是两回事儿,job也就是“just over break”,每一个打工人,无论在国企还是私企都必须开启自己的”career“,而不是一直打工下去,这就是我的人生信条。


作者:浣熊say
来源:juejin.cn/post/7327724945761452042
收起阅读 »

uniapp云开发--微信登录

web
前言 我们主要使用uniapp + uniCloud 实现小程序登录,并将用户数据存入云数据库。 小程序 wx.getUserProfile 调整,接口将被收回 详情,所以需要用户自己填写资料。 注:填写个人资料是一个组件,覆盖在登录之上而已,还是在同一个页面...
继续阅读 »

前言


我们主要使用uniapp + uniCloud 实现小程序登录,并将用户数据存入云数据库。


小程序 wx.getUserProfile 调整,接口将被收回 详情,所以需要用户自己填写资料。


注:填写个人资料是一个组件,覆盖在登录之上而已,还是在同一个页面



uniCloud


创建 uniapp + uniCloud 项目,创建云数据库 数据表 uniCloud传送门


开始


创建项目


39d23acf47b440e2880f5ccadc1417f9~tplv-k3u1fbpfcp-zoom-in-crop-mark_4536_0_0_0.webp


关联云服务空间




创建云数据库 数据表


不使用模版,输入名称直接创建即可。



编辑表结构,想了解更多可以去看云数据库 DB Schema 数据结构文档 传送门


{
"bsonType": "object",
"required": [],
"permission": {
"read": true,
"create": true,
"update": true,
"delete": false
},
"properties": {
"_id": {
"description": "ID,系统自动生成"
},
"nickName": {
"bsonType": "string",
"label": "昵称",
"description": "用户昵称,登录获取的"
},
"avatarUrl": {
"bsonType": "string",
"label": "头像",
"description": "用户头像图片的 URL,登录获取的"
},
"gender": {
"bsonType": "number",
"label": "性别",
"description": "用户性别,1: 男;2: 女"
},
"personalize": {
"bsonType": "string",
"label": "个性签名",
"description": "个性签名,编辑资料获取"
},
"background": {
"bsonType": "object",
"label": "个人中心背景图",
"description": "个人中心背景图,编辑资料获取"
},
"mp_wx_openid": {
"bsonType": "string",
"description": "微信小程序平台openid"
},
"register_date": {
"bsonType": "timestamp",
"description": "注册时间",
"forceDefaultValue": {
"$env": "now"
}
}
}
}

创建云函数




云函数代码


云函数 将 uni.login 取得的 code 获取到用户 session, 并对 数据库进行 增加、修改、查询 操作,第一次注册必须用户主动填写用户资料。


对云数据库的相关操作 传送门


'use strict';

//小程序的AppID 和 AppSecret
const mp_wx_data = {AppID: '************', AppSecret: '***********************'}

//event为客户端上传的参数
exports.main = async (event, context) => {

//使用云数据库
const db = uniCloud.database();
// 获取 `users` 集合的引用
const pro_user = db.collection('users');
// 通过 action 判断请求对象

let result = {};
switch (event.action) {
// 通过 code 获取用户 session
case 'code2Session':
const res_session = await uniCloud.httpclient.request('https://api.weixin.qq.com/sns/jscode2session', {
method: 'GET', data: {
appid: mp_wx_data.AppID,
secret: mp_wx_data.AppSecret,
js_code: event.js_code,
grant_type: 'authorization_code'
}, dataType: 'json'
}
)
const success = res_session.status === 200 && res_session.data && res_session.data.openid
if (!success) {
return {
status: -2, msg: '从微信获取登录信息失败'
}
}

//从数据库查找是否已注册过
const res_user = await pro_user.where({
mp_wx_openid: res_session.data.openid
}).get()
// 没有用户信息,进入注册
if (res_user.data && res_user.data.length === 0) {
//event.user_info 用户信息
if (event.user_info) {
//有信息则进入注册,向数据库写入数据
const register = await uniCloud.callFunction({
name: 'user',
data: {
action: 'register',
open_id: res_session.data.openid,
user_info: event.user_info
}
}).then(res => {
result = res
})
} else {
//没有信息返回{register: true}
result = {
result: {
result: {register: true}
}
}
}
} else {
result = {
result: {
result: res_user.data[0]
}
}
}
break;
//注册 向数据库写入数据
case 'register':
const res_reg = await pro_user.add({
nickName: event.user_info.nickName,
avatarUrl: event.user_info.avatarUrl,
gender: event.user_info.gender,
mp_wx_openid: event.open_id,
register_date: new Date().getTime()
})
if (res_reg.id) {
const res_reg_val = await uniCloud.callFunction({
name: 'user', data: {
action: 'getUser', open_id: event.open_id
}
}).then(res => {
result = res
})
} else {
result = {
status: -1, msg: '微信登录'
}
}
break;
case 'update':
if (event._id && event.info) {
const res_update = await pro_user.doc(event._id).update(event.info)
if (res_update.updated >= 0) {
result = {status: 200, msg: '修改成功'}
} else {
result = {status: -1, msg: '修改失败'}
}
} else {
result = {status: -1, msg: '修改失败'}
}
break;
case 'getUser':
const res_val = await pro_user.where({
mp_wx_openid: event.open_id
}).get()
return res_val.data[0]
break;
}
return result;
};

微信登录操作


如上面所说,用户需手动上传资料,对于用户头像我们需要上传至云储存。


上传用户头像


上传图片函数参数为微信本地图片路径,我们对路径用/进行分割,取最后的图片名称进行上传


/**
* 上传图片至云存储
*/

export async function uploadImage(url) {
const fileName = url.split('/')
return new Promise(resolve => {
uniCloud.uploadFile({
filePath: url,
cloudPath: fileName[fileName.length - 1],
success(res) {
resolve(res)
},
fail() {
uni.showToast({
title: '图片上传失败!',
icon: 'none'
})
resolve(false)
}
})
})
}

登录函数


如果用户第一次上传资料,我们需要先上传头像并取得图片链接,再将用户资料写入数据库。


async wxLogin() {
if (this.userInfo && this.userInfo.avatarUrl) {
uni.showLoading({
title: '正在上传图片...',
mask: true
});
//上传头像至云储存并返回图片链接
const imageUrl = await uploadImage(this.userInfo.avatarUrl)
if (!imageUrl) {
return
}
this.userInfo = {...this.userInfo, avatarUrl: imageUrl.fileID}
}
uni.showLoading({
title: '登陆中...',
mask: true
});
const _this = this
uni.login({
provider: 'weixin',
success: (res) => {
if (res.code) {
//取得code并调用云函数
uniCloud.callFunction({
name: 'user',
data: {
action: 'code2Session',
js_code: res.code,
user_info: _this.userInfo
},
success: (res) => {
//如register为true,用户未填写资料
if (res.result.result.result.register) {
//_this.showUserInfo 显示填写资料组件
_this.showUserInfo = true
uni.hideLoading();
return
}
if (res.result.result.result._id) {
const data = {
_id: res.result.result.result._id,
mp_wx_openid: res.result.result.result.mp_wx_openid,
register_date: res.result.result.result.register_date
}
this.loginSuccess(data)
}
},
fail: () => {
this.loginFail()
}
})
}
}
})
},

登录成功与失败


在用户登录成功后将数据存入 Storage 中,添加登录过期时间,我这里设置的是七天的登录有效期。


loginSuccess(data) {
updateTokenStorage(data)
updateIsLoginStorage(true)
uni.showToast({
title: '登陆成功!',
icon: 'none'
});
uni.navigateBack()
},

将用户数据存入 Storage,并设置过期时间 expiresTime


export function updateTokenStorage(data = null) {
if (data) {
const expiresTime = new Date().getTime() + 7 * 24 * 60 * 60 * 1000
data = {...data, expiresTime: expiresTime}
}
uni.setStorageSync('user', data)
}

isLogin 用于判断是否是否登录


export function updateIsLoginStorage(data = null) {
uni.setStorageSync('isLogin', data)
}

登录失败


loginFail() {
updateTokenStorage()
updateIsLoginStorage()
uni.showToast({
title: '登陆失败!',
icon: 'none'
});
}

判断是否登录


除了判断 isLogin 还要判断 expiresTime 是否登录过期


//判断是否登陆
export function isLogin() {
try {
const user = uni.getStorageSync('user')
const isLogin = uni.getStorageSync('isLogin')
const nowTime = new Date().getTime()
return !!(isLogin && user && user._id && user.expiresTime > nowTime);
} catch (error) {

}
}

最后


至此就实现了微信登录并将用户信息存入数据库中,我们也可以通过云函数获取用户数据,做出用户个人主页。



以上是我做个人小程序时用的登录流程,整个小程序项目已上传至 GitHub。


GitHub地址


小程序码



作者:Biao
来源:juejin.cn/post/7264592481592705076
收起阅读 »

真的不考虑下grid布局?有时候真的很方便!

web
前言 flex布局大家应该已经运用的炉火纯青了,相信在日常开发中大家和我一样不管遇到什么都是flex一把搜哈。直到我遇到grid,才发现有些场景下,不是说flex实现不了而是使用grid能够更加轻松的完成任务。下面拿几个场景和大家分享一下。 宫格类的布局 比如...
继续阅读 »

前言


flex布局大家应该已经运用的炉火纯青了,相信在日常开发中大家和我一样不管遇到什么都是flex一把搜哈。直到我遇到grid,才发现有些场景下,不是说flex实现不了而是使用grid能够更加轻松的完成任务。下面拿几个场景和大家分享一下。


宫格类的布局


比如我要实现一个布局,最外层元素的宽度是1000px,高度自适应。子元素宽度为300px,一行展示3个,从左到右排列。其中最左边与最右边的元素需要紧挨父元素的左右边框。如下图所示:



使用flex实现


这个页面布局在日常开发中非常常见,考虑下使用flex布局如何实现,横向排列元素,固定宽度300,wrap设置换行显示,设置双端对齐。看起来很简单,来实现一下。


<!DOCTYPE html>
<html lang="en">

<head>
<style>
.box{
width: 1000px;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 10px;
}
.item{
background: pink;
width: 300px;
height: 150px;
}
</style>
</head>

<body>
<div class="box">
<div class="item">1</div>
<div class="item">2</div>
<div class="item">3</div>
<div class="item">4</div>
<div class="item">5</div>
<div class="item">6</div>
<div class="item">7</div>
<div class="item">8</div>
</div>
</body>

</html>


实现之后发现了问题,由于我们设置了双端对齐导致,当最后一行的个数不足三个时,页面展示的效果和我们预期的效果有出入。使用flex实现这个效果就要对这个问题进行额外的处理。


处理的方式有很多种,最常见的处理方式是在元素后面添加空元素,使其成为3的倍数即可。其实这里添加空元素的个数没有限制,因为空元素不会展示到页面上,即使添加100个空元素用户也是感知不到的。个人觉得这并不是一个好办法,在实际处理的时候可能还会遇到别的问题。个人觉得还是把flex下的子元素设置成百分比好一点。


使用grid实现


面对这种布局使用grid是非常方便的,设置3列,每列300px,剩下的元素让它自己往下排即可。几行代码轻松实现该效果,不需要flex那样额外的处理。


<!DOCTYPE html>
<html lang="en">

<head>
<style>
.box {
display: grid;
grid-template-columns: repeat(3, 300px);
justify-content: space-between;
gap: 10px;
width: 1000px;
}

.item {
background: pink;
height: 100px;
}
</style>
</head>

<body>
<div class="box">
<div class="item">1</div>
<div class="item">2</div>
<div class="item">3</div>
<div class="item">4</div>
<div class="item">5</div>
<div class="item">6</div>
<div class="item">7</div>
<div class="item">8</div>

</div>
</body>

</html>


实现后台管理布局



这种后台管理的布局,使用flex实现当然也没有问题。首先需要纵向排列红色的两个div,然后再横向的排列蓝色的两个div,最后再纵向的排列绿色的两个div实现布局。达到效果是没有问题的,但是实现起来较为繁琐,而且需要很多额外的标签嵌套。



由于grid是二维的,所以它不需要额外的标签嵌套。html里面结构清晰,如果需要改变页面结构,只需要改变container的样式就可以了,不需要对html进行修改。


<!DOCTYPE html>
<html lang="en">

<head>
<style>
.container {
display: grid;
grid-template-columns: 250px 1fr;
grid-template-rows: 100px 1fr 100px;
grid-template-areas:
'header header'
'aside main'
'aside footer';
height: 100vh;
}

.header {
grid-area: header;
background: #b3c0d1;
}

.aside {
grid-area: aside;
background: #d3dce6;
}

.main {
grid-area: main;
background: #e9eef3;
}

.footer {
grid-area: footer;
background: #b3c0d1;
}
</style>
</head>

<body>
<div class="container">
<div class="header">Header</div>
<div class="aside">Aside</div>
<div class="main">Main</div>
<div class="footer">Footer</div>
</div>
</body>

</html>

实现响应式布局


借助grid的auto-fillminmax函数可以实现类似响应式布局的效果,可以应用在后台管理的表单布局等场景。



<!DOCTYPE html>
<html lang="en">

<head>
<style>
.box {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
justify-content: space-between;
gap: 10px;
}

.item {
background: pink;
height: 100px;
}
</style>
</head>

<body>
<div class="box">
<div class="item">1</div>
<div class="item">2</div>
<div class="item">3</div>
<div class="item">4</div>
<div class="item">5</div>
<div class="item">6</div>
<div class="item">7</div>
<div class="item">8</div>
</div>
</body>

</html>

兼容性对比


flex的兼容性


image.png


grid的兼容性


image.png


可以看到grid在兼容性上还是不如flex,grid虽然强大,但是在使用前还是需要先考虑一下项目的用户群体。


结尾


除了上述场景外肯定还有许多场景适合使用grid来完成。gridflex都是强大的布局方式,它们并没有明显的优劣之分。关键在于掌握这两种方法,并在开发中根据实际情况选择最合适的方案。


希望大家能有所收获!


作者:欲买炸鸡同载可乐
来源:juejin.cn/post/7326816030042669110
收起阅读 »