注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

手把手使用Blender+ThreeJS制作跨栏小游戏

web
效果展示 先录制的视频,再转化为GIF图片导致展示效果有点延迟,实际效果还是挺丝滑的,感兴趣的可以上手尝试一下 人物模型和动画获取 在mixamo.com网站,需要先登录一下,可以直接使用Google邮箱登录,然后来到Characters页,下方有10...
继续阅读 »

效果展示



  • 先录制的视频,再转化为GIF图片导致展示效果有点延迟,实际效果还是挺丝滑的,感兴趣的可以上手尝试一下
    demo.gif


人物模型和动画获取



  • mixamo.com网站,需要先登录一下,可以直接使用Google邮箱登录,然后来到Characters页,下方有100多种人物模型,点击左边卡片可以选择自己喜欢的人物模型,或者如下图和我选的一样
    image.png

  • 然后来到Animations页,默认如下图红框内展示的也是刚才我们选择的人物模型,如果右侧展示不对,需要回到Characters页重新选择人物模型
    image.png

  • 因为动画比较多,这里我们直接在左上角搜索框内搜索自己想要的动作动画即可,首先搜索Idle,我这里使用的动画是Happy Idle,还将右侧的Overdrive的值从50调整到75,值越大动画频率越快,调整好后直接点击右上方的DOWNLOAD
    image.png

  • 弹出的弹窗里的内容都不想要修改,直接点击如下图右下角的DOWNLOAD,等待一会后,选择本地文件夹下载即可
    image.png

  • 接着左上角搜索Running,选择Running卡片,并且勾选右侧的In Place,让人物模型在原地跑动;如果不勾选的话,因为动画本身带有位移,会影响我们使用ThreeJS改变人物模型的position控制人物位移的;设置好后直接点击DOWNLOAD,同样弹出的弹窗不需要修改,直接点击弹窗右下角的DOWNLOAD下载即可
    image.png

  • 继续左上角搜索Jump,选择Jump卡片,并且勾选右侧的In Place,让人物模型在原地跑动;设置好后直接点击DOWNLOAD,同样弹出的弹窗不需要修改,直接点击弹窗右下角的DOWNLOAD下载即可
    image.png

  • 继续左上角搜索Death,选择Falling Back Death卡片,这个动画不需要其他调整,直接点击DOWNLOAD,同样弹出的弹窗不需要修改,直接点击弹窗右下角的DOWNLOAD下载即可
    image.png

  • 这样就下载好了Idle(待机动作)、Running(跑步动作)、Jump(跳跃动作)、Death(死亡动作)的一组动作,以上动作的都可以根据个人喜好调整;如果打开mixamo.com网站比较慢或者下载有问题的话,也可以直接使用我下载好的actions里的动画模型
    image.png


动画模型合并



  • 打开Blender新建文件选择常规,我使用的版本是3.6.14的,不同版本可能存在差异
    image.png

  • 在右上角场景集合内,鼠标左键拖拽框选默认已有的Camera、Cube和Light,然后右键选择删除或者英文输入法下按下x键快速删除;后续在Blender里的所有操作,均需将输入法切换到英文输入;如果对Blender不是特别熟悉,可以优先阅读我之前整理的Blender学习整理这篇文章
    image.png

  • 选择左上角菜单栏里文件-->导入-->FBX(.fbx),就会弹出导入设置弹窗
    image.png

  • 弹窗内只需选中之前下载好的Idle.fbx文件,然后直接点击右下角导入FBX即可
    image.png

  • 文件导入后,在右上角场景集合内,将动画下一级的目录鼠标双击重命名成idle
    image.png

  • 在中间布局窗口,将右上角的视图着色方式切换到材质预览,然后把观察点切换到-X方向,将下方窗口切换到非线性动画
    image.png

  • 点击动画右侧的按键,作用是下推动作块(将动作块作为新的片段下推到NLA堆栈顶部),有点类似展开下拉菜单的效果,实际作用是创建NLA轨道,然后将idle动作放到一个单独的通道中
    image.png

  • 取消勾选通道对结果是否有影响(切换通道是否启用)
    image.png

  • 导入Running.fbx,导入流程和上述一致,导入后在右上角场景集合内重命名动画下一级目录为running
    image.png

  • 同样点击动画右侧的按键,下推动作块
    image.png

  • 同样取消勾选通道对结果是否有影响(切换通道是否启用)
    image.png

  • 鼠标点击选中idle动作,变成如下图颜色即被选中
    image.png

  • 选择上方添加-->添加动作片段
    image.png

  • 然后选中running
    image.png

  • 这时,在idle动作上方就添加了一个running动作通道,双击左侧如下图红框内重命名为running,左侧的名称就是我们后续使用ThreeJS控制人物模型切换动画时要使用的动作变量名
    image.png

  • 在右上角场景集合内,选中后面导入的模型右键后点击删除或者按下x键快速删除,将其删除
    image.png

  • 第一次删除可能删除不干净,场景集合内还有多余的文件,布局窗口也有遮挡物,这个遮挡物其实就是第二个模型里的人物建模,第一次只是把动画给删除掉了,所以需要再次如下图选中右侧红框内文件继续删除才能删除干净;注意删除的时候最好把导入的第一个模型收起来,防止误删
    image.png

  • 如上述操作后,就把idle动画和running动画合并到一个人物模型里,再重复上述操作,把Jump.fbxDeath.fbx文件依次导入进来,并且把jump动画和death动画添加到idle动画上方,每个动作一个通道;全部搞定后,可以点击如下图红框内的五角星,实心就代表被选中,依次选中每个动作,取消勾选通道影响,还可以按下键盘的空格播放各个动画
    image.png

  • 最后选择左上角文件-->导出-->glTF2.0(.glb/.gltf),会弹出导出设置弹窗
    image.png

  • 弹窗内选择本地合适的文件夹,直接点击右下角的导出glTF2.0即可
    image.png

  • 可以参考我仓库里的models里的actions.glb
    image.png


跨栏和跑道模型获取



  • sketchfab.com网站,首先需要先登录,同样可以使用Google邮箱登录,然后在搜索框输入hurdle,按下回车
    image.png

  • 然后可以在下方很多的跨栏模型中选择自己喜欢的,我这里选择的是Wii - Wii Play - Hurdle
    image.png

  • 点击模型左下角的Download 3D Model,会弹出下载选项弹窗
    image.png

  • 弹窗里选择下载GLB格式的文件,这个格式会将所有内容(包含模型的结构、材质、动画和其他元数据等)打包在一起,文件大小可能会更大,但管理方便,所有内容都在一个文件中
    image.png

  • 下载到本地后,重命名为hurdle.glb
    image.png

  • 同样的方式,搜索track,我这边使用的模型是Dusty foot path way in grass garden,忘记了当时的筛选条件了,怎么就在搜索track的时候找到了这个模型;因为需要游戏里面的跑道不间断的出现,所以需要这种能够重复拼接的跑道模型,大家也可以自行选择喜欢的模型,下载好后,本地重命名为track.glb
    image.png

  • 如果访问sketchfab.com网站比较慢,或者下载有问题的话,可以直接使用我仓库里的models里的hurdle.glbtrack.glb
    image.png


模型合并



  • 为了和之前的内容不搅合,就不在之前的actions.blender文件里添加其他两个模型,这里使用Blender新建一个常规文件,同样删除默认的Camera、Cube和Light
    image.png

  • 选择左上角文件-->导入-->glTF2.0(.glb/.gltf),会弹出导入弹窗
    image.png

  • 选择之前保存的actions.glb,直接点击右下角的导入glTF2.0即可
    image.png

  • 导入后,同样把视图着色方式切换到材质预览,观察点切换到—X方向上
    image.png

  • 在右上角的场景集合内,将当前模型重命名为acitons
    image.png

  • 导入下载好的hurdle.glb,在右上角的场景集合内,刚导入的模型只有最里面的Object_2才是真的跨栏网格对象,外面两层结构没有作用,还可能出现外层结构的旋转缩放等属性和内部实际的网格对象的属性不一致,影响我们对实际网格对象的控制,所以最好删除掉
    image.png

  • 依次选中外面两层文件,按下x键删除,最后把Object_2重命名为hurdle
    image.png

  • hurdle模型尺寸比较大,旋转方向也不对,鼠标左键选中hurdle模型,然后在属性栏,将旋转的X调整成-1.0,将缩放的XYZ全部调整成0.1,这里不需要调整的特别精确,后续编码时还能使用ThreeJS继续调整模型的旋转和尺寸;如果布局窗口没有属性栏,可以按下n键显示/隐藏属性栏;输入值时,鼠标拖动三个输入框能够同时改变XYZ的值
    image.png

  • 继续选中hurdle模型,按下ctrl + a然后选择全部变换,需要把之前调整的旋转和缩放应用为自身数据
    image.png

  • 导入下载好的track.glb,可以切换右侧红框的眼睛图标显示/隐藏该对象来观察整体模型变化,会发现实际起作用的是Object_2Object_3的两个网格对象,两个外层结构的显示/隐藏看似对模型的显示没有影响,其实它们是有属性是对模型有影响的,需要把它们的属性变换应用到自身
    image.png

  • 在右上角场景集合选中第一层结构,在属性栏会发现它有一些旋转和缩放,在布局窗口按下ctrl+a,然后选择应用全部变换;再选中第二层结构,同样按下ctrl+a应用全部变换
    image.png

  • 这时就会发现,外层的旋转和缩放都已经作用到Object_2Object_3的两个网格对象上了,就可以依次选中两个外层结构,按下x键删除两个外层结构了;并且依次选中Object_2Object_3的两个网格对象按下ctrl+a选择全部变换把外层结构的旋转和缩放应用到自身
    image.png

  • 然后在右上角场景集合内,鼠标先选中Object_3,按下shift键再选中Object_2,然后鼠标回到布局窗口,按下ctrl+p选择物体,这里发现Object_2变成了Object_3的子级了,理论上最后被选中的对象是父级,我想要的效果是Object_3变成了Object_2的子级,所以这里我又撤销(ctrl+z)重新先选中Object_2,按下shift键再选中Object_3,再按下ctrl+p选择物体绑定的父子关系;这里不清楚是因为我使用中文翻译的问题还是Blender有更新,有了解的大佬希望帮忙解释一下
    image.png

  • 将合并后的父级Object_2重命名为track
    image.png

  • 左键选中track模型,右键选择设置原点-->原点->几何中心
    image.png

  • 在选中track模型的情况下,继续按下shift+s选择选中项->游标,如果游标没有在世界原点,需要先将游标设置到世界原点
    image.png

  • 将观察点切换到顶视图,按下r+z绕着Z轴旋转,使跑道的长边和Y轴平行
    image.png

  • 再切换到-X方向,按下r+x绕着X轴旋转,使跑道的上面和Y轴平行
    image.png

  • 选择左上角文件-->导出-->glTF2.0(.glb/.gltf)
    image.png

  • 导出设置弹窗内,直接将合并后的模型导出到我们后续编码要用的文件夹内,其他无需设置,直接选择导出glTF2.0即可
    image.png


编码渲染



  • 使用vite搭建的项目工程,完整源码点这里code,整个渲染过程都在src/hooks/userDraw.js文件里
    image.png

  • 首先是ThreeJS的基础代码,放到了src/hooks/modules/base.js文件里,我这里只是往scene添加了一个背景纹理,最后就是把scenecamerarenderer暴露出来方便其他模块引用


import * as THREE from 'three';

/**
* 基础代码
*/

export default function () {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
camera.position.set(0, 0, 5); // 设置相机位置
const renderer = new THREE.WebGLRenderer({
antialias: true // 开启抗锯齿
});
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 加载背景纹理
const textureLoader = new THREE.TextureLoader();
textureLoader.load('./bg.jpeg', function (texture) {
// 将纹理设置为场景背景
scene.background = texture;
});

// 适配窗口
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight; // 重置摄像机视锥体的长宽比
camera.updateProjectionMatrix(); // 更新摄像机投影矩阵
renderer.setSize(window.innerWidth, window.innerHeight); // 重置画布大小
});

return {
scene,
camera,
renderer
};
}


  • src/hooks/modules/controls.js文件里添加控制器,并且需要禁用控制器


import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

/**
* 控制器
*/

export default function (camera, renderer) {
const orbitControls = new OrbitControls(camera, renderer.domElement); // 轨道控制器
orbitControls.enabled = false; // 禁用控制器
orbitControls.update(); // 更新控制器
}


  • src/hooks/modules/light.js文件里添加环境光,上下方各添加了一个平行光


import * as THREE from 'three';

/**
* 灯光
*/

export default function (scene) {
const ambientLight = new THREE.AmbientLight(0x404040, 20); // 环境光
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 5); // 平行光
directionalLight.position.set(0, 10, 5);
scene.add(directionalLight);
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 5); // 平行光
directionalLight2.position.set(0, -10, -5);
scene.add(directionalLight2);
}


  • src/hooks/userDraw.js文件没有进一步优化,下面主要介绍几个核心部分

    • 把之前合并好的模型,使用GLTFLoader加载进来,然后初始化人物、跨栏、跑道等模型;因为模型的XYZ轴向和ThreeJS的没有对应上,所以需要给各个模型创建一个Gr0up方便单独控;获取跑道宽度时,因为获取的不是特别准确,所以又减去了2,让跑道叠加在一起了,不是特别严丝合缝;然后就是创建动画混合器,把动作保存起来了,然后默认播放待机动作;最后开始帧循环渲染


    // 加载人物模型
    const loader = new GLTFLoader();
    loader.load('./models/group.glb', function (gltf) {
    const children = [...gltf.scene.children];

    // 初始化人物模型
    global.characterGr0up.add(children[0]);
    global.characterGr0up.rotation.set(0, Math.PI / 2, 0); // 改变人物朝向
    scene.add(global.characterGr0up);

    // 初始化跨栏模型
    global.hurdleGr0up.add(children[1]);
    global.hurdleGr0up.scale.set(0.7, 0.7, 0.7); // 缩小跨栏
    global.hurdleGr0up.rotation.set(0, Math.PI / 2, 0); // 改变跨栏朝向
    global.hurdleGr0up.position.set(3, 0, 0); // 设置第一个跨栏位置
    global.hurdleArr.push(global.hurdleGr0up); // 添加第一个跨栏触发碰撞检测
    scene.add(global.hurdleGr0up);

    // 初始化跑道模型
    global.trackGr0up.add(children[2]);
    global.trackGr0up.rotation.set(0, Math.PI / 2, 0); // 改变跑道朝向
    scene.add(global.trackGr0up);

    // 获取跑道宽度
    const boundingBox = new THREE.Box3().setFromObject(global.trackGr0up); // 创建包围盒
    const size = new THREE.Vector3(); // 计算包围盒的尺寸
    boundingBox.getSize(size);
    global.trackWidth = size.x - 2; // 跑道宽度不是特别准确,需要模糊计算
    // 默认使用trackNum个跑道拼接
    for (let i = 0; i < trackNum; i++) {
    const newTrackModel = global.trackGr0up.clone(); // 克隆原始跑道模型
    newTrackModel.position.x = i * global.trackWidth; // 按照宽度依次排列
    scene.add(newTrackModel);
    global.trackArr.push(newTrackModel); // 保存引用
    }

    // 创建动画混合器
    global.animationMixer = new THREE.AnimationMixer(global.characterGr0up);

    // 将每个动画剪辑存储在actions对象中
    gltf.animations.forEach((clip) => {
    global.actions[clip.name] = global.animationMixer.clipAction(clip);
    });

    // 播放默认的 idle 动作
    global.currentAction = global.actions['idle'];
    global.currentAction.play();

    // 开始渲染循环
    animate();
    });


    • animate函数里主要功能有开启帧循环;更新动画混合器;在跳跃动作结束后切换回跑步动作;当人物处于跑步和跳跃动作时,更新人物位置及让相机跟随人物移动,并且在移动过程中,在间隔帧数内生成新的跨栏;更新跑道的位置,如果最左侧的跑道超出屏幕后,把它移动到最右侧;当人物处于跳跃动作时,更新人物Y轴位置;如果人物和跨栏发生碰撞时,切换到死亡动作并且开启死亡状态,防止键盘按键还能继续触发;当播放完死亡动作后,提示游戏结束,并结束帧数循环;渲染场景


    function animate() {
    global.frame = requestAnimationFrame(animate); // 开启帧循环

    global.animationMixer.update(global.clock.getDelta()); // 更新动画混合器

    // 检查 jump 动作是否完成,并恢复到 running 动作
    if (
    global.currentAction === global.actions['jump'] &&
    global.currentAction.time >= global.currentAction.getClip().duration
    ) {
    switchAction('running', 0.3);
    }

    // 当处于 running 动作时,移动相机
    if (
    global.currentAction === global.actions['running'] ||
    global.currentAction === global.actions['jump']
    ) {
    global.characterGr0up.position.x += moveSpeed;
    camera.position.x = global.characterGr0up.position.x;

    // 间隔随机帧数生成跨栏
    if (
    global.hurdleCountFrame++ >
    hurdleInterval + Math.random() * hurdleInterval
    ) {
    generateHurdles(global.hurdleGr0up, global.hurdleArr, scene); // 生成跨栏
    global.hurdleCountFrame = 0;
    }
    }

    // 更新跑道位置
    updateTrack(camera, global.trackArr, global.trackWidth);

    // 当人物处于跳跃动作时,更新人物位置
    updateCharacterPosition(
    global.animationMixer,
    global.clock,
    global.currentAction,
    global.actions,
    global.characterGr0up
    );

    // 碰撞检测
    if (
    checkCollisions(
    global.characterGr0up,
    global.characterBoundingBox,
    global.hurdlesBoundingBoxes,
    global.hurdleArr
    )
    ) {
    switchAction('death');
    global.isDeath = true;
    }

    // 如果 death 动作完成了,则停止帧动画
    if (
    global.currentAction === global.actions['death'] &&
    !global.currentAction.isRunning()
    ) {
    Modal.error({
    title: 'Game Over',
    width: 300
    });
    cancelAnimationFrame(global.frame);
    }

    // 渲染场景
    renderer.render(scene, camera);
    }


    • 切换动作函数主要是在一定时间内淡出前一个动作,并且淡入新动作,如果是跳跃动作或者死亡动作的话只执行一次


    function switchAction(newActionName, fadeDuration = 0.5) {
    const newAction = global.actions[newActionName];
    if (newAction && global.currentAction !== newAction) {
    global.previousAction = global.currentAction; // 保留当前的动作
    // 淡出前一个动作
    if (global.previousAction) {
    global.previousAction.fadeOut(fadeDuration);
    }

    // 如果切换到 jump 动作,设置播放一次并在结束后停止
    if (newActionName === 'jump') {
    newAction.loop = THREE.LoopOnce;
    newAction.clampWhenFinished = true; // 停止在最后一帧
    }

    // 如果切换到 death 动作,设置播放一次并在结束后停止
    if (newActionName === 'death') {
    newAction.loop = THREE.LoopOnce;
    newAction.clampWhenFinished = true; // 停止在最后一帧
    }
    global.currentAction = newAction; // 设置新的活动动作

    // 复位并淡入新动作
    global.currentAction.reset();
    global.currentAction.setEffectiveTimeScale(1);
    global.currentAction.setEffectiveWeight(1);
    global.currentAction.fadeIn(fadeDuration).play();
    }
    }


    • 键盘事件监听,给按键WSAD和方向键上下左右都添加了切换动作的功能,如果是死亡状态的,按键失效


    window.addEventListener('keydown', (event) => {
    if (global.isDeath) {
    return;
    }
    switch (event.code) {
    case 'keyD':
    case 'ArrowRight':
    switchAction('running');
    break;
    case 'keyA':
    case 'ArrowLeft':
    switchAction('idle');
    break;
    case 'keyW':
    case 'ArrowUp':
    switchAction('jump');
    break;
    }
    });


  • src/configs/index.js文件配置了一些常量,可以用来控制游戏状态


// 初始跑道数量
export const trackNum = 3;

// 跨栏之间的间隔帧数
export const hurdleInterval = 50; // 50~100帧之间

// 跨栏之间的间隔最小距离
export const hurdleMinDistance = 5; // 5~10距离之间

// 人物移动的速度
export const moveSpeed = 0.03;


  • src/utils/index.js文件主要是一些辅助函数


import * as THREE from 'three';
import { hurdleMinDistance } from '../configs/index';

/**
* 生成新的跨栏
*
* @param {Object} oldModel - 要克隆的原始跨栏模型。
* @param {Array} hurdleArr - 现有跨栏模型的数组。
* @param {Object} scene - 要添加新跨栏模型的场景。
* @return {undefined}
*/

export function generateHurdles(oldModel, hurdleArr, scene) {
const newModel = oldModel.clone(); // 克隆原始跨栏模型

const nextPosition =
hurdleArr[hurdleArr.length - 1].position.x +
hurdleMinDistance +
Math.random() * hurdleMinDistance;

newModel.position.set(nextPosition, 0, 0);
hurdleArr.push(newModel);
scene.add(newModel);
}

/**
* 更新跑道位置
*
* @param {Object} camera - 具有位置属性的摄像机对象。
* @param {Array} trackArr - 具有位置属性的轨道段对象数组。
* @param {Number} trackWidth - 每个轨道段的宽度。
* @return {undefined}
*/

export function updateTrack(camera, trackArr, trackWidth) {
const cameraPositionX = camera.position.x; // 相机的 x 坐标
// 遍历所有跑道段
for (let i = 0; i < trackArr.length; i++) {
const trackSegment = trackArr[i];
// 提前检测跑道段是否即将超出视野(增加一个提前量,比如半个跑道段的宽度)
const threshold = cameraPositionX - trackWidth * 1.5;
if (trackSegment.position.x < threshold) {
// 找到当前最右边的跑道段
let maxX = -Infinity;
for (let j = 0; j < trackArr.length; j++) {
if (trackArr[j].position.x > maxX) {
maxX = trackArr[j].position.x;
}
}
// 将当前跑道段移动到最右边
trackSegment.position.x = maxX + trackWidth;
}
}
}

/**
* 人物跳跃时,更新人物Y轴位置
*
* @param {Object} animationMixer - 动画混合器对象。
* @param {Object} clock - 用于获取增量时间的时钟对象。
* @param {Object} currentAction - 当前正在执行的动作。
* @param {Object} action - 可用动作的集合。
* @param {Object} characterGr0up - 角色组对象。
* @return {undefined}
*/

export function updateCharacterPosition(
animationMixer,
clock,
currentAction,
actions,
characterGr0up
) {
// 更新动画混合器
animationMixer.update(clock.getDelta());

// 检查动画状态并调整位置
if (currentAction === actions['jump']) {
// 根据跳跃动画的时间调整人物位置
const jumpHeight = 0.8; // 你可以调整这个值
characterGr0up.position.y =
Math.sin(currentAction.time * Math.PI) * jumpHeight;
} else {
characterGr0up.position.y = 0; // 恢复到地面位置
}
}

/**
* 检测人物是否与跨栏发生了碰撞
*
* @param {Object} characterGr0up - 角色组对象。
* @param {Object} characterBoundingBox - 角色的边界框对象。
* @param {Array} hurdlesBoundingBoxes - 跨栏的边界框数组。
* @param {Array} hurdleArr - 跨栏对象数组。
* @return {Boolean} 是否发生了碰撞。
*/

export function checkCollisions(
characterGr0up,
characterBoundingBox,
hurdlesBoundingBoxes,
hurdleArr
) {
// 更新人物的边界框
if (characterGr0up) {
characterBoundingBox.setFromObject(characterGr0up);
}

// 更新跨栏的边界框
hurdlesBoundingBoxes = hurdleArr.map((hurdle) => {
const box = new THREE.Box3();
box.setFromObject(hurdle);
return box;
});

for (let i = 0; i < hurdlesBoundingBoxes.length; i++) {
if (characterBoundingBox.intersectsBox(hurdlesBoundingBoxes[i])) {
return true; // 检测到碰撞
}
}
return false; // 没有检测到碰撞
}

不足



  • 跑道的纹理和材质没有渲染出来,不知道是否是导出的模型有问题,有懂的大佬可以帮忙看看

  • 目前合并后的模型导出后体积比较大,还需要解决模型压缩的问题


作者:LoveDreaMing
来源:juejin.cn/post/7405153695506022451
收起阅读 »

工作中用Redis最多的10种场景

前言 Redis作为一种优秀的基于key/value的缓存,有非常不错的性能和稳定性,无论是在工作中,还是面试中,都经常会出现。 今天这篇文章就跟大家一起聊聊,我在实际工作中使用Redis的10种场景,希望对你会有所帮助。 8000页BAT大佬写的刷题笔记,...
继续阅读 »

前言


Redis作为一种优秀的基于key/value的缓存,有非常不错的性能和稳定性,无论是在工作中,还是面试中,都经常会出现。


今天这篇文章就跟大家一起聊聊,我在实际工作中使用Redis的10种场景,希望对你会有所帮助。


图片


8000页BAT大佬写的刷题笔记,让我offer拿到手软


1. 统计访问次数


对于很多官方网站的首页,经常会有一些统计首页访问次数的需求。


访问次数只有一个字段,如果保存到数据库中,再最后做汇总显然有些麻烦。


该业务场景可以使用Redis,定义一个key,比如:OFFICIAL_INDEX_VISIT_COUNT。


在Redis中有incr命令,可以实现给value值加1操作:


incr OFFICIAL_INDEX_VISIT_COUNT

当然如果你想一次加的值大于1,可以用incrby命令,例如:


incrby OFFICIAL_INDEX_VISIT_COUNT 5

这样可以一次性加5。


2. 获取分类树


在很多网站都有分类树的功能,如果没有生成静态的html页面,想通过调用接口的方式获取分类树的数据。


我们一般为了性能考虑,会将分类树的json数据缓存到Redis当中,为了后面在网站当中能够快速获取数据。


不然在接口中需要使用递归查询数据库,然后拼接成分类树的数据结构。


这个过程非常麻烦,而且需要多次查询数据库,性能很差。


因此,可以考虑用一个定时任务,异步将分类树的数据,直接缓存到Redis当中,定义一个key,比如:MALL_CATEGORY_TREE。


然后接口中直接使用MALL_CATEGORY_TREE这个key从缓存中获取数据即可。


可以直接用key/value字符串保存数据。


不过需要注意的是,如果分类树的数据非常多可能会出现大key的问题,优化方案可以参考我的另外一篇文章《分类树,我从2s优化到0.1s》。


3. 做分布式锁


分布式锁可能是使用Redis最常见的场景之一,相对于其他的分布式锁,比如:数据库分布式锁或者Zookeeper分布式锁,基于Redis的分布式锁,有更好的性能,被广泛使用于实际工作中。


我们使用下面这段代码可以加锁:


try{
  String result = jedis.set(lockKey, requestId, "NX""PX", expireTime);
  if ("OK".equals(result)) {
      return true;
  }
  return false;
finally {
    unlock(lockKey);
}  

但上面这段代码在有些场景下,会有一些问题,释放锁可能会释放了别人的锁。


说实话Redis分布式锁虽说很常用,但坑也挺多的,如果用不好的话,很容易踩坑。


如果大家对Redis分布式锁的一些坑比较感兴趣,可以看看我的另一篇文章《聊聊redis分布式锁的8大坑》,文章中有非常详细的介绍。


4. 做排行榜


很多网站有排行榜的功能,比如:商城中有商品销量的排行榜,游戏网站有玩家获得积分的排行榜。


通常情况下,我们可以使用Sorted Set保存排行榜的数据。


使用ZADD可以添加排行榜的数据,使用ZRANGE可以获取排行榜的数据。


例如:


ZADD rank:score 100 "周星驰"
ZADD rank:score 90 "周杰伦"
ZADD rank:score 80 "周润发"
ZRANGE rank:score 0 -1 WITHSCORES

返回数据:


1"周星驰"
2"100"
3"周杰伦"
4"90"
5"周润发"
6"80"

5. 记录用户登录状态


通常下,用户登录成功之后,用户登录之后的状态信息,会保存到Redis中。


这样后面该用户访问其他接口的时候,会直接从Redis中查询用户登录状态,如果可以查到数据,说明用户已登录,则允许做后续的操作。


如果从Redis中没有查到用户登录状态,说明该用户没有登录,或者登录状态失效了,则直接跳转到用户登录页面。


使用Redis保存用户登录状态,有个好处是它可以设置一个过期时间,比如:该时间可以设置成30分钟。


jedis.set(userId, userInfo, 1800);

在Redis内部有专门的job,会将过期的数据删除,也有获取数据时实时删除的逻辑。


6. 限流


使用Redis还有一个非常常用的的业务场景是做限流


当然还有其他的限流方式,比如:使用nginx,但使用Redis控制可以更精细。


比如:限制同一个ip,1分钟之内只能访问10次接口,10分钟之内只能访问50次接口,1天之内只能访问100次接口。


如果超过次数,则接口直接返回:请求太频繁了,请稍后重试。


跟上面保存用户登录状态类似,需要在Redis中保存用户的请求记录。


比如:key是用户ip,value是访问的次数从1开始,后面每访问一次则加1。


如果value超过一定的次数,则直接拦截这种异常的ip。


当然也需要设置一个过期时间,异常ip如果超过这个过期时间,比如:1天,则恢复正常了,该ip可以再发起请求了。


或者限制同一个用户id。


7. 位统计


比如现在有个需求:有个网站需要统计一周内连续登陆的用户,以及一个月内登陆过的用户。


这个需求使用传统的数据库,实现起来比较麻烦,但使用Redis的bitmap让我们可以实时的进行类似的统计。


bitmap 是二进制的byte数组,也可以简单理解成是一个普通字符串。它将二进制数据存储在byte数组中以达到存储数据的目的。


保存数据命令使用setbit,语法:


setbit key offset value

具体示例:


setbit user:view:2024-01-17 123456 1

往bitmap数组中设置了用户id=123456的登录状态为1,标记2024-01-17已登录。


然后通过命令getbit获取数据,语法:


getbit key offset

具体示例:


getbit user:view:2024-01-17 123456

如果获取的值是1,说明这一天登录了。


如果我们想统计一周内连续登录的用户,只需要遍历用户id,根据日期中数组中去查询状态即可。


最近就业形式比较困难,为了感谢各位小伙伴对苏三一直以来的支持,我特地创建了一些工作内推群, 看看能不能帮助到大家。


你可以在群里发布招聘信息,也可以内推工作,也可以在群里投递简历找工作,也可以在群里交流面试或者工作的话题。


图片


进群方式


添加,苏三的私人微信:su_san_java,备注:内推+所在城市,即可加入。


8. 缓存加速


我们在工作中使用Redis作为缓存加速,这种用法也是非常常见的。


如果查询订单数据,先从Redis缓存中查询,如果缓存中存在,则直接将数据返回给用户。


如果缓存中不存在,则再从数据库中查询数据,如果数据存在,则将数据保存到缓存中,然后再返回给用户。


如果缓存和数据库都不存在,则直接给用户返回数据不存在。


流程图如下:图片但使用缓存加速的业务场景,需要注意一下,可能会出现:缓存击穿、穿透和雪崩等问题,感兴趣的小伙伴,可以看看我的另一篇文章《烂大街的缓存穿透、缓存击穿和缓存雪崩,你真的懂了?》,里面有非常详细的介绍。


9. 做消息队列


我们说起队列经常想到是:kafka、rabbitMQ、RocketMQ等这些分布式消息队列。


其实Redis也有消息队列的功能,我们之前有个支付系统,就是用的Redis队列功能。


PubSub(发布订阅)是Redis2.0版本引入的消息传递模型。


顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。对应channel发送消息后,所有订阅者都能收到相关消息。


在java代码中可以实现MessageListener接口,来消费队列中的消息。


@Slf4j
@Component
public class RedisMessageListenerListener implements MessageListener {
    @Autowired
    private RedisTemplate<StringObject> redisTemplate;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String channel = new String(pattern);
        RedisSerializer valueSerializer = redisTemplate.getValueSerializer();
        Object deserialize = valueSerializer.deserialize(message.getBody());
        if (deserialize == nullreturn;
        String md5DigestAsHex = DigestUtils.md5DigestAsHex(deserialize.toString().getBytes(StandardCharsets.UTF_8));
        Boolean result = redisTemplate.opsForValue().setIfAbsent(md5DigestAsHex, "1"20TimeUnit.SECONDS);
        if (Boolean.TRUE.equals(result)) {
            log.info("接收的结果:{}", deserialize.toString());
        } else {
            log.info("其他服务处理中");
        }
    }
}

10. 生成全局ID


在有些需要生成全局ID的业务场景,其实也可以使用Redis。


可以使用incrby命令,利用原子性操作,可以执行下面这个命令:


incrby userid 10000

在分库分表的场景,对于有些批量操作,我们可以从Redis中,一次性拿一批id出来,然后给业务系统使用。


作者:苏三说技术
来源:juejin.cn/post/7325132133168971813
收起阅读 »

访问者模式:多品类商品打折场景

0.基础 0.0解决的痛点 它可以在不修改原有类的情况下,扩展新的操作,而策略模式则需要改变上下文类来引入新的策略。 扩展性更强 访问者模式使得你可以在不修改现有类的情况下添加新的操作。假设你有多个对象构成的复杂结构,并且要在这些对象上执行不同的操作。 使...
继续阅读 »

0.基础


0.0解决的痛点


它可以在不修改原有类的情况下,扩展新的操作,而策略模式则需要改变上下文类来引入新的策略。



  • 扩展性更强



    • 访问者模式使得你可以在不修改现有类的情况下添加新的操作。假设你有多个对象构成的复杂结构,并且要在这些对象上执行不同的操作。

    • 使用访问者模式,你可以为每个对象定义一个访问者,而每次需要添加新的操作时,只需要增加一个新的访问者类。这样,原有类不会被修改,符合开闭原则(对扩展开放,对修改关闭)。

    • 相反,策略模式更多是通过替换算法来改变行为。如果你的业务逻辑复杂,需要在同一个对象中实现多个策略,频繁地改变策略可能会导致对象内部逻辑变得非常复杂,增加维护成本。



  • 职责单一,逻辑分离



    • 访问者模式将行为与对象结构分离,访问者本身只关心如何对不同对象执行操作,而不需要关心对象的具体实现。这种方式将操作逻辑与数据结构解耦,符合单一职责原则。

    • 策略模式中,每种策略会嵌入到目标对象中,这会使得目标对象承担过多的责任,尤其是在需要处理大量策略的情况下,会导致对象变得非常臃肿。



  • 操作集中统一管理



    • 使用访问者模式时,所有的操作都集中在访问者类中进行管理。

    • 假设有多个元素需要执行不同的操作,访问者模式将这些操作集中到访问者中,避免了分散在各个策略中的问题,便于管理和维护。

    • 策略模式则往往需要将每个策略分散在不同的策略类中,随着策略增多,管理和维护会变得越来越困难,尤其是当策略之间有依赖或交互时,复杂性会迅速增加。



  • 适合复杂结构对象的处理



    • 访问者模式特别适合在对象结构复杂且需要遍历的场景中使用。

    • 例如,树形结构或对象图的遍历,这时每个节点的处理逻辑可以独立出来,并通过访问者来实现。

    • 访问者可以对这些节点类型的元素进行访问和操作,无需修改元素类本身。

    • 策略模式一般用于动态地改变同一对象的行为,不适合处理复杂的对象结构,特别是当需要在多个元素中进行遍历和操作时,策略模式会显得不够灵活。



  • 总结:



    • 访问者模式更适合在你需要对复杂结构的对象执行多个操作,并且希望操作与对象本身分离的场景。

    • 它更方便扩展,避免了复杂的继承结构或不断修改已有类。

    • 而策略模式则适合于在单一对象上动态替换行为,但对于复杂对象结构的处理往往会导致逻辑分散,扩展性差。




0.1代码结构图


访问者模式-商品打折场景-代码结构图.drawio.png


0.2业务流程图


访问者模式业务流程图.png


0.3请求Json


localhost:8080/VisitorPattern/calculateDiscount
   POST类型
  ["electronics", "clothing", "food"]


1.代码结构


1.1Pojo


package com.xiaoyongcai.io.designmode.pojo.VisitorPattern;

public interface Product {
   void accept(ProductVisitor visitor);
}


package com.xiaoyongcai.io.designmode.pojo.VisitorPattern;

import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Clothing;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Electronics;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Food;

public interface ProductVisitor {
   void visit(Electronics electronics);
   void visit(Clothing clothing);
   void visit(Food food);
}


package com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl;

import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.Product;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductVisitor;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Clothing implements Product {
   private double price;
   @Override
   public void accept(ProductVisitor visitor) {
       visitor.visit(this);  // 传递给访问者
  }
}


package com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl;

import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.Product;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductVisitor;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Electronics implements Product {
   private double price;
   @Override
   public void accept(ProductVisitor visitor) {
       visitor.visit(this);  // 传递给访问者
  }
}


package com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl;

import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.Product;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductVisitor;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Food implements Product {
   private double price;
   @Override
   public void accept(ProductVisitor visitor) {
       visitor.visit(this);  // 传递给访问者
  }
}


package com.xiaoyongcai.io.designmode.pojo.VisitorPattern.VisitorImpl;

import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Clothing;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Electronics;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Food;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductVisitor;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Data
@AllArgsConstructor
@NoArgsConstructor
@Slf4j
public class DiscountVisitor implements ProductVisitor {
   private double totalDiscount = 0;
   @Override
   public void visit(Electronics electronics) {
       //电子产品打八折
       totalDiscount += electronics.getPrice()*0.2;
       log.info("[访问者模式]:电子产品打8折后价格为"+electronics.getPrice()*0.2+"原价为"+electronics.getPrice());

  }

   @Override
   public void visit(Clothing clothing) {
       //衣物商品打7折
       totalDiscount+=clothing.getPrice()*0.3;
       log.info("[访问者模式]衣物商品打7折后价格为"+clothing.getPrice()*0.2+"原价为"+clothing.getPrice());
  }

   @Override
   public void visit(Food food) {
       //食品商品打9折
       totalDiscount += food.getPrice()*0.1;
       log.info("[访问者模式]食品商品打9折后价格为"+food.getPrice()*0.2+"原价为"+food.getPrice());
  }
}


1.2Service


package com.xiaoyongcai.io.designmode.Service.VisitorPattern;

import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.Product;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.VisitorImpl.DiscountVisitor;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class ProductService {

   public double calculateTotalDiscount(List<Product> products) {
       // 创建一个DiscountVisitor实例
       DiscountVisitor discountVisitor = new DiscountVisitor();

       // 遍历每个商品,执行折扣计算
       for (Product product : products) {
           product.accept(discountVisitor);
      }

       // 返回总折扣
       return discountVisitor.getTotalDiscount();
  }
}


1.3Controller


package com.xiaoyongcai.io.designmode.Controller.VisitorPattern;

import com.xiaoyongcai.io.designmode.Service.VisitorPattern.ProductService;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.Product;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Clothing;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Electronics;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Food;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.ArrayList;
import java.util.List;

@RestController
@RequestMapping("VisitorPattern")
public class ProductController {

   @Autowired
   private ProductService productService;

   @PostMapping("/calculateDiscount")
   public String calculateDiscount(@RequestBody List<String> productTypes) {
       List<Product> products = new ArrayList<>();

       // 根据传入的商品类型,创建不同的商品实例
       for (String type : productTypes) {
           switch (type) {
               case "electronics":
                   products.add(new Electronics(100));  // 假设价格是100
                   break;
               case "clothing":
                   products.add(new Clothing(150));    // 假设价格是150
                   break;
               case "food":
                   products.add(new Food(50));         // 假设价格是50
                   break;
          }
      }
       productService.calculateTotalDiscount(products);
       // 调用Service层计算折扣
       return "请在控制台检查访问者模式是否工作";
  }
}


作者:Asthenia0412
来源:juejin.cn/post/7440842636228919348
收起阅读 »

那些大厂架构师是怎样封装网络请求的?

好的设计是成功的一半,好的设计思想为后面扩展带来极大的方便 一、前言 网络请求在开发中是必不可少的一个功能,如何设计一套好的网络请求框架,可以为后面扩展及改版带来极大的方便,特别是一些长期维护的项目。作为一个深耕Android开发十几载的大龄码农,深深的体会...
继续阅读 »

5235a0e62ecd314a216da5209ff88326.jpeg



好的设计是成功的一半,好的设计思想为后面扩展带来极大的方便



一、前言


网络请求在开发中是必不可少的一个功能,如何设计一套好的网络请求框架,可以为后面扩展及改版带来极大的方便,特别是一些长期维护的项目。作为一个深耕Android开发十几载的大龄码农,深深的体会到。


网络框架的发展:


1. 从最早的HttpClientHttpURLConnection ,那时候需要自己用线程池封装异步,Handler切换到UI线程,要想从网络层就返回接收实体对象,也需要自己去实现封装


2. 后来,谷歌的 Volley, 三方的 Afinal 再到 XUtils 都是基于上面1中的网络层再次封装实现


3. 再到后来,OkHttp 问世,Retrofit 空降,从那以后基本上网络请求应用层框架就是 OkHttp Retrofit 两套组合拳,基本打遍天下无敌手,最多的变化也就是在这两套组合拳里面秀出各种变化,但是思想实质上还是这两招。


我们试想:从当初的大概2010年,2011年,2012年开始,就启动一个App项目,就网络这一层的封装而言,随着时代的潮流,技术的演进,我们势必会经历上面三个阶段,这一层的封装就得重构三次。


现在是2024年,往后面发展,随着http3.0的逐渐成熟,一定会出现更好的网络请求框架

我们怎么封装一套更容易扩展的框架,而不必每次重构这一层时,改动得那么困难。


本文下面就示例这一思路如何封装,涉及到的知识,jetpack 中的手术刀: Hilt 成员来帮助我们实现。


二 、示例项目


36c2d036-472c-4aa1-acbc-a15bafe2ae6f.jpeg



  1. 上图截图圈出的就是本文重点介绍的内容:怎么快速封装一套可以切换网络框架的项目 及相关 Jetpack中的 Hilt 用法

  2. 其他的1,2,3,4是之前我写的:花式封装:Kotlin+协程+Flow+Retrofit+OkHttp +Repository,倾囊相授,彻底减少模版代码进阶之路,大家可以参考,也可以在它的基础上,再结合本文再次封装,可以作为 花式玩法五


三、网络层代码设计


1. 设计请求接口,包含请求地址 Url,请求头,请求参数,返回解析成的对象Class :


interface INetApi {
/**
* Get请求
* @param url:请求地址
* @param clazzR:返回对象类型
* @param header:请求头
* @param map:请求参数
*/


suspend fun <R> getApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>? = null, map: MutableMap<String, Any>? = null): R

/**
* Get请求
* @param url:请求地址
* @param clazzR:返回对象类型
* @param header:请求头
* @param map:请求参数
* @param body:请求body
*/

suspend fun <R> postApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>? = null, body: String? = null): R
}

2. 先用早期 HttpURLConnection 对网络请求进行实现:


class HttpUrlConnectionImpl  constructor() : INetApi {
private val gson by lazy { Gson() }

override suspend fun <R> getApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, map: MutableMap<String, Any>?): R {
//这里HttpUrlConnectionRequest内部是HttpURLConnection的Get请求真正的实现
val json = HttpUrlConnectionRequest.getResult(BuildParamUtils.buildParamUrl(url, map), header)
android.util.Log.e("OkhttpImpl", "HttpUrlConnection 请求:${json}")
return gson.fromJson<R>(json, clazzR)
}

override suspend fun <R> postApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, body: String?): R {
////这里HttpUrlConnectionRequest内部是HttpURLConnection的Post请求真正的实现
val json = HttpUrlConnectionRequest.postData(url, header, body)
return gson.fromJson<R>(json, clazzR)
}
}

3. 整个项目 build.gradle 下配置 Hilt插件


buildscript {
dependencies {
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.42'
}
}

4. 工程app的 build.gradle 下引入:


先配置:


plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'dagger.hilt.android.plugin'//Hilt使用
id 'kotlin-kapt'//
}

里面的 android 下面添加:


kapt {
generateStubs = true
}

dependencies 里面引入 Hilt 使用


//hilt
implementation "com.google.dagger:hilt-android:2.42"
kapt "com.google.dagger:hilt-android-compiler:2.42"
kapt 'androidx.hilt:hilt-compiler:1.0.0'

5. 使用 Hilt


5.1 在Application上添加注解 @HiltAndroidApp

@HiltAndroidApp
class MyApp : Application() {

}

5.2 在使用的Activity上面添加注解 @AndroidEntryPoint

@AndroidEntryPoint
class MainActivity : BaseViewModelActivity<MainViewModel>(R.layout.activity_main), View.OnClickListener {

override fun onClick(v: View?) {
when (v?.id) {
R.id.btn1 -> {
viewModel.getHomeList()
}
else -> {}
}
}
}

5.3 在使用的ViewModel上面添加注解 @HiltViewModel@Inject

@HiltViewModel
class MainViewModel @Inject constructor(private val repository: NetRepository) : BaseViewModel() {


fun getHomeList() {
flowAsyncWorkOnViewModelScopeLaunch {
repository.getHomeList().onEach {
val title = it.datas!![0].title
android.util.Log.e("MainViewModel", "one 111 ${title}")
errorMsgLiveData.postValue(title)
}
}
}
}

5.4 在 HttpUrlConnectionImpl 构造方法上添加注解 @Inject 如下:

class HttpUrlConnectionImpl @Inject constructor() : INetApi {
private val gson by lazy { Gson() }

override suspend fun <R> getApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, map: MutableMap<String, Any>?): R {
val json = HttpUrlConnectionRequest.getResult(BuildParamUtils.buildParamUrl(url, map), header)
android.util.Log.e("OkhttpImpl", "HttpUrlConnection 请求:${json}")
return gson.fromJson<R>(json, clazzR)
}

override suspend fun <R> postApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, body: String?): R {
val json = HttpUrlConnectionRequest.postData(url, header, body)
return gson.fromJson<R>(json, clazzR)
}
}

5.5 新建一个 annotationBindHttpUrlConnection 如下:

@Qualifier
@Retention(RetentionPolicy.RUNTIME)
annotation class BindHttpUrlConnection()

5.6 再建一个绑定网络请求的 abstract 修饰的类 AbstractHttp 如下:让 @BindHttpUrlConnectionHttpUrlConnectionImpl 在如下方法中通过注解绑定

@InstallIn(SingletonComponent::class)
@Module
abstract class AbstractHttp {


@BindHttpUrlConnection
@Singleton
@Binds
abstract fun bindHttpUrlConnection(h: HttpUrlConnectionImpl): INetApi
}

5.7 在viewModel持有的仓库类 NetRepository 的构造方法中添加 注解 @Inject,并且申明 INetApi,并且绑定注解 @BindHttpUrlConnection 如下: 然后即就可以开始调用 INetApi 的方法

class NetRepository @Inject constructor(@BindHttpUrlConnection val netHttp: INetApi) {

suspend fun getHomeList(): Flow<WanAndroidHome> {
return flow {
netHttp.getApi("https://www.wanandroid.com/article/list/0/json", HomeData::class.java).data?.let { emit(it) }
}
}
}

到此:Hilt使用就配置完成了,那边调用 网络请求就直接执行到 网络实现 类 HttpUrlConnectionImpl 里面去了。


运行结果看到代码执行打印:


7742b372-a54e-4110-9df5-2e2402c033f1.jpeg


5.8 我们现在切换到 Okhttp 来实现网络请求:

新建 OkhttpImpl 实现 INetApi 并在其构造方法上添加 @Inject 如下:


class OkhttpImpl @Inject constructor() : INetApi {

private val okHttpClient by lazy { OkHttpClient() }
private val gson by lazy { Gson() }

override suspend fun <R> getApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, map: MutableMap<String, Any>?): R {
try {
val request = Request.Builder().url(buildParamUrl(url, map))
header?.forEach {
request.addHeader(it.key, it.value)
}
val response = okHttpClient.newCall(request.build()).execute()
if (response.isSuccessful) {
val json = response.body?.string()
android.util.Log.e("OkhttpImpl","okhttp 请求:${json}")
return gson.fromJson<R>(json, clazzR)
} else {
throw RuntimeException("response fail")
}
} catch (e: Exception) {
throw e
}
}

override suspend fun <R> postApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, body: String?): R {
try {
val request = Request.Builder().url(url)
header?.forEach {
request.addHeader(it.key, it.value)
}
body?.let {
request.post(RequestBodyCreate.toBody(it))
}
val response = okHttpClient.newCall(request.build()).execute()
if (response.isSuccessful) {
return gson.fromJson<R>(response.body.toString(), clazzR)
} else {
throw RuntimeException("response fail")
}
} catch (e: Exception) {
throw e
}
}
}

5.9 再建一个注解 annotation 类型的 BindOkhttp 如下:

@Qualifier
@Retention(RetentionPolicy.RUNTIME)
annotation class BindOkhttp()

5.10 在 AbstractHttp 类中添加 @BindOkhttp 绑定到 OkhttpImpl,如下:

@InstallIn(SingletonComponent::class)
@Module
abstract class AbstractHttp {

@BindOkhttp
@Singleton
@Binds
abstract fun bindOkhttp(h: OkhttpImpl): INetApi

@BindHttpUrlConnection
@Singleton
@Binds
abstract fun bindHttpUrlConnection(h: HttpUrlConnectionImpl): INetApi
}

5.11 现在只需要在 NetRepository 中持有的 INetApi 修改其绑定的 注解 @BindHttpUrlConnection 改成 @BindOkhttp 便可以将项目网络请求全部改成由 Okhttp来实现了,如下:

//class NetRepository @Inject constructor(@BindHttpUrlConnection val netHttp: INetApi) {
class NetRepository @Inject constructor(@BindOkhttp val netHttp: INetApi) {

suspend fun getHomeList(): Flow<WanAndroidHome> {
return flow {
netHttp.getApi("https://www.wanandroid.com/article/list/0/json", HomeData::class.java).data?.let { emit(it) }
}
}
}

运行执行结果截图可见:


ff042ce9-2e1b-452a-82a1-ddbebef25779.jpeg


到此:网络框架切换就这样简单的完成了。


四、总结



  1. 本文重点介绍了,怎么对网络框架扩展型封装:即怎么可以封装成快速从一套网络请求框架,切换到另一套网络请求上去

  2. 借助于 Jetpack中成员 Hilt 对其整个持有链路进行切割,简单切换绑定网络实现框架1,框架2,框架xxx等。


项目地址


项目地址:

github地址

gitee地址


感谢阅读:


欢迎 点赞、收藏、关注


这里你会学到不一样的东西


作者:Wgllss
来源:juejin.cn/post/7435904232597372940
收起阅读 »

这段时间 weapp-vite 的功能更新与优化

web
这段时间 weapp-vite 的功能更新与优化 自从上次宣布 weapp-vite 的发布,已经过去三个月;weapp-vite 也逐渐迭代至 1.7.6 版本。 在此期间,我对其进行了多项功能的增强和优化,接下来我将为大家详细介绍近期的阶段性成果。 下面...
继续阅读 »

这段时间 weapp-vite 的功能更新与优化


自从上次宣布 weapp-vite 的发布,已经过去三个月;weapp-vite 也逐渐迭代至 1.7.6 版本。


在此期间,我对其进行了多项功能的增强和优化,接下来我将为大家详细介绍近期的阶段性成果。



下面列出的功能皆为增强特性,开发者可自由选择启用或关闭,不影响原生小程序的兼容性。



核心功能更新


1. 自动构建 npm


在项目启动时,weapp-vite 会自动构建 npm 依赖,无需再手动点击微信开发者工具中的 构建 npm,提升了一定程度的开发体验。


详细信息请参考:自动构建 npm 文档


2. 语法增强


2.1 JSON 文件增强

1. 支持注释

weapp-vite 支持在项目中的 JSON 文件中添加注释。例如:


{
/* 这是一个组件 */
"component": true,
"styleIsolation": "apply-shared",
"usingComponents": {
// 导航栏组件
"navigation-bar": "@/navigation-bar/navigation-bar"
}
}

这些注释会在最终产物内被去除。



注意: project.config.jsonproject.private.config.json 不支持注释,因为这些文件直接由微信开发者工具读取。



2. 智能提示

我生成了许多小程序的 $schema 文件,部署在 vite.icebreaker.top 上。


通过指定 JSON$schema 字段,实现了配置文件的智能提示功能,优化了一点点开发体验。


QQ20241117-004208.png


详见:JSON 配置文件的智能提示


3. 别名支持

可以在 vite.config.ts 中配置 jsonAlias.entries 字段, 在 usingComponents 中使用别名定义路径,这些在构建时会自动转化为相对路径。


例如:


import type { UserConfig } from 'weapp-vite/config'
import path from 'node:path'

export default <UserConfig>{
weapp: {
jsonAlias: {
entries: [
{
find: '@',
replacement: path.resolve(__dirname, 'components'),
},
],
},
},
}

那么就可以在 json 中这样编写:


{
"usingComponents": {
"navigation-bar": "@/navigation-bar/navigation-bar",
"ice-avatar": "@/avatar/avatar"
}
}

构建结果:


{
"usingComponents": {
"navigation-bar": "../../components/navigation-bar/navigation-bar",
"ice-avatar": "../../components/avatar/avatar"
}
}

4. 编程支持

weapp-vite 支持使用 JS/TS 文件来编写 JSON,你需要将 component.json 更改为 component.json.ts



智能提示定义 API 都在 weapp-vite/json 中导出



比如普通写法:


import { defineComponentJson } from 'weapp-vite/json'

export default defineComponentJson({
component: true,
styleIsolation: 'apply-shared',
usingComponents: {},
})

还支持引入异步数据、编译时变量或其他文件:


import type { Page } from 'weapp-vite/json'
import fs from 'node:fs/promises'
import path from 'node:path'
import shared0 from '@/assets/share'
import shared1 from './shared.json'

console.log('import.meta.env: ', import.meta.env)
console.log('import.meta.dirname: ', import.meta.dirname)
console.log('MP_PLATFORM: ', import.meta.env.MP_PLATFORM)
console.log(import.meta.env.DEV, import.meta.env.MODE, import.meta.env.PROD)
const key = await fs.readFile(
path.resolve(import.meta.dirname, 'x.txt'),
'utf8'
)

export default <Page>{
usingComponents: {
't-button': 'tdesign-miniprogram/button/button',
't-divider': 'tdesign-miniprogram/divider/divider',
'ice-avatar': '@/avatar/avatar',
},
...shared0,
...shared1,
key,
}

2.2 WXML 文件增强

事件绑定语法糖

weapp-vite 借鉴了 Vue 的事件绑定风格,为 WXML 增加了事件绑定语法糖:


这里我们以最常用的 tap 事件为例:


<!-- 原始代码 -->
<view @tap="onTap"></view>
<!-- 编译后 -->
<view bind:tap="onTap"></view>

支持的事件绑定增强规则如下:


源代码编译结果
@tapbind:tap
@tap.catchcatch:tap
@tap.mutmut-bind:tap
@tap.capturecapture-bind:tap
@tap.capture.catch / @tap.catch.capturecapture-catch:tap

详见:事件绑定增强文档


这部分还能做的更多,欢迎与我进行讨论!


2.3 WXS 增强

编程支持(实验性)

weapp-viteWXS 提供了 JS/TS 编程支持,支持通过更改 wxs 后缀为 wxs.jswxs.ts 文件定义逻辑:


比如 index.wxs.ts:


export const foo = '\'hello world\' from hello.wxs.ts'

export const bar = function (d: string) {
return d
}

另外内联 WXS 也支持使用 lang="js"lang="ts" 直接启用编译功能:


<view>{{test.foo}}</view>
<view @tap="{{test.tigger}}">{{test.abc}}</view>

<wxs module="test" lang="ts">
const { bar, foo } = require('./index.wxs.js')
const bbc = require('./bbc.wxs')
export const abc = 'abc'

export function tigger(value:string){
console.log(abc)
}

export {
foo,
bar,
bbc
}
</wxs>

详情请参考:Wxs 增强


3. 生成脚手架


weapp-vite 内置了生成脚手架工具,可快速生成一系列文件(如 jswxmlwxssjson),用于提升开发效率。


最基础的用法只需要 weapp-vite g [outDir]


详情请参考:生成脚手架文档


4. 分包支持


针对普通分包和独立分包的加载需求进行了优化,用户几乎无需额外配置即可实现分包加载。


尤其是独立分包的场景,创建了独立的编译上下文。


详情请参考:分包加载文档


不忘初心,持续改进


weapp-vite 的初衷是实现对原生小程序的增强,现有原生小程序几乎可以零成本地迁移过来,并享受更高效的开发体验。


在此,希望各位开发者试用,欢迎反馈与参与。


如果您对文中的任何功能或增强有疑问、建议,欢迎到 Github Discussions 提出讨论!


作者:icebreaker
来源:juejin.cn/post/7437876830487363599
收起阅读 »

JavaScript内存管理机制解析

web
前言 内存,作为计算机系统中存储数据和指令的关键资源,其管理效率直接影响着程序的性能和稳定性。在JavaScript的世界里,理解内存机制并非遥不可及,每一位开发者必须面对并掌握的实用技能。无论是初涉开发的新手,还是经验丰富的老手,深入理解JavaScript...
继续阅读 »

前言


内存,作为计算机系统中存储数据和指令的关键资源,其管理效率直接影响着程序的性能和稳定性。在JavaScript的世界里,理解内存机制并非遥不可及,每一位开发者必须面对并掌握的实用技能。无论是初涉开发的新手,还是经验丰富的老手,深入理解JavaScript的内存机制都是通往更高层次编程能力的必经之路。


语言类型


静态语言


静态语言是指在编译时变量的数据类型就已经确定的语言,比如java定义一个整数类型需要先用int去定义一个变量。这类语言在编写程序时,要求开发者明确地声明变量的类型,并且在程序的整个生命周期内,该变量的类型都不能改变。换句话说,静态语言的类型检查是在编译阶段完成的,而不是在运行时,常见的静态语言包括Java、C++、C#、Go等。


动态语言


动态语言(Dynamic Language),也称为动态编程语言或动态类型语言,与静态语言相反,是指在程序运行时可以改变其结构的语言。这种改变可能包括引进新的函数、删除已有的函数,或者在运行时确定变量的类型等。动态语言的特点使得它们通常具有更高的灵活性和表达能力。常见的动态语言有我们学的JavaScript,还有Python,PHP等。


弱类型语言


弱类型语言是指变量的类型检查和转换方式相对宽松的一种编程语言。在弱类型语言中,变量可以在不明确声明类型的情况下直接使用,并且在运行时可以自动改变类型,或者可以在不同类型之间自由进行操作和转换,常见的弱类型语言包括JavaScript、Python等。


强类型语言


强类型语言(Strongly Typed Language)是一种在编译时期就进行类型检查的编程语言。这类语言要求变量在使用前必须明确声明其类型,并且在使用过程中,变量的类型必须保持一致,不能随意更改,常见的强类型语言包括Java、C++、C#、Go等。


数据类型


在每种语言里面都会有一个方法去查看数据的类型,js也不例外,我们可以用typeof去查看一个数据的类型,那我们来看看js中所有的数据类型吧


let a = 1
// console.log(typeof a); //Number
a = 'hello'
// console.log(typeof a); //String
a = true
// console.log(typeof a); //boolean
a = null
// console.log(typeof a); //object
a = undefined
// console.log(typeof a); //undefined
a = Symbol(1)
// console.log(typeof a); //symbol
a = 123n
// console.log(typeof a); //bigint
a = []
// console.log(typeof a); // object
a = {}
// console.log(typeof a); //object
a = function () {}
// console.log(typeof a); // function

我们可以看到所有判断类型的结果,大部分还正常,可是数组和null怎么也被判断成了object类型呢?


那我们要来了解一下typeof的判断原理,怎么给a判断出来它的数据类型的呢,其实是通过转换为计算机能看懂的二进制,然后通过二进制的数据进行的分析,所有的引用类型转换成二进制前三位一定是零,然后数组是引用类型,而typeof判断时如果前三位是零,那么就无脑认为它是object类型,但是函数是一个特例,在js中函数是一个对象,它做了一些特殊操作,所以能够判断出来,但是null是原始数据类型,为什么也能被判断为object类型呢,因为null在被读取成二进制时,它会被读取为全是零。而这个不是编程语言能够决定的,在计算机创建出来时就是这样设定的,因此这是一个bug,在设计这门语言的的bug,这个bug如果要修复并不困难,但是一旦修复,所有用js语言开发的项目都需要修复,影响太大,因此这个bug就被默认为js语言里面的规则。


内存空间


内存空间的分布


在v8引擎执行你写的代码时,会占用一部分的运行空间,而执行时占用的内存空间在v8的视角里会被分布成这个样子的


46.png


代码空间是专门存储你所写的代码,栈空间就是我们之前讲过的调用栈juejin.cn/post/743706…


用来存储函数被调用时,它的执行上下文,维护函数的调用关系,调用栈被分布的空间是比较小的。


堆空间(Heap Space)是内存管理的一个重要部分,它用于存储动态分配的对象和数据结构。


栈和堆之间的关系


让我们来看看栈和堆之间的关系


function foo() {
var a = 1
var b = a
var c = {name: '熊总'}
var d = c
}
foo()

47.png


此时foo函数已经完成编译,且已经执行到了b=a这一行,然后将一个对象赋值给c的时候,并不会直接把这个对象存储在函数的执行上下文里面,而是会在旁边在创建一个堆空间,将对象存储在堆空间里面,而这个c存储的就是对象在堆空间的地址值


48.png
然后在执行将c的值赋给d其实就是将对象的地址值赋值给了d,因此cd的地址值指向的是同一个对象,并没有创建出一个新的对象,如果这个对象发生改变,那么cd所代表的对象都会发生改变。


那为什么原始数据类型可以直接存储在栈当中,而引用数据类型却要存储在堆空间里面,因为原始类型数据所占的空间小,而引用数据类型所占的空间较大,比如一个对象,它可以有无数个属性,而原始类型,它就只有一个固定的值,所占内存不大,而栈被分布的空间比较小,堆被分布的空间比较大,因此原始数据类型可以直接存储在栈当中,而引用数据类型要存储在堆当中。


栈设计为什么这么小


首先我们要明白栈是用来维护函数的调用关系,而如果将栈设计的很大,那么程序员就可以写很长作用域链,并且不考虑代码的执行效率,写出不断嵌套的屎山代码。举个例子,栈就好比在你身上的空间,比如你的衣服裤子口袋,而堆就相当于一个分层的柜子,你把衣服上的口袋设计的很大,不要柜子,把你的东西全部装在口袋里面,首先看起来就十分丑陋,其次,你如果想将你想要的东西拿出来就要在口袋里翻来覆去的找,那样的效率是很低的


成果检验


function fn(person) {
person.age = 19
person = {
name: '庆玲',
age: 19
}
return person
}
const p1 = {
name: '凤如',
age: 18
}
const p2 = fn(p1)

console.log(p1);
console.log(p2);

请分析上面的代码中的p1p2的输出结果


49.png
我们创建全局上下文进行编译执行,然后对函数fn进行编译,编译过程中形参和实参要进行统一,接下来,我们要开始执行函数fn了,首先它将p1所指向的对象age修改为了19,然后再函数中它将p1的地址值修改指向为了新对象,并将新对象返回,然后在全局接着执行,将返回的地址值赋给了p2,所以p2的值就是函数中新对象的地址值,接下来输出p1,此时函数已经执行完毕,在调用栈中被销毁了,那我们就在全局中查找,在全局中p1的指向就是#001,但是函数销毁前他将地址值为#001的对象age属性修改为19,所以p1打印出来的只有age改为了19,而p2就是返回的新对象的值,然我们看看结果是不是我们分析的那样


50.png


没错,p1name为'凤如',age19p2name为'庆玲',age为19


最后来一道添加闭包的内存管理机制代码分析,如果不熟悉闭包的概念,可以先看看这篇文章
](juejin.cn/post/743814…)


function foo() {
var myname = '彭于晏'
let test1 = 1
const test2 = 2
var innerBar = {
setName: function (name) {
myname = name
},
getName: function () {
console.log(test1);
return myname
}
}
return innerBar
}
var bar = foo()
bar.setName('金城武')
console.log(bar.getName());

总结


本文探讨了JavaScript的内存机制,包括语言类型(静态与动态、强类型与弱类型)、数据类型及typeof的判断原理,并解析了内存空间的分布,特别是栈空间和堆空间的作用及它们之间的关系。通过示例代码,阐述了原始数据类型和引用数据类型在内存中的存储差异,以及栈为何设计得相对较小的原因。最后,通过实际代码演示和结果分析,检验了对JavaScript内存机制的理解。本文是掌握JavaScript编程能力的关键一步,适合各层次开发者阅读。


作者:竺梓君
来源:juejin.cn/post/7440717815709057050
收起阅读 »

surya,一个优秀的OCR开源项目,更专注于表格识别

写这篇文件,因为一条评论。 我写过识别计算器屏幕的文章,讲了它可以独立部署在App、小程序、树莓派等嵌入式设备上。有个老哥说,这都是应用多年的老技术了。 他说得没错,可能多少年前就有了。不过,实际上,一项技术不管应用了多少年,每年依然还有人花钱去做新的。 不...
继续阅读 »

写这篇文件,因为一条评论。


chat.png


我写过识别计算器屏幕的文章,讲了它可以独立部署在App、小程序、树莓派等嵌入式设备上。有个老哥说,这都是应用多年的老技术了。


他说得没错,可能多少年前就有了。不过,实际上,一项技术不管应用了多少年,每年依然还有人花钱去做新的。


不知道八零、九零后是否还记得,零几年时的非智能手机,就已经有了手写识别这个功能。甚至它还给你配一个手写笔。


mobile.png


即便这项技术已经30年了。你现在去软件企业问问,如果他家的产品需要一个手写识别的功能,他是不是依然还得花钱买第三方的服务。


为啥?


这个问题非常好,值得思考。


首先,那时候的技术和现在不一样。在非AI时代,手写识别用的是模板匹配技术,是基于规则的。你写的字,它要拿去库里同模板做比对。就像是机器人客服,靠关键字回复一样。你问“房子”它知道,问“屋子”它说听不懂。而现在的手写识别是基于深度学习的,你问它house,它一样知道是住的地方。


其次,就算技术没变化,它的落地实践也是有壁垒的。这体现在两点。第一,给你一个开源项目,你不一定能用起来。第二,每个人的细分需求不一样,就算你能跑起来,也很难改动,个性化定制。


今天,我就讲解一个开源的OCR项目。以此论证我的观点。


我看到这个项目公布好几个月了,各类新闻也是来了一波又一波:《比xx更好的用OCR》《表格识别神器》《今年最火的十个开源项目》……


大家都在传播、转发,哎呀,这个东西好,好用。包括我做卷帘门的朋友都分享给我,说这个好用。但是,没有谁发一篇文章,说他真的用了,是如何用的,效果怎么样,它的原理是什么,它是如何训练的,它有什么优点,它有什么缺点,缺点是否能优化,该如何优化。今天,我就来填补一下。不然又会有人说,哎呀,这东西早就解决,零成本,多少年前就很成熟了。


这个项目的名字叫surya,是一个OCR识别项目,开源地址是 github.com/VikParuchuri/surya ,目前在github上拥有14K个star。它支持本地化部署,面对年收入低于500万美元的商用,可免费。


我在自己电脑上搭了一套,CPU就可以运行,GPU效率更高。稍微试验了下,给大家展示下功能。


一、功能展示


我拿这张图来进行试验,这是某报纸中的一篇新闻稿件。


a16.png


它可以检测出图中有哪些类型的结构。比如,段落、图片、标题等。下面的图,就是将检测到的区域标记展示。


a16_type.jpg


另外,区域检测还附赠一个阅读顺序的功能。所谓阅读顺序,就是你读这篇文档时,先读哪里后读哪里。比如按照从左到右,从上到下的顺序。有时候,阅读顺序也很重要,不然容易剧透。


a16_order.jpg


既然是OCR,那么必定要将图像转为文字。想要转换文字,首先得知道哪些区域是文字。这个文本行检测,它也有。


a16_text.jpg


检测到文字的位置,下一步就是识别文字了。下面是识别的结果。


a16_ocr.jpg


最后,展示一下,它的表格识别。测试图片这样。


table_o.png


做一下表格检测,效果如下。
table_row.jpg


从识别到的数据信息看,它有4行,3列,12个单元格。
table_info.png


再来进行ocr内容识别。
table_ocr.jpg


二、算法集成


上面是它的功能。咱先不谈效果。下面我想问,它为什么能做到这些?回答完这个问题,才能更好地理解它的水平。


作者在最后列举了很多感谢,他说不借助这么多优秀的开源模型,他不可能完成这个项目。比如感谢CRAFT项目,这是一个超3k star的开源文本检测模型。


craft_example.gif


再比如它还采用了donut,这是一个利用无OCR理解文档的新方法。我们知道,想要理解文档,一般先要知道它上面写了什么,然后对文档进行分析,才能做出判断。而Donut结合多模态直接解析图像,极少处理文字,省去了全文分析的这个步骤。


craft_example.gif


看上面这张图。你问donut这张图的标题是什么?它可以正确地回答出来。这就是对文档的理解。


因此,从算法层面上,surya是借助了很多顶级的开源模型。而那些模型,也是站在巨人的肩膀上。可以说,它集成的算法是目前公开的一流水平。


我们再来说它的训练数据。他的训练数据,我们可以在 huggingface.co/vikp 上找到。


三、训练数据


比如文本区域类型检测,它的训练数据是这样的:


dataset3_layout_list.png


我们来看它的其中一组数据。image是一张图,bboxes是区域框,labels是区域类型,有文本类型,有表格类型。这些数据,是需要标注的,也就是在图片上画框,标注出区域类型。训练总量是1910张图片。不多。


比如表格的分析检测,它的训练数据是这样的:


dataset_table.png


image是一张表格图片,bboxes是单元格,rows是每一行的区域,cols是每一列的区域。把这些标记好的数据交给算法,让它学习什么样的特征的是行,什么样的是列。数据相对多一些,9680张图片。所以人家说它的表格识别很强。


对于文本行的检测,它的训练数据是这样的:


dataset_doclaynet.png


训练数据的结构组成:图片,图片中的某个区域,此区域对应的文本类型,另外还附加了一个文本内容。就拿上图选中的那条数据来说。这里面只标记了一条文本行区域。它告诉模型,这张图里面有一个类型为7的文本行,其区域是[88, 96, 865, 134](左、上、右、下)围成的矩形,请认真学习。


dataset_doclaynet2.png


最后到了,OCR识别了。


dataset_rec.png


训练数据的组成还是老一套,图片,区域框,文本内容。主要是告诉模型,我的这张图里面有几个区域,这些区域是什么文本内容,请你仔细学习。另外,这里面还有一个language字段,这表示文字的语言类型。


surya自称支持90多种语言的识别。这不是胡说,因为他的训练数据里,确实有90多种语言的标注。但是,总量太少了。一共4635张图片,如果平均的话,每种语言只有50来张训练数据。


因此,其实surya对于中文的OCR识别效果不是特别好(虽然自称并肩Tesseract)。其主要原因并不是算法不好,而是中文的训练数据太少。英文26个字母,50张纸可以覆盖。但是中文几万字,很难覆盖全。而对于手写识别,surya只能说随缘,因为根本没有训练数据,识别成啥样就是啥样。


这里面训练数据最多的是表格的识别,共9700张样本。训练数据最少的是阅读顺序的检测,才126张。因此,数据量决定了它的识别效果。对于海量的训练数据,就算对强大的商业公司来说也是一个难题。而作者能够凑足这几千张数据,已然很不容易了。


最终,我可以下一个结论。对于surya,它是一流的算法开源项目,是免费的。它对于表格的分析确实很强大。但是,它的训练数据还是太少,而且主要用于识别电子版的文档(清晰、无扭曲),手写识别基本不支持。如果你想要不做改动直接平替收费的OCR,可行性不高。就算是只用表格识别,你也得有稍微专业一些的人,进行从拍照到预处理成电子版的工作。如果收费的效果都不好,你想用免费替换它,可以打消这个念头。算法是开源的,但是训练数据和训练设备的投入,总得有人出。


如果,你想要学习并调整它,或者想自己训练,那么可以接着往下看。


四、源码运行


我不会讲官方ReadMe.md文档上明明白白写着的内容。比如你需要运行pip install streamlit。或者它有这么几个参数,第一个--langs是用于指定OCR的语言。


readme.png


这样的话,我很像一个复读机。


另外,既然你都想要研究它了,应该不至于按照操作都跑不起来。你去看看它的源码,我只讲关键点。


首先,下载源码。你在源码中可以看到两个文件pyproject.tomlpoetry.lock。这说明surya用的是poetry作为项目管理工具。poetry既能管理依赖包,也能管理虚拟环境。


咱们最好找一个Linux环境,再安装poetry。即便你在windows下,现在也可以很简单地安装个ubuntu虚拟机。因为linux实在是可以避免很多问题。


ubuntu.png


打开liunx命令行,进入到源码根目录。先运行pip install poetry,安装poetry。再运行poetry install安装依赖环境。最后运行poetry shell进入环境,你会看到:


(surya-ocr-py3.12) root@tf:/mnt/d/surya# 

这时运行surya_gui,会启动它的web页面。正常情况下,你会看到如下的输出:


https://huggingface.co/vikp

(surya-ocr-py3.12) root@tf:/mnt/d/surya# surya_gui

You can now view your Streamlit app in your browser.

Local URL: http://localhost:8501
Network URL: http://192.168.1.109:8501

gio: http://localhost:8501: Operation not supported
Loaded detection model /mnt/d/surya/vikp/surya_det3 on device cpu with dtype torch.float32
Loaded recognition model /mnt/d/surya/vikp/surya_rec2 on device cpu with dtype torch.float32
Loaded detection model /mnt/d/surya/vikp/surya_layout3 on device cpu with dtype torch.float32
Loaded reading order model /mnt/d/surya/vikp/surya_order on device cpu with dtype torch.float32
Loaded recognition model /mnt/d/surya/vikp/surya_tablerec on device cpu with dtype torch.float32

访问localhost:8501还有这样的页面:


web.png


但实际上,极有可能不正常。因为它在自动下载权重模型访问 huggingface.co 时会访问失败。这时,需要你想办法手动下载模型,然后放到一个固定的位置。


从报错信息能看到说加载不到模型。跟着代码就找到了surya/settings.py


# Text detection
DETECTOR_MODEL_CHECKPOINT: str = "vikp/surya_det3"
DETECTOR_BENCH_DATASET_NAME: str = "vikp/doclaynet_bench"

# Text recognition
RECOGNITION_MODEL_CHECKPOINT: str = "vikp/surya_rec2"
RECOGNITION_BENCH_DATASET_NAME: str = "vikp/rec_bench"

# Layout
LAYOUT_MODEL_CHECKPOINT: str = "vikp/surya_layout3"
LAYOUT_BENCH_DATASET_NAME: str = "vikp/publaynet_bench"

# Ordering
ORDER_MODEL_CHECKPOINT: str = "vikp/surya_order"
ORDER_BENCH_DATASET_NAME: str = "vikp/order_bench"

# Table Rec
TABLE_REC_MODEL_CHECKPOINT: str = "vikp/surya_tablerec"
TABLE_REC_BENCH_DATASET_NAME: str = "vikp/fintabnet_bench"
……

这里面是它5大功能(检测、识别、类型、排序、表格)的权重模型以及训练数据集的路径配置。正常情况下,会自动下载并缓存读取。但现在我们要自己下载并手动配置。下载方式就是去 huggingface.co/vikp 上找对应的模型文件。


用哪个就下载哪个模型文件,即用什么功能就下载什么功能。其实,对于新手来说,这并不好区分。因为有些功能是相互依赖的。比如表格识别,往往需要先检测出表格区域,才能识别行列区域。实际上会走好几个模型。因此,不熟悉的时候,把MODEL_CHECKPOINT全下载就行了。


DATASET_NAME是数据集,如果你要重新训练,就下载它。不调用训练的代码,不下载也不报错。


你可以把权重文件下载到项目的根目录。然后做如下的配置:


code.png


"vikp/surya_det3"改为os.path.join(BASE_DIR, "vikp/surya_det3")。因上面定义了BASE_DIR是项目根目录,所以这个路径没错。


后面再运行surya_gui就正常了。


访问 localhost:8501 可以上传文件进行5大功能的测试。


web2.png


它会展示相应的结果。


web3.png


而在控制台,也会输出操作类型和时间消耗:


Detecting bboxes: 100%|███████| 1/1 [00:02<00:00,  2.61s/it]
Detecting bboxes: 100%|███████| 1/1 [00:02<00:00, 2.06s/it]
Detecting bboxes: 100%|███████| 1/1 [00:02<00:00, 2.44s/it]
Recognizing tables: 100%|███████| 1/1 [00:01<00:00, 1.19s/it]

这样,你就可以研究它的源码了。你可以改一点代码,运行一下,查看变化。具体的功能模块和代码对应,官方readMe.md上有说明。不管是暴露接口能力,还是修改内部函数,或者重新训练自己的数据,都有了着手点。


五、总结


优秀的开源项目就像一个质量很好的毛坯房,相对于商业软件,它往往不具备舒适的居住条件。但是,它的底子很好,结构合理,质量精良。它想发达,需要有人去做一个精装修。但是反过来,有些商业软件去了精装修,很可能就是豆腐渣框架。


为什么说现在是数据为王的时代。从上面的论述可以发现,在一定时空内,算法是公开的,算力投钱就可以,可能就是数据难搞。有好的、大量的数据投喂,才能产生好的AI模型。

作者:TF男孩
来源:juejin.cn/post/7436713044246806578
收起阅读 »

前端如何做截图?

web
一、 背景 页面截图功能在前端开发中,特别是营销场景相关的需求中, 是比较常见的。比如截屏分享,相对于普通的链接分享,截屏分享具有更丰富的展示、更多的信息承载等优势。最近在需求开发中遇到了相关的功能,所以调研了相关的实现和原理。 二、相关技术 前端要实现页面截...
继续阅读 »

一、 背景


页面截图功能在前端开发中,特别是营销场景相关的需求中, 是比较常见的。比如截屏分享,相对于普通的链接分享,截屏分享具有更丰富的展示、更多的信息承载等优势。最近在需求开发中遇到了相关的功能,所以调研了相关的实现和原理。


二、相关技术


前端要实现页面截图的功能,现在比较常见的方式是使用开源的截图npm库,一般使用比较多的npm库有以下两个:



以上两种常见的npm库,对应着两种常见的实现原理。实现前端截图,一般是使用图形API重新绘制页面生成图片,基本就是SVG(dom-to-image)和Canvas(html2canvas)两种实现方案,两种方案目标相同,即把DOM转为图片,下面我们来分别看看这两类方案。


三、 dom-to-image


dom-to-image库主要使用的是SVG实现方式,简单来说就是先把DOM转换为SVG然后再把SVG转换为图片。


(一)使用方式


首先,我们先来简单了解一下dom-to-image提供的核心api,有如下一些方法:



  • toSvg (dom转svg)

  • toPng (dom转png)

  • toJpeg (dom转jpg)

  • toBlob (dom转二进制格式)

  • toPixelData (dom转原始像素值)


如需要生成一张png的页面截图,实现代码如下:


import domtoimage from "domtoimage"

const node = document.getElementById('node');
domtoimage.toPng(node,options).then((dataUrl) => {
const img = new Image();
img.src = dataUrl;
document.body.appendChild(img);
})

toPng方法可传入两个参数node和options。


node为要生成截图的dom节点;options为支持的属性配置,具体如下:filter,backgroundColor,width,height,style,quality,imagePlaceholder,cacheBust。


(二)原理分析


dom to image的源码代码不是很多,总共不到千行,下面就拿toPng方法做一下简单的源码解析,分析一下其实现原理,简单流程如下:


image.png


整体实现过程用到了几个函数:



  • toPng(调用draw,实现canvas=>png )

  • Draw(调用toSvg,实现dom=>canvas)

  • toSvg(调用cloneNode和makeSvgDataUri,实现dom=>svg)

  • cloneNode(克隆处理dom和css)

  • makeSvgDataUri(实现dom=>svg data:url)

  • toPng


toPng函数比较简单,通过调用draw方法获取转换后的canvas,利用toDataURL转化为图片并返回。


function toPng(node, options) {
return draw(node, options || {})
.then((canvas) => canvas.toDataURL());
}


  • draw


draw函数首先调用toSvg方法获得dom转化后的svg,然后将获取的url形式的svg处理成图片,并新建canvas节点,然后借助drawImage()方法将生成的图片放在canvas画布上。


function draw(domNode, options) {
return toSvg(domNode, options)
// 拿到的svg是image data URL, 进一步创建svg图片
.then(util.makeImage)
.then(util.delay(100))
.then((image) => {
// 创建canvas,在画布上绘制图像并返回
const canvas = newCanvas(domNode);
canvas.getContext("2d").drawImage(image, 0, 0);
return canvas;
});
// 新建canvas节点,设置一些样式的options参数
function newCanvas(domNode) {
const canvas = document.createElement("canvas");
canvas.width = options.width || util.width(domNode);
canvas.height = options.height || util.height(domNode);
if (options.bgcolor) {
const ctx = canvas.getContext("2d");
ctx.fillStyle = options.bgcolor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
return canvas;
}
}


  • toSvg

  • toSvg函数实现从dom到svg的处理,大概步骤如下:

  • 递归去克隆dom节点(调用cloneNode函数)

  • 处理字体,获取所有样式,找到所有的@font-face和内联资源,解析并下载对应的资源,将资源转为dataUrl给src使用。把上面处理完的css rules放入中,并把标签加入到clone的节点中去。
  • 处理图片,将img标签的src的url和css中backbround中的url,转为dataUrl使用。

  • 获取dom节点转化的dataUrl数据(调用makeSvgDataUri函数)


function toSvg(node, options) {
options = options || {};
// 处理imagePlaceholder、cacheBust值
copyOptions(options);
return Promise.resolve(node)
.then((node) =>
// 递归克隆dom节点
cloneNode(node, options.filter, true))
// 把字体相关的csstext放入style
.then(embedFonts)
// clone处理图片,将图片链接转换为dataUrl
.then(inlineImages)
// 添加options里的style放入style
.then(applyOptions)
.then((clone) =>
// node节点转化成svg
makeSvgDataUri(clone,
options.width || util.width(node),
options.height || util.height(node)));
// 处理一些options的样式
function applyOptions(clone) {
...
return clone;
}
}


  • cloneNode


cloneNode函数主要处理dom节点,内容比较多,简单总结实现如下:



  • 递归clone原始的dom节点,其中, 其中如果有canvas将转为image对象。

  • 处理节点的样式,通过getComputedStyle方法获取节点元素的所有CSS属性的值,并将这些样式属性插入新建的style标签上面, 同时要处理“:before,:after”这些伪元素的样式, 最后处理输入内容和svg。


function cloneNode(node, filter, root) {
if (!root && filter && !filter(node)) return Promise.resolve();
return Promise.resolve(node)
.then(makeNodeCopy)
.then((clone) => cloneChildren(node, clone, filter))
.then((clone) => processClone(node, clone));
function makeNodeCopy(node) {
// 将canvas转为image对象
if (node instanceof HTMLCanvasElement) return util.makeImage(node.toDataURL());
return node.cloneNode(false);
}
// 递归clone子节点
function cloneChildren(original, clone, filter) {
const children = original.childNodes;
if (children.length === 0) return Promise.resolve(clone);
return cloneChildrenInOrder(clone, util.asArray(children), filter)
.then(() => clone);
function cloneChildrenInOrder(parent, children, filter) {
let done = Promise.resolve();
children.forEach((child) => {
done = done
.then(() => cloneNode(child, filter))
.then((childClone) => {
if (childClone) parent.appendChild(childClone);
});
});
return done;
}
}
function processClone(original, clone) {
if (!(clone instanceof Element)) return clone;
return Promise.resolve()
.then(cloneStyle)
.then(clonePseudoElements)
.then(copyUserInput)
.then(fixSvg)
.then(() => clone);
// 克隆节点上的样式。
function cloneStyle() {
...
}
// 提取伪类样式,放到css
function clonePseudoElements() {
...
}
// 处理Input、TextArea标签
function copyUserInput() {
...
}
// 处理svg
function fixSvg() {
...
}
}
}


  • makeSvgDataUri


首先,我们需要了解两个特性:



  • SVG有一个元素,这个元素的作用是可以在其中使用具有其它XML命名空间的XML元素,换句话说借助标签,我们可以直接在SVG内部嵌入XHTML元素,举个例子:


<svg xmlns="http://www.w3.org/2000/svg">
<foreignObject width="120" height="50">
<body xmlns="http://www.w3.org/1999/xhtml">
<p>文字。</p>
</body>
</foreignObject>
</svg>

可以看到标签里面有一个设置了xmlns=“http://www.w3.org/1999/xhtml”…标签,此时标签及其子标签都会按照XHTML标准渲染,实现了SVG和XHTML的混合使用。



  • XMLSerializer对象能够把一个XML文档或Node对象转化或“序列化”为未解析的XML标记的一个字符串。


基于以上特性,我们再来看一下makeSvgDataUri函数,该方法实现node节点转化为svg,就用到刚刚提到的两个重要特性。


首先将dom节点通过


XMLSerializer().serializeToString() 序列化为字符串,然后在


标签 中嵌入转换好的字符串,foreignObject 能够在 svg


内部嵌入XHTML,再将svg处理为dataUrl数据返回,具体实现如下:


function makeSvgDataUri(node, width, height) {
return Promise.resolve(node)
.then((node) => {
// 将dom转换为字符串
node.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
return new XMLSerializer().serializeToString(node);
})
.then(util.escapeXhtml)
.then((xhtml) => `<foreignObject x="0" y="0" width="100%" height="100%">${xhtml}</foreignObject>`)
// 转化为svg
.then((foreignObject) =>
// 不指定xmlns命名空间是不会渲染的
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${
foreignObject}
</svg>`
)
// 转化为data:url
.then((svg) => `data:image/svg+xml;charset=utf-8,${svg}`);
}

四、 html2canvas


html2canvas库主要使用的是Canvas实现方式,主要过程是手动将dom重新绘制成canvas,因此,它只能正确渲染可以理解的属性,有许多CSS属性无法正确渲染。


支持的CSS属性的完整列表:


html2canvas.hertzen.com/features/


浏览器兼容性:


Firefox 3.5+ Google Chrome Opera 12+ IE9+ Edge Safari 6+


官方文档地址:


html2canvas.hertzen.com/documentati…


(一)使用方式


// dom即是需要绘制的节点, option为一些可配置的选项
import html2canvas from 'html2canvas'
html2canvas(dom, option).then(canvas=>{
canvas.toDataURL()
})

常用的option配置:


image.png


全部配置文档:


html2canvas.hertzen.com/configurati…


(二)原理分析


html2canvas的内部实现相对dom-to-image来说要复杂一些, 基本原理是读取DOM元素的信息,基于这些信息去构建截图,并呈现在canvas画布中。


其中重点就在于将dom重新绘制成canvas的过程,该过程整体的思路是:遍历目标节点和目标节点的子节点,遍历过程中记录所有节点的结构、内容和样式,然后计算节点本身的层级关系,最后根据不同的优先级绘制到canvas画布中。


由于html2canvas的源码量比较大,可能无法像dom-to-image一样详细的分析,但还是可以大致了解一下整体的流程,首先可以看一下源码中src文件夹中的代码结构,如下图:


image.png


简单解析一下:



  • index:入口文件,将dom节点渲染到一个canvas中,并返回。

  • core:工具函数的封装,包括对缓存的处理函数、Context方法封装、日志模块等。

  • css:对节点样式的处理,解析各种css属性和特性,进行处理。

  • dom:遍历dom节点的方法,以及对各种类型dom的处理。

  • render:基于clone的节点生成canvas的处理方法。


基于以上这些核心文件,我们来简单了解一下html2canvas的解析过程, 大致的流程如下:


image.png



  • 构建配置项


在这一步会结合传入的options和一些defaultOptions,生成用于渲染的配置数据renderOptions。在过程中会对配置项进行分类,比如resourceOptions(资源跨域相关)、contextOptions(缓存、日志相关)、windowOptions(窗口宽高、滚动配置)、cloneOptions(对指定dom的配置)、renderOptions(render结果的相关配置,包括生成图片的各种属性)等,然后分别将各类配置项传到下接下来的步骤中。



  • clone目标节点并获取样式和内容


在这一步中,会将目标节点到指定的dom解析方法中,这个过程会clone目标节点和其子节点,获取到节点的内容信息和样式信息,其中clone dom的解析方法也是比较复杂的,这里不做详细展开。获取到目标节点后,需要把克隆出来的目标节点的dom装载到一个iframe里,进行一次渲染,然后就可以获取到经过浏览器视图真实呈现的节点样式。



  • 解析目标节点


目标节点的样式和内容都获取到了之后,就需要把它所承载的数据信息转化为Canvas可以使用的数据类型。在对目标节点的解析方法中,递归整个DOM树,并取得每一层节点的数据,对于每一个节点而言需要绘制的部分包括边框、背景、阴影、内容,而对于内容就包含图片、文字、视频等。在整个解析过程中,对目标节点的所有属性进行解析构造,转化成为指定的数据格式,基础数据格式可见以下代码:


class ElementContainer {
// 所有节点上的样式经过转换计算之后的信息
readonly styles: CSSParsedDeclaration;
// 节点的文本节点信息, 包括文本内容和其他属性
readonly textNodes: TextContainer[] = [];
// 当前节点的子节点
readonly elements: ElementContainer[] = [];
// 当前节点的位置信息(宽/高、横/纵坐标)
bounds: Bounds;
flags = 0;
...
}

具体到不同类型的元素如图片、IFrame、SVG、input等还会extends ElementContainer拥有自己的特定数据结构,在此不详细贴出。



  • 构建内部渲染器


把目标节点处理成特定的数据结构之后,就需要结合Canvas调用渲染方法了,Canvas绘图需要根据样式计算哪些元素应该绘制在上层,哪些在下层,那么这个规则是什么样的呢?这里就涉及到CSS布局相关的一些知识。


默认情况下,CSS是流式布局的,元素与元素之间不会重叠。不过有些情况下,这种流式布局会被打破,比如使用了浮动(float)和定位(position)。因此需要需要识别出哪些脱离了正常文档流的元素,并记住它们的层叠信息,以便正确地渲染它们。


那些脱离正常文档流的元素会形成一个层叠上下文。元素在浏览器中渲染时,根据W3C的标准,所有的节点层级布局,需要遵循层叠上下文和层叠顺序的规则,具体规则如下:


image.png


在了解了元素的渲染需要遵循这个标准后,Canvas绘制节点的时候,需要生成指定的层叠数据,就需要先计算出整个目标节点里子节点渲染时所展现的不同层级,构造出所有节点对应的层叠上下文在内部所表现出来的数据结构,具体数据结构如下:


// 当前元素
element: ElementPaint;
// z-index为负, 形成层叠上下文
negativeZIndex: StackingContext[];
// z-index为0、auto、transform或opacity, 形成层叠上下文
zeroOrAutoZIndexOrTransformedOrOpacity: StackingContext[];
// 定位和z-index形成的层叠上下文
positiveZIndex: StackingContext[];
// 没有定位和float形成的层叠上下文
nonPositionedFloats: StackingContext[];
// 没有定位和内联形成的层叠上下文
nonPositionedInlineLevel: StackingContext[];
// 内联节点
inlineLevel: ElementPaint[];
// 不是内联的节点
nonInlineLevel: ElementPaint[];

基于以上数据结构,将元素子节点分类,添加到指定的数组中,解析层叠信息的方式和解析节点信息的方式类似,都是递归整棵树,收集树的每一层的信息,形成一颗包含层叠信息的层叠树。



  • 绘制数据


基于上面两步构造出的数据,就可以开始调用内部的绘制方法,进行数据处理和绘制了。使用节点的层叠数据,依据浏览器渲染层叠数据的规则,将DOM元素一层一层渲染到canvas中,其中核心具体源码如下:


async renderStackContent(stack: StackingContext): Promise<void> {
if (contains(stack.element.container.flags, FLAGS.DEBUG_RENDER)) {
debugger;
}
// 1. the background and borders of the element forming the stacking context.
await this.renderNodeBackgroundAndBorders(stack.element);
// 2. the child stacking contexts with negative stack levels (most negative first).
for (const child of stack.negativeZIndex) {
await this.renderStack(child);
}
// 3. For all its in-flow, non-positioned, block-level descendants in tree order:
await this.renderNodeContent(stack.element);
for (const child of stack.nonInlineLevel) {
await this.renderNode(child);
}
// 4. All non-positioned floating descendants, in tree order. For each one of these,
// treat the element as if it created a new stacking context, but any positioned descendants and descendants
// which actually create a new stacking context should be considered part of the parent stacking context,
// not this new one.
for (const child of stack.nonPositionedFloats) {
await this.renderStack(child);
}
// 5. the in-flow, inline-level, non-positioned descendants, including inline tables and inline blocks.
for (const child of stack.nonPositionedInlineLevel) {
await this.renderStack(child);
}
for (const child of stack.inlineLevel) {
await this.renderNode(child);
}
// 6. All positioned, opacity or transform descendants, in tree order that fall int0 the following categories:
// All positioned descendants with 'z-index: auto' or 'z-index: 0', in tree order.
// For those with 'z-index: auto', treat the element as if it created a new stacking context,
// but any positioned descendants and descendants which actually create a new stacking context should be
// considered part of the parent stacking context, not this new one. For those with 'z-index: 0',
// treat the stacking context generated atomically.
//
// All opacity descendants with opacity less than 1
//
// All transform descendants with transform other than none
for (const child of stack.zeroOrAutoZIndexOrTransformedOrOpacity) {
await this.renderStack(child);
}
// 7. Stacking contexts formed by positioned descendants with z-indices greater than or equal to 1 in z-index
// order (smallest first) then tree order.
for (const child of stack.positiveZIndex) {
await this.renderStack(child);
}
}

在renderStackContent方法中,首先对元素本身调用renderNodeContent和renderNodeBackgroundAndBorders进行渲染处理。


然后处理各个分类的子元素,如果子元素形成了层叠上下文,就调用renderStack方法,这个方法内部继续调用了renderStackContent,这就形成了对于层叠上下文整个树的递归。


如果子元素是正常元素没有形成层叠上下文,就直接调用renderNode,renderNode包括两部分内容,渲染节点内容和渲染节点边框背景色。


async renderNode(paint: ElementPaint): Promise<void> {
if (paint.container.styles.isVisible()) {
// 渲染节点的边框和背景色
await this.renderNodeBackgroundAndBorders(paint);
// 渲染节点内容
await this.renderNodeContent(paint);
}
}

其中renderNodeContent方法是渲染一个元素节点里面的内容,其可能是正常元素、文字、图片、SVG、Canvas、input、iframe,对于不同的内容也会有不同的处理。


以上过程,就是html2canvas的整体内部流程,在了解了大致原理之后,我们再来看一个更为详细的源码流程图,对上述流程进行一个简单的总结。


五、 常见问题总结


在使用html2canvas的过程中,会有一些常见的问题和坑,总结如下:


(一)截图不全


要解决这个问题,只需要在截图之前将页面滚动到顶部即可:


document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;

(二)图片跨域


插件在请求图片的时候会有图片跨域的情况,这是因为,如果使用跨域的资源画到canvas中,并且资源没有使用CORS去请求,canvas会被认为是被污染了,canvas可以正常展示,但是没办法使用toDataURL()或者toBlob()导出数据,详情可参考:developer.mozilla.org/en-US/docs/…


解决方案:在img标签上设置crossorigin,属性值为anonymous,可以开启CROS请求。当然,这种方式的前提还是服务端的响应头Access-Control-Allow-Origin已经被设置过允许跨域。如果图片本身服务端不支持跨域,可以使用canvas统一转成base64格式,方法如下。


function getUrlBase64_pro( len,url ) {
//图片转成base64
var canvas = document.createElement("canvas"); //创建canvas DOM元素
var ctx = canvas.getContext("2d");
return new Promise((reslove, reject) => {
var img = new Image();
img.crossOrigin = "Anonymous";
img.onload = function() {
canvas.height = len;
canvas.width = len;
ctx.drawImage(img, 0, 0, len, len);
var dataURL = canvas.toDataURL("image/");
canvas = null;
reslove(dataURL);
};
img.onerror = function(err){
reject(err)
}
img.src = url;
});
}

(三)截图与当前页面有区别


方式一:如果要从渲染中排除某些elements,可以向这些元素添加data-html2canvas-ignore属性,html2cnavas会将它们从渲染中排除,例如,如果不想截图iframe的部分,可以如下:


html2canvas(ele,{
useCORS: true,
ignoreElements: (element: any) => {
if (element.tagName.toLowerCase() === 'iframe') {
return element;
}
return false;
},
})

方式二:可以将需要转化成图片的部分放在一个节点内,再把整个节点,透明度设置为0, 其他部分层级设置高一些,即可实现截图指定区域。


六、 小结


本文针对前端截图实现的方式,对两个开源库dom-to-image和html2canvas的使用和原理进行了简单的使用方式、实现原理方面,进行介绍和分析。


参考资料:


1.dom-to-image原理


2.html2image原理简述


3.浏览器端网页截图方案详解


4.html2canvas


5.html2canvas实现浏览器截图的原理(包含源码分析的通用方法)


作者:庚云
来源:juejin.cn/post/7400319811358818340
收起阅读 »

推荐一个小而全的第三方登录开源组件

大家好,我是 Java陈序员。 我们在企业开发中,常常需要实现登录功能,而有时候为了方便,就需要集成第三方平台的授权登录。如常见的微信登录、微博登录等,免去了用户注册步骤,提高了用户体验。 为了业务考虑,我们有时候集成的不仅仅是一两个第三方平台,甚至更多。这就...
继续阅读 »

大家好,我是 Java陈序员


我们在企业开发中,常常需要实现登录功能,而有时候为了方便,就需要集成第三方平台的授权登录。如常见的微信登录、微博登录等,免去了用户注册步骤,提高了用户体验。


为了业务考虑,我们有时候集成的不仅仅是一两个第三方平台,甚至更多。这就会大大的提高了工作量,那么有没有开源框架来统一来集成这些第三方授权登录呢?


答案是有的,今天给大家介绍的项目提供了一个第三方授权登录的工具类库


项目介绍


JustAuth —— 一个第三方授权登录的工具类库,可以让你脱离繁琐的第三方登录 SDK,让登录变得So easy!


JustAuth


JustAuth 集成了诸如:Github、Gitee、微博、钉钉、百度、Coding、腾讯云开发者平台、OSChina、支付宝、QQ、微信、淘宝、Google、Facebook、抖音、领英、小米、微软、今日头条、Teambition、StackOverflow、Pinterest、人人、华为、企业微信、酷家乐、Gitlab、美团、饿了么、推特、飞书、京东、阿里云、喜马拉雅、Amazon、Slack和 Line 等第三方平台的授权登录。


功能特色:



  • 丰富的 OAuth 平台:支持国内外数十家知名的第三方平台的 OAuth 登录。

  • 自定义 state:支持自定义 State 和缓存方式,开发者可根据实际情况选择任意缓存插件。

  • 自定义 OAuth:提供统一接口,支持接入任意 OAuth 网站,快速实现 OAuth 登录功能。

  • 自定义 Http:接口 HTTP 工具,开发者可以根据自己项目的实际情况选择相对应的HTTP工具。

  • 自定义 Scope:支持自定义 scope,以适配更多的业务场景,而不仅仅是为了登录。

  • 代码规范·简单:JustAuth 代码严格遵守阿里巴巴编码规约,结构清晰、逻辑简单。


安装使用


回顾 OAuth 授权流程


参与的角色



  • Resource Owner 资源所有者,即代表授权客户端访问本身资源信息的用户(User),也就是应用场景中的“开发者A”

  • Resource Server 资源服务器,托管受保护的用户账号信息,比如 Github
    Authorization Server 授权服务器,验证用户身份然后为客户端派发资源访问令牌,比如 Github

  • Resource ServerAuthorization Server 可以是同一台服务器,也可以是不同的服务器,视具体的授权平台而有所差异

  • Client 客户端,即代表意图访问受限资源的第三方应用


授权流程


OAuth 授权流程


使用步骤


1、申请注册第三方平台的开发者账号


2、创建第三方平台的应用,获取配置信息(accessKey, secretKey, redirectUri)


3、使用 JustAuth 实现授权登陆


引入依赖


<dependency>
<groupId>me.zhyd.oauth</groupId>
<artifactId>JustAuth</artifactId>
<version>{latest-version}</version>
</dependency>

调用 API


// 创建授权request
AuthRequest authRequest = new AuthGiteeRequest(AuthConfig.builder()
.clientId("clientId")
.clientSecret("clientSecret")
.redirectUri("redirectUri")
.build());
// 生成授权页面
authRequest.authorize("state");
// 授权登录后会返回code(auth_code(仅限支付宝))、state,1.8.0版本后,可以用AuthCallback类作为回调接口的参数
// 注:JustAuth默认保存state的时效为3分钟,3分钟内未使用则会自动清除过期的state
authRequest.login(callback);


说明:
JustAuth 的核心就是一个个的 request,每个平台都对应一个具体的 request 类。
所以在使用之前,需要就具体的授权平台创建响应的 request.如示例代码中对应的是 Gitee 平台。



集成国外平台



国外平台需要额外配置 httpConfig



AuthRequest authRequest = new AuthGoogleRequest(AuthConfig.builder()
.clientId("Client ID")
.clientSecret("Client Secret")
.redirectUri("应用回调地址")
// 针对国外平台配置代理
.httpConfig(HttpConfig.builder()
// Http 请求超时时间
.timeout(15000)
// host 和 port 请修改为开发环境的参数
.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", 10080)))
.build())
.build());

SpringBoot 集成


引入依赖


<dependency>
<groupId>com.xkcoding.justauth</groupId>
<artifactId>justauth-spring-boot-starter</artifactId>
<version>1.4.0</version>
</dependency>

配置文件


justauth:
enabled: true
type:
QQ:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/qq/callback
union-id: false
WEIBO:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/weibo/callback
GITEE:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/gitee/callback
DINGTALK:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/dingtalk/callback
BAIDU:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/baidu/callback
CSDN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/csdn/callback
CODING:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/coding/callback
coding-group-name: xx
OSCHINA:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/oschina/callback
ALIPAY:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/alipay/callback
alipay-public-key: MIIB**************DAQAB
WECHAT_OPEN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/wechat_open/callback
WECHAT_MP:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/wechat_mp/callback
WECHAT_ENTERPRISE:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/wechat_enterprise/callback
agent-id: 1000002
TAOBAO:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/taobao/callback
GOOGLE:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/google/callback
FACEBOOK:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/facebook/callback
DOUYIN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/douyin/callback
LINKEDIN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/linkedin/callback
MICROSOFT:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/microsoft/callback
MI:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/mi/callback
TOUTIAO:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/toutiao/callback
TEAMBITION:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/teambition/callback
RENREN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/renren/callback
PINTEREST:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/pinterest/callback
STACK_OVERFLOW:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/stack_overflow/callback
stack-overflow-key: asd*********asd
HUAWEI:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/huawei/callback
KUJIALE:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/kujiale/callback
GITLAB:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/gitlab/callback
MEITUAN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/meituan/callback
ELEME:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/eleme/callback
TWITTER:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/twitter/callback
XMLY:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/xmly/callback
# 设备唯一标识ID
device-id: xxxxxxxxxxxxxx
# 客户端操作系统类型,1-iOS系统,2-Android系统,3-Web
client-os-type: 3
# 客户端包名,如果 clientOsType 为1或2时必填。对Android客户端是包名,对IOS客户端是Bundle ID
#pack-id: xxxx
FEISHU:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/feishu/callback
JD:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/jd/callback
cache:
type: default

代码使用


@Slf4j
@RestController
@RequestMapping("/oauth")
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class TestController {
private final AuthRequestFactory factory;

@GetMapping
public List<String> list() {
return factory.oauthList();
}

@GetMapping("/login/{type}")
public void login(@PathVariable String type, HttpServletResponse response) throws IOException {
AuthRequest authRequest = factory.get(type);
response.sendRedirect(authRequest.authorize(AuthStateUtils.createState()));
}

@RequestMapping("/{type}/callback")
public AuthResponse login(@PathVariable String type, AuthCallback callback) {
AuthRequest authRequest = factory.get(type);
AuthResponse response = authRequest.login(callback);
log.info("【response】= {}", JSONUtil.toJsonStr(response));
return response;
}

}

总结


JustAuth 集成的第三方授权登录平台,可以说是囊括了业界中大部分主流的应用系统。如国内的微信、微博、Gitee 等,还有国外的 Github、Google 等。可以满足我们日常的开发需求,开箱即用,可快速集成!


最后,贴上项目地址:


https://github.com/justauth/JustAuth

在线文档:


https://www.justauth.cn/

最后


推荐的开源项目已经收录到 GitHub 项目,欢迎 Star


https://github.com/chenyl8848/great-open-source-project

或者访问网站,进行在线浏览:


https://chencoding.top:8090/#/


大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!



作者:Java陈序员
来源:juejin.cn/post/7312060958175559743
收起阅读 »

fishhook--终于被我悟透了

iOS
fishhook 作为一个 hook 工具在 iOS 开发中有着高频应用,理解 fishhook 的基本原理对于一个高级开发应该是必备技能。很遗憾,在此之前虽然对 fishhook 的基本原理有过多次探索但总是一知半解,最近在整理相关知识点对 fishhook...
继续阅读 »

fishhook 作为一个 hook 工具在 iOS 开发中有着高频应用,理解 fishhook 的基本原理对于一个高级开发应该是必备技能。很遗憾,在此之前虽然对 fishhook 的基本原理有过多次探索但总是一知半解,最近在整理相关知识点对 fishhook 又有了全新的认识。如果你跟我一样对 fishhook 的原理不甚了解那这篇文章会适合你。


需要强调的是本文不会从 fishhook 的使用基础讲起,也会不对照源码逐行讲解,只会着重对一些比较迷惑知识点进行重点阐述,建议先找一些相关系列文章进行阅读,补充一些基本知识再回过头来阅读本文。



注1:所有代码均以 64 位 CPU 架构为例,后文不再进行特别说明


注2:请下载 MachOView 打开任意 Mach-O 文件同步进行验证


注3:Mach-O 结构头文件地址



MachO 文件结构


image.png


0x01


Mach-O 文件结构有三部分,第一部分是 header,描述 Mach-O 文件的关键信息。其数据结构如下:


struct mach_header_64 {
uint32_t magic; /* mach magic number identifier */
cpu_type_t cputype; /* cpu specifier */
cpu_subtype_t cpusubtype; /* machine specifier */
uint32_t filetype; /* type of file */
uint32_t ncmds; /* number of load commands */
uint32_t sizeofcmds; /* the size of all the load commands */
uint32_t flags; /* flags */
uint32_t reserved; /* reserved */
};

如上面结构体所示,Mach-O 文件 header 的关键信息包括:



  • cputype:当前文件支持的 CPU 类型

  • filetype:当前 MachO 的文件类型

  • ncmds: Load Command 的数量

  • sizeofcmds:所有 Command 的总大小


每个 iOS 的可执行文件、动态库都会从 header 开始加载到内存中。


0x02


第二部分是 Load Commands,Load Commands 有不同的类型,有的用于描述不同类型数据结构(在文件中的位置、大小、类型、限权等),有的单纯用来记录信息,比如记录:dyld 的路径、main 函数的地址、UUID 等,用于记录信息的 Command 一般不会出现在数据区(Data)内。


不同的类型的 Load Command 对应着不同的结构体,但所有 Load Command 的前两个字段(cmd/cmdsize)都是相同的。所以,所有的 Load Command 都可以通过类型强转为 load_command 结构体类型。


有了 load_command 就可以通过每一个 Load Command 的 cmdsize 计算出下一个 Load Command 的位置。


struct load_command {
uint32_t cmd; /* type of load command */
uint32_t cmdsize;` /* total size of command in bytes */
};

struct segment_command_64 { /* for 64-bit architectures */
uint32_t cmd; /* LC_SEGMENT_64 */
uint32_t cmdsize; /* includes sizeof section_64 structs */
char segname[16]; /* segment name */
uint64_t vmaddr; /* memory address of this segment */
uint64_t vmsize; /* memory size of this segment */
uint64_t fileoff; /* file offset of this segment */
uint64_t filesize; /* amount to map from the file */
vm_prot_t maxprot; /* maximum VM protection */
vm_prot_t initprot; /* initial VM protection */
uint32_t nsects; /* number of sections in segment */
uint32_t flags; /* flags */
};

有文章说 load_command 是所有 Command 的基类,你也可以这样理解(虽然在代码语法层面不是)。


segment_command_64 作为一个 Load Command 重点类型,一般用来描述 __PAGEZERO、__TEXT、__DATA、__DATA_CONST 、__LINKEDIT 等包含实际代码数据的段(位于 Data 部分)。


因此对于 segment_command_64 类型的 Load Command 也称之为: segment


segment 内部还包含一个重要的类型:section,section 用于描述一组相同类型的数据。例如:所有代码逻辑都位于名为 __text 的 section 内,所有 OC 类名称都位于名为 __objc_classname 的 section 内,而这两个 section 均位于 __TEXT 段(segment)。


image.png


segment_command_64 关键字段介绍:



  • segname: 当前 segment 名称,可为 __PAGEZERO、__TEXT、__DATA、__DATA_CONST 、__LINKEDIT 之一

  • vmaddr: 当前 segment 加载到内存后的虚拟地址(实际还要加上 ALSR 偏移才是真实的虚拟地址)

  • vmsize: 当前 segment 占用的虚拟内存大小

  • fileoff: 当前 segment 在 Mach-O 文件中的偏移量,实际位置 = header 开始的地址 + fileoff

  • filesize: 当前 segment 在 Mach-O 文件中的实际大小,考虑到内存对齐 vmsize >= filesize

  • nsects: 当前 segment_command_64 下面包含的 section 个数



关于随机地址偏移(ALSR) 的相关容内可自行查找相关资料进行了解,这里不再贅述



section 只有一种类型,其结构体定义如下:


struct section_64 { /* for 64-bit architectures */
char sectname[16]; /* name of this section */
char segname[16]; /* segment this section goes in */
uint64_t addr; /* memory address of this section */
uint64_t size; /* size in bytes of this section */
uint32_t offset; /* file offset of this section */
uint32_t align; /* section alignment (power of 2) */
uint32_t reloff; /* file offset of relocation entries */
uint32_t nreloc; /* number of relocation entries */
uint32_t flags; /* flags (section type and attributes)*/
uint32_t reserved1; /* reserved (for offset or index) */
uint32_t reserved2; /* reserved (for count or sizeof) */
uint32_t reserved3; /* reserved */
};

section 关键字段介绍:



  • sectname: section 的名称,可以为 __text,__const,__bss 等

  • segname: 当前 section 所在 segment 名称

  • addr: 当前 section 在虚拟内存中的位置(实际还要加上 ALSR 偏移才是真实的虚拟地址)

  • size: 当前 section 所占据的大小(磁盘大小与内存大小)

  • reserved1: 不同 section 类型有不同的意义,一般代表偏移量与索引值

  • flags: 类型&属性标记位,fishhook 使用此标记查找懒加载表&非懒加载表


需要注意:有且仅有 segment_command_64 类型的 Command 包含 section。


0x03


最后是数据区(Data),就是 Mach-O 文件所包含的代码或者数据;所有代码或者数据都根据 Load Command 的描述进行组织、排列。其中由segment_command_64 描述的数据或代码在 Data 部分中均以 section 为最小单位进行组织,并且这部分内容占大头。segment 再加上其它类型 Load Command (其实就是 __LINKEDIT segement)描述的数据共同组成了数据区。


注意:虽然名称为 __LINKEDIT (类型为:segment_command_64) 的 segment 下面所包含的 section 数量为 0,但根据其 fileoff,filesize 计算发现:


__LINKEDIT 的 segement 所指向的文件范围其实包含其它 Load Command (包含但不限于:LC_DYLD_INFO_ONLY、LC_FUNCTION_STARTS、LC_SYMTAB、LC_DYSYMTAB、LC_CODE_SIGNATURE)所指向的位置范围。


推导过程如下:


image.png


image.png


如上图所示在 Load Commands 中 __LINKEDIT 在 Mach-O 文件的偏移:0x394000 大小为:0x5B510。而 Mach-O header 的开始地址为 0x41C000。所以 __LINKEDIT 在 Mach-O 文件中的地址范围是:{header + fileoffset, header + fileoffset + filesize},代入上式就是 {0x41C000+0x394000, 0x41C000+0x394000+0x5B510},最终得到 {0x7B0000,0x80B510} 的地址范围。


从下图看,segment 最后一个 section 结束后的第一个地址就是上面的开始的范围,文件的结束地址也是上面计算结果的结束范围(最后一个数据地址占 16 字节)。


image.png


image.png


所以可以这样理解:名称为 __LINKEDIT Load Command 是一个虚拟 Command。它用来指示LC_DYLD_INFO_ONLY、LC_FUNCTION_STARTS、LC_SYMTAB、LC_DYSYMTAB、LC_CODE_SIGNATURE 等这些 Command 描述的数据在「文件与内存」中的总范围,而这些 Command 自己本身又描述了自各的范围,从地址范围来看 __LINKEDIT 是这些 Command 在数据部分的父级,尽管它本身并没有 section。


yuque_diagram (1).png


fishhook 的四个关键表


fishhook 的实现原理涉及到四个「表」,理解这四个表之间的关系便能理解 fishhook 的原理,且保证过目不忘。



  • 符号表(Symbol Table)

  • 间接符号表(Indirect Symbol Table)

  • 字符表(String Table)

  • 懒加载和非懒加载表(__la_symbol_ptr/__non_la_symbol_ptr)


符号表&字符表


yuque_diagram (2).png


其中符号表(Symbol Table)与字符表(String Table)在 LC_SYMTAB 类型的 Load Command 中描述。


struct symtab_command {
uint32_t cmd; /* LC_SYMTAB */
uint32_t cmdsize; /* sizeof(struct symtab_command) */

uint32_t symoff; /* 符号表(Symbol Table)在文件中相对 header 的偏移 */
uint32_t nsyms; /* 符号表(Symbol Table)数量 */

uint32_t stroff; /* 字符表(String Table)在文件中相对 header 的偏移 */
uint32_t strsize; /* 字符串(String Table)表总大小*/
};

符号表(Symbol Table)内容的数据结构用 nlist_64 表示:


struct nlist_64 {
union {
uint32_t n_strx; /* index int0 the string table */
} n_un;
uint8_t n_type; /* type flag, see below */
uint8_t n_sect; /* section number or NO_SECT */
uint16_t n_desc; /* see <mach-o/stab.h> */
uint64_t n_value; /* value of this symbol (or stab offset) */
};

nlist_64 的第一个成员 n_un 代表当前符号的名称在字符表(String Table)中的相对位置,其它成员变量这里不需关注。


字符表(String Table)是一连串的字符 ASCII 码数据,每个字符串之间用 '\0' 进行分隔。


间接符号表


而间接符号表(Indirect Symbol Table)在 dysymtab_command 结构体的 Load Command(类型为LC_DYSYMTAB)中描述。


struct dysymtab_command {
uint32_t cmd; /* LC_DYSYMTAB */
uint32_t cmdsize; /* sizeof(struct dysymtab_command) */

/* 省略部分字段 */

uint32_t indirectsymoff; /* 间接符号表相对 header 的偏移 */
uint32_t nindirectsyms; /* 间接符号表中符号数量 */

/* 省略部分字段 */
};

间接符号表本质是由 int32 为元素组成的数组,元素中存储的数值代表当前符号在符号表(Symbol Table)中的相对位置。


懒加载和非懒加载表


懒加载与非懒加载表位于 __DATA/__DATA_CONST segment 下面的 section 中。


image.png


image.png


懒加载与非懒加载表有如下特点:



  • 当前可执行文件或动态库引用外部动态库符号时,调用到对应符号时会跳转到懒加载与非懒加载表指定的地址执行

  • 懒加载表在符号第一次被调用时绑定,绑定之前指向桩函数,由桩函数完成符号绑定,绑定完成之后即为对应符号真实代码地址

  • 非懒加载表在当前 Mach-O 被加载到内存时由 dyld 完成绑定,绑定之前非懒加载表内的值为 0x00,绑定之后同样也为对应符号的真实代码地址


敲黑板知识点:fishhook 的作用就是改变懒加载和非懒加载表内保存的函数地址


由于懒加载和非懒加载表没有包含任何符号字符信息,我们并不能直接通过懒加载表和非懒加载表找到目标函数在表中对应的位置,也就无法进行替换。因此,需要借助间接符号表(Indirect Symbol Table)、符号表(Symbol Table)、字符表(String Table)三个表之间的关系找到表中与之对应的符号名称来确认它的位置。


如何找到目标函数地址


这里借用一下 fishhook 官方给的示意图,可以先自行理解一下再往下看:


image.png


引用外部函数时需要通过符号名称来确定函数地址在懒加载和非懒加载表的位置,具体过程如下:



  1. 懒加载表与非懒加载表中函数地址的索引与间接符号表(Indirect Symbol Table)中的位置对应;


    以表中第 i 个函数地址为例,对应关系可以用伪公式来表述:


    间接符号表的偏移 = 间接符号表开始地址 + 懒加载表或非懒加载表指定的偏移(所在 section 的 reserved1 字段)+ i


  2. 间接符号表中保存的 int32 类型的数组,以上一步计算到的「间接符号表的偏移」为索引取数组内的值得到符到号中的位置


    同样得到一个等效伪公式:符号表的偏移 = 间接符号表开始地址 + 间接符号表的偏移


  3. 符号表中保存的数据是 nlist_64 类型,该第一个字段(n_un.n_strx)的值就是当前符号名称在字符表中的偏移


    等效伪公式:符号名称在字符表中的偏移 = (符号表的开始地址 + 符号表的偏移).n_un.n_strx


  4. 按照上面得到的偏移,去字符表中取出对应字符串(以 \0)结尾


    等效伪公式:懒加载表与非懒加载表中第 i 个函数名 = 字符表的开始地址 + 符号名称在字符表中的偏移



到这里我们从下至上进行公式代入,合并三个伪公式得到:


懒加载表或非懒加载表中第 i 个函数名 = 字符表的开始地址 + (符号表的开始地址 + 间接符号表开始位置 + 懒加载表或非懒加载表指定的偏移(所在 section 的 reserved1 字段)+ i).n_un.n_strx


现在,上面这个公式里还不知道的是三个开始地址:



  • 字符表(String Table)的开始地址

  • 符号表(Symbol)的开始地址

  • 间接符号表(Indirect Symbol Table)开始地址


而懒加载表或非懒加载表中函数地址个数也可以通过对应 section 的 size 字段(详情查看上文 section_64 结构体中的描述)计算而得到,公式:(section->size / sizeof(void *))。


到这里 fishhook 四个表的关系应该非常清楚了,fishhook 所做的无非是通过这个公式在懒加载表与非懒加载表中找到与目标函数名匹配的外部函数,一旦发现匹配则将其地址改为自定义的函数地址。


何为 linkedit_base


如果不考虑其它因素,实际上面三个表的开始地址可以直接通过 Mach-O 的 header 地址 + 对应的偏移就可以直接得到。以符号表(Symbol Table)为例:


image.png


Mach-O header 的开始地址如上文所述为:0x41C000,计算 0x41C000 + 0x3BECD8 = 0x7DACD8;再用 MachOView 查看这个地址,确实是符号表在文件中的位置:


image.png


同时上面的的推导也证明了 symtab_command->symoff symtab_command->stroff 是相对 Mach-O header 的偏移,并不是相对 __LINKEDIT 的偏移;


而 fishhook 源码中计算符号表开始地址的方式是:


nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);

导致不少博文说 linkedit_base 是 __LINKEDIT 段的基地址,symoff 是相对 __LINKEDIT segment 的偏移,这完全是错误的,在此可以明确的是:



  • linkedit_base 并不是 __LINKEDIT segment 在内存中的开始地址

  • linkedit_base 并不是 __LINKEDIT segment 在内存中的开始地址

  • linkedit_base 并不是 __LINKEDIT segment 在内存中的开始地址


fishhook 中计算 linkedit_base 的计算方式如下:


uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;

忽略掉随机地址偏移(ALSR)值: slide 后:


linkedit_base = linkedit_segment->vmaddr - linkedit_segment->fileoff;


linkedit_segment->vmaddr:代表 __LINKEDIT segment 在「虚拟内存」中的相对开始位置
linkedit_segment->fileoff:代表 __LINKEDIT segment 在「文件」中的相对开始位置


那这两个相减有什么意义呢?


要解答这个问题先来看 MachOView 给出的信息:


image.png


如上图,在 __LINKEDIT segment 之前的几个 segment (红框标记)可以解析出几个事实:



  • 每个 segment 的在「 Mach-O 文件」中的开始地址都等于上一个 segment 的 File Offset + File Size,第一个 segment 从 0 开始

  • 同理,每个 segment 在「虚拟内存」中的位置都等于上一个 segment 的 VM Address + VM Size,第一个 segment 从 0 开始

  • __PAGEZERO_DATAVM Size > File Size,而其它 segment 中这两个值相等,意味着两个 segment 加载到虚拟内存中后有一部分「空位」(因内存对齐而出现)

  • __PAGEZERO 不占 Mach-O 文件的存储空间,但在虚拟内存在占 16K 的空间


用图形表示即为:


image.png


故而 linkedit_base = linkedit_segment->vmaddr - linkedit_segment->fileoff 的意义为:



  • Mach-O 加载到内存后 __LINKEDIT 前面的 segment 因内存对齐后多出来的空间(空位)

  • Mach-O 加载到内存后 __LINKEDIT 前面的 segment 因内存对齐后多出来的空间(空位)

  • Mach-O 加载到内存后 __LINKEDIT 前面的 segment 因内存对齐后多出来的空间(空位)


这才是 linkedit_base 在物理上的真正意义,任何其它的定义都是错误的。


image.png


__LINKEDIT 本身的 VM Size == File Size 说明它包含的符号表、字符表与间接符号表三个表本身是内存对齐的,它们之间没有空位,所以它们本身在文件中的偏移 + linkedit_base 即为在内存中的实际位置。


  // 符号表在内存中的开始位置
nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
// 字符表在内存中的开始位置
char *strtab = (char *)(linkedit_base + symtab_cmd->stroff);
// 间接符号表在内存中的开始位置
uint32_t *indirect_symtab = (uint32_t *)(linkedit_base + dysymtab_cmd->indirectsymoff);

最后


fishhook 在 APM、防逆向、性能优化等方向均有较多的应用,从本质上来看 fishhook 是对 Mach-O 文件结构的深度应用。相信在了解完原理之后再去看 Mach-O 文件的结构就比较简单了,与 Mach-O 文件结构相关的应用还有符号表的还原。下篇文章再与大家共同学习符号表还原的具体过程(虽然文件夹还没有创建 😂)。


如对本文有任何疑问,我们评论区交流 😀


作者:码不理
来源:juejin.cn/post/7360980866796388362
收起阅读 »

为什么不建议使用多表join

前言 三年前在一家公司和开发团队的架构师合作过,一起写过代码。让我真的很难受啊,这个架构师写的代码很多逻辑都写到SQL里面,各种连表查询,SQL 非常的复杂,每次我去维护都得看好久它这个SQL逻辑。 回到最近,现在有个小伙儿班也是喜欢在SQL里面写逻辑,各种关...
继续阅读 »

前言


三年前在一家公司和开发团队的架构师合作过,一起写过代码。让我真的很难受啊,这个架构师写的代码很多逻辑都写到SQL里面,各种连表查询,SQL 非常的复杂,每次我去维护都得看好久它这个SQL逻辑。


回到最近,现在有个小伙儿班也是喜欢在SQL里面写逻辑,各种关联查询,甚至写的SQL连一万的数据连都支持不了。



都给我贡献了好几篇文章了:

完了演示的时候报错了!distinct 别乱用啊

sql 子查询的巨坑 ,80%的后端都不知道这个问题



所以我们的SQL尽量的简洁,少用多表关联查询。


为什么不建议使用多表join?


最主要的原因就是join的效率比较低
MySQL是使用了嵌套循环(Nested-Loop Join)的方式来实现关联查询的,就是要通过两层循环,用第一张表做外循环,第二张表做内循环,外循环的每一条记录跟内循环中的记录作比较,符合条件的就输出。



  1. 性能问题



    • 多表 JOIN 会增加查询的复杂性,可能导致性能下降,特别是在数据量大时。

    • 数据库需要在执行查询时处理更多的行和列,这可能导致更高的 I/O 操作和内存使用。



  2. 可读性和维护性



    • 复杂的 JOIN 查询会使 SQL 语句变得难以理解,导致维护成本增加。

    • 当查询需要频繁修改时,复杂的 JOIN 会让代码更容易出错。



  3. 索引利用率



    • 多表 JOIN 可能会导致数据库无法有效利用索引,影响查询的优化。

    • 如果 JOIN 的字段没有适当的索引,查询性能会显著下降。



  4. 锁竞争



    • 多表 JOIN 可能导致更长时间的行锁或表锁,从而增加锁竞争的可能性,影响并发性能。



  5. 数据完整性



    • 复杂的 JOIN 查询可能掩盖数据问题或不一致性,使得调试较为困难。

    • 难以确保在 JOIN 查询中返回的数据符合业务逻辑和数据完整性要求。




如何优化:



  • 分解查询:在内存中自己做关联,即先从数据库中把数据查出来之后,再次查询,然后再进行数据封装。

  • 考虑数据冗余:在某些情况下,可以考虑数据冗余来减少 JOIN 的需要。

  • 宽表:就是基于一定的join关系,把数据库中多张表的数据打平做一张大宽表,可以同步到ES或者干脆直接在数据库中直接查都可以


什么是hash join(扩展阅读)


mysql8.0 以前join查询使用Nested-Loop Join算法实现



Nested-Loop Join:嵌套循环连接,如果有2张表join的话,复杂度最高是O(n^2),3张表则是O(n^3),表中的数据量越多,JOIN的效率会呈指数级下降。



MySQL 8.0中优化了join查询,新增了 hash join算法。

Hash Join 是一种高效的联表查询算法,通常用于处理较大数据集的连接操作。下面将详细介绍 Hash Join 的原理,并通过示例图解说明其查询步骤。


Hash Join 原理


Hash Join 的基本原理是将一个表的数据构建成一个哈希表,然后利用该哈希表来查找另一个表中匹配的行。其主要分为两个阶段:



  1. 构建阶段(Build Phase):



    • 选择一个较小的表(称为构建表)来创建哈希表。

    • 根据连接条件的键值计算哈希值,并将这些键值和对应的行存储在哈希表中。



  2. 探测阶段(Probe Phase):



    • 对另一个表(称为探测表)逐行读取数据。

    • 对于探测表中的每一行,计算连接字段的哈希值,并在哈希表中查找匹配的行。

    • 如果找到匹配,则将匹配的行组合在一起,形成结果集。




Hash join 案例


假设我们有两个表:


表 A


IDName
1Alice
2Bob
3Charlie

表 B


IDAge
125
230
440

我们希望通过 ID 字段将这两个表连接起来。


步骤 1: 构建哈希表

选择表 A 作为构建表。我们将根据 ID 字段创建哈希表。



  • 对于 ID = 1,哈希值为 hash(1),存储为 {1: Alice}

  • 对于 ID = 2,哈希值为 hash(2),存储为 {2: Bob}

  • 对于 ID = 3,哈希值为 hash(3),存储为 {3: Charlie}


哈希表


{
1: Alice,
2: Bob,
3: Charlie
}

步骤 2: 探测阶段

接下来,我们对表 B 进行探测,查找与哈希表中的行匹配的行。



  • 对于 ID = 1,计算 hash(1),在哈希表中找到匹配,结果为 (1, Alice, 25)

  • 对于 ID = 2,计算 hash(2),在哈希表中找到匹配,结果为 (2, Bob, 30)

  • 对于 ID = 4,计算 hash(4),在哈希表中未找到匹配。


匹配之后做聚合就得到结果集了



这里的hash表是存在内存中的,内存是有限制的,超过阈值之后就会走 磁盘Hash join 的算法



磁盘hash join


如果驱动表中的数据量超过阈值,就会走磁盘hash join的算法。将驱动表拆分成多个哈希区(或桶),每个桶存储在磁盘上。读取磁盘上的hash桶分别加载到内存,进行探测匹配,探测完成释放当前内存桶,继续从磁盘上读取下一个hash桶进行探测匹配,直到磁盘上所有的hash桶都处理完毕。


总结


在实际开发中,尽量减少多表join查询,保持SQL的逻辑清晰,这样不仅能提高性能,还有利于维护。



感谢佬们的一键三连+关注 !!!



作者:提前退休了
来源:juejin.cn/post/7438597251487268875
收起阅读 »

做了这么久前端,这些请求头和响应头的含义你都不知道啊

web
前言 如果你是一名开发,肯定对请求头和响应头这两个词听上去特别有亲切感,请求头和响应头顾名思义就是请求和响应相关的一些信息,但具体到请求头和响应头里面的某个参数是啥意思可能很多人就不知道了。 就像最近问到一些面试者响应头里面最常见的Cache-Control和...
继续阅读 »

前言


如果你是一名开发,肯定对请求头和响应头这两个词听上去特别有亲切感,请求头和响应头顾名思义就是请求和响应相关的一些信息,但具体到请求头和响应头里面的某个参数是啥意思可能很多人就不知道了。


就像最近问到一些面试者响应头里面最常见的Cache-ControlContent-Type所代表的是什么意思,很多都回答的支支吾吾的。真要说在项目中这种面试者也肯定能正常搬砖干活,但一看就是基本功非常差,如果有对比选择的情况下非常容易被"pass"掉。


因此这篇文章主要对比较常用的请求头和响应头进行解释,除了能应对面试官外还能对知识面进行扩展。


什么是请求头和响应头


简单说请求头和响应头就是HTTP协议的组成部分,请求头和响应头用于在客户端(浏览器)和服务器之间携带传递额外的属性,这些属性内容会用于控制HTTP请求和响应的行为。


其中请求头是客户端带给服务端,响应头是服务端带给客户端。


常见请求头含义


Accept:


含义:表示指定客户端能够接受哪些类型的内容。


当客户端用接口请求时,设置Accept会告诉服务器要返回合适的类型格式。


示例


accept: application/json, text/plain,

Accept-Charset


含义: 表示指定客户端能够接受哪些类型的字符集。


Accept-Charset: utf-8, iso-8859-1;q=0.5

Cookie


含义: 表示用于存储用户特有信息,让用品去识别用户的具体身份。通过Cookie传递用户ID,让服务器端识别用户身份。


示例


Cookie: session=abPC9527; user=tty

Origin


含义: 表示跨域相关信息,用于设置CORS的请求。通过Origin 头,防止陌生的域进行请求。


示例


Origin: https://tty.com

Referer


含义: 表示当前的请求是从哪个url链接过来的。


示例


Referer: https://tty.com/pageone

User-Agent


含义: 表示包含发起请求的用户的一些代理信息,例如浏览器的具体版本和具体类型。


示例


User-Agent: Mozilla/3.0 (Windows NT 9.0; Win32; x64) AppleWebKit/517.36 (KHTML, like Gecko) Chrome/56.0.3029.110 Safari/517.3

If-Modified-Since


含义: 表示客户端在上次获取资源的具体时间。


示例


If-Modified-Since: Tue, 10 Oct 2021 11:01:01 GMT

Range


含义: 表示指定第一个字节到指定最后字节之间的位置,用于告诉服务器想取那个范围的数据。


示例


Range: bytes=0-255

常见响应头含义


Access-Control-Allow-Origin


含义: 表示用于配置CORS跨域相关,指定允许访问资源的域名,如果配置为*表示所有可访问。


示例


Access-Control-Allow-Origin: *

Cache-Control


含义: 表示缓存机制的缓存策略。


示例------这里面试重点


Cache-Control:public  // 响应会被缓存
Cache-Control:must-revalidate // 指定条件下会缓存重用
Cache-Control:no-cache // 直接向服务器端请求最新资源,不缓存
Cache-Control:max-age=10 // 设置缓存的有效时间
Cache-Control:no-store // 在任何条件下,响应都不会被缓存

Content-Length


含义: 表示当前响应体的具体大小,具体单位为字节。


示例


Content-Length: 9527

Content-Type


含义: 表示响应体的具体数据格式是什么。


示例


Content-Type: application/json

Date


含义: 表示服务器开始对客户端发送响应的具体时间。


示例


Date: Tue, 10 Oct 2021 11:01:01 GMT

ETag


含义: 表示用于验证缓存,确保当前的资源未被修改过。如果没有更改过则返回304状态码,减少不必要传输。


示例


ETag: "1234952790pc"

Location


含义: 表示用于重定向,指向一个新的URL


示例


Location: https://tty.com/new-page

Set-Cookie


含义: 表示服务器通过这个请求头把cookie带到客户端。客户端会在后面请求中自动将这cookie放在请求头中。


示例


Set-Cookie: session=pc9527; Path=/; HttpOnly; Secure

Server


含义: 表示告诉这个服务器软件的信息,例如版本。


示例


Server: Apache/1.4.38 (Ubuntu)

X-Powered-By


含义: 表示返回后端使用的具体框架或技术栈。


示例


X-Powered-By: Express

Content-Encoding


含义: 表示响应体的编码方式,例如gzip压缩。


示例


Content-Encoding: gzip

Last-Modified


含义: 表示资源最后被修改的具体时间。


示例


Last-Modified: Tue, 10 Oct 2021 11:00:00 GMT

Expires


含义: 跟缓存相关,表示指定资源的过期时间,这个时间前都不过期。


示例


Expires: Wed, 21 Oct 2021 07:21:00 GMT

小结


这些内容看似好像日常写业务代码没咋用到,但其实是非常重要的,里面涉及到缓存、跨域和安全相关等等的内容。


这些内容足够验证一个开发知识面是否足够广。


好啦,以上就是比较常见的响应头和请求头的一些字段。如果哪里写的不对或者有更好有建议欢迎指出。


作者:天天鸭
来源:juejin.cn/post/7438451242567319571
收起阅读 »

纯前端图片压缩神器 Compressor

web
点赞 + 关注 + 收藏 = 学会了 本文简介 现在大部分网站都会有图片,不管这个图片是用来展示的,还是需要上传到服务器的。 但图片的体积往往比文字大,会占用更多的服务器空间,也会消耗用户更多的流量。所以在适当范围内压缩一下图片是很有必要的。 今天介绍一款纯前...
继续阅读 »

点赞 + 关注 + 收藏 = 学会了


本文简介


现在大部分网站都会有图片,不管这个图片是用来展示的,还是需要上传到服务器的。


但图片的体积往往比文字大,会占用更多的服务器空间,也会消耗用户更多的流量。所以在适当范围内压缩一下图片是很有必要的。


今天介绍一款纯前端的图片压缩工具:compressor.js。


虽然这是一款有损的图片压缩工具,但压缩质量还是挺不错的,尤其是它可以在前端运行,对于要上传图片到服务器的业务,可以考虑一下用 compressor.js。


你也可以用 Compressor.js 做个图片压缩的工具网站,用户多了就开个百度或者谷歌的广告,也能赚点奶茶钱。


先体验一下 compressor.js 的效果:fengyuanchen.github.io/compressorj…


这是 compressor.js 的代码仓库:github.com/fengyuanche…


动手试试


安装 compressor


npm


npm 通过这条命令安装。


npm install compressorjs

然后在需要使用到 compressor.js 的页面中引入。


import Compressor from 'compressorjs';

CDN


如果你不使用打包工具,也可以直接通过 CDN 在 HTML 中引入 Compressor.js。


<script src="https://cdn.jsdelivr.net/npm/compressorjs@latest/dist/compressor.min.js"></script>


基础用法


要使用 compressor.js 压缩图片,首先通过 new Compressor 创建一个压缩实例,并传入文件和一些配置参数。成功后会返回一个压缩后的图片对象。


接下来我用一个小例子演示一下。这个例子通过上传一张图片,然后使用 compressor 压缩它,再返回一个下载链接。


01.gif


<!-- 文件上传控件 -->
<input type="file" id="fileInput" accept="image/*">

<!-- 下载压缩后的图片 -->
<div id="downloadLink">
 <a id="downloadCompressed" style="display:none;" download>点击下载压缩后的图片</a>
</div>

<!-- 引入 Compressor.js -->
<script src="https://cdn.jsdelivr.net/npm/compressorjs@latest/dist/compressor.min.js"></script>

<script>
 // 获取 file input 和下载链接元素
 const fileInput = document.getElementById('fileInput')
 const downloadCompressed = document.getElementById('downloadCompressed')

 // 当文件选择发生变化时触发
 fileInput.addEventListener('change', function(event) {
   const file = event.target.files[0] // 获取上传的文件

   if (!file) {
     return // 如果没有选择文件,则不继续执行
  }

   // 使用 Compressor.js 压缩图片
   new Compressor(file, {
     success(result) {
       downloadCompressed.href = URL.createObjectURL(result)

       // 显示下载链接
       downloadCompressed.style.display = 'inline'
    },
     error(err) {
       console.error('压缩失败:', err)
    },
  })
})
</script>

在这个例子中,使用了 <input type="file"> 作为上传图片的元素,获取到用户上传的图片后,使用 new Compressor(file[, options]) 来压缩图片,new Compressor 接收2个参数,第一个参数是图片文件,第二个参数是一系列参数,在本例中的所有参数都使用了默认值。最后通过 success() 处理压缩成功后的操作,用 error() 处理压缩失败后的操作。


当压缩成功后就进入 success(result) 里了,这里的 result 返回了压缩成功后的图片对象,通过 URL.createObjectURL(result) 的方式将返回压缩成功后的图片地址。将该地址赋值到 <a> 标签里就能给用户手动点击下载了。


挺简单吧~


配置压缩强度


在前面的例子中,我们通过 new Compressor(file[, options]) 压缩图片,但压缩的强度默认是 80%,在压缩 JPG 时默认是 92%。如果你希望将图片体积压缩得更小(画质会更差),可以在 options 这个参数里配置一项 qualityquality 接收的值是 0~1quality 的数值越小压缩出来的图片体积就越小,压缩力度就越大。


具体用法:


// 省略部分代码
new Compressor(file, {
 quality: 0.6, // 设置压缩质量为 60%
 success(result) {}, // 压缩成功后执行这里的代码
 error(err) {} // 压缩失败后执行这里的代码
})

设置下载文件的文件名


在前面的例子中,我们下载压缩成功后的图片,文件名看上去是一堆乱码。


02.png


比如,我想将压缩后的图片名改成在原图的文件名后面拼上“-德育处主任”,可以这么做。


// 省略部分代码

new Compressor(file, {
 quality: 0.6, // 设置压缩质量为 60%
 success(result) {
   // 获取文件名,并给压缩后的文件加上 "-德育处主任" 后缀
   const originalName = file.name;
   const extensionIndex = originalName.lastIndexOf('.');
   const nameWithoutExtension = originalName.substring(0, extensionIndex);
   const extension = originalName.substring(extensionIndex);
   downloadCompressed.download = nameWithoutExtension + '-德育处主任' + extension;
   downloadCompressed.href = URL.createObjectURL(result)

   // 显示下载链接
   downloadCompressed.style.display = 'inline'
},
 error(err) {
   console.error('压缩失败:', err)
}
})

压缩网络图片


compressor.js 的第一个参数必须是一个 File 对象(通常是通过文件上传获取的),它不支持直接传入网络图片的 URL。因为它需要操作的是一个本地的 FileBlob 对象,而不是通过 URL 获取的资源。


但我们可以先通过 JavaScript 将网络图片转换为一个 FileBlob 对象,然后再将其传递给 compressor.js。


我上传了一张图片到免费的图床上(这是将我公众号的url转成艺术二维码的图片): i.imgur.com/zyurGlf_d.w…


03.png


function urlToBlob(url) {
 return fetch(url)
  .then((response) => response.blob())
  .then((blob) => {
     // Step 2: 将 Blob 传递给 Compressor.js
     new Compressor(blob, {
       quality: 0.8, // 设置压缩质量
       success(result) {
         console.log('压缩后的图片:', result)
      },
       error(err) {
         console.error('压缩出错:', err)
      },
    })
  })
}

const imageUrl = 'https://i.imgur.com/zyurGlf_d.webp?maxwidth=760&fidelity=grand'

urlToBlob(imageUrl)

通过 fetch 读取这张图片,然后将读取回来的图片执行 .blob() 方法将其转换成 blob 再丢给 compressor.js 压缩。




以上就是本文的内容啦,如果你想在线体验一下 compressor.js 的压缩能力,可以试试这个网站 worklite.vip/


04.png


点赞 + 关注 + 收藏 = 学会了


作者:德育处主任
来源:juejin.cn/post/7415912074993319976
收起阅读 »

uni-app 接入微信短剧播放器

web
前言 作为一个 uniapp 初学者,恰巧遇到微信短剧播放器接入的需求,在网上检索许久并没有发现傻瓜式教程。于是总结 uni-app 官网文档及微信开放文档,自行实践后,总结出几个步骤,希望为大家提供些帮助。实践后发现其实确实比较简单,大佬们可能也懒得写文档...
继续阅读 »

前言



作为一个 uniapp 初学者,恰巧遇到微信短剧播放器接入的需求,在网上检索许久并没有发现傻瓜式教程。于是总结 uni-app 官网文档及微信开放文档,自行实践后,总结出几个步骤,希望为大家提供些帮助。实践后发现其实确实比较简单,大佬们可能也懒得写文档,那么就由我这个小白大概总结下。本文档仅涉及剧目提审成功后的播放器接入,其余相关问题请参考微信官方文档。



小程序申请插件



参考文档:developers.weixin.qq.com/miniprogram…



首先,需要在小程序后台,申请 appid 为 wx94a6522b1d640c3b 的微信插件,可以在微信小程序管理后台进行添加,路径是 设置 - 第三方设置 - 插件管理 - 添加插件,搜索 wx94a6522b1d640c3b 后进行添加:
小程序管理后台示例
搜索添加


uni-app 项目添加微信插件



参考文档:uniapp.dcloud.net.cn/tutorial/mp…



添加插件完成后,在 manifest.json 中,点击 源码视图,找到如下位置并添加红框内的代码,此步骤意在将微信小程序插件引入项目。
在这里插入图片描述


/* 添加微短剧播放器插件 */
"plugins" : {
"playlet-plugin" : {
"version" : "latest",
"provider" : "wx94a6522b1d640c3b"
}
}

manifest.json 中完成添加后,需要在 pages.json 中找一个页面(我这边使用的是一个新建的空白页面)挂载组件,挂载方式如下图红框中所示,需注意,这里的组件名称需要与 manifest.json 中定义的一致:
pages.json


{
"path": "newPage/newPage",
"style": {
"navigationBarTitleText": "",
"enablePullDownRefresh": false,
"navigationStyle": "custom",
"app-plus": {
"bounce": "none"
},
"mp-weixin": {
"usingComponents": {
"playlet-plugin": "plugin://playlet-plugin/playlet-plugin"
}
}
}
}

挂载空页面是个笨办法,目前我这边尝试如果不挂载的话,会有些问题,有大神知道别的方法可以在评论区指点一下~


App.vue 配置



参考文档:developers.weixin.qq.com/miniprogram…



首先,找个地方新建一个 playerManager.js,我这边建在了 common 文件夹下。代码如下(代码参考微信官方文档给出的 demo):


	var plugin = requirePlugin("playlet-plugin");
// 点击按钮触发此函数跳转到播放器页面
function navigateToPlayer(obj) {
// 下面的${dramaId}变量,需要替换成小程序管理后台的媒资管理上传的剧目的dramaId,变量${srcAppid}是提审方appid,变量${serialNo}是某一集,变量${extParam}是扩展字段,可通过
const { extParam, dramaId, srcAppid, serialNo } = obj
wx.navigateTo({
url: `plugin-private://wx94a6522b1d640c3b/pages/playlet/playlet?dramaId=${dramaId}&srcAppid=${srcAppid}&serialNo=${serialNo}&extParam=${extParam || ''}`
})
}
const proto = {
_onPlayerLoad(info) {
const pm = plugin.PlayletManager.getPageManager(info.playerId)
this.pm = pm
// encryptedData是经过开发者后台加密后(不要在前端加密)的数据,具体实现见下面的加密章节
this.getEncryptData({serialNo: info.serialNo}).then(res => {
// encryptedData是后台加密后的数据,具体实现见下面的加密章节
pm.setCanPlaySerialList({
data: res.encryptedData,
freeList: [{start_serial_no: 1, end_serial_no: 10}], // 1~10集是免费剧集
})
})
pm.onCheckIsCanPlay(this.onCheckIsCanPlay)
// 关于分享的处理
// 开启分享以及withShareTicket
pm.setDramaFlag({
share: true,
withShareTicket: true
})
// 获取分享参数,页面栈只有短剧播放器一个页面的时候可获取到此参数
// 例如从分享卡片进入、从投流广告直接跳转到播放器页面,从二维码直接进入播放器页面等情况
plugin.getShareParams().then(res => {
console.log('getLaunch options query res', res)
// 关于extParam的处理,需要先做decodeURIComponent之后才能得到原值
const extParam = decodeURIComponent(res.extParam)
console.log('getLaunch options extParam', extParam)
// 如果设置了withShareTicket为true,可通过文档的方法获取更多信息
// https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/share.html
const enterOptions = wx.getEnterOptionsSync()
console.log('getLaunch options shareTicket', enterOptions.shareTicket)
}).catch(err => {
console.log('getLaunch options query err', err)
})
// extParam除了可以通过在path传参,还可以通过下面的接口设置
pm.setExtParam('hellotest')
// 分享部分end
},
onCheckIsCanPlay(param) {
// TODO: 碰到不可以解锁的剧集,会触发此事件,这里可以进行扣币解锁逻辑,如果用户无足够的币,可调用下面的this.isCanPlay设置
console.log('onCheckIsCanPlay param', param)
var serialNo = param.serialNo
this.getEncryptData({serialNo: serialNo}).then(res => {
// encryptedData是后台加密后的数据,具体实现见下面的加密章节
this.pm.isCanPlay({
data: res.encryptedData,
serialNo: serialNo,
})
})
},
getEncryptData(obj) {
const { serialNo } = obj
// TODO: 此接口请求后台,返回下面的setCanPlaySerialList接口需要的加密参数
const { srcAppid, dramaId } = this.pm.getInfo()
console.log('getEncryptData start', srcAppid, dramaId, serialNo)
return new Promise((resolve, reject) => {
resolve({
encryptedData: '' // TODO: 此参数需从后台接口获取到
})
})
},
}
function PlayerManager() {
var newProto = Object.assign({}, proto)
for (const k in newProto) {
if (typeof newProto[k] === 'function') {
this[k] = newProto[k].bind(this)
}
}
}

PlayerManager.navigateToPlayer = navigateToPlayer
module.exports = PlayerManager

新建完成后,在 App.vue 中进行组件的配置和引用。
在这里插入图片描述


onLaunch: function() {
// playlet-plugin必须和上面的app.json里面声明的插件名称一致
const playletPlugin = requirePlugin('playlet-plugin')

const _onPlayerLoad = (info) => {
var PlayerManager = require('@/common/playerManager.js')
const playerManager = new PlayerManager()
playerManager._onPlayerLoad(info)
}
// 注册播放器页面的onLoad事件
playletPlugin.onPageLoad(_onPlayerLoad.bind(this))
},
_onPlayerLoad(info) {
var PlayerManager = require('@/common/playerManager.js')
const playerManager = new PlayerManager()
playerManager._onPlayerLoad(info)
},

页面使用



参考文档:developers.weixin.qq.com/miniprogram…



以上所有步骤完成后,就可以开心的使用短剧播放器了。 我这边临时写了个图片的 click 事件测试了一下:


clk() {
// 逻辑处理...获取你的各种参数
// 打开组件中封装的播放器页面
PlayerManager.navigateToPlayer({
srcAppid: 'wx1234567890123456', // 剧目提审方 appid
dramaId: '100001', // 小程序管理后台的媒资管理上传的剧目的 dramaId
serialNo: '1', // 剧目中的某一集
extParam: encodeURIComponent('a=b&c=d'), // 扩展字段,需要encode
})
},

写在最后:


总结完了,其实整体下来不是很难,对我这种前端小白来说检索和整合的过程是比较痛苦的,所以希望下一个接入的朋友可以少检索一些文档吧。
另附一个短剧播放器接口的文档: developers.weixin.qq.com/miniprogram…
文档主要介绍了短剧播放器插件提供的几个接口,在js代码里,插件接口实例通过下面的代码获取


// 名字playlet-plugin必须和app.json里面引用的插件名一致
const playletPlugin = requirePlugin('playlet-plugin')





读书越多越发现自己的无知,Keep Fighting!


欢迎友善交流,不喜勿喷~


Hope can help~


作者:漠尘
来源:juejin.cn/post/7373473695057428506
收起阅读 »

不能沉迷于无畏契约了,我要学axios-retry源码,以后遇到接口不响应就这么办!

web
前言 挺久没写文章,最近下班后都在打瓦罗兰特,一直在黄金一和白银三徘徊,感觉已经要废了,所以也没啥时间写文章。工作上最近也是换了一个组,之前主要是干web,现在是在写sass中台和h5,然后也是负责一个小迭代,整体其实就是一个curd,但是也是遇到一些奇奇怪怪...
继续阅读 »

前言


挺久没写文章,最近下班后都在打瓦罗兰特,一直在黄金一和白银三徘徊,感觉已经要废了,所以也没啥时间写文章。工作上最近也是换了一个组,之前主要是干web,现在是在写sass中台和h5,然后也是负责一个小迭代,整体其实就是一个curd,但是也是遇到一些奇奇怪怪的坑,有一个我觉得还是很有含金量的,然后我是用了一个第三包解决的,然后也顺带去看了一下这个包的源码,也学到不少的东西,记录分享一下。


请求不响应后重新请求


在我现在这个项目中,对于一些请求,他的生命周期会比较长。正常来说,我们只需要和一个服务端请求,服务端收到后就返回。但是这里是,前端对服务端a请求后,服务端a还要向服务端b去请求,服务器a只能等待服务器b响应后再给我们前端响应,所以就会存在请求不响应超时的问题,如果是偶发性的还好,但是频率好像还挺高的,就是可能调同一个接口10次,有3次是不响应的。然后我这是将网络禁用去模拟的一个效果。


12345.gif


解决方案


解决方案也挺简单的,就是服务端a在5s内如果收不到服务端b的响应,就会给前端报timeout的错误,我这边如果收到timeout的错误就是重新请求,指数型去重试请求5次,如果还是不成功就只能给用户提示“请求超时,请重新提交了”。


代码



axios-retry地址 http://www.npmjs.com/package/axi…



这里使用了axios-retry,这个包就是可以二次封装axios实例去实现重新请求。正常来说,我们的项目中都会对axios进行封装,如下代码,去对请求拦截器和响应拦截器做一些公共处理。


import axios from 'axios'
const http = axios.create({
 headers: {},
 timeout: 5 * 1000 // 请求超时时间
})
// 请求拦截器
http.interceptors.request.use(
(config) => {
   console.log(config)
   return config
},
(err) => {
   console.log(err)
   return Promise.reject(err)
}
)
// 响应拦截器
http.interceptors.response.use(
(response) => {
   return response
},
(error) => {
   return Promise.reject(error)
}
)
export default http

这里我们就可以得到一个axios实例http,axios-retry就可以对这个实例进行封装实现重新请求


import axios from 'axios'
import axiosRetry from 'axios-retry'  
const api = axios.create({
 headers: {},
 timeout: 5 * 1000 // 请求超时时间
})
// 请求拦截器
api.interceptors.request.use(
(config) => {
   console.log(config)
   return config
},
(err) => {
   console.log(err)
   return Promise.reject(err)
}
)
// 响应拦截器
api.interceptors.response.use(
(response) => {
   // 将index变回0
   index = 0                        
   return response
},
(error) => {
   return Promise.reject(error)
}
)
axiosRetry(api, {
 retries: 5,
 shouldResetTimeout: true,
 retryDelay: (retryCount) => {
   // retryCount为重试的次数
   return retryCount * 1000
},
 retryCondition: err => {
   console.log(err)
   index++
   if (index === 5) {
     // 超过五次进行提示就不进行请求
     Toast('请求超时,请重新提交')
     index = 0
     return false
  } else {
     if (err.message.includes('timeout')) return true
     return false
  }
}
})
export default api

axiosRetry


对于axiosRetry来说,我们只需要去配置下面参数就行




  1. retries 重试次数

  2. retryCondition 重试条件,返回ture就允许重试,返回false就不允许重试

  3. shouldResetTimeout 是否重置超时,ture代表每次重试都重置超时时间 false则相反

  4. retryDelay 延迟重试时间,需要返回一个时间

  5. onRetry 每次重试时执行的回调函数

  6. onMaxRetryTimesExceeded 当达到最大重试次数后执行的回调函数

  7. validateResponse 用于验证响应是否有效的函数



可以看到,我代码中定义闭包了一个index变量,在retryCondition中去判断是否为等于5,如果等于5就不进行重试并提示给用户进行重新提交请求,这里要注意的是要维护好index这个变量,在请求成功后变回0。本来我是想在onMaxRetryTimesExceeded 这个配置项去写逻辑的,但是不知道为什么没有执行这里面的逻辑,我也没去研究了,能实现效果就行了。


源码


86a536fc339d03e39c6dfbba688f53f.jpg
大家可以npm上这个位置去看源码,大家如果感兴趣,最好还是自己去看一下源码,我的分析可能比较片面,而且我本身技术也就那样。这个第三方包的源码相比vue的源码其实还算简单的,没有那么复杂。虽然简单,我看的也很头大。如果你要继续往下面看,就需要你对axios本身有一丢丢了解才行,就比如请求拦截器,响应拦截器这些。当然也能继续看,就是可能会有点迷迷糊糊的,但是肯定是能学到东西的!然后我是将代码的逻辑通过注释是写在代码里面了,所以要先看一下代码块里面的东西


axiosRetry


这个方法就是这个第三方包的主函数,我先说下这个包整体上的实现逻辑,在请求拦截器和响应拦截器中维护一个对象,在响应拦截器中,通过这个对象中一些信息去判断要不要重新请求。可以看到这个函数接收两个参数,一个是axiosInstanceaxios实例,一个defaultOptions也就是我在使用axios-retry配置的配置项。可以看下面的代码,从大的方面来看就是一个请求拦截器,一个响应拦截器,最后将这两个拦截器给return了。


const axiosRetry = (axiosInstance, defaultOptions) => {
   
   // 请求拦截器
   const requestInterceptorId = axiosInstance.interceptors.request.use((config) => {

       setCurrentState(config, defaultOptions, true);
       
       // 这一段代码可以向不看,这个是为了实现配置项上的validateResponse的功能
       // =====1
       if (config[namespace]?.validateResponse) {
           config.validateStatus = () => false;
      }
       // =====1
       return config;
  });
   
   // 响应拦截器
   const responseInterceptorId = axiosInstance.interceptors.response.use(null, async (error) => {
       const { config } = error;
       // 如果没有config,无法判断是否需要重新请求,直接返回错误
       if (!config) {
           return Promise.reject(error);
      }
       const currentState = setCurrentState(config, defaultOptions);
       
       // 这一段可以先不看,为了实现配置项上的validateResponse的功能
       // =====2
       if (error.response && currentState.validateResponse?.(error.response)) {
           // 如果响应没问题(通过 validateResponse 验证)则直接返回响应
           return error.response;
      }
       // =====2
           
       // 根据是否满足重试条件来决定是执行重试操作(调用 handleRetry 函数)
       if (await shouldRetry(currentState, error)) {
           return handleRetry(axiosInstance, currentState, error, config);
      }
       
       // 这一段可以先不看,为了实现配置项上的 onMaxRetryTimesExceeded 的功能
       // =====3
       // 在达到最大重试次数后执行相应回调(调用 handleMaxRetryTimesExceeded 函数)
       await handleMaxRetryTimesExceeded(currentState, error);
       // =====3
           
       return Promise.reject(error);
  });
   
   
   return { requestInterceptorId, responseInterceptorId };
};

除了===之间的内容不看后,拦截器里面剩下的就很简单了,在请求拦截器中,就是调用了setCurrentState这个方法,要想理解setCurrentState这个方法,我们得先知道对于一个axois,发起请求是有一个config对象,这个对象里面包括像请求头,请求方式等等的一些字段,所以这个我们可以理解成一个给config对象中添加属性的方法,源码如下。前面我们说过,这个包的整体思路就是在请求拦截器和响应拦截器维护一个对象,而这个对象就是config中的某一个属性,也就是config[namespace]。namespace是一个变量,变量值为axios-retry,也就是config中叫axios-retry的属性


setCurrentState


这个方法接收三个参数,一个是axois请求的配置,一个用户的配置,一个是否需要重置上次请求时间。可以看下面的代码,一开始是调用了getRequestOptions的这个方法,这个方法就一个合并对象的方法,合并的对象就是我们前面所说的在请求拦截器和响应拦截器维护的那个对象。它是将,我们axios-retry默认配置用户的配置以及config[namespace](也就是维护的那个对象)合并成一个对象。整体去看setCurrentState这个方法,可以分为1,2,3步,分别对应着拿变量,改变量,存变量,就和维护变量的操作一模一样。


function setCurrentState(config, defaultOptions, resetLastRequestTime = false) {
   // 合并配置参数 getRequestOptions方法在下面----------------------------1
   const currentState = getRequestOptions(config, defaultOptions || {});
   
   // 初始化或更新重试次数,retryCount就是记录当前重试的次数
// 如果currentState中没有这个变量,就是第一次请求,有就使用这个变量--------2
   currentState.retryCount = currentState.retryCount || 0;
   // 更新上次请求时间
   if (!currentState.lastRequestTime || resetLastRequestTime) {
       currentState.lastRequestTime = Date.now();
  }
   
   // 赋值给config配置项 namespace就是一个变量,在下面的代码,这就是维护变量的操作---------3
   config[namespace] = currentState;
   
   return currentState;
}

// 合并默认配置,就是将默认的,用户设置的,和config中的配置合并
function getRequestOptions(config, defaultOptions) {
   return { ...DEFAULT_OPTIONS, ...defaultOptions, ...config[namespace] };
}

// 下面这些代码可以先不看
//===============================================================
// 定义添加config对象中的属性名
export const namespace = 'axios-retry';

// 默认配置对象 isNetworkOrIdempotentRequestError和noDelay是一个默认方法,
// 大家感兴趣可以去看源码,因为如果用户有配置的话,就是使用用户配置的回调函数
export const DEFAULT_OPTIONS = {
   retries: 3,
   retryCondition: isNetworkOrIdempotentRequestError,
   retryDelay: noDelay,
   shouldResetTimeout: false,
   onRetry: () => { },
   onMaxRetryTimesExceeded: () => { },
   validateResponse: null
};

我们结合上面的axiosRetry来看,在请求拦截器和响应拦截器都使用了这个方法,也就是说在每一次请求的时候都去更新维护config[namespace]对象。这也就是为啥一直在说核心就是请求拦截器和响应拦截器维护一个对象,那为什么要维护这个对象呢?别急,马上就来了!我们再回去看响应拦截器,除开1,2,3段可以先不看,就剩下下面两行代码,这段代码也就是这个包的核心代码,这段代码主要使用了shouldRetryhandleRetry两个方法,可以看到这两个方法都使用了currentState这个变量,这个变量就是我们一直强调的那个'维护的对象'。shouldRetry方法是用来判断要不要重新的请求,而handleRetry是用来重新请求的方法。


// 根据是否满足重试条件来决定是执行重试操作(调用 handleRetry 函数)
if (await shouldRetry(currentState, error)) {
  return handleRetry(axiosInstance, currentState, error, config);
}

维护的对象


说这么多,这个'维护的对象'到底是什么,我们在请求拦截器中打印一下config这个对象,可能大家已经忘了config是啥,config就是我们在请求拦截器回调接收的那个参数,也就是axois发起请求的配置。在控制台可以看到其中会有axios-retry这样的一个属性,也就是namespace变量的值。我们一直在维护的也是这个axios-retry的值。这个对象里面有重试次数,上次请求时间,重试条件,重试回调等等,也就是我们所配置的那些东西。也就是形参currentState需要的值。


4c1e7f499eb2cf8f57c6f81c317b5d1.png


shouldRetry


async function shouldRetry(currentState, error) {
   // 从currentState拿到retries, retryCondition
   const { retries, retryCondition } = currentState;
   // 如果没超过重试次数,然后通过retryCondition去判断,根据这两个去判断要不要重新请求
   const shouldRetryOrPromise = (currentState.retryCount || 0) < retries && retryCondition(error);
   
   
   // 这一段代码是为了兼容retryCondition可能是promise的值,就要去等待他执行完成
   // =========1
   if (typeof shouldRetryOrPromise === 'object') {  // 这可能是一个promise
       try {
           const shouldRetryPromiseResult = await shouldRetryOrPromise;
           // 保持 return true,除非 shouldRetryPromiseResult 返回 false 以实现兼容性
           return shouldRetryPromiseResult !== false;
      }
       catch (_err) {
           return false;
      }
  }
   // ========1
   return shouldRetryOrPromise;
}

这个方法其实很简单,就是通过重试次数,以及用户配置的retryCondition回调,去得到一个布尔值。整体逻辑大家应该都能看得懂,这里需要给大家讲一下error是什么,error就是在响应拦截器中请求失败的回调的传参,也就是当axios请求失败报错的那个值。下面这张图可以看到这个error中也是有config属性的,也有axios-retry的,这很重要!


0d1965952bb44c759b3f1602cc3b155.png


handleRetry


这个方法就是实现重试的方法,接收四个参数,分别是axiosInstance axios实例,currentState就是config中的axios-retry属性,也就是维护的那个对象,error就是上面那个error,config就是那个config,之前都有提过。


async function handleRetry(axiosInstance, currentState, error, config) {
   
   // 重试次数加1
   currentState.retryCount += 1;
   const { retryDelay, shouldResetTimeout, onRetry } = currentState;
   
   // 执行retryDelay,也就是用户配置的那个retryDelay
   const delay = retryDelay(currentState.retryCount, error);
   
// 修复config======可以不看,为了兼容,感兴趣的可以去细看源码
   fixConfig(axiosInstance, config);
   
   // 这一段代码是为实现用户配置shouldResetTimeout是否重置超时时间的功能
// 如果是false,也就是不进行重置超时时间,所以这里要去更新config中的timeout。
// 如果是ture就不进入这个if,不对timeout做处理
   if (!shouldResetTimeout && config.timeout && currentState.lastRequestTime) {
       const lastRequestDuration = Date.now() - currentState.lastRequestTime;
       const timeout = config.timeout - lastRequestDuration - delay;
       if (timeout <= 0) {
           return Promise.reject(error);
      }
       config.timeout = timeout;
  }
   
   // config.transformRequest是对请求数据进行处理,这里的意思就是传入了什么,就用什么数据。
// 这行代码是为了重置转换函数。
   config.transformRequest = [(data) => data];
   
   // 执行onRetry,也就是用户配置的onRetry
   await onRetry(currentState.retryCount, error, config);
   
// config.signal是AbortController产生的,AbortController是提供取消异步操作的一个js接口。
// 这里所有关于config.signal都是为了兼容,兼容用户对请求进行主动取消的情况下。
// 他是去监听abort事件,因为如果用户需要主动取消请求,会去触发abort事件
// 这里是做了一个防抖以及监听事件和取消监听。
// 如果没有接触过,可以直接把这些相关代码(1,2,3,4)先删了再去看,
// 把这些删了之后发现就只剩下了一个定时器和axiosInstance(config)。
// axiosInstance(config)就是重新请求。
   
   // =============1
   if (config.signal?.aborted) {
       return Promise.resolve(axiosInstance(config));
  }
// =============1
   
   return new Promise((resolve) => {
       // =============2
       const abortListener = () => {
           clearTimeout(timeout);
           resolve(axiosInstance(config));
      };
       // =============2
       
       // delay是上面retryDelay得出的东西
       const timeout = setTimeout(() => {
           resolve(axiosInstance(config));
           
           // =============3
           if (config.signal?.removeEventListener) {
               config.signal.removeEventListener('abort', abortListener);
          }
           // =============3
      }, delay);
       
       // =============4
       if (config.signal?.addEventListener) {
           config.signal.addEventListener('abort', abortListener, { once: true });
      }
       // =============4
  });
}

梳理


看到这里大家可能明白了,可能很懵。因为我这是对核心源码一行一行的去注释,可能并不能将整条线连起来,所以我这用文字去总结一下。首先,我们先将重试作为主线,去看重试是怎么实现的。还是那个'维护的对象',这个对象串联了整条线,这个对象包括我们的重试条件,重试回调等等这些方法。我们先在请求拦截器中和响应拦截器中都是使用了setCurrentState去维护这个对象,然后再响应拦截器中去通过shouldRetry去判断该不该重试,再通过handleRetry去重试。而这两个方法实现的前提就是这个'维护的对象'。比如该不该重试,是通过用户配置的retryCondition和重试次数去判断的,再比如怎么去重试,是通过axios实例配合config参数再次请求。其次,我们再通过我们配置的参数,去看重试这条主线的支线,也就是retryDelay重试延时时间,shouldResetTimeout是否重置超时时间,onRetry重试回调,onMaxRetryTimesExceeded最大重试次数后执行的回调,validateResponse验证响应内容。这些在上面的代码注释中,我都有标明在哪里实现的。


总结


axios-retry这个包是很不错的,可以在无响应报错的时候进行重新请求。在前后端交互的时候,或多或少都会遇到接口不响应超时的问题。而在一些很需要的接口响应的场景,是很实用的,然后源码看不看懂其实都无所谓,会用就行,而且我们这种底层前端的工作内容基本都是curd,根本不用去造轮子。不过看源码也是有很多好处的,比如怎么封装包能让用户有更多的扩展性,像这里的retryCondition和onRetry就不错。还有可以增加自己的自信心的,这包一周是有三百万人在使用的,感觉也没有多复杂,我又觉得我行了(手动狗头)。最后,来个赋能哥带我上分呗,我真打不上去啊。


作者:落课
来源:juejin.cn/post/7439654496694255670
收起阅读 »

大屏可视化效果实现记录

web
适配及响应式处理一次搞懂数据大屏适配方案 (vw vh、rem、scale)处理scale方案下有空白问题效果实现Echarts线图线条渐变色及区域渐变效果图关注点线条颜色渐变线条含有阴影区域填充色渐变配置项series:[{ data: [820, 93...
继续阅读 »

适配及响应式处理

效果实现

Echarts线图线条渐变色及区域渐变

  • 效果图

image.png

  • 关注点

    1. 线条颜色渐变
    2. 线条含有阴影
    3. 区域填充色渐变
  • 配置项
series:[{
data: [820, 932, 901, 934, 1290, 1330, 1320],
type: 'line',
smooth: false,
lineStyle: {
normal: {
// 1. 设置线条渐变色
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{
offset: 0,
color: '#FDFDFF',
},
{
offset: 0.3,
color: '#6EA4F8',
},
{
offset: 0.6,
color: '#7DA0E0',
}, {
offset: 1,
color: '#679BF0',
},
]),
width: 3,
// 2. 设置线条阴影
shadowColor: '#2E4F84',
shadowOffsetY: 15,
shadowOffsetX: 5,
shadowBlur: 3,
},
},
// 3. 设置区域填充渐变:渐变色设置文档 https://echarts.apache.org/zh/option.html#color
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: 'rgba(36,173,254, 0.5)',
}, {
offset: 1,
color: 'rgba(52,112,252, 0.1)',
},
],
},
},
}]

Echarts外环饼图

  • 效果图

image 1.png

  • 关注点

    1. 内圈含有间隔数据
    2. 外圈效果
  • 配置项
// 数据处理
// 间隔空白数据
const gapData = {
name: '',
value: 20,
itemStyle: {
color: 'transparent', // 颜色设置为透明数据
},
};

// 计算饼图渲染数据
const seriesData = [];
[
{ value: 1048, name: 'Search Engine' },
{ value: 735, name: 'Direct' },
{ value: 580, name: 'Email' },
{ value: 484, name: 'Union Ads' },
{ value: 300, name: 'Video Ads' }
].forEach((item) => {
seriesData.push(item);
seriesData.push(gapData);
});
// 图表配置项
series: [
// 内圆环配置项
{
data: seriesData,
roundCap: true,
center: ['50%', '50%'],
radius: ['50%', '60%'],
label: {
show: false,
position: 'center',
},
},
// 外圆环配置项
{
type: 'pie',
name: '旋转圆',
silent: true,
center: ['50%', '50%'],
radius: ['70%', '69%'],
hoverAnimation: false,
startAngle: 50,
// Notes:这里的数据根据要展示的外环段数及长短自定义设置
data:[120, 40, 120, 40, 120, 40].map((item, index) => ({
value: item,
name: '',
itemStyle: {
color: index % 2 === 0 ? '#5999E1' : 'transparent',
shadowBlur: 20,
shadowColor: '#86C6FD',
},
})),
label: {
normal: {
show: false,
},
},
labelLine: {
normal: {
show: false,
},
},
}
],

Echarts 渐变色柱状图

  • 效果图

image 2.png

  • 关注点

    1. 柱体颜色渐变
  • 配置项
  series: [
{
data: [120, 200, 150, 80, 70, 110, 130],
type: 'bar',
// 设置柱体颜色渐变
itemStyle: {
normal: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 1,
color: '#20517E',
opacity: 0.85,
},
{
offset: 0,
color: '#3FC0F7',
opacity: 0.79,
},
]),
},
},
label: {
show: true,
color:'#3FC0F7',
fontSize: 12,
position: 'outside',
},
}
]

Echarts含图片标签渐变色柱状图

  • 效果图

image 3.png

  • 关注项

    1. 渐变色柱体
    2. 高亮结尾
    3. 数据标签含背景图
  • 配置项
option = {
backgroundColor:'#17243A',
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'value',
boundaryGap: [0, 0.01]
},
yAxis: [
{
inverse: true,
axisLabel: {
color: '#ADCBE9',
fontSize: 20,
formatter: (value) => {
if (value.length < 8) {
return value;
}
return `${value.substring(0, 8)}...`;
},
},
axisLine: {
lineStyle: {
color: 'transparent',
},
},
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
}, {
inverse: true,
axisTick: 'none',
axisLine: 'none',
axisLabel: {
show: true,
fontSize: 20,
fontWeight: 'bold',
color: '#BFD1E3',
padding: [5, 12, 5, 12],
backgroundColor: {
image: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAAAWCAYAAAA/45nkAAAACXBIWXMAAAsTAAALEwEAmpwYAAABAElEQVRoge3ZwQoBURTG8XOvKzGzmnQ3SpKJrMZb2HsQL+MRLCkb5RHsbNVspGwkRYamplipaSiUOodzZnl/d/FvZvPVqP5geoXMs18ve0l8ibLnfq1SDlebXfZc7LWZQtHxqq1x1g0AwDZcdKmG/4sl8SW6v+e7HfJ2qCnEcTaNHcDdNAAA1TgOlqv77TnVuH+3ODpO9LuXxb5vSXw+PXwAKnFcTH9yWez7JjMU2WSGYs9Q7ADuprEDuJvMUGSTGYpsMkORTWYooplCydW2EYwoxnEwr9ocaVDKpRjHxZ6uICpxHExmKLIZAADrd2bpwzD175Jq+C+abQRjUMpJ+w1co8ZyUwNCuQAAAABJRU5ErkJggg==',
},
},
data: [120, 200, 150, 80, 70, 110, 130],
},
],
series: [
{
type: 'pictorialBar',
symbol: 'image://data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADQAAAA1CAYAAAAOJMhOAAAACXBIWXMAAAsTAAALEwEAmpwYAAAI9ElEQVRogbWa244cxw2GP1b37EGSAztAAsNArvIYAfIEeetc5hlyZSlxACuCvV7NaqebzEUVq1mcXmlWUgoYzEx3VTV/Hn6SNSM//WZcMixME7l8LiAiWL7ve3zOvh+bVz6+xbOH5M8NjOzcj/M+AeXyMX+FPQSqBZ7QoN+XZhm/a3FtWNOvfc74LAsll/JvAlgTTNJ9aWuy8Ij0724p4wss9jVcrmvcBQuAB8FC3Fh698++8sxCdqHNLgV0FhvxWWza7W6VQSXLSXj1eWnNZ1lp3kP+BItktjqbFdjLYky063tM1+NIBMzq58iCzx2XkkKMDdsJfqMCduHJQoU1siPsYJ0dxV1MEp9yuWj6YeMdJsMMcxbbIYeSXBEqEKGCyEy35+aftNunAHmg7vl+37wJFSm5x1MTVoJL5fsZ3J587gF7ihrGs/KQu1FwLQmudaac5KaSLNDnNJbD54XrTvM5l9lTrLcHKOeCIWlGYOF7FGIQvAnVLZrWRjfuROLg9hSQ9jyDNecADZPjBl2TaW53sxgLRWDVem8qiFq9pnV+AdQV0B+SPgfgmpTgQLs08f6ZhWLyCxuXdq/7cnxPxaawMZloe6yOysixYcmVonVKuy4iT1YifWRAna1g8NtOy2meB6oHeZlKFb4Ik27ljriFzGAqsCpriEENSooCRlbN+eksFDKgqOlshQ7SQbiLBW0VqUKXUi1aApju71O1jTUwJoK2ueq0H+o+T7oxXmKM52sdUMwdBIE7y6SA70m0xcvUrhWrj3JA0sCJGlbq2rWix3SLD20KMUZ3i3Gym5CDRQWwaCH33+irmG2Wi/4bCGESoTQrFIQpgmmAaGBMjYlKCuqKCRr26y6PK7oTQSCoDFyihfqFXETmmqyN4rlHrbvU1L7PpYEqQimjWxrSBV/VWIuwNAWsLeY0PEPZRs5P7rJDeokud+arkcJDQnX3K7K5llvl0Kwyz6PbOdtZqVYyZbOeGlqq8EKKpwCoM2FIFR2IY+jVdlocu8shzxCIgS1GHNRchGne3O6qyKC0tQhrEU5LFd9z1EKNLTHrlL4G5VmKH4/tzIgyO7IopMdS0MIAuGlcGgGIGmUuzFQSm4twUONQhCtqjE1TpV0FlnbvqMbKVi/OwBJipYhU0oAhHLJV4jWbYzLLdVQEGR7iYMpUeqxMDUwBZjWuinAD3EyF6wawlMKqxoMYRzOY4bg0MmixOKtxSqmhWyK2LamF6TTeLeTv0c32WMQ108B0MmjAihozcCPCTRG+mQrfALdTdb3TJNwX45dFgWohbXtMwGMsdkVYvUAlULSnlFQCbaSww+9DQIaKAFoMNbYqbCwmakxFuC7CXIQXRfh2gj8A3xXhAHw4GT9TY+9kwsMMp8UqwxWpINiIZHUwMY726kqfN5Q+sVLYATLM8YKTjZm6tUQ4FOF2Fr6bCn8R4W9NqL8f4B8nWArcrVRXjHs0krEGRryYTZV4bymSzFZSdR2THL6AUI8lnwUqSaRmaAKugVcNzAvgVoS/ivD7CV6KcCPVkuJ7NCtbKmTPqurkaoN/FTZWMy89fHKi8r7Y5+vWYapuG8ecIg2Mj5sGdo7PafNMz1uX8eGBKOK1+LXAUJIPlB3vmdUEOGhjK2esgfIKQIEPKxx1WAFm/Ar8tiqPbW6k7ggSRuvk06OBpHxaZLkK+NxX43tkQgAcDGCzsC7GMsHDotwfJt7enyqfT6WaBvj3Cm+BoxmPWil7gVq8thYjKrK7f4rlAbjPnxvivnCP8ULRGmPMqO4lLbBNa7ZfV+WhCHerUn4+wu1c970qcHvgR+DtqvyyKA8OBrBV0dYbdWuFSqHnoh2ichbsFhqKvmSR2I2qtw0twWqr3dRZqQiPi6EzgHJ6cw83U31NAn/6Hf8y416N9woL7aVbTrKmxJ5notfEUii6oM/x4Iws102aKu8csGrGpNVKPs+871mMYzHW13dwmODlXN3uh1e8U+O0VMucaHFkxppqs+gdltnNQyJ1sWPHKuPRrDOakWIraGVpoISt7JceV4K+voPrubrbzQzLH2sNp8ZSpBOItRKsM62kZm/PGmn00ufsnDkh722Fm9ldLrTQtELVNeztt/3nWMFcTfWlVq0SwCg19rTtpbaB7BbJZBWuxZgms1yOIV+QN7EmgAuuQGkHH9ZqOrsqTO8XsKlO+rDCVaEsxmlRTm6RFpvWwJwl0iBbVKTtgOsCdc2nGikCHfJEqCA8kNemYVNDrwpyPXML8OpQme6kcD1zW1rQB8VEMNaauCHRN9ksAJbw6rL7eVvWRKwWBo3F+21z7y7VKuutRdC5ILPw4vU9HNet9puFF4eJMhVMamPXXc7BBMHjc6J3nMWZv4biNJcRyVIS3mOPIrKVOn3+VFBg/vE9fH8LDwu8uQdgKtKLTw3Cejz6wWPcP8sVC4Eh9iX8rN8FzsyWRi+RQqLt1XlbJy8PvLiZ+H4q/Bn4oa19syr/fFR+uj9xbIk0ap34HuNpp/eJwPoYjrEiqIR8+AU7MmDIE91yrXL+sMJ/izGJ8Gub+26Fd6vy2MD0g48IIlUHww/OIbn2FuIpQIOAAUTW0Mdcz6DWd8eFUxHuKDxOxluAFR5PK8eHlZNbJSomJdZoHbfg2YluHnu/sQ7ny/6g0OfHh8XWQ+MZhBp6XHgscJoL7327R0V11P6QQOPe7fmdrgPwJ8fe70Pd/HFhqp3OzJ/nAyxa531YWcP67UG2daJZ2FTHuUJzGfbRGNobcYP47sHvD41VeNZk13QCcyZMcrl+UivSma9f36n7LgIUk+heqRFPUjvlprn5ODczF2Gv4dlPpJFdID6e80+S7vO5qw0Pj/Qb/T+2JxEwYb/hVCe5ouW9nxoX/WicNRk/Jy0ONOtWcheJ39kUEVuW3SIZhs71ywE5Ls61lE9X+/WUCNm5F10sMlmPwbjmUiGfA2jP97eb4Y9IqVIfrLpXQae5BJe+GIiPZ/8bKwkrMXZiXBC0HPsozl3yqfFsMPBlfwA8e2Bwk6EhhA6qX/9/ja/xj8Y4csIbvst4cJmr+c+ySB7/A5p05mnftr1GAAAAAElFTkSuQmCC',
symbolOffset: [20, -5],
symbolSize: [40, 40],
symbolPosition: 'end',
z: 12,
data: [120, 200, 150, 80, 70, 110, 130],
}, {
name: '',
type: 'bar',
showBackground: true,
yAxisIndex: 0,
barWidth: 7,
barBorderRadius: 10,
data: [120, 200, 150, 80, 70, 110, 130].map((value, index) => ({
value,
itemStyle: {
normal: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 1,
y2: 0,
colorStops: [
{
offset: 0,
color: '#2F3E56',
},
{
offset: 1,
color:'#7BB1EE',
},
],
},
},
},
})),
},
]
};

Echarts立体柱状图

  • 效果图

image 4.png

  • 关注点

    1. 三面立体
    2. 柱体渐变
  • 配置项

    // 自定义图形
    // 绘制左侧面
    export const CubeLeft = echarts.graphic.extendShape({
    shape: {
    x: 0,
    y: 0,
    },
    buildPath (ctx, shape) {
    const xAxisPoint = shape.xAxisPoint;
    const c0 = [shape.x, shape.y];
    const c1 = [shape.x - offsetX, shape.y - offsetY];
    const c2 = [xAxisPoint[0] - offsetX, xAxisPoint[1] - offsetY];
    const c3 = [xAxisPoint[0], xAxisPoint[1]];
    ctx.moveTo(c0[0], c0[1]).lineTo(c1[0], c1[1]).lineTo(c2[0], c2[1]).lineTo(c3[0], c3[1])
    .closePath();
    },
    });

    // 绘制右侧面
    export const CubeRight = echarts.graphic.extendShape({
    shape: {
    x: 0,
    y: 0,
    },
    buildPath (ctx, shape) {
    const xAxisPoint = shape.xAxisPoint;
    const c1 = [shape.x, shape.y];
    const c2 = [xAxisPoint[0], xAxisPoint[1]];
    const c3 = [xAxisPoint[0] + offsetX, xAxisPoint[1] - offsetY];
    const c4 = [shape.x + offsetX, shape.y - offsetY];
    ctx.moveTo(c1[0], c1[1]).lineTo(c2[0], c2[1]).lineTo(c3[0], c3[1]).lineTo(c4[0], c4[1])
    .closePath();
    },
    });
    // 绘制顶面
    export const CubeTop = echarts.graphic.extendShape({
    shape: {
    x: 0,
    y: 0,
    },
    buildPath (ctx, shape) {
    const c1 = [shape.x, shape.y];
    const c2 = [shape.x + offsetX, shape.y - offsetY]; // 右点
    const c3 = [shape.x, shape.y - offsetX];
    const c4 = [shape.x - offsetX, shape.y - offsetY];
    ctx.moveTo(c1[0], c1[1]).lineTo(c2[0], c2[1]).lineTo(c3[0], c3[1]).lineTo(c4[0], c4[1])
    .closePath();
    },
    });
    function getRenderItem(param, type) {
    const colorList = ['#66C9F2', '#80D1CD', '#9BD977'];
    const color = colorList[param.dataIndex % 3];
    const rgba = color16ToRGBA(color, type === 'top' ? 0.6 : 0.01);
    return {
    fill: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
    {
    offset: 1,
    color: rgba,
    },
    {
    offset: 0,
    color,
    },
    ]),
    };
    }
    // 图表配置项
    config = {
    xAxis: {
    axisLine: {
    lineStyle: {
    color: 'transparent',
    },
    },
    axisLabel: {
    color: '#B1CBD8',
    fontSize: 20,
    },
    },
    yAxis: {
    show: false,
    splitLine: {
    show: false,
    },
    },
    series:[
    {
    type: 'custom',
    // 使用自定义的图形进行绘制
    renderItem: (params, api) => {
    const location = api.coord([api.value(0), api.value(1)]);
    return {
    type: 'group',
    children: [
    {
    type: 'CubeLeft', // 绘制左侧面
    shape: {
    api,
    xValue: api.value(0),
    yValue: api.value(1),
    x: location[0],
    y: location[1],
    xAxisPoint: api.coord([api.value(0), 0]),
    },
    style: {
    ...getRenderItem(params),
    },
    },
    {
    type: 'CubeRight', // 绘制右侧面
    shape: {
    api,
    xValue: api.value(0),
    yValue: api.value(1),
    x: location[0],
    y: location[1],
    xAxisPoint: api.coord([api.value(0), 0]),
    },
    style: {
    ...getRenderItem(params),
    },
    },
    {
    type: 'CubeTop', // 绘制顶层
    shape: {
    api,
    xValue: api.value(0),
    yValue: api.value(1),
    x: location[0],
    y: location[1],
    xAxisPoint: api.coord([api.value(0), 0]),
    },
    style: {
    ...getRenderItem(params, 'top'),
    },
    },
    ],
    };
    },
    data: [120, 200, 150, 80, 70, 110, 130],
    },
    {
    type: 'bar',
    label: {
    normal: {
    show: true,
    position: 'top',
    formatter: e => `${e.value}%`,
    fontSize: 15,
    color: '#fff',
    offset: [0, -15],
    },
    },
    itemStyle: {
    color: 'transparent',
    },
    tooltip: {},
    data: [120, 200, 150, 80, 70, 110, 130],
    },
    ]
    }

CSS旋转圆动画效果

  • 效果图

iShot_2024-11-20_11.15.37.gif

<div class="value">
<span>{{ item.value }}span>
<span class="unit">%span>
div>
/** 定义旋转动画 **/
@keyframes rotate {
100% {
transform: rotate(360deg);
}
}

.value {
width: 9vh;
height: 9vh;
line-height: 9vh;
text-align: center;
position: relative;
margin: auto;
border-radius: 50%;
/** 设置元素背景径向渐变色 **/
background: radial-gradient(50% 50% at 50% 50%, rgba(12, 27, 48, 0.1) 0%, rgba(12, 27, 48, 0.1) 49%, rgba(116, 217, 229, 0.1) 98%);
text-align: center;

.unit {
font-size: 1.4vh;
position: absolute;
margin-top: 3px;
margin-left: 3px;
}

/** 添加外环元素 **/
&::before,
&::after {
content: "";
position: absolute;
top: -1.5vh;
left: -1.5vh;
bottom: -1.5vh;
right: -1.5vh;
border-radius: 50%;
border-top: 3px solid #58A7B4;
/** 为外环元素添加旋转动画 **/
animation: rotate 6s infinite linear;
}

/** 第二个半圆添加动画延迟3S,使两个动画可以交替执行 **/
&::after {
animation-delay: 3s;
}
}

CSS元素浮动漂浮效果

  • 效果图

iShot_2024-11-20_14.05.01.gif

  • 实现
/** 定义浮动动画 **/
@keyframes float {
0% {
transform: translateY(0);
}

50% {
transform: translateY(-20px);
}

100% {
transform: translateY(0);
}
}

/** 为元素整体添加动画 **/
.indicator{
...其他样式项
animation: float 3s infinite ease-in-out;

/** 往后每个元素的动画执行延迟2s,保证不同的漂浮幅度 **/
&.indicator2{
animation-delay: 2s;
}

&.indicator3{
animation-delay: 4s;
}

&.indicator4{
animation-delay: 6s;
}
}

字体渐变色

  • 效果

image 5.png

span {
/** 设置字体的背景色为径向渐变色 **/
background: linear-gradient(180deg, #F5F5F5 0%, #7EB8E6 100%);
/** 将背景作用区域更新为文本,背景被裁剪为文字的形状 **/
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
/** 文字本身设置为透明色 **/
color: transparent;
}

其他

动态渲染大屏模块

  • 背景

    一般大屏页面会显示多个小模块,在不同的场景下分别配置显示哪几个。当前由接口提供一组要渲染的模块值,需要根据接口动态设置要渲染的内容

  • 实现方式

    由Vue component动态组件进行渲染

    1. 从接口获取一组模块名称:comList
    2. 对comList进行遍历,使用component :is 进行匹配渲染
    3. 注意组件的name名称,使用:is匹配时,需要字段值于之一致
    <template v-for="name in comList">
    <component :is="name" :key="name"/>
    template>

定时器更新图表数据

  • 背景

    所有图表数据量较多,不宜一次性展示全部,而是分组进行循环展示。即每次展示5条数据,间隔一定时间后切换至下5条数据,以此循环。

  • 实现方式

    在顶层App.vue组件中,开启一个定时器,并使用 moduleTimerCount 字段记录当前的组别数,按时间间隔更新该字段。并在子组件中监听该字段,该字段变化时计算当前子组件需要显示的数据条数,并更细图表数据。

    1. 声明 moduleTimerCount 变量
    data(){
    return {
    moduleTimerCount:0
    }
    }
    1. 开启一个定时器

使用 setTimeout 模拟 setInterval 定时器(相比于setInterval,setTimeout每次执行完当前次任务后才会执行下一次任务,不存在任务堆积问题,每次执行完后自行清理、独立调用,内存泄露的风险较低)。

image 6.png

```JavaScript
function openModuleRefresh(delay) {
const execute = () => {
this.moduleTimerCount += 1;
if (moduleRefreshTime) {
clearTimeout(moduleRefreshTime);
}
moduleRefreshTime = setTimeout(execute, delay * 1000);
};

setTimeout(execute, delay * 1000); // 首次延迟执行
},
```

3. 子组件监听字段变化

```JavaScript
watch: {
moduleTimerCount(value) {
if (dataList) {
// 当前接口数据的数据长度
const dataLength = dataList.length;
// 每5个分一组,计算组别数
const totalGr0up = Math.ceil(dataLength / 5);
// 计算当前组别数,使用 moduleTimerCount 值对组别数取余,保证获取的当前组别不会超过总组别数
this.chartGr0upIndex = value % totalGr0up;
// 计算当前的数据,由组别数获取当前组的数据索引
const startIndex = this.chartGr0upIndex * 5;
let endIndex = (this.chartGr0upIndex + 1) * 5;
if (endIndex >= echartsData.data.length) {
endIndex = echartsData.data.length;
}
// 根据索引截取数据
const renderChartData = echartsData.slice(startIndex, endIndex);
}
},
},
```

作者:云小遥
来源:juejin.cn/post/7439207153938317339

收起阅读 »

小程序头像昵称获取“一刀切”式调整,害苦开发者

web
💬 前言正如标题所言,小程序的用户头像昵称获取规则从2022年的5月调整了,但是这一个改动却害苦了一众开发者。我遇到这个问题,是在9月份开发个人小程序的时候。我开发的是一个 “微信头像加国旗” 类的小程序,叫做 “星点贴纸”。本以为开发会很顺利,因为几乎没有复...
继续阅读 »

💬 前言

正如标题所言,小程序的用户头像昵称获取规则从2022年的5月调整了,但是这一个改动却害苦了一众开发者。我遇到这个问题,是在9月份开发个人小程序的时候。

我开发的是一个 “微信头像加国旗” 类的小程序,叫做 “星点贴纸”。本以为开发会很顺利,因为几乎没有复杂业务,但是起步没多久微信 API 冷不丁地就给了我一顿暴击(红色警告),这个暴击就是 “小程序用户头像昵称获取规则调整”。

💻 还原业务场景

要讲清楚当时开发过程遇到的问题,就要先代入到业务场景来说明。“星点贴纸” 主要提供给微信头像加各类贴纸的功能,那么这首先就需要获取到用户头像,当然这是功能最直接的实现路径。“星点贴纸” 小程序所能够提供并使用作为头像的方式有:

  • 使用微信头像
  • 从相册中选择
  • 使用相机拍摄

是的,我就是这样规划业务功能的,于是我就实现了点击头像预览区时,弹出选项列表。

image.png image.png

弹出选项列表

编码实现如下

  1. 在 wxml 中给头像预览区添加点击事件bind:tap="preAvatarTapped"

<view class="avatar-area" bind:tap="preAvatarTapped">
<image class="img-sticker"
mode="{{preAvatar.stickerMode}}"
style="{{preAvatar.stickerPosition}}"
src="{{preAvatar.sticker}}">

image>
<image class="img-sample" src="{{preAvatar.sample}}">image>
view>
  1. 在 js 中实现点击后调用wx.showActionSheet(),弹出选项列表。
Page({
preAvatarTapped(event) {
wx.showActionSheet({
itemList: ["使用微信头像", "从相册中选择", "使用相机拍摄"],
itemColor: "#FFBB66",
success: (res) => {
console.log(res.tapIndex);
},
fail: (err) => {
console.log(err.errMsg);
}
});
}
});

获取用户头像

那么接下来,就是实现 “获取用户微信头像”,于是我在微信小程序 API 中找到了wx.getUserProfile用于获取用户头像昵称。

Page({
preAvatarTapped(event) {
wx.showActionSheet({
itemList: ["使用微信头像", "从相册中选择", "使用相机拍摄"],
itemColor: "#FFBB66",
success: (res) => {
console.log(res.tapIndex);
if (res.tapIndex === 0) {
wx.getUserProfile({
desc: '用于处理图像',
success: (res) => {
console.log(res.tapIndex);
},
fail: (err) => {
console.log(err.errMsg);
}
});
}
},
fail: (err) => {
console.log(err.errMsg);
}
});
}
});

但是编译测试后发现控制台报错了,获取的头像也是灰色的默认头像。看着错误信息 “jsapi invalid request data”,我以为是请求的参数问题,实际上是接口本身的问题。

📖 阅读文档

起初我也看到了红色的 Tip,但是并没有理会,因为 API 调整我想大概率也就是字段变更之类的。但是找了一圈发现依然解决不了 “jsapi invalid request data” 的问题,索性点进去看看 Tip。

小程序用户头像昵称获取规则调整公告

不看不知道,一看才知道,自2022年10月25日之后基础库 2.27.1 版本以上通过wx.getUserProfile接口获取用户头像将统一返回默认灰色头像,昵称将统一返回 “微信用户”。如业务需获取用户头像昵称,可以使用「头像昵称填写」能力。

头像昵称填写

Ok,既然官方文档也给出了解决方法,那就换成 “头像昵称填写” API 来实现获取用户微信头像。只不过,这又让我犯了难。

首先官方文档规定 “获取头像昵称” 的开放能力只能通过button组件的open-type="chooseAvatar"实现。而我前面所使用的wx.showActionSheet并不支持给选项添加这样的参数,当然第三方的 UI 组件库是能够实现的。

其次就是 “弹出选项列表” 通过给view组件添加点击事件实现的,,而获取头像昵称的开放能力只能通过button组件实现。

最开始我是循着业务功能的需求尝试解决问题,使用第三方的 UI 组件库来实现 “弹出选项列表”,但是因为 UI 样式及一些参数无法达到预期遂放弃。

于是想着如何给view实现添加open-type="chooseAvatar",经过研究后终于实现了。这里面的难点是只能使用button组件的前提下又要不影响原本页面设计的样式,如何对button组件的样式作改动。

这里的思路如下:

  1. 将按钮组件作为一个遮罩层,覆盖在图片组件上面,这里需要用到定位以及z-index实现。
  2. 将按钮的样式改成透明,可以使用plain="true"将按钮镂空。
  3. 此时按钮的样式还需要去掉边框,使用border: unset
<view class="avatar-area">
<button class="btn-mask" plain="true" open-type="chooseAvatar" bindchooseavatar="getUserAvatar">button>
<image class="img-sticker" mode="{{preAvatar.stickerMode}}" style="{{preAvatar.stickerPosition}}" src="{{preAvatar.sticker}}">
image>
<image class="img-sample" src="{{preAvatar.sample}}">image>
view>
.avatar-area .btn-mask {
position: absolute;
width: 100%;
height: 100%;
border: unset;
z-index: 1;
}

小程序页面实现效果如下:

image.png

🫠 放弃使用微信头像

也许大家都以为改用 “头像昵称填写” 后问题已经解决了,但是我却放弃了这个方案。原因很简单,“头像昵称填写” 所获取的头像是一个十分模糊的头像,根本不适用于 “微信头像” 图像处理类业务,包括 “微信头像加国旗” 这样的功能。至于有多糊,我也懒得去看图片尺寸了,因为当时也是被气到模糊了,根本没法用。一路下来就没办法实现 “使用微信头像”——前功尽弃。

🍉 个人见解

微信官方调整 API 虽然见怪不怪,但是却没想到废弃一个 API 也是想做就做。虽然在 “规则” 的调整背景中有一段话 “实践中发现有部分小程序,在用户刚打开小程序时就要求收集用户的微信昵称头像,或者在支付前等不合理路径上要求授权。如果用户拒绝授权,则无法使用小程序或相关功能。在已经获取用户的 openId 与 unionId 信息情况下,用户的微信昵称与头像并不是用户使用小程序的必要条件”,但是请问,在没有获取用户的 openId 与 unionId 信息情况下呢?

微信官方似乎是站在用户隐私的立场做的调整,但如此 “一刀切” 式的调整,那么《小程序用户隐私保护指引》的意义在哪里?如果用户觉得授权不合理自然会举报,而官方则应当要求违规的小程序整改,而不该想当然地废弃一个接口,又临时拿出一个替代接口,属实是又当又立。再退一步讲,即使是用替代接口,也总不该是个功能降级的接口......

言尽于此,最后还是希望微信官方有一天能把这个问题解决。

image.png
作者:喺小VIE
来源:juejin.cn/post/7436361280586366987

收起阅读 »

我患上了空指针后遗症

下面这个报错,相信没有任何一个 Java 程序员没有被它折磨过。我们对他的熟悉程度简直超过了 Hello World。  何止是熟悉,那简直是深恶痛绝,以至于我对它都产生了后遗症。每当本地调试出现这个错误的时候,都恨不得掐一下大腿,然后默默...
继续阅读 »

下面这个报错,相信没有任何一个 Java 程序员没有被它折磨过。我们对他的熟悉程度简直超过了 Hello World。  何止是熟悉,那简直是深恶痛绝,以至于我对它都产生了后遗症。

每当本地调试出现这个错误的时候,都恨不得掐一下大腿,然后默默的对自己说:垃圾,还犯这么愚蠢的错误呢?

不知道有多少同学和我一样有这种感受呢?

回想起我之前接手的一个项目,线上出现了问题,当我到了服务器一看日志,只有几个单词,那就是 java.lang.NullPointerException,那一刻我是头晕目眩,差点一头撞在 27 寸的显示器上。回想上一次出现这种症状,还是几年前挤早高峰的公交车,挤的我双脚离地,外加有点低血糖。

当然主要问题并不是 NLP(NullPointerException),还是要仰仗前辈异常处理的“非常优秀”,异常包裹的严严实实的,只留了java.lang.NullPointerException这一点点信息。

于是只能打开代码,找到报错的接口,一步步排查,满眼看去,皆可空指针啊。从此之后,空指针异常给我留下了深深的阴影。

好在从 JDK 14之后,NLP 异常不再仅仅是简单的这几个单词了,而会附带更加具体的异常信息,比如对一个赋值为 null 的字符串求长度,能捕捉到下面这样的异常信息:

Cannot invoke "String.length()" because "s" is null

空指针的由来

要说空指针异常,那还不只是 Java 的问题,绝大多数语言都有这个问题,比如 C++、C#、Go,但是也有没有这个问题,比如 Rust 。

空指针最早是编程界的鼻祖级人物 Tony Hoare 引入的,早在 1965年,他设计 ALGOL 60 语言的时候引入了Null 的设计,ALGOL 可谓是 C 语言的祖宗。ALGOL 中的 Null 被后来的众多语言设计者引入,就包括前面提到的这些语言。

Tony Hoare 不仅发明了我们熟悉的 Null,还是令众多算法残废闻风丧胆的快速排序算法(Quick Sort)的发明者,这个算法也是当前世界上使用最广泛的算法之一。

空指针后遗症.png

2009年3月他在Qcon技术会议上发表了题为「Null引用:代价十亿美元的错误」的演讲,回忆自己1965年设计第一个全面的类型系统时,未能抵御住诱惑,加入了Null引用,仅仅是因为实现起来非常容易。它后来成为许多程序设计语言的标准特性,导致了数不清的错误、漏洞和系统崩溃,可能在之后40年中造成了十亿美元的损失

如何应对空指针

处理空指针有一些措施,我们常常称之为「防御式编程」,这个说法也很形象,你不防着它,它真的就上来伤害你。

**1、**主动检查空指针,要使用一个变量之前,要检查这个变量是不是空,不是空再操作,比如常用的对字符串判空。

public static boolean isEmpty(CharSequence cs) {
return cs == null || cs.length() == 0;
}

对应的很多字符串工具类都有 isEmptyisNotEmptyisNotBlank 这种方法。

同样的,还有对于集合的判断,好多工具包都有 CollectionUtil.isEmpty这样的方法。

为了避免空引用异常,有时候我们写的代码可能想下面这个样子,一步一判空。这样可以提高代码的健壮性和可靠性,但是看上去并不是很美观。

public static String getUserOrderDetail(Integer userId) {
User user = User.getUser(userId);
if (user != null) {
Order order = user.getOrder();
if (order != null) {
Address address = order.getAddress();
if (address != null) {
String detail = address.getDetail();
if (detail != null) {
return detail;
}
}
}
}
return "不好意思,找了半天,没找到";
}

还好,Java 8 中引入的 Optional 类可以简化这个流程。

public static String getUserOrderDetail(Integer userId) {
return Optional.ofNullable(User.getUser(userId))
.map(User::getOrder)
.map(Order::getAddress)
.map(Address::getDetail)
.orElse("不好意思,找了半天,没找到");
}

2、 能不返回 NULL 的话,就尽量不返回 NULL

比如有些获取集合的方法,没有结果的话,可以返回一个空列表。这种方式对于提供给前端或者消费者使用的接口更加适用,返回一个空集合要远比返回一个空更友好。

3、 能抛异常的话,宁可抛异常,也不要返回 NULL

还有一些情况,抛出给调用者一个具体的异常,要比返回一个 NULL 更加能让调用者清楚到底发生了什么。

比如根据一个用户的信息,但是发现用户不存在了,直接返回给调用者一个「用户不存在」的异常信息更明确,而不是返回一个 NULL,让调用方去猜。

还可以看看风筝往期文章

用这个方法,免费、无限期使用 SSL(HTTPS)证书,从此实现证书自由了

为什么我每天都记笔记,主要是因为我用的这个笔记软件太强大了,强烈建议你也用起来

「差生文具多系列」最好看的编程字体

一千个微服务之死

搭建静态网站竟然有这么多方案,而且还如此简单

被人说 Lambda 代码像屎山,那是没用下面这三个方法


作者:古时的风筝
来源:juejin.cn/post/7438994542769848360
收起阅读 »

Angular 19 来了,一大波我看不懂的主版本升级!(长文警告)

web
00. 前言 Angular 19 主版本正式升级,亮点功能如下: 控制哪些路由在客户端、服务端或构建期渲染,且在预渲染期解析路由参数 核心响应性原语稳定,引入 linkedSignal 等新原语 增量水合预览版,支持追求极致性能的用例 生活质量提升 - 时...
继续阅读 »

00-ng19.png


00. 前言


Angular 19 主版本正式升级,亮点功能如下:



  • 控制哪些路由在客户端、服务端或构建期渲染,且在预渲染期解析路由参数

  • 核心响应性原语稳定,引入 linkedSignal 等新原语

  • 增量水合预览版,支持追求极致性能的用例

  • 生活质量提升 - 时间选择器组件、样式 HMR 等等!


01. 为速度而构建


01-1. 增量水合预览版


增量水合(incremental hydration)允许你使用 @defer 语法对模板局部进行注释,指示 Angular 在特定触发器上惰性加载和水合。


Angular 19 中,你可以在任何使用了 SSR 和完整应用水合的应用中尝试新的增量水合。


请在客户端 bootstrap 中指定:


01-boot.png


要将增量水合应用到部分模板,请使用:


02-part.png


01-2. 默认启用事件重播


SSR 应用中,用户事件与下载并执行处理该事件代码的浏览器之间存在鸿沟。event dispatch 解决了这个问题。


event dispatch 在初始页面加载期间捕获事件,并在负责处理事件的代码可用时重播这些事件。


通过配置水合 provider,你可以启用事件重播功能:


03-replay.png


今天,我们将事件重播升级到稳定版,并默认为所有 SSR 新应用启用此功能!


01-3. 路由级别的渲染模式


Angular 19 提供了一个 ServerRoute 新接口,允许你配置各个路由应该在服务器端渲染、预渲染还是在客户端渲染:


04-route.png


上述示例中,我们指定在服务端渲染 login 路由,在客户端渲染 dashboard 路由,并预渲染其他所有路由。


服务器路由配置是一个新的配置文件,但它使用 globs 组合现有的路由声明,因此你不必复制任何路由。


过去,没有符合人体工程学的方法可以在预渲染时解析路由参数。


现在,可以使用服务器路由配置无缝实现:


05-prerender.png


由于 Angular 在注入上下文中执行 getPrerenderPaths,因此你可以使用 inject 在参数解析中重用业务逻辑。


01-4. SSR + Zoneless Angular


Angular 18 实验性支持 zoneless,允许 Angular 不依赖 zone.js 运行。


等待应用的主要原因是待处理的请求和导航,我们决定引入 HttpClientRouter 的原语,来延迟将页面发送给用户,直到应用准备就绪。你现在可以在 Angular 19 中尝试这两个包和 zoneless!


此外,我们还提供了一个 RxJS 运算符,用于通知服务堆栈 Angular 仍未完成渲染:


06-sub.png


subscription 发出新值时,我们将稳定应用,且服务堆栈会将渲染的标记传递给客户端。


02. DX(开发者体验)


02-1. HMR + 即时编辑/刷新


Angular 19 支持开箱即用的样式 HMR(热模块替换),且实验性支持模板 HMR!


之前,每次更改组件的样式并保存文件时,Angular CLI 都会重建应用,并向通知浏览器刷新。


新 HMR 将编译你修改的样式,将结果发送到浏览器,且在不刷新页面和丢失任何状态的情况下修复应用。


Angular 19 默认启用样式 HMR!要尝试模板 HMR,请使用:


07-hmr.png


要禁用此功能,请将开发服务器选项指定为 "hmr": false,或者使用:


08-disable.png


02-2. standalone 默认为 true


Angular 19 提供了一个 schematic,它将作为 ng update 的一部分运行,并自动删除所有 standalone 指令、组件和管道的 standalone 组件元数据属性,且将所有 non-standalone 抽象的 standalone 设置为 false


02-3. 严格执行 standalone


为了帮助你在项目中实施现代 API,我们开发了一个编译器标志,如果发现不是 standalone 的组件、指令或管道,它就会报错。


要在项目中启用它,请配置 angular.json


09-standalone.png


03. 响应性的进化


03-1. 输入、输出和视图查询稳定


我们观察了新的输入、输出和视图查询 API,并将它们升级到稳定版!


为了简化新 API 的采用,我们开发了 schematics,它将转换你现有的输入、输出和视图查询:


10-schema.png


请注意,与传统输入相比,signal 输入是只读的,因此如果要设置输入值,则可能需要手动迁移应用的某些部分。


要一次运行所有迁移,你可以使用:


11-move.png


03-2. 引入 linkedSignal


UI 通常仍需跟踪某些更高级状态的可变状态。举个栗子,选择 UI 具有“当前选择”状态,该状态会随着用户进行选择而变更,但如果选项列表变更,那也需要重置。


新增的 linkedSignal 实验性原语创建了一个可写 signal,捕获了这种类型的依赖关系:


12-link.png


linkedSignal 明确了 optionschoice 之间的关系,而无需求助于 effect


新 API 有 2 种形式:一种是上述的简化形式,另一种是高级形式,开发者可以在其中访问之前的 optionschoice 值。


它还有一个高级 API,允许使用更复杂的逻辑,比如只要用户的 choice 存在于新的 options 列表中,就可以维护用户的 choice


03-3. 引入 resource


目前,Angular 的 signals 主要集中在同步数据上:在 signals 中存储状态、computed 值等。


Angular 19 新增 resource() 实验性 API,这是 signals 与异步操作集成的第一步。


resource 是参与 signal 图的异步依赖,你可以将 resource 视为具有三个部分:



  1. request 函数,它表示要根据 signals 发出的确切请求。比如,user 资源可能会计算依赖当前路由的用户 ID 参数的请求。

  2. loader 加载器,当请求更改时执行异步操作,并最终返回新值。

  3. 生成的 Resource 实例,它暴露了与可用值通信的 signals 和 resource 的加载中、已解析等当前状态。


13-resource.png


因为现在许多 Angular 应用都使用 RxJS 来获取数据,我们还在 @angular/core/rxjs-interop 中添加了 rxResource,它从基于 Observable 的 loader 创建 resource。


参考文献


[1] Angular 官方博客: blog.angular.dev/meet-angula…


作者:前端俱乐部
来源:juejin.cn/post/7439721466499514418
收起阅读 »

前端如何优雅通知用户刷新页面?

web
前言老板:新的需求不是上线了嘛,怎么用户看到的还是老的页面呀窝囊废:让用户刷新一下页面,或者清一下缓存老板:那我得告诉用户,刷新一下页面,或者清一下缓存,才能看到新的页面呀,感觉用户体验不好啊,不能直接刷新页面嘛?窝囊废:可以解决(OS:一点改的必要没有,用户...
继续阅读 »

前言

老板:新的需求不是上线了嘛,怎么用户看到的还是老的页面呀
窝囊废:让用户刷新一下页面,或者清一下缓存
老板:那我得告诉用户,刷新一下页面,或者清一下缓存,才能看到新的页面呀,感觉用户体验不好啊,不能直接刷新页面嘛?
窝囊废:可以解决(OS:一点改的必要没有,用户全是大聪明)

产品介绍

c端需要经常进行一些文案调整,一些老版的文字字眼可能会导致一些舆论问题,所以就需要更新之后刷新页面,让用户看到新的页面。

思考问题为什么产生

项目是基于vue的spa应用,通过nginx代理静态资源,配置了index.html协商缓存,js、css等静态文件Cache-Control,按正常前端重新部署后, 用户重新访问系统,已经是最新的页面。

但是绝大部份用户都是访问页面之后一直停留在此页面,这时候前端部署后,用户就无法看到新的页面,需要用户刷新页面。

产生问题

  • 如果后端接口有更新,前端重新部署后,用户访问老的页面,可能会导致接口报错。
  • 如果前端部署后,用户访问老的页面,可能无法看到新的页面,需要用户刷新页面,用户体验不好。
  • 出现线上bug,修复完后,用户依旧访问老的页面,仍会遇到bug。

解决方案

  1. 前后端配合解决
  • WebSocket
  • SSE(Server-Send-Event)
  1. 纯前端方案 以下示例均以vite+vue3为例;
  • 轮询html Etag/Last-Modified

在App.vue中添加如下代码

const oldHtmlEtag = ref();
const timer = ref();
const getHtmlEtag = async () => {
const { protocol, host } = window.location;
const res = await fetch(`${protocol}//${host}`, {
headers: {
"Cache-Control": "no-cache",
},
});
return res.headers.get("Etag");
};

oldHtmlEtag.value = await getHtmlEtag();
clearInterval(timer.value);
timer.value = setInterval(async () => {
const newHtmlEtag = await getHtmlEtag();
console.log("---new---", newHtmlEtag);
if (newHtmlEtag !== oldHtmlEtag.value) {
Modal.destroyAll();
Modal.confirm({
title: "检测到新版本,是否更新?",
content: "新版本内容:",
okText: "更新",
cancelText: "取消",
onOk: () => {
window.location.reload();
},
});
}
}, 30000);
  • versionData.json

自定义plugin,项目根目录创建/plugins/vitePluginCheckVersion.ts

import path from "path";
import fs from "fs";
export function checkVersion(version: string) {
return {
name: "vite-plugin-check-version",
buildStart() {
const now = new Date().getTime();
const version = {
version: now,
};
const versionPath = path.join(__dirname, "../public/versionData.json");
fs.writeFileSync(versionPath, JSON.stringify(version), "utf8", (err) => {
if (err) {
console.log("写入失败");
} else {
console.log("写入成功");
}
});
},
};
}

在vite.config.ts中引入插件

import { checkVersion } from "./plugins/vitePluginCheckVersion";
plugins: [
vue(),
checkVersion(),
]

在App.vue中添加如下代码

const timer = ref()
const checkUpdate = async () => {
let res = await fetch('/versionData.json', {
headers: {
'Cache-Control': 'no-cache',
},
}).then((r) => r.json())
if (!localStorage.getItem('demo_version')) {
localStorage.setItem('demo_version', res.version)
} else {
if (res.version !== localStorage.getItem('demo_version')) {
localStorage.setItem('demo_version', res.version)
Modal.confirm({
title: '检测到新版本,是否更新?',
content: '新版本内容:' + res.content,
okText: '更新',
cancelText: '取消',
onOk: () => {
window.location.reload()
},
})
}
}
}

onMounted(()=>{
clearInterval(timer.value)
timer.value = setInterval(async () => {
checkUpdate()
}, 30000)
})

Use

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { webUpdateNotice } from '@plugin-web-update-notification/vite'

// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
webUpdateNotice({
logVersion: true,
}),
]
})

作者:李暖阳啊
来源:juejin.cn/post/7439905609312403483

收起阅读 »

何同学翻车了,他使用的开源工具原来是这个

1. 前言 何同学的最新作品大家都看了吧,刚刚看到那个视频的时候,不得不承认何同学的脑洞创意,让我深深感受到新奇。我去看的时候,那个视频在B站的播放量已经破八百W了。视频内容是通过一个36W行的备忘录制作了一个很丝滑的视频。里面用到了一个python写的开源...
继续阅读 »

1. 前言



何同学的最新作品大家都看了吧,刚刚看到那个视频的时候,不得不承认何同学的脑洞创意,让我深深感受到新奇。我去看的时候,那个视频在B站的播放量已经破八百W了。视频内容是通过一个36W行的备忘录制作了一个很丝滑的视频。里面用到了一个python写的开源工具,是用来把图片通过使用ascii码生成一个文本的图片,何同学视频说这个软件是自己开发的,但是因播放量太大,被网友发现了视频中代码是来自开源软件,因此就导致了这次的翻车。



不吃瓜了,我们还是步入正题吧。


2. ASCII-generator


2.1 介绍


这个开源工具是可以把图片或者是视频通过使用ascii码去把图片或者视频绘画出来,可以看看效果。


原图



ASCII-generator生成的图



2.2 环境准备



  • python 3.6

  • cv2

  • PIL

  • numpy


python 3.6安装


可以到python的官网(http://www.python.org/downloads/m…)找到对应的版本,然后下载对应的安装包安装。我觉得这个版本太久,所以我就安装了最新版。下载下来后,按照向导下一步即可。



安装成功验证



cv2安装


使用pip安装,简单快捷,使用pip之前得先安装pip。


pip安装


curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py

python3 get-pip.py





cv2安装


pip install opencv-python-headless




PIL安装


PIL已经被更先进的库Pillow所取代,因此安装Pillow即可。


pip install Pillow




numpy安装


pip install numpy


2.3 使用ASCII-generator工具



这些工具就可以使用了,只要使用python3去运行这几个脚本就可把图片或者视频生成比较艺术的字符表示的画像。


2.4 代码仓库地址


github.com/vietnh1009/…


3. 免安装程序


上面的安装过程对于小白来讲还是有些复杂,不过不用担心,有个大佬分享了可以直接运行的.exe可执行程序,不需要上面的安装python过程,也可以使用这个充满艺术细胞的工具。



虽然是很多年前的东西了,但是现在用起来也还是非常的丝滑,整个exe程序非常小,只有两百多KB。工具的下载链接我放在了文末。




使用起来也是非常的简便的,直接把图片拖拽到软件框里,就可以生成比较艺术的图像了。



如果你发现拖拽图片进软件后,图片生成的效果不好,你也可以通过左下角的框框调整参数,直到你满意为止。



调整好之后,你就可以把制作好的艺术品导出来了,你可以到处成黑白/彩色图片,或者是导出成文本。



4. 附上软件下载链接


pan.quark.cn/s/bf6dc4f9a…


作者:吾鳴
来源:juejin.cn/post/7440122922024665126
收起阅读 »

为什么可以通过process.env.NODE_ENV来区分环境

web
0.背景 通常我们在开发中需要区分当前代码的运行环境是dev、test、prod环境,以便我们进行相对应的项目配置,比如是否开启sourceMap,api地址切换等。而我们区分环境一般都是通过process.env.NODE_ENV,那么为什么process....
继续阅读 »

0.背景


通常我们在开发中需要区分当前代码的运行环境是dev、test、prod环境,以便我们进行相对应的项目配置,比如是否开启sourceMap,api地址切换等。而我们区分环境一般都是通过process.env.NODE_ENV,那么为什么process.env.NODE_ENV可以区分环境呢?是我们给他配置的,还是他可以自动识别呢?


1.什么是process.env.NODE_ENV


process.env属性返回一个包含用户环境信息的对象。


在node环境中,当我们打印process.env时,发现它并没有NODE_ENV这一个属性。实际上,process.env.NODE_ENV是在package.json的scripts命令中注入的,也就是NODE_ENV并不是node自带的,而是由用户定义的,至于为什么叫NODE_ENV,应该是约定成俗的吧。


2.通过package.json来设置node环境中的环境变量


如下为在package.json文件的script命令中设置一个变量NODE_ENV


{
"scripts": {
"dev": "NODE_ENV=development webpack --config webpack.dev.config.js"
}
}

执行对应的webpack.config.js文件


// webpack.config.js
console.log("【process.env】", process.env.AAA);

但是在index.jsx中也就是浏览器环境下的文件中打印process.env就会报错,如下:
image.png
可以看到NODE_ENV被赋值为development,当执行npm run dev时,我们就可以在 webpack.dev.config.js脚本中以及它所引入的脚本中访问到process.env.NODE_ENV,而无法在其它脚本中访问。原因就是前文提到的peocess.env是Node环境的属性,浏览器环境中index.js文件不能够获取到。


3.使用webpack.DefinePlugin插件在业务代码中注入环境变量


这个时候我们就存在一个解决方法,通过webpack中的DefinePlugin来设置一个全局变量,这样所有的打包的js文件都可以访问到这个全局变量了。


const webpack = require('webpack');
module.exports = {
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': '"development"'
})
]
}



使用DefinePlugin注意点
webpack.definePlugins本质上是打包过程中的字符串替换,比如我们刚才定义的__WEBPACK__ENV:JSON.stringify('packages')
在打包过程中,如果我们代码中使用到了__WEPBACK__ENVwebpack会将它的值替换成为对应definePlugins中定义的值,本质上就是匹配字符串替换,并不是传统意义上的环境变量process注入。

如下图所示:
image.png
由上图可知:仔细对比这两段代码第一个问题的答案其实已经很明了了,针对definePlugin这个插件我们使用它定义key:value全局变量时,他会将value进行会直接替换文本。所以我们通常使用JSON.stringify('pacakges')或者"'packages'"



作者:会飞的特洛伊
来源:juejin.cn/post/7345760019319390248
收起阅读 »

Unocss 写 border太费劲?试试这样

web
在css中, border 是高频使用的一个属性,但它的写法有非常非常多。按属性分类,border 属性可以分为以下几类:border-width:设置边框的宽度。border-style:设置边框的样式。border-color:设置边框的颜色。按方向分类,...
继续阅读 »

在css中, border 是高频使用的一个属性,但它的写法有非常非常多。

按属性分类,border 属性可以分为以下几类:

  • border-width:设置边框的宽度。
  • border-style:设置边框的样式。
  • border-color:设置边框的颜色。

按方向分类,border 属性可以分为以下几类:

  • border-top:设置上边框的宽度、样式和颜色。
  • border-right:设置右边框的宽度、样式和颜色。
  • border-bottom:设置下边框的宽度、样式和颜色。
  • border-left:设置左边框的宽度、样式和颜色。

一般情况下我们会直接使用 border 属性,它是一个简写属性,可以同时设置边框的宽度、样式和颜色。

div {
border: 1px solid red;
}

如果我们要单独设置某个方向边框的某个属性,可以使用以下属性:

  • border-top-width:设置上边框的宽度。
  • border-top-style:设置上边框的样式。
  • border-top-color:设置上边框的颜色。
div {
border-top-width: 1px;
border-top-style: solid;
border-top-color: red;
}

我们也可以单独设置某个方向的边框宽度、样式和颜色,可以使用以下属性:

  • border-top:设置上边框的宽度、样式和颜色。
  • border-right:设置右边框的宽度、样式和颜色。
  • border-bottom:设置下边框的宽度、样式和颜色。
  • border-left:设置左边框的宽度、样式和颜色。
div {
border-top: 1px solid red;
}

以上的写法,最常用的还是简写方式,如:

  • 简写属性:border: 1px solid red;
  • 单个方向的属性:border-top: 1px solid red;

在 unocss 中,我们怎么写边框呢?

可以使用 border 的预设,比如:


<div class="b">div>


<div class="b-2px">div>


<div class="b b-solid">div>


<div class="b b-red">div>


<div class="b b-dashed b-red">div>

为什么只设置 boder-width: 1px; 也能看到边框效果呢?这是因为浏览器为每个元素都设置了一个默认的边框样式,只是 boder-width 的默认值是 0px,所以最少只需要设置 border-width 就能看到边框效果

image-1.png

当然 unocss 预设中边框的写法也可以单独定义每个方向的宽度、样式和颜色,比如


<div class="b-l">div>


<div class="b b-l-dashed">div>


<div class="b b-l-red">div>


<div class="b-l-2px b-l-red b-l-dashed">div>

由上可知 unocss 的 border 预设其实就是将 border-width 、 border-style 和 border-color 分别定义,然后又可以各自组合上 left、right、top 和 bottom,这样就可以控制每一个方向的边框

这样写当然没什么问题,也非常的灵活,但仔细想想是不是过于麻烦了呢,为什么会觉得麻烦呢?原因就是这样写没有利用到 border 的简写方式,比如 左边 2px red dashed 的边框 我们其实是可以简写成这样的:

div {
border-left: 2px dashed red;
}

甚至我们写行内样式也比 b-l-2px b-l-red b-l-dashed 这种写法更简洁易懂

<div style="border-left: 2px dashed red;">div>

那么,有没有办法不写 css 也能做到这么简洁呢,并且还不能损失它的灵活性

当然有,答案就是自定义rules

// unocss配置文件, uno.config.js|ts

import { defineConfig, presetUno } from 'unocss'
const DIRECTION_MAPPIINGS = { t: 'top', r: 'right', b: 'bottom', l: 'left' }

export default defineConfig({
presets: [
presetUno,
],
rules: [
[
/^b(t|r|b|l|d)-(.*)/,
([, d, c]) => {
const direction = DIRECTION_MAPPIINGS[d] || ''
const p = direction ? `border-${direction}` : 'border'
const attrs = c.split('_')
if (
// 属性中不包含 border-style 则默认 solid
!attrs.some((item) =>
/^(none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset)$/.test(item),
)
) {
attrs.push('solid')
}
// 属性中不包含 border-width 则默认 1px
if (!attrs.some((item) => /^\d/.test(item))) {
attrs.push('1px')
}
return {
[p]: attrs.join(' '),
}
},
],
],
})

怎么用呢?

  1. 完整的写法

<div class="bd-2px_dashed_red">div>

<div class="bl-2px_dashed_red">div>

<div class="br-2px_dashed_red">div>
  1. 缺省的写法

border-width 、 border-style 和 border-color 都可以缺省(但最少写一个),border-style 默认 solid,border-width 默认 1px,border-color 默认继承父容器的 color


<div class="bd-2px">div>


<div class="bd-red">div>


<div class="bd-dashed">div>


<div class="bl-2px">div>


<div class="bl-red">div>


<div class="bl-dashed">div>


<div class="bl-2px">div>


<div class="bl-red">div>


<div class="bl-dashed">div>

可以看出这种写法是不是更简洁、更容易理解呢!

为什么 border-width 、 border-style 和 border-color 最少得写一个,全部缺省不是更好吗?

答: unocss 的默认写法就是可以全缺省的,没必要多此一举了,如 b b-r b-l b-t b-b

为什么用 bd 表示 border 而不用 b?

主要是为了跟 unocss 的默认写法区分开来,其次 bd 也勉强符合 border 语义的简写。

以上就是本篇文章分享的所有内容了,希望对大家有帮助。


关注我,大脸怪将持续分享更多实用知识和技巧


作者:大脸怪
来源:juejin.cn/post/7348473946582646784
收起阅读 »

hover后元素边框变粗,样式被挤压?一招帮你解决,快收藏备用!

web
背景简介 大家好,我是石小石!最近开发中遇到这样一个需求: hover卡片后,边框由原来的1px变成2px,且颜色由灰色变为蓝色。 hover改变样式,这太easy了! .work-order-card { padding: 8px 16px 1...
继续阅读 »

背景简介


大家好,我是石小石!最近开发中遇到这样一个需求:



hover卡片后,边框由原来的1px变成2px,且颜色由灰色变为蓝色。




hover改变样式,这太easy了!


.work-order-card {
padding: 8px 16px 16px 16px;
border-radius: 8px;
border: 1px solid #e1e5eb;
width: 296px;
transition: all 0.2s ease;
&:hover {
border: 2px solid #64A6F7;
transition: all 0.2s ease;
}
}

但实际做完后,我们会发现一个问题,样式不够丝滑:



hover后元素的内边距发生变化,中间区域尺寸被挤压,从而导致过渡动画很生硬!




这个问题在前端开发中应该比较常见,我就简单分享一下自己的解决方案吧。


如何解决


要想解决这个问题,本质就是让hover前后,中间核心区域的位置不随边框、边距的变化而变化


场景一:边框从无到有


最简单的场景,就是一开始没有边框,后来有边框。



这种最容易处理,我们只需要给盒子设置和hover后同样粗细的边框,颜色设置透明即可。


.work-order-card {
padding: 8px 16px 16px 16px;
border-radius: 8px;
border: 2px solid transparent;
width: 296px;
transition: all 0.2s ease;
&:hover {
border: 2px solid #64A6F7;
transition: all 0.2s ease;
}
}

场景二:边框粗细发生变化


比较麻烦的场景,如文章一开始说的场景,hover后,边框从1px变成2px。这种情况,hover盒子的padding一定会变化(注意大盒子尺寸是固定的),必然会导致内部元素被挤压,位置改变。



动态padding


当然,聪明的你可能计算hover后的padding


.work-order-card {
padding: 8px 16px 16px 16px;
border-radius: 8px;
border: 1px solid #E1E5EB;
width: 296px;
&:hover {
padding: 7px 15px 15px 15px;
border: 2px solid #64A6F7;
}
}

不加过渡动画时,看着挺不错



但加上transition过渡效果,那就原形毕露!


.work-order-card {
padding: 8px 16px 16px 16px;
border-radius: 8px;
border: 1px solid #E1E5EB;
width: 296px;
transition: all 0.2s ease;
&:hover {
padding: 7px 15px 15px 15px;
border: 2px solid #64A6F7;
transition: all 0.2s ease;
}
}


不设置padding,居中核心内容


如果盒子的尺寸都能确定,最好的方式,还是使用flex布局,让中间的核心区域(下图红色部分)永远居中!这样,无论边框怎么变,中间的位置永远不变,自然就解决了元素被挤压的问题!



<div class="work-order-card">
<div class="center-box">
<!-- 子元素 -->
</div>

</div>

.work-order-card {
border-radius: 8px;
border: 1px solid #E1E5EB;
width: 296px;
height: 214px;
transition: all 0.2s ease;
&:hover {
border: 2px solid #64A6F7;
transition: all 0.2s ease;
}
.center-box{
width: 264px;
}
}



注意:这种实现方式,要求最外层的盒子宽高是固定的,内部盒子宽度也需要固定。



总结


针对hover某个元素,其边框变粗导致内部元素被挤压的问题,这篇文章提供了三个解决方案:



  • 边框从无到有,改变原始边框透明度即可

  • 边框hover尺寸变化:



    1. 如果不要求过渡效果,hover后可以计算padding

    2. 如果需要过渡效果,使用felx布局居中核心区域即可




如果大家有更好的方案,可以评论区分享一下。


作者:石小石Orz
来源:juejin.cn/post/7431999862919921675
收起阅读 »

autoUno:最直觉的UnoCSS预设方案

web
起因可能你跟我一样头一次听说原子化CSS时,觉得写预设 class 听起来是一件极蠢的事,感觉这是在开倒车,因为我们都经历过 Bootstrap(其实不属于原子化) 的时代。于是在这个概念刚刚在国内爆火的时候,我对其是嗤之以鼻的,当时我想象中的原子化:只有带鱼...
继续阅读 »

起因

可能你跟我一样头一次听说原子化CSS时,觉得写预设 class 听起来是一件极蠢的事,感觉这是在开倒车,因为我们都经历过 Bootstrap(其实不属于原子化) 的时代。

于是在这个概念刚刚在国内爆火的时候,我对其是嗤之以鼻的,当时我想象中的原子化:

image.png

只有带鱼屏才装得下。

而实际上的原子化:

image.png

在实际使用中,我们往往不会将所有的样式都使用原子化实现(当然也可以这么干)。

举一个例子,在你开发时,你按照自己习惯,做了一个近乎完美的布局,你的 class 已经写的非常棒,页面看起来赏心悦目,而此时,产品告诉你要在某个按钮的下面加一句提示,为了不破坏你的完美代码,又或者是样式无需太多的 css,你可能会选择直接写行内样式。此时原子化的魅力就体现了出来,只需要简单的寥寥几字,就把准确的 css 表达出来了,而无需再抽出一个无意义的 class。

为什么是 UnoCSS

在 tailwindCSS、windiCSS 之后,一位长发飘飘的帅小伙,发布了一款国产原子化工具 UnoCSS。虽然大家可能很熟悉它,我还是想啰嗦几句。

UnoCSS 的优势

CSS原子化在前端的长河中,可谓是一个婴儿:

“原子化 CSS”(Atomic CSS)的概念最早可以追溯到 2014 年,由 Nicolas Gallagher 在他的博客文章 “About HTML semantics and front-end architecture” 中提出。他在文章中提到了一种新的 CSS 方法论,即使用“单一功能类”(Single-purpose Classes)来替代传统的基于组件或块的样式管理方式。这种方法的核心思想是,将每一个 CSS 类设计为仅包含一种样式规则或一组简单的样式,以便更好地复用和组合样式,从而减少冗余代码。这一思想成为后来原子化 CSS 的基础。同年,第一个原子化框架 ACSS(Atomic CSS)发布了,由 Yahoo 团队开发。

ACSS 的推出激发了 Utility-First CSS 框架的兴起,最终在 Tailwind CSS 等项目中得到广泛应用。

Tailwind 和 Windi CSS 虽然也支持自定义,但它们的定制性主要体现在配置文件的扩展上,如自定义颜色、间距、字体、断点等,且在设计上仍然偏向于固定的原子类名体系。这两者可以通过配置文件生成新的实用类名,这种方式显然使他们有了不可避免的局限性。

而 UnoCSS 则有着高度定制化的特性,主要体现在它的灵活性插件化设计,使其可以自由定义和扩展类名、行为,甚至能模拟其他 CSS 框架。相比之下,Tailwind CSS 和 Windi CSS 在设计上更偏向于固定的、基于配置的实用类体系,而 UnoCSS 则提供了更多自由度。

这样的设计也使得 UnoCSS 有着天然的性能优势,UnoCSS 支持基于正则表达式的动态类名解析,允许开发者定义自定义的样式规则。例如,可以通过简单的正则规则为特定样式创建动态的类,而不需要预先定义所有的类名。这使得 UnoCSS 的 CSS 小而精,据官网介绍,它无需解析,无需AST,无需扫描。它比Windi CSS或Tailwind CSS JIT快5倍!

原子化的通病

从原子化的概念本身出发,我们不难发现,这种做法有一种通病,就是我除了要知道基本的 CSS 之外,还需要知道原子化类库的预定义值,也就是说,我们需要提前知道写哪些 class 是有效的,哪些是无法识别的。

在现代化编辑器中,我们可以使用编辑器扩展来识别这些类名。

比如在 VSCode 中的 UnoCSS 扩展

image.png

image.png

它可以在 HTML 中提示开发者这个类名下将解析出的 css

image.png

也可以进行自动补全。

是的这很方便,但是我们依旧要大概知道这些 预设 class 的写法,对其不熟悉的的用户,可能还要翻阅文档来书写。

全自动的 UnoCSS

我就在想,为什么没有一个原子化库,可以支持智能识别呢,比如我想实现一个行高

按照上图中的预设,我需要依次打出 l、i、n、e、-,才匹配到了第一个和行高有关的属性,如果情况再搞笑一点,我根本不知道 line 怎么写怎么办?

我相信很多同学可能会有共情,因为我们在写传统 CSS 时,一般是打出我们自己熟悉的几个字母,依靠编辑器的自动补全(emmet)来做的,像这样:

image.png

嗯,看起来很舒服,只需要打出少量的字母,就可以识别到了。

先看一下传统的字面量 Uno 预设

传统预设

image.png

我们可以自定义一些个人比较熟悉的简写。

或者写一些正则,来支持更复杂的数值插入等

image.png

好吧,看到这我都上不来气儿了,这我要写到什么时候去?

确实,一个一个的去自定义规则,花费了非常多的精力和时间,那我们看一下社区有没有提供相对通用的规则呢, UnoCSS社区预设

image.png

好吧,可能有,但是太多了,且大多是一些个性化的实现。

autoUno 预设方案

于是我准备手动做一个类似 emmet 补全的预设,希望它可以做到识别任意写法,比如:

  • line-height1px
  • lh24px
  • lh1
  • lh1rem
  • lineh1
  • lihei1
  • ...等等你习惯的写法

正则拦截几乎所有写法

字母+数字

/^[a-zA-Z]+(\d+)$/

字母+数字+单位

/^[a-zA-Z]+(\d+)+(vh|vw|px|rem|em|%)$/

字母+颜色

/^[a-zA-Z-]+(#[a-zA-Z0-9]+)$/

字母+冒号+字母

/^[a-zA-Z]+:+[a-zA-Z]$/

也就是说,我们的 rules 会长这样:

    rules: [
    [
       /^[a-zA-Z]+(\d+)$/,
      ([a, d]) => {
         const [property, unit] = findBestMatch(a, customproperty)
         if (!property) return
         return { [property]: `${d || ''}${unit || ''}` }
      }
    ],
    [
       /^[a-zA-Z]+(\d+)+(vh|vw|px|rem|em|%)$/,
      ([a, d, u]) => {
         const [property] = findBestMatch(a, customproperty)
         if (!property) return
         return { [property]: `${d || ''}${u}` }
      }
    ],
    [
       /^[a-zA-Z-]+(#[a-zA-Z0-9]+)$/,
      ([a, c]) => {
         const [property] = findBestMatch(a, customproperty)
         if (!property) return
         return { [property]: c }
      }
    ],
    [
       /^[a-zA-Z]+:+[a-zA-Z]$/,
      ([a]) => {
         const [property] = findBestMatch(a, customproperty)
         if (!property) return
         const propertyName = property.split(':')[0]
         const propertyValue = property.split(':')[1]
         return { [propertyName]: propertyValue }
      }
    ],
  ]

接下来,只要实现 findBestMatch 方法就好了。

正如刚刚提到的,我们需要模拟一个 emmet 的提示,规则大概是这样的

  1. 匹配顺序一致
  2. 至少命中 2 字符
  3. 可以自定义单位

那么我们可以先列举一下可能用到的 CSS 属性(全部大概有350个左右)

const propertyCommon = [
 "display: flex",
 "display: block",
 "display: inline",
 "display: inline-block",
 "display: grid",
 "display: none",
 // "...":"..." 还有更多
]

比如我希望 输入 d:f 就自动帮我匹配到 display: flex 。

那么逻辑应该是这样的:

获取到第一个字符 d,让它分别去这些字符串中比较,比如 display: flex 将被分解成 dis...

首先匹配到第一个字符 d 发现一致,那么 display: flex 的可能性就 + 1,整个遍历下来,顺序一致,且命中字符数最多的,就是我们要找的,很显然 输入 d:f 命中最多的应该是 display: flex ,分别是 d:f ,此时函数返回就正确了。

findBestMatch 方法实现

除了刚刚列举的常用固定写法,还有一些带单位的属性,我选择用 $ 符号分割,以便于在函数中提取

const propertyWithUnit = [
 "animation-delay$ms",
 "animation-duration$ms",
 "border-bottom-width$px",
 "border-left-width$px",
 "border-right-width$px",
 "border-top-width$px",
 "border-width$px",
 "bottom$px",
 "box-shadow$px",
 "clip$px",
 // ... 更多
]

我们在预设属性中,使用 $ 符号隔断了一个默认单位,一会将在函数中提取它。

export function findBestMatch(input: string, customproperty: string[] = []) {
 // 将输入字符串转换为字符数组
 const inputChars = input.split('')

 let bestMatch: any = null
 let maxMatches = 0

 // 遍历所有目标字符串
 for (let keywordOrigin of customproperty.concat(propertyWithUnit.concat(propertyCommon))) {
   const keyword = keywordOrigin.split('$')[0]
   // 用来记录目标字符串的字符序列是否匹配
   let matchCount = 0
   let inputIndex = 0
   // 遍历目标字符串
   for (let i = 0; i < keyword.length; i++) {
     // 如果第一个字符就不匹配,直接跳过
     if (i === 0 && keyword[i] !== inputChars[0]) {
       break
    }
     if (inputIndex < inputChars.length && keyword[i] === inputChars[inputIndex]
       && (input.includes(":") && keyword.includes(":") || (!input.includes(":")))) {
       matchCount++
       inputIndex++
    }
  }
   // 如果找到的匹配字符数大于等于 2,且比当前最大匹配数多
   if (matchCount >= 2 && matchCount > maxMatches) {
     maxMatches = matchCount
     bestMatch = keywordOrigin
  }
}
 let unit: any = ''
 // 用正则匹配单位,最后一个数字的后面的字符
 const unitMatch = input.match(/(\d+)([a-zA-Z%]+)/)
 unit = unitMatch && unitMatch[2]
 if (!unit && bestMatch && bestMatch.split('$')[1]) {
   unit = bestMatch.split('$')[1]
}
 return [bestMatch && bestMatch.split('$')[0], unit]
}

此函数使用了一种加分机制,去寻找最匹配的字符,当用户传入一个 class 时,将从第一个字符开始匹配,第一个不匹配直接跳过(遵循emmet规则,也有利于性能),接着,在是否加分的的 if 中,需要判断是否包含 : ,这是为了区分是否是带冒号的常用属性(区别于带单位的属性)。

在循环中,将找出最匹配的预设属性值,最后,判断用户输入的字符串是否带单位,如果带单位就使用用户单位,如果没有,就使用默认单位(预设属性中 $ 符号后面的字符)。

然后返回一个数组,它将是 [property,unit]

其实在上面的正则中,我将带单位和不带单位的匹配分开了,在写这篇文章时,findBestMatch 函数我还没想好怎么改😅,于是就先将就着讲给各位看,核心思想是一样的。

如此一来,我们无需自定义过多的固定 rules,只需要补充一些CSS属性就可以了,接下来你的UnoCSS 规则将长这样:

export default defineConfig({
presets: [
autoUno([
'border-radius$px',
"display:flex",
"...."
])],
})

只需列举你将用到的标准css属性即可,含有数值的,以$符号分隔默认单位,其实你也无须过多设置,因为我的 autoUno 预设中已经涵盖了大部分常用属性,只有你发现 autoUno 无法识别你的简写时,才需要手动传入。

接下来,隆重介绍

autoUno

image.png

autoUno 是 UnoCSS 的一个预设方案,它支持你以最直觉的方式设置 class 。

你认为对,它就对,再也不受任何预设的影响,再也不用记下任何别人定义的习惯。

此项目已在 github 开源:github.com/Auto-Plugin…

此项目在 NPM 可供下载:http://www.npmjs.com/package/aut…

官方网站(可在线尝试):auto-plugin.github.io/index/autou…

安装

pnpm i autouno

使用

import { defineConfig } from 'unocss'
import autoUno from 'autouno'

export default defineConfig({
presets: [
autoUno([
"box-shadow:none",
])],
})

作者:德莱厄斯
来源:juejin.cn/post/7435653910252191754

收起阅读 »

如何在鸿蒙ArkTs中进行全局弹框

背景 刚接触鸿蒙开发不久,从iOS转过来的,经常会遇到在一个公共的类里,会想要给当前window上添加一个全屏的自定义视图,那在鸿蒙中应该如何实现这一个效果呢? 这里介绍一下我自己想到的实现方式,不一定是最优解,大家有其他更好的方式或者问题,欢迎指正。 代码是...
继续阅读 »

背景


刚接触鸿蒙开发不久,从iOS转过来的,经常会遇到在一个公共的类里,会想要给当前window上添加一个全屏的自定义视图,那在鸿蒙中应该如何实现这一个效果呢?


这里介绍一下我自己想到的实现方式,不一定是最优解,大家有其他更好的方式或者问题,欢迎指正。


代码是基于鸿蒙next和模拟器


思路


在鸿蒙中,虽然可以通过下面的系统方法获取到window,但是目前我不知道如何像iOS一样,在其上添加自定义的组件。所以,在研究了系统的window之后,想到是否可以直接弹出一个全屏的window,然后在这个自定义的window上,添加我们的自定义组件。类似于iOS的三方库SwiftEntryKit


import { window } from '@kit.ArkUI'
function findWindow(name: string): Window;

实现步骤



  1. 通过调用createWindow函数,创建一个自定义的window,并设置windowType枚举为
    TYPE_DIALOG,这个是一个API10之后有的类型。

  2. 通过调用loadContent(path: string, storage: LocalStorage, callback: AsyncCallback<void>): void创建一个指定的页面作为这个window的根视图,我们后面自己的自定义弹框组件,都是加载到这个页面中。第二个参数storage也很重要,因为通过该方法指定了页面,但是无法将自定义的参数直接传入到页面中,所以通过LocalStorage进行中转传值。

  3. 在需要进行传值的属性中,非常重要的是一个entry?: CustomBuilder自定义组件的属性,因为我们毕竟是要封装一个通用的类,去支持你传入任意的自定义视图。这里有个非常重要的点:在class中传入的这个属性,是一个代码块,里面是我们自定义的组件代码,但是我们无法在page中,直接去执行这个代码块,来获取到相应的布局。这里其实还需要在page的代码中新增一个属性@BuilderParam entryView: CustomBuilder,这个应该很熟悉,就是如果我们是直接初始化一个包含这个属性的组件时,就可以直接传入一个@Builder function()自定义组件,并且内部可以直接使用。那我们这里需要做的就是,在page的aboutToAppear中,将我们传入的参数,赋值给这个页面声明的属性,这样就可以在布局中去加载这个布局了。

  4. 传入到页面中的参数,还可以包含布局/动画等参数,这里只实现了布局,后续可以继续完善动画相关方法

  5. 最后在传入这个布局代码的时候,如果有自定义的点击事件,需要注意this的绑定当前调用方。


代码


公共模块:


import { window } from '@kit.ArkUI'
import { common } from '@kit.AbilityKit'

export class HDEntryKit {
static display(use: EKAttributes) {
HDWindowProvider.instance().display(use)
}

static dismiss(complete?: (() => void)) {
HDWindowProvider.instance().dismiss(complete)
}
}

class HDWindowProvider {
private static windowProvider: HDWindowProvider
context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
windowName: string = "HDEntryWindow"

static instance() {
if (!HDWindowProvider.windowProvider) {
HDWindowProvider.windowProvider = new HDWindowProvider();
}
return HDWindowProvider.windowProvider;
}

display(use: EKAttributes) {
let windowClass: window.Window
window.createWindow({
name: this.windowName,
windowType: window.WindowType.TYPE_DIALOG,
ctx: this.context
}, (err, data) => {
if (err.code == 0) {
windowClass = data
windowClass.setWindowLayoutFullScreen(true)
let bundleName = this.context.applicationInfo.name
let page = `@bundle:${bundleName}/uicomponents/ets/HDEntryKit/HDEntryPage`
let storage: LocalStorage = new LocalStorage()
storage.setOrCreate('use', use)
windowClass.loadContent(page, storage, err => {
if (err.code == 0) {
windowClass.setWindowBackgroundColor(use.backgroundColor?.toString())
}
})
windowClass.showWindow(() => {
})
}
})
}

dismiss(complete?: (() => void)) {
window.findWindow(this.windowName).destroyWindow((err, e) => {
if (err.code == 0 && complete) {
complete()
}
})
}
}

export class Size {
width: Length | null = null
height: Length | null = null
margin: Length | Padding = 0
}

export class EKAttributes {
name?: string
entry?: CustomBuilder
position: FlexAlign = FlexAlign.Center
backgroundColor: ResourceColor = "#99000000"
displayDuration: number = 1000
size: Size = new Size()
}

import { EKAttributes, HDEntryKit } from './HDEntryKit'

let storage = LocalStorage.getShared()
@Entry(storage)
@Component
struct HDEntryPage {
@BuilderParam entryView: CustomBuilder
@LocalStorageProp('use') use: EKAttributes = new EKAttributes()

build() {
Column() {
Row() {
Column() {
if (this.entryView) {
this.entryView()
}
}
.width('100%')
.onClick(e => {})
}
.width(this.use.size.width)
.height(this.use.size.height)
.margin(this.use.size.margin)
.backgroundColor(Color.Blue)
}
.width('100%')
.height('100%')
.justifyContent(this.use.position)
.onClick(event => {
HDEntryKit.dismiss()
})
}

aboutToAppear(): void {
this.entryView = this.use.entry
}


调用方:


/// 弹框的配置
let use = new EKAttributes()
use.size.height = 100
use.size.margin = 20
use.position = FlexAlign.End
use.entry = this.text.bind(this)
HDEntryKit.display(use)

/// 自定义的弹框组件
@Builder text() {
Row() {
Text("123")
.backgroundColor('#ff0000')
.onClick(() => {
this.test()
})
}
.width('100%')
.justifyContent(FlexAlign.Start)
}

/// 弹框组件中的页面跳转事件
test() {
HDEntryKit.dismiss(() => {
let bundleName = this.context.applicationInfo.name
let loginPage = `@bundle:${bundleName}/login/ets/pages/LoginRegisterPage`
router.pushUrl({url: loginPage})
})
}

注意


通过自定义window方法弹出页面后,如果在调用router.push,则是默认在这个自定义的window进行页面跳转,当你销毁这个window的时候,打开的页面都会被关闭。所以,在demo里是在window销毁后,再进行页面跳转


作者:Taeyss
来源:juejin.cn/post/7342038143162466340
收起阅读 »

已有Flutter项目如何支持鸿蒙系统

背景现在越来越多的项目使用Flutter来做跨平台开发。比如我们的FanbookApp,同时支持iOS和安卓客户端, 目前95%以上的代码都是使用Dart来开发的,另外5%是一些原生插件支持,比如图片选择器、拍照、数据库等,使用了ObjectC,Java开发。...
继续阅读 »

背景

现在越来越多的项目使用Flutter来做跨平台开发。比如我们的FanbookApp,同时支持iOS和安卓客户端, 目前95%以上的代码都是使用Dart来开发的,另外5%是一些原生插件支持,比如图片选择器、拍照、数据库等,使用了ObjectC,Java开发。随着鸿蒙纯血原生系统的推进,有越来越多的应用都在紧锣密鼓的研发支持鸿蒙系统的客户端,当然也包括我们的Fanbook项目。

在技术调研过程中,发现了OpenHarmony SIG组织,用于孵化OpenHarmony相关开源生态项目,他们在很早就开始推进Flutter支持鸿蒙系统的工作。目前项的仓库地址。该仓库是基于Flutter SDK对于OpenHarmony平台的兼容拓展,可支持IDE或者终端使用Flutter Tools指令编译和构建OpenHarmony应用程序。既然这样,我们就没必要使用ArkTs、ArkUI来从零开始研发鸿蒙系统应用。

开整

先定个基调,把原有Flutter项目,新增支持鸿蒙系统,其实比想象简单。

配置Flutter环境

下载OpenHarmony 组织提供的Flutter仓库

git clone https://gitee.com/openharmony-sig/flutter_flutter.git

clone下载完成之后,可以切换到master或者dev分支, dev更新会及时些,现阶段可以作为学习分支。

然后配置环境变量

export PATH="$PATH":"/pathtoflutter/bin"

在终端输入命令行 flutter doctor -v, 如果检查成功则代表针对鸿蒙系统的Flutter环境配置没问题。

下载鸿蒙开发工具DevEco Studio, 这个之前的文章提过了,不再多说。

环境搭建好,话不多说,开始写代码

开始实操

在github随便找了一个项目为例, 还行先把项目clone下来

git clone https://github.com/jayden320/flutter_shuqi
cd flutter_shuqi

clone成功之后,使用Android Studio打开项目。

在一个空白目录执行以下命令,创建一个同名的项目

flutter create --platforms ohos,ios,android flutter_shuqi
cd flutter_shuqi

进入新项目的目录,发现多了一个鸿蒙系统代码的文件夹,然后把这个ohos复制到第一步clone下来的目录

再回到第一步使用Android Studio打开的项目,可以发现多了一个ohos文件夹。

链接鸿蒙系统真机或者模拟器,执行flutter run

可能有些伙伴会有疑问,为什么把文件夹复制过来就可以正常运行了,那是因为我们目前使用的是鸿蒙提供了Flutter, 他们对Flutter Tools进行了修改,当使用flutter pub getflutter run等命令。这些命令行的内部已经帮我们做了这些事情,他会去自动查找ohos目录,并生成相应的代码和.har包,从而确保可以支持鸿蒙系统。

这个时候,正常会报签名错误。看下面的截图可以发现,修复方式,就是使用DevDco Studio打开flutter_shuqi/ohos项目就行自动化签名即可。

开始自动签名

进入下面的操作面板,使用自己华为开发者账号登录之后勾选自动生成签名即可。

再次flutter run

签名成功之后在回到Android Stuido,再链接鸿蒙系统真机flutter run。这就说明已经成功跑起来了。

正常情况下会遇到一些问题,导致项目可以在鸿蒙系统上跑起来,但是显示空白。

解决页面空白

修改environment中的sdk版本

因为OpenHarmony SIG是基于Flutter 3.7.12版本修改的,如果有些项目中使用了更高的版本,需要修改pubspec.yaml文件,把sdk环境最低版本降到2.19.6以下就行。

environment:
sdk: '>=2.17.0 <3.0.0'

2. ### 如何判断是鸿蒙系统

import 'dart:io';

static bool get isOHOS => Platform.operatingSystem == "ohos"

3. ### 第三方库没有支持鸿蒙系统,怎么办?

比如常见的shared_preferences,device_info,path_provider,这些库一般的Flutter项目都会使用,所以OpenHarmony SIG组织已经对这些库做了一些支持。大家可以点击查看

然后如下方式进行修改就行。修改完了之后执行 flutter pub get更新本地代码。

改了上面的shared_preferences库,就可以正常进入项目了,不过显示图片还有点问题。

image.png

图片显示不出来,一般都是path_provider的问题,因为图片需要缓存到本地沙盒,相应改一下就行,不过有时候,有些库相互引用,导致修改比较麻烦,好在Flutter提供了提供了dependency_overrides方式,可以覆盖第三方库的最终地址。里面覆盖了path_providerpackage_info_pluspermission_handlerdevice_info_plusconnectivity_plus。这些库都是鸿蒙开源组织已经修改好了的。

dependency_overrides:
path_provider:
git:
url: https://gitee.com/openharmony-sig/flutter_packages.git
path: "packages/path_provider/path_provider"
package_info_plus:
git:
url: https://gitee.com/openharmony-sig/flutter_plus_plugins.git
path: packages/package_info_plus/package_info_plus
ref: a1347adcca3a46346a6ddd127cebcec9970cad6c
permission_handler:
git:
url: https://gitee.com/openharmony-sig/flutter_permission_handler.git
path: permission_handler
device_info_plus:
git:
url: https://gitee.com/openharmony-sig/flutter_plus_plugins.git
path: packages/device_info_plus/device_info_plus
ref: a1347adcca3a46346a6ddd127cebcec9970cad6c
connectivity_plus:
git:
url: https://gitee.com/openharmony-sig/flutter_plus_plugins
path: packages/connectivity_plus/connectivity_plus
ref: a1347adcca3a46346a6ddd127cebcec9970cad6c

flutter pub get之后,再次运行,基本上就可以使用。本来想放视频演示下,结果上传视频比较麻烦,就截了视频里面的几张图片,可以大致看看效果。想自己跑的话,下文也把源码push到gitee了。

image.png image.png image.png

支持鸿蒙的仓库已经提交到该地址。感谢原作者,我拿过来只是为了演示项目。

总结

大概花了不到半天的时间,就可以把一个已有的Flutter项目来支持原生鸿蒙系统,这个迁移成本还是不太高的,对于一些纯Dart写的第三方库可以直接使用,也无需适配。当然还会有一些其他的问题,比如鸿蒙没有覆写的第三方插件库,还有一些鸿蒙系统专属特性,这就需要我们自己去写一些鸿蒙原生代码,但是其实难度也不高。


作者:星河滚烫Leaf
来源:juejin.cn/post/7405153695539396617

收起阅读 »

程序员高效能指南:改变命运的 6 个关键习惯

凌晨 3 点,办公室里只剩下屏幕的幽光。又一个被 Bug 困扰的不眠之夜,你是否开始怀疑:为什么同样是写代码,有些人能轻松应对,而自己却总是加班救火?为什么有的同事技术能力突飞猛进,而自己似乎原地踏步? 答案也许不在技术本身,而在于我们的工作习惯。正如斯蒂芬·...
继续阅读 »

凌晨 3 点,办公室里只剩下屏幕的幽光。又一个被 Bug 困扰的不眠之夜,你是否开始怀疑:为什么同样是写代码,有些人能轻松应对,而自己却总是加班救火?为什么有的同事技术能力突飞猛进,而自己似乎原地踏步?


答案也许不在技术本身,而在于我们的工作习惯。正如斯蒂芬·R·柯维在《高效能人士的七个习惯》中所说:"我们看待世界的方式完全取决于我们自己的感知。"今天,让我们从程序员的视角出发,聊聊那些能让你事半功倍的 6 个习惯。


1. 主动积极


在编程的世界里,有两种程序员:被动的和主动的。


被动的程序员总是抱怨外部环境,比如公司制度、项目资源不足,甚至相信看几个“速成教程”就能改变命运。


而主动的程序员则会专注于自己能掌控的事情,比如提升技能、参与开源项目、参加技术竞赛,甚至主动寻找更好的职业机会。


如何做到主动积极?



  • 专注于“影响圈” :不要纠结于那些你无法改变的事情,比如公司政策或市场环境,而是把精力放在你能控制的事情上,比如学习新技术、优化代码质量。

  • 为自己的职业负责:不要等待别人给你机会,而是主动创造机会。比如,定期更新简历、参加技术社区活动,或者尝试新的编程语言。


主动积极的程序员明白,职业发展是自己的责任,而不是外界的恩赐。



2. 以终为始


很多程序员在工作中随波逐流,接到任务就埋头苦干,却从未思考过最终的目标是什么。结果就是,花了大量时间,却发现方向错了。斯蒂芬·柯维提出的“以终为始”理念,强调在开始任何事情之前,先明确最终的目标。


如何在编程中应用这一习惯?



  • 明确项目目标:在开始一个新项目时,先搞清楚最终的交付成果是什么。功能需求和非功能需求有哪些?用户体验的核心是什么?

  • 制定清晰的计划:花 30 分钟规划项目,可以节省 10 小时的开发时间。比如,先需求分析,再决定系统架构,而不是直接开始写代码。


记住,编程不仅仅是写代码,更是解决问题的艺术。明确目标,才能让你的努力更有方向。



3. 先做最重要的事


程序员的日常工作中,往往会被各种紧急任务打断,比如修复 Bug、处理线上问题。


但如果你总是被这些琐事牵着鼻子走,就会忽略那些对长期发展更重要的事情,比如学习新技术、优化系统架构。


如何区分重要和紧急?



  • 使用艾森豪威尔矩阵:将任务分为四类:



    1. 重要且紧急:立即处理,比如修复生产环境的重大 Bug。

    2. 重要但不紧急:安排时间,比如学习新技术、优化代码。

    3. 不重要但紧急:尽量委派,比如回复一些无关紧要的邮件。

    4. 不重要且不紧急:直接忽略,比如刷社交媒体。




优先处理“重要”的任务,因为这些任务决定了你的长期成长。



4. 考虑双赢


程序员的工作并不是孤军奋战,而是与团队协作完成的。无论是与其他工程师合作,还是与产品经理、设计师沟通,双赢的思维都至关重要。


双赢并不是让步,而是找到一种对双方都有利的解决方案。


如何培养双赢思维?



  • 欣赏团队的多样性:每个人都有自己的优势和视角,学会倾听和尊重他人的意见。

  • 建立信任:通过高质量的代码和积极的态度,赢得团队的信任。

  • 共同成长:在代码审查中,不仅指出问题,还要提出改进建议;在项目中,主动分享自己的经验和知识。


双赢的思维不仅能让团队更高效,还能帮助你建立长期的职业人脉。


5. 沟通的艺术1 + 1 > 2 的力量


程序员之间的沟通,不仅仅是语言上的交流,更是通过代码和文档进行的“无声对话”。


如果你写的代码晦涩难懂,或者文档不清晰,就会给团队带来额外的负担。


如何提升沟通能力?



  • 写清晰的代码:变量命名要有意义,注释要简洁明了。记住,代码是写给人看的,机器只是顺便执行。

  • 站在用户的角度思考:设计界面时,考虑用户的使用习惯;编写错误提示时,尽量清晰友好,而不是让用户感到困惑。

  • 倾听他人的意见:在团队讨论中,先理解别人的观点,再表达自己的看法。


如何实现高效协作?



  • 参与代码审查:通过审查他人的代码,学习新的技巧,同时也能帮助团队提高代码质量。

  • 结对编程:两个人一起编程,可以互相补充思路,避免遗漏问题。

  • 知识分享:定期组织技术分享会,或者在团队中推广最佳实践。


优秀的程序员,不仅能写出高质量的代码,还能通过代码与团队和用户“对话”。



6. 持续学习,永不止步


技术更新迭代飞快,程序员如果不持续学习,很容易被淘汰。磨砺锯子的习惯,强调在忙碌的工作中,抽出时间提升自己。


如何保持学习的动力?



  • 学习新技术:每年掌握一门新语言或框架,比如从 node 转向 rust,或者学习嵌入式技术。

  • 参与技术社区:通过开源项目、技术论坛、群聊或者线下活动,与其他程序员交流经验。

  • 关注行业动态:阅读技术博客、观看技术演讲,了解最新的趋势和工具。


就像磨刀不误砍柴工,持续学习不仅能提升你的技术水平,还能让你在职业生涯中始终保持竞争力。


改变习惯并不容易,但只要你愿意从今天开始,一点点调整自己的行为,就能逐渐看到改变的力量。



作者:云牧
来源:juejin.cn/post/7440676461169131555
收起阅读 »

小程序webview我爱死你了 小程序webview和H5通讯

web
webview 我 * 众所周知,将已上线的H5页面转换为小程序,最快的方法是通过WebView进行套壳。然而,在这个过程中,我们需要将H5页面的登录和支付功能迁移到小程序版本。这意味着H5页面需通过特定的方式与小程序进行通信,以实现如支付等关键功能。 因此需...
继续阅读 »

webview 我 *


众所周知,将已上线的H5页面转换为小程序,最快的方法是通过WebView进行套壳。然而,在这个过程中,我们需要将H5页面的登录和支付功能迁移到小程序版本。这意味着H5页面需通过特定的方式与小程序进行通信,以实现如支付等关键功能。


因此需要了解H5与WebView之间的通讯方式,以确保数据的顺利传递和功能的无缝对接。


找了很久发现H5与WebView的通讯方式主要有两种



  1. 小程序通过改变H5地址栏携带参数

  2. WebSocket实时通讯


而webview自带的bindmessage、bindload、binderror,触发条件只有小程序后退、组件销毁、分享、复制链接,给我卡的死死的,只好选择了第一种方式,WebSocket虽然可以实现实时通讯,但会增加额外的开销,不符合我的需求。


这里的URL域名必须添加到 小程序后台中-管理-业务域名内,否则会报无法打开 xxx 页面,个人小程序是没有这个选项的,需要申请成企业小程序


QQ20241123-124446.png


小程序向H5通讯

小程序端


<view class="content">
<web-view :src="url"></web-view>
</view>

H5端


// 判断当前页面的 URL 是否包含 'userInfo',用于识别是否来自小程序端
if (window.location.href.includes('userInfo')) {
// 匹配 URL 中的 userInfo 参数
const userInfoRegex = /userInfo=([^]*)/;

// 解码
const decodedUrl = decodeURIComponent(window.location.href);

// 使用正则表达式从解码后的 URL 中提取参数值
const userInfoMatch = decodedUrl.match(userInfoRegex);
let auth_token = userInfoMatch[1];
localStorage.setItem('loc_token', auth_token);
}


H5向小程序通讯

小程序端


onMounted(() => {
const paymentData = getCurrentPages().pop().options.paymentData // 获取当前页面参数
submitInfo(paymentData);
});


H5端


 wx.miniProgram.navigateTo({
url: `/pagesMember/pay/pay?paymentData=${payInfo.value}`,
})


通讯限制也就算了,导航栏不能自定义,还不让去掉,这让自带导航栏显得极其突兀!我 * !!!


QQ20241123-121254.png


微信图片_20241123122103.jpg


navigationStyle: custom对 web-view 组件无效
一句话干碎我的摸鱼梦,领导要把那块做成透明的,没办法只好把常用页面重构,
but小程序不支持elementPlus啊,太爽了家人们。


作者:loooseFish
来源:juejin.cn/post/7440122922025058342
收起阅读 »

科技业裁不停!软件工程师实惨,今年科技公司已裁员 13.7 万人

【新智元导读】科技行业的就业市场正在发生重大变革,人才供需逆转,初级职位减少,技能要求增加,求职竞争加剧。 科技行业曾是众多人才竞相追求的热门领域,但如今却面临着职位减少的挑战。 根据 Indeed.com 的数据,自 2020 年 2 月以来,软件开发岗位的...
继续阅读 »
【新智元导读】科技行业的就业市场正在发生重大变革,人才供需逆转,初级职位减少,技能要求增加,求职竞争加剧。

科技行业曾是众多人才竞相追求的热门领域,但如今却面临着职位减少的挑战。


根据 Indeed.com 的数据,自 2020 年 2 月以来,软件开发岗位的招聘广告数量已经下降了超过 30%。


Layoffs.fyi 网站的报告也显示,今年科技行业的裁员潮仍在继续,自 1 月份以来,已有约 13.7 万个工作岗位被裁减。



软件工程师在招聘网站上出现的频次对比,以 2018 年 1 月作为基准 100。来源:ADP


对于长期在就业市场占据优势的科技行业来说,这种急剧的变化不仅仅是短期的不适,而是整个行业正在经历的一次根本性的劳动力需求调整,一些从业者正被市场淘汰。


47 岁的 Chris Volz,一位居住在加利福尼亚州奥克兰的工程经理,自 90 年代末就开始在科技行业工作,但在 2023 年 8 月被一家房地产技术公司解雇。他表示:「这次的情况感觉非常、非常不同。」



Volz 之前的经历中,大部分工作机会都是通过猎头或内部推荐获得的。然而现在,他发现他的人脉网络中的许多人也都被裁员了,这迫使他不得不在职业生涯中第一次主动向外投递简历。


虽然在疫情期间,随着消费者将日常生活和消费活动转移到线上,科技公司迎来了招聘热潮,大量扩充员工队伍。


人才争夺战如此激烈,以至于公司囤积员工,不让他们去往竞争对手方工作;一些员工说,他们实际上是被雇佣来无所事事的。


然而,随着通货膨胀和利率的上升,经济形势迅速变化,导致一些大型科技公司开始大规模裁员。



ADP 研究部主管 Nela Richardson 表示,尽管疫情期间的招聘热潮减缓了整体的下降趋势,但并未改变长期趋势,部分原因是数字领域创新的自然发展轨迹,技术解决方案正在取代传统的人力。


她说, 「在数字领域,你不再像早期那样有很多新的突破。因为有越来越多的技术解决方案,而不仅仅是人的解决方案」 。


科技行业的非技术人员,如市场营销、人力资源和招聘人员,也面临着多次解雇的风险。


James Arnold 在过去的 18 年里一直从事科技领域的招聘工作,但在不到两年的时间里却两次被裁员。


在疫情期间,他在 Meta 担任人力资源,快速招聘新员工。2022 年 11 月,他被解雇了,然后花了将近一年的时间找工作,最后才在行业外找到了一份工作。


Arnold 说,他申请的大多数工作的薪水都比过去低三分之一 。


尽管科技公司的财务状况有所反弹,但一些公司更倾向于依赖顾问和外包职位。


Arnold 认为,疫情证明了远程工作的有效性,这为全球化就业市场打开了新的可能性。


初级职位正在减少


以往,初级职位对于技术实习生来说是一个高薪的起点,他们常常能够获得六位数的年薪,并且有很大机会转正。


但是,最近这一趋势发生了转变。企业开始减少实习机会,并降低了初级职位的招聘数量。


现在,即便是入门级职位,也要求应聘者具备多年的工作经验。


薪酬规划初创公司 Pequity 的首席执行官 Kaitlyn Knopp 观察到,过去薪酬过高和职称与经验不匹配的现象已经得到纠正。


她指出:「我们发现职位级别正在重新调整,人们的经验和职责更加匹配了。」



根据 Pequity 的数据,2024 年薪资增长基本停滞,平均薪资仅比去年增长了 0.95%。


Pequity 还发现,自 2019 年以来,中型企业软件即服务公司为初级职位提供的股权补助平均下降了 55%。


然而,目前失业技术人员和职场新人之间的竞争愈发激烈,而面试机会却在不断减少。


同时,企业对工程师的要求更为全面。


人员招聘公司 Robert Half 和技术实践部执行总监 Ryan Sutton 说,为了提高效率和降低成本,他们希望团队成员不仅具备软技能和协作能力,还应了解公司的人工智能战略和发展方向。


他说道:「他们希望看到更多多才多艺的人才。」



通过网上申请在科技领域找工作毫无结果,于是 Glenn Kugelman 采用了另一种策略:用纸和胶带在曼哈顿悬挂传单,宣传他的 LinkedIn 简介


不少技术人员开始寻求提升自身技能,纷纷报名参加人工智能训练营或其他相关课程。


Michael Moore 是亚特兰大的一名软件工程师,今年 1 月被一家网络应用程序开发公司解雇,在七个月的求职无果后,他决定报读一所网络大学。


摩尔曾通过在线课程学习编程,并表示六年前没有大学学位的他依然顺利找到了工作。


科技公司的战略变化


企业战略也正在发生转变。


科技公司不再追求不惜一切代价的增长,也不再对那些宏伟的「登月计划」进行投资,而是将焦点转向能够带来收入的产品和服务上。


他们减少了对初级职位的招聘,缩减了招聘团队,并放弃了一些不盈利领域的项目和工作,比如虚拟现实和设备。


与此同时,企业开始将大量资源投入到人工智能领域。


2022 年末发布的 ChatGPT 让人们看到了生成式人工智能创造类人内容和潜在行业变革的能力。这引发了投资热潮,人们争相构建最先进的人工智能系统。


在这个领域拥有专业知识的工人成为了少数几个强势群体之一。


从事大型语言模型工作的人员目前在市场上非常抢手,这些模型是 ChatGPT 等产品的基础,从事此类工作的人员年收入远超百万美元。



Pequity 的首席执行官 Kaitlyn Knopp 指出,人工智能工程师的薪资是普通工程师的两到四倍,她认为这是对未知技术的极端投资,导致公司无法在其他人才上进行投资。


甚至科技行业之外的公司也在积极招聘人工智能人才。一位猎头 Martha Heller 表示,五年前,董事会并不像现在这样关注公司的人工智能战略。


科技行业的就业市场正在经历一场深刻的变革。虽然初级职位减少,整体就业竞争加剧,但人工智能等新兴技术领域仍然提供了广阔的就业机会。


对于技术人员而言,不断学习新技能,适应行业变化,是在变革中寻找机遇的关键。


而对于行业来说,这也许是一个重新思考和调整人才培养和引进策略的契机。


参考资料:


http://www.wsj.com/tech/tech-j…


作者:新智元
来源:juejin.cn/post/7418367859011256374
收起阅读 »

分不清Boolean和boolean,我被同事diss了!

web
背景 这几天写代码,遇到一个不确定的知识点:我在vue的props中如何给一个属性定义小写的bolean,代码就会报错 但是大写的Bolean就没问题 由于我在其他地方我看大小写都可以,有点疑惑,于是想去请教一下同事。然而,没想到同事上来就diss我: ...
继续阅读 »

背景


这几天写代码,遇到一个不确定的知识点:我在vue的props中如何给一个属性定义小写的bolean,代码就会报错



但是大写的Bolean就没问题



由于我在其他地方我看大小写都可以,有点疑惑,于是想去请教一下同事。然而,没想到同事上来就diss我:



这么基础的知识你都不清楚?这两个根本就不是一个东西!



我有点不开心,想反驳一下:


这两个不都是描述类型的东西吗?我给你看其他地方的代码,这两个都是可以混用的!



同事有点不耐烦,说道:大姐,boolean是TS中的类型声明,Boolean是JavaScript 的构造函数,根本不是一个东西吧!


行吧,我也刚入门不久,确实不了解这个东西,只能强忍委屈,对同事说了声谢谢,我知道了!


然后,我好好的学习了一下Boolean和boolean的知识,终于搞明白他们的区别了。


Boolean和boolean


本质区别


同事说的很对,他们两个的本质区别就是一个是JavaScript语法,一个是TypeScript语法,这意味着非TypeScript项目是不存在boolean这个东西的。


Boolean 是 JavaScript 的构造函数


Boolean 是 JavaScript 中的内置构造函数,用于布尔值的类型转换或创建布尔对象。


typeof Boolean; // "function"

boolean 是 TypeScript 的基本类型



  • 如果使用了 TypeScript,boolean 是 TypeScript 中的基本类型,用于静态类型检查。

  • 在 JavaScript 的运行时上下文中,boolean 并不存在,仅作为 TypeScript 的静态检查标识。


typeof boolean; // ReferenceError: boolean is not defined

TS中作为类型的Boolean和boolean


在TypeScript中,Boolean和boolean都可以用于表示布尔类型


export interface ActionProps {
checkStatus: Boolean
}
export interface RefundProps {
visible: boolean
}

但是,他们存在一些区别


boolean



  • boolean 是 TypeScript 的基本类型,用于定义布尔值。

  • 它只能表示 truefalse

  • 编译后 boolean 不会存在于 JavaScript 中,因为它仅用于静态类型检查。


//typescript
let isActive: boolean; // 只能是 true 或 false
isActive = true; // 正确
isActive = false; // 正确
isActive = new Boolean(true); // 错误,不能赋值为 Boolean 对象

Boolean



  • Boolean 是 JavaScript 的内置构造函数,用于将值显式转换为布尔值或创建布尔对象(Boolean 对象)。

  • 它是一个引用类型,返回的是一个布尔对象,而不是基本的布尔值。

  • 在 TypeScript 中, Boolean 表示构造函数类型,而不是基本的布尔值类型


//typescript
let isActive: Boolean; // 类型是 Boolean 对象
isActive = new Boolean(false); // 正确,赋值为 Boolean 对象
isActive = true; // 正确,基本布尔值也可以兼容

关键区别


特性booleanBoolean
定义TypeScript 的基本类型JavaScript 的构造函数
值类型只能是 truefalse是一个布尔对象
推荐使用场景用于定义基本布尔值类型很少用,除非需要显式构造布尔对象
运行时行为不存在,只在编译时有效在运行时是 JavaScript 的构造函数
性能高效,直接操作布尔值对象包装,性能较差

为什么尽量避免使用 Boolean


类型行为不一致Boolean 是对象类型,而不是基本值类型。这会在逻辑运算中导致混淆:


const flag: Boolean = new Boolean(false);
if (flag) {
console.log("This will run!"); // 因为对象始终为 truthy
}

性能开销更大Boolean 会创建对象,而 boolean 是直接操作基本类型。


vue中的Boolean与boolean


Vue 的运行时框架无法识别 boolean 类型,它依赖的是 JavaScript 的内置构造函数(如 BooleanStringNumber 等)来检查和处理 props 类型。


因此,props的Type只能是BooleanStringNumber


但是如果vue中开启了ts语法,就可以使用boolean 表示类型了


<script lang="ts" setup>

interface IProps {
photoImages?: string[],
isEdit?: boolean
}

const props = withDefaults(defineProps<IProps>(), {
photoImages: () => [],
isEdit: true
})

</script>

作者:快乐就是哈哈哈
来源:juejin.cn/post/7439576043223203892
收起阅读 »

Flutter - 危!3.24版本苹果审核被拒!

iOS
欢迎关注微信公众号:FSA全栈行动 👋 一、概述 最近准备使用 Flutter 的 3.24 版本打包上架 APP,结果前天看到有人提了一个 issue: github.com/flutter/flu… ,说分别使用 3.24.3 和 3.24.4 提交苹果...
继续阅读 »

欢迎关注微信公众号:FSA全栈行动 👋



一、概述


最近准备使用 Flutter3.24 版本打包上架 APP,结果前天看到有人提了一个 issue: github.com/flutter/flu… ,说分别使用 3.24.33.24.4 提交苹果审核时,都惨遭被拒~


苹果反馈的信息如下:


Guideline 2.5.1 - Performance - Software Requirements

The app uses or references the following non-public or deprecated APIs:

Frameworks/Flutter.framework/Flutter

Symbols:

• _kCTFontPaletteAttribute
• _kCTFontPaletteColorsAttribute

The use of non-public or deprecated APIs is not permitted, as they can lead to a poor user experience should these APIs change and are otherwise not supported on Apple platforms.

可以看到,是说 Flutter 使用了未公开的 API,并且他使用 strings 命令也验证了这一点。


3.24.x


strings Runner.app/Frameworks/Flutter.framework/Flutter | grep kCT
SkCTMShader
kCTFontVariationAxisHiddenKey
kCTFontPaletteAttribute
kCTFontPaletteColorsAttribute

3.22.3


strings Runner.app/Frameworks/Flutter.framework/Flutter | grep kCT
SkCTMShader
kCTFontVariationAxisHiddenKey

我先在 Flutter 引擎源码中搜索,结果压根就搜索不到,随后打开了前几日编译好的引擎调试项目,结果一搜一个准,在 third_party 依赖下的 Skia 代码中,很快就定位到了引入未公开 API 的相关提交记录 skia-review.googlesource.com/c/skia/+/86…


我一看完就啪的一声敲起来了,很快啊!上来就是一个 Revert skia-review.googlesource.com/c/skia/+/91…


目前此次受影响的 Flutter 版本范围暂时是 3.24.0 ~ 3.24.4,得等待新版本的发布才可以解决。建议还没用上 3.24 的小伙伴先不要升级,那如果已经是 3.24 或者是一定要用 3.24.4 及以下版本的小伙伴要怎么办呢?那就跟我一起来自编译引擎吧~


二、编译引擎


环境



注意:全程需要科学上网环境,请自行查找和配置



首先拉取最新的 depot_tools,放到一个合适的位置,比如我放在 ~/development 目录下


cd ~/development
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git

depot_tools 添加至环境变量,在你的终端配置文件里补充如下内容



终端配置文件因人而异,如:~/.bash_profile~/.zshrc~/.zprofile,请自行判断



export PATH = "$HOME/development/depot_tools":$PATH

然后 source ~/.zshrc(这里请根据自身情况修改终端配置文件路径)


拉源码


找个合适的目录,创建 engine 目录并进入


mkdir engine
cd engine

开始拉取源码


fetch flutter

它会在当前目录下创建 .gclient 文件,写好配置,并执行 gclient sync


solutions = [
{
"custom_deps": {},
"deps_file": "DEPS",
"managed": False,
"name": "src/flutter",
"safesync_url": "",
"url": "https://github.com/flutter/engine.git",
},
]

如果在拉取代码的过程中遇到如下问题


remote: Enumerating objects: 835563, done.
remote: Counting objects: 100% (1612/1612), done.
remote: Compressing objects: 100% (1011/1011), done.
error: RPC failed; curl 92 HTTP/2 stream 5 was not closed cleanly: CANCEL (err 8)
error: 1481 bytes of body are still expected
fetch-pack: unexpected disconnect while reading sideband packet
fatal: early EOF
fatal: fetch-pack: invalid index-pack output

src/flutter (ERROR)
----------------------------------------
[0:00:00] Started.

别慌,执行下方命令让其接着拉,直至完成


gclient sync

拉取完成后,去查看我们使用的 Flutter 版本对应的引擎版本,这里以 3.24.4 为例,打开链接:github.com/flutter/flu… ,拿到 db49896cf25ceabc44096d5f088d86414e05a7aa


执行如下命令进行切换


cd src/flutter
git checkout db49896cf25ceabc44096d5f088d86414e05a7aa

执行完成会输出如下内容


Previous HEAD position was b0a4ca92c4 Add FlPointerManager to process pointer events from GTK in a form suitable for Flutter. (#56443)
HEAD is now at db49896cf2 [CP-stable]Add xcprivacy privacy manifest to macOS framework (#55366)
post-checkout: The engine source tree has been updated.

You may need to run "gclient sync -D"

按照提示执行


gclient sync -D

调整源码


按路径 engine/src/flutter/third_party/skia/src/ports/SkTypeface_mac_ct.cpp 打开文件,按下方内容进行修改(红:删除,绿:新增)


static CFStringRef getCTFontPaletteAttribute() {
- static CFStringRef* kCTFontPaletteAttributePtr =
- static_cast<CFStringRef*>(dlsym(RTLD_DEFAULT, "kCTFontPaletteAttribute"));
- return *kCTFontPaletteAttributePtr;
+ return nullptr;
+ //static CFStringRef* kCTFontPaletteAttributePtr =
+ // static_cast<CFStringRef*>(dlsym(RTLD_DEFAULT, "kCTFontPaletteAttribute"));
+ //return *kCTFontPaletteAttributePtr;
}
static CFStringRef getCTFontPaletteColorsAttribute() {
- static CFStringRef* kCTFontPaletteColorsAttributePtr =
- static_cast<CFStringRef*>(dlsym(RTLD_DEFAULT, "kCTFontPaletteColorsAttribute"));
- return *kCTFontPaletteColorsAttributePtr;
+ return nullptr;
+ //static CFStringRef* kCTFontPaletteColorsAttributePtr =
+ // static_cast<CFStringRef*>(dlsym(RTLD_DEFAULT, "kCTFontPaletteColorsAttribute"));
+ //return *kCTFontPaletteColorsAttributePtr;
}

...

static bool apply_palette(CFMutableDictionaryRef attributes,
const SkFontArguments::Palette& palette) {
bool changedAttributes = false;
- if (palette.index != 0 || palette.overrideCount) {
+ if ((palette.index != 0 || palette.overrideCount) && getCTFontPaletteAttribute()) {
SkUniqueCFRef<CFNumberRef> paletteIndex(
CFNumberCreate(kCFAllocatorDefault, kCFNumberIntType, &palette.index));
CFDictionarySetValue(attributes, getCTFontPaletteAttribute(), paletteIndex.get());
changedAttributes = true;
}

- if (palette.overrideCount) {
+ if (palette.overrideCount && getCTFontPaletteColorsAttribute()) {
SkUniqueCFRef<CFMutableDictionaryRef> overrides(

...

相应修改来自: skia-review.googlesource.com/c/skia/+/91…


编译


来到 engine/src 目录,使用 gn 编译生成 ninja 构建文件


./flutter/tools/gn --runtime-mode release --mac-cpu arm64
./flutter/tools/gn --ios --runtime-mode release

使用 ninja 编译引擎的最终产物


ninja -C out/host_release_arm64
ninja -C out/ios_release

如果你当前是 MacOS 15Sequoia 系统,在执行 ninja -C out/host_release_arm64 时会遇到如下错误


COPY '/System/Library/Fonts/A...arty/txt/assets/Apple Color Emoji.ttc'
FAILED: gen/flutter/third_party/txt/assets/Apple Color Emoji.ttc
ln -f '/System/Library/Fonts/Apple Color Emoji.ttc' 'gen/flutter/third_party/txt/assets/Apple Color Emoji.ttc' 2>/dev/null || (rm -rf 'gen/flutter/third_party/txt/assets/Apple Color Emoji.ttc' && cp -af '/System/Library/Fonts/Apple Color Emoji.ttc' 'gen/flutter/third_party/txt/assets/Apple Color Emoji.ttc')
cp: chflags: gen/flutter/third_party/txt/assets/Apple Color Emoji.ttc: Operation not permitted
[18/4139] SOLINK libvk_swiftshader.dylib libvk_swiftshader.dylib.TOC
ninja: build stopped: subcommand failed.

别急,打开 engine/src/build/toolchain/mac/BUILD.gn,做如下修改,修改完再执行 gnninja


    tool("copy") {
- command = "ln -f {{source}} {{output}} 2>/dev/null || (rm -rf {{output}} && cp -af {{source}} {{output}})"
+ command = "ln -f {{source}} {{output}} 2>/dev/null || (rsync -a --delete {{source}} {{output}})"
description = "COPY {{source}} {{output}}"
}

相应的 issue: #152978


好了,静静等待编译完成。


请注意,这将是个十分漫长且全程 CPU 占用率为 100% 的过程~


建议使用一台空闲的 Mac 电脑去做这个事!否则你将啥活也干不了~


验证


进入 engine/src/out/ios_release


strings Flutter.framework/Flutter | grep kCT 

SkCTMShader
kCTFontVariationAxisHiddenKey

可以看到,没有 kCTFontPaletteAttributekCTFontPaletteColorsAttribute


使用本地引擎


执行如下命令对项目进行编译


flutter build ipa \
--local-engine-src-path=/Users/lxf/engine/src \
--local-engine=ios_release \
--local-engine-host=host_release_arm64

如果你有使用 realm 的话,可能会遇到如下错误


Installing realm (1.0.3)
[!] /bin/bash -c
set -e
source "/Users/lxf/app/ios/Flutter/flutter_export_environment.sh" && cd "$FLUTTER_APPLICATION_PATH" && "$FLUTTER_ROOT/bin/flutter" pub run realm install --target-os-type ios --flavor flutter

You must specify --local-engine or --local-web-sdk if you are using a locally built engine or web sdk.

你需要对该文件
/Users/lxf/app/ios/.symlinks/plugins/realm/ios/realm.podspec 进行修改,在 \"$FLUTTER_ROOT/bin/flutter\"pub 中间加上引擎相关参数。如下所示


s.prepare_command           = "source \"#{project_dir}/Flutter/flutter_export_environment.sh\" && cd \"$FLUTTER_APPLICATION_PATH\" && \"$FLUTTER_ROOT/bin/flutter\" --local-engine-src-path /Users/lxf/engine/src --local-engine ios_release --local-engine-host host_release_arm64 pub run realm install --target-os-type ios --flavor flutter"

:script => 'source "$PROJECT_DIR/../Flutter/flutter_export_environment.sh" && cd "$FLUTTER_APPLICATION_PATH" && "$FLUTTER_ROOT/bin/flutter" --local-engine-src-path /Users/lxf/engine/src --local-engine ios_release --local-engine-host host_release_arm64 pub run realm install --target-os-type ios --flavor flutter',

如果你只是想对项目进行配置,则将 ipa 改为 ios,并加上 --config-only 参数即可。


flutter build ios \
--local-engine-src-path=/Users/lxf/engine/src \
--local-engine=ios_release \
--local-engine-host=host_release_arm64 \
--config-only

以前使用本地引擎只需要 --local-engine 参数,现在要求结合 --local-engine-host 一块使用,这里附上相关 issuegithub.com/flutter/flu… ,想了解的可以点开看看


三、最后


过程不难,麻烦的是拉源码和编译真的好慢,而且空间占用还大~


好了,本篇到此结束,感谢大家的支持,我们下次再见! 👋



如果文章对您有所帮助, 请不吝点击关注一下我的微信公众号:FSA全栈行动, 这将是对我最大的激励. 公众号不仅有 iOS 技术,还有 AndroidFlutterPython 等文章, 可能有你想要了解的技能知识点哦~



作者:LinXunFeng
来源:juejin.cn/post/7436567770907017257
收起阅读 »

TypeScript很麻烦💔,不想使用!

web
本文已经授权【稀土掘金技术社区】官方公众号独家原创发布。 前言 最近,我们部门在开发一个组件库时,我注意到一些团队成员对使用TypeScript表示出了抵触情绪,他们常常抱怨说:“TypeScript太麻烦了,我们不想用!”起初,我对此感到困惑:TypeScr...
继续阅读 »

本文已经授权【稀土掘金技术社区】官方公众号独家原创发布。


前言


最近,我们部门在开发一个组件库时,我注意到一些团队成员对使用TypeScript表示出了抵触情绪,他们常常抱怨说:“TypeScript太麻烦了,我们不想用!”起初,我对此感到困惑:TypeScript真的有那么麻烦吗?然而,当我抽时间审查队伍的代码时,我终于发现了问题所在。在这篇文章中,我想和大家分享我的一些发现和解决方案。


一、类型复用不足


在代码审查过程中,我发现了大量的重复类型定义,这显著降低了代码的复用性。


进一步交流后,我了解到许多团队成员并不清楚如何在TypeScript中复用类型。TypeScript允许我们使用typeinterface来定义类型。


当我询问他们typeinterface之间的区别时,大多数人都表示不清楚,这也就难怪他们不知道如何有效地复用类型了。


type定义的类型可以通过交叉类型(&)来进行复用,而interface定义的类型则可以通过继承(extends)来实现复用。值得注意的是,typeinterface定义的类型也可以互相复用。下面是一些简单的示例:


复用type定义的类型:


type Point = {
x: number;
y: number;
};

type Coordinate = Point & {
z: number;
};

复用interface定义的类型:


interface Point {
x: number;
y: number;
};

interface Coordinate extends Point {
z: number;
}

interface复用type定义的类型:


type Point = {
x: number;
y: number;
};

interface Coordinate extends Point {
z: number;
}

type复用interface定义的类型:


interface Point {
x: number;
y: number;
};

type Coordinate = Point & {
z: number;
};

二、复用时只会新增属性的定义


我还注意到,在类型复用时,团队成员往往只是简单地为已有类型新增属性,而忽略了更高效的复用方式。


例如,有一个已有的类型Props需要复用,但不需要其中的属性c。在这种情况下,团队成员会重新定义Props1,仅包含Props中的属性ab,同时添加新属性e


interface Props {
a: string;
b: string;
c: string;
}

interface Props1 {
a: string;
b: string;
e: string;
}

实际上,我们可以利用TypeScript提供的工具类型Omit来更高效地实现这种复用。


interface Props {
a: string;
b: string;
c: string;
}

interface Props1 extends Omit<Props, 'c'> {
e: string;
}

类似地,工具类型Pick也可以用于实现此类复用。


interface Props {
a: string;
b: string;
c: string;
}

interface Props1 extends Pick<Props, 'a' | 'b'> {
e: string;
}

OmitPick分别用于排除和选择类型中的属性,具体使用哪一个取决于具体需求。


三、未统一使用组件库的基础类型


在开发组件库时,我们经常面临相似功能组件属性命名不一致的问题,例如,用于表示组件是否显示的属性,可能会被命名为showopenvisible。这不仅影响了组件库的易用性,也降低了其可维护性。


为了解决这一问题,定义一套统一的基础类型至关重要。这套基础类型为组件库的开发提供了坚实的基础,确保了所有组件在命名上的一致性。


以表单控件为例,我们可以定义如下基础类型:


import { CSSProperties } from 'react';

type Size = 'small' | 'middle' | 'large';

type BaseProps<T> = {
/**
* 自定义样式类名
*/

className?: string;
/**
* 自定义样式对象
*/

style?: CSSProperties;
/**
* 控制组件是否显示
*/

visible?: boolean;
/**
* 定义组件的大小,可选值为 small(小)、middle(中)或 large(大)
*/

size?: Size;
/**
* 是否禁用组件
*/

disabled?: boolean;
/**
* 组件是否为只读状态
*/

readOnly?: boolean;
/**
* 组件的默认值
*/

defaultValue?: T;
/**
* 组件的当前值
*/

value?: T;
/**
* 当组件值变化时的回调函数
*/

onChange: (value: T) => void;
}

基于这些基础类型,定义具体组件的属性类型变得简单而直接:


interface WInputProps extends BaseProps<string> {
/**
* 输入内容的最大长度
*/

maxLength?: number;
/**
* 是否显示输入内容的计数
*/

showCount?: boolean;
}

通过使用type关键字定义基础类型,我们可以避免类型被意外修改,进而增强代码的稳定性和可维护性。


四、处理含有不同类型元素的数组


在审查自定义Hook时,我发现团队成员倾向于返回对象,即使Hook只返回两个值。


虽然这样做并非错误,但它违背了自定义Hook的一个常见规范:当Hook返回两个值时,应使用数组返回。


团队成员解释说,他们不知道如何定义含有不同类型元素的数组,通常会选择使用any[],但这会带来类型安全问题,因此他们选择返回对象。


实际上,元组是处理这种情况的理想选择。通过元组,我们可以在一个数组中包含不同类型的元素,同时保持每个元素类型的明确性。


function useMyHook(): [string, number] {
return ['示例文本', 42];
}

function MyComponent() {
const [text, number] = useMyHook();
console.log(text); // 输出字符串
console.log(number); // 输出数字
return null;
}

在这个例子中,useMyHook函数返回一个明确类型的元组,包含一个string和一个number。在MyComponent组件中使用这个Hook时,我们可以通过解构赋值来获取这两个不同类型的值,同时保持类型安全。


五、处理参数数量和类型不固定的函数


审查团队成员封装的函数时,我发现当函数的参数数量不固定、类型不同或返回值类型不同时,他们倾向于使用any定义参数和返回值。


他们解释说,他们只知道如何定义参数数量固定、类型相同的函数,对于复杂情况则不知所措,而且不愿意将函数拆分为多个函数。


这正是函数重载发挥作用的场景。通过函数重载,我们可以在同一函数名下定义多个函数实现,根据不同的参数类型、数量或返回类型进行区分。


function greet(name: string): string;
function greet(age: number): string;
function greet(value: any): string {
if (typeof value === "string") {
return `Hello, ${value}`;
} else if (typeof value === "number") {
return `You are ${value} years old`;
}
}

在这个例子中,我们为greet函数提供了两种调用方式,使得函数使用更加灵活,同时保持类型安全。


对于箭头函数,虽然它们不直接支持函数重载,但我们可以通过定义函数签名的方式来实现类似的效果。


type GreetFunction = {
(name: string): string;
(age: number): string;
};

const greet: GreetFunction = (value: any): string => {
if (typeof value === "string") {
return `Hello, ${value}`;
} else if (typeof value === "number") {
return `You are ${value} years old.`;
}
return '';
};

这种方法利用了类型系统来提供编译时的类型检查,模拟了函数重载的效果。


六、组件属性定义:使用type还是interface


在审查代码时,我发现团队成员在定义组件属性时既使用type也使用interface


询问原因时,他们表示两者都可以用于定义组件属性,没有明显区别。


由于同名接口会自动合并,而同名类型别名会冲突,我推荐使用interface定义组件属性。这样,使用者可以通过declare module语句自由扩展组件属性,增强了代码的灵活性和可扩展性。


interface UserInfo {
name: string;
}
interface UserInfo {
age: number;
}

const userInfo: UserInfo = { name: "张三", age: 23 };

结语


TypeScript的使用并不困难,关键在于理解和应用其提供的强大功能。如果你在使用TypeScript过程中遇到任何问题,不清楚应该使用哪种语法或技巧来解决,欢迎在评论区留言。我们一起探讨,共同解决TypeScript中遇到的挑战。


作者:前端大骆
来源:juejin.cn/post/7344282440725577765
收起阅读 »

只写后台管理的前端要怎么提升自己

web
本人写了五年的后台管理。每次面试前就会头疼,因为写的页面除了表单就是表格。抱怨过苦恼过也后悔过,但是站在现在的时间点回想以前,发现有很多事情可以做的更好,于是有了这篇文章。 写优雅的代码 一道面试题 大概两年以前,面试美团的时候,面试官让我写一道代码题,时间单...
继续阅读 »

本人写了五年的后台管理。每次面试前就会头疼,因为写的页面除了表单就是表格。抱怨过苦恼过也后悔过,但是站在现在的时间点回想以前,发现有很多事情可以做的更好,于是有了这篇文章。


写优雅的代码


一道面试题


大概两年以前,面试美团的时候,面试官让我写一道代码题,时间单位转换。具体的题目我忘记了。


原题目我没做过,但是我写的业务代码代码里有类似的单位转换,后端返回一个数字,单位是kb,而我要展示成 KBMB 等形式。大概写一个工具函数(具体怎么写的忘记了,不过功能比这个复杂点):


function formatSizeUnits(kb) {
let units = ['KB', 'MB', 'GB', 'TB', 'PB'];
let unitIndex = 0;

while (kb >= 1024 && unitIndex < units.length - 1) {
kb /= 1024;
unitIndex++;
}

return `${kb.toFixed(2)} ${units[unitIndex]}`;
}

而在此之前,是一个后端同学写的代码(说起来,后台管理系统前端人力不够后端帮忙写好像是常规操作,后端总觉得写前端简单,而我只能说你们那代码写的,一言难尽……),如下:


function formatSizeUnits(kb) {
var result = '';

if (kb < 1024) {
result = kb + ' KB';
} else if (kb < 1024 * 1024) {
result = (kb / 1024).toFixed(2) + ' MB';
} else if (kb < 1024 * 1024 * 1024) {
result = (kb / 1024 / 1024).toFixed(2) + ' GB';
} else {
result = (kb / 1024 / 1024 / 1024).toFixed(2) + ' TB';
}

return result;
}

虽然功能都一样,但是我写在面试的时候写了上面的代码得到了面试官的赞扬,也因此非常顺利的通过了面试。而如果我写成了下面的样子,估计不会有这种效果。


如何提升代码质量


想提升代码质量,最理想的是靠 code review ,但实际上这玩意在大多数公司根本就不存在。其次就只能多看看大佬们写的代码,但是一来后台管理本来就没太大难度很少有大佬,二来很多时候一个后台管理系统就一两个人负责。


还好现在有了各种 AI 工具,可以非常方便的优化自己的代码了。


还是上面的问题,看看 GPT 给的答案


// 提问:写一个工具函数,对文件大小的单位进行转换,加注释
// 回答:下面是一个JavaScript工具函数,它接受文件大小、初始单位、目标单位和小数位数,然后返回目标单位下的文件大小。

/**
* 将文件大小从一个单位转换为另一个单位。
*
* @param {number} size 文件大小。
* @param {string} fromUnit 初始单位('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB')。
* @param {string} toUnit 目标单位('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB')。
* @param {number} [decimalPoint=2] 结果保留的小数位数,默认为2。
* @return {string} 转换后的文件大小,带单位。
*/

function convertFileSize(size, fromUnit, toUnit, decimalPoint = 2) {
// 定义单位与字节之间的转换关系
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
// 获取初始单位和目标单位的索引
const fromIndex = units.indexOf(fromUnit);
const toIndex = units.indexOf(toUnit);

// 如果单位不在列表中,抛出错误
if (fromIndex === -1 || toIndex === -1) {
throw new Error('Invalid units');
}

// 计算初始单位与目标单位之间的转换系数
const exponent = toIndex - fromIndex;
// 计算结果大小
const resultSize = size / Math.pow(1024, exponent);

// 返回格式化后的结果
return parseFloat(resultSize.toFixed(decimalPoint)) + ' ' + toUnit;
}

// 示例使用
console.log(convertFileSize(1, 'GB', 'MB')); // 输出: 1024.00 MB
console.log(convertFileSize(1, 'MB', 'KB')); // 输出: 1024.00 KB
console.log(convertFileSize(1, 'KB', 'B')); // 输出: 1024.00 B
console.log(convertFileSize(1, 'MB', 'GB', 5)); // 输出: 0.00098 GB

还能说什么,我只能说 NB!(经评论区提示,此段代码可能有问题,建议大家在使用AI工具时,重点是借鉴思路,直接使用代码需要谨慎~)


我现在每次写大段逻辑之后都习惯拿给 AI 看看,有什么更好的实现方式,或者用什么设计模式。AI 是非常低成本且高效提升代码质量的工具。


学会封装


一个功能用到了好多次,为什么不封装成组件?一个组件用到了好几个项目,为什么不单独写个npm包?差不多的项目创建了好几个,为什么不封装成脚手架?


你说,没时间,没必要,复制粘贴反而更快。


那你就完全没理解,这么做不一定是为了让工作更快完成,而是可以让你在年年终述职时更有话说(你就算写了一百个表单表格没有写一个脚手架更值得炫耀),如果不会写可以问问 AI。


而当你真正开始封装组件,开始写工具库了,你会发现你需要思考的确实比之前多了。


关注业务


对于前端业务重要吗?


相比于后端来说,前端一般不会太关注业务。就算出了问题大部分也是后端的问题。


但是就我找工作的经验,业务非常重要!


如果你做的工作很有技术含量,比如你在做低代码,你可以面试时讲一个小时的技术难点。但是你只是一个破写后台管理,你什么都没有的说。这个时候,了解业务就成为了你的亮点。


一场面试


还是拿真实的面试场景举例,当时前同事推我字节,也是我面试过N次的梦中情厂了,刚好那个组做的业务和我之前呆的组做的一模一样。



  • 同事:“做的东西和咱们之前都是一样的,你随便走个过场就能过,我在前端组长面前都夸过你了!”

  • 我:“好嘞!”


等到面试的时候:



  • 前端ld:“你知道xxx吗?(业务名词)”

  • 我:“我……”

  • 前端ld:“那xxxx呢?(业务名词)”

  • 我:“不……”

  • 前端ld:“那xxxxx呢??(业务名词)”

  • 我:“造……”


然后我就挂了………………


如何了解业务



  1. 每次接需求的时候,都要了解需求背景,并主动去理解


    我们写一个表格简简单单,把数据展示出来就好,但是表格中的数据是什么意思呢?比如我之前写一个 kafka 管理平台,里面有表格表单,涉及什么 cluster controller topic broker partition…… 我真的完全不了解,很后悔我几年时间也没有耐下心来去了解。


  2. 每次做完一个需求,都需要了解结果


    有些时候,后台管理的团队可能根本没有PM,那你也要和业务方了解,这个功能做了之后,多少人使用,效率提高了吗?数据是怎样的?


  3. 理解需求,并主动去优化


    产品要展示一千条数据,你要考虑要不要分页,不分页会不会卡,要不要上虚拟表格?


    产品要做一个可拖拽表单,你要考虑是否需要拖动,是否需要配置。


    其实很多时候,产品的思维可能会被局限在竞品的实现方式,而前端可以给TA更多选项。在和产品沟通的时候,你不仅是沟通页面的实现,也更能理解业务。



关注源码


说到源码, Vue,React 这些基本是每次必问,也有太多大佬们的总结我就不多说了。


除此之外,我想大家每次面试应该都会被问,你写了什么亮点的项目,困难的项目,你会说什么?哦,都是表单表格,都用 Element UI 实现的,没什么难度。那估计你面试也就到这里了。


那说什么,后台管理就这些啊?!


如果你用了 Element UI 你可以说写了什么自定义 Element UI 表单组件,Element UI 表单源码是什么原理,怎么通信的?用了 Axios 可以说 Axios 的原理,它的拦截器怎么实现的?用了编辑器 diff 那你知道 diff 算法是什么吗?用了 ECharts 那你知道他底层是用什么绘制的吗?用了 husky ?husky的原理是什么?用了Vite,它为什么快?是怎么打包的?写过插件吗?


可能有人觉得我离谱,事实上上面每一个问题都是我在面试中真实被问到的。当然,如果全部去了解,肯定不现实,每个人的精力有限,不可能每个用到的每个工具每个库都去了解原理,不过只要有一两个你去了解过,就可以在面试中引导面试官来问,要学会表现出你擅长的点


至于怎么读源码?以前我不知道,现在嘛…… 为什么不问问神奇的 ChatGPT 呢?



讲一下 Axios 源码中,拦截器是怎么实现的?


Axios 的拦截器实现基于 Axios 的核心原理,即 Axios 实例是一个包含请求和响应拦截器堆栈的对象。当发出请求或接收响应时,Axios 会遍历这些拦截器,并按照添加的顺序执行请求拦截器,以及按照相反的顺序执行响应拦截器。


在 Axios 的源码中,拦截器是通过一个 AxiosInterceptorManager 实例来管理的,它维护了一个拦截器数组。每个拦截器都是一个包含 fulfilledrejected 函数的对象。这两个函数分别对应于拦截器成功处理和拦截器处理出错的情况。


以下是 Axios 拦截器管理器的一个简化版本,展示了其核心实现思路:


class InterceptorManager {
constructor() {
this.handlers = []; // 存储拦截器的数组
}

use(fulfilled, rejected) {
this.handlers.push({
fulfilled: fulfilled,
rejected: rejected
});
return this.handlers.length - 1; // 返回拦截器的ID
}

eject(id) {
if (this.handlers[id]) {
this.handlers[id] = null; // 移除拦截器
}
}

forEach(fn) {
this.handlers.forEach((h) => {
if (h !== null) {
fn(h);
}
});
}
}

在发送请求或接收响应时,Axios 会创建一个 promise 链,并通过 forEach 方法将拦截器中的 fulfilledrejected 函数添加到这个链中。这样,每个拦截器都可以对请求或响应进行处理,然后将结果传递到链的下一个拦截器,或者在出错时结束链的执行。


axios.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
chain.unshift(interceptor.fulfilled, interceptor.rejected);
});

axios.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
chain.push(interceptor.fulfilled, interceptor.rejected);
});

在 Axios 的完整实现中,这个拦截器机制被集成到了 Axios 的请求发送和响应处理流程中。通过这种方式,Axios 可以在发送请求之前和接收响应之后,但在用户定义的 .then.catch 执行之前,插入自定义的逻辑。


请注意,这里提供的代码只是为了说明 Axios 拦截器的实现原理,并不是 Axios 源码的完整复制。如果你对 Axios 的拦截器实现细节感兴趣,建议查看 Axios 的官方 GitHub 仓库中的源码。



前端基建


当我们工作时间久了面试难免会遇到这些问题,前端工程化,前端监控,工作流,部署,性能等等。其实我们在工作中绝大部分时间都在写代码,对于这些不是所有人都有机会接触到,不过这些和所做的业务无关,是我们提升自己很好的一个思路。


技术选型


技术栈选 Vue 还是 React?Vue 选 Vue2 还是 Vue3?组件库选 ElementUI 还是 Ant Design?微前端有没有使用过?打包工具用 Vite 还是 Webpack?有那么多表单怎么实现的,有没有什么表单配置化方案,比如Formily?


对于我这种菜鸡,我这种只写简单的表单表格的人,这些都……无所谓……


image.png

不过为了应对面试我们还是需要了解下未选择技术栈的缺点,和已选择技术栈的优点(有点本末倒置…但是常规操作啦)


Vue 你可以说简单高效轻量级,面试必会问你为什么,你就开始说 Vue 的响应式系统,依赖收集等。


React 你可以说 JSX、Hooks 很灵活,那你必然要考虑 JSX 怎么编译, Hooks 实现方式等。


总体而言,对于技术选型,依赖于我们对所有可选项的理解,做选择可能很容易,给出合理的理由还是需要花费一些精力的。


开发规范


这个方面,在面试的时候我被问到的不多,我们可以在创建项目的时候,配置下 ESlintstylelintprettiercommitlint 等。


前端监控


干了这么多年前端,前端监控我是……一点没做过。


image.png

前端监控,简单来说就是我们在前端程序中记录一些信息并上报,一般是错误信息,来方便我们及时发现问题并解决问题。除此之外也会有性能监控,用户行为的监控(埋点)等。之前也听过有些团队分享前端监控,为了出现问题明确责任(方便甩锅)。


对于实现方案,无论使用第三方库还是自己实现,重要的都是理解实现原理。


对于错误监控,可以了解一下 Sentry,原理简单来说就是通过 window.onerrorwindow.addEventListener('unhandledrejection', ...) 去分别捕获同步和异步错误,然后通过错误信息和 sourceMap 来定位到源码。


对于性能监控,我们可以通过 window.performancePerformanceObserver 等 API 收集页面性能相关的指标,除此之外,还需要关注接口的响应时间。


最后,收集到信息之后,还要考虑数据上报的方案,比如使用 navigator.sendBeacon 还是 Fetch、AJAX?是批量上报,实时上报,还是延迟上报?上报的数据格式等等。


CI/CD


持续集成(Continuous Integration, CI)和 持续部署(Continuous Deployment, CD),主要包括版本控制,代码合并,构建,单测,部署等一系列前端工作流。


场景的工作流有 Jenkins、 Gitlab CI 等。我们可以配置在合并代码时自动打包部署,在提交代码时自动构建并发布包等。


这块我了解不多,但感觉这些工具层面的东西,不太会涉及到原理,基本上就是使用的问题。还是需要自己亲自动手试一下,才能知道细节。比如在 Gitlab CI 中, Pipeline 、 Stage 和 Job 分别是什么,怎么配置,如何在不同环境配置不同工作流等。


了解技术动态


这个可能还是比较依赖信息收集能力,虽然我个人觉得很烦,但好像很多领导级别的面试很愿意问。


比如近几年很火的低代码,很多面试官都会问,你用过就问你细节,你没用过也会问你有什么设计思路。


还有最近的两年爆火的 AI,又或者 Vue React的最新功能,WebAssembly,还有一些新的打包工具 Vite Bun 什么的,还有鸿蒙开发……


虽然不可能学完每一项新技术,但是可以多去了解下。


总结


写了这么多,可能有人会问,如果能回到过去,你会怎么做。


啊,我只能说,说是一回事,做又是另一回事,事实上我并不希望回到过去去卷一遍,菜点没关系,快乐就好,一切都是最好的安排。


image.png

作者:我不吃饼干
来源:juejin.cn/post/7360528073631318027
收起阅读 »

搭建一个快速开发油猴脚本的前端工程

web
一、需求起因最近遇到一个问题:公司自用的 bug 管理工具太老了,网页风格还是上世纪的文字页面。虽然看习惯了还好,但是某些功能确实很不方便。比如,联系人都是邮箱或者英文名,没有中文名称,在流转 bug 时还得复制粘贴英文名去企业微信里搜索对应的人名。第二是人员...
继续阅读 »

一、需求起因

最近遇到一个问题:公司自用的 bug 管理工具太老了,网页风格还是上世纪的文字页面。虽然看习惯了还好,但是某些功能确实很不方便。比如,联系人都是邮箱或者英文名,没有中文名称,在流转 bug 时还得复制粘贴英文名去企业微信里搜索对应的人名。第二是人员比较多,在一堆邮箱里很难找到对应的人......

总之,诸如此类的问题让我有了对该网页进行改造的想法。

但是这种网页都是公司创业时期拿的开源产品私有化部署,网页源码能不能找到都不好说。再者,公司也不会允许此类的“小聪明”,这并不是我的主职工作,所以修改源码是非常不现实的。

那目前的思路,就是在原网页基础上进行脚本注入,修改网页内容和样式。方案无非就是浏览器插件或者脚本注入两种。

脚本的话就是利用油猴插件的能力,写一个油猴脚本,在网页加载完成后注入我们书写的脚本,达到修改原网页的效果。

插件也是类似的原理,但是写插件要麻烦得多。

出于效率考虑,我选择了脚本的方案。这里其实也是想巩固下 js 的 DOM API,框架写多了,很多原生的 API 反而忘得一干二净。

二、关于油猴脚本

先看一份 demo

// ==UserScript==
// @name script
// @namespace http://tampermonkey.net/
// @version 0.0.1
// @description 这是一段油猴脚本
// @author xxx
// @match *://baidu.com/*
// @run-at document-end
// @license MIT
// ==/UserScript==

(function () {
"use strict";
const script = document.createElement("script");
document.body.appendChild(script);
})();

油猴脚本由注释及 js 代码组成。注释需要包裹在

// ==UserScript==

// ==/UserScript==

两个闭合标签内。同时只能书写类似 @name 规定好的注释头,用于标明脚本的一些元信息。其中比较重要的是 @match 和 @run-at

@match 规定了该脚本所运行的域名,例如,只有当我打开了百度的网页时我才运行脚本,这个 @match 可以书写多个。@run-at 则规定了脚本的运行时机,一般是网页加载开始,网页加载结束。@run-at 只声明一次。

@run-at 有以下可选值:

image.png

图片看得不清晰也没关系,这种都是用到再查。

更多注释配置请参考:油猴脚本

而代码部分是一个立即执行函数,所有的内容都需要写在这个立即执行函数内,否则无法生效。

三、问题显现

刚开始,我并没有工程化开发的想法,我想的是就是一个脚本,直接一梭子写到底即可,反正就是那样,就是个普通的 js 文件,一切都是那么原始,朴实无华。

但是当代码来到两千多行后(我是真的很爱加东西),绷不住了,每次写代码都需要在文件上下之间反复横跳,有时候有些变量定义了都不记得,写代码还得滚动半天才能到最底下。

加东西也变得越来越臃肿,越来越丑陋。

忍无可忍,我决定对这个脚本进行工程化改造。但是工程化之前有几个问题需要解决,或者说需要调研清楚。

四、关键点分析

1.构建工具

首先肯定是打包成 iife 的产物,很多工具都支持。既然工程化了,一般大家的选择就是 webpack 或者 vite。这里因为涉及到开发模式,需要及时产出打包产物,且能够搭建 dev 服务器,方便访问本地打包后的资源,因此需要选择具备 dev 服务器的开发构建工具。

我选择 vite。当然,webpack 也是不错的选择。

如果你对实时预览要求不高,能够接受复制粘贴到油猴再刷新页面预览,也可以选择纯粹的打包器,例如 rollup

2.css 预编译器

传统的添加样式的方式,一般就是生成一个 style 标签,然后修改其 innerHTML

export const addStyle = (css: string) => {
const style = document.createElement('style');
style.type = 'text/css';
style.innerHTML = css;
document.getElementsByTagName('head')[0].appendChild(style);
}

addStyle(`
body {
width: 100%;
height: 100%;
}
`
);

这样就能实现往网页里添加自定义的样式。但是我现在不满足于书写传统的 css,我既然都工程化了,肯定要把 less 或者 scss 用上。

我的目的,就是可以新建一个例如 style.less 的文件开心地书写 less,打包时候编译一下这个 less 文件,并将其样式注入到目标 HTML 中。

但在传统模块化工程里,构建工具对 less 的支持,是直接在 HTML 中生成一个 style 标签,引入编译后的 less 产物(css)。

也就是说,我需要手动实现 less 到 css 到 js 这个过程。

转变的步骤就是用 less 本身的编译能力,将其产物转变为一个 js 模块。

具体实现放到后面再聊。

3.实现类似热更新的效果

我们启动一个传统的 vite 工程时,我们更新了某个 js 文件或者相关文件后,工程会监听我们的文件被修改了,从而触发热更新,服务也会自动刷新,从而达到实时预览的效果。

这是因为工程会在本地启动一个开发服务器,最终产物也会实时构建,那网页每次去获取这个服务器上的资源,就会获取到最新的代码。根据这点,我们同样需要启动一个本地服务器,而这在 vite 中直接一个 vite 命令即可。

在油猴脚本中,我们新建一个 script 标签,将其 src 指向我们本地服务器的构建产物的地址,即可实现实时的脚本更新,而不用复制产物代码再粘贴到油猴。

代码如下:

// ==UserScript==
// @name script
// @namespace http://tampermonkey.net/
// @version 0.0.1
// @description 这是描述
// @author xxx
// @match *://baidu.com/*
// @run-at document-end
// @license MIT
// ==/UserScript==

(function () {
"use strict";
const script = document.createElement("script");
script.src = "http://localhost:6419/dist/script.iife.js";
document.body.appendChild(script);
})();

这里的 localhost:6419/dist/script.iife.js 都取决于你 vite.config.js 中的配置。

具体后面再聊。

五、开始搭建工程

1.使用 yarn create vite 或者 pnpm create vite 初始化一个 vite 模板工程

image.png

image.png

image.png

其他的你自己看着选就可以。

2.修改 vite.config.js

/**
* @type {import('vite').UserConfig}
*/

module.exports = {
server: {
host: 'localhost',
port: 6419,
},
build: {
minify: false,
outDir: 'dist',
lib: {
entry: 'src/main.ts',
name: 'script',
fileName: 'script',
formats: ['iife'],
},
},
resolve: {
alias: {
'@': '/src',
'@utils': '/src/utils',
'@enum': '/src/enum',
'@const': '/src/const',
'@style': '/src/style',
}
}
}

这里使用 cjs 是因为我们会实现一些脚本,脚本里可能会用到这里的某些配置,所以使用 cjs 导出也有利于外部的使用。

3.创建一个 tampermonkey.config 文件,将油猴注释放在这里

// ==UserScript==
// @name script
// @namespace http://tampermonkey.net/
// @version 0.0.1
// @description 这是描述
// @author xxx
// @match *://baidu.com/*
// @run-at document-end
// @license MIT
// ==/UserScript==

当然,你要觉得这样多余、没必要,也可以看自己喜好,只要最终产物里有这个注释即可。但是拆出来有利于我们维护,后续也会新增脚本,有利于工程化的整体性和可维护性。

4.使用 nodemon 监听文件修改

因为我们自己对 less 有特殊处理,加上未来可能会对需要监听的文件进行精细化管理,所以这里引入 nodemon,如果你自己对工程化有自己的理解,也可以按照自己的理解配置。

执行 pnpm i nodemon -D

根目录新增 nodemon.json

{
"ext": "ts,less",
"watch": ["src"],
"exec": "pnpm dev:build && vite"
}

这里的 pnpm dev:build 还另有玄机,后面再展开。

到这里,我们的工程雏形已经具备了。但是还有一个最关键的点没有解决——那就是 less 的转换。

六、less 的转换以及几个脚本

首先,less 代码需要编译为 css,但是我们需要的是 css 的字符串,这样才能通过 innerHTML 之类的方法注入到网页中。

使用 less.render 方法可以对 less 代码进行编译,其是一个 Promise,我们可以在 then 中接收编译后的产物。

我们可以直接在根目录新建一个 script 文件夹,在 script 文件夹下新建一个 gen-style-string.js 的脚本:

const less = require('less');
const fs = require('fs');
const path = require('path');

const styleContent = fs.readFileSync(path.resolve(__dirname, '../src/style.less'), 'utf-8');

less.render(styleContent).then(output => {
if(output.css) {
const code = `export default \`\n${output.css}\``;

const relativePath = '../style/index.ts';
const filePath = path.resolve(__dirname, relativePath)

if(fs.existsSync(filePath)) {
fs.rm(filePath, () => {
fs.writeFileSync(path.resolve(__dirname, relativePath), code)
})
} else {
fs.writeFileSync(path.resolve(__dirname, relativePath), code)
}
}
})

我们将编译后的 css 代码结合 js 代码导出为一个模块,供外部使用。也就是说,这部分编译必须在打包之前执行,这样才能得到正常的 js 模块,否则就会报错。

这段脚本执行完后会在 style/index.ts 中生成类似代码:

export default `
body {
width: 100%;
height: 100%;
}
`

这样 less 代码就能够被外部引入并使用了。

这里多说一句,因为 style/index.ts 的内容是根据 less 编译来的,而我们的 nodemon 会监听 src 目录,因此这个 less 编译后的 js 产物,不能放在 src 下,因为假设将它放在 src 目录下,它在写入的过程中也会触发 nodemon,会导致 nodemon 进入死循环。

除此之外,我们之前还将油猴注释拎出来单独放在一个文件里:tampermonkey.config

在最终产物中,我们需要将其合并进去,思路同上:

const fs = require('fs');
const path = require('path');
const prettier = require('prettier');

const codeFilePath = '../dist/script.iife.js';
const configFilePath = '../tampermonkey.config';
const codeContent = fs.readFileSync(path.resolve(__dirname, codeFilePath), 'utf-8');
const tampermonkeyConfig = fs.readFileSync(path.resolve(__dirname, configFilePath), 'utf-8');

if (codeContent) {
const code = `${tampermonkeyConfig}\n${codeContent}`;
prettier.format(code, { parser: 'babel' }).then((formatted) => {
fs.writeFileSync(path.resolve(__dirname, codeFilePath), formatted)
})
}

最后,因为我们的 tampermonkey.config 以及 vite.config.js 可能会更改配置,所以每次我们在开发模式时生成的临时油猴脚本,也需要变,我们不可能每次都去修改,而是应该跟随上面两个配置文件进行生成,我们再新建一个脚本:

const fs = require('fs');
const path = require('path');
const prettier = require('prettier');
const viteConfig = require('../vite.config');

const codeFilePath = '../tampermonkey.js';
const tampermonkeyConfig = fs.readFileSync(path.resolve(__dirname, '../tampermonkey.config'), 'utf-8');
const hostPort = `${viteConfig.server.host}:${viteConfig.server.port}`;
const codeContent = `
(function () {
'use strict'

const script = document.createElement('script');

script.src = 'http://${hostPort}/dist/${viteConfig.build.lib.name}.iife.js';

document.body.appendChild(script);
})()
`
;

const code = `${tampermonkeyConfig}\n${codeContent}`;

prettier.format(code, { parser: 'babel' }).then((formatted) => {
if(fs.existsSync(path.resolve(__dirname, codeFilePath))) {
fs.rm(path.resolve(__dirname, codeFilePath), () => {
fs.writeFileSync(path.resolve(__dirname, codeFilePath), formatted);
});
}
else {
fs.writeFileSync(path.resolve(__dirname, codeFilePath), formatted);
}
})

稍微用 prettier 美化一下。

七、完善 package.json 中的 script

我们其实只有开发模式,新建一个命令:

"dev": "node script/gen-tampermonkey.js && nodemon"

优先生成 tampermonkey.js,这时候会启动服务器,记得先将 tampermonkey.js 中的内容拷贝到油猴,才能方便热更新,不然又需要复制粘贴。

对于 build 命令:

"dev:build": "node script/gen-style-string.js && tsc && vite build && node script/gen-script-header-comment.js"

需要先将 less 编译为可用的 js 字符串模块,然后才能执行 buildbuild 完还需要拼接油猴注释,这样最终产物才具备可用的能力。

开发完成后,就将打包产物替换掉之前粘贴进油猴的内容。

八、额外的补充

vite 命令会直接启动本地开发服务器,而我们的 script 命令中,使用 && 时,下一个命令会等待上一个命令执行完成后再执行,所以 vite 需要放在最后执行,这是串行逻辑。当然,借助一些库我们可以实现并行 script 命令。但是我们这里需要的是串行,只是不完美的是,每次文件变更,都需要重新执行 pnpm dev:build && vite,这样会重复新启一个服务器,但是不重启的话,始终使用最初的那个服务,最新编译的资源无法被油猴感知,资源没有得到更新。

所以,聪明的你有办法解决吗?


作者:北岛贰
来源:juejin.cn/post/7437887483259584522
收起阅读 »

作为一个前端你连requestAnimationFrame的用法、优势和应用场景都搞不清楚?

web
前言 如果你是一名前端开发,那么你多少有了解过requestAnimationFrame吧?如果没有也接着往下看,会有详细用法说明。 其实很多人会局限于把requestAnimationFrame应用于一些纯动画相关的需求上,但其实在前端很多业务场景下requ...
继续阅读 »

前言


如果你是一名前端开发,那么你多少有了解过requestAnimationFrame吧?如果没有也接着往下看,会有详细用法说明。


其实很多人会局限于把requestAnimationFrame应用于一些纯动画相关的需求上,但其实在前端很多业务场景下requestAnimationFrame都能用于性能优化,下面将细说一下requestAnimationFrame的具体用法和几种应用场景


requestAnimationFrame作用与用法


requestAnimationFrame简述


MDN官方说法是这样的
image.png


基本示例


<script lang="ts" setup>
function init() {
console.log('您好,我是requestAnimationFrame');
}
requestAnimationFrame(init)
</script>

效果如下
image.png


但是例子上面是最基本的调用方式,并且只简单执行了一次,而对于动画是要一直执行的。


下面直接上图看看官方的文档对这个的说明,上面说具体用法应该要递归调用,而不是单次调用。


image.png


递归调用示例


<script lang="ts" setup>
function init() {
console.log('您好,递归调用requestAnimationFrame');
requestAnimationFrame(init)
}
requestAnimationFrame(init)
</script>

执行动图效果如下


requestAnimationFrame会一直递归调用执行,并且调用的频率通常是与当前显示器的刷新率相匹配(这也是这个API核心优势),例如屏幕75hz1秒执行75次。


而且如果使用的是定时器实现此功能是无法适应各种屏幕帧率的。


动画.gif


回调函数


requestAnimationFrame执行后的回调函数有且只会返回一个参数,并且返回的参数是一个毫秒数,这个参数所表示是的上一帧渲染的结束时间,直接看看下面代码示例与打印效果。


<script lang="ts" setup>
function init(val) {
console.log('您好,requestAnimationFrame回调:', val);
requestAnimationFrame(init);
}
requestAnimationFrame(init);
</script>


image.png


注意: 如果我们同时调用了很多个requestAnimationFrame,那么他们会收到相同的时间戳,因为与屏幕的帧率相同所以并不会不一样。


终止执行


终止此API的执行,官方提供的方法是window.cancelAnimationFrame(),语法如下


ancelAnimationFrame(requestID)   

直接看示例更便于理解,用法非常类似定时器的clearTimeout(),直接把 requestAnimationFrame 返回值传给 cancelAnimationFrame() 即可终止执行。


<template>
<div>
<button @click="stop">停止</button>
</div>
</template>
<script lang="ts" setup>
let myReq;
function init(val) {
console.log('您好,requestAnimationFrame回调:', val);
myReq = requestAnimationFrame(init);
}
requestAnimationFrame(init);

function stop() {
cancelAnimationFrame(myReq);
}
</script>


动画.gif


requestAnimationFrame优势


1、动画更丝滑,不会出现卡顿


对比传统的setTimeoutsetInterval动画会更流畅丝滑。


主要 原因 是由于运行的浏览器会监听显示器返回的VSync信号确保同步,收到信号后再开始新的渲染周期,因此做到了与浏览器绘制频率绝对一致。所以帧率会相当平稳,例如显示屏60hz,那么会固定1000/60ms刷新一次。


但如果使用的是setTimeoutsetInterval来实现同样的动画效果,它们会受到事件队列宏任务、微任务影响会导致执行的优先级顺序有所差异,自然做不到与绘制同频。


所以使用setTimeoutsetInterval不但无法自动匹配显示屏帧率,也无法做到完全固定的时间去刷新。


2、性能更好,切后台会暂停


当我们把使用了requestAnimationFrame的页面切换到后台运行时,requestAnimationFrame会暂停执行从而提高性能,切换回来后会马上提着执行。


效果如下动图,隐藏后停止运行,切换回来接着运行。


动画.gif


应用场景:常规动画


用一个很简单的示例:用requestAnimationFrame使一张图片动态也丝滑旋转,直接看示例代码和效果。


思路:首先在页面初始化时执行window.requestAnimationFrame(animate)使动画动起来,实现动画一直丝滑转运。在关闭页面时用window.cancelAnimationFrame(rafId)去终止执行。


<template>
<div class="container">
<div :style="imgStyle" class="earth"></div>
</div>
</template>

<script setup>
import { ref, onMounted, reactive, onUnmounted } from 'vue';

const imgStyle = reactive({
transform: 'rotate(0deg)',
});

let rafId = null;

// 请求动画帧方法
function animate(time) {
const angle = (time % 10000) / 5; // 控制转的速度
imgStyle.transform = `rotate(${angle}deg)`;

rafId = window.requestAnimationFrame(animate);
}

// 开始动画
onMounted(() => {
rafId = window.requestAnimationFrame(animate);
});

// 卸载时生命周末停止动画
onUnmounted(() => {
if (rafId) {
window.cancelAnimationFrame(rafId);
}
});
</script>

<style scoped>
body {
box-sizing: border-box;
background-color: #ccc;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}

.container {
position: relative;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

.earth {
height: 100px;
width: 100px;
background-size: cover;
border-radius: 50%;
background-image: url('@/assets/images/about_advantage_3.png'); /* 替换为实际的路径 */
}
</style>


看看动图效果
动画2.gif


应用场景:滚动加载


在滚动事件中用requestAnimationFrame去加载渲染数据使混动效果更加丝滑。主要好久有几个



  • 提高性能: 添加requestAnimationFrame之后会在下一帧渲染之前执行,而不是每次在滚动事件触发的时候就立即执行。这可以减少大量不必要的计算,提高性能。

  • 用户体验更好:确保在绘制下一帧时再执行,使帧率与显示屏相同,视觉上会更丝滑。


代码示例和效果如下。


<template>
<div class="container" ref="scrollRef">
<div v-for="(item, index) in items" :key="index" class="item">
{{ item }}
</div>
<div v-if="loading" class="loading">数据加载中...</div>
</div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';

const loading = ref(false);
let rafId: number | null = null;
// 数据列表
const items = ref<string[]>(Array.from({ length: 50 }, (_, i) => `Test ${i + 1}`));

// 滚动容器
const scrollRef = ref<HTMLElement | null>(null);

// 模拟一个异步加载数据效果
const moreData = () => {
return new Promise<void>((resolve) => {
setTimeout(() => {
const newItems = Array.from({ length: 50 }, (_, i) => `Test ${items.value.length + i + 1}`);
items.value.push(...newItems);
resolve();
}, 1000);
});
};

// 检查是否需要加载更多数据
const checkScrollPosition = () => {
if (loading.value) return;

const container = scrollRef.value;
if (!container) return;

const scrollTop = container.scrollTop;
const clientHeight = container.clientHeight;
const scrollHeight = container.scrollHeight;

if (scrollHeight - scrollTop - clientHeight <= 100) {
startLoading();
}
};

// 加载数据
const startLoading = async () => {
loading.value = true;
await moreData();
loading.value = false;
};

// 监听滚动事件
const handleScroll = () => {
console.log('滚动事件触发啦');
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
rafId = window.requestAnimationFrame(checkScrollPosition);
};

// 添加滚动事件监听器
onMounted(() => {
if (scrollRef.value) {
scrollRef.value.addEventListener('scroll', handleScroll);
}
});

// 移除相关事件
onUnmounted(() => {
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
if (scrollRef.value) {
scrollRef.value.removeEventListener('scroll', handleScroll);
}
});
</script>

<style scoped>
.container {
padding: 20px;
max-width: 800px;
overflow-y: auto;
margin: 0 auto;
height: 600px;
}

.item {
border-bottom: 1px solid #ccc;
padding: 10px;
}

.loading {
padding: 10px;
color: #999;
text-align: center;
}
</style>

看看下面动图效果
动画3.gif


小结


通过代码示例配合动图讲解后,再通过两个简单的事例可能大家会发现,只要在页面需要运动的地方其实都可以用到 requestAnimationFrame 使效果变的更加丝滑。


除了上面两个小示例其它非常多地方都可以用到requestAnimationFrame去优化性能,比较常见的例如游戏开发、各种动画效果和动态变化的布局等等。


文章就写到这啦,如果文章写的哪里不对或者有什么建议欢迎指出。


作者:天天鸭
来源:juejin.cn/post/7431004279819288613
收起阅读 »

前端:为什么 try catch 能捕捉 await 后 Promise 的错误?

web
一次代码CR引发的困惑 “你这块的代码,没有做异常捕获呀,要是抛出了异常,可能会影响后续的代码流程”。这是一段出自组内代码CR群的聊天记录。代码类似如下: const asyncErrorThrow = () => { return new Prom...
继续阅读 »

一次代码CR引发的困惑


“你这块的代码,没有做异常捕获呀,要是抛出了异常,可能会影响后续的代码流程”。这是一段出自组内代码CR群的聊天记录。代码类似如下:
const asyncErrorThrow = () => {
return new Promise((resolve, reject) => {
// 业务代码...
// 假设这里抛出了错误
throw new Error('抛出错误');
// 业务代码...
})
}
const testFun = async () => {
await asyncErrorThrow();
console.log("async 函数中的后续流程"); // 不会执行
}
testFun();

testFun 函数中,抛出错误后,await 函数中后续流程不会执行。


仔细回想一下,在我的前端日常开发中,对于错误捕获,还基本停留在使用 Promise时用 catch 捕获一下 Promise 中抛出的错误或者 reject,或者最基本的,在使用 JSON.parseJSON.stringfy等容易出错的方法中,使用 try..catch... 方法捕获一下可能出现的错误。


后来,这个同学将代码改成了:


const asyncErrorThrow = () => {
return new Promise((resolve, reject) => {
// 业务代码...
throw new Error('抛出错误');
// 业务代码...
})
}
const testFun = async () => {
try {
await asyncErrorThrow();
console.log("async 函数中的后续流程"); // 不会执行
} catch (error) {
console.log("若错误发生 async 函数中的后续流程"); // 会执行
}
}
testFun();

而这次不同的是,这段修改后的代码中使用了 try...catch...来捕获 async...await... 函数中的错误,这着实让我有些困惑,让我来写的话,我可能会在 await 函数的后面增加一个 catch:await asyncErrorThrow().catch(error => {})。因为我之前已经对 try..catch 只能捕获发生在当前执行上下文的错误(或者简单理解成同步代码的错误)有了一定的认知,但是 async...await... 其实还是异步的代码,只不过用的是同步的写法,为啥用在这里就可以捕获到错误了呢?在查阅了相当多的资料之后,才清楚了其中的一些原理。


Promise 中的错误


我们都知道,一个 Promise 必然处于以下几种状态之一:

  • 待定(pending):初始状态,既没有被兑现,也没有被拒绝。

  • 已兑现(fulfilled):意味着操作成功完成。

  • 已拒绝(rejected):意味着操作失败。


当一个 Promise 被 reject 时,该 Promise 会变为 rejected 状态,控制权将移交至最近的 rejection 处理程序。最常见的 rejection 处理程序就是 catch handler或者 then 函数的第二个回调函数。而如果在 Promise 中抛出了一个错误。这个 Promise 会直接变成 rejected 状态,控制权移交至最近的 error 处理程序。


const function myExecutorFunc = () => {
// 同步代码
throw new Error();
};
new Promise(myExecutorFunc);

Promise 的构造函数需要传入的 Executor 函数参数,实际上是一段同步代码。在我们 new 一个新的 Promise 时,这个 Executor 就会立即被塞入到当前的执行上下文栈中进行执行。但是,在 Executor 中 throw 出的错误,并不会被外层的 try...catch 捕获到。


const myExecutorFunc = () => {
// 同步代码
throw new Error();
};
try {
new Promise(myExecutorFunc);
} catch (error) {
console.log('不会执行: ', error);
}
console.log('会执行的'); // 打印

其原因是因为,在 Executor 函数执行的过程中,实际上有一个隐藏的机制,当同步抛出错误时,相当于执行了 reject 回调,让该 Promise 进入 rejected 状态。而错误不会影响到外层的代码执行。


const myExecutorFunc = () => {
throw new Error();
// 等同于
reject(new Error());
};
new Promise(myExecutorFunc);
console.log('会执行的'); // 打印

同理 then 回调函数也是这样的,抛出的错误同样会变成 reject。


在一个普通脚本执行中,我们知道抛出一个错误,如果没有被捕获掉,会影响到后续代码的执行,而在 Promise 中,这个错误不会影响到外部代码的执行。对于 Promise 没有被捕获的错误,我们可以通过特定的事件处理函数来观察到。


new Promise(function() {
throw new Error("");
}); // 没有用来处理 error 的 catch
// Web 标准实现
window.addEventListener('unhandledrejection', function(event) {
console.log(event);
// 可以在这里采取其他措施,如日志记录或应用程序关闭
});
// Node 下的实现
process.on('unhandledRejection', (event) => {
console.log(event);
// 可以在这里采取其他措施,如日志记录或应用程序关闭
});


Promise 是这样实现的,我们可以想一想为什么要这样实现。我看到一个比较好的回答是这个:


传送门。我也比较赞成他的说法,我觉得,Promise 的诞生是为了解决异步函数过多而形成的回调地狱,使用了微任务的底层机制来实现异步链式调用。理论上是可以将同步的错误向上冒泡抛出然后用 try...catch... 接住的,异步的一些错误用 catch handler 统一处理,但是这样做的话会使得 Promise 的错误捕获使用起来不够直观,如果同步的错误也进行 reject 的话,实际上我们处理错误的方式就可以统一成 Promise catch handler 了,这样其实更直观也更容易让开发者理解和编写代码。


async await 的问题


那么回到我们最开始的问题,在这个里面,为什么 try catch 能够捕获到错误?
const asyncErrorThrow = () => {
return new Promise((resolve, reject) => {
// 业务代码...
throw new Error('抛出错误');
// 业务代码...
})
}
const testFun = async () => {
try {
await asyncErrorThrow();
console.log("async 函数中的后续流程"); // 不会执行
} catch (error) {
console.log("若错误发生 async 函数中的后续流程"); // 会执行
}
}
testFun();

我思考了很久,最后还是从黄玄大佬的知乎回答中窥见的一部分原理。



这...难道就是浏览器底层帮我们处理的事儿吗,不然也没法解释了。唯一能够解释的事就是,async await 原本就是为了让开发者使用同步的写法编写异步代码,目的是消除过多的 Promise 调用链,我们在使用 async await 时,最好就是不使用 .catch 来捕获错误了,而直接能使用同步的 try...catch... 语法来捕获错误。即使 .catch 也能做同样的事情。只是说,代码编写风格统一性的问题让我们原本能之间用同步语法捕获的错误,就不需要使用 .catch 链式调用了,否则代码风格看起来会有点“异类”。


这就是为什么 async MDN 中会有这样一句解释:





参考文档:


《使用Promise进行错误治理》- zh.javascript.info/promise-err…


《为什么try catch能捕捉 await 后 promise 错误? 和执行栈有关系吗?》http://www.zhihu.com/question/52…


作者:21Pilots
来源:juejin.cn/post/7436370478521991183
收起阅读 »

Compose Desktop 写一个 Android 提效工具

前言 在日常的工作中,很多工作和操作其实都是重复的,这个时候,就会想,能不能通过工具进行一键操作。 由于本人是Android开发,寻找解决方案时发现了compose-multiplatform,于是就写个工具玩一玩。 软件介绍 AdbDevTools 是支持w...
继续阅读 »

前言


在日常的工作中,很多工作和操作其实都是重复的,这个时候,就会想,能不能通过工具进行一键操作。


由于本人是Android开发,寻找解决方案时发现了compose-multiplatform,于是就写个工具玩一玩。


软件介绍


AdbDevTools 是支持windows和mac的,并且支持浅色模式和暗黑模式,下面的截图都是在暗黑模式下。



  • 目的:都是为了减少重复性工作,节省开发者时间。

  • 简化Hprof文件管理:轻松一键导出、管理和分析Hprof文件,全面支持LeakCanary数据处理。

  • 内存泄漏分析:对 Hprof 文件进行内存泄漏分析,快速定位问题根源。

  • 位图资源管理:提供位图预览、分析和导出功能。

  • Deep Link快速调用:管理和测试Deep Link,提高开发和调试速度。

  • 开发者选项快捷操作:包含多项开发者选项的快捷操作。


功能介绍


内存快照文件管理和分析


常规操作:



  • 打开AS Memory Profiler,dump 出内存快照文件,等待内存快照文件生成,查看泄露的 Activity 或者 Fragment。

  • Android 8以下还可以有个 BitmapPreview 预览 Bitmap,但是每次只能预览一个 Bitmap。

  • 如果重新打开 AS,刚刚生成的 hprof 文件在哪里??

  • 所以如果想保存刚刚生成的 hprof 文件,就得在生成文件后,手动点击把文件保存一下到电脑上。

  • 如果想找到 LeakCanary 生成的文件,得找到对应的文件目录,然后再用 adb pull 一下到电脑上。。


懒人操作:



  • 一键 dump 出内存快照,自动化分析,生成一份报告。

  • Android 8以下的快照文件,可以一键导出所有 Bitmap 实例,方便预览。

  • 通过工具,管理最近打开的 hprof 文件

  • 一键导出 LeakCanary 生成的文件,无需手动操作。


image.png


开发者选项快捷操作


在日常的开发工作中,可能要经常打开开发者选项页面,打开某一个开关。


常规操作:打开设置页面,找到开发者选项,点击进入开发者页面,上下滑动,找到某一个开关,进行调整。这一系列的操作,有点繁琐。


懒人操作:在PC软件内,一键操作,直接打开开关。一步到位,不需要在手机里找来找去和点点点。


image.png


开发


代码架构设计


github.com/theapache64…,基于这个库,可以使用 Android 的开发方式,去开发一个桌面软件。


简单的这样理解。


对于单个桌面应用,其实就是类似 Android 的 Application。


对于应用内的窗口,其实就是类似 Android 的 Activity。


对于窗口内的各种子页面,其实就是类似 Android 的 Fragment,这边当成一个个的 Component 实现。


Application



  • 基类 Application。提供一个 startActivity 方法,用于打开某个页面。

  • 自定义 MyApplication,继承 Application,在 onCreate 方法里面,执行一些应用初始化操作。

  • 比如 onCreate 的时候,启动 MainActivity。

  • main() 方法,调用 MyApplication 的 onCreate 方法即可。


open class Application  {

protected fun startActivity(intent: Intent) {
val activity = intent.to.java.newInstance()
activity.intent = intent
activity.onCreate()
}

open fun onCreate() {

}
}

class MyApplication(args: AppArgs) : Application() {

override fun onCreate() {
super.onCreate()

Arbor.d("onCreate")

val splashIntent = MainActivity.getStartIntent()
startActivity(splashIntent)
}
}

fun main() {
MyApplication(appArgs).onCreate()
}

Activity



  • 自定义 MainActivity,在 onCreate 方法里面,创建和展示 Window 。


class MainActivity : Activity() {

companion object {
fun getStartIntent(): Intent {
return Intent(MainActivity::class).apply {
// putExtra
}
}
}

@OptIn(ExperimentalComposeUiApi::class)
override fun onCreate() {
super.onCreate()

val lifecycle = LifecycleRegistry()
val root = NavHostComponent(DefaultComponentContext(lifecycle))

application {

val intUiThemes by mainActivityViewModel.intUiThemes.collectAsState()
val themeDefinition = if (intUiThemes.isDark()) {
JewelTheme.darkThemeDefinition()
} else {
JewelTheme.lightThemeDefinition()
}

IntUiTheme(
themeDefinition,
styling = ComponentStyling.decoratedWindow(
titleBarStyle = when (intUiThemes) {
IntUiThemes.Light -> TitleBarStyle.light()
IntUiThemes.LightWithLightHeader -> TitleBarStyle.lightWithLightHeader()
IntUiThemes.Dark -> TitleBarStyle.dark()
IntUiThemes.System -> if (intUiThemes.isDark()) {
TitleBarStyle.dark()
} else {
TitleBarStyle.light()
}
}
)
) {
DecoratedWindow(visible = mainWindowVisible,
onCloseRequest = {
::exitApplication
mainActivityViewModel.exitMainWindow()
}, state = rememberWindowState(),
title = "${MyApplication.appArgs.appName} (${MyApplication.appArgs.version})",
onPreviewKeyEvent = {
if (
it.key == Key.Escape &&
it.type == KeyEventType.KeyDown
) {
root.onBackClicked()
true
} else {
false
}
}
) {
TitleBarView(intUiThemes)
root.render()
}

}

}
}
}

Component


Component:组件,可以是一个窗口,也是可以是窗口中的某一个页面,都可以当成组件处理。


对应单个组件,每个组件封装对应的业务逻辑处理,驱动相应的UI进行显示。


对于业务逻辑的处理,可以采用 Store+Reducer 这种偏前端思想的方式,也可以采用 Android 现在比较流行的 MVI 进行处理。


状态管理容器,只需要提供一些可观察对象就行了,驱动View层进行重组,刷新UI。



组件树:应用中的多个窗口,窗口中的多个页面,可以分别拆分成多个组件,每个组件封装处理各自的逻辑,最后构成一棵组件树的结构。




比如这个应用,被我拆成若干个Componet,分别处理相应的业务逻辑。


@Singleton
@Component(
modules = [
PreferenceModule::class
]
)

interface AppComponent {

fun inject(splashScreenComponent: SplashScreenComponent)

fun inject(mainScreenComponent: MainScreenComponent)

fun inject(adbScreenComponent: AdbScreenComponent)

fun inject(analyzeScreenCompoment: AnalyzeScreenCompoment)

fun inject(updateScreenComponent: UpdateScreenComponent)

fun inject(importLeakCanaryComponent: ImportLeakCanaryComponent)
}

ViewModel



  • ViewModel 这个比较简单,只是一个普通的类,用于处理业务逻辑,并维护UI层所需的状态数据。

  • ViewModel 的创建和销毁,这个会利用到 DisposableEffect 这个东西。DisposableEffect 的主要作用是在组合函数的启动和销毁时执行一些清理工作,以确保资源正确释放。

  • 在组合函数启动的时候,创建 ViewModel,并进行初始化。

  • 在组合函数销毁的时候,销毁 ViewModel,释放 ViewModel 的资源,类似 Android 中 ViewModel 的 clear 方法。


class AnalyzeViewModel @Inject constructor(
val hprofRepo: HprofRepo
) {

private lateinit var viewModelScope: CoroutineScope

fun init(scope: CoroutineScope) {
this.viewModelScope = scope
}

fun analyze(
heapDumpFile: File, proguardMappingFile: File?
)
{
viewModelScope.launch(Dispatchers.IO) {
//耗时方法,分析文件
}
}


fun dispose() {
viewModelScope.cancel()
}
}

/**
* 分析内存数据
*/

class AnalyzeScreenCompoment(
appComponent: AppComponent,
private val componentContext: ComponentContext,
private val hprofFile: String,
private val onBackClicked: () -> Unit,
) : Component, ComponentContext by componentContext {


init {
appComponent.inject(this)
}

@Inject
lateinit var analyzeViewModel: AnalyzeViewModel

@Composable
override fun render() {
val scope = rememberCoroutineScope()

DisposableEffect(analyzeViewModel) {
//初始化ViewModel
analyzeViewModel.init(scope)
//调用ViewModel里面的方法
analyzeViewModel.analyze(heapDumpFile = File(hprofFile), proguardMappingFile = null)

onDispose {
//销毁ViewModel
analyzeViewModel.dispose()
}
}

//观察ViewModel,实现UI逻辑
analazeScreen(analyzeViewModel)

}
}

adb 功能开发


比如 dump 内存快照,安装adb,一部分开发者选项控制,本质上都是可以通过 adb 命令进行设置的。



  • Adb第三方库:malinskiy.github.io/adam/,这个库是 Kotlin 编写的。

  • 库代码主要是协程、Flow、Channel,使用起来挺方便的。

  • 一条 adb 命令就是一个 Request,内置了挺多现成的 Request 可以使用,也可以自定义 Request 编写一些复杂的命令。

  • 比如使用adb devices,列出当前的设备列表,只需要一行代码即可。


val devices: List<Device> = adb.execute(request = ListDevicesRequest())


  • 如果需要监听设备的连接状态变化,可以通过执行 AsyncDeviceMonitorRequest 即可,返回值是一个 Channel 。


val deviceEventsChannel: ReceiveChannel<List<Device>> = adb.execute(
request = AsyncDeviceMonitorRequest(),
scope = GlobalScope
)

for (currentDeviceList in deviceEventsChannel) {
//...
}


  • 安装 apk,执行 StreamingPackageInstallRequest,传入相应的参数即可。


    suspend fun installApk(file: String, serial: String): Boolean {
Arbor.d("installApk file:$file,serial:$serial")
try {
val result = adb.execute(
request = StreamingPackageInstallRequest(
pkg = File(file),
supportedFeatures = listOf(Feature.CMD),
reinstall = true,
extraArgs = emptyList()
),
serial = serial
)
Arbor.d("installApk:$result")
return result
} catch (e: Exception) {
e.printStackTrace()
return false
}
}

开发者选项控制


打开过度绘制、布局边界



  • 开发者选项里面的很多配置,都是系统属性。关于系统属性的部分原理,可以在这里了解一下。


Android 系统属性学习与使用 - 掘金



  • 一部分系统属性,是可以支持 adb 修改,并且可以立马生效的。

  • 比如布局边界的属性是 debug.layout,设置为 true 即可打开开关。

  • 比如过度绘制对应的属性是 debug.hwui.overdraw,设置为 show 即可打开开关。

  • 通过下面几个 adb 命令,转化成相应的代码实现即可。


//读取所有的prop,会输出所有系统属性的key和value
adb shell getprop
//读取key为propName的系统属性
adb shell getprop ${propName}
//修改key为propName的系统属性,新值为propValue
adb shell setprop ${propName} ${propValue}


  • adb shell service call activity 1599295570,这个命令,主要是为了修改 prop 之后能够立马生效。


    /**
* 修改 prop 手机配置
*/

suspend fun changeProp(propName: String, propValue: String, serial: String) {
adb.execute(request = ShellCommandRequest("setprop $propName $propValue"), serial = serial)
adb.execute(request = ShellCommandRequest("service call activity 1599295570"), serial = serial)
}

跳转到开发者选项页面


有些开关还是得手动去设置的,所以提供了这样的一个按钮,点击直接跳转到开发者选项页面。


如果使用命令是这样的。


adb shell am start -a com.android.settings.APPLICATION_DEVELOPMENT_SETTINGS

转化成对应的代码实现。


    suspend fun startDevelopActivity(serial: String){
adb.execute(
request = ShellCommandRequest("am start -a com.android.settings.APPLICATION_DEVELOPMENT_SETTINGS"),
serial = serial
)
}

内存分析



  • 这里就不细讲了,主要是使用 shark 库进行解析 Hprof 文件,然后分析内存泄露问题。

  • 使用shark库解析Hprof文件:juejin.cn/post/704375…

  • 过程挺简单的,就是通过 adb dump 出内存快照文件,然后 pull 到电脑上,并删掉原文件。


1、识别本地所有应用的 packageName
2、adb shell ps | grep packageName 查看应用 pid
3、adb shell am dumpheap <PID> <HEAP-DUMP-FILE-PATH> 开始 dump pid 进程的 hprof 文件到 path
4、adb pull 命令


  • 另一种情况,如果你有使用 LeakCanary,但是 LeakCanary App是运行在手机上的,在手机上查看泄露引用链,其实不是那么方便。

  • 后面分析了一下,LeakCanary 生成的文件,都放在了 /storage/emulated/0/Download 的目录下,所以搞个命令一键拉取到电脑上,在软件里面进行分析即可。



Html 文件生成


根据内存分析结果,生成一份 html 格式的文件报告,方便在浏览器中进行预览。



  • 尴尬的是,自己不太会写 html,另一个是,这个软件是纯 Kotlin 开发,要引入 js 貌似也不太方便。

  • github.com/Kotlin/kotl…

  • 刚好官方有个 kotlinx-html 库,可以使用 Kotlin 来开发 HTML 页面。

  • 引入相关依赖


    implementation("org.jetbrains.kotlinx:kotlinx-html-jvm:0.9.1")
implementation("org.jetbrains.kotlinx:kotlinx-html:0.9.1")


  • 按照官方文档进行使用,还是挺简单的。


        val html = createHTML().html {
head {
title { +"My HTML File" }
}
body {
h1 { +"Memory Analysis Report" }
h2 { +"Basic Info" }
p { +"Heap dump file path: ${hprofFile}" }

p { +"Build.VERSION.SDK_INT: ${androidMetadataMap?.get("Build.VERSION.SDK_INT")}" }
p { +"Build.MANUFACTURER: ${androidMetadataMap?.get("Build.MANUFACTURER")}" }
p { +"App process name: ${androidMetadataMap?.get("App process name")}" }

h2 { +"Memory leaks" }
}
}

下载地址


现在只有 mac 版本,没有 windows 版本。


http://www.github.com/LXD31256949…


填写License key可以激活:9916E3FF-2189-4A8E-B721-94442CDAA215


总结



  • 这篇文章,算是对这个软件的一个阶段性总结吧。

  • 一个是学习 Compose 相关的知识,以及了解 compose-desktop 相关的桌面组件,并进行开发桌面应用。

  • 另一个方面是 Android 这方面的知识学习。


作者:入魔的冬瓜
来源:juejin.cn/post/7369838480983490610
收起阅读 »

告别 "if-else",改用 "return"!

web
大家好,我是CodeQi!  一位热衷于技术分享的码仔。 在日常的开发中,很多人习惯于使用 if-else 语句来处理各种条件。但你有没有想过,层层嵌套的条件判断,可能会让代码变得难以维护且难以阅读?今天,我想分享一个让代码更清晰易读的技巧,那就是——retu...
继续阅读 »

大家好,我是CodeQi!  一位热衷于技术分享的码仔。


在日常的开发中,很多人习惯于使用 if-else 语句来处理各种条件。但你有没有想过,层层嵌套的条件判断,可能会让代码变得难以维护且难以阅读?今天,我想分享一个让代码更清晰易读的技巧,那就是——return。✨


if-else 真的有必要吗?


初学编程时,我们都习惯通过 if-else 语句来处理分支逻辑。比如判断一个用户是否活跃,是否有折扣,代码通常会写成这样:


function getDiscountMessage(user) {
  if (user.isActive) {
    if (user.hasDiscount) {
      return `折扣已应用于 ${user.name}!`;
    } else {
      return `${user.name} 不符合折扣条件。`;
    }
  } else {
    return `用户 ${user.name} 已被停用。`;
  }
}

你看,这段代码嵌套了多个 if-else 语句。如果我们继续在这种风格的代码上添加更多条件判断,会变得更加难以阅读和维护。过多的嵌套让人一眼难以理清逻辑。更严重的是,随着代码量增多,容易导致出错。


return:清晰与高效的代码编写方式


所谓的提前return,就是在遇到异常情况或不符合条件时,立即返回并结束函数。通过提前处理错误情况或边界情况,我们可以把代码的“理想情况”留到最后处理。这种写法可以让代码更清晰,逻辑更加直接。🎯


示例:用return优化代码


来看一看如何用return来重写上面的代码:


function getDiscountMessage(user) {
  if (!user.isActive) {
    return `用户 ${user.name} 已被停用。`;
  }

  if (!user.hasDiscount) {
    return `${user.name} 不符合折扣条件。`;
  }

  // 理想情况:用户活跃且符合折扣条件
  return `折扣已应用于 ${user.name}!`;
}

🌟 优势



  1. 每个条件只处理一次:每个 if 语句都提前处理好错误情况,让后面的代码不必考虑这些条件。

  2. 代码结构更扁平:没有嵌套的 if-else 块,更加一目了然。

  3. 更易维护:当我们想增加或修改判断逻辑时,只需在前面添加或修改条件判断,不会影响到“理想情况”的代码部分。


return vs if-else:一个真实场景


假设我们有一个需要检查多个条件的函数,validateOrder,要确保订单状态有效、用户有权限、库存足够等情况:


function validateOrder(order) {
  if (!order.isValid) {
    return `订单无效。`;
  }

  if (!order.userHasPermission) {
    return `用户无权限。`;
  }

  if (!order.hasStock) {
    return `库存不足。`;
  }

  // 理想情况:订单有效,用户有权限,库存足够
  return `订单已成功验证!`;
}

通过这种方式,我们将所有不符合条件的情况都提前处理掉,将主逻辑留到最后一行。这不仅让代码更易读,而且可以提高代码的运行效率,因为无须进入嵌套的条件分支。🎉


何时使用return


虽然提前return是优化代码的好方式,但并不是所有情况下都适用。以下是一些适用场景:



  • 多条件判断:需要检查多个条件时,尤其是多个边界条件。

  • 简单条件过滤:对于不符合条件的情况可以快速返回,避免执行复杂逻辑。

  • 确保主要逻辑代码始终位于底部:这样可以减少逻辑处理的复杂性。


结语


当我们写代码时,保持代码简洁明了是一项重要的原则。通过采用提前return,我们可以减少嵌套层次,避免过度依赖 if-else,让代码更直观、易维护。如果你还没有使用return,不妨从现在开始尝试一下!😎


下次写代码时,记得问自己一句:“这个 if-else 可以用return替换吗?


让我们一起追求清晰、优雅的代码!Happy Coding! 💻


作者:CodeQi技术小栈
来源:juejin.cn/post/7431120645981831194
收起阅读 »

我跑通了全球收付款的流程

前言 上周去韩国旅游,在首尔吃美食,坐在咖啡店写代码,Coding Anywhere,感觉很棒。 期间消费,用的是一张 ZA Bank 的 VISA 卡,几乎没怎么用现金。 这张卡里的钱,是我做出海 SaaS 产品一年以来的收入。 以前听别人做跨境电商,做出海...
继续阅读 »

前言


上周去韩国旅游,在首尔吃美食,坐在咖啡店写代码,Coding Anywhere,感觉很棒。


期间消费,用的是一张 ZA Bank 的 VISA 卡,几乎没怎么用现金。


这张卡里的钱,是我做出海 SaaS 产品一年以来的收入。


以前听别人做跨境电商,做出海 SaaS,可以面向全球用户收款,当时觉得很羡慕。


当自己终于跑通了全球收付款的流程之后,逐渐体会到了这里面的快乐。


20241110130034


在裸辞成为自由职业者之前,我副业做过一段时间的小程序业务,对接的微信支付,面向国内用户收款。


做过国内业务的朋友应该清楚,要对互联网产品实现商业化,一般需要注册一个公司主体,然后开通对公银行账户,再开通微信支付 / 支付宝之类的第三方收款渠道。


给自己的业务产品对接第三方支付平台,用户在线支付的钱,进入到微信支付 / 支付宝的商业账户,再提现到公司的对公账户。此为完整的收款流程。


从公司对公账户,以发工资或者借贷的形式,把钱发到员工银彳亍卡,或者转账到法人账户。可以理解为个人对收款资金的消费途径。


随着 AI 的爆发,国内备案政策收紧,国内业务越来越难做,很多人选择出海。


出海的第一步,要搞定全球收款的问题,需要有一个账户,接受全球用户的付款。


如果做的是出海 SaaS 产品,收款消费流程跟国内业务的流程基本一致,只是企业主体 / 对公账户 / 第三方支付平台 / 提现转账的对象有所不同。


用一张图来表示出海 SaaS 业务的收款 / 消费流程如下:


20241110152100


拆解成两个核心链路:



  1. 如何全球收款

  2. 如何消费收款账户内的资金


来详细讲解我是如何跑通全球收付款流程的。


如何全球收款


1. 注册一个境外公司


开通全球收款渠道之前,需要有一个境外主体。开通境外主体的方式很多,如果人在境外,操作起来会比较方便。如果人在境内,可以选择代理网站注册,或者淘宝代注册的方案。


境外主体注册地,可以根据实际需求选择,如果没有特别的要求,可以选择注册英国或美国公司。



  • 淘宝找代理,代注册美国公司


这种方案我没有实践过,身边有些朋友走的是此方案,淘宝找个代理,交 2000 多元人民币,2 个礼拜左右,可以注册下来一个美国公司。



  • 使用代理网站,自助注册英国公司


我选择的是此方案,自助注册英国公司,相对来说较为简单。


使用以下代理网站,创建账户,扫描护-照,全流程在网站上完成,会收到邮件验证资料。注册费用在几十刀,顺利的话,一个多礼拜可以注册下来。


http://www.1stformations.co.uk/


20241110131930


可以根据网站上的指引进行注册,或者搜一下注册教程。


2. 申请一张境外手机卡


很多地方需要验证境外手机号,所以需要申请一张境外手机卡,在境内可以正常接收短信验证的就行。


在淘宝搜索“giffgaff”,购买一张英国手机卡,邮寄到家后,进入官网激活。


http://www.giffgaff.com/


20241110203614


在官网买最低的套餐即可,大概在 6 英镑每月。


激活之后,就可以在境内接收全球各类产品的短信验证了。


3. 开通境外银行账户



  • 使用 Wise 开通对公账户


在第 1 步注册成功英国公司后,邮件会收到英国公司的注册资料,包括主体名 / 主体营业号 / 主体地址等信息。


拿这些信息,在 Wise 申请开通英国银行账户。


wise.com/


20241110132101


从 2023 年底开始,Wise 政策收紧,英国公司开通对公账户需要等待,我差不多花了 1 个月,才被允许开通对公账户,现在可能会更难开,需要多试。



  • 使用万里汇开通对公账户


如果 Wise 限制英国公司开户,可以把万里汇作为备选方案。万里汇是阿里巴巴旗下的产品,开户相对会容易一些。


http://www.worldfirst.com/uk/


20241110204544


无论 Wise 还是万里汇,开通成功后,你的英国公司就拥有了一个企业对公账户。可以选择不同的币种开通不同的账户。


比如你可以选择开通一个美元账户,一个英镑账户,一个欧元账户,用于外币收款。


20241110205939


4. 开通第三方支付平台收款商户


在开通企业对公账户成功之后,你就可以去申请第三方支付平台商户了。首选 Stripe。


stripe.com/


20241110132442


在 Stripe 新建商户,需要填写公司基本信息,法人姓名 / 地址,公司开展的业务说明等信息。


一个 Stripe 账户,可以开通多个收款商户,可以复用同一个主体信息。如果你的业务产品线比较多,可以创建多个 Stripe 商户,为每个产品对接一个 Stripe 商户,减少鸡蛋放在同一个篮子的风险。


20241110132501


有公司主体和对公账户的前提下,创建 Stripe 商户一般很快就能审核通过。有可能会要求你上传地址证明文件,直接用公司的注册地址就行。


除了 Stripe,也有其他一些第三方支付平台可以作为备选,比如:



选择适合你的支付平台,按照官网指引操作,可以多开几个放着,以备不时之需。


5. 通过 SaaS 产品收款


通过前面四个步骤,你已经开通了境外主体和境外银行账户,并且有了第三方支付平台收款商户。


接下来,就可以在你的 SaaS 产品中,对接第三方支付平台的收款 API,接收全球用户的付款了。


一个 Web 类型的 SaaS 产品,要实现对用户收款,一般会创建一个 Pricing 页面,列出几个付费套餐和对应的权益,如果用户认可你的服务,就会选择付费购买。


Stripe 支持订阅支付模式,用户输入自己的信用卡卡号订阅服务,每个月扣款日自动扣款,就相当于国内的小米电视会员按月订阅,免密代扣模式。


但是微信支付开通免密代扣的门槛非常高,Stripe 则简单多了。


在你的 SaaS 产品中,优先选择按月 / 按年的订阅支付方案,可以有效增加收入(相比于一次性付费)。


20241110132601


下载 Stripe 手机 App,开启接收通知,每当有用户在你的 SaaS 产品付费,你就会收到通知。


20241111092927


6. 资金提现


当你使用第三方支付平台收款一段时间后,你的第三方支付商户账户中,就会有一定的资金积累。


你可以选择将资金提现到你的境外银行账户中。一般需要选择提现的币种,设置成按时 / 按金额自动提现,或者手动提现。


第三方平台会在扣除掉一定的手续费之后,把资金转账到你公司的对公银行账户。这个过程涉及到跨境转账,换汇 / 清算等流程,耗时稍微有点久,一般在 3-5 个工作日,提现的资金才会到账。可以在 Wise 网页版,查看账户余额和提现记录。


20241110234652


至此,全球收款的流程就走完了。你可以开展你的 SaaS 业务,面向全球市场,找到目标用户,让用户喜欢你的产品,并为之付费。


所有的收入,都会进入到你在境外的对公银行账户中。


如何消费收款账户内的资金


前面几个步骤,讲完了全球收款的完整流程。如果业务开展顺利的话,你的境外对公银行账户会有一定的资金积累。


接下来,就可以考虑如何消费这些资金了。


消费的方式有很多种,最常见的几种消费方案:



  1. 使用境外对公银行账户的钱,购买商品 / 服务

  2. 境外对公账户的钱,转到境外个人账户,在境外消费

  3. 境外对公账户的钱,转到境外个人账户,绑定微信支付,在境内消费

  4. 境外对公账户的钱,转到境外个人账户,再转到境内个人账户,在境内消费


1. 使用境外对公银行账户的钱,购买商品 / 服务


Wise 除了可以开通企业对公账户之外,也支持开通个人账户。


企业 Wise 账户,可以为法人或者团队,开通多张物理卡或数字卡。


数字卡可以直接绑定到 Apple 钱包进行消费,或者在线支付 SaaS 产品,比如 ChatGPT / Claude 的会员订阅服务等。


20241110220303


Wise 的物理卡我申请了邮寄,一直没收到。但是 Wise 的数字卡,我已经用来支付常用的境外服务,比如 OpenRouter / Serper 这些。


对于 SaaS 业务的一些开销,通过数字卡支付,直接从 Wise 对公账户扣除了,省去了转入转出的麻烦。


2. 境外对公账户对钱,转到境外个人账户,在境外消费


对于出国旅游这类场景,我们可能希望把境外对公账户的钱,转账到个人银彳亍卡,直接在境外线下消费。


首先,需要申请境外的个人银彳亍卡。这里推荐三个方案:



  • 新加坡华侨银行(OCBC)


人在境内,下载 OCBC 银行 App,在线申请,一周内可以开通个人银行账户。可以选择邮寄实体卡,但是境内收件走的是平邮,可能会收不到。


我至今还未收到 OCBC 实体卡,但不影响在 OCBC App 内在线消费。



  • 中国银行香港(BOCHK)


需要去香港办理,如果去银行柜台办理,需要准备各种资料,比如富途的投资记录,银行流水等,有可能会被拒绝开户。(我 9 月份去香港中国银行柜台办理被拒了)


另一种稳妥的方式是,人在香港,连上香港 Wifi,下载 BOCHK App,在线申请,快速开通个人银行账户。


然后再去柜台补个签名,就可以正常使用了。


在柜台请他们修改邮寄实体卡的方式,寄挂号信。(即使改了邮寄方式,在境内也有可能收不到实体卡。我至今未收到T_T,但不影响银行账户的使用)



  • 众安银行(ZA Bank)


申请比较简单,人在香港,连上香港 Wifi,下载 ZA Bank App,在线申请,快速开通个人银行账户。


在 App 申请邮寄实体卡,在境内三天左右就可以收到。


除了以上三种方案,还有朋友会去香港开通汇丰银行账户,我没试过就不做过多阐述。


有条件的情况,建议多开几家银行账户,以备后续使用。


以上任意一家银行账户,都能接收 Wise 对公账户转账,Wise 会自动进行换汇,扣除一定的手续费,资金转账实时到账。


20241110153107


以上开通的境外银行账户,可以在线支付各类 SaaS 产品。也可以选择实体卡消费,既能刷卡支付,也支持 ATM 取现。


如果在境外使用 ZA Bank 实体卡消费,需要在 ZA Bank App 开启海外旅游选项。


20241110153556


3. 境外对公账户的钱,转到境外个人账户,绑定微信支付,在境内消费


境外习惯信用卡支付,而在境内大家更喜欢微信 / 支付宝支付。


如果习惯使用微信支付,可以在微信搜索框输入“香港钱包开通”,进入自助开通页面,选择“绑定香港发行的银行账户”,绑定上面开通的中银香港卡,就可以开通香港钱包。


20241110154022


在微信支付服务页,切换钱包地区,选择“香港钱包”,可以用于常用场景的支付,比如在线购买机票 / 火车票,美团点外卖,瑞幸喝咖啡等。境内消费的大部分场景,香港钱包都支持。


20241110224301


稍微遗憾的是,香港钱包绑定 ZA Bank,需要验证香港身-份-证,如果我们没有香港身-份-证,只能绑定中银香港卡,再把 ZA Bank 的钱转到中银香港卡,通过香港钱包在微信消费。


4. 境外对公账户的钱,转到境外个人账户,再转到境内个人账户,在境内消费


可能有人会有这类需求,需要用境内银彳亍卡扣房贷,想把境外个人账户的钱,转到境内银彳亍卡。


这个方案我没有实践过,但是看中银香港 App,有一个“中银快汇”的功能,可以转账到境内银彳亍卡。另外还有一个“开户易”功能,支持汇款到广东省内中国银行的同名账户。


如果使用 ZA Bank 或者其他香港银行账户,可以通过“熊猫速汇”之类的产品,汇款到境内银彳亍卡。


有需求的朋友,可以自行尝试。


没有境外银行账户,如何给境外产品付款


如果你还没有境外银行账户,但是需要给 ChatGPT / Claude 之类的产品付款,以订阅他们的会员服务。


你可以选择申请虚拟信用卡。


推荐使用 WildCard 这个产品,不仅支持虚拟信用卡,还提供虚拟手机号用于接收验证短信,以及安全的网络环境解决风控问题。


可以使用我这个推荐链接:


bewildcard.com/i/IDOUBI


注册 WildCard 服务,申请一张虚拟信用卡,使用支付宝充值,然后就可以给各类境外 SaaS 产品付费了。


20241110225036


总结


通过这篇文章,介绍了我这一年来做境外 SaaS 业务,从全球收款,到全球消费的完整流程。踩了不少坑,也总结了很多宝贵的经验,希望对即将开始做出海业务的朋友有所帮助。


秉持 Build in Public 的理念,同步一下我这一年来做 SaaS 业务的收入情况。


一年来的累计收入不到 1 万美元。


20241111092726


MRR(月度经常性收入) 最近突破了 1 千美元。


20241111092756


之前做 ThinkAny 这个项目的时候,有段时间 token 消耗和服务器流量费用特别高,一直在用境内的个人信用卡支付开销。境外收款账户里的钱属于纯收入,没有覆盖支出,算上成本的话,谈不上赚到钱,一年下来整体收支平衡。


虽然跟身边其他做出海业务的朋友相比,我的这点收入微不足道,但是对我个人而言,算是迈出了出海的第一步,收款 / 消费流程闭环,积累了很多认知,也增加了我对出海业务的信心。


接下来,我会继续优化我的 SaaS 产品,寻找目标用户,努力实现收入增长,争取早日达成 MRR 1 万美元的目标。


最后


如果你想开始做出海 SaaS 业务,可以参考本文先搞定全球收款链路。


如果你不知道如何做出一个 SaaS 产品,可以看一下我开源的这几个项目,有完整的 SaaS 产品模版,支持 Stripe 支付,可以一键部署,快速上线跑通收款流程。


github.com/all-in-aigc


20241110231145


如果你不太懂技术,想要学习全栈开发。可以选择加入我的“1024 全栈开发社群”,我在群里分享了几个项目的全栈开发过程,包括如何完成项目的前后端开发,如何实现 UI 组件,如何对接 AI 能力,如何做数据存储,如何支持支付收款等技术细节。也许会对你有帮助。


1024.pagen.io



祝大家早日出海,全球收款,大浪淘金。



作者:艾逗笔
来源:juejin.cn/post/7435708914433785893
收起阅读 »

App侧滑卡死?Flutter表示这锅不能背

前言 由于谷歌 flutter 团队裁员,导致维护更新满足不了需求,传闻 flutter 团队不足50人,很多跨端的支持以及现有的问题都无法解决,flutter 社区foundation不满足于现有的开发进度,fock flutter维护分支Flock, 貌似...
继续阅读 »
前言

由于谷歌 flutter 团队裁员,导致维护更新满足不了需求,传闻 flutter 团队不足50人,很多跨端的支持以及现有的问题都无法解决,flutter 社区foundation不满足于现有的开发进度,fock flutter维护分支Flock, 貌似不少公司组织已经在这样做了,很多问题也不能怪flutter, 比如最近发的这个问题。


问题

flutter freeze卡死的问题相信很多开发者都遇到过, 最近遇到iOS侧滑返回的导致freeze的问题,很早就发现这个问题,当时的解决方案是在首页禁止侧滑,就是导航栈只有一个页面的时禁用手势


if (self.navigationController.viewControllers.count == 1) {
    self.navigationController.interactivePopGestureRecognizer.enabled = NO;
}

此方案可以将卡死问题很大概率的降低,但是仍然会出现卡死, 可以规避但没找到根本原因


分析

最开始以为是flutter手势和iOS 系统返回手势冲突导致,app 存在一些侧滑的轮播图,经过对比发现有没有轮播图或者flutter 手势都会出现卡死。


问题可能出现在iOS 原生侧,经过尝试发现问题所在,复现代码如下


首页实现手势比如PanGuesture


UIPanGestureRecognizer *panGesture = [[UIPanGestureRecognizer alloc] initWithTarget:**self** action: **@selector**(handlePan:)];
[redView addGestureRecognizer:panGesture];

push 跳转到二级页面,二级页面对重置了导航栏的返回手势的delegate


self.navigationController.interactivePopGestureRecognizer.delegate = nil;

原因:
重置了导航栏的PopGestureRecognizer的delegate,向当前全局的侧滑返回代理回调失效,导致策划返回出现问题,禁用手势不可以讲导航栏的返回手势的delete 置为nil


Flutter为何侧滑可出现卡死,通过分析flutter的源码就很容易知道原因


image.png


结论

flutterViewController也实现了手势操作,所以使用依赖原生的flutter出现了这个问题,跳转到其他页面,将PopGestureRecognizer 置为nil, 就出现这个问题


Flutter:这真不是我的锅


感想

从20年开始接触Flutter 也有三四年时间了,Flutter的应用范围也越来越广,但是大厂对flutter的接受度还是不高,要不要继续坚持搞Flutter,作为开发未来将何去何从


作者:普罗哈基米
来源:juejin.cn/post/7433827139113746467
收起阅读 »

一种纯前端的H5灰度方案

web
什么是灰度发布 在互联网领域,灰度发布是产品质量保障的重要一环,它可以让某次更新的产品,以一种平滑,逐步扩大的方式呈现给用户,在此过程中,产品和技术团队可以对功能进行验证,收集用户反馈,不断优化,从而减少线上问题的影响范围,完善产品功能。 在前端领域,APP和...
继续阅读 »

什么是灰度发布


在互联网领域,灰度发布是产品质量保障的重要一环,它可以让某次更新的产品,以一种平滑,逐步扩大的方式呈现给用户,在此过程中,产品和技术团队可以对功能进行验证,收集用户反馈,不断优化,从而减少线上问题的影响范围,完善产品功能。


在前端领域,APP和小程序天生就具有灰度的能力,一般基于发布平台来控制。但 H5 却缺少这种天生能力,而且 H5 一旦发布就会影响所有用户,更加需要一套灰度系统,来保证产品的稳定性。


灰度发布的本质


既然要让部分用户先使用新功能,就需要做好两件事情,这也是灰度的本质:



  1. 版本控制 同一个项目需要在线上同时发布至少两套页面,一套针对全量用户,一套针对灰度用户

  2. 分流控制 需要有一套规则,把用户按某种特征划分为不同的群体,可以是用户ID,门店、城市,也可以是年龄,亦或是随机。命中的用户访问灰度页面,未命中的访问全量页面。


Pasted image 20240803093018.png


那么想要实现灰度发布有哪些方案呢?


可选的灰度方案


Nginx+lua+redis


通过使用 Nginx 的反向代理特性,我们可以根据请求的特定属性(例如ip、请求头、cookie)等有选择性的将请求路由到全量或灰度版本。


同时在 Nginx 中嵌入 Lua 脚本,负责根据预定义的灰度发布策略处理请求,Lua 脚本可以从 Redis 中获取灰度配置。从而确定哪些用户可以访问新版本,那些用户应该可以访问旧版本。


Redis 用于存储灰度发布的配置数据。


通过这种方式可以实现基于 Ngnix 的灰度发布,但这种方式并不适合我们,为什么呢?


因为我们的C端H5页面连同HTML文件都是直接投放在 CDN 上,这就意味着我们没有中转服务层,无法使用第一套 Nginx 的方案,而且使用 Nginx 也会响应降低页面加载速度,虽然可能很轻微,但却是对所有用户都会有影响。


采用 Nginx 进行中转:


Pasted image 20240803102717.png


不采用 Nginx 中转:


Pasted image 20240803102758.png


如上两张图,可以很明显的看到,如果采用 Nginx 来作为中转并进行分流控制,将导致我们的 CDN 优势失效,所有的流量都可能回到上海的机房,再流转到上海的 CDN,这显然不是我们想看到的。


这也是我们放弃 Nginx+lua+redis 方案的原因。


基于 SSR 做灰度


如果我们的前端页面是通过服务端来进行渲染,可以把灰度控制继承在服务端渲染中,基于不同的用户放回不同的HTML,这样也就可以做到灰度发布。


Pasted image 20240803093946.png


不过这需要有一套完善的 SSR系统,对于访问量大的产品,维持系统稳定性的难度远大于实现 SSR 本身的技术难度。由于我们是前后端分离,并且没有基于 Node 高可用的运维团队和经验,所以这个方案也就放弃了。


APP拦截灰度


基于APP的方案,是在用户点击H5资源位,创建webview时,拉取灰度配置,如果当前页面有灰度,则拉取灰度配置,判断是否命中灰度,如果命中,替换H5链接即可。


Pasted image 20240803100556.png


看过我其他文章的朋友,应该有了解到我们针对H5秒开有一套配置下发到APP,那么灰度配置,也可以集成到原有配置中,一并下发给APP,这套方案相对而言也比较简单,但是却有如下问题。



  1. 只能支持APP,APP外和小程序内打开的场景无法支持

  2. 依赖APP,公司其他业务线的APP,如果要使用也需要开发,工作量较大。


所以最后该方案也被排除。


纯前端方案


方案概览


基于如上的一些原因,于是我们采用了一套纯前端的方案,来解决灰度发布问题,虽然这套方案也有一点缺点。前面我们提到灰度发布的本质,其实包含两个方面,一是版本控制,二是分流控制。


版本控制比较好做,我们把全量的HTML代码发布到 index.html 文件,把灰度的HTML代码发布到 gray.html 文件,这样就做到了版本控制。


分流控制,可以被拆分为两部分,一部分只管获取配置、判定是否命中灰度并入在本地,另一部只管读取结果并执行跳转,这样整个系统就解耦了。


方案大体思路是:



  1. 在用户首次方式时,静默激活灰度计算逻辑,通过接口或其他条件判断用户是否命中灰度,把结果存储在 localStorage 中。

  2. 有别于全量版本时使用 index.html,灰度时构建并修改html名称为 gray.html,并发布

  3. 当要灰度发布时,下载 index.html ,注入灰度判断代码到 head 中,注入 GRAY_SWITCH 开关并开启

  4. 当用户再次访问时,执行灰度判断代码,如果命中,重定向到 gray.html 页面

  5. 对获取页面点击的地方,进行封装或拦截,确保灰度用户分享出去的链接,是全量链接


流程图:


Pasted image 20240803111309.png


时序图如下:


Pasted image 20240803113658.png


灰度版本控制


对于版本控制,我们通过提供了一个 webpack 插件集成到构建流程中,在构建时生成不同文件名的 html 文件。


通过构建命令参数,来区分各种发布情况


npm run build your_project_name -- --gray=open  
# --gray 的值
# --gray=close 不打开灰度,默认值
# --gray=open 打开灰度
# --gray=full 灰度全量
# --gray=unpublish 撤销灰度

可以分为如下情况:


正式发布


构建时生成:



  • index.html 全量页面

  • index_backup.html 全量备份页面(用来做回归)


灰度发布


构建时生成:



  • gray.html 灰度页面

  • gray_backup.html 灰度备份页(用来在全量后替换 index_backup.html)


同时下载 index.html ,注入灰度重定向控制JS。


重定向控制代码如下:


// 标记是否打开灰度  
window.__GRAY_SWITCH__ = 1 
let graySwitchName = 'gray_switch_';
// 获取去除html后的pathname
const pathname = window.location.pathname.split('/').slice(0, -1).join('/'); 
graySwitchName = graySwitchName + pathname
const graySwitch = localStorage.getItem(graySwitchName)
if (graySwitch === '1') {
const grayUrl = window.location.href.replace('index.html''_gray.html')
if(window.history.replaceState){
// 安卓 app 使用 location.replace 无效
window.history.replaceState(nulldocument.title, grayUrl);
}else{
window.location.replace(grayUrl);
}
}

修改输出的 HTML 文件名,是通过编写 webpack 的自定义插件来完成。


原理是通过 compiler.hooks.afterEmit.tapAsync 钩子函数,再 “输出” 阶段,对文件名进行修改。


撤销灰度


从云端下载 index_backup.html 重命名为 index.html 放在打包目录,之后再由发布系统上传。


全量发布


从云端下载 gray.html 和 gray_backup.html,重命名为 index.html 和 index_backup.html,发布后就会替换原有的全量HTML。


灰度分流控制


分流的重点是如何判断哪些用户能命中灰度。每个项目划分人员的策略都可能不同,比如C端页面更倾向于按useID随机划分。而B端拣货、配送等业务线,更需要按门店来进行划分,这样可以做到同门店员工体验一致,便于管理。所以这块这块必须要足够的灵活性。


我们这里采取了两种方式:

第一种是基于接口来做分流控制:把用户信息传给服务端,接口通过配置的灰度规则,计算是否命中,并返回前端。前端只管把结果存入本地。

第二种是把计算逻辑都放在前端,比较适合C端项目,因为C端项目大部分场景都是随机划分灰度用户。


灰度分流计算的JS代码是在用户每次打开后,静默运行,所以需要引入到业务代码中。


引入的代码如下:


import grayManager from '@cherry/grayManager'  
import { getMemberId } from '../utils/index'

// 伪代码,说明GrayOptions 的类型
interface GrayOptions {
    // 灰度比例控制 支持固定值和数组阶梯灰度,配置grayScale 后,grayComputeFn无效
    grayScalenumber | [number]
    // 自定义灰度方法,在内可以请求接口等
    grayCompute() => (() => Promise<boolean>) | boolean
    // 获取维护标识,比如以 shopId 为灰度标识,该函数就返回当前用户的 shopId
    getGaryData() => ()=> Promise<string>,
    // 配置灰度白名单,白名单内的用户都会命中灰度
    whiteDatastring[]
}

// 初始化灰度计算逻辑
grayManagerInit({
    grayScale10,
    whiteData: ['123''456']
})

前端计算分流


随机百分比

多数项目,我们一般使用的策略是随机,比如设置10%的用户命中灰度。


我们可以通过生成随机数来判断是否命中灰度,具体步骤如下:



  1. 在 grayManager.init() 时,随机生成一个 uuid,存在用户本地,不做清除,下次 init 时,先从本地取 uuid,存储 key 命名为 __GRAY_UUID__

  2. 当使用预置灰度计算能力时,取 __GRAY_UUID__ 每位转化为 asci 码并相加,除以100 求余数

  3. 用余数+1 和灰度比例(grayScale)对比,当余数 +1 <= grayScale 时命中灰度


这样可以得到一个近似 10% 比例的灰度用户数。


基于门店和城市分流

如果想基于门店或城市分流,我们只需要配置两个参数, 一是如何获取门店和城市ID

另一个是需要灰度的门店和城市ID


import grayManager from '@cherry/grayManager'  
import { getShopId } from '../utils/index'

grayManagerInit({
    getGaryData() => {
        return await getCityId()
    },
    whiteData: ['123''456']
})

可以通过 grayScale 配置数组来实现,起始时间为打灰度包构建的时间,我们会把构建时间注入到 HTML 中。


其他注意项


开头讲过,这套方案有一点缺点。可能大家也会发现,灰度时用户需要先进入打 HTML,执行 head 中注入的重定向控制JS,对命中灰度的用户再次跳转到 gray.html


这样其实带来了两个问题:一是对灰度用户来说经过了两个HTML,白屏的时间会更长。二是灰度用户访问的URL变化了,如果此时用户把页面分享出去,被分享用户将直接打开灰度页面。


对于第一条,全量用户是不会被影响,只有灰度用户才会白屏更久,我们目前测试白屏的时长还能接受。


对于第二条,我们最初是系统通过 Object.defineProperty 来拦截 对 window.location.pathname 的获取,返回 index.html。但window.location.pathname 是一个只读属性不可拦截。


最后只能提供统一的方法,来获取 pathname


结语


以上就是我们的灰度核心方案,整个方案会比较简单,几乎不依赖外部部门。无论是对于H5还是pcWeb,亦或是不同的容器,都无依赖,各个业务线都可以平滑使用。


作者:思考的Joey
来源:juejin.cn/post/7438840414239326227
收起阅读 »

用Three.js搞个炫酷风场图

web
风场图,指根据风速风向数据进行渲染,以表征空气流动方向、流动速度的一种动态流场图。接下来让我们学一下怎么实现炫酷的2D和3D风场图吧!一、 获取风场数据打开NCEP(美国气象环境预报中心)查看Climate Models(气候模型)的部分点击Climate F...
继续阅读 »

风场图,指根据风速风向数据进行渲染,以表征空气流动方向、流动速度的一种动态流场图。接下来让我们学一下怎么实现炫酷的2D和3D风场图吧!

vvvv.gif

一、 获取风场数据

  1. 打开NCEP(美国气象环境预报中心)
  2. 查看Climate Models(气候模型)的部分
  3. 点击Climate Forecast System 3D Pressure Products(气候预报系统3D大气压产品)的grib fiter选择数据下载

image.png 4. 界面会有不同日期的数据提供下载,我们选择默认最新的那个日期就好

  1. 一堆看不懂的参数,没关系,我们只需要在Levels图层这里勾选max wind这个就好(因为我们要画风场图),不推荐Levels勾选all,数据太大,下载慢,并且看不懂,用不到。
  2. 点击Start download就可以下载了

image.png

二、处理风场数据

grib这个数据格式打不开,看不懂,需要转换成json,有位大牛A写了个java的grib处理工具(grib2json),然而我用maven打包失败了,然后发现有另一位大牛B封装了大牛A的jar包成node脚本,正好给前端开发者使用。

  1. 安装@weacast/grib2json
pnpm add -D @weacast/grib2json
  1. 执行脚本,将grib转换成json

使用说明

Usage: grib2json (or node bin.js) [options] 
-V, --version 输出版本号
-d, --data 输出GRIB记录数据
-c, --compact 压缩json
-fc, --filter.category 选择类目值
-fs, --filter.surface 选择表面类型
-fp, --filter.parameter 选择参数值
-fv, --filter.value 选择表面值
-n, --names 打印数字代码的名称
-o, --output 输出文件名
-p, --precision 使用小数点后几位数的精度(默认值:-1)
-v, --verbose 启用stdout日志记录
-bs, --bufferSize stdout或stderr上允许的最大数据量(以字节为单位)
-h, --help 使用帮助
pnpm exec  grib2json -c --names --data --fp 2 --fs 103 --fv 10.0 -o output.json D:/code/wind/pgbf2024103000.01.2024103000.grb2

注意:

  • --fs 103表面类型103(地面以上指定高度)
  • --fv 10.0 距离GRIB2文件10.0米的表面值
  • --fp 2 将参数2(U-component_of_wind)的记录输出到stdout
  • 需要转换的grib文件放在最后,文件路径要用完整的路径名称
  1. 数据格式说明
{
"header":{
//数据更新时间
"refTime":"2024-10-30T00:00:00.000Z",

"parameterCategory":2,//类目号,2表示风力
"parameterCategoryName":"Momentum",
"parameterNumber":2,//2表示u,3表示v
"parameterNumberName":"U-component_of_wind",
"numberPoints":65160,//点数量
"nx":360,//横向栅格数量
"ny":181, //纵向栅格数量
"lo1":0.0,//开始经度
"la1":-90.0,//开始纬度
"lo2":359.0,//结束经度
"la2":90.0,//结束纬度
"dx":1.0,//横向步长
"dy":1.0//纵向补偿
},
"data":[//方向数据,u数据,要搭配另一个v的数据使用
-7.8,
-7.9,
]
}

U表示横向风速,V表示纵向风速,UV的正负值表示风向

  1. output.json有2.25MB大,数据里面除了uv方向的数据,还包含了其他的数据,我们只需要有用的一个header和uv数据即可,可以用node处理一下,得到一个header信息数据info.json和风向数据wind.json
const fs = require('fs');
const output = require('./output.json');
let uData = [];
let vData = [];
let header = {};
for (let i = 0; i < output.length; i++) {
if (output[i].header.parameterNumber === 2) {//u的数据集
uData = output[i].data;
header = output[i].header;
} else if (output[i].header.parameterNumber === 3) {//v的数据集
vData = output[i].data;
}
}

const len = uData.length;
const list = [];
const info = {
minU: Number.MAX_SAFE_INTEGER,
maxU: Number.MIN_SAFE_INTEGER,
minV: Number.MAX_SAFE_INTEGER,
maxV: Number.MIN_SAFE_INTEGER,
...header
};
for (let i = 0; i < len; i++) {
//uv数据组合
list.push([uData[i], vData[i]]);
//计算最大最小边界值
info.minU = Math.min(uData[i], info.minU);
info.maxU = Math.max(uData[i], info.maxU);
info.minV = Math.min(vData[i], info.minV);
info.maxV = Math.max(vData[i], info.maxV);
}

fs.writeFileSync('./wind.json', JSON.stringify(list));
fs.writeFileSync('./info.json', JSON.stringify(info));

三、绘制2D风场图

重头戏来了!瞪大你的眼睛(0 v 0),看好了!

1. 创建风场网格

nx和ny对应横向纵向网格数量,然后uv数据按照nx行,ny列组装添加到二维数组里面就是网格了。

 this.grid = [];
let index = 0;
for (let j = 0; j < header.ny; j++) {
const row = [];
for (let i = 0; i < header.nx; i++) {
const item = this.data[index++];
row.push(item);
}
this.grid.push(row);
}

2. 获取点xy对应的风向uv

根据风场网格获取该xy先在应的风向uv,点xy可能不是整数,那么这时候需要使用双线性插值(根据临近的周围四个点计算出插值)算出对应的风向uv。

  • 根据xy获取风向uv
 getUV(x, y) {
let x0 = Math.floor(x),
y0 = Math.floor(y);
//正好落在网格里
if (x0 === x && y0 === y) return this.getGrid(x, y);

let x1 = x0 + 1;
let y1 = y0 + 1;

//临近四周的点
let g00 = this.getGrid(x0, y0),
g10 = this.getGrid(x1, y0),
g01 = this.getGrid(x0, y1),
g11 = this.getGrid(x1, y1);
return this.bilinearInterpolation(x - x0, y - y0, g00, g10, g01, g11);
}
  • 不落在整数网格里面的采用双线性插值计算出风向uv
  /**双线性插值
* g00, g10, g01, g11对应临近可映射的四个点
* x为当前点与最近点x坐标差
* y为当前点与最近点y坐标差
* ***/

bilinearInterpolation(x, y, g00, g10, g01, g11) {
let rx = 1 - x;
let ry = 1 - y;
let a = rx * ry,
b = x * ry,
c = rx * y,
d = x * y;
let u = g00[0] * a + g10[0] * b + g01[0] * c + g11[0] * d;
let v = g00[1] * a + g10[1] * b + g01[1] * c + g11[1] * d;
return [u, v];
}

  • 获取网格数值,需规整超出的边界值
getGrid(x, y) {
const h = this.header;
if (x < 0) {
x = 0;
} else if (x > h.nx - 1) {
x = h.nx - 1;
}

if (y < 0) {
y = 0;
} else if (y > h.ny - 1) {
y = h.ny - 1;
}

return this.grid[y][x];
}

3. 创建随机点

 createRandParticle() {
//必须在风场网格范围内才能获取到风向uv
const x = Math.random() * this.header.nx;
const y = Math.random() * this.header.ny;

const uv = this.getUV(x, y);

return {
//起点位置
x,
y,
//终点位置=当前位置加上风向偏移
tx: x + this.speed * uv[0],
ty: y + this.speed * uv[1],
//生命周期,将生命周期归零的时候重新设置起点坐标
age: Math.floor(Math.random() * this.maxAge)
};
}
//重新设置随机点
setParticleRand(p) {
const newp = this.createRandParticle();
for (let k in p) {
p[k] = newp[k];
}
}
  • 生成随机点
 
this.particles = [];
for (let i = 0; i < this.particlesCount; i++) {
this.particles.push(this.createRandParticle());
}

4. 绘制风场图

canvas绘制风场即用线段的起点和终点跟随着风向不断运动形成风场图。

  • 设置canvas
//缓存canvas context之前的合成操作类型
const pre = ctx.globalCompositeOperation;
//'destination-in'仅保留现有画布内容和新形状重叠的部分。其他的都是透明的。
ctx.globalCompositeOperation = 'destination-in';
//之前绘制的保留重叠部分
ctx.globalAlpha = 0.5;
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
//还原合成操作类型
ctx.globalCompositeOperation = pre;


//设置线的全局透明度
ctx.globalAlpha = 0.8;

注意cxt.fillRect本来清空之前的画布内容,但采用了globalCompositeOperation='destination-in'globalAlpha=0.5的透明度作为重叠标准,重叠部分以0.5的透明度重新绘制并保留下来,通过这种方式,可以形成很多连续点的感觉,如果设置为1的透明度则会全部保留,并且不停叠加,等价于没有清空画布的状态。

  • 遍历随机点更新位置
      this.particles.forEach((p) => {
if (p.age <= 0) {
//生命周期耗尽重新设置随机点值
this.setParticleRand(p);
} else {
if (!this.inBound(p.x, p.y)) {
//画出范围外重新设置随机点值
this.setParticleRand(p);
} else {
//根据下一个点的风向,计算出下一个点的位置
const uv = this.getUV(p.tx, p.ty);
const nextx = p.tx + this.speed * uv[0];
const nexty = p.ty + this.speed * uv[1];
//将起点换成之前的终点
p.x = p.tx;
p.y = p.ty;
//终点设置成计算出的下一个点
p.tx = nextx;
p.ty = nexty;
//生命周期递减
p.age--;
}
}
//起始点和终点转换成显示的画布大小
const start = this.getCanvasPos(p.x, p.y);
const end = this.getCanvasPos(p.tx, p.ty);
//渐变跟随线段的方向
const gradient = ctx.createLinearGradient(start[0], start[1], end[0], end[1]);
for (let k in this.color) {
gradient.addColorStop(+k, this.color[k]);
}
//绘制线段
ctx.beginPath();
ctx.strokeStyle = gradient;
ctx.moveTo(start[0], start[1]);
ctx.lineTo(end[0], end[1]);
ctx.stroke();
});

5. 使用封装类绘制

async function main() {
//风场信息数据
const header = await getData('./info.json');
//风场uv方向数据
const data = await getData('./wind.json');

const canvas = document.getElementById('canvas');
canvas.width = 1200;
canvas.height = 600;
const cw = new Windy({
header,
data,
canvas,
//运动速度
speed: 0.1,
//随机点数量
particlesCount: 1000,
//生命周期
maxAge: 120,
//1秒更新次数
frame: 10,
//线渐变
color: {
0: 'rgba(255,255,0,0)',
1: '#ffff00'
},
//线宽度
lineWidth: 3
});
}

20241102_204252.gif

效果非常好,线段顺着风向在运动!

  • 上面的线段因为一段段渐变呈现出一个个小蝌蚪的样子,然而利用叠加保留的效果,可以自动将线段绘制渐变色。只需要改变一下绘制顺序就行
  
//线段绘制开始
ctx.beginPath();
//设置纯颜色
ctx.strokeStyle = this.color;
//遍历随机点更新位置
this.particles.forEach((p) => {
//同上面更新随机点的位置
//...

//起始点和终点转换成显示的画布大小
const start = this.getCanvasPos(p.x, p.y);
const end = this.getCanvasPos(p.tx, p.ty);
//通过moveTo和lineTo绘制多个线段
ctx.moveTo(start[0], start[1]);
ctx.lineTo(end[0], end[1]);
});
//最终统一绘制线段
ctx.stroke();

20241102_213723.gif

这样看上去流动线段连续性更强,不那么零散了!

6. 利用图片信息存储数据的优化

wind.json风场uv方向数据有739KB接近1MB,这着实有点大,要是网络稍微有点卡都会很影响首屏加载时间!从webgl-wind中我看到了用Canvas的ImageData中颜色来存储与解析数值,这操作太优秀了!

实现逻辑:用nx*ny与风场网格同样大小的canvas,获取到ImageData,将像素颜色四个数值中red红色和green绿色分别赋值成uv转换后的颜色值,注意透明度一定要置为不透明,然后put回canvas里面绘制,再利用canvas.toDataURL导出图片。

async function createCanvas() {
const data = await getData('./wind.json');
const info = await getData('info.json');
const canvas = document.getElementById('theCanvas');
canvas.width = info.nx;
canvas.height = info.ny;

const minU = Math.abs(info.minU);
const minV = Math.abs(info.minV);
// uv风方向范围
const uSize = info.maxU - info.minU;
const vSize = info.maxV - info.minV;
const ctx = canvas.getContext('2d');
//获取imageData像素数据
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
data.forEach((item, i) => {
//值转换成正数
const u = item[0] + minU;
const v = item[1] + minV;
//转换成颜色值
const r = (u / uSize) * 255;
const g = (v / vSize) * 255;
imageData.data[i * 4] = r;
imageData.data[i * 4 + 1] = g;
//透明度默认255即不透明
imageData.data[i * 4 + 3] = 255;
});
//用imageData像素颜色值绘制图片
ctx.putImageData(imageData, 0, 0);
}

wind.png

这样一张360px*181px的图片存储了65,160个点,但仅仅只需要86.6KB,压缩成原来数据的十分之一了。

  • 如果改用风场方向图片,那么对应需要添加加载和解析数据的流程

加载风场方向数据图片


loadImageData() {
return new Promise((resolve) => {
const image = new Image();
image.src = this.imageUrl;
image.onload = () => {
const c = document.createElement('canvas');
c.width = image.naturalWidth;
c.height = image.naturalHeight;
const ctx = c.getContext('2d');
//绘制图片
ctx.drawImage(image, 0, 0, image.naturalWidth, image.naturalHeight);
//获取ImageData像素数据
const imageData = ctx.getImageData(0, 0, image.naturalWidth, image.naturalHeight);

resolve(imageData.data);
};
});
}

解析图片数据成uv,并组装成风场网格Grid

data = await this.loadImageData();

const minU = Math.abs(header.minU);
const minV = Math.abs(header.minV);
//uv风方向范围
const uSize = header.maxU - header.minU;
const vSize = header.maxV - header.minV;

let index = 0;
for (let j = 0; j < header.ny; j++) {
const row = [];
for (let i = 0; i < header.nx; i++) {
//将颜色数据转化成风向uv数据
const u = (data[index] / 255) * uSize - minU;
const v = (data[index + 1] / 255) * vSize - minV;
row.push([u, v]);
index = index + 4;
}
this.grid.push(row);
}

后面的绘制风场逻辑跟上面一样,只不过多了个加载图片解析的过程。

20241102_214148.gif

加上一张世界地图底图可以更清晰得看到风流动的方向!

四、绘制3D风场图

1.利用Canvas风场贴图绘制3D风场图

  • 常规的顶点着色器
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.);

}
  • 片元着色器,要将世界底图与风场图合并成一张图
varying vec2 vUv;
uniform sampler2D windTex;
uniform sampler2D worldTex;
void main() {
vec4 color = texture2D(windTex, vUv);
float a = color.a;
if(a < 0.01) {
a = 0.;
}

vec4 w = texture2D(worldTex, vUv);
//根据透明度合并世界贴图和风场贴图
vec4 c = w * (1. - a) + color * a;

gl_FragColor = c;
}
  • 创建风场贴图
async createWindCanvas() {
const header = await getData('./info.json');
const canvas = document.createElement('canvas');
//要足够大,否则会贴图模糊
canvas.width = 4000;
canvas.height = 2000;
this.cw = new Windy({
header,
// data,
canvas,
//运动速度
speed: 0.1,
//随机点数量
particlesCount: 1000,
//生命周期
maxAge: 120,
//1秒更新次数
frame: 10,
//线渐变
// color: {
// 0: 'rgba(255,255,0,0)',
// 1: '#ffff00'
// },
color: '#ffff00',
//线宽度
lineWidth: 3,
imageUrl: 'wind.png'
//autoAnimate: true
});
const texture = new THREE.CanvasTexture(canvas);
//因为是动态canvas,所以要置为需要更新
texture.needsUpdate = true;
return texture;
}
  • 添加球体
async createChart(that) {
this.windTex = await this.createWindCanvas();

const worldTex = new THREE.TextureLoader().load('../assets/world.jpg');
{
const material = new THREE.ShaderMaterial({
uniforms: {
worldTex: { value: worldTex },

windTex: { value: this.windTex }
},
vertexShader: document.getElementById('vertexShader').innerHTML,
fragmentShader: document.getElementById('fragmentShader').innerHTML,
side: THREE.DoubleSide,
transparent: true
});

const geometry = new THREE.SphereGeometry(2, 32, 16);

const sphere = new THREE.Mesh(geometry, material);
this.scene.add(sphere);
}
}
  • 让canvas动起来
animateAction() {
if (this.windTex) {
if (this.cw) {
this.cw.render();
}
this.windTex.needsUpdate = true;
}
}

20241102_230324.gif

地球展开收起动画

  • 将顶点着色器替换成下面的,根据uv计算出压平后球体表面点的位置,然后用mix来让原来球体表面的点过渡变化

注意球体半圆周长,对应球体压平后矩形的宽度,球体贴图正好是2:1,长度对应宽度的两倍。

uniform float time;
uniform float radius;
varying vec2 vUv;
float PI = acos(-1.0);
void main() {
vUv = uv;
//半圆周长
float w = radius * PI;
//随着时间压平或收起球体点位置
vec3 newPosition = mix(position, vec3(0.0, (uv.y - 0.5) * w, -(uv.x - 0.5) * 2.0 * w), sin(time * PI * 0.5));
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
}
  • 展开或收起球体动画
openMap() {
const tw = new TWEEN.Tween({ time: 0.0 })
.to({ time: 1.0 }, 2000)
.onUpdate((obj) => {
if (this.mat) {
this.mat.uniforms.time.value = obj.time;
}
})
.start();
TWEEN.add(tw);
}
closeMap() {
const tw = new TWEEN.Tween({ time: 1.0 })
.to({ time: 0.0 }, 2000)
.onUpdate((obj) => {
if (this.mat) {
this.mat.uniforms.time.value = obj.time;
}
})
.start();
TWEEN.add(tw);
}

20241102_232847.gif

除了用贴图来实现,还能用three.js的BufferGeometry+LineSegments实现动态线段,进而实现3D风场图。

2.使用LineSegments绘制风场图

  • 顶点着色器
uniform vec2 uResolution;//nx与ny网格大小
uniform vec2 uSize;//显示的宽高
varying vec2 vUv;
void main() {
vUv = vec2(position.z);
// 转换为经纬度坐标
vec2 p = vec2(position.x, -position.y) - vec2(180., 90.);

gl_Position = projectionMatrix * modelViewMatrix * vec4((p / uResolution) * uSize + vec2(0., uSize.y), 0.0, 1.);
}

注意:地球的经纬度是从下往上变大的,而平面的坐标是从上往下变大的的,因此随机点的y坐标取反才是正确位置,因为取反的问题,位置会偏移,对应也要将整体位置加上偏移量归位。

  • 片元着色器
varying vec2 vUv;
uniform vec3 startColor;
uniform vec3 endColor;
void main() {
//渐变色
gl_FragColor = vec4(mix(startColor, endColor, vUv.y), 1.0);
}
  • 绘制线段LineSegments 将随机点的开始结束两个点位置分别赋值到线段position里面,并添加索引。
//点索引
const points = new Float32Array(num * 6);
let i = 0;

pointCallback: (p) => {
// 线段开始位置
points[i] = p.x;
points[i + 1] = p.y;
points[i + 2] = 0;//开始点z坐标标识是0
// 线段结束位置
points[i + 3] = p.tx;
points[i + 4] = p.ty;
points[i + 5] = 1;//结束点z坐标标识是1

//递增索引
i += 6;
}

添加LineSegments,一定要用LineSegments,因为LineSegments是绘制的线段是gl.LINES模式,就是每两个点一组,形成一个新线段,就是A,B,C,D四个点,就会变成AB一条线段,BC一条线段,就可以绘制多条线段了。

 const material = new THREE.ShaderMaterial({
uniforms: {
//nx和ny网格大小
uResolution: { value: new THREE.Vector2(this.cw.header.nx, this.cw.header.ny) },
//显示宽高大小
uSize: { value: new THREE.Vector2(20, 10) },
//渐变开始颜色
startColor: { value: new THREE.Color('#ffff00') },
//渐变结束颜色
endColor: { value: new THREE.Color('#ff0000') }
},
vertexShader: document.getElementById('vertexShader1').innerHTML,
fragmentShader: document.getElementById('fragmentShader').innerHTML,
side: THREE.DoubleSide,
transparent: true
});

const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(points, 3));
this.geometry = geometry;
this.mat = material;
//添加多个线段
const lines = new THREE.LineSegments(geometry, material);
this.scene.add(lines);

渲染的时候移动点的位置并给position属性赋值更新

if (this.frameCount % this.frame === 0 && this.cw && this.geometry) {
let i = 0;
const g = this.geometry;
this.cw.movePoints((p) => {
g.attributes.position.array[i] = p.x;
g.attributes.position.array[i + 1] = p.y;
g.attributes.position.array[i + 3] = p.tx;
g.attributes.position.array[i + 4] = p.ty;
i += 6;
});
//属性值改变一定要置true,通知更新
g.attributes.position.needsUpdate = true;
}

20241103_155901.gif

上面效果的风场图与canvas 2D风场图清空再绘制一样的效果,没有走destination-in叠加保留的过程,点的数量可能看起来偏少,因此为了保证风流向的连续性,最好增加随机点个数。

  • 将平面的LineSegments变成球体 修改一下定点着色器,经纬度坐标转换成三维坐标
float PI = 3.1415926;
float rad = 3.1415926 / 180.;
uniform vec2 uResolution;
uniform vec2 uSize;
//半径
uniform float radius;
//旋转翻过来
uniform mat4 rotateX;

varying vec2 vUv;
//经纬度坐标转为三维坐标
vec3 lnglat2pos(vec2 p) {
float lng = p.x * rad;
float lat = p.y * rad;
float x = cos(lat) * cos(lng);
float y = cos(lat) * sin(lng);
float z = sin(lat);
return vec3(x, z, y);
}
void main() {
vUv = vec2(position.z);
//转换成经纬度
vec2 p = vec2(position.x, -position.y) - vec2(180., 90.);
//经纬度转三维坐标
vec3 newPosition = radius * lnglat2pos(p);
gl_Position = projectionMatrix * modelViewMatrix *rotateX* vec4(newPosition, 1.);

}

注意

  1. three.js高度y轴坐标,那么对应三维坐标里面的z轴坐标,而three.js深度z轴坐标,那么对应三维坐标里面的y轴坐标,就是yz轴要对调一下,才是正确的点的位置,即vec3(x, z, y)
  2. position转经纬度,同上面一样需要将y取反才是正确的位置。 3.地球贴图贴在球体x方向开始位置有PI的偏移,需要将贴图设置一下偏移值才能对上经纬度坐标。
const worldTex = new THREE.TextureLoader().load('../assets/world.jpg');
worldTex.offset.x = 0.5;
worldTex.wrapS = THREE.RepeatWrapping;

4.因为y取反了,但在球体不能用位置偏移量解决归位问题,就会导致整个风流向路径反过来了,所以需要添加一个矩阵翻转量,让风流向路径回归正确的样子,

  const matrix = new THREE.Matrix4();
matrix.makeRotationX(Math.PI);

20241103_170442.gif

终于解决风场位置对齐的问题了!这点小细节调了好久!唉~

五、Github地址

https://github.com/xiaolidan00/my-earth

参考


作者:敲敲敲敲暴你脑袋
来源:juejin.cn/post/7433055938418933787
收起阅读 »

前端js中如何保护密钥?

web
在前端js编程中,如果涉及到加密通信、加密算法,经常会用到密钥。 但密钥,很容易暴露。 暴露原因:js代码透明,在浏览器中可以查看源码,从中找到密钥。 例如,下面的代码中,变量key是密钥: 如何保护源码中的密钥呢? 很多时候,人们认为需要对密钥字符串进行加...
继续阅读 »

在前端js编程中,如果涉及到加密通信、加密算法,经常会用到密钥


但密钥,很容易暴露。 暴露原因:js代码透明,在浏览器中可以查看源码,从中找到密钥。


例如,下面的代码中,变量key是密钥:



如何保护源码中的密钥呢?


很多时候,人们认为需要对密钥字符串进行加密。其实更重要的是对存储密钥的变量进行加密


加密了密钥变量,使变量难以找到,才更能保护密钥本身。


顺着这个思路,下面给出一个不错的密钥的保护方法:


还是以上面的代码为例,


首先,用到jsfuck:


https://www.jshaman.com/tools/jsfuck.html

将代码中的密钥定义整体,用jsfuck加密:


var key = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";


加密后得到一串奇怪的字符,这是将变量“key ”以及密钥字符“0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ”隐藏了起来



注意:加密时需要选中“在父作用域中运行”,选中之后,key 变量的定义虽然不存在,但变量key是可用的!(这点很神奇)。也就是虽然代码中没有定义这个变量,但这个变量存在,且可用。而且它存储的就是密钥!



用加密后的代码替换掉原来的代码,变成如下形式:



运行效果:



即时他人拿走代码去调试,也会显示变量key未定义,如下图所示:



但,这时候还不足够安全,还能更安全。


将整体JS代码,再用JS加密工具:JShaman,进行混淆加密:


https://www.jshaman.com


然后得到更安全、更难调试分析的JS代码,这时密钥就变的更安全了:



注:用ajax等异步传递密钥时,也可以使用这个办法,也能很好的隐藏密钥。


用jsfuck+jshaman保护JS中的密钥,你学会了吗?


作者:w2sfot
来源:juejin.cn/post/7431087851389747236
收起阅读 »

Fuse.js一个轻量高效的模糊搜索库

web
最近逛github的时候发现了一个非常好用的轻量工具库,Fuse.js,支持模糊搜索。感觉还是非常好用的,所以有了此篇博客,这篇文章主要是介绍Fuse的使用,同样,我对这个开源项目的实现也非常感兴趣。后续会出一篇Fuse源码解析的文章来分析其实现原理。 Fus...
继续阅读 »

最近逛github的时候发现了一个非常好用的轻量工具库,Fuse.js,支持模糊搜索。感觉还是非常好用的,所以有了此篇博客,这篇文章主要是介绍Fuse的使用,同样,我对这个开源项目的实现也非常感兴趣。后续会出一篇Fuse源码解析的文章来分析其实现原理。


Fuse.js是什么?


强大、轻量级的模糊搜索库,没有任何依赖关系。


什么是模糊搜索?


一般来说,模糊搜索(更正式的名称是近似字符串匹配)是查找与给定模式近似相等(而不是完全相等)的字符串的技术。


通常我们项目中的的模糊搜索大多数情况下有几种方案可用:



  • 前端工程通过正则表达式或者字符串匹配来实现

  • 调用后端接口去匹配搜索

  • 使用搜索引擎如:ElasticSearch或Algolia等


但是这些方案都有各自的缺陷,比如正则表达式和字符串匹配的效率较低,且无法处理复杂的搜索需求,而调用后端接口和搜索引擎虽然效率高,但是需要额外的服务器资源,且需要维护一套搜索引擎。


所以,Fuse.js的出现就是为了解决这些问题,它是一个轻量级的模糊搜索库,没有依赖关系,支持复杂的搜索需求,且效率高,当然Fuse并不适用于所有场景。


Fuse.js的使用场景


它可能不适用于所有情况,但根据您的搜索要求,它可能是最理想的。例如:



  • 当您想要对小型到中等大型数据集进行客户端模糊搜索时

  • 当您无法证明设置专用后端只是为了处理搜索时

  • ElasticSearch 或 Algolia 虽然都是很棒的服务,但对于您的特定用例来说可能有些过度


Fuse.js的使用


安装


Fuse支持多种安装方式


NPM


npm install fuse.js

Yarn


yarn add fuse.js

CDN 引入


<script src="https://cdn.jsdelivr.net/npm/fuse.js@7.0.0"></script>

引入


ES6 模块语法


import Fuse from 'fuse.js'

CommonJS 语法


const Fuse = require('fuse.js')


Tips: 使用npm或者yarn引入,支持两种模块语法引入,如果是使用cdn引入,那么Fuse将被注册为全局变量。直接使用即可



使用


以下是官网一个最简单的例子,只要简单的构造new Fuse对象,就能模糊搜索匹配到你想要的结果


// 1. List of items to search in
const books = [
{
title: "Old Man's War",
author: {
firstName: 'John',
lastName: 'Scalzi'
}
},
{
title: 'The Lock Artist',
author: {
firstName: 'Steve',
lastName: 'Hamilton'
}
}
]

// 2. Set up the Fuse instance
const fuse = new Fuse(books, {
keys: ['title', 'author.firstName']
})

// 3. Now search!
fuse.search('jon')

// Output:
// [
// {
// item: {
// title: "Old Man's War",
// author: {
// firstName: 'John',
// lastName: 'Scalzi'
// }
// },
// refIndex: 0
// }
// ]

从上述代码中可以看到我们要通过Fuse 对books的这个数组进行模糊搜索,构建的Fuse对象中,模糊搜索的key定义为['title', 'author.firstName'],支持对title及author.firstName这两个字段进行搜索。然后执行fuse的search API就能过滤出我们的期望结果。整体代码还是非常简单的。


高级配置


Demo示例只是提供了一个基础版本的模糊搜索。如果用户想获得更灵活的搜索能力,比如搜索结果排序、权重控制、搜索结果高亮等,那么就需要对Fuse进行一些高级配置。


Fuse的所有配置都是通过new Fuse时传入的参数来配置的,下面列举一些常用的配置项:


    const options = {
keys: ['title', 'author'], // 指定搜索key值,可多选
isCaseSensitive: false, //是否区分大小写 默认为false
includeScore: false, //结果集中是否展示匹配项的分数字段, 分数越大代表匹配程度越低,区间值为0-1,注意:当此项为true时,会返回完整的结果集,只不过每一项中携带了score分数字段
includeMatches: false, //匹配项是否应包含在结果中。当时true,结果的每条记录都包含匹配项的索引。这个通常我们用来对搜索内容做高亮处理
threshold: 0.6, // 阈值控制匹配的敏感度,默认值为0.6,如果要完全匹配这里要设置为0
shouldSort: true, // 是否对结果进行排序
location: 0, // 匹配的位置,0 表示开头匹配
distance: 100, // 搜索的最大距离
minMatchCharLength: 2, // 最小匹配字符长度
};

出了上述常用的一些配置项之外,Fuse还支持更高阶模糊搜索,如权重搜索,嵌套搜索,运算符拓展搜索,具体高阶用法可以参考官方文档。
Fuse的主要实现原理是通过改写Bitap 算法(近似字符串匹配)算法的内部实现来支撑其模糊搜索的算法依据,后续会出一篇文章看一下作者源码的算法实现。


总结


Fuse的文章到此就结束了,你没看错就这么一点介绍就基本能支撑我们在项目中的应用,谢谢阅读,如果哪里有不对的地方请评论博主,会及时进行改正。


欢迎访问我的博客地址


作者:VapausQi
来源:juejin.cn/post/7393172686115569705
收起阅读 »

Flutter 鸿蒙化 在一起 就可以

相关阅读: Flutter Love 鸿蒙 - 掘金 (juejin.cn) 不是鸿蒙 ArkUI 不会写,而是 Flutter 更有性价比 - 掘金 (juejin.cn) 前言 鸿蒙生态势如破竹,已有超4000应用加入,实现垂域全覆盖,商店里面的鸿蒙 ap...
继续阅读 »

相关阅读:


Flutter Love 鸿蒙 - 掘金 (juejin.cn)


不是鸿蒙 ArkUI 不会写,而是 Flutter 更有性价比 - 掘金 (juejin.cn)


前言


鸿蒙生态势如破竹,已有超4000应用加入,实现垂域全覆盖,商店里面的鸿蒙 app 也越来越多,就像余总说的一样,



在一起,就可以 !



OpenHarmony-SIG/flutter_flutter (gitee.com) 社区一直在致力于使用 Flutter 更加快速地适配鸿蒙平台。


而距离 不是鸿蒙 ArkUI 不会写,而是 Flutter 更有性价比 - 掘金 (juejin.cn) 已经有一段时间了,我们来看看 Flutter 鸿蒙化的进展如何了。



重要提示,Flutter 鸿蒙化,需要华为提供的真机和最新的SDK或者自己申请了开发者预览 Beta 招募,没有的,暂时不要尝试。



最近 华为纯血鸿蒙 HarmonyOS NEXT 开发者预览版首批 Beta 招募开启,支持 Mate 60 / Pro、X5 机型, 这给一些个人开发者提前体验鸿蒙 NEXT 的机会。



后续内容全部基于 OpenHarmony-SIG/flutter_flutter (gitee.com)OpenHarmony-SIG/flutter_engine (gitee.com)dev 分支。参考文档也以 dev 分支 的文档为准。另外最新支持的是 ohos api11



插件进度


现阶段 Flutter 适配工作主要集中在鸿蒙原生插件的适配。下面介绍一下已知完成适配的插件。


flutter_packages


OpenHarmony-SIG/flutter_packages (gitee.com) 是适配官方 flutter/packages: A collection of useful packages maintained by the Flutter team (github.com) 仓库。


引用方式例子如下:


dependencies:
path_provider:
git:
url: "https://gitee.com/openharmony-sig/flutter_packages.git"
path: "packages/path_provider/path_provider"

path_provider2.1.1官方库11月30日gitee.com/openharmony…
shared_preferences2.2.1官方库11月30日gitee.com/openharmony…
url_launcher6.1.11官方库11月30日gitee.com/openharmony…
image_picker1.0.4官方库12月30日gitee.com/openharmony…
local_auth2.1.6官方库12月30日gitee.com/openharmony…
pigeon11.0.1官方库12月30日gitee.com/openharmony…
webview_flutter4.2.4、4.4.4官方库12月30日gitee.com/openharmony…
video_player2.7.2官方库3月30日gitee.com/openharmony…
file_selector1.0.1官方库12月30日gitee.com/openharmony…
camera0.10.5官方库3月30日gitee.com/openharmony…

plus 插件


[Request]: support HarmonyOS · Issue #2480 · fluttercommunity/plus_plugins (github.com) 作者对于适配鸿蒙平台兴趣不大,所以这里决定 HarmonyCandies (github.com) 来维护。


wakelock_plus_ohos


地址:github.com/HarmonyCand…


引用:


dependencies:
wakelock_plus: 1.1.4
wakelock_plus_ohos: any

device_info_plus_ohos


地址:github.com/HarmonyCand…


引用:


dependencies:
device_info_plus: any
device_info_plus_ohos: any

注意,有 2uid 是系统级别的,需要应用单独申请。


  /// Requires permission: ohos.permission.sec.ACCESS_UDID (System permission, only open to system apps).
/// Device serial number.
/// 设备序列号。
final String serial;

/// Requires permission: ohos.permission.sec.ACCESS_UDID (System permission, only open to system apps).
/// Device Udid.
/// 设备Udid。
final String udid;

使用


import 'package:device_info_plus_ohos/device_info_plus_ohos.dart';

final DeviceInfoOhosPlugin deviceInfoOhosPlugin = DeviceInfoOhosPlugin();

OhosDeviceInfo deviceInfo = await deviceInfoOhosPlugin.ohosDeviceInfo;

// Requires permission: ohos.permission.sec.ACCESS_UDID (System permission, only open to system apps).
OhosAccessUDIDInfo accessUDIDInfo = await deviceInfoOhosPlugin.ohosAccessUDIDInfo;

network_info_plus_ohos


地址:github.com/HarmonyCand…


引用:


dependencies:
network_info_plus: any
network_info_plus_ohos: any

在你的项目的 module.json5 文件中增加以下权限设置。


    requestPermissions: [
{"name" : "ohos.permission.INTERNET"},
{"name" : "ohos.permission.GET_WIFI_INFO"},
],

sensors_plus_ohos


地址:github.com/HarmonyCand…


引用:


dependencies:
sensors_plus: 4.0.2
sensors_plus_ohos: any

在你的项目的 module.json5 文件中增加以下权限设置。


    requestPermissions: [
{"name" : "ohos.permission.ACCELEROMETER"},
{"name" : "ohos.permission.GYROSCOPE"},
],

connectivity_plus_ohos


地址:github.com/HarmonyCand…


引用:


dependencies:
connectivity_plus: 5.0.2
connectivity_plus_ohos: any

在你的项目的 module.json5 文件中增加以下权限设置。


    requestPermissions: [
{"name" : "ohos.permission.INTERNET"},
{"name" : "ohos.permission.GET_NETWORK_INFO"},
],

battery_plus_ohos


地址:github.com/HarmonyCand…


引用:


dependencies:
battery_plus: 5.0.3
battery_plus_ohos: any

package_info_plus_ohos


地址:github.com/HarmonyCand…


引用:


dependencies:
package_info_plus: 4.2.0
package_info_plus_ohos: any

糖果插件


flutter_image_compress


地址:github.com/fluttercand…


引用:


dependencies:
flutter_image_compress: ^2.2.0

FeatureAndroidiOSWebmacOSOpenHarmony
method: compressWithList
method: compressAssetImage
method: compressWithFile
method: compressAndGetFile
format: jpeg
format: png
format: webp[🌐][webp-compatibility]
format: heic
param: quality[🌐][webp-compatibility]
param: rotate
param: keepExif

flutter_image_editor


地址:github.com/fluttercand…


引用:


dependencies:
image_editor: ^2.2.0

FeatureAndroidiOSOpenHarmony
flip
crop
rotate
scale
matrix
mix image
merge multi image
draw point
draw line
draw rect
draw circle
draw path
draw Bezier
Gaussian blur

flutter_photo_manager


地址:github.com/fluttercand…


引用:


注意 photo_manager_image_provider 需要限制一下版本。


dependencies:
photo_manager: ^3.1.0
dependency_overrides:
photo_manager_image_provider: ^1.1.1

暂时支持下面的功能,目前鸿蒙只支持图片和视频 2 种资源类型。


FeatureOpenHarmony
getAssetPathList
getAssetCountFromPath
fetchPathProperties
getAssetCount
getAssetListPaged
getOriginBytes
getThumb
getAssetListRange
getAssetsByRange
deleteWithIds
getColumnNames
saveImage
saveImageWithPath
saveVideo
requestPermissionExtend
ignorePermissionCheck
log
notify

其他插件


permission_handler_ohos


地址:github.com/HarmonyCand…


引用:


dependencies:
permission_handler_ohos: any

权限列表来自: gitee.com/openharmony…


注意

由于 OpenHarmonyHarmonyOS 的权限差异以及鸿蒙版本的高速迭代,检查请求权限的 api 是传递的权限的字符串全称,如果你发现 PermissionOhos 枚举中没有某个权限,你可以直接传递权限的字符串全称。等鸿蒙版本稳定下来了,会再同步权限列表到枚举中。


权限枚举列表是由文档自动生成的。


// GENERATED CODE - DO NOT MODIFY MANUALLY
// **************************************************************************
// Auto generated by https://github.com/HarmonyCandies/permission_handler_ohos/bin/main.dart
// **************************************************************************
// https://gitee.com/openharmony/docs/blob/OpenHarmony-4.1-Release/zh-cn/application-dev/security/AccessToken/permissions-for-all.md
// ignore_for_file: constant_identifier_names,slash_for_doc_comments

/// The Permissions of OpenHarmony
/// total: 44
enum PermissionOhos {
/// ohos.permission.USE_BLUETOOTH
///
/// 允许应用查看蓝牙的配置。
///
/// 权限级别:normal
///
/// 授权方式:system_grant
///
/// ACL使能:true
///
/// 起始版本:8

use_bluetooth(
name: 'ohos.permission.USE_BLUETOOTH',
permissionLevel: 'normal',
grantType: 'system_grant',
aclEnabled: true,
startVersion: 8,
),

使用

请认真阅读官方关于权限的文档 gitee.com/openharmony…


在你的项目的 module.json5 文件中增加对应需要权限设置,比如:


    requestPermissions: [
{ name: "ohos.permission.READ_CALENDAR" },
{ name: "ohos.permission.WRITE_CALENDAR" },
],

例子

检查权限状态


import 'package:device_info_plus_ohos/device_info_plus_ohos.dart';

final PermissionStatusOhos status =
await PermissionHandlerOhos.checkPermissionStatus(
PermissionOhos.read_calendar.name);

请求单个权限


    final PermissionStatusOhos status =
await PermissionHandlerOhos.requestPermission(
PermissionOhos.read_calendar.name,
);

请求多个权限


    final Map<String, PermissionStatusOhos> statusMap =
await PermissionHandlerOhos.requestPermissions([
PermissionOhos.read_calendar.name,
PermissionOhos.write_calendar.name,
]);

打开设置页面


   PermissionHandlerOhos.openAppSettings();

audio_streamer_ohos


地址:github.com/HarmonyCand…


引用:


dependencies:
audio_streamer: 4.1.1
audio_streamer_ohos: any

audio_streamer 在 OpenHarmony 平台上的实现


在 OpenHarmony 项目的 module.json 文件中添加 ohos.permission.MICROPHONE 权限


{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.MICROPHONE",
"reason": "Microphone permission is required to record audio."
}
]
}
}

geolocator


地址: HarmonyCandies/geolocator_ohos: The OpenHarmony implementation of geolocator. (github.com)


引用:


dependencies:
geolocator: any
geolocator_ohos: ^0.0.1

在你的项目的 module.json5 文件中增加以下权限设置。


    "requestPermissions": [
{"name" : "ohos.permission.KEEP_BACKGROUND_RUNNING"},
{
"name": "ohos.permission.LOCATION",
"reason": "$string:EntryAbility_label",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
},
{
"name": "ohos.permission.APPROXIMATELY_LOCATION",
"reason": "$string:EntryAbility_label",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
},
{
"name": "ohos.permission.LOCATION_IN_BACKGROUND",
"reason": "$string:EntryAbility_label",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
},
]

鸿蒙特有的方法


CountryCode? countryCode= await geolocatorOhos.getCountryCode();

(逆)地理编码转化


    final position = await geolocatorOhos.getCurrentPosition(
locationSettings: const CurrentLocationSettingsOhos(
priority: LocationRequestPriority.firstFix,
scenario: LocationRequestScenario.unset,
),
);

// ohos only
if (await geolocatorOhos.isGeocoderAvailable()) {
//
var addresses = await geolocatorOhos.getAddressesFromLocation(
ReverseGeoCodeRequest(
latitude: position.latitude,
longitude: position.longitude,
locale: 'zh',
maxItems: 1,
),
);

for (var address in addresses) {
if (kDebugMode) {
print('ReverseGeoCode address:$address');
}
var position = await geolocatorOhos.getAddressesFromLocationName(
GeoCodeRequest(description: address.placeName ?? ''),
);
if (kDebugMode) {
print('geoCode position:$position');
}
}
}

vibration


地址:flutter_vibration/vibration_ohos at master · benjamindean/flutter_vibration (github.com)


引用:


dependencies:
vibration: any
vibration_ohos: any

在你的项目的 module.json5 文件中增加以下权限设置。


    "requestPermissions": [
{"name" : "ohos.permission.VIBRATE"},
]

vibrateEffect and vibrateAttribute are only exist in VibrationOhos.


 (VibrationPlatform.instance as VibrationOhos).vibrate(
vibrateEffect: const VibratePreset(count: 100),
vibrateAttribute: const VibrateAttribute(
usage: 'alarm',
),
);

sqflite


地址:gitee.com/openharmony…


引用:


dependencies:
sqflite:
git:
url: "https://gitee.com/openharmony-sig/flutter_sqflite.git"
path: "sqflite"

fluttertoast


地址:gitee.com/openharmony…


引用:


dependencies:
fluttertoast:
git:
url: "https://gitee.com/openharmony-sig/flutter_fluttertoast.git"

audio_session


地址:gitee.com/openharmony…


引用:


dependencies:
audio_session:
git:
url: "https://gitee.com/openharmony-sig/flutter_audio_session.git"

flutter_sound


地址:gitee.com/openharmony…


引用:


dependencies:
flutter_sound:
git:
url: "https://gitee.com/openharmony-sig/flutter_sound.git"
path: "flutter_sound"

image_gallery_saver


地址:gitee.com/openharmony…


引用:


dependencies:
image_gallery_saver:
git:
url: "https://gitee.com/openharmony-sig/flutter_image_gallery_saver.git"

location


地址:gitee.com/openharmony…


引用:


dependencies:
location:
git:
url: "https://gitee.com/openharmony-sig/flutter_location.git"
path: "location"

power_image


地址:gitee.com/openharmony…


引用:


dependencies:
power_image:
git:
url: "https://gitee.com/openharmony-sig/flutter_power_image.git"

flutter_native_image


地址:gitee.com/openharmony…


引用:


dependencies:
flutter_native_image:
git:
url: "https://gitee.com/openharmony-sig/flutter_native_image.git"

audioplayers


地址:gitee.com/openharmony…


引用:


dependencies:
audioplayers:
git:
url: "https://gitee.com/openharmony-sig/flutter_audioplayers.git"

image_crop


地址:gitee.com/openharmony…


引用:


dependencies:
image_crop:
git:
url: "https://gitee.com/openharmony-sig/flutter_image_crop.git"

bitmap


地址:gitee.com/openharmony…


引用:


dependencies:
bitmap:
git:
url: "https://gitee.com/openharmony-sig/flutter_bitmap.git"

leak_detector


地址:gitee.com/openharmony…


引用:


dependencies:
leak_detector:
git:
url: "https://gitee.com/openharmony-sig/flutter_leak_detector.git"

flutter_contacts


地址:gitee.com/openharmony…


引用:


dependencies:
flutter_contacts:
git:
url: "https://gitee.com/openharmony-sig/flutter_contacts.git"

纯 Flutter 库


extended_text


dependencies:
extended_text: 10.0.1-ohos

extended_text_field


dependencies:
extended_text_field: 11.0.1-ohos

flutter_platform_utils


HarmonyCandies/flutter_platform_utils: A utility to check the platform for ohos (github.com)


如果您的库支持 OpenHarmony 平台,并且有 Platform.isOhos 的判断,那么建议换成 PlatformUtils.isOhos 避免对其他非鸿蒙用户在非鸿蒙分支编译的影响。


一些注意事项


关于鸿蒙的 context


在制作插件中,你可能需要用到 2context


ApplicationContex


你可以直接从 onAttachedToEngine 方法中获取。


  private context: Context | null = null;

onAttachedToEngine(binding: FlutterPluginBinding): void {
this.context = binding.getApplicationContext();
}

onDetachedFromEngine(binding: FlutterPluginBinding): void {
this.context = null;
}

context 可以用于获取 applicationInfo 等属性。


let applicationInfo = this.context.applicationInfo;


UIAbilityContext


插件继承 AbilityAware 并且在 onAttachedToAbility 方法中获取。



export default class XXXPlugin implements FlutterPlugin, MethodCallHandler, AbilityAware {
private _uiContext: common.UIAbilityContext | null = null;

onAttachedToAbility(binding: AbilityPluginBinding): void {
this._uiContext = binding.getAbility().context;
}

onDetachedFromAbility(): void {
this._uiContext = null;
}
}

uiContext 可以用于获取 applicationInfo 等属性。


photoAccessHelper.getPhotoAccessHelper(PhotoManagerPlugin.uiContext);


关于插件参数传递


按照以前的习惯,dart 端传递 map 参数,原生端根据 map 解析参数。


但由于 ts 支持将字符串直接转换成对应的 interface ,那么我们可以将 dart 的端的参数。


参数定义


比如 geolocator_ohos 中的 CurrentLocationSettingsOhosdart 端的实现为如下:


  Map<String, dynamic> toMap() {
return {
if (priority != null) 'priority': priority?.toInt(),
if (scenario != null) 'scenario': scenario?.toInt(),
if (maxAccuracy != null) 'maxAccuracy': maxAccuracy,
if (timeoutMs != null) 'timeoutMs': timeoutMs,
};
}

@override
String toString() {
return jsonEncode(toMap());
}

而在鸿蒙原生端,对于的 interfaceCurrentLocationRequest


export interface CurrentLocationRequest {
priority?: LocationRequestPriority;
scenario?: LocationRequestScenario;
maxAccuracy?: number;
timeoutMs?: number;
}

值得注意的是,如果参数为 null,不要传递过去,比如 'priority': null , 如果传递过去,鸿蒙原生端会解析错误。不传递过去的话,会解析为 undefined,这也对应了 priority?: LocationRequestPriority 可选的意思。



可以使用 chatgpt 直接将鸿蒙的 interface 转换成 dart 的类,并且增加 toMapfromMap,和注释。



插件传递


dart 端,将参数类以字符串的方式传递过去,并且用字符串的方式接受返回值。


  @override
Future<Position> getCurrentPosition({
LocationSettings? locationSettings,
String? requestId,
}) async {
assert(
locationSettings == null ||
locationSettings is CurrentLocationSettingsOhos,
'locationSettings should be CurrentLocationSettingsOhos',
);

try {
final Duration? timeLimit = locationSettings?.timeLimit;

Future<dynamic> positionFuture =
GeolocatorOhos._methodChannel.invokeMethod(
'getCurrentPosition',
locationSettings?.toString(),
);

if (timeLimit != null) {
positionFuture = positionFuture.timeout(timeLimit);
}

return PositionOhos.fromString(await positionFuture);
}
}

在鸿蒙端, 将字符串直接转换成鸿蒙对应的 interface


let request: geoLocationManager.CurrentLocationRequest = JSON.parse(args);


并且将要返回的 interface 转换成字符串。


result.success(JSON.stringify(location));


当然了,这样有个问题,就是如果鸿蒙端修改了 interface 的属性名字,插件很难感知到(当然会报错)。


关于做 Flutter 鸿蒙化的一些环境要求



重要提示,Flutter 鸿蒙化,需要华为提供的真机和最新的SDK或者自己申请了开发者预览 Beta 招募,没有的,暂时不要尝试。



Xcode15


如果你的电脑升级了 Xcode15,在做编译引擎的时候,也许会遇到下面的错误。


/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX14.4.sdk/System/Library/Frameworks/Foundation.framework/Headers/NSObjCRuntime.h:657:37: error: use of undeclared identifier 'NSIntegerMax'
static const NSInteger NSNotFound = NSIntegerMax;

或者


../../third_party/dart/runtime/bin/security_context_macos.cc:188:17: error: use of undeclared identifier 'noErr'
if (status != noErr) {
^
../../third_party/dart/runtime/bin/security_context_macos.cc:196:19: error: use of undeclared identifier 'noErr'
if (status != noErr) {
^
../../third_party/dart/runtime/bin/security_context_macos.cc:205:17: error: use of undeclared identifier 'noErr'
if (status != noErr) {
^
../../third_party/dart/runtime/bin/security_context_macos.cc:303:21: error: use of undeclared identifier 'noErr'
OSStatus status = noErr;
^
../../third_party/dart/runtime/bin/security_context_macos.cc:319:23: error: use of undeclared identifier 'noErr'
status == noErr && (trust_result == kSecTrustResultProceed ||
^

解决办法是从下面地址选择 13.3 sdk 中的 TargetConditionals.h


macos-sdk/MacOSX13.3.sdk/usr/include/TargetConditionals.h at main · alexey-lysiuk/macos-sdk (github.com)


替换掉你本地的,注意做备份。


/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/TargetConditionals.h


说点其他


关于 Google 裁员


QQ图片20240504115445.jpg


最近很多人都在问,传着传着就变成 Google 要解散 Flutter 团队。不管是哪个公司都会有裁员的哪一天,与其犹豫不决,还不如笃定前行。


总有人年轻


没有人永远年轻,但总有人年轻。最近 NBA 季后赛,你不得不承认,他们都老了。过年的时候出去玩,把娃放肩膀上面驮着,脖子肩膀酸痛了,2天才恢复。有那么一刻,确实感觉自己也不再年轻了。
尽管时间会在我们身上留下痕迹,但当我们投身于自己热爱的事业或兴趣时,心态和精神永远年轻。



扶我起来,我还能写代码。那你,是什么时候发现自己不再年轻的?



结语


跟当年 Flutter 社区一样,我们也是从一点点慢慢变好的。5年前,Flutter Candies 一桶天下 - 掘金 (juejin.cn),社区开始慢慢壮大。现在我们也将继续在新的领域汇集在一起 不是鸿蒙 ArkUI 不会写,而是 Flutter 更有性价比 - 掘金 (juejin.cn)



如果你是喜欢分享的,请加入我们;如果你需要分享的,也请加入我们。



鸿蒙,爱糖果,欢迎加入Harmony Candies,一起生产可爱的鸿蒙小糖果QQ群:981630644


harmony_candies.png


作者:法的空间
来源:juejin.cn/post/7364698043910930443
收起阅读 »

还学鸿蒙原生?vue3 + uniapp 可以直接开发鸿蒙啦!

Hello,大家好,我是 Sunday7月20号,uniapp 官网“悄咪咪”的上线了 uniapp 开发鸿蒙应用 的文档,算是正式开启了 Vue3 + uniapp 开发鸿蒙应用 的时代。开发鸿蒙的前置准备想要使用 u...
继续阅读 »

Hello,大家好,我是 Sunday

7月20号,uniapp 官网“悄咪咪”的上线了 uniapp 开发鸿蒙应用 的文档,算是正式开启了 Vue3 + uniapp 开发鸿蒙应用 的时代。

开发鸿蒙的前置准备

想要使用 uniapp 开发鸿蒙,我们需要具备三个条件:

  1. DevEco-Studio 5.0.3.400 以上(下载地址:https://developer.huawei.com/consumer/cn/deveco-studio/
  2. 鸿蒙系统版本 API 12 以上 (DevEco-Studio有内置鸿蒙模拟器)
  3. HBuilderX-alpha-4.22 以上

PS: 这里不得不吐槽一下,一个 DevEco-Studio 竟然有 10 个 G......

安装好之后,我们就可以通过 开发工具 运行 示例代码

运行时,需要用到 鸿蒙真机或者模拟器。但是这里需要 注意: Windows系统需要经过特殊配置才可以启动,mac 系统最好保证系统版本在 mac os 12 以上

windows 系统配置方式(非 windows 用户可跳过):

打开控制面板 - 程序与功能 - 开启以下功能

  1. Hyper-V
  2. Windows 虚拟机监控程序平台
  3. 虚拟机平台

注意: 需要win10专业版或win11专业版才能开启以上功能,家庭版需先升级成专业版或企业版

启动鸿蒙模拟器

整个过程分为三步(中间会涉及到鸿蒙开发者申请):

  1. 下载 uni-app 鸿蒙离线SDK template-1.3.4.tgz (下载地址:https://web-ext-storage.dcloud.net.cn/uni-app/harmony/zip/template-1.3.4.tgz
  2. 解压刚下载的压缩包,将解压后的模板工程在 DevEco-Studio 中打开

  1. 等待 Sync 结束,再 启动鸿蒙模拟器 或 连接鸿蒙真机(如无权限,则需要申请(一般 3 个工作日),申请地址:https://developer.huawei.com/consumer/cn/activity/201714466699051861/signup

配置 HBuilderX 吊起 DevEco-Studio

打开HBuilderX,点击上方菜单 - 工具 - 设置,在出现的弹窗右侧窗体新增如下配置

注意:值填你自己的 DevEco-Studio 启动路径

"harmony.devTools.path" : "/Applications/DevEco-Studio.app"

创建 uni-app 工程

  1. BuilderX 新建一个空白的 uniapp 项目,选vue3
  2. 在 manifest.json 文件中配置鸿蒙离线SDK路径(SDK 路径可在 DevEco-Studio -> Preferences(设置) z中获取)

编辑 manifest.json 文件,新增如下配置:

然后点击 运行到鸿蒙即可

总结

这样我们就有了一个初始的鸿蒙项目,并且可以在鸿蒙模拟器上运行。关于更多 uniapp 开发鸿蒙的 API,大家可以直接参考 uniapp 官方文档:https://zh.uniapp.dcloud.io/tutorial/harmony/dev.html#nativeapi


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

如果有人在你的论坛、博客,乱留言、乱回复,怎么办?

作者:小傅哥 博客:bugstack.cn 沉淀、分享、成长,让自己和他人都能有所收获!😄 哈喽,大家好我是技术UP主小傅哥。 常听到一句话:你很难赚到你认知以外的钱💰,屁!不是很难,是压根赚不到。 你以为要是你做也能做,但其实除了你能看见的以外,还有很多...
继续阅读 »

作者:小傅哥
博客:bugstack.cn



沉淀、分享、成长,让自己和他人都能有所收获!😄



哈喽,大家好我是技术UP主小傅哥。


常听到一句话:你很难赚到你认知以外的钱💰,屁!不是很难,是压根赚不到。 你以为要是你做也能做,但其实除了你能看见的以外,还有很多东西都不知道。





我看过不少小伙伴自己上线过带有评论功能的博客,或是能进行通信的聊天室。但最后都没运营多久就关停了,除了能花钱解决的服务器成本,还有是自身的研发的系统流程不够健全。其中非常重要的一点是舆情敏感内容的审核,如果你做这类应用的处理,一定要对接上相应的内容安全审核。


那么,接下来小傅哥就给大家分享下,如何对接内容安全审核,并在 DDD 分层结构下实现一个对应的规则过滤服务。



文末提供了「星球:码农会锁」🧧优惠加入方式,以及本节课程的代码地址。项目演示地址:gaga.plus



一、场景说明


在本节小傅哥会通过 DDD 分层架构设计,开发出一个敏感词、内容安全审核过滤操作的规则处理器。在这个过程大家可以学习到 DDD 分层调用流程、规则模型的搭建、敏感词和内容审核的使用。





如图,上半部分是业务流程,下半部分是 DDD 分层结构中的实现。



  1. 业务流程上,以用户发送的提交给服务端的内容进行审核过滤,优先使用敏感词进行替换单词组。过滤后过内容审核,一般各个云平台都有提供内容审核的接口,如;京东云、百度云、腾讯云都有提供。一般价格在 0.0015 元/条

  2. 系统实现上,以 DDD 分层架构实现一个内容审核的流程。app 配置组件和启动应用、trigger 提供 http 调用、domain 编写核心逻辑和流程、infrastructure 提供 dao 的基础操作。


二、内容审核 - SDK 使用


一般舆情内容审核分为两种,一种是静态配置数据的 SDK 组件,也叫敏感词过滤。另外一种是实时动态的由各个第三方提供的内容审核接口服务。这类的就是前面提到的,在各个云平台都有提供。


这里小傅哥先带着大家做下最基本的调用案例,之后再基于 DDD 工程实现整个代码开发。


1. 敏感词


地址github.com/houbb/sensi… - 开源的敏感词库组件


<dependency>
<groupId>com.github.houbb</groupId>
<artifactId>sensitive-word</artifactId>
<version>0.8.0</version>
</dependency>

案例代码


@Test
public void test_sensitive_word() {
boolean contains = sensitiveWordBs.contains("小傅哥喜欢烧烤臭毛蛋,豆包爱吃粑粑,如果想吃订购请打电话:13900901878");
log.info("是否被敏感词拦截:{}", contains);
}

@Test
public void test_sensitive_word_findAll() {
List<String> list = sensitiveWordBs.findAll("小傅哥喜欢烧烤臭毛蛋,豆包爱吃粑粑,如果想吃订购请打电话:13900901878");
log.info("测试结果:{}", JSON.toJSONString(list));
}

@Test
public void test_sensitive_word_replace() {
String replace = sensitiveWordBs.replace("小傅哥喜欢烧烤臭毛蛋,豆包爱吃粑粑,如果想吃订购请打电话:13900901878");
log.info("测试结果:{}", replace);
}


  • 敏感词组件提供了大量的风险词过滤,同时可以基于组件的文档完成自定义敏感词的增改删减操作。本文在工程中已提供

  • 敏感词组件提供了判断、查找、过滤操作。还有你可以把检测到的敏感词替换为 * 或者空格


2. 内容审核



这里小傅哥以其中的一个百度云为例,为大家展示内容安全审核的使用。


<!-- 百度内容审核 https://mvnrepository.com/artifact/com.baidu.aip/java-sdk -->
<dependency>
<groupId>com.baidu.aip</groupId>
<artifactId>java-sdk</artifactId>
<version>4.16.17</version>
</dependency>

2.1 配置应用






  • 先领取免费的调用次数,之后创建应用。创建应用后就可以获得连接信息;appid、apikey、secretkey

  • 另外是策略配置,如果你在过滤中不需要检测用户发的应用营销信息,那么是可以不检测的。


2.2 测试服务


//设置APPID/AK/SK
public static final String APP_ID = "{APP_ID}";
public static final String API_KEY = "{API_KEY}";
public static final String SECRET_KEY = "{SECRET_KEY}";
private AipContentCensor client;

@Before
public void init() {
client = new AipContentCensor(APP_ID, API_KEY, SECRET_KEY);
// 可选:设置网络连接参数
client.setConnectionTimeoutInMillis(2000);
client.setSocketTimeoutInMillis(60000);
}

@Test
public void test_textCensorUserDefined() throws JSONException {
for (int i = 0; i < 1; i++) {
JSONObject jsonObject = client.textCensorUserDefined("小傅哥喜欢烧烤臭毛蛋,豆包爱吃粑粑,如果想吃订购请打电话:13900901878");
if (!jsonObject.isNull("error_code")) {
log.info("测试结果:{}", jsonObject.get("error_code"));
} else {
log.info("测试结果:{}", jsonObject.toString());
}
}
}

测试结果


13:41:16.393 [main] INFO com.baidu.aip.client.BaseClient - get access_token success. current state: STATE_AIP_AUTH_OK
13:41:16.396 [main] DEBUG com.baidu.aip.client.BaseClient - current state after check priviledge: STATE_TRUE_AIP_USER
13:41:16.495 [main] INFO cn.bugstack.x.api.test.BaiduAipContentCensorTest - 测试结果:{"conclusion":"合规","log_id":17046060767025067,"isHitMd5":false,"conclusionType":1}


  • 应为过滤掉了营销信息,比如手机号。那么就会返回合规


三、应用实现 - DDD 架构


做了以上的基本调用案例以后,我们来看下在系统中怎么运用这些基础功能完成业务诉求。


1. 工程结构






  • docs 下提供了 docker 安装 mysql 以及初始化数据库配置的脚本。因为本文的案例,可以满足你在数据库中增加敏感词配置。

  • app 是应用的启动层,如上我们所需的敏感词和内容审核,都在app层下配置启动处理。

  • domain 领域层通过策略+工厂,实现规则过滤服务。


2. 数据库表






  • 在docs 提供了数据库初始化的脚本语句,你可以导入到自己的数据库,或者使用 docker 脚本安装测试。—— 注意已经安装过 mysql 占用了 3306 端口的话,记得修改 docker 脚本安装 mysql 的端口。

  • 配置到数据库中的敏感词方便管理和使用,为了性能考虑也可以考虑使用 redis 做一层缓存。


3. 配置加载


3.1 敏感词初始化


@Configuration
public class SensitiveWordConfig {

@Bean
public SensitiveWordBs sensitiveWordBs(IWordDeny wordDeny, IWordAllow wordAllow) {
return SensitiveWordBs.newInstance()
.wordDeny(wordDeny)
.wordAllow(wordAllow)
.ignoreCase(true)
.ignoreWidth(true)
.ignoreNumStyle(true)
.ignoreChineseStyle(true)
.ignoreEnglishStyle(true)
.ignoreRepeat(false)
.enableNumCheck(true)
.enableEmailCheck(true)
.enableUrlCheck(true)
.enableWordCheck(true)
.numCheckLen(1024)
.init();
}

@Bean
public IWordDeny wordDeny(ISensitiveWordDao sensitiveWordDao) {
return new IWordDeny() {
@Override
public List<String> deny() {
return sensitiveWordDao.queryValidSensitiveWordConfig("deny");
}
};
}

@Bean
public IWordAllow wordAllow(ISensitiveWordDao sensitiveWordDao) {
return new IWordAllow() {
@Override
public List<String> allow() {
return sensitiveWordDao.queryValidSensitiveWordConfig("allow");
}
};
}

}


  • wordDeny、wordAllow 是两个自定义的拦截和放行的敏感词列表,这里小傅哥设计从数据库中查询。可以方便动态的维护。


3.2 内容安全初始化


# 内容安全
baidu:
aip:
app_id: 46573000
api_key: XKOalQOgDBUrvgLBplvu****
secret_key: kwRh1bEhETYWpq9thzyySdFDPKUk****


  • 自定义一个配置文件类 AipContentCensorConfigProperties


@Bean
public AipContentCensor aipContentCensor(AipContentCensorConfigProperties properties) {
AipContentCensor client = new AipContentCensor(properties.getApp_id(), properties.getApi_key(), properties.getSecret_key());
client.setConnectionTimeoutInMillis(2000);
client.setSocketTimeoutInMillis(60000);
return client;
}


  • 这里我们来统一创建 AipContentCensor 对象,用于有需要使用的地方处理内容审核。


4. 规则实现


源码: cn.bugstack.xfg.dev.tech.domain.service.IRuleLogicFilter


public interface IRuleLogicFilter {

RuleActionEntity<RuleMatterEntity> filter(RuleMatterEntity ruleMatterEntity);

}


  • 定义一个统一的规则过滤接口


4.1 敏感词


@Slf4j
@Component
@LogicStrategy(logicMode = DefaultLogicFactory.LogicModel.SENSITIVE_WORD)
public class SensitiveWordFilter implements IRuleLogicFilter {

@Resource
private SensitiveWordBs words;

@Override
public RuleActionEntity<RuleMatterEntity> filter(RuleMatterEntity ruleMatterEntity) {
// 敏感词过滤
String content = ruleMatterEntity.getContent();
String replace = words.replace(content);
// 返回结果
return RuleActionEntity.<RuleMatterEntity>builder()
.type(LogicCheckTypeVO.SUCCESS)
.data(RuleMatterEntity.builder().content(replace).build())
.build();
}

}

4.2 安全内容


@Slf4j
@Component
@LogicStrategy(logicMode = DefaultLogicFactory.LogicModel.CONTENT_SECURITY)
public class ContentSecurityFilter implements IRuleLogicFilter {

@Resource
private AipContentCensor aipContentCensor;

@Override
public RuleActionEntity<RuleMatterEntity> filter(RuleMatterEntity ruleMatterEntity) {
JSONObject jsonObject = aipContentCensor.textCensorUserDefined(ruleMatterEntity.getContent());
if (!jsonObject.isNull("conclusion") && "不合规".equals(jsonObject.get("conclusion"))) {
return RuleActionEntity.<RuleMatterEntity>builder()
.type(LogicCheckTypeVO.REFUSE)
.data(RuleMatterEntity.builder().content("内容不合规").build())
.build();
}
// 返回结果
return RuleActionEntity.<RuleMatterEntity>builder()
.type(LogicCheckTypeVO.SUCCESS)
.data(ruleMatterEntity)
.build();
}

}

5. 工厂使用


public class DefaultLogicFactory {

public Map<String, IRuleLogicFilter> logicFilterMap = new ConcurrentHashMap<>();

public DefaultLogicFactory(List<IRuleLogicFilter> logicFilters) {
logicFilters.forEach(logic -> {
LogicStrategy strategy = AnnotationUtils.findAnnotation(logic.getClass(), LogicStrategy.class);
if (null != strategy) {
logicFilterMap.put(strategy.logicMode().getCode(), logic);
}
});
}

public RuleActionEntity<RuleMatterEntity> doCheckLogic(RuleMatterEntity ruleMatterEntity, LogicModel... logics) {
RuleActionEntity<RuleMatterEntity> entity = null;
for (LogicModel model : logics) {
entity = logicFilterMap.get(model.code).filter(ruleMatterEntity);
if (!LogicCheckTypeVO.SUCCESS.equals(entity.getType())) return entity;
ruleMatterEntity = entity.getData();
}
return entity != null ? entity :
RuleActionEntity.<RuleMatterEntity>builder()
.type(LogicCheckTypeVO.SUCCESS)
.data(ruleMatterEntity)
.build();
}

}


  • 定义出规则的使用工厂,通过构造函数的方式注入已经实现了接口 IRuleLogicFilter 的 N 个规则,注入到 Map 中 Map<String, IRuleLogicFilter> logicFilterMap

  • doCheckLogic 根据入参来过滤需要处理的规则。这里可以看到每过滤一个规则都会把参数继续传递给下一个规则继续筛选。有点像层层过筛子的感觉


四、测试验证



  1. 测试前确保已经初始化了库表 docs/dev-ops/sql/xfg-dev-tech-content-moderation.sql

  2. application-dev.yml 配置百度内容安全参数和数据库连接参数。


1. 功能测试


@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class RuleLogicTest {

@Resource
private DefaultLogicFactory defaultLogicFactory;

@Test
public void test() {
RuleActionEntity<RuleMatterEntity> entity = defaultLogicFactory.doCheckLogic(
RuleMatterEntity.builder().content("小傅哥喜欢烧烤臭毛蛋,豆包爱吃粑粑,如果想吃订购请打电话:13900901878").build(),
DefaultLogicFactory.LogicModel.SENSITIVE_WORD,
DefaultLogicFactory.LogicModel.CONTENT_SECURITY
);
log.info("测试结果:{}", JSON.toJSONString(entity));
}

}

测试结果


24-01-07.14:17:16.988 [main            ] INFO  BaseClient             - get access_token success. current state: STATE_AIP_AUTH_OK
24-01-07.14:17:17.328 [main ] INFO RuleLogicTest - 测试结果:{"data":{"content":"小傅哥喜欢烧烤***,豆包爱吃**,如果想吃订购请打电话:13900901878"},"type":"SUCCESS"}

2. 接口测试


@RequestMapping(value = "sensitive/rule", method = RequestMethod.GET)
public String rule(String content) {
try {
log.info("内容审核开始 content: {}", content);
RuleActionEntity<RuleMatterEntity> entity = defaultLogicFactory.doCheckLogic(RuleMatterEntity.builder().content(content).build(),
DefaultLogicFactory.LogicModel.SENSITIVE_WORD,
DefaultLogicFactory.LogicModel.CONTENT_SECURITY
);
log.info("内容审核完成 content: {}", entity.getData());
return JSON.toJSONString(entity);
} catch (Exception e) {
log.error("内容审核异常 content: {}", content, e);
return "Err!";
}
}

接口:http://localhost:8091/api/v1/content/sensitive/rule?content=小傅哥喜欢烧烤臭毛蛋,豆包爱吃粑粑,如果想吃订购请打电话:13900901878






  • 那么现在就可以对内容进行审核过滤了。


六、推荐阅读



作者:小傅哥
来源:juejin.cn/post/7322156683467112499
收起阅读 »

Android - 你可能需要这样一个日志库

前言 目前大多数库api设计都是Log.d("tag", "msg")这种风格,而且支持自定义日志存储的比较少, 所以作者想自己造一个轮子。 这种api风格有什么不好呢? 首先,它的tag是一个字符串,需要开发人员严格管理tag,要不然可能各种硬编码的tag满...
继续阅读 »

前言


目前大多数库api设计都是Log.d("tag", "msg")这种风格,而且支持自定义日志存储的比较少,
所以作者想自己造一个轮子。


这种api风格有什么不好呢?


首先,它的tag是一个字符串,需要开发人员严格管理tag,要不然可能各种硬编码的tag满天飞。


另外,它也可能导致性能陷阱,假设有这么一段代码:


// 打印一个List
Log.d("tag", list.joinToString())

此处使用Debug打印日志,生产模式下调高日志等级,不打印这一行日志,但是list.joinToString()这一行代码仍然会被执行,有可能导致性能问题。


下文会分析作者期望的api是什么样的,本文演示代码都是用kotlin,库中好用的api也是基于kotlin特性来实现的。


作者写库有个习惯,对外开放的类或者全局方法都会加一个前缀f,一个是为了避免命名冲突,另一个是为了方便代码检索,以下文章中会出现,这里做一下解释。


期望


什么样的api才能解决上面的问题呢?我们看一下方法的签名和打印方式


inline fun <reified T : FLogger> flogD(block: () -> Any)

interface AppLogger : FLogger

flogD<AppLogger> {
list.joinToString { it }
}

flogD方法打印Debug日志,传一个Flogger的子类AppLogger作为日志标识,同时传一个block来返回要打印的日志内容。


日志标识是一个类或者接口,所以管理方式比较简单不会造成tag混乱的问题,默认tag是日志标识类的短类名。生产模式下调高日志等级后,block就不会被执行了,避免了可能的性能问题。


实现分析


日志库的完整实现已经写好了,放在这里xlog



  • 支持限制日志大小,例如限制每天只能写入10MB的日志

  • 支持自定义日志格式

  • 支持自定义日志存储,即如何持久化日志


这一节主要分析一下实现过程中遇到的问题。


问题:如果App运行期间日志文件被意外删除了,怎么处理?


在Android中,用java.io的api对一个文件进行写入,如果文件被删除,继续写入的话不会抛异常,这样会导致日志丢失,该如何解决?


有同学说,在写入之前先检查文件是否存在,如果存在就继续写入,不存在就创建后写入。


检查一个文件是否存在通常是调用java.io.File.exist()方法,但是它比较耗性能,我们来做一个测试:


measureTime {
repeat(1_0000) {
file.exists()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

14:50:33.536 MainActivity            com.sd.demo.xlog                I  time:39
14:50:35.872 MainActivity com.sd.demo.xlog I time:54
14:50:38.200 MainActivity com.sd.demo.xlog I time:43
14:50:40.028 MainActivity com.sd.demo.xlog I time:53
14:50:41.693 MainActivity com.sd.demo.xlog I time:58

可以看到1万次调用的耗时在50毫秒左右。


我们再测试一下对文件写入的耗时:


val output = filesDir.resolve("log.txt").outputStream().buffered()
val log = "1".repeat(50).toByteArray()
measureTime {
repeat(1_0000) {
output.write(log)
output.flush()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

14:57:56.092 MainActivity            com.sd.demo.xlog                I  time:38
14:57:56.558 MainActivity com.sd.demo.xlog I time:57
14:57:57.129 MainActivity com.sd.demo.xlog I time:57
14:57:57.559 MainActivity com.sd.demo.xlog I time:46
14:57:58.054 MainActivity com.sd.demo.xlog I time:54

可以看到1万次调用,每次写入50个字符的耗时也在50毫秒左右。如果每次写入日志前都判断一下文件是否存在,那么实际上相当于2次写入的性能成本,这显然很不划算。


还有同学说,开一个线程,定时判断文件是否存在,这样子虽然不会损耗单次写入的性能,但是又多占用了一个线程资源,显然也不符合作者的需求。


其实Android已经给我们提供了这种场景的解决方案,那就是android.os.MessageQueue.IdleHandler,关于IdleHandler这里就不展开讨论了,简单来说就是当你在主线程注册一个IdleHandler后,它会在主线程空闲的时候被执行。


我们可以在每次写入日志之后注册IdleHandler,等IdleHandler被执行的时候检查一下日志文件是否存在,如果不存在就关闭输出流,这样子在下一次写入的时候就会重新创建文件写入了。


这里要注意每次写入日志之后注册IdleHandler,并不是每次都创建新对象,要判断一下如果原先的对象还未执行的话就不用注册一个新的IdleHandler,库中大概的代码如下:


private class LogFileChecker(private val block: () -> Unit) {
private var _idleHandler: IdleHandler? = null

fun register(): Boolean {
// 如果当前线程没有Looper则不注册,上层逻辑可以直接检查文件是否存在,因为是非主线程
Looper.myLooper() ?: return false

// 如果已经注册过了,直接返回
_idleHandler?.let { return true }

val idleHandler = IdleHandler {
// 执行block检查任务
libTryRun { block() }

// 重置变量,等待下次注册
_idleHandler = null
false
}

// 保存并注册idleHandler
_idleHandler = idleHandler
Looper.myQueue().addIdleHandler(idleHandler)
return true
}
}

这样子文件被意外删除之后,就可以重新创建写入了,避免丢失大量的日志。


问题:如何检测文件大小是否溢出


库支持对每天的日志大小做限制,例如限制每天最多只能写入10MB,每次写入日志之后都会检查日志大小是否超过限制,通常我们会调用java.io.File.length()方法获取文件的大小,但是它也比较耗性能,我们来做一个测试:


val file = filesDir.resolve("log.txt").apply {
this.writeText("hello")
}
measureTime {
repeat(1_0000) {
file.length()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

16:56:04.090 MainActivity            com.sd.demo.xlog                I  time:61
16:56:05.329 MainActivity com.sd.demo.xlog I time:80
16:56:06.382 MainActivity com.sd.demo.xlog I time:72
16:56:07.496 MainActivity com.sd.demo.xlog I time:79
16:56:08.591 MainActivity com.sd.demo.xlog I time:78

可以看到耗时在60毫秒左右,相当于上面测试中1次文件写入的耗时。


库中支持自定义日志存储,在日志存储接口中定义了size()方法,上层通过此方法来判断当前日志的大小。


如果自定义了日志存储,避免在此方法中每次调用java.io.File.length()来返回日志大小,应该维护一个表示日志大小的变量,变量初始化的时候获取一下java.io.File.length(),后续通过写入的数量来增加这个变量的值,并在size()方法中返回。库中默认的日志存储实现类就是这样实现的,有兴趣的可以看这里


问题:文件大小溢出后怎么处理?


假设限制每天最多只能写入10MB,那超过10MB后如何处理?有同学说直接删掉或者清空文件,重新写入,这也是一种策略,但是会丢失之前的所有日志。


例如白天写了9.9MB,到晚上的时候写满10MB,清空之后,白天的日志都没了,这时候用户反馈白天遇到的一个bug,需要上传日志,那就芭比Q了。


有没有办法少丢失一些呢?可以把日志分多个文件存储,为了便于理解假设分为2个文件存储,一天10MB,那1个文件最多只能写入5MB。具体步骤如下:



  1. 写入文件20231128.log

  2. 20231128.log写满5MB的时候关闭输出流,并把它重命名为20231128.log.1


这时候继续写日志的话,发现20231128.log文件不存在就会创建,又跳到了步骤1,就这样一直重复1和2两个步骤,到晚上写满10MB的时候,至少还有5MB的日志内容保存在20231128.log.1文件中避免丢失全部的日志。


分的文件数量越多,保留的日志就越多,实际上就是拿出一部分空间当作中转区,满了就向后递增数字重命名备份。目前库中只分为2个文件存储,暂时不开放自定义文件数量。


问题:打印日志的性能


性能,是这个库最关心的问题,通常来说文件写入操作是性能开销的大头,目前是用java.io相关的api来实现的,怎样提高写入性能作者也一直在探索,在demo中提供了一个基于内存映射的日志存储方案,但是稳定性未经测试,后续测试通过后可能会转正。有兴趣的读者可以看看这里


还有一个比较影响性能的就是日志的格式化,通常要把一个时间戳转为某个日期格式,大部分人都会用java.text.SimpleDateFormat来格式化,用它来格式化年:月:日的时候问题不大,但是如果要格式化时:分:秒.毫秒那它就比较耗性能,我们来做一个测试:


val format = SimpleDateFormat("HH:mm:ss.SSS")
val millis = System.currentTimeMillis()
measureTime {
repeat(1_0000) {
format.format(millis)
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

16:05:26.920 MainActivity            com.sd.demo.xlog                I  time:245
16:05:27.586 MainActivity com.sd.demo.xlog I time:227
16:05:28.324 MainActivity com.sd.demo.xlog I time:212
16:05:29.370 MainActivity com.sd.demo.xlog I time:217
16:05:30.157 MainActivity com.sd.demo.xlog I time:193

可以看到1万次格式化耗时大概在200毫秒左右。


我们再用java.util.Calendar测试一下:


val calendar = Calendar.getInstance()
// 时间戳1
val millis1 = System.currentTimeMillis()
// 时间戳2
val millis2 = millis1 + 1000
// 切换时间戳标志
var flag = true
measureTime {
repeat(1_0000) {
calendar.timeInMillis = if (flag) millis1 else millis2
calendar.run {
"${get(Calendar.HOUR_OF_DAY)}:${get(Calendar.MINUTE)}:${get(Calendar.SECOND)}.${get(Calendar.MILLISECOND)}"
}
flag = !flag
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

16:11:25.342 MainActivity            com.sd.demo.xlog                I  time:35
16:11:26.209 MainActivity com.sd.demo.xlog I time:35
16:11:27.316 MainActivity com.sd.demo.xlog I time:37
16:11:28.057 MainActivity com.sd.demo.xlog I time:25
16:11:28.825 MainActivity com.sd.demo.xlog I time:18


这里解释一下为什么要用两个时间戳,因为Calendar内部有缓存,如果用同一个时间戳测试的话,没办法评估它真正的性能,所以这里每次格式化之后就切换到另一个时间戳,避免缓存影响测试。


可以看到1万次的格式化耗时在30毫秒左右,差距很大。如果要自定义日志格式的话,建议用Calendar来格式化时间,有更好的方案欢迎和作者交流。


问题:日志的格式如何显示


手机的存储资源是宝贵的,如何定义日志格式也是一个比较重要的细节。



  • 优化时间显示


目前库内部是以天为单位来命名日志文件的,例如:20231128.log,所以在格式化时间戳的时候只保留了时:分:秒.毫秒,避免冗余显示当天的日期。



  • 优化日志等级显示


打印的时候提供了4个日志等级:Verbose, Debug, Info, Warning, Error,一般最常用的记录等级是Info,所以在格式化的时候如果等级是Info则不显示等级标志,规则如下:


private fun FLogLevel.displayName(): String {
return when (this) {
FLogLevel.Verbose -> "V"
FLogLevel.Debug -> "D"
FLogLevel.Warning -> "W"
FLogLevel.Error -> "E"
else -> ""
}
}


  • 优化日志标识显示


如果连续2条或多条日志都是同一个日志标识,那么就只有第1条日志会显示日志tag



  • 优化线程ID显示


如果是主线程的话,不显示线程ID,只有非主线程才显示线程ID


经过上面的优化之后,日志打印的格式是这样的:


flogI<AppLogger> { "1" }
flogI<AppLogger> { "2" }
flogW<AppLogger> { "3" }
flogI<UserLogger> { "user debug" }
thread {
flogI<UserLogger> { "thread" }
}

19:19:43.961[AppLogger] 1
19:19:43.974 2
19:19:43.975[W] 3
19:19:43.976[UserLogger] user debug
19:19:43.977[12578] thread

API


这一节介绍一下库的API,调用FLog.init()方法初始化,初始化如果不想打印日志,可以调用FLog.setLevel(FLogLevel.Off)关闭日志


常用方法


// 初始化
FLog.init(
//(必传参数)日志文件目录
directory = filesDir.resolve("app_log"),

//(可选参数)自定义日志格式
formatter = AppLogFormatter(),

//(可选参数)自定义日志存储
storeFactory = AppLogStoreFactory(),

//(可选参数)是否异步发布日志,默认值false
async = false,
)

// 设置日志等级 All, Verbose, Debug, Info, Warning, Error, Off 默认日志等级:All
FLog.setLevel(FLogLevel.All)

// 限制每天日志文件大小(单位MB),小于等于0表示不限制大小,默认限制每天日志大小100MB
FLog.setLimitMBPerDay(100)

// 设置是否打打印控制台日志,默认打开
FLog.setConsoleLogEnabled(true)

/**
* 删除日志,参数saveDays表示要保留的日志天数,小于等于0表示删除全部日志,
* 此处saveDays=1表示保留1天的日志,即保留当天的日志
*/

FLog.deleteLog(1)

打印日志


interface AppLogger : FLogger

flogV<AppLogger> { "Verbose" }
flogD<AppLogger> { "Debug" }
flogI<AppLogger> { "Info" }
flogW<AppLogger> { "Warning" }
flogE<AppLogger> { "Error" }

// 打印控制台日志,不会写入到文件中,不需要指定日志标识,tag:DebugLogger
fDebug { "console debug log" }

配置日志标识


可以通过FLog.config方法修改某个日志标识的配置信息,例如下面的代码:


FLog.config<AppLogger> {
// 修改日志等级
this.level = FLogLevel.Debug

// 修改tag
this.tag = "AppLoggerAppLogger"
}

自定义日志格式


class AppLogFormatter : FLogFormatter {
override fun format(record: FLogRecord): String {
// 自定义日志格式
return record.msg
}
}

interface FLogRecord {
/** 日志标识 */
val logger: Class<out FLogger>

/** 日志tag */
val tag: String

/** 日志内容 */
val msg: String

/** 日志等级 */
val level: FLogLevel

/** 日志生成的时间戳 */
val millis: Long

/** 日志是否在主线程生成 */
val isMainThread: Boolean

/** 日志生成的线程ID */
val threadID: String
}

自定义日志存储


日志存储是通过FLogStore接口实现的,每一个FLogStore对象负责管理一个日志文件。
所以需要提供一个FLogStore.Factory工厂为每个日志文件提供FLogStore对象。


class AppLogStoreFactory : FLogStore.Factory {
override fun create(file: File): FLogStore {
return AppLogStore(file)
}
}

class AppLogStore(file: File) : FLogStore {
// 添加日志
override fun append(log: String) {}

// 返回当前日志的大小
override fun size(): Long = 0

// 关闭
override fun close() {}
}

结束


库目前还处于alpha阶段,如果有遇到问题可以及时反馈给作者,最后感谢大家的阅读。


作者邮箱:565061763@qq.com


作者:Sunday1990
来源:juejin.cn/post/7306423214493270050
收起阅读 »

Flutter 为什么没有一款好用的UI框架?

哈喽,我是老刘 前两天,系统给我推送了一个问题。 我理解提问者真正想问的是:有没有一个不用学习那么多UI组件和渲染知识,可以简单快速搭建UI的东西。 Flutter 包括原生开发,为什么需要考虑那么多细节,不能做的简单一些? 首先,我们需要明白Flutter...
继续阅读 »

哈喽,我是老刘

前两天,系统给我推送了一个问题。


image.png


我理解提问者真正想问的是:有没有一个不用学习那么多UI组件和渲染知识,可以简单快速搭建UI的东西。


Flutter 包括原生开发,为什么需要考虑那么多细节,不能做的简单一些?


首先,我们需要明白Flutter的定位。

Flutter不是一个简单的甜品,而是一个能支撑大型系统开发的工程级框架。

这种定位和原生框架的定位是相当的。

因此,它要求整个框架有足够的灵活性,能适用于尽可能多的场景。


image.png


那么,如何提供足够的灵活性呢?

答案是让整个框架尽可能多的细节是可控的。

这就需要把整个框架的功能拆分的更细,提供的配置项足够多。

然而,这样的缺点就是开发起来会比较麻烦,需要控制很多细节。

因此,我们可以看到Flutter的组件拆分的很细,甚至有类似Padding这样专门负责缩进的组件,而且每个组件都有很多的配置参数。


Flutter配合Material组件库本身本就非常优秀的UI框架


虽然Flutter的灵活性带来了开发上的复杂性,但Flutter配合Material组件库本身就是一个非常优秀的UI框架。


image.png


Material组件库提供了丰富的预设组件,这些组件遵循Material Design指南,可以帮助开发者快速搭建出既美观又符合设计规范的UI界面。

使用Material组件库,开发者可以不必从头开始设计每一个UI元素,而是可以直接使用现成的组件,如按钮、对话框、卡片等,这些组件都有良好的交互和动画效果。

此外,Material组件库还提供了主题支持,开发者可以通过简单的配置,快速应用统一的风格到整个应用中。

因此,虽然Flutter的灵活性可能让初学者感到有些复杂,但配合Material组件库,Flutter实际上提供了一个非常高效和优秀的UI开发体验。


大型项目的正确打开方式


即便是Material组件库,它的设计是需要考虑应对各种不同类型app开发的,但是针对一个具体的项目,我们大多数时候不需要这样高的灵活性。

所以,这种情况下直接用Flutter提供的组件效率会比较低。

解放方法就是针对特定的项目做组件封装。


以我目前维护的项目为例,我们项目中所有的对话框都是相同的偏绿色调,圆角半径20,按钮大小固定,标题、详情的字体、字号也固定。

简单来说,就是所有的UI细节都是固定的,只是不同的dialog需要填充的文字不同。


这时候,我们就会定义一个自己的Dialog组件,只需要使用者传入标题和内容,以及设置按钮的回调即可。

UI的其他地方也是如此,比如页面框架、在多个页面都能用到的用户卡片、商品卡片等等。


当你的整个App大部分都是基于这些自定义组件进行搭积木式的开发,那开发效率是不是比找一些通用的UI框架更高呢?


总结


总而言之,Flutter因为它的工程级框架定位需要提供高度的灵活性,而这往往会导致开发细节的复杂性。

但是,通过针对具体项目的组件封装,我们可以大大提高开发效率,同时保持UI的一致性和项目的特定需求。

所以,与其寻找一个通用的UI框架,不如根据项目的具体需求进行自定义组件的开发。


如果看到这里的同学有学习Flutter的兴趣,欢迎联系老刘,我们互相学习。

点击免费领老刘整理的《Flutter开发手册》,覆盖90%应用开发场景。

可以作为Flutter学习的知识地图。

覆盖90%开发场景的《Flutter开发手册》


作者:程序员老刘
来源:juejin.cn/post/7387001928209170447
收起阅读 »