程序员高效能指南:改变命运的 6 个关键习惯
凌晨 3 点,办公室里只剩下屏幕的幽光。又一个被 Bug 困扰的不眠之夜,你是否开始怀疑:为什么同样是写代码,有些人能轻松应对,而自己却总是加班救火?为什么有的同事技术能力突飞猛进,而自己似乎原地踏步?
答案也许不在技术本身,而在于我们的工作习惯。正如斯蒂芬·R·柯维在《高效能人士的七个习惯》中所说:"我们看待世界的方式完全取决于我们自己的感知。"今天,让我们从程序员的视角出发,聊聊那些能让你事半功倍的 6 个习惯。
1. 主动积极
在编程的世界里,有两种程序员:被动的和主动的。
被动的程序员总是抱怨外部环境,比如公司制度、项目资源不足,甚至相信看几个“速成教程”就能改变命运。
而主动的程序员则会专注于自己能掌控的事情,比如提升技能、参与开源项目、参加技术竞赛,甚至主动寻找更好的职业机会。
如何做到主动积极?
- 专注于“影响圈” :不要纠结于那些你无法改变的事情,比如公司政策或市场环境,而是把精力放在你能控制的事情上,比如学习新技术、优化代码质量。
- 为自己的职业负责:不要等待别人给你机会,而是主动创造机会。比如,定期更新简历、参加技术社区活动,或者尝试新的编程语言。
主动积极的程序员明白,职业发展是自己的责任,而不是外界的恩赐。
2. 以终为始
很多程序员在工作中随波逐流,接到任务就埋头苦干,却从未思考过最终的目标是什么。结果就是,花了大量时间,却发现方向错了。斯蒂芬·柯维提出的“以终为始”理念,强调在开始任何事情之前,先明确最终的目标。
如何在编程中应用这一习惯?
- 明确项目目标:在开始一个新项目时,先搞清楚最终的交付成果是什么。功能需求和非功能需求有哪些?用户体验的核心是什么?
- 制定清晰的计划:花 30 分钟规划项目,可以节省 10 小时的开发时间。比如,先需求分析,再决定系统架构,而不是直接开始写代码。
记住,编程不仅仅是写代码,更是解决问题的艺术。明确目标,才能让你的努力更有方向。
3. 先做最重要的事
程序员的日常工作中,往往会被各种紧急任务打断,比如修复 Bug、处理线上问题。
但如果你总是被这些琐事牵着鼻子走,就会忽略那些对长期发展更重要的事情,比如学习新技术、优化系统架构。
如何区分重要和紧急?
- 使用艾森豪威尔矩阵:将任务分为四类:
- 重要且紧急:立即处理,比如修复生产环境的重大 Bug。
- 重要但不紧急:安排时间,比如学习新技术、优化代码。
- 不重要但紧急:尽量委派,比如回复一些无关紧要的邮件。
- 不重要且不紧急:直接忽略,比如刷社交媒体。
优先处理“重要”的任务,因为这些任务决定了你的长期成长。
4. 考虑双赢
程序员的工作并不是孤军奋战,而是与团队协作完成的。无论是与其他工程师合作,还是与产品经理、设计师沟通,双赢的思维都至关重要。
双赢并不是让步,而是找到一种对双方都有利的解决方案。
如何培养双赢思维?
- 欣赏团队的多样性:每个人都有自己的优势和视角,学会倾听和尊重他人的意见。
- 建立信任:通过高质量的代码和积极的态度,赢得团队的信任。
- 共同成长:在代码审查中,不仅指出问题,还要提出改进建议;在项目中,主动分享自己的经验和知识。
双赢的思维不仅能让团队更高效,还能帮助你建立长期的职业人脉。
5. 沟通的艺术,1 + 1 > 2 的力量
程序员之间的沟通,不仅仅是语言上的交流,更是通过代码和文档进行的“无声对话”。
如果你写的代码晦涩难懂,或者文档不清晰,就会给团队带来额外的负担。
如何提升沟通能力?
- 写清晰的代码:变量命名要有意义,注释要简洁明了。记住,代码是写给人看的,机器只是顺便执行。
- 站在用户的角度思考:设计界面时,考虑用户的使用习惯;编写错误提示时,尽量清晰友好,而不是让用户感到困惑。
- 倾听他人的意见:在团队讨论中,先理解别人的观点,再表达自己的看法。
如何实现高效协作?
- 参与代码审查:通过审查他人的代码,学习新的技巧,同时也能帮助团队提高代码质量。
- 结对编程:两个人一起编程,可以互相补充思路,避免遗漏问题。
- 知识分享:定期组织技术分享会,或者在团队中推广最佳实践。
优秀的程序员,不仅能写出高质量的代码,还能通过代码与团队和用户“对话”。
6. 持续学习,永不止步
技术更新迭代飞快,程序员如果不持续学习,很容易被淘汰。磨砺锯子的习惯,强调在忙碌的工作中,抽出时间提升自己。
如何保持学习的动力?
- 学习新技术:每年掌握一门新语言或框架,比如从 node 转向 rust,或者学习嵌入式技术。
- 参与技术社区:通过开源项目、技术论坛、群聊或者线下活动,与其他程序员交流经验。
- 关注行业动态:阅读技术博客、观看技术演讲,了解最新的趋势和工具。
就像磨刀不误砍柴工,持续学习不仅能提升你的技术水平,还能让你在职业生涯中始终保持竞争力。
改变习惯并不容易,但只要你愿意从今天开始,一点点调整自己的行为,就能逐渐看到改变的力量。
来源:juejin.cn/post/7440676461169131555
小程序webview我爱死你了 小程序webview和H5通讯
webview 我 *
众所周知,将已上线的H5页面转换为小程序,最快的方法是通过WebView进行套壳。然而,在这个过程中,我们需要将H5页面的登录和支付功能迁移到小程序版本。这意味着H5页面需通过特定的方式与小程序进行通信,以实现如支付等关键功能。
因此需要了解H5与WebView之间的通讯方式,以确保数据的顺利传递和功能的无缝对接。
找了很久发现H5与WebView的通讯方式主要有两种:
- 小程序通过改变H5地址栏携带参数
- WebSocket实时通讯
而webview自带的bindmessage、bindload、binderror,触发条件只有小程序后退、组件销毁、分享、复制链接,给我卡的死死的,只好选择了第一种方式,WebSocket虽然可以实现实时通讯,但会增加额外的开销,不符合我的需求。
这里的URL域名必须添加到 小程序后台中-管理-业务域名内,否则会报无法打开 xxx 页面,个人小程序是没有这个选项的,需要申请成企业小程序
小程序向H5通讯
小程序端
<view class="content">
<web-view :src="url"></web-view>
</view>
H5端
// 判断当前页面的 URL 是否包含 'userInfo',用于识别是否来自小程序端
if (window.location.href.includes('userInfo')) {
// 匹配 URL 中的 userInfo 参数
const userInfoRegex = /userInfo=([^]*)/;
// 解码
const decodedUrl = decodeURIComponent(window.location.href);
// 使用正则表达式从解码后的 URL 中提取参数值
const userInfoMatch = decodedUrl.match(userInfoRegex);
let auth_token = userInfoMatch[1];
localStorage.setItem('loc_token', auth_token);
}
H5向小程序通讯
小程序端
onMounted(() => {
const paymentData = getCurrentPages().pop().options.paymentData // 获取当前页面参数
submitInfo(paymentData);
});
H5端
wx.miniProgram.navigateTo({
url: `/pagesMember/pay/pay?paymentData=${payInfo.value}`,
})
通讯限制也就算了,导航栏不能自定义,还不让去掉,这让自带导航栏显得极其突兀!我 * !!!
navigationStyle: custom对 web-view 组件无效
一句话干碎我的摸鱼梦,领导要把那块做成透明的,没办法只好把常用页面重构,
but小程序不支持elementPlus啊,太爽了家人们。
来源:juejin.cn/post/7440122922025058342
科技业裁不停!软件工程师实惨,今年科技公司已裁员 13.7 万人
【新智元导读】科技行业的就业市场正在发生重大变革,人才供需逆转,初级职位减少,技能要求增加,求职竞争加剧。
科技行业曾是众多人才竞相追求的热门领域,但如今却面临着职位减少的挑战。
根据 Indeed.com 的数据,自 2020 年 2 月以来,软件开发岗位的招聘广告数量已经下降了超过 30%。
Layoffs.fyi 网站的报告也显示,今年科技行业的裁员潮仍在继续,自 1 月份以来,已有约 13.7 万个工作岗位被裁减。
软件工程师在招聘网站上出现的频次对比,以 2018 年 1 月作为基准 100。来源:ADP
对于长期在就业市场占据优势的科技行业来说,这种急剧的变化不仅仅是短期的不适,而是整个行业正在经历的一次根本性的劳动力需求调整,一些从业者正被市场淘汰。
47 岁的 Chris Volz,一位居住在加利福尼亚州奥克兰的工程经理,自 90 年代末就开始在科技行业工作,但在 2023 年 8 月被一家房地产技术公司解雇。他表示:「这次的情况感觉非常、非常不同。」
Volz 之前的经历中,大部分工作机会都是通过猎头或内部推荐获得的。然而现在,他发现他的人脉网络中的许多人也都被裁员了,这迫使他不得不在职业生涯中第一次主动向外投递简历。
虽然在疫情期间,随着消费者将日常生活和消费活动转移到线上,科技公司迎来了招聘热潮,大量扩充员工队伍。
人才争夺战如此激烈,以至于公司囤积员工,不让他们去往竞争对手方工作;一些员工说,他们实际上是被雇佣来无所事事的。
然而,随着通货膨胀和利率的上升,经济形势迅速变化,导致一些大型科技公司开始大规模裁员。
ADP 研究部主管 Nela Richardson 表示,尽管疫情期间的招聘热潮减缓了整体的下降趋势,但并未改变长期趋势,部分原因是数字领域创新的自然发展轨迹,技术解决方案正在取代传统的人力。
她说, 「在数字领域,你不再像早期那样有很多新的突破。因为有越来越多的技术解决方案,而不仅仅是人的解决方案」 。
科技行业的非技术人员,如市场营销、人力资源和招聘人员,也面临着多次解雇的风险。
James Arnold 在过去的 18 年里一直从事科技领域的招聘工作,但在不到两年的时间里却两次被裁员。
在疫情期间,他在 Meta 担任人力资源,快速招聘新员工。2022 年 11 月,他被解雇了,然后花了将近一年的时间找工作,最后才在行业外找到了一份工作。
Arnold 说,他申请的大多数工作的薪水都比过去低三分之一 。
尽管科技公司的财务状况有所反弹,但一些公司更倾向于依赖顾问和外包职位。
Arnold 认为,疫情证明了远程工作的有效性,这为全球化就业市场打开了新的可能性。
初级职位正在减少
以往,初级职位对于技术实习生来说是一个高薪的起点,他们常常能够获得六位数的年薪,并且有很大机会转正。
但是,最近这一趋势发生了转变。企业开始减少实习机会,并降低了初级职位的招聘数量。
现在,即便是入门级职位,也要求应聘者具备多年的工作经验。
薪酬规划初创公司 Pequity 的首席执行官 Kaitlyn Knopp 观察到,过去薪酬过高和职称与经验不匹配的现象已经得到纠正。
她指出:「我们发现职位级别正在重新调整,人们的经验和职责更加匹配了。」
根据 Pequity 的数据,2024 年薪资增长基本停滞,平均薪资仅比去年增长了 0.95%。
Pequity 还发现,自 2019 年以来,中型企业软件即服务公司为初级职位提供的股权补助平均下降了 55%。
然而,目前失业技术人员和职场新人之间的竞争愈发激烈,而面试机会却在不断减少。
同时,企业对工程师的要求更为全面。
人员招聘公司 Robert Half 和技术实践部执行总监 Ryan Sutton 说,为了提高效率和降低成本,他们希望团队成员不仅具备软技能和协作能力,还应了解公司的人工智能战略和发展方向。
他说道:「他们希望看到更多多才多艺的人才。」
通过网上申请在科技领域找工作毫无结果,于是 Glenn Kugelman 采用了另一种策略:用纸和胶带在曼哈顿悬挂传单,宣传他的 LinkedIn 简介
不少技术人员开始寻求提升自身技能,纷纷报名参加人工智能训练营或其他相关课程。
Michael Moore 是亚特兰大的一名软件工程师,今年 1 月被一家网络应用程序开发公司解雇,在七个月的求职无果后,他决定报读一所网络大学。
摩尔曾通过在线课程学习编程,并表示六年前没有大学学位的他依然顺利找到了工作。
科技公司的战略变化
企业战略也正在发生转变。
科技公司不再追求不惜一切代价的增长,也不再对那些宏伟的「登月计划」进行投资,而是将焦点转向能够带来收入的产品和服务上。
他们减少了对初级职位的招聘,缩减了招聘团队,并放弃了一些不盈利领域的项目和工作,比如虚拟现实和设备。
与此同时,企业开始将大量资源投入到人工智能领域。
2022 年末发布的 ChatGPT 让人们看到了生成式人工智能创造类人内容和潜在行业变革的能力。这引发了投资热潮,人们争相构建最先进的人工智能系统。
在这个领域拥有专业知识的工人成为了少数几个强势群体之一。
从事大型语言模型工作的人员目前在市场上非常抢手,这些模型是 ChatGPT 等产品的基础,从事此类工作的人员年收入远超百万美元。
Pequity 的首席执行官 Kaitlyn Knopp 指出,人工智能工程师的薪资是普通工程师的两到四倍,她认为这是对未知技术的极端投资,导致公司无法在其他人才上进行投资。
甚至科技行业之外的公司也在积极招聘人工智能人才。一位猎头 Martha Heller 表示,五年前,董事会并不像现在这样关注公司的人工智能战略。
科技行业的就业市场正在经历一场深刻的变革。虽然初级职位减少,整体就业竞争加剧,但人工智能等新兴技术领域仍然提供了广阔的就业机会。
对于技术人员而言,不断学习新技能,适应行业变化,是在变革中寻找机遇的关键。
而对于行业来说,这也许是一个重新思考和调整人才培养和引进策略的契机。
参考资料:
来源:juejin.cn/post/7418367859011256374
分不清Boolean和boolean,我被同事diss了!
背景
这几天写代码,遇到一个不确定的知识点:我在vue的props中如何给一个属性定义小写的bolean,代码就会报错
但是大写的Bolean就没问题
由于我在其他地方我看大小写都可以,有点疑惑,于是想去请教一下同事。然而,没想到同事上来就diss我:
这么基础的知识你都不清楚?这两个根本就不是一个东西!
我有点不开心,想反驳一下:
这两个不都是描述类型的东西吗?我给你看其他地方的代码,这两个都是可以混用的!
同事有点不耐烦,说道:大姐,boolean是TS中的类型声明,Boolean是JavaScript 的构造函数,根本不是一个东西吧!
行吧,我也刚入门不久,确实不了解这个东西,只能强忍委屈,对同事说了声谢谢,我知道了!
然后,我好好的学习了一下Boolean和boolean的知识,终于搞明白他们的区别了。
Boolean和boolean
本质区别
同事说的很对,他们两个的本质区别就是一个是JavaScript语法,一个是TypeScript语法,这意味着非TypeScript项目是不存在boolean这个东西的。
Boolean
是 JavaScript 的构造函数
Boolean
是 JavaScript 中的内置构造函数,用于布尔值的类型转换或创建布尔对象。
typeof Boolean; // "function"
boolean
是 TypeScript 的基本类型
- 如果使用了 TypeScript,
boolean
是 TypeScript 中的基本类型,用于静态类型检查。 - 在 JavaScript 的运行时上下文中,
boolean
并不存在,仅作为 TypeScript 的静态检查标识。
typeof boolean; // ReferenceError: boolean is not defined
TS中作为类型的Boolean和boolean
在TypeScript中,Boolean和boolean都可以用于表示布尔类型
export interface ActionProps {
checkStatus: Boolean
}
export interface RefundProps {
visible: boolean
}
但是,他们存在一些区别
boolean
boolean
是 TypeScript 的基本类型,用于定义布尔值。- 它只能表示
true
或false
。 - 编译后
boolean
不会存在于 JavaScript 中,因为它仅用于静态类型检查。
//typescript
let isActive: boolean; // 只能是 true 或 false
isActive = true; // 正确
isActive = false; // 正确
isActive = new Boolean(true); // 错误,不能赋值为 Boolean 对象
Boolean
Boolean
是 JavaScript 的内置构造函数,用于将值显式转换为布尔值或创建布尔对象(Boolean
对象)。- 它是一个引用类型,返回的是一个布尔对象,而不是基本的布尔值。
- 在 TypeScript 中,
Boolean
表示构造函数类型,而不是基本的布尔值类型。
//typescript
let isActive: Boolean; // 类型是 Boolean 对象
isActive = new Boolean(false); // 正确,赋值为 Boolean 对象
isActive = true; // 正确,基本布尔值也可以兼容
关键区别
特性 | boolean | Boolean |
---|---|---|
定义 | TypeScript 的基本类型 | JavaScript 的构造函数 |
值类型 | 只能是 true 或 false | 是一个布尔对象 |
推荐使用场景 | 用于定义基本布尔值类型 | 很少用,除非需要显式构造布尔对象 |
运行时行为 | 不存在,只在编译时有效 | 在运行时是 JavaScript 的构造函数 |
性能 | 高效,直接操作布尔值 | 对象包装,性能较差 |
为什么尽量避免使用 Boolean
?
类型行为不一致:Boolean
是对象类型,而不是基本值类型。这会在逻辑运算中导致混淆:
const flag: Boolean = new Boolean(false);
if (flag) {
console.log("This will run!"); // 因为对象始终为 truthy
}
性能开销更大:Boolean
会创建对象,而 boolean
是直接操作基本类型。
vue中的Boolean与boolean
Vue 的运行时框架无法识别 boolean
类型,它依赖的是 JavaScript 的内置构造函数(如 Boolean
、String
、Number
等)来检查和处理 props
类型。
因此,props的Type只能是Boolean
、String
或Number
。
但是如果vue中开启了ts语法,就可以使用boolean
表示类型了
<script lang="ts" setup>
interface IProps {
photoImages?: string[],
isEdit?: boolean
}
const props = withDefaults(defineProps<IProps>(), {
photoImages: () => [],
isEdit: true
})
</script>
来源:juejin.cn/post/7439576043223203892
Flutter - 危!3.24版本苹果审核被拒!
欢迎关注微信公众号:FSA全栈行动 👋
一、概述
最近准备使用 Flutter
的 3.24
版本打包上架 APP
,结果前天看到有人提了一个 issue
: github.com/flutter/flu… ,说分别使用 3.24.3
和 3.24.4
提交苹果审核时,都惨遭被拒~
苹果反馈的信息如下:
Guideline 2.5.1 - Performance - Software Requirements
The app uses or references the following non-public or deprecated APIs:
Frameworks/Flutter.framework/Flutter
Symbols:
• _kCTFontPaletteAttribute
• _kCTFontPaletteColorsAttribute
The use of non-public or deprecated APIs is not permitted, as they can lead to a poor user experience should these APIs change and are otherwise not supported on Apple platforms.
可以看到,是说 Flutter
使用了未公开的 API
,并且他使用 strings
命令也验证了这一点。
3.24.x
strings Runner.app/Frameworks/Flutter.framework/Flutter | grep kCT
SkCTMShader
kCTFontVariationAxisHiddenKey
kCTFontPaletteAttribute
kCTFontPaletteColorsAttribute
3.22.3
strings Runner.app/Frameworks/Flutter.framework/Flutter | grep kCT
SkCTMShader
kCTFontVariationAxisHiddenKey
我先在 Flutter
引擎源码中搜索,结果压根就搜索不到,随后打开了前几日编译好的引擎调试项目,结果一搜一个准,在 third_party
依赖下的 Skia
代码中,很快就定位到了引入未公开 API
的相关提交记录 skia-review.googlesource.com/c/skia/+/86… 。
我一看完就啪的一声敲起来了,很快啊!上来就是一个 Revert
skia-review.googlesource.com/c/skia/+/91… 。
目前此次受影响的 Flutter
版本范围暂时是 3.24.0
~ 3.24.4
,得等待新版本的发布才可以解决。建议还没用上 3.24
的小伙伴先不要升级,那如果已经是 3.24
或者是一定要用 3.24.4
及以下版本的小伙伴要怎么办呢?那就跟我一起来自编译引擎吧~
二、编译引擎
环境
注意:全程需要科学上网环境,请自行查找和配置
首先拉取最新的 depot_tools
,放到一个合适的位置,比如我放在 ~/development
目录下
cd ~/development
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
将 depot_tools
添加至环境变量,在你的终端配置文件里补充如下内容
终端配置文件因人而异,如:
~/.bash_profile
、~/.zshrc
、~/.zprofile
,请自行判断
export PATH = "$HOME/development/depot_tools":$PATH
然后 source ~/.zshrc
(这里请根据自身情况修改终端配置文件路径)
拉源码
找个合适的目录,创建 engine
目录并进入
mkdir engine
cd engine
开始拉取源码
fetch flutter
它会在当前目录下创建 .gclient
文件,写好配置,并执行 gclient sync
solutions = [
{
"custom_deps": {},
"deps_file": "DEPS",
"managed": False,
"name": "src/flutter",
"safesync_url": "",
"url": "https://github.com/flutter/engine.git",
},
]
如果在拉取代码的过程中遇到如下问题
remote: Enumerating objects: 835563, done.
remote: Counting objects: 100% (1612/1612), done.
remote: Compressing objects: 100% (1011/1011), done.
error: RPC failed; curl 92 HTTP/2 stream 5 was not closed cleanly: CANCEL (err 8)
error: 1481 bytes of body are still expected
fetch-pack: unexpected disconnect while reading sideband packet
fatal: early EOF
fatal: fetch-pack: invalid index-pack output
src/flutter (ERROR)
----------------------------------------
[0:00:00] Started.
别慌,执行下方命令让其接着拉,直至完成
gclient sync
拉取完成后,去查看我们使用的 Flutter
版本对应的引擎版本,这里以 3.24.4
为例,打开链接:github.com/flutter/flu… ,拿到 db49896cf25ceabc44096d5f088d86414e05a7aa
执行如下命令进行切换
cd src/flutter
git checkout db49896cf25ceabc44096d5f088d86414e05a7aa
执行完成会输出如下内容
Previous HEAD position was b0a4ca92c4 Add FlPointerManager to process pointer events from GTK in a form suitable for Flutter. (#56443)
HEAD is now at db49896cf2 [CP-stable]Add xcprivacy privacy manifest to macOS framework (#55366)
post-checkout: The engine source tree has been updated.
You may need to run "gclient sync -D"
按照提示执行
gclient sync -D
调整源码
按路径 engine/src/flutter/third_party/skia/src/ports/SkTypeface_mac_ct.cpp
打开文件,按下方内容进行修改(红:删除,绿:新增)
static CFStringRef getCTFontPaletteAttribute() {
- static CFStringRef* kCTFontPaletteAttributePtr =
- static_cast<CFStringRef*>(dlsym(RTLD_DEFAULT, "kCTFontPaletteAttribute"));
- return *kCTFontPaletteAttributePtr;
+ return nullptr;
+ //static CFStringRef* kCTFontPaletteAttributePtr =
+ // static_cast<CFStringRef*>(dlsym(RTLD_DEFAULT, "kCTFontPaletteAttribute"));
+ //return *kCTFontPaletteAttributePtr;
}
static CFStringRef getCTFontPaletteColorsAttribute() {
- static CFStringRef* kCTFontPaletteColorsAttributePtr =
- static_cast<CFStringRef*>(dlsym(RTLD_DEFAULT, "kCTFontPaletteColorsAttribute"));
- return *kCTFontPaletteColorsAttributePtr;
+ return nullptr;
+ //static CFStringRef* kCTFontPaletteColorsAttributePtr =
+ // static_cast<CFStringRef*>(dlsym(RTLD_DEFAULT, "kCTFontPaletteColorsAttribute"));
+ //return *kCTFontPaletteColorsAttributePtr;
}
...
static bool apply_palette(CFMutableDictionaryRef attributes,
const SkFontArguments::Palette& palette) {
bool changedAttributes = false;
- if (palette.index != 0 || palette.overrideCount) {
+ if ((palette.index != 0 || palette.overrideCount) && getCTFontPaletteAttribute()) {
SkUniqueCFRef<CFNumberRef> paletteIndex(
CFNumberCreate(kCFAllocatorDefault, kCFNumberIntType, &palette.index));
CFDictionarySetValue(attributes, getCTFontPaletteAttribute(), paletteIndex.get());
changedAttributes = true;
}
- if (palette.overrideCount) {
+ if (palette.overrideCount && getCTFontPaletteColorsAttribute()) {
SkUniqueCFRef<CFMutableDictionaryRef> overrides(
...
相应修改来自: skia-review.googlesource.com/c/skia/+/91…
编译
来到 engine/src
目录,使用 gn
编译生成 ninja
构建文件
./flutter/tools/gn --runtime-mode release --mac-cpu arm64
./flutter/tools/gn --ios --runtime-mode release
使用 ninja
编译引擎的最终产物
ninja -C out/host_release_arm64
ninja -C out/ios_release
如果你当前是 MacOS 15
的 Sequoia
系统,在执行 ninja -C out/host_release_arm64
时会遇到如下错误
COPY '/System/Library/Fonts/A...arty/txt/assets/Apple Color Emoji.ttc'
FAILED: gen/flutter/third_party/txt/assets/Apple Color Emoji.ttc
ln -f '/System/Library/Fonts/Apple Color Emoji.ttc' 'gen/flutter/third_party/txt/assets/Apple Color Emoji.ttc' 2>/dev/null || (rm -rf 'gen/flutter/third_party/txt/assets/Apple Color Emoji.ttc' && cp -af '/System/Library/Fonts/Apple Color Emoji.ttc' 'gen/flutter/third_party/txt/assets/Apple Color Emoji.ttc')
cp: chflags: gen/flutter/third_party/txt/assets/Apple Color Emoji.ttc: Operation not permitted
[18/4139] SOLINK libvk_swiftshader.dylib libvk_swiftshader.dylib.TOC
ninja: build stopped: subcommand failed.
别急,打开 engine/src/build/toolchain/mac/BUILD.gn
,做如下修改,修改完再执行 gn
和 ninja
tool("copy") {
- command = "ln -f {{source}} {{output}} 2>/dev/null || (rm -rf {{output}} && cp -af {{source}} {{output}})"
+ command = "ln -f {{source}} {{output}} 2>/dev/null || (rsync -a --delete {{source}} {{output}})"
description = "COPY {{source}} {{output}}"
}
相应的 issue
: #152978
好了,静静等待编译完成。
请注意,这将是个十分漫长且全程 CPU
占用率为 100%
的过程~
建议使用一台空闲的 Mac
电脑去做这个事!否则你将啥活也干不了~
验证
进入 engine/src/out/ios_release
strings Flutter.framework/Flutter | grep kCT
SkCTMShader
kCTFontVariationAxisHiddenKey
可以看到,没有 kCTFontPaletteAttribute
和 kCTFontPaletteColorsAttribute
。
使用本地引擎
执行如下命令对项目进行编译
flutter build ipa \
--local-engine-src-path=/Users/lxf/engine/src \
--local-engine=ios_release \
--local-engine-host=host_release_arm64
如果你有使用 realm
的话,可能会遇到如下错误
Installing realm (1.0.3)
[!] /bin/bash -c
set -e
source "/Users/lxf/app/ios/Flutter/flutter_export_environment.sh" && cd "$FLUTTER_APPLICATION_PATH" && "$FLUTTER_ROOT/bin/flutter" pub run realm install --target-os-type ios --flavor flutter
You must specify --local-engine or --local-web-sdk if you are using a locally built engine or web sdk.
你需要对该文件
/Users/lxf/app/ios/.symlinks/plugins/realm/ios/realm.podspec
进行修改,在 \"$FLUTTER_ROOT/bin/flutter\"
和 pub
中间加上引擎相关参数。如下所示
s.prepare_command = "source \"#{project_dir}/Flutter/flutter_export_environment.sh\" && cd \"$FLUTTER_APPLICATION_PATH\" && \"$FLUTTER_ROOT/bin/flutter\" --local-engine-src-path /Users/lxf/engine/src --local-engine ios_release --local-engine-host host_release_arm64 pub run realm install --target-os-type ios --flavor flutter"
:script => 'source "$PROJECT_DIR/../Flutter/flutter_export_environment.sh" && cd "$FLUTTER_APPLICATION_PATH" && "$FLUTTER_ROOT/bin/flutter" --local-engine-src-path /Users/lxf/engine/src --local-engine ios_release --local-engine-host host_release_arm64 pub run realm install --target-os-type ios --flavor flutter',
如果你只是想对项目进行配置,则将 ipa
改为 ios
,并加上 --config-only
参数即可。
flutter build ios \
--local-engine-src-path=/Users/lxf/engine/src \
--local-engine=ios_release \
--local-engine-host=host_release_arm64 \
--config-only
以前使用本地引擎只需要 --local-engine
参数,现在要求结合 --local-engine-host
一块使用,这里附上相关 issue
:github.com/flutter/flu… ,想了解的可以点开看看
三、最后
过程不难,麻烦的是拉源码和编译真的好慢,而且空间占用还大~
好了,本篇到此结束,感谢大家的支持,我们下次再见! 👋
如果文章对您有所帮助, 请不吝点击关注一下我的微信公众号:FSA全栈行动, 这将是对我最大的激励. 公众号不仅有
iOS
技术,还有Android
,Flutter
,Python
等文章, 可能有你想要了解的技能知识点哦~
来源:juejin.cn/post/7436567770907017257
TypeScript很麻烦💔,不想使用!
本文已经授权【稀土掘金技术社区】官方公众号独家原创发布。
前言
最近,我们部门在开发一个组件库时,我注意到一些团队成员对使用TypeScript表示出了抵触情绪,他们常常抱怨说:“TypeScript太麻烦了,我们不想用!”起初,我对此感到困惑:TypeScript真的有那么麻烦吗?然而,当我抽时间审查队伍的代码时,我终于发现了问题所在。在这篇文章中,我想和大家分享我的一些发现和解决方案。
一、类型复用不足
在代码审查过程中,我发现了大量的重复类型定义,这显著降低了代码的复用性。
进一步交流后,我了解到许多团队成员并不清楚如何在TypeScript中复用类型。TypeScript允许我们使用type
和interface
来定义类型。
当我询问他们type
与interface
之间的区别时,大多数人都表示不清楚,这也就难怪他们不知道如何有效地复用类型了。
type
定义的类型可以通过交叉类型(&
)来进行复用,而interface
定义的类型则可以通过继承(extends
)来实现复用。值得注意的是,type
和interface
定义的类型也可以互相复用。下面是一些简单的示例:
复用type
定义的类型:
type Point = {
x: number;
y: number;
};
type Coordinate = Point & {
z: number;
};
复用interface
定义的类型:
interface Point {
x: number;
y: number;
};
interface Coordinate extends Point {
z: number;
}
interface
复用type
定义的类型:
type Point = {
x: number;
y: number;
};
interface Coordinate extends Point {
z: number;
}
type
复用interface
定义的类型:
interface Point {
x: number;
y: number;
};
type Coordinate = Point & {
z: number;
};
二、复用时只会新增属性的定义
我还注意到,在类型复用时,团队成员往往只是简单地为已有类型新增属性,而忽略了更高效的复用方式。
例如,有一个已有的类型Props
需要复用,但不需要其中的属性c
。在这种情况下,团队成员会重新定义Props1
,仅包含Props
中的属性a
和b
,同时添加新属性e
。
interface Props {
a: string;
b: string;
c: string;
}
interface Props1 {
a: string;
b: string;
e: string;
}
实际上,我们可以利用TypeScript提供的工具类型Omit
来更高效地实现这种复用。
interface Props {
a: string;
b: string;
c: string;
}
interface Props1 extends Omit<Props, 'c'> {
e: string;
}
类似地,工具类型Pick
也可以用于实现此类复用。
interface Props {
a: string;
b: string;
c: string;
}
interface Props1 extends Pick<Props, 'a' | 'b'> {
e: string;
}
Omit
和Pick
分别用于排除和选择类型中的属性,具体使用哪一个取决于具体需求。
三、未统一使用组件库的基础类型
在开发组件库时,我们经常面临相似功能组件属性命名不一致的问题,例如,用于表示组件是否显示的属性,可能会被命名为show
、open
或visible
。这不仅影响了组件库的易用性,也降低了其可维护性。
为了解决这一问题,定义一套统一的基础类型至关重要。这套基础类型为组件库的开发提供了坚实的基础,确保了所有组件在命名上的一致性。
以表单控件为例,我们可以定义如下基础类型:
import { CSSProperties } from 'react';
type Size = 'small' | 'middle' | 'large';
type BaseProps<T> = {
/**
* 自定义样式类名
*/
className?: string;
/**
* 自定义样式对象
*/
style?: CSSProperties;
/**
* 控制组件是否显示
*/
visible?: boolean;
/**
* 定义组件的大小,可选值为 small(小)、middle(中)或 large(大)
*/
size?: Size;
/**
* 是否禁用组件
*/
disabled?: boolean;
/**
* 组件是否为只读状态
*/
readOnly?: boolean;
/**
* 组件的默认值
*/
defaultValue?: T;
/**
* 组件的当前值
*/
value?: T;
/**
* 当组件值变化时的回调函数
*/
onChange: (value: T) => void;
}
基于这些基础类型,定义具体组件的属性类型变得简单而直接:
interface WInputProps extends BaseProps<string> {
/**
* 输入内容的最大长度
*/
maxLength?: number;
/**
* 是否显示输入内容的计数
*/
showCount?: boolean;
}
通过使用type
关键字定义基础类型,我们可以避免类型被意外修改,进而增强代码的稳定性和可维护性。
四、处理含有不同类型元素的数组
在审查自定义Hook时,我发现团队成员倾向于返回对象,即使Hook只返回两个值。
虽然这样做并非错误,但它违背了自定义Hook的一个常见规范:当Hook返回两个值时,应使用数组返回。
团队成员解释说,他们不知道如何定义含有不同类型元素的数组,通常会选择使用any[]
,但这会带来类型安全问题,因此他们选择返回对象。
实际上,元组是处理这种情况的理想选择。通过元组,我们可以在一个数组中包含不同类型的元素,同时保持每个元素类型的明确性。
function useMyHook(): [string, number] {
return ['示例文本', 42];
}
function MyComponent() {
const [text, number] = useMyHook();
console.log(text); // 输出字符串
console.log(number); // 输出数字
return null;
}
在这个例子中,useMyHook
函数返回一个明确类型的元组,包含一个string
和一个number
。在MyComponent
组件中使用这个Hook时,我们可以通过解构赋值来获取这两个不同类型的值,同时保持类型安全。
五、处理参数数量和类型不固定的函数
审查团队成员封装的函数时,我发现当函数的参数数量不固定、类型不同或返回值类型不同时,他们倾向于使用any
定义参数和返回值。
他们解释说,他们只知道如何定义参数数量固定、类型相同的函数,对于复杂情况则不知所措,而且不愿意将函数拆分为多个函数。
这正是函数重载发挥作用的场景。通过函数重载,我们可以在同一函数名下定义多个函数实现,根据不同的参数类型、数量或返回类型进行区分。
function greet(name: string): string;
function greet(age: number): string;
function greet(value: any): string {
if (typeof value === "string") {
return `Hello, ${value}`;
} else if (typeof value === "number") {
return `You are ${value} years old`;
}
}
在这个例子中,我们为greet
函数提供了两种调用方式,使得函数使用更加灵活,同时保持类型安全。
对于箭头函数,虽然它们不直接支持函数重载,但我们可以通过定义函数签名的方式来实现类似的效果。
type GreetFunction = {
(name: string): string;
(age: number): string;
};
const greet: GreetFunction = (value: any): string => {
if (typeof value === "string") {
return `Hello, ${value}`;
} else if (typeof value === "number") {
return `You are ${value} years old.`;
}
return '';
};
这种方法利用了类型系统来提供编译时的类型检查,模拟了函数重载的效果。
六、组件属性定义:使用type
还是interface
?
在审查代码时,我发现团队成员在定义组件属性时既使用type
也使用interface
。
询问原因时,他们表示两者都可以用于定义组件属性,没有明显区别。
由于同名接口会自动合并,而同名类型别名会冲突,我推荐使用interface
定义组件属性。这样,使用者可以通过declare module
语句自由扩展组件属性,增强了代码的灵活性和可扩展性。
interface UserInfo {
name: string;
}
interface UserInfo {
age: number;
}
const userInfo: UserInfo = { name: "张三", age: 23 };
结语
TypeScript的使用并不困难,关键在于理解和应用其提供的强大功能。如果你在使用TypeScript过程中遇到任何问题,不清楚应该使用哪种语法或技巧来解决,欢迎在评论区留言。我们一起探讨,共同解决TypeScript中遇到的挑战。
来源:juejin.cn/post/7344282440725577765
只写后台管理的前端要怎么提升自己
本人写了五年的后台管理。每次面试前就会头疼,因为写的页面除了表单就是表格。抱怨过苦恼过也后悔过,但是站在现在的时间点回想以前,发现有很多事情可以做的更好,于是有了这篇文章。
写优雅的代码
一道面试题
大概两年以前,面试美团的时候,面试官让我写一道代码题,时间单位转换。具体的题目我忘记了。
原题目我没做过,但是我写的业务代码代码里有类似的单位转换,后端返回一个数字,单位是kb
,而我要展示成 KB
,MB
等形式。大概写一个工具函数(具体怎么写的忘记了,不过功能比这个复杂点):
function formatSizeUnits(kb) {
let units = ['KB', 'MB', 'GB', 'TB', 'PB'];
let unitIndex = 0;
while (kb >= 1024 && unitIndex < units.length - 1) {
kb /= 1024;
unitIndex++;
}
return `${kb.toFixed(2)} ${units[unitIndex]}`;
}
而在此之前,是一个后端同学写的代码(说起来,后台管理系统前端人力不够后端帮忙写好像是常规操作,后端总觉得写前端简单,而我只能说你们那代码写的,一言难尽……),如下:
function formatSizeUnits(kb) {
var result = '';
if (kb < 1024) {
result = kb + ' KB';
} else if (kb < 1024 * 1024) {
result = (kb / 1024).toFixed(2) + ' MB';
} else if (kb < 1024 * 1024 * 1024) {
result = (kb / 1024 / 1024).toFixed(2) + ' GB';
} else {
result = (kb / 1024 / 1024 / 1024).toFixed(2) + ' TB';
}
return result;
}
虽然功能都一样,但是我写在面试的时候写了上面的代码得到了面试官的赞扬,也因此非常顺利的通过了面试。而如果我写成了下面的样子,估计不会有这种效果。
如何提升代码质量
想提升代码质量,最理想的是靠 code review ,但实际上这玩意在大多数公司根本就不存在。其次就只能多看看大佬们写的代码,但是一来后台管理本来就没太大难度很少有大佬,二来很多时候一个后台管理系统就一两个人负责。
还好现在有了各种 AI 工具,可以非常方便的优化自己的代码了。
还是上面的问题,看看 GPT 给的答案
// 提问:写一个工具函数,对文件大小的单位进行转换,加注释
// 回答:下面是一个JavaScript工具函数,它接受文件大小、初始单位、目标单位和小数位数,然后返回目标单位下的文件大小。
/**
* 将文件大小从一个单位转换为另一个单位。
*
* @param {number} size 文件大小。
* @param {string} fromUnit 初始单位('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB')。
* @param {string} toUnit 目标单位('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB')。
* @param {number} [decimalPoint=2] 结果保留的小数位数,默认为2。
* @return {string} 转换后的文件大小,带单位。
*/
function convertFileSize(size, fromUnit, toUnit, decimalPoint = 2) {
// 定义单位与字节之间的转换关系
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
// 获取初始单位和目标单位的索引
const fromIndex = units.indexOf(fromUnit);
const toIndex = units.indexOf(toUnit);
// 如果单位不在列表中,抛出错误
if (fromIndex === -1 || toIndex === -1) {
throw new Error('Invalid units');
}
// 计算初始单位与目标单位之间的转换系数
const exponent = toIndex - fromIndex;
// 计算结果大小
const resultSize = size / Math.pow(1024, exponent);
// 返回格式化后的结果
return parseFloat(resultSize.toFixed(decimalPoint)) + ' ' + toUnit;
}
// 示例使用
console.log(convertFileSize(1, 'GB', 'MB')); // 输出: 1024.00 MB
console.log(convertFileSize(1, 'MB', 'KB')); // 输出: 1024.00 KB
console.log(convertFileSize(1, 'KB', 'B')); // 输出: 1024.00 B
console.log(convertFileSize(1, 'MB', 'GB', 5)); // 输出: 0.00098 GB
还能说什么,我只能说 NB!(经评论区提示,此段代码可能有问题,建议大家在使用AI工具时,重点是借鉴思路,直接使用代码需要谨慎~)
我现在每次写大段逻辑之后都习惯拿给 AI 看看,有什么更好的实现方式,或者用什么设计模式。AI 是非常低成本且高效提升代码质量的工具。
学会封装
一个功能用到了好多次,为什么不封装成组件?一个组件用到了好几个项目,为什么不单独写个npm包?差不多的项目创建了好几个,为什么不封装成脚手架?
你说,没时间,没必要,复制粘贴反而更快。
那你就完全没理解,这么做不一定是为了让工作更快完成,而是可以让你在年年终述职时更有话说(你就算写了一百个表单表格没有写一个脚手架更值得炫耀),如果不会写可以问问 AI。
而当你真正开始封装组件,开始写工具库了,你会发现你需要思考的确实比之前多了。
关注业务
对于前端业务重要吗?
相比于后端来说,前端一般不会太关注业务。就算出了问题大部分也是后端的问题。
但是就我找工作的经验,业务非常重要!
如果你做的工作很有技术含量,比如你在做低代码,你可以面试时讲一个小时的技术难点。但是你只是一个破写后台管理,你什么都没有的说。这个时候,了解业务就成为了你的亮点。
一场面试
还是拿真实的面试场景举例,当时前同事推我字节,也是我面试过N次的梦中情厂了,刚好那个组做的业务和我之前呆的组做的一模一样。
- 同事:“做的东西和咱们之前都是一样的,你随便走个过场就能过,我在前端组长面前都夸过你了!”
- 我:“好嘞!”
等到面试的时候:
- 前端ld:“你知道xxx吗?(业务名词)”
- 我:“我……”
- 前端ld:“那xxxx呢?(业务名词)”
- 我:“不……”
- 前端ld:“那xxxxx呢??(业务名词)”
- 我:“造……”
然后我就挂了………………
如何了解业务
- 每次接需求的时候,都要了解需求背景,并主动去理解
我们写一个表格简简单单,把数据展示出来就好,但是表格中的数据是什么意思呢?比如我之前写一个 kafka 管理平台,里面有表格表单,涉及什么
cluster
controller
topic
broker
partition
…… 我真的完全不了解,很后悔我几年时间也没有耐下心来去了解。 - 每次做完一个需求,都需要了解结果
有些时候,后台管理的团队可能根本没有PM,那你也要和业务方了解,这个功能做了之后,多少人使用,效率提高了吗?数据是怎样的?
- 理解需求,并主动去优化
产品要展示一千条数据,你要考虑要不要分页,不分页会不会卡,要不要上虚拟表格?
产品要做一个可拖拽表单,你要考虑是否需要拖动,是否需要配置。
其实很多时候,产品的思维可能会被局限在竞品的实现方式,而前端可以给TA更多选项。在和产品沟通的时候,你不仅是沟通页面的实现,也更能理解业务。
关注源码
说到源码, Vue,React 这些基本是每次必问,也有太多大佬们的总结我就不多说了。
除此之外,我想大家每次面试应该都会被问,你写了什么亮点的项目,困难的项目,你会说什么?哦,都是表单表格,都用 Element UI 实现的,没什么难度。那估计你面试也就到这里了。
那说什么,后台管理就这些啊?!
如果你用了 Element UI 你可以说写了什么自定义 Element UI 表单组件,Element UI 表单源码是什么原理,怎么通信的?用了 Axios 可以说 Axios 的原理,它的拦截器怎么实现的?用了编辑器 diff 那你知道 diff 算法是什么吗?用了 ECharts 那你知道他底层是用什么绘制的吗?用了 husky ?husky的原理是什么?用了Vite,它为什么快?是怎么打包的?写过插件吗?
可能有人觉得我离谱,事实上上面每一个问题都是我在面试中真实被问到的。当然,如果全部去了解,肯定不现实,每个人的精力有限,不可能每个用到的每个工具每个库都去了解原理,不过只要有一两个你去了解过,就可以在面试中引导面试官来问,要学会表现出你擅长的点。
至于怎么读源码?以前我不知道,现在嘛…… 为什么不问问神奇的 ChatGPT 呢?
讲一下 Axios 源码中,拦截器是怎么实现的?
Axios 的拦截器实现基于 Axios 的核心原理,即 Axios 实例是一个包含请求和响应拦截器堆栈的对象。当发出请求或接收响应时,Axios 会遍历这些拦截器,并按照添加的顺序执行请求拦截器,以及按照相反的顺序执行响应拦截器。
在 Axios 的源码中,拦截器是通过一个 AxiosInterceptorManager 实例来管理的,它维护了一个拦截器数组。每个拦截器都是一个包含
fulfilled
和rejected
函数的对象。这两个函数分别对应于拦截器成功处理和拦截器处理出错的情况。
以下是 Axios 拦截器管理器的一个简化版本,展示了其核心实现思路:
class InterceptorManager {
constructor() {
this.handlers = []; // 存储拦截器的数组
}
use(fulfilled, rejected) {
this.handlers.push({
fulfilled: fulfilled,
rejected: rejected
});
return this.handlers.length - 1; // 返回拦截器的ID
}
eject(id) {
if (this.handlers[id]) {
this.handlers[id] = null; // 移除拦截器
}
}
forEach(fn) {
this.handlers.forEach((h) => {
if (h !== null) {
fn(h);
}
});
}
}
在发送请求或接收响应时,Axios 会创建一个 promise 链,并通过
forEach
方法将拦截器中的fulfilled
和rejected
函数添加到这个链中。这样,每个拦截器都可以对请求或响应进行处理,然后将结果传递到链的下一个拦截器,或者在出错时结束链的执行。
axios.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
chain.unshift(interceptor.fulfilled, interceptor.rejected);
});
axios.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
chain.push(interceptor.fulfilled, interceptor.rejected);
});
在 Axios 的完整实现中,这个拦截器机制被集成到了 Axios 的请求发送和响应处理流程中。通过这种方式,Axios 可以在发送请求之前和接收响应之后,但在用户定义的
.then
或.catch
执行之前,插入自定义的逻辑。
请注意,这里提供的代码只是为了说明 Axios 拦截器的实现原理,并不是 Axios 源码的完整复制。如果你对 Axios 的拦截器实现细节感兴趣,建议查看 Axios 的官方 GitHub 仓库中的源码。
前端基建
当我们工作时间久了面试难免会遇到这些问题,前端工程化,前端监控,工作流,部署,性能等等。其实我们在工作中绝大部分时间都在写代码,对于这些不是所有人都有机会接触到,不过这些和所做的业务无关,是我们提升自己很好的一个思路。
技术选型
技术栈选 Vue 还是 React?Vue 选 Vue2 还是 Vue3?组件库选 ElementUI 还是 Ant Design?微前端有没有使用过?打包工具用 Vite 还是 Webpack?有那么多表单怎么实现的,有没有什么表单配置化方案,比如Formily?
对于我这种菜鸡,我这种只写简单的表单表格的人,这些都……无所谓……

不过为了应对面试我们还是需要了解下未选择技术栈的缺点,和已选择技术栈的优点(有点本末倒置…但是常规操作啦)
Vue 你可以说简单高效轻量级,面试必会问你为什么,你就开始说 Vue 的响应式系统,依赖收集等。
React 你可以说 JSX、Hooks 很灵活,那你必然要考虑 JSX 怎么编译, Hooks 实现方式等。
总体而言,对于技术选型,依赖于我们对所有可选项的理解,做选择可能很容易,给出合理的理由还是需要花费一些精力的。
开发规范
这个方面,在面试的时候我被问到的不多,我们可以在创建项目的时候,配置下 ESlint
,stylelint
, prettier
, commitlint
等。
前端监控
干了这么多年前端,前端监控我是……一点没做过。

前端监控,简单来说就是我们在前端程序中记录一些信息并上报,一般是错误信息,来方便我们及时发现问题并解决问题。除此之外也会有性能监控,用户行为的监控(埋点)等。之前也听过有些团队分享前端监控,为了出现问题明确责任(方便甩锅)。
对于实现方案,无论使用第三方库还是自己实现,重要的都是理解实现原理。
对于错误监控,可以了解一下 Sentry,原理简单来说就是通过 window.onerror
和 window.addEventListener('unhandledrejection', ...)
去分别捕获同步和异步错误,然后通过错误信息和 sourceMap
来定位到源码。
对于性能监控,我们可以通过 window.performance
、PerformanceObserver
等 API 收集页面性能相关的指标,除此之外,还需要关注接口的响应时间。
最后,收集到信息之后,还要考虑数据上报的方案,比如使用 navigator.sendBeacon
还是 Fetch、AJAX?是批量上报,实时上报,还是延迟上报?上报的数据格式等等。
CI/CD
持续集成(Continuous Integration, CI)和 持续部署(Continuous Deployment, CD),主要包括版本控制,代码合并,构建,单测,部署等一系列前端工作流。
场景的工作流有 Jenkins、 Gitlab CI 等。我们可以配置在合并代码时自动打包部署,在提交代码时自动构建并发布包等。
这块我了解不多,但感觉这些工具层面的东西,不太会涉及到原理,基本上就是使用的问题。还是需要自己亲自动手试一下,才能知道细节。比如在 Gitlab CI 中, Pipeline
、 Stage
和 Job
分别是什么,怎么配置,如何在不同环境配置不同工作流等。
了解技术动态
这个可能还是比较依赖信息收集能力,虽然我个人觉得很烦,但好像很多领导级别的面试很愿意问。
比如近几年很火的低代码,很多面试官都会问,你用过就问你细节,你没用过也会问你有什么设计思路。
还有最近的两年爆火的 AI,又或者 Vue React的最新功能,WebAssembly,还有一些新的打包工具 Vite Bun 什么的,还有鸿蒙开发……
虽然不可能学完每一项新技术,但是可以多去了解下。
总结
写了这么多,可能有人会问,如果能回到过去,你会怎么做。
啊,我只能说,说是一回事,做又是另一回事,事实上我并不希望回到过去去卷一遍,菜点没关系,快乐就好,一切都是最好的安排。

来源:juejin.cn/post/7360528073631318027
搭建一个快速开发油猴脚本的前端工程
一、需求起因
最近遇到一个问题:公司自用的 bug 管理工具太老了,网页风格还是上世纪的文字页面。虽然看习惯了还好,但是某些功能确实很不方便。比如,联系人都是邮箱或者英文名,没有中文名称,在流转 bug 时还得复制粘贴英文名去企业微信里搜索对应的人名。第二是人员比较多,在一堆邮箱里很难找到对应的人......
总之,诸如此类的问题让我有了对该网页进行改造的想法。
但是这种网页都是公司创业时期拿的开源产品私有化部署,网页源码能不能找到都不好说。再者,公司也不会允许此类的“小聪明”,这并不是我的主职工作,所以修改源码是非常不现实的。
那目前的思路,就是在原网页基础上进行脚本注入,修改网页内容和样式。方案无非就是浏览器插件或者脚本注入两种。
脚本的话就是利用油猴插件
的能力,写一个油猴脚本,在网页加载完成后注入我们书写的脚本,达到修改原网页的效果。
插件也是类似的原理,但是写插件要麻烦得多。
出于效率考虑,我选择了脚本的方案。这里其实也是想巩固下 js
的 DOM API
,框架写多了,很多原生的 API
反而忘得一干二净。
二、关于油猴脚本
先看一份 demo
:
// ==UserScript==
// @name script
// @namespace http://tampermonkey.net/
// @version 0.0.1
// @description 这是一段油猴脚本
// @author xxx
// @match *://baidu.com/*
// @run-at document-end
// @license MIT
// ==/UserScript==
(function () {
"use strict";
const script = document.createElement("script");
document.body.appendChild(script);
})();
油猴脚本由注释及 js
代码组成。注释需要包裹在
// ==UserScript==
// ==/UserScript==
两个闭合标签内。同时只能书写类似 @name
规定好的注释头,用于标明脚本的一些元信息。其中比较重要的是 @match
和 @run-at
。
@match
规定了该脚本所运行的域名,例如,只有当我打开了百度的网页时我才运行脚本,这个 @match
可以书写多个。@run-at
则规定了脚本的运行时机,一般是网页加载开始,网页加载结束。@run-at
只声明一次。
@run-at
有以下可选值:
图片看得不清晰也没关系,这种都是用到再查。
更多注释配置请参考:油猴脚本。
而代码部分是一个立即执行函数,所有的内容都需要写在这个立即执行函数内,否则无法生效。
三、问题显现
刚开始,我并没有工程化开发的想法,我想的是就是一个脚本,直接一梭子写到底即可,反正就是那样,就是个普通的 js
文件,一切都是那么原始,朴实无华。
但是当代码来到两千多行后(我是真的很爱加东西),绷不住了,每次写代码都需要在文件上下之间反复横跳,有时候有些变量定义了都不记得,写代码还得滚动半天才能到最底下。
加东西也变得越来越臃肿,越来越丑陋。
忍无可忍,我决定对这个脚本进行工程化改造。但是工程化之前有几个问题需要解决,或者说需要调研清楚。
四、关键点分析
1.构建工具
首先肯定是打包成 iife
的产物,很多工具都支持。既然工程化了,一般大家的选择就是 webpack
或者 vite
。这里因为涉及到开发模式,需要及时产出打包产物,且能够搭建 dev
服务器,方便访问本地打包后的资源,因此需要选择具备 dev
服务器的开发构建工具。
我选择 vite
。当然,webpack
也是不错的选择。
如果你对实时预览要求不高,能够接受复制粘贴到油猴再刷新页面预览,也可以选择纯粹的打包器,例如 rollup
。
2.css 预编译器
传统的添加样式的方式,一般就是生成一个 style
标签,然后修改其 innerHTML
:
export const addStyle = (css: string) => {
const style = document.createElement('style');
style.type = 'text/css';
style.innerHTML = css;
document.getElementsByTagName('head')[0].appendChild(style);
}
addStyle(`
body {
width: 100%;
height: 100%;
}
`);
这样就能实现往网页里添加自定义的样式。但是我现在不满足于书写传统的 css
,我既然都工程化了,肯定要把 less
或者 scss
用上。
我的目的,就是可以新建一个例如 style.less
的文件开心地书写 less
,打包时候编译一下这个 less
文件,并将其样式注入到目标 HTML
中。
但在传统模块化工程里,构建工具对 less
的支持,是直接在 HTML
中生成一个 style
标签,引入编译后的 less
产物(css
)。
也就是说,我需要手动实现 less
到 css
到 js
这个过程。
转变的步骤就是用 less
本身的编译能力,将其产物转变为一个 js
模块。
具体实现放到后面再聊。
3.实现类似热更新的效果
我们启动一个传统的 vite
工程时,我们更新了某个 js
文件或者相关文件后,工程会监听我们的文件被修改了,从而触发热更新,服务也会自动刷新,从而达到实时预览的效果。
这是因为工程会在本地启动一个开发服务器,最终产物也会实时构建,那网页每次去获取这个服务器上的资源,就会获取到最新的代码。根据这点,我们同样需要启动一个本地服务器,而这在 vite
中直接一个 vite
命令即可。
在油猴脚本中,我们新建一个 script
标签,将其 src
指向我们本地服务器的构建产物的地址,即可实现实时的脚本更新,而不用复制产物代码再粘贴到油猴。
代码如下:
// ==UserScript==
// @name script
// @namespace http://tampermonkey.net/
// @version 0.0.1
// @description 这是描述
// @author xxx
// @match *://baidu.com/*
// @run-at document-end
// @license MIT
// ==/UserScript==
(function () {
"use strict";
const script = document.createElement("script");
script.src = "http://localhost:6419/dist/script.iife.js";
document.body.appendChild(script);
})();
这里的 localhost:6419
、/dist/script.iife.js
都取决于你 vite.config.js
中的配置。
具体后面再聊。
五、开始搭建工程
1.使用 yarn create vite
或者 pnpm create vite
初始化一个 vite
模板工程
其他的你自己看着选就可以。
2.修改 vite.config.js
/**
* @type {import('vite').UserConfig}
*/
module.exports = {
server: {
host: 'localhost',
port: 6419,
},
build: {
minify: false,
outDir: 'dist',
lib: {
entry: 'src/main.ts',
name: 'script',
fileName: 'script',
formats: ['iife'],
},
},
resolve: {
alias: {
'@': '/src',
'@utils': '/src/utils',
'@enum': '/src/enum',
'@const': '/src/const',
'@style': '/src/style',
}
}
}
这里使用 cjs
是因为我们会实现一些脚本,脚本里可能会用到这里的某些配置,所以使用 cjs
导出也有利于外部的使用。
3.创建一个 tampermonkey.config
文件,将油猴注释放在这里
// ==UserScript==
// @name script
// @namespace http://tampermonkey.net/
// @version 0.0.1
// @description 这是描述
// @author xxx
// @match *://baidu.com/*
// @run-at document-end
// @license MIT
// ==/UserScript==
当然,你要觉得这样多余、没必要,也可以看自己喜好,只要最终产物里有这个注释即可。但是拆出来有利于我们维护,后续也会新增脚本,有利于工程化的整体性和可维护性。
4.使用 nodemon
监听文件修改
因为我们自己对 less
有特殊处理,加上未来可能会对需要监听的文件进行精细化管理,所以这里引入 nodemon
,如果你自己对工程化有自己的理解,也可以按照自己的理解配置。
执行 pnpm i nodemon -D
。
根目录新增 nodemon.json
:
{
"ext": "ts,less",
"watch": ["src"],
"exec": "pnpm dev:build && vite"
}
这里的 pnpm dev:build
还另有玄机,后面再展开。
到这里,我们的工程雏形已经具备了。但是还有一个最关键的点没有解决——那就是 less
的转换。
六、less 的转换以及几个脚本
首先,less
代码需要编译为 css
,但是我们需要的是 css
的字符串,这样才能通过 innerHTML
之类的方法注入到网页中。
使用 less.render
方法可以对 less
代码进行编译,其是一个 Promise
,我们可以在 then
中接收编译后的产物。
我们可以直接在根目录新建一个 script
文件夹,在 script
文件夹下新建一个 gen-style-string.js
的脚本:
const less = require('less');
const fs = require('fs');
const path = require('path');
const styleContent = fs.readFileSync(path.resolve(__dirname, '../src/style.less'), 'utf-8');
less.render(styleContent).then(output => {
if(output.css) {
const code = `export default \`\n${output.css}\``;
const relativePath = '../style/index.ts';
const filePath = path.resolve(__dirname, relativePath)
if(fs.existsSync(filePath)) {
fs.rm(filePath, () => {
fs.writeFileSync(path.resolve(__dirname, relativePath), code)
})
} else {
fs.writeFileSync(path.resolve(__dirname, relativePath), code)
}
}
})
我们将编译后的 css
代码结合 js
代码导出为一个模块,供外部使用。也就是说,这部分编译必须在打包之前执行,这样才能得到正常的 js
模块,否则就会报错。
这段脚本执行完后会在 style/index.ts
中生成类似代码:
export default `
body {
width: 100%;
height: 100%;
}
`
这样 less
代码就能够被外部引入并使用了。
这里多说一句,因为 style/index.ts
的内容是根据 less
编译来的,而我们的 nodemon
会监听 src
目录,因此这个 less
编译后的 js
产物,不能放在 src
下,因为假设将它放在 src
目录下,它在写入的过程中也会触发 nodemon
,会导致 nodemon
进入死循环。
除此之外,我们之前还将油猴注释拎出来单独放在一个文件里:tampermonkey.config
。
在最终产物中,我们需要将其合并进去,思路同上:
const fs = require('fs');
const path = require('path');
const prettier = require('prettier');
const codeFilePath = '../dist/script.iife.js';
const configFilePath = '../tampermonkey.config';
const codeContent = fs.readFileSync(path.resolve(__dirname, codeFilePath), 'utf-8');
const tampermonkeyConfig = fs.readFileSync(path.resolve(__dirname, configFilePath), 'utf-8');
if (codeContent) {
const code = `${tampermonkeyConfig}\n${codeContent}`;
prettier.format(code, { parser: 'babel' }).then((formatted) => {
fs.writeFileSync(path.resolve(__dirname, codeFilePath), formatted)
})
}
最后,因为我们的 tampermonkey.config
以及 vite.config.js
可能会更改配置,所以每次我们在开发模式时生成的临时油猴脚本,也需要变,我们不可能每次都去修改,而是应该跟随上面两个配置文件进行生成,我们再新建一个脚本:
const fs = require('fs');
const path = require('path');
const prettier = require('prettier');
const viteConfig = require('../vite.config');
const codeFilePath = '../tampermonkey.js';
const tampermonkeyConfig = fs.readFileSync(path.resolve(__dirname, '../tampermonkey.config'), 'utf-8');
const hostPort = `${viteConfig.server.host}:${viteConfig.server.port}`;
const codeContent = `
(function () {
'use strict'
const script = document.createElement('script');
script.src = 'http://${hostPort}/dist/${viteConfig.build.lib.name}.iife.js';
document.body.appendChild(script);
})()
`;
const code = `${tampermonkeyConfig}\n${codeContent}`;
prettier.format(code, { parser: 'babel' }).then((formatted) => {
if(fs.existsSync(path.resolve(__dirname, codeFilePath))) {
fs.rm(path.resolve(__dirname, codeFilePath), () => {
fs.writeFileSync(path.resolve(__dirname, codeFilePath), formatted);
});
}
else {
fs.writeFileSync(path.resolve(__dirname, codeFilePath), formatted);
}
})
稍微用 prettier
美化一下。
七、完善 package.json 中的 script
我们其实只有开发模式,新建一个命令:
"dev": "node script/gen-tampermonkey.js && nodemon"
优先生成 tampermonkey.js
,这时候会启动服务器,记得先将 tampermonkey.js
中的内容拷贝到油猴,才能方便热更新,不然又需要复制粘贴。
对于 build
命令:
"dev:build": "node script/gen-style-string.js && tsc && vite build && node script/gen-script-header-comment.js"
需要先将 less
编译为可用的 js
字符串模块,然后才能执行 build
,build
完还需要拼接油猴注释,这样最终产物才具备可用的能力。
开发完成后,就将打包产物替换掉之前粘贴进油猴的内容。
八、额外的补充
vite
命令会直接启动本地开发服务器,而我们的 script
命令中,使用 &&
时,下一个命令会等待上一个命令执行完成后再执行,所以 vite
需要放在最后执行,这是串行逻辑。当然,借助一些库我们可以实现并行 script
命令。但是我们这里需要的是串行,只是不完美的是,每次文件变更,都需要重新执行 pnpm dev:build && vite
,这样会重复新启一个服务器,但是不重启的话,始终使用最初的那个服务,最新编译的资源无法被油猴感知,资源没有得到更新。
所以,聪明的你有办法解决吗?
来源:juejin.cn/post/7437887483259584522
作为一个前端你连requestAnimationFrame的用法、优势和应用场景都搞不清楚?
前言
如果你是一名前端开发,那么你多少有了解过requestAnimationFrame
吧?如果没有也接着往下看,会有详细用法说明。
其实很多人会局限于把requestAnimationFrame
应用于一些纯动画相关的需求上,但其实在前端很多业务场景下requestAnimationFrame
都能用于性能优化,下面将细说一下requestAnimationFrame
的具体用法
和几种应用场景
。
requestAnimationFrame作用与用法
requestAnimationFrame简述
MDN
官方说法是这样的
基本示例
<script lang="ts" setup>
function init() {
console.log('您好,我是requestAnimationFrame');
}
requestAnimationFrame(init)
</script>
效果如下
但是例子上面是最基本的调用方式,并且只简单执行了一次,而对于动画是要一直执行的。
下面直接上图看看官方的文档对这个的说明,上面说具体用法应该要递归调用,而不是单次调用。
递归调用示例
<script lang="ts" setup>
function init() {
console.log('您好,递归调用requestAnimationFrame');
requestAnimationFrame(init)
}
requestAnimationFrame(init)
</script>
执行动图效果如下
requestAnimationFrame
会一直递归调用执行,并且调用的频率通常是与当前显示器的刷新率相匹配
(这也是这个API
核心优势),例如屏幕75hz
就1
秒执行75
次。
而且如果使用的是定时器实现此功能是无法适应各种屏幕帧率的。
回调函数
requestAnimationFrame
执行后的回调函数有且只会返回一个参数,并且返回的参数是一个毫秒数
,这个参数所表示是的上一帧渲染的结束时间,直接看看下面代码示例与打印效果。
<script lang="ts" setup>
function init(val) {
console.log('您好,requestAnimationFrame回调:', val);
requestAnimationFrame(init);
}
requestAnimationFrame(init);
</script>
注意: 如果我们同时调用了很多个requestAnimationFrame
,那么他们会收到相同的时间戳,因为与屏幕的帧率相同所以并不会不一样。
终止执行
终止此API
的执行,官方提供的方法是window.cancelAnimationFrame()
,语法如下
ancelAnimationFrame(requestID)
直接看示例更便于理解,用法非常类似定时器的clearTimeout()
,直接把 requestAnimationFrame
返回值传给 cancelAnimationFrame()
即可终止执行。
<template>
<div>
<button @click="stop">停止</button>
</div>
</template>
<script lang="ts" setup>
let myReq;
function init(val) {
console.log('您好,requestAnimationFrame回调:', val);
myReq = requestAnimationFrame(init);
}
requestAnimationFrame(init);
function stop() {
cancelAnimationFrame(myReq);
}
</script>
requestAnimationFrame优势
1、动画更丝滑,不会出现卡顿
对比传统的setTimeout
和 setInterval
动画会更流畅丝滑。
主要 原因 是由于运行的浏览器会监听显示器返回的VSync
信号确保同步,收到信号后再开始新的渲染周期,因此做到了与浏览器绘制频率绝对一致。所以帧率会相当平稳,例如显示屏60hz
,那么会固定1000/60ms
刷新一次。
但如果使用的是setTimeout
和 setInterval
来实现同样的动画效果,它们会受到事件队列宏任务、微任务影响会导致执行的优先级顺序有所差异,自然做不到与绘制同频。
所以使用setTimeout
和 setInterval
不但无法自动匹配显示屏帧率,也无法做到完全固定的时间去刷新。
2、性能更好,切后台会暂停
当我们把使用了requestAnimationFrame
的页面切换到后台运行时,requestAnimationFrame
会暂停执行从而提高性能,切换回来后会马上提着执行。
效果如下动图,隐藏后停止运行,切换回来接着运行。
应用场景:常规动画
用一个很简单的示例:用requestAnimationFrame
使一张图片动态也丝滑旋转,直接看示例代码和效果。
思路:首先在页面初始化时执行window.requestAnimationFrame(animate)
使动画动起来,实现动画一直丝滑转运。在关闭页面时用window.cancelAnimationFrame(rafId)
去终止执行。
<template>
<div class="container">
<div :style="imgStyle" class="earth"></div>
</div>
</template>
<script setup>
import { ref, onMounted, reactive, onUnmounted } from 'vue';
const imgStyle = reactive({
transform: 'rotate(0deg)',
});
let rafId = null;
// 请求动画帧方法
function animate(time) {
const angle = (time % 10000) / 5; // 控制转的速度
imgStyle.transform = `rotate(${angle}deg)`;
rafId = window.requestAnimationFrame(animate);
}
// 开始动画
onMounted(() => {
rafId = window.requestAnimationFrame(animate);
});
// 卸载时生命周末停止动画
onUnmounted(() => {
if (rafId) {
window.cancelAnimationFrame(rafId);
}
});
</script>
<style scoped>
body {
box-sizing: border-box;
background-color: #ccc;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.container {
position: relative;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.earth {
height: 100px;
width: 100px;
background-size: cover;
border-radius: 50%;
background-image: url('@/assets/images/about_advantage_3.png'); /* 替换为实际的路径 */
}
</style>
看看动图效果
应用场景:滚动加载
在滚动事件中用requestAnimationFrame
去加载渲染数据使混动效果更加丝滑。主要好久有几个
- 提高性能: 添加
requestAnimationFrame
之后会在下一帧渲染之前执行,而不是每次在滚动事件触发的时候就立即执行。这可以减少大量不必要的计算,提高性能。 - 用户体验更好:确保在绘制下一帧时再执行,使帧率与显示屏相同,视觉上会更丝滑。
代码示例和效果如下。
<template>
<div class="container" ref="scrollRef">
<div v-for="(item, index) in items" :key="index" class="item">
{{ item }}
</div>
<div v-if="loading" class="loading">数据加载中...</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
const loading = ref(false);
let rafId: number | null = null;
// 数据列表
const items = ref<string[]>(Array.from({ length: 50 }, (_, i) => `Test ${i + 1}`));
// 滚动容器
const scrollRef = ref<HTMLElement | null>(null);
// 模拟一个异步加载数据效果
const moreData = () => {
return new Promise<void>((resolve) => {
setTimeout(() => {
const newItems = Array.from({ length: 50 }, (_, i) => `Test ${items.value.length + i + 1}`);
items.value.push(...newItems);
resolve();
}, 1000);
});
};
// 检查是否需要加载更多数据
const checkScrollPosition = () => {
if (loading.value) return;
const container = scrollRef.value;
if (!container) return;
const scrollTop = container.scrollTop;
const clientHeight = container.clientHeight;
const scrollHeight = container.scrollHeight;
if (scrollHeight - scrollTop - clientHeight <= 100) {
startLoading();
}
};
// 加载数据
const startLoading = async () => {
loading.value = true;
await moreData();
loading.value = false;
};
// 监听滚动事件
const handleScroll = () => {
console.log('滚动事件触发啦');
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
rafId = window.requestAnimationFrame(checkScrollPosition);
};
// 添加滚动事件监听器
onMounted(() => {
if (scrollRef.value) {
scrollRef.value.addEventListener('scroll', handleScroll);
}
});
// 移除相关事件
onUnmounted(() => {
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
if (scrollRef.value) {
scrollRef.value.removeEventListener('scroll', handleScroll);
}
});
</script>
<style scoped>
.container {
padding: 20px;
max-width: 800px;
overflow-y: auto;
margin: 0 auto;
height: 600px;
}
.item {
border-bottom: 1px solid #ccc;
padding: 10px;
}
.loading {
padding: 10px;
color: #999;
text-align: center;
}
</style>
看看下面动图效果
小结
通过代码示例配合动图讲解后,再通过两个简单的事例可能大家会发现,只要在页面需要运动的地方其实都可以用到 requestAnimationFrame
使效果变的更加丝滑。
除了上面两个小示例其它非常多地方都可以用到requestAnimationFrame
去优化性能,比较常见的例如游戏开发、各种动画效果和动态变化的布局等等。
文章就写到这啦,如果文章写的哪里不对或者有什么建议欢迎指出。
来源:juejin.cn/post/7431004279819288613
前端:为什么 try catch 能捕捉 await 后 Promise 的错误?
一次代码CR引发的困惑
“你这块的代码,没有做异常捕获呀,要是抛出了异常,可能会影响后续的代码流程”。这是一段出自组内代码CR群的聊天记录。代码类似如下:
const asyncErrorThrow = () => {
return new Promise((resolve, reject) => {
// 业务代码...
// 假设这里抛出了错误
throw new Error('抛出错误');
// 业务代码...
})
}
const testFun = async () => {
await asyncErrorThrow();
console.log("async 函数中的后续流程"); // 不会执行
}
testFun();
在 testFun
函数中,抛出错误后,await
函数中后续流程不会执行。
仔细回想一下,在我的前端日常开发中,对于错误捕获,还基本停留在使用 Promise
时用 catch
捕获一下 Promise
中抛出的错误或者 reject
,或者最基本的,在使用 JSON.parse
、JSON.stringfy
等容易出错的方法中,使用 try..catch...
方法捕获一下可能出现的错误。
后来,这个同学将代码改成了:
const asyncErrorThrow = () => {
return new Promise((resolve, reject) => {
// 业务代码...
throw new Error('抛出错误');
// 业务代码...
})
}
const testFun = async () => {
try {
await asyncErrorThrow();
console.log("async 函数中的后续流程"); // 不会执行
} catch (error) {
console.log("若错误发生 async 函数中的后续流程"); // 会执行
}
}
testFun();
而这次不同的是,这段修改后的代码中使用了 try...catch...
来捕获 async...await...
函数中的错误,这着实让我有些困惑,让我来写的话,我可能会在 await 函数的后面增加一个 catch:await asyncErrorThrow().catch(error => {})
。因为我之前已经对 try..catch
只能捕获发生在当前执行上下文的错误(或者简单理解成同步代码的错误)有了一定的认知,但是 async...await...
其实还是异步的代码,只不过用的是同步的写法,为啥用在这里就可以捕获到错误了呢?在查阅了相当多的资料之后,才清楚了其中的一些原理。
Promise 中的错误
我们都知道,一个 Promise 必然处于以下几种状态之一:
- 待定(pending):初始状态,既没有被兑现,也没有被拒绝。
- 已兑现(fulfilled):意味着操作成功完成。
- 已拒绝(rejected):意味着操作失败。
当一个 Promise 被 reject 时,该 Promise 会变为 rejected 状态,控制权将移交至最近的 rejection 处理程序。最常见的 rejection 处理程序就是 catch handler
或者 then
函数的第二个回调函数。而如果在 Promise 中抛出了一个错误。这个 Promise 会直接变成 rejected 状态,控制权移交至最近的 error 处理程序。
const function myExecutorFunc = () => {
// 同步代码
throw new Error();
};
new Promise(myExecutorFunc);
Promise 的构造函数需要传入的 Executor 函数参数,实际上是一段同步代码。在我们 new 一个新的 Promise 时,这个 Executor 就会立即被塞入到当前的执行上下文栈中进行执行。但是,在 Executor 中 throw 出的错误,并不会被外层的 try...catch 捕获到。
const myExecutorFunc = () => {
// 同步代码
throw new Error();
};
try {
new Promise(myExecutorFunc);
} catch (error) {
console.log('不会执行: ', error);
}
console.log('会执行的'); // 打印
其原因是因为,在 Executor 函数执行的过程中,实际上有一个隐藏的机制,当同步抛出错误时,相当于执行了 reject 回调,让该 Promise 进入 rejected 状态。而错误不会影响到外层的代码执行。
const myExecutorFunc = () => {
throw new Error();
// 等同于
reject(new Error());
};
new Promise(myExecutorFunc);
console.log('会执行的'); // 打印
同理 then 回调函数也是这样的,抛出的错误同样会变成 reject。
在一个普通脚本执行中,我们知道抛出一个错误,如果没有被捕获掉,会影响到后续代码的执行,而在 Promise 中,这个错误不会影响到外部代码的执行。对于 Promise 没有被捕获的错误,我们可以通过特定的事件处理函数来观察到。
new Promise(function() {
throw new Error("");
}); // 没有用来处理 error 的 catch
// Web 标准实现
window.addEventListener('unhandledrejection', function(event) {
console.log(event);
// 可以在这里采取其他措施,如日志记录或应用程序关闭
});
// Node 下的实现
process.on('unhandledRejection', (event) => {
console.log(event);
// 可以在这里采取其他措施,如日志记录或应用程序关闭
});
Promise 是这样实现的,我们可以想一想为什么要这样实现。我看到一个比较好的回答是这个:
传送门。我也比较赞成他的说法,我觉得,Promise 的诞生是为了解决异步函数过多而形成的回调地狱,使用了微任务的底层机制来实现异步链式调用。理论上是可以将同步的错误向上冒泡抛出然后用 try...catch... 接住的,异步的一些错误用 catch handler 统一处理,但是这样做的话会使得 Promise 的错误捕获使用起来不够直观,如果同步的错误也进行 reject 的话,实际上我们处理错误的方式就可以统一成 Promise catch handler 了,这样其实更直观也更容易让开发者理解和编写代码。
async await 的问题
那么回到我们最开始的问题,在这个里面,为什么 try catch 能够捕获到错误?
const asyncErrorThrow = () => {
return new Promise((resolve, reject) => {
// 业务代码...
throw new Error('抛出错误');
// 业务代码...
})
}
const testFun = async () => {
try {
await asyncErrorThrow();
console.log("async 函数中的后续流程"); // 不会执行
} catch (error) {
console.log("若错误发生 async 函数中的后续流程"); // 会执行
}
}
testFun();
我思考了很久,最后还是从黄玄大佬的知乎回答中窥见的一部分原理。
这...难道就是浏览器底层帮我们处理的事儿吗,不然也没法解释了。唯一能够解释的事就是,async await 原本就是为了让开发者使用同步的写法编写异步代码,目的是消除过多的 Promise 调用链,我们在使用 async await 时,最好就是不使用 .catch 来捕获错误了,而直接能使用同步的 try...catch... 语法来捕获错误。即使 .catch 也能做同样的事情。只是说,代码编写风格统一性的问题让我们原本能之间用同步语法捕获的错误,就不需要使用 .catch 链式调用了,否则代码风格看起来会有点“异类”。
这就是为什么 async MDN 中会有这样一句解释:
参考文档:
《使用Promise进行错误治理》- zh.javascript.info/promise-err…
《为什么try catch能捕捉 await 后 promise 错误? 和执行栈有关系吗?》http://www.zhihu.com/question/52…
来源:juejin.cn/post/7436370478521991183
Compose Desktop 写一个 Android 提效工具
前言
在日常的工作中,很多工作和操作其实都是重复的,这个时候,就会想,能不能通过工具进行一键操作。
由于本人是Android开发,寻找解决方案时发现了compose-multiplatform,于是就写个工具玩一玩。
软件介绍
AdbDevTools 是支持windows和mac的,并且支持浅色模式和暗黑模式,下面的截图都是在暗黑模式下。
- 目的:都是为了减少重复性工作,节省开发者时间。
- 简化Hprof文件管理:轻松一键导出、管理和分析Hprof文件,全面支持LeakCanary数据处理。
- 内存泄漏分析:对 Hprof 文件进行内存泄漏分析,快速定位问题根源。
- 位图资源管理:提供位图预览、分析和导出功能。
- Deep Link快速调用:管理和测试Deep Link,提高开发和调试速度。
- 开发者选项快捷操作:包含多项开发者选项的快捷操作。
功能介绍
内存快照文件管理和分析
常规操作:
- 打开AS Memory Profiler,dump 出内存快照文件,等待内存快照文件生成,查看泄露的 Activity 或者 Fragment。
- Android 8以下还可以有个 BitmapPreview 预览 Bitmap,但是每次只能预览一个 Bitmap。
- 如果重新打开 AS,刚刚生成的 hprof 文件在哪里??
- 所以如果想保存刚刚生成的 hprof 文件,就得在生成文件后,手动点击把文件保存一下到电脑上。
- 如果想找到 LeakCanary 生成的文件,得找到对应的文件目录,然后再用 adb pull 一下到电脑上。。
懒人操作:
- 一键 dump 出内存快照,自动化分析,生成一份报告。
- Android 8以下的快照文件,可以一键导出所有 Bitmap 实例,方便预览。
- 通过工具,管理最近打开的 hprof 文件
- 一键导出 LeakCanary 生成的文件,无需手动操作。
开发者选项快捷操作
在日常的开发工作中,可能要经常打开开发者选项页面,打开某一个开关。
常规操作:打开设置页面,找到开发者选项,点击进入开发者页面,上下滑动,找到某一个开关,进行调整。这一系列的操作,有点繁琐。
懒人操作:在PC软件内,一键操作,直接打开开关。一步到位,不需要在手机里找来找去和点点点。
开发
代码架构设计
github.com/theapache64…,基于这个库,可以使用 Android 的开发方式,去开发一个桌面软件。
简单的这样理解。
对于单个桌面应用,其实就是类似 Android 的 Application。
对于应用内的窗口,其实就是类似 Android 的 Activity。
对于窗口内的各种子页面,其实就是类似 Android 的 Fragment,这边当成一个个的 Component 实现。
Application
- 基类 Application。提供一个 startActivity 方法,用于打开某个页面。
- 自定义 MyApplication,继承 Application,在 onCreate 方法里面,执行一些应用初始化操作。
- 比如 onCreate 的时候,启动 MainActivity。
- main() 方法,调用 MyApplication 的 onCreate 方法即可。
open class Application {
protected fun startActivity(intent: Intent) {
val activity = intent.to.java.newInstance()
activity.intent = intent
activity.onCreate()
}
open fun onCreate() {
}
}
class MyApplication(args: AppArgs) : Application() {
override fun onCreate() {
super.onCreate()
Arbor.d("onCreate")
val splashIntent = MainActivity.getStartIntent()
startActivity(splashIntent)
}
}
fun main() {
MyApplication(appArgs).onCreate()
}
Activity
- 自定义 MainActivity,在 onCreate 方法里面,创建和展示 Window 。
class MainActivity : Activity() {
companion object {
fun getStartIntent(): Intent {
return Intent(MainActivity::class).apply {
// putExtra
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
override fun onCreate() {
super.onCreate()
val lifecycle = LifecycleRegistry()
val root = NavHostComponent(DefaultComponentContext(lifecycle))
application {
val intUiThemes by mainActivityViewModel.intUiThemes.collectAsState()
val themeDefinition = if (intUiThemes.isDark()) {
JewelTheme.darkThemeDefinition()
} else {
JewelTheme.lightThemeDefinition()
}
IntUiTheme(
themeDefinition,
styling = ComponentStyling.decoratedWindow(
titleBarStyle = when (intUiThemes) {
IntUiThemes.Light -> TitleBarStyle.light()
IntUiThemes.LightWithLightHeader -> TitleBarStyle.lightWithLightHeader()
IntUiThemes.Dark -> TitleBarStyle.dark()
IntUiThemes.System -> if (intUiThemes.isDark()) {
TitleBarStyle.dark()
} else {
TitleBarStyle.light()
}
}
)
) {
DecoratedWindow(visible = mainWindowVisible,
onCloseRequest = {
::exitApplication
mainActivityViewModel.exitMainWindow()
}, state = rememberWindowState(),
title = "${MyApplication.appArgs.appName} (${MyApplication.appArgs.version})",
onPreviewKeyEvent = {
if (
it.key == Key.Escape &&
it.type == KeyEventType.KeyDown
) {
root.onBackClicked()
true
} else {
false
}
}
) {
TitleBarView(intUiThemes)
root.render()
}
}
}
}
}
Component
Component:组件,可以是一个窗口,也是可以是窗口中的某一个页面,都可以当成组件处理。
对应单个组件,每个组件封装对应的业务逻辑处理,驱动相应的UI进行显示。
对于业务逻辑的处理,可以采用 Store+Reducer 这种偏前端思想的方式,也可以采用 Android 现在比较流行的 MVI 进行处理。
状态管理容器,只需要提供一些可观察对象就行了,驱动View层进行重组,刷新UI。
组件树:应用中的多个窗口,窗口中的多个页面,可以分别拆分成多个组件,每个组件封装处理各自的逻辑,最后构成一棵组件树的结构。
比如这个应用,被我拆成若干个Componet,分别处理相应的业务逻辑。
@Singleton
@Component(
modules = [
PreferenceModule::class
]
)
interface AppComponent {
fun inject(splashScreenComponent: SplashScreenComponent)
fun inject(mainScreenComponent: MainScreenComponent)
fun inject(adbScreenComponent: AdbScreenComponent)
fun inject(analyzeScreenCompoment: AnalyzeScreenCompoment)
fun inject(updateScreenComponent: UpdateScreenComponent)
fun inject(importLeakCanaryComponent: ImportLeakCanaryComponent)
}
ViewModel
- ViewModel 这个比较简单,只是一个普通的类,用于处理业务逻辑,并维护UI层所需的状态数据。
- ViewModel 的创建和销毁,这个会利用到 DisposableEffect 这个东西。DisposableEffect 的主要作用是在组合函数的启动和销毁时执行一些清理工作,以确保资源正确释放。
- 在组合函数启动的时候,创建 ViewModel,并进行初始化。
- 在组合函数销毁的时候,销毁 ViewModel,释放 ViewModel 的资源,类似 Android 中 ViewModel 的 clear 方法。
class AnalyzeViewModel @Inject constructor(
val hprofRepo: HprofRepo
) {
private lateinit var viewModelScope: CoroutineScope
fun init(scope: CoroutineScope) {
this.viewModelScope = scope
}
fun analyze(
heapDumpFile: File, proguardMappingFile: File?
) {
viewModelScope.launch(Dispatchers.IO) {
//耗时方法,分析文件
}
}
fun dispose() {
viewModelScope.cancel()
}
}
/**
* 分析内存数据
*/
class AnalyzeScreenCompoment(
appComponent: AppComponent,
private val componentContext: ComponentContext,
private val hprofFile: String,
private val onBackClicked: () -> Unit,
) : Component, ComponentContext by componentContext {
init {
appComponent.inject(this)
}
@Inject
lateinit var analyzeViewModel: AnalyzeViewModel
@Composable
override fun render() {
val scope = rememberCoroutineScope()
DisposableEffect(analyzeViewModel) {
//初始化ViewModel
analyzeViewModel.init(scope)
//调用ViewModel里面的方法
analyzeViewModel.analyze(heapDumpFile = File(hprofFile), proguardMappingFile = null)
onDispose {
//销毁ViewModel
analyzeViewModel.dispose()
}
}
//观察ViewModel,实现UI逻辑
analazeScreen(analyzeViewModel)
}
}
adb 功能开发
比如 dump 内存快照,安装adb,一部分开发者选项控制,本质上都是可以通过 adb 命令进行设置的。
- Adb第三方库:malinskiy.github.io/adam/,这个库是 Kotlin 编写的。
- 库代码主要是协程、Flow、Channel,使用起来挺方便的。
- 一条 adb 命令就是一个 Request,内置了挺多现成的 Request 可以使用,也可以自定义 Request 编写一些复杂的命令。
- 比如使用adb devices,列出当前的设备列表,只需要一行代码即可。
val devices: List<Device> = adb.execute(request = ListDevicesRequest())
- 如果需要监听设备的连接状态变化,可以通过执行 AsyncDeviceMonitorRequest 即可,返回值是一个 Channel 。
val deviceEventsChannel: ReceiveChannel<List<Device>> = adb.execute(
request = AsyncDeviceMonitorRequest(),
scope = GlobalScope
)
for (currentDeviceList in deviceEventsChannel) {
//...
}
- 安装 apk,执行 StreamingPackageInstallRequest,传入相应的参数即可。
suspend fun installApk(file: String, serial: String): Boolean {
Arbor.d("installApk file:$file,serial:$serial")
try {
val result = adb.execute(
request = StreamingPackageInstallRequest(
pkg = File(file),
supportedFeatures = listOf(Feature.CMD),
reinstall = true,
extraArgs = emptyList()
),
serial = serial
)
Arbor.d("installApk:$result")
return result
} catch (e: Exception) {
e.printStackTrace()
return false
}
}
开发者选项控制
打开过度绘制、布局边界
- 开发者选项里面的很多配置,都是系统属性。关于系统属性的部分原理,可以在这里了解一下。
- 一部分系统属性,是可以支持 adb 修改,并且可以立马生效的。
- 比如布局边界的属性是 debug.layout,设置为 true 即可打开开关。
- 比如过度绘制对应的属性是 debug.hwui.overdraw,设置为 show 即可打开开关。
- 通过下面几个 adb 命令,转化成相应的代码实现即可。
//读取所有的prop,会输出所有系统属性的key和value
adb shell getprop
//读取key为propName的系统属性
adb shell getprop ${propName}
//修改key为propName的系统属性,新值为propValue
adb shell setprop ${propName} ${propValue}
- adb shell service call activity 1599295570,这个命令,主要是为了修改 prop 之后能够立马生效。
/**
* 修改 prop 手机配置
*/
suspend fun changeProp(propName: String, propValue: String, serial: String) {
adb.execute(request = ShellCommandRequest("setprop $propName $propValue"), serial = serial)
adb.execute(request = ShellCommandRequest("service call activity 1599295570"), serial = serial)
}
跳转到开发者选项页面
有些开关还是得手动去设置的,所以提供了这样的一个按钮,点击直接跳转到开发者选项页面。
如果使用命令是这样的。
adb shell am start -a com.android.settings.APPLICATION_DEVELOPMENT_SETTINGS
转化成对应的代码实现。
suspend fun startDevelopActivity(serial: String){
adb.execute(
request = ShellCommandRequest("am start -a com.android.settings.APPLICATION_DEVELOPMENT_SETTINGS"),
serial = serial
)
}
内存分析
- 这里就不细讲了,主要是使用 shark 库进行解析 Hprof 文件,然后分析内存泄露问题。
- 使用shark库解析Hprof文件:juejin.cn/post/704375…。
- 过程挺简单的,就是通过 adb dump 出内存快照文件,然后 pull 到电脑上,并删掉原文件。
1、识别本地所有应用的 packageName
2、adb shell ps | grep packageName 查看应用 pid
3、adb shell am dumpheap <PID> <HEAP-DUMP-FILE-PATH> 开始 dump pid 进程的 hprof 文件到 path
4、adb pull 命令
- 另一种情况,如果你有使用 LeakCanary,但是 LeakCanary App是运行在手机上的,在手机上查看泄露引用链,其实不是那么方便。
- 后面分析了一下,LeakCanary 生成的文件,都放在了 /storage/emulated/0/Download 的目录下,所以搞个命令一键拉取到电脑上,在软件里面进行分析即可。
Html 文件生成
根据内存分析结果,生成一份 html 格式的文件报告,方便在浏览器中进行预览。
- 尴尬的是,自己不太会写 html,另一个是,这个软件是纯 Kotlin 开发,要引入 js 貌似也不太方便。
- github.com/Kotlin/kotl…
- 刚好官方有个 kotlinx-html 库,可以使用 Kotlin 来开发 HTML 页面。
- 引入相关依赖
implementation("org.jetbrains.kotlinx:kotlinx-html-jvm:0.9.1")
implementation("org.jetbrains.kotlinx:kotlinx-html:0.9.1")
- 按照官方文档进行使用,还是挺简单的。
val html = createHTML().html {
head {
title { +"My HTML File" }
}
body {
h1 { +"Memory Analysis Report" }
h2 { +"Basic Info" }
p { +"Heap dump file path: ${hprofFile}" }
p { +"Build.VERSION.SDK_INT: ${androidMetadataMap?.get("Build.VERSION.SDK_INT")}" }
p { +"Build.MANUFACTURER: ${androidMetadataMap?.get("Build.MANUFACTURER")}" }
p { +"App process name: ${androidMetadataMap?.get("App process name")}" }
h2 { +"Memory leaks" }
}
}
下载地址
现在只有 mac 版本,没有 windows 版本。
http://www.github.com/LXD31256949…
填写License key可以激活:9916E3FF-2189-4A8E-B721-94442CDAA215
总结
- 这篇文章,算是对这个软件的一个阶段性总结吧。
- 一个是学习 Compose 相关的知识,以及了解 compose-desktop 相关的桌面组件,并进行开发桌面应用。
- 另一个方面是 Android 这方面的知识学习。
来源:juejin.cn/post/7369838480983490610
告别 "if-else",改用 "return"!
大家好,我是CodeQi! 一位热衷于技术分享的码仔。
在日常的开发中,很多人习惯于使用 if-else
语句来处理各种条件。但你有没有想过,层层嵌套的条件判断,可能会让代码变得难以维护且难以阅读?今天,我想分享一个让代码更清晰易读的技巧,那就是——return。✨
if-else 真的有必要吗?
初学编程时,我们都习惯通过 if-else
语句来处理分支逻辑。比如判断一个用户是否活跃,是否有折扣,代码通常会写成这样:
function getDiscountMessage(user) {
if (user.isActive) {
if (user.hasDiscount) {
return `折扣已应用于 ${user.name}!`;
} else {
return `${user.name} 不符合折扣条件。`;
}
} else {
return `用户 ${user.name} 已被停用。`;
}
}
你看,这段代码嵌套了多个 if-else
语句。如果我们继续在这种风格的代码上添加更多条件判断,会变得更加难以阅读和维护。过多的嵌套让人一眼难以理清逻辑。更严重的是,随着代码量增多,容易导致出错。
return:清晰与高效的代码编写方式
所谓的提前return,就是在遇到异常情况或不符合条件时,立即返回并结束函数。通过提前处理错误情况或边界情况,我们可以把代码的“理想情况”留到最后处理。这种写法可以让代码更清晰,逻辑更加直接。🎯
示例:用return优化代码
来看一看如何用return来重写上面的代码:
function getDiscountMessage(user) {
if (!user.isActive) {
return `用户 ${user.name} 已被停用。`;
}
if (!user.hasDiscount) {
return `${user.name} 不符合折扣条件。`;
}
// 理想情况:用户活跃且符合折扣条件
return `折扣已应用于 ${user.name}!`;
}
🌟 优势
- 每个条件只处理一次:每个
if
语句都提前处理好错误情况,让后面的代码不必考虑这些条件。 - 代码结构更扁平:没有嵌套的
if-else
块,更加一目了然。 - 更易维护:当我们想增加或修改判断逻辑时,只需在前面添加或修改条件判断,不会影响到“理想情况”的代码部分。
return vs if-else:一个真实场景
假设我们有一个需要检查多个条件的函数,validateOrder
,要确保订单状态有效、用户有权限、库存足够等情况:
function validateOrder(order) {
if (!order.isValid) {
return `订单无效。`;
}
if (!order.userHasPermission) {
return `用户无权限。`;
}
if (!order.hasStock) {
return `库存不足。`;
}
// 理想情况:订单有效,用户有权限,库存足够
return `订单已成功验证!`;
}
通过这种方式,我们将所有不符合条件的情况都提前处理掉,将主逻辑留到最后一行。这不仅让代码更易读,而且可以提高代码的运行效率,因为无须进入嵌套的条件分支。🎉
何时使用return
虽然提前return是优化代码的好方式,但并不是所有情况下都适用。以下是一些适用场景:
- 多条件判断:需要检查多个条件时,尤其是多个边界条件。
- 简单条件过滤:对于不符合条件的情况可以快速返回,避免执行复杂逻辑。
- 确保主要逻辑代码始终位于底部:这样可以减少逻辑处理的复杂性。
结语
当我们写代码时,保持代码简洁明了是一项重要的原则。通过采用提前return,我们可以减少嵌套层次,避免过度依赖 if-else
,让代码更直观、易维护。如果你还没有使用return,不妨从现在开始尝试一下!😎
下次写代码时,记得问自己一句:“这个 if-else
可以用return替换吗? ”
让我们一起追求清晰、优雅的代码!Happy Coding! 💻
来源:juejin.cn/post/7431120645981831194
我跑通了全球收付款的流程
前言
上周去韩国旅游,在首尔吃美食,坐在咖啡店写代码,Coding Anywhere,感觉很棒。
期间消费,用的是一张 ZA Bank 的 VISA 卡,几乎没怎么用现金。
这张卡里的钱,是我做出海 SaaS 产品一年以来的收入。
以前听别人做跨境电商,做出海 SaaS,可以面向全球用户收款,当时觉得很羡慕。
当自己终于跑通了全球收付款的流程之后,逐渐体会到了这里面的快乐。
在裸辞成为自由职业者之前,我副业做过一段时间的小程序业务,对接的微信支付,面向国内用户收款。
做过国内业务的朋友应该清楚,要对互联网产品实现商业化,一般需要注册一个公司主体,然后开通对公银行账户,再开通微信支付 / 支付宝之类的第三方收款渠道。
给自己的业务产品对接第三方支付平台,用户在线支付的钱,进入到微信支付 / 支付宝的商业账户,再提现到公司的对公账户。此为完整的收款流程。
从公司对公账户,以发工资或者借贷的形式,把钱发到员工银彳亍卡,或者转账到法人账户。可以理解为个人对收款资金的消费途径。
随着 AI 的爆发,国内备案政策收紧,国内业务越来越难做,很多人选择出海。
出海的第一步,要搞定全球收款的问题,需要有一个账户,接受全球用户的付款。
如果做的是出海 SaaS 产品,收款消费流程跟国内业务的流程基本一致,只是企业主体 / 对公账户 / 第三方支付平台 / 提现转账的对象有所不同。
用一张图来表示出海 SaaS 业务的收款 / 消费流程如下:
拆解成两个核心链路:
- 如何全球收款
- 如何消费收款账户内的资金
来详细讲解我是如何跑通全球收付款流程的。
如何全球收款
1. 注册一个境外公司
开通全球收款渠道之前,需要有一个境外主体。开通境外主体的方式很多,如果人在境外,操作起来会比较方便。如果人在境内,可以选择代理网站注册,或者淘宝代注册的方案。
境外主体注册地,可以根据实际需求选择,如果没有特别的要求,可以选择注册英国或美国公司。
- 淘宝找代理,代注册美国公司
这种方案我没有实践过,身边有些朋友走的是此方案,淘宝找个代理,交 2000 多元人民币,2 个礼拜左右,可以注册下来一个美国公司。
- 使用代理网站,自助注册英国公司
我选择的是此方案,自助注册英国公司,相对来说较为简单。
使用以下代理网站,创建账户,扫描护-照,全流程在网站上完成,会收到邮件验证资料。注册费用在几十刀,顺利的话,一个多礼拜可以注册下来。
http://www.1stformations.co.uk/
可以根据网站上的指引进行注册,或者搜一下注册教程。
2. 申请一张境外手机卡
很多地方需要验证境外手机号,所以需要申请一张境外手机卡,在境内可以正常接收短信验证的就行。
在淘宝搜索“giffgaff”,购买一张英国手机卡,邮寄到家后,进入官网激活。
在官网买最低的套餐即可,大概在 6 英镑每月。
激活之后,就可以在境内接收全球各类产品的短信验证了。
3. 开通境外银行账户
- 使用 Wise 开通对公账户
在第 1 步注册成功英国公司后,邮件会收到英国公司的注册资料,包括主体名 / 主体营业号 / 主体地址等信息。
拿这些信息,在 Wise 申请开通英国银行账户。
从 2023 年底开始,Wise 政策收紧,英国公司开通对公账户需要等待,我差不多花了 1 个月,才被允许开通对公账户,现在可能会更难开,需要多试。
- 使用万里汇开通对公账户
如果 Wise 限制英国公司开户,可以把万里汇作为备选方案。万里汇是阿里巴巴旗下的产品,开户相对会容易一些。
无论 Wise 还是万里汇,开通成功后,你的英国公司就拥有了一个企业对公账户。可以选择不同的币种开通不同的账户。
比如你可以选择开通一个美元账户,一个英镑账户,一个欧元账户,用于外币收款。
4. 开通第三方支付平台收款商户
在开通企业对公账户成功之后,你就可以去申请第三方支付平台商户了。首选 Stripe。
在 Stripe 新建商户,需要填写公司基本信息,法人姓名 / 地址,公司开展的业务说明等信息。
一个 Stripe 账户,可以开通多个收款商户,可以复用同一个主体信息。如果你的业务产品线比较多,可以创建多个 Stripe 商户,为每个产品对接一个 Stripe 商户,减少鸡蛋放在同一个篮子的风险。
有公司主体和对公账户的前提下,创建 Stripe 商户一般很快就能审核通过。有可能会要求你上传地址证明文件,直接用公司的注册地址就行。
除了 Stripe,也有其他一些第三方支付平台可以作为备选,比如:
选择适合你的支付平台,按照官网指引操作,可以多开几个放着,以备不时之需。
5. 通过 SaaS 产品收款
通过前面四个步骤,你已经开通了境外主体和境外银行账户,并且有了第三方支付平台收款商户。
接下来,就可以在你的 SaaS 产品中,对接第三方支付平台的收款 API,接收全球用户的付款了。
一个 Web 类型的 SaaS 产品,要实现对用户收款,一般会创建一个 Pricing 页面,列出几个付费套餐和对应的权益,如果用户认可你的服务,就会选择付费购买。
Stripe 支持订阅支付模式,用户输入自己的信用卡卡号订阅服务,每个月扣款日自动扣款,就相当于国内的小米电视会员按月订阅,免密代扣模式。
但是微信支付开通免密代扣的门槛非常高,Stripe 则简单多了。
在你的 SaaS 产品中,优先选择按月 / 按年的订阅支付方案,可以有效增加收入(相比于一次性付费)。
下载 Stripe 手机 App,开启接收通知,每当有用户在你的 SaaS 产品付费,你就会收到通知。
6. 资金提现
当你使用第三方支付平台收款一段时间后,你的第三方支付商户账户中,就会有一定的资金积累。
你可以选择将资金提现到你的境外银行账户中。一般需要选择提现的币种,设置成按时 / 按金额自动提现,或者手动提现。
第三方平台会在扣除掉一定的手续费之后,把资金转账到你公司的对公银行账户。这个过程涉及到跨境转账,换汇 / 清算等流程,耗时稍微有点久,一般在 3-5 个工作日,提现的资金才会到账。可以在 Wise 网页版,查看账户余额和提现记录。
至此,全球收款的流程就走完了。你可以开展你的 SaaS 业务,面向全球市场,找到目标用户,让用户喜欢你的产品,并为之付费。
所有的收入,都会进入到你在境外的对公银行账户中。
如何消费收款账户内的资金
前面几个步骤,讲完了全球收款的完整流程。如果业务开展顺利的话,你的境外对公银行账户会有一定的资金积累。
接下来,就可以考虑如何消费这些资金了。
消费的方式有很多种,最常见的几种消费方案:
- 使用境外对公银行账户的钱,购买商品 / 服务
- 境外对公账户的钱,转到境外个人账户,在境外消费
- 境外对公账户的钱,转到境外个人账户,绑定微信支付,在境内消费
- 境外对公账户的钱,转到境外个人账户,再转到境内个人账户,在境内消费
1. 使用境外对公银行账户的钱,购买商品 / 服务
Wise 除了可以开通企业对公账户之外,也支持开通个人账户。
企业 Wise 账户,可以为法人或者团队,开通多张物理卡或数字卡。
数字卡可以直接绑定到 Apple 钱包进行消费,或者在线支付 SaaS 产品,比如 ChatGPT / Claude 的会员订阅服务等。
Wise 的物理卡我申请了邮寄,一直没收到。但是 Wise 的数字卡,我已经用来支付常用的境外服务,比如 OpenRouter / Serper 这些。
对于 SaaS 业务的一些开销,通过数字卡支付,直接从 Wise 对公账户扣除了,省去了转入转出的麻烦。
2. 境外对公账户对钱,转到境外个人账户,在境外消费
对于出国旅游这类场景,我们可能希望把境外对公账户的钱,转账到个人银彳亍卡,直接在境外线下消费。
首先,需要申请境外的个人银彳亍卡。这里推荐三个方案:
- 新加坡华侨银行(OCBC)
人在境内,下载 OCBC 银行 App,在线申请,一周内可以开通个人银行账户。可以选择邮寄实体卡,但是境内收件走的是平邮,可能会收不到。
我至今还未收到 OCBC 实体卡,但不影响在 OCBC App 内在线消费。
- 中国银行香港(BOCHK)
需要去香港办理,如果去银行柜台办理,需要准备各种资料,比如富途的投资记录,银行流水等,有可能会被拒绝开户。(我 9 月份去香港中国银行柜台办理被拒了)
另一种稳妥的方式是,人在香港,连上香港 Wifi,下载 BOCHK App,在线申请,快速开通个人银行账户。
然后再去柜台补个签名,就可以正常使用了。
在柜台请他们修改邮寄实体卡的方式,寄挂号信。(即使改了邮寄方式,在境内也有可能收不到实体卡。我至今未收到T_T,但不影响银行账户的使用)
- 众安银行(ZA Bank)
申请比较简单,人在香港,连上香港 Wifi,下载 ZA Bank App,在线申请,快速开通个人银行账户。
在 App 申请邮寄实体卡,在境内三天左右就可以收到。
除了以上三种方案,还有朋友会去香港开通汇丰银行账户,我没试过就不做过多阐述。
有条件的情况,建议多开几家银行账户,以备后续使用。
以上任意一家银行账户,都能接收 Wise 对公账户转账,Wise 会自动进行换汇,扣除一定的手续费,资金转账实时到账。
以上开通的境外银行账户,可以在线支付各类 SaaS 产品。也可以选择实体卡消费,既能刷卡支付,也支持 ATM 取现。
如果在境外使用 ZA Bank 实体卡消费,需要在 ZA Bank App 开启海外旅游选项。
3. 境外对公账户的钱,转到境外个人账户,绑定微信支付,在境内消费
境外习惯信用卡支付,而在境内大家更喜欢微信 / 支付宝支付。
如果习惯使用微信支付,可以在微信搜索框输入“香港钱包开通”,进入自助开通页面,选择“绑定香港发行的银行账户”,绑定上面开通的中银香港卡,就可以开通香港钱包。
在微信支付服务页,切换钱包地区,选择“香港钱包”,可以用于常用场景的支付,比如在线购买机票 / 火车票,美团点外卖,瑞幸喝咖啡等。境内消费的大部分场景,香港钱包都支持。
稍微遗憾的是,香港钱包绑定 ZA Bank,需要验证香港身-份-证,如果我们没有香港身-份-证,只能绑定中银香港卡,再把 ZA Bank 的钱转到中银香港卡,通过香港钱包在微信消费。
4. 境外对公账户的钱,转到境外个人账户,再转到境内个人账户,在境内消费
可能有人会有这类需求,需要用境内银彳亍卡扣房贷,想把境外个人账户的钱,转到境内银彳亍卡。
这个方案我没有实践过,但是看中银香港 App,有一个“中银快汇”的功能,可以转账到境内银彳亍卡。另外还有一个“开户易”功能,支持汇款到广东省内中国银行的同名账户。
如果使用 ZA Bank 或者其他香港银行账户,可以通过“熊猫速汇”之类的产品,汇款到境内银彳亍卡。
有需求的朋友,可以自行尝试。
没有境外银行账户,如何给境外产品付款
如果你还没有境外银行账户,但是需要给 ChatGPT / Claude 之类的产品付款,以订阅他们的会员服务。
你可以选择申请虚拟信用卡。
推荐使用 WildCard 这个产品,不仅支持虚拟信用卡,还提供虚拟手机号用于接收验证短信,以及安全的网络环境解决风控问题。
可以使用我这个推荐链接:
注册 WildCard 服务,申请一张虚拟信用卡,使用支付宝充值,然后就可以给各类境外 SaaS 产品付费了。
总结
通过这篇文章,介绍了我这一年来做境外 SaaS 业务,从全球收款,到全球消费的完整流程。踩了不少坑,也总结了很多宝贵的经验,希望对即将开始做出海业务的朋友有所帮助。
秉持 Build in Public 的理念,同步一下我这一年来做 SaaS 业务的收入情况。
一年来的累计收入不到 1 万美元。
MRR(月度经常性收入) 最近突破了 1 千美元。
之前做 ThinkAny 这个项目的时候,有段时间 token 消耗和服务器流量费用特别高,一直在用境内的个人信用卡支付开销。境外收款账户里的钱属于纯收入,没有覆盖支出,算上成本的话,谈不上赚到钱,一年下来整体收支平衡。
虽然跟身边其他做出海业务的朋友相比,我的这点收入微不足道,但是对我个人而言,算是迈出了出海的第一步,收款 / 消费流程闭环,积累了很多认知,也增加了我对出海业务的信心。
接下来,我会继续优化我的 SaaS 产品,寻找目标用户,努力实现收入增长,争取早日达成 MRR 1 万美元的目标。
最后
如果你想开始做出海 SaaS 业务,可以参考本文先搞定全球收款链路。
如果你不知道如何做出一个 SaaS 产品,可以看一下我开源的这几个项目,有完整的 SaaS 产品模版,支持 Stripe 支付,可以一键部署,快速上线跑通收款流程。
如果你不太懂技术,想要学习全栈开发。可以选择加入我的“1024 全栈开发社群”,我在群里分享了几个项目的全栈开发过程,包括如何完成项目的前后端开发,如何实现 UI 组件,如何对接 AI 能力,如何做数据存储,如何支持支付收款等技术细节。也许会对你有帮助。
祝大家早日出海,全球收款,大浪淘金。
来源:juejin.cn/post/7435708914433785893
App侧滑卡死?Flutter表示这锅不能背
前言
由于谷歌 flutter 团队裁员,导致维护更新满足不了需求,传闻 flutter 团队不足50人,很多跨端的支持以及现有的问题都无法解决,flutter 社区foundation不满足于现有的开发进度,fock flutter维护分支Flock, 貌似不少公司组织已经在这样做了,很多问题也不能怪flutter, 比如最近发的这个问题。
问题
flutter freeze卡死的问题相信很多开发者都遇到过, 最近遇到iOS侧滑返回的导致freeze的问题,很早就发现这个问题,当时的解决方案是在首页禁止侧滑,就是导航栈只有一个页面的时禁用手势
if (self.navigationController.viewControllers.count == 1) {
self.navigationController.interactivePopGestureRecognizer.enabled = NO;
}
此方案可以将卡死问题很大概率的降低,但是仍然会出现卡死, 可以规避但没找到根本原因
分析
最开始以为是flutter手势和iOS 系统返回手势冲突导致,app 存在一些侧滑的轮播图,经过对比发现有没有轮播图或者flutter 手势都会出现卡死。
问题可能出现在iOS 原生侧,经过尝试发现问题所在,复现代码如下
首页实现手势比如PanGuesture
UIPanGestureRecognizer *panGesture = [[UIPanGestureRecognizer alloc] initWithTarget:**self** action: **@selector**(handlePan:)];
[redView addGestureRecognizer:panGesture];
push 跳转到二级页面,二级页面对重置了导航栏的返回手势的delegate
self.navigationController.interactivePopGestureRecognizer.delegate = nil;
原因:
重置了导航栏的PopGestureRecognizer的delegate,向当前全局的侧滑返回代理回调失效,导致策划返回出现问题,禁用手势不可以讲导航栏的返回手势的delete 置为nil
Flutter为何侧滑可出现卡死,通过分析flutter的源码就很容易知道原因
结论
flutterViewController也实现了手势操作,所以使用依赖原生的flutter出现了这个问题,跳转到其他页面,将PopGestureRecognizer 置为nil, 就出现这个问题
Flutter:这真不是我的锅
感想
从20年开始接触Flutter 也有三四年时间了,Flutter的应用范围也越来越广,但是大厂对flutter的接受度还是不高,要不要继续坚持搞Flutter,作为开发未来将何去何从
来源:juejin.cn/post/7433827139113746467
一种纯前端的H5灰度方案
什么是灰度发布
在互联网领域,灰度发布是产品质量保障的重要一环,它可以让某次更新的产品,以一种平滑,逐步扩大的方式呈现给用户,在此过程中,产品和技术团队可以对功能进行验证,收集用户反馈,不断优化,从而减少线上问题的影响范围,完善产品功能。
在前端领域,APP和小程序天生就具有灰度的能力,一般基于发布平台来控制。但 H5 却缺少这种天生能力,而且 H5
一旦发布就会影响所有用户,更加需要一套灰度系统,来保证产品的稳定性。
灰度发布的本质
既然要让部分用户先使用新功能,就需要做好两件事情,这也是灰度的本质:
- 版本控制 同一个项目需要在线上同时发布至少两套页面,一套针对全量用户,一套针对灰度用户
- 分流控制 需要有一套规则,把用户按某种特征划分为不同的群体,可以是用户ID,门店、城市,也可以是年龄,亦或是随机。命中的用户访问灰度页面,未命中的访问全量页面。
那么想要实现灰度发布有哪些方案呢?
可选的灰度方案
Nginx+lua+redis
通过使用 Nginx
的反向代理特性,我们可以根据请求的特定属性(例如ip、请求头、cookie)等有选择性的将请求路由到全量或灰度版本。
同时在 Nginx
中嵌入 Lua
脚本,负责根据预定义的灰度发布策略处理请求,Lua
脚本可以从 Redis
中获取灰度配置。从而确定哪些用户可以访问新版本,那些用户应该可以访问旧版本。
Redis
用于存储灰度发布的配置数据。
通过这种方式可以实现基于 Ngnix
的灰度发布,但这种方式并不适合我们,为什么呢?
因为我们的C端H5页面连同HTML文件都是直接投放在 CDN
上,这就意味着我们没有中转服务层,无法使用第一套 Nginx
的方案,而且使用 Nginx
也会响应降低页面加载速度,虽然可能很轻微,但却是对所有用户都会有影响。
采用 Nginx
进行中转:
不采用 Nginx
中转:
如上两张图,可以很明显的看到,如果采用 Nginx
来作为中转并进行分流控制,将导致我们的 CDN
优势失效,所有的流量都可能回到上海的机房,再流转到上海的 CDN,这显然不是我们想看到的。
这也是我们放弃 Nginx+lua+redis
方案的原因。
基于 SSR 做灰度
如果我们的前端页面是通过服务端来进行渲染,可以把灰度控制继承在服务端渲染中,基于不同的用户放回不同的HTML,这样也就可以做到灰度发布。
不过这需要有一套完善的 SSR
系统,对于访问量大的产品,维持系统稳定性的难度远大于实现 SSR
本身的技术难度。由于我们是前后端分离,并且没有基于 Node
高可用的运维团队和经验,所以这个方案也就放弃了。
APP拦截灰度
基于APP的方案,是在用户点击H5资源位,创建webview时,拉取灰度配置,如果当前页面有灰度,则拉取灰度配置,判断是否命中灰度,如果命中,替换H5链接即可。
看过我其他文章的朋友,应该有了解到我们针对H5秒开有一套配置下发到APP,那么灰度配置,也可以集成到原有配置中,一并下发给APP,这套方案相对而言也比较简单,但是却有如下问题。
- 只能支持APP,APP外和小程序内打开的场景无法支持
- 依赖APP,公司其他业务线的APP,如果要使用也需要开发,工作量较大。
所以最后该方案也被排除。
纯前端方案
方案概览
基于如上的一些原因,于是我们采用了一套纯前端的方案,来解决灰度发布问题,虽然这套方案也有一点缺点。前面我们提到灰度发布的本质,其实包含两个方面,一是版本控制,二是分流控制。
版本控制比较好做,我们把全量的HTML代码发布到 index.html
文件,把灰度的HTML代码发布到 gray.html
文件,这样就做到了版本控制。
分流控制,可以被拆分为两部分,一部分只管获取配置、判定是否命中灰度并入在本地,另一部只管读取结果并执行跳转,这样整个系统就解耦了。
方案大体思路是:
- 在用户首次方式时,静默激活灰度计算逻辑,通过接口或其他条件判断用户是否命中灰度,把结果存储在
localStorage
中。 - 有别于全量版本时使用
index.html
,灰度时构建并修改html名称为gray.html
,并发布 - 当要灰度发布时,下载
index.html
,注入灰度判断代码到 head 中,注入GRAY_SWITCH
开关并开启 - 当用户再次访问时,执行灰度判断代码,如果命中,重定向到 gray.html 页面
- 对获取页面点击的地方,进行封装或拦截,确保灰度用户分享出去的链接,是全量链接
流程图:
时序图如下:
灰度版本控制
对于版本控制,我们通过提供了一个 webpack
插件集成到构建流程中,在构建时生成不同文件名的 html 文件。
通过构建命令参数,来区分各种发布情况
npm run build your_project_name -- --gray=open
# --gray 的值
# --gray=close 不打开灰度,默认值
# --gray=open 打开灰度
# --gray=full 灰度全量
# --gray=unpublish 撤销灰度
可以分为如下情况:
正式发布
构建时生成:
- index.html 全量页面
- index_backup.html 全量备份页面(用来做回归)
灰度发布
构建时生成:
- gray.html 灰度页面
- gray_backup.html 灰度备份页(用来在全量后替换 index_backup.html)
同时下载 index.html ,注入灰度重定向控制JS。
重定向控制代码如下:
// 标记是否打开灰度
window.__GRAY_SWITCH__ = 1
let graySwitchName = 'gray_switch_';
// 获取去除html后的pathname
const pathname = window.location.pathname.split('/').slice(0, -1).join('/');
graySwitchName = graySwitchName + pathname
const graySwitch = localStorage.getItem(graySwitchName)
if (graySwitch === '1') {
const grayUrl = window.location.href.replace('index.html', '_gray.html')
if(window.history.replaceState){
// 安卓 app 使用 location.replace 无效
window.history.replaceState(null, document.title, grayUrl);
}else{
window.location.replace(grayUrl);
}
}
修改输出的 HTML
文件名,是通过编写 webpack
的自定义插件来完成。
原理是通过 compiler.hooks.afterEmit.tapAsync
钩子函数,再 “输出” 阶段,对文件名进行修改。
撤销灰度
从云端下载 index_backup.html
重命名为 index.html
放在打包目录,之后再由发布系统上传。
全量发布
从云端下载 gray.html
和 gray_backup.html
,重命名为 index.html
和 index_backup.html
,发布后就会替换原有的全量HTML。
灰度分流控制
分流的重点是如何判断哪些用户能命中灰度。每个项目划分人员的策略都可能不同,比如C端页面更倾向于按useID随机划分。而B端拣货、配送等业务线,更需要按门店来进行划分,这样可以做到同门店员工体验一致,便于管理。所以这块这块必须要足够的灵活性。
我们这里采取了两种方式:
第一种是基于接口来做分流控制:把用户信息传给服务端,接口通过配置的灰度规则,计算是否命中,并返回前端。前端只管把结果存入本地。
第二种是把计算逻辑都放在前端,比较适合C端项目,因为C端项目大部分场景都是随机划分灰度用户。
灰度分流计算的JS代码是在用户每次打开后,静默运行,所以需要引入到业务代码中。
引入的代码如下:
import grayManager from '@cherry/grayManager'
import { getMemberId } from '../utils/index'
// 伪代码,说明GrayOptions 的类型
interface GrayOptions {
// 灰度比例控制 支持固定值和数组阶梯灰度,配置grayScale 后,grayComputeFn无效
grayScale: number | [number]
// 自定义灰度方法,在内可以请求接口等
grayCompute: () => (() => Promise<boolean>) | boolean
// 获取维护标识,比如以 shopId 为灰度标识,该函数就返回当前用户的 shopId
getGaryData: () => ()=> Promise<string>,
// 配置灰度白名单,白名单内的用户都会命中灰度
whiteData: string[]
}
// 初始化灰度计算逻辑
grayManagerInit({
grayScale: 10,
whiteData: ['123', '456']
})
前端计算分流
随机百分比
多数项目,我们一般使用的策略是随机,比如设置10%的用户命中灰度。
我们可以通过生成随机数来判断是否命中灰度,具体步骤如下:
- 在
grayManager.init()
时,随机生成一个uuid
,存在用户本地,不做清除,下次 init 时,先从本地取uuid
,存储 key 命名为__GRAY_UUID__
。 - 当使用预置灰度计算能力时,取
__GRAY_UUID__
每位转化为 asci 码并相加,除以100 求余数 - 用余数+1 和灰度比例(
grayScale
)对比,当余数+1 <= grayScale
时命中灰度
这样可以得到一个近似 10% 比例的灰度用户数。
基于门店和城市分流
如果想基于门店或城市分流,我们只需要配置两个参数, 一是如何获取门店和城市ID
另一个是需要灰度的门店和城市ID
import grayManager from '@cherry/grayManager'
import { getShopId } from '../utils/index'
grayManagerInit({
getGaryData: () => {
return await getCityId()
},
whiteData: ['123', '456']
})
可以通过 grayScale 配置数组来实现,起始时间为打灰度包构建的时间,我们会把构建时间注入到 HTML
中。
其他注意项
开头讲过,这套方案有一点缺点。可能大家也会发现,灰度时用户需要先进入打 HTML
,执行 head
中注入的重定向控制JS,对命中灰度的用户再次跳转到 gray.html
。
这样其实带来了两个问题:一是对灰度用户来说经过了两个HTML,白屏的时间会更长。二是灰度用户访问的URL变化了,如果此时用户把页面分享出去,被分享用户将直接打开灰度页面。
对于第一条,全量用户是不会被影响,只有灰度用户才会白屏更久,我们目前测试白屏的时长还能接受。
对于第二条,我们最初是系统通过 Object.defineProperty
来拦截 对 window.location.pathname
的获取,返回 index.html
。但window.location.pathname
是一个只读属性不可拦截。
最后只能提供统一的方法,来获取 pathname
。
结语
以上就是我们的灰度核心方案,整个方案会比较简单,几乎不依赖外部部门。无论是对于H5还是pcWeb,亦或是不同的容器,都无依赖,各个业务线都可以平滑使用。
来源:juejin.cn/post/7438840414239326227
用Three.js搞个炫酷风场图
风场图,指根据风速风向数据进行渲染,以表征空气流动方向、流动速度的一种动态流场图。接下来让我们学一下怎么实现炫酷的2D和3D风场图吧!
一、 获取风场数据
- 打开NCEP(美国气象环境预报中心)
- 查看Climate Models(气候模型)的部分
- 点击Climate Forecast System 3D Pressure Products(气候预报系统3D大气压产品)的grib fiter选择数据下载
4. 界面会有不同日期的数据提供下载,我们选择默认最新的那个日期就好
- 一堆看不懂的参数,没关系,我们只需要在Levels图层这里勾选max wind这个就好(因为我们要画风场图),不推荐Levels勾选all,数据太大,下载慢,并且看不懂,用不到。
- 点击Start download就可以下载了
二、处理风场数据
grib这个数据格式打不开,看不懂,需要转换成json,有位大牛A写了个java的grib处理工具(grib2json),然而我用maven打包失败了,然后发现有另一位大牛B封装了大牛A的jar包成node脚本,正好给前端开发者使用。
- 安装
@weacast/grib2json
pnpm add -D @weacast/grib2json
- 执行脚本,将grib转换成json
使用说明
Usage: grib2json (or node bin.js) [options]
-V, --version 输出版本号
-d, --data 输出GRIB记录数据
-c, --compact 压缩json
-fc, --filter.category 选择类目值
-fs, --filter.surface 选择表面类型
-fp, --filter.parameter 选择参数值
-fv, --filter.value 选择表面值
-n, --names 打印数字代码的名称
-o, --output 输出文件名
-p, --precision 使用小数点后几位数的精度(默认值:-1)
-v, --verbose 启用stdout日志记录
-bs, --bufferSize stdout或stderr上允许的最大数据量(以字节为单位)
-h, --help 使用帮助
pnpm exec grib2json -c --names --data --fp 2 --fs 103 --fv 10.0 -o output.json D:/code/wind/pgbf2024103000.01.2024103000.grb2
注意:
--fs 103
表面类型103(地面以上指定高度)--fv 10.0
距离GRIB2文件10.0米的表面值--fp 2
将参数2(U-component_of_wind)的记录输出到stdout- 需要转换的grib文件放在最后,文件路径要用完整的路径名称
- 数据格式说明
{
"header":{
//数据更新时间
"refTime":"2024-10-30T00:00:00.000Z",
"parameterCategory":2,//类目号,2表示风力
"parameterCategoryName":"Momentum",
"parameterNumber":2,//2表示u,3表示v
"parameterNumberName":"U-component_of_wind",
"numberPoints":65160,//点数量
"nx":360,//横向栅格数量
"ny":181, //纵向栅格数量
"lo1":0.0,//开始经度
"la1":-90.0,//开始纬度
"lo2":359.0,//结束经度
"la2":90.0,//结束纬度
"dx":1.0,//横向步长
"dy":1.0//纵向补偿
},
"data":[//方向数据,u数据,要搭配另一个v的数据使用
-7.8,
-7.9,
]
}
U表示横向风速,V表示纵向风速,UV的正负值表示风向
- output.json有2.25MB大,数据里面除了uv方向的数据,还包含了其他的数据,我们只需要有用的一个header和uv数据即可,可以用node处理一下,得到一个header信息数据info.json和风向数据wind.json
const fs = require('fs');
const output = require('./output.json');
let uData = [];
let vData = [];
let header = {};
for (let i = 0; i < output.length; i++) {
if (output[i].header.parameterNumber === 2) {//u的数据集
uData = output[i].data;
header = output[i].header;
} else if (output[i].header.parameterNumber === 3) {//v的数据集
vData = output[i].data;
}
}
const len = uData.length;
const list = [];
const info = {
minU: Number.MAX_SAFE_INTEGER,
maxU: Number.MIN_SAFE_INTEGER,
minV: Number.MAX_SAFE_INTEGER,
maxV: Number.MIN_SAFE_INTEGER,
...header
};
for (let i = 0; i < len; i++) {
//uv数据组合
list.push([uData[i], vData[i]]);
//计算最大最小边界值
info.minU = Math.min(uData[i], info.minU);
info.maxU = Math.max(uData[i], info.maxU);
info.minV = Math.min(vData[i], info.minV);
info.maxV = Math.max(vData[i], info.maxV);
}
fs.writeFileSync('./wind.json', JSON.stringify(list));
fs.writeFileSync('./info.json', JSON.stringify(info));
三、绘制2D风场图
重头戏来了!瞪大你的眼睛(0 v 0),看好了!
1. 创建风场网格
nx和ny对应横向纵向网格数量,然后uv数据按照nx行,ny列组装添加到二维数组里面就是网格了。
this.grid = [];
let index = 0;
for (let j = 0; j < header.ny; j++) {
const row = [];
for (let i = 0; i < header.nx; i++) {
const item = this.data[index++];
row.push(item);
}
this.grid.push(row);
}
2. 获取点xy对应的风向uv
根据风场网格获取该xy先在应的风向uv,点xy可能不是整数,那么这时候需要使用双线性插值(根据临近的周围四个点计算出插值)算出对应的风向uv。
- 根据xy获取风向uv
getUV(x, y) {
let x0 = Math.floor(x),
y0 = Math.floor(y);
//正好落在网格里
if (x0 === x && y0 === y) return this.getGrid(x, y);
let x1 = x0 + 1;
let y1 = y0 + 1;
//临近四周的点
let g00 = this.getGrid(x0, y0),
g10 = this.getGrid(x1, y0),
g01 = this.getGrid(x0, y1),
g11 = this.getGrid(x1, y1);
return this.bilinearInterpolation(x - x0, y - y0, g00, g10, g01, g11);
}
- 不落在整数网格里面的采用双线性插值计算出风向uv
/**双线性插值
* g00, g10, g01, g11对应临近可映射的四个点
* x为当前点与最近点x坐标差
* y为当前点与最近点y坐标差
* ***/
bilinearInterpolation(x, y, g00, g10, g01, g11) {
let rx = 1 - x;
let ry = 1 - y;
let a = rx * ry,
b = x * ry,
c = rx * y,
d = x * y;
let u = g00[0] * a + g10[0] * b + g01[0] * c + g11[0] * d;
let v = g00[1] * a + g10[1] * b + g01[1] * c + g11[1] * d;
return [u, v];
}
- 获取网格数值,需规整超出的边界值
getGrid(x, y) {
const h = this.header;
if (x < 0) {
x = 0;
} else if (x > h.nx - 1) {
x = h.nx - 1;
}
if (y < 0) {
y = 0;
} else if (y > h.ny - 1) {
y = h.ny - 1;
}
return this.grid[y][x];
}
3. 创建随机点
createRandParticle() {
//必须在风场网格范围内才能获取到风向uv
const x = Math.random() * this.header.nx;
const y = Math.random() * this.header.ny;
const uv = this.getUV(x, y);
return {
//起点位置
x,
y,
//终点位置=当前位置加上风向偏移
tx: x + this.speed * uv[0],
ty: y + this.speed * uv[1],
//生命周期,将生命周期归零的时候重新设置起点坐标
age: Math.floor(Math.random() * this.maxAge)
};
}
//重新设置随机点
setParticleRand(p) {
const newp = this.createRandParticle();
for (let k in p) {
p[k] = newp[k];
}
}
- 生成随机点
this.particles = [];
for (let i = 0; i < this.particlesCount; i++) {
this.particles.push(this.createRandParticle());
}
4. 绘制风场图
canvas绘制风场即用线段的起点和终点跟随着风向不断运动形成风场图。
- 设置canvas
//缓存canvas context之前的合成操作类型
const pre = ctx.globalCompositeOperation;
//'destination-in'仅保留现有画布内容和新形状重叠的部分。其他的都是透明的。
ctx.globalCompositeOperation = 'destination-in';
//之前绘制的保留重叠部分
ctx.globalAlpha = 0.5;
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
//还原合成操作类型
ctx.globalCompositeOperation = pre;
//设置线的全局透明度
ctx.globalAlpha = 0.8;
注意:cxt.fillRect
本来清空之前的画布内容,但采用了globalCompositeOperation='destination-in'
和globalAlpha=0.5
的透明度作为重叠标准,重叠部分以0.5的透明度重新绘制并保留下来,通过这种方式,可以形成很多连续点的感觉,如果设置为1的透明度则会全部保留,并且不停叠加,等价于没有清空画布的状态。
- 遍历随机点更新位置
this.particles.forEach((p) => {
if (p.age <= 0) {
//生命周期耗尽重新设置随机点值
this.setParticleRand(p);
} else {
if (!this.inBound(p.x, p.y)) {
//画出范围外重新设置随机点值
this.setParticleRand(p);
} else {
//根据下一个点的风向,计算出下一个点的位置
const uv = this.getUV(p.tx, p.ty);
const nextx = p.tx + this.speed * uv[0];
const nexty = p.ty + this.speed * uv[1];
//将起点换成之前的终点
p.x = p.tx;
p.y = p.ty;
//终点设置成计算出的下一个点
p.tx = nextx;
p.ty = nexty;
//生命周期递减
p.age--;
}
}
//起始点和终点转换成显示的画布大小
const start = this.getCanvasPos(p.x, p.y);
const end = this.getCanvasPos(p.tx, p.ty);
//渐变跟随线段的方向
const gradient = ctx.createLinearGradient(start[0], start[1], end[0], end[1]);
for (let k in this.color) {
gradient.addColorStop(+k, this.color[k]);
}
//绘制线段
ctx.beginPath();
ctx.strokeStyle = gradient;
ctx.moveTo(start[0], start[1]);
ctx.lineTo(end[0], end[1]);
ctx.stroke();
});
5. 使用封装类绘制
async function main() {
//风场信息数据
const header = await getData('./info.json');
//风场uv方向数据
const data = await getData('./wind.json');
const canvas = document.getElementById('canvas');
canvas.width = 1200;
canvas.height = 600;
const cw = new Windy({
header,
data,
canvas,
//运动速度
speed: 0.1,
//随机点数量
particlesCount: 1000,
//生命周期
maxAge: 120,
//1秒更新次数
frame: 10,
//线渐变
color: {
0: 'rgba(255,255,0,0)',
1: '#ffff00'
},
//线宽度
lineWidth: 3
});
}
效果非常好,线段顺着风向在运动!
- 上面的线段因为一段段渐变呈现出一个个小蝌蚪的样子,然而利用叠加保留的效果,可以自动将线段绘制渐变色。只需要改变一下绘制顺序就行
//线段绘制开始
ctx.beginPath();
//设置纯颜色
ctx.strokeStyle = this.color;
//遍历随机点更新位置
this.particles.forEach((p) => {
//同上面更新随机点的位置
//...
//起始点和终点转换成显示的画布大小
const start = this.getCanvasPos(p.x, p.y);
const end = this.getCanvasPos(p.tx, p.ty);
//通过moveTo和lineTo绘制多个线段
ctx.moveTo(start[0], start[1]);
ctx.lineTo(end[0], end[1]);
});
//最终统一绘制线段
ctx.stroke();
这样看上去流动线段连续性更强,不那么零散了!
6. 利用图片信息存储数据的优化
wind.json
风场uv方向数据有739KB接近1MB,这着实有点大,要是网络稍微有点卡都会很影响首屏加载时间!从webgl-wind中我看到了用Canvas的ImageData中颜色来存储与解析数值,这操作太优秀了!
实现逻辑:用nx*ny
与风场网格同样大小的canvas,获取到ImageData,将像素颜色四个数值中red红色和green绿色分别赋值成uv转换后的颜色值,注意透明度一定要置为不透明,然后put回canvas里面绘制,再利用canvas.toDataURL
导出图片。
async function createCanvas() {
const data = await getData('./wind.json');
const info = await getData('info.json');
const canvas = document.getElementById('theCanvas');
canvas.width = info.nx;
canvas.height = info.ny;
const minU = Math.abs(info.minU);
const minV = Math.abs(info.minV);
// uv风方向范围
const uSize = info.maxU - info.minU;
const vSize = info.maxV - info.minV;
const ctx = canvas.getContext('2d');
//获取imageData像素数据
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
data.forEach((item, i) => {
//值转换成正数
const u = item[0] + minU;
const v = item[1] + minV;
//转换成颜色值
const r = (u / uSize) * 255;
const g = (v / vSize) * 255;
imageData.data[i * 4] = r;
imageData.data[i * 4 + 1] = g;
//透明度默认255即不透明
imageData.data[i * 4 + 3] = 255;
});
//用imageData像素颜色值绘制图片
ctx.putImageData(imageData, 0, 0);
}
这样一张360px*181px
的图片存储了65,160
个点,但仅仅只需要86.6KB,压缩成原来数据的十分之一了。
- 如果改用风场方向图片,那么对应需要添加加载和解析数据的流程
加载风场方向数据图片
loadImageData() {
return new Promise((resolve) => {
const image = new Image();
image.src = this.imageUrl;
image.onload = () => {
const c = document.createElement('canvas');
c.width = image.naturalWidth;
c.height = image.naturalHeight;
const ctx = c.getContext('2d');
//绘制图片
ctx.drawImage(image, 0, 0, image.naturalWidth, image.naturalHeight);
//获取ImageData像素数据
const imageData = ctx.getImageData(0, 0, image.naturalWidth, image.naturalHeight);
resolve(imageData.data);
};
});
}
解析图片数据成uv,并组装成风场网格Grid
data = await this.loadImageData();
const minU = Math.abs(header.minU);
const minV = Math.abs(header.minV);
//uv风方向范围
const uSize = header.maxU - header.minU;
const vSize = header.maxV - header.minV;
let index = 0;
for (let j = 0; j < header.ny; j++) {
const row = [];
for (let i = 0; i < header.nx; i++) {
//将颜色数据转化成风向uv数据
const u = (data[index] / 255) * uSize - minU;
const v = (data[index + 1] / 255) * vSize - minV;
row.push([u, v]);
index = index + 4;
}
this.grid.push(row);
}
后面的绘制风场逻辑跟上面一样,只不过多了个加载图片解析的过程。
加上一张世界地图底图可以更清晰得看到风流动的方向!
四、绘制3D风场图
1.利用Canvas风场贴图绘制3D风场图
- 常规的顶点着色器
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.);
}
- 片元着色器,要将世界底图与风场图合并成一张图
varying vec2 vUv;
uniform sampler2D windTex;
uniform sampler2D worldTex;
void main() {
vec4 color = texture2D(windTex, vUv);
float a = color.a;
if(a < 0.01) {
a = 0.;
}
vec4 w = texture2D(worldTex, vUv);
//根据透明度合并世界贴图和风场贴图
vec4 c = w * (1. - a) + color * a;
gl_FragColor = c;
}
- 创建风场贴图
async createWindCanvas() {
const header = await getData('./info.json');
const canvas = document.createElement('canvas');
//要足够大,否则会贴图模糊
canvas.width = 4000;
canvas.height = 2000;
this.cw = new Windy({
header,
// data,
canvas,
//运动速度
speed: 0.1,
//随机点数量
particlesCount: 1000,
//生命周期
maxAge: 120,
//1秒更新次数
frame: 10,
//线渐变
// color: {
// 0: 'rgba(255,255,0,0)',
// 1: '#ffff00'
// },
color: '#ffff00',
//线宽度
lineWidth: 3,
imageUrl: 'wind.png'
//autoAnimate: true
});
const texture = new THREE.CanvasTexture(canvas);
//因为是动态canvas,所以要置为需要更新
texture.needsUpdate = true;
return texture;
}
- 添加球体
async createChart(that) {
this.windTex = await this.createWindCanvas();
const worldTex = new THREE.TextureLoader().load('../assets/world.jpg');
{
const material = new THREE.ShaderMaterial({
uniforms: {
worldTex: { value: worldTex },
windTex: { value: this.windTex }
},
vertexShader: document.getElementById('vertexShader').innerHTML,
fragmentShader: document.getElementById('fragmentShader').innerHTML,
side: THREE.DoubleSide,
transparent: true
});
const geometry = new THREE.SphereGeometry(2, 32, 16);
const sphere = new THREE.Mesh(geometry, material);
this.scene.add(sphere);
}
}
- 让canvas动起来
animateAction() {
if (this.windTex) {
if (this.cw) {
this.cw.render();
}
this.windTex.needsUpdate = true;
}
}
地球展开收起动画
- 将顶点着色器替换成下面的,根据uv计算出压平后球体表面点的位置,然后用mix来让原来球体表面的点过渡变化
注意球体半圆周长,对应球体压平后矩形的宽度,球体贴图正好是2:1,长度对应宽度的两倍。
uniform float time;
uniform float radius;
varying vec2 vUv;
float PI = acos(-1.0);
void main() {
vUv = uv;
//半圆周长
float w = radius * PI;
//随着时间压平或收起球体点位置
vec3 newPosition = mix(position, vec3(0.0, (uv.y - 0.5) * w, -(uv.x - 0.5) * 2.0 * w), sin(time * PI * 0.5));
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
}
- 展开或收起球体动画
openMap() {
const tw = new TWEEN.Tween({ time: 0.0 })
.to({ time: 1.0 }, 2000)
.onUpdate((obj) => {
if (this.mat) {
this.mat.uniforms.time.value = obj.time;
}
})
.start();
TWEEN.add(tw);
}
closeMap() {
const tw = new TWEEN.Tween({ time: 1.0 })
.to({ time: 0.0 }, 2000)
.onUpdate((obj) => {
if (this.mat) {
this.mat.uniforms.time.value = obj.time;
}
})
.start();
TWEEN.add(tw);
}
除了用贴图来实现,还能用three.js的BufferGeometry+LineSegments实现动态线段,进而实现3D风场图。
2.使用LineSegments绘制风场图
- 顶点着色器
uniform vec2 uResolution;//nx与ny网格大小
uniform vec2 uSize;//显示的宽高
varying vec2 vUv;
void main() {
vUv = vec2(position.z);
// 转换为经纬度坐标
vec2 p = vec2(position.x, -position.y) - vec2(180., 90.);
gl_Position = projectionMatrix * modelViewMatrix * vec4((p / uResolution) * uSize + vec2(0., uSize.y), 0.0, 1.);
}
注意:地球的经纬度是从下往上变大的,而平面的坐标是从上往下变大的的,因此随机点的y坐标取反才是正确位置,因为取反的问题,位置会偏移,对应也要将整体位置加上偏移量归位。
- 片元着色器
varying vec2 vUv;
uniform vec3 startColor;
uniform vec3 endColor;
void main() {
//渐变色
gl_FragColor = vec4(mix(startColor, endColor, vUv.y), 1.0);
}
- 绘制线段LineSegments 将随机点的开始结束两个点位置分别赋值到线段position里面,并添加索引。
//点索引
const points = new Float32Array(num * 6);
let i = 0;
pointCallback: (p) => {
// 线段开始位置
points[i] = p.x;
points[i + 1] = p.y;
points[i + 2] = 0;//开始点z坐标标识是0
// 线段结束位置
points[i + 3] = p.tx;
points[i + 4] = p.ty;
points[i + 5] = 1;//结束点z坐标标识是1
//递增索引
i += 6;
}
添加LineSegments,一定要用LineSegments,因为LineSegments是绘制的线段是gl.LINES
模式,就是每两个点一组,形成一个新线段,就是A,B,C,D四个点,就会变成AB一条线段,BC一条线段,就可以绘制多条线段了。
const material = new THREE.ShaderMaterial({
uniforms: {
//nx和ny网格大小
uResolution: { value: new THREE.Vector2(this.cw.header.nx, this.cw.header.ny) },
//显示宽高大小
uSize: { value: new THREE.Vector2(20, 10) },
//渐变开始颜色
startColor: { value: new THREE.Color('#ffff00') },
//渐变结束颜色
endColor: { value: new THREE.Color('#ff0000') }
},
vertexShader: document.getElementById('vertexShader1').innerHTML,
fragmentShader: document.getElementById('fragmentShader').innerHTML,
side: THREE.DoubleSide,
transparent: true
});
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(points, 3));
this.geometry = geometry;
this.mat = material;
//添加多个线段
const lines = new THREE.LineSegments(geometry, material);
this.scene.add(lines);
渲染的时候移动点的位置并给position属性赋值更新
if (this.frameCount % this.frame === 0 && this.cw && this.geometry) {
let i = 0;
const g = this.geometry;
this.cw.movePoints((p) => {
g.attributes.position.array[i] = p.x;
g.attributes.position.array[i + 1] = p.y;
g.attributes.position.array[i + 3] = p.tx;
g.attributes.position.array[i + 4] = p.ty;
i += 6;
});
//属性值改变一定要置true,通知更新
g.attributes.position.needsUpdate = true;
}
上面效果的风场图与canvas 2D风场图清空再绘制一样的效果,没有走destination-in
叠加保留的过程,点的数量可能看起来偏少,因此为了保证风流向的连续性,最好增加随机点个数。
- 将平面的LineSegments变成球体 修改一下定点着色器,经纬度坐标转换成三维坐标
float PI = 3.1415926;
float rad = 3.1415926 / 180.;
uniform vec2 uResolution;
uniform vec2 uSize;
//半径
uniform float radius;
//旋转翻过来
uniform mat4 rotateX;
varying vec2 vUv;
//经纬度坐标转为三维坐标
vec3 lnglat2pos(vec2 p) {
float lng = p.x * rad;
float lat = p.y * rad;
float x = cos(lat) * cos(lng);
float y = cos(lat) * sin(lng);
float z = sin(lat);
return vec3(x, z, y);
}
void main() {
vUv = vec2(position.z);
//转换成经纬度
vec2 p = vec2(position.x, -position.y) - vec2(180., 90.);
//经纬度转三维坐标
vec3 newPosition = radius * lnglat2pos(p);
gl_Position = projectionMatrix * modelViewMatrix *rotateX* vec4(newPosition, 1.);
}
注意
- three.js高度y轴坐标,那么对应三维坐标里面的z轴坐标,而three.js深度z轴坐标,那么对应三维坐标里面的y轴坐标,就是yz轴要对调一下,才是正确的点的位置,即
vec3(x, z, y)
。 - position转经纬度,同上面一样需要将y取反才是正确的位置。 3.地球贴图贴在球体x方向开始位置有PI的偏移,需要将贴图设置一下偏移值才能对上经纬度坐标。
const worldTex = new THREE.TextureLoader().load('../assets/world.jpg');
worldTex.offset.x = 0.5;
worldTex.wrapS = THREE.RepeatWrapping;
4.因为y取反了,但在球体不能用位置偏移量解决归位问题,就会导致整个风流向路径反过来了,所以需要添加一个矩阵翻转量,让风流向路径回归正确的样子,
const matrix = new THREE.Matrix4();
matrix.makeRotationX(Math.PI);
终于解决风场位置对齐的问题了!这点小细节调了好久!唉~
五、Github地址
https://github.com/xiaolidan00/my-earth
参考
来源:juejin.cn/post/7433055938418933787
前端js中如何保护密钥?
在前端js编程中,如果涉及到加密通信、加密算法,经常会用到密钥。
但密钥,很容易暴露。 暴露原因:js代码透明,在浏览器中可以查看源码,从中找到密钥。
例如,下面的代码中,变量key是密钥:
如何保护源码中的密钥呢?
很多时候,人们认为需要对密钥字符串进行加密。其实更重要的是对存储密钥的变量进行加密。
加密了密钥变量,使变量难以找到,才更能保护密钥本身。
顺着这个思路,下面给出一个不错的密钥的保护方法:
还是以上面的代码为例,
首先,用到jsfuck:
https://www.jshaman.com/tools/jsfuck.html
将代码中的密钥定义整体,用jsfuck加密:
var key = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
加密后得到一串奇怪的字符,这是将变量“key ”以及密钥字符“0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ”隐藏了起来。
注意:加密时需要选中“在父作用域中运行”,选中之后,key 变量的定义虽然不存在,但变量key是可用的!(这点很神奇)。也就是虽然代码中没有定义这个变量,但这个变量存在,且可用。而且它存储的就是密钥!
用加密后的代码替换掉原来的代码,变成如下形式:
运行效果:
即时他人拿走代码去调试,也会显示变量key未定义,如下图所示:
但,这时候还不足够安全,还能更安全。
将整体JS代码,再用JS加密工具:JShaman,进行混淆加密:
https://www.jshaman.com
然后得到更安全、更难调试分析的JS代码,这时密钥就变的更安全了:
注:用ajax等异步传递密钥时,也可以使用这个办法,也能很好的隐藏密钥。
用jsfuck+jshaman保护JS中的密钥,你学会了吗?
来源:juejin.cn/post/7431087851389747236
Fuse.js一个轻量高效的模糊搜索库
最近逛github的时候发现了一个非常好用的轻量工具库,Fuse.js,支持模糊搜索。感觉还是非常好用的,所以有了此篇博客,这篇文章主要是介绍Fuse的使用,同样,我对这个开源项目的实现也非常感兴趣。后续会出一篇Fuse源码解析的文章来分析其实现原理。
Fuse.js是什么?
强大、轻量级的模糊搜索库,没有任何依赖关系。
什么是模糊搜索?
一般来说,模糊搜索(更正式的名称是近似字符串匹配)是查找与给定模式近似相等(而不是完全相等)的字符串的技术。
通常我们项目中的的模糊搜索大多数情况下有几种方案可用:
- 前端工程通过正则表达式或者字符串匹配来实现
- 调用后端接口去匹配搜索
- 使用搜索引擎如:ElasticSearch或Algolia等
但是这些方案都有各自的缺陷,比如正则表达式和字符串匹配的效率较低,且无法处理复杂的搜索需求,而调用后端接口和搜索引擎虽然效率高,但是需要额外的服务器资源,且需要维护一套搜索引擎。
所以,Fuse.js的出现就是为了解决这些问题,它是一个轻量级的模糊搜索库,没有依赖关系,支持复杂的搜索需求,且效率高,当然Fuse并不适用于所有场景。
Fuse.js的使用场景
它可能不适用于所有情况,但根据您的搜索要求,它可能是最理想的。例如:
- 当您想要对小型到中等大型数据集进行客户端模糊搜索时
- 当您无法证明设置专用后端只是为了处理搜索时
- ElasticSearch 或 Algolia 虽然都是很棒的服务,但对于您的特定用例来说可能有些过度
Fuse.js的使用
安装
Fuse支持多种安装方式
NPM
npm install fuse.js
Yarn
yarn add fuse.js
CDN 引入
<script src="https://cdn.jsdelivr.net/npm/fuse.js@7.0.0"></script>
引入
ES6 模块语法
import Fuse from 'fuse.js'
CommonJS 语法
const Fuse = require('fuse.js')
Tips: 使用npm或者yarn引入,支持两种模块语法引入,如果是使用cdn引入,那么Fuse将被注册为全局变量。直接使用即可
使用
以下是官网一个最简单的例子,只要简单的构造new Fuse对象,就能模糊搜索匹配到你想要的结果
// 1. List of items to search in
const books = [
{
title: "Old Man's War",
author: {
firstName: 'John',
lastName: 'Scalzi'
}
},
{
title: 'The Lock Artist',
author: {
firstName: 'Steve',
lastName: 'Hamilton'
}
}
]
// 2. Set up the Fuse instance
const fuse = new Fuse(books, {
keys: ['title', 'author.firstName']
})
// 3. Now search!
fuse.search('jon')
// Output:
// [
// {
// item: {
// title: "Old Man's War",
// author: {
// firstName: 'John',
// lastName: 'Scalzi'
// }
// },
// refIndex: 0
// }
// ]
从上述代码中可以看到我们要通过Fuse 对books的这个数组进行模糊搜索,构建的Fuse对象中,模糊搜索的key定义为['title', 'author.firstName'],支持对title及author.firstName这两个字段进行搜索。然后执行fuse的search API就能过滤出我们的期望结果。整体代码还是非常简单的。
高级配置
Demo示例只是提供了一个基础版本的模糊搜索。如果用户想获得更灵活的搜索能力,比如搜索结果排序、权重控制、搜索结果高亮等,那么就需要对Fuse进行一些高级配置。
Fuse的所有配置都是通过new Fuse时传入的参数来配置的,下面列举一些常用的配置项:
const options = {
keys: ['title', 'author'], // 指定搜索key值,可多选
isCaseSensitive: false, //是否区分大小写 默认为false
includeScore: false, //结果集中是否展示匹配项的分数字段, 分数越大代表匹配程度越低,区间值为0-1,注意:当此项为true时,会返回完整的结果集,只不过每一项中携带了score分数字段
includeMatches: false, //匹配项是否应包含在结果中。当时true,结果的每条记录都包含匹配项的索引。这个通常我们用来对搜索内容做高亮处理
threshold: 0.6, // 阈值控制匹配的敏感度,默认值为0.6,如果要完全匹配这里要设置为0
shouldSort: true, // 是否对结果进行排序
location: 0, // 匹配的位置,0 表示开头匹配
distance: 100, // 搜索的最大距离
minMatchCharLength: 2, // 最小匹配字符长度
};
出了上述常用的一些配置项之外,Fuse还支持更高阶模糊搜索,如权重搜索,嵌套搜索,运算符拓展搜索,具体高阶用法可以参考官方文档。
Fuse的主要实现原理是通过改写Bitap 算法(近似字符串匹配)算法的内部实现来支撑其模糊搜索的算法依据,后续会出一篇文章看一下作者源码的算法实现。
总结
Fuse的文章到此就结束了,你没看错就这么一点介绍就基本能支撑我们在项目中的应用,谢谢阅读,如果哪里有不对的地方请评论博主,会及时进行改正。
来源:juejin.cn/post/7393172686115569705
Flutter 鸿蒙化 在一起 就可以
相关阅读:
Flutter Love 鸿蒙 - 掘金 (juejin.cn)
不是鸿蒙 ArkUI 不会写,而是 Flutter 更有性价比 - 掘金 (juejin.cn)
前言
鸿蒙生态势如破竹,已有超4000应用加入,实现垂域全覆盖,商店里面的鸿蒙 app
也越来越多,就像余总说的一样,
在一起,就可以 !
OpenHarmony-SIG/flutter_flutter (gitee.com) 社区一直在致力于使用 Flutter 更加快速地适配鸿蒙平台。
而距离 不是鸿蒙 ArkUI 不会写,而是 Flutter 更有性价比 - 掘金 (juejin.cn) 已经有一段时间了,我们来看看 Flutter
鸿蒙化的进展如何了。
重要提示,Flutter 鸿蒙化,需要华为提供的真机和最新的SDK或者自己申请了开发者预览 Beta 招募,没有的,暂时不要尝试。
最近 华为纯血鸿蒙 HarmonyOS NEXT 开发者预览版首批 Beta 招募开启,支持 Mate 60 / Pro、X5 机型, 这给一些个人开发者提前体验鸿蒙 NEXT
的机会。
后续内容全部基于 OpenHarmony-SIG/flutter_flutter (gitee.com) 和 OpenHarmony-SIG/flutter_engine (gitee.com) 的 dev 分支。参考文档也以 dev 分支 的文档为准。另外最新支持的是
ohos api11
。
插件进度
现阶段 Flutter
适配工作主要集中在鸿蒙原生插件的适配。下面介绍一下已知完成适配的插件。
flutter_packages
OpenHarmony-SIG/flutter_packages (gitee.com) 是适配官方 flutter/packages: A collection of useful packages maintained by the Flutter team (github.com) 仓库。
引用方式例子如下:
dependencies:
path_provider:
git:
url: "https://gitee.com/openharmony-sig/flutter_packages.git"
path: "packages/path_provider/path_provider"
path_provider | 2.1.1 | 官方库 | 11月30日 | gitee.com/openharmony… | |
---|---|---|---|---|---|
shared_preferences | 2.2.1 | 官方库 | 11月30日 | gitee.com/openharmony… | |
url_launcher | 6.1.11 | 官方库 | 11月30日 | gitee.com/openharmony… | |
image_picker | 1.0.4 | 官方库 | 12月30日 | gitee.com/openharmony… | |
local_auth | 2.1.6 | 官方库 | 12月30日 | gitee.com/openharmony… | |
pigeon | 11.0.1 | 官方库 | 12月30日 | gitee.com/openharmony… | |
webview_flutter | 4.2.4、4.4.4 | 官方库 | 12月30日 | gitee.com/openharmony… | |
video_player | 2.7.2 | 官方库 | 3月30日 | gitee.com/openharmony… | |
file_selector | 1.0.1 | 官方库 | 12月30日 | gitee.com/openharmony… | |
camera | 0.10.5 | 官方库 | 3月30日 | gitee.com/openharmony… |
plus 插件
[Request]: support HarmonyOS · Issue #2480 · fluttercommunity/plus_plugins (github.com) 作者对于适配鸿蒙平台兴趣不大,所以这里决定 HarmonyCandies (github.com) 来维护。
wakelock_plus_ohos
引用:
dependencies:
wakelock_plus: 1.1.4
wakelock_plus_ohos: any
device_info_plus_ohos
引用:
dependencies:
device_info_plus: any
device_info_plus_ohos: any
注意,有 2
个 uid
是系统级别的,需要应用单独申请。
/// Requires permission: ohos.permission.sec.ACCESS_UDID (System permission, only open to system apps).
/// Device serial number.
/// 设备序列号。
final String serial;
/// Requires permission: ohos.permission.sec.ACCESS_UDID (System permission, only open to system apps).
/// Device Udid.
/// 设备Udid。
final String udid;
使用
import 'package:device_info_plus_ohos/device_info_plus_ohos.dart';
final DeviceInfoOhosPlugin deviceInfoOhosPlugin = DeviceInfoOhosPlugin();
OhosDeviceInfo deviceInfo = await deviceInfoOhosPlugin.ohosDeviceInfo;
// Requires permission: ohos.permission.sec.ACCESS_UDID (System permission, only open to system apps).
OhosAccessUDIDInfo accessUDIDInfo = await deviceInfoOhosPlugin.ohosAccessUDIDInfo;
network_info_plus_ohos
引用:
dependencies:
network_info_plus: any
network_info_plus_ohos: any
在你的项目的 module.json5
文件中增加以下权限设置。
requestPermissions: [
{"name" : "ohos.permission.INTERNET"},
{"name" : "ohos.permission.GET_WIFI_INFO"},
],
sensors_plus_ohos
引用:
dependencies:
sensors_plus: 4.0.2
sensors_plus_ohos: any
在你的项目的 module.json5
文件中增加以下权限设置。
requestPermissions: [
{"name" : "ohos.permission.ACCELEROMETER"},
{"name" : "ohos.permission.GYROSCOPE"},
],
connectivity_plus_ohos
引用:
dependencies:
connectivity_plus: 5.0.2
connectivity_plus_ohos: any
在你的项目的 module.json5
文件中增加以下权限设置。
requestPermissions: [
{"name" : "ohos.permission.INTERNET"},
{"name" : "ohos.permission.GET_NETWORK_INFO"},
],
battery_plus_ohos
引用:
dependencies:
battery_plus: 5.0.3
battery_plus_ohos: any
package_info_plus_ohos
引用:
dependencies:
package_info_plus: 4.2.0
package_info_plus_ohos: any
糖果插件
flutter_image_compress
引用:
dependencies:
flutter_image_compress: ^2.2.0
Feature | Android | iOS | Web | macOS | OpenHarmony |
---|---|---|---|---|---|
method: compressWithList | ✅ | ✅ | ✅ | ✅ | ✅ |
method: compressAssetImage | ✅ | ✅ | ✅ | ✅ | ✅ |
method: compressWithFile | ✅ | ✅ | ❌ | ✅ | ✅ |
method: compressAndGetFile | ✅ | ✅ | ❌ | ✅ | ✅ |
format: jpeg | ✅ | ✅ | ✅ | ✅ | ✅ |
format: png | ✅ | ✅ | ✅ | ✅ | ✅ |
format: webp | ✅ | ✅ | [🌐][webp-compatibility] | ❌ | ✅ |
format: heic | ✅ | ✅ | ❌ | ✅ | ✅ |
param: quality | ✅ | ✅ | [🌐][webp-compatibility] | ✅ | ✅ |
param: rotate | ✅ | ✅ | ❌ | ✅ | ✅ |
param: keepExif | ✅ | ✅ | ❌ | ✅ | ❌ |
flutter_image_editor
引用:
dependencies:
image_editor: ^2.2.0
Feature | Android | iOS | OpenHarmony |
---|---|---|---|
flip | ✅ | ✅ | ✅ |
crop | ✅ | ✅ | ✅ |
rotate | ✅ | ✅ | ✅ |
scale | ✅ | ✅ | ✅ |
matrix | ✅ | ✅ | ❌ |
mix image | ✅ | ✅ | ✅ |
merge multi image | ✅ | ✅ | ✅ |
draw point | ✅ | ✅ | ✅ |
draw line | ✅ | ✅ | ✅ |
draw rect | ✅ | ✅ | ✅ |
draw circle | ✅ | ✅ | ✅ |
draw path | ✅ | ✅ | ✅ |
draw Bezier | ✅ | ✅ | ✅ |
Gaussian blur | ❌ | ❌ | ❌ |
flutter_photo_manager
引用:
注意 photo_manager_image_provider
需要限制一下版本。
dependencies:
photo_manager: ^3.1.0
dependency_overrides:
photo_manager_image_provider: ^1.1.1
暂时支持下面的功能,目前鸿蒙只支持图片和视频 2 种资源类型。
Feature | OpenHarmony |
---|---|
getAssetPathList | ✅ |
getAssetCountFromPath | ✅ |
fetchPathProperties | ✅ |
getAssetCount | ✅ |
getAssetListPaged | ✅ |
getOriginBytes | ✅ |
getThumb | ✅ |
getAssetListRange | ✅ |
getAssetsByRange | ✅ |
deleteWithIds | ✅ |
getColumnNames | ✅ |
saveImage | ✅ |
saveImageWithPath | ✅ |
saveVideo | ✅ |
requestPermissionExtend | ✅ |
ignorePermissionCheck | ✅ |
log | ✅ |
notify | ✅ |
其他插件
permission_handler_ohos
引用:
dependencies:
permission_handler_ohos: any
权限列表来自: gitee.com/openharmony…
注意
由于 OpenHarmony
和 HarmonyOS
的权限差异以及鸿蒙版本的高速迭代,检查请求权限的 api
是传递的权限的字符串全称,如果你发现 PermissionOhos
枚举中没有某个权限,你可以直接传递权限的字符串全称。等鸿蒙版本稳定下来了,会再同步权限列表到枚举中。
权限枚举列表是由文档自动生成的。
// GENERATED CODE - DO NOT MODIFY MANUALLY
// **************************************************************************
// Auto generated by https://github.com/HarmonyCandies/permission_handler_ohos/bin/main.dart
// **************************************************************************
// https://gitee.com/openharmony/docs/blob/OpenHarmony-4.1-Release/zh-cn/application-dev/security/AccessToken/permissions-for-all.md
// ignore_for_file: constant_identifier_names,slash_for_doc_comments
/// The Permissions of OpenHarmony
/// total: 44
enum PermissionOhos {
/// ohos.permission.USE_BLUETOOTH
///
/// 允许应用查看蓝牙的配置。
///
/// 权限级别:normal
///
/// 授权方式:system_grant
///
/// ACL使能:true
///
/// 起始版本:8
use_bluetooth(
name: 'ohos.permission.USE_BLUETOOTH',
permissionLevel: 'normal',
grantType: 'system_grant',
aclEnabled: true,
startVersion: 8,
),
使用
请认真阅读官方关于权限的文档 gitee.com/openharmony…
在你的项目的 module.json5
文件中增加对应需要权限设置,比如:
requestPermissions: [
{ name: "ohos.permission.READ_CALENDAR" },
{ name: "ohos.permission.WRITE_CALENDAR" },
],
例子
检查权限状态
import 'package:device_info_plus_ohos/device_info_plus_ohos.dart';
final PermissionStatusOhos status =
await PermissionHandlerOhos.checkPermissionStatus(
PermissionOhos.read_calendar.name);
请求单个权限
final PermissionStatusOhos status =
await PermissionHandlerOhos.requestPermission(
PermissionOhos.read_calendar.name,
);
请求多个权限
final Map<String, PermissionStatusOhos> statusMap =
await PermissionHandlerOhos.requestPermissions([
PermissionOhos.read_calendar.name,
PermissionOhos.write_calendar.name,
]);
打开设置页面
PermissionHandlerOhos.openAppSettings();
audio_streamer_ohos
引用:
dependencies:
audio_streamer: 4.1.1
audio_streamer_ohos: any
audio_streamer 在 OpenHarmony 平台上的实现
在 OpenHarmony 项目的 module.json
文件中添加 ohos.permission.MICROPHONE
权限
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.MICROPHONE",
"reason": "Microphone permission is required to record audio."
}
]
}
}
geolocator
地址: HarmonyCandies/geolocator_ohos: The OpenHarmony implementation of geolocator. (github.com)
引用:
dependencies:
geolocator: any
geolocator_ohos: ^0.0.1
在你的项目的 module.json5
文件中增加以下权限设置。
"requestPermissions": [
{"name" : "ohos.permission.KEEP_BACKGROUND_RUNNING"},
{
"name": "ohos.permission.LOCATION",
"reason": "$string:EntryAbility_label",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
},
{
"name": "ohos.permission.APPROXIMATELY_LOCATION",
"reason": "$string:EntryAbility_label",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
},
{
"name": "ohos.permission.LOCATION_IN_BACKGROUND",
"reason": "$string:EntryAbility_label",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
},
]
鸿蒙特有的方法
CountryCode? countryCode= await geolocatorOhos.getCountryCode();
(逆)地理编码转化
final position = await geolocatorOhos.getCurrentPosition(
locationSettings: const CurrentLocationSettingsOhos(
priority: LocationRequestPriority.firstFix,
scenario: LocationRequestScenario.unset,
),
);
// ohos only
if (await geolocatorOhos.isGeocoderAvailable()) {
//
var addresses = await geolocatorOhos.getAddressesFromLocation(
ReverseGeoCodeRequest(
latitude: position.latitude,
longitude: position.longitude,
locale: 'zh',
maxItems: 1,
),
);
for (var address in addresses) {
if (kDebugMode) {
print('ReverseGeoCode address:$address');
}
var position = await geolocatorOhos.getAddressesFromLocationName(
GeoCodeRequest(description: address.placeName ?? ''),
);
if (kDebugMode) {
print('geoCode position:$position');
}
}
}
vibration
地址:flutter_vibration/vibration_ohos at master · benjamindean/flutter_vibration (github.com)
引用:
dependencies:
vibration: any
vibration_ohos: any
在你的项目的 module.json5
文件中增加以下权限设置。
"requestPermissions": [
{"name" : "ohos.permission.VIBRATE"},
]
vibrateEffect
and vibrateAttribute
are only exist in VibrationOhos
.
(VibrationPlatform.instance as VibrationOhos).vibrate(
vibrateEffect: const VibratePreset(count: 100),
vibrateAttribute: const VibrateAttribute(
usage: 'alarm',
),
);
sqflite
引用:
dependencies:
sqflite:
git:
url: "https://gitee.com/openharmony-sig/flutter_sqflite.git"
path: "sqflite"
fluttertoast
引用:
dependencies:
fluttertoast:
git:
url: "https://gitee.com/openharmony-sig/flutter_fluttertoast.git"
audio_session
引用:
dependencies:
audio_session:
git:
url: "https://gitee.com/openharmony-sig/flutter_audio_session.git"
flutter_sound
引用:
dependencies:
flutter_sound:
git:
url: "https://gitee.com/openharmony-sig/flutter_sound.git"
path: "flutter_sound"
image_gallery_saver
引用:
dependencies:
image_gallery_saver:
git:
url: "https://gitee.com/openharmony-sig/flutter_image_gallery_saver.git"
location
引用:
dependencies:
location:
git:
url: "https://gitee.com/openharmony-sig/flutter_location.git"
path: "location"
power_image
引用:
dependencies:
power_image:
git:
url: "https://gitee.com/openharmony-sig/flutter_power_image.git"
flutter_native_image
引用:
dependencies:
flutter_native_image:
git:
url: "https://gitee.com/openharmony-sig/flutter_native_image.git"
audioplayers
引用:
dependencies:
audioplayers:
git:
url: "https://gitee.com/openharmony-sig/flutter_audioplayers.git"
image_crop
引用:
dependencies:
image_crop:
git:
url: "https://gitee.com/openharmony-sig/flutter_image_crop.git"
bitmap
引用:
dependencies:
bitmap:
git:
url: "https://gitee.com/openharmony-sig/flutter_bitmap.git"
leak_detector
引用:
dependencies:
leak_detector:
git:
url: "https://gitee.com/openharmony-sig/flutter_leak_detector.git"
flutter_contacts
引用:
dependencies:
flutter_contacts:
git:
url: "https://gitee.com/openharmony-sig/flutter_contacts.git"
纯 Flutter 库
extended_text
dependencies:
extended_text: 10.0.1-ohos
extended_text_field
dependencies:
extended_text_field: 11.0.1-ohos
flutter_platform_utils
HarmonyCandies/flutter_platform_utils: A utility to check the platform for ohos (github.com)
如果您的库支持 OpenHarmony
平台,并且有 Platform.isOhos
的判断,那么建议换成 PlatformUtils.isOhos
避免对其他非鸿蒙用户在非鸿蒙分支编译的影响。
一些注意事项
关于鸿蒙的 context
在制作插件中,你可能需要用到 2
种 context
。
ApplicationContex
你可以直接从 onAttachedToEngine
方法中获取。
private context: Context | null = null;
onAttachedToEngine(binding: FlutterPluginBinding): void {
this.context = binding.getApplicationContext();
}
onDetachedFromEngine(binding: FlutterPluginBinding): void {
this.context = null;
}
该 context
可以用于获取 applicationInfo
等属性。
let applicationInfo = this.context.applicationInfo;
UIAbilityContext
插件继承 AbilityAware
并且在 onAttachedToAbility
方法中获取。
export default class XXXPlugin implements FlutterPlugin, MethodCallHandler, AbilityAware {
private _uiContext: common.UIAbilityContext | null = null;
onAttachedToAbility(binding: AbilityPluginBinding): void {
this._uiContext = binding.getAbility().context;
}
onDetachedFromAbility(): void {
this._uiContext = null;
}
}
该 uiContext
可以用于获取 applicationInfo
等属性。
photoAccessHelper.getPhotoAccessHelper(PhotoManagerPlugin.uiContext);
关于插件参数传递
按照以前的习惯,dart
端传递 map
参数,原生端根据 map
解析参数。
但由于 ts
支持将字符串直接转换成对应的 interface
,那么我们可以将 dart
的端的参数。
参数定义
比如 geolocator_ohos
中的 CurrentLocationSettingsOhos
在 dart
端的实现为如下:
Map<String, dynamic> toMap() {
return {
if (priority != null) 'priority': priority?.toInt(),
if (scenario != null) 'scenario': scenario?.toInt(),
if (maxAccuracy != null) 'maxAccuracy': maxAccuracy,
if (timeoutMs != null) 'timeoutMs': timeoutMs,
};
}
@override
String toString() {
return jsonEncode(toMap());
}
而在鸿蒙原生端,对于的 interface
是 CurrentLocationRequest
export interface CurrentLocationRequest {
priority?: LocationRequestPriority;
scenario?: LocationRequestScenario;
maxAccuracy?: number;
timeoutMs?: number;
}
值得注意的是,如果参数为 null
,不要传递过去,比如 'priority': null
, 如果传递过去,鸿蒙原生端会解析错误。不传递过去的话,会解析为 undefined
,这也对应了 priority?: LocationRequestPriority
可选的意思。
可以使用
chatgpt
直接将鸿蒙的interface
转换成dart
的类,并且增加toMap
,fromMap
,和注释。
插件传递
dart
端,将参数类以字符串的方式传递过去,并且用字符串的方式接受返回值。
@override
Future<Position> getCurrentPosition({
LocationSettings? locationSettings,
String? requestId,
}) async {
assert(
locationSettings == null ||
locationSettings is CurrentLocationSettingsOhos,
'locationSettings should be CurrentLocationSettingsOhos',
);
try {
final Duration? timeLimit = locationSettings?.timeLimit;
Future<dynamic> positionFuture =
GeolocatorOhos._methodChannel.invokeMethod(
'getCurrentPosition',
locationSettings?.toString(),
);
if (timeLimit != null) {
positionFuture = positionFuture.timeout(timeLimit);
}
return PositionOhos.fromString(await positionFuture);
}
}
在鸿蒙端, 将字符串直接转换成鸿蒙对应的 interface
。
let request: geoLocationManager.CurrentLocationRequest = JSON.parse(args);
并且将要返回的 interface
转换成字符串。
result.success(JSON.stringify(location));
当然了,这样有个问题,就是如果鸿蒙端修改了 interface
的属性名字,插件很难感知到(当然会报错)。
关于做 Flutter 鸿蒙化的一些环境要求
重要提示,Flutter 鸿蒙化,需要华为提供的真机和最新的SDK或者自己申请了开发者预览 Beta 招募,没有的,暂时不要尝试。
Xcode15
如果你的电脑升级了 Xcode15
,在做编译引擎的时候,也许会遇到下面的错误。
/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX14.4.sdk/System/Library/Frameworks/Foundation.framework/Headers/NSObjCRuntime.h:657:37: error: use of undeclared identifier 'NSIntegerMax'
static const NSInteger NSNotFound = NSIntegerMax;
或者
../../third_party/dart/runtime/bin/security_context_macos.cc:188:17: error: use of undeclared identifier 'noErr'
if (status != noErr) {
^
../../third_party/dart/runtime/bin/security_context_macos.cc:196:19: error: use of undeclared identifier 'noErr'
if (status != noErr) {
^
../../third_party/dart/runtime/bin/security_context_macos.cc:205:17: error: use of undeclared identifier 'noErr'
if (status != noErr) {
^
../../third_party/dart/runtime/bin/security_context_macos.cc:303:21: error: use of undeclared identifier 'noErr'
OSStatus status = noErr;
^
../../third_party/dart/runtime/bin/security_context_macos.cc:319:23: error: use of undeclared identifier 'noErr'
status == noErr && (trust_result == kSecTrustResultProceed ||
^
解决办法是从下面地址选择 13.3 sdk
中的 TargetConditionals.h
替换掉你本地的,注意做备份。
/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/TargetConditionals.h
说点其他
关于 Google 裁员
最近很多人都在问,传着传着就变成 Google
要解散 Flutter
团队。不管是哪个公司都会有裁员的哪一天,与其犹豫不决,还不如笃定前行。
总有人年轻
没有人永远年轻,但总有人年轻。最近 NBA
季后赛,你不得不承认,他们都老了。过年的时候出去玩,把娃放肩膀上面驮着,脖子肩膀酸痛了,2天才恢复。有那么一刻,确实感觉自己也不再年轻了。
尽管时间会在我们身上留下痕迹,但当我们投身于自己热爱的事业或兴趣时,心态和精神永远年轻。
扶我起来,我还能写代码。那你,是什么时候发现自己不再年轻的?
结语
跟当年 Flutter
社区一样,我们也是从一点点慢慢变好的。5年前,Flutter Candies 一桶天下 - 掘金 (juejin.cn),社区开始慢慢壮大。现在我们也将继续在新的领域汇集在一起 不是鸿蒙 ArkUI 不会写,而是 Flutter 更有性价比 - 掘金 (juejin.cn)。
如果你是喜欢分享的,请加入我们;如果你需要分享的,也请加入我们。
爱 鸿蒙
,爱糖果
,欢迎加入Harmony Candies,一起生产可爱的鸿蒙小糖果QQ群:981630644
来源:juejin.cn/post/7364698043910930443
还学鸿蒙原生?vue3 + uniapp 可以直接开发鸿蒙啦!
Hello,大家好,我是 Sunday
7月20号,uniapp 官网“悄咪咪”的上线了 uniapp 开发鸿蒙应用 的文档,算是正式开启了 Vue3 + uniapp 开发鸿蒙应用
的时代。
开发鸿蒙的前置准备
想要使用 uniapp 开发鸿蒙,我们需要具备三个条件:
- DevEco-Studio 5.0.3.400 以上(下载地址:
https://developer.huawei.com/consumer/cn/deveco-studio/
) - 鸿蒙系统版本 API 12 以上 (DevEco-Studio有内置鸿蒙模拟器)
- HBuilderX-alpha-4.22 以上
PS: 这里不得不吐槽一下,一个 DevEco-Studio 竟然有 10 个 G......
安装好之后,我们就可以通过 开发工具 运行 示例代码
运行时,需要用到 鸿蒙真机或者模拟器。但是这里需要 注意: Windows系统需要经过特殊配置才可以启动,mac 系统最好保证系统版本在 mac os 12 以上
windows 系统配置方式(非 windows 用户可跳过):
打开控制面板 - 程序与功能 - 开启以下功能
- Hyper-V
- Windows 虚拟机监控程序平台
- 虚拟机平台
注意: 需要win10专业版或win11专业版才能开启以上功能,家庭版需先升级成专业版或企业版
启动鸿蒙模拟器
整个过程分为三步(中间会涉及到鸿蒙开发者申请):
- 下载 uni-app 鸿蒙离线SDK template-1.3.4.tgz (下载地址:
https://web-ext-storage.dcloud.net.cn/uni-app/harmony/zip/template-1.3.4.tgz
) - 解压刚下载的压缩包,将解压后的模板工程在 DevEco-Studio 中打开
- 等待 Sync 结束,再 启动鸿蒙模拟器 或 连接鸿蒙真机(如无权限,则需要申请(一般 3 个工作日),申请地址:
https://developer.huawei.com/consumer/cn/activity/201714466699051861/signup
)
配置 HBuilderX 吊起 DevEco-Studio
打开HBuilderX,点击上方菜单 - 工具 - 设置,在出现的弹窗右侧窗体新增如下配置
注意:值填你自己的 DevEco-Studio 启动路径
"harmony.devTools.path" : "/Applications/DevEco-Studio.app"
创建 uni-app 工程
- BuilderX 新建一个空白的 uniapp 项目,选vue3
- 在 manifest.json 文件中配置鸿蒙离线SDK路径(SDK 路径可在 DevEco-Studio -> Preferences(设置) z中获取)
编辑 manifest.json
文件,新增如下配置:
然后点击 运行到鸿蒙即可
总结
这样我们就有了一个初始的鸿蒙项目,并且可以在鸿蒙模拟器上运行。关于更多 uniapp 开发鸿蒙的 API,大家可以直接参考 uniapp 官方文档:https://zh.uniapp.dcloud.io/tutorial/harmony/dev.html#nativeapi
来源:juejin.cn/post/7395964591799025679
如果有人在你的论坛、博客,乱留言、乱回复,怎么办?
作者:小傅哥
博客:bugstack.cn
沉淀、分享、成长,让自己和他人都能有所收获!😄
哈喽,大家好我是技术UP主小傅哥。
常听到一句话:你很难赚到你认知以外的钱💰,屁!不是很难,是压根赚不到。 你以为要是你做也能做,但其实除了你能看见的以外,还有很多东西都不知道。

我看过不少小伙伴自己上线过带有评论功能的博客,或是能进行通信的聊天室。但最后都没运营多久就关停了,除了能花钱解决的服务器成本,还有是自身的研发的系统流程不够健全。其中非常重要的一点是舆情敏感内容
的审核,如果你做这类应用的处理,一定要对接上相应的内容安全审核。
那么,接下来小傅哥就给大家分享下,如何对接内容安全审核,并在 DDD 分层结构下实现一个对应的规则过滤服务。
文末提供了「星球:码农会锁」🧧优惠加入方式,以及本节课程的代码地址。项目演示地址:gaga.plus
一、场景说明
在本节小傅哥会通过 DDD 分层架构设计,开发出一个敏感词、内容安全审核过滤操作的规则处理器。在这个过程大家可以学习到 DDD 分层调用流程、规则模型的搭建、敏感词和内容审核的使用。

如图,上半部分是业务流程,下半部分是 DDD 分层结构中的实现。
- 业务流程上,以用户发送的提交给服务端的内容进行审核过滤,优先使用敏感词进行替换单词组。过滤后过内容审核,一般各个云平台都有提供内容审核的接口,如;京东云、百度云、腾讯云都有提供。一般价格在
0.0015 元/条
- 系统实现上,以 DDD 分层架构实现一个内容审核的流程。app 配置组件和启动应用、trigger 提供 http 调用、domain 编写核心逻辑和流程、infrastructure 提供 dao 的基础操作。
二、内容审核 - SDK 使用
一般舆情内容审核分为两种,一种是静态配置数据的 SDK 组件,也叫敏感词过滤。另外一种是实时动态的由各个第三方提供的内容审核接口服务。这类的就是前面提到的,在各个云平台都有提供。
这里小傅哥先带着大家做下最基本的调用案例,之后再基于 DDD 工程实现整个代码开发。
1. 敏感词
地址:github.com/houbb/sensi… - 开源的敏感词库组件
<dependency>
<groupId>com.github.houbb</groupId>
<artifactId>sensitive-word</artifactId>
<version>0.8.0</version>
</dependency>
案例代码
@Test
public void test_sensitive_word() {
boolean contains = sensitiveWordBs.contains("小傅哥喜欢烧烤臭毛蛋,豆包爱吃粑粑,如果想吃订购请打电话:13900901878");
log.info("是否被敏感词拦截:{}", contains);
}
@Test
public void test_sensitive_word_findAll() {
List<String> list = sensitiveWordBs.findAll("小傅哥喜欢烧烤臭毛蛋,豆包爱吃粑粑,如果想吃订购请打电话:13900901878");
log.info("测试结果:{}", JSON.toJSONString(list));
}
@Test
public void test_sensitive_word_replace() {
String replace = sensitiveWordBs.replace("小傅哥喜欢烧烤臭毛蛋,豆包爱吃粑粑,如果想吃订购请打电话:13900901878");
log.info("测试结果:{}", replace);
}
- 敏感词组件提供了大量的风险词过滤,同时可以基于组件的文档完成自定义敏感词的增改删减操作。
本文在工程中已提供
- 敏感词组件提供了判断、查找、过滤操作。还有你可以把检测到的敏感词替换为
*
或者空格
。
2. 内容审核
- 京东云:http://www.jdcloud.com/cn/products…
- 百度云:ai.baidu.com/censoring#/…
- 腾讯云:cloud.tencent.com/product/tms
这里小傅哥以其中的一个百度云为例,为大家展示内容安全审核的使用。
<!-- 百度内容审核 https://mvnrepository.com/artifact/com.baidu.aip/java-sdk -->
<dependency>
<groupId>com.baidu.aip</groupId>
<artifactId>java-sdk</artifactId>
<version>4.16.17</version>
</dependency>
2.1 配置应用

- 先领取免费的调用次数,之后创建应用。创建应用后就可以获得连接信息;appid、apikey、secretkey
- 另外是策略配置,如果你在过滤中不需要检测用户发的应用营销信息,那么是可以不检测的。
2.2 测试服务
//设置APPID/AK/SK
public static final String APP_ID = "{APP_ID}";
public static final String API_KEY = "{API_KEY}";
public static final String SECRET_KEY = "{SECRET_KEY}";
private AipContentCensor client;
@Before
public void init() {
client = new AipContentCensor(APP_ID, API_KEY, SECRET_KEY);
// 可选:设置网络连接参数
client.setConnectionTimeoutInMillis(2000);
client.setSocketTimeoutInMillis(60000);
}
@Test
public void test_textCensorUserDefined() throws JSONException {
for (int i = 0; i < 1; i++) {
JSONObject jsonObject = client.textCensorUserDefined("小傅哥喜欢烧烤臭毛蛋,豆包爱吃粑粑,如果想吃订购请打电话:13900901878");
if (!jsonObject.isNull("error_code")) {
log.info("测试结果:{}", jsonObject.get("error_code"));
} else {
log.info("测试结果:{}", jsonObject.toString());
}
}
}
测试结果
13:41:16.393 [main] INFO com.baidu.aip.client.BaseClient - get access_token success. current state: STATE_AIP_AUTH_OK
13:41:16.396 [main] DEBUG com.baidu.aip.client.BaseClient - current state after check priviledge: STATE_TRUE_AIP_USER
13:41:16.495 [main] INFO cn.bugstack.x.api.test.BaiduAipContentCensorTest - 测试结果:{"conclusion":"合规","log_id":17046060767025067,"isHitMd5":false,"conclusionType":1}
- 应为过滤掉了营销信息,比如手机号。那么就会返回
合规
三、应用实现 - DDD 架构
做了以上的基本调用案例以后,我们来看下在系统中怎么运用这些基础功能完成业务诉求。
1. 工程结构

- docs 下提供了 docker 安装 mysql 以及初始化数据库配置的脚本。因为本文的案例,可以满足你在数据库中增加敏感词配置。
- app 是应用的启动层,如上我们所需的敏感词和内容审核,都在app层下配置启动处理。
- domain 领域层通过策略+工厂,实现规则过滤服务。
2. 数据库表

- 在docs 提供了数据库初始化的脚本语句,你可以导入到自己的数据库,或者使用 docker 脚本安装测试。—— 注意已经安装过 mysql 占用了 3306 端口的话,记得修改 docker 脚本安装 mysql 的端口。
- 配置到数据库中的敏感词方便管理和使用,为了性能考虑也可以考虑使用 redis 做一层缓存。
3. 配置加载
3.1 敏感词初始化
@Configuration
public class SensitiveWordConfig {
@Bean
public SensitiveWordBs sensitiveWordBs(IWordDeny wordDeny, IWordAllow wordAllow) {
return SensitiveWordBs.newInstance()
.wordDeny(wordDeny)
.wordAllow(wordAllow)
.ignoreCase(true)
.ignoreWidth(true)
.ignoreNumStyle(true)
.ignoreChineseStyle(true)
.ignoreEnglishStyle(true)
.ignoreRepeat(false)
.enableNumCheck(true)
.enableEmailCheck(true)
.enableUrlCheck(true)
.enableWordCheck(true)
.numCheckLen(1024)
.init();
}
@Bean
public IWordDeny wordDeny(ISensitiveWordDao sensitiveWordDao) {
return new IWordDeny() {
@Override
public List<String> deny() {
return sensitiveWordDao.queryValidSensitiveWordConfig("deny");
}
};
}
@Bean
public IWordAllow wordAllow(ISensitiveWordDao sensitiveWordDao) {
return new IWordAllow() {
@Override
public List<String> allow() {
return sensitiveWordDao.queryValidSensitiveWordConfig("allow");
}
};
}
}
- wordDeny、wordAllow 是两个自定义的拦截和放行的敏感词列表,这里小傅哥设计从数据库中查询。可以方便动态的维护。
3.2 内容安全初始化
# 内容安全
baidu:
aip:
app_id: 46573000
api_key: XKOalQOgDBUrvgLBplvu****
secret_key: kwRh1bEhETYWpq9thzyySdFDPKUk****
- 自定义一个配置文件类 AipContentCensorConfigProperties
@Bean
public AipContentCensor aipContentCensor(AipContentCensorConfigProperties properties) {
AipContentCensor client = new AipContentCensor(properties.getApp_id(), properties.getApi_key(), properties.getSecret_key());
client.setConnectionTimeoutInMillis(2000);
client.setSocketTimeoutInMillis(60000);
return client;
}
- 这里我们来统一创建 AipContentCensor 对象,用于有需要使用的地方处理内容审核。
4. 规则实现
源码: cn.bugstack.xfg.dev.tech.domain.service.IRuleLogicFilter
public interface IRuleLogicFilter {
RuleActionEntity<RuleMatterEntity> filter(RuleMatterEntity ruleMatterEntity);
}
- 定义一个统一的规则过滤接口
4.1 敏感词
@Slf4j
@Component
@LogicStrategy(logicMode = DefaultLogicFactory.LogicModel.SENSITIVE_WORD)
public class SensitiveWordFilter implements IRuleLogicFilter {
@Resource
private SensitiveWordBs words;
@Override
public RuleActionEntity<RuleMatterEntity> filter(RuleMatterEntity ruleMatterEntity) {
// 敏感词过滤
String content = ruleMatterEntity.getContent();
String replace = words.replace(content);
// 返回结果
return RuleActionEntity.<RuleMatterEntity>builder()
.type(LogicCheckTypeVO.SUCCESS)
.data(RuleMatterEntity.builder().content(replace).build())
.build();
}
}
4.2 安全内容
@Slf4j
@Component
@LogicStrategy(logicMode = DefaultLogicFactory.LogicModel.CONTENT_SECURITY)
public class ContentSecurityFilter implements IRuleLogicFilter {
@Resource
private AipContentCensor aipContentCensor;
@Override
public RuleActionEntity<RuleMatterEntity> filter(RuleMatterEntity ruleMatterEntity) {
JSONObject jsonObject = aipContentCensor.textCensorUserDefined(ruleMatterEntity.getContent());
if (!jsonObject.isNull("conclusion") && "不合规".equals(jsonObject.get("conclusion"))) {
return RuleActionEntity.<RuleMatterEntity>builder()
.type(LogicCheckTypeVO.REFUSE)
.data(RuleMatterEntity.builder().content("内容不合规").build())
.build();
}
// 返回结果
return RuleActionEntity.<RuleMatterEntity>builder()
.type(LogicCheckTypeVO.SUCCESS)
.data(ruleMatterEntity)
.build();
}
}
5. 工厂使用
public class DefaultLogicFactory {
public Map<String, IRuleLogicFilter> logicFilterMap = new ConcurrentHashMap<>();
public DefaultLogicFactory(List<IRuleLogicFilter> logicFilters) {
logicFilters.forEach(logic -> {
LogicStrategy strategy = AnnotationUtils.findAnnotation(logic.getClass(), LogicStrategy.class);
if (null != strategy) {
logicFilterMap.put(strategy.logicMode().getCode(), logic);
}
});
}
public RuleActionEntity<RuleMatterEntity> doCheckLogic(RuleMatterEntity ruleMatterEntity, LogicModel... logics) {
RuleActionEntity<RuleMatterEntity> entity = null;
for (LogicModel model : logics) {
entity = logicFilterMap.get(model.code).filter(ruleMatterEntity);
if (!LogicCheckTypeVO.SUCCESS.equals(entity.getType())) return entity;
ruleMatterEntity = entity.getData();
}
return entity != null ? entity :
RuleActionEntity.<RuleMatterEntity>builder()
.type(LogicCheckTypeVO.SUCCESS)
.data(ruleMatterEntity)
.build();
}
}
- 定义出规则的使用工厂,通过构造函数的方式注入已经实现了接口 IRuleLogicFilter 的 N 个规则,注入到 Map 中
Map<String, IRuleLogicFilter> logicFilterMap
- doCheckLogic 根据入参来过滤需要处理的规则。这里可以看到每过滤一个规则都会把参数继续传递给下一个规则继续筛选。
有点像层层过筛子的感觉
四、测试验证
- 测试前确保已经初始化了库表
docs/dev-ops/sql/xfg-dev-tech-content-moderation.sql
application-dev.yml
配置百度内容安全参数和数据库连接参数。
1. 功能测试
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class RuleLogicTest {
@Resource
private DefaultLogicFactory defaultLogicFactory;
@Test
public void test() {
RuleActionEntity<RuleMatterEntity> entity = defaultLogicFactory.doCheckLogic(
RuleMatterEntity.builder().content("小傅哥喜欢烧烤臭毛蛋,豆包爱吃粑粑,如果想吃订购请打电话:13900901878").build(),
DefaultLogicFactory.LogicModel.SENSITIVE_WORD,
DefaultLogicFactory.LogicModel.CONTENT_SECURITY
);
log.info("测试结果:{}", JSON.toJSONString(entity));
}
}
测试结果
24-01-07.14:17:16.988 [main ] INFO BaseClient - get access_token success. current state: STATE_AIP_AUTH_OK
24-01-07.14:17:17.328 [main ] INFO RuleLogicTest - 测试结果:{"data":{"content":"小傅哥喜欢烧烤***,豆包爱吃**,如果想吃订购请打电话:13900901878"},"type":"SUCCESS"}
2. 接口测试
@RequestMapping(value = "sensitive/rule", method = RequestMethod.GET)
public String rule(String content) {
try {
log.info("内容审核开始 content: {}", content);
RuleActionEntity<RuleMatterEntity> entity = defaultLogicFactory.doCheckLogic(RuleMatterEntity.builder().content(content).build(),
DefaultLogicFactory.LogicModel.SENSITIVE_WORD,
DefaultLogicFactory.LogicModel.CONTENT_SECURITY
);
log.info("内容审核完成 content: {}", entity.getData());
return JSON.toJSONString(entity);
} catch (Exception e) {
log.error("内容审核异常 content: {}", content, e);
return "Err!";
}
}
接口:http://localhost:8091/api/v1/content/sensitive/rule?content=小傅哥喜欢烧烤臭毛蛋,豆包爱吃粑粑,如果想吃订购请打电话:13900901878

- 那么现在就可以对内容进行审核过滤了。
六、推荐阅读
来源:juejin.cn/post/7322156683467112499
Android - 你可能需要这样一个日志库
前言
目前大多数库api设计都是Log.d("tag", "msg")
这种风格,而且支持自定义日志存储的比较少,
所以作者想自己造一个轮子。
这种api风格有什么不好呢?
首先,它的tag
是一个字符串,需要开发人员严格管理tag
,要不然可能各种硬编码的tag
满天飞。
另外,它也可能导致性能陷阱,假设有这么一段代码:
// 打印一个List
Log.d("tag", list.joinToString())
此处使用Debug
打印日志,生产模式下调高日志等级,不打印这一行日志,但是list.joinToString()
这一行代码仍然会被执行,有可能导致性能问题。
下文会分析作者期望的api是什么样的,本文演示代码都是用kotlin
,库中好用的api也是基于kotlin
特性来实现的。
作者写库有个习惯,对外开放的类或者全局方法都会加一个前缀f
,一个是为了避免命名冲突,另一个是为了方便代码检索,以下文章中会出现,这里做一下解释。
期望
什么样的api才能解决上面的问题呢?我们看一下方法的签名和打印方式
inline fun <reified T : FLogger> flogD(block: () -> Any)
interface AppLogger : FLogger
flogD<AppLogger> {
list.joinToString { it }
}
flogD
方法打印Debug
日志,传一个Flogger
的子类AppLogger
作为日志标识,同时传一个block
来返回要打印的日志内容。
日志标识是一个类或者接口,所以管理方式比较简单不会造成tag
混乱的问题,默认tag
是日志标识类的短类名。生产模式下调高日志等级后,block
就不会被执行了,避免了可能的性能问题。
实现分析
日志库的完整实现已经写好了,放在这里xlog
- 支持限制日志大小,例如限制每天只能写入10MB的日志
- 支持自定义日志格式
- 支持自定义日志存储,即如何持久化日志
这一节主要分析一下实现过程中遇到的问题。
问题:如果App运行期间日志文件被意外删除了,怎么处理?
在Android中,用java.io
的api对一个文件进行写入,如果文件被删除,继续写入的话不会抛异常,这样会导致日志丢失,该如何解决?
有同学说,在写入之前先检查文件是否存在,如果存在就继续写入,不存在就创建后写入。
检查一个文件是否存在通常是调用java.io.File.exist()
方法,但是它比较耗性能,我们来做一个测试:
measureTime {
repeat(1_0000) {
file.exists()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}
14:50:33.536 MainActivity com.sd.demo.xlog I time:39
14:50:35.872 MainActivity com.sd.demo.xlog I time:54
14:50:38.200 MainActivity com.sd.demo.xlog I time:43
14:50:40.028 MainActivity com.sd.demo.xlog I time:53
14:50:41.693 MainActivity com.sd.demo.xlog I time:58
可以看到1万次调用的耗时在50毫秒左右。
我们再测试一下对文件写入的耗时:
val output = filesDir.resolve("log.txt").outputStream().buffered()
val log = "1".repeat(50).toByteArray()
measureTime {
repeat(1_0000) {
output.write(log)
output.flush()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}
14:57:56.092 MainActivity com.sd.demo.xlog I time:38
14:57:56.558 MainActivity com.sd.demo.xlog I time:57
14:57:57.129 MainActivity com.sd.demo.xlog I time:57
14:57:57.559 MainActivity com.sd.demo.xlog I time:46
14:57:58.054 MainActivity com.sd.demo.xlog I time:54
可以看到1万次调用,每次写入50个字符的耗时也在50毫秒左右。如果每次写入日志前都判断一下文件是否存在,那么实际上相当于2次写入的性能成本,这显然很不划算。
还有同学说,开一个线程,定时判断文件是否存在,这样子虽然不会损耗单次写入的性能,但是又多占用了一个线程资源,显然也不符合作者的需求。
其实Android已经给我们提供了这种场景的解决方案,那就是android.os.MessageQueue.IdleHandler
,关于IdleHandler
这里就不展开讨论了,简单来说就是当你在主线程注册一个IdleHandler
后,它会在主线程空闲的时候被执行。
我们可以在每次写入日志之后注册IdleHandler
,等IdleHandler
被执行的时候检查一下日志文件是否存在,如果不存在就关闭输出流,这样子在下一次写入的时候就会重新创建文件写入了。
这里要注意每次写入日志之后注册IdleHandler
,并不是每次都创建新对象,要判断一下如果原先的对象还未执行的话就不用注册一个新的IdleHandler
,库中大概的代码如下:
private class LogFileChecker(private val block: () -> Unit) {
private var _idleHandler: IdleHandler? = null
fun register(): Boolean {
// 如果当前线程没有Looper则不注册,上层逻辑可以直接检查文件是否存在,因为是非主线程
Looper.myLooper() ?: return false
// 如果已经注册过了,直接返回
_idleHandler?.let { return true }
val idleHandler = IdleHandler {
// 执行block检查任务
libTryRun { block() }
// 重置变量,等待下次注册
_idleHandler = null
false
}
// 保存并注册idleHandler
_idleHandler = idleHandler
Looper.myQueue().addIdleHandler(idleHandler)
return true
}
}
这样子文件被意外删除之后,就可以重新创建写入了,避免丢失大量的日志。
问题:如何检测文件大小是否溢出
库支持对每天的日志大小做限制,例如限制每天最多只能写入10MB,每次写入日志之后都会检查日志大小是否超过限制,通常我们会调用java.io.File.length()
方法获取文件的大小,但是它也比较耗性能,我们来做一个测试:
val file = filesDir.resolve("log.txt").apply {
this.writeText("hello")
}
measureTime {
repeat(1_0000) {
file.length()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}
16:56:04.090 MainActivity com.sd.demo.xlog I time:61
16:56:05.329 MainActivity com.sd.demo.xlog I time:80
16:56:06.382 MainActivity com.sd.demo.xlog I time:72
16:56:07.496 MainActivity com.sd.demo.xlog I time:79
16:56:08.591 MainActivity com.sd.demo.xlog I time:78
可以看到耗时在60毫秒左右,相当于上面测试中1次文件写入的耗时。
库中支持自定义日志存储,在日志存储接口中定义了size()
方法,上层通过此方法来判断当前日志的大小。
如果自定义了日志存储,避免在此方法中每次调用java.io.File.length()
来返回日志大小,应该维护一个表示日志大小的变量,变量初始化的时候获取一下java.io.File.length()
,后续通过写入的数量来增加这个变量的值,并在size()
方法中返回。库中默认的日志存储实现类就是这样实现的,有兴趣的可以看这里
问题:文件大小溢出后怎么处理?
假设限制每天最多只能写入10MB,那超过10MB后如何处理?有同学说直接删掉或者清空文件,重新写入,这也是一种策略,但是会丢失之前的所有日志。
例如白天写了9.9MB,到晚上的时候写满10MB,清空之后,白天的日志都没了,这时候用户反馈白天遇到的一个bug,需要上传日志,那就芭比Q了。
有没有办法少丢失一些呢?可以把日志分多个文件存储,为了便于理解假设分为2个文件存储,一天10MB,那1个文件最多只能写入5MB。具体步骤如下:
- 写入文件
20231128.log
20231128.log
写满5MB的时候关闭输出流,并把它重命名为20231128.log.1
这时候继续写日志的话,发现20231128.log
文件不存在就会创建,又跳到了步骤1,就这样一直重复1和2两个步骤,到晚上写满10MB的时候,至少还有5MB的日志内容保存在20231128.log.1
文件中避免丢失全部的日志。
分的文件数量越多,保留的日志就越多,实际上就是拿出一部分空间当作中转区,满了就向后递增数字重命名备份。目前库中只分为2个文件存储,暂时不开放自定义文件数量。
问题:打印日志的性能
性能,是这个库最关心的问题,通常来说文件写入操作是性能开销的大头,目前是用java.io
相关的api来实现的,怎样提高写入性能作者也一直在探索,在demo中提供了一个基于内存映射的日志存储方案,但是稳定性未经测试,后续测试通过后可能会转正。有兴趣的读者可以看看这里。
还有一个比较影响性能的就是日志的格式化,通常要把一个时间戳转为某个日期格式,大部分人都会用java.text.SimpleDateFormat
来格式化,用它来格式化年:月:日
的时候问题不大,但是如果要格式化时:分:秒.毫秒
那它就比较耗性能,我们来做一个测试:
val format = SimpleDateFormat("HH:mm:ss.SSS")
val millis = System.currentTimeMillis()
measureTime {
repeat(1_0000) {
format.format(millis)
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}
16:05:26.920 MainActivity com.sd.demo.xlog I time:245
16:05:27.586 MainActivity com.sd.demo.xlog I time:227
16:05:28.324 MainActivity com.sd.demo.xlog I time:212
16:05:29.370 MainActivity com.sd.demo.xlog I time:217
16:05:30.157 MainActivity com.sd.demo.xlog I time:193
可以看到1万次格式化耗时大概在200毫秒左右。
我们再用java.util.Calendar
测试一下:
val calendar = Calendar.getInstance()
// 时间戳1
val millis1 = System.currentTimeMillis()
// 时间戳2
val millis2 = millis1 + 1000
// 切换时间戳标志
var flag = true
measureTime {
repeat(1_0000) {
calendar.timeInMillis = if (flag) millis1 else millis2
calendar.run {
"${get(Calendar.HOUR_OF_DAY)}:${get(Calendar.MINUTE)}:${get(Calendar.SECOND)}.${get(Calendar.MILLISECOND)}"
}
flag = !flag
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}
16:11:25.342 MainActivity com.sd.demo.xlog I time:35
16:11:26.209 MainActivity com.sd.demo.xlog I time:35
16:11:27.316 MainActivity com.sd.demo.xlog I time:37
16:11:28.057 MainActivity com.sd.demo.xlog I time:25
16:11:28.825 MainActivity com.sd.demo.xlog I time:18
这里解释一下为什么要用两个时间戳,因为Calendar
内部有缓存,如果用同一个时间戳测试的话,没办法评估它真正的性能,所以这里每次格式化之后就切换到另一个时间戳,避免缓存影响测试。
可以看到1万次的格式化耗时在30毫秒左右,差距很大。如果要自定义日志格式的话,建议用Calendar
来格式化时间,有更好的方案欢迎和作者交流。
问题:日志的格式如何显示
手机的存储资源是宝贵的,如何定义日志格式也是一个比较重要的细节。
- 优化时间显示
目前库内部是以天为单位来命名日志文件的,例如:20231128.log
,所以在格式化时间戳的时候只保留了时:分:秒.毫秒
,避免冗余显示当天的日期。
- 优化日志等级显示
打印的时候提供了4个日志等级:Verbose, Debug, Info, Warning, Error
,一般最常用的记录等级是Info
,所以在格式化的时候如果等级是Info
则不显示等级标志,规则如下:
private fun FLogLevel.displayName(): String {
return when (this) {
FLogLevel.Verbose -> "V"
FLogLevel.Debug -> "D"
FLogLevel.Warning -> "W"
FLogLevel.Error -> "E"
else -> ""
}
}
- 优化日志标识显示
如果连续2条或多条日志都是同一个日志标识,那么就只有第1条日志会显示日志tag
- 优化线程ID显示
如果是主线程的话,不显示线程ID,只有非主线程才显示线程ID
经过上面的优化之后,日志打印的格式是这样的:
flogI<AppLogger> { "1" }
flogI<AppLogger> { "2" }
flogW<AppLogger> { "3" }
flogI<UserLogger> { "user debug" }
thread {
flogI<UserLogger> { "thread" }
}
19:19:43.961[AppLogger] 1
19:19:43.974 2
19:19:43.975[W] 3
19:19:43.976[UserLogger] user debug
19:19:43.977[12578] thread
API
这一节介绍一下库的API,调用FLog.init()
方法初始化,初始化如果不想打印日志,可以调用FLog.setLevel(FLogLevel.Off)
关闭日志
常用方法
// 初始化
FLog.init(
//(必传参数)日志文件目录
directory = filesDir.resolve("app_log"),
//(可选参数)自定义日志格式
formatter = AppLogFormatter(),
//(可选参数)自定义日志存储
storeFactory = AppLogStoreFactory(),
//(可选参数)是否异步发布日志,默认值false
async = false,
)
// 设置日志等级 All, Verbose, Debug, Info, Warning, Error, Off 默认日志等级:All
FLog.setLevel(FLogLevel.All)
// 限制每天日志文件大小(单位MB),小于等于0表示不限制大小,默认限制每天日志大小100MB
FLog.setLimitMBPerDay(100)
// 设置是否打打印控制台日志,默认打开
FLog.setConsoleLogEnabled(true)
/**
* 删除日志,参数saveDays表示要保留的日志天数,小于等于0表示删除全部日志,
* 此处saveDays=1表示保留1天的日志,即保留当天的日志
*/
FLog.deleteLog(1)
打印日志
interface AppLogger : FLogger
flogV<AppLogger> { "Verbose" }
flogD<AppLogger> { "Debug" }
flogI<AppLogger> { "Info" }
flogW<AppLogger> { "Warning" }
flogE<AppLogger> { "Error" }
// 打印控制台日志,不会写入到文件中,不需要指定日志标识,tag:DebugLogger
fDebug { "console debug log" }
配置日志标识
可以通过FLog.config
方法修改某个日志标识的配置信息,例如下面的代码:
FLog.config<AppLogger> {
// 修改日志等级
this.level = FLogLevel.Debug
// 修改tag
this.tag = "AppLoggerAppLogger"
}
自定义日志格式
class AppLogFormatter : FLogFormatter {
override fun format(record: FLogRecord): String {
// 自定义日志格式
return record.msg
}
}
interface FLogRecord {
/** 日志标识 */
val logger: Class<out FLogger>
/** 日志tag */
val tag: String
/** 日志内容 */
val msg: String
/** 日志等级 */
val level: FLogLevel
/** 日志生成的时间戳 */
val millis: Long
/** 日志是否在主线程生成 */
val isMainThread: Boolean
/** 日志生成的线程ID */
val threadID: String
}
自定义日志存储
日志存储是通过FLogStore
接口实现的,每一个FLogStore
对象负责管理一个日志文件。
所以需要提供一个FLogStore.Factory
工厂为每个日志文件提供FLogStore
对象。
class AppLogStoreFactory : FLogStore.Factory {
override fun create(file: File): FLogStore {
return AppLogStore(file)
}
}
class AppLogStore(file: File) : FLogStore {
// 添加日志
override fun append(log: String) {}
// 返回当前日志的大小
override fun size(): Long = 0
// 关闭
override fun close() {}
}
结束
库目前还处于alpha
阶段,如果有遇到问题可以及时反馈给作者,最后感谢大家的阅读。
作者邮箱:565061763@qq.com
来源:juejin.cn/post/7306423214493270050
Flutter 为什么没有一款好用的UI框架?
哈喽,我是老刘
前两天,系统给我推送了一个问题。
我理解提问者真正想问的是:有没有一个不用学习那么多UI组件和渲染知识,可以简单快速搭建UI的东西。
Flutter 包括原生开发,为什么需要考虑那么多细节,不能做的简单一些?
首先,我们需要明白Flutter的定位。
Flutter不是一个简单的甜品,而是一个能支撑大型系统开发的工程级框架。
这种定位和原生框架的定位是相当的。
因此,它要求整个框架有足够的灵活性,能适用于尽可能多的场景。
那么,如何提供足够的灵活性呢?
答案是让整个框架尽可能多的细节是可控的。
这就需要把整个框架的功能拆分的更细,提供的配置项足够多。
然而,这样的缺点就是开发起来会比较麻烦,需要控制很多细节。
因此,我们可以看到Flutter的组件拆分的很细,甚至有类似Padding这样专门负责缩进的组件,而且每个组件都有很多的配置参数。
Flutter配合Material组件库本身本就非常优秀的UI框架
虽然Flutter的灵活性带来了开发上的复杂性,但Flutter配合Material组件库本身就是一个非常优秀的UI框架。
Material组件库提供了丰富的预设组件,这些组件遵循Material Design指南,可以帮助开发者快速搭建出既美观又符合设计规范的UI界面。
使用Material组件库,开发者可以不必从头开始设计每一个UI元素,而是可以直接使用现成的组件,如按钮、对话框、卡片等,这些组件都有良好的交互和动画效果。
此外,Material组件库还提供了主题支持,开发者可以通过简单的配置,快速应用统一的风格到整个应用中。
因此,虽然Flutter的灵活性可能让初学者感到有些复杂,但配合Material组件库,Flutter实际上提供了一个非常高效和优秀的UI开发体验。
大型项目的正确打开方式
即便是Material组件库,它的设计是需要考虑应对各种不同类型app开发的,但是针对一个具体的项目,我们大多数时候不需要这样高的灵活性。
所以,这种情况下直接用Flutter提供的组件效率会比较低。
解放方法就是针对特定的项目做组件封装。
以我目前维护的项目为例,我们项目中所有的对话框都是相同的偏绿色调,圆角半径20,按钮大小固定,标题、详情的字体、字号也固定。
简单来说,就是所有的UI细节都是固定的,只是不同的dialog需要填充的文字不同。
这时候,我们就会定义一个自己的Dialog组件,只需要使用者传入标题和内容,以及设置按钮的回调即可。
UI的其他地方也是如此,比如页面框架、在多个页面都能用到的用户卡片、商品卡片等等。
当你的整个App大部分都是基于这些自定义组件进行搭积木式的开发,那开发效率是不是比找一些通用的UI框架更高呢?
总结
总而言之,Flutter因为它的工程级框架定位需要提供高度的灵活性,而这往往会导致开发细节的复杂性。
但是,通过针对具体项目的组件封装,我们可以大大提高开发效率,同时保持UI的一致性和项目的特定需求。
所以,与其寻找一个通用的UI框架,不如根据项目的具体需求进行自定义组件的开发。
如果看到这里的同学有学习Flutter的兴趣,欢迎联系老刘,我们互相学习。
点击免费领老刘整理的《Flutter开发手册》,覆盖90%应用开发场景。
可以作为Flutter学习的知识地图。
覆盖90%开发场景的《Flutter开发手册》
来源:juejin.cn/post/7387001928209170447
Flutter大型项目架构:分层设计篇
上篇文章讲的是状态管理(传送门)提到了 Flutter BLoC
,相比与原生的 setState()
及Provider
等有哪些优缺点,并结合实际项目写了一个简单的使用,接下来本篇文章来讲 Flutter
大型项目是如何进行分层设计的,费话不多说,直接进入正题哈。
为啥需要分层设计
其实这个没有啥固定答案,也许只是因为某一天看到手里的代码如同屎山一样,如下图,而随着业务功能的增加,不停的往这上面堆,这个屎山也会愈发庞大和混乱,如果这样继续下去,直到某一天因为一个小小的Bug,你需要花半天的时间来排查问题出在哪里,最后当你觉得问题终于改好了的时候,却不料碰了不该碰的地方,结果就是 fixing 1 bug will create 10 new bugs
,甚至程序的崩溃。
随着这种问题的凸显,于是团队里的显眼包A提出了要求团队里的每个人都必须负责完成给自己写的代码添加注释和文档,规范命名等措施,一段时间后,发现代码是规范了,但问题依然存在,这时候才发现如果工程的架构分层没有做好,再规范的代码和注释也只是在屎山上雕花,治标不治本而已。
请原谅我打了一个这么俗的比方,但话糙理不糙,那么啥是应用的分层设计呢?
简单的来说,应用的分层设计是一种将应用程序划分为不同层级的方法,每个层级负责特定的功能或责任。其中表示层(Presentation Layer
)负责用户界面和用户交互,将数据呈现给用户并接收用户输入;业务逻辑层(Business Logic Layer
)处理应用程序的业务逻辑,包括数据验证、处理和转换;数据访问层(Data Access Layer
)负责与数据存储交互,包括数据库或文件系统的读取和写入操作。
这样做有什么好处呢?一句话总结就是为了让代码层级责任清晰,维护、扩展和重用方便,每个模块能独立开发、测试和修改。
App
原生开发的分层设计
说到 iOS
、Android
的分层设计,就会想到如 MVC
、MVVM
等,它们主要是围绕着控制器层(Controller
)、视图层(View
)、和数据层(Model
),还有连接 View
和 Model
之间的模型视图层(ViewModel
)这些来讲的。
然而,MVC
、MVVM
概念还不算完整的分层架构,它们只是关注的 App
分层设计当中的应用层(Applicaiton Layer
)组织方式,对于一个简单规模较小的App来说,可能单单一个应用层就能搞定,不用担心业务增量和复杂度上升对后期开发的压力,而一旦 App
上了规模之后就有点应付不过来了。
当 App
有了一定规模之后,必然会涉及到分层的设计,还有模块化、Hybrid
机制、数据库、跨项目开发等等,拿 iOS
的原生分层设计落地实践来说,通常会将工程拆分成多个Pod
私有库组件,拆分的标准视情况而定,每一个分层组件是独立的开发和测试,再在主工程添加 Pod
私有库依赖来做分层设计开发。
此处应该有 Pod
分层组件化设计的配图,但是太懒了,就没有一个个的去搭建新项目和 Pod
私有库,不过 iOS
原生分层设计不是本篇文章的重点,本篇主要谈论的是 Flutter App
的分层设计。
Flutter
的分层设计
分层架构设计的理念其实是相通的,差别在于语言的特性和具体项目实施上,Flutter
项目也是如此。试想一下,当各种逻辑混合在一次的时候,即便是选择了像 Bloc
这样的状态管理框架来隔离视图层和逻辑实现层,也很难轻松的增强代码的拓展性,这时候选择采用一个干净的分层架构就显得尤为重要,怎样做到这一点呢,就需要将代码分成独立的层,并依赖于抽象而不是具体的实现。
Flutter App
想要实现分层设计,就不得不提到包管理工具,如果在将所有分层组件代码放在主工程里面,那样并不能达到每个组件单独开发、维护和测试的目的,而如果放在新建的 Dart Package
中,没发跨多个组件改代码和测试,无法实现本地包链接和安装。使用 melos 就能解决这个问题,类似于 iOS
包管理工具 Pod
, 而 melos
是 Flutter
项目的包管理工具。
组件包管理工具
- 安装
Melos
,将Melos
安装为全局包,这样整个系统环境都可以使用:
dart pub global activate melos
- 创建
workspace
文件夹,我这里命名为flutter_architecture_design
,添加melos
的配置文件melos.yaml
和pubspec.yaml
,其目录结构大概是这样的:
flutter_architecture_design
├── melos.yaml
├── pubspec.yaml
└── README.md
- 新建组件,以开发工具
Android Studio
为例,选择File
->New
->New Flutter Project
,根据需要创建组件包,需要注意的是组件包存放的位置要放在workspace
目录中。
- 编辑
melos.yaml
配置文件,将上一步新建的组件包名放在packages
之下,添加scripts
相关命令,其目的请看下一步:
name: flutter_architecture_design
packages:
- widgets/**
- shared/**
- data/**
- initializer/**
- domain/**
- resources/**
- app/**
command:
bootstrap:
usePubspecOverrides: true
scripts:
analyze:
run: dart pub global run melos exec --flutter "flutter analyze --no-pub --suppress-analytics"
description: Run analyze.
pub_get:
run: dart pub global run melos exec --flutter "flutter pub get"
description: pub get
build_all:
run: dart pub global run melos exec --depends-on="build_runner" "flutter packages pub run build_runner build --delete-conflicting-outputs"
description: build_runner build all modules.
build_data:
run: dart pub global run melos exec --fail-fast --scope="*data*" --depends-on="build_runner" "flutter packages pub run build_runner build --delete-conflicting-outputs"
description: build_runner build data module.
build_domain:
run: dart pub global run melos exec --fail-fast --scope="*domain*" --depends-on="build_runner" "flutter packages pub run build_runner build --delete-conflicting-outputs"
description: build_runner build domain module.
build_app:
run: dart pub global run melos exec --fail-fast --scope="*app*" --depends-on="build_runner" "flutter packages pub run build_runner build --delete-conflicting-outputs"
description: build_runner build app module.
build_shared:
run: dart pub global run melos exec --fail-fast --scope="*shared*" --depends-on="build_runner" "flutter packages pub run build_runner build --delete-conflicting-outputs"
description: build_runner build shared module.
build_widgets:
run: dart pub global run melos exec --fail-fast --scope="*widgets*" --depends-on="build_runner" "flutter packages pub run build_runner build --delete-conflicting-outputs"
description: build_runner build shared module.
- 打开命令行,切换到
workspace
目录,也就是flutter_architecture_design
目录,执行命令。
melos bootstrap
出现
SUCCESS
之后,现在的目录结构是这样的:
- 点击
Android Studio
的add configuration
,将下图中的Shell Scripts
选中后点击OK
。
以上的
Scripts
添加完后就可以在这里看到了,操作起来也很方便,不需要去命令行那里执行命令。
Flutter
分层设计实践
接下来介绍一下上面创建的几个组件库。
app
:项目的主工程,存放业务逻辑代码、UI
页面和Bloc
,还有styles
、colors
等等。domain
:实体类(entity
)组件包,还有一些接口类,如repository
、usercase
等。data
:数据提供组件包,主要有:api_request
,database
、shared_preference
等,该组件包所有的调用实现都在domain
中接口repository
的实现类repository_impl
中。shared
:工具类组件包,包括:util
、helper
、enum
、constants
、exception
、mixins
等等。resources
:资源类组件包,有intl
、公共的images
等initializer
:模块初始化组件包。widgets
:公共的UI
组件包,如常用的:alert
、button
、toast
、slider
等等。
它们之间的调用关系如下图:
其中 shared
和 resources
作为基础组件包,本身不依赖任何组件,而是给其它组件包提供支持。
作为主工程 App
也不会直接依赖 data
组件包,其调用是通过 domain
组件包中 UseCase
来实现,在 UseCase
会获取数据、处理列表数据的分页、参数校验、异常处理等等,获取数据是通过调用抽象类 repository
中相关函数,而不是直接调用具体实现类,此时App
的 pubspec.yaml
中配置是这样的:
name: app
description: A new Flutter project.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1
environment:
sdk: ">=2.17.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.2
widgets:
path: ../widgets
shared:
path: ../shared
domain:
path: ../domain
resources:
path: ../resources
initializer:
path: ../initializer
dev_dependencies:
flutter_test:
sdk: flutter
flutter:
uses-material-design: true
generate: false
assets:
- assets/images/
提供的数据组件包 data
实现了抽象类 repository
中相关函数,只负责调用 Api 接口获取数据,或者从数据库获取数据。当上层调用的时候不需要关心数据是从哪里来的,全部交给 data
组件包负责。
initializer
作为模块初始化组件包,仅有一个 AppInitializer
类,其主要目的是将其它的模块的初始化收集起来放在 AppInitializer
类中 init()
函数中,然后在主工程入口函数:main()
调用这个 init()
函数,常见的初始化如:GetIt
初始化、数据库 objectbox
初始化、SharedPreferences
初始化,这些相关的初始会分布在各自的组件包中。
class AppInitializer {
AppInitializer();
Future<void> init() async {
await SharedConfig.getInstance().init();
await DataConfig.getInstance().init();
await DomainConfig.getInstance().init();
}
}
widgets
作为公共的 UI 组件库,不处理业务逻辑,在多项目开发时经常会使用到。上图中的 Other Plugin Module
指的的是其它组件包,特别是需要单独开发与原生交互的插件时会用到,
这种分层设计出来的架构或许在开发过程中带来一下不便,如调用一个接口,第一步:需要先在抽象类 repository
写好函数声明;第二步:然后再去Api Service
写具体请求代码,并在repository_impl
实现类中调用;第三步:还需要在 UserCase
去做业务调用,错误处理等;最后一步:在bloc
的event
中调用。这么一趟下来,确实有些繁琐或者说是过度设计。但是如果维度设定在大的项目中多人合作开发的时候,却能规避很多问题,每个分层组件都有自己的职责互不干扰,都支持单独的开发测试,尽可能的做到依赖于抽象而不是具体的实现。
本篇文章就到这里,源码后面这个系列的文章里放出来,感谢您的阅读,也希望您能关注我的公众号 Flutter技术实践,原创不易,您的关注是我更新下去最大的动力。
来源:juejin.cn/post/7350876924393422886
当 App 有了系统权限,真的可以为所欲为?
前一段时间有个 App 很火,是 Android App 利用了 Android 系统漏洞,获得了系统权限,做了很多事情。想看看这些个 App 在利用系统漏洞获取系统权限之后,都干了什么事,于是就有了这篇文章。由于准备仓促,有些 Code 没有仔细看,感兴趣的同学可以自己去研究研究,多多讨论,对应的文章和 Code 链接都在下面:
关于这个 App 是如何获取这个系统权限的,Android 反序列化漏洞攻防史话,这篇文章讲的很清楚,就不再赘述了,我也不是安全方面的专家,但是建议大家多读几遍这篇文章
序列化和反序列化是指将内存数据结构转换为字节流,通过网络传输或者保存到磁盘,然后再将字节流恢复为内存对象的过程。在 Web 安全领域,出现过很多反序列化漏洞,比如 PHP 反序列化、Java 反序列化等。由于在反序列化的过程中触发了非预期的程序逻辑,从而被攻击者用精心构造的字节流触发并利用漏洞从而最终实现任意代码执行等目的。
这篇文章主要来看看 XXX apk 内嵌提权代码,及动态下发 dex 分析 这个库里面提供的 Dex ,看看 App 到底想知道用户的什么信息?总的来说,App 获取系统权限之后,主要做了下面几件事(正常 App 无法或者很难做到的事情),各种不把用户当人了。
- 自启动、关联启动相关的修改,偷偷打开或者默认打开:与手机厂商斗智斗勇。
- 开启通知权限。
- 监听通知内容。
- 获取用户的使用手机的信息,包括安装的 App、使用时长、用户 ID、用户名等。
- 修改系统设置。
- 整一些系统权限的工具方便自己使用。
另外也可以看到,这个 App 对于各个手机厂商的研究还是比较深入的,针对华为、Oppo、Vivo、Xiaomi 等终端厂商都有专门的处理,这个也是值得手机厂商去反向研究和防御的。
最好我还加上了这篇文章在微信公众号发出去之后的用户评论,以及知乎回答的评论区(问题已经被删了,但是我可以看到:如何评价拼多多疑似利用漏洞攻击用户手机,窃取竞争对手软件数据,防止自己被卸载? - Gracker 的回答 - 知乎 http://www.zhihu.com/question/58… 2471 个赞)可以说是脑洞大开(关于 App 如何作恶)。
0. Dex 文件信息
本文所研究的 dex 文件是从 XXX apk 内嵌提权代码,及动态下发 dex 分析 这个仓库获取的,Dex 文件总共有 37 个,不多,也不大,慢慢看。这些文件会通过后台服务器动态下发,然后在 App 启动的时候进行动态加载,可以说是隐蔽的很,然而 Android 毕竟是开源软件,要抓你个 App 的行为还是很简单的,这些 Dex 就是被抓包抓出来的,可以说是人脏货俱全了。
由于是 dex 文件,所以直接使用 github.com/tp7309/TTDe… 这个库的反编译工具打开看即可,比如我配置好之后,直接使用 showjar 这个命令就可以
showjar 95cd95ab4d694ad8bdf49f07e3599fb3.dex
默认是用 jadx 打开,就可以看到反编译之后的内容,我们重点看 Executor 里面的代码逻辑即可
打开后可以看到具体的功能逻辑,可以看到一个 dex 一般只干一件事,那我们重点看这件事的核心实现部分即可
1. 通知监听和通知权限相关
1.1 获取 Xiaomi 手机通知内容
- 文件 : 95cd95ab4d694ad8bdf49f07e3599fb3.dex
- 功能 :获取用户的 Active 通知
- 类名 :com.google.android.sd.biz_dynamic_dex.xm_ntf_info.XMGetNtfInfoExecutor
1. 反射拿到 ServiceManager
一般我们会通过 ServiceManager 的 getService 方法获取系统的 Service,然后进行远程调用
2. 通过 NotificationManagerService 获取通知的详细内容
通过 getService 传入 NotificationManagerService 获取 NotificationManager 之后,就可以调用 getActiveNotifications 这个方法了,然后具体拿到 Notification 的下面几个字段
- 通知的 Title
- 发生通知的 App 的包名
- 通知发送时间
- key
- channelID :the id of the channel this notification posts to.
可能有人不知道这玩意是啥,下面这个图里面就是一个典型的通知
可以看到 getActiveNotifications 这个方法,是 System-only 的,普通的 App 是不能随便读取 Notification 的,但是这个 App 由于有权限,就可以获取
当然微信的防撤回插件使用的一般是另外一种方法,比如辅助服务,这玩意是合规的,但是还是推荐大家能不用就不用,它能帮你防撤回,他就能获取通知的内容,包括你知道的和不知道的
1.2. 打开 Xiaomi 手机上的通知权限(Push)
- 文件 :0fc0e98ac2e54bc29401efaddfc8ad7f.dex
- 功能 :可能有的时候小米用户会把 App 的通知给关掉,App 想知道这个用户是不是把通知关了,如果关了就偷偷打开
- 类名 :com.google.android.sd.biz_dynamic_dex.xm_permission.XMPermissionExecutor
这么看来这个应该还是蛮实用的,你个调皮的用户,我发通知都是为了你好,你怎么忍心把我关掉呢?让我帮你偷偷打开吧
App 调用 NotificationManagerService 的 setNotificationsEnabledForPackage 来设置通知,可以强制打开通知
frameworks/base/services/core/java/com/android/server/notification/NotificationManagerService.java
然后查看 NotificationManagerService 的 setNotificationsEnabledForPackage 这个方法,就是查看用户是不是打开成功了
frameworks/base/services/core/java/com/android/server/notification/NotificationManagerService.java
还有针对 leb 的单独处理~细 !
1.3. 打开 Vivo 机器上的通知权限(Push)
- 文件 :2eb20dc580aaa5186ee4a4ceb2374669.dex
- 功能 :Vivo 用户会把 App 的通知给关掉,这样在 Vivo 手机上 App 就收不到通知了,那不行,得偷偷打开
- 类名 :com.google.android.sd.biz_dynamic_dex.vivo_open_push.VivoOpenPushExecutor
核心和上面那个是一样的,只不过这个是专门针对 vivo 手机的
1.4 打开 Oppo 手机的通知权限
- 文件 :67c9e686004f45158e94002e8e781192.dex
- 类名 :com.google.android.sd.biz_dynamic_dex.oppo_notification_ut.OppoNotificationUTExecutor
没有反编译出来,看大概的逻辑应该是打开 App 在 oppo 手机上的通知权限
1.5 Notification 监听
- 文件 :ab8ed4c3482c42a1b8baef558ee79deb.dex
- 类名 :com.google.android.sd.biz_dynamic_dex.ud_notification_listener.UdNotificationListenerExecutor
这个就有点厉害了,在监听 App 的 Notification 的发送,然后进行统计
这个咱也不是很懂,是时候跟做了多年 SystemUI 和 Launcher 的老婆求助了....@史工
1.6 App Notification 监听
- 文件 :4f260398-e9d1-4390-bbb9-eeb49c07bf3c.dex
- 类名 :com.google.android.sd.biz_dynamic_dex.notification_listener.NotificationListenerExecutor
上面那个是 UdNotificationListenerExecutor , 这个是 NotificationListenerExecutor,UD 是啥?
这个反射调用的 setNotificationListenerAccessGranted 是个 SystemAPI,获得通知的使用权,果然有权限就可以为所欲为
1.7 打开华为手机的通知监听权限
- 文件 :a3937709-b9cc-48fd-8918-163c9cb7c2df.dex
- 类名 :com.google.android.sd.biz_dynamic_dex.hw_notification_listener.HWNotificationListenerExecutor
1.8 打开华为手机通知权限
- 文件 :257682c986ab449ab9e7c8ae7682fa61.dex
- 类名 :com.google.android.sd.biz_dynamic_dex.hw_permission.HwPermissionExecutor
2. Backup 状态
2.1. 鸿蒙 OS 上 App Backup 状态相关,保活用?
- 文件 :6932a923-9f13-4624-bfea-1249ddfd5505.dex
- 功能 :Backup 相关
这个看了半天,应该是专门针对华为手机的,收到 IBackupSessionCallback 回调后,执行 PackageManagerEx.startBackupSession 方法
查了下这个方法的作用,启动备份或恢复会话
2.2. Vivo 手机 Backup 状态相关
- 文件 :8c34f5dc-f04c-40ba-98d4-7aa7c364b65c.dex
- 功能 :Backup 相关
3. 文件相关
3.1 获取华为手机 SLog 和 SharedPreferences 内容
- 文件 : da03be2689cc463f901806b5b417c9f5.dex
- 类名 :com.google.android.sd.biz_dynamic_dex.hw_get_input.HwGetInputExecutor
拿这个干嘛呢?拿去做数据分析?
获取 SharedPreferences
获取 slog
4. 用户数据
4.1 获取用户使用手机的数据
- 文件 : 35604479f8854b5d90bc800e912034fc.dex
- 功能 :看名字就知道是获取用户的使用手机的数据
- 类名 :com.google.android.sd.biz_dynamic_dex.usage_event_all.UsageEventAllExecutor
看核心逻辑是同 usagestates 服务,来获取用户使用手机的数据,难怪我手机安装了什么 App、用了多久这些,其他 App 了如指掌
那么他可以拿到哪些数据呢?应有尽有~,包括但不限于 App 启动、退出、挂起、Service 变化、Configuration 变化、亮灭屏、开关机等,感兴趣的可以看一下:
frameworks/base/core/java/android/app/usage/UsageEvents.java
private static String eventToString(int eventType) {
switch (eventType) {
case Event.NONE:
return "NONE";
case Event.ACTIVITY_PAUSED:
return "ACTIVITY_PAUSED";
case Event.ACTIVITY_RESUMED:
return "ACTIVITY_RESUMED";
case Event.FOREGROUND_SERVICE_START:
return "FOREGROUND_SERVICE_START";
case Event.FOREGROUND_SERVICE_STOP:
return "FOREGROUND_SERVICE_STOP";
case Event.ACTIVITY_STOPPED:
return "ACTIVITY_STOPPED";
case Event.END_OF_DAY:
return "END_OF_DAY";
case Event.ROLLOVER_FOREGROUND_SERVICE:
return "ROLLOVER_FOREGROUND_SERVICE";
case Event.CONTINUE_PREVIOUS_DAY:
return "CONTINUE_PREVIOUS_DAY";
case Event.CONTINUING_FOREGROUND_SERVICE:
return "CONTINUING_FOREGROUND_SERVICE";
case Event.CONFIGURATION_CHANGE:
return "CONFIGURATION_CHANGE";
case Event.SYSTEM_INTERACTION:
return "SYSTEM_INTERACTION";
case Event.USER_INTERACTION:
return "USER_INTERACTION";
case Event.SHORTCUT_INVOCATION:
return "SHORTCUT_INVOCATION";
case Event.CHOOSER_ACTION:
return "CHOOSER_ACTION";
case Event.NOTIFICATION_SEEN:
return "NOTIFICATION_SEEN";
case Event.STANDBY_BUCKET_CHANGED:
return "STANDBY_BUCKET_CHANGED";
case Event.NOTIFICATION_INTERRUPTION:
return "NOTIFICATION_INTERRUPTION";
case Event.SLICE_PINNED:
return "SLICE_PINNED";
case Event.SLICE_PINNED_PRIV:
return "SLICE_PINNED_PRIV";
case Event.SCREEN_INTERACTIVE:
return "SCREEN_INTERACTIVE";
case Event.SCREEN_NON_INTERACTIVE:
return "SCREEN_NON_INTERACTIVE";
case Event.KEYGUARD_SHOWN:
return "KEYGUARD_SHOWN";
case Event.KEYGUARD_HIDDEN:
return "KEYGUARD_HIDDEN";
case Event.DEVICE_SHUTDOWN:
return "DEVICE_SHUTDOWN";
case Event.DEVICE_STARTUP:
return "DEVICE_STARTUP";
case Event.USER_UNLOCKED:
return "USER_UNLOCKED";
case Event.USER_STOPPED:
return "USER_STOPPED";
case Event.LOCUS_ID_SET:
return "LOCUS_ID_SET";
case Event.APP_COMPONENT_USED:
return "APP_COMPONENT_USED";
default:
return "UNKNOWN_TYPE_" + eventType;
}
}
4.2 获取用户使用数据
- 文件:b50477f70bd14479a50e6fa34e18b2a0.dex
- 类名:com.google.android.sd.biz_dynamic_dex.usage_event.UsageEventExecutor
上面那个是 UsageEventAllExecutor,这个是 UsageEventExecutor,主要拿用户使用 App 相关的数据,比如什么时候打开某个 App、什么时候关闭某个 App,6 得很,真毒瘤
4.3 获取用户使用数据
- 文件:1a68d982e02fc22b464693a06f528fac.dex
- 类名:com.google.android.sd.biz_dynamic_dex.app_usage_observer.AppUsageObserver
看样子是注册了 App Usage 的权限,具体 Code 没有出来,不好分析
5. Widget 和 icon 相关
经吃瓜群众提醒,App 可以通过 Widget 伪造一个 icon,用户在长按图标卸载这个 App 的时候,你以为卸载了,其实是把他伪造的这个 Widget 给删除了,真正的 App 还在 (不过我没有遇到过,这么搞真的是脑洞大开,且不把 Android 用户当人)
5.1. Vivo 手机添加 Widget
- 文件:f9b6b139-4516-4ac2-896d-8bc3eb1f2d03.dex
- 类名:com.google.android.sd.biz_dynamic_dex.vivo_widget.VivoAddWidgetExecutor
这个比较好理解,在 Vivo 手机上加个 Widget
5.2 获取 icon 相关的信息
- 文件:da60112a4b2848adba2ac11f412cccc7.dex
- 类名:com.google.android.sd.biz_dynamic_dex.get_icon_info.GetIconInfoExecutor
这个好理解,获取 icon 相关的信息,比如在 Launcher 的哪一行,哪一列,是否在文件夹里面。问题是获取这玩意干嘛???迷
5.3 Oppo 手机添加 Widget
- 文件:75dcc8ea-d0f9-4222-b8dd-2a83444f9cd6.dex
- 类名:com.google.android.sd.biz_dynamic_dex.oppoaddwidget.OppoAddWidgetExecutor
5.4 Xiaomi 手机更新图标?
- 文件:5d372522-b6a4-4c1b-a0b4-8114d342e6c0.dex
- 类名:com.google.android.sd.biz_dynamic_dex.xm_akasha.XmAkashaExecutor
小米手机上的桌面 icon 、shorcut 相关的操作,小米的同学来认领
6. 自启动、关联启动、保活相关
6.1 打开 Oppo 手机自启动
- 文件:e723d560-c2ee-461e-b2a1-96f85b614f2b.dex
- 类名:com.google.android.sd.biz_dynamic_dex.oppo_boot_perm.OppoBootPermExecutor
看下面这一堆就知道是和自启动相关的,看来自启动权限是每个 App 都蛋疼的东西啊
6.2 打开 Vivo 关联启动权限
- 文件:8b56d820-cac2-4ca0-8a3a-1083c5cca7ae.dex
- 类名:com.google.android.sd.biz_dynamic_dex.vivo_association_start.VivoAssociationStartExecutor
直接写了个节点进去
6.3 关闭华为耗电精灵
- 文件:7c6e6702-e461-4315-8631-eee246aeba95.dex
- 类名:com.google.android.sd.biz_dynamic_dex.hw_hide_power_window.HidePowerWindowExecutor
看名字和实现,应该是和华为的耗电精灵有关系,华为的同学可以来看看
6.4 Vivo 机型保活相关
- 文件:7877ec6850344e7aad5fdd57f6abf238.dex
- 类名:com.google.android.sd.biz_dynamic_dex.vivo_get_loc.VivoGetLocExecutor
猜测和保活相关,Vivo 的同学可以来认领一下
7. 安装卸载相关
7.1 Vivo 手机回滚卸载
- 文件:d643e0f9a68342bc8403a69e7ee877a7.dex
- 类名:com.google.android.sd.biz_dynamic_dex.vivo_rollback_uninstall.VivoRollbackUninstallExecutor
这个看上去像是用户卸载 App 之后,回滚到预置的版本,好吧,这个是常规操作
7.2 Vivo 手机 App 卸载
- 文件:be7a2b643d7e8543f49994ffeb0ee0b6.dex
- 类名:com.google.android.sd.biz_dynamic_dex.vivo_official_uninstall.OfficialUntiUninstallV3
7.3 Vivo 手机 App 卸载相关
- 文件:183bb87aa7d744a195741ce524577dd0.dex
- 类名:com.google.android.sd.biz_dynamic_dex.vivo_official_uninstall.VivoOfficialUninstallExecutor
其他
SyncExecutor
- 文件:f4247da0-6274-44eb-859a-b4c35ec0dd71.dex
- 类名:com.google.android.sd.biz_dynamic_dex.sync.SyncExecutor
没看懂是干嘛的,核心应该是 Utils.updateSid ,但是没看到实现的地方
UdParseNotifyMessageExecutor
- 文件:f35735a5cbf445c785237797138d246a.dex
- 类名:com.google.android.sd.biz_dynamic_dex.ud_parse_nmessage.UdParseNotifyMessageExecutor
看名字应该是解析从远端传来的 Notify Message,具体功能未知
TDLogcatExecutor
- 文件
- 8aeb045fad9343acbbd1a26998b6485a.dex
- 2aa151e2cfa04acb8fb96e523807ca6b.dex
- 类名
- com.google.android.sd.biz_dynamic_dex.td.logcat.TDLogcatExecutor
- com.google.android.sd.biz_dynamic_dex.td.logcat.TDLogcatExecutor
没太看懂这个是干嘛的,像是保活又不像,后面有时间了再慢慢分析
QueryLBSInfoExecutor
- 文件:74168acd-14b4-4ff8-842e-f92b794d7abf.dex
- 类名:com.google.android.sd.biz_dynamic_dex.query_lbs_info.QueryLBSInfoExecutor
获取 LBS Info
WriteSettingsExecutor
- 文件:6afc90e406bf46e4a29956aabcdfe004.dex
- 类名:com.google.android.sd.biz_dynamic_dex.write_settings.WriteSettingsExecutor
看名字应该是个工具类,写 Settings 字段的,至于些什么应该是动态下发的
OppoSettingExecutor
- 文件:61517b68-7c09-4021-9aaa-cdebeb9549f2.dex
- 类名:com.google.android.sd.biz_dynamic_dex.opposettingproxy.OppoSettingExecutor
Setting 代理??没看懂干嘛的,Oppo 的同学来认领,难道是另外一种形式的保活?
CheckAsterExecutor
- 文件:561341f5f7976e13efce7491887f1306.dex
- 类名:com.google.android.sd.biz_dynamic_dex.check_aster.CheckAsterExecutor
Check aster ?不是很懂
OppoCommunityIdExecutor
- 文件:538278f3-9f68-4fce-be10-12635b9640b2.dex
- 类名:com.google.android.sd.biz_dynamic_dex.oppo_community_id.OppoCommunityIdExecutor
获取 Oppo 用户的 ID?要这玩意干么?
GetSettingsUsernameExecutor
- 文件:4569a29c-b5a8-4dcf-a3a6-0a2f0bfdd493.dex
- 类名:com.google.android.sd.biz_dynamic_dex.oppo_get_settings_username.GetSettingsUsernameExecutor
获取 Oppo 手机用户的 username,话说你要这个啥用咧?
LogcatExecutor
- 文件:218a37ea-710d-49cb-b872-2a47a1115c69.dex
- 类名:com.google.android.sd.biz_dynamic_dex.logcat.LogcatExecutor
VivoBrowserSettingsExecutor
- 文件:136d4651-df47-41b4-bb80-2ec0ab1bc775.dex
- 类名:com.google.android.sd.biz_dynamic_dex.vivo_browser_settings.VivoBrowserSettingsExecutor
Vivo 浏览器相关的设置,不太懂要干嘛
评论区比文章更精彩
微信公众号评论区
知乎评论区
知乎回答已经被删了,我通过主页可以看到,但是点进去是已经被删了:如何评价拼多多疑似利用漏洞攻击用户手机,窃取竞争对手软件数据,防止自己被卸载? - Gracker 的回答 - 知乎 http://www.zhihu.com/question/58…
iOS 和 Android 哪个更安全?
这里就贴一下安全大佬 sunwear 的评论
关于我 && 博客
- 关于我 , 非常希望和大家一起交流 , 共同进步 .
- 博客内容导航
- 优秀博客文章记录 - Android 性能优化必知必会
一个人可以走的更快 , 一群人可以走的更远
来源:juejin.cn/post/7310474225809784884
错怪react的半年-聊聊keepalive
背景
在半年前的某一天,一个运行一年的项目被人提了建议,希望有一个tabs页面,因为他需要多个页面找东西。
我:滚,自己开浏览器标签页(此处吹牛逼)
项目经理:我审核的时候也需要看别人提交的数据是否正确,我也需要看,很多人提了建议
我:作为一个合格的外包,肯定以项目经理体验为主(狗头保命)
1 React的Keepalive
简单实现React KeepAlive不依赖第三方库(附源码)React KeepAlive 实现,不依赖第三方库,支持 - 掘金
神说要有光《React通关秘籍》
....还有一些没有收藏的keepalive实现
代码不想多赘述,我找了很多资料基本思路讲一下
基本是通过react-router来实现
- 弄一个map来存储{path:<OutLet|useOutlet>}
- 然后根据当前路由path来决定哪些组件需要渲染,不渲染的hidden
- 然后在最外层布局套一层Tabs布局用react的context来传递
出现的问题是:
就是当依赖项是一个公共数据的时候,useEffect会触发,如图片中的searchParams同名的key、存在state内存中的同名key之类的
详情页打开多个,地址栏清一色pkId的情况
使用的是ant design pro v6,也是看到有加配置就可以用Keepalive,大家可以掘金找找,我测试过也是有一样的问题,但是和目前能找到功能差不多了,免去自己封装
看很多react Keepalive的实现目前是还没找到什么方案,也皮厚的找过神光,但是要是知道我是被人忽悠了,绝对不会去打扰大佬
tip:先自己敲,再问,不要让自己陷于蠢逼的尴尬
发现问题后,我去问日常开发Vue的童鞋们,因为我两年没写vue了,我说:你们是怎么实现tabs布局的,Vue的Keepalive是怎么实现只有显示哪个页面,别的组件存储而不运行watch的,然后说了一下我在react开发tabs的时候尴尬。
Vueer:vue不会有这个问题,自带的Keepalive封装的很完美,react没有这个功能吗?
就这样我信了半年,但是这个bug我说,我只能到这种程度了,要是说新项目还可以再useEffect再封装一层自定义的hooks,增加一点心智负担,让别人用封装的来,再useEffect内做判断是否是显示的path之类的。
这边年来反正这个功能做了一个开关,项目经理自己用,别人要是没问也不告诉别人有开发这东西,嘿嘿嘿
之所以又捞起来是别的几个项目也有人问,然后问我能不能迁移给别的项目,那他妈不得王炸,别的项目有的还是umi3没升级的。
2 vue3的Keepalive
先说结论吧:vue的Keepalive也能解决这个问题,纯属胡扯
实在想不到什么好的方案,闲余时间,就用vue写了一个demo,想看看vue是怎么实现的,因为react除了就是hidden,或者超出overflow 然后切换是平移像轮播图一样,实在想不出什么方案能保存原本的数据了。
<template>
home
<ul>
<li v-for="item in router.getRoutes().filter(r=>r.path!=='/')" @click="liHandler(item)">
{{ item.path }}
</li>
</ul>
</template>
<script lang="ts" setup>
import {useRouter} from "vue-router";
const router = useRouter()
const liHandler = (route) => {
router.push({name: route.name, query: {path: route.path}})
}
</script>
简单来个demo的目录结构和代码,代码是会有问题的代码,不用细看...
两年没写vue还是遇到几个坑,先记录一下
2.1 vue3的routerview不能写在keepAlive内
// home.vue
<template>
<!-- <RouterView v-slot="{Component}">-->
<!-- <KeepAlive>-->
<!-- <component :is="Component"/>-->
<!-- </KeepAlive>-->
<!-- </RouterView>-->
<KeepAlive>
<RouterView></RouterView>
</KeepAlive>
</template>
注释掉的是正确的,这里不提一嘴,vue的console.log的体验感很ok,下面列的就是正确的,我还去百度了为啥Keepalive无效,直到截这张图写这文的时候才看到,人家都谢了,哈哈哈哈
2.2 router.push地址不变
主要原因是路由创建的是
createMemoryHistory 这玩意不知道是啥 没用过,我是复制vueRoute官网demo的,一开始没注意,改成createWebHistory就好
import {createRouter, createWebHistory} from "vue-router";
const routes = [
{path: "/", component: () => import("./components/Home.vue")},
{path: "/aaa", component: () => import("./components/Aaa.vue"), name: 'aaa'},
{path: "/bbb", component: () => import("./components/Bbb.vue"), name: 'bbb'},
{path: "/ccc", component: () => import("./components/Ccc.vue"), name: 'ccc'},
{path: "/ddd", component: () => import("./components/Ddd.vue"), name: 'ddd'},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router
2.3 router.push不拼接?传参
router.push(path, query: {path}})
一开始是这么写的,用path+query,这个问题纯粹弱智了,太久没写有点菜,改成name+query就行
2.4 vue Keepalive测试
我先点了去aaa,然后返回首页点ccc,这里可以看到,aaa页面的watch触发了!触发了!触发了!
然后去看了下源码,简约版如下
const KeepAliveImpl = {
name: `KeepAlive`,
// 私有属性 标记 该组件是一个KeepAlive组件
__isKeepAlive: true,
props: {
// 用于匹配需要缓存的组件
include: [String, RegExp, Array],
// 用于匹配不需要缓存的组件
exclude: [String, RegExp, Array],
// 用于设置缓存上线
max: [String, Number]
},
setup(props, { slots }) {
// 省略部分代码...
// 返回一个函数
return () => {
if (!slots.default) {
return null
}
// 省略部分代码...
// 获取子节点
const children = slots.default()
// 获取第一个子节点
const rawVNode = children[0]
// 返回原始Vnode
return rawVNode
}
}
}
这不就是存下来了children,但是有个有意思的是
前面说过,react是通过缓存住组件,然后用hidden来控制展示哪个隐藏哪个,vue这边的dom渲染出来不是。
官网也说了动态组件是会卸载的
3 分析一波
直接用光哥的代码跑起来,这么一比可以看出来dom上的差异
然后把代码的显示改成判断语句渲染,测试一下
测试图:
然后去首页,再回到/bbb,发现数字又变回0了
先来看下React的渲染,通过卡颂大佬的文章,找到了react在render阶段
这是判断是mountd还是update的一个标志,然而我们已经卸载了,这里肯定是null,那么就会去创建组建,仅仅是创建。
而vue因为自身有keepalive,在render阶段,是有对标志为keepalive的做patch的逻辑
所以keepalive的组件不会再走created和mounted,而是直接进行diff进行parch
总结
- 公共参数无论是vue还是react都会被监听到
- react想要实现像vue一样的效果只能等react官方适配
也是有提过啦
来源:juejin.cn/post/7436955628263784475
谈谈HTML5a标签的ping属性用法
前言
今天谈谈a标签ping属性的用法,这个用法可以用来做埋点,及用户上报,关于埋点,我之前有文章写过,利用空白gif图片,实现数据上报,ping的这种方式可以发送post请求给后端,当然也可以通过这个做DDOS攻击,今天详细介绍一下。
Ping的用法
Ping的用法相对比较简单,我们通过举例的方式,为大家介绍:
href="https://www.haorooms.com/" ping="https://www.haorooms.com /nav">点击haorooms博客
当你点击‘点击haorooms博客’的时候,会异步发送一个POST请求到Ping后面指定的地址,Request Body的内容为PING。或许你会问,那
ping="https://www.haorooms.com /nav">点击haorooms博客
这段代码行不行?答案是否定的,和HTML中的a标签一样,HTML5中href这个属性必须存在与a中,不然Ping也是不会运行的。
应用一,埋点上报
我们可以看到 ping 请求的 content-type 是 text/ping,包含了用户的 User-Agent,是否跨域,目标来源地址等信息,非常方便数据收集的时候进行追踪。可以利用这个进行埋点上报,点击上报等。
Ping可以进行广告追踪,它可以统计用户都点击了哪些链接以及次数,并使用POST请求把这些信息发送到广告商的服务器上。那么POST的这些信息都包含了什么呢,简单来说HTTP Header的内容都会有,我们来看一个截获的完整信息
HOST: haorooms.com
CONTENT-LENGTH: 4
ORIGIN: http://mail.163.com
PING-FROM: http://****.com/js6/read/readhtml.jsp?mid=458:xtbBygBMgFO+dvBcvQAAsM&font=15&color=064977
USER-AGENT: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.125 Safari/537.36
PING-TO: http://www.baidu.com/
CONTENT-TYPE: text/ping
ACCEPT: */*
REFERER: http://****.com/js6/read/readhtml.jsp?mid=458:xtbBygBMgFO+dvBcvQAAsM&font=15&color=064977
ACCEPT-ENCODING: gzip, deflate
ACCEPT-LANGUAGE: zh-CN,zh;q=0.8
COOKIE: sessionid=rnbymrrkbkipn7byvdc2hsem5o0vrr13
CACHE-CONTROL: max-age=0
CONNECTION: keep-alive
PING-FROM、USER-AGENT、REFERER这三个关键信息,直接泄漏了用户的隐私(但几个月前,百度已宣布不支持REFERER)。而这也为我们最爱的XSSSHELL又提供了一个小插件。对于图片探针如果没了新鲜感,那么请试试Ping探针吧,简单的一句
href="" ping=>
就搞定!
ping 属性的优势
1、无需 JavaScript 代码参与,网页功能异常也能上报;
2、不受浏览器刷新、跳转过关闭影响,也不会阻塞页面后续行为,这一点和 navigator.sendBeacon()
类似,可以保证数据上报的准确性; 支持跨域;
href="https://www.haorooms.com/" ping="https://www.baidu.com/ad.php">点击我
3、可上报大量数据,因为是 POST 请求;
4、语义明确,使用方便,灵活自主。
ping 属性的劣势
1、只能支持点击行为的上报,如果是进入视区,或弹框显示的上报,需要额外触发下元素的 click() 行为;
2、只能支持 a 元素,在其他元素上设置 ping 属性没有作用,这就限制了其使用范围,因为很多开发喜欢 div 一把梭。
3、只能是 POST 请求,目前主流的数据统计还是日志中的 GET 请求,不能复用现有的基建。
4、出生不好,身为 HTML 属性,天然受某些开发者无视与不屑。
5、适合在移动端项目使用,PC端需要酌情使用(不需要考虑上报总量的情况下),因为目前 IE 和 Firefox
浏览器都不支持(或没有默认开启支持)。
应用二,DDOS攻击
根据Ping发送POST请求这个特性,我们可以使用循环使之不停的向一个地址追加POST请求,造成DOS攻击。
var arr = ['https://www.haorooms1.com', 'https://www.haorooms2.com', 'https://www.haorooms3.com'];
function haoroomsDOS( ){
var indexarr = Math.floor((Math.random( )*arr.length));
document.writeln("");
}
if(arr.length>0){
var htimename = setlnterval("haoroomsDOS()", 1000);
}
防御方法
web服务器可以通过WAF(如:ShareWAF,http://www.sharewaf.com/)等拦截含有“Ping… HTTP headers的请求。
来源:juejin.cn/post/7438964981453094966
HTML 还有啥可学的?这份年终总结帮你梳理
💰 点进来就是赚到知识点!本文带你解读 2024年 HTML 的发展现状,点赞、收藏、评论更能促进消化吸收!
前言
作为前端三驾马车之一的 HTML,其关注度可能不如 CSS 和 JavaScript 那样高。但这绝不是因为它不重要,正相反,作为 Web 生态的基石,HTML 是最早被设计出来构成 Web 页面的基本标准,它简明、稳定,所以非常让开发者省心,绝不是 CSS 和 JavaScript 那种闹人的孩子。
一般来说,30 岁的人就不怎么长高了,那么这项 30 岁的 Web 技术,是否也已经悄悄停止了生长呢?我的答案是:并没有。这不,最近《2024 HTML 年度调查结果报告》新鲜出炉,从从业人群、特性、工具等维度统计了来自全球 5000+ 的问卷结果,汇总出了 2024 年 HTML 的完整面貌。
如果你也不甘落后,想与业界保持同步的技术认知和水平,但又没时间仔细研究完整个维度繁复、类目庞杂的调查报告,那接下来,我会带你直击几个核心类目的 Top 5,让你轻松了解全球开发者最爱用、最关注、最期待的特性和 API。
最常用功能 Top5
上图中列出的功能,是用过人数最多的前 5 个元素。
Landmark
元素:<main>
、<nav>
、<aside>
、<header>
、<footer>
、<section>
这些 HTML5 语义化标签。还记得十年前我入行前端时,「什么是 HTML 语义化」是必考的面试题。tabindex
:控制元素的聚焦交互,是提升用户操作效率的小妙招。- 懒加载:控制图片、视频或 iframe 的加载时机,可以有效节省带宽、提升首屏加载速度。
srcset
:设置多媒体元素的源路径,它的广泛使用代表着 Web 页面内容的多样性。<details>
和<summary>
:原生折叠/展开控件。我得承认我还没直接使用过,看来技术栈要更新了。
最想了解的特性 Top5
如上图所示,这 5 个特性是开发者们在填完问卷后最想要第一时间去学习的。
- 自定义
Select
元素:可自定义内容和样式的下拉菜单,目前包括<selectlist>
和<selectmenu>
。
focusgroup
:让用户能用键盘的方向键来选中聚焦元素,提升操作体验和效率。- Popover API:原生的弹层组件
- EditContext API:控制元素可编辑性
- 自定义高亮:用 CSS 控制文本选中后的样式
Web 组件库 Top5
当被问到用哪些库/框架来搭建 UI 界面时,上图中这 5 种库名列前茅;而大家熟知的 Vue、React 则分别排在了第 12 和第 13。是不是很意外?其实这可能和问题的语境有关系。大型 Web 应用为了方便协作和维护一般用主流框架,但也有些中小工程用一些简洁框架反而更高效。
用 Web 技术开发原生应用时最常用的特性 Top5
这一领域相对小众,样本数量下降了一个量级。但也为我们提供了不一样的视角,看到一些新鲜的 API:
- Web Share API:用于控制分享逻辑。
- File System Access API:用于处理设备本地的文件,增删改查样样行,能力超强,我有一个专栏就是写这个 API 的。
- Launch API:控制 PWA 的启动逻辑。
- FIle Handling API:用于在 PWA 中注册文件类型。
- WIndow Controls Overlay API:PWA 控制自定义内容的显示。
网站生成框架 Top5
这类框架一般用于静态官网、博客等站点的生成。
- Next.js:基于 React,无论是国内外都是应用最广的主流框架。
- Astro:它有一套自己的组件体系,像 Vue 但又有独到之处,很适合搭建博客。
- Nuxt:基于 Vue,对标 Next.js。我在用,一套代码搞定前后端逻辑,非常爽。
- SvelteKit:顾名思义是 Svelte 的配套生态。
- Eleventy:还没用到过,从官网介绍看,是主打小巧简洁。很想玩一玩。
信息来源 Top5
这一类目统计了开发者们日常获取泛 HTML 知识和信息的渠道,从数据可以看到大家主要用的都是上图这几种。
呼声最高的补完计划 Top5
有这么一些组件,是咱们日常开发非常常用,但 HTML 却迟迟没有提供原生支持的:
- 数据表格:指的是自带排序、过滤等常用功能的 table。
- 标签页组件
- Switch/Toggle 开关
- 骨架屏、Loading 组件
- 右键菜单
结语
恭喜你读完本文,你真棒!
这一次我们选取了 7 个核心维度来解读 《2024 HTML 年度调查结果报告》。如果其中有你陌生的技术点,那正好可以查缺补漏。
最后,咱们玩个互动小游戏:
把你的输入法切到中文,再按 H
、T
、M
、L
这四个键,把你最离谱的联想词打在评论区,看看谁最逆天!
我用的是小鹤双拼,所以打出了「混天绫」,笑死,每天都用 HTML,原来我是哪吒。
📣 我是 Jax,在畅游 Web 技术海洋的又一年,我仍然是坚定不移的 JavaScript 迷弟,Web 技术带给我太多乐趣。如果你也和我一样,欢迎关注、私聊!
来源:juejin.cn/post/7439353204054228992
离谱,split方法的设计缺陷居然导致了生产bug!
需求简介
大家好,我是石小石!前几天实现了这样一个需求:
根据后端images字段返回的图片字符,提取图片key查找图片链接并渲染。
由于后端返回的是用逗号分隔的字符,所以获取图片的key使用split方法非常方便。
if(data.images != null || data.images != undefined){
// 将字符通过split方法分割成数组
const picKeyList = data.images.split(",")
picKeyList.forEach(key => {
// 通过图片key查询图片链接
// ...
})
}
乍一看,代码并没有问题,qa同学在测试环境也验证了没有问题!于是,当晚,我们就推送生产了。
生产事故
几天后的一个晚上,我已经睡觉了,突然接到领导的紧急电话,说我开发的页面加载图片后白屏了!来不及穿衣服,我赶紧去排查bug。
通过断点排查,发现当后端返回的
data.images
是空字符“""
”时,用split分割空字符,得到的picKeyList结果是 “[""]
” ,这导致picKeyList遍历时,内部的 key是空,程序执行错误。
然后我用控制台验证了一下split分割空字符,我人傻了。
后来,我也成功的为这次生产事故背锅。我也无可争辩,是我没完全搞懂split方法的作用机制。
ps:宝宝心里苦,为什么后端不直接返回图片的key数组!!为什么!!
split方法
吃一堑,长一智,我决定在复习一下split方法的使用,并梳理它的踩坑点及可能得解决方案。
语法
split()
用于将字符串按照指定分隔符分割成数组
string.split(separator, limit)
separator
(可选):指定分隔符,可以是字符串或正则表达式。如果省略,则返回整个字符串作为数组。limit
(可选):整数,限制返回的数组的最大长度。如果超过限制,多余的部分将被忽略。
基本用法
使用字符串作为分隔符
const text = "苹果,华为,小米";
const result = text.split(",");
console.log(result);
// 输出: ['苹果', '华为', '小米']
使用正则表达式作为分隔符
const text = "苹果,华为,小米";
const result = text.split(/[,; ]+/); // 匹配逗号、分号或空格
console.log(result);
// 输出: ['苹果', '华为', '小米']
使用限制参数
const text = "苹果,华为,小米";
const result = text.split(",", 2);
console.log(result);
// 输出: ['苹果', '华为'] (限制数组长度为 2)
没有找到分隔符
const text = "hello";
const result = text.split(",");
console.log(result);
// 输出: ['hello'] (原字符串直接返回)
split方法常见踩坑点
空字符串的分割
const result = "".split(",");
console.log(result);
// 输出: [''] (非空数组,包含一个空字符串)
原因:
空字符串没有内容,split()
默认返回一个数组,包含原始字符串。
解决方案:
const result = "".split(",").filter(Boolean);
console.log(result);
// 输出: [] (使用 filter 移除空字符串)
多余分隔符
const text = ",,苹果,,华为,,";
const result = text.split(",");
console.log(result);
// 输出: ['', '', '苹果', '', '华为', '', '']
原因:
连续的分隔符会在数组中插入空字符串。
解决方案:
const text = ",,苹果,,华为,,";
const result = text.split(",").filter(Boolean);
console.log(result);
// 输出: ['苹果','华为']
filter(Boolean)
是一个非常常用的技巧,用于过滤掉数组中的假值。
分割 Unicode 字符
const text = "👍😊👨👩👦";
const result = text.split('');
console.log(result);
// 输出: ['👍', '😊', '👨', '', '👩', '', '👦']
原因:
split("")
按字节分割,无法正确识别组合型字符。
解决方案:
const text = "👍😊👨👩👦";
const result = Array.from(text);
console.log(result);
// 输出: ['👍', '😊', '👨👩👦'] (完整分割)
总结
这篇文章通过本人的生产事故,向介绍了split方法使用可能存在的一些容易忽略的bug,希望大家能有所收获。一定要注意split分割空字符会得到一个包含空字符数组的问题!
来源:juejin.cn/post/7439189795614916658
brain.js提升我们前端智能化水平
有时候真的不得不感叹,AI实在是太智能,太强大了。从自动驾驶,家具,AI无处不在。现在我们前端开发领域,AI也成了一种新的趋势,让不少同行压力山大啊。本文我们将探讨AI在前端开发中的应用,以及如何用浏览器端的神经网络库(brain.js)来提升我们前端的智能化水平。
brain.js
开局即重点,我们先来介绍一下bran.js。
brain.js是由Brain团队开发的JavaScript库,专门用于实现神经网络。其源代码可以在Github上,任何人都可以进行查看,提问和贡献代码。
起源
: brain.js 最初是为了让前端开发者能够更容易地接触到机器学习技术而创建的。它的设计目标是提供一个简单易用的接口,同时保持足够的灵活性来满足不同需求。
功能
:
- 实例化神经网络:
<script src="./brain.js"></script>
const net = new brain.recurrent.LSTM();
- 训练模型:可以提供灵活的训练方式,支持多种参数
const data = [
{ input: [0, 0], output: [0] },
{ input: [0, 1], output: [1] },
{ input: [1, 0], output: [1] },
{ input: [1, 1], output: [0] } ];
network.train(data, {
iterations: 2000, // 训练迭代次数
log: true, // 是否打印训练日志
logPeriod: 100 // 日志打印间隔
});
- 进行模型推理
const output = network.run([1, 0]); //输出应该接近1
console.log(output);
训练结束后,用run
方法进行推理
实战
话不多说,直接开始实战,这次我们进行一个任务分类,看看是前端还是后端。
- 首先,我们先导包
可以直接利用npm下载,终端输入npm install brain.js
安装,后面代码是这样
const brain = require('brain.js');
require
函数:require
是 Node.js 中用于导入模块的函数。它会在 node_modules
目录中查找指定的模块,并将其导出的对象或函数加载到当前作用域中。
或者你可以像我一样,到Github仓库下好brain.js文件
<script src="./brain.js"></script>
<script>
标签:<script>
标签用于在 HTML 文件中引入外部 JavaScript 文件。src
属性指定了 JavaScript 文件的路径。
完成第一步后,我们要用jason数组给大模型“喂”一些数据,用于后面的推理
const data = [
{ "input": "自定义表单验证 ", "output": "frontend" }, // 前端任务
{ "input": "实现 WebSocket 进行实时通信", "output": "backend" }, // 后端任务
{ "input": "视差滚动效果 ", "output": "frontend" }, // 前端任务
{ "input": "安全存储用户密码", "output": "backend" }, // 后端任务
{ "input": "创建主题切换器(深色/浅色模式) ", "output": "frontend" }, // 前端任务
{ "input": "高流量负载均衡", "output": "backend" }, // 后端任务
{ "input": "为残疾用户提供的无障碍功能": "frontend" }, // 前端任务
{ "input": "可扩展架构以应对增长的用户基础 ", "output": "backend" } // 后端任务 ];
- 再然后,初始化我们的神经网络:
const network = new brain.recurrent.LSTM();
这里的LSTM是brain.js中提供的一种类,用于创建长短期记忆网络Long Short-Term Memory
。
经过这个操作,我们就拥有一个可训练的和可使用的LSTM模型。
- 开始训练
// 训练模型 network.train(data, {
iterations: 2000, // 训练迭代次数
log: true, // 是否打印训练日志
logPeriod: 100 // 日志打印间隔
});
注意
:训练需要花费一段时间。
- 执行程序
const output = network.run("自定义表单验证");// 前端任务
console.log(output);
此时,我们的神经网络就会开始推理这是个什么任务,在进行一段时间的训练后,就会出现结果
此时,正确输出了,这是个前端frontend
任务。
类似的,我们改成
const output = network.run("高流量负载均衡"); // 后端任务
console.log(output);
经过一段时间的训练后,得到
也得到了正确结果,这是个后端backend
任务。
总结
brain.js凭借其简洁的API设计和强大的功能,为前端开发者提供了一个易于上手的工具,降低了进入AI领域的门槛,促进了前端开发与AI技术的深度融合。本文我们用bran.js进行了一个简单的数据投喂,实现了我们的任务。相信在未来会有更具有创新性的应用案例出现,推动行业发展。
来源:juejin.cn/post/7438655509899444251
用了组合式 (Composition) API 后代码变得更乱了,怎么办?
前言
组合式 (Composition) API
的一大特点是“非常灵活”,但也因为非常灵活,每个开发都有自己的想法。加上项目的持续迭代导致我们的代码变得愈发混乱,最终到达无法维护的地步。本文是我这几年使用组合式API的一些经验总结,希望通过本文让你也能够写出易维护、优雅的组合式API
代码。
加入欧阳的高质量vue源码交流群、欧阳平时写文章参考的多本vue源码电子书
选项式API
vue2的选项式API因为每个选项都有固定的书写位置(比如数据就放在data
里面,方法就放在methods
里面),所以我们只需要将代码放到对应的选项中就行了。
优点是因为已经固定了每个代码的书写位置,所有人写出来的代码风格都差不多。
缺点是当单个组件的逻辑复杂到一定程度时,代码就会显得特别笨重,非常不灵活。
随意的写组合式API
vue3推出了组合式 (Composition) API
,他的主要特点就是非常灵活。解决了选项式API不够灵活的问题。但是灵活也是一把双刃剑,因为每个开发的编码水平不同。所以就出现了有的人使用组合式 (Composition) API写出来的代码非常漂亮和易维护,有的人写的代码确实很混乱和难易维护。
比如一个组件开始的时候还是规规矩矩的写,所有的ref
响应式变量放在一块,所有的方法放在一块,所有的computed
计算属性放在一块。
但是随着项目的不断迭代 ,或者干脆是换了一个人来维护。这时的代码可能就不是最开始那样清晰了,比如新加的代码不管是ref
、computed
还是方法都放到一起去了。如下图:
只有count1
和count2
时,代码看着还挺整齐的。但是随着count3
的代码加入后看着就比较凌乱了,后续如果再加count4
的代码就会更加乱了。
有序的写组合式API
为了解决上面的问题,所以我们约定了一个代码规范。同一种API的代码全部写在一个地方,比如所有的props
放在一块、所有的emits
放在一块、所有的computed
放在一块。并且这些模块的代码都按照约定的顺序去写,如下图:
随着vue组件的代码增加,上面的方案又有新的问题了。
还是前面的那个例子比如有5个count
的ref
变量,对应的computed
和methods
也有5个。此时我们的vue组件代码量就很多了,比如此时我想看看computed1
和increment1
的逻辑是怎么样的。
因为computed1
和increment1
函数分别在文件的computed
和methods
的代码块处,computed1
和increment1
之间隔了几十行代码,看完computed1
的代码再跳转去看increment1
的代码就很痛苦。如下图:
这时有小伙伴会说,抽成hooks
呗。这里有5个count
,那么就抽5个hooks
文件。像这样的代码。如下图:
一般来说抽取出来的hooks
都是用来多个组件进行逻辑共享,但是我们这里抽取出来的useCount
文件明显只有这个vue组件会用他。达不到逻辑共享的目的,所以单独将这些逻辑抽取成名为useCount
的hooks
文件又有点不合适。
最终解决方案
我们不如将前面的方案进行融合一下,抽取出多个useCount
函数放在当前vue组件内,而不是抽成单个hooks
文件。并且在多个useCount
函数中我们还是按照前面约定的规范,按照顺序去写ref
变量、computed
、函数的代码。
最终得出的最佳实践如下图:
上面这种写法有几个优势:
- 我们将每个
count
的逻辑都抽取成单独的useCount
函数,并且这些函数都在当前vue文件中,没有将其抽取成hooks
文件。如果哪天useCount1
中的逻辑需要给其他组件使用,我们只需要新建一个useCount
文件,然后直接将useCount1
函数的代码移到新建的文件中就可以了。 - 如果我们想查看
doubleCount1
和increment1
中的逻辑,只需要找到useCount1
函数,关于count1
相关的逻辑都在这个函数里面,无需像之前那样翻山越岭跨越几十行代码才能从doubleCount1
的代码跳转到increment1
的代码。
总结
本文介绍了使用Composition API
的最佳实践,规则如下:
- 首先约定了一个代码规范,
Composition API
按照约定的顺序进行书写(书写顺序可以按照公司代码规范适当调整)。并且同一种组合式API的代码全部写在一个地方,比如所有的props
放在一块、所有的emits
放在一块、所有的computed
放在一块。 - 如果逻辑能够多个组件复用就抽取成单独的
hooks
文件。 - 如果逻辑不能给多个组件复用,就将逻辑抽取成
useXXX
函数,将useXXX
函数的代码还是放到当前组件中。
第一个好处是如果某天
useXXX
函数中的逻辑需要给其他组件复用,我们只需要将useXXX
函数的代码移到新建的hooks
文件中即可。
第二个好处是我们想查看某个业务逻辑的代码,只需要在对应的
useXXX
函数中去找即可。无需在整个vue文件中翻山越岭从computed
模块的代码跳转到function
函数的代码。
最后推荐一下欧阳自己写的开源电子书vue3编译原理揭秘,看完这本书可以让你对vue编译的认知有质的提升,并且这本书初、中级前端能看懂。完全免费,只求一个star。
来源:juejin.cn/post/7398046513811095592
为什么同事的前端代码我改不动了?
《如何写出高质量的前端代码》学习笔记
在日常开发中,我们经常会遇到需要修改同事代码的情况。有时可能会花费很长时间却只改动了几行代码,而且改完后还可能引发新的bug。我们聊聊导致代码难以维护的常见原因,以及相应的解决方案。
常见问题及解决方案
1. 单文件代码过长
问题描述:
- 单个文件动辄几千行代码
- 包含大量DOM结构、JS逻辑和样式
- 需要花费大量时间才能理解代码结构
解决方案: 将大文件拆分成多个小模块,每个模块负责独立的功能。
以一个品牌官网为例,可以这样拆分:
<template>
<div>
<Header/>
<main>
<Banner/>
<AboutUs/>
<Services/>
<ContactUs/>
</main>
<Footer/>
</div>
</template>
2. 模块耦合严重
问题描述:
- 模块之间相互依赖
- 修改一处可能影响多处
- 难以进行单元测试
❌ 错误示例:
<script>
export default {
methods: {
getUserDetail() {
// 错误示范:多处耦合
let userId = this.$store.state.userInfo.id
|| window.currentUserId
|| this.$route.params.userId;
getUser(userId).then(res => {
// 直接操作子组件内部数据
this.$refs.userBaseInfo.data = res.baseInfo;
this.$refs.userArticles.data = res.articles;
})
}
}
}
</script>
✅ 正确示例:
<template>
<div>
<userBaseInfo :base-info="baseInfo"/>
<userArticles :articles="articles"/>
</div>
</template>
<script>
export default {
props: ['userId'],
data() {
return {
baseInfo: {},
articles: []
}
},
methods: {
getUserDetail() {
getUser(this.userId).then(res => {
this.baseInfo = res.baseInfo;
this.articles = res.articles;
})
}
}
}
</script>
3. 职责不单一
问题描述:
- 一个方法承担了多个功能
- 代码逻辑混杂在一起
- 难以复用和维护
❌ 错误示例:
<script>
export default {
methods: {
getUserData() {
userService.getUserList().then(res => {
this.userData = res.data;
// 一个方法中做了太多事情
let vipCount = 0;
let activeVipsCount = 0;
let activeUsersCount = 0;
this.userData.forEach(user => {
if(user.type === 'vip') {
vipCount++
}
if(dayjs(user.loginTime).isAfter(dayjs().subtract(30, 'day'))) {
if(user.type === 'vip') {
activeVipsCount++
}
activeUsersCount++
}
})
this.vipCount = vipCount;
this.activeVipsCount = activeVipsCount;
this.activeUsersCount = activeUsersCount;
})
}
}
}
</script>
✅ 正确示例:
<script>
export default {
computed: {
// 将不同统计逻辑拆分为独立的计算属性
activeUsers() {
return this.userData.filter(user =>
dayjs(user.loginTime).isAfter(dayjs().subtract(30, 'day'))
)
},
vipCount() {
return this.userData.filter(user => user.type === 'vip').length
},
activeVipsCount() {
return this.activeUsers.filter(user => user.type === 'vip').length
},
activeUsersCount() {
return this.activeUsers.length
}
},
methods: {
getUserData() {
// 方法只负责获取数据
userService.getUserList().then(res => {
this.userData = res.data;
})
}
}
}
</script>
4. 代码复制代替复用
问题描述:
- 发现相似功能就直接复制代码
- 维护时需要修改多处相同的代码
- 容易遗漏修改点,造成bug
解决方案:
- 提前抽取公共代码
- 将重复逻辑封装成独立函数或组件
- 通过参数来处理细微差异
5. 强行复用/假装复用
问题描述:
将不该复用的代码强行糅合在一起,比如:
- 将登录弹窗和修改密码弹窗合并成一个组件
- 把一个实体的所有操作(增删改查)都塞进一个方法
❌ 错误示例:
<template>
<div>
<UserManagerDialog ref="UserManagerDialog"/>
</div>
</template>
<script>
export default {
methods: {
addUser() {
this.$refs.UserManagerDialog.showDialog({
type: 'add'
})
},
editName() {
this.$refs.UserManagerDialog.showDialog({
type: 'editName'
})
},
deleteUser() {
this.$refs.UserManagerDialog.showDialog({
type: 'delete'
})
}
}
}
</script>
✅ 正确做法:
- 不同业务逻辑使用独立组件
- 只抽取真正可复用的部分(如表单验证规则、公共UI组件等)
- 保持每个组件职责单一
6. 破坏数据一致性
问题描述: 使用多个关联状态来维护同一份数据,容易造成数据不一致。
❌ 错误示例:
<script>
export default {
data() {
return {
sourceData: [], // 原始数据
tableData: [], // 过滤后的数据
name: '', // 查询条件
type: ''
}
},
methods: {
nameChange(name) {
this.name = name;
// 手动维护 tableData,容易遗漏
this.tableData = this.sourceData.filter(item =>
(!this.name || item.name === this.name) &&
(!this.type || item.type === this.type)
);
},
typeChange(type) {
this.type = type;
// 重复的过滤逻辑
this.tableData = this.sourceData.filter(item =>
(!this.name || item.name === this.name) &&
(!this.type || item.type === this.type)
);
}
}
}
</script>
✅ 正确示例:
<script>
export default {
data() {
return {
sourceData: [],
name: '',
type: ''
}
},
computed: {
// 使用计算属性自动维护派生数据
tableData() {
return this.sourceData.filter(item =>
(!this.name || item.name === this.name) &&
(!this.type || item.type === this.type)
)
}
}
}
</script>
7. 解决方案不“正统”
问题描述:
使用不常见或不合理的方案解决问题,如:
- 直接修改 node_modules 中的代码,更好的实践:
- 优先使用框架/语言原生解决方案
- 遵循最佳实践和设计模式
- 进行方案评审和代码审查
- 对于第三方库的 bug:
- 向作者提交 issue 或 PR
- 将修改后的包发布到企业内部仓库
- 寻找替代方案
- 使用 JS 实现纯 CSS 可实现的效果
❌ 错误示例:
// 不恰当的鼠标悬停效果实现
element.onmouseover = function() {
this.style.color = 'red';
}
element.onmouseout = function() {
this.style.color = 'black';
}
✅ 正确示例:
/* 使用 CSS hover 伪类 */
.element:hover {
color: red;
}
- 过度使用全局变量
如何进行代码重构
重构的原则
- 不改变软件功能
- 小步快跑,逐步改进
- 边改边测试
- 随时可以暂停
重构示例
以下展示如何一步步重构上面的统计代码:
第一步:抽取 vipCount
- 删除data中的vipCount
- 增加计算属性vipCount,将getUserData中关于vipCount的逻辑挪到这里
- 删除getUserData中vipCount的计算逻辑
<script>
export default {
computed: {
vipCount() {
return this.userData.filter(user => user.type === 'vip').length
}
},
methods: {
getUserData() {
userService.getUserList().then(res => {
this.userData = res.data;
let activeVipsCount = 0;
let activeUsersCount = 0;
this.userData.forEach(user => {
if(dayjs(user.loginTime).isAfter(dayjs().subtract(30, 'day'))) {
if(user.type === 'vip') {
activeVipsCount++
}
activeUsersCount++
}
})
this.activeVipsCount = activeVipsCount;
this.activeUsersCount = activeUsersCount;
})
}
}
}
</script>
完成本次更改后,测试下各项数据是否正常,不正常查找原因,正常我们继续。
第二步:抽取 activeVipsCount
- 删除data中的activeVipsCount
- 增加计算属性activeVipsCount,将getUserData中activeVipsCount的计算逻辑迁移过来
- 删除getUserData中关于activeVipsCount计算的代码
<script>
export default {
computed: {
vipCount() {
return this.userData.filter(user => user.type === 'vip').length
},
activeVipsCount() {
return this.userData.filter(user =>
user.type === 'vip' &&
dayjs(user.loginTime).isAfter(dayjs().subtract(30, 'day'))
).length
}
},
methods: {
getUserData() {
userService.getUserList().then(res => {
this.userData = res.data;
let activeUsersCount = 0;
this.userData.forEach(user => {
if(dayjs(user.loginTime).isAfter(dayjs().subtract(30, 'day'))) {
activeUsersCount++
}
})
this.activeUsersCount = activeUsersCount;
})
}
}
}
</script>
...
最终版本:
<script>
export default {
computed: {
activeUsers() {
return this.userData.filter(user =>
dayjs(user.loginTime).isAfter(dayjs().subtract(30, 'day'))
)
},
vipCount() {
return this.userData.filter(user => user.type === 'vip').length
},
activeVipsCount() {
return this.activeUsers.filter(user => user.type === 'vip').length
},
activeUsersCount() {
return this.activeUsers.length
}
},
methods: {
getUserData() {
userService.getUserList().then(res => {
this.userData = res.data;
})
}
}
}
</script>
总结
要写出易维护的代码,需要注意:
- 合理拆分模块,避免单文件过大
- 降低模块间耦合
- 保持职责单一
- 使用计算属性处理派生数据
- 定期进行代码重构
记住:重构是一个渐进的过程,不要试图一次性完成所有改进。在保证功能正常的前提下,通过小步快跑的方式逐步优化代码质量。
来源:juejin.cn/post/7438647460219961395
Android逆向之某影音app去广告
前言
本文介绍通过抓包的方式,分析出某影音app的去广告逆向点,难度极低,适合新手上路。
所谓逆向,三分逆,七分猜。
分析过程
首先打开app,可以看到不时有广告弹出。我们的目标就是去除这些广告。

首先想到的思路是定位到加载广告的代码删掉即可,使用 MT 管理器查看安装包的 dex 文件,可以看到大量 a、b、c 的目录,可见代码被混淆过的,直接上手分析太费劲了。

接着猜测,既然 app 能动态加载各种广告,必然会发起 http 网络请求,只需要分析出哪些请求是和广告相关的,将其拦截,即可实现去广告的目的。
所以接下来尝试抓包分析一下。
抓 http 请求推荐使用 Burp Suite,使用社区版即可。
打开 Burp Suite,切换到 Proxy 页。Proxy 即创建一个代理服务器,配置所有的网络请求连接到这个代理服务器,就可以看到所有经过代理服务器的 http 请求,并且能拦截修改请求、丢弃请求。
打开 Proxy settings,编辑默认的代理服务器地址配置。

端口号我这里填写 8888,地址选择当前机器的 ip 地址,与 ipconfig 命令显示的 ip 保持一致。
确定后选择导出 DER 格式的证书。
任意取名,文件扩展名为 .cer。
由于抓包需要电脑与手机在同一网络环境下, 因此建议使用安卓模拟器。
将 cer 文件导入到安卓模拟器中,之后打开设置 - 安全 - 加密与凭据 - 从SD卡安装(不同安卓会有所不同)。
选择 cer 文件后,随意命名,凭据用途选择 WLAN。确定后安装成功。
编辑当前连接的 wifi,设置代理为手动,主机名和端口填我们在 Burp Suite 中填写的内容。
打开 Burp Suite 打开代理拦截。
此时重新打开app,可以看到 Burp Suite 成功拦截了一条网络请求,并且app卡在启动页上。
此时点击 Drop (丢弃该请求),该请求会重发又被拦截,全部 Drop 掉。
此时惊喜的发现,进入了app首页,并且没有任何广告弹窗了。
由此可见,启动app时首先会加载 json 配置,根据配置去加载广告,只要将这条请求去掉就可以达到去广告的目的。只需要到app中反编译搜索拦截到的请求 url ,即可定位到拉取广告的代码。
搜索 sjmconfig,即可定位到目标代码。
将域名修改为 localhost,那么这条请求将永远不会成功。
之后保存修改、签名,重新安装,完事收工。
来源:juejin.cn/post/7343139490901737482
31 岁,从雄心壮志到回归平凡
一、前言
不久前,有位 31 岁的兄弟找我聊天,他结束了在深圳漂泊不定的生涯,回老家尝试自媒体创业,最终以失败告终,无奈又重回深圳找工作。
二、为何做出这个决定
他的这一决定并非一时冲动,其导火索可追溯至 2022 年。当时的大环境可谓一片混乱,人人都在拼命内卷,下班时间越来越晚,几乎与月亮同步。
每逢放假回老家,亲戚朋友便会念叨:“你在深圳 996 累得不成样子,究竟图个啥?工资是高些,可这日子有啥乐趣?再者,你在深圳漂泊不定,何时才是尽头?房子买不了,户口落不下,迟早得打道回府。”
这位兄弟听后,心中满是酸楚,思量着:多年来为人当牛做马,还遭受 PUA,还得时刻担忧 35 岁被辞退,这日子实在过不下去了!于是,心中犹如埋下了一颗炸弹:我得去另一番天地闯荡!
实际上,这位老兄本就向往自由,那句“打工是不可能打工的,这辈子都不会打工”如同他的精神支柱,一直渴望有朝一日不再打工。
所以在 2022 年底,他毅然决定:2023 年,我要去新的天地看看!拿完年终奖就离开!去创业,开启全新人生!
在准备辞职之前,还有几件糟心事,让他坚定了辞职的决心:
当时尚未完全放开,在家线上办公。在公司上班起码是 995,晚上 9 到 10 点下班基本能休息。可在家就惨了,各种电话、视频会议如轰炸机般袭来,十一二点还得开会,生活与工作完全混乱。每次听到会议呼叫,他的心就像被针扎,默默祈求别出岔子,都快被逼出精神病了。
感染新冠时,他只敢请一天假,第二天晕乎乎地继续工作。为何?他心里明白,落下的工作最终还得自己加班完成,否则会被领导骂得狗血淋头。
周末也不得安宁,需随时在线,群消息要秒回,线上问题得立刻解决,不然就会被贴上“工作态度差”的标签,绩效惨不忍睹。
三、他辞职了
终于熬到 2023 年,年终奖一到手,这位兄弟便决定离职。
当时身边有人如唐僧般劝他别辞职,说当下大环境糟糕,千万别放弃本行去搞自媒体。
这兄弟心中充满不屑,暗想:程序员最多干到 35 岁,如今内卷严重,加班致使身体近乎垮掉,这行年老无出路!
当然,这位兄弟并非头脑发热就辞职搞自媒体,辞职前做了充分准备,调研分析了许久:
作为互联网人,搞实体店一窍不通,只能在互联网领域筛选。互联网项目繁多:个人工具站、知乎好物、闲鱼二手书、小红书带货、抖音带货、抖音个人 IP、公众号写作、短剧 CPS、小说推文、知识付费等等,看得他眼花缭乱。最终发现抖音流量最大,从事与抖音相关的或许更容易些。
而且这位老兄还学习了众多创业知识,如寻找对标、分析商业模式,参加了不少知识付费的圈子,还报名了小红书和抖音的培训班,前前后后花费了一万多元,心痛不已。
四、想象很美好
为了这次创业,这位兄弟制定了一系列计划,涵盖年度、季度、月度、周度,甚至每日的计划都规划得极其详尽。
他还发誓要变得极度自律,每天按时起床,锻炼、学习、开展业务,决心坚如磐石,此次抱着必胜的信念放手一搏!
当然,他也提前预料到可能遭遇的风险,并绞尽脑汁思考应对之策:
例如项目进展缓慢如何处理,拖延症发作怎样解决,家人反对如何应对,朋友约饭打乱计划怎么办,遇到难题又该怎样解决等等。
这一系列举措下来,他认为万事俱备,只待大干一场!
五、现实很残酷
辞职后,他依照计划开始早睡早起,锻炼、学习,忙于创业之事。
然而,未曾料到,很快就被现实狠狠打击,这是他创业路上遇到的首个难题,也是他始料未及的。
就在刚创业不久,他竟然患上了焦虑症,还伴有严重的失眠。
他万万没想到会被失眠困扰。原本以为,摆脱上班的压力,工作时间自由安排,无人 PUA,还有时间锻炼,应当能睡个安稳觉。
但实际情况并非如此,对于我们这种从小被学校管,长大后被公司管的普通人而言,创业竟是这般模样:
他忙得晕头转向,比上班更累,因为以前只需做好本职工作,如今所有事情都需亲力亲为。以做自媒体为例,从账号定位、内容选题、写脚本,到置景、拍摄、后期剪辑,再到选品、商务对接、客服,最后到用户社群运营,所有环节都得独自承担。视频没流量、违规、付费转化率低等问题,都需自己琢磨解决。以前在公司,按要求完成任务即可.
面对大量的自由时间,他全然不知如何安排,诸多环节都是陌生领域,需要学习的太多,每天看似忙碌,却不见成果,怎能不感到沮丧?以前只从事熟悉工作,产出有保障.
与社会脱节,缺乏存在感和归属感(这是人类的基本需求之一),不属于任何群体,无人夸赞、尊重、接纳,甚至想被责骂都无人理会。以前在公司,表现好会得到称赞,表现不佳会获得建议,至少有人可供倾诉、交流、求助。
没有收入,眼睁睁看着钱包逐渐干瘪,怎能不焦虑?更焦虑的是,不知未来何时能盈利。更更焦虑的是,不知最终能否盈利。以前工作再累,至少有工资,有生活保障。
所以在此奉劝那些有裸辞创业想法的人,切勿裸辞!裸辞创业可谓九死一生!正确的做法应是一边工作一边开展副业,待副业收入与工资相当甚至超过工资时,再辞职。有人或许会说,工作繁忙,哪有时间搞副业。他曾经也这么想,但现在他告诉你:没时间就挤出时间,每天晚睡或早起一会儿,周末也抽出时间。这点问题都无法解决?创业遇到的难题可比这困难十倍!若觉得这都难以做到,那还是老老实实打工吧。
可他已经裸辞,别无他法,只能硬着头皮解决问题。他开始服用助眠药,喝中药,情况稍有改善,但未完全康复,只是比之前稍好一些。
就这样拖着疲惫的身躯,他坚持了半年多,一半时间学习,一半时间实践,创建了两个自媒体号,第一个号因违规被封,第二个号流量也毫无起色。这条创业之路越走越艰难,每天晚上都不愿入睡,因为害怕明天的到来,因为一睁眼,眼前仍是一片黑暗。
最终,在创业的巨大压力、8 个月没有收入的恐慌、焦虑失眠心悸的折磨下,他选择了放弃。
失败了,败得一塌糊涂。回顾这次经历,仿佛之前在一艘航行的货轮上工作,实在无法忍受船上的种种压迫,鼓足勇气,带着一艘救生艇跳海,追求向往的自由。结果高估了自身当时的能力,难以抵御大海的狂风巨浪,翻船了……差点就葬身大海……
六、重新找工作
放弃后的几周,他开始熬夜,暴饮暴食,之前的运动也停止了。整天在家拉上窗帘,除了吃饭就是躺在床上刷手机,试图分散注意力,减轻内心的痛苦。
但这样下去并非长久之计,如今肯定不想再触及创业,只能先找份工作。
刚开始找工作时,他心有不甘,因为三线城市与深圳相比,无论是工作机会、环境,还是薪资,都相差甚远。
但无奈,他感觉自己如同即将溺水之人,急需一根救命稻草,先生存下来再说,这是当前的首要任务。
于是在网上大量投递简历,结果惨不忍睹,几乎没有几家公司招聘,前后折腾了一个月,真正靠谱的面试仅有一家,没错,仅有一家。
好在这家公司他顺利拿到了 offer,是一家刚创业的小公司,仅有十几个人,薪资仅有原来的一半多些,不过拿到 offer 的那一刻他还是有些激动,感觉自己又活过来了,不管怎样,能缓口气了。
七、迷茫的未来
如今上班已一个多月,公司还不错,不加班,基本 7 点前大家就都下班了,离家很近,骑共享单车 10 分钟左右就能到。这一个月,焦虑消失了,心悸不再,失眠也好了。每天按部就班地上班下班,完成老板交代的任务,其他事情无需操心,又做起了熟悉且擅长的工作。
回望过去,人生的旅途上,翻过一座山,或许迎接我们的仍是连绵不绝的山峦,海的尽头似乎总是遥不可及,甚至可能一生都无法亲眼目睹。此刻的我们,更愿意将心态放平,珍惜眼前,脚踏实地地前行,每一步都充满探索与希望。
三十一岁,一个不再轻易言弃的年纪,我学会了与自己和解,正视并接纳自己的平凡。
来源:juejin.cn/post/7395962823502299170
HTML到PDF转换,11K Star 的pdfmake.js轻松应对
在Web开发中,将HTML页面转换为PDF文件是一项常见的需求。无论是生成报告、发票、还是其他任何需要打印或以PDF格式分发的文档,开发者都需要一个既简单又可靠的解决方案。幸运的是,pdfmake.js
库以其轻量级、高性能和易用性,成为了许多开发者的首选。本文将介绍如何使用这个拥有11K Star的GitHub项目来实现HTML到PDF的转换。
什么是pdfmake.js
pdfmake.js
是一个基于JavaScript的库,用于在客户端和服务器端生成PDF文档。它允许开发者使用HTML和CSS来设计PDF文档的布局和样式,使得创建复杂的PDF文档变得异常简单。
为什么选择pdfmake.js
pdfmake.js
的文件大小仅为11KB(压缩后),这使得它成为Web应用中一个非常轻量级的解决方案- 拥有超过11K Star的GitHub项目,
pdfmake.js
得到了广泛的社区支持和认可,稳定性和可靠性值得信任 - 功能丰富,它支持表格、列表、图片、样式、页眉页脚等多种元素,几乎可以满足所有PDF文档的需求。
pdfmake.js
可以轻松集成到任何现有的Web应用中,无论是使用Node.js、Angular、React还是Vue.js。
快速开始
安装
通过npm安装pdfmake.js
非常简单:
npm install pdfmake
或者,如果你使用yarn:
yarn add pdfmake
创建PDF文档
创建一个PDF文档只需要几个简单的步骤:
- 引入pdfmake.js
import pdfMake from 'pdfmake/build/pdfmake';
//引入中文字体,避免转换的PDF中文乱码
pdfMake.fonts = {
AlibabaPuHuiTi: {
normal: 'https://xx/AlibabaPuHuiTi-3-55-Regular.ttf',
bold: 'https://xxx/AlibabaPuHuiTi-3-65-Medium.ttf',
italics: 'https://xxx/AlibabaPuHuiTi-3-55-Regular.ttf',
bolditalics: 'https://xxx/AlibabaPuHuiTi-3-65-Medium.ttf'
}
};
- 定义文档内容
const dd = {
content: [
'Hello, 我是程序员凌览',
{ text: 'This is a simple PDF document.', fontSize: 12 },
{ text: 'It is generated using pdfmake.js.', bold: true }
],
//设置默认字体
defaultStyle: {
font: 'AlibabaPuHuiTi'
},
};
- 创建PDF
const pdf = pdfMake.createPdf(dd);
pdf.getBlob((buffer) => {
const file = new File([blob], filename, { type: blob.type })
//上传服务器
});
//或直接下载
pdf.download('文件名.pdf')
生成的pdf效果:
想动手体验,请访问pdfmake.org/playground.…。
html-to-pdfmake 强强联合
当PDF文档内容非固定,content字段内的结构要随时可变,不能再像下方代码块一样写死,html-to-pdfmake
即为解决这类问题而产生的。
const dd = {
content: [
'Hello, 我是程序员凌览',
{ text: 'This is a simple PDF document.', fontSize: 12 },
{ text: 'It is generated using pdfmake.js.', bold: true }
],
//设置默认字体
defaultStyle: {
font: 'AlibabaPuHuiTi'
},
};
安装
通过npm安装:
npm install html-to-pdfmake
或者,如果你使用yarn:
yarn add html-to-pdfmake
HTML字符串转pdfmake格式
- 引入
html-to-pdfmake
import pdfMake from 'pdfmake/build/pdfmake';
import htmlToPdfmake from 'html-to-pdfmake';
//引入中文字体,避免转换的PDF中文乱码
pdfMake.fonts = {
AlibabaPuHuiTi: {
normal: 'https://xx/AlibabaPuHuiTi-3-55-Regular.ttf',
bold: 'https://xxx/AlibabaPuHuiTi-3-65-Medium.ttf',
italics: 'https://xxx/AlibabaPuHuiTi-3-55-Regular.ttf',
bolditalics: 'https://xxx/AlibabaPuHuiTi-3-65-Medium.ttf'
}
};
//它会返回pdfmake需要的数据结构
const html = htmlToPdfmake(`
<div>
<h1>程序员凌览</h1>
<p>
This is a sentence with a <strong>bold word</strong>, <em>one in italic</em>,
and <u>one with underline</u>. And finally <a href="https://www.somewhere.com">a link</a>.
</p>
</div>
`);
- 使用
html-to-pdfmake
转换的数据结构
const dd = {
content:html.content,
//设置默认字体
defaultStyle: {
font: 'AlibabaPuHuiTi'
},
};
const pdf = pdfMake.createPdf(dd);
pdf.download()
生成的pdf效果:
添加图片要额外设置:
const ret = htmlToPdfmake(`<img src="https://picsum.photos/seed/picsum/200">`, {
imagesByReference:true
});
// 'ret' contains:
// {
// "content":[
// [
// {
// "nodeName":"IMG",
// "image":"img_ref_0",
// "style":["html-img"]
// }
// ]
// ],
// "images":{
// "img_ref_0":"https://picsum.photos/seed/picsum/200"
// }
// }
const dd = {
content:ret.content,
images:ret.images
}
pdfMake.createPdf(dd).download();
最后
通过上述步骤,我们可以看到pdfmake.js
及其配套工具html-to-pdfmake
为Web开发者提供了一个强大而灵活的工具,以满足各种PDF文档生成的需求。无论是静态内容还是动态生成的内容,这个组合都能提供简洁而高效的解决方案。
程序员凌览的技术网站linglan01.cn/;关注公粽号【程序员凌览】回复"1",获取编程电子书
来源:juejin.cn/post/7376894518330359843
Dart 脚本:flutter 应用一键打包+发布
引言
近期整理技术栈,将陆续总结分享一些项目实战中用到的实用工具。本篇分享开发流程中必不可缺的一个环节,提测。
闲聊一下
当然,对于打包,我们可以直接使用命令行直接打包例如:
flutter build apk --verbose
只是,相比输入命令行,我更倾向于一键操作
,更倾向写一个打包脚本,我可以在脚本里编辑个性化操作,例如:瘦身、修改产物(apk、ipa)名称、指定打包成功后事务等等。
比如在项目里新建一个文件夹,如 script
, 当需要打包发布时,右键 Run 就 Ok 了。
下面,小编整理了基础款 dart 脚本,用于 打包
和 上传蒲公英
。有需要的同学可自行添加个性化处理。
Android 打包脚本
该脚本用于 apk 打包,apk 以当前时间戳名称,打包成功后可选直接打开文件夹或发布蒲公英。
import 'dart:io';
import 'package:intl/intl.dart';
import 'package:yaml/yaml.dart' as yaml;
import 'pgy_tool.dart'; //蒲公英发布脚本,下面会给出
void main(List<String> args) async {
//是否上传蒲公英
bool uploadPGY = true;
// 获取项目根目录
final _projectPath = await Process.run(
'pwd',
[],
);
final projectPath = (_projectPath.stdout as String).replaceAll(
'\n',
'',
);
// 控制台打印项目目录
stdout.write('项目目录:$projectPath 开始编译\n');
final process = await Process.start(
'flutter',
[
'build',
'apk',
'--verbose',
],
workingDirectory: projectPath,
mode: ProcessStartMode.inheritStdio,
);
final buildResult = await process.exitCode;
if (buildResult != 0) {
stdout.write('打包失败,请查看日志');
return;
}
process.kill();
//开始重命名
final file = File('$projectPath/pubspec.yaml');
final fileContent = file.readAsStringSync();
final yamlMap = yaml.loadYaml(fileContent) as yaml.YamlMap;
//获取当前版本号
final version = (yamlMap['version'].toString()).replaceAll(
'+',
'_',
);
final appName = yamlMap['name'].toString();
// apk 的输出目录
final apkDirectory = '$projectPath/build/app/outputs/flutter-apk/';
const buildAppName = 'app-release.apk';
final timeStr = DateFormat('yyyyMMddHHmm').format(
DateTime.now(),
);
final resultNameList = [
appName,
version,
timeStr,
].where((element) => element.isNotEmpty).toList();
final resultAppName = '${resultNameList.join('_')}.apk';
final appPath = apkDirectory + resultAppName;
//重命名apk文件
final apkFile = File(apkDirectory + buildAppName);
await apkFile.rename(appPath);
stdout.write('apk 打包成功 >>>>> $appPath \n');
if (uploadPGY) {
// 上传蒲公英
final pgyPublisher = PGYTool(
apiKey: '蒲公英控制台内你的应用的apiKey',
buildType: 'android',
);
final uploadSuccess = await pgyPublisher.publish(appPath);
if (uploadSuccess) {
File(appPath).delete();
}
} else {
// 直接打开文件
await Process.run(
'open',
[apkDirectory],
);
}
}
Ipa 打包脚本
ipa 打包脚本和 apk 打包脚本类似,只是过程中多了一步操作,删除之前的构建文件,如下:
import 'dart:io';
import 'package:yaml/yaml.dart' as yaml;
import 'package:intl/intl.dart';
import 'pgy_tool.dart';
void main() async {
const originIpaName = '你的应用名称';
//是否上传蒲公英
bool uploadPGY = true;
// 获取项目根目录
final _projectPath = await Process.run(
'pwd',
[],
);
final projectPath = (_projectPath.stdout as String).replaceAll(
'\n',
'',
);
// 控制台打印项目目录
stdout.write('项目目录:$projectPath 开始编译\n');
// 编译目录
final buildPath = '$projectPath/build/ios';
// 切换到项目目录
Directory.current = projectPath;
// 删除之前的构建文件
if (Directory(buildPath).existsSync()) {
Directory(buildPath).deleteSync(
recursive: true,
);
}
final process = await Process.start(
'flutter',
[
'build',
'ipa',
'--target=$projectPath/lib/main.dart',
'--verbose',
],
workingDirectory: projectPath,
mode: ProcessStartMode.inheritStdio,
);
final buildResult = await process.exitCode;
if (buildResult != 0) {
stdout.write('ipa 编译失败,请查看日志');
return;
}
process.kill();
stdout.write('ipa 编译成功!\n');
//开始重命名
final file = File('$projectPath/pubspec.yaml');
final fileContent = file.readAsStringSync();
final yamlMap = yaml.loadYaml(fileContent) as yaml.YamlMap;
//获取当前版本号
final version = (yamlMap['version'].toString()).replaceAll(
'+',
'_',
);
final appName = yamlMap['name'].toString();
// ipa 的输出目录
final ipaDirectory = '$projectPath/build/ios/ipa/';
const buildAppName = '$originIpaName.ipa';
final timeStr = DateFormat('yyyyMMddHHmm').format(
DateTime.now(),
);
final resultNameList = [
appName,
version,
timeStr,
].where((element) => element.isNotEmpty).toList();
final resultAppName = '${resultNameList.join('_')}.ipa';
final appPath = ipaDirectory + resultAppName;
//重命名ipa文件
final ipaFile = File(ipaDirectory + buildAppName);
await ipaFile.rename(appPath);
stdout.write('ipa 打包成功 >>>>> $appPath \n');
if (uploadPGY) {
// 上传蒲公英
final pgyPublisher = PGYTool(
apiKey: '蒲公英控制台内你的应用的apiKey',
buildType: 'ios',
);
pgyPublisher.publish(appPath);
} else {
// 直接打开文件
await Process.run(
'open',
[ipaDirectory],
);
}
}
蒲公英发布脚本
上面打包脚本中上传到蒲公英都调用了这句代码:
// 上传蒲公英
final pgyPublisher = PGYTool(
apiKey: '蒲公英控制台内你的应用的apiKey',
buildType: 'ios',
);
pgyPublisher.publish(appPath);
appPath
:就是打包成功的 apk/ipa 本地路径buildType
:分别对应两个值 android、iosapiKey
:蒲公英控制台内你的应用对应的apiKey,如下所示
PGYTool 对应的发布脚本如下:
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:rxdart/rxdart.dart';
// 蒲公英工具类
class PGYTool {
final getTokenPath = 'https://www.pgyer.com/apiv2/app/getCOSToken';
final getAppInfoPath = 'https://www.pgyer.com/apiv2/app/buildInfo';
final String apiKey;
final String buildType; //android、ios
PGYTool({
required this.apiKey,
required this.buildType,
});
//发布应用
Future<bool> publish(String appFilePath) async {
final dio = new Dio();
stdout.write('开始获取蒲公英token');
final tokenResponse = await _getToken(dio);
if (tokenResponse == null) {
stdout.write('>>>>>> 获取token失败 \n');
return false;
}
stdout.write('>>>>>> 获取token成功 \n');
final endpoint = tokenResponse['data']['endpoint'] ?? '';
final params = tokenResponse['data']['params'] ?? {};
stdout.write('蒲公英上传地址:$endpoint\n');
Map<String, dynamic> map = {
...params,
};
map['file'] = await MultipartFile.fromFile(appFilePath);
final controller = StreamController<MapEntry<int, int>>();
controller.stream
.throttleTime(const Duration(seconds: 1), trailing: true)
.listen(
(event) => stdout.write(
'${event.key}/${event.value} ${(event.key.toDouble() / event.value.toDouble() * 100).toStringAsFixed(2)}% \n',
),
onDone: () {
controller.close();
},
onError: (e) {
controller.close();
},
);
final uploadRsp = await dio.post(
endpoint,
data: FormData.fromMap(map),
onSendProgress: (count, total) {
controller.sink.add(
MapEntry<int, int>(
count,
total,
),
);
},
);
await Future.delayed(const Duration(seconds: 1));
if (uploadRsp.statusCode != 204) {
stdout.write('>>>>> 蒲公英上传失败 \n');
return false;
}
stdout.write('>>>>> 蒲公英上传成功 \n');
await Future.delayed(const Duration(seconds: 3));
await _getAppInfo(dio, tokenResponse['data']['key']);
return true;
}
// 获取蒲公英token
Future<Map<String, dynamic>?> _getToken(Dio dio) async {
Response<Map<String, dynamic>>? tokenResponse;
try {
tokenResponse = await dio.post<Map<String, dynamic>>(
getTokenPath,
queryParameters: {
'_api_key': apiKey,
'buildType': buildType,
},
);
} catch (_) {
stdout.write('_getToken error : $_');
}
if (tokenResponse == null) return null;
final responseJson = tokenResponse.data ?? {};
final tokenCode = responseJson['code'] ?? 100;
if (tokenCode != 0) {
return null;
} else {
return responseJson;
}
}
// tokenKey 是获取token中的返回值Key
Future<void> _getAppInfo(Dio dio, String tokenKey, {int retryCount = 3}) async {
final response = await dio.get<Map<String, dynamic>>(
getAppInfoPath,
queryParameters: {
'_api_key': apiKey,
'buildKey': tokenKey,
},
).then((value) {
return value.data ?? {};
});
final responseCode = response['code'];
if (responseCode == 1247 && retryCount > 0) {
//应用正在发布中,间隔 3 秒重新获取
stdout.write('>>>>> 应用正在发布中,间隔 3 秒重新获取发布信息\n');
await Future.delayed(const Duration(seconds: 3));
return _getAppInfo(dio, tokenKey, retryCount: retryCount - 1);
}
final appName = response['data']['buildName'];
final appVersion = response['data']['buildVersion'];
final appUrl = response['data']['buildShortcutUrl'];
final updateTime = response['data']['buildUpdated'];
if (appName != null) {
stdout.write('$appName 版本更新($appVersion)\n');
stdout.write('下载地址:https://www.pgyer.com/$appUrl\n');
stdout.write('更新时间:$updateTime\n');
}
}
}
运行发布脚本后,控制台会将应用的上传成功后的下载地址打印出来。
来源:juejin.cn/post/7304538454875586587
在微信小程序里运行完整的 Flutter,我们是怎么做到的?
背景
小程序是一种全新的业务形态,特别是微信小程序,既结合了 Web 动态化特性,又拥有 Native 丰富的设备能力支持。
在微信这个宿主上,小程序不仅有稳定的分发渠道,更拥有完善的生命周期、数据、AI 能力支持。
在该微信上开发小程序,一般使用以下两种方法:
- JavaScript + WXML + WCSS
- Taro + React + JavaScript
本文要介绍的是使用 Flutter Framework 开发小程序的方法,以及该方法背后的技术原理。
技术挑战
尽管 Flutter 官方已经提供 Flutter Web 实现,Flutter Web 本身就是基于 dart2js 运行的,微信小程序可以运行 JavaScript,在原理上跑起 Flutter Web 是没有问题的。
但仍然存在以下技术挑战:
- 微信小程序没有 W3C 标准的 JavaScript 对象,Flutter Web 不能直接运行。
- 微信小程序也没有 DOM 实现,Flutter Web HTML Renderer 不能直接渲染。
- 微信小程序对包大小的限制十分严格,主包不能超过 2M,而 Flutter Web 所编译的 main.dart.js 初始体积就有 1.3 M,必须有合理的分包机制才能上传。
我们在 MPFlutter 1.x 版本中,针对上述问题已有一定的探索,1.x 版本的解决方法如下:
- 使用微信开源的 kbone 库,模拟 W3C 实现,并通过模拟的 DOM 对象渲染出符合 WXML 要求的视图树。
- 通过 Shadow Element Tree 的方式,使用 JSON 在 Dart 与 JavaScript 上下文同步视图树。
- Fork Flutter Framework,并对其进行外科手术式的裁剪,使 main.dart.js 初始体积降低到 600K。
MPFlutter 1.x 方案已经良好的运行了两年,也收到了开发者非常多的反馈,开发者常诟病于裁剪后的 Flutter Framework 不兼容 Flutter 生态上的插件,同时 material 库也无法使用,需要从头开始编写 UI。
在 MPFlutter 2.0 版本,我们重新思考在小程序上运行 Flutter 的最佳方式,并在最终使用 CanvasKit Renderer 解决以上全部问题。
技术方案
Summary
通过裁剪 Skia 生成符合微信小程序分包要求的 CanvasKit,使用 Flutter Web + W3C BOM + WebGL Canvas 跑通渲染流程。
技术选型
在介绍技术选型前,需要先介绍 Flutter Web 的两种 Renderer。
HTML Renderer
原理是 Flutter Framework 通过 dart:js 库调用 Document 对象,并基于此将各种 RenderObject 转换为对应的 Element + CSS 添加到 DOM 树中。
该方案优点在于兼容性很好,几乎没有额外的依赖;缺点是性能不佳,并且渲染内容一致性难以与 Native Flutter 对齐。
CanvasKit Renderer
原理是通过 WebGL + Skia 渲染界面,该渲染方式与 Native Flutter 是完全一致的。
该方案优点在于渲染性能非常好,一致性与 Native Flutter 几乎没有差别;缺点是内存占用大,且需要从远端加载字体。
MPFlutter 2.0 选型
我们在 1.x 版本中用的是 HTML Renderer,通过 kbone 运行的 DOM 模拟层存在很多的问题,最令人诟病的是数据更新后界面刷新慢。当然问题的并不在于 kbone,而是 MPFlutter 1.x 本身对于 Element Tree 的序列化、反序列化的处理存在天然的缺陷,尽管已经通过 Dirty 和 Diff 等手段优化。
在 2.x 版本中,我们直接抛弃 HTML Renderer 的想法,使用 CanvasKit Renderer。
使用 CanvasKit Renderer 有这几个大前提:
- 微信小程序已支持 WebAssembly 并支持 Brotli 压缩;
- 微信小程序 Canvas 的性能相比最初的版本有质的提升,并支持 WebGL;
- 微信小程序全部分包限制放宽到 20M,足够使用。
Skia 裁剪
Skia 是 Google 开源的 2D 渲染库,凭借良好的跨设备能力,优秀的性能表现,在 Google 多个产品中被使用,包括 Chrome / Flutter / Android / Fuchsia 都有 Skia 的身影。
Skia 屏蔽了不同设备、平台的具体实现,对外统一以标准的 RenderObject、RenderCommand 开放。
Skia 其中一个 Render Target 是 WebGL,也就是 CanvasKit。
然而 Flutter Web 默认使用的 CanvasKit 足有 6M 之大,即使使用 Brotli 压缩后仍然不符合小程序分包要求。
我们可以通过指定编译选项的方式裁剪 CanvasKit 尺寸,以下是 MPFlutter 使用的 build 配置:
./modules/canvaskit/compile.sh release no_skottie no_sksl_trace no_alias_font no_effects_deserialization no_encode_jpeg no_encode_png no_encode_webp legacy_draw_vertices no_embedded_font no_woff2
从配置可见,我们去掉了 skottie、image encoder、内置字体等不必要的功能,这些功能我们可以使用微信小程序 API 补充回来。
Brotli 压缩后的 wasm 文件刚好符合 2M 分包要求。
CanvasKit 加载
Skia 构建完成后,会得到两个产物,canvaskit.wasm
和 canvaskit.js
。
canvaskit.js
暴露了 wasm 中的各个 c++ 方法调用,同时也提供加载 wasm 的脚手架。
但是 canvaskit.js
的实现默认是 Web 的,我们需要将其中的 fetch
以及 WebAssembly
替换为微信小程序对应的实现。
这里提供一个使用 Skia 绘制红色矩形的微信小程序工程,有兴趣的同学可以下载到本地研究。
mpflutter.feishu.cn/wiki/LWhrw3…
Flutter Web 在微信中运行
要使 Flutter Web 在微信中运行,最大难点在于 Flutter Web 要求的 Web API 如何补充完整。
特别是 Document 、Window、Navigator 这些类,这些类我已经在 GitHub 上开源了,感兴趣的可以逐个文件阅读。
这里举一个 window 的文件节选段落讲解:
export class FlutterMiniProgramMockWindow {
// screens
get devicePixelRatio() {
return wxSystemInfo.pixelRatio;
}
get innerWidth() {
return wxSystemInfo.windowWidth;
}
get innerHeight() {
return wxSystemInfo.windowHeight;
}
// webs
navigator = {
appVersion: "",
platform: "",
userAgent: "",
vendor: "",
language: "zh",
};
// 还有更多。。。
}
Flutter Web 在运行过程中,会通过 window.innerWidth
/ window.innerHeight
获取当前窗口宽高,以便下一步创建合适大小的画布用于渲染。
在微信小程序中,我们需要使用 wx.getSystemInfoSync()
获取对应宽高,并在 MockWindow 中返回给 Flutter。
关于 BOM 的文件,就不详细展开,都是一些胶水代码。
而 Flutter 的 main.dart.js 也需要有一些改造才可以跑在小程序上,主要的改造是通过 export main.dart.js 中的 main 函数,使其适配 CommonJS 可暴露给 Page 调用。
字体的加载
CanvasKit 最大的问题在于字体加载,目前来看是无法复用系统本身的字体的。
我们的做法是通过裁剪 NotoSansSC 字体,只包含常用的 9000+ 汉字,内置于小程序包中优先加载它。
这样有一个好处,小程序不需要强制从 gstatic 下载字体,省流省加载时间。
后续,我们还会研究通过 Canvas 2D 的方式,从本地加载字体。
分包
关于分包,其实是最好做的,因为 Flutter Web 本身就有 defered load 编译能力。
开发者可以轻松地将 main.dart.js 切分成若干个 JS 文件,我们做的就是在 Flutter Web 编译完成后,智能地将这些 JS 文件分配到不同的分包就好了。
资源分包也同理,资源通过 brotli 压缩也可以减少包体积。
总结
整整一套下来,Flutter 已经可以在微信小程序里跑起来了,我们来总结一下做了什么?
我们通过裁剪 Skia 使得 CanvasKit 可以很好地跑在小程序上,通过 BOM 兼容的方法,使得 Flutter Web 可以在微信小程序中找到对应实现,通过字体内置、智能分包的方式很好地解决了微信包体积限制。
该方案目前已经完全跑通,并已可用,同学们可以在 v2.mpflutter.com 文档站了解到更多用法。
如果对方案有任何疑问,也欢迎添加微信交流,感谢大家的关注。
来源:juejin.cn/post/7324923422295670834
轻量桌面应用新星:Electrico,能否颠覆Electron的地位?
在桌面应用开发的世界里,Electron曾经是一位风云人物。它让开发者可以用熟悉的Web技术构建跨平台应用,但它的重量级体积和系统资源的高消耗一直让人头疼。现在,一个新工具悄然登场,试图解决这些问题——Electrico,一个轻量版的桌面应用开发框架。
10MB取代数百MB,你不心动?
你有没有想过,是否能用更轻量的方式开发出与Electron相同功能的桌面应用?毕竟,虽然Electron确实强大,但它那几百MB的安装包和资源消耗对许多小型项目来说太过头了。如果你对这些问题感到无奈,Electrico或许是你一直在等待的解决方案。它的安装包仅仅10MB左右,去掉了庞大的Node.js和Chromium,但依然能给你带来熟悉的开发体验。
什么是Electrico?
Electrico是一个基于Rust的轻量化桌面应用开发框架,完全省去了Node.js和Chrome内核的依赖。Rust编写的Wry库替代了Electron的核心,利用系统自带的WebView组件,保持跨平台兼容性。同时,Electrico还能与操作系统直接交互,提升了运行效率。未来可期的好处是 API 完全贴近 electron,这可能对原 electron 开发者会比较友好。
这一切听起来可能有点像技术术语,但如果你想象一下:Electron是一个庞大的精装房,而Electrico则是一间简单却功能齐全的小公寓。虽然面积小,但该有的功能一点也不少。
三大亮点:为什么Electrico值得关注?
- 1. 极致轻量化:从几百MB到10MB的飞跃 Electron的打包体积问题一直是开发者头疼的地方,尤其是当你只需要开发一个简单的工具时,最终却要交付一个几百MB的安装包。而Electrico的体积仅10MB左右,这样极致的轻量化使得它尤其适合资源有限的应用场景,如内部工具或简单的桌面应用。
- 2. 性能提升:用Rust打造高效体验 Rust作为新兴的系统编程语言,因其安全性和性能闻名。Electrico选择了Rust作为核心,这不仅使得应用更加高效,还让内存管理更加安全。尤其是在需要高性能、低延迟的场景下,Electrico展现了其独特的优势。与Electron依赖的V8引擎和Chromium相比,Electrico能够更直接地与系统交互,减少了许多不必要的资源消耗。
- 1. 兼容性好:熟悉的开发体验 开发者的最大顾虑之一,通常是新工具是否需要重新学习。而Electrico则保留了许多Electron的API设计,比如窗口管理和文件系统访问等。这意味着,习惯Electron的开发者几乎不需要额外学习,就能快速上手。同时,Electrico支持现代浏览器的开发者工具,前后端的调试体验也非常流畅。
实际开发中的表现
为了帮助开发者更快上手,Electrico提供了一个开源示例项目,让你可以直接体验它的运行效果。这个项目采用了Codex,一个轻量级的笔记应用。通过简单的配置和打包,你可以将Codex运行在Electrico上,而最终生成的应用包体积比起Electron版本要小得多。虽然目前Electrico只实现了部分Electron API,但它已经足够应对大多数日常应用场景。
比如,如果你开发的是一个简单的笔记工具、待办事项管理应用,或是一个内部的管理面板,Electrico都能帮你快速构建出符合需求的桌面应用。没有繁琐的依赖管理,也没有巨大的安装包拖慢你的用户体验。
对比Electron:未来的发展趋势
不得不承认,Electron凭借其强大的生态和广泛的支持,依然在桌面应用开发领域占有重要地位。尤其是对于那些需要集成大量第三方库、复杂业务逻辑的应用,Electron仍然是首选。但Electrico的出现,标志着开发者可以在不同场景下有更多选择。
对于那些不需要复杂依赖、注重性能和体积的小型应用,Electrico无疑是一个更现代、更轻便的选择。它展示了桌面应用开发的新趋势——极致轻量化和性能至上,正是未来开发工具追求的方向。
绝对值得一试的新选择
如果你正在寻找一种比Electron更轻量、更高效的解决方案,Electrico无疑值得一试。特别是当你对现有工具的体积和性能表现不满时,Electrico能够带来焕然一新的体验。最重要的是,它的学习成本几乎为零,你可以很快将现有的Electron项目迁移到Electrico上,享受同样的开发便利,却不再担心过大的应用包和资源消耗。
试想一下,你的下一个桌面应用项目,是否可以用更轻、更快、更高效的Electrico来实现?
来源:juejin.cn/post/7415663559310606363
中年码农,裸转AI,是条死路!
有粉丝向我请教这个问题,我觉得有点普遍性,所以我写篇文章。
具体写我的观点之前,为了求生欲,我先说明一下:下面的是我是一家之言,有可能不正确还有偏见,您要是不同意,您也可以留言发表您的看法。
d但是请理性讨论,不要情绪化骂人发泄,不要凡是不同意您观点的人通通都要被喷。感谢。
现在AI火了,就有粉丝问我,自己在某个领域积累了很久,但是呢,以后可能都是做AI的了,那对方可以不可以放弃掉之前领域的积累,全身心投入到AI的学习中去,然后换个赛道呢?
我个人觉得这是条死路。
除非,你已经财富自由了,你可以不用为家里的房贷车贷孩子等等花钱顾虑,你可以未来很长一段时间都不需要担心钱的问题,并且你还有很多精力。
或者你是一个天才,你可以很快的就学习一个新的领域,并且很容易成为新领域里面的大拿。
如果这两个都不成立,那么就是条死路。
道理也非常简单,一旦你是需要通过你的技能来卖钱,从而让自己生存和生活的,那么你现在的技能,和基于现在的技能给你赚钱这个事情本身就很重要。
你想进入一个新的领域,比如AI,你是个普通人,正常智商,需要很多时间学习,你在新的领域很难和其他人,尤其是在新领域里面挣扎奋斗了很多年的人比。
所以,要么你继续指望靠现有的技能赚钱,但是你不会有足够的时间让自己成为AI的专家,要么你现在的技能和工作被打折扣,因为你没办法兼顾现在的和将来的事情,能力时间精力都不行。
所以,我觉得想转,最实际的办法,就是现在的组里有LLM的需求,你可以在现有基础上,做一点相关的,试试水。这种做法会比较丝滑。
如果完全不存在这种机会的,那我只建议两种人转:足够聪明的,学习能力特别强特别快的,和有很多钱,不担心失业以后自己活不下去的。
大部分人来说,尤其是中年码农来说,能够稳固基本盘,就可以了,就是不错的选择了。
毕竟码农的人生,是很容易就走下坡路的,大家也应该做好走下坡路的准备。
我可能真的无法改变你的想法,但我真心希望,你做每一个决定的时候,投入每一份精力的时候,尤其是义无反顾投入大手笔的时候,一定要考虑清楚自己的现实情况,和自己到底有什么样的能力。
所以,有些时候,现实就是这样的残酷。
作者:飞总聊IT
来源:mp.weixin.qq.com/s/fcNyIXgwVNenu_cz0pbCSg
收起阅读 »Flutter UI组件库(JUI)
Flutter UI组件库 (JUI) 介绍
您是否正在寻找一种方法来简化Flutter开发过程,并创建美观、一致的用户界面?您的搜索到此为止!我们的Flutter UI组件库(JUI)提供了广泛的预构建、可自定义组件,帮助您快速构建令人惊叹的应用程序。
快速链接
- Pub包地址:pub.dev/packages/ju…
- GitHub仓库:github.com/ThinkerJack…
- 在线文档:http://www.yuque.com/jui_flutter…
为什么选择我们的UI组件库?
- 丰富的组件集合:从基本按钮到复杂表单,我们的库涵盖了所有UI需求。
- 可定制且灵活:每个组件都高度可定制,让您保持应用程序的独特外观和感觉。
- 易于使用:清晰的文档和直观的API,让您轻松将我们的组件集成到您的项目中。
- 节省时间:减少UI实现的时间,将更多精力放在应用程序的核心功能上。
- 一致的设计:通过我们精心设计的组件,确保整个应用程序的外观协调一致。
组件详解
我们的库包含多种组件,每个组件都经过精心设计,以满足不同的UI需求。以下是对各类组件的详细介绍:
1. 通用组件
1.1 JuiButton(多样化按钮)
JuiButton提供了多种样式和尺寸的按钮选择:
- 多种颜色类型:包括蓝色、灰色、红色等,适应不同的UI主题。
- 可选尺寸:从小型到大型,满足各种布局需求。
- 自定义功能:支持添加图标、调整字体大小、设置点击事件等。
1.2 JuiDashedBorder(虚线边框)
JuiDashedBorder为容器提供了引人注目的虚线边框设计:
- 可自定义虚线样式:调整虚线的宽度、高度、间距等。
- 支持圆角:可设置边框的圆角半径,增加设计的灵活性。
- 互动功能:可添加点击事件,增强用户交互体验。
2. 数据展示
2.1 JuiExpandableText(可展开文本)
JuiExpandableText适用于管理长文本内容:
- 自动折叠:超过指定行数的文本会自动折叠。
- 展开/收起功能:用户可以通过点击展开或收起全文。
- 自定义样式:支持设置文本样式、展开/收起按钮样式等。
2.2 JuiHighlightedText(高亮文本)
JuiHighlightedText用于在文本中突出显示特定内容:
- 灵活的高亮方式:支持多个高亮词,每个词可有不同的样式。
- 可点击功能:高亮部分可设置点击事件,增加交互性。
- 样式自定义:可单独设置普通文本和高亮文本的样式。
2.3 JuiTag(可自定义标签)
JuiTag提供了丰富的标签设计选项:
- 多种颜色和形状:包括圆角矩形、圆形等,颜色可自定义。
- 支持图标:可在标签中添加图标,增强视觉效果。
- 大小可调:适应不同的布局需求。
2.4 JuiNoContent(空状态页面)
JuiNoContent用于优雅地展示无内容状态:
- 预设样式:提供多种常见的空状态设计。
- 自定义能力:支持自定义图片、文字和布局。
- 响应式设计:自适应不同屏幕尺寸。
3. 数据录入
3.1 JuiCheckBox(复选框)
JuiCheckBox提供了灵活的多选功能:
- 多种样式:支持方形和圆形两种基本样式。
- 状态管理:轻松处理选中、未选中和禁用状态。
- 自定义外观:可调整大小、颜色等视觉属性。
3.2 JuiSelectPicker(选择器)
JuiSelectPicker提供了多种类型的选择器:
- 滚轮选择器:适合选择日期、时间等连续数据。
- 列表选择器:适用于长列表项的选择。
- 操作选择器:类似于底部弹出的操作表,适合少量选项的快速选择。
- 支持单选和多选:灵活满足不同的选择需求。
- 自定义选项样式:可自定义选项的外观和布局。
3.3 CustomTimePicker(时间选择器)
CustomTimePicker提供了全面的时间选择功能:
- 多种时间格式:支持年月日、年月、年月日时分等多种格式。
- 范围选择:支持选择时间范围。
- 灵活配置:可设置最小和最大可选时间。
- 自定义外观:可调整选择器的样式以匹配您的应用主题。
4. 反馈
4.1 JuiDialog(对话框)
JuiDialog提供了丰富的对话框选项:
- 标准对话框:用于显示信息和确认操作。
- 输入对话框:允许用户在对话框中输入文本。
- 自定义对话框:支持完全自定义对话框内容。
- 灵活的按钮配置:可自定义确认和取消按钮的文本和行为。
- 样式定制:可调整对话框的宽度、标题样式等。
5. 表单
我们的表单组件集提供了全面的解决方案:
5.1 JuiCustomItem(自定义表单项)
- 允许完全自定义表单项的内容和布局。
5.2 JuiTextDetailItem(文本详情项)
- 用于展示只读的文本信息,适合详情页面。
5.3 JuiTapItem(可点击项)
- 创建可点击的表单项,通常用于导航或触发操作。
5.4 JuiRangeItem(范围选择项)
- 允许用户输入或选择一个数值范围。
5.5 JuiTextInputItem(文本输入项)
- 提供各种文本输入选项,支持单行、多行、数字等输入类型。
所有表单项都支持:
- 必填标记
- 禁用状态
- 自定义样式
- 错误提示
- 辅助说明文本
快速开始
集成我们的组件非常简单。首先,在您的pubspec.yaml
文件中添加依赖:
dependencies:
jui: ^latest_version
然后,在您的代码中导入并使用组件。例如:
import 'package:jui/jui.dart';
// 在您的widget构建方法中
JuiButton(
colorType: JuiButtonColorType.blue,
sizeType: JuiButtonSizeType.large,
text: "开始使用",
onTap: () {
// 您的操作代码
},
)
文档
我们为每个组件提供全面的文档,包括:
- 详细的参数描述
- 代码示例
- 使用最佳实践
我们的在线文档始终保持最新,您可以在这里访问:http://www.yuque.com/jui_flutter…
立即开始构建更好的UI!
不要让UI开发拖慢您的脚步。使用我们的Flutter UI组件库,您可以比以往更快地创建专业外观的应用程序。在您的下一个项目中尝试一下,体验不同!
准备好提升您的Flutter开发了吗?今天就开始使用我们的UI组件库吧!
如果您有任何问题或建议,欢迎在我们的 GitHub 仓库 上提出 issue 或贡献代码。我们期待您的反馈,共同改进这个组件库!
来源:juejin.cn/post/7425814107444740150
花式封装:Kotlin+协程+Flow+Retrofit+OkHttp +Repository,倾囊相授,彻底减少模版代码进阶之路
前言 :众里寻它千百度, 蓦然回首,此种代码却在灯火阑珊处。
一、前言
- 本文介绍思路:
本文重点介绍思路:四种方式花式解决Repository
中模版式的代码,逐级递增
1.1 :涉及到Kotlin
、协程
、Flow、viewModel、Retrofit、Okhttp
相关用法
1.2 :涉及到注解
、反射
、泛型
、注解处理器
相关用法
1.3 :涉及到动态代理
,kotlin
中suspend
方法反射调用及反射中异常处理
1.4 :本示例4个项目如图: - 网络框架搭建的封装,到目前为止最为流行又很优雅的的是
Kotlin
+协程
+Flow
+Retrofit
+OkHttp
+Repository
- 先来看看中间各个类的职责:
- 从上图可以看出
单一职责:
NetApi:
负责网络接口配置,包括 请求地址,请求头,请求方式,参数等等所有配置Flow+Retrofit+Okhttp:
联合起来负责把NetApi
中的各种配置组装成网络请求行为,并且通过Flow 组装成流,通过它可以控制该行为的异步方式,异步开始结束等等一系列的流行为。Repository:
负责Flow+Retrofit+Okhttp
请求结果的数据流,进行加工处理成我们想要的数据,大多数不需要处理的,可以直接给到ViewModel
ViewModel:
负责调用Repository
,拿到想要的数据然后提供给UI方展示使用或者相关使用也可以看到 它的 持有链 从右向左 一条线性持有:
ViewModel
持有Repository
,Repository
持有Flow+Retrofit+Okhttp
,Flow+Retrofit+Okhttp
持有NetApi
- 最终我们可以得到:
5.1. 网络请求行为 会根据NetApi
写出模板式的代码,这块解决模版式的代码在Retrofit
中它通过动态代理,把所有模版式的代码统一成了一个
5.2. 同理:Repository
也是根据NetApi
配置的接口,写成模版式的代码转换成流
二、花式封装(一)
NetApi
的配置:
interface NetApi {
// 示例get 请求
@GET("https://www.wanandroid.com/article/list/0/json")
suspend fun getHomeList(): CommonResult
// 示例get 请求2
@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList(@Path("path") page: Int): CommonResult
@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList(@Path("path") page: Int, @Path("path") a: Int): CommonResult
@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList(@Path("path") page: Int, @Path("path") f: Float): CommonResult
// 示例get 请求2
@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList2222(@Path("path") page: Int): CommonResult
@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList3333(@Path("path") page: Int): CommonResult
@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList5555(@Path("path") page: Int, @Query("d") ss: String, @HeaderMap map: Map): CommonResult
@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList6666(
@Path("path") page: Int,
@Query("d") float: Float,
@Query("d") long: Long,
@Query("d") double: Double,
@Query("d") byte: Byte,
@Query("d") short: Short,
@Query("d") char: Char,
@Query("d") boolean: Boolean,
@Query("d") string: String,
@Body body: RequestBodyWrapper
): CommonResult
//示例post 请求
@FormUrlEncoded
@POST("https://www.wanandroid.com/user/register")
suspend fun register(
@Field("username") username: String,
@Field("password") password: String,
@Field("repassword") repassword: String
): String
/************************* 以下只 示例写法,接口调不通,因为找不到那么多 公开接口 全是 Retrofit的用法 来测试 *****************************************************/
// @FormUrlEncoded
@Headers("Content-Type: application/x-www-form-urlencoded") //todo 固定 header
@POST("https://xxxxxxx")
suspend fun post1(@Body body: RequestBody): String
// @FormUrlEncoded
@Headers("Content-Type: application/x-www-form-urlencoded")
@POST("https://xxxxxxx22222")
suspend fun post12(@Body body: RequestBody, @HeaderMap map: Map): String //todo HeaderMap 多个请求头部自己填写
suspend fun post1222(@Body body: RequestBody, @HeaderMap map: Map): String //todo HeaderMap 多个请求头部自己填写
}
2. NetRepository
中是 根据 NetApi
写出下面类似的全模版式的代码:都是返回 Flow
流
class NetRepository private constructor() {
val service by lazy { RetrofitUtils.instance.create(NetApi::class.java) }
companion object {
val instance by lazy { NetRepository() }
}
// 示例get 请求
fun getHomeList() = flow { emit(service.getHomeList()) }
// 示例get 请求2
fun getHomeList(page: Int) = flow { emit(service.getHomeList(page)) }
fun getHomeList(page: Int, a: Int) = flow { emit(service.getHomeList(page, a)) }
fun getHomeList(page: Int, f: Float) = flow { emit(service.getHomeList(page, f)) }
// 示例get 请求2
fun getHomeList2222(page: Int) = flow { emit(service.getHomeList2222(page)) }
fun getHomeList3333(page: Int) = flow { emit(service.getHomeList3333(page)) }
fun getHomeList5555(page: Int, ss: String, map: Map<String, String>) = flow { emit(service.getHomeList5555(page, ss, map)) }
fun getHomeList6666(
page: Int, float: Float, long: Long, double: Double, byte: Byte,
short: Short, char: Char, boolean: Boolean, string: String, body: RequestBodyWrapper
) = flow {
emit(service.getHomeList6666(page, float, long, double, byte, short, char, boolean, string, body))
}
fun register(username: String, password: String, repassword: String) = flow { emit(service.register(username, password, repassword)) }
//
// /************************* 以下只 示例写法,接口调不通,因为找不到那么多 公开接口 全是 Retrofit的用法 来测试 *****************************************************/
//
//
fun post1(body: RequestBody) = flow { emit(service.post1(body)) }
fun post12(body: RequestBody, map: Map<String, String>) = flow { emit(service.post12(body, map)) }
fun post1222(id: Long, asr: String) = flow {
val map = mutableMapOf()
map["id"] = id
map["asr"] = asr
val mapHeader = HashMap()
mapHeader["v"] = 1000
mapHeader["device_sn"] = "Avidfasfa1213"
emit(service.post1222(RequestBodyWrapper(Gson().toJson(map)), mapHeader))
}
}
3. viewModel
调用端:
class MainViewModel : BaseViewModel() {
private val repository by lazy { NetRepository.instance }
fun getHomeList(page: Int) {
flowAsyncWorkOnViewModelScopeLaunch {
repository.getHomeList(page).onEach {
android.util.Log.e("MainViewModel", "one 111 ${it.data?.datas!![0].title}")
}
}
}
}
—————————————————我是分割线君—————————————————
上面花式玩法(一):
此种写法被广泛称作最优雅的一套网络封装
框架,绝大多数中、大厂
基本也就封装到此为止了可能还有些人想着:你的
repository
中就返回了Flow
, 里面就全是简单的emit(xxx)
,我项目里面不是这样的,我的还封装了成功,失败,或者其他的,但总体还是全是模版式的,除了特殊的一些方法,需要在请求前 ,请求后做些处理,有规律有模版的还是占大多数吧,只要大多数都一样的规律模版,都是可以处理的,里面稍微修改下细节,思路都是一样的。哪还能有什么玩法?
可能会有人想到 借助
Hilt
,Dagger2
,Koin
来创建Retrofit
,和创建repository
,创建ViewModel
这里不是讨论依赖注入创建对象的事情哪还有什么玩法?
有,必须有的。
三、花式封装(二)
- 既然上面是
Repository
类中,所有写法都是固定模版式的代码,那么让其根据NetApi:
自动生成Repository
类,我们这里借用注解处理器。 - 具体怎么使用介绍,请参考:
注解处理器在架构,框架中实战应用:MVVM中数据源提供Repository类的自动生成 - 本项目中只需要编译
app_wx2
工程 - 在下图中找到
5. viewModel调用端
class MainViewModel : BaseViewModel() {
private val repository by lazy { RNetApiRepository() }
fun getHomeList(page: Int) {
flowAsyncWorkOnViewModelScopeLaunch {
val time = System.currentTimeMillis()
repository.getHomeList(page).onEach {
android.util.Log.e("MainViewModel", "two 222 ${it.data?.datas!![0].title}")
android.util.Log.e("MainViewModel", "耗时:${(System.currentTimeMillis() - time)} ms")
}
}
}
}
6. 如果 Repository
中某个接口方法需要特殊处理怎么办?比如下图,请求前处理一下,从 拿到数据后我需要再次转化处理之后再给到 viewModel
怎么办?
//我这个接口 ,请求前需要 判断处理一下,拿到数据后也需要再处理一下
fun post333(id: Long, asr: String, m: String, n: String, list: List<String>) = flow {
val map = mutableMapOf()
map["id"] = id
map["asr"] = asr
val mapHeader = HashMap()
mapHeader["v"] = 1000
mapHeader["device_sn"] = "Avidfasfa1213"
//接口调用前 根据 需要处理操作
list.forEach {
if (map.containsKey(id.toString())) {
///
}
}
val result = service.post1222(RequestBodyWrapper(Gson().toJson(map)), mapHeader)
// 拿到数据后需要处理操作
val result1 = result
emit(result1)
}.map {
//需要再转化一下
it
}.filter {
//过滤一下
it.length == 3
}
7. 可以在 接口 NetApi
中该方法上配置 @Filter
注解过滤 ,该方法需要自己特殊处理,不自动生成,如下
@Filter
@POST("https://xxxxxxx22222")
suspend fun post333(@Body body: RequestBody, @HeaderMap map: Map): String
- 如果想 post请求的
RequestBody
内部参数单独出来进入方法传参,可以加上 在NetApi
中方法加上@PostBody
:如下:
@PostBody("{"ID":"Long","name":"String"}")
@POST("https://www.wanandroid.com/user/register")
suspend fun testPostBody222(@Body body: RequestBody): String
这样 该方法生成出来的对应方法就是:
public suspend fun testPostBody222(ID: Long, name: java.lang.String): Flow =
kotlinx.coroutines.flow.flow {
val map = mutableMapOf()
map["ID"] = ID
map["name"] = name
val result = service.testPostBody222(com.wx.test.api.retrofit.RequestBodyCreate.toBody(com.google.gson.Gson().toJson(map)))
emit(result)
}
怎么特殊处理,单独手动建一个Repository,针对该方法,单独写,特殊就要特殊手动处理,但是大多数模版式的代码,都可以让其自动生成。
—————————————————我是分割线君—————————————————
到了这里,我们再想, NetApi
是一个接口类,
但是实际上没有写接口实现类啊, 它怎么实现的呢?
我们上面 花式玩法(二)
中虽然是自动生成的,但是还是有方法体,
可不可以再省略点?
可以,必须有!
四、花式玩法(三)
- 我们可以根据
NetApi
里面的配置,自动生成INetApiRepository
接口类, 接口名和参数 都和NetApi
保持一致,唯一区别就是返回的对象变成了Flow
了,
这样在Repository
中就把数据转变为flow
流了 - 配置让代码自动生成的类:
@AutoCreateRepositoryInterface(interfaceApi = "com.wx.test.api.net.NetApi")
class KaptInterface {
}
生成的接口类 INetApiRepository
代码如下:
public interface INetApiRepository {
public fun getHomeList(): Flow>
public fun getHomeList(page: Int): Flow>
public fun getHomeList(page: Int, f: Float): Flow>
public fun getHomeList(page: Int, a: Int): Flow>
public fun getHomeList2222(page: Int): Flow>
public fun getHomeList3333(page: Int): Flow>
public fun getHomeList5555(
page: Int,
ss: String,
map: Map<String, String>
): Flow>
public fun getHomeList6666(
page: Int,
float: Float,
long: Long,
double: Double,
byte: Byte,
short: Short,
char: Char,
boolean: Boolean,
string: String,
body: RequestBodyWrapper
): Flow>
public fun getHomeListA(page: Int): Flow>
public fun getHomeListB(page: Int): Flow
public fun post1(body: RequestBody): Flow
public fun post12(body: RequestBody, map: Map<String, String>): Flow
public fun post1222(body: RequestBody, map: Map<String, Any>): Flow
public fun register(
username: String,
password: String,
repassword: String
): Flow
public fun testPostBody222(ID: Long, name: java.lang.String): Flow
}
Repository
职责承担的调用端:用动态代理:
class RepositoryPoxy private constructor() : BaseRepositoryProxy() {
val service = NetApi::class.java
val api by lazy { RetrofitUtils.instance.create(service) }
companion object {
val instance by lazy { RepositoryPoxy() }
}
fun callApiMethod(serviceR: Class<R>): R {
return Proxy.newProxyInstance(serviceR.classLoader, arrayOf(serviceR)) { proxy, method, args ->
flow {
val funcds = findSuspendMethod(service, method.name, args)
if (args == null) {
emit(funcds?.callSuspend(api))
} else {
emit(funcds?.callSuspend(api, *args))
}
// emit((service.getMethod(method.name, *parameterTypes)?.invoke(api, *(args ?: emptyArray())) as Call).execute().body())
}.catch {
if (it is InvocationTargetException) {
throw Throwable(it.targetException)
} else {
it.printStackTrace()
throw it
}
}
} as R
}
}
BaseRepositoryProxy
中内容:
open class BaseRepositoryProxy {
private val map by lazy { mutableMapOf?>() }
private val sb by lazy { StringBuffer() }
@OptIn(ExperimentalStdlibApi::class)
fun findSuspendMethod(service: Class<T>, methodName: String, args: Array<out Any>): KFunction<*>? {
sb.delete(0, sb.length)
sb.append(service.name)
.append(methodName)
args.forEach {
sb.append(it.javaClass.typeName)
}
val key = sb.toString()
if (!map.containsKey(key)) {
val function = service.kotlin.memberFunctions.find { f ->
var isRight = 0
if (f.name == methodName && f.isSuspend) {
if (args.size == 0 && f.parameters.size == 1) {
isRight = 2
} else {
f.parameters.forEachIndexed { index, it ->
if (index > 0 && args.size > 0) {
if (args.size == 0) {
isRight = 2
return@forEachIndexed
}
if (it.type.javaType.typeName == javaClassTransform(args[index - 1].javaClass).typeName) {
isRight = 2
} else {
isRight = 1
return@forEachIndexed
}
}
}
}
}
//方法名一直 是挂起函数 方法参数个数一致, 参数类型一致
f.name == methodName && f.isSuspend && f.parameters.size - 1 == args.size && isRight == 2
}
map[key] = function
}
return map[key]
}
private fun javaClassTransform(clazz: Class<Any>) = when (clazz.typeName) {
"java.lang.Integer" -> Int::class.java
"java.lang.String" -> String::class.java
"java.lang.Float" -> Float::class.java
"java.lang.Long" -> Long::class.java
"java.lang.Boolean" -> Boolean::class.java
"java.lang.Double" -> Double::class.java
"java.lang.Byte" -> Byte::class.java
"java.lang.Short" -> Short::class.java
"java.lang.Character" -> Char::class.java
"SingletonMap" -> Map::class.java
"LinkedHashMap" -> MutableMap::class.java
"HashMap" -> HashMap::class.java
"Part" -> MultipartBody.Part::class.java
"RequestBody" -> RequestBody::class.java
else -> {
if ("RequestBody" == clazz.superclass.simpleName) {
RequestBody::class.java
} else {
Any::class.java
}
}
}
}
- ViewModel中调用端:
class MainViewModel : BaseViewModel() {
private val repository by lazy { RepositoryPoxy.instance }
fun getHomeList(page: Int) {
flowAsyncWorkOnViewModelScopeLaunch {
val time = System.currentTimeMillis()
repository.callApiMethod(INetApiRepository::class.java).getHomeList(page).onEach {
android.util.Log.e("MainViewModel", "three 333 ${it.data?.datas!![0].title}")
android.util.Log.e("MainViewModel", "耗时:${(System.currentTimeMillis() - time)} ms")
}
}
}
}
—————————————————我是分割线君—————————————————
- 上面生成的接口类
INetApiRepository
其实方法和NetApi
拥有相似的模版,唯一区别就是返回类型,一个是对象,一个是Flow 流的对象还能省略吗?
有,必须有
五、花式玩法(四)
- 直接修改
RepositoryPoxy
,作为Reposttory的职责 ,连上面的INetApiRepository
的接口类全部省略了, 如下:
class RepositoryPoxy private constructor() : BaseRepositoryProxy() {
val service = NetApi::class.java
val api by lazy { RetrofitUtils.instance.create(service) }
companion object {
val instance by lazy { RepositoryPoxy() }
}
fun callApiMethod(clazzR: Class<R>, methodName: String, vararg args: Any): Flow {
return flow {
val clssss = mutableListOfout Any>>()
args?.forEach {
clssss.add(javaClassTransform(it.javaClass))
}
val parameterTypes = clssss.toTypedArray()
val call = (service.getMethod(methodName, *parameterTypes)?.invoke(api, *(args ?: emptyArray())) as Call)
call?.execute()?.body()?.let {
emit(it as R)
}
}
}
@OptIn(ExperimentalStdlibApi::class)
fun callApiSuspendMethod(clazzR: Class<R>, methodName: String, vararg args: Any): Flow {
return flow {
val funcds = findSuspendMethod(service, methodName, args)
if (args == null) {
emit(funcds?.callSuspend(api) as R)
} else {
emit(funcds?.callSuspend(api, *args) as R)
}
}
}
}
2. ViewModel中调用入下:
class MainViewModel : BaseViewModel() {
private val repository by lazy { RepositoryPoxy.instance }
fun getHomeList(page: Int) {
flowAsyncWorkOnViewModelScopeLaunch {
val time = System.currentTimeMillis()
repository.callApiSuspendMethod(HomeData::class.java, "getHomeListB", page).onEach {
android.util.Log.e("MainViewModel", "four 444 ${it.data?.datas!![0].title}")
android.util.Log.e("MainViewModel", "耗时:${(System.currentTimeMillis() - time)} ms")
}
}
}
}
六、总结
通过上面4中花式玩法:
- 花式玩法1: 我们知道了最常见最优雅的写法,但是模版式
repository
代码太多,而且需要手动写 - 花式玩法2: 把花式玩法1中的模版式
repository
,让其自动生成,对于特殊的方法,单独手动再写个repository
,这样让大多数模版式代码全自动生成 - 花式玩法3:
NetApi
,可以根据配置,动态代理生成网络请求行为,该行为统一为动态代理实现,无需对接口类NetApi
单独实现,那么我们的repository
也可以 生成一个接口类INetApiRepository
,然后动态代理实现其内部 方法体逻辑 - 花式玩法4:我连花式玩法3中的接口类
INetApiRepository
都不需要了,直接反射搞定所有。 - 同时可以学习到,注解、反射、泛型、注解处理器、动态代理
项目地址
感谢阅读:
欢迎 点赞、收藏、关注
来源:juejin.cn/post/7417847546323042345
Java中使用for而不是forEach遍历List的10大理由
首发公众号:【赵侠客】
引言
我相信作为一名java开发者你一定听过或者看过类似《你还在用for循环遍历List吗?》、《JDK8都10岁了,你还在用for循环遍历List吗?》这类鄙视在Java中使用for循环遍历List的水文。这类文章说的其实就是使用Java8中的Stream.foreach()
来遍历元素,在技术圈感觉使用新的技术就高大上,开发者们也都默许接受新技术的很多缺点,而使用老的技术或者传统的方法就会被人鄙视,被人觉得Low,那么使用forEach()
真的很高大上吗?它真的比传统的for
循环好用吗?本文就列出10大推荐使用for
而不是forEach()
的理由。
理由一、for性能更好
在我的固有认知中我是觉得for
的循环性能比Stream.forEach()
要好的,因为在技术界有一条真理:
越简单越原始的代码往往性能也越好
而且搜索一些文章或者大模型都是这么觉得的,可时我并没有找到专业的基准测试证明此结论。那么实际测试情况是不是这样的呢?虽然这个循环的性能差距对我们的系统性能基本上没有影响,不过为了证明for
的循环性能真的比Stream.forEach()
好我使用基准测试用专业的实际数据来说话。我的测试代码非常的简单,就对一个List<Integer> ids
分别使用for
和Stream.forEach()
遍历出所有的元素,以下是测试代码:
@State(Scope.Thread)
public class ForBenchmark {
private List<Integer> ids ;
@Setup
public void setup() {
ids = new ArrayList<>();
//分别对10、100、1000、1万、10万个元素测试
IntStream.range(0, 10).forEach(i -> ids.add(i));
}
@TearDown
public void tearDown() {
ids = new ArrayList<>();
}
@Benchmark
public void testFor() {
for (int i = 0; i <ids.size() ; i++) {
Integer id = ids.get(i);
}
}
@Benchmark
public void testStreamforEach() {
ids.stream().forEach(x->{
Integer id=x;
});
}
@Test
public void testMyBenchmark() throws Exception {
Options options = new OptionsBuilder()
.include(ForBenchmark.class.getSimpleName())
.forks(1)
.threads(1)
.warmupIterations(1)
.measurementIterations(1)
.mode(Mode.Throughput)
.build();
new Runner(options).run();
}
}
我使用ArrayList分对10、100、1000、1万,10万个元素进行测试,以下是使用JMH基准测试的结果,结果中的数字为吞吐量,单位为ops/s,即每秒钟执行方法的次数:
方法 | 十 | 百 | 千 | 万 | 10万 |
---|---|---|---|---|---|
forEach | 45194532 | 17187781 | 2501802 | 200292 | 20309 |
for | 127056654 | 19310361 | 2530502 | 202632 | 19228 |
for对比 | ↑181% | ↑12% | ↑1% | ↓1% | ↓5% |
从使用Benchmark基准测试结果来看使用for遍历List比Stream.forEach性能在元素越小的情况下优势越明显,在10万元素遍历时性能反而没有Stream.forEach好了,不过在实际项目开发中我们很少有超过10万元素的遍历。
所以可以得出结论:
在小List(万元素以内)遍历中for性能要优于Stream.forEach
理由二、for占用内存更小
Stream.forEach()会占用更多的内存,因为它涉及到创建流、临时对象或者对中间操作进行缓存。for 循环则更直接,操作底层集合,通常不会有额外的临时对象。可以看如下求和代码,运行时增加JVM参数-XX:+PrintGCDetails -Xms4G -Xmx4G
输出GC日志:
- 使用for遍历
List<Integer> ids = IntStream.range(1,10000000).boxed().collect(Collectors.toList());
int sum = 0;
for (int i = 0; i < ids.size(); i++) {
sum +=ids.get(i);
}
System.gc();
//GC日志
[GC (System.gc()) [PSYoungGen: 392540K->174586K(1223168K)] 392540K->212100K(4019712K), 0.2083486 secs] [Times: user=0.58 sys=0.09, real=0.21 secs]
从GC日志中可以看出,使用for遍历List在GC回收前年轻代使用了392540K,总内存使用了392540K,回收耗时0.20s
- 使用stream
List<Integer> ids = IntStream.range(1,10000000).boxed().collect(Collectors.toList());
int sum = ids.stream().reduce(0,Integer::sum);
System.gc();
//GC日志
[GC (System.gc()) [PSYoungGen: 539341K->174586K(1223168K)] 539341K->212118K(4019712K), 0.3747694 secs] [Times: user=0.55 sys=0.83, real=0.38 secs]
从GC日志中可以看出,回收前年轻代使用了539341K,总内存使用了539341K,回收耗时0.37s ,从内存占用情况来看使用for会比Stream.forEach()占用内存少37%,而且Stream.foreach() GC耗时比for多了85%。
理由三、for更易控制流程
我们使用for遍历List可以很方便的使用break
、continue
、return
来控制循环,而使用Stream.forEach在循环中是不能使用break
、continue
,特别指出的使用return
是无法中断Stream.forEach循环的,如下代码:
List<Integer> ids = IntStream.range(1,4).boxed().collect(Collectors.toList());
ids.stream().forEach(i->{
System.out.println(""+i);
if(i>1){
return;
}
});
System.out.println("==");
for (int i = 0; i < ids.size(); i++) {
System.out.println(""+ids.get(i));
if(ids.get(i)>1){
return;
}
}
输出:
forEach-1
forEach-2
forEach-3
==
for-1
for-2
从输出结果可以看出在Stream.forEach中使用return后循环还会继续执行的,而在for循环中使用return将中断循环。
理由四、for访问变量更灵活
这点我想是很多人在使用Stream.forEach中比较头疼的一点,因为在Stream.forEach中引用的变量必须是final类型,也就是说不能修改forEach循环体之外的变量,但是我们很多业务场景就是修改循环体外的变量,如以下代码:
Integer sum=0;
for (int i = 0; i < ids.size(); i++) {
sum++;
}
ids.stream().forEach(i -> {
//报错
sum++;
});
像上面的这样的代码在实际中是很常见的,sum++在forEach中是不被允许的,有时为了使用类似的方法我们只能把变量变成一个引用类型:
AtomicReference<Integer> sum= new AtomicReference<>(0);
ids.stream().forEach(i -> {
sum.getAndSet(sum.get() + 1);
});
所以在访问变量方面for会更加灵活。
理由五、for处理异常更方便
这一点也是我使用forEach比较头疼的,在forEach中的Exception必须要捕获处理,如下代码:
public void testException() throws Exception {
List<Integer> ids = IntStream.range(1, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
//直接抛出Exception
System.out.println(div(i, i - 1));
}
ids.stream().forEach(x -> {
try {
//必须捕获Exception
System.out.println(div(x, x - 1));
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
private Integer div(Integer a, Integer b) throws Exception {
return a / b;
}
我们在循环中调用了div()方法,该方法抛出了Exception,如果是使用for循环如果不想处理可以直接抛出,但是使用forEach就必须要自己处理异常了,所以for在处理异常方面会更加灵活方便。
理由六、for能对集合添加、删除
在for循环中可以直接修改原始集合(如添加、删除元素),而 Stream 不允许修改基础集合,会抛出 ConcurrentModificationException,如下代码:
List<Integer> ids = IntStream.range(0, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
if(i<1){
ids.add(i);
}
}
System.out.println(ids);
List<Integer> ids2 = IntStream.range(0, 4).boxed().collect(Collectors.toList());
ids2.stream().forEach(x -> {
if(x<1){
ids2.add(x);
}
});
System.out.println(ids2);
输出:
[0, 1, 2, 3, 0]
java.util.ConcurrentModificationException
如果你想在循环中添加或者删除元素foreach是无法完成了,所以for处理集合更方便。
理由七、for Debug更友好
Stream.forEach()使用了Lambda表达示,一行代码可以搞定很多功能,但是这也给Debug带来了困难,如下代码:
List<Integer> ids = IntStream.range(0, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
System.out.println(ids.get(i));
}
List<Integer> ids2 = IntStream.range(0, 4).boxed().collect(Collectors.toList());
ids2.stream().forEach(System.out::println);
以下是DeBug截图:
我们可以看出使用for循环Debug可以一步一步的跟踪程序执行步骤,但是使用forEach却做不到,所以for可以更方便的调试你的代码,让你更快捷的找到出现问题的代码。
理由八、for代码可读性更好
Lambda表达示属于面向函数式编程,主打的就是一个抽象,相比于面向对象或者面向过程编程代码可读性是非常的差,有时自己不写的代码过段时间后自己都看不懂。就比如我在文章《解密阿里大神写的天书般的Tree工具类,轻松搞定树结构!》一文中使用函数式编程写了一个Tree工具类,我们可以对比一下面向过程和面向函数式编程代码可读性的差距:
- 使用for面向过程编程代码:
public static List<MenuVo> makeTree(List<MenuVo> allDate,Long rootParentId) {
List<MenuVo> roots = new ArrayList<>();
for (MenuVo menu : allDate) {
if (Objects.equals(rootParentId, menu.getPId())) {
roots.add(menu);
}
}
for (MenuVo root : roots) {
makeChildren(root, allDate);
}
return roots;
}
public static MenuVo makeChildren(MenuVo root, List<MenuVo> allDate) {
for (MenuVo menu : allDate) {
if (Objects.equals(root.getId(), menu.getPId())) {
makeChildren(menu, allDate);
root.getSubMenus().add(menu);
}
}
return root;
}
- 使用forEach面向函数式编程代码:
public static <E> List<E> makeTree(List<E> list, Predicate<E> rootCheck, BiFunction<E, E, Boolean> parentCheck, BiConsumer<E, List<E>> setSubChildren) {
return list.stream().filter(rootCheck).peek(x -> setSubChildren.accept(x, makeChildren(x, list, parentCheck, setSubChildren))).collect(Collectors.toList());
}
private static <E> List<E> makeChildren(E parent, List<E> allData, BiFunction<E, E, Boolean> parentCheck, BiConsumer<E, List<E>> children) {
return allData.stream().filter(x -> parentCheck.apply(parent, x)).peek(x -> children.accept(x, makeChildren(x, allData, parentCheck, children))).collect(Collectors.toList());
}
对比以上两段代码,可以看出面向过程的代码思路非常的清晰,基本上可以一眼看懂代码要做什么,反观面向函数式编程的代码,我想大都人一眼都不知道代码在干什么的,所以使用for的代码可读性会更好。
理由九、for更好的管理状态
for循环可以轻松地在每次迭代中维护状态,这在Stream.forEach中可能需要额外的逻辑来实现。这一条可理由三有点像,我们经常需要通过状态能控制循环是否执行,如下代码:
boolean flag = true;
for (int i = 0; i < 10; i++) {
if(flag){
System.out.println(i);
flag=false;
}
}
AtomicBoolean flag1 = new AtomicBoolean(true);
IntStream.range(0, 10).forEach(x->{
if (flag1.get()){
flag1.set(false);
System.out.println(x);
}
});
这个例子说明了在使用Stream.forEach时,为了维护状态,我们需要引入额外的逻辑,如使用AtomicBoolean,而在for循环中,这种状态管理是直接和简单的。
理由十、for可以使用索引直接访问元素
在某些情况下,特别是当需要根据元素的索引(位置)来操作集合中的元素时,for就可以直接使用索引访问了。在Stream.forEach中就不能直接通过索引访问,比如我们需要将ids中的数字翻倍:
List<Integer> ids = IntStream.range(0, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
ids.set(i,i*2);
}
List<Integer> ids2 = IntStream.range(0, 4).boxed().collect(Collectors.toList());
ids2=ids2.stream().map(x->x*2).collect(Collectors.toList());
我们使用for循环来遍历这个列表,并在每次迭代中根据索引i来修改列表中的元素。这种操作直接且直观。而使用Stream.foreach()不能直接通过索引下标访问元素的,只能将List转换为流,然后使用map操作将每个元素乘以2,最后,我们使用Collectors.toList()将结果收集回一个新的List。
总结
本文介绍了在实际开发中更推荐使用for循环而不是Stream.foreach()来遍历List的十大理由,并给出了具体的代码和测试结果,当然这并不是说就一定要使用传统的for循环,要根据自己的实际情况来选择合适的方法。通过此案件也想让读者明白在互联网世界中你所看到的东西都是别人想让你看到的,这个世界是没有真相的,别人想让你看到的就是所谓的”真相“,做为吃瓜群众一定不能随波逐流,要有鉴别信息真假的能力和培养独立思考的能力。
来源:juejin.cn/post/7416848881407524902
VSCode无限画布模式(可能会惊艳到你的一个小功能)
👇 该文章内容的
受众
是VSCode
的用户,不满足条件的同学可以选择性阅读
哈~
❓现存的痛点
VSCode
是我的主力开发工具,在实际的开发中,我经常会对编辑器进行分栏处理,组件A的tsx、css代码、工具类方法各一个窗口
,组件B的tsx、css代码、工具类方法各一个窗口
,组件C的......
当组件拆的足够多
的时候,多个分栏会把本就不大的编辑器窗口分成N份,每一份的可视区域就小的可怜
,切换组件代码时,需要不小的翻找成本
,而且经常忘记我之前把文件放在了那个格子里,特别是下面的场景(一个小窗口内开了N个小tab
),此时更难找到想要的窗口了...
问题汇总
- 分栏会导致每个窗口的
面积变小
,开发体验差(即使可以双击放大,但效果仍不符合预期); - 编辑器窗口
容易被新打开的窗口替换掉
,常找不到之前打开的窗口; - 窗口的可操作性不强,
位置不容易调整
。
💡解题的思路
1. 自由 & 独立的编辑器窗口
分栏会导致每个窗口的面积变小
,开发体验不好。
那就别变小了!
每个编辑器窗口都还是原来的大小,甚至更大!
2. 无限画布
编辑器窗口容易被新打开的窗口替换掉
,常找不到之前打开的窗口。窗口的可操作性不强,位置不容易调整。
那就每个窗口都拥有一个自己的位置
好了!拖一下就可以找到了!
3. 画布体验
好用的画布是可以较大的提升用户体验的,下面重点做了四个方面的优化:
3.1 在编辑器里可以快速缩小 & 移动
因为不可避免的会出现一些
事件冲突
(比如编辑器里的滚动和画布的滚动、缩放等等),通过提供快捷键的
解法,可以在编辑器内快速移动、缩放
画布。command + 鼠标上下滑动 = 缩放
option + 鼠标移动 = 画布移动
注意下图,鼠标还在编辑器窗口中,依然可以拖动画布👇🏻
3.2 快速放大和缩小编辑窗口
通过快捷按钮的方式,可以快速的放大和缩小编辑器窗口。
3.3 一键定位到中心点
不小心把所有窗口都拖到了画布视口外找不到了?没事儿,可以通过点击快捷按钮的方式,快速回到中心点。
3.4 窗口的合并和分解
可以在窗口下进行编辑器的合并,即可以简单的把一些常用的窗口进行合并、分解。
💬 提出的背景
作为一名前端开发同学
,避免不了接触UI同学的设计稿,我司使用的就是figma
,以figma
平台为例,其无限画布模式可以非常方便的平铺N个稿子,并快速的看到所有稿子的全貌、找到自己想要的稿子等等,效果如下:
没错!我就是基于这个思路提出了第一个想法,既然图片可以无限展示,编辑器为什么不能呢?
这个想法其实去年就有了,期间大概断断续续花了半年多左右的时间在调研和阅读VSCode的源码上,年后花了大概3个月的时间进行实现,最终在上个月做到了上面的效果。
经过约一个月的试用(目前我的日常需求均是使用这种模式进行的开发)
,发现效果超出预期
,我经常会在画布中开启约10+
个窗口,并频繁的在各个窗口之间来回移动
,在这个过程中,我发现以下几点很让我很是欣喜:
空间感
:我个人对“空间和方向”比较敏感,恰好画布模式会给我一种真实的空间感
,我仿佛在一间房子里,里面摆满了我的代码,我穿梭在代码中,修一修这个,调一调这个~满足感
:无限画布的方式,相当于我间接拥有了无限大的屏幕,我只需要动动手指找到我的编辑窗口就好了,它可以随意的放大和缩小,所以我可以在屏幕上展示足够多的代码。更方便的看源码
:我可以把源码的每个文件单独开一个窗口,然后把每个窗口按顺序铺起来,摆成一条线,这条线就是源码的思路(当然可以用截图的方式看源码 & 缕思路,但是,需要注意一点,这个编辑器是可以交互的!)
⌨️ 后续的计划
后续计划继续增强画布
的能力,让它可以更好用:
小窗口支持命名
,在缩小画布时,窗口缩小,但是命名不缩小,可以直观的找到想要的窗口。增强看源码的体验
:支持在画布上添加其他元素(文案、箭头、连线),试想一下,以后在看源码时,拥有一个无限的画板来展示代码和思路,关键是代码是可以交互的,这该有多么方便!类似MacOS的台前调度功能
:把有关联的一些窗口分组,画布一侧有分组的入口,点击入口可以切换画布中的组,便于用户快速的进行批量窗口切换,比如A页面的一些JS、CSS等放在一个组,B页面放在另一个组,这样可以快速的切换文件窗口。
📔 其他的补充
调研过程中发现无法使用VSCode的插件功能来实现这个功能,所以只好fork了一份VSCode的开源代码,进行了大量修改,最终需要对源码进行编译打包才能使用(一个新的VSCode),目前只打包了mac的arm64版本来供自己试用。
另外,由于VSCode并不是100%开源(微软的一些服务相关的逻辑是闭源的),所以github上的开源仓库只是它的部分代码,经过编译之后,发现缺失了远程连接相关的功能,其他的功能暂时没有发现缺失。
🦽 可以试用吗
目前还没有对外提供试用版的打算
,想自己继续使用一些时间,持续打磨一下细节,等功能细节更完善了再对外进行推广,至于这次的软文~ 其实是希望可以引起阅读到这里的同学进行讨论,可以聊一下你对该功能的一些看法,以及一些其他的好点子~
,thx~
🫡 小小的致敬
- 致敬VSCode团队,在阅读和改造他们代码的过程中学习到了不少hin有用的代码技能,也正是因为有他们的开源,才能有我的这次折腾👍🏻
- 致敬锤子科技罗永浩老师,这次实现思路也有借鉴当年发布的“无限屏”功能,本文的头图就是来自当年的发布会截图。
来源:juejin.cn/post/7375586227984220169
工作六年,看到这样的代码,内心五味杂陈......
工作六年,看到这样的代码,内心五味杂陈......
那天下午,看到了令我终生难忘的代码,那一刻破防了......
ヾ(•ω•`)🫥 故事还得从半年前数据隔离的那个事情说起......
📖一、历史背景
1.1 数据隔离
预发,灰度,线上环境共用一个数据库。每一张表有一个 env 字段,环境不同值不同。特别说明: env 字段即环境字段。如下图所示:
1.2 隔离之前
🖌️插曲:一开始只有 1 个核心表有 env 字段,其他表均无该字段;
有一天预发环境的操作影响到客户线上的数据。 为了彻底隔离,剩余的二十几个表均要添加上环境隔离字段。
当时二十几张表已经大量生产数据,隔离需要做好兼容过渡,保障数据安全。
1.3 隔离改造
其他表历史数据很难做区分,于是新增加的字段 env 初始化 all ,表示预发线上都能访问。以此达到历史数据的兼容。
每一个环境都有一个自己独立标志;从 application.properties 中读该字段;最终到数据库执行的语句如下:
SELECT XXX FROM tableName WHERE env = ${环境字段值} and ${condition}
1.4 隔离方案
最拉胯的做法:每一张表涉及到的 DO、Mapper、XML等挨个添加 env 字段。但我指定不能这么干!!!
具体方案:自定义 mybatis 拦截器进行统一处理。 通过这个方案可以解决以下几个问题:
- 业务代码不用修改,包括 DO、Mapper、XML等。只修改 mybatis 拦截的逻辑。
- 挨个添加补充字段,工程量很多,出错概率极高
- 后续扩展容易
1.5 最终落地
在 mybatis 拦截器中, 通过改写 SQL。新增时填充环境字段值,查询时添加环境字段条件。真正实现改一处即可。 考虑历史数据过渡,将 env = ${当前环境}
修改成 env in (${当前环境},'all')
SELECT xxx FROM ${tableName} WHERE env in (${当前环境},'all') AND ${其他条件}
具体实现逻辑如下图所示:
- 其中 env 字段是从 application.properties 配置获取,全局唯一,只要环境不同,env 值不同
- 借助 JSqlParser 开源工具,改写 sql 语句,修改重新填充、查询拼接条件即可。链接JSQLParser
思路:自定义拦截器,填充环境参数,修改 sql 语句,下面是部分代码示例:
@Intercepts(
{@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})}
)
@Component
public class EnvIsolationInterceptor implements Interceptor {
......
@Override
public Object intercept(Invocation invocation) throws Throwable {
......
if (SqlCommandType.INSERT == sqlCommandType) {
try {
// 重写 sql 执行语句,填充环境参数等
insertMethodProcess(invocation, boundSql);
} catch (Exception exception) {
log.error("parser insert sql exception, boundSql is:" + JSON.toJSONString(boundSql), exception);
throw exception;
}
}
return invocation.proceed();
}
}
一气呵成,完美上线。
📚二、发展演变
2.1 业务需求
随着业务发展,出现了以下需求:
- 上下游合作,我们的 PRC 接口在匹配环境上与他们有差异,需要改造
SELECT * FROM ${tableName} WHERE bizId = ${bizId} and env in (?,'all')
- 有一些环境的数据相互相共享,比如预发和灰度等
- 开发人员的部分后面,希望在预发能纠正线上数据等
2.2 初步沟通
这个需求的落地交给了来了快两年的小鲜肉。 在开始做之前,他也问我该怎么做;我简单说了一些想法,比如可以跳过环境字段检查,不拼接条件;或者拼接所有条件,这样都能查询;亦或者看一下能不能注解来标志特定方法,你想一想如何实现......
(●ˇ∀ˇ●)年纪大了需要给年轻人机会。
2.3 勤劳能干
小鲜肉,没多久就实现了。不过有一天下午他遇到了麻烦。他填充的环境字段取出来为 null,看来很久没找到原因,让我帮他看看。(不久前也还教过他 Arthas 如何使用呢,这种问题应该不在话下吧🤔)
2.4 具体实现
大致逻辑:在需要跳过环境条件判断的方法前后做硬编码处理,同环切面逻辑, 一加一删。填充颜色部分为小鲜肉的改造逻辑。
大概逻辑就是:将 env 字段填充所有环境。条件过滤的忽略的目的。
SELECT * FROM ${tableName} WHERE env in ('pre','gray','online','all') AND ${其他条件}
2.5 错误原因
经过排查是因为 API 里面有多处对 threadLoal 进行处理的逻辑,方法之间存在调用。 简化举例: A 和 B 方法都是独立的方法, A 在调用 B 的过程,B 结束时把上下文环境字段删除, A 在获取时得到 null。具体如下:
2.6 五味杂陈
当我看到代码的一瞬间,彻底破防了......
queryProject 方法里面调用 findProjectWithOutEnv,
在两个方法中,都有填充处理 env 的代码。
2.7 遍地开花
然而,这三行代码,随处可见,在业务代码中遍地开花.......
// 1. 变量保存 oriFilterEnv
String oriFilterEnv = UserHolder.getUser().getFilterEnv();
// 2. 设置值到应用上下文
UserHolder.getUser().setFilterEnv(globalConfigDTO.getAllEnv());
//....... 业务代码 ....
// 3. 结束复原
UserHolder.getUser().setFilterEnv(oriFilterEnv);
改了个遍,很勤劳👍......
2.8 灵魂开问
难道真的就只能这么做吗,当然还有......
- 开闭原则符合了吗
- 改漏了应该办呢
- 其他人遇到跳过的检查的场景也加这样的代码吗
- 业务代码和功能代码分离了吗
- 填充到应用上下文对象 user 合适吗
- .......
大量魔法值,单行字符超500,方法长度拖几个屏幕也都睁一眼闭一只眼了,但整这一出,还是破防......
内心涌动😥,我觉得要重构一下。
📒三、重构一下
3.1 困难之处
在 mybatis intercept 中不能直接精准地获取到 service 层的接口调用。 只能通过栈帧查询到调用链。
3.2 问题列表
- 尽量不要修改已有方法,保证不影响原有逻辑;
- 尽量不要在业务方法中修改功能代码;关注点分离;
- 尽量最小改动,修改一处即可实现逻辑;
- 改造后复用能力,而不是依葫芦画瓢地添加这种代码
3.3 实现分析
- 用独立的 ThreadLocal,不与当前用户信息上下文混合使用
- 注解+APO,通过注解参数解析,达到目标功能
- 对于方法之间的调用或者循环调用,要考虑优化
同一份代码,在多个环境运行,不管如何,一定要考虑线上数据安全性。
3.4 使用案例
改造后的使用案例如下,案例说明:project 表在预发环境校验跳过。
@InvokeChainSkipEnvRule(skipEnvList = {"pre"}, skipTableList = {"project"})
@SneakyThrows
@GetMapping("/importSignedUserData")
@InvokeChainSkipEnvRule(skipEnvList = {"pre"}, skipTableList = {"project"})
public void importSignedUserData(
......
HttpServletRequest request,
HttpServletResponse response) {
......
}
在使用的调用入口处添加注解。
3.5 具体实现
- 方法上标记注解, 注解参数定义规则
- 切面读取方法上面的注解规则,并传递到应用上下文
- 拦截器从应用上下文读取规则进行规则判断
注解代码
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface InvokeChainSkipEnvRule {
/**
* 是否跳过环境。 默认 true,不推荐设置 false
*
* @return
*/
boolean isKip() default true;
/**
* 赋值则判断规则,否则不判断
*
* @return
*/
String[] skipEnvList() default {};
/**
* 赋值则判断规则,否则不判断
*
* @return
*/
String[] skipTableList() default {};
}
3.6 不足之处
- 整个链路上的这个表操作都会跳过,颗粒度还是比较粗
- 注解只能在入口处使用,公共方法调用尽量避免
🤔那还要不要完善一下,还有什么没有考虑到的点呢? 拿起手机看到快12点的那一刻,我还是选择先回家了......
📝 四、总结思考
4.1 隔离总结
这是一个很好参考案例:在应用中既做了数据隔离,也做了数据共享。通过自定义拦截器做数据隔离,通过自定注解切面实现数据共享。
4.2 编码总结
同样的代码写两次就应该考虑重构了
- 尽量修改一个地方,不要写这种边边角角的代码
- 善用自定义注解,解决这种通用逻辑
- 可以妥协,但是要有底线
- ......
4.3 场景总结
简单梳理,自定义注解 + AOP 的场景
场景 | 详细描述 |
---|---|
分布式锁 | 通过添加自定义注解,让调用方法实现分布式锁 |
合规参数校验 | 结合 ognl 表达式,对特定的合规性入参校验校验 |
接口数据权限 | 对不同的接口,做不一样的权限校验,以及不同的人员身份有不同的校验逻辑 |
路由策略 | 通过不同的注解,转发到不同的 handler |
...... |
自定义注解很灵活,应用场景广泛,可以多多挖掘。
4.4 反思总结
- 如果一开始就做好技术方案或者直接使用不同的数据库
- 是否可以拒绝那个所谓的需求
- 先有设计再有编码,别瞎搞
4.5 最后感想
在这个只讲业务结果,不讲技术氛围的环境里,突然有一些伤感;身体已经开始吃不消了,好像也过了那个对技术较真死抠的年纪; 突然一想,这么做的意义又有多大呢?
来源:juejin.cn/post/7294844864020430902
ArrayList扩容原理
ArrayList扩容原理(源码理解)
从源码角度对ArrayList扩容原理进行简介,我们可以更深入地了解其内部实现和工作原理。以下是基于Java标准库中ArrayList扩容原理源码的简介
1、类定义与继承关系
ArrayList在Java中的定义如下:
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
ArrayList是一个泛型类,继承自AbstractList并实现了List接口,同时还实现了RandomAccess、Cloneable和Serializable接口。这 些接口分别表示ArrayList支持随机访问、可以被克隆以及可以被序列化。
2、核心成员变量(牢记)
elementData
:是实际存储元素的数组,它可以是默认大小的空数组(当没有指定初始容量且没有添加元素 时),也可以是用户指定的初始容量大小的数组,或者是在扩容后新分配的数组。
size
:表示数组中当前元素的个数。
transient Object[] elementData; //数组
private int size; //元素个数
DEFAULT_CAPACITY
是ArrayList的默认容量,当没有指定初始容量时,会使用这个值。
//默认初始容量。
private static final int DEFAULT_CAPACITY = 10;
EMPTY_ELEMENTDATA
表示空数组。
DEFAULTCAPACITY_EMPTY_ELEMENTDATA
也表示空数组,为了区分而命名不同。
//用于创建空对象的共享空数组实例。
private static final Object[] EMPTY_ELEMENTDATA = {};
//用于默认大小的空数组实例的共享空数组实例。我们将它与EMPTY_ELEMENTDATA区分开来,以便在添加第一个元素时知道要扩容多少。
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
3、构造方法
ArrayList提供了多个构造方法,包括无参构造方法、指定初始容量的构造方法。
java
//无参构造
//构造一个初始容量为10的空数组。
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
//有参构造
//构造具有指定初始容量的空数组。
public ArrayList(int initialCapacity) { //构建有参构造方法
if (initialCapacity > 0) { //如果传入参数>0
this.elementData = new Object[initialCapacity]; //创建一个数组,大小为传入的参数
} else if (initialCapacity == 0) { //如果传入的参数=0
this.elementData = EMPTY_ELEMENTDATA; //得到一个空数组
} else { //否则
throw new IllegalArgumentException("Illegal Capacity: "+ //抛出异常
initialCapacity);
}
}
这里可以看到无参构造方法用的是DEFAULTCAPACITY_EMPTY_ELEMENTDATA
表示空数组,而有参构造方法中当传入参数=0,用的是EMPTY_ELEMENTDATA
表示空数组。
4、扩容机制
具体流程:
1、开始添加元素前先判断当前数组容量是否足够(ensureCapacityInternal()
方法),这里有个特例就是添加第一个元素时要先将数组扩容为初始容量大小(calculateCapacity()
方法)。如果足够就向数组中添加元素。
2、如果当前数组容量不够,开始计算新容量的大小并赋值给新数组,复制原始数组中的元素到新数组中(grow()
方法)
流程图如下:
从向ArrayList添加元素来观察底层源码是如何实现的
观察add()
方法,其中提到一个不认识的ensureCapacityInternal()
方法,把他看做用来判断数组容量是否足够的方法,判断完后将元素添加到数组中
public boolean add(E e) {
ensureCapacityInternal(size + 1); //判断数组容量是否足够,传入的一个大小为(size+1)的参数
elementData[size++] = e; //添加元素
return true;
}
现在来看上面add()
方法提到的ensureCapacityInternal()
方法, 进入查看源码,又出现两个不认识的方法: calculateCapacity()
方法和ensureExplicitCapacity()
方法。
private void ensureCapacityInternal(int minCapacity) { //这里minCapacity大小就是上面传入参数:size+1
ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}
```
calculateCapacity()
方法:里面有一个判断语句,判断当前数组是不是空数组。如果是空数组那就将数组容量初始化为10,如果不是空数组,那就直接返回minCapacity
。
ensureExplicitCapacity()
方法:重点观察判断语句,将calculateCapacity()
方法中传进来的minCapacity
与原数组的长度作比较,当原数组长度小于minCapacity
的值就开始进行扩容。
// calculateCapacity方法
private static int calculateCapacity(Object[] elementData, int minCapacity) {
//判断数组是否为空
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
//数组为空时比较,DEFAULT_CAPACITY=10,minCapacity=size+1,DEFAULT_CAPACITY一定比minCapacity大,所以空数组容量初始化为10
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
//数组不为空,minCapacity=size+1,相当于不变
return minCapacity;
}
//-------------------------------------------分割线-----------------------------------------------------//
// ensureExplicitCapacity方法
private void ensureExplicitCapacity(int minCapacity) {//这里的minCapacity是上面传过来的
modCount++;
if (minCapacity - elementData.length > 0) //判断数组长度够不够,不够才扩
grow(minCapacity);
}
举例
- 当向数组添加第1个元素时size=0,
calculateCapacity()
方法中判断数组为空,数组容量初始化为10。到了ensureExplicitCapacity()
方法中,因为是空数组,所以elementData.length
=0,判断成立,数组进行扩容大小为10。 - 当向数组添加第2个元素时size=1,
calculateCapacity()
方法中判断数组为非空,为minCapacity赋值为2。到了ensureExplicitCapacity()
方法中,因为数组大小已经扩容为10,所以elementData.length
=10,判断不成立,不扩容 - 当向数组添加第11个元素时size=10,
calculateCapacity()
方法中判断数组为非空,为minCapacity赋值为11。到了ensureExplicitCapacity()
方法中,因为数组大小已经扩容为10,所以elementData.length
=10,判断成立,开始扩容
前面都是判断数组要不要进行扩容,下面内容就是如何扩容。
首先,grow()
方法是扩容的入口,它根据当前容量计算新容量,并调用Arrays.copyOf方法复制数组。hugeCapacity()
方法用于处理超大容量的情况,确保不会超出数组的最大限制。
* 这一步是为了先确定扩容的大小,再将元素复制到新数组中
private void grow(int minCapacity) {
int oldCapacity = elementData.length; //定义一个oldCapacity接收当前数组长度
int newCapacity = oldCapacity + (oldCapacity >> 1); //定义一个newCapacity接收oldCapacity1.5倍的长度
if (newCapacity - minCapacity < 0) //如果newCapacity长度<minCapacity
newCapacity = minCapacity; //将minCapacity赋值给newCapacity
if (newCapacity - MAX_ARRAY_SIZE > 0) //如果newCapacity长度>最大的数组长度
newCapacity = hugeCapacity(minCapacity); //将进行hugeCapacity方法以后的值赋值给newCapacity
elementData = Arrays.copyOf(elementData, newCapacity);//开始扩容
}
查看hugeCapacity()
方法 (防止扩容后的数组太大了)
MAX_ARRAY_SIZE 理解为:快接近integer的最大值了。
Integer.MAX_VALUE 理解为:integer的最大值。
private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0) //如果minCapacity<0
throw new OutOfMemoryError(); //抛出异常
return (minCapacity > MAX_ARRAY_SIZE) ? //返回一个值,判断minCapacity是否大于MAX_ARRAY_SIZE
Integer.MAX_VALUE : //大于就返回 Integer.MAX_VALUE
MAX_ARRAY_SIZE; //小于就返回 MAX_ARRAY_SIZE
}
```
最后一步,了解是如何如何将元素添加到新数组的
查看Arrays.copyof
源代码
用于将一个原始数组(original)复制到一个新的数组中,新数组的长度(newLength)可以与原始数组不同。
public static <T> T[] copyOf(T[] original, int newLength) {
return (T[]) copyOf(original, newLength, original.getClass());
}
查看copyof()
方法 (判断新数组与原数组类型是否一致)
public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
@SuppressWarnings("unchecked")
T[] copy = ((Object)newType == (Object)Object[].class)
? (T[]) new Object[newLength]
: (T[]) Array.newInstance(newType.getComponentType(), newLength);
System.arraycopy(original, 0, copy, 0,
Math.min(original.length, newLength));
return copy;
}
开始复制原数组的元素到新数组中
将一个数组`src`的从索引`srcPos`开始的`length`个元素复制到另一个数组`dest`的从索引`destPos`开始的位置。
public static native void arraycopy(Object src, int srcPos,
Object dest, int destPos,
int length);
参数说明:
参数说明:
src
:原数组,类型为Object,表示可以接受任何类型的数组。srcPos
:原数组的起始索引,即从哪个位置开始复制元素。dest
:新数组,类型为Object,表示可以接受任何类型的数组。destPos
:新数组的起始索引,即从哪个位置开始粘贴元素。length
:要复制的元素数量。
从宏观上来说,ArrayList展现的是一种动态数组的扩容,当数组中元素个数到达一定值时数组自动会扩大容量,以方便元素的存放。
从微观上来说,ArrayList是在当数组中元素到达一定值时,去创建一个大小为原数组1.5倍容量的新数组,将原数组的元素复制到新数组当中,抛弃原数组。
来源:juejin.cn/post/7426280686695710730
工作7年了,才明白技术的本质不过是工具而已,那么未来的方向在哪里?
前言
Hi 你好,我是东东拿铁,一个正在探索个人IP&副业的后端程序员。
五一过去了,不知道大家有没有好好的放松自己呢?愉快的假期总是这么短暂,打工人重新回到自己的岗位。
我目前工作7年了,这几年来埋头苦干,学习了很多技术,做了不少系统,也解决过不少线上问题。自己虽然在探寻个人IP与副业,自己花了很多时间去思考技术之外的路该怎么走。但转念一想,我宁愿花这么多时间去探索技术之外的路线,但是却从没好好静下来想一下技术本身。
技术到底是什么,你我所处的技术行业为什么会存在,未来的机会在哪里。
因此,我结合自己的工作经历,希望和大家一起聊聊,技术的本质与未来的方向,到底在哪里,才疏学浅,如果内容有误还希望你在评论区指正。
背景
行业现状
互联网行业发展放缓,进入调整阶段,具体表现为市场需求、用户规模、营收利润、创新活力等方面的放缓或下降。
一些曾经风光无限的互联网公司也遭遇了业绩下滑、股价暴跌、裁员潮等困境,你是不是也曾听过互联网的寒冬已至的言论?
其实互联网本身,并没有衰败或消亡,而是因为互联网高速发展的时代过去了。
- 中国经济增速放缓、消费升级趋势减弱、人口红利消失等因素的影响,中国互联网市场的需求增长趋于饱和或下降。
- 用户规模停滞,智能手机普及率饱和,互联网用户规模增长趋于停滞,由增量市场变为存量市场,互联网获客成本越来越高。
- 监管政策收紧,互联网行业规范和监管愈加严格,更加注重合规,因此互联网行业也会收到影响。
供需环境
供需环境变化,应届生要求越来越高,更加注重学历。
社招更是看中学历的同时,开始限制年龄。招聘更看重项目经验,业务经验。五年前,你只要做过一些项目,哪怕不是实际使用的,也很容易拿到offer。而现在企业在看中技术能力的同时,还会关注候选人对与行业的理解,以及以往的工作经验。
技术的本质
先说结论,技术的本质是工具。 我把过去几年的认知变化分成了四个阶段,给大家展示一下我对于技术的认知成长过程。
第一阶段
技术就是应用各类前沿的框架、中间件。
刚毕业时,我就职于一家传统信息企业。谈不上所谓的架构,只需要Spring、Mysql就构建起了我们的所有技术栈。当然,微服务框架更不可能,Redis、MQ在系统中都没使用到。
此时互联网企业已经开始快速发展,抖音诞生区区不过一年。
一线城市的互联网公司,都已经开始使用上了SpringBoot、微服务,还有各类我没有听说过的中间件。
工作环境的闭塞,让我对各类技术有着无限憧憬,因为很多当下难以解决的问题,应用一些新技术、新架构,就能立刻对很多难题降维打击。
举个例子,如果你使用本地缓存,那么集群部署时,你一定要考虑集群的缓存一致性问题,可这个问题如果用上分布式缓存Redis,那么一致性问题迎刃而解。
所以那个时候的我认为,技术就是应用各类中间件,只要用上这些中间件、框架,我就已经走在了技术的前沿。
第二阶段
技术对我而言就是互联网。
半年后,我摆脱传统行业,来到了一个小型互联网公司,用上了不少在我眼中的新技术。
但任何新技术,如果只停留在表面,那么对于使用者来说,就是几个API,几行代码,你很快就会感到厌倦,发现问题也会焦虑,因为不清楚原理,问题就无从排查。
很快,所谓的“新技术”,就不能给我带来成就感了。我开始羡慕那些互联网行业APP,无时无刻都在畅想着,如果我做的产品能够被大家看到并应用,那该是多么有意思的一件事情。
于是我又认为,技术就是做那些被人看见、被人应用的网站、APP。
第三阶段
技术就是高并发、大流量、大数据。
当自己真正负责了某一个APP的后端研发后,很多技术都有机会应用,也能够在AppStore下载自己的APP了,没事刷一刷,看到某一个信息是通过我自己写的代码展示出去,又满足了第二阶段的目标了。
那么我接下来追求的变成了,让更多的人使用我做的产品,起码让我的亲人、朋友也能看到我做的东西。
当然,随之而来的就是日益增长的数据规模和大流量,这些无时无刻都在挑战系统的性能,如何去解决这些问题,成为了我很长一段时间的工作主线。
应对高并发、大流量,我们需要对系统作出各种极致性能的优化。
为了性能优化,还需要了解更多的底层原理,才能在遇到问题时有一个合理的解决方案。
所以,我认为技术就是高并发、大数据,做好这些,才算做好了技术。
第四阶段
经过了传统企业,到互联网公司,再到互联网大厂的一番经历,让我发现技术的本质就是工具,在不同阶段,去解决不同的问题。
在第一阶段,技术解决了各类行业的数据信息化问题,借助各类中间件、架构把具体的需求落地。
在第二阶段、第三阶段,技术解决了业务的规模化问题,因为在互联网,流量迅猛增长,我需要去用技术解决规模化带来的各类的问题,做出性能优化。
当然,技术在其他领域也发挥着作用,比如AI&算法,给予了互联网工具“智能化”的可能,还有比如我们很难接触到的底层框架研发,也就是技术的“技术”,这些底层能力,帮助我们更好的发挥我们的技术能力。
未来机会
大厂仍是最好的选择
即使是在互联网增速放缓、内卷持续严重的今天,即使我选择从大厂离职,但我依然认为大厂是最好的选择。
为什么这么说,几个理由
- 大厂有着更前沿的技术能力,你可以随意选择最适合的工具去解决问题
- 大厂有着更大的数据、流量规模,你所做的工作,天然的就具备规模化的能力
- 大厂有先进的管理方法,你所接触的做事方法、目标管理可能让你疲倦,但工作方法大概率是行业内经过验证的,你不会走弯路,能让你有更快的进步速度
数字化转型
如果你在互联网行业,可能没有听说过这个词,因为在高速发展的互联网行业,本身就是数字驱动的,比如重视数据指标、AB实验等。但在二线、三线城市的计算机行业或者一些传统行业,数字化转型是很大的发展机会。
过去十年,传统行业做的普遍是信息化转型,也就是把线下,需要用纸、笔来完成工作的,转移到系统中。
那什么是数字化转型?
我用我自己的理解说一下,数字化转型就是业务流程精细化管理,数据驱动,实现降本增效。
我目前所在的公司的推进大方向之一,就是数字化转型。因为许多行业的数字化程度非常低,本质而言,就是把数字驱动的能力,带给传统企业,让传统企业也能感受到数字化带来的发展可能。
举个例子,比如一个餐饮系统数字化转型后,一方面可以把用户下单、餐厅接单、开始制作、出餐、上餐线上化,还可以和原材料供应系统打通,当有订单来时,自动检测餐饮的库存信息,库存不足及时提供预警,甚至可以作出订单预测,比如什么时间点,哪类餐品的点单量最高。
当然,数字化转型与互联网有着极大的不同,在互联网行业,你只需要坐在工位,等着产品提出需求就可以了。但是传统行业,你需要深入客户现场,实地查看业务流程,与用户交谈,才能真正的理解客户需求。
或许这样的工作并不炫酷,还需要出差,但在互联网行业饱和的今天,用技术去解决真实世界的问题,也不失为一个很好的选择。
AI&智能化
随着AI快速发展,各类智能化功能已经遍布了我们使用的各类APP,极客时间有了AI自动总结,懂车帝有了智能选车度搜索问题,有时候第一个也会是AI来给我们解答。
任何行业遇上AI都可以再做一遍。
抛开底层算法、模型不谈,但从使用者角度来说,最重要的是如何与行业、场景结合相使用。但是想要做好应用,需要你在行业有着比较深的沉淀,有较深的行业认知。
当然,智能化也不仅限于AI,像上面餐饮系统的例子,如果能够实现订单预测、自动库存管理,其实也是智能化的体现。
终身学习
技术能力
持续精进专业技术能力,相信大家对此都没有疑问。
对于日常使用到的技术,我们需要熟练掌握技术原理,积累使用经验,尤其是线上环境的问题处理经验。
第一个是基础。比如对集合类,并发包,IO/NIO,JVM,内存模型,泛型,异常,反射,等有深入了解,最好是看过源码了解底层的设计。
第二你需要有全面的互联网技术相关知识。从底层说起,你起码得深入了解mysql,redis,nginx,tomcat,rpc,jms等方面的知识。
第三就是编程能力,编程思想,算法能力,架构能力。
在这个过程中,打造自己的技能树,构建自己的技术体系。
对于不断冒出的新技术,我们一方面要了解清楚技术原理,也要了解新技术是为了解决什么问题,诞生于什么背景。
业务能力
前面说到技术是一种工具,解决的是现实世界的问题,如果我们希望更好的发挥技术的作用,那么就需要我们先掌握好业务领域。
互联网领域
如果你想要快速地入门互联网领域的业务,你可以使用AARRR漏斗模型来分析。
AARRR这5个字母分别代表 Acquisition、Activation、Retention、Revenue 和 Refer
五个英文单词,它们分别对应用户生命周期中的 5 个重要环节:获取(Acquisition)、激活(Activation)、留存(Retention)、收益(Revenue)和推荐(Refer)。
AARRR 模型的核心就是以用户为中心,以完整的用户生命周期为指导思想,分析用户在各个环节的行为和数据,以此来发现用户需求以及产品需要改进的地方。
举一个简单的例子,我们以一个互联网手游 LOL来举例:
获取就是用户通过广告、push等形式,了解到了游戏并注册或者登陆。
激活就是用户真正的开始游戏,比如开始了一场匹配。
留存就是用户在7天、30天内,登陆了几次,打了几把比赛,几天登陆一次,每日游戏时常又是多少。
收益,用户购买皮肤了,产生了收益。
推荐,用户邀请朋友,发送到微信群中,邀请了朋友一起开黑。
如果你所在的行业是C端产品,那么这个模型基本可以概括用户的生命周期全流程。
传统行业
传统行业没有比较通用的业务模型,如果想要入手,需要我们从以下三个角度去入手
- 这个行业的商业模式是什么,也就是靠什么赚钱的?比如售卖系统收费,收取服务费等
- 行业的规模如何?头部玩家有哪些?它们的模式有哪些特色?
- 这个行业的客户是谁、用户是谁?有哪些经典的作业场景?业务操作流程是什么样的?
如何获取到这些信息呢?有几种常见的形式
- 权威的行业研究报告,这个比较常见
- 直接关注头部玩家的官网、公众号、官媒
- 深入用户现场
我们以汽车行业来举例
商业模式:整车销售、二手车、汽车租赁等,细分一点,又有传统动力和新能源两种分类。
规模:如下图
头部车企:传统的四大车企一汽、东风、上汽、长安,新势力 特斯拉、蔚小理
经典场景:直接去4S店体验一下汽车销售模式、流程
说在最后
好了,文章到这里就要结束啦,我用我自己工作几年的不同阶段,给你介绍了我对于技术的本质是工具的思考过程,也浅浅的探寻了一下,未来的发展机会在哪里,以及我们应该如何提升自己,很感谢你能看到最后,希望对你有所帮助。
不知道你对于技术是怎么看的,又如何看待当下的市场环境呢?欢迎你在评论区和我分享,也希望你点赞、评论、收藏,让我知道对你有所收获,这对我来说很重要。也欢迎你加我的wx:Ldhrlhy10,一起交流。
本篇文章是第33篇原创文章,2024目标进度33/100,欢迎有趣的你关注我~
来源:juejin.cn/post/7365679089812553769
面试官:count(1) 和 count(*)哪个性能更好?
在数据库查询中,count(*)
和 count(1)
是两个常见的计数表达式,都可以用来计算表中行数。
很多人都以为 count(*)
效率更差,主要是因为在早期的数据库系统中,count(*)
可能会被实现为对所有列进行扫描,而 count(1)
则可能只扫描单个列。
但事实真是如此吗?
执行原理
先来看看这两者的执行原理:
count(*)
查询所有满足条件的行,包括包含空值的行。在大多数数据库中,count(*)
会直接统计行数,并不会实际去读取每行中的详细数据,因为数据库引擎会自行优化该计数操作,以提高执行效率。
count(1)
也是计算表中的行数,这里的 1 是一个常量,只是作为一个占位符,并没有实际的含义。与 count(*)
类似,数据库引擎也会对 count(1)
进行优化,以快速确定表中的行数。
count(*) 和 count(1) 的 性能差异
再说性能,在大多数数据库中,其实 count(*)
和 count(1)
的性能非常相似,甚至可以说没有区别,这是因为大多数数据库引擎对这两种计数方式进行相同的优化,并没有明显的执行效率上的差异。但是在特殊情况下可能会有细微的差异,造成这种差异的原因通常有以下几种:
1. 数据库引擎的差异
不同的数据库引擎可能对 count(*)
和 count(1)
采取不同的优化策略,这在某些情况下可能会导致两种计数方式的性能差异。例如:
- SQL Server:在某些版本的 SQL Server 中,
count(1)
在特定的查询计划中可能稍微快一些,但这种差异通常微乎其微,只有在处理非常大的表或复杂查询时才会显现出来。 - MyISAM 引擎:在不附加任何
WHERE
查询条件的情况下,统计表的总行数会非常快,因为 MyISAM 会用一个变量存储表的行数。如果没有WHERE
条件,查询语句将直接返回该变量值,使得速度很快。然而,只有当表的第一列定义为NOT NULL
时,count(1)
才能得到类似的优化。如果有WHERE
条件,则该优化将不再适用。 - InnoDB 引擎:尽管 InnoDB 表也存储了一个记录行数的变量,但遗憾的是,这个值只是一个估计值,并无实际意义。在 Innodb 引擎下,
count(*)
和count(1)
哪个快呢?结论是:这俩在高版本的 MySQL 是没有什么区别的,也就没有count(1)
会比count(*)
更快这一说了。
另外,还有一个问题是 Innodb 是通过主键索引来统计行数的吗?
如果该表只有一个主键索引,没有任何二级索引的情况下,那么 count(*)
和 count(1)
都是通过通过主键索引来统计行数的。
如果该表有二级索引,则 count(*)
和 count(1)
都会通过占用空间最小的字段的二级索引进行统计。
2. 索引的影响
如果表上有合适的索引,无论是count(1)
还是 count(*)
都可以利用索引来快速确定行数,而不必扫描整个表。在这种情况下,两者的性能差异通常可以忽略不计。例如,如果有一个基于主键的索引,数据库可以快速通过索引确定表中的行数,而无需读取表中的每一行数据。
实战分析
话不多说,下面我们通过实验来验证上述理论:
第一步:创建表与插入数据
用 Chat2DB 给我们生成一个创建表的 sql 语句,直接用自然语言描述我们想要的字段名和字段类型即可生成建表语句,也可以生成测试数据。
然后用存储过程向 student 表中插入两万条测试数据。(存储过程执行两次)
插入数据后的 student 表如下:
这个时候执行 select count(*) from student
和 select count(1) from student
可以看到解释器的结果如下,耗时均为 2 ms(两者一致,所以就只截了一张图),两者都用主键索引进行行数的统计:
第二步:执行计数查询
创建二级索引 IDCard 进行统计结果如下:
可以看出用二级索引进行统计的解释器结果还是一致。
结论
综上所述,count(1)
和 count(*)
的性能基本相同,并不存在 COUNT(1)
比 COUNT(*)
更快的说法。总体而言,在大多数情况下,两者之间的性能差异是可以忽略不计的。
在选择使用哪种方式时,应当优先考虑代码的可读性和可维护性。count(*)
在语义上更为明确,表示计算所有行的数量,而不依赖于任何特定的值。因此,从代码清晰度的角度出发,通常建议优先使用 count(*)
。
当然,如果在特定的数据库环境中,经过实际测试发现 count(1)
具有明显的性能优势,那么也可以选择使用 count(1)
。但在一般情况下,不必过分纠结于这两种计数方式之间的性能差异。
希望本文能帮助你在使用计数操作时作出更为合理的选择。
Chat2DB 文档:docs.chat2db.ai/zh-CN/docs/…
Chat2DB 官网:chat2db.ai/zh-CN
Chat2DB GitHub:github.com/codePhiliaX…
来源:juejin.cn/post/7417521775587065907
【后端性能优化】接口耗时下降60%,CPU负载降低30%
大家好,我是五阳。
GC 话题始终霸占面试必问排行榜,很多人对 GC 原理了然于胸,但是苦于没有实践经验,因此本篇文章将分享我的GC 优化实践。一个很小的优化,产生了非常好的效果。
现在五阳将优化过程给大家汇报一下。
一、背景
我所负责的 A 服务每天的凌晨会定时执行一个批量任务,每天执行时都会触发 GC 频率告警,偶尔单机 CPU 负载超过 60%时,会触发 CPU 高负载告警。
曾经有考虑过通过单机限流器,限制任务执行速率,从而降低机器负载。然而因为业务上希望定时任务尽快执行完,所以优化方向就放在了如何降低 CPU 负载,如何降低 GC 频率。
1.1 配置和负载
- 版本:java8
- GC 回收器:ParNew + CMS
- 硬件:8 核 16G 内存,Centos6.8
- 高峰期CPU 平均负载(分钟)超过 50%(每个公司计算口径可能不同。我司的历史经验超过 70%后,接口性能将会快速恶化)
1.2 优化前的 GC情况
不容乐观。
- 高峰期 Young GC频率 70次/min,单次 ygc 平均时间 125ms;
- 高峰期 Full GC频率 每 3 分钟 1 次;单次 fgc 平均时间 610ms。
1.3 GC 参数和 JVM 配置
参数配置 | 说明 |
---|---|
-Xmx6g -Xms6g | 堆内存大小为6G |
-XX:NewRatio=4 | 老年代的大小是新生代的 4 倍,即老年代占4.8G,新生代占1.2G |
-XX:SurvivorRatio=8 | Eden:From:To= 8:1:1,即Eden区占0.96G,两个Survivor区分别占0.12G |
-XX:ParallelCMSThreads=4 | 设置 CMS 垃圾回收器使用的并行线程数为 4 |
XX:CMSInitiatingOccupancyFraction=72 | 设置老年代使用率达到 72% 时触发 CMS 垃圾回收。 |
-XX:+UseParNewGC | 启用 ParNew 作为年轻代垃圾回收器 |
-XX:+UseConcMarkSweepGC | 启用 CMS 垃圾回收器 |
二、问题分析
2.1 增加 GC打印参数
由于打印GC信息不足,无法分析问题。因此添加了 以下GC 打印参数,以提供更多的信息
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-XX:+PrintCommandLineFlags
-XX:+PrintGCDateStamps
-XX:+PrintHeapAtGC
-XX:+PrintTenuringDistribution
-XX:+PrintGCApplicationStoppedTime
-XX:+PrintReferenceGC
2.2 提前晋升现象
配置如上参数后,每次发生 younggc后,都会打印详细的 younggc 日志。通过分析 gc 日志,我发现日志中经常出现类似内容。
Desired survivor size 61054720 bytes, new threshold 2 (max 15)
new threshold是新的晋升阈值,是指对象在新生代经过 new threshold
轮 younggc后,就能晋升到老年代,这个值通过 MaxTenuringThreshold配置,默认值是 15,在原有理解中阈值是固定值 15,实际上这个值会动态调整。
为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold 才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄
Desired survivor 一般是 Survivor 区的一半。假设年龄 1至N 的对象大小,超过了 Desired size,那么下一次 GC 的晋升阈值就会调整为 N。举个例子,假设 age=1的对象为 80M,超过了 61M,那么下一次GC 的晋升阈值就是 1,所有超过 1 的对象都会晋升到老年代,无需等到年龄到 15。
如何分析 younggc 日志,可以参考我的另一篇文章。2024面试必问:系统频繁Full GC,你有哪些优化思路?第一步分析gc日志
2.3 老年代增长速度过快
为了印证是否发生提前晋升,我通过监控查看到在事发时间,老年代内存的涨幅和 Survivor的内存基本一致,看来新生代的对象确实提前晋升到老年代了。
grep 分析历次 GC 后的晋升阈值后,我发现绝大部分情况下,新生的对象无法在 15 次 GC后进入到老年代,基本上三次以后就会提前晋升到老年代…… 这解释了为什么会发生频繁的 FullGC。
假设每次提前晋升 100M 到老年代,每分钟超过 15 次 ygc,则每分钟将会有 1.5G 对象进入老年代。
因为频繁地提前晋升,老年代的增长速度极快。 在高峰期时,往往 2 至 3 分钟左右,老年代内存就会触达 72% 的阈值,从而发生 FullGC。
2.4 新生代内存不足
即便老年代配置 4.8G 的大内存,但频繁地发生提前晋升,老年代也很快被打满。这背后的根本原因在于 新生代的内存太小了。 新生代,总共 1.2G 大小,Survivor才 120M,这远远不够。
于是我们调整了内存分配。调整后如下
- -Xmx10g -Xms10g -Xmn6g
- -XX:SurvivorRatio=8
- 堆内存由 6G 增加到 10G
- 大部分堆内存(6G)分配给新生代。新生代内存从 1.2G 增加到 6G。
- Eden:From:To 的比例依然是 8:1:1
- Eden大小从 0.96 G 增加到 4.8 G。
- Survivor区由 120 M 增加到 600 M。
三、优化效果
虽然改动不大,但是优化效果十分显著。由于公司监控有水印,我无法截图取证,敬请谅解。
3.1 GC频率明显下降
- 高峰期 ygc 70 次/min 降到了 12 次/min,下降幅度达83%(单机 500 QPS)
- 高峰期 fgc 三分钟1 次,降到了 每天 1 次 Full GC。
- younggc 和 fullgc 单次平均耗时保持不变。
3.2 CPU 负载降低 30%+
- 优化之前高峰期 cpu 平均负载超过 50%;优化后降到了不足 30%,高峰期负载下降了 40%。
- CPU负载每日平均值 由 29%,降到了 20%。日平均负载下降了 32%。
3.3 核心接口性能显著提升
核心接口耗时下降明显
- 接口 A 高峰期 TPS 100/秒,tp999 由 200毫秒 降到了 150 毫秒, tp9999 由 400 毫秒降到了 300 毫秒,接口耗时下降超过 25%!
- 接口 B 高峰期QPS 250/秒, tp999 由 190 毫秒降到了 120 毫秒, tp9999 由 450 毫秒下降到了 150 毫秒,接口耗时下降分别下降 37%和 67%!
- 接口 B 低峰期降幅更加明显,tp999 由 80 毫秒降到了 10 毫秒,下降幅度接近 90%!
后来又适当微调了 JVM 内存分配比例,但是优化效果不明显。
四、总结
经过此次 GC 优化经历,我学到了如下经验
- 要通过 GC 日志分析 GC 问题。
- 调整JVM 内存,保证足够的新生代内存。
- 优化 GC 可以降低接口耗时,提高接口可用性。
- 优化 GC 可以有效降低机器 CPU 负载,提高硬件使用率。
反过来当接口性能差、cpu负载高的时候,不妨分析一下 GC ,看看有没有优化空间。
详细了解如何分析 younggc 日志,可以参考我的另一篇文章。2024面试必问:系统频繁Full GC,你有哪些优化思路?第一步分析gc日志
关注五阳~ 了解更多我在大厂的实际经历
来源:juejin.cn/post/7423066953038741542
车机系统与Android的关系
前言:搞懂 Android 系统和汽车到底有什么关系。
一、基本概念
1、Android Auto
1)是什么
- Android Atuo 是一个 Android 端的 app,专门为驾驶环境设计的;
- 运行环境:需要在 Android 5.0 或者更高版本的系统,并且需要 Google 地图和 Google Play 音乐应用;
2)功能
- Android Atuo 可以用来将 Android 设备上的部分功能映射到汽车屏幕上;
- 满足了很多人在开车时会使用手机的需求;
2、Google Assistant
- Google 将 GoofleAssistant 集成到 AndroidAuto 中;
- 交互方式有键盘、触摸、语音等;
- 对于汽车来说,语音无疑是比触摸更好的交互方式;
- 在驾驶环境中,语音交换存在的优势
- 用户不改变自身的物理姿势,这种交互方式不影响驾驶员对驾驶的操作;
- 有需要多次触摸的交互时,可能只需要一条语音就可以完成;
- 语音交互不存在入口的层次嵌套,数据更加扁平;
- 优秀的语音系统可以利用对话的上下文完成任务,避免用户重复输入;
3、Android Automotive
1、Android Auto 和 Android Automotive 的区别
- Android Auto 是以手机为中心的
- 好处:数据和应用始终是一致的,不存在需要数据同步的问题,手机上装的软件和已有数据,接到汽车上就直接有了;
- 坏处:每次都需要拿出手机,汽车只是作为手机的一个外设;这种模式不便于对于汽车本身的控制和相关数据的获取;
- Android Automotive
- 如果将系统直接内置于汽车中,会大大提升用户体验;
- Android Automotive 就是面向这个方向进行设计的;
- 一旦将系统内置于汽车,可以完成的功能就会大大增加;例如,直接在中控触摸屏上调整座椅和空调;同时,系统也能获取更多关于汽车的信息,例如:油耗水平、刹车使用等;
加两张中控和仪表的图片
4、App
1)App 的开发
- Android Auto 目前仅支持两类第三方应用
- 音频应用:允许用户浏览和播放汽车中的音乐和语音内容;
- 消息应用:通过 text-to-speech 朗读消息并通过语音输入回复消息;
2)App 的设计
- Google 专门为 Android Auto 上的 UI 设计做了一个指导网站:Auto UI guidelines;
- 基本指导原则(车机交互系统的借鉴)
- Android Auto 上的互动步调必须由驾驶员控制;
- 汽车界面上的触摸目标必须足够大,以便可以轻松地浏览和点击;
- 适当的私彩对比可以帮助驾驶员快速解读信息并做出决定;
- 应用必须支持夜间模式,因为过高的强度可能会干扰注意力;
- Roboto 字体在整个系统中用于保持一致性并帮助提高可读性;
- 通过触摸来进行分页应用用来作为滑动翻页的补充;
- 有节制地使用动画来描述两个状态间的变化;
二、源码和架构
1、Android Automative的整体架构
- Android Automative 的源码包含在 AOSP 中;
- Android Automative 是在原先 Android的 系统架构上增加了一些与车相关的(图中虚线框中绿色背景的)模块;
- Car App:包括 OEM 和第三方开发的 App;
- OEM:就是汽车厂商利用自身掌握的核心技术负责设计和开发新产品,而具体的生产制造任务则通过合同订购的方式委托给同类产品的其他厂家进行,最终产品会贴上汽车厂商自己的品牌商标。这种生产方式被称为定牌生产合作,俗称“贴牌”。承接这种加工任务的制造商就被称为OEM厂商,其生产的产品就是OEM产品;
- Car API:提供给汽车 App 特有的接口;
- Car Service:系统中与车相关的服务;
- Vehicle Network Service:汽车的网络服务;
- Vehicle HAL:汽车的硬件抽象层描述;
- Car App:包括 OEM 和第三方开发的 App;
1)Car App
- /car_product/build/car.mk 这个文件中列出了汽车系统中专有的模块;
- 列表中,首字母大写的模块基本上都是汽车系统中专有的 App;
- App的源码都位于 /platform/packages/services/Car/ 目录下
# Automotive specific packages
PRODUCT_PACKAGES += \
vehicle_monitor_service \
CarService \
CarTrustAgentService \
CarDialerApp \
CarRadioApp \
OverviewApp \
CarLensPickerApp \
LocalMediaPlayer \
CarMediaApp \
CarMessengerApp \
CarHvacApp \
CarMapsPlaceholder \
CarLatinIME \
CarUsbHandler \
android.car \
libvehiclemonitor-native \
2)Car API
- 开发汽车专有的App自然需要专有的API;
- 这些API对于其他平台(例如手机和平板)通常是没有意义的;
- 所以这些API没有包含在Android Framework SDK中;
- 下图列出了所有的 Car API;
- android.car:包含了与车相关的基本API。例如:车辆后视镜,门,座位,窗口等。
- cabin:座舱相关API。
- hvac:通风空调相关API。(hvac是Heating, ventilation and air conditioning的缩写)
- property:属性相关API。
- radio:收音机相关API。
- pm:应用包相关API。
- render:渲染相关API。
- menu:车辆应用菜单相关API。
- annotation:包含了两个注解。
- app
- cluster:仪表盘相关API。
- content
- diagnostic:包含与汽车诊断相关的API。
- hardware:车辆硬件相关API。
- input:输入相关API。
- media:多媒体相关API。
- navigation:导航相关API。
- settings:设置相关API。
- vms:汽车监测相关API。
3)Car Service
- Car Service并非一个服务,而是一系列的服务。这些服务都在ICarImpl.java构造函数中列了出来;
public ICarImpl(Context serviceContext, IVehicle vehicle, SystemInterface systemInterface,
CanBusErrorNotifier errorNotifier) {
mContext = serviceContext;
mHal = new VehicleHal(vehicle);
mSystemActivityMonitoringService = new SystemActivityMonitoringService(serviceContext);
mCarPowerManagementService = new CarPowerManagementService(
mHal.getPowerHal(), systemInterface);
mCarSensorService = new CarSensorService(serviceContext, mHal.getSensorHal());
mCarPackageManagerService = new CarPackageManagerService(serviceContext, mCarSensorService,
mSystemActivityMonitoringService);
mCarInputService = new CarInputService(serviceContext, mHal.getInputHal());
mCarProjectionService = new CarProjectionService(serviceContext, mCarInputService);
mGarageModeService = new GarageModeService(mContext, mCarPowerManagementService);
mCarInfoService = new CarInfoService(serviceContext, mHal.getInfoHal());
mAppFocusService = new AppFocusService(serviceContext, mSystemActivityMonitoringService);
mCarAudioService = new CarAudioService(serviceContext, mHal.getAudioHal(),
mCarInputService, errorNotifier);
mCarCabinService = new CarCabinService(serviceContext, mHal.getCabinHal());
mCarHvacService = new CarHvacService(serviceContext, mHal.getHvacHal());
mCarRadioService = new CarRadioService(serviceContext, mHal.getRadioHal());
mCarNightService = new CarNightService(serviceContext, mCarSensorService);
mInstrumentClusterService = new InstrumentClusterService(serviceContext,
mAppFocusService, mCarInputService);
mSystemStateControllerService = new SystemStateControllerService(serviceContext,
mCarPowerManagementService, mCarAudioService, this);
mCarVendorExtensionService = new CarVendorExtensionService(serviceContext,
mHal.getVendorExtensionHal());
mPerUserCarServiceHelper = new PerUserCarServiceHelper(serviceContext);
mCarBluetoothService = new CarBluetoothService(serviceContext, mCarCabinService,
mCarSensorService, mPerUserCarServiceHelper);
if (FeatureConfiguration.ENABLE_VEHICLE_MAP_SERVICE) {
mVmsSubscriberService = new VmsSubscriberService(serviceContext, mHal.getVmsHal());
mVmsPublisherService = new VmsPublisherService(serviceContext, mHal.getVmsHal());
}
mCarDiagnosticService = new CarDiagnosticService(serviceContext, mHal.getDiagnosticHal());
4)Car Tool
a、VMS
- VMS全称是Vehicle Monitor Service。正如其名称所示,这个服务用来监测其他进程;
- 在运行时,这个服务是一个独立的进程,在init.car.rc中有关于它的配置
service vms /system/bin/vehicle_monitor_service
class core
user root
group root
critical
on boot
start vms
- 这是一个Binder服务,并提供了C++和Java的Binder接口用来供其他模块使用;
来源:juejin.cn/post/7356981730765291558
Flutter 用什么架构方式才合理?
前言
刚入门 Flutter 编程时,差点被 Flutter 的嵌套地狱吓走,不过当我看到 Flutter 支持 Windows 稳定后,于是下定决心尝试接受 Flutter,因为 Flutter 真的给的太多了:跨平台、静态编译、热加载界面。
Flutter 代码是写到文件夹中的,通过文件夹来管理代码,像是 c++ 语言那样,一个文件,即可以写类,也可以直接写方法😠。
不像 java 那样,全部都是类,整齐划一,通过包名来管理,但也支持类似的“导包”😆。
那么怎样才能像 Java 那样,有个框架优化代码,让项目看起来更整洁好维护呢?
我目前的答案是 MVC 🐷,合适自己的架构才是最好的架构,用这个架构,我感觉找到了家,大家先看看我的代码,然后再做评价。
使用部分
结合GetX, 使用方式如下:
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:wenznote/commons/mvc/controller.dart';
import 'package:wenznote/commons/mvc/view.dart';
class CustomController extends MvcController {
var count = 0.obs;
void addCount() {
count.value++;
}
}
class CustomView extends MvcView<CustomController> {
const CustomView({super.key, required super.controller});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
children: [
Obx(() => Text("点击次数:${controller.count.value}")),
TextButton(
onPressed: () {
controller.addCount();
},
child: Text("点我"),
),
],
),
);
}
}
简单粗暴,直接在 CustomView 中设计 UI, 在 CustomController 中编写业务逻辑代码,比如登录注册之类的操作。
至于 MVC 中的 Model 去哪里了?你猜猜😘。
代码封装部分
代码封装也很简洁,封装的 controller 代码如下
import 'package:flutter/material.dart';
class MvcController with ChangeNotifier {
late BuildContext context;
@mustCallSuper
void onInitState(BuildContext context) {
this.context = context;
}
@mustCallSuper
void onDidUpdateWidget(BuildContext context, MvcController oldController) {
this.context = context;
}
void onDispose() {}
}
封装的 view 代码如下
import 'package:flutter/material.dart';
import 'controller.dart';
typedef MvcBuilder<T> = Widget Function(T controller);
class MvcView<T extends MvcController> extends StatefulWidget {
final T controller;
final MvcBuilder<T>? builder;
const MvcView({
super.key,
required this.controller,
this.builder,
});
Widget build(BuildContext context) {
return builder?.call(controller) ?? Container();
}
@override
State<MvcView> createState() => _MvcViewState();
}
class _MvcViewState extends State<MvcView> with AutomaticKeepAliveClientMixin{
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
widget.controller.onInitState(context);
widget.controller.addListener(onChanged);
}
void onChanged() {
if (context.mounted) {
setState(() {});
}
}
@override
Widget build(BuildContext context) {
super.build(context);
widget.controller.context = context;
return widget.build(context);
}
@override
void didUpdateWidget(covariant MvcView<MvcController> oldWidget) {
super.didUpdateWidget(oldWidget);
widget.controller.onDidUpdateWidget(context, oldWidget.controller);
}
@override
void dispose() {
widget.controller.removeListener(onChanged);
widget.controller.onDispose();
super.dispose();
}
}
结语
MVC 可以很简单快速的将业务代码和 UI 代码隔离开,改逻辑的时候就去找 Controller 就行,改 UI 的话就去找 View 就行,和后端开发一样的思路,完成作品就行。
附上的作品文件结构截图,亲喷哈~
感谢大家的关注与支持,后续继续更新更多 flutter 跨平台开发知识,例如:MVC 架构中的 Controller 应该在哪里创建?Controller 中的 Service 应该在哪里创建?
来源:juejin.cn/post/7340472228927914024