注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

分享我使用两年的极简网页记事本

web
若无单独说明,按照文章代码块中命令的顺序,一条一条执行,即可实现目标。 适用系统:Debian 系发行版,包括 Ubuntu 和 Armbian,其他发行版按流程稍改命令一般也可。 走通预计时间:10 分钟(Docker) 可以访问这个实...
继续阅读 »

若无单独说明,按照文章代码块中命令的顺序,一条一条执行,即可实现目标。 适用系统:Debian 系发行版,包括 Ubuntu 和 Armbian,其他发行版按流程稍改命令一般也可。




走通预计时间:10 分钟(Docker)





可以访问这个实例: https://forward.vfly.app/index.php ,试试怎么样,公开使用的网页记事本。


minimalist-web-notepad



image.png

image.png



这个网页记事本是我 2 年前玩机子初期的一大驱动力。当时主要从手机上浏览信息,刚转变到在电脑上处理信息,需要一种简便的渠道在两者之间传递文本、网址。




网盘太重,微信需要验证,tg 很好,但在找到这个记事本后,都是乐色,这就是最好的全平台传递文本的工具。



极简网页 记事本,是一个使用浏览器访问的轻量好用的记事本,专注于文本记录。


Github:pereorga/minimalist-web-notepad: Minimalist Web Notepad (github.com)




使用方法





  1. 访问网页: https://forward.vfly.app/index.php



  2. 它会随机分配 5 个字符组成的地址,如 https://forward.vfly.app/5b79m ,如果想指定地址,只需要访问时手动修改,如 https://forward.vfly.app/this_is_a_path 。下面以 5b79m 为例。



  3. 在上面编辑文本



  4. 等待一会(几秒,取决于延迟),服务端就会存储网页内容到名为 5b79m 的文件里。



  5. 关闭网页,如果关闭太快,会来不及保存,丢失编辑。



  6. 在其他平台再访问同样的网址,就能剪切内容了 ٩۹(๑•̀ω•́ ๑)۶



只要不关闭过快和在两个网页同时编辑,它都能很好地工作。因为极简,项目作者不会考虑增加多余功能。




webnote-in-phone_compressed.webp

webnote-in-phone_compressed.webp


在远控其他电脑时,用这个先传递命令,在目标电脑上使用,非常方便,而且适应性强。多个手机之间也一样。或者用于临时传送敏感数据,避免受到平台审查。


使用 Docker 安装网页 记事本


GitHub: pereorga/minimalist-web-notepad at docker (github.com)


全复制并执行,一键创建工作目录并开放端口


myserve="webnote"
sudo ufw allow 8088/tcp comment $myserve && sudo ufw reload
cd ~/myserve/
wget https://github.com/pereorga/minimalist-web-notepad/archive/refs/heads/docker.zip
unzip docker.zip && mv minimalist-web-notepad-docker webnote
cd webnote

根据注释自定义,然后执行,一键创建 docker-compose.yml 文件


cat > docker-compose.yml << EOF
---

version: "2.4"
services:
  minimalist-web-notepad:
    build: .
    container_name: webnote
    restart: always
    ports:
     - "8088:80"
    volumes:
     - ./_tmp:/var/www/html/_tmp
EOF

前面的 5b79m 就存储在 _tmp 中。


构建并启动容器(完成后就可以访问网页了,通过 http://ip_addr_or_domain:8088 访问。将 ip_addr_or_domain 替换为服务器的 IP 或域名)


docker compose up -d

Docker 版很久没更新了,有技术的可以参考博文中原生安装流程创建镜像。


迁移


数据都在 /var/www/webnote/_tmp 中,也就是 ~/myserve/webnote/_tmp,在新机子上重新部署一遍,复制这个目录到新机子上即可。




受限于篇幅,如果对原生安装(Apache + PHP)网页 记事本 感兴趣,请到我的博客浏览,链接在下面。



原文链接: https://blog.vfly2.com/2023/08/a-minimalist-web-notepad-used-for-two-years/ 版权声明:本博客所有文章除特別声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 承飞之咎 (blog.vfly2.com)


作者:AhFei
来源:mdnice.com/writing/611872e312654a22aa2472d71a6b3844
收起阅读 »

当我遇见了强制横屏签字的需求...

web
序言 人的一生就是进行尝试,尝试的越多,生活就越美好。——爱默生 在前一阶段的工作中,突然接到了这个需求:手写签批的页面在移动端竖屏时强制页面横屏展示进行签字,一开始我觉着只要将页面使用 CSS3 的 transform 进行 rotate 一下就可以了...
继续阅读 »

序言



人的一生就是进行尝试,尝试的越多,生活就越美好。——爱默生



在前一阶段的工作中,突然接到了这个需求:手写签批的页面在移动端竖屏时强制页面横屏展示进行签字,一开始我觉着只要将页面使用 CSS3 的 transform 进行 rotate 一下就可以了,但是当我尝试后发现并不是像我想象的那样简单。


vue2实现手写签批


在介绍横屏签字之前,我想先说明一下我实现签批使用的插件以及插件所调用的方法,这样在之后说到横屏签字的时候,大佬们不会感觉唐突。


vue-signature-pad


项目使用 vue-signature-pad 插件进行签名功能实现,强调一下如果使用vue2进行开发,安装的 vue-signature-pad 的版本我自测 2.0.5 是可以的


安装


npm i vue-signature-pad@2.0.5

引入


// main.js
import Vue from 'vue'
import App from './App.vue'

import VueSignaturePad from 'vue-signature-pad'

Vue.use(VueSignaturePad)

Vue.config.productionTip = false

new Vue({
render: (h) => h(App),
}).$mount('#app')

使用 vue-signature-pad 完成签批功能


这里我为了方便直接写了一个demo放到App.vue中,没有封装成组件


// app.vue
<template>
<div id="app">
<div style="background: #fff">
<vue-signature-pad
id="signature"
width="95%"
height="400px"
ref="signaturePad"
:options="options"
/>

</div>

<button @click="save">保存</button>
<button @click="resume">重置</button>
</div>

</template>

<script>
export default {
name: 'App',
data() {
return {
options: {
penColor: '#000',
},
}
},
methods: {
save() {
const { isEmpty, data } = this.$refs.signaturePad.saveSignature()
console.log(isEmpty)
console.log(data)
},

//清除重置
resume() {
this.$refs.signaturePad.clearSignature()
},
},
}
</script>


<style lang="scss">
html,
body {
padding: 0;
margin: 0;
}
#app {
width: 100vw;
height: 100vh;
background: #ececec;
}
</style>



代码比较通俗易懂,就是调用组件封装好的方法,保存后能够解构出data为base64编码的图片
Kapture 2023-07-28 at 10.27.49.gif
之后需要将base64编码格式转换成File文件格式的图片最后进行接口请求,那么转换方法如下展示👇🏻


<template>
<div id="app">
<div style="background: #fff">
<vue-signature-pad
id="signature"
width="95%"
height="300px"
ref="signaturePad"
:options="options"
/>

</div>

<div v-for="(item, index) in imgList" :key="index">
<img :src="item.src" alt="" width="100" />
</div>

<button @click="save" class="btn">保存</button>
<button @click="resume" class="btn">重置</button>
</div>

</template>

<script>
export default {
name: 'App',
data() {
return {
options: {
penColor: '#000',
},
imgList: [],
}
},
methods: {
save() {
const { isEmpty, data } = this.$refs.signaturePad.saveSignature()
this.imgList.push({
src: data,
})
let res = this.dataURLtoFile(data, 'demo')
console.log(res)
},

// 清除重置
resume() {
this.$refs.signaturePad.clearSignature()
},

// 将base64转换为文件
dataURLtoFile(dataurl, filename) {
var arr = dataurl.split(','),
mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]),
n = bstr.length,
u8arr = new Uint8Array(n)

while (n--) {
u8arr[n] = bstr.charCodeAt(n)
}

return new File([u8arr], filename, { type: mime })
},
},
}
</script>


<style lang="scss">
html,
body {
padding: 0;
margin: 0;
}
#app {
width: 100vw;
height: 100vh;
background: #ececec;
}

.btn {
width: 35%;
color: #fff;
background: #5daaf3;
border: none;
height: 40px;
border-radius: 20px;
margin-top: 20px;
margin-left: 40px;
}
</style>


调用后,打印出转换成文件的图片如图


image.png
之后根据需求调用接口将文件图片作为入参即可。


阶段总结


经过上面的操作,我们就实现了前端的签批的完整流程,还是比较容易理解的。




新的需求


在实现这个功能不久之后,客户那边提出了新的需求:手机竖屏时将签字功能进行横屏展示。


错误思路


刚开始接到这个需求的时候,通过我所掌握的技术首先就是想到用CSS3的transform:rotate方法进行页面90deg的旋转,将签字组件也进行旋转之后进行签名;由于我对canvas不是很了解,所以我把包裹在签字组件外的div标签进行了旋转后签字发现落笔点位置错乱。


    <div style="background: #fff; transform: rotate(-90deg)">
<vue-signature-pad
id="signature"
width="95%"
height="300px"
ref="signaturePad"
:options="options"
/>

</div>


改变思路


既然不能旋转外层的div,那我想到一种欺骗方式:不旋转div,样式修改成与横屏样式相似,然后将生成的图片进行一个旋转,这样就ok了!那么我们的目标就明确了,找到能够旋转bas64编码的方法然后返回一个旋转后的base64图片在转换成file文件传递给后端问题就解决了。

经过一个苦苦寻找,终于找到了方法并实现了这个功能,话不多说,先撸为敬(样式大佬们自己改下,我这里展示下转换后的图片)。


<template>
<div id="app">
<div style="background: #fff">
<vue-signature-pad
id="signature"
width="95%"
height="300px"
ref="signaturePad"
:options="options"
/>

</div>

<div v-for="(item, index) in imgList" :key="index">
<img :src="item.src" alt="" width="100" />
</div>

<div class="buttons">
<button @click="save" class="btn">保存</button>
<button @click="resume" class="btn">重置</button>
</div>
</div>

</template>

<script>
export default {
name: 'App',
data() {
return {
options: {
penColor: '#000',
},
imgList: [],
fileList: [],
}
},
methods: {
save() {
const { isEmpty, data } = this.$refs.signaturePad.saveSignature()

this.rotateBase64Img(data, 90, (res) => {
console.log(res) // 旋转后的base64图片src
this.fileList.push({
file: this.dataURLtoFile(res, 'sign'),
name: 'sign',
})
this.imgList.push({
src: res,
})
})
},

// 清除重置
resume() {
this.$refs.signaturePad.clearSignature()
},

// 将base64转换为文件
dataURLtoFile(dataurl, filename) {
var arr = dataurl.split(','),
mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]),
n = bstr.length,
u8arr = new Uint8Array(n)

while (n--) {
u8arr[n] = bstr.charCodeAt(n)
}

return new File([u8arr], filename, { type: mime })
},

// 通过canvas旋转图片
rotateBase64Img(src, edg, callback) {
var canvas = document.createElement('canvas')
var ctx = canvas.getContext('2d')

var imgW //图片宽度
var imgH //图片高度
var size //canvas初始大小

if (edg % 90 != 0) {
console.error('旋转角度必须是90的倍数!')
throw '旋转角度必须是90的倍数!'
}
edg < 0 && (edg = (edg % 360) + 360)
const quadrant = (edg / 90) % 4 //旋转象限
const cutCoor = { sx: 0, sy: 0, ex: 0, ey: 0 } //裁剪坐标

var image = new Image()

image.crossOrigin = 'anonymous'
image.src = src

image.onload = function () {
imgW = image.width
imgH = image.height
size = imgW > imgH ? imgW : imgH

canvas.width = size * 2
canvas.height = size * 2
switch (quadrant) {
case 0:
cutCoor.sx = size
cutCoor.sy = size
cutCoor.ex = size + imgW
cutCoor.ey = size + imgH
break
case 1:
cutCoor.sx = size - imgH
cutCoor.sy = size
cutCoor.ex = size
cutCoor.ey = size + imgW
break
case 2:
cutCoor.sx = size - imgW
cutCoor.sy = size - imgH
cutCoor.ex = size
cutCoor.ey = size
break
case 3:
cutCoor.sx = size
cutCoor.sy = size - imgW
cutCoor.ex = size + imgH
cutCoor.ey = size + imgW
break
}

ctx.translate(size, size)
ctx.rotate((edg * Math.PI) / 180)
ctx.drawImage(image, 0, 0)

var imgData = ctx.getImageData(
cutCoor.sx,
cutCoor.sy,
cutCoor.ex,
cutCoor.ey
)

if (quadrant % 2 == 0) {
canvas.width = imgW
canvas.height = imgH
} else {
canvas.width = imgH
canvas.height = imgW
}
ctx.putImageData(imgData, 0, 0)
callback(canvas.toDataURL())
}
},
},
}
</script>


<style lang="scss">
html,
body {
padding: 0;
margin: 0;
}
#app {
width: 100vw;
height: 100vh;
background: #ececec;
}

.btn {
width: 35%;
color: #fff;
background: #5daaf3;
border: none;
height: 40px;
border-radius: 20px;
margin-top: 20px;
margin-left: 40px;
}
</style>


那么经过翻转后当我们横屏移动设备时,保存出的图片会进行90度旋转,传递给后端的图片就是正常的了✅(代码可直接食用)


处理细节


后来我发现签字的笔锋太细了,打印出来的效果很差,于是通过查阅,只要设置 options 中的 minWidth和maxWidth 大一些即可
到此所有需求就已经都解决了。


总结


其实平时开发中没有对canvas用到很多,导致对这块的知识很薄弱,我在查阅的时候找到过用原生实现此功能,不过因为时间不够充裕,为了完成需求耍了一个小聪明,后续应该对canvas更多的了解一下,在深入了解上面的旋转方法具体是如何实现的,希望这篇文章能够对遇到这种需求并且时间紧迫的你有所帮助!如果有更好的方式,也希望大佬们分享,

作者:爱泡澡的小萝卜
来源:juejin.cn/post/7260697932173590565
交流经验变得更强!!

收起阅读 »

如何找到方向感走出前端职业的迷茫区

引言 最近有几天没写技术文章了,因为最近我也遇到了前端职业的迷茫,于是我静下来,回想了下这几年来在工作上处理问题的方式,整理了下思路 ,写了这一片文章。 关于对前端职业的迷茫,如何摆脱或者说衰减,我觉得最重要的是得找到一个自己愿意持续学习、有领域知识积累的细...
继续阅读 »

引言


image.png
最近有几天没写技术文章了,因为最近我也遇到了前端职业的迷茫,于是我静下来,回想了下这几年来在工作上处理问题的方式,整理了下思路 ,写了这一片文章。


关于对前端职业的迷茫,如何摆脱或者说衰减,我觉得最重要的是得找到一个自己愿意持续学习、有领域知识积累的细分方向。工作了3-5年的同学应该需要回答这样一个问题,自己的技术领域是什么?前端工程化、nodejs、数据可视化、互动、搭建、多媒体?如果确定了自己的技术领域,前端的迷茫感和方向感应该会衰弱很多。关于技术领域的学习可以参照 前端开发如何给自己定位?初级?中级?高级!这篇,来确定自己的技术领域。


前端职业是最容易接触到业务,对于业务的要求,都有很大的业务压力,但公司对我们的要求是除了业务还要体现技术价值,这就需要我们做事情之前有充分的思考。在评估一个项目的时候,要想清楚3个问题:业务的目标是什么、技术团队的策略是什么,我们作为前端在里面的价值是什么。如果3个问题都想明白了,前后的衔接也对了,这事情才靠谱。


我们将从业务目标、技术团队策略和前端在其中的价值等方面进行分析。和大家一起逐渐走出迷茫区。


业务目标


image.png
前端开发的最终目标是为用户提供良好的使用体验,并支持实现业务目标。然而,在不同的项目和公司中,业务目标可能存在差异。有些项目注重界面的美观和交互性,有些项目追求高性能和响应速度。因此,作为前端开发者,我们需要了解业务的具体需求,并确保我们的工作能够满足这些目标。


举例来说,假设我们正在开发一个电商网站,该网站的业务目标是提高用户购买商品的转化率。作为前端开发者,我们可以通过改善页面加载速度、优化用户界面和提高网站的易用性来实现这一目标。



  1. 改善页面加载速度: 使用懒加载(lazy loading)来延迟加载图片和其他页面元素,而不是一次性加载所有内容。


htmlCopy Code
<img src="placeholder.jpg" data-src="image.jpg" class="lazyload">

javascriptCopy Code
document.addEventListener("DOMContentLoaded", function() {
var lazyloadImages = document.querySelectorAll(".lazyload");

function lazyLoad() {
lazyloadImages.forEach(function(img) {
if (img.getBoundingClientRect().top <= window.innerHeight && img.getBoundingClientRect().bottom >= 0 && getComputedStyle(img).display !== "none") {
img.src = img.dataset.src;
img.classList.remove("lazyload");
}
});
}

lazyLoad();

window.addEventListener("scroll", lazyLoad);
window.addEventListener("resize", lazyLoad);
});


  1. 优化用户界面: 使用响应式设计确保网站在不同设备上都有良好的显示效果。


htmlCopy Code
<meta name="viewport" content="width=device-width, initial-scale=1.0">

cssCopy Code
@media (max-width: 768px) {
/* 适应小屏幕设备的样式 */
}

@media (min-width: 769px) and (max-width: 1200px) {
/* 适应中等屏幕设备的样式 */
}

@media (min-width: 1201px) {
/* 适应大屏幕设备的样式 */
}


  1. 提高网站易用性: 添加搜索功能和筛选功能,使用户能够快速找到他们想要购买的商品。


htmlCopy Code
<form>
<input type="text" name="search" placeholder="搜索商品">
<button type="submit">搜索</button>
</form>

<select name="filter">
<option value="">全部</option>
<option value="category1">分类1</option>
<option value="category2">分类2</option>
<option value="category3">分类3</option>
</select>

javascriptCopy Code
document.querySelector("form").addEventListener("submit", function(e) {
e.preventDefault();
var searchQuery = document.querySelector("input[name='search']").value;
// 处理搜索逻辑
});

document.querySelector("select[name='filter']").addEventListener("change", function() {
var filterValue = this.value;
// 根据筛选条件进行处理
});

协助技术团队制定策略


image.png
为了应对前端开发中的挑战,协助技术团队需要制定相应的策略。这些策略可以包括技术选型、代码规范、测试流程等方面。通过制定清晰的策略,团队成员可以更好地协作,并在面对困难时有一个明确的方向。


举例来说,我们的团队决定采用React作为主要的前端框架,因为它提供了组件化开发和虚拟DOM的优势,能够提高页面性能和开发效率。同时,我们制定了一套严格的代码规范,包括命名规范、文件组织方式等,以确保代码的可读性和可维护性。



  1. 组件化开发: 创建可重用的组件来构建用户界面,使代码更模块化、可复用和易于维护。


jsxCopy Code
// ProductItem.js
import React from "react";

function ProductItem({ name, price, imageUrl }) {
return (
<div className="product-item">
<img src={imageUrl} alt={name} />
<div className="product-details">
<h3>{name}</h3>
<p>{price}</p>
</div>
</div>

);
}

export default ProductItem;


  1. 虚拟DOM优势: 通过使用React的虚拟DOM机制,只进行必要的DOM更新,提高页面性能。


jsxCopy Code
// ProductList.js
import React, { useState } from "react";
import ProductItem from "./ProductItem";

function ProductList({ products }) {
const [selectedProductId, setSelectedProductId] = useState(null);

function handleItemClick(productId) {
setSelectedProductId(productId);
}

return (
<div className="product-list">
{products.map((product) => (
<ProductItem
key={product.id}
name={product.name}
price={product.price}
imageUrl={product.imageUrl}
onClick={() =>
handleItemClick(product.id)}
isSelected={selectedProductId === product.id}
/>
))}
</div>

);
}

export default ProductList;


  1. 代码规范示例: 制定一套严格的代码规范,包括命名规范、文件组织方式等。


命名规范示例:



  • 使用驼峰式命名法:例如,productItem而不是product_item

  • 组件命名使用大写开头:例如,ProductList而不是productList

  • 常量全大写,使用下划线分隔单词:例如,API_URL


文件组织方式示例:


Copy Code
src/
components/
ProductList.js
ProductItem.js
utils/
api.js
styles/
product.css
App.js
index.js

前端的价值


image.png
作为前端开发者,在业务中发挥着重要的作用,并能为团队和产品创造价值。前端的价值主要体现在以下几个方面:


1. 用户体验


前端开发直接影响用户体验,良好的界面设计和交互能够提高用户满意度并增加用户的黏性。通过技术的提升,我们可以实现更流畅的页面过渡效果、更友好的交互反馈等,从而提高用户对产品的喜爱度。


例如,在电商网站的商品详情页面中,我们可以通过使用React和动画库来实现图片的缩放效果和购物车图标的动态变化,以吸引用户的注意并提升用户体验。


jsxCopy Code
import React from 'react';
import { Motion, spring } from 'react-motion';

class ProductDetail extends React.Component {
constructor(props) {
super(props);
this.state = {
isImageZoomed: false,
isAddedToCart: false,
};
}

handleImageClick = () => {
this.setState({ isImageZoomed: !this.state.isImageZoomed });
};

handleAddToCart = () => {
this.setState({ isAddedToCart: true });
// 添加到购物车的逻辑
};

render() {
const { isImageZoomed, isAddedToCart } = this.state;

return (
<div>
<img
src={product.image}
alt={product.name}
onClick={this.handleImageClick}
style={{
transform: `scale(${isImageZoomed ? 2 : 1})`,
transition: 'transform 0.3s',
}}
/>

<button
onClick={this.handleAddToCart}
disabled={isAddedToCart}
className={isAddedToCart ? 'disabled' : ''}
>

{isAddedToCart ? '已添加到购物车' : '添加到购物车'}
</button>
</div>

);
}
}

export default ProductDetail;

2. 跨平台兼容性


在不同的浏览器和设备上,页面的呈现效果可能会有所差异。作为前端开发者,我们需要解决不同平台和浏览器的兼容性问题,确保页面在所有环境下都能正常运行。


通过了解各种前端技术和标准,我们可以使用一些兼容性较好的解决方案,如使用flexbox布局代替传统的浮动布局,使用媒体查询来适配不同的屏幕尺寸等。



  1. 使用Flexbox布局代替传统的浮动布局: Flexbox是一种弹性布局模型,能够更轻松地实现自适应布局和等高列布局。


cssCopy Code
.container {
display: flex;
flex-direction: row;
justify-content: space-between;
}

.item {
flex: 1;
}


  1. 使用媒体查询适配不同的屏幕尺寸: 媒体查询允许根据不同的屏幕尺寸应用不同的CSS样式。


cssCopy Code
@media (max-width: 767px) {
/* 小屏幕设备 */
}

@media (min-width: 768px) and (max-width: 1023px) {
/* 中等屏幕设备 */
}

@media (min-width: 1024px) {
/* 大屏幕设备 */
}


  1. 使用Viewport单位设置响应式元素: Viewport单位允许根据设备的视口尺寸设置元素的宽度和高度。


cssCopy Code
.container {
width: 100vw; /* 100% 视口宽度 */
height: 100vh; /* 100% 视口高度 */
}

.element {
width: 50vw; /* 50% 视口宽度 */
}


  1. 使用Polyfill填补兼容性差异: 对于一些不兼容的浏览器,可以使用Polyfill来实现缺失的功能,以确保页面在各种环境下都能正常工作。


htmlCopy Code
<script src="polyfill.js"></script>

3. 性能优化


用户对网页加载速度的要求越来越高,前端开发者需要关注页面性能并进行优化。这包括减少HTTP请求、压缩和合并资源、使用缓存机制等。


举例来说,我们可以通过使用Webpack等构建工具来将多个JavaScript文件打包成一个文件,并进行代码压缩,从而减少页面的加载时间。


结论


image.png
作为前端开发者,我们经常面临各种挑战,如业务目标的实现、技术团队策略的制定等。通过不断学习和提升,我们可以解决前端开发中的各种困难,并为业务目标做出贡献。同时,我们的工作还能够直接影响用户体

作者:Jony_men
来源:juejin.cn/post/7262133010912100411
验,提高产品的竞争。

收起阅读 »

前端简洁表单模型

web
大家好,我是前端菜鸡木子 今天想和大家浅谈下前端表单的简洁模型。说起表单大家一定都不陌生,因为各自团队内部一定充斥着各种或简单或复杂的表单场景。为了解决表单开发问题,市面上也有着许多优秀的表单解决方案,例如:Formily、Ant Design、FormRen...
继续阅读 »

gabriel-ramos-azbe3hSHNHU-unsplash.jpg


大家好,我是前端菜鸡木子


今天想和大家浅谈下前端表单的简洁模型。说起表单大家一定都不陌生,因为各自团队内部一定充斥着各种或简单或复杂的表单场景。为了解决表单开发问题,市面上也有着许多优秀的表单解决方案,例如:FormilyAnt DesignFormRender 等。这些框架的底层都维护着一套基础的「表单模型」,虽然框架不同,但是「表单模型」的设计却是基本一致,只是上层应用层的设计会随着业务的需求进行调整。今天的主题也会围绕着「表单模型」进行展开


前言


本文是偏基础层面的介绍,不会涉及到太多框架的源码解析。另外,我会以最近如日中天的 Formily 为例进行讲解,大家如果对 Formily 不太了解,可以先去了解和使用。


表单模型的基础概念


我们知道一个表单包含了 N 多个字段,每个字段都需要用户输入或者联动带出,当用户输入完成之后我们可以通过 Form.Values 的形式直接获取到表单内部 N 多个字段的值,那么这是如何实现的呢?


我们通过一张图来简单阐述下:


yuque_diagram.png


其中:



  • Form:是通过 JS 维护的一个表单模型实例,FormilycreateForm 返回的就是这个实例,它负责维护表单的所有数据和每个字段 Field 的实例

  • Field: 是通过 JS 维护的每一个字段的实例,它负责维护当前字段的所有数据和状态

  • Component: 是每个字段对应的展示层组件,可以是 Input 或者 Select,也可以是其它的自定义组件


从图中不难看出,每个 Field 都对应着一个展示层的 Component,当用户在 Component 层输入时,会触发 props.onChange 事件,然后在事件内部将用户输入的值传入到 Field 里。同时当 Field 值变化时 (比如初始化时的默认值,或者通过 field.setValue 修改字段的值 ),又会将 Field.value 通过 props.value 的形式传入到 Component 内部,以此来达到 ComponetField 的数据联动。


我们可以看下在 Formily 内部是如何实现的(已对源码进行一些优化和注释):


const renderComponent = () => {
// 获取 Field 的 value
const value = !isVoidField(field) ? field.value : undefined;

// 设置 onChange 事件
const onChange = !isVoidField(field)
? (...args: any[]) => {
field.onInput(...args)
field.componentProps?.onChange?.(...args)
}
: field.componentProps?.onChange

// 生成 Field 对应的 Component
return React.createElement(
getComponent(field.componentType),
{
value,
onChange,
},
content
)
}

这里面的 onChange 事件里触发了 field.onInput 的事件,在 field.onInput 内会做两件事情:



  • onChange 携带的 value 赋值给 field.value

  • onChange 携带的 value 赋值给 form.values


这里需要额外说明的是,一个 Form 会通过「路径」系统聚合多个 Field,每个 Field.value 也是通过路径系统被聚合到 Form.values 下。


我们通过一个简单的 demo 来介绍下路径的概念:


const formValues = {
key1: {
key2: 'value',
}
};

我们通过 key1.key2 可以找到一个具体的值,这个 key1.key2 就是一个路径。在 Formily 内维护了一个高级的路径模块,感兴趣的可以去看下 form-path


表单模型的响应式


聊完表单模型的基础概念后,我们知道



  • Component 组件通过 props.onChange 将用户的数据回传到 FieldForm 实例内

  • Field 实例内的 value 会通过 props.value 形式传递到 Component 组件内


那么问题来了,Field 实例内部的 value 改变后,Component 组件是如何做到细粒度的重新渲染呢?


不卖关子,直接公布答案:



  • formily: 通过 formily/reactive 进行响应式跟踪,能知道具体是哪个组件依赖了 Field.value, 并做到精准刷新

  • Antd:通过 rc-field-form/useForm 这个 hook 来实现,本质上是通过 const [, forceUpdate] = React.useState({}); 来实现的


虽然这两种方法都能实现响应式,但是 Ant 的方式比较暴力,当其中一个 Field.value 发生改变时,整个表单组件都需要 render 。而 Formily 能通过 formily/reacitve 追踪到具体改变的 Field 对应的 Componet 组件,只让这个组件进行 render



formily/reactive 实现比较复杂,这边不会深入探讨具体实现方式,感兴趣的小伙伴可以看下这篇文章 从零开始撸一个「响应式」框架 (本质上是通过 Proxy 来拦截 getset,从而实现依赖追踪)



接下来,我们就看下如何借助 formily/reactive 来实现响应式


第一步:我们需要在 Field 初始化时将 value 变成响应式:


import { define, observable } from '@formily/reactive'

class Field {
constructor(props) {
// 初始化 value 值
this.value = props.value;

// 将 this.value 变成响应式
define(this, {
value: observable.computed
})
}
}

第二步:对 Field 对应的 Componet 进行下 "包装":


import { observer } from '@formily/reactive-react'

const ReactiveComponentInernal = () => {
// renderComponent 源码在 「基础概念」章节里
return renderComponent();
}

export const FieldComponent = observer(ReactiveComponentInernal);


observer 内部也和 rc-field-form/useForm 类似,通过 const [, forceUpdate] = React.useState({}); 来实现依赖改变时,子组件级别的动态 render



到此为止,表单模型的响应式也基本完成了


表单模型的规范


有了以上的表单模型,我们就可以构建一个简单的表单框架。但是真实的业务场景却不可能这么简单,迎面而来的第一个问题就是「联动」,举个例子:


QQ20230729-134607-HD.gif


需求:当城市名称改变后,城市编码字段需要联动带出对应的值。我们可以快速想到两种方案:



  • 方案1:在 城市名称 字段的 onChange 事件里通过 form.values.cityCode = hz 的形式去动态修改 城市编码 字段。

  • 方案2:在 城市编码 字段里显示的配置对 城市名称 字段的依赖,同时需要配置依赖改变时的处理逻辑,例如:


const formSchema = {
cityName: {
'x-component': 'Select',
},
cityCode: {
'x-component': 'Input',
'x-reactions': {
dependencies: ['cityName'],
fulfill: {
state: {
value: '{{ $deps[0]?.value }}',
},
},
},
},
};

无论方案 1 还是方案 2 都能实现需求,但是两个方案各有缺点


方案 1 有两个问题:



  • 问题一:打破了【表单模型的基础概念】,cityName 对应的组件的 onChange 事件里「直接」对 cityNamecityCode 字段进行了修改。

  • 问题二:我们不能「直观」的看到 cityCodecityName 字段产生了依赖,只有在看具体代码时才能知道


方案 2 也会有两个问题:



  • 问题一:schema 本身的可读性不强,且使用 formily schema 时,配置内容比较多

  • 问题二:使用 schema 配置 x-component-props 时不能使用 ts 特性


当表单逐渐复杂起来的时候,方案 1 的弊端会逐步显现出来,字段间会产生诸多的 「幽灵」依赖和控制,导致后续迭代的时候根本无从下手。所以在我自己的团队内部,我们规定出了几条「表单模型」的使用规范:




  • 规范 1: 每个 Field 对应的 Component 只对自己的字段负责,不允许通过 Form api 直接修改其他字段

  • 规范 2: 在 formSchema 里需要维护表单的所有字段配置和依赖,字段间不允许出现「幽灵」依赖

  • 规范 3: 尽量不要使用 form.setValuesform.queryField('xxx').setValue 等动态修改字段值的 Form api(特殊场景除外)

  • 规范 4: 表单涉及到的所有字段都尽量存储到表单模型中,不要使用外部变量来保存



这些规范其实是个普适性的范式,无论你在使用 Formily 也好,还是 Ant Design 也好,都需要去遵守。规范 2 里我用了 Formilyschema 来说明,但如果你使用的是 Ant Design,可以把 formSchema 理解为 <Form.Item reaction={{ xxx }}></Form.Item>



其实 formily 的 schema 最终会通过 RecursionField 组件递归渲染成具体的 FormItem 形式



表单模型的应用层


有了上述的「表单模型」概念和规范之后,我们就可以来构建表单模型的应用层了


yuque_diagram(1).png



  • Form Scheam: 整个表单的配置中心,负责表单各个字段的配置和联动 、校验等,它只负责定义不负责实现。它可以是个 Json Schema,也可以是 Ant Design<FormItem>

  • Form Component: 表单内每个字段的 UI 层组件,可以再分为:基础组件业务组件,每个组件都只负责和自己对应的 Field 字段交互

  • 业务逻辑:将复杂业务抽象出来的业务逻辑层,纯 JS 层。当然这一层是虚拟的概念,它可以存在于 Form Componet 里,也可以放在入口的 Index Component 内。如果业务复杂, 也可以放到 hooks 里或者单独的 JS 模块内部


有了应用层架构后,在写具体表单页面时,我们需要在脑海中清晰的勾勒出每层(Schema Component Logic)的设计。当页面足够简单时,也许会没有 Logic 层,Component 层也可以直接使用自带的基础表单组件,但是在设计层面我们不能混淆


表单模型的实践 - Formily


从去年开始,我们团队便引入 formily 作为中后台表单解决方案。在不断的实践过程中,我们逐步形成了一套自己的开发范式。主要有以下几个方面


Formily 的取舍


我们借助了 formily 的以下几个能力:



  • formily/reactive: 通过 reacitve 响应式框架来构建业务侧的数据模型

  • formily/schema: 通过 json-schema 配置来描述整个表单字段的属性,当然其背后还携带着 formily 关于 schema 的解析、渲染能力

  • formily/antd: 一些高级组件


同时,我们也在尽量避免使用 formily 的一些灵活 API:



  • Form 相关 API:比如 useFormform.setValues 等,我们不希望在任何组件内部都能「方便」的窜改整个表单的所有字段值,如果当前字段对 XX 字段有依赖或者影响,你应该在 schema 里显示的声明出来,而不是偷偷摸摸的修改。

  • Query 相关 API: 比如 form.query('field'),原因同上


当然,这不代表我们绝不会使用这些 API ,比如在表单初始化时需要回填信息的场景,我们就会用到 form.setValues 。我想说明的是不能滥用!!!


静态化的 schema


我们认为 schema 和普通的 JSX 相差不大,只不过前者是通过 JSON 标准语言来表述而已,举个例子:


// chema 形式
const formSchema = {
name: {
type: 'string',
'x-decorate': 'FormItem',
'x-component': 'Input',
'x-component-props': {
placeholder: '请输入名称'
}
}
}

// jsx 形式
const Form = () => {
return (
<Form>
<FormItem name"name">
<Input placeholder="请输入名称" />
</FormItem>
</Form>

)
}

schema 最终也会被 formily/react 解析成 jsx 格式。那为什么我们推荐使用 schema 呢?



  • 原因一:schema 可以做到足够的静态化,避免我们做一些灵活的动态操作 (在 jsx 里我们几乎能做通过 form 实例动态的做任何事情)

  • 原因二: schema 更容易被解析和生成,为之后的智能化生成做铺垫(不一定是低代码)


表单模型的挑战


在真实业务开发过程中,我们对表单模型的使用会出现一些问题,以两个常见的问题为例:



  • 问题 1:我们是通过表单的 UI 结构来设计 schema 还是通过表单数据结构来设计?

  • 问题 2:有时候为了简单,我们会设计出一个巨大的 Component,这个 Componet 对应的 Field 嵌套了很多层字段


下面这个案例就可能触发上述的两个问题:


demo2.gif


其中,每个分类都对应着一组商品,所以最终表单的数据格式应该是这样的:


{
categoryList: [
{
categoryName: '分类一'
productList: [{ productName: '商品一', others: 'xxx' }],
},
{
categoryName: '分类二'
productList: [{ productName: '商品二', others: 'xxx' }],
}
],
}

我们提供两种思路来设计这个表单


方案一


我们发现简单的通过 ArrayTable 是实现不出这种交互的,所以我们直接设计出一个大而全的 Component,那么我们的实现方式应该是这样的:


// 设计一个大而全组件,过滤组件内部实现
const BigComponent = (props) => {
return (
<Row>
<CategoryArrayTable />
<ProductArrayTable />
</Row>

)
};

// schema 设计
const formSchema = {
categoryList: {
type: 'array',
'x-component': BigComponent,
}
}

在这种方案里,BigComponent 组件需要 onChange 整个表单的值(多层嵌套的对象数组),这会出现一个问题:formSchema 里看不到表单的所有字段配置,如果字段间需要有联动,那么只能在 BigComponent 组件内部去实现(违反了规范2)。


方案二


我们认为 schema 是面对表单数据结构设计的,Component 是面对 UI 设计的,两者的设计思路是分开的(但是在大多数场景下两者的设计结果是一致的)
那么我们的实现方式应该是这样的:


// 基于 formily/antd/ArrayTable + formily/react RecursionField 来实现
const CategoryArrayTable = (props) => {
return (
<Row>
<ArrayTableWithoutProductList />
<ArrayTableWithProductList />
</Row>

)
};

// schema 设计
const formSchema = {
categoryList: {
type: 'array',
'x-component': CategoryArrayTable,
items: {
categoryName: {
type: 'string',
'x-component': 'Select',
},
productList: {
type: 'array,
'
x-component': 'ArrayTable',
items: {
productName: {
type: '
string',
'
x-component': 'Select',
},
others: {},
}
}
},
}
}

在这种方案的 schema 里能够直接反映出表单的所有字段配置,一目了然,而且真实的代码实现会比方案一简洁很多


但是呢,这个方案有个难点,需要开发者对 formily 的渲染机制,主要是 RecursionFieldArrayTable 的源码有一定程度的了解。


当然,还有很多其他的方案可以实现这个需求,这边只是拿出两个方案来对比下设计思路上的差异,虽然最终的方案取舍是根据团队内部协商 + 规范而定的,但是在我自己的团队里,我们一直保持着一种设计准则:



schema 是面对表单结构的,Component 是面对 UI 的



后续


在实践过程中,我们发现了一些待优化点:


1、我们发现对于复杂的表单页面,schema 的配置会非常冗长,如果 schema 足够静态化的话,我们是否可以简化对 schema 的编写,同时能提高 schema 的可读性呢?低代码平台是个方案,但是太重,是否可以考虑弄个 vsocde 插件类接管 schema ?


2、如果表单配置、表单子组件、业务逻辑都由 schemaComponentLogic Fucntion 来负责了,我们是否可以取消表单页面的入口组件 index.tsx 呢?


当然随着对表单的不断深入研究,还有很多其他问题可以优化和解决

作者:木与子
来源:juejin.cn/post/7261262567304921146
,这边就不一一列举了

收起阅读 »

技术负责人如何应对工作中频频被打断

本文翻译自《Managing your interrupt rate as a tech lead》原文分为三篇,作者是 Nicholas C. Zakas,他也是《Professional JavaScript for Web Developers, 3rd...
继续阅读 »

本文翻译自《Managing your interrupt rate as a tech lead》原文分为三篇,作者是 Nicholas C. Zakas,他也是《Professional JavaScript for Web Developers, 3rd Edition》的作者。本文主要是在讲 tech lead 如何进行时间管理,写得特别好,建议仔细阅读一下。



我在 2008 年成为了技术负责人(tech lead),起初并不知道这是一种怎样的体验。在我的认知里,技术负责人和其他软件工程师没有太大区别,唯一的差别是技术负责人可以对技术决策做出最终决定。然而,我没想到的是,在那之后的日子里,发生了很大的变化。特别让我震惊的是,要越来越多的时间被花费到讨论上,而不是编码。


我的日程被会议填满了,在这些会议里与产品经理、项目经理和工程经理讨论项目。与此同时,其他工程师也经常打断我,向我问一些问题。不知不觉,我似乎没有完成太多工作。


对于新的技术负责人来说,这是一种常见的经历:你之前所掌握的时间管理技巧不再起作用。相反,你每天都会被频繁的打断,让你无法完成自己的工作,而且也没人来指导你如何去应对工作频频被打断这一问题。


1. 种瓜得瓜,种豆得豆


1.1 为什么技术负责人会遇到工作被打断问题


尽管技术负责人的角色在不同公司甚至不同项目中有所不同,但通常由两类任务组成:




  • 帮助他人 - 作为技术负责人,你会花费一部分时间帮助其他工程师完成他们的工作。这可能包括提供正式的代码或规范审查、指导、检查进展、回答问题,或其他各种事项,以帮助团队前进。




  • 自己的工作 - 你也需要有自己的产出。这可能是代码、技术规范、演示文稿、项目计划或其他你作为主要推动者的工作。




在与他人合作和独立开展工作之间来回切换,需要不同的时间管理技巧,这通常是技术负责人所面临的难题。他们觉得自己的工作是确保团队没有困扰,并让团队不断地前进。因此,一旦处于失联的状态,就会对工作效率产生负面影响。他们鼓励团队成员在任何时候都可以打断他们寻求帮助,通常通过实时通信渠道,比如聊天或即时消息。他们认为这样做可以建立信任,同时确保团队成员不会因为等待回复而受阻。虽然这种方法背后的思路值得称赞,即希望为团队成员提供支持,但最终结果是一天的工作混乱不堪,很难或根本无法在个人的工作上取得进展。


由于每次被打断需要大约 15 分钟才能重新集中注意力,技术负责人在这种以打断为驱动的环境下很难完成任何事情。那么解决方案是什么呢?


1.2 你的团队实际上并不需要那么依赖于你


当我第一次成为技术负责人时,我们团队使用 Yahoo Messenger 进行沟通。(对于不了解的人来说,Yahoo Messenger 是 2000 年代流行的即时通讯程序。)作为一个分布在不同楼层、不同校区乃至不同国家的团队,能够快速找到有问题的人是我们日常工作中的重要部分。作为技术负责人,我经常收到来自团队中 24 名前端工程师以及后端工程师、产品经理和工程经理的消息。


我的压力特别大,以至于我与我的主管开了一次会,探讨我是否适合这个职位。我向他描述了我的一天是如何度过的,他非常冷静地给了我一些建议。



“起初,你很好地引导团队成员在需要帮助时来找你。现在,你需要引导他们自己解决问题。并非每个问题都是 Nicholas (本文作者) 的问题。如果是只有你才能解决的问题,那就没问题,你可以去解决。但是如果是其他人可以解决的问题,就让他们自己去解决吧。”



新上任的技术负责人通常会认为,只有他们尽可能多参与项目才能成功,起初我也是这样想的。事实上,你参与项目的部分越多,团队的功能性就越低,进展就越慢。你的团队成员(理论上)被雇佣是因为他们也是有能力的成年人,可以在你的帮助下或者没有你的情况下交付高质量的软件。有时候你会帮助他们解决问题,但大多数时候他们有能力自己解决问题。


这是否意味着人们在解决问题时不会像你帮助他们那样迅速?实际上,并非如此,而这两者也没什么关系。每个人在解决之前从未遇到过的问题时,都会比较慢。通过自己努力解决问题,比直接得到解决方案更有助于巩固学习。通过过早地介入并为他人解决问题,实际上会剥夺他们更具成效的学习体验。通过努力解决问题所学到的经验是成长的源泉。


1.3 你得到的打断是你奖励的结果


虽然一些打断是因为人们遇到困难,但很多打断并不是因为他们真的遇到麻烦,而是因为他们不想花时间自己解决问题。如果他们可以发信息给你,并在几分钟内得到回应,那么他们为什么要花 15 分钟自己解决问题呢?这太低效了!当他们得到他们寻求的答案时,你们都会获得多巴胺的刺激,你为帮助别人前进感到自豪。这是一个上瘾的行为,对你们两个人都没有好处。


在我在 Yahoo 工作时,我注意到一个令人沮丧的模式:人们给我发送只是写着“嗨”的消息。我会回复以了解他们想要什么,结果发现那只是一些他们本可以自己处理的普通问题。这个“嗨”是另一种即时满足的行为,这只是一次询问,以确定我是否有空,然后再决定是否打扰我告知他们想要什么。


经过几个星期的这种情况,我决定尝试一种不同的方法。当我收到一个只说“嗨”(或“你好”或类似的)的消息时,我不会立即回复。我会等待看看他们是否会跟进并解释他们需要什么,如果他们没有,那就结束了。而且大多数情况是他们并未跟进,这告诉我两件事:1)他们所需要的其实并不那么重要,2)他们找到了如何自己处理的方法。而且作为额外的收获,一旦我这样做了几个星期,“嗨”这样的消息几乎就消失了。


这对我来说是一个重要的教训:你得到的打断是你奖励的结果。如果我继续迅速回复“嗨”的消息,我将继续收到它们。通过不立即回复,我给出了负面反馈,教导每个人不要打扰我。他们无法通过这种行为获得他们寻求的多巴胺刺激。另一方面,如果他们给我发具体的问题,我会立即回答。所以我在不想要的打断上提供了负面反馈,而在我想要的打断上提供了积极反馈。


要控制你不想要的打断,只需不立即回应。练习在收到不想要的打断后等待一个小时,逐渐教导人们不要期望即时满足感。(如果一个小时感觉太长,可以先等待 15 分钟再回应。设定一个计时器,直到它响起后再回应。你会习惯的。)


1.4 其他你可能在鼓励的打断方式


当然,“嗨”消息并不是你可能无意中鼓励的唯一交互方式。每当我听到有人抱怨与他人的互动时,我经常会想起首席执行官教练杰瑞·科隆纳问他的客户的这个问题:



“你如何能够串联一切来制造那些你说你不想要的条件?”



你遇到的许多打断都直接与你对待它们的方式有关。因此,每当你遇到一个不受欢迎且不重要的打断时,请问自己你做了什么来鼓励这种行为,以及你能够做些什么来阻止它。以下是一些人们常常困扰的其他常见打断情况:




  • 下班后的电子邮件 - 我曾经与很多人谈过,他们认为下班后的电子邮件是他们工作生活的正常组成部分。虽然对某些人来说可能是真的,但下班后的电子邮件几乎总是因为你回复了下班后的电子邮件而产生的。当人们知道你在下午 5 点后查看电子邮件时,他们就会在下午 5 点后发送电子邮件;当人们知道你在下午 5 点后不查看电子邮件时,你将在下午 5 点后几乎不会收到电子邮件(或者至少,你收到的电子邮件不需要立即回复)。




  • 阻塞时间内的会议 - 一些技术负责人学会在日程安排中为工作或个人任务设置阻塞时间,但当有人在这段时间安排了会议时,他们却接受了。猜猜发生了什么?一旦你这样做了,你就传达了这种打断是适当且可接受的信号,因此这种情况会更加频繁发生。当我通过拒绝会议邀请将我的阻塞时间视为神圣而保护起来时,我就不再收到那些会议请求了。




  • 专注时间的打断 - 如果你与团队在办公室共同工作,建议设置专注时间指示器,让人们知道你不希望被打断。常见的指示器包括戴着耳罩式耳机、小纸条上贴着绿色/红色标记以及方格间上方挂着红绿灯。当然,当你设定了专注时间指示器后,需要拒绝试图打断你的人。保护你的专注时间会逐渐教导人们等待。




当然,你肯定会遇到更多你鼓励但实际上想要避免的行为的情况。你能够避免所有打断吗?当然不能。有一些合理的打断,但这种情况比填满你整天并造成不必要压力的打断要少得多。


1.5 小结


技术负责人需要学会平衡自己的工作任务和帮助团队其他成员。这必然会产生一些摩擦,因为大多数技术负责人倾向于将团队的需求放在个人之上,所以他们会明确或含蓄地鼓励打断。虽然这可能对团队中的其他人有利,但这也意味着你没有时间完成自己的工作。


好消息是,你对于接受打断拥有 99% 的控制权。当现状不适合你时,你不必接受它。通过每一次互动,你都在训练你的同事什么样的打断是可接受的,如果你遭到了你不想要的打断,那么你可以通过消除积极反馈来改变这种情况。


你的团队成员是成年人,他们被聘请来做一项特定的工作,虽然通过不断打断你他们可能更高效,但他们也可以在不打断你的情况下正常工作。你可以鼓励他们尝试自己解决一些问题,如果真的遇到困难再向你寻求帮助,或者告诉他们你现在不方便但几个小时后会有时间,看看会发生什么。在第二部分中,我将讨论如何管理你的日程,以最大程度地减少对你工作的不必要的打断。


2. 主动出击,填充日程


如果你和大多数人一样,你的工作日程主要用于展示给他人你的空闲时间。当别人想和你交谈时,他们会在你的日程上的空白时间段安排会议。随着越来越多的会议被安排,空余时间变得更少,留给别人与你安排会议的选项也减少了。在成为技术负责人之前,你的会议数量仍然给你留下了很多编码的空闲时间。毕竟,你的主要工作仍然是产出代码,你的同事(希望如此)会尽量减少打断。但作为技术负责人,这些空闲时间段被占用得更快。你永远不知道会议请求何时会显示在你的日程上,这使得开始任何需要专注的任务变得困难。幸运的是,还有另一种方法。


2.1 倒置你的日程,使用时间块


另一种方法叫做时间块。与其让你的日程大部分为空白,让人们通过发送会议邀请来填满它,不如从给特定任务设置时间块开始。也许你需要编写或审核技术规范?在日程上为此安排一个小时的时间块。你需要编写一些代码吗?在日程上留出 90 分钟的时间块。你负责批准拉取请求吗?早上和晚上各留出 30 分钟的时间块来处理这些事务。如果你在想,“等一下。如果我用所有这些任务填满我的日程,看起来我比实际上要忙”,那么是时候换一种思路了。


无论你需要做什么,都需要时间,甚至包括人们认为是“免费”的事情,比如查看电子邮件或吃午饭。当这些任务不在你的日程上时,实际上会产生你有空闲时间的错觉。这些事情仍然需要完成。只不过看你的日程的同事不知道你何时会做这些事情。对他们来说,一个空闲时间段意味着你可以参加会议,即使你计划在周三下午处理那个迭代任务。


通过在日程上放置你需要完成的实际任务,你正在做两件事情:




  • 准确地规划你的日程安排,所以你知道什么时候该做什么。如果一个会议邀请在任务时间块期间到来,你清楚知道如果接受它,你将放弃什么。




  • 让同事们知道你真正有空参加会议的时间。当你的日程被任务填满,并留出一些空闲时间段时,人们通常会默认在这些时间段安排会议。这样,周三下午的迭代任务就不会被打断。




无论哪种情况,你都在为你的可用性和时间使用方式创建一个更准确的视图。


2.2 常见的需要放在日程上的任务


确定要放在日程上的任务比看起来更具挑战性。一些任务很容易界定,并可能存在于迭代计划中,但你每天还做些什么呢?以下是一个非详尽无遗的任务类型列表,可以放在你的日程上:




  • 异步沟通 - 你肯定需要时间检查公司使用的任何沟通系统,无论是电子邮件、Slack、Yammer 还是其他任何方式。你可能会在一天中的某个时候定期进行这样的检查,但更好的方法是在一天中安排时间进行检查。




  • 午餐/休息 - 每当你想自己花点时间时,把它放在你的日程上。全天都要定期休息和恢复精力,将这些放在日程上会提醒你去做。




  • 家庭事务处理 - 如果你有任何需要定期检查的个人问题,也要放在你的日程上。




  • 社交媒体检查 - 不管在工作中是否检查社交媒体是一个好主意,很多人都这样做。如果这是你的例行事项,应该放在你的日程上。(如果这是你的工作内容,那肯定应该放在你的日程上。)




  • 分配的任务 - 无论你使用的是迭代、看板还是其他形式的计划,你可能被分配了一些任务,有交付内容和截止日期。把它们放在你的日程上,并具体标注你将要完成的任务(而不是“编码时间”,你可能不知道自己打算做什么)。




  • 代码审查 - 作为技术负责人,审查同事的代码是一项常见任务。你可能希望每天安排时间进行审查,甚至一天进行几次,以确保人们不受阻碍。




  • 临时任务 - 你可能会有一些自己分派的任务,这些任务也应该放在你的日程上。再次提醒,务必标记出这个时间块,以便清楚知道你打算用那段时间做什么。例如,审查规范、审查简历、撰写博客文章、撰写文档和准备演示文稿等任务都是这样的例子,这些任务可能没有在你的工单系统中正式分配给你,但你仍然需要完成它们。




  • 日程计划 - 每周五,花 15 到 30 分钟的时间来设置下周的时间块。你应该对自己即将处理的任务有足够的了解,以便使自己能在周一上班时准确安排时间。




从这个列表中可以看出,你的日程不再只是用来安排会议。它可以用于任何需要消耗你时间的任务。会议将始终通过会议组织者找到你的日程,而这些任务只有在你有意添加它们时才会出现在日程上。


时间块的黄金法则是:如果做某事需要时间,那么它就会出现在你的日程上。


2.3 消除时间块中的干扰


也许时间块中最重要的部分是在预定时间段内消除所有干扰。毕竟,如果你在检查规范的一个小时时间段内被电子邮件、Slack 或社交媒体打断,那么这个时间块有多有用呢?因此,消除干扰对于使时间块成功非常关键。


当你在进行任务块时,尽量消除所有干扰。以下是一些建议:




  • 关闭电子邮件、Slack 和社交媒体(不仅仅是最小化窗口,要关闭应用程序)。你应该在日程上安排了定期检查的时间。




  • 考虑将手机设置为“勿扰”模式,以消除应用程序的提示音和信息通知。




  • 关闭任何你没有在使用的应用程序(包括网页浏览器)。




  • 如果你正在使用网页浏览器,请关闭所有你没有在积极使用的标签。可以使用像 Momentum2 这样的扩展来帮助你在打开新标签时集中注意力。




如果你正在使用具有“专注模式”的应用程序,请将其打开。例如,Visual Studio Code 有一个名为“禅意模式”的功能,可以将屏幕上的其他内容屏蔽掉。


总体目标是,当你说你要在周一的 13 点到 14 点之间检查规范时,你实际上是在检查规范,而不会被其他事情分心。只专注于你在此期间分配给自己的任务,并一直工作直到完成或达到下一个合适停止点。


2.4 日程的前后变化


为了让你对时间块在实践中是什么样子有一个概念,看看在实施时间块之前和之后,你的日程可能会是什么样子是很有帮助的。如果你是一位技术负责人,尚未设置用于时间块的日程,你的日程可能如下所示:



之前的日程:每天早上 9:30-9:45 进行日常站立会议;周三中午吃团队午餐;一对一会议和其他会议零散地安排在整个星期中。


紫色的预约日程是由你的团队设定的,蓝色的预约日程是你与其他人实时互动的时间。当你看这个日程时,除了每天上午 9 点 30 分的日常站立会议和每周三下午 1 点的团队午餐外,日程上大部分都是空白。当然,它实际上并不是空的 - 你需要那段空闲的时间来完成被分配给你的所有工作,以及预期的但通常未计算在内的所有工作。无论何时有人想要与你安排事情,你日程上的任何空闲时间都可以使用,在这个日程上,有很多空闲时间。


一旦你过渡到时间块,你的日程会更像这样:



时间块日程:每天都有特定的任务在特定的时间安排,中间夹杂了其他会议


当你看到一个时间块日程时,很容易发现原始日程上的所有空白空间实际上并不是空闲的。那些日常站立会议、团队午餐以及第一个日程中的其他所有会议仍然存在,但现在你可以看到在这些会议之间你要完成什么任务。我用红色标记了涉及特定软件的任务,用黄色表示你独自完成的任务,用绿色表示休息时间。你的日程上仍然有空闲时间,但比之前少得多。


2.4 保护你的时间块


在为日程设置时间块并计划消除干扰之后,还有最后一步需要注意:保护你的时间块免受同事的干扰。根据你所在公司的日程文化,你的同事可能能够看到你在时间块上标记的内容,或者他们只会看到那段时间是“忙碌”的。在这两种情况下,你可能会在你的日程被阻塞的时间段收到会议请求。你如何应对这些请求将决定你的同事是否会尊重你的日程。


正如在第一部分中所讨论的,你会得到更多你所鼓励的打扰。如果你总是接受在时间块上的会议请求,那么你就传达了你的时间块并不重要,别人可以随时安排会议。另一方面,如果你总是拒绝在时间块上的会议请求,人们可能会感到沮丧,并抱怨你从来没有空闲。自然而然,正确的做法在于取中间道路。


假设人们在很大程度上遵守你的日程可用性,只偶尔会在时间块上发送会议请求,最简单的做法是询问这个请求的重要性。毫无疑问,在紧急情况下,你需要改变当天的计划并重新安排时间块,但这应该是例外而不是规则。规则是让人们找到你日程上的一个空闲时间段,并在那里请求会议。如果由于某种原因无法找到合适的时段,那么你的责任就是确定会议请求的重要性,只接受最重要的邀请。如果副总裁想要召集一组 10 个人讨论新举措,那么你很可能需要调整你的日程安排;如果同事想请教你对他们撰写的内容的意见,那可以等待。


在处理这些请求时要根据情况行事,但始终以拒绝为默认,并提议一个你有空闲时间的新日期和时间。有时可能要等到下周才有空闲,对许多请求来说这是可以接受的。


2.5 小结


在成为技术负责人之前,你可能没有花太多时间管理自己的日程,所以你的日程很可能大部分都是空闲时间,偶尔会安排一些会议。剩下的时间都是属于你自己的,而且大部分时间都用来完成任务。偶尔出现在你日程上的会议并不是什么大问题,因为你有很多空闲时间来履行其他的职责。


作为一名技术负责人,你需要平衡自己的任务和帮助他人的工作,如果允许他人通过会议请求来控制你的日程,那么你将经常被打断,计划好的事情也会受到干扰。解决方案是使用时间块,在你的日程上创建专门用于特定任务的时间块。这些任务包括查看电子邮件、代码审查和编写代码等。任何需要时间或专注力来完成的事情都应该成为你日程上的一个时间块,这样你和同事们都知道在这段时间里你是忙碌的。


仅仅表示在某个时间段你很忙是不够的。你需要通过关闭电子邮件、聊天和社交媒体来消除自己的干扰,以便能够专注于自己的工作;你可以安排一段时间来稍后查看这些。当你为某个特定任务安排了时间块时,只专注于这个任务,不要处理其他事情。


当你收到与你的时间块重叠的会议请求时,你还需要保护你被阻塞的时间。你如何应对这样的请求将决定其他人是否尊重你的日程。如果你总是在被阻塞的时间接受会议邀请,你就会继续收到这些请求。通过拒绝大部分请求并建议一个更合适的时间来保护你的日程。是的,会有紧急情况需要腾出时间,但那应该是例外而不是规则。


也许你会想,这听起来很好,但这是否意味着我整天都在忽视我的同事?我什么时候能和他们交流?答案是在办公时间内,这将在本系列的第三部分进行讨论。


3. 集中处理,提前预约


如果你按照之前两部分的建议去做,现在你可能已经让你的同事们不再随意打断你。你停止了奖励那些你不希望发生的打扰行为,所以这些情况变得越来越少。你已经在日程上划分出了专门用于任务的时间,并拒绝了在这些时间段发送的会议请求。你还选定了每天的特定时间来回复电子邮件和 Slack 消息。但是你感到不舒服。似乎你在忽视你的同事。你喜欢你新的高效水平,但不喜欢感觉自己让队友失望。这就是办公时间(Office hours)的用处所在。


3.1 将打断集中在办公时间中


大学和学院通常要求教授有办公时间,以便学生在课外获得额外的帮助或提问。教授每周发布他们在办公室的时间,学生知道他们可以随时过去(或在这个时间段内安排一个时间)寻求额外的帮助。这样教授就可以不需要处理来自所有学生的不断问题和打扰。作为技术负责人,你也可以采取同样的做法。


还记得系列文章第一部分提到的那些随机的“嘿”消息吗?你可能遇到了类似的情况,人们只需要你几分钟的时间来回答问题。这是你作为技术负责人的工作的一部分,而且这种情况永远不会消失。相反,你可以将所有这些打扰集中到一周的几个特定时间段内。这样,你就不会整天接收到随机的请求,而是让队友们知道他们可以在办公时间内向你提问,并乐意回答他们的问题。


由于你已经在日程上划分时间块,你可以每周多次为办公时间添加时间块。根据你的打断频率,你可能想每天预留一个小时,或者在周一、周三和周五预留两个小时......确切的时间安排可以根据你的需求进行调整。最重要的是,这是一个专门用于集中处理打扰的时间块,而不是让它们在全天不定期发生。


如果你使用的是 Google Calendar,在这种情况下有一个完美适用的功能,叫做预约日程(appointment schedules)。你可以创建一个预约日程,让人们在你的时间段内请求预约。当人们查看你的日程时,他们将看到你的预约日程,并可通过链接在该时间段内安排时间。如果你没有使用 Google Calendar,你可以在日程上创建一个标记为“办公时间”的时间块,并将其设置为“可用”而不是“忙碌”,以便让其他人轻松看到你什么时候有空。


起初,你可能会遇到一些抵触这种变化的反对声。人们会觉得如果不能立即联系到你,就无法发挥自己的最大效率。因此,我建议每天开始先安排一个办公时间,并等到你和团队都适应了之后再调整(在极端情况下,每天安排两个时间段可能会有用)。如果人们知道他们需要等待与你交谈的最长时间是 24 小时,那么他们就会感到更少的焦虑。后来,你可以调整时间安排以更好地满足团队的需求。


几周后,你可能会发现,与之前不经安排的打断相比,你收到的计划好的打断变少了。为什么呢?因为人们在等待与你见面的过程中解决了自己的问题。这是一件好事,因为团队正在学会更有韧性和独立解决问题,从而释放出你的时间,让你专注于自己的工作任务。


此时,你可能正在思考,那对于其他的所有会议,我该怎么办呢?


3.2 为其他会议设置预约时间块


为办公时间预留时间块实际上只是在你的日程上设立特定类型的预约时间块,也就是说这些是用于可能与他人进行预约的定期时间段。技术负责人经常被拉入不同类型的会议中:与其他工程师的一对一会议、与经理的会议、产品或架构审查等。如果有你需要参加的定期会议,好消息是,这些已经在你的日程上有所体现,并且我鼓励你将其设置为重复会议,这样你就不必再去考虑它们了。对于其他类别的会议,人们请求你的时间,最好为这些目的设置预约时间块。


如何设置这些时间块完全取决于你。也许你想要为一对一会议单独设置专门的时间块,与其他类型的会议分开。也许只有一个大的“会议”时间块,可以安排任何类型的会议。你可以根据你经常参加的会议选择最合适的系统。而且请友善地对待你的同事:在早晨或深夜预留会议时间并不能解决问题。确保你的预约时间段是在人们最有可能请求的时间段内。


当然,有时候人们需要在你规定的预约时间之外安排会议,但希望他们提前联系你,告诉你原因。当然,你不能告诉工程副总裁说你不能参加临时会议。但是当你开始更好地管理你的日程时,你会发现某些会议会在特定的时间出现,然后你就可以相应地调整你的时间块。


3.3 日程的前后变化


之前,我向您展示了在使用时间块之前,您的日程可能会是什么样子:



之前的日程:每天早上 9:30 到 9:45 有团队站立会议;周三中午有团队午餐;一对一会议和其他会议零星分布在整个周内。


接下来,您学习了如何使用时间块将任务安排到日程中,并初步规划了您一周的安排。现在,当您添加预约时间块时,您将更清楚地知道特定类型的会议安排在何处:



时间块日程:每天都有特定时间分配给具体任务,办公时间和一对一会议也分别安排在本周的特定时间段内。


这是一个完全按时间块划分的日程,其中包括办公时间和一对一会议的预约时间块,这两种是技术负责人经常被要求参加的重复性会议。办公时间比较规律,通常是午饭后的 30 或 60 分钟;而一对一会议的时间段则根据可用性安排在周二至周四的不同时间。请记住,尽管这些时间已被预留,但您可能并没有在这些时间段内有任何预约。目标是将它们安排到您的日程中,以便在需要时您知道何时会发生。


请记住,您不需要将每一天的每个小时都预留出来。当您在周五进行每周日程规划时,您可能会发现您的任务和预约时间块并没有填满整个日程,这是可以接受的。只要为您知道需要做的所有事情预留了时间块,留下空闲时间段并不是错的。您甚至可能会发现到了周五,所有的空闲时间段都被填满了。


那么,如何过渡到这个新系统呢?


3.4 沟通至关重要


为了更好地控制你的中断率,你必须明确告知什么样的打扰是可以接受的,以及何时可以打扰。突然改变你的行为会给团队带来不必要的干扰,也会让团队成员更难接受。你可以通过提前告知团队并对其反馈持开放态度来尽量减少这种干扰。一个好的方法是解释说你将尝试这个新流程六周,并根据之后的反馈进行调整。


以下是一封重设关于打扰期望的电子邮件示例:



大家好,


作为技术负责人,我在不断发展的过程中发现自己的时间利用效率不够高,也无法达到我希望的效果。为了解决这个问题,我计划从下周一开始做一些工作上的改变,并且想提前告诉大家以防有任何问题或顾虑。我将进行以下改变:


Slack:我将限制在 Slack 上花费的时间。我发现经常被通知打扰很难集中注意力完成任务。我每天只会检查三次 Slack,并且只在那些时间段内回复消息。如果你需要得到简短的答复,Slack 是与我联系的最佳方式。


技术帮助:我每天下午 2 点到 3 点将作为我解答技术问题的办公时间。请在那段时间内安排一个 15 分钟的时间块,我很乐意直接与你沟通。这样,我就能在我知道自己可以集中注意力的时候全神贯注地帮助你。


一对一会议:如果你需要安排更长时间的讨论,请使用我日程上的一对一时间段。
其他会议:如果你需要安排其他类型的会议,请在我的日程中找到空闲时间段。如果你在你所需的时间范围内找不到合适的时间,请告诉我,我会调整我的日程安排。


紧急情况:如果有任何需要我立即处理的紧急情况,请打电话(而不是发短信)给我的手机。我关闭了短信通知,但电话总是会接通。


我计划尝试这个流程六周,看看效果如何,并且非常希望在过程中得到你们的反馈,以便我在有需要时进行调整。同时,如果你有任何问题或顾虑,请随时与我联系。



当你发送这样的电子邮件时,你很少会遇到对改变的阻力。明确要求反馈并允许人们提出问题可以降低问题发生的可能性。你也没有指责别人或告诉别人不要打扰你。相反,你重新界定了希望如何与你进行不同类型的讨论和如何安排时间。这种清晰度在某些情况下会使人们更有可能找你,因为他们不再担心在不合适的时候打扰你。


3.5 小结


技术负责人的角色充满挑战,通常情况下,你的经理可能并不是一个技术负责人,可能无法为你提供关于如何管理时间的最佳指导。在帮助他人和处理自己的工作之间找到合适的平衡非常重要,而管理被打扰的频率是找到这种平衡的最佳方式之一。


管理打扰频率的一种方法是通过时间块划分来调整你的日程,确保每个你想要专注完成的任务都安排在特定的时间段。这样可以防止人们在这些时间安排会议。只要你专注于手头的任务,关闭其他应用程序并避免其他干扰,你会发现你能做更多的事情。为电子邮件和 Slack 设置时间块,确保你在一天中与团队成员保持正常的沟通。


为了弥补随机打扰的损失,安排定期的办公时间可以让团队成员知道什么时候可以与你联系。具体的办公时间安排取决于你和团队的决定,但应该每周进行几次。在找到最适合你的团队的办公时间安排之前,你可能需要经历几轮尝试。你还可以为其他类型的定期会议,如一对一会议或产品评审,设置类似的预约时间块。


作为技术负责人,很大程度上是关于有效分配时间来帮助团队实现目标。本文讨论的工具可以帮助你重新掌握自己的时间,并使你在交付结果方面更加高效,同时让团队能够在没有持续打扰

作者:KooFE
来源:juejin.cn/post/7263085999970861116
的情况下成长和学习。

收起阅读 »

三言两语说透柯里化和反柯里化

web
JavaScript中的柯里化(Currying)和反柯里化(Uncurrying)是两种很有用的技术,可以帮助我们写出更加优雅、泛用的函数。本文将首先介绍柯里化的概念、实现原理和应用场景,然后介绍反柯里化的概 念、实现原理和应用场景,通过大量的代码示例帮助读...
继续阅读 »

JavaScript中的柯里化(Currying)和反柯里化(Uncurrying)是两种很有用的技术,可以帮助我们写出更加优雅、泛用的函数。本文将首先介绍柯里化的概念、实现原理和应用场景,然后介绍反柯里化的概 念、实现原理和应用场景,通过大量的代码示例帮助读者深入理解这两种技术的用途。


JavaScript中的柯里化


概念


柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。这个技术由数学家Haskell Curry命名。


简单来说,柯里化可以将使用多个参数的函数转换成一系列使用一个参数的函数。例如:


function add(a, b) {
  return a + b; 
}

// 柯里化后
function curriedAdd(a) {
  return function(b) {
    return a + b;
  }
}

实现原理


实现柯里化的关键是通过闭包保存函数参数。以下是柯里化函数的一般模式:


function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    } else {
      return function(...args2) {
        return curried.apply(this, args.concat(args2));
      }
    }
  }
}

curry函数接受一个fn函数为参数,返回一个curried函数。curried函数检查接收的参数个数args.length是否满足fn函数需要的参数个数fn.length。如果满足,则直接调用fn函数;如果不满足,则继续返回curried函数等待接收剩余参数。


这样通过闭包保存每次收到的参数,直到参数的总数达到fn需要的参数个数,然后将保存的参数全部 apply fn执行。


利用这个模式可以轻松将普通函数柯里化:


// 普通函数
function add(a, b) {
  return a + b;


// 柯里化后
let curriedAdd = curry(add); 
curriedAdd(1)(2); // 3

应用场景



  1. 参数复用


柯里化可以让我们轻松复用参数。例如:


function discounts(price, discount) {
  return price * discount;
}

// 柯里化后
const tenPercentDiscount = discounts(0.1); 
tenPercentDiscount(500); // 50
tenPercentDiscount(200); // 20


  1. 提前返回函数副本


有时我们需要提前返回函数的副本给其他模块使用,这时可以用柯里化。


// 模块A
function ajax(type, url, data) {
  // 发送ajax请求
}

// 柯里化后
export const getJSON = curry(ajax)('GET');

// 模块B
import { getJSON } from './moduleA'

getJSON('/users', {name'John'});


  1. 延迟执行


柯里化函数在调用时并不会立即执行,而是返回一个函数等待完整的参数后再执行。这让我们可以更加灵活地控制函数的执行时机。


let log = curry(console.log);

log('Hello'); // 不会立即执行

setTimeout(() => {
  log('Hello'); // 2秒后执行
}, 2000);

JavaScript中的反柯里化


概念


反柯里化(Uncurrying)与柯里化相反,它将一个接受单一参数的函数转换成接受多个参数的函数。


// 柯里化函数  
function curriedAdd(a) {
  return function(b) {
    return a + b;
  }
}

// 反柯里化后
function uncurriedAdd(a, b) {
  return a + b; 
}

实现原理


反柯里化的关键是通过递归不停调用函数并传入参数,Until参数的数量达到函数需要的参数个数。


function uncurry(fn) {
  return function(...args) {
    let context = this;
    return args.reduce((acc, cur) => {
      return acc.call(context, cur); 
    }, fn);
  }
}

uncurry 接收一个函数 fn,返回一个函数。这个函数利用reduce不停调用 fn 并传入参数,Untilargs所有参数都传给 fn


利用这个模式可以轻松实现反柯里化:


const curriedAdd = a => b => a + b;

const uncurriedAdd = uncurry(curriedAdd);
uncurriedAdd(1, 2); // 3

应用场景



  1. 统一接口规范


有时我们会从其他模块接收到一个柯里化的函数,但我们的接口需要一个普通的多参数函数。这时可以通过反柯里化来实现统一。


// 模块A导出
export const curriedGetUser = id => callback => {
  // 调用callback(user)
};

// 模块B中
import { curriedGetUser } from './moduleA';

// 反柯里化以符合接口
const getUser = uncurry(curriedGetUser); 

getUser(123user => {
  // use user
});


  1. 提高参数灵活性


反柯里化可以让我们以任意顺序 passes 入参数,增加了函数的灵活性。


const uncurriedLog = uncurry(console.log);

uncurriedLog('a''b'); 
uncurriedLog('b''a'); // 参数顺序灵活


  1. 支持默认参数


柯里化函数不容易实现默认参数,而反柯里化后可以方便地设置默认参数。


function uncurriedRequest(url, method='GET', payload) {
  // 请求逻辑
}

大厂面试题解析


实现add(1)(2)(3)输出6的函数


这是一道典型的柯里化面试题。解析:


function curry(fn) {
  return function curried(a) {
    return function(b) {
      return fn(a, b);
    }
  }
}

function add(a, b) {
  return a + b;
}

const curriedAdd = curry(add);

curriedAdd(1)(2)(3); // 6

利用柯里化技术,我们可以将普通的 add 函数转化为 curriedAdd,它每次只接收一个参数,并返回函数等待下一个参数,从而实现了 add(1)(2)(3) 的效果。


实现单参数compose函数


compose函数可以将多个函数合并成一个函数,这也是一道常见的柯里化面试题。解析:


function compose(fn1) {
  return function(fn2) { 
    return function(x) {
      return fn1(fn2(x));
    };
  };
}

function double(x) {
  return x * 2;
}

function square(x) {
  return x * x;
}

const func = compose(double)(square);

func(5); // 50

利用柯里化,我们创建了一个单参数的 compose 函数,它每次返回一个函数等待下一个函数参数。这样最终实现了 compose(double)(square) 的效果。


反柯里化Function.bind


Function.bind 函数实现了部分参数绑定,这本质上是一个反柯里化的过程。解析:


Function.prototype.uncurriedBind = function(context) {
  const fn = this;
  return function(...args) {
    return fn.call(context, ...args);
  } 
}

function greet(greeting, name) {
  console.log(greeting, name);
}

const greetHello = greet.uncurriedBind('Hello');
greetHello('John'); // Hello John

uncurriedBind 通过递归调用并传参实现了反柯里化,使 bind 参数从两步变成一步传入,这也是 Function.bind 的工作原理。


总结


柯里化和反柯里化都是非常有用的编程技巧,让我们可以写出更加灵活通用的函数。理解这两种技术的实现原理可以帮助我们更好地运用它们。在编码中,我们可以根据需要决定是将普通函数柯里化,还是将柯里化函数反柯里化。合理运用这两种技术可以大大

作者:一码平川哟
来源:juejin.cn/post/7262349502920605753
提高我们的编程效率。

收起阅读 »

【KRouter】一个简单轻量的 Kotlin 路由框架

KRouter(Kotlin-Router) 是一个非常轻量级的 Kotlin 路由框架。具体而言,KRouter 是一个通过 URI 发现接口实现类的框架。就像这样:val homeScreen = KRouter.route<Screen>("...
继续阅读 »

KRouter(Kotlin-Router) 是一个非常轻量级的 Kotlin 路由框架

具体而言,KRouter 是一个通过 URI 发现接口实现类的框架。就像这样:

val homeScreen = KRouter.route<Screen>("screen/home?name=zhangke")

起因是段时间用 Voyager 时发现模块间的互相通信没这么灵活,需要一些配置,以及 DeepLink 的使用也有点奇怪,相比较而言我更希望能用路由的方式来实现模块间通信,于是就有了这个库。

github.com/0xZhangKe/K…

主要通过 KSP、ServiceLoader 以及反射实现。

使用

上面的那行代码几乎就是全部的使用方式了。

正如上面说的,这个是用来发现接口实现类并且通过 URI 匹配目的地的库,那么我们需要先定义一个接口。

interface Screen

然后我们的项目中与很多各自独立的模块,他们都会实现这个接口,并且每个都有所不同,我们需要通过他们各自的路由(即 URI )来进行区分。

// HomeModule
@Destination("screen/home")
class HomeScreen(@Router val router: String = "") : Screen

// ProfileModule
@Destination("screen/profile")
class ProfileScreen : Screen {
@Router
lateinit var router: String
}

现在我们的两个独立的模块都有了各自的 Screen 了,并且他们都有自己的路由地址。

val homeScreen = KRouter.route<Screen>("screen/home?name=zhangke")
val profileScreen = KRouter.route<Screen>("screen/profile?name=zhangke")

现在就可以通过 KRouter 拿到这两个对象了,并且这两个对象中的 router 属性会被赋值为具体调用 KRouter.route 时的路由。这样你就可以在 HomeScreen 以及 ProfileScreen 拿到通过 uri 传的参数了,然后可以使用这些参数做一些初始化之类的操作。

@Destination

Destination 注解用于注解一个目的地,它包含两个参数:

  • route: 目的地的唯一标识的路由地址,必须是个 URI 类型的 String,不需要包含 query。
  • type : 路由目的地的接口,如果这个类只有一个父类或接口的话是不用设置这个参数的,可以自动推断出来,但如果包含多个父类就需要通过 type 显示指定了。

然后还有个很重要的点,Destination 注解的类,也就是目的地类,必须包含一个无参构造器,否则 ServiceLoader 无法创建对象,对于 Kotlin 类来说,需要保证构造器中的每个入参都有默认值。

@Router

Router 注解用于表示目的地类中的那个属性是用来接受传入的 router 参数的,该属性必须是 String 类型。

标记了该注解的属性会被自动赋值,也可以不设置改注解。

举例来说,上面的例子中的 HomeScreen 对象被创建完成后,其 router 字段的值为 screen/home?name=zhangke

特别注意,如果 @Router 注解的属性不在构造器中,那么需要设置为可修改的,即 Kotlin 中的 var 修饰的变量属性。

KRouter

KRouter 是个单例类,其中只有一个方法。

inline fun <reified T : Any> route(router: String): T?

包含一个范形以及一个路由地址,路由地址可以包含 query 也可以不包含,匹配目的地时会忽略 query 字段。

匹配成功后会通过这个 uri 构建对象,并将 uri 传递给改对象中的 @router 注解标注的字段。

集成

首先需要在项目中集成 KSP

然后添加依赖:

// module's build.gradle.kts
implementation("com.github.0xZhangKe.KRouter:core:0.1.5")
ksp("com.github.0xZhangKe.KRouter:compiler:0.1.5")

因为是使用了 ServiceLoader ,所以还需要设置 SourceSet。

// module's build.gradle.kts
kotlin {
sourceSets.main {
resources.srcDir("build/generated/ksp/main/resources")
}
}

或许你还需要添加 JitPack 仓库:

maven { setUrl("https://jitpack.io") }

原理

正如上面所说,本框架主要使用 ServiceLoader + KSP + 反射实现。

框架主要包含两部分,一是编译阶段的部分,二是运行时部分。

KSP 插件

KSP 插件相关的代码在 compiler 模块。

KSP 插件的主要作用是根据 Destination 注解生成 ServiceLoader 的 services 文件

KSP 的其他代码基本都差不多,主要就是先配置 services 文件,然后根据注解获取到类,然后通过 Visitor 遍历处理,我们直接看 KRouterVisitor 即可。

override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {
val superTypeName = findSuperType(classDeclaration)
writeService(superTypeName, classDeclaration)
}

在 visitClassDeclaration 方法主要做两件事情,第一是获取父类,第二是写入或创建 services 文件。

流程就是先获取 type 指定的父类,没有就判断只有一个父类就直接返回,否则抛异常。

// find super-type by type parameter
val routerAnnotation = classDeclaration.requireAnnotation<Destination>()
val typeFromAnnotation = routerAnnotation.findArgumentTypeByName("type")
?.takeIf { it != badTypeName }

// find single-type
if (classDeclaration.superTypes.isSingleElement()) {
val superTypeName = classDeclaration.superTypes
.iterator()
.next()
.typeQualifiedName
?.takeIf { it != badSuperTypeName }
if (!superTypeName.isNullOrEmpty()) {
return superTypeName
}
}

获取到之后我们需要按照 ServiceLoader 的要求将接口或抽象类的权限定名作为文件名创建一个文件。

然后再将实现类的权限定名写入该文件。

val resourceFileName = ServicesFiles.getPath(superTypeName)
val serviceClassFullName = serviceClassDeclaration.qualifiedName!!.asString()
val existsFile = environment.codeGenerator
.generatedFile
.firstOrNull { generatedFile ->
generatedFile.canonicalPath.endsWith(resourceFileName)
}
if (existsFile != null) {
val services = existsFile.inputStream().use { ServicesFiles.readServiceFile(it) }
services.add(serviceClassFullName)
existsFile.outputStream().use { ServicesFiles.writeServiceFile(services, it) }
} else {
environment.codeGenerator.createNewFile(
dependencies = Dependencies(aggregating = false, serviceClassDeclaration.containingFile!!),
packageName = "",
fileName = resourceFileName,
extensionName = "",
).use {
ServicesFiles.writeServiceFile(setOf(serviceClassFullName), it)
}
}

这样就自动生成了 ServiceLoader 所需要的 services 文件了。

KRouter

KRouter 主要做三件事情:

  • 通过 ServiceLoader 获取接口所有的实现类。
  • 通过 URI 匹配具体的目的地类。
  • 通过 URI 构建目的地类对象。

第一件事情很简单:

inline fun <reified T> findServices(): List<T> {
val clazz = T::class.java
return ServiceLoader.load(clazz, clazz.classLoader).iterator().asSequence().toList()
}

获取到之后就可以通过 URL 来开始匹配。

匹配方式就是获取每个目的地类的 Destination 注解中的 router 字段,然后与路由进行对比。

fun findServiceByRouter(
serviceClassList: List<Any>,
router: String,
): Any? {
val routerUri = URI.create(router).baseUri
val service = serviceClassList.firstOrNull {
val serviceRouter = getRouterFromClassAnnotation(it::class)
if (serviceRouter.isNullOrEmpty().not()) {
val serviceUri = URI.create(serviceRouter!!).baseUri
serviceUri == routerUri
} else {
false
}
}
return service
}

private fun getRouterFromClassAnnotation(targetClass: KClass<*>): String? {
val routerAnnotation = targetClass.findAnnotation<Destination>() ?: return null
return routerAnnotation.router
}

因为匹配策略是忽略 query 字段,所以只通过 baseUri 匹配即可。

下面就是创建对象,这里有两种情况需要考虑。

第一是 @Router 注解在构造器中,这种情况需要重新使用构造器创建对象。

第二种是 @Router 注解在普通属性中,此时直接使用 ServiceLoader 创建好的对象然后赋值即可。

如果在构造器中,先获取 routerParameter 参数,然后通过 PrimaryConstructor 重新创建对象即可。

private fun fillRouterByConstructor(router: String, serviceClass: KClass<*>): Any? {
val primaryConstructor = serviceClass.primaryConstructor
?: throw IllegalArgumentException("KRouter Destination class must have a Primary-Constructor!")
val routerParameter = primaryConstructor.parameters.firstOrNull { parameter ->
parameter.findAnnotation<Router>() != null
} ?: return null
if (routerParameter.type != stringKType) errorRouterParameterType(routerParameter)
return primaryConstructor.callBy(mapOf(routerParameter to router))
}

如果是普通的变量属性,那么先获取到这个属性,然后做一些类型权限之类的校验,然后调用 setter 赋值即可。

private fun fillRouterByProperty(
router: String,
service: Any,
serviceClass: KClass<*>,
): Any? {
val routerProperty = serviceClass.findRouterProperty() ?: return null
fillRouterToServiceProperty(
router = router,
service = service,
property = routerProperty,
)
return service
}

private fun KClass<*>.findRouterProperty(): KProperty<*>? {
return declaredMemberProperties.firstOrNull { property ->
val isRouterProperty = property.findAnnotation<Router>() != null
isRouterProperty
}
}

private fun fillRouterToServiceProperty(
router: String,
service: Any,
property: KProperty<*>,
) {
if (property !is KMutableProperty<*>) throw IllegalArgumentException("@Router property must be non-final!")
if (property.visibility != KVisibility.PUBLIC) throw IllegalArgumentException("@Router property must be public!")
val setter = property.setter
val propertyType = setter.parameters[1]
if (propertyType.type != stringKType) errorRouterParameterType(propertyType)
property.setter.call(service, router)
}

OK,以上就是关于 KRouter 的所有内容了。


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

一篇文章了解Kotlin的泛型

Kotlin 泛型类型Kotlin 的泛型特性允许我们编写出更加灵活和通用的代码,提高了代码的可重用性和类型安全性。本文将介绍 Kotlin 中的四种泛型类型类型参数星号投影型变泛型限制类型参数定义一个泛型类或函数时,使用尖括号 < >&...
继续阅读 »

Kotlin 泛型类型

Kotlin 的泛型特性允许我们编写出更加灵活和通用的代码,提高了代码的可重用性和类型安全性。

本文将介绍 Kotlin 中的四种泛型类型

  • 类型参数
  • 星号投影
  • 型变
  • 泛型限制

类型参数

定义一个泛型类或函数时,使用尖括号 < > 来指定类型参数。例如,以下是一个将泛型类型 T 用作参数的示例:

class MyList<T> { ... }

在这个示例中,T 是一个占位符类型参数,用于表示某个类型。在使用该类时,可以通过指定实际的类型参数来创建具体类型的实例。例如:

val list = MyList<String>()

在这个示例中,我们创建了一个 MyList 类型的实例,并将 String 类型指定为其类型参数。这意味着 list 变量可以存储 String 类型的元素。

星号投影

星号投影是一种特殊语法,用于表示您不关心实际类型参数的情况。通过使用 * 替代类型参数,您可以指定该参数将被忽略。例如,以下是一个使用星号投影的示例:

fun printList(list: List<*>) {
for (item in list) {
println(item)
}
}

在这个示例中,printList 函数接收一个 List<*> 类型的参数,该类型使用星号投影来表示它可以存储任何类型的元素。循环遍历该列表,并将每个元素输出到控制台。

型变

型变是指泛型类型之间的继承关系。在 Kotlin 中,有三种型变:in、out 和 invariant。这些型变用于描述子类型和超类型之间的关系,并影响如何将泛型类型赋值给其他类型。

  1. in 型变:用于消费型位置(比如方法参数),表示只能从泛型类型中读取数据,不能写入数据。

    interface Source<out T> {
    fun next(): T
    }

    fun demo(strs: Source<String>) {
    val objects: Source<Any> = strs
    // ...
    }

    在这个示例中,我们定义了一个泛型接口 Source,并使用 out 关键字将其标记为协变类型。这意味着我们可以将 Source<String> 类型的对象视为 Source<Any> 类型的对象,并将其赋值给 objects 变量。

  2. out 型变:用于生产型位置(比如返回值),表示只能向泛型类型中写入数据,不能读取数据。

    interface Sink<in T> {
    fun put(element: T)
    }

    fun demo(sinkOfAny: Sink<Any>) {
    val sinkOfString: Sink<String> = sinkOfAny
    // ...
    }

    在这个示例中,我们定义了一个泛型接口 Sink,并使用 in 关键字将其标记为逆变类型。这意味着我们可以将一个 Sink<Any> 类型的对象视为 Sink<String> 类型的对象,并将其赋值给 sinkOfString 变量。

  3. invariant 型变:默认情况下,Kotlin 中的泛型类型都是不变(invariant)的。这意味着不能将一个 List<String> 类型的对象视为 List<Any> 类型的对象。

泛型限制

泛型限制用于约束泛型类型可以具体化为哪些类型。例如,使用 where 关键字可以给泛型类型添加多个限制条件。以下是一个使用泛型限制的示例:

fun <T> showItems(list: List<T>) where T : CharSequence, T : Comparable<T> {
list.filter { it.length > 5 }.sorted().forEach(::println)
}

在这个示例中,我们定义了一个名为 showItems 的函数,它接受一个 List<T> 类型的参数,并对该列表进行过滤、排序和输出操作。其中,T 是一个泛型类型参数,用于表示列表中的元素类型。

为了限制 T 的类型,我们使用 where 关键字并添加了两个限制条件:T 必须实现 CharSequence 接口和 Comparable 接口。这意味着当我们调用 showItems 函数时,只能传递那些既实现了 CharSequence 接口又实现了 Comparable 接口的类型参数。

需要注意的是,在 Kotlin 中使用泛型限制时,限制条件必须放在 where 关键字之后,并且使用逗号 , 分隔各个限制条件。如果有多个限制条件,建议将它们放在新行上,以提高代码的可读性。


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

优雅可拓展的登录封装,让你远离if-else

前言Hi,大家好,我是你们的秃头朋友程序员小甲,相信各位码农朋友在搭建从0到1项目时在搭建完基建等任务后,最先去做的都是去搭建系统的用户体系,那么每一个码农朋友都会去编码属于自己系统的一套用户登录注册体系;但是登录方式极其多样,光小甲一个人对接的就有googl...
继续阅读 »

前言

Hi,大家好,我是你们的秃头朋友程序员小甲,相信各位码农朋友在搭建从0到1项目时在搭建完基建等任务后,最先去做的都是去搭建系统的用户体系,那么每一个码农朋友都会去编码属于自己系统的一套用户登录注册体系;但是登录方式极其多样,光小甲一个人对接的就有google登录,苹果登录,手机验证码,微信验证码登录,微博登录等各种各样的登录;

针对这么多的登录方式,小甲是如何进行功能接入的呢?(Ps:直接switch-case和if-else接入不香吗,又不是不能用,这其实是小甲做功能时最真实的想法了,但是迫于团队老大哥的强大气场,小甲自然不敢这样硬核编码了),接下来就让秃头小甲和大伙一起分享一下是怎么让普普通通的登录也能玩出逼格的!(由于篇幅过长,接下来进入硬核时刻,希望各位能挺住李云龙二营长的意大利跑前进哈)

功能实现

技术栈:SpringBoot,MySQL,MyBatisPlus,hutool,guava,Redis,Jwt,Springboot-emial等;

sdk组件架构

项目结构包:

  1.    tea-api(前台聚合服务)
  2.    tea-mng(后管聚合服务)
  3.    tea-sdk(SpringBoot相关组件模块)
  4.    tea-common(公共模块,提供一些工具类支持和公有类引用)

项目结构引用关系: sdk引入了common包,api和mng引入了sdk包;

封装思路

思路一:通过前端登录路由请求头key值通过反射生成对应的LoginProvider类来进行登录业务逻辑的执行。具体的做法如下:

  1. 在classPath路径下新增一个json/Provider.json文件,json格式如下图所示:

1683047225979.png

  1. 定义具体的Provider继承基类Provider,秃头小甲这里定义了一般业务系统最常对接的集中Provider(PS:由于google登录和App登录主要是用于对接海外业务,因此小甲这里就没把集成代码放出来了)如下图是小甲定义的几个Provider:

1683047738587.png

其中UserLoginService是所有Provider的基类接口,封装了模板方法。EmialLoginProvider类主要是实现邮箱验证码登录,PasswordProvider用于实现账号密码登录,PhoneLoginProvider是用于手机号验证码登录.WbLoginProvider用于实现PC端微博授权登录,WxLoginPrvider用于实现微信PC端授权登录;

3.EmailLoginProvider和PhoneLoginProvider需要用到验证码校验,因此需要实现UserLoginService接口的验证码获取,并将获取到的验证码存储到redis中;

4.将前端的路由gateWay作为key值,需要加载的动态类名作为value值。定义一个LoginService业务处理类,类中定义一个Map缓存对象,在bean注入加载到ioc容器时,通过读取解析json文件对Map缓存进行反射属性注入,该设计理念参考了Springboot的SPI注入原理以此实现对Provider的可拔插操作;

思路二:

  1. 通过SpringBoot事件监听机制,通过前端路由请求头的key值发布生成不同的ApplicationEvent事件,利用事件监听对业务处理解耦;
  2. 定义具体的Event事件以及Listener;
  3. 根据前端路由gateWay值生成需要发布的Event事件基类,在具体的listener类上根据@EventListener注解来对具体的事件进行监听处理;

思路对比

思路一通过模板+工厂+反射等设计模式的原理对多方式登录方式来达到解耦和拓展,从而规避了开发人员大量的if-else或switch等硬编码的方式,思路二通过模板+工厂+事件监听机制等设计模式也做到了对多方式登录的解耦和拓展,两种思路均能做到延伸代码的拓展性的作用;

封装源码

1.基类UserLoginService

/**
* 登录
*
* @param req 登录请求体
* @return
*/
LoginResp login(LoginReq req);


/**
* 验证码获取
*
* @param req 登录请求体
* @return
*/
LoginResp vertifyCode(LoginReq req);

2.拓展类Provider代码

public class EmailLoginProvider implements UserLoginService {

@Override
public LoginResp login(LoginReq req) {
UserService userService = SpringUtil.getBean(UserService.class);
User user = userService.getOne(Wrappers.lambdaQuery(new User()).eq(User::getEmail, req.getEmail()).eq(User::getStatus, 1));
if (Objects.isNull(user)) {
return null;
}
String redisKey = req.getEmail();
RedisTemplate redisTemplate = SpringUtil.getBean(StringRedisTemplate.class);
String code = (String) redisTemplate.opsForValue().get(redisKey);
if (StringUtils.isEmpty(code)||!code.equals(req.getCode())) {
return null;
}
String token = JwtParse.getoken(user);
LoginResp resp = new LoginResp();
resp.setToken(token);
return resp;
}

@Override
public LoginResp vertifyCode(LoginReq req) {
String redisKey = req.getEmail();
LoginResp resp = new LoginResp();
RedisTemplate redisTemplate = SpringUtil.getBean(StringRedisTemplate.class);
String code = (String) redisTemplate.opsForValue().get(redisKey);
if (StringUtils.isNotEmpty(code)) {
resp.setCode(code);
return resp;
}
MailService mailService = SpringUtil.getBean(MailService.class);
String mailCode = CodeUtils.make(4);
mailService.sendMail(req.getEmail(), "邮箱验证码", mailCode);
redisTemplate.opsForValue().set(req.getEmail(), mailCode);
return resp;
}
}
public class PasswordProvider implements UserLoginService {

@Override
public LoginResp login(LoginReq req) {
UserService userService = SpringUtil.getBean(UserService.class);
User user = userService.getOne(Wrappers.lambdaQuery(new User()).eq(User::getPassword, req.getPassword()).eq(User::getStatus, 1));
if (Objects.isNull(user)) {
return null;
}
String token = JwtParse.getoken(user);
LoginResp resp = new LoginResp();
resp.setToken(token);
return resp;
}

@Override
public LoginResp vertifyCode(LoginReq req) {
return null;
}
}
public class PhoneLoginProvider implements UserLoginService {

@Override
public LoginResp login(LoginReq req) {
UserService userService = SpringUtil.getBean(UserService.class);
User user = userService.getOne(Wrappers.lambdaQuery(new User()).eq(User::getPhone, req.getPhone()).eq(User::getStatus, 1));
if (Objects.isNull(user)) {
return null;
}
String redisKey = req.getPhone();
RedisTemplate redisTemplate = SpringUtil.getBean(RedisTemplate.class);
String code = (String) redisTemplate.opsForValue().get(redisKey);
if (!code.equals(req.getCode())) {
return null;
}
String token = JwtParse.getoken(user);
LoginResp resp = new LoginResp();
resp.setToken(token);
return resp;
}

@Override
public LoginResp vertifyCode(LoginReq req) {
String redisKey = req.getPhone();
LoginResp resp = new LoginResp();
RedisTemplate redisTemplate = SpringUtil.getBean(RedisTemplate.class);
String code = (String) redisTemplate.opsForValue().get(redisKey);
if (StringUtils.isNotEmpty(code)) {
resp.setCode(code);
return resp;
}
MailService mailService = SpringUtil.getBean(MailService.class);
String mailCode = CodeUtils.make(4);
mailService.sendMail(req.getPhone(), "手机登录验证码", mailCode);
redisTemplate.opsForValue().set(req.getEmail(), mailCode);
return resp;
}
}
public class WxLoginProvider implements UserLoginService {

@Override
public LoginResp login(LoginReq req) {
WxService wxService = SpringUtil.getBean(WxService.class);
WxReq wxReq = new WxReq();
wxReq.setCode(req.getAuthCode());
WxResp token = wxService.getAccessToken(wxReq);
String accessToken = token.getAccessToken();
if (StringUtils.isEmpty(accessToken)) {

}
wxReq.setOpenid(token.getOpenid());
WxUserInfoResp userInfo = wxService.getUserInfo(wxReq);
//根据unionId和openid查找一下当前用户是否已经存在系统,如果不存在,帮其注册这里单纯是为了登录;
UserService userService = SpringUtil.getBean(UserService.class);
User user = userService.getOne(Wrappers.lambdaQuery(new User()).eq(User::getOpenId, token.getOpenid()).eq(User::getUnionId, token.getUnionId()));
if (Objects.isNull(user)) {

}
String getoken = JwtParse.getoken(user);
LoginResp resp = new LoginResp();
resp.setToken(getoken);
return resp;
}

@Override
public LoginResp vertifyCode(LoginReq req) {
return null;
}
}

3.接口暴露Service--LoginService源码

@Service
@Slf4j
public class LoginService {

private Map<String, UserLoginService> loginServiceMap = new ConcurrentHashMap<>();

@PostConstruct
public void init() {
try {
List<JSONObject> jsonList = JSONArray.parseObject(ResourceUtil.getResource("json/Provider.json").openStream(), List.class);
for (JSONObject object : jsonList) {
String key = object.getString("key");
String className = object.getString("value");
Class loginProvider = Class.forName(className);
UserLoginService loginService = (UserLoginService) loginProvider.newInstance();
loginServiceMap.put(key, loginService);
}
} catch (Exception e) {
log.info("[登录初始化异常]异常堆栈信息为:{}", ExceptionUtils.parseStackTrace(e));
}
}

/**
* 统一登录
*
* @param gateWayRoute 路由路径
* @param req 登录请求
* @return
*/
public RetunrnT<LoginResp> login(String gateWayRoute, LoginReq req) {
UserLoginService userLoginService = loginServiceMap.get(gateWayRoute);
LoginResp loginResp = userLoginService.login(req);
return RetunrnT.success(loginResp);
}


/**
* 验证码发送
*
* @param gateWayRoute 路由路径
* @param req 登录请求
* @return
*/
public RetunrnT<LoginResp> vertifyCode(String gateWayRoute, LoginReq req) {
UserLoginService userLoginService = loginServiceMap.get(gateWayRoute);
LoginResp resp = userLoginService.vertifyCode(req);
return RetunrnT.success(resp);
}

}

4.邮件发送Service具体实现--MailService

public interface MailService {

/**
* 发送邮件
*
* @param to 收件人
* @param subject 主题
* @param content 内容
*/
void sendMail(String to, String subject, String content);
}
@Service
@Slf4j
public class MailServiceImpl implements MailService {

/**
* Spring Boot 提供了一个发送邮件的简单抽象,直接注入即可使用
*/
@Resource
private JavaMailSender mailSender;
/**
* 配置文件中的发送邮箱
*/
@Value("${spring.mail.from}")
private String from;

@Override
@Async
public void sendMail(String to, String subject, String content) {
//创建一个邮箱消息对象
SimpleMailMessage message = new SimpleMailMessage();
//邮件发送人
message.setFrom(from);
//邮件接收人
message.setTo(to);
//邮件主题
message.setSubject(subject);
//邮件内容
message.setText(content);
//发送邮件
mailSender.send(message);
log.info("邮件发成功:{}", message.toString());
}
}

5.token生成JsonParse类

private static final String SECRECTKEY = "zshsjcbchsssks123";

public static String getoken(User user) {
//Jwts.builder()生成
//Jwts.parser()验证
JwtBuilder jwtBuilder = Jwts.builder()
.setId(user.getId() + "")
.setSubject(JSON.toJSONString(user)) //用户对象
.setIssuedAt(new Date())//登录时间
.signWith(SignatureAlgorithm.HS256, SECRECTKEY).setExpiration(new Date(System.currentTimeMillis() + 86400000));
//设置过期时间
//前三个为载荷playload 最后一个为头部 header
log.info("token为:{}", jwtBuilder.compact());
return jwtBuilder.compact();
}

6.微信认证授权Service---WxService


public interface WxService {

/**
* 通过code获取access_token
*/
WxResp getAccessToken(WxReq req);

/**
* 通过accessToken获取用户信息
*/
WxUserInfoResp getUserInfo(WxReq req);
}
@Service
@Slf4j
public class WxServiceImpl implements WxService {

@Resource
private WxConfig wxConfig;


@Override
public WxResp getAccessToken(WxReq req) {
req.setAppid(wxConfig.getAppid());
req.setSecret(wxConfig.getSecret());
Map map = JSON.parseObject(JSON.toJSONString(req), Map.class);
WxResp wxResp = JSON.parseObject(HttpUtil.createGet(wxConfig.getTokenUrl()).formStr(map).execute().body(), WxResp.class);
return wxResp;
}

@Override
public WxUserInfoResp getUserInfo(WxReq req) {
req.setAppid(wxConfig.getAppid());
req.setSecret(wxConfig.getSecret());
Map map = JSON.parseObject(JSON.toJSONString(req), Map.class);
return JSON.parseObject(HttpUtil.createGet(wxConfig.getGetUserUrl()).formStr(map).execute().body(), WxUserInfoResp.class);
}
}

功能演练

1683049554852.png

项目总结

相信很多小伙伴在平时开发过程中都能看到一定的业务硬核代码,前期设计不合理,后续开发只能在前人的基础上不断的进行if-else或者switch来进行业务的功能拓展,千里之行基于跬步,地基不稳注定是要地动山摇的,希望在接下来的时光,秃头小甲也能不断提升自己的水平,写出更多有水准的代码;

碎碎念时光

首先很感谢能看完全篇幅的各位老铁兄弟们,希望本篇文章能对各位和秃头小甲一样码农有所帮助,当然如果各位技术大大对这模块做法有更优质的做法的,也欢迎各位技术大大能在评论区留言探讨,写在最后~~~~~~ 创作不易,希望各位老铁能不吝惜于自己的手指,帮秃头点下您宝贵的赞把!


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

Kotlin的语法糖到底有多甜?

JYM大家好,好久没来写文了。今天带给大家 Kotlin 的内容,可能一些常关注我的朋友也发现了,在我之前的文章中就开始用 Kotlin 代码做代码示例了,这是因为最近一年我都在高强度使用 Kotlin 进行后端开发。相信很多安卓开发的朋友早就开始用上 Kot...
继续阅读 »

JYM大家好,好久没来写文了。

今天带给大家 Kotlin 的内容,可能一些常关注我的朋友也发现了,在我之前的文章中就开始用 Kotlin 代码做代码示例了,这是因为最近一年我都在高强度使用 Kotlin 进行后端开发。

相信很多安卓开发的朋友早就开始用上 Kotlin 了,但是许多后端对这门语言应该还是不太了解,反正在我的朋友圈里没有见到过用 Kotlin 写后端的程序员存在,在我亲身用了一年 Kotlin 之后已经不太想用 Java 进行代码开发了,起码在开发效率方面就已经是天差地别了,所以今天特地给大家分享一下 Kotlin 的好,希望能带领更多人入坑。

1. 第一段代码

很多人说 Kotlin 就是披了一层语法糖的 Java,因为它百分百兼容 Java,甚至可以做到 Kotlin 调用 Java 代码。

其实我对这个说法是赞同的,但是又不完全一样,因为 Kotlin 有自己的语法、更有自己的编译器、还有着多端支持,更有着自己的设计目标。

我更倾向于把 Kotlin 看成一个 JVM 系语言,就像 Scala 语言一样,只是恰好 Kotlin 有一个设计目标就是百分百兼容 Java。

在语言层面,Kotlin 几乎是借鉴了市面上所有的现代化语言的强大特性,协程、函数式、扩展函数、空安全,这些广受好评的特性全部都有。

而且从我个人感受来看,我用过 Java、Kotlin、JS、Go、Dart、TS、还有一点点 Python,我觉得 JS 和 Kotlin 的语法是比较方便易用的,Dart、Java 和 Go 的语法都不是太方便易用,语法的简单也在一定程度上减少了开发者的心智负担。

下面我将用一段代码,来简单说明一下 Kotlin 常见的语法特性:

fun main(args: Array<String>) {
val name = "Rookie"

// Hello World! Rookie
println("Hello World! $name")

// Hello World! 82,111,111,107,105,101
println("Hello World! ${name.chars().toList().joinToString(",")}")

test(createPerson = { Person("Rookie", 25) })

}

data class Person(
var name: String = "",
var age: Int = 0,
)

fun test(createPerson: () -> Person, test : String = "test"): Person {

val person = createPerson()

// Person(name=Rookie, age=25)
println(person)

return person
}

上面是一段简简单单的 Kotlin 代码,但是却可以看出 Kotlin 的很多特性,请听我娓娓道来~

  1. Kotlin 的启动方法也是 main 函数,但是 Kotlin 移除了所有基础类型,一切皆对象,比如 Java 中的数组对应的就是 Array 类,int 对应的是 Int 类。
  2. Kotlin 使用类型推断来声明类型,一共有两个关键字,val 代表这是一个不可变变量,var 代表这是一个可变的变量,这两个关键字选用我感觉比 JS 还要好。
  3. Kotlin 代码每一行不需要英文分号结尾。
  4. Kotlin 支持字符串模板,可以直接字符串中使用 '$' 符号放置变量,如果你想放置一个函数的计算结果,需要用 '${}' 来包裹。
  5. Kotlin 是一个函数式语言,支持高阶函数,闭包、尾递归优化等函数式特性。
  6. Kotlin 为了简化 Java Bean,支持了数据类 data class,它会自动生成无参构造、getter、setter、equals()、hashCode()、copy()、toJSON()、toString() 方法。
  7. Kotlin 的函数关键字是 fun,返回值在函数的最后面,变量名在类型的前面,几乎新兴语言都是这样设计的,可以明显感受到语言设计者想让我们更多关注业务含义而非数据类型。
  8. Kotlin 具有一些类似 go 和 Python 的内置函数,比如 println。
  9. Kotlin 的函数参数支持默认值。
  10. Kotlin 不支持多参数返回,但是为了解决这个问题它内置了两个类:Pair 和 Triple,分别可以包装两个返回值和三个返回值。

2. 基础常用特性

了解了一些 Kotlin 的基础语法之后,我再来介绍一些常用的基础特性。

第一个就是空安全和可空性。

Kotlin 中的变量可以声明为非空和可空,默认的声明都是非空,如果需要一个变量可空的,需要在类型后面加一个问号,就像这样:

fun start() {
val name1 : String = ""
val name : String? = null
}

函数的参数声明也一样,也会区分非空和可空,Kotlin 编译器会对代码上下文进行检查,在函数调用处也会对变量是否可空进行一致性检查,如果不通过则会有编译器提醒,我是强烈建议不用可空变量,一般都可以通过默认值来处理。

那么如果你接手的是前人代码,他声明变量为可空,但是希望为空的时候传递一个默认值,则可以使用这个语法进行处理:

fun start() {
val name : String? = null
println(name ?: "Rookie")
}

这是一个类似三元表达式的语法(Elvis 运算符),在 Kotlin 中极其常见,除此之外你还可以进行非空调用:

fun start() {
val name : String? = null
println(name?.chars() ?: "Rookie")
}

这段代码就表示:如果变量不为空就调用 chars 方法,如果为空则返回默认值 Rookie,在所有可空变量上都支持这种写法,并且支持链式调用。

第二个常用特性是异常处理, 写到这里突然想到了一个标题,Kotlin 的异常处理,那叫一个优雅!!!

fun start() {
val person = runCatching {
test1()
}.onFailure {

}.onSuccess {

}.getOrNull() ?: Person("Rookie", 25)
}

fun test1() : Person {
return Person()
}

这一段代码中的 test1 方法你可以当作一个远程调用方法或者逻辑方法,对了,这里隐含了一个语法,就是一个函数中的最后一行的计算结果是它的返回值,你不需要显示的去写 return。

我使用 runCatching 包裹我们的逻辑方法,然后有三个链式调用:

  1. onFailure:当逻辑方法报错时会进入这个方法。
  2. onSuccess:当逻辑方法执行成功时会进入这个方法。
  3. getOrNull:当逻辑方法执行成功时正常返回,执行失败时返回一个空变量,然后我们紧跟一个 ?: ,这代表当返回值为空时我们返回自定义的默认值。

如此一来,一个异常处理的闭环就完成了,每一个环节都会被考虑到,这些链式调用的方法都是可选的,如果你不手动调用处理会有默认的处理方式,大家伙觉得优雅吗?

第三个特性是改进后的流程控制。

fun start() {
val num = (1..100).random()
val name = if (num == 1) "1" else { "Rookie" }
val age = when (num) {
1 -> 10
2 -> 20
else -> { (21..30).random() }
}
}

我们先声明一个随机数,然后根据条件判断语句返回不同的值,其中 Java 中的 Switch 由 When 语法来替代。

而且这里每一段表达式都可以是一个函数,大家可以回忆一下,如果你使用 Java 来完成通过条件返回不同变量的逻辑会有多麻烦。

如果大家在不了解 Kotlin 的情况下尝试用更简单的方式来写逻辑,可以问问类似 ChatGPT 这种对话机器人来进行辅助你。

3. 常用内置函数

就像每个变量类型都有 toString 方法一样,Kotlin 中的每个变量都具有一些内置的扩展函数,这些函数可以极大的方便我们开发。

apply和also

fun start() {
val person = Person("Rookie", 25)

val person1 = person.apply {
println("name : $name, age : $age, This : $this")
}

val person2 = person.also {
println("name : ${it.name}, age : ${it.age}, This : $it")
}
}

这两个函数调用之后都是执行函数体后返回调用变量本身,不同的是 apply 的引用为 this,内部取 this 变量时不需要 this.name 可以直接拿 name 和 age 变量。

而 also 函数则默认有一个 it,it 就是这个变量本身的引用,我们可以通过这个 it 来获取相关的变量和方法。

run 和 let

fun start() {
val person = Person("Rookie", 25)

val person1 = person.run {
println("name : $name, age : $age, This : $this")
"person1"
}

val person2 = person.let {
println("name : ${it.name}, age : ${it.age}, This : $it")
"person2"
}
}

run 函数和 let 函数都支持返回与调用变量不同的返回值,只需要将返回值写到函数最后一行或者使用 return 语句进行返回即可,上例中 person 变量进行调用之后的返回结果就是一个 String 类型。

在使用上的具体差异也就是引用对象的指向不同,具体更多差异可以看一下网络上总结,我这里表明用法就可以了。

除了这四个函数之外,还有许多的类似函数帮我们来做一些很优雅的代码处理和链式调用,但是我根本没有用过其它的函数,这四个函数对我来说已经足够了,有兴趣的朋友可以慢慢发掘。

4. 扩展函数与扩展属性

上文了我们举了几个常见的内置函数,其实他们都是使用 Kotlin 的扩展函数特性实现的。

所谓扩展函数就是可以为某个类增加扩展方法,比如给 JDK 中的 String 类增加一个 isRookie 方法来判断某个字符串是否是 Rookie:

fun start() {
val name = "rookie"
println(name.isRookie())
}

fun String.isRookie(): Boolean {
return this == "Rookie"
}

this 代表了当前调用者的引用,利用扩展函数你可以很方便的封装一些常用方法,比如 Long 型转时间类型,时间类型转 Long 型,不比像以前一样再用工具类做调用了。

除了扩展函数,Kotlin 还支持扩展属性:

fun start() {
val list = listOf(1, 2, 3)
println(list.maxIndex)
}

val <T> List<T>.maxIndex: Int
get() = if (this.isEmpty()) -1 else this.size - 1

通过定义一个扩展属性和定义它的 get 逻辑,我们就可以为 List 带来一个全新属性——maxIndex,这个属性用来返回当前 List 的最大元素下标。

扩展函数和扩展属性多用于封闭类,比如 JDK、第三方 jar 包作为扩展使用,它的实际使用效果其实和工具类是一样的,只不过更加优雅。

不过借用这个能力,Kotlin 为所有的常用类都增加了一堆扩展,比如 String:

基本上你可以想到的大部分函数都已经被 Kotlin 内置了,这就是 Kotlin 的语法糖。

5. Kotlin的容器

终于来到我们这篇文章的大头了,Kotlin 中的容器基本上都是:List、Map 扩展而来,作为一个函数式语言,Kotlin 将容器分为了可变与不可变。

我们先来看一下普遍的用法:

fun start() {
val list = listOf(1, 2, 3)
val set = setOf(1, 2, 3)
val map = mapOf(1 to "one", 2 to "two", 3 to "three")
}

上面的例子中,我们使用三个内置函数来方便的创建对应的容器,但是此时创建的容器是不可变的,也就是说容器内的元素只能读取,不能添加、删除和修改。

当然,Kotlin 也为此类容器增加了一些方法,使其可以方便的增加元素,但实际行为并不是真的往容器内增加元素,而是创建一个新的容器将原来的数据复制过去:

fun start() {
val list = listOf(1, 2, 3).plus(4)
val set = setOf(1, 2, 3).plus(4)
val map = mapOf(1 to "one", 2 to "two", 3 to "three").plus(4 to "four")
}

如果我们想要创建一个可以增加、删除元素的容器,也就是可变容器,可以用以下函数:

fun start() {
val list = mutableListOf(1, 2, 3)
val set = mutableSetOf(1, 2, 3)
val map = mutableMapOf(1 to "one", 2 to "two", 3 to "three")
}

讲完了,容器的创建,可以来聊聊相关的一些操作了,在 Java 中有一个 Stream 流,在 Stream 中可以很方便的做一些常见的函数操作,Kotlin 不仅完全继承了过来,还加入了大量方法,大概可以包含以下几类:

  1. 排序:sort
  2. 乱序:shuffle
  3. 分组:group、associate、partition、chunked
  4. 查找:filter、find
  5. 映射:map、flatMap
  6. 规约:reduce、min、max

由于函数实在太多,我不能一一列举,只能给大家举一个小例子:filter:

一个 filter 有这么多种多样的函数,几乎可以容纳你所有的场景,这里说两个让我感觉到惊喜的函数:chunked 和 partition。

chunked 函数是一个分组函数,我常用的场景是避免请求量过大,比如在批量提交时,我可以将一个 list 中的元素进行 1000 个一组,每次提交一组:

fun start() {
val list = mutableListOf(1, 2, 3)

val chunk : List<List<Int>> = list.chunked(2)
}

示例代码中为了让大家看的清楚我故意声明了类型,实际开发中可以不声明,会进行自动推断。

在上面这个例子中,我将一个 list 进行每组两个进行分组,最终得到一个 List<List> 类型的变量,接下来我可以使用 forEach 进行批量提交,它底层通过 windowed 函数进行调用,这个函数也可以直接调用,有兴趣的朋友可以研究一下效果,通过名字大概可以知道是类似滑动窗口。

partition 你可以将其看作一个分组函数,它算是 filter 的补充:

fun start() {
val list = mutableListOf(1, 2, 3)

val partition = list.partition { it > 2 }

println(partition.first)
println(partition.second)
}

它通过传入一个布尔表达式,将一个 List 分为两组,返回值是上文提到过的 Pair 类型,Pair 有两个变量:first 和 second。

partition 函数会将符合条件的元素放到 first 中去,不符合条件的元素放到 second 中,我自己的使用的时候很多是为了日志记录,要把不处理的元素也记录下来。

容器与容器之间还可以直接通过类似:toList、toSet之类的方法进行转换,非常方便,转换 Map 我一般使用 associate 方法,它也有一系列方法,主要作用就是可以转换过程中自己指定 Map 中的 K 和 V。

6. 结束语

不知不觉都已经快四千字了,我已经要结束这篇文章了,但是仍然发现几乎什么都没写,也对,这只是一篇给大家普及 Kotlin 所带来效率的提升的文章,而不是专精的技术文章。

正如我在标题中写的那样:Kotlin 的语法糖到底有多甜?Kotlin 的这一切我都将其当作语法糖,它能极大提高我的开发效率,但是一些真正 Kotlin 可以做到而 Java 没有做到的功能我却没有使用,比如:协程。

由于我一直是使用 Kotlin 写后端,而协程的使用场景我从来没有遇到过,可能做安卓的朋友更容易遇到,所以我没有对它进行举例,对于我来说,Kotlin 能为我的开发大大提效就已经很不错了。

使用 Kotlin 有一种使用 JS 的感觉,有时候可以一个方法开头就写一个 return,然后链式调用一直到方法结束。

我还是蛮希望 Java 开发者们可以转到 Kotlin,感受一下 Kotlin 的魅力,毕竟是百分百兼容。

在这里要说一下我使用的版本,我使用的是 JDK17、Kotlin 1.8、Kotlin 编译版本为 1.8,也就是说 Kotlin 生成的代码可以跑在最低 JDK1.8 版本上面,这也是一个 Kotlin 的好处,你可以通过升级 Kotlin 的版本体验最新的 Kotlin 特性,但是呢,你的 JDK 平台不用变。

对了,Kotlin 将反射封装的极好,喜欢研究的朋友也可以研究一下。

好了,这篇文章就到这里,希望大家能帮我积极点赞,提高更新动力,人生苦短,我用KT。


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

MySQL Join原理

Join的类型left join,以左表为驱动表,以左表作为结果集基础,连接右表的数据补齐到结果集中right join,以右表为驱动表,以右表作为结果集基础,连接左表的数据补齐到结果集中inner join,结果集取两个表的交集full join,结果集取两...
继续阅读 »

Join的类型

  1. left join,以左表为驱动表,以左表作为结果集基础,连接右表的数据补齐到结果集中
  1. right join,以右表为驱动表,以右表作为结果集基础,连接左表的数据补齐到结果集中
  1. inner join,结果集取两个表的交集
  1. full join,结果集取两个表的并集
    1. mysql没有full join,union取代
    2. union与union all的区别为,union会去重
  1. cross join 笛卡尔积
    1. 如果不使用where条件则结果集为两个关联表行的乘积
    2. 与,的区别为,cross join建立结果集时会根据on条件过滤结果集合
  1. straight_join
    1. 严格根据SQL顺序指定驱动表,左表是驱动

Join原理

本质上可以理解为嵌套循环的操作,驱动表作为外层for循环,被驱动表作为内层for循环。根据连接组成数据的策略可以分为三种算法。

Simpe Nested-Loop Join

  1. 连接比如有A表,B表,两个表JOIN的话会拿着A表的连表条件一条一条在B表循环,匹配A表和B表相同的id 放入结果集,这种效率是最低的。

Index Nested-Loop Join

  1. 执行流程(磁盘扫描)
    1. 从表t1中读入一行数据 R;
    2. 从数据行R中,取出a字段到表t2里进行树搜索查找
    3. 取出表t2中满足条件的行,跟R组成一行,作为结果集的一部分;
    4. 重复执行步骤1到3,直到表t1的末尾循环结束。
  1. 而对于每一行R,根据a字段去表t2查找,走的是树搜索过程。

Block Nested-Loop Join

  1. mysql使用了一个叫join buffer的缓冲区去减少循环次数,这个缓冲区默认是256KB,可以通过命令show variables like 'join_%'查看
  2. 其具体的做法是,将第一表中符合条件的列一次性查询到缓冲区中,然后遍历一次第二个表,并逐一和缓冲区的所有值比较,将比较结果加入结果集中
  3. 只有当JOIN类型为ALL,index,rang或者是index_merge的时候才会使用join buffer,可以通过explain查看SQL的查询类型。

Join优化

  1. 为了优化join算法采用Index nested-loop join算法,在连接字段上建立索引字段
  2. 使用数据量小的表去驱动数据量大的表
  3. 增大join buffer size的大小(一次缓存的数据越多,那么外层表循环的次数就越少)
  4. 注意连接字段的隐式转换与字符编码,避免索引失效

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

为什么很多公司都开始使用Go语言了?

Go
为什么很多公司都开始使用Go语言了?写在前面最近和几个小伙伴们在写字节跳动第五届青训营后端组的大作业。接近尾期了,是时候做一些总结了,那从什么地方开始呢?那就从我们为什么要选择Go语言开始吧~🌐项目地址📝项目文档越来越多的互联网大厂开始使用Go语言了,譬如腾讯...
继续阅读 »

为什么很多公司都开始使用Go语言了?

写在前面

最近和几个小伙伴们在写字节跳动第五届青训营后端组的大作业。

接近尾期了,是时候做一些总结了,那从什么地方开始呢?那就从我们为什么要选择Go语言开始吧~


越来越多的互联网大厂开始使用Go语言了,譬如腾讯、美团、滴滴、百度、Google、bilibili...

还有最初使用Python的字节跳动,甚至已经全面拥向Go了。这么多国内外首屈一指的公司,都在开始使用它了,它到底有什么优势呢?这就得谈谈它的一些优势了。

ps:当然了,还有Go-To-Byte的成员,想要学习go语言,并且用它完成青训营的大项目呐~

Go的一些优势

说起优势,在某些方面多半是因为它有一些别人没有的特性,或者优化了别人麻烦的地方,相比起来,才会更胜一筹。那我们来了解一下Go的一些特点吧,但在了解生硬的特点之前,我们先来看看其它几种常见的语言:

常见的一些语言

这里不是对比哟,不是说谁好谁坏,而是小马过河,因人而异~

1、C/C++

C语言是在1971年的时候,被大神Ken Thompson和Dennis Ritchie发明的,而Go语言的主导开发者之一就是Ken Thompson,所以在很多地方和C语言类似,(比如struct、Printf、&取值符)

C/C++也作为很多初学初学的语言,它们都是直接编译为机器码,所以执行效率会更高,并且都不需要执行环境,用户的使用成本会更低,不像很多语言还需要安装所需的环境。

也因为这些原因,它们的一次编码或编译只适用于一种平台,对于不同操作系统而言,有时需要修改编码再编译,有时直接重新编译即可。

而且对于开发者也"很不友好"😒,需要自己处理垃圾回收(GC)的问题。编码时,还需要考虑,堆上的内存什么时候free、delete?代码会不会造成内存泄露、不安全?

2、Java

自己作为一个从Java来学习Go的菜鸟,还未正式开发,就感到开发效率会比Java低了(个人感觉,不喜勿喷)~😁

Java直接编译成字节码(.class),这种编译产物是介于原始编码和机器码的一种中间码。这样的话,Java程序就需要特定的执行环境(JVM)了,执行效率相比会低一些,还可能有虚拟化损失。但是这样也有一个好处就是可以编译一次,多处执行(跨平台)。而且它也是自带GC

3、JavaScript

Python一样,JS是一种解释型语言,它们不需要编译,解释后即可运行。所以Js也是需要特定的执行环境(浏览器引擎) 的。

将其代码放入浏览器后,浏览器需要解析代码,所以也会有虚拟化损失Js只需要浏览器即可运行,所以它也是跨平台的。

再谈Go

看完了前面几种常见语言的简单介绍。C/C++性能很高,因为它直接编译为二进制,且没有虚拟化损失,Go觉得还不错;Java自动垃圾回收机制很好,Go觉得也不错;Js一次编码可以适用可以适用多种平台Go觉得好极了;而且Go天然具备高并发的能力,是所有语言无可比及的。那我们来简单总结一下吧!

  1. 自带运行环境Runtime,且无须处理GC问题

Go程序的运行环境可厉害了,其实大部分语言都有Runtime的概念,比如Java,它程序的运行环境是JVM,需要单独安装。对于Java程序,如果不经过特殊处理,只能运行在有JMV环境的机器上。

Go程序是自带运行环境的,Go程序的Runtime会作为程序的一部分打包进二进制产物,和用户程序一起运行,也就是说Runtime也是一系列.go代码和汇编代码等,用户可以“直接”调用Runtime的函数(比如make([]int, 2, 6),这样的语法,其实就是去调用Runtime中的makeslice函数)。对于Go程序,简单来说就是不需要安装额外的运行环境,即可运行。除非你需要开发Go的程序。

正因为这样,Go程序也无须处理GC的问题,全权交由Runtime处理(反正要打包到一起)

  1. 快速编译,且跨平台

不同于C/C++,对于多个平台,可能需要修改代码后再编译。也不同于Java的一次编码,编译成中间码运行在多个平台的虚拟机上。Go只需要一次编码,就能轻松在多个平台编译成机器码运行。

值得一提的就是它这跨平台的能力也是Runtime赋予的,因为Runtime有一定屏蔽系统调用的能力。

  1. 天然支持高性能高并发,且语法简单、学习曲线平缓

C++处理并发的能力也不弱,但由于C++的编码要求很高,如果不是很老练、专业的C++程序员,可能会出很多故障。而Go可能经验不是那么丰厚,也能写出性能很好的高并发程序。

值得一提的就是它这超强的高并发,也是Runtime赋予的去处理协程调度能力。

  1. 丰富的标准库、完善的工具链

对于开发者而言,安装好Golang的环境后,就能用官方的标准库开发很多功能了。比如下图所示的很多常用包:

而且Go自身就具有丰富的工具链(比如:代码格式化、单元测试、基准测试、包管理...)

  1. 。。。。。。

很多大厂开始使用Go语言、我们团队为什么使用GoLang,和这些特性,多少都有一些关系吧~


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

认识自动化测试

自动化测试有以下几个概念:单元测试集成测试E2E 测试快照测试测试覆盖率TDD 以及 BDD 等简述项目开发过程中会有几个经历。版本发布上线之前,会有好几个小时甚至是更长时间对应用进行测试,这个过程非常枯燥而痛苦代码的复杂度达到了一定的级别,当维护者的数量不止...
继续阅读 »

自动化测试有以下几个概念:

  1. 单元测试
  2. 集成测试
  3. E2E 测试
  4. 快照测试
  5. 测试覆盖率
  6. TDD 以及 BDD 等

简述

项目开发过程中会有几个经历。

  1. 版本发布上线之前,会有好几个小时甚至是更长时间对应用进行测试,这个过程非常枯燥而痛苦
  2. 代码的复杂度达到了一定的级别,当维护者的数量不止一个人,你应该会逐渐察觉到你在开发新功能或修复 bug 的时候,会变得越发小心翼翼,即使代码看起来没什么问题,但你心里还是会犯嘀咕:会不会引起其他的bug。
  3. 对项目中的代码进行重构的时候,会花费大量的时间进行回归测试

这些问题都是由于大多数使用最基本的手动测试的方式所带来的问题,解决它可以引入自动化测试方式。

我们日常的开发中,代码的完工其实并不等于开发的完工。如果没有测试,不能保证代码能够正常运行。

如何进行应用程序测试?

  • 手动测试:通过测试人员与应用程序的交互来检查其是否正常工作。
  • 自动化测试:编写应用程序来替代人工检验。

手动测试

开发者都懂得手动测试代码。在编写完源代码之后,下一步理所当然就是去手动测试它。

手动测试的优势在于足够简单灵活,但是缺点也很明显:

  • 手动不适合大型项目
  • 忘记测试某项功能
  • 大部分时间都在做回归测试

虽然有一部分手动测试时间是花在测试新特性上,但是大部分时间还是用来检查之前的特性是否仍正常工作。这种测试被称为回归测试。回归测试对人来说是非常困难的任务————它们是重复性的,要求投入很多注意力,而且没有创造性的输入。总之,这种测试太枯燥了。幸运的是,计算机特别擅长此类工作,这也是自动化测试可以大展身手的地方!

自动化测试

自动化测试是利用计算机程序检查软件是否运行正常的测试方法。换句话说,就是用其他额外的代码检查被测软件的代码。当测试代码编写完之后,就可以不费吹灰之力地进行无数次重复测试。

可使用多种不同的方法来编写自动化测试脚本:

  • 可以编写通过浏览器自动执行的程序
  • 可以直接调用源代码里的函数
  • 也可以直接对比程序渲染之后的截图

每一种方法的优势各不相同,但它们有一大共同点:相比手动测试而言节省了大量时间以及提高了程序的稳定性。

自动化测试还有很多优点,比如:

  • 尽早的发现程序的 bug 和不足
  • 增强程序员对程序健壮性、稳定性的信心
  • 改进设计
  • 快速反馈,减少调试时间
  • 促进重构

当然,自动化测试不可能保证一个程序是完全正确的,而且事实上,在实际开发过程中,编写自动化测试代码通常是开发者不太喜欢的一个环节。大多数情况下,前端开发者在开发完一项功能后,只是打开浏览器手动点击,查看效果是否正确,之后就很少对该块代码进行管理。造成这种情况的原因主要有两个:

  • 一个是业务繁忙,没有时间进行测试的编写
  • 另一个是该如何编写测试

测试类型

前端开发最常见的测试主要是以下几种:

  • 单元测试:验证独立的单元是否正常工作
  • 集成测试:验证多个单元协同工作
  • 端到端测试:从用户角度以机器的方式在真实浏览器环境验证应用交互
  • 快照测试:验证程序的 UI 变化

单元测试

单元测试是对应用程序最小的部分(单元)运行测试的过程。通常,测试的单元是函数,但在前端应用中,组件也是被测单元。

单元测试可以单独调用源代码中的函数并断言其行为是否正确。

// sum.js
function sum(a, b) {
return a + b;
}
module.exports = sum;
// sum.test.js
const sum = require('./sum');

test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3);
});
PASS  ./sum.test.js
✓ adds 1 + 2 to equal 3 (5ms)

与端到端测试不同,单元测试运行速度很快,只需要几秒钟的运行时间,因此可以在每次代码变更后都运行单元测试,从而快速得到变更是否破坏现有功能的反馈。

单元测试应该避免依赖性问题,比如不存取数据库、不访问网络等等,而是使用工具虚拟出运行环境。这种虚拟使得测试成本最小化,不用花大力气搭建各种测试环境。

单元测试的优点:

  • 提升代码质量,减少 bug
  • 快速反馈,减少调试时间
  • 让代码维护更容易
  • 有助于代码的模块化设计
  • 代码覆盖率高

单元测试的缺点:

  • 由于单元测试是独立的,所以无法保证多个单元运行到一起是否正确

常见的 JavaScript 单元测试框架:

  • Jest
  • Mocha
  • Jasmine
  • Karma
  • ava
  • Tape

Mocha 跟 Jest 是用的较多的两个单元测试框架,基本上前端单元测试就在这两个库之间选了。总的来说就是 Jest 功能齐全,配置方便,Mocha 灵活自由,自由配置。

推荐使用Jest。

集成测试

定义集成测试的方式并不相同,尤其是对于前端。有些人认为在浏览器环境上运行的测试是集成测试;有些人认为对具有模块依赖性的单元进行的任何测试都是集成测试;也有些人认为任何完全渲染的组件测试都是集成测试。

优点:

  • 由于是从用户使用角度出发,更容易获得软件使用过程中的正确性
  • 集成测试相对于写了软件的说明文档
  • 由于不关注底层代码实现细节,所以更有利于快速重构
  • 相比单元测试,集成测试的开发速度要更快一些

缺点:

  • 测试失败的时候无法快速定位问题
  • 代码覆盖率较低
  • 速度比单元测试要慢

端到端测试(E2E)

E2E(end to end)端到端测试是最直观可以理解的测试类型。在前端应用程序中,端到端测试可以从用户的视角通过浏览器自动检查应用程序是否正常工作。

想象一下,你正在编写一个计算器应用程序,并且你想测试两个数求和的运算方法是否正确。你可以编写一个端到端测试,打开浏览器,加载计算器应用程序,单击“1”按钮,单击加号“+”按钮,再次单击“1”按钮,单击等号“=”,最后检查屏幕是否显示正确结果“2”。

编写完一个端到端测试后,可以根据自己的需求随时运行它。想象一下,相比执行数百次同样的手动测试,这样一套测试代码可以节省多少时间!

优点:

  • 真实的测试环境,更容易获得程序的信心

缺点:

  • 首先,端到端测试运行不够快。启动浏览器需要占用几秒钟,网站响应速度又慢。通常一套端到端测试需要 30 分钟的运行时间。如果应用程序完全依赖于端到端测试,那么测试套件将需要数小时的运行时间。
  • 端到端测试的另一个问题是调试起来比较困难。要调试端到端测试,需要打开浏览器并逐步完成用户操作以重现 bug。本地运行这个调试过程就已经够糟糕了,如果测试是在持续集成服务器上失败而不是本地计算机上失败,那么整个调试过程会变得更加糟糕。

一些流行的端到端测试框架:

快照测试

快照测试类似于“找不同”游戏。快照测试会给运行中的应用程序拍一张图片,并将其与以前保存的图片进行比较。如果图像不同,则测试失败。这种测试方法对确保应用程序代码变更后是否仍然可以正确渲染很有帮助。

传统快照测试是在浏览器中启动应用程序并获取渲染页面的屏幕截图。它们将新拍摄的屏幕截图与已保存的屏幕截图进行比较,如果存在差异则显示错误。这种快照测试在操作系统或浏览器存在版本间差异时,即使快照并没有改变,也会遇到测试失败问题。

使用 Jest 测试框架编写快照测试。取代传统对比屏幕截图的方式,Jest 快照测试可以对 JavaScript 中任何可序列化值进行对比。可以使用它们来比较前端组件的 DOM 输出。

应用场景:

  • 开发纯函数库,建议写更多的单元测试 + 少量的集成测试
  • 开发组件库,建议写更多的单元测试、为每个组件编写快照测试、写少量的集成测试 + 端到端测试
  • 开发业务系统,建议写更多的集成测试、为工具类库、算法写单元测试、写少量的端到端测试

测试覆盖率

测试覆盖率是衡量软件测试完整性的一个重要指标。掌握测试覆盖率数据,有利于客观认识软件质量,正确了解测试状态,有效改进测试工作

度量测试覆盖率:

  • 代码覆盖率
  • 需求覆盖率

代码覆盖率

一种面向软件开发和实现的定义。它关注的是在执行测试用例时,有哪些软件代码被执行到了,有哪些软件代码没有被执行到。被执行的代码数量与代码总数量之间的比值,就是代码覆盖率

根据代码粒度的不同,代码覆盖率可以进一步分为四个测量维度。它们形式各异,但本质是相同的。

  • 行覆盖率(line coverage):是否每一行都执行了?
  • 函数覆盖率(function coverage):是否每个函数都调用了?
  • 分支覆盖率(branch coverage):是否每个if代码块都执行了?
  • 语句覆盖率(statement coverage):是否每个语句都执行了?

如何度量代码覆盖率呢?一般可以通过第三方工具完成,比如 Jest 自带了测试覆盖率统计。

这些度量工具有个特点,那就是它们一般只适用于白盒测试,尤其是单元测试。对于黑盒测试(例如功能测试/系统测试)来说,度量它们的代码覆盖率则相对困难多了。

需求覆盖率

对于黑盒测试,例如功能测试/集成测试/系统测试等来说,测试用例通常是基于软件需求而不是软件实现所设计的。因此,度量这类测试完整性的手段一般是需求覆盖率,即测试所覆盖的需求数量与总需求数量的比值。视需求粒度的不同,需求覆盖率的具体表现也有不同。例如,系统测试针对的是比较粗的需求,而功能测试针对的是比较细的需求。当然,它们的本质是一致的。

如何度量需求覆盖率呢?通常没有现成的工具可以使用,而需要依赖人工计算,尤其是需要依赖人工去标记每个测试用例和需求之间的映射关系。

对于代码覆盖率来说,广为诟病的一点就是 100% 的代码覆盖率并不能说明代码就被完全覆盖没有遗漏了。因为代码的执行顺序和函数的参数值,都可能是千变万化的。一种情况被覆盖到,不代表所有情况被覆盖到。

对于需求覆盖率来说,100% 的覆盖率也不能说“万事大吉”。因为需求可能有遗漏或存在缺陷,测试用例与需求之间的映射关系,尤其是用例是否真正能够覆盖对应的测试需求,也可能是存在疑问的。

总结

适用于不同的场景,有各自的优势与不足。需要注意的是,它们不是互相排斥,而是相互补充的。

关于测试覆盖率,最重要的一点应该是迈出第一步,即有意识地去收集这种数据。没有覆盖率数据,测试工作会有点像在“黑灯瞎火”中走路。有了覆盖率数据,并持续监测,利用和改进这个数据,才是一条让测试工作越来越好的光明大道。

是不是所有代码都要有测试用例支持呢?

测试覆盖率还是要和测试成本结合起来,比如一个不会经常变的公共方法就尽可能的将测试覆盖率做到趋于 100%。而对于一个完整项目,前期先做最短的时间覆盖 80% 的测试用例,后期再慢慢完善。

经常做更改的活动页面我认为没必要必须趋近 100%,因为要不断的更改测试永用例,维护成本太高。

大多数情况下,将 100% 代码覆盖率作为目标并没有意义。

实现 100% 代码覆盖率不仅耗时,而且即使代码覆盖率达到 100%,测试也并非总能发现 bug。有时你可能还会做出错误的假设,当你调用一个 API 代码时,假定的是该 API 永远不会返回错误,然而当 API确实在生产环境中返回错误时,应用就崩溃了。


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

2023年story的年中总结

故事之前一、关于过去今天是2023年5月28号,距离2022年总结已经过去了近半年了,我和我的小伙伴回到了去年同样的茶馆,品一杯和以往一样的茶,但讲述和以往不一样的故事。自从去年之后,我们定下了一个传统,每个半年只要我们还在杭州,我们就会聚到一起来一次总结,讲...
继续阅读 »

故事之前

一、关于过去

今天是2023年5月28号,距离2022年总结已经过去了近半年了,我和我的小伙伴回到了去年同样的茶馆,品一杯和以往一样的茶,但讲述和以往不一样的故事。

自从去年之后,我们定下了一个传统,每个半年只要我们还在杭州,我们就会聚到一起来一次总结,讲述我们每个人这个半年发生的故事。

2022年虽然过去了,但是对于我来说依然历历在目,我去年一年都很焦虑,也没有特别值得骄傲的事情,生活上更没有重要的突破比如:买房、买车、谈恋爱、创业、出国游等等。所以去年年末的时候我就痛定思痛2023年一定要做些什么,思前想后,我给2023的我定下了作品的主题,意思是2023年我可以没买车、没买房、没谈恋爱、即便看起来一事无成,但是我必须要留下一些作品,这个作品是我努力用心付出做出来的东西。只要完成了好的作品那么就没有人可以说我的2023是失败的,是虚度的。

当钢琴曲响起的时候,世界就会静下来,而我脑子里面就不自觉的慢慢回忆这个半年发生的事情,我很感谢你能够耐心观看这篇文章,接下来一起来看一看2023前半年关于我的故事!

故事之中

一、年度日历

一月

不知从什么时候起,没有鞭炮声了,过年的快乐慢慢在变少,而烦恼却越来越多。因为我母亲生病的原因,在大年初四就需要去西安做放疗,为了方便更快的去西安,不耽误治疗,今年我和父母都在离西安更近的安康的亲戚家过年。很感谢小姨在过年期间都很照顾我们,对我妈妈可以说是无微不至,我再一次感受到对于父辈来说,他们的情感真的很深,虽然现在都说亲戚间关系变淡了,但是我相信我小姨和我妈妈就是最好的朋友和闺蜜,互相都有无话不谈的亲情和感情。

因为亲戚过生日的原因,我第一次带父母和我小姨一家去KTV唱歌,给他们庆生,在我记忆中这应该是第一次在父母面前唱歌,我小姨他们也很开心氛围很好。

我还第一次陪小七(我的亲外甥女)玩了很久,我给她买吃的,她想吃什么我就他她买什么,想玩什么游乐设施就带她去玩,在我这里主打的就是一个嚎无人性的宠爱,哈哈。

这个年过的很短,只有短短的不到5天时间就要去西安了,我意识到所有的奔波都是因为我没有在西安定下来,我需要加快在西安买房定居下来的速度了。

二月

不知用了多少的零碎时间,我用心准备的网站终于在我的努力下上线了,从买域名到开发到部署,我都自己感受了一遍,也踩了很多的坑,但是皇天不负有心人,当我写了用零碎时间个人建站这篇文章发表之后,居然在后来成为了一篇算是高赞的文章,我的第一个作品虽然不完美,但是也收到了很多掘有的鼓励,直到现在我每天都会点开我的自己的网站看一下,它也管理着我每一天的行为和一年的年度目标,我正在受益于这个网站带给我的好处。

我也在写系列的chrome插件的文章,收录在我的chorme插件专栏里面,我希望把这个chorme相关的知识做成一个系统的知识产品,这也算我自己的年度作品呀!

二月的感受是充实的,并且在继续努力中。。。

三月

三月份有一本书很影响我,就是张鑫旭老师(旭哥)的《技术写作指南》。这本书让我坚定了要好好培养写作习惯的决心,我还特别写了一篇读后感,不仅如此,文章中特别提到了分享的力量,如果写了东西就可以勇敢分享出来,不要怕别人的恶评和瞧不起,要让自己的领导和同事看到,否则没有人会觉得你在努力,也更不会去关注你,如果都不关注你,怎么可能获得职场上的晋升。因此不要害怕展现自己,因此之后几乎大部分文章我都会在朋友圈分享出来,要利用一切方式提升自己的影响力,不要做无名小卒,任何人都可以把自己忽略。所以在这里我要感谢旭哥的书籍,你在无形中影响了很多很多人,并且是积极饿影响。

所以在那之后,我几乎每一本都会写读后感,无论是小说还是技术书籍,每个月也必产出两篇优质的技术文章,我给自己定了严酷的惩罚,因此我坚信没有任何事情可以阻挡我完成博客读书写作的目标。

截屏2023-05-28 下午4.28.38.png

四月

四月份我依然注重自己的作品,所以我在思考自己要做一个开源的作品,最终这个东西出来了,它叫做story

截屏2023-05-28 下午4.33.21.png

这个是一个monorepo风格的工程化解决方案,算是自己工作以来的一个沉淀,它不是什么特别出色的作品,但我会用一年时间去用心打造,算是自己开源的一次尝试,就像现在我正在做一个web端截图的组件,很有意思,它会成为一个属于我自己的前端工程化的方案。路过的程序员朋友也可以看一下哈,最新的代码分支是feat-tool。

五月

五月的核心是java学习,我现在已经确定了自己的发展方向了——web端产品,因为考虑到未来的城市选择和发展,可能这个方向更适合我,所以在未来我会专注于web端BS架构的技术,做web端产品体验的方向,因此前后端我都需要比较专业熟悉,一年之后我也会考虑专职做java开发,等到2年过后,前后端都掌握好之后更好的向上发展。

这个月我看了很多的java教程,算是在恶补java吧!

这个月我也经历了一个很重要的事情,我朋友给我介绍了一个女生,我们认识了一下,我们微信聊的不错。我是一个只要想做就会去做的人,所以这个月我回了一趟西安去见她,但是见面之后发现和微信聊天还是很不一样的,没有太多共同话题,而且我也不知道聊些什么,所以后来就不了了之了。

六月

六月份是上半年的最后一个月,我遇到了小R同学,她是一个特别细腻和用心的女生,我们一起做了很多事情,一起去看电影,去玩蹦床,去逛街,去弹吉他,谢谢她在我枯燥的日子里增添了许多美好的时刻。让我整个上半年能够一个很好的句号结束。

二、年度KPI

在今年年初的时候,我给自己设定了几个KPI,也就是这一年我需要完成的事情。

截屏2023-05-28 下午4.47.50.png

目标随时都可以新增,但是一定要保证过去的某些个目标已经大概率可以完成才可以。我的个人网站有一个类似大屏的页面可以看到目标完成情况,我真的享受这种目标被一点点推进的感觉。

分析

健身:目前健身计划有点不符合预期,已经5个月了,但是应该完成到41%左右的,但是之前因为五一放假的关系,耽搁了几天,不过后面跑勤一些应该问题也不大,可以完成。

博客,读书都是正常进行的,我发现只要对自己狠一些,其实定个死目标就一定可以完成,最重要我定的目标其实也不高,按照自己的赛道慢慢超越自己就好了。

英语还未开始,因为去年错过了考试,今年的考试应该在12月份左右,我需要最后几个月好好复习,通过的可能性更大一些,不然现在刷题到时候考就忘了。

java还挺有意思的,继续学习吧!但我需要稳一点,我有的时候在急功近利了,java这个东西应该不要求快,而是基础知识要打牢固才是真的学到了。

三、心动时刻

我很珍惜在我们的时间流逝的过程中那些个心动时刻,不要误会哈,除了看到美女有心动时刻以外。生活中还有其他的一些心动时刻更让人流连忘返。

影响力

5月份的某一刻,我正在刷掘金,我看到了这个内容

WechatIMG26.jpeg

这一刻我真的内心非常涌动,我曾经在我的文章中说过一句话,在这个世界上没有什么比当你得知你对别人施加了一定的积极的影响之后更感动了。我更加坚定了写作的意义,可能这种事情没有为我们带来一分钱的收获,甚至让我们耗费了大量的时间精力,但是你真的会感到自己影响到了别人,生命的厚度也因此而增加,我可能明白了为什么神光大佬那么执着于技术写作了。

心流

5月份的某个周六,早上我买了一杯生椰拿铁,简单吃了一点东西。然后我开始写一篇文章,不知不觉一直写到了下午4点钟,写完之后感到前所未有的充实。也就是那一刻我感到生命的厚度增加了。虽然这篇文章点赞很少,看的人也不多,但是我能够真切的感受到我居然也有“专注,认真”的这样一种品质。我由此感受到了个结论:自信从来不是靠别人夸奖而获得,而是自己真正的认可自己。

运动的执着

如果原生家庭没能让自己感到骄傲,那么我们的自信到底怎么建立呢!虽然可能是错的,但我仍然感觉大部分普通孩子其实是自卑的,没有优越的家庭环境,处在社会的最底层,别人看起来唾手可得的东西自己需要拼了老命才能获得,不仅如此,反而会因为自卑错过很多本该的到的机会,从而陷入更为窘迫的境地。这样更多如我一般的人要怎么才可能破局呢!答案只有一个,靠自己,靠自己的坚定和执着,靠自己从来不轻易违背自己的目标和承诺,靠自己小心翼翼的保护自己的自尊心和少的可怜的自信。

当我年初定下250天健身目标之后,无论发生什么,我都会坚定的执行这个目标,不是喜欢跑步,而是为了捍卫目标。

故事之后

一、6个月

2023年还剩下6个月,这6个月任重而道远,我会继续推进目标栏上的目标,并且慢慢把英语放到中心上来,算法、react原理、vue原理都要抓起来了,老早学过,现在感觉都忘了好多了,以下是下半年的核心:

  1. 英语BEC考试
  2. chrome插件专栏
  3. story工程化方案

二、未来

我目前在现在的公司其实是很开心的,今年遇到了好几个很有意思的小伙伴,和去年完全不一样,去年还有些孤独,但今年来了几个新伙伴我们就是无话不谈的饭搭子,说说笑笑就是一天,而且今年工作起来更加的得心应手了,如果可以的话,我很希望在这里一直做下去,明年希望在薪资上有一个可观的涨幅,因为这个对我来说特别的重要。

二、感恩

最后我想以感恩来结束这篇文章的写作,我们生活在一个并不孤独的世界,有的人仅仅是出现在我们的生命中,就值得用心去感恩!

我很感恩小R同学,谢谢你出现在我的生命中,我真的很感谢你对我的生日那么的用心,给我写了每一岁的信,尤其是我出生那天的报纸让我非常惊讶,我可能是上辈子修来的福分才能遇到这么一位在乎我和关心我的女朋友,希望我也能在你的生命中带给你更多真正优质的东西。

感谢幸福之家的天豪、涛涛、绍路你们的存在让我在杭州有了亲兄弟和家的感觉,尤其是有一天我很晚没回家,你们都给我打电话,还想着给我送伞,我那一刻甚至快流下了眼泪,我觉得原来被人在乎和关心真的很好。

感恩公司的旭哥、亮琦、振明、张写,今年有你们让我觉得原来工作中欢声笑语的感觉真的好温暖呀,这样的工作氛围真的很好,尤其是每次下班回去健身回家的时候,就像上学时一起奔跑的少年一样,很清爽,很舒服。

感恩凯凯同学,今年特别感谢你给我介绍女朋友,每次回西安第一个就希望去找你,特别希望以后在西安我们买房能买到一起!这样不就又能够一起玩了嘛哈哈!

感恩赵勇、陈欢、邓撼、旭东同学,你们都是杭州俱乐部的成员,我们每月的总结一定要一直坚持下去,正正是因为这里我才能够每一天都不懈怠自己,因为大家都真的很努力,这样的团队怎么能不让人热爱呢!

感恩jerry、Andy,今年你们两位大佬能够来找我,我很开心,以往真的很难想过居然有一天还能和你们做朋友,我也会慢慢让自己变得强大,变得和原来的你们一样优秀。

感恩我姐,我不在西安的时候,医院的事情都是你在操心,我没有办法时时刻刻关注到很多事情,但是你都是晚上上班,白天又可能去看望爸妈,这让我很惭愧,但是我知道这是没有办法的事情,我唯一能够做的就是不断的提升自己,不过我承诺这只是暂时的。


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

三十岁前端的破冰尝试

本人简介JavaScrip码农,今年三十,宿主是非互联网类型的外企,提供的内存虽然不大,但也基本够存活。工作之余,我的主题就是咸鱼。但或许是我的咸度不够,最近开始腐烂了,尤其是夜深人静,主要的信息输入被关闭之后,我就感觉内在的信息流在脑海里乱窜,各种健康指数开...
继续阅读 »

本人简介

JavaScrip码农,今年三十,宿主是非互联网类型的外企,提供的内存虽然不大,但也基本够存活。

工作之余,我的主题就是咸鱼。但或许是我的咸度不够,最近开始腐烂了,尤其是夜深人静,主要的信息输入被关闭之后,我就感觉内在的信息流在脑海里乱窜,各种健康指数开始飙升。就像是一台老旧的电脑,非要带最新的显卡游戏,发出嘤嘤嘤的EMO声,最后在卡死在昏睡页面。

大多时候醒来会一切安好,像是被删去了前一晚的日志。但有时也会存有一些没删除干净的缓存,它们就像是病毒,随着第二天的重启复苏。我会感到无比的寒冷,冷到我哪怕是饥饿也不敢出门,只有戴上口罩会给我一丝丝的勇气。

这种寒冷会刺激着我无病呻吟,我会感到惊恐和害怕,害怕某天被宿主的回收机制发现这里的不正常,然后被文明的光辉抹除,就如新冠背后那鲜红的死亡人数一样。

或许是幼年求学寄人篱下时烙下的病根,但那时候心田干涸了还可以哭泣。如今呢,心田之上早已是白雪皑皑。

这些年也有人帮助过我,我也努力挣扎过,但大多时候毫无章法,不仅伤了别人的心,也盲目地消耗着心中的热血,愧疚与自责的泪水最终只是让冰层越积越深。

今天也不知哪根筋抽抽了,想着破冰。

嗯,就是字面上的意思,满脑子都是“破冰”二字……

破冰项目

发表这个稿子算是破冰的第一步~

项目的组织架构初步定为凌凌漆,敏捷周期为一周,其中周日进行复盘和制定新计划,其余作为执行日。由于项目长期且紧迫,年假就不予考虑了,病假可以另算,津贴方面目前只考虑早餐,其他看项目发展情况再做调整。

硬件层面

目前作息相当紊乱,供电稳定性差,从近几年的硬件体验报告可以看出,总体运行还算正常,但小毛病层出不穷,电压不稳是当前主要矛盾。OKR如下:

O:保持一个良好的作息
KR1: 保证每天八小时的睡眠。
KR2:保证每天凌晨前关灯睡下。
KR3:保证每天早上九点前起床。

软件层面

英语是硬伤,其次是底层算法需要重写,不然跑着跑着还是会宕机。

翻译是个不错的路子,但数据源是个头痛的问题……肯定得找和技术相关的东西来翻译,并且可以有反馈。嗯…… 想到可以找掘金里已经有的翻译文章,截取其中一小段来进行快速试错。

至于底层算法的问题,此前在leetcode练过一段时间,但仅停留在已知的变得熟练,未知的依旧不会。

因此我觉得有必要先梳理出关于算法的个人认知的知识体系……

总结下来下一阶段任务:

  1. 选择一篇翻译文章,找到其原文,选其中完整的一段进行翻译。
  2. 根据当前认知画个关于算法的思维导图。

下周日会出这周的运行报告以及新一期的计划表。

最后随想

若是觉得我这样的尝试也想试一试,欢迎在评论附上自己的链接,一起尝试,相互借鉴,共同进步~


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

看见的熄灭了,消失的忘记了

许多年前,也许是许多世纪前,我在陶盆里养了两条鱼。在此之前,陶盆里下着漫长的雨,一只蟾蜍在里面参禅,显得很寂寞。若干年后,雨下累了,月亮从桂枝上醒来,蟾蜍还困在禅意里。他俩都不知道对方是自己另外一个化身。陶盆之外是原始的黑暗。烛龙蜷在黑暗之中想心事,有一天,我...
继续阅读 »

许多年前,也许是许多世纪前,我在陶盆里养了两条鱼。在此之前,陶盆里下着漫长的雨,一只蟾蜍在里面参禅,显得很寂寞。若干年后,雨下累了,月亮从桂枝上醒来,蟾蜍还困在禅意里。他俩都不知道对方是自己另外一个化身。

陶盆之外是原始的黑暗。烛龙蜷在黑暗之中想心事,有一天,我打开他的心事,点亮烛火,开始寂静地书写。我写到一些年代和场景,陶盆哭了,而那时,我的字句里还没有它。写到后来,雨水也哭了,而那时,它正想去陶盆漫长地飘落。

那一年春天,池塘边生出青草。母亲说,鱼该上岸了,它原本就是鹿。我让自己躺在梨花和雨飘过的窗下,漫不经心地构思字句。我想到,在原始的黑暗中,陶盆是唯一的光亮。年代在陶盆里不断进化,最后演变成大大小小的裂纹。

这时,邻家的女孩汲水归来,唱起一支悠长而陈旧的民歌,悠长得不知所终,陈旧到诗经出现之前。我构思的景象开始土崩瓦解,最后只剩下两粒羞涩的字。雨水下累那天,我将它们埋进陶盆,如同把秘密埋进心里。我知道,从此那两粒字将被我反复书写和记忆。

后来桂花开了,秋香飘过窗前,母亲说鹿该下水了,它原本就是鱼。月光下,鹿还在岸边吃草,两粒羞涩的字已化作游鱼,首尾相依,你追我赶,将万物搅成巨大的漩涡。黑暗坍塌了,烛龙收起心事,将我关在年代和场景的中心。

一天午后女孩停在窗前告诉我,很久以前,烛龙来到梦里替她照亮,她看见我在烛龙紧闭的心房喂鱼,大大小小的裂纹蛛网似的从四周缓慢地向我爬来。她还说,陶盆哭的时候,她看见雨水哭了,雨水哭的时候,她醒了。

这是一个幸福而悲伤的午后。我对她说,你在我的梦里梦见我的时候,我正在构思和书写。烛龙,游鱼,裂纹,以及你和你的梦,仅是我漫长书写中的一些温暖字句。这些字句有的已经完成,有的尚未写到,最终都会与我精心构思的景象一样,除了在某些悲伤的时刻对我有所安慰外,将变得毫无意义。我还告诉她,其实陶盆并不存在,雨水也不存在,蟾蜍偶尔闪过的禅意,或者月亮久已遗忘的光线之中,包含了我所有的书写。

2010-3-27


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

学习能力必然是职场的核心能力

最近新工作的编程语言换为了Golang,同时也在面试招聘相关岗位的人才。通过简历面试(别人的经历),以及自己的亲身学习经历,真切的感受到学习能力将是未来的一大竞争力。从面试方面来看,大多数人工作稳定之后便失去了学习能力,以为现在的工作可以长久的干下去。结果,互...
继续阅读 »

最近新工作的编程语言换为了Golang,同时也在面试招聘相关岗位的人才。通过简历面试(别人的经历),以及自己的亲身学习经历,真切的感受到学习能力将是未来的一大竞争力。

从面试方面来看,大多数人工作稳定之后便失去了学习能力,以为现在的工作可以长久的干下去。结果,互联网的风停下来之后,市场的需求变了,从单一的编程语言、单一业务的能力变成更加综合的能力,需要的人逐渐变为T型人才甚至π型人才。此时,学习能力就变得更加重要。否则,面临的只能是市场的淘汰。

下面分享一下自己最近三周学习Golang的一些经验和方法,大家可以拿来借鉴的其他学习方面上:

第一、实践。任何的学习都离不开实践。不能够运用到实践中的学习大概率是无效学习,而实践也是学习最有效的手段。在刚开学学习Golang时,找了一份基础语法的文档,花一两个小时看了一遍,知道常见的语法结构怎么用的,便开始搭建项目,写业务功能。其实这样的效果最快,以具体的功能实践来驱动学习,同时把对这方面的手感和思路锻炼出来。

第二、系统学习。单纯动手实践的过程中会掺杂着业务逻辑的实现,学习效率和范围上会有一些局限,属于用到什么学什么,缺点是不够系统。这时还需要一两本书,通读全书,帮助系统的了解这门语言(或某个行业)是怎么运作的,整个生态是什么样的,底层逻辑是怎样的,以便查漏补缺。在系统学习这块,建议以书籍为主,书籍的优势就是方便、快捷、系统、准确。

第三、交流。之前找一个懂的大佬请教和交流不是那么容易。但随着AI的发展,交流形式不仅仅限于大佬了,也可以是GPT。GPT最强大的能力是无所不知,知无不言。当然,对于它提供的结果也需要辩证的去看,某些地方可能会有错误,但大方向基本上是没错的,再辅以佐证,基本上能够解决80%的问题。

如果有机会参与面试,无论是作为面试官或者被面试者,都是一个交流的过程。在相互沟通的过程中了解市场需要什么,市场流行什么。

最后,针对某些问题,还是得去跟大佬交流才行,交流的过程中会碰撞出很多火花来。比如,不断的迭代某个算法,学到更好的实现方式,了解到你不知道的知识点等。曾经,一个字符串截取的功能,与大佬交流了三次,升级了三版,也学到了不同的API的使用方法和特性。

第四,输出。检验是否学会的一个标准就是你能否清晰的给别人描述出来,让别人听得懂。这一条是否很耳熟?对,它就是费曼学法,世界公认的最快的学习法。如果没办法很好的表达,说明这块掌握的还不是很清楚。当然,这个过程中也属于交流,也会拿到别人的反馈,根据别人的反馈来认识到自己的掌握程度和薄弱点。

第五,利用别人的时间。个人的时间总是有限的,不可能什么事情都自己做,也不可能都亲手验证。而作为管理者,最大的技能之一就是靠别人、靠团队来实现目标。那么,一个技术方案是否可行,是否有问题,也可以交给别人来调研、实践、验证。这样,可以让学习的效率并行起来。

另外,我们可能都听说过“一万小时定律”,这个概念是极具迷惑性的,会让你觉得学习任何东西都需要花费大量的时间的。其实不然,一万小时定律指的是学习一个复杂的领域并且成为这个领域的专家。

而我们在生活和实践的过程中,往往不需要什么方面都成为专家,只需要知道、掌握或会用某一领域的知识即可。对于入门一个新领域,一般来说,可能只需要20小时、100小时不等,没有想象中那么难。对于一个懂编程语言的人来说,从零学习另外一门语言,一般也就一两周时间就可以上手了。因此,我们不要对此产生畏惧心理。

上面讲的是学习方法,但最根本的是学习的意愿。你是选择花一年时间学习一门技术,然后重复十年,还是愿意每年都不断的学习迭代自己?两者的结果差距超乎你的想象。


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

像黑客一样使用 Linux 命令行

前言##之前看到一篇介绍 IntelliJ IDEA 配置的文章,它里面用的是 gif 动态图片进行展示,我觉得很不错。所以在我今天以及以后的博文中,我也会尽量使用 gif 动图进行展示。制作 gif 动图很花时间,为了把我的博客打造成精品我也是蛮拼的了。使用...
继续阅读 »

前言##

之前看到一篇介绍 IntelliJ IDEA 配置的文章,它里面用的是 gif 动态图片进行展示,我觉得很不错。所以在我今天以及以后的博文中,我也会尽量使用 gif 动图进行展示。制作 gif 动图很花时间,为了把我的博客打造成精品我也是蛮拼的了。使用动图的优点是演示效果好,缺点是动图体积过大,为了降低图片体积,我只能降低分辨率了。

关于高效使用命令行这个话题,在网上已经是老生常谈了。而且本文也借鉴了 CSDN 极客头条中推荐了的《像黑客一样使用 Linux 命令行》。但是在本文中,也有不少我自己的观点和体会,比如我会提到有些快捷键要熟记,有些则完全不需要记,毕竟我们的记忆力也是有限的,我还会提到一些助记的方法。所以,本文绝对不是照本宣科,值得大家拥有,请大家一定记得点赞。

使用 tmux 复用控制台窗口##

高效使用命令行的首要原则就是要尽量避免非命令行的干扰,什么意思呢?就是说一但开启了一个控制台窗口,就尽量不要再在桌面上切换来切换去了,不要一会儿被别的窗口挡住控制台,一会儿又让别的窗口破坏了控制台的背景,最好是把控制台最大化或全屏,最好连鼠标都不要用。只有这样,才能达到比较高的效率。但是在实际工作中,我们又经常需要同时在多个控制台中进行工作,例如:在一个控制台中运行录制屏幕的命令,在另外一个控制台中工作,或者在一个控制台中工作,在另外一个控制台中阅读文档。如果既想在多个控制台中工作,又不想一大堆窗口挡来挡去、换来换去的话,就可以考虑试试 tmux 了。如下图:

tmux 的功能很多,什么 Session 啊、Detach 啊、Atach 啊什么的我们暂时不用去关心,只用好它的控制台窗口复用功能就行了。tmux 中有 window 和 pane 的概念,tmux 可以创建多个 window,这些 window 是不会互相遮挡的,每次只显示一个 window,其它的 window 会自动隐藏,可以使用快捷键在 window 之间切换。同时,可以把一个 window 切分成多个 pane,这些 pane 同时显示在屏幕上,可以使用快捷键在 pane 之间切换。

tmux 的快捷键很多,要想全面了解 tmux 的最好办法当然是阅读 tmux 的文档了,使用命令 man tmux 就可以了。但是我们只需要记住少数几个重要的快捷键就可以了,如下表:

快捷键功能
Ctrl+B c创建一个 window
Ctrl+B [n][p]切换到下一个窗口或上一个窗口
Ctrl+B &关闭当前窗口
Ctrl+B "将当前 window 或 pane 切分成两个 pane,上下排列
Ctrl+B %将当前 window 或 pane 切分成两个 pane,左右排列
Ctrl+B x关闭当前 pane
Ctrl+B [↑][↓][←][→]在 pane 之间移动
Ctrl+[↑][↓][←][→]调整当前 pane 的大小,一次调整一格
Alt+[↑][↓][←][→]调整当前 pane 的大小,一次调整五格

tmux 的快捷键比较特殊,除了调整 pane 大小的快捷键之外,其它的都是先按 Ctrl+B,再按一个字符。先按 Ctrl+B,再按 c,就会创建一个 window,这里 c 就是 create window。先按 Ctrl+B,再按 n 或者 p,就可以在窗口之间切换,它们是 next window 和 previous window 的意思。关闭窗口是先按 Ctrl+B,再按 &,这个只能死记。先按 Ctrl+B,再按 " ,表示上下拆分窗口,可以想象成单引号和双引号在键盘上是上下铺关系。先按 Ctrl+B,再按 % 表示左右拆分窗口,大概是因为百分数都是左右书写的吧。至于在 pane 之间移动和调整 pane 大小的方向键,就不用多说了吧。

在命令行中快速移动光标##

在命令行中输入命令时,经常要在命令行中移动光标。这个很简单嘛,使用左右方向键就可以了,但是有时候我们输入了很长一串命令,却突然要修改这个命令最开头的内容,如果使用向左的方向键一个字符一个字符地把光标移到命令的开头,是否太慢了呢?有时我们需要直接在命令的开头和结尾之间切换,有时又需要能够一个单词一个单词地移动光标,在命令行中,其实这都不是事儿。如下图:

这几种移动方式都是有快捷键的。其实一个字符一个字符地移动光标也有快捷键 Ctrl+B 和 Ctrl+F,但是这两个快捷键我们不需要记,有什么能比左右方向键更方便的呢?我们真正要记的是下面这几个:

快捷键功能
Ctrl + A将光标移动到命令行的开头
Ctrl + E将光标移动到命令行的结尾
Alt + B将光标向左移动一个单词
Alt + F将光标向右移动一个单词

这几个快捷键太好记了,A 代表 ahead,E 代表 end,B 代表 back,F 代表 forward。为什么按单词移动光标的快捷键都是以 Alt 开头呢?那是因为按字符移动光标的快捷键把 Ctrl 占用了。但是按字符移动光标的快捷键我们用不到啊,因为我们有左右方向键啊。

在命令行中快速删除文本##

对输入的内容进行修改也是我们经常要干的事情,对命令行进行修改就涉及到先删除一部分内容,再输入新内容。我们碰到的情况是有时候只需要修改个别字符,有时候需要修改个别单词,而有时候,输入了半天的很长的一段命令,我们说不要就全都不要了,整行删除。常用的删除键当然是 BackSpace 和 Delete 啦,不过一次删除一个字符,是否太慢了呢?那么,请熟记以下几个快捷键吧:

快捷键功能
Ctrl + U删除从光标到行首的所有内容,如果光标在行尾,自然就整行都删除了啊
Ctrl + K删除从光标到行尾的所有内容,如果光标在行首,自然也是整行都删除了啊
Ctrl + W删除光标前的一个单词
Alt + D删除光标后的一个单词
Ctrl + Y将刚删除的内容粘贴到光标处,有时候删错了可以用这个快捷键恢复删除的内容

效果请看下图:

这几个快捷键也是蛮好记的,U 代表 undo,K 代表 kill,W 代表 word,D 代表 delete, Y 代表 yank。其中比较奇怪的是 Alt+D 又是以 Alt 开头的,那是因为 Ctrl+D 又被占用了。Ctrl+D 有几个意思,在编辑命令行的时候它代表删除一个字符,当然,这个快捷键其实我们用不到,因为 BackSpace 和 Delete 方便多了。在某些程序从 stdin 读取数据的时候,Ctrl+D 代表 EOF,这个我们偶尔会用到。

快速查看和搜索历史命令##

对于曾经运行过的命令,除非特别短,我们一般不会重复输入,从历史记录中找出来用自然要快得多。我们用得最多的就是 ↑ 和 ↓,特别是不久前才刚刚输入过的命令,使用 ↑ 向上翻几行就找到了,按一下 Enter 就执行,多舒服。但是有时候,明明记得是不久前才用过的命令,但是向上翻了半天也没找到,怎么办?那只好使用 history 命令来查看所有的历史记录了。历史记录又特别长,怎么办?可以使用 history | less 和 history | grep '...'。但是还有终极大杀招,那就是按 Ctrl+R 从历史记录中进行搜索。按了 Ctrl+R 之后,每输入一个字符,都会和历史记录中进行增量匹配,输入得越多,匹配越精确。当然,有时候含有相同搜索字符串的命令特别多,怎么办?继续按 Ctrl+R,就会继续搜索下一条匹配的历史记录。如下图:

这里,需要记住的命令和快捷键如下表:

命令或快捷键功能
history查看历史记录
historyless分页查看历史记录
historygrep '...'在历史记录中搜索匹配的命令,并显示
Ctrl + R逆向搜索历史记录,和输入的字符进行增量匹配
Esc停止搜索历史记录,并将当前匹配的结果放到当前输入的命令行上
Enter停止搜索历史记录,并将当前匹配的结果立即执行
Ctrl + G停止搜索历史记录,并放弃当前匹配的结果
Alt + >将历史记录中的位置标记移动到历史记录的尾部

这里需要注意的是,当我们在历史记录中搜索的时候,是有位置标记的,Ctrl+R 是指从当前位置开始,逆向搜索,R 代表的是 reverse,每搜索一条记录,位置标记都会向历史记录的头部移动,下次搜索又从这里开始继续向头部搜索。所以,我们一定要记住快捷键 Alt+>,它可以把历史记录的位置标记还原。另外需要注意的是停止搜索历史记录的快捷键有三个,如果按 Enter 键,匹配的命令就立即执行了,如果你还想有修改这条命令的机会的话,一定不要按 Enter,而要按 Esc。如果什么都不想要,就按 Ctrl+G 吧,它会还你一个空白的命令行。

快速引用和修饰历史命令##

除了查看和搜索历史记录,我们还可以以更灵活的方式引用历史记录中的命令。常见的简单的例子有 !! 代表引用上一条命令,!$代表引用上一条命令的最后一个参数,^oldstring^newstring^代表将上一条命令中的 oldstring 替换成 newstring。这些操作是我们平时使用命令行的时候的一些常用技巧,其实它们的本质,是由 history 库提供的 history expansion 功能。Bash 使用了 history 库,所以也能使用这些功能。其完整的文档可以查看 man history 手册页。知道了 history expansion 的理论,我们还可以做一些更加复杂的操作,如下图:

引用和修饰历史命令的完整格式是这样的:

![!|[?]string|[-]number]:[n|x-y|^|$|*|n*|%]:[h|t|r|e|p|s|g]

可以看到,一个对历史命令的引用被 : 分为了三个部分,第一个部分决定了引用哪一条历史命令;第二部分决定了选取该历史命令中的第几个单词,单词是从0开始编号的,也就是说第0个单词代表命令本身,第1个到最后一个单词代表命令的参数;第三部分决定了对选取的单词如何修饰。下面我列出完整表格:

表格一、引用哪一条历史命令:

操作符功能
!所有对历史命令的引用都以 ! 开始,除了 oldstringnewstring^ 形式的快速替换
!n引用第 n 条历史命令
!-n引用倒数第 n 条历史命令
!!引用上一条命令,等于 !-1
!string逆向搜索历史记录,第一条以 string 开头的命令
!?string[?]逆向搜索历史记录,第一条包含 string 的命令
oldstringnewstring^对上一条命令进行快速替换,将 oldstring 替换为 newstring
!#引用当前输入的命令

表格二、选取哪一个单词:

操作符功能
0第0个单词,在 shell 中就是命令本身
n第n个单词
第1个单词,使用 ^ 时可以省略前面的冒号
$最后一个单词,使用 $ 是可以省略前面的冒号
%和 ?string? 匹配的单词,可以省略前面的冒号
x-y从第 x 个单词到第 y 个单词,-y 代表 0-y
*除第 0 个单词外的所有单词,等于 1-$
x*从第 x 个单词到最后一个单词,等于 x-$,可以省略前面的冒号
x-从第 x 个单词到倒数第二个单词

表格三、对选取的单词做什么修饰:

操作符功能
h选取路径开头,不要文件名
t选取路径结尾,只要文件名
r选取文件名,不要扩展名
e选取扩展名,不要文件名
s/oldstring/newstring/将 oldstring 替换为 newstring
g全局替换,和 s 配合使用
p只打印修饰后的命令,不执行

这几个命令其实挺好记的,h 代表 head,只要路径开头不要文件名,t 代表 tail,只要路径结尾的文件名,r 代表 realname,只要文件名不要扩展名,e 代表 extension,只要扩展名不要文件名,s 代表 substitute,执行替换功能,g 代表 global,全局替换,p 代表 print,只打印不执行。有时候光使用 :p 还不够,我们还可以把这个经过引用修饰后的命令直接在当前命令行上展开而不立即执行,它的快捷键是:

操作符功能
Ctrl + Alt + E在当前命令行上展开历史命令引用,展开后不立即执行,可以修改,按 Enter 后才会执行
Alt + ^和上面的功能一样

这两个快捷键,记住一个就行。这样,当我们对历史命令的引用修饰完成后,可以先展开来看一看,如果正确再执行。眼见为实嘛,反正我是每次都展开看看才放心。

录制屏幕并转换为 gif 动画图片##

最后,给大家展示我做 gif 动画图片的过程。我用到的软件有 recordmydesktopmplayer 和 convert。使用 recordmydesktop 时需要一个单独的控制台来运行录像功能,录像完成后需要在该控制台中输入 Ctrl+C 终止录像。所以我用到了 tmux 。首先,我启动 tmux,然后运行 recordmydesktop --full-shots --fps 2 --no-sound --no-frame --delay 5 -o ~/图片/record_to_gif.ogv命令开始录像。由于 recordmydesktop 运行后不会马上退出,录像开始后,这个 window 就被占用了,所以我按 Ctrl+B c 让 tmux 再创建一个 window,然后在这个 window 中做的任何操作都会被录制下来。被录制的操作完成后,按 Ctrl+B n 切换到 recordmydesktop 命令运行的窗口,按 Ctrl+C 终止录像。然后,使用 mplayer -ao null record_to_gif.ogv -vo jpeg:outdir=./record_to_gif 将录制的视频提取为图片。当然,这时的图片比较多,为了缩减最后制作成的 gif 文件的大小,我们可以删掉其中无关紧要的帧,只留下关键帧。最后使用命令 convert -delay 100 record_to_gif/* record_to_gif.gif 生成 gif 动画。整个过程如下图:

最后生成的 gif 图片一般都比较大,往往超过 20M,如果时间长一点,超过 60M 也是常事儿。而制作成 gif 之前每一帧图片也就 200k 左右而已。我想可能是因为 gif 没有像 jpeg 或 png 这么好的压缩算法吧。gif 对付向量图效果很不错,对付照片和我这样的截图,压缩就有点力不从心了。博客园允许上传的图片每张不能超过 10M,所以,为了减小 gif 文件的体积,我只有用 convert -resize 1024x576 record_to_gif.gif record_to_gif_small.gif 命令将图片变小后再上传了。

总结##

使用 Linux 命令行的技巧还有很多,我这里不可能全部讲到。学习 Linux 命令行的最好办法当然还是使用 man bash 查看 Bash 的文档。但是我这里讲的内容已经可以显著提高使用命令行的效率了,至少这两天下来,我觉得我自己有了质的飞跃。另外,在博客中使用 gif 动态图片做示例,我觉得也是我写博客以来一个质的飞跃。希望大家喜欢。


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

Flutter 混合架构方案探索

得益于 Flutter 优秀的跨平台表现,混合开发在如今的 App 中随处可见,如最近微信公布的小程序新渲染引擎 Skyline 发布正式版也在底层渲染上使用了 Flutter,号称渲染速度提升50%。在现有的原生 App 中引入 Flutter 来开发不是一...
继续阅读 »

得益于 Flutter 优秀的跨平台表现,混合开发在如今的 App 中随处可见,如最近微信公布的小程序新渲染引擎 Skyline 发布正式版也在底层渲染上使用了 Flutter,号称渲染速度提升50%。

在现有的原生 App 中引入 Flutter 来开发不是一件简单的事,需要解决混合模式下带来的种种问题,如路由栈管理、包体积和内存突增等;另外还有一种特殊的情况,一个最初就由 Flutter 来开发的 App 也有可能在后期混入、原生 View 去开发。

我所在的团队目前就是处于这种情况,Flutter 目前在性能表现上面还不够完美,整体页面还不够流畅,并且在一些复杂的页面场景下会出现比较严重的发热行为,尽管目前 Flutter 团队发布了新的渲染引擎 impeller,它在 iOS 上表现优异,流畅度有了质的提升,但还是无法完全解决一些性能问题且 Android 下 impeller 也还没开发完成。

为了应对当下出现的困局和以后可能出现的未知问题,我们期望通过混合模式来扩宽更多的可能性。

路由管理

混合开发下最难处理的就是路由问题了,我们知道原生和 Flutter 都有各自的路由管理系统,在原生页面和 Flutter 页面穿插的情况下如何统一管理和互相交互是一大难点。目前比较流行的单引擎方案,代表框架是闲鱼团队出品flutter_boost;flutter 官方代表的多引擎解决方案 FlutterEngineGroup

单引擎方案 flutter_boost

flutter_boost 通过复用 Engine 达到最小内存的目的

在引擎处理上,flutter_boost 定义了一个通用的 CacheId:"flutter_boost_default_engine",当原生需要跳转到 Flutter 页面时,通过FlutterEngineCache.getInstance().get(ENGINE_ID); 获取同一个 Engine,这样无论打开了多少如图中的 A、B、C 的 Flutter 页面时,都不会产生额外的Engine内存损耗。

public class FlutterBoost {
public static final String ENGINE_ID = "flutter_boost_default_engine";
...
}

另外,双端都注册了导航的接口,通过Channel来通知,用于请求路由变化、页面返回以及页面的生命周期处理等。在这种模式下,这一层Channel的接口处理是重点。

多引擎方案 FlutterEngineGroup

为了应对内存爆炸问题,官方对多引擎场景做了优化,FlutterEngineGroup应运而生,FlutterEngineGroup下的 Engine 共用一些通用的资源,例如GPU 上下文、线程快照等,生成额外的 Engine 时,号称内存占用缩小到 180k。这个程度,基本可以视为正常的损耗了。

以上图中的 B、C 页面为例,两者都是 Flutter 页面,在 FlutterEngineGroup 这种处理下,因为它们所在的 Engine 不是同一个,这会产生完全的隔离行为,也就是 B、C 页面使用不同的堆栈,处在不同的 Isolate 中,两者是无法直接进行交互的。

多引擎的优点是:它可以抹掉上图所示的 F、E、C 和 D、A 等内部路由,每次新增 Flutter 页面时,全部回调到原生,让原生生成新的 Engine 去承载页面,这样路由的管理全部由原生去处理,一个 Engine 只对应一个 Flutter 页面。

但它也会带来一些额外的处理,像上面提到的,处在不同 Engine 下的Flutter 页面之间是无法直接交互的,如果涉及到需要通知和交互的场景,还得通过原生去转发。

关于FlutterEngineGroup的更多信息,可以参考官方说明

性能对比

官方号称 FlutterEngineGroup 创建新的 Engine 只会占用 180k 的内存,那么是不是真就如它所说呢?下面我们来针对上面这两种方案做一个内存占用测试

flutter_boost

测试机型:OPPO CPH2269

测试代码:github.com/alibaba/flu…

内存 dump 命令: adb shell dumpsys meminfo com.idlefish.flutterboost.example

条件PSSRSS最大变化
1 Native88667165971
+26105+28313+27M
1 Native + 1 Flutter114772194284
-282+1721+1M
2 Native + 2 Flutter114490196005
+5774+5992+6M
5 Native + 5 Flutter120264201997
+13414+14119+13M
10 Native + 10 Flutter133678216116

第一次加载 Flutter 页面时,增加 27M 左右内存,此后多开一个页面内存增加呈现从 1M -> 2M -> 2.6 M 这种越来越陡的趋势(数值只是参考,因为其中有 Native 页面,只看趋势变化上看)

FlutterEngineGroup

测试机型:OPPO CPH2269

测试代码:github.com/flutter/sam…

内存 dump 命令: adb shell dumpsys meminfo dev.flutter.multipleflutters

条件PSSRSS最大变化
1 Native45962140817
+29822+31675+31M
1 Native + 1 Flutter75784172492
-610+2063+2M
2 Native + 2 Flutter75174174555
+7451+7027+3.7M
5 Native + 5 Flutter82625181582
+8558+7442+8M
10 Native + 10 Flutter91183189024

第一次加载 Flutter 页面时,增加 31M 左右内存,此后多开一个页面内存增加呈现从 1M -> 1.2M -> 1.6 M 这种越来越陡的趋势(数值只是参考,因为其中有 Native 页面,只看趋势变化上看)

结论

两个测试使用的是不同的 demo 代码,不能通过数值去得出孰优孰劣。但通过数值的表现,我们基本可以确认,两个方案都不会带来异常的内存暴涨,完全在可以接受的范围。

PlatformView

PlatformView 也可实现混合 UI,Flutter 中的 WebView 就是通过 PlatformView 这种方式引入的。

PlatformView 允许我们向 Flutter 界面中插入原生 View,在一个页面的最外层包裹一层 PlatformView,路由的管理都由 Flutter 来处理。这种方式下没有额外的 Engine 产生,是最简单的混合方式。

但它也有缺点,不适合主 Native 混 Flutter 的场景,而现在大多都是以主 Native 混 Flutter的场景为主。另外,PlatformView 因其底层实现,会出现兼容性问题,在一些机型下可能会出现键盘问题、闪烁或其它的性能开销,具体可看这篇介绍

数据共享

原生和 Flutter 使用不同的开发语言去开发,所以在一侧定义的数据结构对象和内存对象对方都无法感知,在数据同步和处理上必须使用其它手段。

MethodChannel

Flutter 开发者对 MethodChannel 一定不陌生,开发当中免不了跟原生交互,MethodChannel 是双向设计,即允许我们在 Flutter 中调用原生的方法,也允许我们在原生中调用 Flutter 的方法。对 Channel 不太了解的可以看一下官方文档,如文档中提到的,这个通道传输的过程中需要将数据编解码,对应的关系以kotlin为例(完整的映射可以查看文档):

Dart                         | Kotlin      |
| -------------------------- | ----------- |
| null | null |
| bool | Boolean |
| int | Int |
| int, if 32 bits not enough | Long |
| double | Double |
| String | String |
| Uint8List | ByteArray |
| Int32List | IntArray |
| Int64List | LongArray |
| Float32List | FloatArray |
| Float64List | DoubleArray |
| List | List |
| Map | HashMap |

本地存储

这种方式比较容易理解,将本地存储视为中转站,Flutter中将数据操作存储到本地上,回到原生页面时在某个时机(如onResume)去查询本地数据库即可,反之亦然。

问题

不管是MethodChannel或是本地存储,都会面临一个问题:对象的数据结构是独立的,两边需要重复定义。比如我在 Flutter 中有一个 Student 对象,Android 端也要定义一个同样结构的 Student,这样才能方便操作,现在我将Student student转成Unit8List传到Android,Channel中解码成Kotlin能操作的ByteArray,再将ByteArray转译成AndroidStudent对象。

class Student {
String name;
int age;
Student(this.name, this.age);
}

对于这个问题最好的解决办法是使用DSL一类的框架,如Google的ProtoBuf,将同一份对象配置文件编译到不同的语言环境中,便能省去这部分双端重复定义的行为。

图片缓存

在内存方面,如果同样的图片在两边都加载时,会使得原生和 Flutter 都会产生一次缓存。在 Flutter 下默认就会缓存在ImageCache中,原生下不同的框架由不同的对象负责,为了去掉重复的图片缓存,势必要统一图片的加载管理。

阿里的方案也是如此,通过外接原生图片库,共享图片的本地文作缓存和内存缓存。它的实现思路是通过自定义ImageProviderCodec,对接外部图库,获取到图片数据做解析,对接的处理是通过扩展 Flutter Engine。

如果期望不修改Flutter Engine,也可通过外接纹理的方式去处理。通过PlatformChannel去请求原生,使到图片的外接纹理数据,通过TextTure组件展示图片。

// 自定义 ImageProvider 中,通过 Channel 去请求 textureId
var id = await _channel.invokeMethod('newTexture', {
"imageUrl": imageUrl,
"width": width ?? 0,
"height": height ?? 0,
"minWidth": constraints.minWidth,
"minHeight": constraints.minHeight,
"maxWidth": constraints.maxWidth,
"maxHeight": constraints.maxHeight,
"cacheKey": cacheKey,
"fit": fit.index,
"cacheOriginFile": cacheOriginFile,
});

// ImageWidget 中展示时通过 textureId 去显示图片
SizedBox(
width: width,
heigt: height,
child: Texture(
filterQuality: FilterQuality.high,
textureId: _imageProvider.textureId.value,
),
)

总结

不同业务对于混合的程度和要求有所要求,并没有万能的方案。比如我团队的情况就是主Flutter混原生,在路由管理上我选择了PlatformView这种处理模式,这种方式更容易开发和维护,后期如果发现有兼容性问题,也可过渡到flutter_boostFlutterEngineGroup上。


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

7个你应该知道的Glide的使用技巧

对于Android开发者来说,Glide是最常使用的库。这里介绍了开发过程中,7个使用Glide的技巧。不要使用wrap_content不清楚你是否这样使用过,把 ImageView 的宽和高设置成 wrap_content,并通过Glide来加载图...
继续阅读 »

对于Android开发者来说,Glide是最常使用的库。这里介绍了开发过程中,7个使用Glide的技巧。

不要使用wrap_content

不清楚你是否这样使用过,把 ImageView 的宽和高设置成 wrap_content,并通过Glide来加载图片

<ImageView
android:id="@+id/image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>

Glide.with(context)
.load(url)
.into(image)

为什么不建议把ImageView设置成 wrap_content,我们看一下Glide的文档是怎么说的(文档地址中文地址 最新英文地址):

文档上写得很明显,在某些情况下会使用屏幕的尺寸代替 wrap_content,这可能导致原来的小图片变成大图,Glide transform 问题分析这篇文章就介绍了这种问题。为了避免这种情况发生,我们最好是不要使用 wrap_content。当然如果你实在是需要使用 wrap_content,你可以按照Glide的建议,使用Target.SIZE_ORIGINAL。

需要注意的是:使用Target.SIZE_ORIGINAL 在加载大图时可能造成oom,因此你需要确保加载的图片不会太大。

自定义内存缓存大小

在某些情况下,我们可能需要自定义Glide的内存缓存大小和Bitmap池的大小,比如图片显示占大头的app,就希望Glide的图片缓存大一些。Glide内部使用MemorySizeCalculator类来决定内存缓存和Bitmap池的大小。

@GlideModule
class MyGlideModel: AppGlideModule() {

override fun applyOptions(context: Context, builder: GlideBuilder) {
super.applyOptions(context, builder)
//通过MemorySizeCalculator获取MemoryCache和BitmapPool的size大小
val calculator = MemorySizeCalculator.Builder(context).build()
val defaultMemoryCacheSize = calculator.memoryCacheSize
val defaultBitmapPoolSize = calculator.bitmapPoolSize

//根据业务计算出需要的缓存大小,这里简化处理,都乘以1.5
val customMemoryCacheSize = (1.5 * defaultMemoryCacheSize).toLong()
val customBitmapPoolSize = (1.5 * defaultBitmapPoolSize).toLong()
//设置缓存
builder.setMemoryCache(LruResourceCache(customMemoryCacheSize))
builder.setBitmapPool(LruBitmapPool(customBitmapPoolSize))
}
}

memoryCache 和 BitmapPool 的区别:

  • memoryCache:通过key-value才缓存数据,缓存之前用过的Bitmap
  • BitmapPool:重用Bitmap对象的对象池,根据Bitmap的宽高来复用。复用的原理可以看Bitmap全解析

具体区别见What is difference between MemoryCacheSize and BitmapPoolSize in Glide

自定义磁盘缓存

Glide 使用 DiskLruCacheWrapper 作为默认的 磁盘缓存 。 DiskLruCacheWrapper 是一个使用 LRU 算法的固定大小的磁盘缓存。默认磁盘大小为 250 MB ,位置是在应用的 缓存文件夹 中的一个 特定目录 。我们也可以自定义磁盘缓存,代码如下:

@GlideModule

class MyGlideModel: AppGlideModule() {

override fun applyOptions(context: Context, builder: GlideBuilder) {
super.applyOptions(context, builder)
val size: Long = 1024 * 1024 * 100 //100MB
builder.setDiskCache(InternalCacheDiskCacheFactory(context, cacheFolderName, size))
}
}

牢记在onLoadCleared释放图片资源

如上图Glide的官方文档所示,我们在使用Target时,必须在重新绘制(通常是View)或改变其可见性之前,你必须确保在onResourceReady中收到的任何当前Drawable不再被使用。这是因为Glide内部缓存在内存不足或者主动回收Glide.get(context).clearMemory()时,会回收Bitmap,如果此时ImageView还使用被回收的Bitmap,就会发生 trying to use a recycled bitmap 的错误。

解决办法是不再使用在onResourceReady中获取的Bitmap,代码如下:

        Glide.with(this)
.load(Url)
.into(object : CustomTarget<Bitmap>(width, height) {
override fun onResourceReady(
resource: Bitmap,
transition: Transition<in Bitmap>?,
) {
mBitmap = resource
}

override fun onLoadCleared(placeholder:Drawable?){
mBitmap = null
}
})

优先加载指定图片

如上图所示,当一个页面有多个图片时,我们希望某些图片优先被加载出来(这个界面里面是上面的一拳超人的封面),某些图片后加载,比如这个界面里的互动点评的用户头像列表。Glide提供了优先级来解决这个问题,它的优先级如下:

  • Priority.LOW
  • Priority.NORMAL
  • Priority.HIGH
  • Priority.IMMEDIATE

使用代码如下:

    Glide
.with(context)
.load("url")
.priority(Priority.LOW)//底优先级的图片
.into(imageView);

Glide
.with(context)
.load("url")
.priority(Priority.HIGH)//高优先级的图片
.into(imageView);

注意:优先级高的加载任务会尽量首先启动,但是无法保证加载开始或完成的顺序。

使用Glide前,先判断页面是否回收

一般我们会通过网络请求来获取图片的链接,再通过Glide来加载图片,代码如下:

service?.fetchUserProfile(id) { result, errMsg, icon ->
if (result == 200) {
Glide.with(context)
.load(icon)
.into(view)
}
}

但是这里有个问题,当界面被destory后,这个网络请求刚好成功了,调用Glide.with就会发生 You cannot start a load for a destroyed activity错误。解决方法是在调用Glide.with前先判断,代码如下:


service?.fetchUserProfile(id) { result, errMsg, icon ->
if (result == 200) {
if (context is FragmentActivity) {
if ((context as FragmentActivity).isFinishing || (context as FragmentActivity).isDestroyed) {
return
}
}
Glide.with(context)
.load(icon)
.into(view)
}
}

加载大图时使用skipMemoryCache

当我们使用Glide加载大图时,应该避免使用内存缓存,如果不好好处理可能发生oom。在Glide中,我们可以使用skipMemoryCache来跳过内存缓存。代码如下:

    Glide.with(context)
.load(url)
.skipMemoryCache(true)
.into(imageview)

与skipMemoryCache对应的是 onlyRetrieveFromCache,它只从缓存中获取对象,不会从网络或者本地缓存中就直接加载失败。


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

RecyclerView刷新后定位问题

问题描述做需求开发时,遇到RecyclerView刷新时,通常会使用notifyItemXXX方法去做局部刷新。但是刷新后,有时会遇到RecyclerView定位到我们不希望的位置,这时候就会很头疼。这周有时间深入了解了下RecyclerView的源码,大致梳...
继续阅读 »

问题描述

做需求开发时,遇到RecyclerView刷新时,通常会使用notifyItemXXX方法去做局部刷新。但是刷新后,有时会遇到RecyclerView定位到我们不希望的位置,这时候就会很头疼。这周有时间深入了解了下RecyclerView的源码,大致梳理清楚刷新后位置跳动的原因了。

原因分析

先简单描述下RecyclerView在notify后的过程:

  1. 根据是否是全量刷新来选择触发RecyclerView.RecyclerViewDataObserver的onChanged方法或onItemRangeXXX方法

onChanged会直接调用requestlayout来重新layuout。 onItemRangeXXX会先把刷新数据保存到mAdapterHelper中,然后再调用requestlayout 2. 进入dispatchLayout流程 这一步分为三个步骤:

  • dispatchLayoutStep1:处理adapter的更新、决定哪些view执行动画、保存view的信息
  • dispatchLayoutStep2:真正执行childView的layout操作
  • dispatchLayoutStep3:触发动画、保存状态、清理信息

需要注意的是,在onMeasure的过程中,如果传入的measureMode不是exactly,会去调用dispatchLayoutStep1和dispatchLayoutStep2从而取得真正需要的宽高。 所以在dispatchLayout会先判断是否需要重新执行dispatchLayoutStep1和dispatchLayoutStep2

重点分析dispatchLayoutStep2这一步: 核心操作在 mLayout.onLayoutChildren(mRecycler, mState)这一行。以LinearLayoutManager为例继续往下挖:

public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
...
final View focused = getFocusedChild();
if (!mAnchorInfo.mValid || mPendingScrollPosition != RecyclerView.NO_POSITION
|| mPendingSavedState != null) {
mAnchorInfo.reset();
mAnchorInfo.mLayoutFromEnd = mShouldReverseLayout ^ mStackFromEnd;
// 关键步骤1,寻找锚点View位置
updateAnchorInfoForLayout(recycler, state, mAnchorInfo);
mAnchorInfo.mValid = true;
} else if (focused != null && (mOrientationHelper.getDecoratedStart(focused)
>= mOrientationHelper.getEndAfterPadding()
|| mOrientationHelper.getDecoratedEnd(focused)
<= mOrientationHelper.getStartAfterPadding())) {
mAnchorInfo.assignFromViewAndKeepVisibleRect(focused, getPosition(focused));
}
...
// fill towards end
updateLayoutStateToFillEnd(mAnchorInfo);
mLayoutState.mExtraFillSpace = extraForEnd;
//关键步骤2,从锚点View位置往后填充
fill(recycler, mLayoutState, state, false);
endOffset = mLayoutState.mOffset;
final int lastElement = mLayoutState.mCurrentPosition;
if (mLayoutState.mAvailable > 0) {
//如果锚点位置后面数据不足,无法填满剩余的空间,那把剩余空间加到顶部
extraForStart += mLayoutState.mAvailable;
}
// fill towards start
updateLayoutStateToFillStart(mAnchorInfo);
mLayoutState.mExtraFillSpace = extraForStart;
mLayoutState.mCurrentPosition += mLayoutState.mItemDirection;
//关键步骤3,从锚点View位置向前填充
fill(recycler, mLayoutState, state, false);
startOffset = mLayoutState.mOffset;

if (mLayoutState.mAvailable > 0) {
//如果锚点View位置前面数据不足,那把剩余空间加到尾部再做一次尝试
extraForEnd = mLayoutState.mAvailable;
// start could not consume all it should. add more items towards end
updateLayoutStateToFillEnd(lastElement, endOffset);
mLayoutState.mExtraFillSpace = extraForEnd;
fill(recycler, mLayoutState, state, false);
endOffset = mLayoutState.mOffset;
}
}

先解释一下锚点View,锚点View在一次layout过程中的位置不会发生变化,即之前在哪里显示,这次layout完还在哪,从视觉上看没有位移。

总结一下,mLayout.onLayoutChildren主要做了以下几件事:

  1. 调用updateAnchorInfoForLayout方法确定锚点view位置
  2. 从锚点view后面的位置开始填充,直到后面空间被填满或者已经遍历到最后一个itemView
  3. 从锚点view前面的位置开始填充,直到空间被填满或者遍历到indexe为0的itemView
  4. 经过第三步后仍有剩余空间,则把剩余空间加到尾部再做一次尝试

所以回到一开始的问题,RecyclerView在notify之后位置跳跃的关键在于锚点View的确定,也就是updateAnchorInfoForLayout方法,所以下面重点看下这个方法:

private void updateAnchorInfoForLayout(RecyclerView.Recycler recycler, RecyclerView.State state,
AnchorInfo anchorInfo) {
if (updateAnchorFromPendingData(state, anchorInfo)) {
if (DEBUG) {
Log.d(TAG, "updated anchor info from pending information");
}
return;
}

if (updateAnchorFromChildren(recycler, state, anchorInfo)) {
if (DEBUG) {
Log.d(TAG, "updated anchor info from existing children");
}
return;
}
if (DEBUG) {
Log.d(TAG, "deciding anchor info for fresh state");
}
anchorInfo.assignCoordinateFromPadding();
anchorInfo.mPosition = mStackFromEnd ? state.getItemCount() - 1 : 0;
}

这个方法比较短,所以代码全贴出来了。如果是调用了scrollToPosition后的刷新,会通过updateAnchorFromPendingData方法确定锚点View位置,否则调用updateAnchorFromChildren来计算:

private boolean updateAnchorFromChildren(RecyclerView.Recycler recycler,
RecyclerView.State state, AnchorInfo anchorInfo) {
if (getChildCount() == 0) {
return false;
}
final View focused = getFocusedChild();
if (focused != null && anchorInfo.isViewValidAsAnchor(focused, state)) {
anchorInfo.assignFromViewAndKeepVisibleRect(focused, getPosition(focused));
return true;
}
if (mLastStackFromEnd != mStackFromEnd) {
return false;
}
View referenceChild =
findReferenceChild(
recycler,
state,
anchorInfo.mLayoutFromEnd,
mStackFromEnd);
if (referenceChild != null) {
anchorInfo.assignFromView(referenceChild, getPosition(referenceChild));
...
return true;
}
return false;
}

代码比较简单,如果有焦点View,并且焦点View没被remove,则使用焦点View作为锚点。否则调用findReferenceChild来查找:

View findReferenceChild(RecyclerView.Recycler recycler, RecyclerView.State state,
boolean layoutFromEnd, boolean traverseChildrenInReverseOrder) {
ensureLayoutState();

// Determine which direction through the view children we are going iterate.
int start = 0;
int end = getChildCount();
int diff = 1;
if (traverseChildrenInReverseOrder) {
start = getChildCount() - 1;
end = -1;
diff = -1;
}

int itemCount = state.getItemCount();

final int boundsStart = mOrientationHelper.getStartAfterPadding();
final int boundsEnd = mOrientationHelper.getEndAfterPadding();

View invalidMatch = null;
View bestFirstFind = null;
View bestSecondFind = null;

for (int i = start; i != end; i += diff) {
final View view = getChildAt(i);
final int position = getPosition(view);
final int childStart = mOrientationHelper.getDecoratedStart(view);
final int childEnd = mOrientationHelper.getDecoratedEnd(view);
if (position >= 0 && position < itemCount) {
if (((RecyclerView.LayoutParams) view.getLayoutParams()).isItemRemoved()) {
if (invalidMatch == null) {
invalidMatch = view; // removed item, least preferred
}
} else {
// b/148869110: usually if childStart >= boundsEnd the child is out of
// bounds, except if the child is 0 pixels!
boolean outOfBoundsBefore = childEnd <= boundsStart && childStart < boundsStart;
boolean outOfBoundsAfter = childStart >= boundsEnd && childEnd > boundsEnd;
if (outOfBoundsBefore || outOfBoundsAfter) {
// The item is out of bounds.
// We want to find the items closest to the in bounds items and because we
// are always going through the items linearly, the 2 items we want are the
// last out of bounds item on the side we start searching on, and the first
// out of bounds item on the side we are ending on. The side that we are
// ending on ultimately takes priority because we want items later in the
// layout to move forward if no in bounds anchors are found.
if (layoutFromEnd) {
if (outOfBoundsAfter) {
bestFirstFind = view;
} else if (bestSecondFind == null) {
bestSecondFind = view;
}
} else {
if (outOfBoundsBefore) {
bestFirstFind = view;
} else if (bestSecondFind == null) {
bestSecondFind = view;
}
}
} else {
// We found an in bounds item, greedily return it.
return view;
}
}
}
}
// We didn't find an in bounds item so we will settle for an item in this order:
// 1. bestSecondFind
// 2. bestFirstFind
// 3. invalidMatch
return bestSecondFind != null ? bestSecondFind :
(bestFirstFind != null ? bestFirstFind : invalidMatch);
}

解释一下,查找过程会遍历RecyclerView当前可见的所有childView,找到第一个没被notifyRemove的childView就停止查找,否则会把遍历过程中找到的第一个被notifyRemove的childView作为锚点View返回。

这里需要注意final int position = getPosition(view);这一行代码,getPosition返回的是经过校正的最终position,如果ViewHolder被notifyRemove了,这里的position会是0,所以如果可见的childView都被remove了,那最终定位的锚点View是第一个childView,锚点的position是0,偏移量offset是这个被删除的childView的top值,这就会导致后面fill操作时从位置0开始填充,先把position=0的view填充到偏移量offset的位置,再往后依次填满剩余空间,这也是导致画面上的跳动的根本原因。


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

如何开启一个线程,开启大量线程会有什么问题,如何优化?(美团面试问道)

这是我一个朋友在美团面试中遇到的一个问题,今天拿出来解析一下正文如何开启一个线程如何开启一个线程,再JDK中的说明为:/** * ... * There are two ways to create a new thread of execution. O...
继续阅读 »

这是我一个朋友在美团面试中遇到的一个问题,今天拿出来解析一下

正文

如何开启一个线程

如何开启一个线程,再JDK中的说明为:

/**
* ...
* There are two ways to create a new thread of execution. One is to
* declare a class to be a subclass of <code>Thread</code>.
* The other way to create a thread is to declare a class that
* implements the <code>Runnable</code> interface.
* ....
*/
public class Thread implements Runnable{
     
}

Thread源码的类描述中有这样一段,翻译一下,只有两种方法去创建一个执行线程,一种是声明一个Thread的子类,另一种是创建一个类去实现Runnable接口。

继承Thread类
public class ThreadUnitTest {

   @Test
   public void testThread() {
       //创建MyThread实例
       MyThread myThread = new MyThread();
       //调用线程start的方法,进入可执行状态
       myThread.start();
  }

   //继承Thread类,重写内部run方法
   static class MyThread extends Thread {

       @Override
       public void run() {
           System.out.println("test MyThread run");
      }
  }
}
实现Runnable接口
public class ThreadUnitTest {

   @Test
   public void testRunnable() {
       //创建MyRunnable实例,这其实只是一个任务,并不是线程
       MyRunnable myRunnable = new MyRunnable();
       //交给线程去执行
       new Thread(myRunnable).start();
  }

   //实现Runnable接口,并实现内部run方法
   static class MyRunnable implements Runnable {

       @Override
       public void run() {
           System.out.println("test MyRunnable run");
      }
  }
}
实现Callable

其实实现Callback接口创建线程的方式,归根到底就是Runnable方式,只不过它是在Runnable的基础上又增加了一些能力,例如取消任务执行等。

public class ThreadUnitTest {

   @Test
   public void testCallable() {
       //创建MyCallable实例,需要与FutureTask结合使用
       MyCallable myCallable = new MyCallable();
       //创建FutureTask,与Runnable一样,也只能算是个任务
       FutureTask<String> futureTask = new FutureTask<>(myCallable);
       //交给线程去执行
       new Thread(futureTask).start();

       try {
           //get方法获取任务返回值,该方法是阻塞的
           String result = futureTask.get();
           System.out.println(result);
      } catch (ExecutionException e) {
           e.printStackTrace();
      } catch (InterruptedException e) {
           e.printStackTrace();
      }
  }

   //实现Callable接口,并实现call方法,不同之处是该方法有返回值
   static class MyCallable implements Callable<String> {

       @Override
       public String call() throws Exception {
           Thread.sleep(10000);
           return "test MyCallable run";
      }
  }
}

Callable的方式必须与FutureTask结合使用,我们看看FutureTask的继承关系:

//FutureTask实现了RunnableFuture接口
public class FutureTask<V> implements RunnableFuture<V> {

}

//RunnableFuture接口继承Runnable和Future接口
public interface RunnableFuture<V> extends Runnable, Future<V> {
   void run();
}

开启大量线程会引起什么问题

在Java中,调用Thread的start方法后,该线程即置为就绪状态,等待CPU的调度。这个流程里有两个关注点需要去理解。

start内部怎样开启线程的?看看start方法是怎么实现的。

// Thread类的start方法
public synchronized void start() {
       // 一系列状态检查
       if (threadStatus != 0)
           throw new IllegalThreadStateException();
 
       group.add(this);
         
       boolean started = false;
       try {
            //调用start0方法,真正启动java线程的地方
           start0();
           started = true;
      } finally {
           try {
               if (!started) {
                group.threadStartFailed(this);
              }
          } catch (Throwable ignore) {
          }
      }
  }
 
//start0方法是一个native方法
private native void start0();

JVM中,native方法与java方法存在一个映射关系,Java中的start0对应c层的JVM_StartThread方法,我们继续看一下:

JVM_ENTRY(void, JVM_StartThread(JNIEnv* env, jobject jthread))
 JVMWrapper("JVM_StartThread");
 JavaThread *native_thread = NULL;
 bool throw_illegal_thread_state = false;
{
 
   MutexLocker mu(Threads_lock);
   // 判断Java线程是否已经启动,如果已经启动过,则会抛异常。
   if (java_lang_Thread::thread(JNIHandles::resolve_non_null(jthread)) != NULL) {
     throw_illegal_thread_state = true;
  } else {
     //如果没有启动过,走到这里else分支,去创建线程
     //分配c++线程结构并创建native线程
     jlong size =
            java_lang_Thread::stackSize(JNIHandles::resolve_non_null(jthread));
 
     size_t sz = size > 0 ? (size_t) size : 0;
     //注意这里new JavaThread
     native_thread = new JavaThread(&thread_entry, sz);
     if (native_thread->osthread() != NULL) {
       native_thread->prepare(jthread);
    }
  }
}
......
 Thread::start(native_thread);

走到这里发现,Java层已经过渡到native层,但远远还没结束:

JavaThread::JavaThread(ThreadFunction entry_point, size_t stack_sz) :
                         Thread()
  {
    initialize();
    _jni_attach_state = _not_attaching_via_jni;
    set_entry_point(entry_point);
    os::ThreadType thr_type = os::java_thread;
    thr_type = entry_point == &compiler_thread_entry ? os::compiler_thread :
                                                       os::java_thread;
    //根据平台,调用create_thread,创建真正的内核线程                      
    os::create_thread(this, thr_type, stack_sz);
  }
 
  bool os::create_thread(Thread* thread, ThreadType thr_type,
                         size_t req_stack_size) {
      ......
      pthread_t tid;
      //利用pthread_create()来创建线程
      int ret = pthread_create(&tid, &attr, (void* (*)(void*)) thread_native_entry, thread);
      ......
      return true;
}

pthread_create方法,第三个参数表示启动这个线程后要执行的方法的入口,第四个参数表示要给这个方法传入的参数:

static void *thread_native_entry(Thread *thread) {
......
 //thread_native_entry方法的最下面的run方法,这个thread就是上面传递下来的参数,也就是JavaThread
 thread->run();
......
 return 0;
}

终于开始执行run方法了:

//thread.cpp类
void JavaThread::run() {
......
 //调用内部thread_main_inner  
 thread_main_inner();
}
 
void JavaThread::thread_main_inner() {
 if (!this->has_pending_exception() &&
  !java_lang_Thread::is_stillborn(this->threadObj())) {
  {
     ResourceMark rm(this);
     this->set_native_thread_name(this->get_thread_name());
  }
   HandleMark hm(this);
   //注意:内部通过JavaCalls模块,调用了Java线程要执行的run方法
   this->entry_point()(this, this);
}
 DTRACE_THREAD_PROBE(stop, this);
 this->exit(false);
 delete this;
}

一条U字型代码调用链至此结束:

  • Java中调用Thread的star方法,通过JNI方式,调用到native层。
  • native层,JVM通过pthread_create方法创建一个系统内核线程,并指定内核线程的初始运行地址,即一个方法指针。
  • 在内核线程的初始运行方法中,利用JavaCalls模块,回调到java线程的run方法,开始java级别的线程执行。
线程如何调度

计算机的世界里,CPU会分为若干时间片,通过各种算法分配时间片来执行任务,有耳熟能详时间片轮转调度算法、短进程优先算法、优先级算法等。当一个任务的时间片用完,就会切换到另一个任务。在切换之前会保存上一个任务的状态,当下次再切换到该任务,就会加载这个状态, 这就是所谓的线程的上下文切换。很明显,上下文的切换是有开销的,包括很多方面,操作系统保存和恢复上下文的开销、线程调度器调度线程的开销和高速缓存重新加载的开销等。

image.png

经过上面两个理论基础的回顾,开启大量线程引起的问题,总结起来,就两个字——开销。

消耗时间:线程的创建和销毁都需要时间,当数量太大的时候,会影响效率。 消耗内存:创建更多的线程会消耗更多的内存,这是毋庸置疑的。线程频繁创建与销毁,还有可能引起内存抖动,频繁触发GC,最直接的表现就是卡顿。长而久之,内存资源占用过多或者内存碎片过多,系统甚至会出现OOM。 消耗CPU。在操作系统中,CPU都是遵循时间片轮转机制进行处理任务,线程数过多,必然会引起CPU频繁的进行线程上下文切换。这个代价是昂贵的,某些场景下甚至超过任务本身的消耗。

如何优化

线程的本质是为了执行任务,在计算机的世界里,任务分大致分为两类,CPU密集型任务和IO密集型任务。

CPU密集型任务,比如公式计算、资源解码等。这类任务要进行大量的计算,全都依赖CPU的运算能力,持久消耗CPU资源。所以针对这类任务,其实不应该开启大量线程。因为线程越多,花在线程切换的时间就越多,CPU执行效率就越低,一般CPU密集型任务同时进行的数量等于CPU的核心数,最多再加个1。 IO密集型任务,比如网络读写、文件读写等。这类任务不需要消耗太多的CPU资源,绝大部分时间是在IO操作上。所以针对这类任务,可以开启大量线程去提高CPU的执行效率,一般IO密集型任务同时进行的数量等于CPU的核心数的两倍。 另外,在无法避免,必须要开启大量线程的情况下,我们也可以使用线程池代替直接创建线程的做法进行优化。线程池的基本作用就是复用已有的线程,从而减少线程的创建,降低开销。在Java中,线程池的使用还是非常方便的,JDK中提供了现成的ThreadPoolExecutor类,我们只需要按照自己的需求进行相应的参数配置即可,这里提供一个示例。

/**
* 线程池使用
*/
public class ThreadPoolService {

   /**
    * 线程池变量
    */
   private ThreadPoolExecutor mThreadPoolExecutor;

   private static volatile ThreadPoolService sInstance = null;

   /**
    * 线程池中的核心线程数,默认情况下,核心线程一直存活在线程池中,即便他们在线程池中处于闲置状态。
    * 除非我们将ThreadPoolExecutor的allowCoreThreadTimeOut属性设为true的时候,这时候处于闲置的核心         * 线程在等待新任务到来时会有超时策略,这个超时时间由keepAliveTime来指定。一旦超过所设置的超时时间,闲     * 置的核心线程就会被终止。
    * CPU密集型任务 N+1   IO密集型任务   2*N
    */
   private final int CORE_POOL_SIZE = Runtime.getRuntime().availableProcessors() + 1;
   /**
    * 线程池中所容纳的最大线程数,如果活动的线程达到这个数值以后,后续的新任务将会被阻塞。包含核心线程数+非*     * 核心线程数。
    */
   private final int MAXIMUM_POOL_SIZE = Math.max(CORE_POOL_SIZE, 10);
   /**
    * 非核心线程闲置时的超时时长,对于非核心线程,闲置时间超过这个时间,非核心线程就会被回收。
    * 只有对ThreadPoolExecutor的allowCoreThreadTimeOut属性设为true的时候,这个超时时间才会对核心线       * 程产生效果。
    */
   private final long KEEP_ALIVE_TIME = 2;
   /**
    * 用于指定keepAliveTime参数的时间单位。
    */
   private final TimeUnit UNIT = TimeUnit.SECONDS;
   /**
    * 线程池中保存等待执行的任务的阻塞队列
    * ArrayBlockingQueue 基于数组实现的有界的阻塞队列
    * LinkedBlockingQueue 基于链表实现的阻塞队列
    * SynchronousQueue   内部没有任何容量的阻塞队列。在它内部没有任何的缓存空间
    * PriorityBlockingQueue   具有优先级的无限阻塞队列。
    */
   private final BlockingQueue<Runnable> WORK_QUEUE = new LinkedBlockingDeque<>();
   /**
    * 线程工厂,为线程池提供新线程的创建。ThreadFactory是一个接口,里面只有一个newThread方法。 默认为DefaultThreadFactory类。
    */
   private final ThreadFactory THREAD_FACTORY = Executors.defaultThreadFactory();
   /**
    * 拒绝策略,当任务队列已满并且线程池中的活动线程已经达到所限定的最大值或者是无法成功执行任务,这时候       * ThreadPoolExecutor会调用RejectedExecutionHandler中的rejectedExecution方法。
    * CallerRunsPolicy 只用调用者所在线程来运行任务。
    * AbortPolicy 直接抛出RejectedExecutionException异常。
    * DiscardPolicy 丢弃掉该任务,不进行处理。
    * DiscardOldestPolicy   丢弃队列里最近的一个任务,并执行当前任务。
    */
   private final RejectedExecutionHandler REJECTED_HANDLER = new ThreadPoolExecutor.AbortPolicy();

   private ThreadPoolService() {
  }

   /**
    * 单例
    * @return
    */
   public static ThreadPoolService getInstance() {
       if (sInstance == null) {
           synchronized (ThreadPoolService.class) {
               if (sInstance == null) {
                   sInstance = new ThreadPoolService();
                   sInstance.initThreadPool();
              }
          }
      }
       return sInstance;
  }

   /**
    * 初始化线程池
    */
   private void initThreadPool() {
       try {
           mThreadPoolExecutor = new ThreadPoolExecutor(
                   CORE_POOL_SIZE,
                   MAXIMUM_POOL_SIZE,
                   KEEP_ALIVE_TIME,
                   UNIT,
                   WORK_QUEUE,
                   THREAD_FACTORY,
                   REJECTED_HANDLER);
      } catch (Exception e) {
           LogUtil.printStackTrace(e);
      }
  }

   /**
    * 向线程池提交任务,无返回值
    *
    * @param runnable
    */
   public void post(Runnable runnable) {
       mThreadPoolExecutor.execute(runnable);
  }

   /**
    * 向线程池提交任务,有返回值
    *
    * @param callable
    */
   public <T> Future<T> post(Callable<T> callable) {
       RunnableFuture<T> task = new FutureTask<T>(callable);
       mThreadPoolExecutor.execute(task);
       return task;
  }
}

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

代码改变生活

故事背景在我的老家,西南边陲的某个小县城,因为物资匮乏、基础设施落后,“赶集”这项活动不知流传了多少年。以县城北部区域的四个乡镇为界,每天都有一个集,暂且就叫A、B、C、D集吧。没有官方通知,也没有统一组织方,到了日子大家就会不约而同的前往集市,采购生活用品、...
继续阅读 »

故事背景

在我的老家,西南边陲的某个小县城,因为物资匮乏、基础设施落后,“赶集”这项活动不知流传了多少年。以县城北部区域的四个乡镇为界,每天都有一个集,暂且就叫A、B、C、D集吧。没有官方通知,也没有统一组织方,到了日子大家就会不约而同的前往集市,采购生活用品、炫点小吃、添置衣物、卖点家里的劳动剩余。
赶集的规律是“空三赶四”,ABCDABCD如此循环往复,就A集来说就是A _ _ _ A _ _ _ A。
在早些年间农忙时节,家里人不知白天黑夜地在田间地头劳作,根本记不住今夕是何年,为了确定今天是什么集,经常需要跑出家门问问其他邻居,亦或是看看路上有没有前往集市的“马的”。日子一天一天过,集市的规律就如二十四节气般从来没有混乱过。
2020年新冠肆虐,为减少人员流动,很多自然村封村封路,更别提集市了。就这样集市暂停了一段时间,待情况好转,集市恢复了往常的热闹,还是固定的时间、固定过的地点,大家又聚集在了一起。周期循环规律并没有因为集市暂停而被打断,这让我好生惊讶。

抽象一下

观察规律

其实上面已经给出规律了,就是ABCDABCD循环,每隔三天重复一个集。
假设第1天是A集,那么第5天、第9天都是A集,按常见程序数组从0开始的惯例,那就是
第0天是A集,第4天、第8天都是A集。 

如果我们已知2017-02-05是A集,要计算今天是什么集,其实就是计算今天距离2017-02-05相差多少天,然后对4取余,根据余数可得:

  • 余数为0就是A集
  • 余数为1就是B集
  • 余数为2就是C集
  • 余数为3就是D集

代码实现

Java版

import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;

public class Test {

public static void main(String[] args) throws ParseException {

String[] names = new String[]{"A集", "B集", "C集", "D集"};

DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
Date standard = dateFormat.parse("2007-02-05");
Calendar c1 = Calendar.getInstance();
c1.setTime(standard);

Calendar now = Calendar.getInstance();
int diff = now.get(Calendar.DAY_OF_YEAR) - now.get(Calendar.DAY_OF_YEAR);
System.out.println(names[diff % 4]);
}

}

Go版

package main

import (
"fmt"
"time"
)

const (
STANDARD = "2017-02-05"
)

var names = []string{"A集", "B集", "C集", "D集"}

func main() {
today := time.Now()
standard, _ := time.Parse("2006-01-02", STANDARD)
fmt.Println(names[int(today.Sub(standard).Hours()/24)%4])
}

快捷指令

最后

借助一个简单的数学求余就能解决生活中的问题,即使现在常年在外,但每当回想起家乡、回想起家乡的集市,我还是会掏出手机看下今天是什么集,大概这就是乡愁吧!


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

线程通讯的三种方法!通俗易懂

线程通信是指多个线程之间通过某种机制进行协调和交互,例如,线程等待和通知机制就是线程通讯的主要手段之一。在 Java 中,线程等待和通知的实现手段有以下几种方式:Object 类下的 wait()、notify() 和 notifyAll() 方法;Condi...
继续阅读 »

线程通信是指多个线程之间通过某种机制进行协调和交互,例如,线程等待和通知机制就是线程通讯的主要手段之一。

在 Java 中,线程等待和通知的实现手段有以下几种方式:

  1. Object 类下的 wait()、notify() 和 notifyAll() 方法;
  2. Condition 类下的 await()、signal() 和 signalAll() 方法;
  3. LockSupport 类下的 park() 和 unpark() 方法。

为什么一个线程等待和通知机制就需要这么多的实现方式呢?别着急,咱们先来看实现,再来说原因。

一、wait/notify/notifyAll

Object 类的方法说明:

  1. wait():让当前线程处于等待状态,并释放当前拥有的锁;
  2. notify():随机唤醒等待该锁的其他线程,重新获取锁,并执行后续的流程,只能唤醒一个线程;
  3. notifyAll():唤醒所有等待该锁的线程(锁只有一把,虽然所有线程被唤醒,但所有线程需要排队执行)。

示例代码如下:

Object lock = new Object();
// 创建线程并执行
new Thread(() -> {
    System.out.println("线程1:开始执行");
    synchronized (lock) {
        try {
            System.out.println("线程1:进入等待");
            lock.wait();
            System.out.println("线程1:继续执行");
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("线程1:执行完成");
    }
}).start();

Thread.sleep(1000);
synchronized (lock) {
    // 唤醒线程
    System.out.println("执行 notifyAll()");
    lock.notifyAll();
}

二、await/signal/signalAll

Condition 类的方法说明:

  1. await():对应 Object 的 wait() 方法,线程等待;
  2. signal():对应 Object 的 notify() 方法,随机唤醒一个线程;
  3. signalAll():对应 Object 的 notifyAll() 方法,唤醒所有线程。

示例代码如下:

// 创建 Condition 对象
Lock lock = new ReentrantLock();
Condition condition = lock.newCondition(); // lock 下可创建多个 Condition
// 加锁
lock.lock();
try {
    // 业务方法......
    // 1.进入等待状态
    condition.await();
    // 2.唤醒操作
    condition.signal();
catch (InterruptedException e) {
    e.printStackTrace();
finally {
    lock.unlock();
}

三、park/unpark

LockSupport 类的方法说明:

  1. LockSupport.park():休眠当前线程。
  2. LockSupport.unpark(线程对象):唤醒某一个指定的线程。

PS:LockSupport 无需配锁(synchronized 或 Lock)一起使用。

示例代码如下:

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        LockSupport.park();
        System.out.println("线程1");
    }, "线程1");
    t1.start();
    Thread t2 = new Thread(() -> {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("唤醒线程1");
        LockSupport.unpark(t1);
    }, "线程2");
    t2.start();
}

四、小结

为什么一个线程等待和唤醒的功能需要这么多的实现呢?

  1. LockSupport 存在的必要性:前两种方法 notify 方法以及 signal 方法都是随机唤醒,如果存在多个等待线程的话,可能会唤醒不应该唤醒的线程,因此有 LockSupport 类下的 park 和 unpark 方法指定唤醒线程是非常有必要的。
  2. Condition 存在的必要性:Condition 相比于 Object 类的 wait 和 notify/notifyAll 方法,前者可以创建多个等待集,例如,我们可以创建一个生产者等待唤醒对象,和一个消费者等待唤醒对象,这样我们就能实现生产者只能唤醒消费者,而消费者只能唤醒生产者的业务逻辑了,如下代码所示:
// 创建 Condition 对象
private Lock lock = new ReentrantLock();
// 生产者的 Condition 对象
private Condition producerCondition = lock.newCondition();
// 本篇内容出自磊哥《Java面试突击训练营》 VX:GG_Stone
// 消费者的 Condition 对象
private Condition consumerCondition = lock.newCondition();

也就是 Condition 是 Object 等待唤醒模型的升级,Object 类可以实现的功能它都能实现,但 Condition 能实现的功能,Object 却不能实现,这就是 Condition 类存在的必要性。

那问题来了,为什么还有会 Object 的 wait 和 notify 方法呢? 因为 Object 类诞生的比较早,也就是说 Condition 和 LockSupport 都是 JDK 后期版本才出现的功能,所以就有了现在这么多线程唤醒和等待的方法了。


收起阅读 »

如何为你的 js 项目添加 ts 支持?

web
前一段时间为公司内的一个 JS 公共库,增加了一些 TypeScript 类型支持。在这里简答记录一下。 安装 TypeScript 依赖 首先安装 TypeScript 依赖,我们要通过 tsc 指令创建声明文件: pnpm ins...
继续阅读 »

前一段时间为公司内的一个 JS 公共库,增加了一些 TypeScript 类型支持。在这里简答记录一下。





安装 TypeScript 依赖


首先安装 TypeScript 依赖,我们要通过 tsc 指令创建声明文件:


pnpm install -D typescript

创建配置文件


接下来创建 TypeScript 配置文件:


npx tsc --init

这一步会在项目的根目录下创建一个 tsconfig.json 文件。我们在原来配置的基础上开放一些配置:


{
  "compilerOptions": {
     "target": "es2016",
     "module": "commonjs",
     "esModuleInterop": true,
     "forceConsistentCasingInFileNames": true,
     "strict": true,
     "noImplicitAny": false,
     "skipLibCheck": true,
+    "allowJs": true,
+    "checkJs": true,
+    "declaration": true,
+    "emitDeclarationOnly": true,
+    "rootDir": "./",
+    "outDir": "./types",
   }
+  "include": [
+    "security/**/*"
+  ]
}

字段说明


对上述字段,我们挑几个重要的说明一下。





  • allowJscheckJs 增加 JS 文件支持



  • declarationemitDeclarationOnly 我们只需要 tsc 帮我们生成类型声明文件即可



  • rootDiroutDir 指定了类型声明文件生成到 types/ 目录



  • include 我们只为 security/ 目录下的代码生成类型声明文件


想详细了解每个配置字段的含义,可以参考 TypeScript 官方说明:https://aka.ms/tsconfig


生成类型文件


项目根目录下创建 index.d.ts 文件


export let security: typeof import("./types/security");

接下里修改 package.json, 增加当前 npm 包的类型声明支持和构建脚本 typecheck


{
    "scripts": {
        // ...
        "typecheck""tsc",
    },
    types: "index.d.ts"   
}

接下来执行脚本:


npm run typecheck

最后就能看到在 types/ 目录下为 security/ 生成的类型声明文件了。


作者:zhangbao
来源:mdnice.com/writing/55c153377fd3436581576b7017c25f3a
收起阅读 »

python复数类型的使用及介绍

在Python中,复数类型是用来表示具有实部和虚部的数值。复数由实部和虚部组成,形式为 a + bj,其中 a 是实部,b 是虚部,j 是虚数单位。 要创建一个复数,可以使用 complex() 函数,并提供实部和虚部作为参数。例如: z =&n...
继续阅读 »

在Python中,复数类型是用来表示具有实部和虚部的数值。复数由实部和虚部组成,形式为 a + bj,其中 a 是实部,b 是虚部,j 是虚数单位。


要创建一个复数,可以使用 complex() 函数,并提供实部和虚部作为参数。例如:


z = complex(23)
print(z)  # 输出:(2+3j)

这里,z 是一个复数,实部为2,虚部为3。


可以通过 .real 属性来访问复数的实部,通过 .imag 属性来访问复数的虚部。例如:


print(z.real)  # 输出:2.0
print(z.imag)  # 输出:3.0

可以使用运算符来对复数进行算术运算,包括加法、减法、乘法和除法。例如:


z1 = complex(23)
z2 = complex(45)

addition = z1 + z2
subtraction = z1 - z2
multiplication = z1 * z2
division = z1 / z2

print(addition)      # 输出:(6+8j)
print(subtraction)   # 输出:(-2-2j)
print(multiplication) # 输出:(-7+22j)
print(division)      # 输出:(0.5609756097560976+0.0487804878048781j)

Python还提供了一些内置函数和方法来处理复数,例如 abs() 函数用于计算复数的绝对值,cmath 模块提供了一些数学函数,如求幅度和相位角。


复数的使用可以在需要处理虚数或使用复数运算的情况下非常有用,例如在工程、物理或数学领域。


作者:小小绘
来源:mdnice.com/writing/2cd491bb2ae749d195b965325ba408f3
收起阅读 »

如何在页面关闭时发送 API 请求

web
前言 在一些需求背景下,我们需要在页面销毁(关闭/刷新)时将数据同步给后台,比如 记录视频播放进度、页面浏览时长埋点等。 在 window 全局对象上,提供了 beforeunload 事件,会在浏览器窗口关闭或刷新时触发。 要实现这个需求,普遍的做法是在 w...
继续阅读 »

前言


在一些需求背景下,我们需要在页面销毁(关闭/刷新)时将数据同步给后台,比如 记录视频播放进度、页面浏览时长埋点等


window 全局对象上,提供了 beforeunload 事件,会在浏览器窗口关闭或刷新时触发。


要实现这个需求,普遍的做法是在 window.onbeforeunload 监听事件回调中发起 api 请求。


const onBeforeunload = async () => {
// 发起请求
}
window.addEventListener('beforeunload', onBeforeunload);


注意:在移动设备下,一些浏览器并不支持 beforeunload 事件,最可靠的方式是在 visibilitychange 事件中处理。



document.addEventListener('visibilitychange', function logData() {
if (document.visibilityState === 'hidden') {
...
}
});

发起请求的方式有如下几种:



  1. ajax(XMLHttpRequest)

  2. sendBeacon(Navigator.sendBeacon)

  3. fetch(Fetch keepalive)


下面,我们分析对比以上几种方式的优劣及适用性。


一、ajax


早期前后端进行数据交互多数都采用 XMLHttpRequest 方式创建一个 HTTP 请求,默认采用 异步 方式发起请求:


const ajax = (config) => {
const options = Object.assign({
url: '',
method: 'GET',
headers: {},
success: function () { },
error: function () { },
data: null,
timeout: 0,
async: true, // 是否异步发送请求,默认 true 是异步,同步需设置 false。
}, config);
const method = options.method.toUpperCase();

// 1、创建 xhr 对象
const xhr = new XMLHttpRequest();
xhr.timeout = options.timeout; // 设置请求超时时间

// 2、建立连接
xhr.open(method, options.url, options.async); // 第三参数决定是以 异步/同步 方式发起 HTTP 请求

// 设置请求头
Object.keys(options.headers).forEach(key => {
xhr.setRequestHeader(key, options.headers[key]);
});

// 3. 发送数据
xhr.send(['POST', 'PUT'].indexOf(method) > -1 ? JSON.stringify(options.data) : null);

// 4. 接收数据
xhr.onreadystatechange = function () { // 处理响应
if (xhr.readyState === 4) {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
options.success(xhr.responseText);
} else {
options.error(xhr.status, xhr.statusText);
}
}
};

// 超时处理
xhr.ontimeout = function () { options.error(0, 'timeout') };
// 错误处理
xhr.onerror = function () { options.error(0, 'error') };
// xhr.abort(); // 取消请求
}

对于 ajax 发起异步请求,若在发送过程中 刷新或关闭 浏览器,请求会被自动终止,如下图:


image.png



如果想在控制台查看刷新前页面接口调用情况,可勾选 Preserve log 选项,Network 会保留上个页面的请求记录。



可见,异步方式的 ajax 请求被浏览器自动 cancel 取消,无法将数据正常推送到后台。


一种处理方式是改为 同步 ajax 请求方式,在调用 open 建立连接时,第三参数 async 传递 false 表示以 同步方式 发送请求:


xhr.open(method, options.url, false);

但目前,谷歌浏览器已经不允许在页面关闭期间发起 同步 XHR 请求,建议使用 sendBeacon 或者 fetch keep-alive。我们接着往下看。


二、sendBeacon


navigator.sendBeacon()  方法可用于通过 HTTP POST 将少量数据 异步 传输到 Web 服务器。



官方链接:developer.mozilla.org/zh-CN/docs/…



它的语法如下:


navigator.sendBeacon(url);
navigator.sendBeacon(url, data);


  • url: 指定将要被发送到的网络地址;

  • data: 可选,是将要发送的 ArrayBufferArrayBufferViewBlobDOMStringFormData 或 URLSearchParams 类型的数据。

  • return: 返回值。当用户代理成功把数据加入传输队列时,sendBeacon()  方法将会返回 true,否则返回 false


navigator.sendBeacon 使用示例如下:


// 通过 Blob 方式传递 JSON 数据
const blob = new Blob(
[JSON.stringify({ ... })],
{ type: 'application/json; charset=UTF-8' }
);
// 发送请求
navigator.sendBeacon(url, blob);

sendBeacon 发送请求有以下几个特点:



  1. 通过 HTTP POST 请求方式 异步 发送数据,同时不会延迟页面的卸载或影响下一导航的载入性能;

  2. 支持跨域,但不支持自定义 headers 请求头,这意味着:如果用户信息 Access-Token 是作为请求头信息传递,需要后台接口支持 url querystring 参数传递解析。

  3. 考虑其兼容性。


三、fetch keep-alive


当使用 fetch() 请求时,如果把 RequestInit.keeplive 设置为 true,即便页面被终止请求也会保持连接。


fetch(url, {
method: 'POST',
body: JSON.stringify({ ... }),
headers: {
'Content-Type': 'application/json', // 指定 type
},
keepalive: true,
});

推荐使用 Fetch API 实现「离开网页时,将数据保存到我们的服务器上」。


但它也有一些限制需要注意:



  1. 传输数据大小限制:无法发送兆字节的数数据,我们可以并行执行多个 keepalive 请求,但它们的 body 长度之和不得超过 64KB

  2. 无法处理服务器响应:在网页文档卸载后,尽管设置 keepalive 的 fetch 请求可以成功,但后续的响应处理无法工作。所以在大多数情况下,例如发送统计信息,这不是问题,因为服务器只接收数据,并通常向此类请求发送空的响应。


思考


在框架的生命周期,如 React useEffect 可以实现页面关闭时发送 HTTP 请求记录数据吗?


答案是:不可以


尽管,我们所理解的 useEffect 中的销毁函数会在页面销毁时触发,但有一个前提条件是:程序保活正常运行,即 ReactDOM.render 创建的 FiberRoot 正常运转


试想,浏览器页面进行刷新或关闭,React 所启动的应用会直接中断停止,程序中页面定义的 useEffect 将不会被执行。


参考


Navigator sendBeacon页面关闭也能发送请求方法.

fetch keep

laive.

收起阅读 »

看了我项目中的商品功能设计,同事也开始悄悄模仿了...

商品功能作为电商系统的核心功能,它的设计可谓是非常重要的。就算不是电商系统中,只要是涉及到需要交易物品的项目,商品功能都具有很好的参考价值。今天就以mall项目中的商品功能为例,来聊聊商品功能的设计与实现。 mall项目简介 这里还是简单介绍下mall项目吧...
继续阅读 »

商品功能作为电商系统的核心功能,它的设计可谓是非常重要的。就算不是电商系统中,只要是涉及到需要交易物品的项目,商品功能都具有很好的参考价值。今天就以mall项目中的商品功能为例,来聊聊商品功能的设计与实现。



mall项目简介


这里还是简单介绍下mall项目吧,mall项目是一套基于 SpringBoot + Vue + uni-app 的电商系统,目前在Github已有60K的Star,包括前台商城项目和后台管理系统,能支持完整的订单流程!涵盖商品、订单、购物车、权限、优惠券、会员等功能,功能很强大!



功能设计



首先我们来看下mall项目中商品功能的设计,主要包括商品管理、添加\编辑商品、商品分类、商品类型、品牌管理等功能,这里的功能同时涉及前台商城和后台管理系统。



商品管理


在mall项目的后台管理系统中,后台管理员可以对商品进行管理,比如添加、编辑、删除、上架等操作。



当商品上架完成后,前台会员在mall项目的前台商城的商品列表中就可以看到对应商品了。



添加/编辑商品


后台管理员在添加/编辑商品时,需要填写商品信息、商品促销、商品属性以及选择商品关联。



之后前台会员在前台商城的商品详情页中就可以查看到对应的商品信息了。



商品分类


后台管理员也可以对商品的分类进行添加、编辑、删除、查询等操作。



这样前台会员在前台商城中就可以按商品分类来筛选查看商品了。



商品类型


后台管理员可以对商品的类型属性进行设置,设置好之后在编辑商品时就可以进行商品属性、参数的设置了。



此时前台会员就可以在前台商城中选择对应属性的商品进行购买了。



品牌管理


后台管理员可以对商品的品牌进行添加、编辑、删除、查询等操作。



此时前台会员就可以在前台商城的品牌详情页中查看到品牌信息以及相关的商品了。



功能整理


对于商品模块的功能,我这里整理了一张思维导图方便大家查看,主要是整理了下有哪些功能以及功能需要涉及哪些字段。



数据库设计


根据我们的功能设计和整理好的思维导图,就可以进行数据库设计了,这里是mall项目商品模块的功能设计图。



接口设计


对于mall项目中商品模块的接口设计,大家可以参考项目的Swagger接口文档,以Pms开头的接口就是商品模块对应的接口。



总结


商品模块作为电商系统的核心功能,涉及到商品SKU和SPU的概念,是一个非常好的参考案例。如果你能掌握商品模块的设计,对于开发一些需要交易的系统来说,会有非常大的帮助!


项目源码地址


github.com/macroz

heng/…

收起阅读 »

微前端是怎样炼成的,从思想到实现

web
1 道 “微前端”的概念最早由 Thoughtworks 在2016年提出。 微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将单页面前端应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用还可以独立开发、独立部署。 ——...
继续阅读 »

1 道


“微前端”的概念最早由 Thoughtworks 在2016年提出。



微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将单页面前端应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用还可以独立开发、独立部署。
—— 黄峰达《前端架构——从入门到微前端》



1.1 独立


独立开发、独立部署、独立运行,是微前端应用组织的关键词。独立带来了很多有价值的特性:



  • 不同微应用可以使用不同的技术栈,从而兼容老应用,微应用也可以独立选型、渐进升级;

  • 微应用有单独的 git 仓库,方便管理;

  • 微应用隔离,单独上线,回归测试无需测试整个系统;

  • 拆分应用,加速加载;


为了实现可靠且灵活的独立,微前端必须面对几个核心问题:



  • 微应用间如何调度、解析、加载?

  • 如何避免运行时互相污染?

  • 微应用间如何进行通信?



1.2 大道至简——微前端的理论基础


微前端能成的理论基础是,底层API的唯一性。


首先无论各家前端框架多么天花乱坠,最后都离不开一个操作 ——「通过 js 在一个DOM容器中插入或更新节点树」。所以你在各家的demo也都看得到这样的 api 描述:


ReactDOM.render(<App />, document.getElementById('root'));	// react
createApp(...).mount('#app'); // vue

所以只要提供容器,就能让任何前端框架正常渲染。再上一层,任何 JS API,都离不开在全局对象 window 上的调用,包括 DOM 操作、事件绑定、页面路由、前端存储等等。所以只要封住 window,就可以隔离微应用的运行时。


1.3 主流微前端方案套娃


微前端是一个概念,历史上各种实现方案层出不穷,到今天阿里 qiankun 的方案成为国内主流。


qiankun 底层基于 single-spa,而业务系统也倾向于再在外面包一层,三层方案各自专注解决不同的问题:




  • single-spa 的官方定位是「一个顶层路由,当路由处于活动状态时,它将下载并执行该路由的相关代码」。放到微前端概念中,它专注解决微应用基于路由的调度。

  • qiankun 是一个「微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统」。在 single-spa 的基础上:所谓「简单」,是降低了接入门槛,增强了资源接入方式,支持 HTML Entry;所谓「无痛」,是尽量降低了微前端带来的副作用,即提供了样式和JS的隔离,并通过资源缓存加速微应用切换性能。

  • 到业务系统这一层,着重解决业务生产环境中的问题。最常见的像提供一个MIS管理后台,灵活配置,动态下发微应用信息,实现动态应用插拔。


2 single-spa


single-spa 做的事很聚焦,核心流程是:1、注册路由对应资源 —> 2、监听路由 —> 3、加载对应资源 —> 4、执行资源提供的状态回调。


2.1 api 概览


为了实现这套流程,single-spa 首先提供了「1、注册路由对应资源」的接口:


singleSpa.registerApplication({ name, appLoader, activeWhen });

然后启动「2、监听路由 —> 3、加载对应资源」机制:


singleSpa.start();

对资源则有「提供状态回调」的改造要求:


// 资源代码
export function bootstrap(props) {}
export function mount(props) {}
export function unmount(props) {}
export function unload(props) {} // 可选

2.2 整体实现原理


很显然,这里面有一套应用的状态机制,以及对应的状态流转流程,在 single-spa 内部是这样的:




  • app 池收集注册进来的微应用信息,包括应用资源、对应路由。app 池中的所有微应用,都会维护一个自身的状态机。

  • 刷新器是整个 single-spa 的发动机,负责流转整个状态流程。一旦刷新器被触发(首次启动或路由更新),就开始调度:

    • 拿着最新路由去池子里分拣 app

    • 根据分拣结果,执行 app 资源暴露的生命周期方法




2.3 app 池的实现


app 池的实现都在 src/applications/apps.js 模块中,首先是一个全局池:


const apps = [];

然后直接实现并导出 registerApplication 方法作为向 app 池添加成员的入口:


export function registerApplication( appNameOrConfig, appOrLoadApp, activeWhen, customProps) {
const registration = sanitizeArguments( appNameOrConfig, appOrLoadApp, activeWhen, customProps);
apps.push(
assign(
{ status: NOT_LOADED },
registration
)
);
if (isInBrowser) {
reroute();
}
}

registerApplication 做了几件事:



  1. 构造 app 对象,整理入参,这个和 single-spa 入参兼容有关系。最终 app 对象将包含app 信息、状态、资源、激活条件等信息。

  2. 加入 app 池。这里可以看到初始状态是 NOT_LOADED

  3. 触发了一次 reroute。


2.4 reroute 触发


前面 registerApplication 调了一次 reroute 方法,这就是执行一次刷新。reroute 会在下列场景执行:



  • registerApplication:微应用注册

  • start:框架启动

  • 路由事件(popstate、hashchange)触发


window.addEventListener("hashchange", urlReroute);
window.addEventListener("popstate", urlReroute);
function urlReroute() {
reroute([], arguments);
}

2.5 reroute 分拣执行


reroute 先判断 app 是否应该激活,逻辑很简单,就是把当前路由带到 app.activeWhen 里计算返回(app.activeWhen(window.location)),这里我们只看当前应该处于什么状态。而且按我们通常用法,只有少数 app 会激活。



接下来看微前端应用的激活过程,是先 load 下载应用资源,再 mount 挂载启动应用。



这样结合「app 是否应该激活」X「app 当前状态」,可以得到「应该对 app 做什么操作」。



  1. 「激活」X「not loaded」:应该去加载微应用资源

  2. 「激活」X「not mounted」:应该去挂载启动微应用

  3. 「激活」X「mounted」:什么都不用动

  4. 「不激活」X「not loaded」:什么都不用动

  5. 「不激活」X「not mounted」:应该去卸掉微应用资源

  6. 「不激活」X「mounted」:应该卸载微应用


这里只有1、2、5、6需要操作,也对应了上图中的四个箭头。于是 app 被进一步分拣为四个组:



代码如下:


switch (app.status) {
case NOT_LOADED:
case LOADING_SOURCE_CODE:
if (appShouldBeActive) {
appsToLoad.push(app);
}
break;
case NOT_BOOTSTRAPPED:
case NOT_MOUNTED:
if (!appShouldBeActive && getAppUnloadInfo(toName(app))) {
appsToUnload.push(app);
} else if (appShouldBeActive) {
appsToMount.push(app);
}
break;
case MOUNTED:
if (!appShouldBeActive) {
appsToUnmount.push(app);
}
break;
// all other statuses are ignored
}

拿到四个组后,需要转为具体操作,于是 reroute 中有这种 map:const unloadPromises = appsToUnload.map(toUnloadPromise);,把 app 转换为操作的 Promise。


需要注意的是,load 后app处于中间状态,并未完成激活,还差一步,反之亦然。所以只到中间态的两个组 appsToUnmount、appsToLoad,还需要继续往前走一步。


const unmountUnloadPromises = appsToUnmount
.map(toUnmountPromise)
.map((unmountPromise) => unmountPromise.then(toUnloadPromise));

至于这些Promise是干嘛的也很容易猜到,无非是执行资源暴露的生命周期回调 + 修改应用状态。toXXXPromise 方法都定义在 src/lifecycles 下,可以找到对应生命周期。


至此 reroute 从分拣到执行生命周期的过程完成,完整图如下:



2.6 小结



  • single-spa 主要实现了微前端微应用调度部分,包含一个 app 池及路由变化时刷新回调 app 生命周期函数的机制

  • app 池维护了 app 的信息、资源和状态,暴露添加方法给 registerApplication api

  • 刷新的过程:确定app是否active —> 结合状态判断要做的操作 —> 调用生命周期回调,改状态


3 qiankun


在 single-app 微应用调度的基础上,qiankun 要带来的是「更简单、无痛的」生产应用。这些特性包括:



  • 💪 HTML Entry 接入方式,让你接入微应用像使用 iframe 一样简单。

  • 🛡 样式隔离,确保微应用之间样式互相不干扰。

  • 🧳 JS 沙箱,确保微应用之间 全局变量/事件 不冲突。

  • ⚡️ 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。


我们看它的实现思路。


3.1 qiankun 加载应用的过程


qiankun 的特性和它的应用加载方式密不可分,我们先从一个叫 loadApp 的方法入手,看看子应用加载的全过程。


1、入口解析


qiankun 从入参中拿到子应用的 name 和 entry,过一个import-html-entry库,这个库也是 qiankun 自己的,有俩主要用途:从 html 解析静态资源(HTML Entry 的基础),并使其在特定上下文下运行(JS 隔离的基础)。


解析后可以得到子应用对应的可执行 JS(execScripts 方法)、静态资源(assetPublicPath)、html 模版(template)。


2、创建应用容器


随后 qiankun 需要构造一个给子应用的容器(createElement),这个容器是一个子应用独有的 div,标记了从子应用信息挖出来的 id、name、version、config 等信息。容器的形态取决于几个因素:



  • 子应用是否有 html 模版,有的话需要装进去才能让子应用找到渲染 DOM

  • 子应用是否需要样式隔离,有的话可能要加一层 shadow DOM


然后要确保在子应用挂载前,这个容器被渲染并挂到页面上。


3、沙箱构造


接着 qiankun 会构造一个沙箱(createSandboxContainer),然后依赖 execScripts 方法把 window 代理到沙箱上,并在恰当的时候开关拦截。


4、构造传给下游 single-app 的生命周期


这里先从子应用脚本中解析出生命周期(bootstrap, mount, unmount, update),然后补充一些逻辑:



  • mount 时,补充容器获取和绑定、容器挂载、沙箱开启

  • unmount 时,补充沙箱关闭、容器卸载


3.2 HTML Entry 接入


html 解析能力来自import-html-entry库。


它加载完 html 资源,就按 string 继续解析(processTpl),主要方法是通过正则匹配出里面的字符串,比如异步 script:


// 异步 script
if (matchedScriptSrc) {
var asyncScript = !!scriptTag.match(SCRIPT_ASYNC_REGEX);
scripts.push(asyncScript ? {
async: true,
src: matchedScriptSrc
} : matchedScriptSrc);
return genScriptReplaceSymbol(matchedScriptSrc, asyncScript);
}

然后把这些资源打包返回。


3.3 样式隔离


样式隔离是避免子应用之间、子应用-父应用之间出现 class 名的相互污染。


处理样式隔离一般只有两个方法:一是为所有 class name 增加唯一的 scope 标记;二是利用 shadow dom 的天然隔离。


自己加 scope


参考 qiankun 文档:常见问题 - qiankun,可以通过干预编译、利用 antd 等框架的能力来做。


scope 的qiankun实现


如果懒得自己 scope,可以通过 qiankun 配置直接生成 scope:


sandbox: { experimentalStyleIsolation: true }

这个参数会给所有的 class name 外层增加一个子应用独有的标识:


div[data-qiankun-react16] .app-main {
font-size: 14px;
}

通过遍历所有 style 节点,增加前缀:


const styleNodes = appElement.querySelectorAll('style') || [];
forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
css.process(appElement!, stylesheetElement, appInstanceId);
});
// css.process
const prefix = `${tag}[${QiankunCSSRewriteAttr}="${appName}"]`;
processor.process(stylesheetElement, prefix);

shadow dom 的乾坤实现


qiankun 通过配置也可以实现 shadow dom 隔离:


sandbox: { strictStyleIsolation: true }

其实是在容器和内容间增加了一层 shadow:


// createElement
const { innerHTML } = appElement;
appElement.innerHTML = '';
let shadow: ShadowRoot;
if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: 'open' });
} else {
shadow = (appElement as any).createShadowRoot();
}
shadow.innerHTML = innerHTML;

当然后面获取容器的时候也会兼容这点:


// getAppWrapperGetter
if (strictStyleIsolation && supportShadowDOM) {
return element!.shadowRoot!;
}
return element!;

3.4 JS 沙箱


子应用加载过程中,qiankun 构造 JS 沙箱:


// loadApp
if (sandbox) {
sandboxContainer = createSandboxContainer( appName, /* 其他参数 */ );
global = sandboxContainer.instance.proxy as typeof window;
mountSandbox = sandboxContainer.mount;
unmountSandbox = sandboxContainer.unmount;
}

沙箱创建后,会去包裹子应用脚本的执行上下文。沙箱实例被传到 import-html-entry包里,最终用在对 script 标签的包装执行上:


const code = `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`
eval(code);

这样子应用的所有「模块和全局变量」声明,就都挂到了代理上。脚本导出的生命周期,也都执行在代理上。


JS 沙箱的目的是,任何一个微应用,在活跃期间能正常使用和修改 window,但卸载后能把「初始」window 还回去供其他微应用正常使用,且当微应用再次活跃时,能找回之前修改过的 window。这就必然需要一个和 app 一一对应的代理对象来管理和记录「app 对 window 的修改」,并提供重置和恢复 window 的能力。



qiankun 为我们准备了三套沙箱方案:



  • ProxySandbox:代理沙箱,在支持 Proxy 时使用

  • LegacySandbox:继承沙箱,在支持 Proxy 且用户 useLooseSandbox 时使用

  • SnapshotSandbox:快照沙箱,在不支持 Proxy 时使用


ProxySandbox


当我们有 Proxy 时,这件事很好办。我们可以让 window 处于「只读模式」,所有对 window 的修改,都将属性挂到代理对象上,使用时先找代理对象,再找真 window。



class ProxySandbox {
proxyWindow
isRunning = false
active() {
this.isRunning = true
}
inactive() {
this.isRunning = false
}
constructor() {
const fakeWindow = Object.create(null)
this.proxyWindow = new Proxy(fakeWindow, {
set: (target, prop, value, receiver) => {
if(this.isRunning) target[prop] = value
},
get: (target, prop, receiver) => {
return prop in target ?target[prop]:window[prop];
}
})
}
}

ProxySandbox 的好处是实现简单,在设计上非常严谨,完全不会影响原生 window,所以卸载时也不需要做任何处理。


LegacySandbox


Proxy 的另一种用法是,放 app 去修改 window,只做被修改属性的「键-初始值」、「键-修改值」记录,在卸载后把初始值挨个重置,在再次挂载后把修改值挨个恢复。



LegacySandbox 保证了真实 window 的属性和当前 app 用到的 window 属性完全一致。如果你需要全局监控当前应用的真实环境,这点就很重要。


SnapshotSandbox


如果环境不支持 Proxy,就没法在「活跃时」做监听,只能尝试在挂载卸载的时候想办法。



  • 对 window 来说,我们只要在挂载时备份一份「快照」存起来,卸载时再把快照覆盖回去。

  • 反过来对 app 环境来说,我们需要在卸载时 diff 出一份「被修改过」的快照,挂载时把快照覆盖回去。



拦截其他副作用


三种沙箱都实现了对 window 属性增删改查的拦截和记录,但子应用还可能对 window 做其他有副作用的操作,比如:定时器、事件监听、DOM节点API操作。


这就是沙箱实例暴露 mount、unmount 方法的原因。当子应用 mount 时,实现对其他副作用的拦截和记录,unmount 时再清除掉。这些副作用包括:


patchInterval									劫持定时器
patchWindowListener 劫持window事件监听
patchHistoryListener 劫持history事件监听(umi专用)
patchDocumentCreateElement 劫持DOM节点创建
patchHTMLDynamicAppendPrototypeFunctions 劫持DOM节点添加方法

副作用的拦截方法都采用同样的接口实现:


function patchXXX(global) {
// 给 mount 调用,在 global 上拦截方法
return function free() {
// 给 unmount 调用,清除副作用
}
}

实现思路都是维护一个「池」,把 mount 后注册的定时器、事件、DOM等记录下来,在 unmount 时清除。比如定时器:


function patchInterval(global) {
// 给 mount 调用,在 global 上拦截方法
let intervals: number[] = [];
global.clearInterval = (intervalId: number) => {
intervals = intervals.filter((id) => id !== intervalId);
return rawWindowClearInterval.call(window, intervalId as any);
};
global.setInterval = (handler: CallableFunction, timeout?: number) => {
const intervalId = rawWindowInterval(handler, timeout);
intervals = [...intervals, intervalId];
return intervalId;
};
return function free() {
// 给 unmount 调用,清除副作用
intervals.forEach((id) => global.clearInterval(id));
}
}

3.4 预加载


qiankun 可以通过配置或手动调用发起 prefetch:


start({ prefetch: true });
// or
prefetchApps([...]);

发起预加载的时机无非两种:立即预加载(prefetchImmediately)、首个应用挂载后预加载其他应用(prefetchAfterFirstMounted)。但这只是时机差别,预加载的逻辑是一致的。


qiankun 说了,在浏览器空闲时预加载,那肯定要用 requestIdleCallback:


requestIdleCallback(async () => {
// 第一次空闲时解析入口资源
const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);
// 后面空闲时下载资源
requestIdleCallback(getExternalStyleSheets);
requestIdleCallback(getExternalScripts);
});

3.5 小结



  • 乾坤的特性离不开在子应用加载上下的功夫(loadApp),这个过程包含入口解析、容器创建、沙箱构造、生命周期补充,最后调用 single-app

  • HTML Entry 特性由 import-html-entry 库实现,通过对 html 字符串进行正则匹配,得到资源信息。

  • 样式隔离主要有 class name scope 和 shadow DOM 两种方式,qiankun 都做了支持。前者靠遍历 stylesheet 更改 class name,后者靠容器构建时增加 shadow DOM 层。

  • JS 沙箱是用一个代理对象拦截对 window 的操作。qiankun 提供了Snapshot、Proxy、Legacy三种沙箱,区别在于对属性增删改的拦截方式,效果是一样的。一些直接调用全局 api 的副作用(定时器、DOM操作、事件等)则需要额外拦截和恢复,通常靠维护一个「属于当前子应用的副作用池」。

  • 预加载用的是 requestIdleCallback。


4 业务系统


4.1 配置的数据模型


为了实现动态部署,业务平台要回答一个问题:每次启动时,这个微前端要注册哪些微应用?也就是「平台 - 系统 - 子应用 - 资源」之间关系的维护和下发。


好在这个关系并不复杂:



这是一套最简单的微前端管理模型,在此之上,可根据自己需求选择性加上用户、角色、权限、模版、菜单、导航等。


4.2 用户请求流程


当用户来访问业务平台上的系统时,基本会经过以下流程:




  1. 用户通过域名,经DNS解析,访问到平台的前端服务器。平台作为基建,会承载多个业务系统,每个业务系统又有各自的域名,这里会要求每个接入的业务域名都在DNS配置解析到平台统一的前端服务器IP。

  2. 匹配接入配置,锁定一个系统。一方面系统配置会作为“准入”的nginx配置挂在前端服务器上。另一方面,根据请求携带的域名等信息,可以匹配到具体请求来自哪个系统。

  3. 根据系统获取系统配置。这些配置包含整个系统ID关联的子应用、资源、权限、导航等等配置,通过接口可以一次性返回给客户端,也可以先返回系统ID,客户端再按需请求。客户端的微前端框架现在知道要注册哪些应用了。

  4. 客户端加载静态资源。在配置中会关联应用框架和子应用用到的所有静态资源的CDN地址,按微前端的逻辑,这些资源会在微应用 load 的时候异步加载。


Z 总结



  • 微前端将单体应用拆分为若干个微应用,它们独立开发、独立部署、独立运行。其理论基础是不同框架下相同的底层API。目前主流是在 single-spa、qiankun 的技术方案基础上,做业务系统的封装。

  • single-spa 根据路由变化调度微应用的资源加载和运行,并定义了一套微应用生命周期。实现上依赖内部的一套“应用池”+“刷新器”+路由监听。

  • qiankun 在 single-spa 基础上增加了 js 和 css 隔离、html entry、预加载等开箱即用的工程友好特性。其基础是自己实现了import-html-entry库来控制资源加载和运行时,实现一层容器以隔离样式,并借助 Proxy 等api 实现沙箱来劫持 window 操作。

  • 在业务系统实际应用中,还要在 qiankun 基础上构建数据模型和服务,实现「平台-系统-微应用-资源」各级配置的下发来启动和动态注册微
    作者:几木_Henry
    来源:juejin.cn/post/7262158134322806844
    前端。

收起阅读 »

一文揭秘饿了么跨端技术的演进、实践与落地

web
导读:本文会先带领大家一起简单回顾下跨端技术背景与演进历程与在这一波儿接着一波儿的跨端浪潮中的饿了么跨端现状,以及在这个背景下,相较于业界基于 React/Vue 研发习惯出发的各种跨端方案,饿了么为什么会选择走另外一条路,这个过程中我们的一些思考、遇到及解决...
继续阅读 »

导读:本文会先带领大家一起简单回顾下跨端技术背景与演进历程与在这一波儿接着一波儿的跨端浪潮中的饿了么跨端现状,以及在这个背景下,相较于业界基于 React/Vue 研发习惯出发的各种跨端方案,饿了么为什么会选择走另外一条路,这个过程中我们的一些思考、遇到及解决的问题和取得的一些成果,希望能给大家带来一些跨端方面的新思路。



跨端技术背景与演进历程


跨端,究竟跨的是哪些端?


自 90 年的万维网出现,而后的三十多年,我们依次经历了 PC 时代、移动时代,以及现在的万物互联(的 IoT )时代,繁荣的背后,是越来越多的设备、越来越多的系统以及各种各样的解决方案。


总的来说,按照跨端的场景来划分,主要包含以下 4 类:




  • 跨设备平台,如 PC(电脑)/ Mobile(手机)/ OTT(机顶盒)/ IoT(物联网设备)。不同的设备平台往往意味着不同的硬件能力、传感器、屏幕尺寸与交互方式

  • 跨操作系统,如 Android/iOS/HarmonyOS。不同的操作系统为应用开发通常提供了不同的编程语言、应用框架和 API

  • 跨移动应用,如 微信/支付宝/手淘/抖音/快手等。由于移动平台 CS 架构 及 App 间天然的壁垒,不同 App 间相互隔离,并各自在其封闭体系内遵循一套自有标准进行各类资源的索引、定位及渲染。而同一业务投放至不同 App 端时,就需要分别适配这些不同的规则。

  • 跨渲染容器,如 Webview/React Native/Flutter。前面三类场景催生了针对不同设备平台、不同操作系统、不同 App 间解决方案,因而移动领域的各种 Native 化渲染、自绘渲染与魔改 Webview 的方案也由此被设计出来,在尝试解决跨端问题的同时,也一定程度上提高了跨端的迁移门槛和方案选择难度。


而在当下,移动领域依然是绝对的主角,我们来看一下移动端的跨端技术都经历了哪些阶段。


移动跨端技术演进


随着移动互联网的蓬勃发展,端形态变的多样,除了传统的 Native、H5 之外,以动态化与小程序为代表的新兴模式百花齐放大行其道,世面上的框架/容器/工具也层出不穷,整个业态朝着碎片化方向发展。


对开发者来说,碎片化的直接影响,是带来了包括但不限于,刚才提到的设备平台、操作系统、渲染容器、语法标准等方面的各种不确定性,增加了大量的学习、开发与维护成本。


于是,应运而生的各类跨端技术,核心在于从不确定性中找寻确定性,以保障研发体验与产物一致性为前提,为各端适配到最优解,用最少成本达到最好效果,真正做到 "一次编写,到处运行"。


移动跨端大致经历了如下几个阶段:





  • H5 Wap 阶段:Web 天然跨平台,响应式布局是当时的一个主要手段,但由于早期网络环境原因,页面加载速度无法满足业务预期,加之设备传感器标准缺失、内存占用大、GPU 利用率低等问题,在移动设备量爆发伊始,难堪大任的论调一下子被推上风口浪尖,并在 12 年达到顶峰。




  • Hybrid 阶段:典型代表是 Cordova/ionic。功能上看,Hybrid 解决了历史两大痛点:



    • 1)性能,依靠容器能力,各类离线化、预装包、Prefetch 方案大幅减少加载耗时,配合编码优化在 3/4G 时代使 H5 的体验上了一个台阶;

    • 2)功能,通过 JSBridge 方式规避了与 Native 原生割裂带来的底层能力缺失。




  • 框架+原生阶段:典型代表是 ReactNative/Weex。基于 JSC 或类似的引擎,在语法层与 React/Vue 结合,渲染层使用原生组件绘制,尝试在研发效率与性能体验间寻找更佳的平衡点,各类领域解决方案(受限 DSL + 魔改 web 标准 + Native 渲染能力)开始涌现,拉开了大前端融合渲染方案的序幕。




  • 自绘渲染阶段:典型代表是 Flutter/Qt。这里的 “自绘” 更强调不使用系统原生控件或 Webview 的渲染管线,而是依赖 Skia、Cairo 等跨平台图形库,自底向上自建渲染引擎、研发框架及基础配套的方式,其跨 Android/iOS 的特性迅速点燃了客户端研发领域。




  • 小程序阶段:典型代表是 微信/支付宝小程序。小程序是被创造出来的,其本质是各 APP 厂商出于商业考量构造了相对封闭的生态,在标准与能力上无论与 Web 还是厂商之间均存在差异,能力上是自定义 DSL & API + Hybrid + 同层渲染 + 商业管控的综合体。市面跨端方案策略均是锚定一种研发规约进行各形态编译时与运行时的差异抹平与适配。




回顾了以上跨端技术背景与演进历程,在这股浪潮里面,饿了么的跨端投放情况是什么样的?投了那些端?遇到了哪些问题?又是如何解决的?


饿了么跨端投放诉求、现状与策略



众所周知,饿了么是围绕 O2O 为用户提供线上到线下服务的公司,通过对时、空、人、货 的有机结合,来链接商家与消费者,相比于传统电商,时空人货本身具有区域属性,这意味着我们做的不是一个大卖场生意,更多的是需要围绕区域特性提供精细化的服务,这里面有一系列时空、体验、规模、成本的约束需要考虑与应对


而在这一系列约束背后,其实有一个各方共通的经营诉求:



  • 对于商家来说:为了有更好的经营需要有更多曝光,与客户有更多的触达,以便带来成交

  • 对于平台来说:为了能够让更多消费者享受我们的服务,除了深耕自己的超级APP(饿了么APP)外,还需要在人流量大的地方加大曝光、声量与服务能力来扩大我们的规模


这都导向一个目的:哪里流量多,我们就需要在哪里提供与消费者的连接能力


那么问题来了,流量在哪里?现在的互联网,更多都是在做用户的时间与精力生意,背后拆解下来,其实有几个关键因素可以衡量:用户密度、用户活跃度、市场占有率与用户时间分配,细化来看,其中任意几个条件满足,都可以作为我们流量阵地的候选集。


饿了么经过多年耕耘,对外部关键渠道做了大量布局,业务阵地众多,从效果上看,渠道业务无论是用户流量规模还是订单规模均对大盘贡献度较高,且随着业务的持续精进与外部合作环境的持续改善,增量渠道也在不断的涌现中。



在这么多的业务阵地中,投放在各个端的应用的形态基于:



  • 渠道的运行环境

  • 渠道的流量特性

  • 渠道的业务定位

  • 渠道的管控规则


等的差异和限制,目前形成了 以小程序为主,H5为辅 的承接方式,而这些差异带来了大量的不确定性,主要体现在:



  • 渠道环境的高度不确定性:对接了这么多渠道,每个端的运行环境存在巨大差异,拿小程序能力举例,即使是个别 APP 的小程序方案借鉴了微信的思路,由于其内部商业能力、产品设计思路、能力成熟度与完整度、研发配套(语法、框架、工具等)的不一致也会使研发体感有明显的不同,这对技术同学来说,带来的是渠道环境的高度不确定性;

  • 业务诉求的高度不确定性:同时,我们所投放的 APP 都可划分到某些细分领域,用户特性与用户在该平台上的诉求不一,渠道定位也不一致,随着每个业务域的功能演进越来越多,多个渠道功能是否对齐、要不要对齐、有没有对齐、什么时候对齐成了一个非常现实和麻烦的事情,同时业务域之间可能还存在功能上的关联,这进一步提高了其复杂度,在没有一个好的机制与能力保障下,业务、产品、研发对每个渠道的同步策略、能力范围的感知会有较大偏差,甚至于一个需求的迭代,每个端什么时候能同步都变成了一个无法预期的事情,这对于业、产、研来说,带来的是业务诉求上的高度不确定性。


而我们要做的,就是在这两种不确定性中,找到技术能带来的确定性的事情。如何系统性的解决这些问题,则成为我们在保障渠道业务灵活性的基础上持续提升研发效率和体验的关键。


在差异应对上,业务研发最理想的方式是对底层的变化与不一致无感,安心应对业务诉求,基于这个点出发,我们的主要策略是:围绕 “研发体验一致性提升与复杂应用协作机制改进”来保障业务高效迭代。这需要一套强有力的、贴合业务特性的基础设施来支撑。首先想到的便是如何通过“推动框架统一”和“实现一码多端”,来为业务研发降本增效,然而理想很丰满,现实很骨感:



框架的升级通常情况下,大概率会带来业务重构,综合评估之后,作为外部渠道流量大头的小程序业务,则成为了我们优先要保障的业务,也基于此,为了尽可能降低对业务的影响和接入成本,我们明确了以小程序为第一视角来实现多端。


基于小程序跨端的行业现状和思考


在明确了方向之后,那么问题来了:业界有没有适合我们的开源的框架或解决方案呢?


业界有哪些面向于小程序的研发框架?



市面上,从小程序视角出发,具备类似能力的优秀多端框架有很多,有代表性的如 Taro、uni-app、Rax 等,大多以 React 或者 Vue 作为 DSL


那么这些框架能否解决我们所面临的问题?答案是:并不能。


为什么饿了么选择以小程序 DSL 为基础实现跨端?



综合 饿了么 的渠道业务背景需要考虑以下几点:



  • 改造成本:以支付宝、微信、淘宝为代表的饿了么小程序运营多年,大部分存量业务是以支付宝或微信小程序 DSL 来编写,需关注已有业务逻辑(或组件库)的改造成本,而采纳业界框架基本上会直接引发业务的大量重构,这个改造成本是难以接受的。

  • 性能体验:渠道业务是饿了么非常重要的流量阵地,重视程度与APP无差,在体验和性能上有极致的要求,所以我们期望在推动跨端的同时,尽可能减少运行时引入带来的性能损耗。

  • 业务协同:由于每个渠道都基本相当于一个小型的饿了么APP,复杂度高,涉及到多业务域的协同,包括主线步调一致性考量、多业务线/应用类型集成、全链路功能无缝衔接等,在此之外还需给各业务线最大限度的自控与闭环能力,背后需要的是大型小程序业务的一体化研发支撑。


在做了较多的横向对比与权衡之后,上面的这些框架对于我们而言采纳成本过高,所以我们选择了另外一条相对艰辛但更为契合饿了么多端演进方向的路:以小程序原生 DSL 为基础建设跨端解决方案,最大限度保障各端产物代码贴合小程序原生语法,以此尽可能降低因同构带来的体验损耗和业务多端接入成本。


基于小程序 DSL 的跨端解决方案


确定以小程序 DSL 作为方向建设跨端解决方案之后,首先要解决的就是如果将已有的小程序快速适配到多端。这就需要对各个端的差异做细致的分析并给出解决方案。



如何解决小程序多端编译?


为了能够兼顾性能和研发体验,我们选择了 编译时(重)+运行时(轻) 的解决方案。


静态编译解决了那些问题?



静态编译转换主要用于处理 JSWXS/SJSWXML/AXMLWXSS/ACSSJSON 等源码中约束强且不能动态修改的部分,如:



  • 模块引用:JS/WXS/SJS/WXML/AXML/WXSS/ACSS/JSON 等源码中的模块引用替换和后缀名修改;

  • 模版属性映射或语法兼容: AXML/WXML 中如 a:if → wx:if、 onTap → bind:tap{{`${name}Props`}} →  {{name + 'Props'}} 等;

  • 配置映射:如页面 { "titleBarColor": "#000000" } → { "navigationBarBackgroundColor: "#000000", "navigationBarTextStyle": "white" }


等,原理是通过将源码文件转换为 AST(抽象语法树),并通过操作 AST 的方式来实现将源码转换为目标平台的代码。


但静态编译只能解决一部分的差异,还有一些差异需要通过运行时来抹平。


运行时补偿解决了那些问题?



运行时补偿主要用于处理静态编译无法处理或者处理成本较高的一些运行时动态内容,如:



  • JSAPI:实际业务使用上,不管是 JSAPI 的名字还是 JSAPI 的入参都会存在动态赋值的情况,导致了在 JSAPI 的真实调用上,很难通过 AST 去解析出实际传参;

  • 自定义组件 - Props 属性:比如,支付宝属性使用 props 声明,而微信属性使用 properties 声明,配置方式不同且使用时分别使用 this.props.x 及 this.properties.x 的方式获取,同时可能存在动态取值的情况;

  • 自定义组件 - 生命周期:支付宝小程序中的 didUpdate 生命周期,在触发了 propsdata 更新后都会进入 didUpdate 这个生命周期,且能够在 didUpdate 中访问到prevProps / prevData,而在微信小程序中静态转义出这个生命周期就意味着你需要去动态分析出didUpdate里面要用到的所有属性,然后去动态生成出这些属性的监听函数。这显然可靠程度是极其低的;


等等,类似的场景有很多,这里不再一一列举。


通过静态编译 + 运行时补偿的方式,我们便可以让现有的微信或支付宝小程序快速的迁移到其他小程序平台。


如何解决小程序转 Web?


伴随外卖小程序上线多年之后,各个大的渠道(支付宝、手淘、微信等)已切流为小程序承载,但是还有很多细分渠道或非小程序环境渠道,比如:各个银行金融渠道,客户端的极小包等,还需要依赖 H5 的形态快速投放,但当前饿了么的业务越来越复杂,相关渠道的投入资源有限,历史包袱重、迭代成本大等原因,产品功能和服务能力远远落后于小程序和饿了么App。而业务急需一个可以将小程序的功能快速复制到 h5 端的技术方案,以较低的研发和维护成本,满足业务多渠道能力对齐上线的诉求。


基于这个背景,我们自然而然的可以想到,即然小程序可以转其他小程序,那么是否也可以直接将小程序直接转换为 Web,从而最大程度上提升人效和功能对齐效率。


具体是怎么实现的?主要手段还是通过编译时 + 运行时的有机结合:


Web 转端编译原理



编译部分和小程序转小程序的主要区别和难点是:需要将 JSWXS/SJSWXML/AXML 等文件统一转换并合并为 JS 文件并将 WXML/AXML 文件转换为 JSX 语法,将样式文件统一转换为 CSS 文件,并将小程序的页面和组件都转换为 React 组件。


运行时原理



转 Web 的运行时相较于转换为其他小程序会重很多,为了兼顾性能和体验,运行时的核心在于提供与小程序对等的高效运行环境,这里面包含四个主要模块:



  • 框架:提供了小程序在 Web 中的基础运行时功能,比如:Page 、Component 、App 等全局函数,并且提供完整的生命周期实现,事件的注册、分发等

  • 组件:提供小程序公共组件的支持,比如 viewbuttonscroll-view 等小程序原生提供的组件

  • API:提供了类似小程序中 wx 或者 my 的 一系列 api 的实现

  • 路由:提供了页面路由支持和 getCurrentPages 等方法支持


基于这四个模块,配合编译时的自动注入和代码转换,以及路由映射等,我们就可以把一个小程序转换为一个 Web 的 SPA(单页) 或者 MPA(多页) 应用,也成功的解决了业务的研发效率问题,目前 饿了么的新 M 站就是基于这套方案在运行。


如何解决多端多形态问题?



解决了各端的编译转换问题,是不是就万事大吉,业务接下来只要按部就班的基于这套能力实现一码多端就可以了?


然而并不是,随着饿了么的业务场景和范围快速拓展,诞生了一些新的诉求,比如:



  • 支付宝的独立小程序作为分包接入微信小程序

  • 淘宝 / 支付宝的小程序插件作为分包接入某个现有的微信小程序

  • 支付宝的独立小程序作为插件接入淘宝小程序插件

  • 支付宝小程序插件作为分包接入微信或抖音小程序


等等,大家如果仔细观察这些诉求,就会发现一个共同的点:就是小程序的形态不一样。


虽然我们已经具备了多端的能力,但是形态的差异没有解决掉,而之前相关业务的做法是,尽可能将通用功能沉淀到组件库,并按照多端的方式分端输出产物,然而由于相同业务在不同小程序端形态差异性的问题,业务上难以自行规避,而重构的成本又比较高,所以有一部分业务选择了直接按照不同的端不同的形态(如微信、支付宝、淘宝、抖音)各自维护一套代码,但这样做不仅功能同步迭代周期被拉长,而且 BUG 较多,维护困难,研发过程也是异常痛苦。


小程序形态差异有哪些?


形态差异是指 小程序、小程序分包、小程序插件 三种不同形态的运行方式差异以及转换为其他形态之后产生的差异,具体如下:




  • getApp 差异



    • 小程序: 可通过 getApp() 来获取全局 App 实例及实例上挂载的属性或方法

    • 小程序插件: 无法调用 getApp()

    • 小程序分包: 可通过 getApp() 来获取全局 App 实例及实例上挂载的属性或方法;但当通过小程序转换为分包后,分包自身原本调用的 getApp 将失效,并被替换为宿主小程序的 getApp




  • App 应用生命周期 差异



    • 小程序: 应用会执行 onLaunch、onShow、onHide 等生命周期

    • 小程序插件: 无应用生命周期

    • 小程序分包: 无应用生命周期




  • 全局样式(如:app.wxss 或 app.acss)差异



    • 小程序: 可通过全局样式来声明全局样式

    • 小程序插件: 无全局样式

    • 小程序分包: 无全局样式




  • NPM 使用限制



    • 小程序: 各个小程序平台支持和限制情况不一

    • 小程序插件: 各个小程序平台支持和限制情况不一

    • 小程序分包: 各个小程序平台支持和限制情况不一




  • 接口调用限制





  • 路由差异



    • 小程序: 转换到其他形态后自身路由会发生变化

    • 小程序插件: 转换到其他形态后自身路由会发生变化,跳转插件页面需要包含 plugin:// 或 dynamic-plugin:// 等前缀,小程序或分包则不需要

    • 小程序分包: 转换到其他形态后自身路由会发生变化




  • getCurrentPages 差异



    • 小程序: 无限制

    • 小程序插件: 无法通过 getCurrentPages 获取到小程序的页面堆栈

    • 小程序分包: 无限制




  • 页面或组件样式差异



    • 小程序: 无限制

    • 小程序插件: 基本选择器只支持 ID 与 class 选择器,不支持标签、属性、通配符选择器

    • 小程序分包: 无限制




等等,相关形态差异可结合各个小程序平台查看,这里仅罗列常见的部分。


如何解决这些差异?


这里举几个例子:



通过在编译过程中,自动向产物中注入对 App 和 getApp 的运行时模拟实现,这样就可以解决分包和插件下方法缺失或者是冲突引起的报错问题。



方法也是类似,可以在编译的过程中检测全局样式是否存在,如果存在,则将对应的全局样式引用自动注入到每一个页面和组件中来解决全局样式失效的问题。



而针对各个小程序平台的 NPM 使用规则不同的问题,可以通过依赖解析、动态分组、组件提取打包、引用替换等方式,将 NPM 抽取到特定的地方,并将对应的组件和页面中的引用进行替换,来解决 NPM 的支持问题,这样业务就可以基本无脑使用各类 NPM 而不用关心平台差异。


以此类推,将业务难以自行适配的差异,逐一解决之后,剩余的一些功能差异,则由业务基于条件编译的方式来自行适配,这样便可以大大的降低业务形态转换成本,同时也形成了我们面向多端场景下的形态转换方案。


那么到这里,多端转换的问题才算是基本解决了。


如何治理 “复杂小程序”?


如果说上面讲的内容都是聚焦在如何通过编译的方式来解决多端同构以及形态问题的话,那么接下来要解决的就是针对“复杂小程序”的应用架构与研发协作的问题了。



首先介绍下我们所定义的 “复杂小程序”,即具备跨业务领域的、长周期的、多团队协同的、呈现主链路+多分支业务模式的应用,其之所以“复杂”,主要体现在应用形态多样、诉求多样、关联业务面广等特性上


对于饿了么来说,每个渠道阵地均相当于一个小型饿了么APP,除了在研发上提供便利外,还需一套可靠的应用架构来保证其有序演进。


同时,由于渠道之间定位不同,各域的业务、产品及研发对各渠道重视程度与投入比重均有差异,间接导致渠道间相同业务能力的参差不齐,且不同渠道功能缺失的情况持续出现。


我们以饿了么微信小程序为例:



面临的问题有哪些?



  • 工程复杂导致研发效率低:大量的团队在一个单体小程序应用上研发,带来的直接问题就是小程序巨大化带来的研发体验差和编译效率低,且业务相互依赖,单一模块构建失败会引发整个项目的失败,比如饿了么微信小程序单次编译的时间超过了半个小时,且体积逼近 20m 上限

  • 研发流程不规范导致稳定性差:同时由于不同的业务团队迭代周期不一致,而每次发版都需要所有业务的代码一起发,哪怕是某个业务分包或者插件没有更新,但是对应的底层依赖库发生了变更,也极有可能引入线上 BUG,导致测试回归的成本居高不下,发版质量难以保障


解决方案:线下线上结合的集成研发模式


针对上面两个“复杂小程序”所面临的核心问题,我们针对性的通过 「线下集成研发」和「线上研发协作」来解决。


线下集成研发


重点考虑的是提供什么样的集成研发能力,允许以业务单元维度将多个独立的构建(宿主、小程序、插件、分包等)组成一个可用的小程序,消除业务之间强依赖关系,从而达成业务可独立开发、调试和部署的目的,方面统一业务协作流程、降低多端同构成本,关键策略:



  • 提供统一的集成研发方式和流程

  • 提供标准、可复用的集成产物规范

  • 为复杂小程序提供解耦工具和集成方法

  • 标准化小程序宿主、小程序插件、小程序分包、小程序模块之间的通信及能力注入方式



将小程序宿主和各个业务模块(分包、小程序、插件)通过形态转换、拉包、编译、构建、合并等一系列处理后,合并为一个完整小程序,且根据不同的场景可以支持:



  • 主子分包研发模式:基于不同业务对小程序中的分包进行拆分,以达到各个业务相互解耦,独立迭代的目的;

  • SDK 研发模式:将通用的页面或组件封装置某个 NPM 包中作为提供特定功能的 SDK 交由业务使用;

  • 小程序插件研发模式:集成研发也可以用支持标准的小程序插件研发。


这样我们就可以解决线下研发的问题。


线上研发协作


前面介绍的“线下集成研发”为业务单元提供了无阻塞的开发与调试能力,但对于饿了么业务整体演进来说,重视的是每个版本功能的可用与可控,这里面除了将集成的范围扩展到所有业务域的之外,还需要标准化的流程约束:



具体方式上,在机制层面提供了业务类型定义的能力,开发者可将工程做对应标记(主包、分包、插件、独立小程序),在流程层面定义了开发、集成与发布三个阶段,这和 APP 的研发流程有些类似:



  • 开发:各业务应用自行研发并结合平台部署测试,开发测试通过,等待窗口期开启进入集成测试;

  • 集成:管理员设置集成窗口期,在窗口期,允许业务多次集成研发,确认最终要进集成的稳定版本,期间主包管理员可多次部署体验版用于集成测试。窗口期结束后,不允许随意变更;

  • 发布:集成测试通过,各业务进行代码 CR 并进入发布阶段,等候主包提审通过发布上线,最终由管理员完成本次迭代发布,发布完成后,符合标准的主分包产物会被保存下来,后续的迭代中,如果某个分包未发生变更,则会直接复用产物,极大的降低了业务的发布风险,并提升了整体的构建效率。


再进一步,多端业务的最佳实践


通过线下集成+线上协作的双重能力加持,结合已有的多端编译能力,在成功的支撑了饿了么多端渠道业务的稳定高效研发的同时,我们也在思考,面向于未来的多端研发模式应该是个什么样子?


下图是我们期望同时也是饿了么目前多端应用架构正在演进中的样子:



从图上可以看出,我们将应用架构划分为三层(从下往上看):




  • 基础服务与研发规范:最底部的是基础服务与研发规范,由 多端研发框架、多端研发平台和多端研发规范,来提供统一的研发支撑,保障业务研发的基础能力、体验和效率,并负责将相关的业务统一打包、封装、集成,并部署和投放到不同的渠道;




  • 宿主应用框架:第二层是宿主应用框架(Framework),也可以认为是多端统一解决方案,承接了面向于业务研发并适配了多端差异的基础 API(如 登录、定位、请求、路由、实验、风控、埋点、容器等)、基础组件和最佳实践,通过分渠道的配置化运行、标准化的接入手段和中心化的能力管理,来保障整体框架的轻量化、标准化与持续迭代和升级;




  • 渠道应用主体:最上层是各个业务的应用实体,有一个壳工程 + N个业务工程组成,壳工程承接各个渠道定制化的一些能力,而并将下层应用框架的能力暴露给上层的各个业务,各个业务只需要关心两件事即可:



    • 多端形态:以什么样的形态接入到对应的渠道(即壳工程中)?

    • 业务功能:不同的渠道需要展示那些功能?




基于这种分层协作模式,可以最大程度上消除业务对多端差异的感知,可以将重心放在如何更好的为用户提供服务上。


以上内容为饿了么基于小程序 DSL 的跨端实践和解决方案,下面我们来看一下具体取得的成果。


跨端成果


饿了么各渠道业务效果展示



业务一码多端研发提效数据



  • 研发提效:采用一码多端和集成研发模式的业务平均提效 70%,同构的端越多提效越多

  • 多端占比:饿了么内部 85%+ 的多端业务在基于这套方案实现多渠道业务研发和投放

  • 业务覆盖:涵盖了饿了么全域的各个业务板块


能力沉淀 — 饿了么自研 MorJS 多端研发框架


MorJS 开源



我们将饿了么在跨端多渠道上的多年沉淀和解决方案,融合为 MorJS 多端研发框架,并通过 Github 开源的方式向社区开放。


GitHub 仓库地址:github.com/eleme/morjs


下图为 MorJS 的完整架构图:



MorJS 框架目前支持 :



  • 2 种 DSL:微信小程序 DSL 或 支付宝小程序 DSL

  • 4 种编译形态:小程序、小程序插件、小程序分包、小程序多端组件

  • 9 个目标平台:微信、支付宝、百度、字节、快手、钉钉、手淘、QQ、Web


并支撑了饿了么 C 端大多数业务在各个渠道上的研发和投放。


MorJS 为饿了么解决了大量业务在多端研发上的差异问题,让小程序开发的重心回到产品业务本身,减少使用者对多端差异兼容的投入。通过 MorJS 的开源,我们期望能把其中的实现细节、架构设计和技术思考呈现给大家,为更多有类似多端同构需求的企业和开发者服务。同时,我们也希望能够借此吸引到更多志趣相投的小伙伴参与共建,一起加速小程序一码多端能力的发展。欢迎广大小程序开发者们与我们交流。


MorJS 特性介绍



为了能够帮助社区的用户可以快速上手,我们在易用性、标准化和灵活性方面做了大量的准备:



  • ⭐️ 易用性

    • 💎 DSL 支持:可使用微信小程序 DSL 或 支付宝小程序 DSL 编写小程序,无额外使用成本;

    • 🌴 多端支持:支持将一套小程序转换为各类小程序平台及 Web 应用, 节省双倍人力;

    • 🚀 快速接入:仅需引入两个包,增加一个配置文件,即可简单快速接入到现有小程序项目;



  • 🌟 标准化

    • 📦 开箱即用:内置了脚手架、构建、分析、多端编译等完整研发能力,仅需一个依赖即可上手开发;

    • 🌈 表现一致:通过编译时+运行时抹平多端差异性,让不同平台的小程序获得一致的用户体验;

    • 🖇 形态转换:支持同一个项目的不同的形态,允许小程序、分包、插件不同形态之间的相互转换;



  • ✨ 灵活性

    • 🎉 方便扩展:MorJS 将完备的生命周期和内部功能插件化,使用插件(集)以满足功能和垂直域的分层需求;

    • 📚 类型支持:除小程序标准文件类型外,还支持 ts、less/scss、jsonc/json5 等多种文件类型;

    • 🧰 按需适配:可根据需求选择性接入适配能力,小项目仅需编译功能,中等项目可结合编译和页面注入能力,大型项目推荐使用复杂小程序集成能力;




同时也提供了丰富的文档:mor.eleme.io/ 共大家查阅。


部分使用案例及社区服务


以下为部分基于 MorJS 的案例:



用户原声



MorJS 上线的这几个月里面,我们收到了一些社区用户的正向反馈,也收到了一些诉求和问题,其中用户最担心的问题是:MorJS 是不是 KPI 项目,是否会长期维护?


这里借用一下我在 Github 项目的讨论区(Discussions)的回复:



如果大家对 MorJS 感兴趣,期望有更多了解或者在使用 MorJS 中有遇到任何问题,欢迎加入 MorJS 社区服务钉钉群(群号:29445021084)反馈、交流和学习,也可以🔗 点击链接加入钉钉群


展望未来



未来,在现有的 MorJS 的能力基础上,我们会进一步完善已有的多端能力,提升多端转换可用度,完善对各类社区组件库的兼容,并持续扩展编译目标平台的支持(如 鸿蒙、快应用等),在持续为饿了么自身业务和社区用户提供高质量服务的同时,期望有朝一日 MorJS 可以成为业界小程序多端

作者:lyfeyaj
来源:juejin.cn/post/7262558218169319484
研发的基础设施之一。

收起阅读 »

Android 记录一次因隐私合规引发的权限hook

背景 一天,本该快乐编码flutter的我,突然被集团法务钉了,说在合规扫描排查中发现某xxxApp存在在App静默状态下调用某敏感权限获取用户信息,不合规。通过调用栈排查发现是某第三方推送sdk在静默状态下心跳调用的,本着能动口不动脑的准则,我联系了上了第三...
继续阅读 »

背景


一天,本该快乐编码flutter的我,突然被集团法务钉了,说在合规扫描排查中发现某xxxApp存在在App静默状态下调用某敏感权限获取用户信息,不合规。通过调用栈排查发现是某第三方推送sdk在静默状态下心跳调用的,本着能动口不动脑的准则,我联系了上了第三方的技术,询问是否有静默方面的api,结果一番舌战后,对方告诉我他们隐私政策里有添加说明,之后也没有想要改动的打算,但是集团那边说在隐私里说明也不行。


综上,那只能自己动手。


解决的方法:是通过hook系统权限,添加某个业务逻辑点拦截并处理。


涉及到的知识点:java反射、动态代理、一点点耐心。


本文涉及到的敏感权限:


//wifi
android.net.wifi.WifiManager.getScanResults()
android.net.wifi.WifiManager.getConnectionInfo()
//蓝牙
android.bluetooth.le.BluetoothLeScanner.startScan()
//定位
android.location.LocationManager.getLastKnownLocation()

开始


wifi篇


1.首先寻找切入点,以方法WifiManager.getScanResults()为例查看源码


public List<ScanResult> getScanResults() {
  try {
return mService.getScanResults(mContext.getOpPackageName(),
  mContext.getAttributionTag());
  } catch (RemoteException e) {
  throw e.rethrowFromSystemServer();
  }
}

发现目标方法是由mService对象调用,它的定义


@UnsupportedAppUsage
IWifiManager mService;

查看IWifiManager


interface IWifiManager{
...

List<ScanResult> getScanResults(String callingPackage, String callingFeatureId);

WifiInfo getConnectionInfo(String callingPackage, String callingFeatureId);

...
}

可以看到IWifiManager是一个接口类,包含所需方法,可以当成一个切入点。


若以IWifiManager为切入点,进行hook


方法一

private static void hookWifi(Context context) {
try {
//反射获取相关类、字段对象
Class<?> iWifiManagerClass = HookUtil.getClass("android.net.wifi.IWifiManager");
Field serviceField = HookUtil.getField("android.net.wifi.WifiManager", "mService");

WifiManager wifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
//获取原始mService对象
Object realIwm = serviceField.get(wifiManager);

//创建IWifiManager代理
Object proxy = Proxy.newProxyInstance(iWifiManagerClass.getClassLoader(),
new Class[]{iWifiManagerClass}, new WifiManagerProxy(realIwm));

//设置新代理
serviceField.set(wifiManager, proxy);
} catch (Exception e) {
e.printStackTrace();
}
}

其中新代理类实现InvocationHandler


public class WifiManagerProxy implements InvocationHandler {

private final Object mOriginalTarget;

public WifiManagerProxy(Object mOriginalTarget) {
this.mOriginalTarget = mOriginalTarget;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
String methodName = method.getName();
if (("getScanResults".equals(methodName) || "getConnectionInfo".equals(methodName))){
//todo something
return null;
}
return method.invoke(mOriginalTarget,args);
}
}


2.考虑context问题:


获取原始wifiManager需要用到context上下文,不同context获取到的wifiManager不同。若统一使用application上下文可以基本覆盖所需,但是可能会出现遗漏(比如某处使用的是activity#context)。为了保证hook开关唯一,尝试再往上查找新的切入点。


查看获取wifiManager方法,由context调用.getSystemService()


WifiManager wifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);

继续查看context的实现contextImpl


@Override
public Object getSystemService(String name) {
...
return SystemServiceRegistry.getSystemService(this, name);
}

查看SystemServiceRegistry.getSystemService静态方法


public static Object getSystemService(ContextImpl ctx, String name) {
if (name == null) {
return null;
}
final ServiceFetcher<?> fetcher = SYSTEM_SERVICE_FETCHERS.get(name);
if (fetcher == null) {
...
return null;
}

final Object ret = fetcher.getService(ctx);
if (sEnableServiceNotFoundWtf && ret == null) {
...
return null;
}
return ret;
}

服务由SYSTEM_SERVICE_FETCHERS获取,它是一个静态的HashMap,它的put方法在registerService


private static <T> void registerService(@NonNull String serviceName,
@NonNull Class<T> serviceClass, @NonNull ServiceFetcher<T> serviceFetcher) {
...
SYSTEM_SERVICE_FETCHERS.put(serviceName, serviceFetcher);
...
}

static{
...
//Android 11及以上
WifiFrameworkInitializer.registerServiceWrappers()
...
}

...

@SystemApi
public static <TServiceClass> void registerContextAwareService(
@NonNull String serviceName, @NonNull Class<TServiceClass> serviceWrapperClass,
@NonNull ContextAwareServiceProducerWithoutBinder<TServiceClass> serviceProducer) {
...
registerService(serviceName, serviceWrapperClass,
new CachedServiceFetcher<TServiceClass>() {
@Override
public TServiceClass createService(ContextImpl ctx)
throws ServiceNotFoundException {
return serviceProducer.createService(
ctx.getOuterContext(),
ServiceManager.getServiceOrThrow(serviceName));
}});

}


public static void registerServiceWrappers() {
...
SystemServiceRegistry.registerContextAwareService(
  Context.WIFI_SERVICE,
  WifiManager.class,
  (context, serviceBinder) -> {
  IWifiManager service = IWifiManager.Stub.asInterface(serviceBinder);
  return new WifiManager(context, service, getInstanceLooper());
  }
  );
}

SYSTEM_SERVICE_FETCHERS静态代码块中通过.registerServiceWrappers()注册WIFI_SERVICE服务。


registerService中new了一个CachedServiceFetcher,它返回一个serviceProducer.createService(...)


TServiceClass createService(@NonNull Context context, @NonNull IBinder serviceBinder);

其中第二个参数是一个IBinder对象,它的创建


ServiceManager.getServiceOrThrow(serviceName)

继续


public static IBinder getServiceOrThrow(String name) throws ServiceNotFoundException {
  final IBinder binder = getService(name);
  if (binder != null) {
  return binder;
  } else {
  throw new ServiceNotFoundException(name);
  }
  }
...
@UnsupportedAppUsage
public static IBinder getService(String name) {
  try {
  IBinder service = sCache.get(name);
  if (service != null) {
  return service;
  } else {
  return Binder.allowBlocking(rawGetService(name));
  }
  } catch (RemoteException e) {
  Log.e(TAG, "error in getService", e);
  }
  return null;
  }

最终在getServiceIBinder缓存在sCache中,它是一个静态变量


@UnsupportedAppUsage
private static Map<String, IBinder> sCache = new ArrayMap<String, IBinder>();

综上,如果可以创建新的IBinder,再替换掉sCache中的原始值就可以实现所需。


若以sCache为一个切入点


方法二

private static void hookWifi2() {
try {
Method getServiceMethod = HookUtil.getMethod("android.os.ServiceManager", "getService", String.class);
Object iBinderObject = getServiceMethod.invoke(null, Context.WIFI_SERVICE);

Field sCacheFiled = HookUtil.getField("android.os.ServiceManager", "sCache");
Object sCacheValue = sCacheFiled.get(null);

//生成代理IBinder,并替换原始值
if (iBinderObject != null && sCacheValue != null) {
IBinder iBinder = (IBinder) iBinderObject;
Map<String, IBinder> sCacheMap = (Map<String, IBinder>) sCacheValue;
Object proxy = Proxy.newProxyInstance(iBinder.getClass().getClassLoader(), new Class[]{IBinder.class}, new WifiBinderProxy(iBinder));
sCacheMap.put(Context.WIFI_SERVICE, (IBinder) proxy);
}
} catch (Exception e) {
e.printStackTrace();
}
}

public class WifiBinderProxy implements InvocationHandler {

private final IBinder originalTarget;

public WifiBinderProxy(IBinder originalTarget) {
this.originalTarget = originalTarget;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("queryLocalInterface".equals(method.getName())) {
Object hook = hookQueryLocalInterface();
if (hook != null){
return hook;
}
}
return method.invoke(originalTarget, args);
}

private Object hookQueryLocalInterface(){
try {
//获取原始IWifiManager对象
Method asInterfaceMethod = HookUtil.getMethod("android.net.wifi.IWifiManager$Stub", "asInterface", IBinder.class);
Object iwifiManagerObject = asInterfaceMethod.invoke(null, originalTarget);

//生成新IWifiManager代理
Class<?> iwifiManagerClass = HookUtil.getClass("android.net.wifi.IWifiManager");
return Proxy.newProxyInstance(originalTarget.getClass().getClassLoader(),
new Class[]{IBinder.class, IInterface.class, iwifiManagerClass},
new WifiManagerProxy(iLocationManagerObject));
}catch (Exception e){
e.printStackTrace();
}
return null;
}
}

至此完成无需上下文的全局拦截。


蓝牙篇


BluetoothLeScanner.startScan()为例查找切入点,以下省略非必需源码粘贴


private int startScan(List<ScanFilter> filters, ScanSettings settings,
final WorkSource workSource, final ScanCallback callback,
final PendingIntent callbackIntent,
List<List<ResultStorageDescriptor>> resultStorages) {
...
IBluetoothGatt gatt;
try {
gatt = mBluetoothManager.getBluetoothGatt();
} catch (RemoteException e) {
gatt = null;
}
...

private final IBluetoothManager mBluetoothManager;

...
public BluetoothLeScanner(BluetoothAdapter bluetoothAdapter) {
mBluetoothAdapter = Objects.requireNonNull(bluetoothAdapter);
mBluetoothManager = mBluetoothAdapter.getBluetoothManager();
...
}

向上查找IBluetoothManager,它在BluetoothAdapter中;向下代理getBluetoothGatt方法处理IBluetoothGatt


查看BluetoothAdapter的创建


public static BluetoothAdapter createAdapter(AttributionSource attributionSource) {
IBinder binder = ServiceManager.getService(BLUETOOTH_MANAGER_SERVICE);
if (binder != null) {
return new BluetoothAdapter(IBluetoothManager.Stub.asInterface(binder),
attributionSource);
} else {
Log.e(TAG, "Bluetooth binder is null");
return null;
}
}

ok,他也包含由ServiceManager中获取得到IBinder,然后进行后续操作。


若以IBluetoothManager为切入点


private static void hookBluetooth() {
try {
//反射ServiceManager中的getService(BLUETOOTH_MANAGER_SERVICE = 'bluetooth_manager')方法,获取原始IBinder
Method getServiceMethod = HookUtil.getMethod("android.os.ServiceManager", "getService", String.class);
Object iBinderObject = getServiceMethod.invoke(null, "bluetooth_manager");

//获取ServiceManager对象sCache
Field sCacheFiled = HookUtil.getField("android.os.ServiceManager", "sCache");
Object sCacheValue = sCacheFiled.get(null);

//动态代理生成代理iBinder插入sCache
if (iBinderObject != null && sCacheValue != null) {
IBinder iBinder = (IBinder) iBinderObject;
Map<String, IBinder> sCacheMap = (Map<String, IBinder>) sCacheValue;
Object proxy = Proxy.newProxyInstance(iBinder.getClass().getClassLoader(), new Class[]{IBinder.class}, new BluetoothBinderProxy(iBinder));
sCacheMap.put("bluetooth_manager", (IBinder) proxy);
}
} catch (Exception e) {
e.printStackTrace();
}
}

代理IBluetoothManager


public class BluetoothBinderProxy implements InvocationHandler {

private final IBinder mOriginalTarget;

public BluetoothBinderProxy(IBinder originalTarget) {
this.mOriginalTarget = originalTarget;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("queryLocalInterface".equals(method.getName())) {
//拦截
Object hook = hookQueryLocalInterface();
if (hook != null){
return hook;
}
}
//不拦截
return method.invoke(mOriginalTarget, args);
}

private Object hookQueryLocalInterface(){
try {
//获取原始IBluetoothManager对象
Method asInterfaceMethod = HookUtil.getMethod("android.bluetooth.IBluetoothManager$Stub", "asInterface", IBinder.class);
Object iBluetoothManagerObject = asInterfaceMethod.invoke(null, mOriginalTarget);

//生成代理IBluetoothManager
Class<?> iBluetoothManagerClass = HookUtil.getClass("android.bluetooth.IBluetoothManager");
return Proxy.newProxyInstance(mOriginalTarget.getClass().getClassLoader(),
new Class[]{IBinder.class, IInterface.class, iBluetoothManagerClass},
new BluetoothManagerProxy(iBluetoothManagerObject));
}catch (Exception e){
e.printStackTrace();
}
return null;
}
}

代理IBluetoothGatt


public class BluetoothManagerProxy implements InvocationHandler {

private final Object mOriginalTarget;

public BluetoothManagerProxy(Object mOriginalTarget) {
this.mOriginalTarget = mOriginalTarget;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("getBluetoothGatt".equals(method.getName())) {
Object object = method.invoke(mOriginalTarget,args);
Object hook = hookGetBluetoothGatt(object);
if (hook != null){
return hook;
}
}
return method.invoke(mOriginalTarget, args);
}

private Object hookGetBluetoothGatt(Object object) {
try {
Class<?> iBluetoothGattClass = HookUtil.getClass("android.bluetooth.IBluetoothGatt");
return Proxy.newProxyInstance(mOriginalTarget.getClass().getClassLoader(),
new Class[]{IBinder.class, IInterface.class, iBluetoothGattClass},
new BluetoothGattProxy(object));
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}

处理业务逻辑


public class BluetoothGattProxy implements InvocationHandler {

private final Object mOriginalTarget;

public BluetoothGattProxy(Object mOriginalTarget) {
this.mOriginalTarget = mOriginalTarget;
}


@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("startScan".equals(method.getName())){
//todo something
return null;
}
return method.invoke(mOriginalTarget,args);
}
}

定位篇


LocationManager.getLastKnownLocation()为例查找切入点,此处不粘贴源码,直接展示


private static void hookLocation() {
try {
Method getServiceMethod = HookUtil.getMethod("android.os.ServiceManager", "getService", String.class);
Object iBinderObject = getServiceMethod.invoke(null, Context.LOCATION_SERVICE);

Field sCacheFiled = HookUtil.getField("android.os.ServiceManager", "sCache");
Object sCacheValue = sCacheFiled.get(null);

//动态代理生成代理iBinder插入sCache
if (iBinderObject != null && sCacheValue != null) {
IBinder iBinder = (IBinder) iBinderObject;
Map<String, IBinder> sCacheMap = (Map<String, IBinder>) sCacheValue;
Object proxy = Proxy.newProxyInstance(iBinder.getClass().getClassLoader(), new Class[]{IBinder.class}, new LocationBinderProxy(iBinder));
sCacheMap.put(Context.LOCATION_SERVICE, (IBinder) proxy);
}
} catch (Exception e) {
e.printStackTrace();
}
}

public class LocationBinderProxy implements InvocationHandler {

private final IBinder originalTarget;

public LocationBinderProxy(IBinder originalTarget) {
this.originalTarget = originalTarget;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("queryLocalInterface".equals(method.getName())) {
Object hook = hookQueryLocalInterface();
if (hook != null){
return hook;
}
}
return method.invoke(originalTarget, args);
}

private Object hookQueryLocalInterface(){
try {
//获取原始ILocationManager对象
Method asInterfaceMethod = HookUtil.getMethod("android.location.ILocationManager$Stub", "asInterface", IBinder.class);
Object iLocationManagerObject = asInterfaceMethod.invoke(null, originalTarget);

//生成代理ILocationManager
Class<?> iLocationManagerClass = HookUtil.getClass("android.location.ILocationManager");
return Proxy.newProxyInstance(originalTarget.getClass().getClassLoader(),
new Class[]{IBinder.class, IInterface.class, iLocationManagerClass},
new LocationManagerProxy(iLocationManagerObject));
}catch (Exception e){
e.printStackTrace();
}
return null;
}
}

总结


作为Android进程间通信机制Binder的守护进程,本次所hook的权限都可追溯到ServiceManagerServiceManager中的sCache缓存了权限相关的IBinder,以此为切入点可以进行统一处理,不需要引入context。


在此记录一下因隐私合规引发的hook处理流程,同时也想吐槽一下国内应用市场App上架审核是真滴难,每个市场的合规扫描标准都不一样。


附录


源码查看网站 aospxref.com/


路径:/frameworks/base/core/java/android/os/ServiceManager.j

作者:秋至
来源:juejin.cn/post/7262243685898960955
ava

收起阅读 »

如何选择 Android 唯一标识符

前言 大家好,我是未央歌,一个默默无闻的移动开发搬砖者~ 本文针对 Android 各种标识符做了统一收集,方便大家比对,以供选择适合大家的唯一标识符。 标识符 IMEI 从 Android 6.0 开始获取 IMEI 需要权限,并且从 Android 10...
继续阅读 »

前言


大家好,我是未央歌,一个默默无闻的移动开发搬砖者~


本文针对 Android 各种标识符做了统一收集,方便大家比对,以供选择适合大家的唯一标识符。


标识符


IMEI



  • 从 Android 6.0 开始获取 IMEI 需要权限,并且从 Android 10+ 开始官方取消了获取 IMEI 的 API,无法获取到 IMEI 了


fun getIMEI(context: Context): String {
val telephonyManager = context
.getSystemService(TELEPHONY_SERVICE) as TelephonyManager
return telephonyManager.deviceId
}

Android ID(SSAID)



  • 无需任何权限

  • 卸载安装不会改变,除非刷机或重置系统

  • Android 8.0 之后签名不同的 APP 获取的 Android ID 是不一样的

  • 部分设备由于制造商错误实现,导致多台设备会返回相同的 Android ID

  • 可能为空


fun getAndroidID(context: Context): String {
return Settings.System.getString(context.contentResolver,Settings.Secure.ANDROID_ID)
}

MAC 地址



  • 需要申请权限,Android 12 之后 BluetoothAdapter.getDefaultAdapter().getAddress()需要动态申请 android.permission.BLUETOOTH_CONNECT 权限

  • MAC 地址具有全局唯一性,无法由用户重置,在恢复出厂设置后也不会变化

  • 搭载 Android 10+ 的设备会报告不是设备所有者应用的所有应用的随机化 MAC 地址

  • 在 Android 6.0 到 Android 9 中,本地设备 MAC 地址(如 WLAN 和蓝牙)无法通过第三方 API 使用 会返回 02:00:00:00:00:00,且需要 ACCESS_FINE_LOCATION 或 ACCESS_COARSE_LOCATION 权限


Widevine ID



  • DRM 数字版权管理 ID ,访问此 ID 无需任何权限

  • 对于搭载 Android 8.0 的设备,Widevine 客户端 ID 将为每个应用软件包名称和网络源(对于网络浏览器)返回一个不同的值

  • 可能为空


fun getWidevineID(): String {
try {
val WIDEVINE_UUID = UUID(-0x121074568629b532L, -0x5c37d8232ae2de13L)
val mediaDrm = MediaDrm(WIDEVINE_UUID)
val widevineId = mediaDrm.getPropertyByteArray(MediaDrm.PROPERTY_DEVICE_UNIQUE_ID);
val sb = StringBuilder();
for (byte in widevineId) {
sb.append(String.format("x", byte))
}
return sb.toString();
} catch (e: Exception) {
} catch (e: Error) {
}
return ""
}

AAID



  • 无需任何权限

  • Google 推出的广告 ID ,可由用户重置的标识符,适用于广告用例

  • 系统需要自带 Google Play Services 才支持,且用户可以在系统设置中重置



重置后,在未获得用户明确许可的情况下,新的广告标识符不得与先前的广告标识符或由先前的广告标识符所衍生的数据相关联。




还要注意,Google Play 开发者内容政策要求广告 ID“不得与个人身份信息或任何永久性设备标识符(例如:SSAID、MAC 地址、IMEI 等)相关联。”




在支持多个用户(包括访客用户在内)的 Android 设备上,您的应用可能会在同一设备上获得不同的广告 ID。这些不同的 ID 对应于登录该设备的不同用户。



OAID



  • 无需任何权限

  • 国内移动安全联盟出台的“拯救”国内移动广告的广告跟踪标识符

  • 基本上是国内知名厂商 Android 10+ 才支持,且用户可以在系统设置中重置


UUID



  • 生成之后本地持久化保存

  • 卸载后重新安装、清除应用缓存 会改变


如何选择


同个开发商需要追踪对比旗下应用各用户的行为



  • 可以采用 Android ID(SSAID),并且不同应用需使用同一签名

  • 如果获得的 Android ID(SSAID)为空,可以用 UUID 代替【 OAID / AAID 代替也可,但需要引入第三方库】

  • 在 Android 8.0+ 中, Android ID(SSAID)提供了一个在由同一开发者签名密钥签名的应用之间通用的标识符


希望限制应用内的免费内容(如文章)



  • 可以采用 UUID ,作用域是应用范围,用户要想规避内容限制就必须重新安装应用


用户群体主要是大陆



  • 可以采用 OAID ,低版本配合采用 Android ID(SSAID)/ UUID

  • 可以采用 Android ID(SSAID),空的时候配合采用 UUID 等


用户群体在海外



  • 可以采用 AAID

  • 可以采用 Android ID(SSAID),空的时候配合采用 UUID 等
作者:未央歌
来源:juejin.cn/post/7262558218169008188

收起阅读 »

微信微调助手WeChatTweak for mac(多开和防撤回工具)

WeChatTweak for mac是一款仅限mac平台的微信客户端插件,这款插件拥有防撤回和微信多开的功能。 集成版无需执行终端命令,直接可以右键单击dock栏图标以登录另一个微信帐户。 1、阻止消息撤回 消息列表通知 系统通知 2、客户端无限多开...
继续阅读 »

WeChatTweak for mac是一款仅限mac平台的微信客户端插件,这款插件拥有防撤回和微信多开的功能。


集成版无需执行终端命令,直接可以右键单击dock栏图标以登录另一个微信帐户。


1、阻止消息撤回


消息列表通知


系统通知 2、客户端无限多开


右键单击停靠栏图标以登录另一个微信帐户


或者在终端命令行执行:open -n /Applications/WeChat.app


3、链接类型消息增强 4、支持快捷直接复制链接 5、支持由系统替代浏览器直接打开


WeChatTweak Mac激活下载





作者:多来啊梦要飞
来源:mdnice.com/writing/20c7a437b1fa4ee5bf75657937be2893
收起阅读 »

getClass方法详解

getClass方法详解 在Java中,getClass()是Object类的一个方法,用于返回对象的运行时类(Runtime Class)。它的函数签名如下: public final Class<?> getC...
继续阅读 »

getClass方法详解


在Java中,getClass()是Object类的一个方法,用于返回对象的运行时类(Runtime Class)。它的函数签名如下:


public final Class<?> getClass()

getClass()方法返回一个Class对象,该对象表示调用该方法的对象的运行时类型。换句话说,它返回一个描述对象所属类的元数据的实例。


以下是关于getClass()方法的详解:





  1. 返回值类型:getClass()方法返回一个Class<?>类型的对象,这里的问号表示通配符,表示可以是任何类型的Class对象。





  2. 作用:getClass()方法用于获取对象的类信息,包括类的名称、父类、接口信息等。





  3. 运行时类型:getClass()方法返回的是调用对象的运行时类型,而不是对象的声明类型。也就是说,如果对象的类型发生了变化(向上转型或者子类重写父类方法),getClass()返回的是实际运行时类型。





  4. 示例代码:


    class Animal {
        // ...
    }

    class Dog extends Animal {
        // ...
    }

    public class Main {
        public static void main(String[] args) {
            Animal animal = new Dog();
            Class<?> clazz = animal.getClass();
            System.out.println(clazz.getName()); // 输出: Dog
        }
    }

    在上面的示例中,getClass()方法被调用时,对象animal的运行时类型是Dog,因此返回的Class对象代表Dog类。




需要注意的是,getClass()方法是继承自Object类的,因此可以在任何Java对象上调用。但是,在使用getClass()方法之前,必须确保对象不为null,否则会抛出NullPointerException异常。


getClass()方法与反射密切相关,是反射的基础之一。


在Java中,反射是指在运行时动态地获取类的信息并操作类或对象的能力。它允许程序在运行时检查和修改类、方法、字段等的属性和行为,而不需要在编译时确定这些信息。


通过调用对象的getClass()方法,我们可以获得对象的运行时类型的Class对象。然后,使用Class对象可以进行以下反射操作:





  1. 实例化对象:通过Class.newInstance()方法可以实例化一个类的对象。





  2. 获取类的构造函数:通过Class.getConstructors()方法可以获取类的所有公共构造函数,通过Class.getDeclaredConstructors()方法可以获取所有构造函数(包括私有构造函数),还可以通过参数类型匹配获取指定的构造函数。





  3. 获取类的方法:通过Class.getMethods()方法可以获取类的所有公共方法,通过Class.getDeclaredMethods()方法可以获取所有方法(包括私有方法),还可以通过方法名和参数类型匹配获取指定的方法。





  4. 获取类的字段:通过Class.getFields()方法可以获取类的所有公共字段,通过Class.getDeclaredFields()方法可以获取所有字段(包括私有字段),还可以通过字段名匹配获取指定的字段。





  5. 调用方法和访问字段:通过Method.invoke()方法可以调用方法,通过Field.get()Field.set()方法可以访问字段。




总结来说,getClass()方法提供了从对象到其运行时类型的连接,而反射则利用这个连接来获取和操作类的信息。通过反射,我们可以在运行时动态地使用类的成员,实现灵活的代码编写和执行。


作者:维维
来源:mdnice.com/writing/c1e0400e54e94e4881aacdfc5bb10508
收起阅读 »

Python中列表的惭怍方法

Python中的列表是一种非常常用的数据结构,它可以存储多个元素,并且可以进行各种操作。下面是关于列表操作的一些基本方法:列表的生成:使用方括号 [] 来创建一个空列表:my_list = []使用方括号 [] 并在其中添加元素来创建一个非空列表:my_lis...
继续阅读 »

Python中的列表是一种非常常用的数据结构,它可以存储多个元素,并且可以进行各种操作。下面是关于列表操作的一些基本方法:

  1. 列表的生成:

    • 使用方括号 [] 来创建一个空列表:my_list = []

    • 使用方括号 [] 并在其中添加元素来创建一个非空列表:my_list = [1, 2, 3]

    • 使用列表生成式来生成列表:my_list = [x for x in range(5)]

  2. 列表的增加和删除:

    • 使用 append() 方法在列表末尾添加一个元素:my_list.append(4)

    • 使用 insert() 方法在指定位置插入一个元素:my_list.insert(0, 0)

    • 使用 extend() 方法将另一个列表的元素添加到当前列表末尾:my_list.extend([5, 6, 7])

    • 使用 remove() 方法删除列表中的指定元素:my_list.remove(3)

    • 使用 pop() 方法删除并返回列表中指定位置的元素:my_list.pop(0)

  3. 列表的遍历和循环:

    • 使用 for 循环遍历列表中的每个元素:

      for item in my_list:
          print(item)
    • 使用 enumerate() 函数同时获取元素的索引和值:

      for index, item in enumerate(my_list):
        print(index, item)
    • 使用 while 循环根据条件遍历列表:

      i = 0
      while i < len(my_list):
        print(my_list[i])
        i += 1
    • 使用 range() 函数和 len() 函数结合来遍历列表的索引:

        for i in range(len(my_list)):
            print(my_list[i])

希望这些例子能帮助你更好地理解列表的操作方法。如果有任何问题,请随时提问。

作者:orangewu
来源:mdnice.com/writing/3a3a6e2f2a5c4763a2c8901e205f446c
收起阅读 »

前端异步请求轮询方案

业务背景 在前后端数据交互场景下,使用最多的一种方式是客户端发起 HTTP 请求,等待服务端处理完成后响应给客户端结果。 但在一些场景下,服务端对数据的处理需要较长的时间,比如提交一批数据,对这批数据进行数据分析,将最终分析结果返回给前端。 如果采用一次 HT...
继续阅读 »

业务背景


在前后端数据交互场景下,使用最多的一种方式是客户端发起 HTTP 请求,等待服务端处理完成后响应给客户端结果。


但在一些场景下,服务端对数据的处理需要较长的时间,比如提交一批数据,对这批数据进行数据分析,将最终分析结果返回给前端。


如果采用一次 HTTP 请求,用户会一直处于等待状态,再加上界面不会有进度交互,导致用户不知何时会处理完成;此外,一旦刷新页面或者其他意外情况,用户就无从感知处理结果。


面对这类场景,可以借助 「HTTP 轮询方式」 对交互体验进行优化,具体过程如下:


首先发起一次 HTTP 请求用于提交数据,之后启动轮询在一定间隔时间内查询分析结果,在这期间后台可将分析进度同步到前端来告知用户处理进度;此外即使刷新再次进入页面还可以通过「轮询」实时查询进度结果。


下面,我们来看看代码层面看如何实现这类场景。


JS 实现轮询的方式


在实现代码之前,我们需要先明确 JS 实现轮询的方式有哪些,哪种方式最适合使用。


1. setInterval


作为前端开发人员,提起轮询第一时间能想到的是计时器 setInterval,它会按照指定的时间间隔不间断的轮询执行处理函数。


let index = 1;

setInterval(() => {
console.log('轮询执行: ', index ++);
}, 1000);

回过头来看我们的场景:要轮询的是 异步请求(HTTP),请求响应结果会受限制网络或者服务器处理速度,显然 setInterval 这种固定间隔轮询并不适合这个场景。


2. Promise + setTimeout sleep


setInterval 的不足之处在于 轮询间隔时间 在异步请求场景下无法保证两个请求之间的间隔固定。要解决这个问题,可以使用 sleep 睡眠函数来控制间隔时间。


JS 中没有提供 sleep 相关方法,但可以结合 Promise + setTimeout 来实现。


const sleep = () => {
return new Promise(resolve => {
setTimeout(resolve, 1000);
});
}

sleep 仅控制了轮询间隔,而轮询的执行机制需要我们手动根据异步请求结果来实现,比如下面通过控制 while 循环的条件:


const start = async () => {
let i = 0;
while (i < 5) {
await sleep();
console.log(`第 ${++ i} 次执行`);
}
}

start();


使用轮询的时候可以借助 async/await 同步的方式编写,提高代码阅读质量。



实现异步请求轮询


下面我们通过一个完整示例理解 轮询异步请求 的实现及使用注意事项。


首先我们定义两个变量:index 用于控制何时停止轮询,timer 则用于实现中断轮询。


let index = 1;
let timer = 0;

这里,我们定义 syncPromise 来模拟异步请求,可以看作是一次 HTTP 请求,当进行 5 次异步请求后,会返回 false 表示拿到数据分析结果,停止数据查询轮询:


const syncPromise = () => {
return new Promise(resolve => {
setTimeout(() => {
console.log(`第 ${index} 次请求`);
resolve(index < 5 ? true : false);
index ++;
}, 50);
})
}

现在,我们实现 pollingPromise 作为 sleep 睡眠函数使用,去控制轮询的间隔时间,并在指定时间执行异步请求:


const pollingPromise = () => {
return new Promise(resolve => {
timer = setTimeout(async () => {
const result = await syncPromise();
resolve(result);
}, 1000);
});
}

最后,startPolling 作为开始轮询的入口,包含以下逻辑:



  • 1)在轮询前会清除正在进行的轮询任务,避免出现多次轮询;

  • 2)如果需要,在开始轮询时会立刻调用异步请求查询一次数据结果;

  • 3)最后,通过 while 循环根据异步请求的结果,决定是否继续轮询;


const startPolling = async () => {
// 清除进行中的轮询,重新开启计时轮询
clearTimeout(timer); // !!! 注意:清除计时器后,会导致整个 async/await 链路中断,若计时器的位置下方还存在代码,将不会执行。
index = 1;
// 立刻执行一次异步请求
let needPolling = await syncPromise();
// 根据异步请求结果,判断是否需要开启计时轮询
while (needPolling) {
needPolling = await pollingPromise();
}
console.log('轮询请求处理完成!'); // 若异步请求被 clearTimeout(timer),这里不会被执行打印输出。
}

const start = async () => {
await startPolling();
console.log('若异步请求被 clearTimeout(timer),这里将不会被执行');
}
start();

不过,需要注意的是:一旦清除计时器后,会导致整个 async/await 链路中断,若计时器的位置下方还存在代码,将不会执行。


假设当前执行了两次轮询被 clearTimeout(timer) 后,从 startPollingstart 整个 async/await 链路都会中断,且后面未执行的代码也不会被执行。


基于以上规则,异步轮询的处理逻辑尽量放在 syncPromise 异步请求核心函数中完成,避免在开启轮询

作者:明里人
来源:juejin.cn/post/7262261749105639481
的辅助函数中去实现。

收起阅读 »

一代枭雄曹操也需要借力,何况我们

前言 1、人情世故 如果做得好就会说是情商高,做不好会说是世故,这是冯仑老师一段话,然后怎么做不世故呢,也很难评判。 借着这个聊聊人情世故,在我看来它也是做事规则的一部分,我们发展很长一段历史,从不同的立场、不同的利益分出了派别,又从血缘关系分出了宗族,这些...
继续阅读 »

3e6160aa8d1c936f2ffa5ccb994edcab.jpg


前言




1、人情世故


如果做得好就会说是情商高,做不好会说是世故,这是冯仑老师一段话,然后怎么做不世故呢,也很难评判。


借着这个聊聊人情世故,在我看来它也是做事规则的一部分,我们发展很长一段历史,从不同的立场、不同的利益分出了派别,又从血缘关系分出了宗族,这些都是为了利益最大化的一个产物。


反观博主本人,典型理工男,执着技术研究,所以这块一直是弱项,不太会讲话,但是我人缘一直比较好的。当然有利也有弊,弊端的话比较明显的,当一个人说话很厉害的时候,会给人自信,给人觉得靠谱,当一个人说话不咋样的时候,其实也有好处,就是藏锋,你不说出来个人想法大家是不知道你心里的小九九的,所以保全了你自身。(当一个人份量足的时候,说话会引发很大的影响,所以你可以发现如果一个人在公开场合大发演讲,要么是初出茅庐要么就是有靠山)


2、人生的发展需要平台


王立群老师:人生发展往往需要平台,秦国李斯这么一个故事,他发现仓鼠跟厕鼠待遇很不一样,同样是一个物种,但是一个光明正大的吃着粮食,一个过街老鼠人人喊打,所以他悟到了一个道理,人生好的发展需要借助平台的。


我们今天讲的人物:曹操,我们还是从几个学习角度去看,一个是做事的方法,另一个我们从他的事迹里面看出成事的借力的这么一回事。


曹操




出身


他祖父是一个大太监,伺候皇后还有皇上,古代有三股力量,两股都是因为比较亲近产生的,一个是外戚,另一个太监,还有一股力量是文官,这个是人数最多的。那么他祖父权利很大的,然后收了一个义子也就是曹操的父亲,然后他本身属于夏侯家族,所以他带的资源是曹家还有夏侯家非常有实力。


他并没有说直接躺平,而是想着有所作为,接下来我们再看看他的做事方面


做事手段


1、许劭风评


古代有个一个规则,靠着这些有能力、有品德的人来进行推荐人才,曹操想出来做事,他找到许劭,一开始是不肯的,因为前面讲过三股力量,文官是很鄙视太监的,后面曹操使了点手段最终让许劭给他做了风评,然后他听完大笑而去。


idea:从这件事看做什么事都是有个窍门,这个方式是别人建议曹操这么干,所以做事要恰到好处。另外里面提到曹操使了点手段,哈哈透出了一个狠,有点东西。


2、傍大腿


曹操曾经在袁绍下面干活,然后好几次都把自己的精锐干没了,袁绍作为盟主,慷慨的给予兵马才得以恢复元气。


idea:我们看曹操的出身,这么牛逼的背景,他也需要大腿的支持,更何况普普通通的我们。


3、挟天子以令诸侯


这个是非常著名的历史典故,也是因为这个跟袁绍闹掰了,当汉献帝去了洛阳的时候,他马上去迎接,然后用这个发号施令讨伐别人。


idea:曹操的眼光十分毒辣,他看出潜在的价值,不愧是曹老板。


4、善用人才


像官渡之战,像迎接汉献帝,都是底下这批谋士给的主意,曹操手下文官是人才济济的,另外这个老板是善于听从这些好的计谋,这是非常重要的。


官渡之战,袁绍没有听从谋士的重兵把守粮草,导致给了曹操抓住了机会,乌巢一把火烧光了粮草。


个人看法


a、平台是重要的,借力也是需要的


从曹操的发迹来看,他站在一个大平台上面,不像刘备四处投奔。人并不是说能力很强就能表现出来,需要有平台,有这么伯乐去发现你,然后有这么一股力量在你困难的时候拉你一把,这是重要的。


b、曹操做事狠


这里的狠,不是残暴,而是毒辣,眼光毒辣、做事方式到位,我们从善用人才,许劭风评,挟天子以令诸侯,这些做的都很到位。举个例子,比如说我们要煮开一壶水,需要火柴、木头、可能需要鼓风工具,这都是关键那些点。


这个我们前面也提到了,做事一定要有所研究,事情的关键点是什么,当然有这么一群得力助手也很重要,发现关键突破点。所以古代对英雄标准是:腹有良策,有大气概。


c、驾驭人


司马家起来是在曹操去世后几代的事情,可以说在曹操在的时候,这些有心机的人没有动作的,侧面看出曹操的厉害之处,懂人心。在资治通鉴里面也有一个例子,就是桓温,他也是古代一个权臣,后面几代就不行了压不住这批人。


学历史,学读懂人心




历史里面基本都是那个朝代的精英,他们的事迹,做事方法,当然我们看到很多东西,包括抱负、无奈、遗憾;我们学的不仅仅是做事方法,避开权谋的陷阱,还有就是学习读懂人心、人性。当我们谈到这个,大家第一印象就是坏的人性,其实它是一种自然的表现,就像饿了就要吃饭。


《百家讲坛》里面讲了这么一个故事,曹操的下邳之战生擒了吕布,原本曹操很爱惜人才的,后面刘备的一句话:吕布对以往老板不好,而曹操生性多疑,最终嘎了吕布。王立群老师:人们往往看重结果,以结果说话,而不是问你这么做的原因。


是啊,我们在故事背后,看到整件事情人心的博弈,刘备被人称为仁义之君,但是他在那会落进下石了,因为他之前跟吕布有些矛盾的,吕布把他从原来的根据地赶走了,当然他说的也是事实。所以我们除了学习历史,还需要去洞察人心,往往这

作者:大鸡腿同学
来源:juejin.cn/post/7261231205353242682
些能决定事情的走向。

收起阅读 »

三言两语说透koa的洋葱模型

web
Koa是一个非常轻量化的Node.js web应用框架,其洋葱圈模型是它独特的设计理念和核心实现机制之一。本文将详细介绍Koa的洋葱圈模型背后的设计思想,以及它是如何实现的。 洋葱圈模型设计思想 Koa的洋葱圈模型主要是受函数式编程中的compose思想启发而...
继续阅读 »

Koa是一个非常轻量化的Node.js web应用框架,其洋葱圈模型是它独特的设计理念和核心实现机制之一。本文将详细介绍Koa的洋葱圈模型背后的设计思想,以及它是如何实现的。


洋葱圈模型设计思想


Koa的洋葱圈模型主要是受函数式编程中的compose思想启发而来的。Compose函数可以将需要顺序执行的多个函数复合起来,后一个函数将前一个函数的执行结果作为参数。这种函数嵌套是一种函数式编程模式。


Koa借鉴了这个思想,其中的中间件(middleware)就相当于compose中的函数。请求到来时会经过一个中间件栈,每个中间件会顺序执行,并把执行结果传给下一个中间件。这就像洋葱一样,一层层剥开。


这样的洋葱圈模型设计有以下几点好处:



  • 更好地封装和复用代码逻辑,每个中间件只需要关注自己的功能;

  • 更清晰的程序逻辑,通过中间件的嵌套可以表明代码的执行顺序;

  • 更好的错误处理,每个中间件可以选择捕获错误或将错误传递给外层;

  • 更高的扩展性,可以很容易地在中间件栈中添加或删除中间件。


洋葱圈模型实现机制


Koa的洋葱圈模型主要是通过Generator函数和Koa Context对象来实现的。


Generator函数


Generator是ES6中新增的一种异步编程解决方案。简单来说,Generator函数可以像正常函数那样被调用,但其执行体可以暂停在某个位置,待到外部重新唤起它的时候再继续往后执行。这使其非常适合表示异步操作。


// koa中使用generator函数表示中间件执行链
function *logger(next){
  console.log('outer');
  yield next;
  console.log('inner');
}

function *main(){
  yield logger();
}

var gen = main();
gen.next(); // outer
gen.next(); // inner

Koa使用Generator函数来表示洋葱圈模型中的中间件执行链。外层不断调用next重新执行Generator函数体,Generator函数再按顺序yield内层中间件异步操作。这样就可以很优雅地表示中间件的异步串行执行过程。


Koa Context对象


Koa Context封装了请求上下文,作为所有中间件共享的对象,它保证了中间件之间可以通过Context对象传递信息。具体而言,Context对象在所有中间件间共享以下功能:



  • ctx.request:请求对象

  • ctx.response:响应对象

  • ctx.state:推荐的命名空间,用于中间件间共享数据

  • ctx.throw:手动触发错误

  • ctx.app:应用实例引用


// Context对象示例
ctx = {
  request: {...}, 
  response: {...},
  state: {},
  throwfunction(){...},
  app: {...}
}

// 中间件通过ctx对象传递信息
async function middleware1(ctx){
  ctx.response.body = 'hello';
}

async function middleware2(ctx){
  let body = ctx.response.body
  //...
}

每次请求上下文创建后,这个Context实例会在所有中间件间传递,中间件可以通过它写入响应,传递数据等。


中间件执行流程


当请求到达Koa应用时,会创建一个Context实例,然后按顺序执行中间件栈:



  1. 最内层中间件首先执行,可以操作Context进行一些初始化工作;

  2. 用yield将执行权转交给下一个中间件;

  3. 下一个中间件执行,并再次yield交还执行权;

  4. 当最后一个中间件执行完毕后,倒序执行中间件的剩余逻辑;

  5. 每个中间件都可以读取之前中间件写入Context的状态;

  6. 最外层获得Context并响应请求。


// 示意中间件执行流程
app.use(async function(ctx, next){
  // 最内层执行
  ctx.message = 'hello';

  await next();
  
  // 最内层剩余逻辑  
});

app.use(async function(ctx, next){
  // 第二层执行
  
  await next();

  // 第二层剩余逻辑
  console.log(ctx.message); 
});

// 最外层获得ctx并响应

这就是洋葱圈模型核心流程,通过Generator函数和Context对象实现了优雅的异步中间件机制。


完整解析


Koa中间件是一个Generator函数,可以通过yield关键字来调用下一个中间件。例如:


const Koa = require('koa');
const app = new Koa();

app.use(async (ctx, next) => {
  console.log('中间件1开始');
  
  await next();
  
  console.log('中间件1结束');
});

app.use(async (ctx, next) => {
  console.log('中间件2');

  await next();

  console.log('中间件2结束');  
});

app.use(async ctx => {
  console.log('中间件3')
});

app.listen(3000);

在代码中,可以看到Koa注册中间件是通过app.use实现的。所有中间件的回调函数中,await next()前面的逻辑是按照中间件注册的顺序从上往下执行的,而await next()后面的逻辑是按照中间件注册的顺序从下往上执行的。


执行流程如下:



  1. 收到请求,进入第一个中间件

  2. 第一个中间件打印日志,调用next进入第二个中间件

  3. 第二个中间件打印日志,调用next进入第三个中间件

  4. 第三个中间件打印日志,并结束请求

  5. control返回第二个中间件,打印结束日志

  6. control返回第一个中间件,打印结束日志

  7. 请求结束


这样每个中间件都可以控制请求前和请求后,形成洋葱圈模型。


中间件的实现原理


Koa通过compose函数来组合中间件,实现洋葱圈模型。compose接收一个中间件数组作为参数,执行数组中的中间件,返回一个可以执行所有中间件的函数。


compose函数的实现源码如下:


function compose (middleware{

  return function (context, next{
    // last called middleware #
    let index = -1
    return dispatch(0)
    function dispatch (i{
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

这里利用了函数递归的机制。dispatch函数接收当前中间件的索引i,如果i大于中间件数组长度,则执行next函数。如果i小于中间件数组长度,则取出对应索引的中间件函数执行。


中间件的执行过程


中间件的执行过程


执行中间件函数的时候,递归调用dispatch,同时将索引+1,表示执行下一个中间件。


这样通过递归不断调用dispatch函数,就可以依次执行每个中间件,实现洋葱圈模型。


所以Koa的洋葱圈模型实现得非常简洁优雅,这也是Koa作为新一代Node框架,相比Express更优秀的设计。


洋葱圈模型的优势


提高中间件的复用性


洋葱模型让每个中间件都可以控制请求前和请求后,这样中间件可以根据需要完成各种额外的功能,不会相互干扰,提高了中间件的复用性。


使代码结构更清晰


洋葱模型层层嵌套,执行流程一目了然,代码阅读性好,结构清晰。不会像其他模型那样回调多层嵌套,代码难以维护。


异步编程更简单


洋葱模型通过async/await,使异步代码可以以同步的方式编写,没有回调函数,代码逻辑更清晰。


错误处理更友好


每个中间件都可以捕获自己的错误,并且不会影响其他中间件的执行,这样对错误处理更加友好。


方便Debug


通过洋葱模型可以清楚看到每个中间件的进入和离开,方便Debug。


便于扩展


可以随意在洋葱圈的任意层增加或删除中间件,结构灵活,便于扩展。


总结


总体来说,洋葱模型使中间件更容易编写、维护和扩展,这也是Koa等新框架选择它的主要原因。它的嵌套结构和异步编程支持,使Koa的中间件机制更优雅和高效。


作者:一码平川哟
来源:juejin.cn/post/7262158134323560508
收起阅读 »

2023.28 forEach 、for ... in 、for ... of有什么区别?

web
大家好,我是wo不是黄蓉,今年学习目标从源码共读开始,希望能跟着若川大佬学习源码的思路学到更多的东西。 forEach 、for ... in 、for ... of有什么区别 forEach 数组提供的方法,只能遍历数组 遍历数组:for...in key返...
继续阅读 »

大家好,我是wo不是黄蓉,今年学习目标从源码共读开始,希望能跟着若川大佬学习源码的思路学到更多的东西。


forEach 、for ... in 、for ... of有什么区别


forEach 数组提供的方法,只能遍历数组


遍历数组:for...in key返回数组下标;for...of key返回值;


1690806838416.png
遍历对象:for...in key返回对象的键;for...of 遍历对象报错,提示没有实现person对象不可迭代;


1690806968808.png


iterable什么是可迭代对象?


简单来说就是可以使用for...of遍历的对象,也就是实现了[Symbol.iterator]


迭代和循环有什么区别?


遍历强调把整个数据依次全部取出来,是访问数据结构的所有元素;


迭代虽然也是一次取出数据,但是并不保证取多少,需要调用next方法才能获取数据,不保证把所有的数据取完,是遍历的一种形式。


有哪些对象是可迭代对象呢?


原生的可迭代对象 set map nodelist arguments 数组 string


迭代器是针对某个对象的,有些对象是自己继承了Symbol.Iterator,也可以实现自己的迭代器,必须要实现一个next方法,返回内容



{value:any,done:boolean}

实现对象的迭代器


如果要实现迭代器,需要实现[Symbol.Iterator]是一个函数,这个函数返回一个迭代器


// let arr = ['a', 'b', 'c']
let person = {
name: 'a',
age: 18,
myIterator: function () {
var nextIndex = 0
return {
next: () => {
const array = Object.values(this)
return nextIndex < array.length
? { value: array[nextIndex++], done: false }
: { value: undefined, done: true }
}
}
}
}

let myIterator = person.myIterator()
console.log(person.myIterator())//{ next: [Function: next] }
console.log(myIterator.next())//{ value: 'a', done: false }
console.log(myIterator.next())//{ value: 18, done: false }
console.log(myIterator.next())//{ value: [Function: myIterator], done: false }
console.log(myIterator.next())//{ value: undefined, done: true }
{ value: undefined, done: true }

按道理实现了迭代器该对象就会变为可迭代对象了,可以使用for..of遍历


但是执行后发现还是会提示Person不是可迭代的,是因为for..of只能遍历实现了[Symbol.iterator]接口的的对象,因此我们写的方法名要使用[Symbol.iterator]


1690873941456.png


修改后:


let person = {
name: 'a',
age: 18,
[Symbol.iterator]: function () {
var nextIndex = 0
return {
next: () => {
const array = Object.values(this)
return nextIndex < array.length
? { value: array[nextIndex++], done: false }
: { value: undefined, done: true }
}
}
}
}

//for..in
for (let key in person) {
console.log(key, person[key])
}

//for...of
for (let key of person) {
console.log(key)
}

//打印结果
name a
age 18
a
18

什么时候会用迭代器?


应用场景:可以参考阮一峰老师列举的例子


js语法:for ... of 展开运算符 yield 解构赋值


创建对象时:new map new set new weakmap new weakset


一些方法的调用:promise.all promise.race array.from


for in 和for of 迭代器、生成器(generator)


迭代器中断:


迭代器中定义return方法在迭代器提前关闭时执行,必须返回一个对象


break return throw 在迭代器的return 方法中可以捕获到



let person = {
name: 'a',
age: 18,
[Symbol.iterator]: function () {
var nextIndex = 0
return {
next: () => {
const array = Object.values(this)
return nextIndex < array.length
? { value: array[nextIndex++], done: false }
: { value: undefined, done: true }
},
return: () => {
console.log('结束迭代')
return { done: true }
}
}
}
}

//for...of
for (let key of person) {
console.log(key)
if (key === 'a') break
}

//打印结果
a
结束迭代



作者:wo不是黄蓉
来源:juejin.cn/post/7262212980346404922
>结束:下节讲生成器

收起阅读 »

😋贪心算法

贪心算法 贪心算法是一种寻找最优解的算法思想,它通过局部最优选择来达到全局最优解。在贪心算法中,每一步都会做出当前状态下的最优选择,并且假设做出这样的选择后,剩余的问题可以被简化为一个更小的子问题。 与动态规划不同,贪心算法不需要保存子问题的解,因此通常需要更...
继续阅读 »

贪心算法


贪心算法是一种寻找最优解的算法思想,它通过局部最优选择来达到全局最优解。在贪心算法中,每一步都会做出当前状态下的最优选择,并且假设做出这样的选择后,剩余的问题可以被简化为一个更小的子问题。


与动态规划不同,贪心算法不需要保存子问题的解,因此通常需要更少的空间和时间。


贪心算法通常采用一种贪心的策略,即在每一步选择当前看起来最优的选择,希望最终得到全局最优解。但是,在某些情况下,局部最优解并不能保证一定能够导致全局最优解。由于贪心算法一旦做出选择就不能更改。贪心算法只是一种近似算法。


贪心算法通常需要满足贪心选择性质和最优子结构性质,否则它可能会导致错误的结果。


在使用贪心算法时,我们需要仔细考虑问题的特点和贪心选择的合理性,并尽可能地证明贪心算法的正确性。如果无法证明贪心算法的正确性,我们需要考虑使用其他算法来解决问题。


贪心算法常见的应用场景包括:



  • 贪心选择性质:在求解最优解的过程中,每一步的选择只与当前状态有关,不受之前选择的影响。

  • 最优子结构性质:问题的最优解可以被分解为若干个子问题的最优解,即子问题的最优解可以推导出原问题的最优解。

  • 无后效性:某个状态以前的过程不会影响以后的状态,只与当前状态有关。


举个反例🌰:279. 完全平方数


给你一个整数 n ,返回 和为 n 的完全平方数的最少数量 。


完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,149 和 16 都是完全平方数,而 3 和 11 不是。


 


示例 1:


输入: n = 12
输出: 3
解释: 12 = 4 + 4 + 4

示例 2:


输入: n = 13
输出: 2
解释: 13 = 4 + 9

 


提示:



  • 1<=n<=1041 <= n <= 10^4


错误做法:


class Solution:
def numSquares(self, n: int) -> int:
count = 0
while n != 0:
c = int(n**(1/2))
n -= c**2
count += 1
return count

输入12的时候答案是4,也就是12 = 9 + 1 + 1 + 1


实际上应该是答案为312 = 4 + 4 + 4


这个函数使用的是贪心算法的思想,每次都选择当前能用的最大完全平方数来减去 n,直到 n 减为 0。


在每一步中,选择最大的完全平方数来减去 n,可以确保所需的完全平方数的数量最小,因为如果我们选择了小的完全平方数,那么我们需要更多的完全平方数才能表示 n。


但是它并没有证明贪心策略的正确性,也没有提供正确性的证明。我们已经提供反例,证明这玩意儿是错的了。贪心算法的正确性得不到保证,所以本题不能用贪心算法。


正确答案:


class Solution:
def numSquares(self, n: int) -> int:
dp = [float('inf')]*(n+1)
dp[0] = 0
for i in range(1,n+1):
j = 1
while j*j <= i:
dp[i] = min(dp[i],dp[i-j*j]+1)
j+=1
return dp[-1]

这个代码使用了动态规划来解决完全平方数问题,它的时间复杂度为 O(nn)O(n\sqrt{n}),空间复杂度为 O(n)O(n)




  • i=0 时,不需要任何完全平方数。




  • 对于 i>0 的情况,我们枚举从 1i 中的每个完全平方数 j*j,然后计算 dp[i-j*j]+1 的值,这个值表示在将 i-j*j 分解成完全平方数之和的基础上再加上一个完全平方数 j*j。我们需要使 dp[i-j*j]+1 的值最小,因此我们可以得出状态转移方程:




dp[i]=min(dp[i],dp[ijj]+1)dp[i] = min(dp[i], dp[i-j * j]+1)

最后,dp[n] 的值就是将 n 分解成完全平方数之和所需的最小个数。


该代码正确地解决了完全平方数问题,可以得到全局最优解。


55. 跳跃游戏


给定一个非负整数数组 nums ,你最初位于数组的 第一个下标 。


数组中的每个元素代表你在该位置可以跳跃的最大长度。


判断你是否能够到达最后一个下标。


 


示例 1:


输入: nums = [2,3,1,1,4]
输出: true
解释: 可以先跳 1 步,从下标 0 到达下标 1, 然后再从下标 1 跳 3 步到达最后一个下标。

示例 2:


输入: nums = [3,2,1,0,4]
输出: false
解释: 无论怎样,总会到达下标为 3 的位置。但该下标的最大跳跃长度是 0 , 所以永远不可能到达最后一个下标。

 


提示:



  • 1 <= nums.length <= 3 * 104

  • 0 <= nums[i] <= 105


class Solution:
def canJump(self, nums: List[int]) -> bool:
maxlen = 0
for i,n in enumerate(nums):
if maxlen < i:
return False
maxlen = max(maxlen,i+n)
return maxlen >= len(nums) -1

这段代码实现了一个非常经典的贪心算法,用于判断能否从数组的起点跳到终点。


具体思路是,用 maxlen 记录当前能到达的最远位置,遍历数组中的每个位置,如果当前位置大于 maxlen,说明无法到达该位置,直接返回 False。否则,更新 maxlen 为当前位置能够到达的最远位置。


这个算法的贪心策略是,在每个位置上都选择能够到达的最远位置。由于跳跃的步数只能是整数,所以如果当前位置能到达的最远位置小于当前位置,那么就无法到达该位置。


这个算法的时间复杂度是 O(n)O(n),空间复杂度是 O(1)O(1)


45. 跳跃游戏 II


给定一个长度为 n 的 0 索引整数数组 nums。初始位置为 nums[0]


每个元素 nums[i] 表示从索引 i 向前跳转的最大长度。换句话说,如果你在 nums[i] 处,你可以跳转到任意 nums[i + j] 处:



  • 0 <= j <= nums[i] 

  • i + j < n


返回到达 nums[n - 1] 的最小跳跃次数。生成的测试用例可以到达 nums[n - 1]


 


示例 1:


输入: nums = [2,3,1,1,4]
输出: 2
解释: 跳到最后一个位置的最小跳跃数是 2。
  从下标为 0 跳到下标为 1 的位置,跳 1 步,然后跳 3 步到达数组的最后一个位置。

示例 2:


输入: nums = [2,3,0,1,4]
输出: 2

 


提示:



  • 1 <= nums.length <= 104

  • 0 <= nums[i] <= 1000

  • 题目保证可以到达 nums[n-1]


class Solution:
def jump(self, nums) -> int:
minstep = 0
i = len(nums) - 1
while i > 0:
for j,n in enumerate(nums):
if j+n >= i:
minstep += 1
i = j
break
return minstep

该算法的时间复杂度为 O(n2)O(n^2),其中 nn 为数组的长度。


在最坏情况下,每个元素都需要遍历一遍,以找到它们能够到达的最远距离,这需要 O(n)O(n) 的时间复杂度。同时,每次找到能够到达 ii 的最远距离时,都需要遍历从 00i1i-1 的所有元素,以找到能够到达 ii 的最小步数,这也需要 O(n)O(n) 的时间复杂度。因此,总时间复杂度为 O(n2)O(n^2)


该算法的空间复杂度为 O(1)O(1),因为它只使用了常数级别的额外空间。


优化——从前往后跳:


这个算法是一个基于贪心策略的解法,跟之前的从前往后跳的贪心算法类似,不过稍微做了一些改进,可以将时间复杂度降低到 O(n)O(n)


算法的核心思想是维护一个区间 [0, end],在这个区间内每个位置所能跳到的最远距离都是 i + nums[i],其中 i 是当前位置,nums[i] 是当前位置所能跳的最远距离。维护的时候,我们不断更新能够到达的最远距离 maxlen,当 i 到达区间的末尾 end 时,说明需要跳一步,并将 end 更新为 maxlen


这个算法的时间复杂度为 O(n)O(n),空间复杂度为 O(1)O(1)


class Solution:
def jump(self, nums):
n = len(nums)
maxlen = end = 0
step = 0
for i in range(n - 1):
maxlen = max(maxlen, i + nums[i])
if i == end:
end = maxlen
step += 1
return step
作者:Ann
来源:juejin.cn/post/7262231954191859770

收起阅读 »

忙里偷闲IdleHandler

在Android中,Handler是一个使用的非常频繁的东西,输入事件机制和系统状态,都通过Handler来进行流转,而在Handler中,有一个很少被人提起但是却很有用的东西,那就是IdleHandler,它的源码如下。/** * Callback int...
继续阅读 »

在Android中,Handler是一个使用的非常频繁的东西,输入事件机制和系统状态,都通过Handler来进行流转,而在Handler中,有一个很少被人提起但是却很有用的东西,那就是IdleHandler,它的源码如下。

/**
* Callback interface for discovering when a thread is going to block
* waiting for more messages.
*/
public static interface IdleHandler {
/**
* Called when the message queue has run out of messages and will now
* wait for more. Return true to keep your idle handler active, false
* to have it removed. This may be called if there are still messages
* pending in the queue, but they are all scheduled to be dispatched
* after the current time.
*/
boolean queueIdle();
}

从注释我们就能发现,这是一个IdleHandler的静态接口,可以在消息队列没有消息时或是队列中的消息还没有到执行时间时才会执行的一个回调。

这个功能在某些重要但不紧急的场景下就非常有用了,比如我们要在主页上做一些处理,但是又不想影响原有的初始化逻辑,避免卡顿,那么我们就需要等系统闲下来的时候再来执行我们的操作,这个时候,我们就可以通过IdleHandler来进行回调。

它的使用也非常简单,代码示例如下。

Looper.myQueue().addIdleHandler {
// Do something
false
}

在Handler的消息循环中,一旦队列里面没有需要处理的消息,该接口就会回调,也就是Handler空闲的时候。

这个接口有返回值,代表是否需要持续执行,如果返回true,那么一旦Handler空闲,就会执行IdleHandler中的回调,而如果返回false,那么就只会执行一次。

当返回true时,可以通过removeIdleHandler的方式来移除循环的处理,如果是false,那么在处理完后,它自己会移除。

综上,IdleHandler的使用主要有下面这些场景。

  • 低优先级的任务处理:替换之前为了不在初始化的时候影响性能而使用的Handler.postDelayed方法,通过IdleHandler来自动获取空闲的时机。
  • Idle时循环处理任务:通过控制返回值,在系统空闲时,不断重复某个操作。

但是要注意的是,如果Handler过于繁忙,那么IdleHandler的执行时机是有可能被延迟很久的,所以,要注意一些比较重要的处理逻辑的处理时机。

在很多第三方库里面,都有IdleHandler的使用,例如LeakCanary,它对内存的dump分析过程,就是在IdleHandler中处理的,从而避免对主线程的影响。


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

优化 Android Handler提升性能与稳定性

介绍 HandlerHandler 是一个常见的组件,它在 Android 应用程序开发中被广泛使用。Handler 可以将消息传递给主线程,使开发者能够在子线程中进行长时间的耗时操作,同时也避免了因在主线程中更新 UI 而出现的卡顿和 A...
继续阅读 »

介绍 Handler

Handler 是一个常见的组件,它在 Android 应用程序开发中被广泛使用。Handler 可以将消息传递给主线程,使开发者能够在子线程中进行长时间的耗时操作,同时也避免了因在主线程中更新 UI 而出现的卡顿和 ANR 问题。

Handler 的问题

尽管 Handler 能够帮助处理一些繁琐的任务,然而如果不进行优化,Handler 自身却可能成为你应用程序的问题所在。

以下列出一些常见的 Handler 问题:

内存泄漏

因为 Handler 实例通常会保留对主线程的引用,而主线程通常不会被销毁,所以你在应用程序中使用 Handler时,很有可能会遇到内存泄漏的问题。

ANR

在处理大量消息时,使用 Handler 造成运行过程变慢。此时,当主线程无法在规定时间内完成属于它的操作时,就会发生一种无法响应的情况 ANR。

线程安全问题

如果你没有很好地处理并发问题,Handler 在多个线程中对同一实例的使用,可能会引发线程的安全问题。

优化方法

为了避免以上问题,可以尝试以下优化方法:

使用静态内部类

一个优化处理内存泄漏的方法是将 Handler 实例声明为静态内部类。这样,Handler 将不会保留对外部类的引用,从而避免了内存泄漏。

public class MyActivity extends Activity {

private static class MyHandler extends Handler {
private final WeakReference<MyActivity> mActivity;

public MyHandler(MyActivity activity) {
mActivity = new WeakReference<MyActivity>(activity);
}

@Override
public void handleMessage(Message msg) {
MyActivity activity = mActivity.get();
if (activity != null) {
// do something
}
}
}

private final MyHandler mHandler = new MyHandler(this);
}

移除Handler的回调

为了避免Handler泄露,可以再在Activity或Fragment的生命周期方法中移除Handler的回调。

@Override
protected void onDestroy() {
super.onDestroy();
handler.removeCallbacksAndMessages(null);
}

使用子线程与消息延迟

为避免 Handler 运行缓慢和 ANR 的问题, 可以将耗时任务放在子线程中执行,并在需要更新UI时使用Handler进行线程间通信。 如果消息队列中的消息太多,可以让主线程先处理其他任务,再延迟消息的处理时间。

Handler handler = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message msg) {
// 在主线程更新UI
}
};

// 在子线程中执行耗时任务
new Thread(new Runnable() {
@Override
public void run() {
// 执行耗时操作

handler.sendMessage(handler.obtainMessage());
}
}).start();
private static final int MAX_HANDLED_MESSAGE_COUNT = 500;

private Handler mHandler = new Handler() {
private int mHandledMessageCount = 0;

@Override
public void handleMessage(Message msg) {
// do something

mHandledMessageCount++;
if (mHandledMessageCount > MAX_HANDLED_MESSAGE_COUNT) {
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
mHandledMessageCount = 0;
}
}, 1000);
}
}
};

使用 HandlerThread

为了避免出现线程安全问题,可以使用 HandlerThread 来创建线程从而处理消息。这样做的好处是不必担心多个线程同时访问同一个 Handler 实例的问题。

public class MyHandlerThread extends HandlerThread {
private static final String TAG = "MyHandlerThread";

private Handler mHandler;

public MyHandlerThread() {
super(TAG);
}

@Override
protected void onLooperPrepared() {
mHandler = new Handler(getLooper()) {
@Override
public void handleMessage(Message msg) {
// do something
}
};
}

public Handler getHandler() {
return mHandler;
}
}

使用 SparseArray

如果你的应用程序中有多个 Handler,可以使用 SparseArray 来管理它们。SparseArray 是一个类似于 HashMap的数据结构,它可以非常高效地管理多个 Handler 实例。

private SparseArray<Handler> mHandlerArray = new SparseArray<>();

private void initHandlers() {
mHandlerArray.put(1, new Handler() {
@Override
public void handleMessage(Message msg) {
// do something
}
});

mHandlerArray.put(2, new Handler() {
@Override
public void handleMessage(Message msg) {
// do something
}
});

// add more handlers
}

private void handleMessages(int handlerId, Message msg) {
Handler handler = mHandlerArray.get(handlerId);
if (handler != null) {
handler.handleMessage(msg);
}
}

使用 MessageQueue.IdleHandler

如果你的应用程序中有长时间运行的任务,可以使用 MessageQueue.IdleHandler 来执行它们。MessageQueue.IdleHandler 是一个回调接口,它可以在没有消息时执行任务。

private void executeLongRunningTask() {
Looper.myQueue().addIdleHandler(new MessageQueue.IdleHandler() {
@Override
public boolean queueIdle() {
// do something
return false; // remove the idle handler
}
});
}

结论

Handler 作为 Android 应用程序中非常重要的一个组件,但如果不进行优化,将可能影响应用程序的性能和稳定性。通过这篇文章,我们可以有效地避免问题的出现,让应用程序更加高效稳定。

推荐

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

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

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

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

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


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

一篇文章带你学会Kotlin

都2023年了,新建的项目还是Java项目,或者你还在写Java样式的Kotlin项目,仔细看完这篇文章,带你从Java转到Kotlin,带你学会Koltin,从入坑到脱坑为什么要学习KotlinKotlin是Andorid官方推荐语言最年来Google发布很...
继续阅读 »

都2023年了,新建的项目还是Java项目,或者你还在写Java样式的Kotlin项目,仔细看完这篇文章,带你从Java转到Kotlin,带你学会Koltin,从入坑到脱坑

为什么要学习Kotlin

  1. KotlinAndorid官方推荐语言
  2. 最年来Google发布很多新玩意,都是Kotlin写的,对Kotlin支持比较友好
  3. Compose你不会Kotlin怎么学习
  4. 一些大型开源项目,比如OkhttpRetrofitGlide都改成了Kotlin版本
  5. 使用协程,让你切换线程更加方便,摆脱回调地狱
  6. 让你的代码更简洁

综上所示,笔者认为,Kotlin如今是一名Android开发工程师所必须要掌握的技能,但是偏偏还是有很多人不用,不学,所以就写下来这篇文章,带你快速入门Kotlin,也算是对自己知识的一遍巩固

基础

何为Kotlin,笔者认为是如何快速定义变量,常量,new一个对象,调用一个方法,来看一下Java是怎么做的

int a = 10;
a = 11;
TextView textView = new TextView(context);
textView.setText(a);

嗯,还是比较简洁的,但是还可以更简洁,看一下相同的代码使用Kotlin如何完成

fun Test(context: Context?) {
var a = 10
a = 11
val textView = TextView(context)
textView.text = a.toString()
}

解释一下,Kotlin定义常量是val,变量为,var,什么类型,根本不需要,它会通过后面得内容自动推导出来是什么类型的,但是从本质来说,Kotlin是还是强类型语言,只不过编译器会自动推导出来他真的类型而已,然后是不用写new关键字了,不用写;结尾了,getset方法也不用写,直接等,实际上还是调用了真的getset,原因是通过了属性访问器(accessor)的语法糖形式直接使用等号进行赋值和获取

接下来看一下类,点即创建一个Kotlin类型得File,会出来如下弹框

image.png

  • Class 和JavaClass没什么两样,一般就是一个类
  • File 如果当一个Class中有两个同级别的类,这个时候就会变为File,这个一般在写扩展函数的时候使用,扩展函数后面会讲到
  • Interface 和JavaInterface一样,一个接口
  • Sealed interface 封闭接口,防止不同model之间的互相调用,比如你再B Model中定义 B Sealed interface,那么你只能在B Model中使用这个接口,除此之外,还是使用此接口完成多继承的操作
  • Data class 实体类,和Java实体类有什么不同呢,不用写getset方法和普通Kotlin Class有什么不同呢,首先是必须要有有参构造方法,然后重写了hashCodeequals方法
  • Enum class 枚举类,和Java一样
  • Sealed class 和Sealed interface差不多,都是限制只能在一个Model中使用
  • Annotation 定义一个注解
  • Object 这个笔者最喜欢,常用来定义一个单例类,相当于Java的饿汉式 其中比较常用的有ClassData class,Object总的来说还是比Java的类型多一点

返回值

为什么要单写一个返回值,因为Kotlin的所有方法都有返回值,常规的就不说,看下面代码

val a: Unit = printA()

private fun printA() {
print("A")
}

这里面定义了函数,没有定义任何返回值,但是其实的类型是Unit,我的理解是Unit就代表它是一个函数类型和String一样都是Koltin类型中的一种,明白了这点就可以理解接下来的操作

val a = if ( x > y) x else y

相当于Java的三元换算符,如果x大于y就等于x,否则就等于y,对了Kotlin是没有三元换算符这个概念的,如果要达到相同效果只有使用if...else...,同理when也同样适用于这种操作

还是一种建立在编译器类型推导的基础上一种写法

private fun getA() = {
val a = 0
a
}

这个函数的意义就是返回了一个数字a,为什么没有return 它却拥有返回值返回值,请注意看这个=号,编译器给他推导出来了,并且通过lamaba返回值 还有一个非常特殊的返回值 Nothing 什么意思呢 不是null 就是什么也没有 具体可以看一下这篇文章

NULL安全处理

Kotlin是一个null安全的语言下面详细看一下

//定义一个变量
private var a: String? = null

String后面有个?代表,这个变量是可能为null的那么就需要在使用的时候处理它,不然会报错

if (a != null){
//不为null
}else{
//为null
}
//或者加上!!,代表这个时候肯定不为null,但是一般不建议这样写,玩出现null,就会空指针异常
a!!

当然如果确定使用的时候肯定部位null也可以这样写

private lateinit var a: String

代表定义了一个变量,我不管之前对它做了什么操作,反正我使用的时候,它一定是不为null的,当然与之的对应的还有by lazy延迟初始化,当第一次使用的时候才会初始化,比如这样,当我第一次调用a的时候,by lazy的括号内容即委托就会执行给a赋值,值得注意的是by lazy不是绑定在一起的 也可以只使用by意思也是委托,不过要单独写一个委托的实现

private val a: String by lazy { "123" }

扩展函数

扩展函数可以说是Kotlin比较爽的部分,他的本质其实是一个Kotlin的静态方法然后返回了它原本得了类型,比如这段代码

fun String?.addPlus(number: Int): String? {
if (this == null){
return null
}
return this.toInt().plus(number).toString()
}

扩展了String的方法,在String的基础上添加了一个addPlus方法,然后接受一个number参数,然后强转为int类型之后加上number并返回,这样扩展不用继承也可以在一些类中添加方法,减少了出BUG的可能性

val str = "1"
val str2 = str.addPlus(2)

看一这段代码的本质,可以知道Koltin和Java是可以互相转换的

public static final String addPlus(@Nullable String $this$addPlus, int number) {
return $this$addPlus == null ? null : String.valueOf(Integer.parseInt($this$addPlus) + number);
}

可以看到,扩展函数的本质是一个静态方法,并且多了一个String类型的参数,这个参数其实就是扩展函数类的实体,利用扩展函数可以实现简化很多操作,比如金额计算就可以扩展String,然后接收一个String,又或者是给textView添加一个功能,同样可以使用,我认为它差不多就是简化了静态工具类的使用,让其更方便,更快捷

扩展函数二 let also run apply

还记得上面提到的Kotlin对于null的判断吗,其实拥有更快快捷的方法就是使用扩展函数看这一段代码

fun stringToInt(str: String?): Int {
return str?.let {
it.toIntOrNull() ?: -1
} ?: run {
-1
}
}

一段链式调用,这也是Kotlin的特性之一,后续有时间会讲,链式调用有好处也有坏处,好处是可以更好的代码,坏处是一旦使用了过长的链式调用,后期代码维护就会很麻烦,为什么我会知道,因为我就写过,后期维护的时候痛不欲生,开发一时爽维护两行泪,但是适量使用这种,会让你的代码变得更简洁,维护更方便,主要还是看工程师的把握度。

好了具体看一下代码做了什么工作,首先定义了一个stringToInt的函数,然后接受了一个String参数,这个参数可以为null,然后判断是否等于null,等于null返回-1,不能转化为int返回-1,可以正常转化返回int值 Ok 先解释一下如何利用扩展函数做null判断 ?.let代表如果str这个值不为null,那么就执行it.toIntOrNull() ?: -1,否则这里用 ?:来处理即为null 就走run 然后返回-1

  • let 提供了一个以默认值it的空间,然后返回值的是当前执行控件的最后一行代码
  • also 提供了一个以默认值it的空间,然后返回值是它本身
  • run 提供了一个this的命名空间,然后返回最后一行代码
  • apply 提供了一个this的命名空间,然后返回它本身 这里贴出Kotlin的实现,源码其实很简单
@kotlin.internal.InlineOnly
public inline fun <T, R> T.let(block: (T) -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block(this)
}

@kotlin.internal.InlineOnly
@SinceKotlin("1.1")
public inline fun <T> T.also(block: (T) -> Unit): T {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
block(this)
return this
}

@kotlin.internal.InlineOnly
public inline fun <T, R> T.run(block: T.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block()
}


@kotlin.internal.InlineOnly
public inline fun <T> T.apply(block: T.() -> Unit): T {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
block()
return this
}

代码其实很简单,有兴趣可以自己随便玩玩,这里就以apply为例分析一下,首先返回一个泛型T,这个T就是调用者本身,然后接口了一个black的函数,这个函数就是实际在代码中apply提供的空间,然后执行完成后,然后调用者本身,这样就和上文对应上了,apply 提供了一个this的命名空间,然后返回它本身,也可以仿照实现一个自己myApply哈哈哈,

结语

这篇文章其实原本想写的非常多,还有很多关键字没有介绍,比如by,inline,受限于篇幅问题,暂时把最基础的写了一写,以后会逐步完善这个系列,在写的过程,也有一些是笔者使用起来相对来说比较少的,就比如Sealed interface这个接口,之前就完全不理解,在写的时候特意去查询了一下资料,然后自己测试了一番,才理解,也算是促进了自己学习,希望可以共同进步


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

接口设计

大家好,我是二营长,日拱一卒无有尽,功不唐捐终入海。这里是Java学习小站,关注我,每天进步一点点!接口的重要性:在日常的开发中,在需求确定之后,后端同学首先要做的就是定义接口,接口定义完成之后,前端的同学就可以看接口文档和后端进行同步开发了。接口文档的作用还...
继续阅读 »

大家好,我是二营长,日拱一卒无有尽,功不唐捐终入海。这里是Java学习小站,关注我,每天进步一点点!


接口的重要性:

在日常的开发中,在需求确定之后,后端同学首先要做的就是定义接口,接口定义完成之后,前端的同学就可以看接口文档和后端进行同步开发了。接口文档的作用还有很多:

  1. 沟通:开发、测试和其他人员之间的沟通渠道;它定义了接口的规范和预期行为,确保所有团队成员对接口的功能和使用方式有共同的理解。
  2. 效率:开发人员可以根据文档准确地了解如何调用接口、传递参数以及处理响应。这减少了开发过程中的试错和猜测,使开发人员能够更加专注于业务逻辑的实现。
  3. 并行开发:当多个开发人员同时工作在一个项目中时,接口文档允许他们独立地开发和测试各自的模块。通过定义清晰的接口规范,团队成员可以并行工作,而无需过多的交流和依赖。
  4. 代码质量:清晰的接口先行的方式,可以促使开发人员编写更健壮和可靠的代码。接口定义之后,整个交互过程就了然于胸了。
  5. 方便集成:当不同的系统或团队之间需要进行集成时,接口文档起到了关键的作用。通过提供准确和详细的接口规范,文档可以帮助团队避免集成过程中的误解和错误,降低集成风险。
  6. 支持第三方开发:如果你的应用程序或服务允许第三方开发者使用你的接口,好的接口文档是必不可少的。它为第三方开发者提供了准确的接口描述和示例代码,促进了他们与你的系统进行集成和开发扩展。

工作中常见的维护接口文档的方式:

使用Swagger、YApi等自动化接口管理平台。

Swagger和YApi等工具提供了自动化生成接口文档的功能,它们可以通过解析代码注释、接口定义或接口调用等方式,自动生成接口文档。这样可以减少手动编写和维护文档的工作量,同时确保文档与实际接口保持同步。

这些自动化管理平台还提供了其他有用的功能,例如接口测试、Mock数据生成、权限管理等。它们通常具备用户友好的界面和交互,可以方便团队成员共同编辑和维护接口文档,提高团队协作效率。

怎么设计好一个接口

我曾经遭遇过面试官,疯狂追问接口使如何设计的,虽然这是日常工作的一部分,但是很遗憾我没有表述清楚。

大部分的互联网项目都选择使用HTTP请求的方式进行交互的。

HTTP请求的组成

HTTP请求通常包括以下几个部分:

  1. 请求行(Request Line):包括请求方法(如GET、POST)、请求的URL路径和协议版本(如HTTP/1.1)。

  2. 请求头部(Request Headers):包括多个键值对,用于传递请求的元信息。常见的请求头部字段包括Host、User-Agent、Content-Type、Authorization等。

  3. 空行(Blank Line):请求头部与请求体之间需要有一个空行,用于分隔请求头部和请求体。

  4. 请求体(Request Body):对于某些请求方法(如POST),可以包含请求的内容,如表单数据、JSON数据等。对于其他请求方法(如GET),请求体通常为空。

HTTP请求报文的方式:

HTTP请求报文的方式主要有以下几种:

  1. GET请求:GET请求通过URL参数传递数据,将请求参数附加在URL的末尾,以?开头,多个参数使用&分隔。GET请求的数据会明文显示在URL中,适合用于请求获取资源,对数据安全性要求较低的情况。

  2. POST请求:POST请求将数据放在请求体中传递,适合用于提交表单、上传文件等操作。POST请求的数据不会显示在URL中,相对于GET请求更加安全,但需要在请求头中指定请求体的内容类型(Content-Type)。

  3. PUT请求:PUT请求用于更新(全量替换)指定资源的信息。PUT请求将数据放在请求体中传递,类似于POST请求,但PUT请求要求对指定的资源进行完全替换,而不是部分修改。

  4. PATCH请求:PATCH请求用于部分更新指定资源的信息。PATCH请求将数据放在请求体中传递,用于对资源进行局部修改,而不是全量替换。PATCH请求可以避免对整个资源进行完全替换的开销。

  5. DELETE请求:DELETE请求用于删除指定的资源。DELETE请求通常不包含请求体,而是通过URL指定要删除的资源的路径。

RESTful API 接口规范

REST(Representational State Transfer)是一种软件架构风格和设计原则,用于构建分布式系统和网络应用程序。RESTful是基于REST原则定义的一组规范和约束,用于设计和开发Web API接口。

在RESTful规范中,可以理解为一切即资源,所有请求都是对资源的操作或查询。

RESTful架构中的几个核心概念:

  1. 资源(Resources):每种资源都有一个唯一的统一资源定位符(URI),用于标识和定位该资源。URI代表资源的地址或唯一识别符。

  2. 表现层(Representation):资源的表现层是指将资源具体呈现出来的形式。URI只表示资源的位置,而资源的具体表现形式可以通过HTTP请求的头信息中的Accept和Content-Type字段来指定。这两个字段描述了资源的表现层。

  3. 状态转化(State Transfer):客户端要操作服务器上的资源,需要通过某种方式触发服务器端的状态转变。这种转变是建立在表现层之上的,因此称为"表现层状态转化"。

在RESTful架构中,客户端使用HTTP协议中的四个表示操作方式的动词(GET、POST、PUT、DELETE)来实现状态转化。这些动词分别对应着四种基本操作:GET用于获取资源,POST用于新建资源(也可用于更新资源),PUT用于更新资源,DELETE用于删除资源。

简要总结:

  • 每个URI代表一种资源。
  • 客户端和服务器之间传递资源的表现层。
  • 客户端通过HTTP动词对服务器端资源进行操作,实现表现层状态转化。

举个例子

API命名规范:面向资源命名

当设计符合RESTful规范的接口时,可以在URL路径中添加版本号或者命名空间,以提供更好的可扩展性和可维护性。

获取所有文章:

请求方法:GET

URL路径:/api/articles

示例请求:GET /api/articles

示例响应:

{
  "articles": [
    {
      "id"1,
      "title""RESTful 接口设计",
      "content""这是一篇关于RESTful接口设计的文章。"
    },
    {
      "id"2,
      "title""RESTful 接口实现",
      "content""这是一篇关于RESTful接口实现的文章。"
    }
  ]
}

获取单个文章:

请求方法:GET

URL路径:/api/articles/{id}

示例请求:GET /api/articles/1

示例响应:

{
  "id"1,
  "title""RESTful 接口设计",
  "content""这是一篇关于RESTful接口设计的文章。"
}

创建文章:

请求方法:POST

URL路径:/api/articles

示例请求:

POST /api/articles
Content-Type: application/json

{
  "title""新的文章",
  "content""这是一个全新的文章。"
}

示例响应:

{
  "id"3,
  "title""新的文章",
  "content""这是一个全新的文章。"
}

更新文章:

请求方法:PUT

URL路径:/api/articles/{id}

示例请求:

PUT /api/articles/1
Content-Type: application/json

{
  "title""更新后的文章",
  "content""这是一篇更新后的文章。"
}

示例响应:

{
  "id"1,
  "title""更新后的文章",
  "content""这是一篇更新后的文章。"
}

删除文章:

请求方法:DELETE

URL路径:/api/articles/{id}

示例请求:DELETE /api/articles/1

示例响应:

{
  "message""文章已成功删除。"
}

通过在URL路径中添加/api前缀,可以更好地组织和管理接口,区分不同的功能模块或者版本。这种方式可以提高接口的可扩展性和可维护性,同时也符合常见的API设计实践。

定义统一的请求或响应参数

请求参数:

在定义请求参数时,可以根据具体的业务需求和安全考虑,包括一些常见的参数类型和参数名称。下面是一些常见的请求参数定义:

  1. 查询参数(Query Parameters):这些参数通常包含在URL中,以键值对的形式出现,用于过滤、排序、分页等操作。例如,对于获取文章列表的接口,可以接受page和limit参数来指定返回的页数和每页的数量。

  2. 路径参数(Path Parameters):这些参数通常嵌入在URL路径中,用于标识资源的唯一标识符或其他信息。例如,对于获取单个文章的接口,可以将文章ID作为路径参数,如/articles/{id}。

  3. 请求体参数(Request Body Parameters):这些参数通常包含在请求的消息体中,以JSON、XML或其他格式进行传输,用于传递复杂或大量的数据。例如,对于创建文章的接口,可以将文章的标题、内容等信息作为请求体参数。

  4. 请求头参数(Request Header Parameters):这些参数包含在HTTP请求的头部中,用于传递与请求相关的元数据或控制信息。例如,可以使用Authorization头部参数传递身份验证信息,如token。

对于特定的安全需求,例如身份验证和授权,常见的请求参数包括:

  • Token:用于身份验证和授权的令牌,通常是一个字符串。可以将Token作为请求头参数(如Authorization),请求体参数或查询参数的一部分,具体取决于API设计的需求和标准。

  • API密钥(API Key):用于标识和验证应用程序的身份,通常是一个长字符串。API密钥可以作为请求头参数、请求体参数或查询参数的一部分,以确保只有授权的应用程序可以访问API。

  • 时间戳(Timestamp):用于防止重放攻击和确保请求的时效性,通常是一个表示当前时间的数字或字符串。时间戳可以作为请求头参数、请求体参数或查询参数的一部分。

这些请求参数的具体定义和使用方式应根据你的应用程序需求和安全策略来确定。确保在设计API时考虑到安全性、一致性和易用性。另外,建议参考相关的API设计规范和最佳实践,如OpenAPI规范或RESTful API设计指南。

响应参数

接口响应实例:

{
  "version""string",
  "msg""string",
  "code"200,
  "error""false",
  "data": {},
  "values": {}
}

这个示例中包含了以下参数:

  • version:表示接口版本的字符串。可以用于标识接口的版本号,方便后续的版本控制和兼容性处理。

  • msg:用于提供接口响应的描述信息的字符串。可以包含有关请求处理结果的额外说明或其他相关信息。

  • code:表示请求的处理结果状态码的整数值。一般情况下,200表示成功,其他状态码用于表示不同的错误或结果。

  • error:表示请求处理是否出错的布尔值。当发生错误时,可以将其设置为true,否则设置为false。

  • data:表示接口响应的具体数据的对象。可以包含接口处理结果的数据,例如获取的用户信息、文章内容等。

  • values:表示其他相关数值或附加信息的对象。可以用于传递一些额外的关键值或辅助信息。


                    END

日拱一卒无有尽,功不唐捐终入海。这里是Java学习小站,关注我,每天进步一点点!

收起阅读 »

Android动态权限申请从未如此简单

作者:dreamgyf juejin.cn/post/72255161761711882851. 前言大家是否还在为动态权限申请感到苦恼呢?传统的动态权限申请需要在 Activity 中重写 onRequestPermissionsResu...
继续阅读 »

作者:dreamgyf 
juejin.cn/post/7225516176171188285

1. 前言

大家是否还在为动态权限申请感到苦恼呢?传统的动态权限申请需要在 Activity 中重写 onRequestPermissionsResult 方法来接收用户权限授予的结果。试想一下,你需要在一个子模块中申请权限,那得从这个模块所在的 Activity 的 onRequestPermissionsResult 中将结果一层层再传回到这个模块中,相当的麻烦,代码也相当冗余和不干净,逼死强迫症。

2. 使用

为了解决这个痛点,我封装出了两个方法,用于随时随地快速的动态申请权限,我们先来看看我们的封装方法是如何调用的:

activity.requestPermission(Manifest.permission.CAMERA, onPermit = {
    //申请权限成功 Do something
}, onDeny = { shouldShowCustomRequest ->
    //申请权限失败 Do something
    if (shouldShowCustomRequest) {
        //用户选择了拒绝并且不在询问,此时应该使用自定义弹窗提醒用户授权(可选)
    }
})

这样是不是非常的简单便捷?申请和结果回调都在一个方法内处理,并且支持随用随调。

3. 方案

那么,这么方便好用的方法是怎么实现的呢?不知道小伙伴们在平时开发中有没有注意到过,当你调用 startActivityForResult 时,AS会提示你该方法已被弃用,点进去看会告诉你应该使用 registerForActivityResult 方法替代。没错,这就是 androidx 给我们提供的 ActivityResult 功能,并且这个功能不仅支持 ActivityResult 回调,还支持打开文档,拍摄照片,选择文件等各种各样的回调,同样也包括我们今天要说的权限申请

其实 Android 在官方文档“请求运行时权限”中就已经将其作为动态权限申请的推荐方法了:https://developer.android.com/training/permissions/requesting

如下示例代码所示:

val requestPermissionLauncher =
    registerForActivityResult(RequestPermission()
    ) { isGranted: Boolean ->
        if (isGranted) {
            // Permission is granted. Continue the action or workflow in your
            // app.
        } else {
            // Explain to the user that the feature is unavailable because the
            // feature requires a permission that the user has denied. At the
            // same time, respect the user's decision. Don't link to system
            // settings in an effort to convince the user to change their
            // decision.
        }
    }

when {
    ContextCompat.checkSelfPermission(
            CONTEXT,
            Manifest.permission.REQUESTED_PERMISSION
            ) == PackageManager.PERMISSION_GRANTED -> {
        // You can use the API that requires the permission.
    }
    shouldShowRequestPermissionRationale(...) -> {
        // In an educational UI, explain to the user why your app requires this
        // permission for a specific feature to behave as expected, and what
        // features are disabled if it's declined. In this UI, include a
        // "cancel" or "no thanks" button that lets the user continue
        // using your app without granting the permission.
        showInContextUI(...)
    }
    else -> {
        // You can directly ask for the permission.
        // The registered ActivityResultCallback gets the result of this request.
        requestPermissionLauncher.launch(
                Manifest.permission.REQUESTED_PERMISSION)
    }
}

说到这里,可能有小伙伴要质疑我了:“官方文档里都写明了的东西,你还特地写一遍,还起了这么个标题,是不是在水文章?!”

莫急,如果你遵照以上方法这么写的话,在实际调用的时候会直接发生崩溃:

java.lang.IllegalStateException: 
LifecycleOwner Activity is attempting to register while current state is RESUMED.
LifecycleOwners must call register before they are STARTED.

这段报错很明显的告诉我们,我们的注册工作必须要在 Activity 声明周期 STARTED 之前进行(也就是 onCreate 时和 onStart 完成前),但这样我们就必须要事先注册好所有可能会用到的权限,没办法做到随时随地有需要时再申请权限了,有办法解决这个问题吗?答案是肯定的。

4. 绕过生命周期检测

想解决这个问题,我们必须要知道问题的成因,让我们带着问题进到源码中一探究竟:

public final <I, O> ActivityResultLauncher<I> registerForActivityResult(
        @NonNull ActivityResultContract<I, O> contract,
        @NonNull ActivityResultCallback<O> callback)
 
{
    return registerForActivityResult(contract, mActivityResultRegistry, callback);
}

public final <I, O> ActivityResultLauncher<I> registerForActivityResult(
        @NonNull final ActivityResultContract<I, O> contract,
        @NonNull final ActivityResultRegistry registry,
        @NonNull final ActivityResultCallback<O> callback)
 
{
    return registry.register(
            "activity_rq#" + mNextLocalRequestCode.getAndIncrement(), this, contract, callback);
}

public final <I, O> ActivityResultLauncher<I> register(
        @NonNull final String key,
        @NonNull final LifecycleOwner lifecycleOwner,
        @NonNull final ActivityResultContract<I, O> contract,
        @NonNull final ActivityResultCallback<O> callback)
 
{

    Lifecycle lifecycle = lifecycleOwner.getLifecycle();

    if (lifecycle.getCurrentState().isAtLeast(Lifecycle.State.STARTED)) {
        throw new IllegalStateException("LifecycleOwner " + lifecycleOwner + " is "
                + "attempting to register while current state is "
                + lifecycle.getCurrentState() + ". LifecycleOwners must call register before "
                + "they are STARTED.");
    }

    registerKey(key);
    LifecycleContainer lifecycleContainer = mKeyToLifecycleContainers.get(key);
    if (lifecycleContainer == null) {
        lifecycleContainer = new LifecycleContainer(lifecycle);
    }
    LifecycleEventObserver observer = new LifecycleEventObserver() { ... };
    lifecycleContainer.addObserver(observer);
    mKeyToLifecycleContainers.put(key, lifecycleContainer);

    return new ActivityResultLauncher<I>() { ... };
}

我们可以发现,registerForActivityResult 实际上就是调用了 ComponentActivity 内部成员变量的 mActivityResultRegistry.register 方法,而在这个方法的一开头就检查了当前 Activity 的生命周期,如果生命周期位于STARTED后则直接抛出异常,那我们该如何绕过这个限制呢?

其实在 register 方法的下面就有一个同名重载方法,这个方法并没有做生命周期的检测:

public final <I, O> ActivityResultLauncher<I> register(
        @NonNull final String key,
        @NonNull final ActivityResultContract<I, O> contract,
        @NonNull final ActivityResultCallback<O> callback)
 
{
    registerKey(key);
    mKeyToCallback.put(key, new CallbackAndContract<>(callback, contract));

    if (mParsedPendingResults.containsKey(key)) {
        @SuppressWarnings("unchecked")
        final O parsedPendingResult = (O) mParsedPendingResults.get(key);
        mParsedPendingResults.remove(key);
        callback.onActivityResult(parsedPendingResult);
    }
    final ActivityResult pendingResult = mPendingResults.getParcelable(key);
    if (pendingResult != null) {
        mPendingResults.remove(key);
        callback.onActivityResult(contract.parseResult(
                pendingResult.getResultCode(),
                pendingResult.getData()));
    }

    return new ActivityResultLauncher<I>() { ... };
}

找到这个方法就简单了,我们将 registerForActivityResult 方法调用替换成 activityResultRegistry.register 调用就可以了

当然,我们还需要注意一些小细节,检查生命周期的 register 方法同时也会注册生命周期回调,当 Activity 被销毁时会将我们注册的 ActivityResult 回调移除,我们也需要给我们封装的方法加上这个逻辑,最终实现就如下所示。

5. 最终实现

private val nextLocalRequestCode = AtomicInteger()

private val nextKey: String
    get() = "activity_rq#${nextLocalRequestCode.getAndIncrement()}"

fun ComponentActivity.requestPermission(
    permission: String,
    onPermit: () -> Unit,
    onDeny: (shouldShowCustomRequestBoolean) -> Unit
)
 {
    if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
        onPermit()
        return
    }
    var launcher by Delegates.notNull<ActivityResultLauncher<String>>()
    launcher = activityResultRegistry.register(
        nextKey,
        ActivityResultContracts.RequestPermission()
    ) { result ->
        if (result) {
            onPermit()
        } else {
            onDeny(!ActivityCompat.shouldShowRequestPermissionRationale(this, permission))
        }
        launcher.unregister()
    }
    lifecycle.addObserver(object : LifecycleEventObserver {
        override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
            if (event == Lifecycle.Event.ON_DESTROY) {
                launcher.unregister()
                lifecycle.removeObserver(this)
            }
        }
    })
    launcher.launch(permission)
}

fun ComponentActivity.requestPermissions(
    permissions: Array<String>,
    onPermit: () -> Unit,
    onDeny: (shouldShowCustomRequestBoolean) -> Unit
)
 {
    var hasPermissions = true
    for (permission in permissions) {
        if (ContextCompat.checkSelfPermission(
                this,
                permission
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            hasPermissions = false
            break
        }
    }
    if (hasPermissions) {
        onPermit()
        return
    }
    var launcher by Delegates.notNull<ActivityResultLauncher<Array<String>>>()
    launcher = activityResultRegistry.register(
        nextKey,
        ActivityResultContracts.RequestMultiplePermissions()
    ) { result ->
        var allAllow = true
        for (allow in result.values) {
            if (!allow) {
                allAllow = false
                break
            }
        }
        if (allAllow) {
            onPermit()
        } else {
            var shouldShowCustomRequest = false
            for (permission in permissions) {
                if (!ActivityCompat.shouldShowRequestPermissionRationale(this, permission)) {
                    shouldShowCustomRequest = true
                    break
                }
            }
            onDeny(shouldShowCustomRequest)
        }
        launcher.unregister()
    }
    lifecycle.addObserver(object : LifecycleEventObserver {
        override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
            if (event == Lifecycle.Event.ON_DESTROY) {
                launcher.unregister()
                lifecycle.removeObserver(this)
            }
        }
    })
    launcher.launch(permissions)
}

6. 总结

其实很多实用技巧本质上都是很简单的,但没有接触过就很难想到,我将我的开发经验分享给大家,希望能帮助到大家。

收起阅读 »

Java序列化

Java序列化是一种将对象转换为字节流的过程,使得对象可以在网络传输、持久化存储或跨平台应用中进行传递和重建的技术。它允许将对象以二进制的形式表示,并在需要时重新创建相同的对象。Java序列化使用java.io.Serializable接口来标记可序列化的类。...
继续阅读 »

Java序列化是一种将对象转换为字节流的过程,使得对象可以在网络传输、持久化存储或跨平台应用中进行传递和重建的技术。它允许将对象以二进制的形式表示,并在需要时重新创建相同的对象。

Java序列化使用java.io.Serializable接口来标记可序列化的类。被标记为可序列化的类必须实现该接口,并且不包含非可序列化的成员变量(如果存在非可序列化的成员变量,可以通过关键字transient将其排除在序列化过程之外)。

以下是一个简单的Java序列化示例:

import java.io.*;

class Person implements Serializable {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
}

public class SerializationExample {
    public static void main(String[] args) {
        // 创建一个Person对象
        Person person = new Person("John Doe"30);

        // 将对象序列化到文件
        try (FileOutputStream fileOut = new FileOutputStream("person.ser");
             ObjectOutputStream out = new ObjectOutputStream(fileOut)) {
            out.writeObject(person);
            System.out.println("Serialized data is saved in person.ser");
        } catch (IOException e) {
            e.printStackTrace();
        }

        // 从文件中反序列化对象
        try (FileInputStream fileIn = new FileInputStream("person.ser");
             ObjectInputStream in = new ObjectInputStream(fileIn)) {
            Person deserializedPerson = (Person) in.readObject();
            System.out.println("Deserialized person: " + deserializedPerson.getName() +
                    ", Age: " + deserializedPerson.getAge());
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

在上面的示例中,我们创建了一个名为Person的可序列化类,并在SerializationExample类中进行序列化和反序列化操作。首先,我们将Person对象写入文件person.ser中,然后从该文件中读取并反序列化为新的Person对象。

值得注意的是,被序列化的类必须存在相应的类定义,以便在反序列化时正确重建对象。如果序列化和反序列化使用不同版本的类,可能会导致版本不匹配的错误,因此需要小心处理类的版本控制。

此外,还可以通过实现java.io.Externalizable接口来自定义序列化过程,以更精确地控制序列化和反序列化的行为。

Serializable 接口的工作原理

Serializable 是 Java 中用于实现对象序列化的接口。当一个类实现了 Serializable 接口后,它的对象就可以被序列化为字节流,以便在网络传输或持久化存储中使用。

实现 Serializable 接口的类并不需要显式地定义任何方法,而是作为一个标记接口,表示该类的对象可以被序列化。Java 的序列化机制会根据对象的结构自动将其转换为字节序列。

以下是 Serializable 接口的工作原理:

  1. 序列化过程: 当一个对象被序列化时,Java 将其内部状态(也就是对象的字段)转换为字节流。这个过程称为对象的序列化。序列化过程从对象的根开始,递归地处理对象的所有字段,并将它们转换为字节流。

  2. 对象图: 在序列化过程中,Java 会创建一个对象图,表示对象之间的关系。对象图包括所有需要被序列化的对象及其字段。如果一个对象引用了其他对象,那么被引用的对象也会被序列化,并在对象图中保留其引用关系。

  3. 字段序列化: 对象的每个字段都被独立地序列化。基本类型和字符串直接转换为对应的字节表示形式,而引用类型(如其他对象)则按照相同的序列化过程递归地处理。

  4. transient 关键字: 通过使用 transient 关键字,可以指定某个字段不参与序列化过程。被标记为 transient 的字段在序列化过程中被忽略,不会转换为字节流。

  5. 序列化的结果: 序列化过程完成后,Java 将对象及其字段转换为字节数组,并将其存储到文件、数据库或通过网络传输。

  6. 反序列化过程: 反序列化是序列化的逆过程。在反序列化过程中,Java 会根据字节流恢复对象的状态。它会逐个字段地读取字节流,并创建对应类型的对象。如果字段是引用类型,则会递归地进行反序列化,直至还原整个对象图。

需要注意的是,当一个类实现 Serializable 接口后,它的所有非瞬态(non-transient)字段都会被默认序列化。因此,在序列化类时,需要确保所有的字段都是可序列化的,否则会抛出 NotSerializableException 异常。

Serializable接口对性能影响

在Java中,使用Serializable接口进行对象的序列化和反序列化会对性能产生一定的影响。以下是一些与性能相关的考虑:

  1. 序列化开销:将对象转换为字节序列需要一定的时间和计算资源。这个过程涉及到将对象的状态写入到字节流中,包括对象的字段和其他相关信息。因此,如果需要频繁地序列化大型对象或大量对象,可能会对性能造成一定的影响。

  2. 序列化文件大小:序列化后的字节流通常比对象本身要大。这是因为序列化时会包含一些元数据、字段名称以及其他必要的信息。如果需要存储大量的序列化对象,可能会占用更多的磁盘空间。

  3. 反序列化性能:将字节序列转换回对象的过程也需要一定的时间和计算资源。反序列化涉及将字节流恢复为对象的状态,并创建新的对象实例。如果需要频繁地反序列化大量对象,也可能会对性能产生一定的影响。

  4. 序列化版本控制:在使用Serializable接口进行对象序列化时,需要注意对象的版本控制。如果在序列化和反序列化过程中发生了类的修改,可能会导致版本不匹配的问题。这可能需要额外的处理来确保兼容性,并可能影响性能。

总的来说,对于大多数应用程序而言,使用Serializable接口进行对象序列化并不会对性能产生显著的影响。然而,在某些特定情况下(如需要频繁地序列化大型对象或需要高性能的实时系统),可能需要考虑其他序列化方案或优化策略来满足性能需求。

收起阅读 »

ES6的module语法中export和import的使用

ES6
ES6模块与CommonJS模块的差异ES6 模块与 CommonJS 模块完全不同 它们有三个重大差异CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用CommonJS 模块是运行时加载,ES6 模块是编译时输出接口CommonJS 模...
继续阅读 »

ES6模块与CommonJS模块的差异

ES6 模块与 CommonJS 模块完全不同 它们有三个重大差异

  • CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用
  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口
  • CommonJS 模块的 require() 是同步加载模块,ES6 模块的 import 命令是异步加载,有一个独立的模块依赖的解析阶段 第二个差异是 CommonJS 加载的是一个对象(即 module.exports 属性),该对象只有在脚本运行完才会生成,而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

CommonJS 模块是 Node.js 专用的,语法上面,与 ES6 模块最明显的差异是,CommonJS 模块使用 require() 和 module.exports ,ES6 模块使用 import 和 export

ES6 中 module 的语法

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。 ES6模块不是对象,而是通过 export 命令显式指定输出的代码,再通过 import 命令输入。

export 命令

模块功能主要由两个命令构成:export 和 import。 export 命令用于规定模块的对外接口,import 命令用于输入其他模块提供的功能。 一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果希望外部能够读取模块内部的某个变量,就必须使用 export 关键字输出该变量。

export 输出变量

// export.js
let firstName = 'Mark'
let lastName = 'Dave'

export { firstName, lastName }

上面代码在 export 命令后面,使用大括号指定所要输出的一组变量。

export 输出函数或类

export 命令除了输出变量,还可以输出函数或类(class)

export function multiply(x, y{
 return x * y
}

上面代码对外输出一个函数 `multiply

export使用as重命名

通常情况下,export 输出的变量是本来的名字,但是可以使用 as 关键字重命名

function fun1({ ... }
function fun2({ ... }

export {
 fun1 as streamFun1,
 fun2 as streamFun2,
 fun2 as streamLatestFun 
}

上面代码使用 as 关键字,重命名了函数 fun1 和 fun2 的对外接口,重命名后,fun2可以用不同的名字输出两次

export 规定的对外接口,必须与模块内部的变量一一对应

export 命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系

// 报错
export 1;

// 报错
let m = 1
export m;

上面两种写法都会报错,因为没有提供对外的接口。第一种写法直接输出1,第二种写法通过变量 m,还是直接输出1,1只是一个值,不是接口。正确的写法如下:

// 写法1
export let m = 1;

// 写法2
let m = 1
export { m }

// 写法3
let n = 2
export { n as m }

上面三种写法都是正确的,规定了对外的接口 m。其他脚本可以通过这个接口,取到值 1.它们的实质是,在接口名和模块内部变量之间,建立了一一对应的关系。

同样,function 和 class 的输出,也必须同样遵守这样的写法

// 报错
function f({}
export f

// 正确
function f({}
export { f }

// 正确
export function f({}

export可以出现在模块的任何位置,只要处于模块顶层就可以

export 命令可以出现在模块的任何位置,只要处于模块顶层就可以。如果处于块级作用域内,就会报错

function foo({
 export default 'bar' // SyntaxError
}
foo()

上面代码中,export 语句放在函数之中,结果报错

export default 命令

使用 import 命令的时候,用户需要知道所要加载的变量名或函数名,否则无法加载。但是,用户肯定希望快速上手,未必愿意阅读文档,去了解模块有哪些属性和方法。 为了给用户提供方便,让它们不用阅读文档就能加载模块,就要用到 export default 命令,为模块指定默认输出。

// export-default.js
export default function({
 console.log('foo')
}

上面代码是一个模块文件 export-default.js ,它默认输出是一个函数。 其他模块加载该模块时,import 命令可以为该匿名函数指定任意名字。

// import-default.js
import customName from './export-default'
customName()

上面代码的 import 命令,可以用任意名称指向 export-default.js 输出的方法,这时就不需要知道原模块输出的函数名。需要注意的是,这时 import 命令后面,不使用大括号。 export default 命令用在非匿名函数前,也是可以的

export default function foo({
 console.log('foo')
}

// 或者写成
function foo({
 console.log('foo')
}
export default foo

上面代码中,foo 函数的函数名 foo ,在模块外部是无效的。加载的时候,视同匿名函数加载。 下面比较一下默认输出和正常输出:

// 第一组
export default function crc32({}

import crc32 from 'crc32'

// 第二组
export function crc32({}

import { crc32 } from 'crc32'

上面两组写法,第一组是使用 export default 时,对应的 import 语句不需要使用大括号;第二组是不使用 export default 时,对应的 import 语句需要使用大括号。

export default 命令用于指定模块的默认输出。显然,一个模块只能有一个默认输出,因此 export default 命令只能使用一次。所以,import 命令后面才不用加大括号,因为只可能唯一对应 export default 命令。 本质上,export default 就是输出一个叫做 default 的变量或方法,然后系统允许你为它取任意名字。所以,下面的写法是有效的。

// module.js
function add(x, y{
 return x + y
}
export { add as default }
// 等同于
export default add

// main.js
import { default as foo } from 'modules'
// 等同于
import foo from 'modules'

正是因为 export default 命令其实只是输出一个叫做 default 的变量,所以它后面不能跟变量声明语句。

// 正确
export let a = 1

// 正确
let a = 1
export default a

// 错误
export default let a = 1

上面代码中,export default a 的含义是将变量 a 的值赋值给变量 default。所以,最后一种写法会报错。 同样地,因为 export default 命令的本质是将后面的值,赋给 default 变量,所以可以直接将一根值写在 rcport default 之后

// 正确
export default 42

// 报错
export 42

如果想在一条 import 语句中,同时输入默认方法和其他接口,可以写成下面这样

import _, { each, forEach } from 'lodash'

export default 也可以用来输出类

// MyClass.js
export default class { ... }

// main.js
import MyClass from 'MyClass'
let o = new MyClass()

import 命令

使用 export 命令定义了模块的对外接口后,其他 js 文件就可以通过 import 命令加载这个模块。

// import.js
import { firstName, lastName } from './export.js'

function setName(element{
 element.textContent = firstName + ' ' + lastName
}

上面代码的 import 命令,用于加载 export.js 文件,并从中输入变量。import 命令接受一对大括号,里面指定要从其他模块导入的变量名。大括号里面的变量名,必须与被导入模块 export.js 对外借款的名称相同

import 使用 as 重命名变量

如果想为输入的变量重新取一个名字,import 命令要使用 as 关键字,将输入的变量重命名。

import { lastName } as surname from './export.js'

import`命令输入的变量都是只读的

import 命令输入的变量都是只读的,,因为它的本质是输入接口。也就是说,不允许在加载模块的脚本里面,改写接口。

import { a } from './xxx.js
a = {} // Syntax Error: '
a' is read-only

上面代码中,脚本加载了变量 a,对其重新赋值就会报错,因为 a 是一个只读的接口,但是,如果 a 是一个对象,改写 a 的属性是允许的

import { a } from './xxx.js'

a.foo = 'hello' // 合法操作

上面代码中,a 的属性可以改写成功,并且其他模块也可以读到改写后的值,不过,这种写法很难查错,建议凡是输入的变量,丢完全当做只读,不要轻易改变它的属性。

import 后面的 from 指定模块文件的位置,可以是相对路径,也可以是绝对路径。如果不带有路径,只是一个模块名,那么必须有配置文件,告诉 javascript 引擎该模块的位置。

import 具有提升效果

import 命令具有提升效果,会提升到整个模块的头部,首先执行

foo()

import { foo } from 'my_module'

上面的代码不会报错,因为 import 的执行早于 foo 的调用。这种行为的本质是,import 命令是编译阶段执行的,在代码运行之前。

import 是静态执行,不能使用表达式和变量

由于 import 是静态执行,所以不能使用表达式和变量,这些只有在运行时才能得到结果的语法结构

// 报错
import { 'f' + 'oo' } from ''my_module

// 报错
let module = 'my_module'
import { foo } from module

// 报错
if (x === 1) {
 import { foo } from 'module1'
else {
 import { foo } from 'module2'
}

上面三种写法都会报错,因为它们用到了表达式、变量和 if 结构。在静态分析阶段,这些语法是没法得到值的。 最后,import 语句会执行所加载的模块,因此可以有下面的写法:

import 'lodash'

上面代码仅仅执行 lodash 模块,但是不输入任何值。 如果多次重复执行同一句 import语句,那么只会执行一次,而不会执行多次。

import 'lodash'
import 'lodash'

上面代码加载了两次 lodash,但是只会执行一次

import { foo } from 'my_module'
import { bar } from 'my_module'

// 等同于
import { foo, bar } from 'my_module'

上面代码中,虽然 foo 和 bar 在两个语句中加载,但是它们对应的是同一个 my_module模块,也就是说,import 语句是singleton 模式。

模块的整体加载

除了指定加载某个输出值,还可以使用整体加载,即用星号(*)指定一个对象,所有输出值都加载在这个对象上。 例如,下面是一个 circle.js 文件,它输出两个方法 area 和 circum

// circle.js
export function area(radius{
 return Math.PI * radius * radius
}
export function circum(radius{
 return @ * Math.PI * radius
}

现在,加载这个模块

// main.js
import {area, circum} from './circle'

console.log(area(4))
console.log(circum(14))

上面写法是逐一指定要加载的方法,整体加载的写法如下。

import * as circle from './circle'

console.log(circle.area(4))
console.log(circle.circum(14))

注意:模块整体加载所在的那个对象(上例是 circle),应该是可以静态分析的,所以不允许运行时改变。下面的写法都是不允许的。

import * as circle from './circle'

// 下面两行都是不允许的
circle.foo = 'hello'
circle.area = function ({}

export 和 import 的复合写法

如果在一根模块之中,先输入后输出同一个模块,import 语句可以与 export 语句写在一起。

export { foo, bar } from 'my_module'

// 可以简单理解为
import { foo, bar } from 'my_module'
export { foo, bar }

上面代码中,export 和 import 语句可以结合在一起,写成一行。但需要注意的是,写成一行以后,foo 和 bar 实际上并没有被导入当前模块,只是相当于对外转发了这两个接口,导致当前模块不能直接使用 foo 和 bar。 模块的接口改名和整体输出,也可以采用这种写法

// 接口改名
export { foo as myFoo } from 'my_module'

// 整体输出
export * from 'my_module'

默认接口的写法如下:

export { default } from 'foo'

具名接口改为默认接口的写法如下

export { es6 as default } from './someModule'

// 等同于
import { es6 } from './someModule'
export default es6

同样的,默认接口也可以改名为具名接口

export { default as es6 } from './someModule'

import()

import 和 export 命令只能在模块的顶层,不能在代码块之中(比如,在 if 代码块之中,或在函数之中) ES2020提案引入 import() 函数,支持动态加载模块

import(specifier)

上面代码中,import 函数的参数 specifier,指定所要加载的模块的位置,import 命令能够接受上面参数,import() 函数就能接受上面参数,两者区别主要是后者为动态加载。 import() 返回一个 Promise 对象。如

const main = document.querySelector('main');

import(`./section-modules/${someVariable}.js`)
  .then(module => {
    module.loadPageInto(main);
  })
  .catch(err => {
    main.textContent = err.message;
  });

import() 函数可以用在任何地方,不仅仅是模块,非模块的脚本也可以使用。它是运行时执行,也就是说,什么时候运行到这一句,就会加载指定的模块。另外,import() 函数与所加载的模块没有静态连接关系,这点也是与 import 语句不相同。import() 类似于 Node 的 require 方法,区别主要是前者是异步加载,后者是同步加载。

适用场合

按需加载

import() 可以在需要的时候,再加载某个模块

button.addEventListener('click', event => {
  import('./dialogBox.js')
  .then(dialogBox => {
    dialogBox.open();
  })
  .catch(error => {
    /* Error handling */
  })
});

上面代码中,import() 方法放在 click 事件的监听函数中,只有用户点击了按钮,才会加载这个模块。

条件加载

import() 可以放在 if 代码块,根据不同的情况,加载不同的模块

if (condition) {
  import('moduleA').then(...);
else {
  import('moduleB').then(...);
}

上面代码中,如果满足条件,就加载模块 A,否则加载模块 B

动态的模块路径

import() 允许模块路径动态生成

import(f())
.then(...)

上面代码中,根据函数 f 的返回结果,加载不同的模块。

注意点

import() 加载模块成功以后,这个模块会作为一个对象,当作 then 方法的参数。因此,可以使用对象结构赋值的语法,获取输出接口

import('./myModule.js')
.then({export1, export2}) => {
 // ...
})

上面代码中,export1 和 export2 都是 myModule.js 的输出接口,可以解构获得 如果想同时加载多个模块,可以

Promise.all([
  import('./module1.js'),
  import('./module2.js'),
  import('./module3.js'),
])
.then(([module1, module2, module3]) => {
   ···
});

import() 也可以用在 async 函数中

async function main({
  const myModule = await import('./myModule.js');
  const {export1, export2} = await import('./myModule.js');
  const [module1, module2, module3] =
    await Promise.all([
      import('./module1.js'),
      import('./module2.js'),
      import('./module3.js'),
    ]);
}
main();



收起阅读 »

Flutter 状态组件 InheritedWidget

前言今天会讲下 inheritedWidget 组件,InheritedWidget 是 Flutter 中非常重要和强大的一种 Widget,它可以使 Widget 树中的祖先 Widget 共享数据给它们的后代 Widget,从而简化了状态管理和数据传递的...
继续阅读 »

前言

今天会讲下 inheritedWidget 组件,InheritedWidget 是 Flutter 中非常重要和强大的一种 Widget,它可以使 Widget 树中的祖先 Widget 共享数据给它们的后代 Widget,从而简化了状态管理和数据传递的复杂性,提高了代码的可读性、可维护性和性能。

Provider 就是对 inheritedWidget 的高度封装

https://github.com/rrousselGit/provider/tree/54af320894e3710b8fad2ae3bb4a6ea0e5aba13e/resources/translations/zh-CN

Flutter_bloc 也是这样

https://github.com/felangel/bloc/blob/cef8418a24b916f439f747e2b0c920ee50b8bd18/docs/zh-cn/faqs.md?plain=1#L133

Flutter_bloc 中确实有 provider 的引用

https://github.com/felangel/bloc/blob/cef8418a24b916f439f747e2b0c920ee50b8bd18/packages/flutter_bloc/pubspec.yaml

如果你只是想简单的状态管理几个全局数据,完全可以轻巧的使用 inheritedWidget 。

今天就来讲下如何使用和要注意的地方。

原文 https://ducafecat.com/blog/flutter-inherited-widget

参考

https://api.flutter.dev/flutter/widgets/InheritedWidget-class.html

状态管理

在 Flutter 中,状态管理是指管理应用程序的数据和状态的方法。在应用程序中,有许多不同的组件和部件,它们可能需要在不同的时间点使用相同的数据。状态管理的目的是使这些数据易于访问和共享,并确保应用程序的不同部分保持同步。

在 Flutter 中,有不同的状态管理方法可供选择,包括:

  1. StatefulWidget 和 State:StatefulWidget 允许你创建有状态的部件,而 State 则允许你管理该部件的状态。这是 Flutter 中最基本和最常用的状态管理方法。
  2. InheritedWidget:InheritedWidget 允许你共享数据和状态,并且可以让子部件自动更新当共享的数据发生变化时。
  3. Provider:Provider 是一个第三方库,它基于 InheritedWidget,可以更方便地管理应用程序中的状态。
  4. Redux:Redux 是一个流行的状态管理库,它基于单一数据源和不可变状态的概念,可以使状态管理更加可预测和易于维护。
  5. BLoC:BLoC 是一个基于流的状态管理库,它将应用程序状态分为输入、输出和转换。它可以使应用程序更清晰和可测试。
  6. GetX: GetX 是一个流行的 Flutter 状态管理和路由导航工具包,它提供了许多功能,包括快速且易于使用的状态管理、依赖注入、路由导航、国际化、主题管理等。是由社区开发和维护的第三方工具包。

步骤

第一步:用户状态 InheritedWidget 类

lib/states/user_profile.dart

// 用户登录信息
class UserProfileState extends InheritedWidget {
  ...
}

参数

  const UserProfileState({
    super.key,
    required this.userName,
    required this.changeUserName,
    required Widget child, // 包含的子节点
  }) : super(child: child);

  /// 用户名
  final String userName;

  /// 修改用户名
  final Function changeUserName;

of 方法查询,依据上下文 context

  static UserProfileState? of(BuildContext context) {
    final userProfile =
        context.dependOnInheritedWidgetOfExactType<UserProfileState>();

    // 安全检查
    assert(userProfile != null'No UserProfileState found in context');

    return userProfile;
  }

需要做一个 userProfile 空安全检查

重写 updateShouldNotify 通知更新规则

  @override
  bool updateShouldNotify(UserProfileState oldWidget) {
    return userName != oldWidget.userName;
  }

如果用户名发生改变进行通知

第二步:头部底部组件 StatelessWidget

lib/widgets/header.dart

class HeaderWidget extends StatelessWidget {
  const HeaderWidget({super.key});

  @override
  Widget build(BuildContext context) {
    String? userName = UserProfileState.of(context)?.userName;

    return Container(
      width: double.infinity,
      decoration: BoxDecoration(
        border: Border.all(color: Colors.blue),
      ),
      child: Text('登录:$userName'),
    );
  }
}

通过 String? userName = UserProfileState.of(context)?.userName; 的方式

读取状态数据 userName

lib/widgets/bottom.dart

class BottomWidget extends StatelessWidget {
  const BottomWidget({super.key});

  @override
  Widget build(BuildContext context) {
    String? userName = UserProfileState.of(context)?.userName;

    return Container(
      width: double.infinity,
      decoration: BoxDecoration(
        border: Border.all(color: Colors.blue),
      ),
      child: Text('登录:$userName'),
    );
  }
}

第三步:用户组件 StatefulWidget

lib/widgets/user_view.dart

class UserView extends StatefulWidget {
  const UserView({super.key});

  @override
  State<UserView> createState() => _UserViewState();
}

class _UserViewState extends State<UserView{
  ...

成员变量

class _UserViewState extends State<UserView{
  String? _userName;

重新 didChangeDependencies 依赖函数更新数据

  @override
  void didChangeDependencies() {
    _userName = UserProfileState.of(context)?.userName;
    super.didChangeDependencies();
  }

通过 UserProfileState.of(context)?.userName; 的方式读取

build 函数

  @override
  Widget build(BuildContext context) {
    return Container(
      width: double.infinity,
      decoration: BoxDecoration(
        border: Border.all(color: Colors.purple),
      ),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text('用户名:$_userName'),
          ElevatedButton(
            onPressed: () {
              // 随机 10 个字母
              String randomString = String.fromCharCodes(
                List.generate(
                  10,
                  (index) => 97 + Random().nextInt(26),
                ),
              );

              // 改变用户名
              UserProfileState.of(context)?.changeUserName(randomString);
            },
            child: const Text('改变名称'),
          ),
        ],
      ),
    );
  }

randomString 是一个随机的 10 个字母

通过 UserProfileState.of(context)?.changeUserName(randomString); 的方式触发函数,进行状态更改。

最后:页面调用 AppPage

lib/page.dart

class AppPage extends StatefulWidget {
  const AppPage({super.key});

  @override
  State<AppPage> createState() => _AppPageState();
}

class _AppPageState extends State<AppPage{
  ...

成员变量

class _AppPageState extends State<AppPage{
  String _userName = '未登录';

给了一个 未登录 的默认值

修改用户名函数

  // 修改用户名
  void _changeUserName(String userName) {
    setState(() {
      _userName = userName;
    });
  }

build 函数

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('InheritedWidget'),
      ),
      body: UserProfileState(
        userName: _userName,
        changeUserName: _changeUserName,
        child: SafeArea(
          child: Column(
            children: const [
              // 头部
              HeaderWidget(),

              // 正文
              Expanded(child: UserView()),

              // 底部
              BottomWidget(),
            ],
          ),
        ),
      ),
    );
  }

可以发现 UserProfileState 被套在了最外层,当然还有 Scaffold 。

包裹的子组件有:HeaderWidget、BottomWidget、UserView

状态过程如下:

  1. UserView 触发 _changeUserName 修改用户名
  2. _userName 改变的数据压入 UserProfileState
  3. UserProfileState 触发 updateShouldNotify
  4. 组件 didChangeDependencies 被触发
  5. 最后子成员组件更新成功

代码

https://github.com/ducafecat/flutter_develop_tips/tree/main/flutter_application_inherited_widget

小结

在 Flutter 中,InheritedWidget 是一种特殊的 Widget,它允许 Widget 树中的祖先 Widget 共享数据给它们的后代 Widget,而无需通过回调或参数传递数据。下面是 InheritedWidget 的一些主要作用和好处:

  1. 共享数据:InheritedWidget 允许祖先 Widget 共享数据给它们的后代 Widget,这使得在 Widget 树中传递数据变得更加容易和高效。这种共享数据的方式避免了回调和参数传递的复杂性,使得代码更加简洁易懂。
  2. 自动更新:当共享的数据发生变化时,InheritedWidget 会自动通知它的后代 Widget 进行更新,这使得状态管理变得更加容易。这种自动更新的方式避免了手动管理状态的复杂性,使得代码更加健壮和易于维护。
  3. 跨 Widget 树:InheritedWidget 可以跨 Widget 树共享数据,这使得在应用程序中不同模块之间传递数据变得更加容易。这种跨 Widget 树的共享方式避免了在不同模块之间传递数据时的复杂性,使得代码更加模块化和易于扩展。
  4. 性能优化:InheritedWidget 可以避免不必要的 Widget 重建,从而提高应用程序的性能。当共享的数据没有发生变化时,InheritedWidget 不会通知后代 Widget 进行更新,这避免了不必要的 Widget 重建,提高了应用程序的性能。



收起阅读 »

未来前端框架会如何卷?

前端框架在过去几年间取得了显著的进步和演进。前端框架也将继续不断地演化,以满足日益复杂的业务需求和用户体验要求。从全球web发展角度看,框架竞争已经从第一阶段的前端框架之争(比如Vue、React、Angular等),过渡到第二阶段的框架之争(比如Next、N...
继续阅读 »

前端框架在过去几年间取得了显著的进步和演进。前端框架也将继续不断地演化,以满足日益复杂的业务需求和用户体验要求。从全球web发展角度看,框架竞争已经从第一阶段的前端框架之争(比如Vue、React、Angular等),过渡到第二阶段的框架之争(比如Next、Nuxt、Remix、小程序等)。

组件化开发的持续推进

前端框架的组件化开发将继续成为主流趋势。Vue、React和Angular等成熟框架早已以其优秀的组件化机制著称。未来,这些框架将不断改进组件系统,使组件之间的交互更加灵活、高效,进一步提高开发效率和应用性能。例如,React框架在最近的更新中引入了Suspense机制,让组件的异步加载更加容易和优雅。而小程序框架也将引入更强大的组件化开发机制,使小程序开发更易维护、易扩展。

案例:一个电商企业正在使用Vue框架开发其前端应用。在该应用中,商品展示、购物车、订单结算等功能都被抽象为可复用的组件。这样一来,开发者可以在不同的页面中重复使用这些组件,大大提高了开发效率。同时,当某个功能需要更新或修复时,只需在对应的组件中进行修改,便可以在整个应用中生效,保持了应用的一致性。

更强调性能优化和打包体积

性能优化和打包体积将成为前端框架发展的重点。优化算法和编译工具的不断改进将帮助开发者减少应用的加载时间,提高用户体验。例如,Next.js框架已经内置了自动代码分割和服务端渲染,有效减少了首屏加载时间,使得用户更快地看到页面内容。

案例:一个新闻媒体网站采用了Nuxt.js框架来优化其前端性能。Nuxt.js的服务端渲染功能允许该网站在服务器端生成静态页面,这大大减少了浏览器渲染的工作量。结果,网站的加载速度得到显著提升,用户可以更快地浏览新闻内容,提高了用户留存率和转化率。

深度集成TypeScript

TypeScript作为一种静态类型语言,已经在前端开发中得到广泛应用。未来前端框架将深度集成TypeScript,提供更完善的类型支持和智能提示,减少潜在的Bug,并提升代码的可维护性。例如,Vue框架已经提供了对TypeScript的原生支持,使得开发者可以使用TypeScript编写Vue组件,并获得更强大的类型检查和代码提示。

案例:一家科技公司决定将其现有的JavaScript项目迁移到TypeScript。在迁移过程中,开发团队发现许多隐藏的类型错误,并通过TypeScript提供的类型检查机制及时修复了这些问题。这使得代码质量得到了大幅提升,并为未来的项目维护奠定了良好的基础。

强调用户体验和可访问性

用户体验和可访问性将继续是前端开发的关键词。框架将注重提供更好的用户体验设计,以及更高的可访问性标准,使得应用能够更好地适应不同用户的需求,包括残障用户。例如,React框架支持ARIA(Accessible Rich Internet Applications)标准,使得开发者可以为特殊用户群体提供更好的使用体验。

案例:一家在线教育平台在开发过程中注重可访问性,确保所有用户都能轻松访问其教育内容。平台使用了语义化的HTML标签、ARIA属性以及键盘导航功能,使得视障用户和键盘操作用户也能流畅使用平台。这使得平台在用户中建立了良好的声誉,吸引了更多的用户参与学习。

跨平台开发的融合

前端框架将更加注重跨平台开发的融合。Vue、React等主流框架将提供更便捷的方法,让开发者可以更轻松地将Web应用扩展到其他平台上。例如,React Native框架允许开发者使用React的语法和组件来构建原生移动应用,这使得前端开发者可以在不学习原生开发语言的情况下,快速构建跨平台的移动应用。

这些轻量化前端开发框架也可以与小程序开发相结合,从而提高小程序的开发效率和性能。

在小程序开发中,通常需要使用一些类似于组件化的开发模式,以便更好地管理页面和数据。这些轻量化前端开发框架中,例如 Vue.js 和 React,已经采用了类似于组件化的开发模式,因此可以更好地适应小程序的开发需求。

除此之外,这些轻量化前端开发框架还提供了许多工具和插件,可以帮助开发人员更快地开发小程序。例如,Vue.js 提供了 Vue-CLI 工具,可以快速创建小程序项目和组件;React 提供了 React Native 工具,可以使用类似于 React 的语法开发原生应用程序。这些工具和插件使得小程序开发更加高效和便捷。

1、使用小程序开发框架

类似于 Vue.js 和 React,这些框架可以通过使用小程序框架的渲染层和逻辑层 API,来提高小程序的性能和开发效率。例如,可以使用微信小程序框架和 Vue.js 一起开发小程序,通过引入 mpvue-loader 库来实现 Vue.js 和小程序的整合。

mpvue基于Vue.js核心,修改了Vue.js的 runtime 和 compiler 实现,使其可以运行在小程序环境中。mpvue 支持使用 Vue.js 的大部分特性,如组件、指令、过滤器、计算属性等,同时也支持使用 npm、webpack 等工具来构建项目。mpvue 还提供了一些扩展 API 和插件机制,以适应小程序的特殊需求。

2、使用跨平台开发工具

跨平台开发工具可以让开发人员使用一套代码来同时开发小程序、Web 应用和原生应用。例如,使用 React Native 可以通过 JavaScript 来开发原生应用程序和小程序,同时提高了开发效率和性能。

3、小程序组件库

一些小程序组件库,例如 WeUI 和 Vant,提供了许多常用的 UI 组件和功能,可以帮助开发人员快速地构建小程序页面。这些组件库还可以与 Vue.js 和 React 等轻量化前端开发框架相结合,提高小程序的开发效率和性能。

进一步提升应用价值

Vue 和小程序本质上是两个不同的技术栈,Vue 是一个前端框架,而小程序基于微信语法和规则。由于两者的编程模型和运行环境有很大的差异,因此不能直接将 Vue 代码打包为小程序的。

但可以通过使用小程序开发框架,例如 Taro、Mpvue 和 uni-app,可以将 Vue.js 和 React 等前端框架的开发方式与小程序相结合。这些框架可以将前端框架的语法和特性转换为小程序的语法和特性,从而使得开发人员可以使用熟悉的开发方式来开发小程序。

这里还要推荐一个深化发挥小程序价值的途径,直接将现有的小程序搬到自有 App 中进行运行,这种实现技术路径叫做小程序容器,例如 FinClip SDK 是通过集成 SDK 的形式让自有的 App 能够像微信一样直接运行小程序。

这样一来不仅可以通过前端框架提升小程序的开发效率,还能让小程序运行在微信以外的 App 中,真正实现了一端开发多端上架,另外由于小程序是通过管理后台上下架,相当于让 App 具备热更新能力,避免 AppStore 频繁审核。

最后

综上所述,未来前端框架的发展将持续聚焦在组件化开发、性能优化和打包体积、跨平台开发、小程序框架的崛起、深度集成TypeScript、用户体验和可访问性、全球化和国际化等方向。通过不断地创新和改进,前端框架将推动Web应用开发的进步,为用户提供更好的使用体验和开发者更高效的开发体验。开发者们应密切关注各个框架的更新和改进,以紧跟技术的脚步,为未来的Web应用开发做好准备。

收起阅读 »

面试必备:Android 常见内存泄漏问题盘点

1. 前言当我们开发安卓应用时,性能优化是非常重要的一个方面。一方面,优化可以提高应用的响应速度、降低卡顿率,从而提升用户体验;另一方面,优化也可以减少应用的资源占用,提高应用的稳定性和安全性,降低应用被杀死的概率,从而提高用户的满意度和留存率。但是,对于许多...
继续阅读 »

1. 前言

当我们开发安卓应用时,性能优化是非常重要的一个方面。一方面,优化可以提高应用的响应速度、降低卡顿率,从而提升用户体验;另一方面,优化也可以减少应用的资源占用,提高应用的稳定性和安全性,降低应用被杀死的概率,从而提高用户的满意度和留存率。

但是,对于许多开发者来说,安卓性能优化往往是一个比较棘手的问题。因为性能优化包罗万象,涉及的知识面也比较多,而内存泄露是最常见的一类性能问题,也是各类面试题中的常客,因此了解内存泄漏是每个安卓开发者应该具备的进阶技能。

本文就带大家盘点常见的内存泄漏问题。

2. 内存泄漏的本质

内存泄漏的本质就是对象引用未释放,当对象被创建时,如果没有被正确释放,那么这些对象就会一直占用内存,直到应用程序退出。例如,当一个Activity被销毁时,如果它还持有其他对象的引用,那么这些对象就无法被垃圾回收器回收,从而导致内存泄漏

当存在内存泄漏时,我们需要通过GCRoot来识别内存泄漏的对象和引用。

GCRoot是垃圾回收机制中的根节点,根节点包括虚拟机栈、本地方法栈、方法区中的类静态属性引用、活动线程等,这些对象被垃圾回收机制视为“活着的对象”,不会被回收。

当垃圾回收机制执行时,它会从GCRoot出发,遍历所有的对象引用,并标记所有活着的对象,未被标记的对象即为垃圾对象,将会被回收。

当存在内存泄漏时,垃圾回收机制无法回收一些已经不再使用的对象,这些对象仍然被引用,形成了一些GCRoot到内存泄漏对象的引用链,这些对象将无法被回收,导致内存泄漏。

通过查找内存泄漏对象和GCRoot之间的引用链,可以定位到内存泄漏的根源,进而解决内存泄漏问题,LeakCancry就是通过这个机制实现的。

一些常见的GCRoot包括:

  • 虚拟机栈(Local Variable)中引用的对象。
  • 方法区中静态属性(Static Variable)引用的对象。
  • JNI 引用的对象。
  • Java 线程(Thread)引用的对象。
  • Java 中的 synchronized 锁持有的对象。

什么情况会造成对象引用未释放呢?简单举几个例子:

  • 匿名内部类造成的内存泄漏:匿名内部类通常会持有外部类的引用,如果外部类的生命周期比匿名内部类长,(更正一下,这里用生命周期不太恰当,当外部类被销毁时,内部类并不会自动销毁,因为内部类并不是外部类的成员变量,它们只是在外部类的作用域内创建的对象,所以内部类的销毁时机和外部类的销毁时机是不同的,所以会不会取决与对应对象是否存在被持有的引用)那么就会导致外部类无法被回收,从而导致内存泄漏。

  • 静态变量持有Activity或Context的引用:如果一个静态变量持有Activity或Context的引用,那么这些Activity或Context就无法被垃圾回收器回收,从而导致内存泄漏。

  • 未关闭的Cursor、Stream或者Bitmap对象:如果程序在使用Cursor、Stream或者Bitmap对象时没有正确关闭这些对象,那么这些对象就会一直占用内存,从而导致内存泄漏。

  • 资源未释放:如果程序在使用系统资源时没有正确释放这些资源,例如未关闭数据库连接、未释放音频资源等,那么这些资源就会一直占用内存,从而导致内存泄漏。

接下来我们通过代码示例看一下各种常见内存泄露以及如何避免相关问题的最佳实践

3. 静态引用导致的内存泄漏

当一个对象被一个静态变量持有时,即使这个对象已经不再使用,也不会被垃圾回收器回收,这就会导致内存泄漏

public class MySingleton {
    private static MySingleton instance;
    private Context context;

    private MySingleton(Context context) {
        this.context = context;
    }

    public static MySingleton getInstance(Context context) {
        if (instance == null) {
            instance = new MySingleton(context);
        }
        return instance;
    }
}

上面的代码中,MySingleton持有了一个Context对象的引用,而MySingleton是一个静态变量,导致即使这个对象已经不再使用,也不会被垃圾回收器回收。

最佳实践:如果需要使用静态变量,请注意在不需要时将其设置为null,以便及时释放内存。

4. 匿名内部类导致的内存泄漏

匿名内部类会隐式地持有外部类的引用,如果这个匿名内部类被持有了,就会导致外部类无法被垃圾回收。

public class MyActivity extends Activity {
    private Button button;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        button = new Button(this);
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                // do something
            }
        });
        setContentView(button);
    }
}

匿名内部类OnClickListener持有了外部类MyActivity的引用,如果MyActivity被销毁之前,button没有被清除,就会导致MyActivity无法被垃圾回收。(此处可以将Button 看作是自己定义的一个对象,一般解法是将button对象置为空)

最佳实践:在Activity销毁时,应该将所有持有Activity引用的对象设置为null。

5. Handler引起的内存泄漏

Handler是在Android应用程序中常用的一种线程通信机制,如果Handler被错误地使用,就会导致内存泄漏。

public class MyActivity extends Activity {
    private static final int MSG_WHAT = 1;
    private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
                case MSG_WHAT:
                    // do something
                    break;
                default:
                    super.handleMessage(msg);
            }
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mHandler.sendEmptyMessageDelayed(MSG_WHAT, 1000 * 60 * 5);
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        // 在Activity销毁时,应该将Handler的消息队列清空,以避免内存泄漏。
        mHandler.removeCallbacksAndMessages(null);
        }
}

Handler持有了Activity的引用,如果Activity被销毁之前,Handler的消息队列中还有未处理的消息,就会导致Activity无法被垃圾回收。

最佳实践:在Activity销毁时,应该将Handler的消息队列清空,以避免内存泄漏。

6. Bitmap对象导致的内存泄漏

当一个Bitmap对象被创建时,它会占用大量内存,如果不及时释放,就会导致内存泄漏。

public class MyActivity extends Activity {
    private Bitmap mBitmap;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // 加载一张大图
        mBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.big_image);
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        // 释放Bitmap对象
        mBitmap.recycle();
        mBitmap = null;
    }
}

当Activity被销毁时,Bitmap对象mBitmap应该被及时释放,否则就会导致内存泄漏。

最佳实践:当使用大量Bitmap对象时,应该及时回收不再使用的对象,避免内存泄漏。另外,可以考虑使用图片加载库来管理Bitmap对象,例如Glide、Picasso等。

7. 资源未关闭导致的内存泄漏

当使用一些系统资源时,例如文件、数据库等,如果不及时关闭,就可能导致内存泄漏。例如:

public void readFile(String filePath) throws IOException {
    FileInputStream fis = null;
    try {
        fis = new FileInputStream(filePath);
        // 读取文件...
    } finally {
        if (fis != null) {
            try {
                fis.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

上面的代码中,如果在读取文件之后没有及时关闭FileInputStream对象,就可能导致内存泄漏。

最佳实践:在使用一些系统资源时,例如文件、数据库等,要及时关闭相关对象,避免内存泄漏。

避免内存泄漏需要在编写代码时时刻注意,及时清理不再使用的对象,确保内存资源得到及时释放。 ,同时,可以使用一些工具来检测内存泄漏问题,例如Android Profiler、LeakCanary等。

8. WebView 内存泄漏

当使用WebView时,如果不及时释放,就可能导致内存泄漏

public class MyActivity extends Activity {
    private WebView mWebView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        mWebView = findViewById(R.id.webview);
        mWebView.loadUrl("https://www.example.com");
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        // 释放WebView对象
        if (mWebView != null) {
            mWebView.stopLoading();
            mWebView.clearHistory();
            mWebView.clearCache(true);
            mWebView.loadUrl("about:blank");
            mWebView.onPause();
            mWebView.removeAllViews();
            mWebView.destroy();
            mWebView = null;
        }
    }
}

上面的代码中,当Activity销毁时,WebView对象应该被及时释放,否则就可能导致内存泄漏。

最佳实践:在使用WebView时,要及时释放WebView对象,可以在Activity销毁时调用WebView的destroy方法,同时也要清除WebView的历史记录、缓存等内容,以确保释放所有资源。

9. 监测工具

  1. 内存监视工具:Android Studio提供了内存监视工具,可以在开发过程中实时监视应用程序的内存使用情况,帮助开发者及时发现内存泄漏问题。
  2. DDMS:Android SDK中的DDMS工具可以监视Android设备或模拟器的进程和线程,包括内存使用情况、堆栈跟踪等信息,可以用来诊断内存泄漏问题。
  3. MAT:MAT(Memory Analyzer Tool)是一款基于Eclipse的内存分析工具,可以分析应用程序的堆内存使用情况,识别和定位内存泄漏问题。
  4. 腾讯的Matrix,也是非常好的一个开源项目,推荐大家使用

10. 总结

内存泄漏是指程序中的某些对象或资源没有被妥善地释放,从而导致内存占用不断增加,最终可能导致应用程序崩溃或系统运行缓慢等问题。

常见的内存泄漏问题和对应的最佳实践整理如下

问题最佳实践
长时间持有Activity或Fragment对象导致的内存泄漏及时释放Activity或Fragment对象
匿名内部类和非静态内部类导致的内存泄漏避免匿名内部类和非静态内部类
WebView持有Activity对象导致的内存泄漏在使用WebView时,及时调用destroy方法
单例模式持有资源对象导致的内存泄漏在单例模式中避免长时间持有资源对象
资源未关闭导致的内存泄漏及时关闭资源对象
静态变量持有Context对象导致的内存泄漏避免静态变量持有Context对象
Handler持有外部类引用导致的内存泄漏避免Handler持有外部类引用
Bitmap占用大量内存导致的内存泄漏在使用Bitmap时,及时释放内存
单例持有大量数据导致的内存泄漏避免单例持有大量数据
收起阅读 »